diff --git a/crates/sem-cli/src/commands/context.rs b/crates/sem-cli/src/commands/context.rs index 3cec76d6..d98687e7 100644 --- a/crates/sem-cli/src/commands/context.rs +++ b/crates/sem-cli/src/commands/context.rs @@ -120,7 +120,11 @@ fn find_entity<'a>( std::process::exit(1); }); - let mut matching: Vec<_> = graph.entities.values().filter(|e| e.name == name).collect(); + let mut matching: Vec<_> = graph + .entities + .values() + .filter(|e| super::entity_matches_query(e, name)) + .collect(); if matching.is_empty() { eprintln!("{} Entity '{}' not found", "error:".red().bold(), name); @@ -146,7 +150,10 @@ fn find_entity<'a>( matching.sort_by_key(|e| (&e.file_path, e.start_line)); eprintln!("{} Entity name '{}' is ambiguous ({} matches). Specify --file or --entity-id:", "error:".red().bold(), name, matching.len()); for m in &matching { - eprintln!(" {} ({}:L{})", m.id, m.file_path, m.start_line); + eprintln!( + " {} {} ({}:L{})", + m.entity_type, m.id, m.file_path, m.start_line + ); } std::process::exit(1); } diff --git a/crates/sem-cli/src/commands/impact.rs b/crates/sem-cli/src/commands/impact.rs index 752efb8a..d129ebc1 100644 --- a/crates/sem-cli/src/commands/impact.rs +++ b/crates/sem-cli/src/commands/impact.rs @@ -71,7 +71,11 @@ fn find_entity<'a>( std::process::exit(1); }); - let mut matching: Vec<_> = graph.entities.values().filter(|e| e.name == name).collect(); + let mut matching: Vec<_> = graph + .entities + .values() + .filter(|e| super::entity_matches_query(e, name)) + .collect(); if matching.is_empty() { eprintln!("{} Entity '{}' not found", "error:".red().bold(), name); @@ -99,7 +103,10 @@ fn find_entity<'a>( matching.sort_by_key(|e| (&e.file_path, e.start_line)); eprintln!("{} Entity name '{}' is ambiguous ({} matches). Specify --file or --entity-id:", "error:".red().bold(), name, matching.len()); for m in &matching { - eprintln!(" {} ({}:L{})", m.id, m.file_path, m.start_line); + eprintln!( + " {} {} ({}:L{})", + m.entity_type, m.id, m.file_path, m.start_line + ); } std::process::exit(1); } diff --git a/crates/sem-cli/src/commands/mod.rs b/crates/sem-cli/src/commands/mod.rs index 91276f01..193d33b1 100644 --- a/crates/sem-cli/src/commands/mod.rs +++ b/crates/sem-cli/src/commands/mod.rs @@ -24,6 +24,27 @@ pub fn create_registry(cwd: &str) -> ParserRegistry { registry } +pub fn entity_matches_query(entity: &sem_core::parser::graph::EntityInfo, query: &str) -> bool { + if entity.name == query { + return true; + } + + let Some((entity_type, name)) = split_type_qualified_query(query) else { + return false; + }; + + entity.entity_type == entity_type && entity.name == name +} + +fn split_type_qualified_query(query: &str) -> Option<(&str, &str)> { + let (entity_type, name) = query.split_once(' ')?; + if entity_type.is_empty() || name.is_empty() { + return None; + } + + Some((entity_type, name)) +} + /// Truncate a string to `max_chars` Unicode scalar values (codepoints), appending "..." if /// truncated. Safe for multibyte encodings (CJK, simple emoji). Note: does not split on grapheme /// cluster boundaries — ZWJ emoji sequences may render incorrectly at the truncation point. @@ -61,7 +82,36 @@ pub fn truncate_str(s: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { - use super::truncate_str; + use super::{entity_matches_query, truncate_str}; + use sem_core::parser::graph::EntityInfo; + + fn entity(entity_type: &str, name: &str) -> EntityInfo { + EntityInfo { + id: format!("a.ts::{entity_type}::{name}"), + name: name.to_string(), + entity_type: entity_type.to_string(), + file_path: "a.ts".to_string(), + parent_id: None, + start_line: 1, + end_line: 1, + } + } + + #[test] + fn entity_query_matches_exact_name() { + let entity = entity("function", "getter value"); + + assert!(entity_matches_query(&entity, "getter value")); + } + + #[test] + fn entity_query_matches_type_qualified_name() { + let entity = entity("getter", "value"); + + assert!(entity_matches_query(&entity, "getter value")); + assert!(!entity_matches_query(&entity, "setter value")); + assert!(!entity_matches_query(&entity, "method value")); + } #[test] fn ascii_short_string_unchanged() { diff --git a/crates/sem-cli/src/main.rs b/crates/sem-cli/src/main.rs index ea7eb781..6886778e 100644 --- a/crates/sem-cli/src/main.rs +++ b/crates/sem-cli/src/main.rs @@ -108,7 +108,7 @@ enum Commands { }, /// Show impact of changing an entity (deps, dependents, transitive impact, tests) Impact { - /// Name of the entity to analyze + /// Name of the entity to analyze, optionally as "type name" #[arg(required_unless_present = "entity_id")] entity: Option, @@ -242,7 +242,7 @@ enum Commands { }, /// Show token-budgeted context for an entity Context { - /// Name of the entity + /// Name of the entity, optionally as "type name" #[arg(required_unless_present = "entity_id")] entity: Option, diff --git a/crates/sem-cli/tests/accessor_cli.rs b/crates/sem-cli/tests/accessor_cli.rs new file mode 100644 index 00000000..ee59743d --- /dev/null +++ b/crates/sem-cli/tests/accessor_cli.rs @@ -0,0 +1,99 @@ +use std::fs; +use std::process::Command; + +use tempfile::TempDir; + +fn git(repo: &TempDir, args: &[&str]) { + let status = Command::new("git") + .current_dir(repo.path()) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {:?} failed", args); +} + +fn sem(repo: &TempDir, home: &TempDir, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_sem")) + .current_dir(repo.path()) + .env("HOME", home.path()) + .args(args) + .output() + .expect("sem should run") +} + +#[test] +fn context_and_impact_accept_type_qualified_accessor_queries() { + let repo = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + git(&repo, &["init", "-q"]); + + fs::write( + repo.path().join("box.ts"), + r#"export class Box { + private _v = 0; + get value(): number { return this._v; } + set value(n: number) { this._v = n; } +} +"#, + ) + .unwrap(); + + let context = sem( + &repo, + &home, + &[ + "context", + "getter value", + "--file", + "box.ts", + "--json", + "--no-cache", + ], + ); + assert!( + context.status.success(), + "sem context failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&context.stdout), + String::from_utf8_lossy(&context.stderr) + ); + let context_json: serde_json::Value = serde_json::from_slice(&context.stdout).unwrap(); + assert_eq!(context_json["entity"].as_str(), Some("value")); + assert_eq!( + context_json["entries"][0]["type"].as_str(), + Some("getter"), + "{context_json:?}" + ); + assert!( + context_json["entityId"] + .as_str() + .is_some_and(|id| id.contains("::value@L3")), + "{context_json:?}" + ); + + let impact = sem( + &repo, + &home, + &[ + "impact", + "setter value", + "--file", + "box.ts", + "--json", + "--no-cache", + ], + ); + assert!( + impact.status.success(), + "sem impact failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&impact.stdout), + String::from_utf8_lossy(&impact.stderr) + ); + let impact_json: serde_json::Value = serde_json::from_slice(&impact.stdout).unwrap(); + assert_eq!(impact_json["entity"]["type"].as_str(), Some("setter")); + assert!( + impact_json["entity"]["entityId"] + .as_str() + .is_some_and(|id| id.contains("::value@L4")), + "{impact_json:?}" + ); +} 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 f38973fe..70dd6926 100644 --- a/crates/sem-core/src/parser/plugins/code/entity_extractor.rs +++ b/crates/sem-core/src/parser/plugins/code/entity_extractor.rs @@ -1782,6 +1782,8 @@ fn map_entity_type(node: Node, config: &LanguageConfig) -> &'static str { match node.kind() { "decorated_definition" => map_decorated_type(node), "class_member" => map_class_member_type(node), + "method_definition" => map_js_ts_accessor_method_type(node, config) + .unwrap_or_else(|| map_node_type(node.kind())), "class_declaration" if config.id == "swift" => swift_class_declaration_type(node) .unwrap_or_else(|| map_node_type(node.kind())), // C/C++ declarations with a function_declarator are function prototypes, @@ -1795,6 +1797,34 @@ fn map_entity_type(node: Node, config: &LanguageConfig) -> &'static str { } } +fn map_js_ts_accessor_method_type( + node: Node, + config: &LanguageConfig, +) -> Option<&'static str> { + if !matches!(config.id, "typescript" | "tsx" | "javascript") { + return None; + } + + let name = node.child_by_field_name("name")?; + let mut accessor_type = None; + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.start_byte() >= name.start_byte() { + break; + } + if child.is_extra() { + continue; + } + match child.kind() { + "get" => accessor_type = Some("getter"), + "set" => accessor_type = Some("setter"), + _ => {} + } + } + + accessor_type +} + fn swift_class_declaration_type(node: Node) -> Option<&'static str> { let declaration_kind = node.child_by_field_name("declaration_kind")?; swift_declaration_keyword_type(declaration_kind.kind()) diff --git a/crates/sem-core/src/parser/plugins/code/mod.rs b/crates/sem-core/src/parser/plugins/code/mod.rs index 26233d27..950acef2 100644 --- a/crates/sem-core/src/parser/plugins/code/mod.rs +++ b/crates/sem-core/src/parser/plugins/code/mod.rs @@ -558,6 +558,160 @@ export async function* streamUsers(): AsyncGenerator { assert_eq!(stream.unwrap().entity_type, "function"); } + #[test] + fn test_typescript_accessor_entity_types() { + let code = r#" +export class Box { + private _v = 0; + + get value(): number { + return this._v; + } + + set value(n: number) { + this._v = n; + } + + get(): number { + return 1; + } + + set(n: number): void { + this._v = n; + } +} +"#; + let plugin = CodeParserPlugin; + let entities = plugin.extract_entities(code, "box.ts"); + let find = |name: &str, entity_type: &str| { + entities + .iter() + .find(|e| e.name == name && e.entity_type == entity_type) + .unwrap_or_else(|| { + panic!( + "missing {entity_type} {name}; got: {:?}", + entities + .iter() + .map(|e| (&e.name, &e.entity_type, e.start_line)) + .collect::>() + ) + }) + }; + + assert!(find("value", "getter") + .content + .trim_start() + .starts_with("get value(): number {")); + assert!(find("value", "setter") + .content + .trim_start() + .starts_with("set value(n: number) {")); + assert!(find("get", "method") + .content + .trim_start() + .starts_with("get(): number {")); + assert!(find("set", "method") + .content + .trim_start() + .starts_with("set(n: number): void {")); + } + + #[test] + fn test_tsx_accessor_entity_types() { + let code = r#" +export class Widget { + get label(): string { + return "x"; + } + + set label(value: string) { + console.log(value); + } + + render() { + return
{this.label}
; + } +} +"#; + let plugin = CodeParserPlugin; + let entities = plugin.extract_entities(code, "widget.tsx"); + let find = |name: &str, entity_type: &str| { + entities + .iter() + .find(|e| e.name == name && e.entity_type == entity_type) + .unwrap_or_else(|| { + panic!( + "missing {entity_type} {name}; got: {:?}", + entities + .iter() + .map(|e| (&e.name, &e.entity_type, e.start_line)) + .collect::>() + ) + }) + }; + + assert!(find("label", "getter") + .content + .trim_start() + .starts_with("get label(): string {")); + assert!(find("label", "setter") + .content + .trim_start() + .starts_with("set label(value: string) {")); + assert!(find("render", "method") + .content + .trim_start() + .starts_with("render() {")); + } + + #[test] + fn test_javascript_accessor_entity_types() { + let code = r#" +class Box { + get value() { + return 1; + } + + set value(n) { + this._v = n; + } + + get() { + return 2; + } +} +"#; + let plugin = CodeParserPlugin; + let entities = plugin.extract_entities(code, "box.js"); + let find = |name: &str, entity_type: &str| { + entities + .iter() + .find(|e| e.name == name && e.entity_type == entity_type) + .unwrap_or_else(|| { + panic!( + "missing {entity_type} {name}; got: {:?}", + entities + .iter() + .map(|e| (&e.name, &e.entity_type, e.start_line)) + .collect::>() + ) + }) + }; + + assert!(find("value", "getter") + .content + .trim_start() + .starts_with("get value() {")); + assert!(find("value", "setter") + .content + .trim_start() + .starts_with("set value(n) {")); + assert!(find("get", "method") + .content + .trim_start() + .starts_with("get() {")); + } + #[test] fn test_javascript_generator_function_entity_extraction() { let code = r#"