Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions crates/sem-core/src/parser/plugins/code/entity_extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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::<Vec<_>>().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")?;
Expand Down
31 changes: 31 additions & 0 deletions crates/sem-core/src/parser/plugins/code/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ')}`));
Expand Down
26 changes: 19 additions & 7 deletions src/parser/plugins/code/entity-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions test/code-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,44 @@ describe('CodeParserPlugin', () => {
expect(appEntity?.entityType).toBe('function');
});

it('parses JSX files with JSX syntax', () => {
const content = `
export const App = () => {
return <main><h1>Hello</h1></main>;
};
`;

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 = () => {
Expand Down