diff --git a/strategies/cspreproc.go b/strategies/cspreproc.go new file mode 100644 index 0000000..1d297e7 --- /dev/null +++ b/strategies/cspreproc.go @@ -0,0 +1,202 @@ +package strategies + +import ( + "strings" +) + +// csAlternateInactiveLines returns the 1-based lines that sit in a losing +// branch of a C# `#if ... #elif/#else ... #endif` group, evaluated with no +// conditional symbols defined (what the compiler sees without +// DefineConstants). Only groups with an alternative are decided: a file +// that writes +// +// #if NET20 +// using Newtonsoft.Json.Utilities.LinqBridge; +// #else +// using System.Linq; +// #endif +// +// imports System.Linq, not both. A lone `#if X` with no #else stays live: +// without the build's symbols, keeping it is the conservative reading. +func csAlternateInactiveLines(src []byte) map[int]bool { + if !strings.Contains(string(src), "#else") && !strings.Contains(string(src), "#elif") { + return nil + } + type branch struct { + start, end int // lines strictly inside the branch + cond string + isElse bool + } + type group struct{ branches []branch } + var stack []*group + var groups []*group + lines := strings.Split(string(src), "\n") + for i, raw := range lines { + ln := i + 1 + t := strings.TrimSpace(raw) + if !strings.HasPrefix(t, "#") { + continue + } + t = strings.TrimSpace(t[1:]) + word, rest, _ := strings.Cut(t, " ") + rest = strings.TrimSpace(rest) + if j := strings.Index(rest, "//"); j >= 0 { + rest = strings.TrimSpace(rest[:j]) + } + switch word { + case "if": + g := &group{branches: []branch{{start: ln + 1, cond: rest}}} + stack = append(stack, g) + groups = append(groups, g) + case "elif", "else": + if len(stack) == 0 { + continue + } + g := stack[len(stack)-1] + g.branches[len(g.branches)-1].end = ln - 1 + g.branches = append(g.branches, branch{start: ln + 1, cond: rest, isElse: word == "else"}) + case "endif": + if len(stack) == 0 { + continue + } + g := stack[len(stack)-1] + g.branches[len(g.branches)-1].end = ln - 1 + stack = stack[:len(stack)-1] + } + } + out := map[int]bool{} + for _, g := range groups { + if len(g.branches) < 2 || g.branches[len(g.branches)-1].end == 0 { + continue // no alternative, or unterminated + } + active := -1 + for i, b := range g.branches { + if b.isElse { + active = i + break + } + v, ok := csEvalCondition(b.cond) + if !ok { + active = -2 // an expression we cannot read: decide nothing + break + } + if v { + active = i + break + } + } + if active == -2 { + continue + } + for i, b := range g.branches { + if i == active { + continue + } + for ln := b.start; ln <= b.end; ln++ { + out[ln] = true + } + } + } + return out +} + +// csEvalCondition evaluates a C# preprocessor expression with every symbol +// undefined: identifiers are false, true/false are literals, and !, &&, ||, +// ==, != and parentheses combine them. ok is false on anything else. +func csEvalCondition(expr string) (val, ok bool) { + var toks []string + for i := 0; i < len(expr); { + c := expr[i] + switch { + case c == ' ' || c == '\t' || c == '\r': + i++ + case strings.HasPrefix(expr[i:], "&&"), strings.HasPrefix(expr[i:], "||"), + strings.HasPrefix(expr[i:], "=="), strings.HasPrefix(expr[i:], "!="): + toks = append(toks, expr[i:i+2]) + i += 2 + case c == '!' || c == '(' || c == ')': + toks = append(toks, string(c)) + i++ + case c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z': + j := i + for j < len(expr) && (expr[j] == '_' || expr[j] >= 'a' && expr[j] <= 'z' || expr[j] >= 'A' && expr[j] <= 'Z' || expr[j] >= '0' && expr[j] <= '9') { + j++ + } + toks = append(toks, expr[i:j]) + i = j + default: + return false, false + } + } + p := &csCondParser{toks: toks} + v := p.or() + if p.bad || p.pos != len(toks) { + return false, false + } + return v, true +} + +type csCondParser struct { + toks []string + pos int + bad bool +} + +func (p *csCondParser) peek() string { + if p.pos < len(p.toks) { + return p.toks[p.pos] + } + return "" +} + +func (p *csCondParser) prim() bool { + t := p.peek() + p.pos++ + switch t { + case "!": + return !p.prim() + case "(": + v := p.or() + if p.peek() != ")" { + p.bad = true + } + p.pos++ + return v + case "true": + return true + case "", "&&", "||", "==", "!=", ")": + p.bad = true + } + return false // `false` or an undefined symbol +} + +func (p *csCondParser) eq() bool { + v := p.prim() + for p.peek() == "==" || p.peek() == "!=" { + op := p.peek() + p.pos++ + w := p.prim() + v = (v == w) == (op == "==") + } + return v +} + +func (p *csCondParser) and() bool { + v := p.eq() + for p.peek() == "&&" { + p.pos++ + w := p.eq() + v = v && w + } + return v +} + +func (p *csCondParser) or() bool { + v := p.and() + for p.peek() == "||" { + p.pos++ + w := p.and() + v = v || w + } + return v +} diff --git a/strategies/cspreproc_test.go b/strategies/cspreproc_test.go new file mode 100644 index 0000000..a1381c8 --- /dev/null +++ b/strategies/cspreproc_test.go @@ -0,0 +1,52 @@ +package strategies + +import ( + "strings" + "testing" +) + +func TestCSEvalCondition(t *testing.T) { + for expr, want := range map[string]bool{ + "NET20": false, "!HAVE_LINQ": true, "!(NET20 || NET35)": true, + "!(PORTABLE || DNXCORE50) || NETSTANDARD2_0": true, "true && !X": true, + "A == B": true, "A != B": false, "false": false, + } { + got, ok := csEvalCondition(expr) + if !ok || got != want { + t.Errorf("%q = %v,%v want %v", expr, got, ok, want) + } + } + if _, ok := csEvalCondition("A &&"); ok { + t.Errorf("malformed expression must not evaluate") + } +} + +// Only the branch the compiler takes with no symbols defined imports: the +// #else's System.Linq, not the NET20 LinqBridge polyfill. A lone #if keeps +// its using. +func TestCSharpImportsSkipLosingAlternateBranch(t *testing.T) { + src := `#if NET20 +using Newtonsoft.Json.Utilities.LinqBridge; +#else +using System.Linq; +#endif +#if !HAVE_LINQ +using Polyfill.A; +#else +using Polyfill.B; +#endif +#if !(NET20 || NET35) +using System.Numerics; +#endif +class C {}` + inactive := csAlternateInactiveLines([]byte(src)) + var live []string + for i, l := range strings.Split(src, "\n") { + if strings.HasPrefix(l, "using ") && !inactive[i+1] { + live = append(live, strings.TrimSuffix(strings.TrimPrefix(l, "using "), ";")) + } + } + if got := strings.Join(live, ","); got != "System.Linq,Polyfill.A,System.Numerics" { + t.Errorf("live usings = %s", got) + } +} diff --git a/strategies/extractors.go b/strategies/extractors.go index e5aa355..10b615f 100644 --- a/strategies/extractors.go +++ b/strategies/extractors.go @@ -369,6 +369,7 @@ func goIdentifierNames(n *sitter.Node, src []byte) []string { func extractJSNodes(root *sitter.Node, filePath, blobSHA, language string, src []byte, imports []string) []astkit.Symbol { var out []astkit.Symbol jsVisit(root, filePath, blobSHA, language, src, imports, "", false, &out) + jsLocalFunctions(root, filePath, blobSHA, language, src, imports, &out) out = jsDedupeThisFields(out) for name := range jsNamedExports(root, src) { for i := range out { @@ -2950,7 +2951,9 @@ func cMacroSymbol(name string, paramsNode *sitter.Node, body, raw string, span a // (`log:message:`) or one unary name. func objcMacroSends(code string, line int) []astkit.CallSite { var out []astkit.CallSite - isIdent := func(b byte) bool { return b == '_' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' } + isIdent := func(b byte) bool { + return b == '_' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' + } for i := 0; i < len(code); i++ { if code[i] != '[' { continue @@ -6860,3 +6863,82 @@ func funcSig(n *sitter.Node, src []byte) string { sig = strings.TrimRight(sig, " \t\n{") return strings.TrimSpace(sig) } + +// jsFunctionLike reports the JS/TS nodes that open a function body. +func jsFunctionLike(t string) bool { + switch t { + case "function_declaration", "generator_function_declaration", "function_expression", "function", + "generator_function", "arrow_function", "method_definition", "class_static_block": + return true + } + return false +} + +// jsNestedFunctionDecl reports a function declaration written inside a +// function body (`function encodeOne(packet) {...}` inside encodePayload): +// it is a symbol of its own, so its calls are not its encloser's. +func jsNestedFunctionDecl(n *sitter.Node) bool { + if n.Type() != "function_declaration" && n.Type() != "generator_function_declaration" { + return false + } + for p := n.Parent(); p != nil; p = p.Parent() { + if jsFunctionLike(p.Type()) { + return true + } + } + return false +} + +// jsLocalFunctions emits every nested function declaration as a function +// symbol marked "local": visible only inside the function that declares it +// (consumers scope bare calls by that). socket.io's parser-v3 encodeOne +// was unindexed and its calls were credited to encodePayload. +func jsLocalFunctions(root *sitter.Node, filePath, blobSHA, language string, src []byte, imports []string, out *[]astkit.Symbol) { + var walk func(n *sitter.Node) + walk = func(n *sitter.Node) { + if n == nil { + return + } + if jsNestedFunctionDecl(n) { + if sym := jsNamedSym(n, "name", filePath, blobSHA, language, src, imports, astkit.KindFunction, "", false); sym != nil { + // Named like the TypeScript pass's declaration of it: + // qualified by the enclosing declarations, no parent type. + sym.QualifiedName = qualJoin(jsEnclosingQualifier(n, src), sym.Name) + sym.Modifiers = append(sym.Modifiers, "local") + *out = append(*out, *sym) + } + } + for i := 0; i < int(n.ChildCount()); i++ { + walk(n.Child(i)) + } + } + walk(root) +} + +// jsEnclosingQualifier names the declarations enclosing n, outermost first: +// functions, classes, methods and `const f = () => ...` bindings +// ("Socket.onClose" for a function declared inside that method). Anonymous +// enclosers add nothing. +func jsEnclosingQualifier(n *sitter.Node, src []byte) string { + var parts []string + for p := n.Parent(); p != nil; p = p.Parent() { + name := "" + switch p.Type() { + case "function_declaration", "generator_function_declaration", "class_declaration", "abstract_class_declaration", + "method_definition", "internal_module", "module": + if nn := p.ChildByFieldName("name"); nn != nil { + name = nn.Content(src) + } + case "arrow_function", "function_expression", "function", "generator_function": + if d := p.Parent(); d != nil && d.Type() == "variable_declarator" { + if nn := d.ChildByFieldName("name"); nn != nil && nn.Type() == "identifier" { + name = nn.Content(src) + } + } + } + if name != "" { + parts = append([]string{name}, parts...) + } + } + return strings.Join(parts, ".") +} diff --git a/strategies/jslocal_test.go b/strategies/jslocal_test.go new file mode 100644 index 0000000..c3fe29c --- /dev/null +++ b/strategies/jslocal_test.go @@ -0,0 +1,47 @@ +package strategies_test + +import ( + "testing" + + "github.com/provasign/astkit" +) + +// A function declared inside a function body is its own symbol, marked +// local and qualified by its enclosers; its calls are not its encloser's. +func TestJS_NestedFunctionDeclarationIsLocalSymbol(t *testing.T) { + src := `export function encodePayload(packets, callback) { + function encodeOne(packet, done) { + encodePacket(packet, done); + } + map(packets, encodeOne, callback); +} +class Socket { + onClose() { + function cleanup() { clearTimeout(this.t); } + cleanup(); + } +}` + for _, lang := range []astkit.LanguageKey{astkit.LangTypeScript, astkit.LangJavaScript} { + syms, _ := extract(t, lang, src) + byQual := map[string]astkit.Symbol{} + for _, s := range syms { + byQual[s.QualifiedName] = s + } + one, ok := byQual["encodePayload.encodeOne"] + if !ok || one.ParentName != "" || !contains(one.Modifiers, "local") { + t.Fatalf("%s: encodeOne = %+v (symbols %v)", lang, one, byQual) + } + if len(one.CallSites) != 1 || one.CallSites[0].Callee != "encodePacket" { + t.Errorf("%s: encodeOne calls %v", lang, one.CallSites) + } + for _, cs := range byQual["encodePayload"].CallSites { + if cs.Callee == "encodePacket" { + t.Errorf("%s: encodePayload must not own encodeOne's call", lang) + } + } + if _, ok := byQual["Socket.onClose.cleanup"]; !ok { + t.Errorf("%s: nested function in a method missing: %v", lang, byQual) + } + } +} + diff --git a/strategies/metadata.go b/strategies/metadata.go index a11cc59..c029b51 100644 --- a/strategies/metadata.go +++ b/strategies/metadata.go @@ -26,6 +26,9 @@ type callSpec struct { // genericFn reports whether a call carries explicit type arguments // (DeserializeObject(...)); nil means "never generic" for the language. genericFn func(call *sitter.Node, src []byte) bool + // skip reports a nested node whose calls belong to another symbol (a + // JS/TS function declaration inside a function body is its own symbol). + skip func(n *sitter.Node) bool } // qualifierName reduces a callee's receiver operand to one identifier so @@ -100,6 +103,9 @@ func collectCallSites(body *sitter.Node, src []byte, spec callSpec) []astkit.Cal if n == nil { return } + if n != body && spec.skip != nil && spec.skip(n) { + return + } for _, t := range spec.nodeTypes { if n.Type() == t { callee := spec.calleeFn(n, src) @@ -206,7 +212,17 @@ func argToken(c *sitter.Node, src []byte) string { case "decimal_integer_literal", "hex_integer_literal", "octal_integer_literal", "binary_integer_literal", "int_literal", "integer", "integer_literal", "number_literal": text := string(c.Content(src)) + lower := strings.ToLower(text) + hex := strings.HasPrefix(lower, "0x") switch { + case strings.HasSuffix(lower, "ul") || strings.HasSuffix(lower, "lu"): + return "#ulong" // C# + case strings.HasSuffix(lower, "u") && c.Type() == "integer_literal": + return "#uint" // C# + case strings.HasSuffix(lower, "m") && c.Type() == "integer_literal": + return "#decimal" // C# + case hex && !strings.HasSuffix(lower, "l"): + return "#int" // 0x1F: F and D are hex digits, not suffixes case strings.HasSuffix(text, "L") || strings.HasSuffix(text, "l"): return "#long" case strings.HasSuffix(text, "F") || strings.HasSuffix(text, "f"): @@ -222,7 +238,31 @@ func argToken(c *sitter.Node, src []byte) string { if strings.HasSuffix(text, "F") || strings.HasSuffix(text, "f") { return "#float" } + if c.Type() == "real_literal" && (strings.HasSuffix(text, "M") || strings.HasSuffix(text, "m")) { + return "#decimal" // C# 9.0m + } return "#double" + case "prefix_unary_expression", "unary_expression": + // -1L, -0.0d, +x, ~mask keep the operand's type; !x is a bool. + text := strings.TrimSpace(string(c.Content(src))) + if strings.HasPrefix(text, "!") && !strings.HasPrefix(text, "!=") { + return "#boolean" + } + if (strings.HasPrefix(text, "-") || strings.HasPrefix(text, "+") || strings.HasPrefix(text, "~")) && + !strings.HasPrefix(text, "--") && !strings.HasPrefix(text, "++") && c.NamedChildCount() == 1 { + if v := argToken(c.NamedChild(0), src); strings.HasPrefix(v, "#") { + return v + } + } + case "as_expression": + // C#: `o as Uri` is typed Uri (or null). + if t := c.ChildByFieldName("right"); t != nil { + if name := csTypeLastName(t, src); name != "" { + return "#" + name + } + } + case "is_expression", "is_pattern_expression": + return "#boolean" case "true", "false": return "#boolean" case "cast_expression": @@ -272,11 +312,8 @@ func argToken(c *sitter.Node, src []byte) string { if name != nil && obj != nil { n := string(name.Content(src)) t := string(obj.Content(src)) - if n == "MaxValue" || n == "MinValue" { - switch t { - case "int", "long", "short", "byte", "char", "double", "float", "decimal", "uint", "ulong", "ushort", "sbyte": - return "#" + t - } + if typ := csStaticValueType(t, n); typ != "" { + return "#" + typ } // Formatting.Indented: a PascalCase member of a PascalCase // identifier is an enum value (or a static member) — "%Type" @@ -316,8 +353,18 @@ func argToken(c *sitter.Node, src []byte) string { return argToken(c.NamedChild(0), src) } case "binary_expression": + op := c.ChildByFieldName("operator") + if op != nil && c.ChildByFieldName("left") != nil { + switch string(op.Content(src)) { + case "==", "!=", "<", ">", "<=", ">=", "&&", "||": + // Comparisons and logical operators are bool in every + // C-family language here (C# overloads `operator ==` only + // to return bool by convention; tree-sitter C# names it). + return "#boolean" + } + } // C#: `"#" + hex` is a string whenever either operand is one. - if op := c.ChildByFieldName("operator"); op != nil && string(op.Content(src)) == "+" { + if op != nil && string(op.Content(src)) == "+" { l, r := c.ChildByFieldName("left"), c.ChildByFieldName("right") if l != nil && argToken(l, src) == "#String" || r != nil && argToken(r, src) == "#String" { return "#String" @@ -350,7 +397,7 @@ func argToken(c *sitter.Node, src []byte) string { name = string(fn.NamedChild(0).Content(src)) } } - if name == "ToString" { + if name == "ToString" || name == "nameof" && fn.Type() == "identifier" { return "#String" } if name != "" { @@ -470,6 +517,7 @@ func goCallSites(body *sitter.Node, src []byte) []astkit.CallSite { func jsCallSites(body *sitter.Node, src []byte) []astkit.CallSite { return collectCallSites(body, src, callSpec{ nodeTypes: []string{"call_expression", "new_expression", "jsx_opening_element", "jsx_self_closing_element"}, + skip: jsNestedFunctionDecl, calleeFn: func(call *sitter.Node, src []byte) string { if call.Type() == "jsx_opening_element" || call.Type() == "jsx_self_closing_element" { if name := call.ChildByFieldName("name"); name != nil { @@ -2054,3 +2102,62 @@ func rustExprReceiver(value *sitter.Node, src []byte) string { // call on a value of unknown type — never a bare call to a function. return rustExprReceiverMark } + +// csStaticValueType types a C# BCL static value by its declaring type: +// DateTime.Now, Int32.MaxValue, double.NaN, Guid.Empty, string.Empty. +// Only well-known members whose type is the declaring type itself (or +// string for String.Empty); anything else is "". +func csStaticValueType(typ, member string) string { + kw := map[string]string{ + "Int16": "short", "Int32": "int", "Int64": "long", "UInt16": "ushort", "UInt32": "uint", "UInt64": "ulong", + "Byte": "byte", "SByte": "sbyte", "Single": "float", "Double": "double", "Decimal": "decimal", "Char": "char", + "short": "short", "int": "int", "long": "long", "ushort": "ushort", "uint": "uint", "ulong": "ulong", + "byte": "byte", "sbyte": "sbyte", "float": "float", "double": "double", "decimal": "decimal", "char": "char", + } + if k, ok := kw[typ]; ok { + switch member { + case "MaxValue", "MinValue": + return k + case "NaN", "PositiveInfinity", "NegativeInfinity", "Epsilon": + if k == "double" || k == "float" { + return k + } + case "One", "Zero", "MinusOne": + if k == "decimal" { + return k + } + } + return "" + } + switch typ { + case "String", "string": + if member == "Empty" { + return "String" + } + case "DateTime": + switch member { + case "Now", "UtcNow", "Today", "MinValue", "MaxValue", "UnixEpoch": + return typ + } + case "DateTimeOffset": + switch member { + case "Now", "UtcNow", "MinValue", "MaxValue", "UnixEpoch": + return typ + } + case "TimeSpan": + switch member { + case "Zero", "MinValue", "MaxValue": + return typ + } + case "Guid": + if member == "Empty" { + return typ + } + case "BigInteger": + switch member { + case "One", "Zero", "MinusOne": + return typ + } + } + return "" +} diff --git a/strategies/metadata_test.go b/strategies/metadata_test.go index 4739d8e..db8dcad 100644 --- a/strategies/metadata_test.go +++ b/strategies/metadata_test.go @@ -639,6 +639,44 @@ func TestCSharp_CallSiteArgMarkers(t *testing.T) { } } +// Typed constants and operators the Roslyn overload choice depends on: +// suffixed and negated literals, BCL static values, `as`, comparisons and +// nameof (newtonsoft JValue/WriteValue overloads, oracle review 2026-10-16). +func TestCSharp_CallSiteTypedConstantArgs(t *testing.T) { + src := `class A { + void F(object o) { + W(-0.0d); + W(9.0m); + W(1u); + W(1UL); + W(-5L); + W(DateTime.Now); + W(Int32.MaxValue); + W(double.NaN); + W(Guid.Empty); + W(string.Empty); + W(TimeSpan.Zero); + W(o as Uri); + W(a == b); + W(!ok); + W(nameof(F)); + } +}` + syms, _ := extract(t, astkit.LangCSharp, src) + var got []string + for _, s := range syms { + for _, cs := range s.CallSites { + if cs.Callee == "W" { + got = append(got, strings.Join(cs.Args, ",")) + } + } + } + want := []string{"#double", "#decimal", "#uint", "#ulong", "#long", "#DateTime", "#int", "#double", "#Guid", "#String", "#TimeSpan", "#Uri", "#boolean", "#boolean", "#String"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("args\n got %v\nwant %v", got, want) + } +} + // A C prototype for a function defined in the same file is not a second // function; a prototype without a definition (a header) stays. func TestC_PrototypeCollapsesIntoDefinition(t *testing.T) { diff --git a/strategies/registry.go b/strategies/registry.go index 83d4c7a..e1b340d 100644 --- a/strategies/registry.go +++ b/strategies/registry.go @@ -467,10 +467,14 @@ func (c *csharpStrategy) ExtractImports(tree *sitter.Tree, src []byte) ([]astkit // whole test files in `#if !(NET20 || ...)`) and inside namespace // blocks; walk the tree, not just the root's children, or such a // file reports no imports at all. + inactive := csAlternateInactiveLines(src) internalast.WalkTree(tree.RootNode(), func(n *sitter.Node) { if n == nil || n.Type() != "using_directive" { return } + if inactive[int(n.StartPoint().Row)+1] { + return + } raw := strings.TrimSpace(internalast.NodeText(n, src)) path := strings.TrimSpace(strings.TrimSuffix(raw, ";")) path = strings.TrimSpace(strings.TrimPrefix(path, "global "))