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.
This commit is contained in:
can1357
2026-03-04 16:59:50 +01:00
parent 18d2af3786
commit 50cd2310dc
12 changed files with 1060 additions and 212 deletions
Generated
+158 -38
View File
@@ -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"
+39 -2
View File
@@ -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",
+105 -158
View File
@@ -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<SupportLang> {
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<SupportLang> {
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<D: Doc>(ast: &ast_grep_core::AstGrep<D>) -> bool {
ast.root()
.dfs()
.any(|node| node.is_error() || node.is_missing())
}
fn apply_edits(content: &str, edits: &[Edit<String>]) -> Result<String> {
let mut sorted: Vec<&Edit<String>> = 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<AstFindResult> {
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<AstFindResult> {
}
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<AstReplaceResult>
};
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;";
+511
View File
@@ -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<u16> {
self
.get_ts_language()
.field_id_for_name(field)
.map(|f| f.get())
}
fn build_pattern(&self, builder: &PatternBuilder) -> Result<Pattern, PatternError> {
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<u16> {
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<Pattern, PatternError> {
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<u16> {
self
.get_ts_language()
.field_id_for_name(field)
.map(|f| f.get())
}
fn build_pattern(&self, builder: &PatternBuilder) -> Result<Pattern, PatternError> {
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<L: LanguageExt>(
&self,
root: Node<StrDoc<L>>,
) -> HashMap<String, Vec<TSRange>> {
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<D: Doc>(node: &Node<D>) -> Option<String> {
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<D: Doc>(node: &Node<D>) -> 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<u16>);
impl_lang_method!(meta_var_char, () => char);
impl_lang_method!(expando_char, () => char);
impl_lang_method!(extract_meta_var, (source: &str) => Option<MetaVariable>);
impl_lang_method!(build_pattern, (builder: &PatternBuilder) => Result<Pattern, PatternError>);
fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> {
execute_lang_method! { self, pre_process_pattern, query }
}
fn from_path<P: AsRef<Path>>(path: P) -> Option<Self> {
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<L: LanguageExt>(
&self,
root: Node<StrDoc<L>>,
) -> HashMap<String, Vec<TSRange>> {
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<SupportLang> {
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))
}
+118
View File
@@ -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()
}
+1
View File
@@ -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;
+8
View File
@@ -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
+9 -6
View File
@@ -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<typeof astEditSchema, AstEditToolD
signal,
});
const dedupedParseErrors = dedupeParseErrors(result.parseErrors);
const formatPath = (filePath: string): string => {
const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
if (isDirectory) {
@@ -178,15 +180,15 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
filesSearched: result.filesSearched,
applied: result.applied,
limitReached: result.limitReached,
parseErrors: result.parseErrors,
parseErrors: dedupedParseErrors,
scopePath,
files: fileList,
fileReplacements: [],
};
if (result.totalReplacements === 0) {
const parseMessage = result.parseErrors?.length
? `\n${formatParseErrors(result.parseErrors).join("\n")}`
const parseMessage = dedupedParseErrors.length
? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
: "";
return toolResult(baseDetails).text(`No replacements made${parseMessage}`).done();
}
@@ -258,8 +260,8 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
if (result.limitReached) {
outputLines.push("", "Limit reached; narrow path or increase limit.");
}
if (result.parseErrors?.length) {
outputLines.push("", ...formatParseErrors(result.parseErrors));
if (dedupedParseErrors.length) {
outputLines.push("", ...formatParseErrors(dedupedParseErrors));
}
// Register pending action so `resolve` can apply or discard these previewed changes
@@ -281,13 +283,14 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
maxFiles,
failOnParseError: false,
});
const dedupedApplyParseErrors = dedupeParseErrors(applyResult.parseErrors);
const appliedDetails: AstEditToolDetails = {
totalReplacements: applyResult.totalReplacements,
filesTouched: applyResult.filesTouched,
filesSearched: applyResult.filesSearched,
applied: applyResult.applied,
limitReached: applyResult.limitReached,
parseErrors: applyResult.parseErrors,
parseErrors: dedupedApplyParseErrors,
scopePath,
files: fileList,
fileReplacements,
+11 -5
View File
@@ -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,
@@ -133,6 +134,11 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
signal,
});
const normalizedParseErrors = (result.parseErrors ?? []).map(error => {
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<typeof astGrepSchema, AstGrepToolD
fileCount: result.filesWithMatches,
filesSearched: result.filesSearched,
limitReached: result.limitReached,
parseErrors: result.parseErrors,
parseErrors: dedupedParseErrors,
scopePath,
files: fileList,
fileMatches: [],
};
if (result.matches.length === 0) {
const parseMessage = result.parseErrors?.length
? `\n${formatParseErrors(result.parseErrors).join("\n")}`
const parseMessage = dedupedParseErrors.length
? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
: "";
return toolResult(baseDetails).text(`No matches found${parseMessage}`).done();
}
@@ -253,8 +259,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (result.limitReached) {
outputLines.push("", "Result limit reached; narrow path pattern or increase limit.");
}
if (result.parseErrors?.length) {
outputLines.push("", ...formatParseErrors(result.parseErrors));
if (dedupedParseErrors.length) {
outputLines.push("", ...formatParseErrors(dedupedParseErrors));
}
return toolResult(details).text(outputLines.join("\n")).done();
@@ -533,10 +533,25 @@ export function wrapBrackets(text: string, theme: Theme): string {
export const PARSE_ERRORS_LIMIT = 20;
export function dedupeParseErrors(errors: string[] | undefined): string[] {
if (!errors || errors.length === 0) return [];
const seen = new Set<string>();
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}`)];
}
@@ -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> = {}): 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 });
}
});
});
@@ -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)",
]);
});
});