From 50cd2310dcc8d2c4273263424ca72de4cf6892f0 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 4 Mar 2026 16:59:50 +0100 Subject: [PATCH] feat(pi-natives): added language module with 39 languages and tree-sitter bindings - Added language module with 39 supported languages and tree-sitter parser bindings for ast-grep integration. - Exported dedupeParseErrors utility function to remove duplicate parse error messages in tool output. - Fixed duplicate parse error messages when multiple patterns fail on the same file. - Replaced language alias lookup with O(1) phf::Map and added 30+ new language aliases (golang, julia, toml, zig, etc.). - Migrated from ast-grep-language to direct tree-sitter language dependencies for improved language support. --- Cargo.lock | 196 +++++-- crates/pi-natives/Cargo.toml | 41 +- crates/pi-natives/src/ast.rs | 263 ++++----- crates/pi-natives/src/language/mod.rs | 511 ++++++++++++++++++ crates/pi-natives/src/language/parsers.rs | 118 ++++ crates/pi-natives/src/lib.rs | 1 + packages/coding-agent/CHANGELOG.md | 8 + packages/coding-agent/src/tools/ast-edit.ts | 15 +- packages/coding-agent/src/tools/ast-grep.ts | 16 +- .../coding-agent/src/tools/render-utils.ts | 21 +- .../coding-agent/test/tools/ast-grep.test.ts | 50 ++ .../test/tools/render-utils.test.ts | 32 ++ 12 files changed, 1060 insertions(+), 212 deletions(-) create mode 100644 crates/pi-natives/src/language/mod.rs create mode 100644 crates/pi-natives/src/language/parsers.rs create mode 100644 packages/coding-agent/test/tools/ast-grep.test.ts create mode 100644 packages/coding-agent/test/tools/render-utils.test.ts diff --git a/Cargo.lock b/Cargo.lock index f394b7932..7bcc43b8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,43 +150,6 @@ dependencies = [ "tree-sitter", ] -[[package]] -name = "ast-grep-language" -version = "0.39.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24b571f7f8cde8bd77ea48f63a81094b7ce0695da50d0dc956cc45f1e26533ce" -dependencies = [ - "ast-grep-core", - "ignore", - "serde", - "tree-sitter", - "tree-sitter-bash", - "tree-sitter-c", - "tree-sitter-c-sharp", - "tree-sitter-cpp", - "tree-sitter-css", - "tree-sitter-elixir", - "tree-sitter-go", - "tree-sitter-haskell", - "tree-sitter-hcl", - "tree-sitter-html", - "tree-sitter-java", - "tree-sitter-javascript", - "tree-sitter-json", - "tree-sitter-kotlin-sg", - "tree-sitter-lua", - "tree-sitter-nix", - "tree-sitter-php", - "tree-sitter-python", - "tree-sitter-ruby", - "tree-sitter-rust", - "tree-sitter-scala", - "tree-sitter-solidity", - "tree-sitter-swift", - "tree-sitter-typescript", - "tree-sitter-yaml", -] - [[package]] name = "astral-tl" version = "0.7.11" @@ -2200,7 +2163,6 @@ version = "13.7.5" dependencies = [ "arboard", "ast-grep-core", - "ast-grep-language", "brush-builtins", "brush-core", "bstr", @@ -2230,6 +2192,44 @@ dependencies = [ "sysinfo", "tokio", "tokio-util", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-elixir", + "tree-sitter-go", + "tree-sitter-haskell", + "tree-sitter-hcl", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-julia", + "tree-sitter-kotlin-sg", + "tree-sitter-lua", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-nix", + "tree-sitter-objc", + "tree-sitter-odin", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-regex", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-solidity", + "tree-sitter-starlark", + "tree-sitter-swift", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-verilog", + "tree-sitter-xml", + "tree-sitter-yaml", + "tree-sitter-zig", "unicode-segmentation", "unicode-width", "windows-sys 0.61.2", @@ -3152,6 +3152,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-diff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-elixir" version = "0.3.5" @@ -3232,6 +3242,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-julia" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4144731a178812ee867619b1e98b3b91e54c1652304b26e5ebe3175b701de323" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-kotlin-sg" version = "0.4.0" @@ -3258,6 +3278,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-nix" version = "0.3.0" @@ -3268,6 +3308,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-objc" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca8bb556423fc176f0535e79d525f783a6684d3c9da81bf9d905303c129e1d2" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-odin" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24db210fe9ba2237c71c5030d7b146c7025420ba72dd8013d13cd822c3a8d77a" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-php" version = "0.24.2" @@ -3288,6 +3348,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-regex" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8a59be9f0ac131fd8f062eaaba14882b2fa5a6a7882a20134cb1d60df2e625" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-ruby" version = "0.23.1" @@ -3328,6 +3398,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-starlark" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8934f282d085cc4b9ee28aa688aa3fbe8aa3766201c2a6252f411d45b4c3a721" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-swift" version = "0.7.1" @@ -3338,6 +3418,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-typescript" version = "0.23.2" @@ -3348,6 +3438,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-verilog" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e7e0360395852f1f6ff5b7b82c72dc6557d181073188df1d60ec469ea69c66" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-xml" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e670041f591d994f54d597ddcd8f4ebc930e282c4c76a42268743b71f0c8b6b3" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-yaml" version = "0.7.2" @@ -3358,6 +3468,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-zig" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 75eb3588b..3fe61b082 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -31,8 +31,45 @@ grep-matcher = "0.1" globset = "0.4" ignore = "0.4" rayon = "1.10" -ast-grep-core = "0.39" -ast-grep-language = "0.39" +ast-grep-core = { version = "0.39", default-features = false, features = ["tree-sitter"] } +tree-sitter = "0.25" +tree-sitter-bash = "0.25" +tree-sitter-c = "0.24" +tree-sitter-c-sharp = "0.23" +tree-sitter-cpp = "0.23" +tree-sitter-css = "0.25" +tree-sitter-diff = "0.1" +tree-sitter-elixir = "0.3" +tree-sitter-go = "0.25" +tree-sitter-haskell = "0.23" +tree-sitter-hcl = "1.1" +tree-sitter-html = "0.23" +tree-sitter-java = "0.23" +tree-sitter-javascript = "0.25" +tree-sitter-json = "0.23" +tree-sitter-julia = "0.23" +tree-sitter-kotlin = { version = "0.4", package = "tree-sitter-kotlin-sg" } +tree-sitter-lua = "0.2" +tree-sitter-make = "1.1" +tree-sitter-md = "0.5" +tree-sitter-nix = "0.3" +tree-sitter-objc = "3.0" +tree-sitter-odin = "1.3" +tree-sitter-php = "0.24" +tree-sitter-python = "0.25" +tree-sitter-regex = "0.25" +tree-sitter-ruby = "0.23" +tree-sitter-rust = "0.24" +tree-sitter-scala = "0.24" +tree-sitter-solidity = "1.2" +tree-sitter-starlark = "1.3" +tree-sitter-swift = "0.7" +tree-sitter-toml-ng = "0.7" +tree-sitter-typescript = "0.23" +tree-sitter-verilog = "1.0" +tree-sitter-xml = "0.7" +tree-sitter-yaml = "0.7" +tree-sitter-zig = "1.1" inferno = { version = "0.12", default-features = false } image = { version = "0.25", default-features = false, features = [ "png", diff --git a/crates/pi-natives/src/ast.rs b/crates/pi-natives/src/ast.rs index a704b6982..75e194570 100644 --- a/crates/pi-natives/src/ast.rs +++ b/crates/pi-natives/src/ast.rs @@ -6,16 +6,12 @@ use std::{ }; use ast_grep_core::{ - Language, MatchStrictness, - matcher::Pattern, - source::{Doc, Edit}, - tree_sitter::LanguageExt, + Language, MatchStrictness, matcher::Pattern, source::Edit, tree_sitter::LanguageExt, }; -use ast_grep_language::SupportLang; use napi::bindgen_prelude::*; use napi_derive::napi; -use crate::{fs_cache, glob_util, task}; +use crate::{fs_cache, glob_util, language::SupportLang, task}; const DEFAULT_FIND_LIMIT: u32 = 50; @@ -152,111 +148,112 @@ fn to_u32(value: usize) -> u32 { value.min(u32::MAX as usize) as u32 } -const fn supported_lang_aliases() -> &'static [&'static str] { - &[ - "bash", - "sh", - "c", - "cpp", - "c++", - "cc", - "cxx", - "csharp", - "c#", - "cs", - "css", - "elixir", - "ex", - "go", - "haskell", - "hs", - "hcl", - "tf", - "html", - "java", - "javascript", - "js", - "jsx", - "json", - "kotlin", - "kt", - "lua", - "nix", - "php", - "python", - "py", - "ruby", - "rb", - "rust", - "rs", - "scala", - "solidity", - "sol", - "swift", - "tsx", - "typescript", - "ts", - "yaml", - "yml", - ] +/// Single source of truth: every recognised alias (lowercased) → `SupportLang`. +/// `resolve_supported_lang` does a lookup here; error messages list the keys. +static LANG_ALIASES: phf::Map<&'static str, SupportLang> = phf::phf_map! { + "bash" => SupportLang::Bash, + "sh" => SupportLang::Bash, + "c" => SupportLang::C, + "cpp" => SupportLang::Cpp, + "c++" => SupportLang::Cpp, + "cc" => SupportLang::Cpp, + "cxx" => SupportLang::Cpp, + "csharp" => SupportLang::CSharp, + "c#" => SupportLang::CSharp, + "cs" => SupportLang::CSharp, + "css" => SupportLang::Css, + "diff" => SupportLang::Diff, + "patch" => SupportLang::Diff, + "elixir" => SupportLang::Elixir, + "ex" => SupportLang::Elixir, + "go" => SupportLang::Go, + "golang" => SupportLang::Go, + "haskell" => SupportLang::Haskell, + "hs" => SupportLang::Haskell, + "hcl" => SupportLang::Hcl, + "tf" => SupportLang::Hcl, + "tfvars" => SupportLang::Hcl, + "terraform" => SupportLang::Hcl, + "html" => SupportLang::Html, + "htm" => SupportLang::Html, + "java" => SupportLang::Java, + "javascript" => SupportLang::JavaScript, + "js" => SupportLang::JavaScript, + "jsx" => SupportLang::JavaScript, + "mjs" => SupportLang::JavaScript, + "cjs" => SupportLang::JavaScript, + "json" => SupportLang::Json, + "julia" => SupportLang::Julia, + "jl" => SupportLang::Julia, + "kotlin" => SupportLang::Kotlin, + "kt" => SupportLang::Kotlin, + "lua" => SupportLang::Lua, + "make" => SupportLang::Make, + "makefile" => SupportLang::Make, + "markdown" => SupportLang::Markdown, + "md" => SupportLang::Markdown, + "mdx" => SupportLang::Markdown, + "nix" => SupportLang::Nix, + "objc" => SupportLang::ObjC, + "objective-c" => SupportLang::ObjC, + "odin" => SupportLang::Odin, + "php" => SupportLang::Php, + "python" => SupportLang::Python, + "py" => SupportLang::Python, + "regex" => SupportLang::Regex, + "ruby" => SupportLang::Ruby, + "rb" => SupportLang::Ruby, + "rust" => SupportLang::Rust, + "rs" => SupportLang::Rust, + "scala" => SupportLang::Scala, + "solidity" => SupportLang::Solidity, + "sol" => SupportLang::Solidity, + "starlark" => SupportLang::Starlark, + "star" => SupportLang::Starlark, + "swift" => SupportLang::Swift, + "toml" => SupportLang::Toml, + "tsx" => SupportLang::Tsx, + "typescript" => SupportLang::TypeScript, + "ts" => SupportLang::TypeScript, + "mts" => SupportLang::TypeScript, + "cts" => SupportLang::TypeScript, + "verilog" => SupportLang::Verilog, + "systemverilog" => SupportLang::Verilog, + "sv" => SupportLang::Verilog, + "xml" => SupportLang::Xml, + "xsl" => SupportLang::Xml, + "svg" => SupportLang::Xml, + "yaml" => SupportLang::Yaml, + "yml" => SupportLang::Yaml, + "zig" => SupportLang::Zig, +}; + +fn supported_lang_list() -> String { + let mut keys: Vec<&str> = LANG_ALIASES.keys().copied().collect(); + keys.sort_unstable(); + keys.join(", ") } fn resolve_supported_lang(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "bash" | "sh" => Ok(SupportLang::Bash), - "c" => Ok(SupportLang::C), - "cpp" | "c++" | "cc" | "cxx" => Ok(SupportLang::Cpp), - "csharp" | "c#" | "cs" => Ok(SupportLang::CSharp), - "css" => Ok(SupportLang::Css), - "elixir" | "ex" => Ok(SupportLang::Elixir), - "go" => Ok(SupportLang::Go), - "haskell" | "hs" => Ok(SupportLang::Haskell), - "hcl" | "tf" => Ok(SupportLang::Hcl), - "html" => Ok(SupportLang::Html), - "java" => Ok(SupportLang::Java), - "javascript" | "js" | "jsx" => Ok(SupportLang::JavaScript), - "json" => Ok(SupportLang::Json), - "kotlin" | "kt" => Ok(SupportLang::Kotlin), - "lua" => Ok(SupportLang::Lua), - "nix" => Ok(SupportLang::Nix), - "php" => Ok(SupportLang::Php), - "python" | "py" => Ok(SupportLang::Python), - "ruby" | "rb" => Ok(SupportLang::Ruby), - "rust" | "rs" => Ok(SupportLang::Rust), - "scala" => Ok(SupportLang::Scala), - "solidity" | "sol" => Ok(SupportLang::Solidity), - "swift" => Ok(SupportLang::Swift), - "tsx" => Ok(SupportLang::Tsx), - "typescript" | "ts" => Ok(SupportLang::TypeScript), - "yaml" | "yml" => Ok(SupportLang::Yaml), - _ => Err(Error::from_reason(format!( + let lower = value.to_ascii_lowercase(); + LANG_ALIASES.get(lower.as_str()).copied().ok_or_else(|| { + Error::from_reason(format!( "Unsupported language '{value}'. Supported: {}", - supported_lang_aliases().join(", ") - ))), - } + supported_lang_list() + )) + }) } fn resolve_language(lang: Option<&str>, file_path: &Path) -> Result { if let Some(lang) = lang.map(str::trim).filter(|lang| !lang.is_empty()) { return resolve_supported_lang(lang); } - let Some(guessed) = SupportLang::from_path(file_path) else { - return Err(Error::from_reason(format!( + SupportLang::from_path(file_path).ok_or_else(|| { + Error::from_reason(format!( "Unable to infer language from file extension: {}. Specify `lang` explicitly.", file_path.display() - ))); - }; - // Accept any language that ast-grep can infer from the extension, - // but only if we also support it in resolve_supported_lang. - let name = canonical_lang_name(guessed); - if name == "unknown" { - return Err(Error::from_reason(format!( - "Unsupported inferred language for {}. Supported: {}", - file_path.display(), - supported_lang_aliases().join(", ") - ))); - } - Ok(guessed) + )) + }) } /// Returns true if the file's extension resolves to a supported language. @@ -270,37 +267,6 @@ fn is_supported_file(file_path: &Path, explicit_lang: Option<&str>) -> bool { resolve_language(None, file_path).is_ok() } -const fn canonical_lang_name(lang: SupportLang) -> &'static str { - match lang { - SupportLang::Bash => "bash", - SupportLang::C => "c", - SupportLang::Cpp => "cpp", - SupportLang::CSharp => "csharp", - SupportLang::Css => "css", - SupportLang::Elixir => "elixir", - SupportLang::Go => "go", - SupportLang::Haskell => "haskell", - SupportLang::Hcl => "hcl", - SupportLang::Html => "html", - SupportLang::Java => "java", - SupportLang::JavaScript => "javascript", - SupportLang::Json => "json", - SupportLang::Kotlin => "kotlin", - SupportLang::Lua => "lua", - SupportLang::Nix => "nix", - SupportLang::Php => "php", - SupportLang::Python => "python", - SupportLang::Ruby => "ruby", - SupportLang::Rust => "rust", - SupportLang::Scala => "scala", - SupportLang::Solidity => "solidity", - SupportLang::Swift => "swift", - SupportLang::Tsx => "tsx", - SupportLang::TypeScript => "typescript", - SupportLang::Yaml => "yaml", - } -} - fn infer_single_replace_lang( candidates: &[FileCandidate], ct: &task::CancelToken, @@ -311,7 +277,7 @@ fn infer_single_replace_lang( ct.heartbeat()?; match resolve_language(None, &candidate.absolute_path) { Ok(language) => { - inferred.insert(canonical_lang_name(language).to_string()); + inferred.insert(language.canonical_name().to_string()); }, Err(err) => unresolved.push(format!("{}: {}", candidate.display_path, err)), } @@ -450,12 +416,6 @@ fn compile_pattern( Ok(compiled) } -fn has_syntax_error(ast: &ast_grep_core::AstGrep) -> bool { - ast.root() - .dfs() - .any(|node| node.is_error() || node.is_missing()) -} - fn apply_edits(content: &str, edits: &[Edit]) -> Result { let mut sorted: Vec<&Edit> = edits.iter().collect(); sorted.sort_by_key(|edit| edit.position); @@ -550,7 +510,7 @@ fn resolve_candidates_for_find( ct.heartbeat()?; match resolve_language(lang, &candidate.absolute_path) { Ok(language) => { - let key = canonical_lang_name(language).to_string(); + let key = language.canonical_name().to_string(); languages.entry(key).or_insert(language); resolved.push(ResolvedCandidate { candidate, @@ -662,7 +622,7 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Async { let Some(language) = language else { continue; }; - let lang_key = canonical_lang_name(language); + let lang_key = language.canonical_name(); let source = match std::fs::read_to_string(&candidate.absolute_path) { Ok(source) => source, Err(err) => { @@ -691,14 +651,11 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Async { } let ast = language.ast_grep(source); - if has_syntax_error(&ast) { - for (pattern_name, _) in &runnable_patterns { - parse_errors.push(format!( - "{pattern_name}: {}: parse error (syntax tree contains error nodes)", - candidate.display_path - )); - } - continue; + if ast.root().dfs().any(|node| node.is_error()) { + parse_errors.push(format!( + "{}: parse error (syntax tree contains error nodes)", + candidate.display_path + )); } for (_, pattern) in runnable_patterns { @@ -847,7 +804,7 @@ pub fn ast_edit(options: AstReplaceOptions<'_>) -> task::Async }; let ast = language.ast_grep(&source); - if has_syntax_error(&ast) { + if ast.root().dfs().any(|node| node.is_error()) { let parse_issue = format!( "{}: parse error (syntax tree contains error nodes)", candidate.display_path @@ -959,8 +916,6 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use ast_grep_core::tree_sitter::LanguageExt; - use super::*; struct TempTree { @@ -1060,14 +1015,6 @@ mod tests { assert!(resolve_supported_lang("brainfuck").is_err()); } - #[test] - fn detects_syntax_errors_in_ast() { - let ok = SupportLang::TypeScript.ast_grep("const value = 1;"); - assert!(!has_syntax_error(&ok)); - let bad = SupportLang::TypeScript.ast_grep("export function broken( { return 1; }"); - assert!(has_syntax_error(&bad)); - } - #[test] fn applies_non_overlapping_edits() { let source = "const answer = 41;"; diff --git a/crates/pi-natives/src/language/mod.rs b/crates/pi-natives/src/language/mod.rs new file mode 100644 index 000000000..51f180665 --- /dev/null +++ b/crates/pi-natives/src/language/mod.rs @@ -0,0 +1,511 @@ +//! Vendored and extended language definitions for ast-grep integration. +//! +//! Originally derived from `ast-grep-language` v0.39.9, stripped of +//! serde/ignore machinery, and extended with additional languages. + +mod parsers; + +use std::{borrow::Cow, collections::HashMap, fmt, path::Path}; + +use ast_grep_core::{ + Doc, Language, Node, + matcher::{KindMatcher, Pattern, PatternBuilder, PatternError}, + meta_var::MetaVariable, + tree_sitter::{LanguageExt, StrDoc, TSLanguage, TSRange}, +}; + +/// Implements a stub language (no expando / `pre_process_pattern` needed). +/// Use when the language grammar accepts `$VAR` as valid identifiers. +macro_rules! impl_lang { + ($lang:ident, $func:ident) => { + #[derive(Clone, Copy, Debug)] + pub struct $lang; + impl Language for $lang { + fn kind_to_id(&self, kind: &str) -> u16 { + self.get_ts_language().id_for_node_kind(kind, true) + } + + fn field_to_id(&self, field: &str) -> Option { + self + .get_ts_language() + .field_id_for_name(field) + .map(|f| f.get()) + } + + fn build_pattern(&self, builder: &PatternBuilder) -> Result { + builder.build(|src| StrDoc::try_new(src, *self)) + } + } + impl LanguageExt for $lang { + fn get_ts_language(&self) -> TSLanguage { + parsers::$func().into() + } + } + }; +} + +fn pre_process_pattern(expando: char, query: &str) -> Cow<'_, str> { + let mut ret = Vec::with_capacity(query.len()); + let mut dollar_count = 0; + for c in query.chars() { + if c == '$' { + dollar_count += 1; + continue; + } + let need_replace = matches!(c, 'A'..='Z' | '_') || dollar_count == 3; + let sigil = if need_replace { expando } else { '$' }; + ret.extend(std::iter::repeat_n(sigil, dollar_count)); + dollar_count = 0; + ret.push(c); + } + let sigil = if dollar_count == 3 { expando } else { '$' }; + ret.extend(std::iter::repeat_n(sigil, dollar_count)); + Cow::Owned(ret.into_iter().collect()) +} + +/// Implements a language with `expando_char` / `pre_process_pattern`. +/// Use when the language does NOT accept `$` as a valid identifier character. +macro_rules! impl_lang_expando { + ($lang:ident, $func:ident, $char:expr) => { + #[derive(Clone, Copy, Debug)] + pub struct $lang; + impl Language for $lang { + fn kind_to_id(&self, kind: &str) -> u16 { + self.get_ts_language().id_for_node_kind(kind, true) + } + + fn field_to_id(&self, field: &str) -> Option { + self + .get_ts_language() + .field_id_for_name(field) + .map(|f| f.get()) + } + + fn expando_char(&self) -> char { + $char + } + + fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { + pre_process_pattern(self.expando_char(), query) + } + + fn build_pattern(&self, builder: &PatternBuilder) -> Result { + builder.build(|src| StrDoc::try_new(src, *self)) + } + } + impl LanguageExt for $lang { + fn get_ts_language(&self) -> TSLanguage { + parsers::$func().into() + } + } + }; +} + +// ── Customized languages with expando_char ────────────────────────────── + +impl_lang_expando!(C, language_c, '𐀀'); +impl_lang_expando!(Cpp, language_cpp, '𐀀'); +impl_lang_expando!(CSharp, language_c_sharp, 'µ'); +impl_lang_expando!(Css, language_css, '_'); +impl_lang_expando!(Elixir, language_elixir, 'µ'); +impl_lang_expando!(Go, language_go, 'µ'); +impl_lang_expando!(Haskell, language_haskell, 'µ'); +impl_lang_expando!(Hcl, language_hcl, 'µ'); +impl_lang_expando!(Kotlin, language_kotlin, 'µ'); +impl_lang_expando!(Nix, language_nix, '_'); +impl_lang_expando!(Php, language_php, 'µ'); +impl_lang_expando!(Python, language_python, 'µ'); +impl_lang_expando!(Ruby, language_ruby, 'µ'); +impl_lang_expando!(Rust, language_rust, 'µ'); +impl_lang_expando!(Swift, language_swift, 'µ'); + +// New expando languages +impl_lang_expando!(Make, language_make, 'µ'); +impl_lang_expando!(ObjC, language_objc, '𐀀'); +impl_lang_expando!(Starlark, language_starlark, 'µ'); +impl_lang_expando!(Odin, language_odin, 'µ'); +impl_lang_expando!(Julia, language_julia, 'µ'); +impl_lang_expando!(Verilog, language_verilog, 'µ'); +impl_lang_expando!(Zig, language_zig, 'µ'); + +// ── Stub languages ($ accepted in grammar) ────────────────────────────── + +impl_lang!(Bash, language_bash); +impl_lang!(Java, language_java); +impl_lang!(JavaScript, language_javascript); +impl_lang!(Json, language_json); +impl_lang!(Lua, language_lua); +impl_lang!(Scala, language_scala); +impl_lang!(Solidity, language_solidity); +impl_lang!(Tsx, language_tsx); +impl_lang!(TypeScript, language_typescript); +impl_lang!(Yaml, language_yaml); + +// New stub languages +impl_lang!(Markdown, language_markdown); +impl_lang!(Toml, language_toml); +impl_lang!(Diff, language_diff); +impl_lang!(Xml, language_xml); +impl_lang!(Regex, language_regex); + +// ── Html (custom implementation with injection support) ────────────────── + +#[derive(Clone, Copy, Debug)] +pub struct Html; + +impl Language for Html { + fn expando_char(&self) -> char { + 'z' + } + + fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { + pre_process_pattern(self.expando_char(), query) + } + + fn kind_to_id(&self, kind: &str) -> u16 { + self.get_ts_language().id_for_node_kind(kind, true) + } + + fn field_to_id(&self, field: &str) -> Option { + self + .get_ts_language() + .field_id_for_name(field) + .map(|f| f.get()) + } + + fn build_pattern(&self, builder: &PatternBuilder) -> Result { + builder.build(|src| StrDoc::try_new(src, *self)) + } +} + +impl LanguageExt for Html { + fn get_ts_language(&self) -> TSLanguage { + parsers::language_html() + } + + fn injectable_languages(&self) -> Option<&'static [&'static str]> { + Some(&["css", "js", "ts", "tsx", "scss", "less", "stylus", "coffee"]) + } + + fn extract_injections( + &self, + root: Node>, + ) -> HashMap> { + let lang = root.lang(); + let mut map = HashMap::new(); + let matcher = KindMatcher::new("script_element", lang.clone()); + for script in root.find_all(matcher) { + let injected = find_html_lang(&script).unwrap_or_else(|| "js".into()); + let content = script.children().find(|c| c.kind() == "raw_text"); + if let Some(content) = content { + map.entry(injected) + .or_insert_with(Vec::new) + .push(node_to_range(&content)); + } + } + let matcher = KindMatcher::new("style_element", lang.clone()); + for style in root.find_all(matcher) { + let injected = find_html_lang(&style).unwrap_or_else(|| "css".into()); + let content = style.children().find(|c| c.kind() == "raw_text"); + if let Some(content) = content { + map.entry(injected) + .or_insert_with(Vec::new) + .push(node_to_range(&content)); + } + } + map + } +} + +fn find_html_lang(node: &Node) -> Option { + let html = node.lang(); + let attr_matcher = KindMatcher::new("attribute", html.clone()); + let name_matcher = KindMatcher::new("attribute_name", html.clone()); + let val_matcher = KindMatcher::new("attribute_value", html.clone()); + node.find_all(attr_matcher).find_map(|attr| { + let name = attr.find(&name_matcher)?; + if name.text() != "lang" { + return None; + } + let val = attr.find(&val_matcher)?; + Some(val.text().to_string()) + }) +} + +fn node_to_range(node: &Node) -> TSRange { + let r = node.range(); + let start = node.start_pos(); + let sp = start.byte_point(); + let sp = tree_sitter::Point::new(sp.0, sp.1); + let end = node.end_pos(); + let ep = end.byte_point(); + let ep = tree_sitter::Point::new(ep.0, ep.1); + TSRange { start_byte: r.start, end_byte: r.end, start_point: sp, end_point: ep } +} + +// ── SupportLang enum ──────────────────────────────────────────────────── + +/// All supported languages for ast-grep structural search/replace. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SupportLang { + Bash, + C, + Cpp, + CSharp, + Css, + Diff, + Elixir, + Go, + Haskell, + Hcl, + Html, + Java, + JavaScript, + Json, + Julia, + Kotlin, + Lua, + Make, + Markdown, + Nix, + ObjC, + Odin, + Php, + Python, + Regex, + Ruby, + Rust, + Scala, + Solidity, + Starlark, + Swift, + Toml, + Tsx, + TypeScript, + Verilog, + Xml, + Yaml, + Zig, +} + +impl SupportLang { + pub const fn all_langs() -> &'static [Self] { + use SupportLang::*; + &[ + Bash, C, Cpp, CSharp, Css, Diff, Elixir, Go, Haskell, Hcl, Html, Java, JavaScript, Json, + Julia, Kotlin, Lua, Make, Markdown, Nix, ObjC, Odin, Php, Python, Regex, Ruby, Rust, + Scala, Solidity, Starlark, Swift, Toml, Tsx, TypeScript, Verilog, Xml, Yaml, Zig, + ] + } + + /// The canonical lowercase name used as a stable key in alias maps, + /// file-type inference results, and error messages. + pub const fn canonical_name(self) -> &'static str { + match self { + Self::Bash => "bash", + Self::C => "c", + Self::Cpp => "cpp", + Self::CSharp => "csharp", + Self::Css => "css", + Self::Diff => "diff", + Self::Elixir => "elixir", + Self::Go => "go", + Self::Haskell => "haskell", + Self::Hcl => "hcl", + Self::Html => "html", + Self::Java => "java", + Self::JavaScript => "javascript", + Self::Json => "json", + Self::Julia => "julia", + Self::Kotlin => "kotlin", + Self::Lua => "lua", + Self::Make => "make", + Self::Markdown => "markdown", + Self::Nix => "nix", + Self::ObjC => "objc", + Self::Odin => "odin", + Self::Php => "php", + Self::Python => "python", + Self::Regex => "regex", + Self::Ruby => "ruby", + Self::Rust => "rust", + Self::Scala => "scala", + Self::Solidity => "solidity", + Self::Starlark => "starlark", + Self::Swift => "swift", + Self::Toml => "toml", + Self::Tsx => "tsx", + Self::TypeScript => "typescript", + Self::Verilog => "verilog", + Self::Xml => "xml", + Self::Yaml => "yaml", + Self::Zig => "zig", + } + } +} + +impl fmt::Display for SupportLang { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +// ── Dispatch macro ────────────────────────────────────────────────────── + +macro_rules! execute_lang_method { + ($me:path, $method:ident, $($pname:tt),*) => { + use SupportLang as S; + match $me { + S::Bash => Bash.$method($($pname,)*), + S::C => C.$method($($pname,)*), + S::Cpp => Cpp.$method($($pname,)*), + S::CSharp => CSharp.$method($($pname,)*), + S::Css => Css.$method($($pname,)*), + S::Diff => Diff.$method($($pname,)*), + S::Elixir => Elixir.$method($($pname,)*), + S::Go => Go.$method($($pname,)*), + S::Haskell => Haskell.$method($($pname,)*), + S::Hcl => Hcl.$method($($pname,)*), + S::Html => Html.$method($($pname,)*), + S::Java => Java.$method($($pname,)*), + S::JavaScript => JavaScript.$method($($pname,)*), + S::Json => Json.$method($($pname,)*), + S::Julia => Julia.$method($($pname,)*), + S::Kotlin => Kotlin.$method($($pname,)*), + S::Lua => Lua.$method($($pname,)*), + S::Make => Make.$method($($pname,)*), + S::Markdown => Markdown.$method($($pname,)*), + S::Nix => Nix.$method($($pname,)*), + S::ObjC => ObjC.$method($($pname,)*), + S::Odin => Odin.$method($($pname,)*), + S::Php => Php.$method($($pname,)*), + S::Python => Python.$method($($pname,)*), + S::Regex => Regex.$method($($pname,)*), + S::Ruby => Ruby.$method($($pname,)*), + S::Rust => Rust.$method($($pname,)*), + S::Scala => Scala.$method($($pname,)*), + S::Solidity => Solidity.$method($($pname,)*), + S::Starlark => Starlark.$method($($pname,)*), + S::Swift => Swift.$method($($pname,)*), + S::Toml => Toml.$method($($pname,)*), + S::Tsx => Tsx.$method($($pname,)*), + S::TypeScript => TypeScript.$method($($pname,)*), + S::Verilog => Verilog.$method($($pname,)*), + S::Xml => Xml.$method($($pname,)*), + S::Yaml => Yaml.$method($($pname,)*), + S::Zig => Zig.$method($($pname,)*), + } + }; +} + +macro_rules! impl_lang_method { + ($method:ident, ($($pname:tt: $ptype:ty),*) => $return_type:ty) => { + #[inline] + fn $method(&self, $($pname: $ptype),*) -> $return_type { + execute_lang_method! { self, $method, $($pname),* } + } + }; +} + +impl Language for SupportLang { + impl_lang_method!(kind_to_id, (kind: &str) => u16); + + impl_lang_method!(field_to_id, (field: &str) => Option); + + impl_lang_method!(meta_var_char, () => char); + + impl_lang_method!(expando_char, () => char); + + impl_lang_method!(extract_meta_var, (source: &str) => Option); + + impl_lang_method!(build_pattern, (builder: &PatternBuilder) => Result); + + fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { + execute_lang_method! { self, pre_process_pattern, query } + } + + fn from_path>(path: P) -> Option { + from_extension(path.as_ref()) + } +} + +impl LanguageExt for SupportLang { + impl_lang_method!(get_ts_language, () => TSLanguage); + + impl_lang_method!(injectable_languages, () => Option<&'static [&'static str]>); + + fn extract_injections( + &self, + root: Node>, + ) -> HashMap> { + match self { + Self::Html => Html.extract_injections(root), + _ => HashMap::new(), + } + } +} + +// ── File extension mapping ────────────────────────────────────────────── + +const fn extensions(lang: SupportLang) -> &'static [&'static str] { + use SupportLang::*; + match lang { + Bash => { + &["bash", "bats", "cgi", "command", "env", "fcgi", "ksh", "sh", "tmux", "tool", "zsh"] + }, + C => &["c", "h"], + Cpp => &["cc", "hpp", "cpp", "c++", "hh", "cxx", "cu", "ino"], + CSharp => &["cs"], + Css => &["css", "scss"], + Diff => &["diff", "patch"], + Elixir => &["ex", "exs"], + Go => &["go"], + Haskell => &["hs"], + Hcl => &["hcl", "tf", "tfvars"], + Html => &["html", "htm", "xhtml"], + Java => &["java"], + JavaScript => &["cjs", "js", "mjs", "jsx"], + Json => &["json"], + Julia => &["jl"], + Kotlin => &["kt", "ktm", "kts"], + Lua => &["lua"], + Make => &["mk", "mak"], + Markdown => &["md", "markdown", "mdx"], + Nix => &["nix"], + ObjC => &["m"], + Odin => &["odin"], + Php => &["php"], + Python => &["py", "py3", "pyi", "bzl"], + Regex => &[], // regex has no file extension + Ruby => &["rb", "rbw", "gemspec"], + Rust => &["rs"], + Scala => &["scala", "sc", "sbt"], + Solidity => &["sol"], + Starlark => &["star", "bzl"], + Swift => &["swift"], + Toml => &["toml"], + Tsx => &["tsx"], + TypeScript => &["ts", "cts", "mts"], + Verilog => &["v", "sv", "svh", "vh"], + Xml => &["xml", "xsl", "xslt", "svg", "plist"], + Yaml => &["yaml", "yml"], + Zig => &["zig"], + } +} + +/// Guess language from file extension. +fn from_extension(path: &Path) -> Option { + let ext = path.extension()?.to_str()?; + // Special cases: Makefile has no extension + if ext.is_empty() { + let name = path.file_name()?.to_str()?; + return match name { + "Makefile" | "makefile" | "GNUmakefile" => Some(SupportLang::Make), + _ => None, + }; + } + SupportLang::all_langs() + .iter() + .copied() + .find(|&l| extensions(l).contains(&ext)) +} diff --git a/crates/pi-natives/src/language/parsers.rs b/crates/pi-natives/src/language/parsers.rs new file mode 100644 index 000000000..5c31b31ca --- /dev/null +++ b/crates/pi-natives/src/language/parsers.rs @@ -0,0 +1,118 @@ +//! Tree-sitter parser functions for all supported languages. + +use ast_grep_core::tree_sitter::TSLanguage; + +pub fn language_bash() -> TSLanguage { + tree_sitter_bash::LANGUAGE.into() +} +pub fn language_c() -> TSLanguage { + tree_sitter_c::LANGUAGE.into() +} +pub fn language_cpp() -> TSLanguage { + tree_sitter_cpp::LANGUAGE.into() +} +pub fn language_c_sharp() -> TSLanguage { + tree_sitter_c_sharp::LANGUAGE.into() +} +pub fn language_css() -> TSLanguage { + tree_sitter_css::LANGUAGE.into() +} +pub fn language_diff() -> TSLanguage { + tree_sitter_diff::LANGUAGE.into() +} +pub fn language_elixir() -> TSLanguage { + tree_sitter_elixir::LANGUAGE.into() +} +pub fn language_go() -> TSLanguage { + tree_sitter_go::LANGUAGE.into() +} +pub fn language_haskell() -> TSLanguage { + tree_sitter_haskell::LANGUAGE.into() +} +pub fn language_hcl() -> TSLanguage { + tree_sitter_hcl::LANGUAGE.into() +} +pub fn language_html() -> TSLanguage { + tree_sitter_html::LANGUAGE.into() +} +pub fn language_java() -> TSLanguage { + tree_sitter_java::LANGUAGE.into() +} +pub fn language_javascript() -> TSLanguage { + tree_sitter_javascript::LANGUAGE.into() +} +pub fn language_json() -> TSLanguage { + tree_sitter_json::LANGUAGE.into() +} +pub fn language_julia() -> TSLanguage { + tree_sitter_julia::LANGUAGE.into() +} +pub fn language_kotlin() -> TSLanguage { + tree_sitter_kotlin::LANGUAGE.into() +} +pub fn language_lua() -> TSLanguage { + tree_sitter_lua::LANGUAGE.into() +} +pub fn language_make() -> TSLanguage { + tree_sitter_make::LANGUAGE.into() +} +pub fn language_markdown() -> TSLanguage { + tree_sitter_md::LANGUAGE.into() +} +pub fn language_nix() -> TSLanguage { + tree_sitter_nix::LANGUAGE.into() +} +pub fn language_objc() -> TSLanguage { + tree_sitter_objc::LANGUAGE.into() +} +pub fn language_odin() -> TSLanguage { + tree_sitter_odin::LANGUAGE.into() +} +pub fn language_php() -> TSLanguage { + tree_sitter_php::LANGUAGE_PHP_ONLY.into() +} +pub fn language_python() -> TSLanguage { + tree_sitter_python::LANGUAGE.into() +} +pub fn language_regex() -> TSLanguage { + tree_sitter_regex::LANGUAGE.into() +} +pub fn language_ruby() -> TSLanguage { + tree_sitter_ruby::LANGUAGE.into() +} +pub fn language_rust() -> TSLanguage { + tree_sitter_rust::LANGUAGE.into() +} +pub fn language_scala() -> TSLanguage { + tree_sitter_scala::LANGUAGE.into() +} +pub fn language_solidity() -> TSLanguage { + tree_sitter_solidity::LANGUAGE.into() +} +pub fn language_starlark() -> TSLanguage { + tree_sitter_starlark::LANGUAGE.into() +} +pub fn language_swift() -> TSLanguage { + tree_sitter_swift::LANGUAGE.into() +} +pub fn language_toml() -> TSLanguage { + tree_sitter_toml_ng::LANGUAGE.into() +} +pub fn language_tsx() -> TSLanguage { + tree_sitter_typescript::LANGUAGE_TSX.into() +} +pub fn language_typescript() -> TSLanguage { + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into() +} +pub fn language_verilog() -> TSLanguage { + tree_sitter_verilog::LANGUAGE.into() +} +pub fn language_xml() -> TSLanguage { + tree_sitter_xml::LANGUAGE_XML.into() +} +pub fn language_yaml() -> TSLanguage { + tree_sitter_yaml::LANGUAGE.into() +} +pub fn language_zig() -> TSLanguage { + tree_sitter_zig::LANGUAGE.into() +} diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 2af9ac9af..670496ca0 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -33,6 +33,7 @@ pub mod highlight; pub mod html; pub mod image; pub mod keys; +pub mod language; pub mod prof; pub mod projfs_overlay; pub mod ps; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 39aa9f48c..48ad0de94 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- Exported `dedupeParseErrors` utility function to deduplicate parse error messages while preserving order + +### Fixed + +- Reduced duplicate parse error messages when multiple patterns fail on the same file +- Normalized parse error output in ast-grep to remove pattern-specific prefixes and show only file-level errors ## [13.7.4] - 2026-03-04 ### Added diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 9369629e7..4a7e2dc29 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -16,6 +16,7 @@ import type { ToolSession } from "."; import type { OutputMeta } from "./output-meta"; import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils"; import { + dedupeParseErrors, formatCount, formatEmptyMessage, formatErrorMessage, @@ -140,6 +141,7 @@ export class AstEditTool implements AgentTool { const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath; if (isDirectory) { @@ -178,15 +180,15 @@ export class AstEditTool implements AgentTool { + const parseError = error.match(/^.+: (.+: parse error \(syntax tree contains error nodes\))$/); + return parseError?.[1] ?? error; + }); + const dedupedParseErrors = dedupeParseErrors(normalizedParseErrors); const formatPath = (filePath: string): string => { const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath; if (isDirectory) { @@ -165,15 +171,15 @@ export class AstGrepTool implements AgentTool(); + const deduped: string[] = []; + for (const error of errors) { + if (seen.has(error)) continue; + seen.add(error); + deduped.push(error); + } + return deduped; +} + export function formatParseErrors(errors: string[]): string[] { - if (errors.length === 0) return []; - const capped = errors.slice(0, PARSE_ERRORS_LIMIT); + const deduped = dedupeParseErrors(errors); + if (deduped.length === 0) return []; + const capped = deduped.slice(0, PARSE_ERRORS_LIMIT); const header = - errors.length > PARSE_ERRORS_LIMIT ? `Parse issues (${PARSE_ERRORS_LIMIT} / ${errors.length}):` : "Parse issues:"; + deduped.length > PARSE_ERRORS_LIMIT + ? `Parse issues (${PARSE_ERRORS_LIMIT} / ${deduped.length}):` + : "Parse issues:"; return [header, ...capped.map(err => `- ${err}`)]; } diff --git a/packages/coding-agent/test/tools/ast-grep.test.ts b/packages/coding-agent/test/tools/ast-grep.test.ts new file mode 100644 index 000000000..5a2440118 --- /dev/null +++ b/packages/coding-agent/test/tools/ast-grep.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; + +function createTestSession(cwd = "/tmp/test", overrides: Partial = {}): ToolSession { + return { + cwd, + hasUI: true, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated(), + ...overrides, + }; +} + +describe("ast_grep parse errors", () => { + it("collapses per-pattern parse errors for the same file", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-grep-parse-")); + try { + const filePath = path.join(tempDir, "broken.ts"); + await Bun.write(filePath, "export function broken( { return 1; }"); + + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "ast_grep"); + expect(tool).toBeDefined(); + + const result = await tool!.execute("ast-grep-parse", { + patterns: ["someUnlikelyCall($A)", "anotherUnlikelyCall($A)"], + lang: "typescript", + path: filePath, + }); + + const text = result.content.find(content => content.type === "text")?.text ?? ""; + const details = result.details as { parseErrors?: string[]; matchCount?: number } | undefined; + + expect(details?.matchCount).toBe(0); + expect(text).toContain("No matches found"); + expect(details?.parseErrors).toHaveLength(1); + expect(details?.parseErrors?.[0]).toContain("broken.ts: parse error (syntax tree contains error nodes)"); + expect(details?.parseErrors?.[0]).not.toContain("someUnlikelyCall($A):"); + expect(details?.parseErrors?.[0]).not.toContain("anotherUnlikelyCall($A):"); + expect(text.match(/parse error \(syntax tree contains error nodes\)/g)?.length ?? 0).toBe(1); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/tools/render-utils.test.ts b/packages/coding-agent/test/tools/render-utils.test.ts new file mode 100644 index 000000000..71618bf99 --- /dev/null +++ b/packages/coding-agent/test/tools/render-utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { dedupeParseErrors, formatParseErrors } from "@oh-my-pi/pi-coding-agent/tools/render-utils"; + +describe("parse error formatting", () => { + it("deduplicates parse errors while preserving order", () => { + const errors = [ + "foo.ts: parse error (syntax tree contains error nodes)", + "foo.ts: parse error (syntax tree contains error nodes)", + "bar.ts: parse error (syntax tree contains error nodes)", + "foo.ts: parse error (syntax tree contains error nodes)", + ]; + + expect(dedupeParseErrors(errors)).toEqual([ + "foo.ts: parse error (syntax tree contains error nodes)", + "bar.ts: parse error (syntax tree contains error nodes)", + ]); + }); + + it("formats deduplicated parse errors", () => { + const formatted = formatParseErrors([ + "foo.ts: parse error (syntax tree contains error nodes)", + "foo.ts: parse error (syntax tree contains error nodes)", + "bar.ts: parse error (syntax tree contains error nodes)", + ]); + + expect(formatted).toEqual([ + "Parse issues:", + "- foo.ts: parse error (syntax tree contains error nodes)", + "- bar.ts: parse error (syntax tree contains error nodes)", + ]); + }); +});