1use anyhow::{Context, Result, anyhow, bail};
2use once_cell::sync::Lazy;
3use regex::Regex;
4use serde::{Deserialize, Deserializer};
5use serde_json::{Map, Value, json};
6use std::collections::BTreeMap;
7use std::fmt;
8use std::path::{Component, Path, PathBuf};
9use tokio::fs as afs;
10use tokio::process::Command;
11
12use crate::tools::ast_grep_binary::AST_GREP_INSTALL_COMMAND;
13use crate::tools::ast_grep_language::AstGrepLanguage;
14use crate::tools::editing::patch::resolve_ast_grep_binary_path;
15use crate::tools::error_helpers::deserialize_tool_args;
16use crate::tools::tree_sitter_runtime::parse_source;
17use crate::utils::path::{canonicalize_allow_missing, normalize_path, resolve_workspace_path};
18
19const DEFAULT_MAX_RESULTS: usize = 100;
20const MAX_ALLOWED_RESULTS: usize = 10_000;
21const MAX_ALLOWED_GLOBS: usize = 64;
22const MAX_ALLOWED_CONTEXT_LINES: usize = 20;
23const MAX_AUXILIARY_OUTPUT_CHARS: usize = 64_000;
24const DEFAULT_AST_GREP_CONFIG_PATH: &str = "sgconfig.yml";
25const AST_GREP_FAQ_HINT: &str = "Hints: patterns must be valid parseable code for the selected language; ast-grep matches CST structure, not raw text; if the target is only a fragment, retry with a larger parseable pattern and use `selector` when the real match is a subnode inside that pattern; invalid snippets may appear to work only through tree-sitter recovery, so prefer valid `context` plus `selector` instead of relying on recovery; for C, tree-sitter-c parses fragments differently by context: `test($A)` alone becomes `macro_type_specifier`, while `test($A);` becomes `expression_statement -> call_expression`; use `context` plus `selector: call_expression` for C function-call matching; do not try to force a different node kind by combining separate `kind` and `pattern` rules; use one pattern object with `context` plus `selector` instead; operators and keywords usually are not valid meta-variable positions, so switch to parseable code plus `kind`, `regex`, `has`, or another rule object; `$VAR` matches named nodes by default, `$$VAR` includes unnamed nodes, and `$$$ARGS` matches zero or more nodes lazily; `$_NAME` prefix means non-capturing (no backreference); same-name metavariables enforce identity (`$A == $A` matches `a == a` but not `a == b`); meta variables are only detected when the whole AST node text matches meta-variable syntax, so mixed text, lowercase names, or bare `$` followed by digits will not work; repeat captured names only when the syntax must match exactly, and prefix with `_` to disable capture when equality is not required; if a name must match by prefix or suffix, capture the whole node and narrow it with `constraints.regex` instead of mixing text into the meta variable; if node role matters, make it explicit in the parseable pattern instead of guessing; `selector` can also override the default effective node when statement-level matching matters more than the inner expression; if matches are too broad or too narrow, tune `strictness` (`smart` default; `cst`, `ast`, `relaxed`, and `signature` control what matching may skip); use `debug_query` to inspect parse output when matching is surprising; structural search is syntax-aware, not scope/type/data-flow analysis; `kind` supports ESQuery-style compound selectors: `A > B` (direct child), `A B` (descendant), `A + B` (immediate sibling), `A ~ B` (general sibling), and `A, B` (either); pseudo-selectors `:has()`, `:not()`, `:is()`, `:nth-child()`, and `:nth-last-child()` narrow `kind` and `selector` matches by descendant structure, exclusion, alternatives, or sibling position; for HTML, key node kinds are `element`, `tag_name`, `attribute_name`, `attribute_value`, and `text`; use `kind: element` with `has` to match elements by tag or attribute, `kind: tag_name` to match tag names, `kind: attribute_name` to match attribute names, and `kind: text` to match text content; HTML `inside` with `stopBy: { kind: element }` scopes matches to the nearest enclosing element; HTML `<script>` and `<style>` content is parsed as embedded JavaScript/CSS respectively, so search those regions with `lang: javascript` or `lang: css` rules; for simple pattern-to-pattern rewrites, use `workflow='rewrite'` which previews replacements without applying them; use `workflow='apply'` to write rewrite results directly to disk; for FixConfig rewrites with range expansion via `expandStart`/`expandEnd`, use `workflow='rewrite'` with `fix_config` which generates a temporary YAML rule and previews the expanded replacements; for advanced rewrite operations using `transform` (replace for regex substitution with capture groups, substring for Python-style Unicode slicing, convert for identifier case changes like camelCase/snakeCase/kebabCase/pascalCase), `fix`, `rewriters`, load the bundled `ast-grep` skill which covers the full transform pipeline including regex capture groups, chained sequential transformations, conditional separators from multi-captures, and string-form shorthand syntax; use `matches` to reference a utility rule by name; define local utilities in the `utils` section of the request; composite rules `all` (conjunction), `any` (disjunction), and `not` (negation) combine sub-rules; use `matches` with `utils` for recursive pattern matching; cyclic `matches` dependencies are not allowed; for `matches` and composite rules, `lang` is required because the YAML rule generation path is used.";
26const AST_GREP_PROJECT_CONFIG_HINT: &str = "If the target language is not built into ast-grep, register it in workspace-local `sgconfig.yml` under `customLanguages` with a compiled tree-sitter dynamic library. Prefer `tree-sitter build --output <lib>` to compile it, or use `TREE_SITTER_LIBDIR` with `tree-sitter test` on older tree-sitter versions. Reusing a compatible parser library from Neovim is also valid. If the parser exists but the extension is unusual, map it with `languageGlobs`. Some embedded-language cases are built in, such as HTML `<script>` / `<style>` extraction. If the target syntax is embedded inside another host language, configure `languageInjections` with `hostLanguage`, `rule`, and `injected`; the rule should capture the embedded subregion with a meta variable like `$CONTENT`. If `$VAR` is not valid syntax for that language, use its configured `expandoChar` instead. Use `tree-sitter parse <file>` to inspect parser output when the grammar or file association is unclear. ast-grep rules are single-language, so shared JS/TS-style coverage usually means parsing both through the superset via `languageGlobs` or maintaining separate rules. Use `testConfigs` with `testDir` (required) and optional `snapshotDir` to configure ast-grep test discovery. Use `utilDirs` to declare directories for global utility rules shared across multiple rule files. Use `workflow='inspect'` to see the project's current `testConfigs`, `utilDirs`, `languageInjections`, `customLanguages`, and `languageGlobs` configuration. Utility rules must declare `id` and `language` and can only use `id`, `language`, `rule`, `constraints`, and local `utils`.";
27const DEBUG_QUERY_LANG_HINT: &str = "action='structural' requires an effective `lang` when `debug_query` is set. Inference only works for unambiguous file paths or single-language positive globs; narrow `path`, add a single-language glob, or set `lang` explicitly";
28const STRUCTURAL_FORBIDDEN_KEYS: &[&str] = &[
29 "stdin",
30 "json",
31 "color",
32 "heading",
33 "inspect",
34 "include_metadata",
35 "error",
36 "warning",
37 "info",
38 "hint",
39 "off",
40 "rule",
41 "inline_rules",
42 "config",
43 "yes",
44 "base_dir",
45];
46
47const VALID_NO_IGNORE_VALUES: &[&str] = &["hidden", "dot", "exclude", "global", "parent", "vcs"];
48const VALID_FORMAT_VALUES: &[&str] = &["github", "sarif"];
49const VALID_REPORT_STYLE_VALUES: &[&str] = &["rich", "medium", "short"];
50const VALID_BUILTIN_RULES: &[&str] = &["unused-suppression", "no-suppress-all"];
51const MAX_THREADS: u32 = 256;
52static AST_GREP_METAVARIABLE_RE: Lazy<Regex> = Lazy::new(|| {
53 Regex::new(r"\$\$?[A-Za-z_][A-Za-z0-9_]*").expect("ast-grep metavariable regex must compile")
54});
55static AST_GREP_VALID_METAVAR_RE: Lazy<Regex> = Lazy::new(|| {
58 Regex::new(r"^\$\$?(\$?[A-Z_][A-Z0-9_]*)$").expect("ast-grep valid metavar regex must compile")
59});
60static ANSI_ESCAPE_RE: Lazy<Regex> =
61 Lazy::new(|| Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").expect("ansi escape regex must compile"));
62static AST_GREP_TEST_RESULT_RE: Lazy<Regex> = Lazy::new(|| {
63 Regex::new(r"test result:\s*(ok|failed)\.\s*(\d+)\s+passed;\s*(\d+)\s+failed;")
64 .expect("ast-grep test summary regex must compile")
65});
66static AST_GREP_TEST_RULE_LINE_RE: Lazy<Regex> = Lazy::new(|| {
69 Regex::new(r"^(PASS|FAIL)\s+(\S[\w/\-]*)(.*)$")
70 .expect("ast-grep test rule line regex must compile")
71});
72static AST_GREP_TEST_NOISY_RE: Lazy<Regex> = Lazy::new(|| {
74 Regex::new(r"^\[Noisy\]\s+Expect\s+(\S[\w/\-]*)\s+to report no issue")
75 .expect("ast-grep noisy detail regex must compile")
76});
77static AST_GREP_TEST_MISSING_RE: Lazy<Regex> = Lazy::new(|| {
78 Regex::new(r"^\[Missing\]\s+Expect\s+(?:rule\s+)?(\S[\w/\-]*)\s+to report issues")
79 .expect("ast-grep missing detail regex must compile")
80});
81
82#[cfg(test)]
83mod tests;
84
85#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
86#[serde(rename_all = "snake_case")]
87enum StructuralWorkflow {
88 #[default]
89 Query,
90 Scan,
91 Test,
92 Inspect,
93 Rewrite,
94 Count,
95 Rules,
96 New,
97 Apply,
98}
99
100impl StructuralWorkflow {
101 fn as_str(self) -> &'static str {
102 match self {
103 Self::Query => "query",
104 Self::Scan => "scan",
105 Self::Test => "test",
106 Self::Inspect => "inspect",
107 Self::Rewrite => "rewrite",
108 Self::Count => "count",
109 Self::Rules => "rules",
110 Self::New => "new",
111 Self::Apply => "apply",
112 }
113 }
114}
115
116#[derive(Debug, Clone, Deserialize)]
117#[serde(rename_all = "snake_case")]
118enum StructuralStrictness {
119 Cst,
120 Smart,
121 Ast,
122 Relaxed,
123 Signature,
124 Template,
125}
126
127impl StructuralStrictness {
128 fn as_str(&self) -> &'static str {
129 match self {
130 Self::Cst => "cst",
131 Self::Smart => "smart",
132 Self::Ast => "ast",
133 Self::Relaxed => "relaxed",
134 Self::Signature => "signature",
135 Self::Template => "template",
136 }
137 }
138}
139
140#[derive(Debug, Clone, Deserialize)]
141#[serde(rename_all = "snake_case")]
142enum DebugQueryFormat {
143 Pattern,
144 Ast,
145 Cst,
146 Sexp,
147}
148
149impl DebugQueryFormat {
150 fn as_str(&self) -> &'static str {
151 match self {
152 Self::Pattern => "pattern",
153 Self::Ast => "ast",
154 Self::Cst => "cst",
155 Self::Sexp => "sexp",
156 }
157 }
158}
159
160#[derive(Debug, Clone, Deserialize)]
164#[serde(untagged)]
165enum NthChildInput {
166 Number(usize),
167 Formula(String),
168 Object(NthChildObject),
169}
170
171#[derive(Debug, Clone, Deserialize)]
172struct NthChildObject {
173 position: Value,
174 #[serde(default)]
175 reverse: Option<bool>,
176 #[serde(default, rename = "ofRule")]
177 of_rule: Option<Value>,
178}
179
180#[derive(Debug, Clone, Deserialize)]
183struct RangeInput {
184 start: RangePoint,
185 end: RangePoint,
186}
187
188#[derive(Debug, Clone, Deserialize)]
189struct RangePoint {
190 line: usize,
191 column: usize,
192}
193
194#[derive(Debug, Clone, Deserialize)]
195#[serde(untagged)]
196enum GlobInput {
197 Single(String),
198 Multiple(Vec<String>),
199}
200
201impl GlobInput {
202 fn into_vec(self) -> Vec<String> {
203 match self {
204 Self::Single(glob) => vec![glob],
205 Self::Multiple(globs) => globs,
206 }
207 }
208}
209
210#[derive(Debug, Clone, Deserialize)]
214struct FixExpandRule {
215 #[serde(default)]
216 regex: Option<String>,
217 #[serde(default)]
218 kind: Option<String>,
219 #[serde(default)]
220 pattern: Option<String>,
221 #[serde(default)]
225 stop_by: Option<Value>,
226}
227
228impl FixExpandRule {
229 fn is_empty(&self) -> bool {
230 self.regex.is_none() && self.kind.is_none() && self.pattern.is_none()
231 }
232
233 fn validate(&self, label: &str) -> Result<()> {
234 if self.is_empty() {
235 bail!("`{label}` must specify at least one of `regex`, `kind`, or `pattern`");
236 }
237 Ok(())
238 }
239
240 fn to_yaml_value(&self) -> Value {
243 let mut obj = Map::new();
244 if let Some(regex) = &self.regex {
245 obj.insert("regex".to_string(), Value::String(regex.clone()));
246 }
247 if let Some(kind) = &self.kind {
248 obj.insert("kind".to_string(), Value::String(kind.clone()));
249 }
250 if let Some(pattern) = &self.pattern {
251 obj.insert("pattern".to_string(), Value::String(pattern.clone()));
252 }
253 if let Some(stop_by) = &self.stop_by {
254 obj.insert("stopBy".to_string(), stop_by.clone());
255 }
256 Value::Object(obj)
257 }
258}
259
260#[derive(Debug, Clone, Deserialize)]
268struct FixConfig {
269 template: String,
272 #[serde(default)]
275 expand_start: Option<FixExpandRule>,
276 #[serde(default)]
279 expand_end: Option<FixExpandRule>,
280}
281
282impl FixConfig {
283 fn validate(&self) -> Result<()> {
284 if let Some(expand_start) = &self.expand_start {
287 expand_start.validate("fix_config.expand_start")?;
288 }
289 if let Some(expand_end) = &self.expand_end {
290 expand_end.validate("fix_config.expand_end")?;
291 }
292 Ok(())
293 }
294
295 fn has_expansion(&self) -> bool {
296 self.expand_start.is_some() || self.expand_end.is_some()
297 }
298}
299
300#[derive(Debug, Clone, Deserialize)]
301struct StructuralSearchRequest {
302 #[serde(default)]
303 workflow: StructuralWorkflow,
304 #[serde(default)]
305 pattern: Option<String>,
306 #[serde(default)]
307 kind: Option<String>,
308 #[serde(default)]
309 path: Option<String>,
310 #[serde(default)]
311 config_path: Option<String>,
312 #[serde(default)]
313 filter: Option<String>,
314 #[serde(default)]
315 lang: Option<String>,
316 #[serde(default)]
317 selector: Option<String>,
318 #[serde(default)]
319 strictness: Option<StructuralStrictness>,
320 #[serde(default)]
321 debug_query: Option<DebugQueryFormat>,
322 #[serde(default)]
323 globs: Option<GlobInput>,
324 #[serde(default)]
325 context_lines: Option<usize>,
326 #[serde(default)]
327 max_results: Option<usize>,
328 #[serde(default)]
329 skip_snapshot_tests: Option<bool>,
330 #[serde(default)]
333 update_all: Option<bool>,
334 #[serde(default)]
337 interactive: Option<bool>,
338 #[serde(default)]
341 test_dir: Option<String>,
342 #[serde(default)]
345 snapshot_dir: Option<String>,
346 #[serde(default)]
349 include_off: Option<bool>,
350 #[serde(default)]
351 rewrite: Option<String>,
352 #[serde(default, rename = "fix_config")]
357 fix_config: Option<FixConfig>,
358
359 #[serde(default)]
363 regex: Option<String>,
364
365 #[serde(default, rename = "nth_child")]
370 nth_child: Option<NthChildInput>,
371
372 #[serde(default)]
376 range: Option<RangeInput>,
377
378 #[serde(default)]
381 has: Option<Box<Value>>,
382 #[serde(default)]
384 inside: Option<Box<Value>>,
385 #[serde(default)]
387 follows: Option<Box<Value>>,
388 #[serde(default)]
390 precedes: Option<Box<Value>>,
391 #[serde(default)]
393 constraints: Option<Map<String, Value>>,
394
395 #[serde(default)]
398 matches: Option<String>,
399 #[serde(default)]
401 all: Option<Vec<Value>>,
402 #[serde(default)]
404 any: Option<Vec<Value>>,
405 #[serde(default)]
407 not: Option<Box<Value>>,
408 #[serde(default)]
411 utils: Option<Map<String, Value>>,
412
413 #[serde(default)]
422 transform: Option<Map<String, Value>>,
423
424 #[serde(default)]
430 severities: Option<Vec<String>>,
431
432 #[serde(default, alias = "no-ignore")]
436 no_ignore: Option<Vec<String>>,
437
438 #[serde(default)]
441 follow: Option<bool>,
442
443 #[serde(default)]
446 threads: Option<u32>,
447
448 #[serde(default)]
452 format: Option<String>,
453
454 #[serde(default, alias = "report-style")]
457 report_style: Option<String>,
458
459 #[serde(default, alias = "before-lines")]
463 before_lines: Option<usize>,
464
465 #[serde(default, alias = "after-lines")]
469 after_lines: Option<usize>,
470
471 #[serde(default, alias = "builtin-rules")]
478 builtin_rules: Option<Vec<String>>,
479
480 #[serde(default, rename = "new_subcommand")]
483 new_subcommand: Option<String>,
484
485 #[serde(default, rename = "new_name")]
488 new_name: Option<String>,
489}
490
491impl StructuralSearchRequest {
492 fn from_args(args: &Value) -> Result<Self> {
493 reject_forbidden_args(args)?;
494
495 let mut request: Self = deserialize_tool_args(args, "structural_search")?;
496 request.normalize();
497 request.validate()?;
498
499 Ok(request)
500 }
501
502 fn normalize(&mut self) {
503 if self.workflow == StructuralWorkflow::Query
504 || self.workflow == StructuralWorkflow::Rewrite
505 || self.workflow == StructuralWorkflow::Count
506 || self.workflow == StructuralWorkflow::Apply
507 {
508 self.lang = self.normalized_or_inferred_lang();
509 }
510 }
511
512 fn validate(&self) -> Result<()> {
513 self.validate_limits()?;
514
515 match self.workflow {
516 StructuralWorkflow::Query => self.validate_query(),
517 StructuralWorkflow::Scan => self.validate_scan(),
518 StructuralWorkflow::Test => self.validate_test(),
519 StructuralWorkflow::Inspect => self.validate_inspect(),
520 StructuralWorkflow::Rewrite => self.validate_rewrite(),
521 StructuralWorkflow::Count => self.validate_query(),
522 StructuralWorkflow::Rules => self.validate_scan(),
523 StructuralWorkflow::New => self.validate_new(),
524 StructuralWorkflow::Apply => self.validate_apply(),
525 }
526 }
527
528 fn validate_limits(&self) -> Result<()> {
529 let glob_count = self.normalized_globs().len();
530 if glob_count > MAX_ALLOWED_GLOBS {
531 bail!(
532 "action='structural' accepts at most {} non-empty `globs` entries",
533 MAX_ALLOWED_GLOBS
534 );
535 }
536
537 if let Some(context_lines) = self.context_lines
538 && context_lines > MAX_ALLOWED_CONTEXT_LINES
539 {
540 bail!(
541 "action='structural' accepts at most {} `context_lines`",
542 MAX_ALLOWED_CONTEXT_LINES
543 );
544 }
545
546 if let Some(no_ignore) = &self.no_ignore {
548 for value in no_ignore {
549 let normalized = value.trim().to_ascii_lowercase();
550 if !VALID_NO_IGNORE_VALUES.contains(&normalized.as_str()) {
551 bail!(
552 "invalid `no_ignore` value `{value}`; expected one of: {}",
553 VALID_NO_IGNORE_VALUES.join(", ")
554 );
555 }
556 }
557 }
558
559 if let Some(fmt) = self.effective_format() {
561 if !VALID_FORMAT_VALUES.contains(&fmt) {
562 bail!(
563 "invalid `format` value `{}`; expected one of: {}",
564 fmt,
565 VALID_FORMAT_VALUES.join(", ")
566 );
567 }
568 }
569
570 if let Some(style) = self.effective_report_style() {
572 if !VALID_REPORT_STYLE_VALUES.contains(&style) {
573 bail!(
574 "invalid `report_style` value `{}`; expected one of: {}",
575 style,
576 VALID_REPORT_STYLE_VALUES.join(", ")
577 );
578 }
579 }
580
581 if let Some(rules) = self.effective_builtin_rules() {
583 for rule in rules {
584 let rule_name = rule.split(':').next().unwrap_or(rule);
585 if !VALID_BUILTIN_RULES.contains(&rule_name) {
586 bail!(
587 "invalid builtin rule `{rule_name}`; expected one of: {}",
588 VALID_BUILTIN_RULES.join(", ")
589 );
590 }
591 }
592 }
593
594 if self.context_lines.is_some()
596 && (self.before_lines.is_some() || self.after_lines.is_some())
597 {
598 bail!(
599 "`context_lines` is mutually exclusive with `before_lines` and `after_lines`; use one or the other"
600 );
601 }
602
603 Ok(())
604 }
605
606 fn validate_query(&self) -> Result<()> {
607 let has_relational = self.has.is_some()
608 || self.inside.is_some()
609 || self.follows.is_some()
610 || self.precedes.is_some();
611
612 let has_composite = self.matches.is_some() || self.all.is_some() || self.any.is_some();
613
614 if self.pattern().is_none()
615 && self.kind().is_none()
616 && self.regex_pattern().is_none()
617 && self.nth_child.is_none()
618 && self.range.is_none()
619 && !has_relational
620 && !has_composite
621 && self.constraints.is_none()
622 {
623 bail!(
624 "action='structural' workflow='query' requires a non-empty `pattern`, `kind`, \
625 `regex`, `nth_child`, `range`, `has`, `inside`, `follows`, `precedes`, \
626 `matches`, `all`, or `any`"
627 );
628 }
629
630 self.reject_present("config_path", self.config_path.as_deref())?;
631 self.reject_present("filter", self.filter.as_deref())?;
632 self.reject_flag("skip_snapshot_tests", self.skip_snapshot_tests)?;
633 self.reject_flag("update_all", self.update_all)?;
634 self.reject_flag("interactive", self.interactive)?;
635 self.reject_present("test_dir", self.test_dir.as_deref())?;
636 self.reject_present("snapshot_dir", self.snapshot_dir.as_deref())?;
637 self.reject_flag("include_off", self.include_off)?;
638
639 if self.debug_query.is_some() && self.lang.as_deref().is_none_or(str::is_empty) {
640 bail!(DEBUG_QUERY_LANG_HINT);
641 }
642
643 if self.regex_pattern().is_some() && self.lang.as_deref().is_none_or(str::is_empty) {
644 bail!("action='structural' with `regex` requires `lang` to be set");
645 }
646
647 if has_relational && self.lang.as_deref().is_none_or(str::is_empty) {
648 bail!(
649 "action='structural' with relational rules (`has`/`inside`/`follows`/`precedes`) requires `lang` to be set"
650 );
651 }
652
653 if has_composite && self.lang.as_deref().is_none_or(str::is_empty) {
654 bail!(
655 "action='structural' with composite rules (`matches`/`all`/`any`) requires `lang` to be set"
656 );
657 }
658
659 if self.transform.is_some() && self.lang.as_deref().is_none_or(str::is_empty) {
660 bail!(
661 "action='structural' with `transform` requires `lang` to be set because transform \
662 definitions are emitted into YAML rules that target a specific language"
663 );
664 }
665
666 self.validate_nth_child_position()?;
667
668 Ok(())
669 }
670
671 fn validate_nth_child_position(&self) -> Result<()> {
672 if let Some(ref nth) = self.nth_child {
673 match nth {
674 NthChildInput::Number(n) => {
675 if *n == 0 {
676 bail!("`nth_child` position is 1-based; 0 is not valid");
677 }
678 }
679 NthChildInput::Object(obj) => {
680 if let Some(pos) = obj.position.as_u64() {
681 if pos == 0 {
682 bail!("`nth_child` position is 1-based; 0 is not valid");
683 }
684 }
685 }
686 NthChildInput::Formula(_) => {
687 }
689 }
690 }
691 Ok(())
692 }
693
694 fn validate_scan(&self) -> Result<()> {
695 self.reject_present("pattern", self.pattern.as_deref())?;
696 self.reject_present("kind", self.kind.as_deref())?;
697 self.reject_present("lang", self.lang.as_deref())?;
698 self.reject_present("selector", self.selector.as_deref())?;
699 self.reject_present(
700 "strictness",
701 self.strictness.as_ref().map(StructuralStrictness::as_str),
702 )?;
703 self.reject_present(
704 "debug_query",
705 self.debug_query.as_ref().map(DebugQueryFormat::as_str),
706 )?;
707 self.reject_present("regex", self.regex.as_deref())?;
708 self.reject_flag("skip_snapshot_tests", self.skip_snapshot_tests)?;
709 self.reject_flag("update_all", self.update_all)?;
710 self.reject_flag("interactive", self.interactive)?;
711 self.reject_present("test_dir", self.test_dir.as_deref())?;
712 self.reject_present("snapshot_dir", self.snapshot_dir.as_deref())?;
713 self.reject_flag("include_off", self.include_off)?;
714 self.reject_nth_child()?;
715 self.reject_range()?;
716 self.reject_composite_rules()?;
717 self.reject_transform()?;
718 Ok(())
719 }
720
721 fn validate_test(&self) -> Result<()> {
722 self.reject_present("pattern", self.pattern.as_deref())?;
723 self.reject_present("kind", self.kind.as_deref())?;
724 self.reject_present("path", self.path.as_deref())?;
725 self.reject_present("lang", self.lang.as_deref())?;
726 self.reject_present("selector", self.selector.as_deref())?;
727 self.reject_present(
728 "strictness",
729 self.strictness.as_ref().map(StructuralStrictness::as_str),
730 )?;
731 self.reject_present(
732 "debug_query",
733 self.debug_query.as_ref().map(DebugQueryFormat::as_str),
734 )?;
735 self.reject_present("regex", self.regex.as_deref())?;
736 self.reject_nth_child()?;
737 self.reject_range()?;
738 self.reject_composite_rules()?;
739 self.reject_transform()?;
740 if self.globs.is_some() {
741 bail!(
742 "action='structural' workflow='test' does not accept `globs`; use `config_path`, `filter`, and `skip_snapshot_tests`."
743 );
744 }
745 if self.context_lines.is_some() {
746 bail!(
747 "action='structural' workflow='test' does not accept `context_lines`; use `config_path`, `filter`, and `skip_snapshot_tests`."
748 );
749 }
750 if self.max_results.is_some() {
751 bail!(
752 "action='structural' workflow='test' does not accept `max_results`; use `config_path`, `filter`, and `skip_snapshot_tests`."
753 );
754 }
755 Ok(())
756 }
757
758 fn validate_inspect(&self) -> Result<()> {
759 self.reject_present("pattern", self.pattern.as_deref())?;
760 self.reject_present("kind", self.kind.as_deref())?;
761 self.reject_present("lang", self.lang.as_deref())?;
762 self.reject_present("selector", self.selector.as_deref())?;
763 self.reject_present(
764 "strictness",
765 self.strictness.as_ref().map(StructuralStrictness::as_str),
766 )?;
767 self.reject_present(
768 "debug_query",
769 self.debug_query.as_ref().map(DebugQueryFormat::as_str),
770 )?;
771 self.reject_present("filter", self.filter.as_deref())?;
772 self.reject_present("regex", self.regex.as_deref())?;
773 self.reject_flag("skip_snapshot_tests", self.skip_snapshot_tests)?;
774 self.reject_flag("update_all", self.update_all)?;
775 self.reject_flag("interactive", self.interactive)?;
776 self.reject_present("test_dir", self.test_dir.as_deref())?;
777 self.reject_present("snapshot_dir", self.snapshot_dir.as_deref())?;
778 self.reject_flag("include_off", self.include_off)?;
779 self.reject_nth_child()?;
780 self.reject_range()?;
781 self.reject_composite_rules()?;
782 self.reject_transform()?;
783 if self.globs.is_some() {
784 bail!(
785 "action='structural' workflow='inspect' does not accept `globs`; use `config_path` and `path`."
786 );
787 }
788 if self.context_lines.is_some() {
789 bail!(
790 "action='structural' workflow='inspect' does not accept `context_lines`; use `config_path` and `path`."
791 );
792 }
793 if self.max_results.is_some() {
794 bail!(
795 "action='structural' workflow='inspect' does not accept `max_results`; use `config_path` and `path`."
796 );
797 }
798 Ok(())
799 }
800
801 fn validate_rewrite(&self) -> Result<()> {
802 if self.pattern().is_none() && self.regex_pattern().is_none() {
803 bail!(
804 "action='structural' workflow='rewrite' requires a non-empty `pattern` or `regex`"
805 );
806 }
807
808 let has_string_rewrite = self.rewrite_text().is_some();
809 let has_fix_config = self.fix_config.is_some();
810
811 if !has_string_rewrite && !has_fix_config {
812 bail!(
813 "action='structural' workflow='rewrite' requires a non-empty `rewrite` string \
814 or a `fix_config` object with `template` and optional `expand_start`/`expand_end`"
815 );
816 }
817
818 if has_fix_config {
819 self.fix_config
820 .as_ref()
821 .expect("fix_config validated present")
822 .validate()?;
823 }
824
825 self.reject_present("config_path", self.config_path.as_deref())?;
826 self.reject_present("filter", self.filter.as_deref())?;
827 self.reject_flag("skip_snapshot_tests", self.skip_snapshot_tests)?;
828 self.reject_flag("update_all", self.update_all)?;
829 self.reject_flag("interactive", self.interactive)?;
830 self.reject_nth_child()?;
831 self.reject_range()?;
832 self.reject_relational_rules()?;
833 self.reject_composite_rules()?;
834
835 if self.debug_query.is_some() && self.lang.as_deref().is_none_or(str::is_empty) {
836 bail!(DEBUG_QUERY_LANG_HINT);
837 }
838
839 if self.regex_pattern().is_some() && self.lang.as_deref().is_none_or(str::is_empty) {
840 bail!("action='structural' with `regex` requires `lang` to be set");
841 }
842
843 Ok(())
844 }
845
846 fn validate_new(&self) -> Result<()> {
847 let subcommand = self
848 .new_subcommand
849 .as_deref()
850 .map(str::trim)
851 .filter(|s| !s.is_empty());
852 let subcommand = subcommand.ok_or_else(|| {
853 anyhow!(
854 "action='structural' workflow='new' requires `new_subcommand` \
855 (one of: project, rule, test, util)"
856 )
857 })?;
858
859 if !matches!(subcommand, "project" | "rule" | "test" | "util") {
860 bail!(
861 "action='structural' workflow='new' `new_subcommand` must be one of \
862 project, rule, test, util; got `{subcommand}`"
863 );
864 }
865
866 if subcommand != "project" {
868 let name = self
869 .new_name
870 .as_deref()
871 .map(str::trim)
872 .filter(|s| !s.is_empty());
873 if name.is_none() {
874 bail!(
875 "action='structural' workflow='new' subcommand `{subcommand}` \
876 requires `new_name`"
877 );
878 }
879 }
880
881 if subcommand == "rule" || subcommand == "util" {
883 if self.lang.as_deref().is_none_or(str::is_empty) {
884 bail!(
885 "action='structural' workflow='new' subcommand `{subcommand}` \
886 requires `lang`"
887 );
888 }
889 }
890
891 self.reject_present("pattern", self.pattern.as_deref())?;
893 self.reject_present("kind", self.kind.as_deref())?;
894 self.reject_present("selector", self.selector.as_deref())?;
895 self.reject_present(
896 "strictness",
897 self.strictness.as_ref().map(StructuralStrictness::as_str),
898 )?;
899 self.reject_present(
900 "debug_query",
901 self.debug_query.as_ref().map(DebugQueryFormat::as_str),
902 )?;
903 self.reject_present("filter", self.filter.as_deref())?;
904 self.reject_present("regex", self.regex.as_deref())?;
905 self.reject_present("rewrite", self.rewrite.as_deref())?;
906 self.reject_flag("skip_snapshot_tests", self.skip_snapshot_tests)?;
907 self.reject_flag("update_all", self.update_all)?;
908 self.reject_flag("interactive", self.interactive)?;
909 self.reject_nth_child()?;
910 self.reject_range()?;
911 self.reject_relational_rules()?;
912 self.reject_composite_rules()?;
913 self.reject_transform()?;
914
915 Ok(())
916 }
917
918 fn validate_apply(&self) -> Result<()> {
919 if self.pattern().is_none() && self.regex_pattern().is_none() {
920 bail!("action='structural' workflow='apply' requires a non-empty `pattern` or `regex`");
921 }
922
923 let has_string_rewrite = self.rewrite_text().is_some();
924 let has_fix_config = self.fix_config.is_some();
925
926 if !has_string_rewrite && !has_fix_config {
927 bail!(
928 "action='structural' workflow='apply' requires a non-empty `rewrite` string \
929 or a `fix_config` object with `template` and optional `expand_start`/`expand_end`"
930 );
931 }
932
933 if has_fix_config {
934 self.fix_config
935 .as_ref()
936 .expect("fix_config validated present")
937 .validate()?;
938 }
939
940 self.reject_present("config_path", self.config_path.as_deref())?;
941 self.reject_present("filter", self.filter.as_deref())?;
942 self.reject_flag("skip_snapshot_tests", self.skip_snapshot_tests)?;
943 self.reject_flag("update_all", self.update_all)?;
944 self.reject_flag("interactive", self.interactive)?;
945 self.reject_nth_child()?;
946 self.reject_range()?;
947 self.reject_relational_rules()?;
948 self.reject_composite_rules()?;
949
950 if self.debug_query.is_some() && self.lang.as_deref().is_none_or(str::is_empty) {
951 bail!(DEBUG_QUERY_LANG_HINT);
952 }
953
954 if self.regex_pattern().is_some() && self.lang.as_deref().is_none_or(str::is_empty) {
955 bail!("action='structural' with `regex` requires `lang` to be set");
956 }
957
958 Ok(())
959 }
960
961 fn reject_present(&self, field: &str, value: Option<&str>) -> Result<()> {
962 if value.is_some_and(|value| !value.trim().is_empty()) {
963 bail!(
964 "action='structural' workflow='{}' does not accept `{field}`.",
965 self.workflow.as_str()
966 );
967 }
968 Ok(())
969 }
970
971 fn reject_flag(&self, field: &str, value: Option<bool>) -> Result<()> {
972 if value.is_some() {
973 bail!(
974 "action='structural' workflow='{}' does not accept `{field}`.",
975 self.workflow.as_str()
976 );
977 }
978 Ok(())
979 }
980
981 fn reject_nth_child(&self) -> Result<()> {
982 if self.nth_child.is_some() {
983 bail!(
984 "action='structural' workflow='{}' does not accept `nth_child`.",
985 self.workflow.as_str()
986 );
987 }
988 Ok(())
989 }
990
991 fn reject_range(&self) -> Result<()> {
992 if self.range.is_some() {
993 bail!(
994 "action='structural' workflow='{}' does not accept `range`.",
995 self.workflow.as_str()
996 );
997 }
998 Ok(())
999 }
1000
1001 fn reject_relational_rules(&self) -> Result<()> {
1002 if self.has.is_some()
1003 || self.inside.is_some()
1004 || self.follows.is_some()
1005 || self.precedes.is_some()
1006 || self.constraints.is_some()
1007 {
1008 bail!(
1009 "action='structural' workflow='{}' does not accept relational rules (`has`, `inside`, `follows`, `precedes`) or `constraints`.",
1010 self.workflow.as_str()
1011 );
1012 }
1013 Ok(())
1014 }
1015
1016 fn reject_composite_rules(&self) -> Result<()> {
1017 if self.matches.is_some()
1018 || self.all.is_some()
1019 || self.any.is_some()
1020 || self.not.is_some()
1021 || self.utils.is_some()
1022 {
1023 bail!(
1024 "action='structural' workflow='{}' does not accept composite rules \
1025 (`matches`, `all`, `any`, `not`) or `utils`.",
1026 self.workflow.as_str()
1027 );
1028 }
1029 Ok(())
1030 }
1031
1032 fn reject_transform(&self) -> Result<()> {
1033 if self.transform.is_some() {
1034 bail!(
1035 "action='structural' workflow='{}' does not accept `transform`; \
1036 `transform` is only valid for `query`, `count`, and `rewrite` workflows \
1037 that use YAML rule generation.",
1038 self.workflow.as_str()
1039 );
1040 }
1041 Ok(())
1042 }
1043
1044 fn requested_path(&self) -> &str {
1045 self.path
1046 .as_deref()
1047 .filter(|path| !path.trim().is_empty())
1048 .unwrap_or(".")
1049 }
1050
1051 fn requested_config_path(&self) -> &str {
1052 self.config_path
1053 .as_deref()
1054 .filter(|path| !path.trim().is_empty())
1055 .unwrap_or(DEFAULT_AST_GREP_CONFIG_PATH)
1056 }
1057
1058 fn pattern(&self) -> Option<&str> {
1059 self.pattern
1060 .as_deref()
1061 .map(str::trim)
1062 .filter(|pattern| !pattern.is_empty())
1063 }
1064
1065 fn kind(&self) -> Option<&str> {
1066 self.kind
1067 .as_deref()
1068 .map(str::trim)
1069 .filter(|kind| !kind.is_empty())
1070 }
1071
1072 fn regex_pattern(&self) -> Option<&str> {
1073 self.regex
1074 .as_deref()
1075 .map(str::trim)
1076 .filter(|r| !r.is_empty())
1077 }
1078
1079 fn filter(&self) -> Option<&str> {
1080 self.filter
1081 .as_deref()
1082 .map(str::trim)
1083 .filter(|value| !value.is_empty())
1084 }
1085
1086 fn rewrite_text(&self) -> Option<&str> {
1087 self.rewrite
1088 .as_deref()
1089 .map(str::trim)
1090 .filter(|value| !value.is_empty())
1091 }
1092
1093 fn effective_rewrite_template(&self) -> Option<&str> {
1096 if let Some(rewrite) = self.rewrite_text() {
1097 return Some(rewrite);
1098 }
1099 self.fix_config
1100 .as_ref()
1101 .map(|fc| fc.template.trim())
1102 .filter(|t| !t.is_empty())
1103 }
1104
1105 fn normalized_globs(&self) -> Vec<String> {
1106 self.globs
1107 .clone()
1108 .map(GlobInput::into_vec)
1109 .unwrap_or_default()
1110 .into_iter()
1111 .map(|glob| glob.trim().to_string())
1112 .filter(|glob| !glob.is_empty())
1113 .collect()
1114 }
1115
1116 fn effective_max_results(&self) -> usize {
1117 self.max_results
1118 .unwrap_or(DEFAULT_MAX_RESULTS)
1119 .clamp(1, MAX_ALLOWED_RESULTS)
1120 }
1121
1122 fn normalized_or_inferred_lang(&self) -> Option<String> {
1123 if let Some(lang) = self
1124 .lang
1125 .as_deref()
1126 .map(str::trim)
1127 .filter(|value| !value.is_empty())
1128 {
1129 return Some(
1130 AstGrepLanguage::from_user_value(lang)
1131 .map(|language| language.as_str().to_string())
1132 .unwrap_or_else(|| lang.to_string()),
1133 );
1134 }
1135
1136 if let Some(language) = AstGrepLanguage::infer_from_path_str(self.requested_path()) {
1137 return Some(language.as_str().to_string());
1138 }
1139
1140 let inferred = match self.globs.as_ref() {
1141 Some(GlobInput::Single(glob)) => {
1142 AstGrepLanguage::infer_from_positive_globs([glob.as_str()])
1143 }
1144 Some(GlobInput::Multiple(globs)) => {
1145 AstGrepLanguage::infer_from_positive_globs(globs.iter().map(String::as_str))
1146 }
1147 None => None,
1148 };
1149
1150 inferred.map(|language| language.as_str().to_string())
1151 }
1152
1153 fn effective_severities(&self) -> Option<Vec<&str>> {
1154 self.severities.as_ref().map(|v| {
1155 v.iter()
1156 .map(|s| s.trim().to_ascii_lowercase())
1157 .filter(|s| !s.is_empty())
1158 .map(|s| match s.as_str() {
1159 "error" => "error",
1160 "warning" | "warn" => "warning",
1161 "info" => "info",
1162 "hint" => "hint",
1163 _ => "unknown",
1164 })
1165 .collect()
1166 })
1167 }
1168
1169 fn effective_no_ignore(&self) -> Option<&[String]> {
1170 self.no_ignore
1171 .as_ref()
1172 .filter(|v| !v.is_empty())
1173 .map(|v| v.as_slice())
1174 }
1175
1176 fn effective_follow(&self) -> bool {
1177 self.follow == Some(true)
1178 }
1179
1180 fn effective_threads(&self) -> Option<u32> {
1181 self.threads.map(|t| t.min(MAX_THREADS))
1182 }
1183
1184 fn effective_format(&self) -> Option<&str> {
1185 self.format
1186 .as_deref()
1187 .map(str::trim)
1188 .filter(|f| !f.is_empty())
1189 }
1190
1191 fn effective_report_style(&self) -> Option<&str> {
1192 self.report_style
1193 .as_deref()
1194 .map(str::trim)
1195 .filter(|r| !r.is_empty())
1196 }
1197
1198 fn effective_before_lines(&self) -> Option<usize> {
1199 self.before_lines
1200 .filter(|&n| n <= MAX_ALLOWED_CONTEXT_LINES)
1201 }
1202
1203 fn effective_after_lines(&self) -> Option<usize> {
1204 self.after_lines.filter(|&n| n <= MAX_ALLOWED_CONTEXT_LINES)
1205 }
1206
1207 fn effective_builtin_rules(&self) -> Option<&[String]> {
1208 self.builtin_rules
1209 .as_ref()
1210 .filter(|v| !v.is_empty())
1211 .map(|v| v.as_slice())
1212 }
1213}
1214
1215#[derive(Debug, Clone, Deserialize)]
1216struct AstGrepMetaVar {
1217 text: String,
1218 range: AstGrepRange,
1219}
1220
1221#[derive(Debug, Clone, Deserialize, Default)]
1222struct AstGrepMetaVariables {
1223 #[serde(default)]
1224 single: BTreeMap<String, AstGrepMetaVar>,
1225 #[serde(default)]
1226 multi: BTreeMap<String, Vec<AstGrepMetaVar>>,
1227 #[serde(default)]
1228 transformed: BTreeMap<String, String>,
1229}
1230
1231#[derive(Debug, Clone, Deserialize)]
1232struct AstGrepMatch {
1233 file: String,
1234 text: String,
1235 #[serde(default)]
1236 lines: Option<String>,
1237 #[serde(default)]
1238 language: Option<String>,
1239 range: AstGrepRange,
1240 #[serde(default, rename = "metaVariables")]
1241 meta_variables: Option<AstGrepMetaVariables>,
1242}
1243
1244#[derive(Debug, Clone, Deserialize)]
1245struct AstGrepRewriteMatch {
1246 file: String,
1247 text: String,
1248 #[serde(default)]
1249 lines: Option<String>,
1250 #[serde(default)]
1251 language: Option<String>,
1252 range: AstGrepRange,
1253 #[serde(default, rename = "metaVariables")]
1254 meta_variables: Option<AstGrepMetaVariables>,
1255 #[serde(default)]
1256 replacement: Option<String>,
1257 #[serde(default, rename = "replacementOffsets")]
1258 replacement_offsets: Option<AstGrepByteOffset>,
1259}
1260
1261#[derive(Debug, Clone, Deserialize)]
1262struct AstGrepLabel {
1263 text: String,
1264 range: AstGrepRange,
1265 #[serde(default)]
1266 source: Option<String>,
1267}
1268
1269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1278enum AstGrepSeverity {
1279 Error,
1280 Warning,
1281 Info,
1282 Hint,
1283 Off,
1284}
1285
1286impl AstGrepSeverity {
1287 fn as_str(self) -> &'static str {
1288 match self {
1289 Self::Error => "error",
1290 Self::Warning => "warning",
1291 Self::Info => "info",
1292 Self::Hint => "hint",
1293 Self::Off => "off",
1294 }
1295 }
1296
1297 fn from_str_normalized(s: &str) -> Option<Self> {
1298 match s.trim().to_ascii_lowercase().as_str() {
1299 "error" => Some(Self::Error),
1300 "warning" | "warn" => Some(Self::Warning),
1301 "info" => Some(Self::Info),
1302 "hint" => Some(Self::Hint),
1303 "off" | "none" => Some(Self::Off),
1304 _ => None,
1305 }
1306 }
1307}
1308
1309impl fmt::Display for AstGrepSeverity {
1310 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1311 f.write_str(self.as_str())
1312 }
1313}
1314
1315impl<'de> Deserialize<'de> for AstGrepSeverity {
1316 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1317 let s = String::deserialize(deserializer)?;
1318 AstGrepSeverity::from_str_normalized(&s).ok_or_else(|| {
1319 serde::de::Error::custom(format!(
1320 "unknown severity `{s}`; expected error, warning, info, hint, or off"
1321 ))
1322 })
1323 }
1324}
1325
1326#[derive(Debug, Clone, Deserialize)]
1327struct AstGrepScanFinding {
1328 file: String,
1329 text: String,
1330 #[serde(default)]
1331 lines: Option<String>,
1332 #[serde(default)]
1333 language: Option<String>,
1334 range: AstGrepRange,
1335 #[serde(default, rename = "ruleId")]
1336 rule_id: Option<String>,
1337 #[serde(default)]
1338 severity: Option<AstGrepSeverity>,
1339 #[serde(default)]
1340 message: Option<String>,
1341 #[serde(default)]
1342 note: Option<String>,
1343 #[serde(default)]
1344 metadata: Option<Value>,
1345 #[serde(default)]
1346 labels: Vec<AstGrepLabel>,
1347}
1348
1349#[derive(Debug, Clone, Deserialize)]
1350struct AstGrepByteOffset {
1351 start: usize,
1352 end: usize,
1353}
1354
1355#[derive(Debug, Clone, Deserialize)]
1356struct AstGrepRange {
1357 start: AstGrepPoint,
1358 end: AstGrepPoint,
1359 #[serde(default, rename = "byteOffset")]
1360 byte_offset: Option<AstGrepByteOffset>,
1361}
1362
1363#[derive(Debug, Clone, Deserialize)]
1364struct AstGrepPoint {
1365 line: usize,
1366 column: usize,
1367}
1368
1369pub async fn execute_structural_search(workspace_root: &Path, args: Value) -> Result<Value> {
1370 let request = StructuralSearchRequest::from_args(&args)?;
1371 if request.workflow == StructuralWorkflow::Inspect {
1373 return execute_structural_inspect(workspace_root, &request).await;
1374 }
1375 if request.workflow == StructuralWorkflow::Rules {
1376 return execute_structural_rules(workspace_root, &request).await;
1377 }
1378 let ast_grep = resolve_ast_grep_binary_path().map_err(|reason| {
1379 anyhow!(
1380 "Structural search requires ast-grep (`sg`). {reason}. Install it with `{AST_GREP_INSTALL_COMMAND}`."
1381 )
1382 })?;
1383 match request.workflow {
1384 StructuralWorkflow::Query => {
1385 execute_structural_query(workspace_root, &request, &ast_grep).await
1386 }
1387 StructuralWorkflow::Scan => {
1388 execute_structural_scan(workspace_root, &request, &ast_grep).await
1389 }
1390 StructuralWorkflow::Test => {
1391 execute_structural_test(workspace_root, &request, &ast_grep).await
1392 }
1393 StructuralWorkflow::Inspect => unreachable!("handled above"),
1394 StructuralWorkflow::Rewrite => {
1395 execute_structural_rewrite(workspace_root, &request, &ast_grep).await
1396 }
1397 StructuralWorkflow::Count => {
1398 execute_structural_count(workspace_root, &request, &ast_grep).await
1399 }
1400 StructuralWorkflow::Rules => unreachable!("handled above"),
1401 StructuralWorkflow::New => {
1402 execute_structural_new(workspace_root, &request, &ast_grep).await
1403 }
1404 StructuralWorkflow::Apply => {
1405 execute_structural_apply(workspace_root, &request, &ast_grep).await
1406 }
1407 }
1408}
1409
1410async fn execute_structural_query(
1411 workspace_root: &Path,
1412 request: &StructuralSearchRequest,
1413 ast_grep: &Path,
1414) -> Result<Value> {
1415 let search_path = resolve_search_path(workspace_root, request.requested_path())?;
1416 let globs = request.normalized_globs();
1417 if request.pattern().is_some() {
1418 if let Some(hint) = preflight_parseable_pattern(request)? {
1419 return Ok(build_fragment_result(
1420 request,
1421 &search_path.display_path,
1422 hint,
1423 ));
1424 }
1425 }
1426 let command_path = search_path.command_arg.clone();
1427
1428 if let Some(debug_query) = &request.debug_query {
1429 let mut command = ast_grep_command(ast_grep, workspace_root, "run");
1430 command
1431 .arg(format!(
1432 "--pattern={}",
1433 request.pattern().expect("query pattern validated")
1434 ))
1435 .arg("--lang")
1436 .arg(
1437 request
1438 .lang
1439 .as_deref()
1440 .expect("validated lang for debug query"),
1441 )
1442 .arg(format!("--debug-query={}", debug_query.as_str()))
1443 .arg(&command_path);
1444
1445 let output =
1446 run_ast_grep_command(&mut command, "failed to run ast-grep debug query").await?;
1447
1448 if !output.status.success() {
1449 bail!(
1450 "{}",
1451 format_ast_grep_failure(
1452 "ast-grep debug query failed",
1453 stderr_or_stdout(&output.stderr, &output.stdout)
1454 )
1455 );
1456 }
1457
1458 return Ok(build_debug_query_result(
1459 request,
1460 &search_path.display_path,
1461 debug_query,
1462 &output.stdout,
1463 ));
1464 }
1465
1466 if request.has.is_some()
1470 || request.inside.is_some()
1471 || request.follows.is_some()
1472 || request.precedes.is_some()
1473 || request.constraints.is_some()
1474 || request.matches.is_some()
1475 || request.all.is_some()
1476 || request.any.is_some()
1477 || request.not.is_some()
1478 || request.utils.is_some()
1479 || request.transform.is_some()
1480 {
1481 return execute_atomic_rule_query(workspace_root, request, ast_grep, &search_path).await;
1482 }
1483
1484 let mut command = ast_grep_command(ast_grep, workspace_root, "run");
1485 if let Some(pattern) = request.pattern() {
1486 command.arg(format!("--pattern={pattern}"));
1487 }
1488 command.arg("--json=compact").arg("--color=never");
1489
1490 if let Some(lang) = request
1491 .lang
1492 .as_deref()
1493 .filter(|lang| !lang.trim().is_empty())
1494 {
1495 command.arg("--lang").arg(lang);
1496 }
1497 if let Some(kind) = request.kind() {
1498 command.arg("--kind").arg(kind);
1499 }
1500 if let Some(selector) = request
1501 .selector
1502 .as_deref()
1503 .filter(|selector| !selector.trim().is_empty())
1504 {
1505 command.arg("--selector").arg(selector);
1506 }
1507 if let Some(strictness) = &request.strictness {
1508 command.arg("--strictness").arg(strictness.as_str());
1509 }
1510 apply_context_and_globs(
1511 &mut command,
1512 request.context_lines,
1513 request.effective_before_lines(),
1514 request.effective_after_lines(),
1515 &globs,
1516 );
1517 if request.effective_follow() {
1518 command.arg("--follow");
1519 }
1520 if let Some(no_ignore) = request.effective_no_ignore() {
1521 for value in no_ignore {
1522 command.arg("--no-ignore").arg(value.trim());
1523 }
1524 }
1525 command.arg(&command_path);
1526
1527 let output =
1528 run_ast_grep_command(&mut command, "failed to run ast-grep structural search").await?;
1529
1530 let no_matches = output.status.code() == Some(1);
1531 if !output.status.success() && !no_matches {
1532 bail!(
1533 "{}",
1534 format_ast_grep_failure(
1535 "ast-grep structural search failed",
1536 stderr_or_stdout(&output.stderr, &output.stdout)
1537 )
1538 );
1539 }
1540
1541 let matches = if no_matches && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
1542 Vec::new()
1543 } else {
1544 parse_compact_matches(&output.stdout)?
1545 };
1546 Ok(build_query_result(
1547 request,
1548 &search_path.display_path,
1549 matches,
1550 ))
1551}
1552
1553async fn execute_structural_scan(
1554 workspace_root: &Path,
1555 request: &StructuralSearchRequest,
1556 ast_grep: &Path,
1557) -> Result<Value> {
1558 let search_path = resolve_search_path(workspace_root, request.requested_path())?;
1559 let config_path =
1560 resolve_config_path(workspace_root, request.requested_config_path(), true).await?;
1561 let globs = request.normalized_globs();
1562
1563 let use_ci_format = request.effective_format().is_some();
1566
1567 let mut command = ast_grep_command(ast_grep, workspace_root, "scan");
1568 command
1569 .arg("--config")
1570 .arg(&config_path.command_arg)
1571 .arg("--color=never");
1572
1573 if use_ci_format {
1574 command.arg(format!("--format={}", request.effective_format().unwrap()));
1575 } else {
1576 command.arg("--json=stream").arg("--include-metadata");
1577 }
1578
1579 if let Some(filter) = request.filter() {
1580 command.arg("--filter").arg(filter);
1581 }
1582
1583 if let Some(no_ignore) = request.effective_no_ignore() {
1585 for value in no_ignore {
1586 command.arg("--no-ignore").arg(value.trim());
1587 }
1588 }
1589
1590 if request.effective_follow() {
1592 command.arg("--follow");
1593 }
1594
1595 if let Some(threads) = request.effective_threads() {
1597 command.arg("--threads").arg(threads.to_string());
1598 }
1599
1600 if let Some(style) = request.effective_report_style() {
1602 command.arg(format!("--report-style={style}"));
1603 }
1604
1605 if let Some(builtin_rules) = request.effective_builtin_rules() {
1607 for rule_entry in builtin_rules {
1608 let (rule_name, severity) = match rule_entry.split_once(':') {
1609 Some((name, sev)) => (name.trim(), sev.trim()),
1610 None => (rule_entry.trim(), ""),
1611 };
1612 if severity.is_empty() {
1613 command.arg(format!("--hint={rule_name}"));
1615 } else {
1616 command.arg(format!("--{severity}={rule_name}"));
1617 }
1618 }
1619 }
1620
1621 apply_context_and_globs(
1622 &mut command,
1623 request.context_lines,
1624 request.effective_before_lines(),
1625 request.effective_after_lines(),
1626 &globs,
1627 );
1628 command.arg(&search_path.command_arg);
1629
1630 let output =
1631 run_ast_grep_command(&mut command, "failed to run ast-grep structural scan").await?;
1632
1633 let findings_with_error_exit = output.status.code() == Some(1);
1634 if !output.status.success() && !findings_with_error_exit {
1635 bail!(
1636 "{}",
1637 format_ast_grep_failure(
1638 "ast-grep structural scan failed",
1639 stderr_or_stdout(&output.stderr, &output.stdout)
1640 )
1641 );
1642 }
1643
1644 if use_ci_format {
1647 let raw = String::from_utf8_lossy(&output.stdout);
1648 return Ok(json!({
1649 "backend": "ast-grep",
1650 "workflow": "scan",
1651 "config_path": config_path.display_path,
1652 "path": search_path.display_path,
1653 "format": request.effective_format(),
1654 "output": truncate_auxiliary_output(&raw),
1655 "exit_code": output.status.code(),
1656 }));
1657 }
1658
1659 let findings =
1660 if findings_with_error_exit && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
1661 Vec::new()
1662 } else {
1663 parse_stream_findings(&output.stdout)?
1664 };
1665 Ok(build_scan_result(
1666 request,
1667 &search_path.display_path,
1668 &config_path.display_path,
1669 findings,
1670 ))
1671}
1672
1673async fn execute_structural_test(
1674 workspace_root: &Path,
1675 request: &StructuralSearchRequest,
1676 ast_grep: &Path,
1677) -> Result<Value> {
1678 let config_path =
1679 resolve_config_path(workspace_root, request.requested_config_path(), true).await?;
1680
1681 let mut command = ast_grep_command(ast_grep, workspace_root, "test");
1682 command.arg("--config").arg(&config_path.command_arg);
1683
1684 if let Some(filter) = request.filter() {
1685 command.arg("--filter").arg(filter);
1686 }
1687 if request.skip_snapshot_tests == Some(true) {
1688 command.arg("--skip-snapshot-tests");
1689 }
1690 if request.update_all == Some(true) {
1691 command.arg("--update-all");
1692 }
1693 if request.interactive == Some(true) {
1694 command.arg("--interactive");
1695 }
1696 if let Some(test_dir) = request.test_dir.as_deref().filter(|s| !s.trim().is_empty()) {
1697 command.arg("--test-dir").arg(test_dir);
1698 }
1699 if let Some(snapshot_dir) = request
1700 .snapshot_dir
1701 .as_deref()
1702 .filter(|s| !s.trim().is_empty())
1703 {
1704 command.arg("--snapshot-dir").arg(snapshot_dir);
1705 }
1706 if request.include_off == Some(true) {
1707 command.arg("--include-off");
1708 }
1709
1710 let output =
1711 run_ast_grep_command(&mut command, "failed to run ast-grep structural test").await?;
1712
1713 Ok(build_test_result(
1714 &config_path.display_path,
1715 output.status.success(),
1716 &output.stdout,
1717 &output.stderr,
1718 ))
1719}
1720
1721async fn execute_structural_rewrite(
1722 workspace_root: &Path,
1723 request: &StructuralSearchRequest,
1724 ast_grep: &Path,
1725) -> Result<Value> {
1726 let search_path = resolve_search_path(workspace_root, request.requested_path())?;
1727 let globs = request.normalized_globs();
1728
1729 if let Some(hint) = preflight_parseable_pattern(request)? {
1730 return Ok(build_rewrite_fragment_result(
1731 request,
1732 &search_path.display_path,
1733 hint,
1734 ));
1735 }
1736
1737 let needs_yaml_rewrite = request
1740 .fix_config
1741 .as_ref()
1742 .is_some_and(|fc| fc.has_expansion())
1743 || request.transform.is_some();
1744
1745 if needs_yaml_rewrite {
1746 return execute_fixconfig_rewrite(workspace_root, request, ast_grep, &search_path).await;
1747 }
1748
1749 let command_path = search_path.command_arg.clone();
1751 let mut command = ast_grep_command(ast_grep, workspace_root, "run");
1752 command
1753 .arg(format!(
1754 "--pattern={}",
1755 request.pattern().expect("rewrite pattern validated")
1756 ))
1757 .arg(format!(
1758 "--rewrite={}",
1759 request
1760 .effective_rewrite_template()
1761 .expect("rewrite template validated")
1762 ))
1763 .arg("--json=compact")
1764 .arg("--color=never");
1765
1766 if let Some(lang) = request
1767 .lang
1768 .as_deref()
1769 .filter(|lang| !lang.trim().is_empty())
1770 {
1771 command.arg("--lang").arg(lang);
1772 }
1773 if let Some(selector) = request
1774 .selector
1775 .as_deref()
1776 .filter(|selector| !selector.trim().is_empty())
1777 {
1778 command.arg("--selector").arg(selector);
1779 }
1780 if let Some(strictness) = &request.strictness {
1781 command.arg("--strictness").arg(strictness.as_str());
1782 }
1783 apply_context_and_globs(
1784 &mut command,
1785 request.context_lines,
1786 request.effective_before_lines(),
1787 request.effective_after_lines(),
1788 &globs,
1789 );
1790 if request.effective_follow() {
1791 command.arg("--follow");
1792 }
1793 if let Some(no_ignore) = request.effective_no_ignore() {
1794 for value in no_ignore {
1795 command.arg("--no-ignore").arg(value.trim());
1796 }
1797 }
1798 command.arg(&command_path);
1799
1800 let output =
1801 run_ast_grep_command(&mut command, "failed to run ast-grep structural rewrite").await?;
1802
1803 let no_matches = output.status.code() == Some(1);
1804 if !output.status.success() && !no_matches {
1805 bail!(
1806 "{}",
1807 format_ast_grep_failure(
1808 "ast-grep structural rewrite failed",
1809 stderr_or_stdout(&output.stderr, &output.stdout)
1810 )
1811 );
1812 }
1813
1814 let rewrites = if no_matches && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
1815 Vec::new()
1816 } else {
1817 parse_rewrite_matches(&output.stdout)?
1818 };
1819 Ok(build_rewrite_result(
1820 request,
1821 &search_path.display_path,
1822 rewrites,
1823 ))
1824}
1825
1826async fn execute_structural_count(
1827 workspace_root: &Path,
1828 request: &StructuralSearchRequest,
1829 ast_grep: &Path,
1830) -> Result<Value> {
1831 let search_path = resolve_search_path(workspace_root, request.requested_path())?;
1832 let globs = request.normalized_globs();
1833
1834 if request.nth_child.is_some()
1837 || request.range.is_some()
1838 || request.has.is_some()
1839 || request.inside.is_some()
1840 || request.follows.is_some()
1841 || request.precedes.is_some()
1842 || request.constraints.is_some()
1843 || request.matches.is_some()
1844 || request.all.is_some()
1845 || request.any.is_some()
1846 || request.not.is_some()
1847 || request.utils.is_some()
1848 || request.transform.is_some()
1849 {
1850 return execute_atomic_rule_count(workspace_root, request, ast_grep, &search_path).await;
1851 }
1852
1853 let command_path = search_path.command_arg.clone();
1854 let mut command = ast_grep_command(ast_grep, workspace_root, "run");
1855 if let Some(pattern) = request.pattern() {
1856 command.arg(format!("--pattern={pattern}"));
1857 }
1858 command.arg("--json=compact").arg("--color=never");
1859
1860 if let Some(lang) = request
1861 .lang
1862 .as_deref()
1863 .filter(|lang| !lang.trim().is_empty())
1864 {
1865 command.arg("--lang").arg(lang);
1866 }
1867 if let Some(kind) = request.kind() {
1868 command.arg("--kind").arg(kind);
1869 }
1870 if let Some(regex) = request.regex_pattern() {
1871 command.arg("--regex").arg(regex);
1872 }
1873 if let Some(selector) = request
1874 .selector
1875 .as_deref()
1876 .filter(|selector| !selector.trim().is_empty())
1877 {
1878 command.arg("--selector").arg(selector);
1879 }
1880 if let Some(strictness) = &request.strictness {
1881 command.arg("--strictness").arg(strictness.as_str());
1882 }
1883 apply_context_and_globs(
1884 &mut command,
1885 request.context_lines,
1886 request.effective_before_lines(),
1887 request.effective_after_lines(),
1888 &globs,
1889 );
1890 if request.effective_follow() {
1891 command.arg("--follow");
1892 }
1893 if let Some(no_ignore) = request.effective_no_ignore() {
1894 for value in no_ignore {
1895 command.arg("--no-ignore").arg(value.trim());
1896 }
1897 }
1898 command.arg(&command_path);
1899
1900 let output =
1901 run_ast_grep_command(&mut command, "failed to run ast-grep structural count").await?;
1902
1903 let no_matches = output.status.code() == Some(1);
1904 if !output.status.success() && !no_matches {
1905 bail!(
1906 "{}",
1907 format_ast_grep_failure(
1908 "ast-grep structural count failed",
1909 stderr_or_stdout(&output.stderr, &output.stdout)
1910 )
1911 );
1912 }
1913
1914 let count = if no_matches && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
1915 0
1916 } else {
1917 parse_compact_matches(&output.stdout)?.len()
1918 };
1919
1920 let max_results = request.effective_max_results();
1921 let truncated = count > max_results;
1922
1923 let mut result = json!({
1924 "backend": "ast-grep",
1925 "workflow": "count",
1926 "path": search_path.display_path,
1927 "count": count,
1928 "truncated": truncated,
1929 });
1930 if let Some(pattern) = request.pattern() {
1931 result["pattern"] = json!(pattern);
1932 }
1933 if let Some(kind) = request.kind() {
1934 result["kind"] = json!(kind);
1935 }
1936 Ok(result)
1937}
1938
1939fn build_atomic_rule_yaml(request: &StructuralSearchRequest, lang: &str) -> String {
1941 use std::fmt::Write as _;
1942 let mut yaml = String::new();
1943 let _ = writeln!(yaml, "id: atomic-count");
1944 let _ = writeln!(yaml, "language: {lang}");
1945 let _ = writeln!(yaml, "severity: info");
1946
1947 if let Some(utils) = &request.utils {
1949 if !utils.is_empty() {
1950 yaml.push_str("utils:\n");
1951 for (util_name, util_rule) in utils {
1952 let _ = write!(yaml, " {}:\n", util_name);
1953 value_to_yaml(&mut yaml, util_rule, 4);
1954 }
1955 }
1956 }
1957
1958 let _ = writeln!(yaml, "rule:");
1959
1960 if let Some(pattern) = request.pattern() {
1961 let _ = writeln!(yaml, " pattern: {}", yaml_escape_scalar(pattern));
1962 }
1963 if let Some(kind) = request.kind() {
1964 let _ = writeln!(yaml, " kind: {}", yaml_escape_scalar(kind));
1965 }
1966 if let Some(regex) = request.regex_pattern() {
1967 let _ = writeln!(yaml, " regex: {}", yaml_escape_scalar(regex));
1968 }
1969 if let Some(selector) = request.selector.as_deref().filter(|s| !s.trim().is_empty()) {
1970 let _ = writeln!(yaml, " selector: {}", yaml_escape_scalar(selector));
1971 }
1972 if let Some(strictness) = &request.strictness {
1973 let _ = writeln!(yaml, " strictness: {}", strictness.as_str());
1974 }
1975
1976 if let Some(nth) = &request.nth_child {
1977 match nth {
1978 NthChildInput::Number(n) => {
1979 let _ = writeln!(yaml, " nthChild: {n}");
1980 }
1981 NthChildInput::Formula(f) => {
1982 let _ = writeln!(yaml, " nthChild: {}", yaml_escape_scalar(f));
1983 }
1984 NthChildInput::Object(obj) => {
1985 let _ = writeln!(yaml, " nthChild:");
1986 match &obj.position {
1987 Value::Number(n) => {
1988 let _ = writeln!(yaml, " position: {n}");
1989 }
1990 Value::String(s) => {
1991 let _ = writeln!(yaml, " position: {}", yaml_escape_scalar(s));
1992 }
1993 _ => {
1994 let _ = writeln!(yaml, " position: {}", obj.position);
1995 }
1996 }
1997 if let Some(reverse) = obj.reverse {
1998 let _ = writeln!(yaml, " reverse: {reverse}");
1999 }
2000 if let Some(of_rule) = &obj.of_rule {
2001 let _ = writeln!(yaml, " ofRule:");
2002 if let Some(of_obj) = of_rule.as_object() {
2003 for (k, v) in of_obj {
2004 match v {
2005 Value::String(s) => {
2006 let _ = writeln!(yaml, " {k}: {}", yaml_escape_scalar(s));
2007 }
2008 Value::Number(n) => {
2009 let _ = writeln!(yaml, " {k}: {n}");
2010 }
2011 Value::Bool(b) => {
2012 let _ = writeln!(yaml, " {k}: {b}");
2013 }
2014 _ => {
2015 let _ = writeln!(yaml, " {k}: {v}");
2016 }
2017 }
2018 }
2019 }
2020 }
2021 }
2022 }
2023 }
2024
2025 if let Some(r) = &request.range {
2026 let _ = writeln!(yaml, " range:");
2027 let _ = writeln!(yaml, " start:");
2028 let _ = writeln!(yaml, " line: {}", r.start.line);
2029 let _ = writeln!(yaml, " column: {}", r.start.column);
2030 let _ = writeln!(yaml, " end:");
2031 let _ = writeln!(yaml, " line: {}", r.end.line);
2032 let _ = writeln!(yaml, " column: {}", r.end.column);
2033 }
2034
2035 emit_value_yaml_field(&mut yaml, " ", "has", request.has.as_deref());
2037 emit_value_yaml_field(&mut yaml, " ", "inside", request.inside.as_deref());
2038 emit_value_yaml_field(&mut yaml, " ", "follows", request.follows.as_deref());
2039 emit_value_yaml_field(&mut yaml, " ", "precedes", request.precedes.as_deref());
2040
2041 if let Some(constraints) = &request.constraints {
2043 if !constraints.is_empty() {
2044 yaml.push_str(" constraints:\n");
2045 for (var_name, constraint_value) in constraints {
2046 yaml.push_str(&format!(" {}:\n", var_name));
2047 value_to_yaml(&mut yaml, constraint_value, 6);
2048 }
2049 }
2050 }
2051
2052 if let Some(matches_name) = &request.matches {
2054 let _ = writeln!(yaml, " matches: {}", yaml_escape_scalar(matches_name));
2055 }
2056 if let Some(all_rules) = &request.all {
2057 if !all_rules.is_empty() {
2058 yaml.push_str(" all:\n");
2059 for rule in all_rules {
2060 yaml.push_str(" - ");
2061 match rule {
2062 Value::String(s) => {
2063 let _ = writeln!(yaml, "pattern: {}", yaml_escape_scalar(s));
2064 }
2065 _ => {
2066 yaml.push('\n');
2067 value_to_yaml(&mut yaml, rule, 6);
2068 }
2069 }
2070 }
2071 }
2072 }
2073 if let Some(any_rules) = &request.any {
2074 if !any_rules.is_empty() {
2075 yaml.push_str(" any:\n");
2076 for rule in any_rules {
2077 yaml.push_str(" - ");
2078 match rule {
2079 Value::String(s) => {
2080 let _ = writeln!(yaml, "pattern: {}", yaml_escape_scalar(s));
2081 }
2082 _ => {
2083 yaml.push('\n');
2084 value_to_yaml(&mut yaml, rule, 6);
2085 }
2086 }
2087 }
2088 }
2089 }
2090 if let Some(not_rule) = &request.not {
2091 yaml.push_str(" not:\n");
2092 match not_rule.as_ref() {
2093 Value::String(s) => {
2094 let _ = writeln!(yaml, " pattern: {}", yaml_escape_scalar(s));
2095 }
2096 _ => {
2097 value_to_yaml(&mut yaml, not_rule, 4);
2098 }
2099 }
2100 }
2101
2102 if let Some(transform) = &request.transform {
2104 if !transform.is_empty() {
2105 yaml.push_str("transform:\n");
2106 for (var_name, transform_def) in transform {
2107 let _ = write!(yaml, " {}:\n", var_name);
2108 value_to_yaml(&mut yaml, transform_def, 4);
2109 }
2110 }
2111 }
2112
2113 yaml
2114}
2115
2116fn emit_value_yaml_field(yaml: &mut String, pad: &str, name: &str, value: Option<&Value>) {
2122 if let Some(val) = value {
2123 yaml.push_str(&format!("{pad}{name}:\n"));
2124 match val {
2125 Value::String(s) => {
2126 let child_pad = " ".repeat(pad.len() + 2);
2127 yaml.push_str(&format!("{child_pad}pattern: {}\n", yaml_escape_scalar(s)));
2128 }
2129 _ => {
2130 value_to_yaml(yaml, val, pad.len() + 2);
2131 }
2132 }
2133 }
2134}
2135
2136fn value_to_yaml(yaml: &mut String, value: &Value, indent: usize) {
2138 let pad = " ".repeat(indent);
2139 match value {
2140 Value::String(s) => {
2141 yaml.push_str(&format!("{pad}{}\n", yaml_escape_scalar(s)));
2142 }
2143 Value::Number(n) => {
2144 yaml.push_str(&format!("{pad}{n}\n"));
2145 }
2146 Value::Bool(b) => {
2147 yaml.push_str(&format!("{pad}{b}\n"));
2148 }
2149 Value::Null => {
2150 yaml.push_str(&format!("{pad}null\n"));
2151 }
2152 Value::Array(arr) => {
2153 for item in arr {
2154 yaml.push_str(&format!("{pad}- "));
2155 match item {
2156 Value::String(s) => yaml.push_str(&format!("{}\n", yaml_escape_scalar(s))),
2157 Value::Number(n) => yaml.push_str(&format!("{n}\n")),
2158 Value::Bool(b) => yaml.push_str(&format!("{b}\n")),
2159 _ => {
2160 yaml.push('\n');
2161 value_to_yaml(yaml, item, indent + 2);
2162 }
2163 }
2164 }
2165 }
2166 Value::Object(obj) => {
2167 for (key, val) in obj {
2168 match val {
2169 Value::Object(_) | Value::Array(_) => {
2170 yaml.push_str(&format!("{pad}{key}:\n"));
2171 value_to_yaml(yaml, val, indent + 2);
2172 }
2173 Value::String(s) => {
2174 yaml.push_str(&format!("{pad}{key}: {}\n", yaml_escape_scalar(s)));
2175 }
2176 Value::Number(n) => {
2177 yaml.push_str(&format!("{pad}{key}: {n}\n"));
2178 }
2179 Value::Bool(b) => {
2180 yaml.push_str(&format!("{pad}{key}: {b}\n"));
2181 }
2182 Value::Null => {
2183 yaml.push_str(&format!("{pad}{key}: null\n"));
2184 }
2185 }
2186 }
2187 }
2188 }
2189}
2190
2191async fn execute_atomic_rule_count(
2193 workspace_root: &Path,
2194 request: &StructuralSearchRequest,
2195 ast_grep: &Path,
2196 search_path: &ResolvedSearchPath,
2197) -> Result<Value> {
2198 let lang = request
2199 .lang
2200 .as_deref()
2201 .filter(|l| !l.trim().is_empty())
2202 .unwrap_or("javascript");
2203
2204 let rule_yaml = build_atomic_rule_yaml(request, lang);
2205
2206 let temp_dir = tempfile::tempdir().with_context(|| {
2207 "failed to create temporary directory for atomic rule count".to_string()
2208 })?;
2209 let rules_dir = temp_dir.path().join("rules");
2210 afs::create_dir_all(&rules_dir).await.with_context(|| {
2211 format!(
2212 "failed to create rules directory at {}",
2213 rules_dir.display()
2214 )
2215 })?;
2216
2217 let rule_path = rules_dir.join("atomic-count.yml");
2218 afs::write(&rule_path, &rule_yaml)
2219 .await
2220 .with_context(|| format!("failed to write atomic rule to {}", rule_path.display()))?;
2221
2222 let sgconfig_path = temp_dir.path().join("sgconfig.yml");
2223 let sgconfig_content = format!("ruleDirs:\n - {}\n", rules_dir.display());
2224 afs::write(&sgconfig_path, &sgconfig_content)
2225 .await
2226 .with_context(|| {
2227 format!(
2228 "failed to write sgconfig.yml to {}",
2229 sgconfig_path.display()
2230 )
2231 })?;
2232
2233 let mut command = ast_grep_command(ast_grep, workspace_root, "scan");
2234 command
2235 .arg("--config")
2236 .arg(&sgconfig_path)
2237 .arg("--json=stream")
2238 .arg("--include-metadata")
2239 .arg("--color=never");
2240
2241 let globs = request.normalized_globs();
2242 apply_context_and_globs(
2243 &mut command,
2244 request.context_lines,
2245 request.effective_before_lines(),
2246 request.effective_after_lines(),
2247 &globs,
2248 );
2249 command.arg(&search_path.command_arg);
2250
2251 let output =
2252 run_ast_grep_command(&mut command, "failed to run ast-grep atomic rule count").await?;
2253
2254 let findings_with_error_exit = output.status.code() == Some(1);
2255 if !output.status.success() && !findings_with_error_exit {
2256 bail!(
2257 "{}",
2258 format_ast_grep_failure(
2259 "ast-grep atomic rule count failed",
2260 stderr_or_stdout(&output.stderr, &output.stdout)
2261 )
2262 );
2263 }
2264
2265 let findings =
2266 if findings_with_error_exit && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
2267 Vec::new()
2268 } else {
2269 parse_stream_findings(&output.stdout)?
2270 };
2271
2272 let count = findings.len();
2273 let max_results = request.effective_max_results();
2274 let truncated = count > max_results;
2275
2276 let mut result = json!({
2277 "backend": "ast-grep",
2278 "workflow": "count",
2279 "path": search_path.display_path,
2280 "count": count,
2281 "truncated": truncated,
2282 });
2283 if let Some(pattern) = request.pattern() {
2284 result["pattern"] = json!(pattern);
2285 }
2286 if let Some(kind) = request.kind() {
2287 result["kind"] = json!(kind);
2288 }
2289 Ok(result)
2290}
2291
2292async fn execute_atomic_rule_query(
2295 workspace_root: &Path,
2296 request: &StructuralSearchRequest,
2297 ast_grep: &Path,
2298 search_path: &ResolvedSearchPath,
2299) -> Result<Value> {
2300 let lang = request
2301 .lang
2302 .as_deref()
2303 .filter(|l| !l.trim().is_empty())
2304 .unwrap_or("javascript");
2305
2306 let rule_yaml = build_atomic_rule_yaml(request, lang);
2307
2308 let temp_dir = tempfile::tempdir().with_context(|| {
2309 "failed to create temporary directory for atomic rule query".to_string()
2310 })?;
2311 let rules_dir = temp_dir.path().join("rules");
2312 afs::create_dir_all(&rules_dir).await.with_context(|| {
2313 format!(
2314 "failed to create rules directory at {}",
2315 rules_dir.display()
2316 )
2317 })?;
2318
2319 let rule_path = rules_dir.join("atomic-query.yml");
2320 afs::write(&rule_path, &rule_yaml)
2321 .await
2322 .with_context(|| format!("failed to write atomic rule to {}", rule_path.display()))?;
2323
2324 let sgconfig_path = temp_dir.path().join("sgconfig.yml");
2325 let sgconfig_content = format!("ruleDirs:\n - {}\n", rules_dir.display());
2326 afs::write(&sgconfig_path, &sgconfig_content)
2327 .await
2328 .with_context(|| {
2329 format!(
2330 "failed to write sgconfig.yml to {}",
2331 sgconfig_path.display()
2332 )
2333 })?;
2334
2335 let mut command = ast_grep_command(ast_grep, workspace_root, "scan");
2336 command
2337 .arg("--config")
2338 .arg(&sgconfig_path)
2339 .arg("--json=stream")
2340 .arg("--include-metadata")
2341 .arg("--color=never");
2342
2343 let globs = request.normalized_globs();
2344 apply_context_and_globs(
2345 &mut command,
2346 request.context_lines,
2347 request.effective_before_lines(),
2348 request.effective_after_lines(),
2349 &globs,
2350 );
2351 command.arg(&search_path.command_arg);
2352
2353 let output =
2354 run_ast_grep_command(&mut command, "failed to run ast-grep atomic rule query").await?;
2355
2356 let findings_with_error_exit = output.status.code() == Some(1);
2357 if !output.status.success() && !findings_with_error_exit {
2358 bail!(
2359 "{}",
2360 format_ast_grep_failure(
2361 "ast-grep atomic rule query failed",
2362 stderr_or_stdout(&output.stderr, &output.stdout)
2363 )
2364 );
2365 }
2366
2367 let findings =
2368 if findings_with_error_exit && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
2369 Vec::new()
2370 } else {
2371 parse_stream_findings(&output.stdout)?
2372 };
2373
2374 let max_results = request.effective_max_results();
2375 let truncated = findings.len() > max_results;
2376 let normalized_matches = findings
2377 .into_iter()
2378 .take(max_results)
2379 .map(|finding| {
2380 let mut match_object = Map::new();
2381 match_object.insert("file".to_string(), Value::String(finding.file));
2382 match_object.insert("line_number".to_string(), json!(finding.range.start.line));
2383 match_object.insert("text".to_string(), Value::String(finding.text.clone()));
2384 match_object.insert(
2385 "lines".to_string(),
2386 Value::String(finding.lines.unwrap_or(finding.text)),
2387 );
2388 if let Some(language) = finding.language {
2389 match_object.insert("language".to_string(), Value::String(language));
2390 }
2391 match_object.insert("range".to_string(), build_range_value(&finding.range));
2392 if let Some(message) = finding.message {
2393 match_object.insert("message".to_string(), Value::String(message));
2394 }
2395 if let Some(metadata) = &finding.metadata {
2396 match_object.insert("metadata".to_string(), metadata.clone());
2397 }
2398 Value::Object(match_object)
2399 })
2400 .collect::<Vec<_>>();
2401
2402 let mut result = json!({
2403 "backend": "ast-grep",
2404 "path": search_path.display_path,
2405 "matches": normalized_matches,
2406 "truncated": truncated,
2407 });
2408 if let Some(pattern) = request.pattern() {
2409 result["pattern"] = json!(pattern);
2410 }
2411 if let Some(kind) = request.kind() {
2412 result["kind"] = json!(kind);
2413 }
2414 Ok(result)
2415}
2416
2417async fn execute_fixconfig_rewrite_to_matches(
2422 workspace_root: &Path,
2423 request: &StructuralSearchRequest,
2424 ast_grep: &Path,
2425 search_path: &ResolvedSearchPath,
2426) -> Result<Vec<AstGrepRewriteMatch>> {
2427 let fix_config = request
2428 .fix_config
2429 .as_ref()
2430 .expect("fix_config validated present");
2431 let pattern = request.pattern().expect("rewrite pattern validated");
2432 let lang = request
2433 .lang
2434 .as_deref()
2435 .filter(|l| !l.trim().is_empty())
2436 .unwrap_or("javascript");
2437 let template = request
2438 .effective_rewrite_template()
2439 .unwrap_or_default()
2440 .to_string();
2441
2442 let rule_yaml = build_fixconfig_rule_yaml(
2443 pattern,
2444 lang,
2445 fix_config,
2446 request.selector.as_deref(),
2447 request.transform.as_ref(),
2448 );
2449
2450 let temp_dir = tempfile::tempdir().with_context(|| {
2451 "failed to create temporary directory for FixConfig rewrite matches".to_string()
2452 })?;
2453 let rules_dir = temp_dir.path().join("rules");
2454 afs::create_dir_all(&rules_dir).await.with_context(|| {
2455 format!(
2456 "failed to create rules directory at {}",
2457 rules_dir.display()
2458 )
2459 })?;
2460
2461 let rule_path = rules_dir.join("fixconfig-rewrite.yml");
2462 afs::write(&rule_path, &rule_yaml)
2463 .await
2464 .with_context(|| format!("failed to write FixConfig rule to {}", rule_path.display()))?;
2465
2466 let sgconfig_path = temp_dir.path().join("sgconfig.yml");
2467 let sgconfig_content = format!("ruleDirs:\n - {}\n", rules_dir.display());
2468 afs::write(&sgconfig_path, &sgconfig_content)
2469 .await
2470 .with_context(|| {
2471 format!(
2472 "failed to write sgconfig.yml to {}",
2473 sgconfig_path.display()
2474 )
2475 })?;
2476
2477 let mut command = ast_grep_command(ast_grep, workspace_root, "scan");
2478 command
2479 .arg("--config")
2480 .arg(&sgconfig_path)
2481 .arg("--json=stream")
2482 .arg("--include-metadata")
2483 .arg("--color=never");
2484
2485 let globs = request.normalized_globs();
2486 apply_context_and_globs(
2487 &mut command,
2488 request.context_lines,
2489 request.effective_before_lines(),
2490 request.effective_after_lines(),
2491 &globs,
2492 );
2493 command.arg(&search_path.command_arg);
2494
2495 let output = run_ast_grep_command(
2496 &mut command,
2497 "failed to run ast-grep FixConfig rewrite scan for apply",
2498 )
2499 .await?;
2500
2501 let findings_with_error_exit = output.status.code() == Some(1);
2502 if !output.status.success() && !findings_with_error_exit {
2503 bail!(
2504 "{}",
2505 format_ast_grep_failure(
2506 "ast-grep FixConfig rewrite scan failed",
2507 stderr_or_stdout(&output.stderr, &output.stdout)
2508 )
2509 );
2510 }
2511
2512 let findings =
2513 if findings_with_error_exit && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
2514 Vec::new()
2515 } else {
2516 parse_stream_findings(&output.stdout)?
2517 };
2518
2519 Ok(findings
2522 .into_iter()
2523 .map(|f| AstGrepRewriteMatch {
2524 file: f.file,
2525 text: f.text,
2526 lines: f.lines,
2527 language: f.language,
2528 range: f.range,
2529 meta_variables: None,
2530 replacement: Some(template.clone()),
2531 replacement_offsets: None,
2532 })
2533 .collect())
2534}
2535
2536async fn execute_fixconfig_rewrite(
2544 workspace_root: &Path,
2545 request: &StructuralSearchRequest,
2546 ast_grep: &Path,
2547 search_path: &ResolvedSearchPath,
2548) -> Result<Value> {
2549 let fix_config = request
2550 .fix_config
2551 .as_ref()
2552 .expect("fix_config validated present");
2553 let pattern = request.pattern().expect("rewrite pattern validated");
2554 let lang = request
2555 .lang
2556 .as_deref()
2557 .filter(|l| !l.trim().is_empty())
2558 .unwrap_or("javascript");
2559
2560 let rule_yaml = build_fixconfig_rule_yaml(
2562 pattern,
2563 lang,
2564 fix_config,
2565 request.selector.as_deref(),
2566 request.transform.as_ref(),
2567 );
2568
2569 let temp_dir = tempfile::tempdir().with_context(|| {
2571 "failed to create temporary directory for FixConfig rewrite rule".to_string()
2572 })?;
2573 let rules_dir = temp_dir.path().join("rules");
2574 afs::create_dir_all(&rules_dir).await.with_context(|| {
2575 format!(
2576 "failed to create rules directory at {}",
2577 rules_dir.display()
2578 )
2579 })?;
2580
2581 let rule_path = rules_dir.join("fixconfig-rewrite.yml");
2582 afs::write(&rule_path, &rule_yaml)
2583 .await
2584 .with_context(|| format!("failed to write FixConfig rule to {}", rule_path.display()))?;
2585
2586 let sgconfig_path = temp_dir.path().join("sgconfig.yml");
2587 let sgconfig_content = format!("ruleDirs:\n - {}\n", rules_dir.display());
2588 afs::write(&sgconfig_path, &sgconfig_content)
2589 .await
2590 .with_context(|| {
2591 format!(
2592 "failed to write sgconfig.yml to {}",
2593 sgconfig_path.display()
2594 )
2595 })?;
2596
2597 let mut command = ast_grep_command(ast_grep, workspace_root, "scan");
2599 command
2600 .arg("--config")
2601 .arg(&sgconfig_path)
2602 .arg("--json=stream")
2603 .arg("--include-metadata")
2604 .arg("--color=never");
2605
2606 let globs = request.normalized_globs();
2607 apply_context_and_globs(
2608 &mut command,
2609 request.context_lines,
2610 request.effective_before_lines(),
2611 request.effective_after_lines(),
2612 &globs,
2613 );
2614 command.arg(&search_path.command_arg);
2615
2616 let output = run_ast_grep_command(
2617 &mut command,
2618 "failed to run ast-grep FixConfig rewrite scan",
2619 )
2620 .await?;
2621
2622 let findings_with_error_exit = output.status.code() == Some(1);
2623 if !output.status.success() && !findings_with_error_exit {
2624 bail!(
2625 "{}",
2626 format_ast_grep_failure(
2627 "ast-grep FixConfig rewrite scan failed",
2628 stderr_or_stdout(&output.stderr, &output.stdout)
2629 )
2630 );
2631 }
2632
2633 let findings =
2634 if findings_with_error_exit && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
2635 Vec::new()
2636 } else {
2637 parse_stream_findings(&output.stdout)?
2638 };
2639
2640 Ok(build_fixconfig_rewrite_result(
2642 request,
2643 &search_path.display_path,
2644 findings,
2645 ))
2646}
2647
2648fn build_fixconfig_rule_yaml(
2659 pattern: &str,
2660 lang: &str,
2661 fix_config: &FixConfig,
2662 selector: Option<&str>,
2663 transform: Option<&Map<String, Value>>,
2664) -> String {
2665 let mut yaml = String::new();
2666 yaml.push_str("id: fixconfig-rewrite\n");
2667 yaml.push_str(&format!("language: {lang}\n"));
2668 yaml.push_str("severity: info\n");
2669 yaml.push_str("rule:\n");
2670
2671 if let Some(selector) = selector.filter(|s| !s.trim().is_empty()) {
2672 yaml.push_str(&format!(" pattern: {pattern}\n"));
2673 yaml.push_str(&format!(" selector: {selector}\n"));
2674 } else {
2675 yaml.push_str(&format!(" pattern: {pattern}\n"));
2676 }
2677
2678 if let Some(transform) = transform {
2681 if !transform.is_empty() {
2682 yaml.push_str("transform:\n");
2683 for (var_name, transform_def) in transform {
2684 use std::fmt::Write as _;
2685 let _ = write!(yaml, " {}:\n", var_name);
2686 value_to_yaml(&mut yaml, transform_def, 4);
2687 }
2688 }
2689 }
2690
2691 yaml.push_str("fix:\n");
2692 yaml.push_str(&format!(
2693 " template: {}\n",
2694 yaml_escape_scalar(&fix_config.template)
2695 ));
2696
2697 if let Some(expand_start) = &fix_config.expand_start {
2698 yaml.push_str(" expandStart:\n");
2699 append_expand_rule_yaml(&mut yaml, expand_start);
2700 }
2701
2702 if let Some(expand_end) = &fix_config.expand_end {
2703 yaml.push_str(" expandEnd:\n");
2704 append_expand_rule_yaml(&mut yaml, expand_end);
2705 }
2706
2707 yaml
2708}
2709
2710fn append_expand_rule_yaml(yaml: &mut String, rule: &FixExpandRule) {
2712 if let Some(regex) = &rule.regex {
2713 yaml.push_str(&format!(" regex: {}\n", yaml_escape_scalar(regex)));
2714 }
2715 if let Some(kind) = &rule.kind {
2716 yaml.push_str(&format!(" kind: {}\n", yaml_escape_scalar(kind)));
2717 }
2718 if let Some(pattern) = &rule.pattern {
2719 yaml.push_str(&format!(" pattern: {}\n", yaml_escape_scalar(pattern)));
2720 }
2721 if let Some(stop_by) = &rule.stop_by {
2722 match stop_by {
2723 Value::String(s) => {
2724 yaml.push_str(&format!(" stopBy: {}\n", yaml_escape_scalar(s)));
2725 }
2726 Value::Object(_) => {
2727 yaml.push_str(" stopBy:\n");
2731 if let Some(obj) = stop_by.as_object() {
2732 for (key, val) in obj {
2733 match val {
2734 Value::String(s) => {
2735 yaml.push_str(&format!(
2736 " {}: {}\n",
2737 key,
2738 yaml_escape_scalar(s)
2739 ));
2740 }
2741 Value::Number(n) => {
2742 yaml.push_str(&format!(" {}: {}\n", key, n));
2743 }
2744 Value::Bool(b) => {
2745 yaml.push_str(&format!(" {}: {}\n", key, b));
2746 }
2747 _ => {
2748 yaml.push_str(&format!(" {}: {}\n", key, val));
2749 }
2750 }
2751 }
2752 }
2753 }
2754 _ => {
2755 yaml.push_str(&format!(" stopBy: {}\n", stop_by));
2756 }
2757 }
2758 }
2759}
2760
2761fn yaml_escape_scalar(value: &str) -> String {
2765 if value.is_empty() {
2766 return "''".to_string();
2767 }
2768
2769 let needs_quoting = value.contains(':')
2770 || value.contains('#')
2771 || value.contains('{')
2772 || value.contains('}')
2773 || value.contains('[')
2774 || value.contains(']')
2775 || value.contains(',')
2776 || value.contains('&')
2777 || value.contains('*')
2778 || value.contains('?')
2779 || value.contains('|')
2780 || value.contains('-')
2781 || value.contains('>')
2782 || value.contains('!')
2783 || value.contains('%')
2784 || value.contains('@')
2785 || value.contains('`')
2786 || value.contains('"')
2787 || value.contains('\'')
2788 || value.starts_with(' ')
2789 || value.ends_with(' ')
2790 || value == "true"
2791 || value == "false"
2792 || value == "null"
2793 || value == "yes"
2794 || value == "no"
2795 || value.parse::<f64>().is_ok();
2796
2797 if needs_quoting {
2798 let escaped = value.replace('\'', "''");
2799 format!("'{escaped}'")
2800 } else {
2801 value.to_string()
2802 }
2803}
2804
2805fn build_fixconfig_rewrite_result(
2809 request: &StructuralSearchRequest,
2810 display_path: &str,
2811 findings: Vec<AstGrepScanFinding>,
2812) -> Value {
2813 let max_results = request.effective_max_results();
2814 let truncated = findings.len() > max_results;
2815 let template = request
2816 .effective_rewrite_template()
2817 .unwrap_or_default()
2818 .to_string();
2819
2820 let normalized_rewrites: Vec<Value> = findings
2821 .into_iter()
2822 .take(max_results)
2823 .map(|finding| {
2824 let mut rewrite_object = Map::new();
2825 rewrite_object.insert("file".to_string(), Value::String(finding.file));
2826 rewrite_object.insert("line_number".to_string(), json!(finding.range.start.line));
2827 rewrite_object.insert("text".to_string(), Value::String(finding.text.clone()));
2828 rewrite_object.insert(
2829 "lines".to_string(),
2830 Value::String(finding.lines.unwrap_or(finding.text)),
2831 );
2832 if let Some(language) = finding.language {
2833 rewrite_object.insert("language".to_string(), Value::String(language));
2834 }
2835 rewrite_object.insert("range".to_string(), build_range_value(&finding.range));
2836 rewrite_object.insert("replacement".to_string(), Value::String(template.clone()));
2841 if let Some(message) = finding.message {
2842 rewrite_object.insert("message".to_string(), Value::String(message));
2843 }
2844 Value::Object(rewrite_object)
2845 })
2846 .collect();
2847
2848 let mut result = json!({
2849 "backend": "ast-grep",
2850 "workflow": "rewrite",
2851 "path": display_path,
2852 "rewrites": normalized_rewrites,
2853 "truncated": truncated,
2854 "fix_config": {
2855 "template": request.fix_config.as_ref().map(|fc| fc.template.clone()),
2856 "expand_start": request.fix_config.as_ref().and_then(|fc| fc.expand_start.as_ref().map(|es| es.to_yaml_value())),
2857 "expand_end": request.fix_config.as_ref().and_then(|fc| fc.expand_end.as_ref().map(|ee| ee.to_yaml_value())),
2858 },
2859 });
2860 if let Some(pattern) = request.pattern() {
2861 result["pattern"] = json!(pattern);
2862 }
2863 if let Some(template) = request.effective_rewrite_template() {
2864 result["rewrite"] = json!(template);
2865 }
2866 result
2867}
2868
2869fn build_rewrite_fragment_result(
2870 request: &StructuralSearchRequest,
2871 display_path: &str,
2872 hint: String,
2873) -> Value {
2874 let next_action = "Retry `unified_search` with `action='structural'` using a larger parseable pattern and `selector` when the real target is a subnode inside that pattern. Do not rerun the same fragment unchanged.";
2875
2876 let mut result = json!({
2877 "backend": "ast-grep",
2878 "workflow": "rewrite",
2879 "path": display_path,
2880 "rewrites": [],
2881 "truncated": false,
2882 "is_recoverable": true,
2883 "next_action": next_action,
2884 "hint": hint,
2885 });
2886 if let Some(pattern) = request.pattern() {
2887 result["pattern"] = json!(pattern);
2888 }
2889 if let Some(rewrite_text) = request.rewrite_text() {
2890 result["rewrite"] = json!(rewrite_text);
2891 }
2892 result
2893}
2894
2895fn parse_rewrite_matches(stdout: &[u8]) -> Result<Vec<AstGrepRewriteMatch>> {
2896 serde_json::from_slice(stdout).context("failed to parse ast-grep rewrite JSON output")
2897}
2898
2899fn build_rewrite_result(
2900 request: &StructuralSearchRequest,
2901 display_path: &str,
2902 rewrites: Vec<AstGrepRewriteMatch>,
2903) -> Value {
2904 let max_results = request.effective_max_results();
2905 let truncated = rewrites.len() > max_results;
2906 let normalized_rewrites = rewrites
2907 .into_iter()
2908 .take(max_results)
2909 .map(normalize_rewrite_match)
2910 .collect::<Vec<_>>();
2911
2912 let mut result = json!({
2913 "backend": "ast-grep",
2914 "workflow": "rewrite",
2915 "path": display_path,
2916 "rewrites": normalized_rewrites,
2917 "truncated": truncated,
2918 });
2919 if let Some(pattern) = request.pattern() {
2920 result["pattern"] = json!(pattern);
2921 }
2922 if let Some(rewrite_text) = request.rewrite_text() {
2923 result["rewrite"] = json!(rewrite_text);
2924 }
2925 result
2926}
2927
2928fn normalize_rewrite_match(entry: AstGrepRewriteMatch) -> Value {
2929 let mut rewrite_object = Map::new();
2930 rewrite_object.insert("file".to_string(), Value::String(entry.file));
2931 rewrite_object.insert("line_number".to_string(), json!(entry.range.start.line));
2932 rewrite_object.insert("text".to_string(), Value::String(entry.text.clone()));
2933 rewrite_object.insert(
2934 "lines".to_string(),
2935 Value::String(entry.lines.unwrap_or(entry.text)),
2936 );
2937 if let Some(language) = entry.language {
2938 rewrite_object.insert("language".to_string(), Value::String(language));
2939 }
2940 rewrite_object.insert("range".to_string(), build_range_value(&entry.range));
2941 if let Some(replacement) = entry.replacement {
2942 rewrite_object.insert("replacement".to_string(), Value::String(replacement));
2943 }
2944 if let Some(offsets) = entry.replacement_offsets {
2945 rewrite_object.insert(
2946 "replacementOffsets".to_string(),
2947 json!({ "start": offsets.start, "end": offsets.end }),
2948 );
2949 }
2950 if let Some(meta_vars) = entry.meta_variables {
2951 rewrite_object.insert(
2952 "metaVariables".to_string(),
2953 build_meta_variables_value(&meta_vars),
2954 );
2955 }
2956 Value::Object(rewrite_object)
2957}
2958
2959async fn execute_structural_inspect(
2960 workspace_root: &Path,
2961 request: &StructuralSearchRequest,
2962) -> Result<Value> {
2963 let requested_config = request.requested_config_path();
2964 let config_path = resolve_config_path(workspace_root, requested_config, false).await?;
2965
2966 let resolved_full = if Path::new(&config_path.command_arg).is_absolute() {
2967 PathBuf::from(&config_path.command_arg)
2968 } else {
2969 workspace_root.join(&config_path.command_arg)
2970 };
2971 let config_exists = resolved_full.is_file();
2972
2973 let rule_dir_hints = if config_exists {
2974 extract_rule_dirs(&resolved_full).await
2975 } else {
2976 Vec::new()
2977 };
2978
2979 let language_injections = if config_exists {
2980 extract_language_injections(&resolved_full).await
2981 } else {
2982 Vec::new()
2983 };
2984
2985 let custom_languages = if config_exists {
2986 extract_custom_languages(&resolved_full).await
2987 } else {
2988 Value::Object(Map::new())
2989 };
2990
2991 let language_globs = if config_exists {
2992 extract_language_globs(&resolved_full).await
2993 } else {
2994 Value::Object(Map::new())
2995 };
2996
2997 let test_configs = if config_exists {
2998 extract_test_configs(&resolved_full).await
2999 } else {
3000 Vec::new()
3001 };
3002
3003 let util_dirs = if config_exists {
3004 extract_util_dirs(&resolved_full).await
3005 } else {
3006 Vec::new()
3007 };
3008
3009 let discovered = if !config_exists {
3010 let is_default = requested_config == DEFAULT_AST_GREP_CONFIG_PATH;
3011 if is_default {
3012 match discover_project_config(workspace_root).await {
3013 Some(found) => {
3014 let display = found
3015 .strip_prefix(workspace_root)
3016 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
3017 .unwrap_or_else(|_| found.to_string_lossy().replace('\\', "/"));
3018 vec![display]
3019 }
3020 None => Vec::new(),
3021 }
3022 } else {
3023 Vec::new()
3024 }
3025 } else {
3026 Vec::new()
3027 };
3028
3029 let search_path = resolve_search_path(workspace_root, request.requested_path())?;
3030
3031 Ok(json!({
3032 "backend": "ast-grep",
3033 "workflow": "inspect",
3034 "project_dir": search_path.display_path,
3035 "config_path": config_path.display_path,
3036 "config_exists": config_exists,
3037 "rule_dir_hints": rule_dir_hints,
3038 "test_configs": test_configs,
3039 "util_dirs": util_dirs,
3040 "language_injections": language_injections,
3041 "custom_languages": custom_languages,
3042 "language_globs": language_globs,
3043 "discovered_configs": discovered,
3044 }))
3045}
3046
3047async fn execute_structural_rules(
3048 workspace_root: &Path,
3049 request: &StructuralSearchRequest,
3050) -> Result<Value> {
3051 let requested_config = request.requested_config_path();
3052 let config_path = resolve_config_path(workspace_root, requested_config, false).await?;
3053
3054 let resolved_full = if Path::new(&config_path.command_arg).is_absolute() {
3055 PathBuf::from(&config_path.command_arg)
3056 } else {
3057 workspace_root.join(&config_path.command_arg)
3058 };
3059 let config_exists = resolved_full.is_file();
3060
3061 if !config_exists {
3062 return Ok(json!({
3063 "backend": "ast-grep",
3064 "workflow": "rules",
3065 "config_path": config_path.display_path,
3066 "config_exists": false,
3067 "rules": [],
3068 }));
3069 }
3070
3071 let rule_dirs = extract_rule_dirs(&resolved_full).await;
3072 let config_parent = resolved_full.parent().unwrap_or(workspace_root);
3073
3074 let mut rules = Vec::new();
3075 for dir in &rule_dirs {
3076 let dir_path = config_parent.join(dir);
3077 if !dir_path.is_dir() {
3078 continue;
3079 }
3080 collect_rules_from_dir(&dir_path, &mut rules).await;
3081 }
3082
3083 Ok(json!({
3084 "backend": "ast-grep",
3085 "workflow": "rules",
3086 "config_path": config_path.display_path,
3087 "config_exists": true,
3088 "rule_dirs": rule_dirs,
3089 "rules": rules,
3090 }))
3091}
3092
3093async fn execute_structural_new(
3094 workspace_root: &Path,
3095 request: &StructuralSearchRequest,
3096 ast_grep: &Path,
3097) -> Result<Value> {
3098 let subcommand = request
3099 .new_subcommand
3100 .as_deref()
3101 .expect("new_subcommand validated present");
3102
3103 let mut command = ast_grep_command(ast_grep, workspace_root, "new");
3104 command.arg(subcommand).arg("--yes");
3105
3106 if let Some(name) = request.new_name.as_deref().filter(|s| !s.trim().is_empty()) {
3107 command.arg(name);
3108 }
3109
3110 if let Some(lang) = request.lang.as_deref().filter(|s| !s.trim().is_empty()) {
3111 command.arg("--lang").arg(lang);
3112 }
3113
3114 if let Some(config) = request
3115 .config_path
3116 .as_deref()
3117 .filter(|s| !s.trim().is_empty())
3118 {
3119 command.arg("--config").arg(config);
3120 }
3121
3122 let output = run_ast_grep_command(&mut command, "failed to run ast-grep new").await?;
3123
3124 if !output.status.success() {
3125 bail!(
3126 "{}",
3127 format_ast_grep_failure(
3128 "ast-grep new failed",
3129 stderr_or_stdout(&output.stderr, &output.stdout)
3130 )
3131 );
3132 }
3133
3134 Ok(json!({
3135 "backend": "ast-grep",
3136 "workflow": "new",
3137 "subcommand": subcommand,
3138 "name": request.new_name,
3139 "output": String::from_utf8_lossy(&output.stdout).trim(),
3140 }))
3141}
3142
3143async fn execute_structural_apply(
3144 workspace_root: &Path,
3145 request: &StructuralSearchRequest,
3146 ast_grep: &Path,
3147) -> Result<Value> {
3148 let search_path = resolve_search_path(workspace_root, request.requested_path())?;
3149 let globs = request.normalized_globs();
3150
3151 if let Some(hint) = preflight_parseable_pattern(request)? {
3152 return Ok(json!({
3153 "backend": "ast-grep",
3154 "workflow": "apply",
3155 "path": search_path.display_path,
3156 "files_modified": [],
3157 "total_replacements": 0,
3158 "is_recoverable": true,
3159 "hint": hint,
3160 }));
3161 }
3162
3163 let needs_yaml_rewrite = request
3164 .fix_config
3165 .as_ref()
3166 .is_some_and(|fc| fc.has_expansion())
3167 || request.transform.is_some();
3168
3169 let rewrites: Vec<AstGrepRewriteMatch> = if needs_yaml_rewrite {
3170 execute_fixconfig_rewrite_to_matches(workspace_root, request, ast_grep, &search_path)
3171 .await?
3172 } else {
3173 let command_path = search_path.command_arg.clone();
3174 let mut command = ast_grep_command(ast_grep, workspace_root, "run");
3175 command
3176 .arg(format!(
3177 "--pattern={}",
3178 request.pattern().expect("apply pattern validated")
3179 ))
3180 .arg(format!(
3181 "--rewrite={}",
3182 request
3183 .effective_rewrite_template()
3184 .expect("apply rewrite template validated")
3185 ))
3186 .arg("--json=compact")
3187 .arg("--color=never");
3188
3189 if let Some(lang) = request.lang.as_deref().filter(|s| !s.trim().is_empty()) {
3190 command.arg("--lang").arg(lang);
3191 }
3192 if let Some(selector) = request.selector.as_deref().filter(|s| !s.trim().is_empty()) {
3193 command.arg("--selector").arg(selector);
3194 }
3195 if let Some(strictness) = &request.strictness {
3196 command.arg("--strictness").arg(strictness.as_str());
3197 }
3198 apply_context_and_globs(
3199 &mut command,
3200 request.context_lines,
3201 request.effective_before_lines(),
3202 request.effective_after_lines(),
3203 &globs,
3204 );
3205 command.arg(&command_path);
3206
3207 let output =
3208 run_ast_grep_command(&mut command, "failed to run ast-grep structural apply").await?;
3209
3210 let no_matches = output.status.code() == Some(1);
3211 if !output.status.success() && !no_matches {
3212 bail!(
3213 "{}",
3214 format_ast_grep_failure(
3215 "ast-grep structural apply failed",
3216 stderr_or_stdout(&output.stderr, &output.stdout)
3217 )
3218 );
3219 }
3220
3221 if no_matches && String::from_utf8_lossy(&output.stdout).trim().is_empty() {
3222 Vec::new()
3223 } else {
3224 parse_rewrite_matches(&output.stdout)?
3225 }
3226 };
3227
3228 if rewrites.is_empty() {
3229 return Ok(json!({
3230 "backend": "ast-grep",
3231 "workflow": "apply",
3232 "path": search_path.display_path,
3233 "files_modified": [],
3234 "total_replacements": 0,
3235 }));
3236 }
3237
3238 let mut by_file: BTreeMap<String, Vec<&AstGrepRewriteMatch>> = BTreeMap::new();
3240 for rw in &rewrites {
3241 by_file.entry(rw.file.clone()).or_default().push(rw);
3242 }
3243
3244 let mut files_modified = Vec::new();
3245 let mut total_replacements = 0usize;
3246
3247 for (file_path, file_rewrites) in &by_file {
3248 let abs_path = workspace_root.join(file_path);
3249 let content = afs::read_to_string(&abs_path)
3250 .await
3251 .with_context(|| format!("failed to read {file_path} for apply"))?;
3252 let mut bytes = content.into_bytes();
3253
3254 let mut sorted: Vec<_> = file_rewrites.iter().collect();
3256 sorted.sort_by(|a, b| {
3257 let a_start = a.replacement_offsets.as_ref().map(|o| o.start).unwrap_or(0);
3258 let b_start = b.replacement_offsets.as_ref().map(|o| o.start).unwrap_or(0);
3259 b_start.cmp(&a_start)
3260 });
3261
3262 let mut applied = 0usize;
3263 for rw in &sorted {
3264 let Some(replacement) = &rw.replacement else {
3265 continue;
3266 };
3267 let Some(offsets) = &rw.replacement_offsets else {
3268 continue;
3269 };
3270 if offsets.start > offsets.end || offsets.end > bytes.len() {
3271 continue;
3272 }
3273 let replacement_bytes = replacement.as_bytes();
3274 bytes.splice(
3275 offsets.start..offsets.end,
3276 replacement_bytes.iter().cloned(),
3277 );
3278 applied += 1;
3279 }
3280
3281 if applied > 0 {
3282 afs::write(&abs_path, &bytes)
3283 .await
3284 .with_context(|| format!("failed to write {file_path}"))?;
3285 total_replacements += applied;
3286 files_modified.push(json!({
3287 "file": file_path,
3288 "replacements": applied,
3289 }));
3290 }
3291 }
3292
3293 Ok(json!({
3294 "backend": "ast-grep",
3295 "workflow": "apply",
3296 "path": search_path.display_path,
3297 "files_modified": files_modified,
3298 "total_replacements": total_replacements,
3299 }))
3300}
3301
3302async fn collect_rules_from_dir(dir: &Path, rules: &mut Vec<Value>) {
3304 let mut entries = match afs::read_dir(dir).await {
3305 Ok(e) => e,
3306 Err(_) => return,
3307 };
3308
3309 while let Ok(Some(entry)) = entries.next_entry().await {
3310 let path = entry.path();
3311 if path.is_dir() {
3312 Box::pin(collect_rules_from_dir(&path, rules)).await;
3313 continue;
3314 }
3315 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
3316 continue;
3317 };
3318 if !matches!(ext, "yml" | "yaml") {
3319 continue;
3320 }
3321 let Ok(content) = afs::read_to_string(&path).await else {
3322 continue;
3323 };
3324 if let Some(summary) = extract_rule_summary(&content, &path) {
3325 rules.push(summary);
3326 }
3327 }
3328}
3329
3330fn extract_rule_summary(content: &str, path: &Path) -> Option<Value> {
3332 let mut id = None;
3333 let mut language = None;
3334 let mut severity = None;
3335 let mut message = None;
3336 let mut has_fix = false;
3337 let mut utils_list = Vec::new();
3338 let mut in_utils = false;
3339 let mut utils_indent = 0usize;
3340
3341 for line in content.lines() {
3342 let trimmed = line.trim();
3343 if in_utils {
3347 let indent = line.len() - line.trim_start().len();
3348 if indent <= utils_indent && !trimmed.is_empty() && !trimmed.starts_with('#') {
3349 in_utils = false;
3351 } else if indent == utils_indent + 2 {
3352 if let Some(name) = trimmed.strip_suffix(':') {
3353 let name = name.trim();
3354 if !name.is_empty() && !name.contains(' ') && !name.starts_with('#') {
3355 utils_list.push(name.to_string());
3356 }
3357 }
3358 }
3359 }
3360 if trimmed == "utils:" && !in_utils {
3361 in_utils = true;
3362 utils_indent = line.len() - line.trim_start().len();
3363 }
3364
3365 if trimmed.starts_with("id:") && id.is_none() {
3366 id = Some(trimmed.strip_prefix("id:")?.trim().to_string());
3367 } else if trimmed.starts_with("language:") && language.is_none() {
3368 language = Some(trimmed.strip_prefix("language:")?.trim().to_string());
3369 } else if trimmed.starts_with("severity:") && severity.is_none() {
3370 severity = Some(trimmed.strip_prefix("severity:")?.trim().to_string());
3371 } else if trimmed.starts_with("message:") && message.is_none() {
3372 message = Some(
3373 trimmed
3374 .strip_prefix("message:")?
3375 .trim()
3376 .trim_matches('"')
3377 .trim_matches('\'')
3378 .to_string(),
3379 );
3380 } else if trimmed.starts_with("fix:") && !has_fix {
3381 has_fix = true;
3382 }
3383 }
3384
3385 let id = id?;
3386 let file_name = path
3387 .file_name()
3388 .map(|f| f.to_string_lossy().to_string())
3389 .unwrap_or_default();
3390
3391 let mut summary = json!({
3392 "id": id,
3393 "file": file_name,
3394 });
3395 if let Some(lang) = language {
3396 summary["language"] = json!(lang);
3397 }
3398 if let Some(sev) = severity {
3399 summary["severity"] = json!(sev);
3400 }
3401 if let Some(msg) = message {
3402 summary["message"] = json!(msg);
3403 }
3404 summary["has_fix"] = json!(has_fix);
3405 if !utils_list.is_empty() {
3406 summary["utils"] = json!(utils_list);
3407 }
3408
3409 Some(summary)
3410}
3411
3412fn preflight_parseable_pattern(request: &StructuralSearchRequest) -> Result<Option<String>> {
3413 let Some(language) = request
3414 .lang
3415 .as_deref()
3416 .and_then(AstGrepLanguage::from_user_value)
3417 else {
3418 return Ok(None);
3419 };
3420
3421 if !language.has_local_parser() {
3422 return Ok(None);
3423 }
3424
3425 let pattern = request.pattern().expect("query pattern validated");
3426 validate_metavariable_syntax(pattern)?;
3427
3428 let (sanitized_pattern, contains_metavariables) = sanitize_pattern_for_tree_sitter(pattern);
3429 let tree = match parse_source(language, &sanitized_pattern) {
3430 Ok(tree) => tree,
3431 Err(_) if contains_metavariables => {
3432 return Ok(Some(fragment_pattern_hint(request, language)));
3433 }
3434 Err(detail) => {
3435 bail!(
3436 "{}",
3437 format_ast_grep_failure(
3438 "structural pattern preflight failed",
3439 format!(
3440 "pattern is not parseable as {} syntax ({detail})",
3441 language.display_name()
3442 ),
3443 )
3444 );
3445 }
3446 };
3447
3448 if tree.root_node().has_error() {
3449 if contains_metavariables {
3450 return Ok(Some(fragment_pattern_hint(request, language)));
3451 }
3452
3453 bail!(
3454 "{}",
3455 format_ast_grep_failure(
3456 "structural pattern preflight failed",
3457 format!(
3458 "pattern is not parseable as {} syntax",
3459 language.display_name()
3460 ),
3461 )
3462 );
3463 }
3464
3465 Ok(None)
3466}
3467
3468fn sanitize_pattern_for_tree_sitter(pattern: &str) -> (String, bool) {
3469 let mut contains_metavariables = false;
3470 let sanitized =
3471 AST_GREP_METAVARIABLE_RE.replace_all(pattern, |captures: ®ex::Captures<'_>| {
3472 contains_metavariables = true;
3473 if captures
3474 .get(0)
3475 .is_some_and(|matched| matched.as_str().starts_with("$$"))
3476 {
3477 "placeholders"
3478 } else {
3479 "placeholder"
3480 }
3481 });
3482
3483 (sanitized.into_owned(), contains_metavariables)
3484}
3485
3486fn validate_metavariable_syntax(pattern: &str) -> Result<()> {
3488 static AST_GREP_DOLLAR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
3489 Regex::new(r"\$\$?[A-Za-z0-9_]+").expect("ast-grep dollar token regex must compile")
3490 });
3491
3492 for mat in AST_GREP_DOLLAR_TOKEN_RE.find_iter(pattern) {
3493 let token = mat.as_str();
3494 if !AST_GREP_VALID_METAVAR_RE.is_match(token) {
3495 if token.starts_with("$$$") {
3496 let rest = &token[3..];
3497 if rest.is_empty()
3498 || !rest
3499 .chars()
3500 .next()
3501 .is_some_and(|c| c.is_ascii_uppercase() || c == '_')
3502 {
3503 bail!(
3504 "invalid metavariable `{token}`: multi-metavariable `$$$` must be followed by an uppercase name (e.g. `$$$ARGS`); got `{rest}`"
3505 );
3506 }
3507 } else if token == "$" || token == "$$" {
3508 bail!(
3509 "bare `{token}` is not a valid metavariable; use `$NAME` (named node), `$$NAME` (unnamed node), or `$$$NAME` (zero or more nodes)"
3510 );
3511 } else {
3512 let prefix = if token.starts_with("$$") { "$$" } else { "$" };
3513 let name = &token[prefix.len()..];
3514 if name.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
3515 bail!(
3516 "invalid metavariable `{token}`: names must start with an uppercase letter or underscore; use `{prefix}{upper}` instead",
3517 upper = name.to_ascii_uppercase()
3518 );
3519 }
3520 bail!(
3521 "invalid metavariable `{token}`: names must match `[A-Z_][A-Z0-9_]*` after the `$` or `$$` prefix"
3522 );
3523 }
3524 }
3525 }
3526 Ok(())
3527}
3528
3529fn reject_forbidden_args(args: &Value) -> Result<()> {
3530 let Some(object) = args.as_object() else {
3531 return Ok(());
3532 };
3533
3534 for key in STRUCTURAL_FORBIDDEN_KEYS {
3535 if has_argument_key(object, key) {
3536 bail!(
3537 "action='structural' is read-only; remove `{}`. For `sg scan`, `sg test`, `sg new`, `sgconfig.yml`, or rewrite-oriented ast-grep tasks, load the bundled `ast-grep` skill first and use `unified_exec` only when the public structural surface cannot express the needed CLI flow.",
3538 key
3539 );
3540 }
3541 }
3542
3543 Ok(())
3544}
3545
3546fn has_argument_key(object: &Map<String, Value>, key: &str) -> bool {
3547 object.get(key).is_some()
3548 || key
3549 .contains('_')
3550 .then(|| key.replace('_', "-"))
3551 .as_ref()
3552 .is_some_and(|hyphenated| object.get(hyphenated).is_some())
3553}
3554
3555fn parse_compact_matches(stdout: &[u8]) -> Result<Vec<AstGrepMatch>> {
3556 serde_json::from_slice(stdout).context("failed to parse ast-grep JSON output")
3557}
3558
3559fn parse_stream_findings(stdout: &[u8]) -> Result<Vec<AstGrepScanFinding>> {
3560 let stdout = String::from_utf8_lossy(stdout);
3561 stdout
3562 .lines()
3563 .filter(|line| !line.trim().is_empty())
3564 .map(|line| {
3565 serde_json::from_str(line).with_context(|| {
3566 format!("failed to parse ast-grep JSON stream output line: {line}")
3567 })
3568 })
3569 .collect()
3570}
3571
3572fn ast_grep_command(ast_grep: &Path, workspace_root: &Path, subcommand: &str) -> Command {
3573 let mut command = Command::new(ast_grep);
3574 command.current_dir(workspace_root).arg(subcommand);
3575 command
3576}
3577
3578async fn run_ast_grep_command(
3579 command: &mut Command,
3580 context: &str,
3581) -> Result<std::process::Output> {
3582 command.output().await.with_context(|| context.to_string())
3583}
3584
3585fn apply_context_and_globs(
3586 command: &mut Command,
3587 context_lines: Option<usize>,
3588 before_lines: Option<usize>,
3589 after_lines: Option<usize>,
3590 globs: &[String],
3591) {
3592 if let Some(before) = before_lines {
3593 command.arg("--before").arg(before.to_string());
3594 }
3595 if let Some(after) = after_lines {
3596 command.arg("--after").arg(after.to_string());
3597 }
3598 if before_lines.is_none() && after_lines.is_none() {
3600 if let Some(context_lines) = context_lines {
3601 command.arg("--context").arg(context_lines.to_string());
3602 }
3603 }
3604 for glob in globs {
3605 command.arg("--globs").arg(glob);
3606 }
3607}
3608
3609fn build_debug_query_result(
3610 request: &StructuralSearchRequest,
3611 display_path: &str,
3612 debug_query: &DebugQueryFormat,
3613 stdout: &[u8],
3614) -> Value {
3615 let mut result = json!({
3616 "backend": "ast-grep",
3617 "path": display_path,
3618 "lang": request.lang,
3619 "debug_query": debug_query.as_str(),
3620 "debug_query_output": truncate_auxiliary_output(String::from_utf8_lossy(stdout).trim()),
3621 "matches": [],
3622 "truncated": false,
3623 });
3624 if let Some(pattern) = request.pattern() {
3625 result["pattern"] = json!(pattern);
3626 }
3627 if let Some(kind) = request.kind() {
3628 result["kind"] = json!(kind);
3629 }
3630 result
3631}
3632
3633fn build_query_result(
3634 request: &StructuralSearchRequest,
3635 display_path: &str,
3636 matches: Vec<AstGrepMatch>,
3637) -> Value {
3638 let max_results = request.effective_max_results();
3639 let truncated = matches.len() > max_results;
3640 let normalized_matches = matches
3641 .into_iter()
3642 .take(max_results)
3643 .map(normalize_match)
3644 .collect::<Vec<_>>();
3645
3646 let mut result = json!({
3647 "backend": "ast-grep",
3648 "path": display_path,
3649 "matches": normalized_matches,
3650 "truncated": truncated,
3651 });
3652 if let Some(pattern) = request.pattern() {
3653 result["pattern"] = json!(pattern);
3654 }
3655 if let Some(kind) = request.kind() {
3656 result["kind"] = json!(kind);
3657 }
3658 result
3659}
3660
3661fn build_scan_result(
3662 request: &StructuralSearchRequest,
3663 display_path: &str,
3664 config_display_path: &str,
3665 findings: Vec<AstGrepScanFinding>,
3666) -> Value {
3667 let filtered_findings = if let Some(severities) = request.effective_severities() {
3669 findings
3670 .into_iter()
3671 .filter(|f| {
3672 f.severity
3673 .as_ref()
3674 .map(|s| severities.contains(&s.as_str()))
3675 .unwrap_or(false)
3676 })
3677 .collect::<Vec<_>>()
3678 } else {
3679 findings
3680 };
3681
3682 let max_results = request.effective_max_results();
3683 let truncated = filtered_findings.len() > max_results;
3684 let normalized_findings = filtered_findings
3685 .iter()
3686 .take(max_results)
3687 .map(normalize_scan_finding)
3688 .collect::<Vec<_>>();
3689
3690 json!({
3691 "backend": "ast-grep",
3692 "workflow": "scan",
3693 "config_path": config_display_path,
3694 "path": display_path,
3695 "findings": normalized_findings,
3696 "summary": build_scan_summary(&filtered_findings, normalized_findings.len(), truncated),
3697 "truncated": truncated,
3698 })
3699}
3700
3701fn build_fragment_result(
3702 request: &StructuralSearchRequest,
3703 display_path: &str,
3704 hint: String,
3705) -> Value {
3706 let next_action = "Retry `unified_search` with `action='structural'` using a larger parseable pattern and `selector` when the real target is a subnode inside that pattern. Do not rerun the same fragment unchanged.";
3707
3708 let mut result = json!({
3709 "backend": "ast-grep",
3710 "path": display_path,
3711 "matches": [],
3712 "truncated": false,
3713 "is_recoverable": true,
3714 "next_action": next_action,
3715 "hint": hint,
3716 });
3717 if let Some(pattern) = request.pattern() {
3718 result["pattern"] = json!(pattern);
3719 }
3720 if let Some(kind) = request.kind() {
3721 result["kind"] = json!(kind);
3722 }
3723 result
3724}
3725
3726fn build_range_value(range: &AstGrepRange) -> Value {
3727 let mut range_object = json!({
3728 "start": {
3729 "line": range.start.line,
3730 "column": range.start.column,
3731 },
3732 "end": {
3733 "line": range.end.line,
3734 "column": range.end.column,
3735 },
3736 });
3737 if let Some(byte_offset) = &range.byte_offset {
3738 range_object["byteOffset"] = json!({
3739 "start": byte_offset.start,
3740 "end": byte_offset.end,
3741 });
3742 }
3743 range_object
3744}
3745
3746fn build_meta_var_value(var: &AstGrepMetaVar) -> Value {
3747 json!({
3748 "text": var.text,
3749 "range": build_range_value(&var.range),
3750 })
3751}
3752
3753fn build_meta_variables_value(meta_vars: &AstGrepMetaVariables) -> Value {
3754 let mut single = Map::new();
3755 for (key, var) in &meta_vars.single {
3756 single.insert(key.clone(), build_meta_var_value(var));
3757 }
3758
3759 let mut multi = Map::new();
3760 for (key, vars) in &meta_vars.multi {
3761 let vars_json: Vec<Value> = vars.iter().map(build_meta_var_value).collect();
3762 multi.insert(key.clone(), Value::Array(vars_json));
3763 }
3764
3765 let transformed = if meta_vars.transformed.is_empty() {
3766 Value::Object(Map::new())
3767 } else {
3768 json!(meta_vars.transformed)
3769 };
3770
3771 json!({
3772 "single": Value::Object(single),
3773 "multi": Value::Object(multi),
3774 "transformed": transformed,
3775 })
3776}
3777
3778fn normalize_match(entry: AstGrepMatch) -> Value {
3779 let mut match_object = Map::new();
3780 match_object.insert("file".to_string(), Value::String(entry.file));
3781 match_object.insert("line_number".to_string(), json!(entry.range.start.line));
3782 match_object.insert("text".to_string(), Value::String(entry.text.clone()));
3783 match_object.insert(
3784 "lines".to_string(),
3785 Value::String(entry.lines.unwrap_or(entry.text)),
3786 );
3787 if let Some(language) = entry.language {
3788 match_object.insert("language".to_string(), Value::String(language));
3789 }
3790 match_object.insert("range".to_string(), build_range_value(&entry.range));
3791 if let Some(meta_vars) = entry.meta_variables {
3792 match_object.insert(
3793 "metaVariables".to_string(),
3794 build_meta_variables_value(&meta_vars),
3795 );
3796 }
3797 Value::Object(match_object)
3798}
3799
3800fn normalize_scan_finding(entry: &AstGrepScanFinding) -> Value {
3801 let mut finding_object = Map::new();
3802 finding_object.insert("file".to_string(), Value::String(entry.file.clone()));
3803 finding_object.insert("line_number".to_string(), json!(entry.range.start.line));
3804 finding_object.insert("text".to_string(), Value::String(entry.text.clone()));
3805 finding_object.insert(
3806 "lines".to_string(),
3807 Value::String(entry.lines.clone().unwrap_or_else(|| entry.text.clone())),
3808 );
3809 if let Some(language) = &entry.language {
3810 finding_object.insert("language".to_string(), Value::String(language.clone()));
3811 }
3812 finding_object.insert("range".to_string(), build_range_value(&entry.range));
3813 finding_object.insert("rule_id".to_string(), json!(entry.rule_id));
3814 finding_object.insert(
3815 "severity".to_string(),
3816 json!(entry.severity.map(|s| s.to_string())),
3817 );
3818 finding_object.insert("message".to_string(), json!(entry.message));
3819 finding_object.insert("note".to_string(), json!(entry.note));
3820 if let Some(metadata) = &entry.metadata {
3821 if let Some(url) = metadata.get("url").or_else(|| metadata.get("docs")) {
3822 finding_object.insert("url".to_string(), url.clone());
3823 }
3824 finding_object.insert("metadata".to_string(), metadata.clone());
3825 }
3826 if !entry.labels.is_empty() {
3827 let labels_json: Vec<Value> = entry
3828 .labels
3829 .iter()
3830 .map(|label| {
3831 let mut label_obj = Map::new();
3832 label_obj.insert("text".to_string(), Value::String(label.text.clone()));
3833 label_obj.insert("range".to_string(), build_range_value(&label.range));
3834 if let Some(source) = &label.source {
3835 label_obj.insert("source".to_string(), Value::String(source.clone()));
3836 }
3837 Value::Object(label_obj)
3838 })
3839 .collect();
3840 finding_object.insert("labels".to_string(), Value::Array(labels_json));
3841 }
3842 Value::Object(finding_object)
3843}
3844
3845fn build_scan_summary(findings: &[AstGrepScanFinding], returned: usize, truncated: bool) -> Value {
3846 let mut by_severity = BTreeMap::new();
3847 let mut by_rule = BTreeMap::new();
3848 let mut has_error_findings = false;
3849
3850 for finding in findings {
3851 let severity = finding
3852 .severity
3853 .as_ref()
3854 .map(|s| s.as_str())
3855 .unwrap_or("unknown")
3856 .to_string();
3857 if severity == "error" {
3858 has_error_findings = true;
3859 }
3860 *by_severity.entry(severity).or_insert(0usize) += 1;
3861
3862 let rule = finding.rule_id.as_deref().unwrap_or("unknown").to_string();
3863 *by_rule.entry(rule).or_insert(0usize) += 1;
3864 }
3865
3866 json!({
3867 "total_findings": findings.len(),
3868 "returned_findings": returned,
3869 "truncated": truncated,
3870 "has_error_findings": has_error_findings,
3871 "by_severity": by_severity,
3872 "by_rule": by_rule,
3873 })
3874}
3875
3876fn build_test_result(
3877 config_display_path: &str,
3878 passed: bool,
3879 stdout: &[u8],
3880 stderr: &[u8],
3881) -> Value {
3882 let stdout = truncate_auxiliary_output(&String::from_utf8_lossy(stdout));
3883 let stderr = truncate_auxiliary_output(&String::from_utf8_lossy(stderr));
3884
3885 json!({
3886 "backend": "ast-grep",
3887 "workflow": "test",
3888 "config_path": config_display_path,
3889 "passed": passed,
3890 "stdout": stdout,
3891 "stderr": stderr,
3892 "summary": summarize_test_output(&stdout, &stderr, passed),
3893 })
3894}
3895
3896fn truncate_auxiliary_output(text: &str) -> String {
3897 let char_count = text.chars().count();
3898 if char_count <= MAX_AUXILIARY_OUTPUT_CHARS {
3899 return text.to_string();
3900 }
3901
3902 let truncated = text
3903 .chars()
3904 .take(MAX_AUXILIARY_OUTPUT_CHARS)
3905 .collect::<String>();
3906 format!(
3907 "{truncated}\n...[truncated {} chars]",
3908 char_count - MAX_AUXILIARY_OUTPUT_CHARS
3909 )
3910}
3911
3912fn stderr_or_stdout(stderr: &[u8], stdout: &[u8]) -> String {
3913 let stderr_text = String::from_utf8_lossy(stderr).trim().to_string();
3914 if !stderr_text.is_empty() {
3915 return stderr_text;
3916 }
3917
3918 let stdout_text = String::from_utf8_lossy(stdout).trim().to_string();
3919 if !stdout_text.is_empty() {
3920 return stdout_text;
3921 }
3922
3923 "ast-grep exited without output".to_string()
3924}
3925
3926#[cold]
3927fn format_ast_grep_failure(prefix: &str, detail: String) -> String {
3928 let needs_project_config_hint = looks_like_language_support_issue(&detail);
3929 let mut message = format!("{prefix}: {detail}. {AST_GREP_FAQ_HINT}");
3930 if needs_project_config_hint {
3931 message.push(' ');
3932 message.push_str(AST_GREP_PROJECT_CONFIG_HINT);
3933 }
3934 message.push_str(
3935 " Retry `unified_search` with a refined structural pattern before switching tools. For simple rewrites, use `workflow='rewrite'` on the public structural surface. For FixConfig rewrites with range expansion, use `workflow='rewrite'` with `fix_config` on the public surface. For `sg scan`, `sg test`, `sg new`, `sgconfig.yml`, or advanced rewrite-oriented ast-grep tasks with `transform` or `rewriters`, load the bundled `ast-grep` skill first and use `unified_exec` only when the public structural surface and skill guidance still cannot express the needed CLI flow.",
3936 );
3937 if !detail.contains(AST_GREP_INSTALL_COMMAND) {
3938 message.push(' ');
3939 message.push_str(&format!(
3940 "If the binary is missing, install it with `{AST_GREP_INSTALL_COMMAND}`."
3941 ));
3942 }
3943 message
3944}
3945
3946fn looks_like_language_support_issue(detail: &str) -> bool {
3947 let detail = detail.to_ascii_lowercase();
3948 (detail.contains("lang") || detail.contains("language") || detail.contains("extension"))
3949 && (detail.contains("unsupported")
3950 || detail.contains("invalid value")
3951 || detail.contains("unknown")
3952 || detail.contains("unrecognized")
3953 || detail.contains("not built in")
3954 || detail.contains("not supported"))
3955}
3956
3957fn looks_like_go_call_pattern(pattern: &str) -> bool {
3962 let trimmed = pattern.trim();
3968 let Some(paren) = trimmed.find('(') else {
3969 return false;
3970 };
3971 if paren == 0 || !trimmed.ends_with(')') {
3972 return false;
3973 }
3974 let callee = &trimmed[..paren];
3975 callee.split('.').all(|part| {
3979 let stripped = part.trim_start_matches('$');
3980 !stripped.is_empty()
3981 && stripped
3982 .chars()
3983 .all(|c| c.is_ascii_alphanumeric() || c == '_')
3984 })
3985}
3986
3987fn looks_like_html_attribute_pattern(pattern: &str) -> bool {
3988 let trimmed = pattern.trim();
3992 if trimmed.contains('<') || trimmed.contains('>') {
3993 return false;
3994 }
3995 let Some(eq) = trimmed.find('=') else {
3996 return false;
3997 };
3998 let attr_name = &trimmed[..eq];
3999 !attr_name.is_empty()
4002 && attr_name
4003 .chars()
4004 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':')
4005}
4006
4007fn looks_like_html_tag_pattern(pattern: &str) -> bool {
4008 let trimmed = pattern.trim();
4011 trimmed.starts_with('<')
4012 && trimmed.contains('>')
4013 && !trimmed.contains("</")
4014 && !trimmed.ends_with("/>")
4015}
4016
4017fn looks_like_java_declaration_fragment(pattern: &str) -> bool {
4024 let trimmed = pattern.trim();
4025 if trimmed.starts_with('@') {
4027 return true;
4028 }
4029 if trimmed.ends_with(';') {
4032 let inner = trimmed.trim_end_matches(';').trim();
4034 let parts: Vec<&str> = inner.split_whitespace().collect();
4035 if parts.len() >= 2 {
4036 let last = parts.last().unwrap();
4038 if last.starts_with('$') || last.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
4039 {
4040 return true;
4041 }
4042 }
4043 }
4044 false
4045}
4046
4047fn looks_like_css_selector_fragment(pattern: &str) -> bool {
4054 let trimmed = pattern.trim();
4055 if trimmed.starts_with('.') && trimmed.len() > 1 && !trimmed.contains('{') {
4056 return true;
4057 }
4058 if trimmed.starts_with('#') && trimmed.len() > 1 && !trimmed.contains('{') {
4059 return true;
4060 }
4061 false
4062}
4063
4064fn looks_like_python_decorator_fragment(pattern: &str) -> bool {
4065 let trimmed = pattern.trim();
4066 if trimmed.starts_with('@') && trimmed.len() > 1 && !trimmed.contains('\n') {
4067 let rest = &trimmed[1..];
4068 return rest
4069 .chars()
4070 .next()
4071 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
4072 }
4073 false
4074}
4075
4076fn looks_like_ruby_block_fragment(pattern: &str) -> bool {
4077 let trimmed = pattern.trim();
4078
4079 if trimmed.starts_with('&') && trimmed.len() > 1 {
4081 let after = &trimmed[1..];
4082 if after.starts_with(':') && after.len() > 1 {
4083 return true;
4084 }
4085 }
4086
4087 if trimmed.starts_with('{') && trimmed.contains('|') {
4089 return true;
4090 }
4091 if trimmed.starts_with("do") && trimmed.contains('|') {
4092 return true;
4093 }
4094
4095 if trimmed.starts_with('|') {
4097 return true;
4098 }
4099
4100 false
4101}
4102
4103fn fragment_pattern_hint(request: &StructuralSearchRequest, language: AstGrepLanguage) -> String {
4104 let trimmed = request.pattern().expect("query pattern validated");
4105 let mut message = format!(
4106 "Pattern looks like a code fragment, not standalone parseable {} syntax for `action='structural'`.",
4107 language.display_name()
4108 );
4109
4110 if language == AstGrepLanguage::Rust
4111 && (trimmed.starts_with("Result<")
4112 || trimmed.starts_with("-> Result<")
4113 || trimmed.contains("-> Result<"))
4114 {
4115 message.push_str(
4116 " For Result return-type queries, anchor it in a full signature like `fn $NAME($$ARGS) -> Result<$T> { $$BODY }`.",
4117 );
4118 } else if language == AstGrepLanguage::Go && looks_like_go_call_pattern(trimmed) {
4119 message.push_str(
4120 " In Go, tree-sitter parses bare call-like fragments (e.g. `fmt.Println($A)`) as type conversions, not call expressions. \
4121 Wrap the call in surrounding parseable code like `func t() { fmt.Println($A) }` and use `selector: call_expression` to match only function calls. \
4122 Note: contextual patterns with `context` + `selector` require the CLI skill path via `unified_exec`.",
4123 );
4124 } else if language == AstGrepLanguage::Html && looks_like_html_attribute_pattern(trimmed) {
4125 message.push_str(
4126 " In HTML, bare attribute expressions like `class=$VAL` are not standalone parseable code. \
4127 Use `kind: attribute_name` to match attribute names, `kind: attribute_value` for values, \
4128 or `kind: element` with `has` to match elements containing specific attributes. \
4129 For example, to match elements with a specific attribute, use `kind: element` with \
4130 `has: { kind: attribute_name, regex: \"^class$\" }`.",
4131 );
4132 } else if language == AstGrepLanguage::Html && looks_like_html_tag_pattern(trimmed) {
4133 message.push_str(
4134 " In HTML, tree-sitter parses tag structures as `element` nodes with `tag_name` and `attribute` children. \
4135 Bare tag fragments like `<$TAG>` are not standalone code. Use `kind: element` with \
4136 `has: { field: tag_name, pattern: $TAG }` to match elements by tag name, \
4137 or `kind: tag_name` to match tag name nodes directly.",
4138 );
4139 } else if language == AstGrepLanguage::Java && looks_like_java_declaration_fragment(trimmed) {
4140 message.push_str(
4141 " In Java, bare type declarations, annotations, and field fragments are not standalone parseable code. \
4142 For field or variable declarations with modifiers/annotations, use `kind: field_declaration` with \
4143 `has: { field: type, regex: \"^TypeName$\" }` to match by type regardless of modifiers. \
4144 For annotations, use `kind: marker_annotation` or `kind: annotation` with `inside` to scope \
4145 to the declaration you care about. Wrap bare fragments in a full class body like \
4146 `class _ { $TYPE $VAR; }` and use `selector` to target the inner node.",
4147 );
4148 } else if language == AstGrepLanguage::Ruby && looks_like_ruby_block_fragment(trimmed) {
4149 message.push_str(
4150 " In Ruby, bare block fragments like `{ |$V| $V.$METHOD }` or `do |$V| $V.$METHOD end` are not \
4151 standalone parseable code. Wrap the block in a method call like `$LIST.select { |$V| $V.$METHOD }` \
4152 and use `selector: call` to match the outer call. For symbol-to-proc patterns, match the enclosing \
4153 method call directly with `$LIST.$ITER(&:$METHOD)`. Key Ruby tree-sitter node kinds: `call` for \
4154 method calls, `method_call` for keyword-style calls, `block` for `{{ }}` blocks, `do_block` for \
4155 `do...end` blocks, `symbol` for `:name` literals, `assignment` for variable assignments.",
4156 );
4157 } else if language == AstGrepLanguage::Css && looks_like_css_selector_fragment(trimmed) {
4158 message.push_str(
4159 " In CSS, bare selectors like `.class` or `#id` are not standalone parseable code. \
4160 Use `kind: rule_set` with `has` to match rule sets containing specific selectors, or \
4161 `kind: selector` to match selector nodes.",
4162 );
4163 } else if language == AstGrepLanguage::Python && looks_like_python_decorator_fragment(trimmed) {
4164 message.push_str(
4165 " In Python, bare decorators like `@property` are not standalone parseable code. \
4166 Wrap with the decorated definition and use `selector: decorated_definition` to match.",
4167 );
4168 } else if language == AstGrepLanguage::Bash && !trimmed.contains(';') {
4169 message.push_str(
4170 " In Bash, bare command fragments need script context. Use `kind: command` with `has` to match specific commands.",
4171 );
4172 } else {
4173 message.push_str(
4174 " Wrap the target in surrounding parseable code, then use `selector` only to focus the real subnode inside that larger pattern.",
4175 );
4176 }
4177
4178 message.push_str(" Retry `unified_search` with `action='structural'` using a larger parseable pattern before switching tools. Do not retry the same fragment with grep if syntax matters.");
4179 message
4180}
4181
4182struct ResolvedSearchPath {
4183 command_arg: String,
4184 display_path: String,
4185}
4186
4187fn build_resolved_workspace_path(
4188 workspace_root: &Path,
4189 resolved: PathBuf,
4190) -> Result<ResolvedSearchPath> {
4191 let workspace_root = std::fs::canonicalize(workspace_root).with_context(|| {
4192 format!(
4193 "Failed to canonicalize workspace root {}",
4194 workspace_root.display()
4195 )
4196 })?;
4197
4198 let display_path = if let Ok(relative) = resolved.strip_prefix(&workspace_root) {
4199 if relative.as_os_str().is_empty() {
4200 ".".to_string()
4201 } else {
4202 relative.to_string_lossy().replace('\\', "/")
4203 }
4204 } else {
4205 resolved.to_string_lossy().replace('\\', "/")
4206 };
4207
4208 let command_arg = if display_path == "." {
4209 ".".to_string()
4210 } else if Path::new(&display_path).is_relative() {
4211 display_path.clone()
4212 } else {
4213 resolved.to_string_lossy().to_string()
4214 };
4215
4216 Ok(ResolvedSearchPath {
4217 command_arg,
4218 display_path,
4219 })
4220}
4221
4222fn resolve_search_path(workspace_root: &Path, requested_path: &str) -> Result<ResolvedSearchPath> {
4223 let requested = PathBuf::from(requested_path);
4224 let resolved = resolve_workspace_path(workspace_root, requested.as_path())
4225 .or_else(|original_error| {
4226 let Some(remapped) =
4227 remap_legacy_crates_search_path(workspace_root, requested.as_path())
4228 else {
4229 return Err(original_error);
4230 };
4231 resolve_workspace_path(workspace_root, remapped.as_path()).with_context(|| {
4232 format!("Failed to resolve structural search path: {requested_path}")
4233 })
4234 })
4235 .with_context(|| format!("Failed to resolve structural search path: {requested_path}"))?;
4236
4237 build_resolved_workspace_path(workspace_root, resolved)
4238}
4239
4240fn remap_legacy_crates_search_path(
4241 workspace_root: &Path,
4242 requested_path: &Path,
4243) -> Option<PathBuf> {
4244 let relative = if requested_path.is_absolute() {
4245 requested_path.strip_prefix(workspace_root).ok()?
4246 } else {
4247 requested_path
4248 };
4249
4250 let mut components = relative.components();
4251 match components.next()? {
4252 Component::Normal(component) if component == "crates" => {}
4253 _ => return None,
4254 }
4255
4256 let remapped: PathBuf = components.collect();
4257 if remapped.as_os_str().is_empty() {
4258 return None;
4259 }
4260
4261 workspace_root.join(&remapped).exists().then_some(remapped)
4262}
4263
4264async fn resolve_config_path(
4265 workspace_root: &Path,
4266 requested_path: &str,
4267 require_exists: bool,
4268) -> Result<ResolvedSearchPath> {
4269 let candidate = if Path::new(requested_path).is_absolute() {
4270 PathBuf::from(requested_path)
4271 } else {
4272 workspace_root.join(requested_path)
4273 };
4274 let normalized = normalize_path(&candidate);
4275 let resolved = canonicalize_allow_missing(&normalized)
4276 .await
4277 .with_context(|| format!("Failed to resolve structural config path: {requested_path}"))?;
4278 let workspace_root = tokio::fs::canonicalize(workspace_root)
4279 .await
4280 .with_context(|| {
4281 format!(
4282 "Failed to canonicalize workspace root {}",
4283 workspace_root.display()
4284 )
4285 })?;
4286 if !resolved.starts_with(&workspace_root) {
4287 bail!(
4288 "Path {} escapes workspace root {}",
4289 resolved.display(),
4290 workspace_root.display()
4291 );
4292 }
4293
4294 if require_exists && !resolved.is_file() {
4295 let is_default = requested_path == DEFAULT_AST_GREP_CONFIG_PATH;
4296 let discovered = if is_default {
4297 discover_project_config(&workspace_root).await
4298 } else {
4299 None
4300 };
4301 bail!(
4302 "{}",
4303 format_missing_config_error(
4304 requested_path,
4305 is_default,
4306 &resolved,
4307 discovered.as_deref()
4308 )
4309 );
4310 }
4311
4312 build_resolved_workspace_path(&workspace_root, resolved)
4313}
4314
4315async fn discover_project_config(start: &Path) -> Option<PathBuf> {
4317 let mut current = Some(start.to_path_buf());
4318 while let Some(dir) = current {
4319 let candidate = dir.join(DEFAULT_AST_GREP_CONFIG_PATH);
4320 if candidate.is_file() {
4321 return Some(candidate);
4322 }
4323 current = dir.parent().map(Path::to_path_buf);
4324 }
4325 None
4326}
4327
4328fn format_missing_config_error(
4329 requested: &str,
4330 is_default: bool,
4331 resolved: &Path,
4332 discovered: Option<&Path>,
4333) -> String {
4334 let mut message = if is_default {
4335 format!(
4336 "ast-grep project config `{}` not found at {}. \
4337 `workflow=\"scan\"` and `workflow=\"test\"` require a project config file. \
4338 Create `{}` with at least:\n\n ruleDirs:\n - rules\n\n\
4339 Then place rule YAML files in the `rules/` directory. \
4340 Or scaffold a full project with `ast-grep new project`. \
4341 For config authoring, load the bundled `ast-grep` skill.",
4342 requested,
4343 resolved.display(),
4344 requested,
4345 )
4346 } else {
4347 format!(
4348 "ast-grep project config not found at `{}` (resolved to {}). \
4349 Verify the `config_path` is correct and the file exists. \
4350 For config authoring, load the bundled `ast-grep` skill.",
4351 requested,
4352 resolved.display(),
4353 )
4354 };
4355
4356 if let Some(found) = discovered {
4357 message.push_str(&format!(
4358 "\n\nNote: found `{}` at {}. \
4359 Set `config_path` to that path to use it, or create a local `{}` in the workspace root.",
4360 DEFAULT_AST_GREP_CONFIG_PATH,
4361 found.display(),
4362 DEFAULT_AST_GREP_CONFIG_PATH,
4363 ));
4364 }
4365
4366 message
4367}
4368
4369async fn extract_rule_dirs(config_path: &Path) -> Vec<String> {
4372 let Ok(content) = afs::read_to_string(config_path).await else {
4373 return Vec::new();
4374 };
4375
4376 let mut dirs = Vec::new();
4377 let mut in_rule_dirs = false;
4378
4379 for line in content.lines() {
4380 let trimmed = line.trim();
4381 if trimmed.starts_with("ruleDirs:") {
4382 in_rule_dirs = true;
4383 if let Some(bracket_content) = trimmed.strip_prefix("ruleDirs:").map(str::trim) {
4385 if bracket_content.starts_with('[') {
4386 let inner = bracket_content.trim_matches(|c| c == '[' || c == ']');
4387 for item in inner.split(',') {
4388 let item = item.trim().trim_matches('"').trim_matches('\'');
4389 if !item.is_empty() {
4390 dirs.push(item.to_string());
4391 }
4392 }
4393 in_rule_dirs = false;
4394 }
4395 }
4396 continue;
4397 }
4398
4399 if in_rule_dirs {
4400 if trimmed.starts_with('-') {
4401 let item = trimmed
4402 .strip_prefix('-')
4403 .unwrap_or(trimmed)
4404 .trim()
4405 .trim_matches('"')
4406 .trim_matches('\'');
4407 if !item.is_empty() {
4408 dirs.push(item.to_string());
4409 }
4410 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
4411 in_rule_dirs = false;
4413 }
4414 }
4415 }
4416
4417 dirs
4418}
4419
4420async fn extract_test_configs(config_path: &Path) -> Vec<Value> {
4423 let Ok(content) = afs::read_to_string(config_path).await else {
4424 return Vec::new();
4425 };
4426
4427 let mut configs = Vec::new();
4428 let mut in_test_configs = false;
4429 let mut current_item: Option<Map<String, Value>> = None;
4430
4431 for line in content.lines() {
4432 let trimmed = line.trim();
4433
4434 if trimmed.starts_with("testConfigs:") {
4435 in_test_configs = true;
4436 if trimmed.contains('[') {
4437 break; }
4439 continue;
4440 }
4441
4442 if !in_test_configs {
4443 continue;
4444 }
4445
4446 if trimmed.starts_with("- ") {
4448 if let Some(item) = current_item.take() {
4450 configs.push(Value::Object(item));
4451 }
4452 current_item = Some(Map::new());
4453 let after_dash = trimmed.strip_prefix("- ").unwrap_or(trimmed).trim();
4455 if let Some((key, value)) = parse_yaml_simple_kv(after_dash) {
4456 if let Some(ref mut item) = current_item {
4457 item.insert(key, value);
4458 }
4459 }
4460 continue;
4461 }
4462
4463 if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
4465 if let Some(item) = current_item.take() {
4466 configs.push(Value::Object(item));
4467 }
4468 in_test_configs = false;
4469 continue;
4470 }
4471
4472 if let Some(ref mut item) = current_item {
4474 if let Some((key, value)) = parse_yaml_simple_kv(trimmed) {
4475 item.insert(key, value);
4476 }
4477 }
4478 }
4479
4480 if let Some(item) = current_item {
4482 configs.push(Value::Object(item));
4483 }
4484
4485 configs
4486}
4487
4488async fn extract_util_dirs(config_path: &Path) -> Vec<String> {
4491 let Ok(content) = afs::read_to_string(config_path).await else {
4492 return Vec::new();
4493 };
4494
4495 let mut dirs = Vec::new();
4496 let mut in_util_dirs = false;
4497
4498 for line in content.lines() {
4499 let trimmed = line.trim();
4500 if trimmed.starts_with("utilDirs:") {
4501 in_util_dirs = true;
4502 if let Some(bracket_content) = trimmed.strip_prefix("utilDirs:").map(str::trim) {
4504 if bracket_content.starts_with('[') {
4505 let inner = bracket_content.trim_matches(|c| c == '[' || c == ']');
4506 for item in inner.split(',') {
4507 let item = item.trim().trim_matches('"').trim_matches('\'');
4508 if !item.is_empty() {
4509 dirs.push(item.to_string());
4510 }
4511 }
4512 in_util_dirs = false;
4513 }
4514 }
4515 continue;
4516 }
4517
4518 if in_util_dirs {
4519 if trimmed.starts_with('-') {
4520 let item = trimmed
4521 .strip_prefix('-')
4522 .unwrap_or(trimmed)
4523 .trim()
4524 .trim_matches('"')
4525 .trim_matches('\'');
4526 if !item.is_empty() {
4527 dirs.push(item.to_string());
4528 }
4529 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
4530 in_util_dirs = false;
4532 }
4533 }
4534 }
4535
4536 dirs
4537}
4538
4539async fn extract_language_injections(config_path: &Path) -> Vec<Value> {
4543 let Ok(content) = std::fs::read_to_string(config_path) else {
4544 return Vec::new();
4545 };
4546
4547 let mut injections = Vec::new();
4548 let mut in_injections = false;
4549 let mut current_item: Option<Map<String, Value>> = None;
4550
4551 for line in content.lines() {
4552 let trimmed = line.trim();
4553
4554 if trimmed.starts_with("languageInjections:") {
4556 in_injections = true;
4557 if trimmed.contains('[') {
4559 break; }
4561 continue;
4562 }
4563
4564 if !in_injections {
4565 continue;
4566 }
4567
4568 if trimmed.starts_with("- ") {
4570 if let Some(item) = current_item.take() {
4572 injections.push(Value::Object(item));
4573 }
4574 current_item = Some(Map::new());
4575 let after_dash = trimmed.strip_prefix("- ").unwrap_or(trimmed).trim();
4577 if let Some((key, value)) = parse_yaml_simple_kv(after_dash) {
4578 if let Some(ref mut item) = current_item {
4579 item.insert(key, value);
4580 }
4581 }
4582 continue;
4583 }
4584
4585 if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
4587 if let Some(item) = current_item.take() {
4588 injections.push(Value::Object(item));
4589 }
4590 in_injections = false;
4591 continue;
4592 }
4593
4594 if let Some(ref mut item) = current_item {
4596 if let Some((key, value)) = parse_yaml_simple_kv(trimmed) {
4597 item.insert(key, value);
4598 }
4599 }
4600 }
4601
4602 if let Some(item) = current_item {
4604 injections.push(Value::Object(item));
4605 }
4606
4607 injections
4608}
4609
4610async fn extract_custom_languages(config_path: &Path) -> Value {
4613 let Ok(content) = afs::read_to_string(config_path).await else {
4614 return Value::Object(Map::new());
4615 };
4616
4617 let mut languages = Map::new();
4618 let mut in_custom_languages = false;
4619 let mut current_lang: Option<String> = None;
4620 let mut current_lang_config: Option<Map<String, Value>> = None;
4621
4622 for line in content.lines() {
4623 let trimmed = line.trim();
4624
4625 if trimmed.starts_with("customLanguages:") {
4626 in_custom_languages = true;
4627 continue;
4628 }
4629
4630 if !in_custom_languages {
4631 continue;
4632 }
4633
4634 if !line.starts_with(' ')
4636 && !line.starts_with('\t')
4637 && !trimmed.is_empty()
4638 && !trimmed.starts_with('#')
4639 {
4640 if let (Some(lang), Some(config)) = (current_lang.take(), current_lang_config.take()) {
4641 languages.insert(lang, Value::Object(config));
4642 }
4643 in_custom_languages = false;
4644 continue;
4645 }
4646
4647 let indent = line.len() - line.trim_start().len();
4649 if indent == 2 && trimmed.ends_with(':') && !trimmed.contains(' ') {
4650 if let (Some(lang), Some(config)) = (current_lang.take(), current_lang_config.take()) {
4652 languages.insert(lang, Value::Object(config));
4653 }
4654 current_lang = Some(trimmed.trim_end_matches(':').to_string());
4655 current_lang_config = Some(Map::new());
4656 continue;
4657 }
4658
4659 if let Some(ref mut config) = current_lang_config {
4661 if let Some((key, value)) = parse_yaml_simple_kv(trimmed) {
4662 config.insert(key, value);
4663 }
4664 }
4665 }
4666
4667 if let (Some(lang), Some(config)) = (current_lang, current_lang_config) {
4669 languages.insert(lang, Value::Object(config));
4670 }
4671
4672 Value::Object(languages)
4673}
4674
4675async fn extract_language_globs(config_path: &Path) -> Value {
4678 let Ok(content) = afs::read_to_string(config_path).await else {
4679 return Value::Object(Map::new());
4680 };
4681
4682 let mut globs = Map::new();
4683 let mut in_language_globs = false;
4684 let mut current_lang: Option<String> = None;
4685 let mut current_patterns: Vec<Value> = Vec::new();
4686
4687 for line in content.lines() {
4688 let trimmed = line.trim();
4689
4690 if trimmed.starts_with("languageGlobs:") {
4691 in_language_globs = true;
4692 continue;
4693 }
4694
4695 if !in_language_globs {
4696 continue;
4697 }
4698
4699 if !line.starts_with(' ')
4701 && !line.starts_with('\t')
4702 && !trimmed.is_empty()
4703 && !trimmed.starts_with('#')
4704 {
4705 if let Some(lang) = current_lang.take() {
4706 globs.insert(lang, Value::Array(std::mem::take(&mut current_patterns)));
4707 }
4708 in_language_globs = false;
4709 continue;
4710 }
4711
4712 let indent = line.len() - line.trim_start().len();
4713
4714 if indent == 2 && trimmed.ends_with(':') && !trimmed.starts_with('-') {
4716 if let Some(lang) = current_lang.take() {
4718 globs.insert(lang, Value::Array(std::mem::take(&mut current_patterns)));
4719 }
4720 current_lang = Some(trimmed.trim_end_matches(':').to_string());
4721 continue;
4722 }
4723
4724 if indent >= 4 && trimmed.starts_with("- ") {
4726 let pattern = trimmed
4727 .strip_prefix("- ")
4728 .unwrap_or(trimmed)
4729 .trim()
4730 .trim_matches('"')
4731 .trim_matches('\'');
4732 if !pattern.is_empty() {
4733 current_patterns.push(Value::String(pattern.to_string()));
4734 }
4735 }
4736 }
4737
4738 if let Some(lang) = current_lang {
4740 globs.insert(lang, Value::Array(current_patterns));
4741 }
4742
4743 Value::Object(globs)
4744}
4745
4746fn parse_yaml_simple_kv(input: &str) -> Option<(String, Value)> {
4749 let colon_pos = input.find(':')?;
4750 let key = input[..colon_pos].trim().to_string();
4751 if key.is_empty() || key.contains(' ') {
4752 return None;
4753 }
4754 let raw_value = input[colon_pos + 1..].trim();
4755
4756 if raw_value.is_empty() {
4759 return None;
4760 }
4761
4762 if raw_value.starts_with('[') && raw_value.ends_with(']') {
4764 let inner = &raw_value[1..raw_value.len() - 1];
4765 let items: Vec<Value> = inner
4766 .split(',')
4767 .map(|s| Value::String(s.trim().trim_matches('"').trim_matches('\'').to_string()))
4768 .filter(|v| !v.as_str().is_some_and(str::is_empty))
4769 .collect();
4770 return Some((key, Value::Array(items)));
4771 }
4772
4773 let value = raw_value.trim_matches('"').trim_matches('\'');
4774 Some((key, Value::String(value.to_string())))
4775}
4776
4777fn summarize_test_output(stdout: &str, stderr: &str, passed: bool) -> Value {
4778 let clean_stdout = ANSI_ESCAPE_RE.replace_all(stdout, "");
4779 let clean_stderr = ANSI_ESCAPE_RE.replace_all(stderr, "");
4780 let mut summary = Map::new();
4781 summary.insert(
4782 "status".to_string(),
4783 Value::String(if passed { "ok" } else { "failed" }.to_string()),
4784 );
4785
4786 if let Some(captures) = AST_GREP_TEST_RESULT_RE.captures(&clean_stdout) {
4787 let passed_cases = captures
4788 .get(2)
4789 .and_then(|value| value.as_str().parse::<usize>().ok())
4790 .unwrap_or(0);
4791 let failed_cases = captures
4792 .get(3)
4793 .and_then(|value| value.as_str().parse::<usize>().ok())
4794 .unwrap_or(0);
4795 summary.insert("passed_cases".to_string(), json!(passed_cases));
4796 summary.insert("failed_cases".to_string(), json!(failed_cases));
4797 summary.insert(
4798 "total_cases".to_string(),
4799 json!(passed_cases + failed_cases),
4800 );
4801 }
4802
4803 let rules = parse_test_rule_results(&clean_stdout);
4804 if !rules.is_empty() {
4805 summary.insert(
4806 "rules".to_string(),
4807 Value::Array(
4808 rules
4809 .iter()
4810 .map(|r| {
4811 json!({
4812 "rule_id": r.rule_id,
4813 "passed": r.passed,
4814 "markers": r.markers,
4815 })
4816 })
4817 .collect(),
4818 ),
4819 );
4820 }
4821
4822 let failure_details = parse_test_failure_details(&clean_stdout, &clean_stderr);
4823 if !failure_details.is_empty() {
4824 summary.insert("failure_details".to_string(), Value::Array(failure_details));
4825 }
4826
4827 Value::Object(summary)
4828}
4829
4830struct TestRuleResult {
4832 rule_id: String,
4833 passed: bool,
4834 markers: Vec<String>,
4836}
4837
4838fn parse_test_rule_results(stdout: &str) -> Vec<TestRuleResult> {
4848 let mut results = Vec::new();
4849 for line in stdout.lines() {
4850 let trimmed = line.trim();
4851 if let Some(caps) = AST_GREP_TEST_RULE_LINE_RE.captures(trimmed) {
4852 let status = caps.get(1).map(|m| m.as_str()).unwrap_or("");
4853 let rule_id = caps
4854 .get(2)
4855 .map(|m| m.as_str().to_string())
4856 .unwrap_or_default();
4857 let trailing = caps.get(3).map(|m| m.as_str()).unwrap_or("");
4858 let markers: Vec<String> = trailing
4859 .chars()
4860 .filter_map(|c| match c {
4861 'N' => Some("noisy".to_string()),
4862 'M' => Some("missing".to_string()),
4863 _ => None,
4864 })
4865 .collect();
4866 results.push(TestRuleResult {
4867 rule_id,
4868 passed: status == "PASS",
4869 markers,
4870 });
4871 }
4872 }
4873 results
4874}
4875
4876fn parse_test_failure_details(stdout: &str, stderr: &str) -> Vec<Value> {
4890 let combined = format!("{stdout}\n{stderr}");
4891 let mut details = Vec::new();
4892 let mut lines = combined.lines().peekable();
4893
4894 while let Some(line) = lines.next() {
4895 let trimmed = line.trim();
4896
4897 if let Some(caps) = AST_GREP_TEST_NOISY_RE.captures(trimmed) {
4898 let rule_id = caps.get(1).map(|m| m.as_str()).unwrap_or("");
4899 let code_snippet = extract_failure_snippet(&mut lines);
4900 details.push(json!({
4901 "type": "noisy",
4902 "rule_id": rule_id,
4903 "code_snippet": code_snippet,
4904 }));
4905 } else if let Some(caps) = AST_GREP_TEST_MISSING_RE.captures(trimmed) {
4906 let rule_id = caps.get(1).map(|m| m.as_str()).unwrap_or("");
4907 let code_snippet = extract_failure_snippet(&mut lines);
4908 details.push(json!({
4909 "type": "missing",
4910 "rule_id": rule_id,
4911 "code_snippet": code_snippet,
4912 }));
4913 }
4914 }
4915 details
4916}
4917
4918fn extract_failure_snippet<'a>(lines: &mut std::iter::Peekable<std::str::Lines<'a>>) -> String {
4921 let mut snippet_lines = Vec::new();
4922 while let Some(peeked) = lines.peek() {
4924 if peeked.trim().is_empty() {
4925 lines.next();
4926 } else {
4927 break;
4928 }
4929 }
4930 while let Some(peeked) = lines.peek() {
4932 let trimmed = peeked.trim();
4933 if trimmed.is_empty() || trimmed.starts_with('[') {
4934 break;
4935 }
4936 snippet_lines.push(lines.next().unwrap().to_string());
4937 }
4938 snippet_lines.join("\n")
4939}