diff --git a/crates/sem-core/src/parser/plugins/code/entity_extractor.rs b/crates/sem-core/src/parser/plugins/code/entity_extractor.rs index 9e70134..b6e9bca 100644 --- a/crates/sem-core/src/parser/plugins/code/entity_extractor.rs +++ b/crates/sem-core/src/parser/plugins/code/entity_extractor.rs @@ -81,7 +81,7 @@ fn visit_node( let entity_type = if node_type == "decorated_definition" { map_decorated_type(node) } else { - map_node_type(node_type) + map_node_type(node, source) }; let content_str = node_text(node, source); let content = content_str.to_string(); @@ -285,8 +285,20 @@ fn node_text<'a>(node: Node, source: &'a [u8]) -> &'a str { node.utf8_text(source).unwrap_or("") } -fn map_node_type<'a>(tree_sitter_type: &'a str) -> &'a str { - match tree_sitter_type { +fn map_node_type<'a>(node: Node, source: &'a [u8]) -> &'a str { + match node.kind() { + "lexical_declaration" => { + get_function_like_declaration_type(node, source).unwrap_or_else(|| { + if node_text(node, source).trim_start().starts_with("const") { + "constant" + } else { + "variable" + } + }) + } + "variable_declaration" | "var_declaration" => { + get_function_like_declaration_type(node, source).unwrap_or("variable") + } "function_declaration" | "function_definition" | "function_item" => "function", "method_declaration" | "method_definition" | "method" | "singleton_method" => "method", "class_declaration" | "class_definition" | "class_specifier" => "class", @@ -299,7 +311,7 @@ fn map_node_type<'a>(tree_sitter_type: &'a str) -> &'a str { "trait_item" => "trait", "mod_item" | "module" | "namespace_definition" | "namespace_declaration" => "module", "export_statement" => "export", - "lexical_declaration" | "variable_declaration" | "var_declaration" | "declaration" => "variable", + "declaration" => "variable", "const_declaration" | "const_item" => "constant", "static_item" => "static", "decorated_definition" => "decorated_definition", @@ -312,6 +324,44 @@ fn map_node_type<'a>(tree_sitter_type: &'a str) -> &'a str { } } +fn get_function_like_declaration_type<'a>(node: Node, source: &'a [u8]) -> Option<&'a str> { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() != "variable_declarator" { + continue; + } + + if let Some(value) = child.child_by_field_name("value") { + match value.kind() { + "generator_function" | "generator_function_declaration" => return Some("generator"), + "arrow_function" | "function" | "function_expression" => return Some("function"), + _ => {} + } + } + + let normalized = node_text(child, source).split_whitespace().collect::>().join(" "); + let Some((_, rhs)) = normalized.split_once('=') else { + continue; + }; + let rhs = rhs.trim_start(); + let rhs = rhs.strip_prefix("async ").map(str::trim_start).unwrap_or(rhs); + + if rhs.starts_with("function*") || rhs.starts_with("function *") { + return Some("generator"); + } + + let starts_like_identifier = rhs + .chars() + .next() + .is_some_and(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphabetic()); + if rhs.starts_with("function") || (rhs.contains("=>") && (rhs.starts_with('(') || starts_like_identifier)) { + return Some("function"); + } + } + + None +} + /// Extract entity info from a call node (Elixir macros like def, defmodule, etc.) fn extract_call_entity(node: Node, config: &LanguageConfig, source: &[u8]) -> Option<(String, &'static str)> { let target = node.child_by_field_name("target")?; diff --git a/crates/sem-core/src/parser/plugins/code/mod.rs b/crates/sem-core/src/parser/plugins/code/mod.rs index 439f746..753054d 100644 --- a/crates/sem-core/src/parser/plugins/code/mod.rs +++ b/crates/sem-core/src/parser/plugins/code/mod.rs @@ -363,6 +363,37 @@ export class Greeter { assert!(names.contains(&"Greeter"), "Should find Greeter class"); } + #[test] + fn test_js_ts_binding_classification() { + let code = r#" +const constantValue = "abc"; +let mutableValue = "def"; +var legacyValue = "ghi"; + +var arrowFn = () => 1; +const functionExpr = function () { return 2; }; +let generatorExpr = function* () { yield 3; }; +"#; + let plugin = CodeParserPlugin; + + for file_path in ["bindings.ts", "bindings.js"] { + let entities = plugin.extract_entities(code, file_path); + let entity_type = |name: &str| { + entities + .iter() + .find(|entity| entity.name == name) + .map(|entity| entity.entity_type.as_str()) + }; + + assert_eq!(entity_type("constantValue"), Some("constant"), "{file_path}"); + assert_eq!(entity_type("mutableValue"), Some("variable"), "{file_path}"); + assert_eq!(entity_type("legacyValue"), Some("variable"), "{file_path}"); + assert_eq!(entity_type("arrowFn"), Some("function"), "{file_path}"); + assert_eq!(entity_type("functionExpr"), Some("function"), "{file_path}"); + assert_eq!(entity_type("generatorExpr"), Some("generator"), "{file_path}"); + } + } + #[test] fn test_nested_functions_typescript() { let code = r#" diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index d1ec132..01d4b54 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -94,7 +94,7 @@ export async function reviewCommand(branchOrPR: string, opts: ReviewOptions = {} const risks: string[] = []; const deletedFunctions = result.changes.filter( - c => c.changeType === 'deleted' && (c.entityType === 'function' || c.entityType === 'method') + c => c.changeType === 'deleted' && (c.entityType === 'function' || c.entityType === 'method' || c.entityType === 'generator') ); if (deletedFunctions.length > 0) { risks.push(chalk.red(` ⚠ ${deletedFunctions.length} function${deletedFunctions.length > 1 ? 's' : ''} deleted: ${deletedFunctions.map(f => f.entityName).join(', ')}`)); diff --git a/src/parser/plugins/code/entity-extractor.ts b/src/parser/plugins/code/entity-extractor.ts index cf860e3..78eb527 100644 --- a/src/parser/plugins/code/entity-extractor.ts +++ b/src/parser/plugins/code/entity-extractor.ts @@ -57,7 +57,7 @@ function visitNode( const name = extractName(node, config, sourceCode); const entityType = mapNodeType(node); const shouldSkip = - (context.insideFunction && entityType === 'variable') || + (context.insideFunction && (entityType === 'variable' || entityType === 'constant')) || (node.type === 'pair' && !isFunctionLikePair(node)); if (name && !shouldSkip) { @@ -136,8 +136,12 @@ function mapNodeType(node: TreeSitterNode): string { return isFunctionLikePair(node) ? 'method' : 'property'; } - if ((node.type === 'lexical_declaration' || node.type === 'variable_declaration') && isFunctionLikeDeclaration(node)) { - return 'function'; + if (node.type === 'lexical_declaration') { + return getFunctionLikeDeclarationType(node) ?? (/^\s*const\b/.test(node.text) ? 'constant' : 'variable'); + } + + if (node.type === 'variable_declaration' || node.type === 'var_declaration') { + return getFunctionLikeDeclarationType(node) ?? 'variable'; } const mapping: Record = { @@ -172,22 +176,30 @@ function mapNodeType(node: TreeSitterNode): string { return mapping[node.type] ?? node.type; } -function isFunctionLikeDeclaration(node: TreeSitterNode): boolean { +function getFunctionLikeDeclarationType(node: TreeSitterNode): 'function' | 'generator' | undefined { for (const child of node.namedChildren) { if (child.type !== 'variable_declarator') continue; const value = child.childForFieldName('value'); + if (value?.type === 'generator_function' || value?.type === 'generator_function_declaration') { + return 'generator'; + } + if (value && isFunctionLikeNodeType(value.type)) { - return true; + return 'function'; } const normalized = child.text.replace(/\s+/g, ' ').trim(); + if (/^[^=]+=\s*(?:async\s+)?function\s*\*/.test(normalized)) { + return 'generator'; + } + if (/^[^=]+=\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(normalized)) { - return true; + return 'function'; } } - return false; + return undefined; } function isFunctionLikeNodeType(nodeType: string): boolean { diff --git a/test/code-parser.test.ts b/test/code-parser.test.ts index 1abd676..952df67 100644 --- a/test/code-parser.test.ts +++ b/test/code-parser.test.ts @@ -66,6 +66,44 @@ describe('CodeParserPlugin', () => { expect(appEntity?.entityType).toBe('function'); }); + it('parses JSX files with JSX syntax', () => { + const content = ` + export const App = () => { + return

Hello

; + }; + `; + + const entities = parser.extractEntities(content, 'app.jsx'); + const appEntity = entities.find(e => e.name === 'App'); + + expect(appEntity).toBeDefined(); + expect(appEntity?.entityType).toBe('function'); + }); + + it('classifies bindings by declaration kind and assigned function shape in JS and TS', () => { + const content = ` + const constantValue = "abc"; + let mutableValue = "def"; + var legacyValue = "ghi"; + + var arrowFn = () => 1; + const functionExpr = function () { return 2; }; + let generatorExpr = function* () { yield 3; }; + `; + + for (const filePath of ['bindings.ts', 'bindings.js']) { + const entities = parser.extractEntities(content, filePath); + const types = Object.fromEntries(entities.map(entity => [entity.name, entity.entityType])); + + expect(types.constantValue).toBe('constant'); + expect(types.mutableValue).toBe('variable'); + expect(types.legacyValue).toBe('variable'); + expect(types.arrowFn).toBe('function'); + expect(types.functionExpr).toBe('function'); + expect(types.generatorExpr).toBe('generator'); + } + }); + it('extracts function-like object pairs as methods and skips inner variables', () => { const content = ` export const createHandlers = () => {