From b2104ee3245e1fe406a7623b3bbcf415049e7391 Mon Sep 17 00:00:00 2001 From: coseto6125 <80243681+coseto6125@users.noreply.github.com> Date: Sun, 31 May 2026 07:36:26 +0800 Subject: [PATCH 1/3] fix(parser): close symbol-extraction gaps across 8 languages Adds previously-missing symbol captures so the graph stops dropping real nodes/edges that `ecp impact`/`find` rely on. Each language ships a dedicated test bin (happy path + regression): - Java: record_declaration -> Class + components -> Property - PHP: in-class `use Trait;` -> class->trait heritage edge - C#: operator/conversion-operator/event/event-field/indexer/destructor -> Method nodes so body-calls attach via enclosing_containers - Rust: `extern "C"` foreign_mod function_signature_item captured - Swift: protocol_property_declaration captured - Kotlin: type_alias -> Typedef; companion-object methods -> Method - Python: `X: TypeAlias = ...` (PEP 613) -> Typedef, not Variable - C++: constexpr/const globals -> Const, not Variable Ruby (`class << self`) was investigated and is a verified no-op: the schema has no class-vs-instance method distinction (`RawNode` carries no is_static flag), so `def self.foo` and `class << self; def foo` already produce byte-identical output (Method, owner=EnclosingClass). Promoting the distinction would be a schema-append with no impact/find benefit, so it is intentionally left unchanged. All touched-language regression suites pass (python/csharp/kotlin full bins, 0 failures); clippy clean on ecp-analyzer. --- crates/ecp-analyzer/src/c_sharp/parser.rs | 165 +++++++++++++++++- crates/ecp-analyzer/src/c_sharp/queries.scm | 58 ++++++ crates/ecp-analyzer/src/c_sharp/spec.rs | 5 + crates/ecp-analyzer/src/cpp/parser.rs | 40 ++++- crates/ecp-analyzer/src/java/queries.scm | 27 +++ crates/ecp-analyzer/src/kotlin/parser.rs | 19 +- crates/ecp-analyzer/src/kotlin/queries.scm | 8 + crates/ecp-analyzer/src/kotlin/spec.rs | 3 + crates/ecp-analyzer/src/php/queries.scm | 16 ++ crates/ecp-analyzer/src/python/parser.rs | 12 ++ crates/ecp-analyzer/src/python/queries.scm | 7 +- crates/ecp-analyzer/src/rust/queries.scm | 12 ++ crates/ecp-analyzer/src/swift/queries.scm | 9 + .../ecp-analyzer/tests/cpp_const_globals.rs | 95 ++++++++++ .../tests/csharp_special_members.rs | 159 +++++++++++++++++ crates/ecp-analyzer/tests/java_record.rs | 123 +++++++++++++ .../ecp-analyzer/tests/kotlin_gaps_audit.rs | 82 +++++++++ crates/ecp-analyzer/tests/php_trait_use.rs | 146 ++++++++++++++++ .../ecp-analyzer/tests/python_type_alias.rs | 74 ++++++++ crates/ecp-analyzer/tests/rust_extern_ffi.rs | 115 ++++++++++++ .../tests/swift_protocol_property.rs | 82 +++++++++ 21 files changed, 1248 insertions(+), 9 deletions(-) create mode 100644 crates/ecp-analyzer/tests/cpp_const_globals.rs create mode 100644 crates/ecp-analyzer/tests/csharp_special_members.rs create mode 100644 crates/ecp-analyzer/tests/java_record.rs create mode 100644 crates/ecp-analyzer/tests/kotlin_gaps_audit.rs create mode 100644 crates/ecp-analyzer/tests/php_trait_use.rs create mode 100644 crates/ecp-analyzer/tests/python_type_alias.rs create mode 100644 crates/ecp-analyzer/tests/rust_extern_ffi.rs create mode 100644 crates/ecp-analyzer/tests/swift_protocol_property.rs diff --git a/crates/ecp-analyzer/src/c_sharp/parser.rs b/crates/ecp-analyzer/src/c_sharp/parser.rs index d674ebde1..1dc5acb7a 100644 --- a/crates/ecp-analyzer/src/c_sharp/parser.rs +++ b/crates/ecp-analyzer/src/c_sharp/parser.rs @@ -106,6 +106,17 @@ struct CSharpCaptureIndices { blind_activator_create: Option, blind_method_invoke: Option, function_anonymous: Option, + // Special-member root captures — handled with custom name synthesis in + // the parse loop (these nodes lack a plain `name: (identifier)` field). + operator_decl: Option, + conv_operator_decl: Option, + indexer_decl: Option, + // event_field root (variable_declaration pattern — multiple declarators). + event_field: Option, + // Roots for destructor and event_decl (name-based captures also exist in + // spec, but we need the root span separately like enum_member_node). + destructor: Option, + event_decl: Option, } pub struct CSharpProvider { @@ -161,6 +172,12 @@ impl CSharpProvider { blind_activator_create: query.capture_index_for_name("blind.activator_create"), blind_method_invoke: query.capture_index_for_name("blind.method_invoke"), function_anonymous: query.capture_index_for_name("function.anonymous"), + operator_decl: query.capture_index_for_name("operator_decl"), + conv_operator_decl: query.capture_index_for_name("conv_operator_decl"), + indexer_decl: query.capture_index_for_name("indexer_decl"), + event_field: query.capture_index_for_name("event_field"), + destructor: query.capture_index_for_name("destructor"), + event_decl: query.capture_index_for_name("event_decl"), }; Ok(Self { @@ -220,6 +237,11 @@ impl LanguageProvider for CSharpProvider { let idx_enum_member_node = idx.enum_member_node; let idx_struct = idx.struct_; + // Pre-resolve capture indices needed only for per-node name logic + // (destructor ~ prefix; event_field multi-declarator keying). + let idx_destructor_name = self.query.capture_index_for_name("destructor.name"); + let idx_event_field_name = self.query.capture_index_for_name("event_field.name"); + while let Some(m) = matches.next() { let mut name_node = None; let mut kind = None; @@ -233,6 +255,17 @@ impl LanguageProvider for CSharpProvider { let mut heritage_list = Vec::new(); let mut type_annotation = None; let mut decorators = Vec::new(); + // Set when the name capture comes from a destructor_declaration so + // the name string gets a `~` prefix appended below. + let mut is_destructor_name = false; + // Set for event_field_declaration declarator names so node_id keys + // on the identifier (same logic as Property/Variable multi-declarator). + let mut is_event_field_name = false; + // Deferred special-member root captures — node is emitted AFTER the + // capture loop so decorator/export/type metadata is fully collected. + let mut deferred_operator: Option> = None; + let mut deferred_conv_operator: Option> = None; + let mut deferred_indexer: Option> = None; for cap in m.captures { let cap_idx = cap.index; @@ -248,6 +281,12 @@ impl LanguageProvider for CSharpProvider { // Source of truth: CSharpSpec::CAPTURE_KIND in spec.rs. name_node = Some(cap.node); kind = Some(k_from_spec); + // Track special captures for name post-processing. + if Some(cap_idx) == idx_destructor_name { + is_destructor_name = true; + } else if Some(cap_idx) == idx_event_field_name { + is_event_field_name = true; + } } else if Some(cap_idx) == idx_import_name { import_name = Some(cap.node); } else if Some(cap_idx) == idx_import_source { @@ -329,6 +368,23 @@ impl LanguageProvider for CSharpProvider { }); } } + } else if Some(cap_idx) == idx.operator_decl { + // Defer: node emitted after capture loop with fully-collected metadata. + deferred_operator = Some(cap.node); + } else if Some(cap_idx) == idx.conv_operator_decl { + // Defer: node emitted after capture loop with fully-collected metadata. + deferred_conv_operator = Some(cap.node); + } else if Some(cap_idx) == idx.indexer_decl { + // Defer: node emitted after capture loop with fully-collected metadata. + deferred_indexer = Some(cap.node); + } else if Some(cap_idx) == idx.event_field { + // event_field_declaration root — span anchor for the per-declarator + // nodes emitted by the @event_field.name spec-driven captures. + // We only track this as a root; name_node / kind come from the + // spec dispatch above. + if root_span_node.is_none() { + root_span_node = Some(cap.node); + } } else if (Some(cap_idx) == idx_function || Some(cap_idx) == idx_class || Some(cap_idx) == idx_method @@ -339,13 +395,106 @@ impl LanguageProvider for CSharpProvider { || Some(cap_idx) == idx_namespace || Some(cap_idx) == idx_enum || Some(cap_idx) == idx_enum_member_node - || Some(cap_idx) == idx_struct) + || Some(cap_idx) == idx_struct + || Some(cap_idx) == idx.destructor + || Some(cap_idx) == idx.event_decl) && root_span_node.is_none() { root_span_node = Some(cap.node); } } + // Emit deferred special-member nodes (operator/conv-operator/indexer). + // These are processed AFTER the capture loop so that decorator/export/type + // metadata is fully accumulated before the RawNode is pushed. + let emit_special = + |n: tree_sitter::Node<'_>, + name: String, + is_exp: bool, + ty: Option, + decs: Vec, + nodes: &mut Vec, + id_map: &mut rustc_hash::FxHashMap| { + let start = n.start_position(); + let end = n.end_position(); + id_map.entry(n.id()).or_insert_with(|| { + let i = nodes.len(); + nodes.push(RawNode { + decorators: decs, + is_exported: is_exp, + heritage: Vec::new(), + type_annotation: ty, + name, + kind: NodeKind::Method, + span: ( + start.row as u32, + start.column as u32, + end.row as u32, + end.column as u32, + ), + calls: Vec::new(), + field_reads: Vec::new(), + owner_class: None, + content_hash: ecp_core::uid::xxh3_64_bytes( + &source[n.start_byte()..n.end_byte()], + ), + }); + i + }); + }; + if let Some(op_node) = deferred_operator { + // operator_declaration: name = "op_" from the `operator:` field. + let op_name = op_node + .child_by_field_name("operator") + .and_then(|tok| { + std::str::from_utf8(&source[tok.start_byte()..tok.end_byte()]).ok() + }) + .map(|tok| format!("op_{tok}")) + .unwrap_or_else(|| "op_unknown".to_string()); + emit_special( + op_node, + op_name, + is_exported, + type_annotation.clone(), + decorators.clone(), + &mut nodes, + &mut node_id_to_idx, + ); + } + if let Some(cv_node) = deferred_conv_operator { + // conversion_operator_declaration: name = "op_Implicit" / "op_Explicit". + let conv_name = { + let mut c = cv_node.walk(); + let found = cv_node.children(&mut c).find_map(|ch| match ch.kind() { + "implicit" => Some("op_Implicit"), + "explicit" => Some("op_Explicit"), + _ => None, + }); + found.unwrap_or("op_Conversion").to_string() + }; + emit_special( + cv_node, + conv_name, + is_exported, + type_annotation.clone(), + decorators.clone(), + &mut nodes, + &mut node_id_to_idx, + ); + } + if let Some(idx_node) = deferred_indexer { + // indexer_declaration: canonical name "this[...]". + emit_special( + idx_node, + "this[...]".to_string(), + is_exported, + type_annotation.clone(), + decorators.clone(), + &mut nodes, + &mut node_id_to_idx, + ); + } + // Reclassify class declarations inheriting from `Attribute` (or any // base whose name ends in `Attribute`) as NodeKind::Annotation — // C# attribute-class convention. Heritage check alone is sufficient @@ -402,11 +551,21 @@ impl LanguageProvider for CSharpProvider { // For Property + Variable nodes, multiple declarators // share the same root node id (`int x, y, z;`); key on // the identifier node so each declarator is distinct. - let node_id = if matches!(k, NodeKind::Property | NodeKind::Variable) { + // event_field_declaration has the same multiple-declarator + // pattern (`event EventHandler E, F;`), so same keying. + let node_id = if matches!(k, NodeKind::Property | NodeKind::Variable) + || is_event_field_name + { n.id() } else { root.id() }; + // Destructors: prefix the bare class name with `~`. + let canonical_name = if is_destructor_name { + format!("~{name_str}") + } else { + name_str.to_string() + }; let idx = *node_id_to_idx.entry(node_id).or_insert_with(|| { let i = nodes.len(); nodes.push(RawNode { @@ -414,7 +573,7 @@ impl LanguageProvider for CSharpProvider { is_exported, heritage: Vec::new(), type_annotation: type_annotation.clone(), - name: name_str.to_string(), + name: canonical_name.clone(), kind: k, span: ( start.row as u32, diff --git a/crates/ecp-analyzer/src/c_sharp/queries.scm b/crates/ecp-analyzer/src/c_sharp/queries.scm index a707f7c25..365a287f0 100644 --- a/crates/ecp-analyzer/src/c_sharp/queries.scm +++ b/crates/ecp-analyzer/src/c_sharp/queries.scm @@ -137,6 +137,64 @@ name: (_) @namespace.name ) @namespace +;; Destructors — `~ClassName()`. The `name:` field is the bare class identifier; +;; parser.rs prepends `~` to produce the canonical name `~ClassName`. +;; Emitted as Method so body-calls attach via attach_to_enclosing. +(destructor_declaration + (attribute_list)* @decorator + (modifier)* @export + name: (identifier) @destructor.name +) @destructor + +;; Event declarations with add/remove accessors — `public event EventHandler E { add{} remove{} }`. +;; Body-calls inside add/remove must attach to the event member. Emitted as +;; Method (not Property) so enclosing_containers includes the span. +(event_declaration + (attribute_list)* @decorator + (modifier)* @export + type: (_) @type + name: (identifier) @event.name +) @event_decl + +;; Event field declarations — `public event EventHandler Click, Hover;`. +;; No body, but the node must exist for who-subscribes/who-raises queries. +;; Name extracted per declarator from the variable_declaration child. +;; Emitted as Method for consistency (matches event_declaration node kind). +(event_field_declaration + (attribute_list)* @decorator + (modifier)* @export + (variable_declaration + (variable_declarator + name: (identifier) @event_field.name)) +) @event_field + +;; Operator declarations — `public static Foo operator +(Foo a, Foo b) {}`. +;; The operator token (field `operator:`) is anonymous, so the name is +;; synthesised in parser.rs as `"op_" + token_text` (e.g. `op_+`, `op_==`). +;; Root captured as @operator_decl; parser.rs walks the node for the token. +(operator_declaration + (attribute_list)* @decorator + (modifier)* @export +) @operator_decl + +;; Conversion operator declarations — `public static explicit operator int(Foo f) {}`. +;; Name synthesised in parser.rs as `op_Implicit` or `op_Explicit` based on the +;; implicit/explicit keyword, matching .NET operator-overload naming convention. +(conversion_operator_declaration + (attribute_list)* @decorator + (modifier)* @export +) @conv_operator_decl + +;; Indexer declarations — `public int this[int idx] { get { ... } set { ... } }`. +;; No identifier name; parser.rs assigns the canonical name `"this[...]"` so the +;; node is unambiguously recognisable in ecp find/impact output. +;; Emitted as Method so body-calls inside get/set attach. +(indexer_declaration + (attribute_list)* @decorator + (modifier)* @export + type: (_) @type +) @indexer_decl + ;; Anonymous callbacks passed as call arguments (`list.ForEach(x => ...)`, ;; `Task.Run(() => ...)`, `delegate { ... }`). Without a node here their ;; body's calls are dropped by attach_to_enclosing when no named enclosing diff --git a/crates/ecp-analyzer/src/c_sharp/spec.rs b/crates/ecp-analyzer/src/c_sharp/spec.rs index 1751584e8..d64940069 100644 --- a/crates/ecp-analyzer/src/c_sharp/spec.rs +++ b/crates/ecp-analyzer/src/c_sharp/spec.rs @@ -20,5 +20,10 @@ impl LangSpec for CSharpSpec { "enum.name" => NodeKind::Enum, "enum_member.name" => NodeKind::EnumVariant, "struct.name" => NodeKind::Struct, + // Special members — all emitted as Method so body-calls attach via + // enclosing_containers (which gates on Function|Method|Constructor). + "destructor.name" => NodeKind::Method, + "event.name" => NodeKind::Method, + "event_field.name" => NodeKind::Method, }; } diff --git a/crates/ecp-analyzer/src/cpp/parser.rs b/crates/ecp-analyzer/src/cpp/parser.rs index a48c0ffd8..c807841c1 100644 --- a/crates/ecp-analyzer/src/cpp/parser.rs +++ b/crates/ecp-analyzer/src/cpp/parser.rs @@ -141,6 +141,30 @@ fn is_cpp_reserved_keyword(name: &str) -> bool { ) } +/// True if a file-scope `declaration` node carries a `const` or `constexpr` +/// qualifier. In tree-sitter-cpp a qualifying keyword appears as a +/// `type_qualifier` named child of the `declaration` with text `"const"` or +/// `"constexpr"`. Mutable globals have no such child. +/// +/// This is intentionally scoped to the `@var` use-site (translation-unit +/// level `declaration` nodes). Class fields use `field_declaration` and +/// function parameters use `parameter_declaration` — neither reaches this +/// helper. +fn is_const_qualified(decl: tree_sitter::Node<'_>, source: &[u8]) -> bool { + let mut cursor = decl.walk(); + for child in decl.children(&mut cursor) { + if child.kind() == "type_qualifier" { + if let Ok(text) = std::str::from_utf8(&source[child.start_byte()..child.end_byte()]) { + let text = text.trim(); + if text == "const" || text == "constexpr" { + return true; + } + } + } + } + false +} + /// Per upstream `c-cpp.ts:414-431` `cppProvider.astFrameworkPatterns`. /// Note: upstream's `cProvider` has no `astFrameworkPatterns`, so this is /// C++-only. @@ -502,6 +526,15 @@ impl LanguageProvider for CppProvider { // parameters and type keywords as @var.name. Real var decls in // well-formed code carry has_error=false and never use keywords // as identifier names. + // + // const/constexpr globals → NodeKind::Const: a file-scope + // `declaration` whose first named child is a `type_qualifier` + // with text "const" or "constexpr" is a compile-time constant. + // The `@var` query already anchors to `(translation_unit + // (declaration ...))`, so `v_root` is always translation-unit + // level; const-qualified class fields use `field_declaration` + // (→ @field) and const params use `parameter_declaration` + // (→ @param) — neither reaches this branch. if let (Some(v_root), Some(v_name)) = (var_root, var_name) { if let Ok(name_str) = std::str::from_utf8(&source[v_name.start_byte()..v_name.end_byte()]) @@ -509,13 +542,18 @@ impl LanguageProvider for CppProvider { if !v_root.has_error() && !is_cpp_reserved_keyword(name_str) { let start = v_root.start_position(); let end = v_root.end_position(); + let kind = if is_const_qualified(v_root, source) { + NodeKind::Const + } else { + NodeKind::Variable + }; nodes.push(RawNode { decorators: vec![], is_exported: is_header || is_exported_by_query, heritage: vec![], type_annotation: slice_type_before(v_root, v_name, source), name: name_str.to_string(), - kind: NodeKind::Variable, + kind, span: ( start.row as u32, start.column as u32, diff --git a/crates/ecp-analyzer/src/java/queries.scm b/crates/ecp-analyzer/src/java/queries.scm index a099fb9d5..91cfcdbbd 100644 --- a/crates/ecp-analyzer/src/java/queries.scm +++ b/crates/ecp-analyzer/src/java/queries.scm @@ -53,6 +53,25 @@ name: (identifier) @property.name) ) @property +;; Records — Java 16+ record declarations. Reuse NodeKind::Class (same +;; reference-target semantics; no separate NodeKind needed). Record header +;; components (formal_parameters) emit Property nodes, mirroring field_declaration. +(record_declaration + (modifiers [ + "public" + "protected" + ])? @export + name: (identifier) @class.name + interfaces: (super_interfaces (type_list (_) @heritage))? +) @class + +(record_declaration + parameters: (formal_parameters + (formal_parameter + type: (_) @type + name: (identifier) @property.name) @property) +) + ;; Imports — regular named import (import_declaration [ @@ -132,3 +151,11 @@ ]) name: (identifier) @constructor.name ) @constructor + +(record_declaration + (modifiers [ + (annotation) @decorator + (marker_annotation) @decorator + ]) + name: (identifier) @class.name +) @class diff --git a/crates/ecp-analyzer/src/kotlin/parser.rs b/crates/ecp-analyzer/src/kotlin/parser.rs index e2e33d383..778c48917 100644 --- a/crates/ecp-analyzer/src/kotlin/parser.rs +++ b/crates/ecp-analyzer/src/kotlin/parser.rs @@ -56,6 +56,7 @@ struct KotlinCaptureIndices { class: Option, function: Option, enum_entry: Option, + typedef: Option, // BlindSpot captures (FU-001 P2b). blind_class_forname: Option, blind_method_invoke: Option, @@ -114,9 +115,16 @@ fn is_class_method(func: tree_sitter::Node) -> bool { if parent.kind() != "class_body" { return false; } - parent - .parent() - .is_some_and(|p| p.kind() == "class_declaration") + // class_body may be owned by class_declaration OR companion_object. + // companion_object methods are class-level (static-equivalent) and should + // be Method, not Function — the companion_object node is a class-body + // sibling of regular members, so its parent is also class_declaration. + parent.parent().is_some_and(|p| { + matches!( + p.kind(), + "class_declaration" | "companion_object" | "object_declaration" + ) + }) } /// True when `class_decl` has a direct child whose kind matches `keyword`. @@ -210,6 +218,7 @@ impl KotlinProvider { class: query.capture_index_for_name("class"), function: query.capture_index_for_name("function"), enum_entry: query.capture_index_for_name("enum_entry"), + typedef: query.capture_index_for_name("typedef"), blind_class_forname: query.capture_index_for_name("blind.class_forname"), blind_method_invoke: query.capture_index_for_name("blind.method_invoke"), function_anonymous: query.capture_index_for_name("function.anonymous"), @@ -264,6 +273,7 @@ impl LanguageProvider for KotlinProvider { let idx_decorator = idx.decorator; let idx_override_marker = idx.override_marker; let idx_class = idx.class; + let idx_typedef = idx.typedef; let idx_function = idx.function; let idx_enum_entry = idx.enum_entry; @@ -398,7 +408,8 @@ impl LanguageProvider for KotlinProvider { || Some(cap_idx) == self.idx_constructor || Some(cap_idx) == self.idx_property || Some(cap_idx) == self.idx_variable - || Some(cap_idx) == idx_enum_entry) + || Some(cap_idx) == idx_enum_entry + || Some(cap_idx) == idx_typedef) && root_span_node.is_none() { root_span_node = Some(cap.node); diff --git a/crates/ecp-analyzer/src/kotlin/queries.scm b/crates/ecp-analyzer/src/kotlin/queries.scm index 172e424e4..b744d0bea 100644 --- a/crates/ecp-analyzer/src/kotlin/queries.scm +++ b/crates/ecp-analyzer/src/kotlin/queries.scm @@ -90,6 +90,14 @@ (#eq? @override_marker "override")) (simple_identifier) @function.name) @function +; Type aliases — `typealias Callback = (String) -> Unit`. +; WHY: type aliases are real reference targets; `ecp find Callback` must resolve +; without grep, and impact queries need the Typedef→Function edges. +; tree-sitter-kotlin grammar: type_alias node contains (type_identifier) as the +; alias name (grammar.js: `alias($.simple_identifier, $.type_identifier)`). +(type_alias + (type_identifier) @typedef.name) @typedef + ; Anonymous callbacks: trailing lambda (`list.forEach { process(it) }`) and ; paren-position lambda (`list.map({ x -> f(x) })`). Without a node here their ; body's calls are dropped by attach_to_enclosing when no named enclosing scope diff --git a/crates/ecp-analyzer/src/kotlin/spec.rs b/crates/ecp-analyzer/src/kotlin/spec.rs index 503c54aaa..e4dabb608 100644 --- a/crates/ecp-analyzer/src/kotlin/spec.rs +++ b/crates/ecp-analyzer/src/kotlin/spec.rs @@ -35,6 +35,9 @@ impl LangSpec for KotlinSpec { // resolve to the correct level (was `Enum` before NodeKind::EnumVariant // was introduced). "enum_entry.name" => NodeKind::EnumVariant, + // `typealias Callback = ...` — type aliases are real reference targets; + // `ecp find` and impact queries need the Typedef node to avoid grep fallback. + "typedef.name" => NodeKind::Typedef, }; // Kotlin uses query-level scope anchoring; no runtime scope gate needed. diff --git a/crates/ecp-analyzer/src/php/queries.scm b/crates/ecp-analyzer/src/php/queries.scm index 8259a4cc6..7017e1647 100644 --- a/crates/ecp-analyzer/src/php/queries.scm +++ b/crates/ecp-analyzer/src/php/queries.scm @@ -46,6 +46,22 @@ (trait_declaration name: (name) @name.trait) @trait +;; In-class trait composition — `use TraitName;` inside a class body +;; (use_declaration inside declaration_list). Each trait name in a +;; multi-trait `use A, B;` is a separate named child, so this pattern +;; fires once per trait name and accumulates into the class's heritage +;; list via the same @heritage + node_id_to_idx merge as base_clause / +;; class_interface_clause. Deliberately excludes the use_list child +;; (adaptation-block `use A { ... }`) because use_list children are +;; conflict-resolution clauses, not trait names. Note: the top-level +;; file-import `use Some\Namespace\Thing;` is a namespace_use_declaration +;; node — structurally different and NOT matched here. +(class_declaration + name: (name) @name.class + body: (declaration_list + (use_declaration + (name) @heritage))) @class + ;; Enums (PHP 8.1+) (enum_declaration name: (name) @name.enum) @enum diff --git a/crates/ecp-analyzer/src/python/parser.rs b/crates/ecp-analyzer/src/python/parser.rs index 1103e49a8..32dd5aa67 100644 --- a/crates/ecp-analyzer/src/python/parser.rs +++ b/crates/ecp-analyzer/src/python/parser.rs @@ -1092,6 +1092,18 @@ impl LanguageProvider for PythonProvider { .map(|s| s.to_string()) }); + // `X: TypeAlias = …` (PEP 613) is a reference target, not a + // value binding — reclassify Variable→Typedef. Match the bare + // and dotted (`typing.TypeAlias`) annotation forms. + let k = if k == NodeKind::Variable + && type_str + .as_deref() + .is_some_and(|t| t == "TypeAlias" || t == "typing.TypeAlias") + { + NodeKind::Typedef + } else { + k + }; let final_kind = if k == NodeKind::Function && is_class_method(root) { NodeKind::Method } else { diff --git a/crates/ecp-analyzer/src/python/queries.scm b/crates/ecp-analyzer/src/python/queries.scm index b87702b4a..72dd13634 100644 --- a/crates/ecp-analyzer/src/python/queries.scm +++ b/crates/ecp-analyzer/src/python/queries.scm @@ -64,10 +64,15 @@ ;; Both forms produce an `assignment` node in tree-sitter-python; the annotated ;; form additionally has a `type:` field, but the `left:` field is present in ;; both, so a single pattern suffices. +;; The optional `type:` field is captured in the same match so parser.rs can +;; reclassify `X: TypeAlias = …` (PEP 613) Variable→Typedef — a reference +;; target for `ecp find`/impact, not a value binding. Plain `x = …` and other +;; annotations (`x: int = …`) keep Variable. (module (expression_statement (assignment - left: (identifier) @variable.name) @variable)) + left: (identifier) @variable.name + (type)? @type) @variable)) ;; Decorators — attach raw decorator text to the parent function or class so ;; downstream consumers (Task #10/11 flag wiring, Tier 3 route detectors) can diff --git a/crates/ecp-analyzer/src/rust/queries.scm b/crates/ecp-analyzer/src/rust/queries.scm index ae94c8aa5..9a80d11fc 100644 --- a/crates/ecp-analyzer/src/rust/queries.scm +++ b/crates/ecp-analyzer/src/rust/queries.scm @@ -78,6 +78,18 @@ name: (identifier) @function_item.name return_type: (_)? @type) @method)) +;; FFI declarations: `extern "C" { fn foo(x: i32) -> i32; }`. +;; `foreign_mod_item` body is a `declaration_list` of `function_signature_item` +;; nodes (grammar-verified against tree-sitter-rust 0.24.2 node-types.json). +;; These are callable symbols; without this pattern ecp impact misses callers +;; of extern-C functions — filter (A) graph completeness. +(foreign_mod_item + body: (declaration_list + (function_signature_item + (visibility_modifier)? @export + name: (identifier) @function_item.name + return_type: (_)? @type) @function)) + ;; Associated types inside impl blocks: `type Item = T::Item;` (impl_item body: (declaration_list diff --git a/crates/ecp-analyzer/src/swift/queries.scm b/crates/ecp-analyzer/src/swift/queries.scm index f6081dd8f..8ac67fd95 100644 --- a/crates/ecp-analyzer/src/swift/queries.scm +++ b/crates/ecp-analyzer/src/swift/queries.scm @@ -77,6 +77,15 @@ (property_declaration (pattern) @property.name.pat) @property +;; Protocol property requirements — `var name: String { get }` inside a +;; `protocol` body produces `protocol_property_declaration` (distinct from +;; `property_declaration`). Reuses the same @property / @property.name.pat +;; captures so parser.rs emits a Property node via the existing property +;; path; the parent-chain walk already recognises `protocol_body` as a +;; class-like scope → NodeKind::Property (parser.rs line 472-474). +(protocol_property_declaration + name: (pattern) @property.name.pat) @property + ;; Enum cases — `case foo` / `case bar(Int)` / `case a, b, c`. Each ;; `simple_identifier` inside `enum_entry` is a separately-named case; ;; multi-name `case a, b, c` produces three captures. parser.rs emits diff --git a/crates/ecp-analyzer/tests/cpp_const_globals.rs b/crates/ecp-analyzer/tests/cpp_const_globals.rs new file mode 100644 index 000000000..41450dc38 --- /dev/null +++ b/crates/ecp-analyzer/tests/cpp_const_globals.rs @@ -0,0 +1,95 @@ +//! File-scope const/constexpr globals → NodeKind::Const, mutable globals stay +//! NodeKind::Variable. +//! +//! tree-sitter-cpp represents `const int MAX = 100;` as a `declaration` whose +//! first named child is a `type_qualifier` node with text `"const"`. +//! `constexpr double PI = 3.14;` follows the same shape with text +//! `"constexpr"`. Mutable globals (`int counter = 0;`) have no `type_qualifier` +//! child and remain Variable. The `@var` query anchors to +//! `(translation_unit (declaration ...))`, so class fields (`field_declaration`) +//! and function parameters (`parameter_declaration`) are handled by separate +//! capture branches and are unaffected. + +use ecp_analyzer::cpp::parser::CppProvider; +use ecp_core::analyzer::provider::LanguageProvider; +use ecp_core::analyzer::types::RawNode; +use ecp_core::graph::NodeKind; +use std::path::Path; + +fn parse(src: &str) -> Vec { + let provider = CppProvider::new().expect("CppProvider init"); + let graph = provider + .parse_file(Path::new("t.cpp"), src.as_bytes()) + .expect("parse_file"); + graph.nodes +} + +fn find<'a>(nodes: &'a [RawNode], name: &str, kind: NodeKind) -> &'a RawNode { + nodes + .iter() + .find(|n| n.name == name && n.kind == kind) + .unwrap_or_else(|| panic!("missing {kind:?} `{name}` in {nodes:#?}")) +} + +fn absent(nodes: &[RawNode], name: &str, kind: NodeKind) { + assert!( + nodes + .iter() + .find(|n| n.name == name && n.kind == kind) + .is_none(), + "unexpected {kind:?} `{name}` found in {nodes:#?}" + ); +} + +const SRC: &str = " +const int MAX = 100; +constexpr double PI = 3.14; +int counter = 0; +"; + +#[test] +fn const_int_global_is_const_kind() { + let nodes = parse(SRC); + find(&nodes, "MAX", NodeKind::Const); +} + +#[test] +fn constexpr_double_global_is_const_kind() { + let nodes = parse(SRC); + find(&nodes, "PI", NodeKind::Const); +} + +#[test] +fn mutable_global_stays_variable() { + let nodes = parse(SRC); + find(&nodes, "counter", NodeKind::Variable); +} + +#[test] +fn const_not_emitted_as_variable() { + // Guard against over-emission: MAX and PI must not appear as Variable. + let nodes = parse(SRC); + absent(&nodes, "MAX", NodeKind::Variable); + absent(&nodes, "PI", NodeKind::Variable); +} + +#[test] +fn mutable_global_not_emitted_as_const() { + // Guard against over-reclassification: counter must not appear as Const. + let nodes = parse(SRC); + absent(&nodes, "counter", NodeKind::Const); +} + +#[test] +fn static_const_global_is_const_kind() { + // `static const` still compile-time constant → Const. + let nodes = parse("static const int BUF_SIZE = 4096;\n"); + find(&nodes, "BUF_SIZE", NodeKind::Const); +} + +#[test] +fn plain_int_global_stays_variable() { + // Regression: plain mutable global must not be reclassified. + let nodes = parse("int g_count = 0;\n"); + find(&nodes, "g_count", NodeKind::Variable); +} diff --git a/crates/ecp-analyzer/tests/csharp_special_members.rs b/crates/ecp-analyzer/tests/csharp_special_members.rs new file mode 100644 index 000000000..190820092 --- /dev/null +++ b/crates/ecp-analyzer/tests/csharp_special_members.rs @@ -0,0 +1,159 @@ +//! Operator / event / indexer / destructor members must each emit a node so +//! that calls inside their bodies attach to an enclosing member instead of +//! being dropped by `attach_to_enclosing` (which gates on +//! Function | Method | Constructor). Before this change these members emitted +//! no node, silently corrupting `ecp impact`'s caller set for any function +//! called from inside them. + +use ecp_analyzer::c_sharp::parser::CSharpProvider; +use ecp_core::analyzer::provider::LanguageProvider; +use ecp_core::analyzer::types::LocalGraph; +use ecp_core::graph::NodeKind; +use std::path::Path; + +fn parse(src: &str) -> LocalGraph { + let p = CSharpProvider::new().expect("provider"); + p.parse_file(Path::new("test.cs"), src.as_bytes()) + .expect("parse") +} + +fn method_named<'a>( + g: &'a LocalGraph, + name: &str, +) -> Option<&'a ecp_core::analyzer::types::RawNode> { + g.nodes + .iter() + .find(|n| n.kind == NodeKind::Method && n.name == name) +} + +#[test] +fn operator_emits_method_node() { + let g = parse( + r#" +class Vec { + public static Vec operator +(Vec a, Vec b) { Combine(a, b); return a; } +} +"#, + ); + let op = method_named(&g, "op_+"); + assert!( + op.is_some(), + "expected op_+ Method node, nodes: {:?}", + g.nodes + ); + assert!( + op.unwrap().calls.iter().any(|c| c == "Combine"), + "body call Combine must attach to operator member, calls: {:?}", + op.unwrap().calls + ); +} + +#[test] +fn conversion_operator_emits_method_node() { + let g = parse( + r#" +class Money { + public static explicit operator int(Money m) { return Round(m); } +} +"#, + ); + let conv = method_named(&g, "op_Explicit"); + assert!( + conv.is_some(), + "expected op_Explicit Method node, nodes: {:?}", + g.nodes + ); + assert!( + conv.unwrap().calls.iter().any(|c| c == "Round"), + "body call Round must attach to conversion operator, calls: {:?}", + conv.unwrap().calls + ); +} + +#[test] +fn event_with_accessors_emits_node_and_attaches_body_call() { + let g = parse( + r#" +class Button { + public event System.EventHandler Click { add { Register(value); } remove { } } +} +"#, + ); + let ev = method_named(&g, "Click"); + assert!( + ev.is_some(), + "expected Click event Method node, nodes: {:?}", + g.nodes + ); + assert!( + ev.unwrap().calls.iter().any(|c| c == "Register"), + "body call Register must attach to event member, calls: {:?}", + ev.unwrap().calls + ); +} + +#[test] +fn event_field_emits_node_per_declarator() { + // `event EventHandler A, B;` — both names must exist for who-subscribes queries. + let g = parse( + r#" +class Source { + public event System.EventHandler Opened, Closed; +} +"#, + ); + assert!( + method_named(&g, "Opened").is_some(), + "expected Opened event-field node, nodes: {:?}", + g.nodes + ); + assert!( + method_named(&g, "Closed").is_some(), + "expected Closed event-field node, nodes: {:?}", + g.nodes + ); +} + +#[test] +fn indexer_emits_node_and_attaches_body_call() { + let g = parse( + r#" +class Grid { + public int this[int i] { get { return Lookup(i); } } +} +"#, + ); + let ix = method_named(&g, "this[...]"); + assert!( + ix.is_some(), + "expected this[...] indexer Method node, nodes: {:?}", + g.nodes + ); + assert!( + ix.unwrap().calls.iter().any(|c| c == "Lookup"), + "body call Lookup must attach to indexer member, calls: {:?}", + ix.unwrap().calls + ); +} + +#[test] +fn destructor_emits_node_and_attaches_body_call() { + let g = parse( + r#" +class Resource { + ~Resource() { Release(); } +} +"#, + ); + let dtor = method_named(&g, "~Resource"); + assert!( + dtor.is_some(), + "expected ~Resource destructor Method node, nodes: {:?}", + g.nodes + ); + assert!( + dtor.unwrap().calls.iter().any(|c| c == "Release"), + "body call Release must attach to destructor, calls: {:?}", + dtor.unwrap().calls + ); +} diff --git a/crates/ecp-analyzer/tests/java_record.rs b/crates/ecp-analyzer/tests/java_record.rs new file mode 100644 index 000000000..d9e93bd33 --- /dev/null +++ b/crates/ecp-analyzer/tests/java_record.rs @@ -0,0 +1,123 @@ +use ecp_analyzer::java::parser::JavaProvider; +use ecp_core::analyzer::provider::LanguageProvider; +use ecp_core::analyzer::types::LocalGraph; +use ecp_core::graph::NodeKind; +use std::path::Path; + +fn parse(src: &str) -> LocalGraph { + let p = JavaProvider::new().expect("provider"); + p.parse_file(Path::new("Test.java"), src.as_bytes()) + .expect("parse") +} + +fn find_kind(graph: &LocalGraph, name: &str, kind: NodeKind) -> bool { + graph.nodes.iter().any(|n| n.name == name && n.kind == kind) +} + +fn debug_nodes(graph: &LocalGraph) -> Vec<(&str, NodeKind)> { + graph + .nodes + .iter() + .map(|n| (n.name.as_str(), n.kind)) + .collect() +} + +#[test] +fn java_record_emits_class_node() { + let src = "public record Point(int x, int y) { public int sum() { return x + y; } }"; + let graph = parse(src); + assert!( + find_kind(&graph, "Point", NodeKind::Class), + "Point must be emitted as Class; got: {:?}", + debug_nodes(&graph) + ); +} + +#[test] +fn java_record_emits_component_properties() { + let src = "public record Point(int x, int y) { public int sum() { return x + y; } }"; + let graph = parse(src); + assert!( + find_kind(&graph, "x", NodeKind::Property), + "record component `x` must be emitted as Property; got: {:?}", + debug_nodes(&graph) + ); + assert!( + find_kind(&graph, "y", NodeKind::Property), + "record component `y` must be emitted as Property; got: {:?}", + debug_nodes(&graph) + ); +} + +#[test] +fn java_record_emits_method() { + let src = "public record Point(int x, int y) { public int sum() { return x + y; } }"; + let graph = parse(src); + assert!( + find_kind(&graph, "sum", NodeKind::Method), + "method `sum` inside record body must still be emitted; got: {:?}", + debug_nodes(&graph) + ); +} + +#[test] +fn java_record_simple_no_body() { + let src = "record Range(int low, int high) {}"; + let graph = parse(src); + assert!( + find_kind(&graph, "Range", NodeKind::Class), + "Range must be Class; got: {:?}", + debug_nodes(&graph) + ); + assert!( + find_kind(&graph, "low", NodeKind::Property), + "component `low` must be Property" + ); + assert!( + find_kind(&graph, "high", NodeKind::Property), + "component `high` must be Property" + ); +} + +#[test] +fn java_record_annotated() { + let src = r#" +@JsonDeserialize +public record Person(String name, int age) {} +"#; + let graph = parse(src); + assert!( + find_kind(&graph, "Person", NodeKind::Class), + "annotated record Person must be Class; got: {:?}", + debug_nodes(&graph) + ); + let person_node = graph + .nodes + .iter() + .find(|n| n.name == "Person" && n.kind == NodeKind::Class) + .expect("Person node"); + assert!( + !person_node.decorators.is_empty(), + "Person must carry decorator from @JsonDeserialize" + ); +} + +#[test] +fn java_record_heritage() { + let src = "public record Timestamped(long ts) implements Serializable {}"; + let graph = parse(src); + assert!( + find_kind(&graph, "Timestamped", NodeKind::Class), + "Timestamped must be Class" + ); + let node = graph + .nodes + .iter() + .find(|n| n.name == "Timestamped" && n.kind == NodeKind::Class) + .expect("Timestamped node"); + assert!( + node.heritage.iter().any(|h| h.contains("Serializable")), + "Timestamped must have Serializable in heritage; got: {:?}", + node.heritage + ); +} diff --git a/crates/ecp-analyzer/tests/kotlin_gaps_audit.rs b/crates/ecp-analyzer/tests/kotlin_gaps_audit.rs new file mode 100644 index 000000000..13c72b815 --- /dev/null +++ b/crates/ecp-analyzer/tests/kotlin_gaps_audit.rs @@ -0,0 +1,82 @@ +#[test] +fn type_alias_emits_typedef() { + // WHY: type aliases are real reference targets — `ecp find Callback` must + // resolve without grep, and impact queries need the Typedef→callee edges. + // Kotlin `typealias` is filter-(A) coverage: the graph was missing it, + // causing agents to fall back to BM25/grep for alias lookups. + use ecp_analyzer::kotlin::parser::KotlinProvider; + use ecp_core::analyzer::provider::LanguageProvider; + use ecp_core::graph::NodeKind; + use std::path::Path; + + let p = KotlinProvider::new().expect("provider"); + let source = "typealias Callback = (String) -> Unit\n"; + let g = p + .parse_file(Path::new("Test.kt"), source.as_bytes()) + .expect("parse"); + + let typedef_node = g + .nodes + .iter() + .find(|n| n.name == "Callback" && n.kind == NodeKind::Typedef); + assert!( + typedef_node.is_some(), + "Expected a Typedef node named 'Callback', got: {:?}", + g.nodes + .iter() + .map(|n| (&n.name, n.kind)) + .collect::>() + ); +} + +#[test] +fn companion_object_method_is_method_kind() { + // WHY: companion object methods are class-level (static-equivalent) members; + // emitting them as Function instead of Method causes impact queries to miss + // class-level dispatch and agents to misclassify the method's scope. + use ecp_analyzer::kotlin::parser::KotlinProvider; + use ecp_core::analyzer::provider::LanguageProvider; + use ecp_core::graph::NodeKind; + use std::path::Path; + + let p = KotlinProvider::new().expect("provider"); + let source = r#" +class Foo { + companion object { + fun bar() {} + } +} +"#; + let g = p + .parse_file(Path::new("Test.kt"), source.as_bytes()) + .expect("parse"); + + let foo_node = g + .nodes + .iter() + .find(|n| n.name == "Foo" && n.kind == NodeKind::Class); + assert!( + foo_node.is_some(), + "Expected a Class node named 'Foo', got: {:?}", + g.nodes + .iter() + .map(|n| (&n.name, n.kind)) + .collect::>() + ); + + let bar_node = g.nodes.iter().find(|n| n.name == "bar"); + assert!( + bar_node.is_some(), + "Expected a node named 'bar', got: {:?}", + g.nodes + .iter() + .map(|n| (&n.name, n.kind)) + .collect::>() + ); + assert_eq!( + bar_node.unwrap().kind, + NodeKind::Method, + "companion object method 'bar' should be Method, not {:?}", + bar_node.unwrap().kind + ); +} diff --git a/crates/ecp-analyzer/tests/php_trait_use.rs b/crates/ecp-analyzer/tests/php_trait_use.rs new file mode 100644 index 000000000..dc2b29c1a --- /dev/null +++ b/crates/ecp-analyzer/tests/php_trait_use.rs @@ -0,0 +1,146 @@ +//! In-class trait composition edges for the PHP parser. +//! +//! `use TraitName;` inside a class body must produce a heritage entry on the +//! class node — the same mechanism as `extends` / `implements`. Without this, +//! `ecp impact --target ` misses every class that composes the trait. + +use ecp_analyzer::php::parser::PhpProvider; +use ecp_core::analyzer::provider::LanguageProvider; +use ecp_core::analyzer::types::LocalGraph; +use ecp_core::graph::NodeKind; +use std::path::Path; + +fn parse(src: &str) -> LocalGraph { + let p = PhpProvider::new().expect("provider"); + p.parse_file(Path::new("test.php"), src.as_bytes()) + .expect("parse") +} + +fn class_node(g: &LocalGraph, name: &str) -> ecp_core::analyzer::types::RawNode { + g.nodes + .iter() + .find(|n| n.kind == NodeKind::Class && n.name == name) + .unwrap_or_else(|| panic!("no Class node named {name} in {:#?}", g.nodes)) + .clone() +} + +// ── Single trait use ────────────────────────────────────────────────────────── + +#[test] +fn single_trait_use_adds_heritage() { + let src = " LocalGraph { + let p = PythonProvider::new().expect("provider"); + p.parse_file(Path::new("m.py"), src.as_bytes()) + .expect("parse") +} + +fn kind_of(g: &LocalGraph, name: &str) -> Option { + g.nodes.iter().find(|n| n.name == name).map(|n| n.kind) +} + +#[test] +fn bare_typealias_annotation_is_typedef() { + let g = parse("from typing import TypeAlias\nVector: TypeAlias = list[float]\n"); + assert_eq!( + kind_of(&g, "Vector"), + Some(NodeKind::Typedef), + "TypeAlias-annotated assignment must be Typedef, nodes: {:?}", + g.nodes + ); +} + +#[test] +fn dotted_typealias_annotation_is_typedef() { + let g = parse("import typing\nMatrix: typing.TypeAlias = list[list[float]]\n"); + assert_eq!( + kind_of(&g, "Matrix"), + Some(NodeKind::Typedef), + "typing.TypeAlias-annotated assignment must be Typedef, nodes: {:?}", + g.nodes + ); +} + +#[test] +fn plain_assignment_stays_variable() { + let g = parse("count = 5\n"); + assert_eq!( + kind_of(&g, "count"), + Some(NodeKind::Variable), + "plain assignment must stay Variable, nodes: {:?}", + g.nodes + ); +} + +#[test] +fn non_typealias_annotation_stays_variable() { + let g = parse("count: int = 5\n"); + assert_eq!( + kind_of(&g, "count"), + Some(NodeKind::Variable), + "int-annotated assignment must stay Variable, nodes: {:?}", + g.nodes + ); +} + +#[test] +fn typealias_does_not_double_emit() { + let g = parse("from typing import TypeAlias\nVector: TypeAlias = list[float]\n"); + let count = g.nodes.iter().filter(|n| n.name == "Vector").count(); + assert_eq!( + count, 1, + "Vector must emit exactly one node, nodes: {:?}", + g.nodes + ); +} diff --git a/crates/ecp-analyzer/tests/rust_extern_ffi.rs b/crates/ecp-analyzer/tests/rust_extern_ffi.rs new file mode 100644 index 000000000..c4ad5ff5d --- /dev/null +++ b/crates/ecp-analyzer/tests/rust_extern_ffi.rs @@ -0,0 +1,115 @@ +//! Rust FFI extern block — `function_signature_item` inside `foreign_mod_item`. +//! +//! Verifies that functions declared in `extern "C" { fn foo(...); }` blocks +//! are emitted as `NodeKind::Function` nodes. Without the `foreign_mod_item` +//! pattern in queries.scm these nodes were invisible to the graph. + +use ecp_analyzer::rust::parser::RustProvider; +use ecp_core::analyzer::provider::LanguageProvider; +use ecp_core::graph::NodeKind; +use std::path::Path; + +fn parse_rs(src: &str) -> ecp_core::analyzer::types::LocalGraph { + let provider = RustProvider::new().expect("RustProvider::new"); + provider + .parse_file(Path::new("test.rs"), src.as_bytes()) + .expect("parse_file") +} + +// ── basic extern "C" block ──────────────────────────────────────────────────── + +#[test] +fn rust_extern_c_ffi_functions_are_emitted() { + let src = r#" +extern "C" { + fn c_add(a: i32, b: i32) -> i32; + fn c_free(ptr: *mut u8); +} +fn caller() { unsafe { c_add(1, 2); } } +"#; + let g = parse_rs(src); + + let pool = g.nodes.iter().map(|n| n.name.as_str()).collect::>(); + + let c_add = g + .nodes + .iter() + .find(|n| n.name == "c_add" && matches!(n.kind, NodeKind::Function)); + assert!( + c_add.is_some(), + "expected Function node for c_add; emitted names: {pool:?}" + ); + + let c_free = g + .nodes + .iter() + .find(|n| n.name == "c_free" && matches!(n.kind, NodeKind::Function)); + assert!( + c_free.is_some(), + "expected Function node for c_free; emitted names: {pool:?}" + ); +} + +// ── extern block with multiple ABIs ────────────────────────────────────────── + +#[test] +fn rust_extern_system_ffi_functions_are_emitted() { + let src = r#" +extern "system" { + fn win_sleep(ms: u32); +} +"#; + let g = parse_rs(src); + let found = g + .nodes + .iter() + .any(|n| n.name == "win_sleep" && matches!(n.kind, NodeKind::Function)); + assert!(found, "Function node for win_sleep not emitted"); +} + +// ── regular functions are not double-emitted ───────────────────────────────── + +#[test] +fn rust_extern_ffi_does_not_duplicate_regular_functions() { + let src = r#" +extern "C" { + fn c_strlen(s: *const u8) -> usize; +} +fn regular_fn() {} +"#; + let g = parse_rs(src); + + let c_strlen_count = g + .nodes + .iter() + .filter(|n| n.name == "c_strlen" && matches!(n.kind, NodeKind::Function)) + .count(); + assert_eq!(c_strlen_count, 1, "c_strlen must appear exactly once"); + + let regular_count = g + .nodes + .iter() + .filter(|n| n.name == "regular_fn" && matches!(n.kind, NodeKind::Function)) + .count(); + assert_eq!(regular_count, 1, "regular_fn must appear exactly once"); +} + +// ── FFI function node has correct kind (not Method or other) ───────────────── + +#[test] +fn rust_extern_c_ffi_node_kind_is_function() { + let src = r#" +extern "C" { + fn c_open(path: *const u8, flags: i32) -> i32; +} +"#; + let g = parse_rs(src); + let node = g.nodes.iter().find(|n| n.name == "c_open"); + assert!(node.is_some(), "c_open node not emitted"); + assert_eq!( + node.unwrap().kind, + NodeKind::Function, + "extern C fn must have NodeKind::Function, got: {:?}", + node.unwrap().kind + ); +} diff --git a/crates/ecp-analyzer/tests/swift_protocol_property.rs b/crates/ecp-analyzer/tests/swift_protocol_property.rs new file mode 100644 index 000000000..00358f57c --- /dev/null +++ b/crates/ecp-analyzer/tests/swift_protocol_property.rs @@ -0,0 +1,82 @@ +//! Protocol property requirements — `var name: String { get }` inside a +//! `protocol` body must emit Property nodes (filter-B node coverage). +//! +//! tree-sitter-swift uses `protocol_property_declaration` (distinct from +//! `property_declaration`) for property requirements. Without the dedicated +//! capture in queries.scm, protocol property contracts were invisible to the +//! graph while method requirements were fully captured. + +use ecp_analyzer::swift::parser::SwiftProvider; +use ecp_core::analyzer::provider::LanguageProvider; +use ecp_core::graph::NodeKind; +use std::path::Path; + +fn parse(src: &str) -> Vec { + let provider = SwiftProvider::new().expect("SwiftProvider init"); + provider + .parse_file(Path::new("t.swift"), src.as_bytes()) + .expect("parse_file") + .nodes +} + +fn find<'a>( + nodes: &'a [ecp_core::analyzer::types::RawNode], + name: &str, + kind: NodeKind, +) -> &'a ecp_core::analyzer::types::RawNode { + nodes + .iter() + .find(|n| n.name == name && n.kind == kind) + .unwrap_or_else(|| panic!("missing {kind:?} `{name}` in {nodes:#?}")) +} + +const PROTOCOL_SRC: &str = r#"protocol Named { + var name: String { get } + var age: Int { get set } + func greet() -> String +}"#; + +#[test] +fn protocol_property_requirement_name_emitted() { + let nodes = parse(PROTOCOL_SRC); + find(&nodes, "name", NodeKind::Property); +} + +#[test] +fn protocol_property_requirement_age_emitted() { + let nodes = parse(PROTOCOL_SRC); + find(&nodes, "age", NodeKind::Property); +} + +#[test] +fn protocol_function_requirement_not_regressed() { + // Ensure the existing protocol_function_declaration capture still fires. + let nodes = parse(PROTOCOL_SRC); + // greet is inside protocol_body → is_class_method returns true → Method + find(&nodes, "greet", NodeKind::Method); +} + +#[test] +fn protocol_property_type_annotation_captured() { + // The type annotation `: String` should surface on the emitted Property. + let nodes = parse(PROTOCOL_SRC); + let name_node = find(&nodes, "name", NodeKind::Property); + assert_eq!( + name_node.type_annotation.as_deref(), + Some("String"), + "expected type_annotation=String, got {:?}", + name_node.type_annotation + ); +} + +#[test] +fn protocol_property_age_type_annotation_captured() { + let nodes = parse(PROTOCOL_SRC); + let age_node = find(&nodes, "age", NodeKind::Property); + assert_eq!( + age_node.type_annotation.as_deref(), + Some("Int"), + "expected type_annotation=Int, got {:?}", + age_node.type_annotation + ); +} From 8ba998e6dbc235b695056e04551302746de9f925 Mon Sep 17 00:00:00 2001 From: coseto6125 <80243681+coseto6125@users.noreply.github.com> Date: Sun, 31 May 2026 07:41:42 +0800 Subject: [PATCH 2/3] test(ruby): pin is_static for def self.foo and class << self methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FU-2026-05-29-001 entry flagged Ruby `class << self` methods as producing instance Methods (missing class-level marking). Investigation shows `function_meta::ruby` already sets FLAG_STATIC for both forms — `singleton_method` (`def self.foo`) directly, and methods nested under `singleton_class` (`class << self`) via a parent().parent() walk. The bug was already fixed; what was missing is a regression test. This pins the behaviour: both class-level forms are is_static, instance methods are not. The parent-chain walk in ruby.rs:53-58 is the fragile part — this test fails loudly if a grammar-handling change breaks it. --- .../tests/ruby_singleton_static.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 crates/ecp-analyzer/tests/ruby_singleton_static.rs diff --git a/crates/ecp-analyzer/tests/ruby_singleton_static.rs b/crates/ecp-analyzer/tests/ruby_singleton_static.rs new file mode 100644 index 000000000..85c9718df --- /dev/null +++ b/crates/ecp-analyzer/tests/ruby_singleton_static.rs @@ -0,0 +1,79 @@ +//! Ruby class-level methods must carry `is_static` regardless of which syntax +//! declares them: `def self.foo` (singleton_method) and `class << self; def foo` +//! (method nested under singleton_class) are equivalent. The latter relies on a +//! `parent().parent() == singleton_class` walk in `function_meta::ruby` that is +//! easy to break when the surrounding grammar handling changes — this test pins +//! the behaviour so a regression surfaces immediately. + +use ecp_analyzer::ruby::parser::RubyProvider; +use ecp_core::analyzer::provider::LanguageProvider; +use ecp_core::analyzer::types::LocalGraph; +use ecp_core::graph::{FunctionMeta, NodeKind}; +use std::path::Path; + +fn parse(src: &str) -> LocalGraph { + let p = RubyProvider::new().expect("provider"); + p.parse_file(Path::new("x.rb"), src.as_bytes()) + .expect("parse") +} + +/// `is_static` for the Method node named `name`, paired with its FunctionMeta by span. +fn is_static(g: &LocalGraph, name: &str) -> bool { + let node = g + .nodes + .iter() + .find(|n| n.kind == NodeKind::Method && n.name == name) + .unwrap_or_else(|| panic!("no Method node named {name}, nodes: {:?}", g.nodes)); + g.raw_function_metas + .iter() + .find(|m| m.span == node.span) + .map(|m| m.flags & FunctionMeta::FLAG_STATIC != 0) + .unwrap_or_else(|| panic!("no FunctionMeta for {name} at span {:?}", node.span)) +} + +#[test] +fn def_self_method_is_static() { + let g = parse("class Foo\n def self.build; end\nend\n"); + assert!(is_static(&g, "build"), "def self.build must be is_static"); +} + +#[test] +fn singleton_class_method_is_static() { + let g = parse("class Foo\n class << self\n def build; end\n end\nend\n"); + assert!( + is_static(&g, "build"), + "method inside `class << self` must be is_static" + ); +} + +#[test] +fn multiple_singleton_class_methods_all_static() { + let g = parse("class Foo\n class << self\n def a; end\n def b; end\n end\nend\n"); + assert!( + is_static(&g, "a"), + "first class< Date: Sun, 31 May 2026 07:46:46 +0800 Subject: [PATCH 3/3] perf(csharp-parser): hoist capture indices + drop redundant name alloc simplify pass findings: - destructor.name / event_field.name were resolved via capture_index_for_name inside parse_file (once per file, 50k string scans over a 25k-file index). Moved into CSharpCaptureIndices, resolved once at construction like every other index the struct documents. - canonical_name was built and cloned outside or_insert_with, allocating a String on every match including dedup hits that never insert. Moved the name construction into the closure so it allocates only on actual node insertion. --- crates/ecp-analyzer/src/c_sharp/parser.rs | 25 +++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/ecp-analyzer/src/c_sharp/parser.rs b/crates/ecp-analyzer/src/c_sharp/parser.rs index 1dc5acb7a..77eb4a800 100644 --- a/crates/ecp-analyzer/src/c_sharp/parser.rs +++ b/crates/ecp-analyzer/src/c_sharp/parser.rs @@ -117,6 +117,9 @@ struct CSharpCaptureIndices { // spec, but we need the root span separately like enum_member_node). destructor: Option, event_decl: Option, + // Name captures (not roots) — destructor `~` prefix; event_field multi-declarator keying. + destructor_name: Option, + event_field_name: Option, } pub struct CSharpProvider { @@ -178,6 +181,8 @@ impl CSharpProvider { event_field: query.capture_index_for_name("event_field"), destructor: query.capture_index_for_name("destructor"), event_decl: query.capture_index_for_name("event_decl"), + destructor_name: query.capture_index_for_name("destructor.name"), + event_field_name: query.capture_index_for_name("event_field.name"), }; Ok(Self { @@ -237,10 +242,8 @@ impl LanguageProvider for CSharpProvider { let idx_enum_member_node = idx.enum_member_node; let idx_struct = idx.struct_; - // Pre-resolve capture indices needed only for per-node name logic - // (destructor ~ prefix; event_field multi-declarator keying). - let idx_destructor_name = self.query.capture_index_for_name("destructor.name"); - let idx_event_field_name = self.query.capture_index_for_name("event_field.name"); + let idx_destructor_name = idx.destructor_name; + let idx_event_field_name = idx.event_field_name; while let Some(m) = matches.next() { let mut name_node = None; @@ -560,20 +563,20 @@ impl LanguageProvider for CSharpProvider { } else { root.id() }; - // Destructors: prefix the bare class name with `~`. - let canonical_name = if is_destructor_name { - format!("~{name_str}") - } else { - name_str.to_string() - }; let idx = *node_id_to_idx.entry(node_id).or_insert_with(|| { + // Destructors: prefix the bare class name with `~`. + let name = if is_destructor_name { + format!("~{name_str}") + } else { + name_str.to_string() + }; let i = nodes.len(); nodes.push(RawNode { decorators: vec![], is_exported, heritage: Vec::new(), type_annotation: type_annotation.clone(), - name: canonical_name.clone(), + name, kind: k, span: ( start.row as u32,