Skip to main content

vtcode_core/tools/
structural_search.rs

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});
55/// Valid ast-grep metavariable: `$` or `$$` followed by uppercase/startunderscore,
56/// then uppercase/digits/underscores. Multi-metavariable `$$$` is also valid.
57static 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});
66/// Matches per-rule result lines: `PASS rule-id` or `FAIL rule-id` with
67/// optional trailing dots and N/M markers (e.g. `FAIL rust/foo ...N..M`).
68static 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});
72/// Matches failure detail blocks: `[Noisy]` or `[Missing]` headers.
73static 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/// Accepted forms for the `nth_child` field: a plain number, an An+B
161/// formula string, or a full object with `position`, optional `reverse`,
162/// and optional `ofRule`.
163#[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/// A source-range constraint with 0-based line/column positions.
181/// `start` is inclusive, `end` is exclusive.
182#[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/// A rule object used in `expandStart` / `expandEnd` of a `FixConfig`.
211/// Supports the common rule forms: `regex`, `kind`, `pattern`, plus the
212/// optional `stopBy` field unique to expand rules.
213#[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    /// Controls where the expansion stops. Defaults to `"end"` (expand to
222    /// the end of the enclosing node). Set to `"line"` to stop at end of
223    /// line, or a rule object to stop at a specific sibling.
224    #[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    /// Serialize this expand rule to a YAML-compatible JSON value for rule
241    /// file generation.
242    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/// Advanced fix configuration that allows expanding the replacement range
261/// beyond the matched AST node. This maps to ast-grep's `FixConfig` YAML
262/// rule feature.
263///
264/// Use `FixConfig` when replacing only the matched node is not enough,
265/// especially for deleting list items or key-value pairs that also need
266/// a surrounding comma removed.
267#[derive(Debug, Clone, Deserialize)]
268struct FixConfig {
269    /// The replacement template string. Meta variables from the matched
270    /// pattern can be referenced here (e.g. `$VAR`, `$$$ARGS`).
271    template: String,
272    /// Optional rule to expand the fix range start backwards. The range
273    /// start moves left until the rule is no longer met.
274    #[serde(default)]
275    expand_start: Option<FixExpandRule>,
276    /// Optional rule to expand the fix range end forwards. The range end
277    /// moves right until the rule is no longer met.
278    #[serde(default)]
279    expand_end: Option<FixExpandRule>,
280}
281
282impl FixConfig {
283    fn validate(&self) -> Result<()> {
284        // Template can be empty for "delete" operations (replace matched
285        // node with nothing). Validation ensures the field is present.
286        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    /// Update all snapshot files without interactive confirmation.
331    /// Only valid for `test` workflow. Passed as `--update-all` to `sg test`.
332    #[serde(default)]
333    update_all: Option<bool>,
334    /// Launch an interactive session to accept/reject snapshot updates.
335    /// Only valid for `test` workflow. Passed as `--interactive` to `sg test`.
336    #[serde(default)]
337    interactive: Option<bool>,
338    /// Override the test directory for sg test.
339    /// Only valid for `test` workflow. Passed as `--test-dir` to `sg test`.
340    #[serde(default)]
341    test_dir: Option<String>,
342    /// Override the snapshot directory for sg test.
343    /// Only valid for `test` workflow. Passed as `--snapshot-dir` to `sg test`.
344    #[serde(default)]
345    snapshot_dir: Option<String>,
346    /// Include `severity: off` rules in test.
347    /// Only valid for `test` workflow. Passed as `--include-off` to `sg test`.
348    #[serde(default)]
349    include_off: Option<bool>,
350    #[serde(default)]
351    rewrite: Option<String>,
352    /// Advanced fix configuration for the rewrite workflow. When present,
353    /// the tool generates a temporary YAML rule with `fix` as a `FixConfig`
354    /// object (template + expandStart/expandEnd) and runs `sg scan` instead
355    /// of `sg run --rewrite`.
356    #[serde(default, rename = "fix_config")]
357    fix_config: Option<FixConfig>,
358
359    /// Match node text by Rust regex. Passed as `--regex` to the ast-grep
360    /// CLI. Requires `lang` to be set. Only valid for `query` and `rewrite`
361    /// workflows.
362    #[serde(default)]
363    regex: Option<String>,
364
365    /// Match by 1-based position among named siblings. Accepts a number,
366    /// an An+B formula string, or an object with `position`, optional
367    /// `reverse`, and optional `ofRule`. Only valid for `query` workflow;
368    /// triggers YAML rule generation.
369    #[serde(default, rename = "nth_child")]
370    nth_child: Option<NthChildInput>,
371
372    /// Match by source position (0-based line/column, start inclusive, end
373    /// exclusive). Only valid for `query` workflow; triggers YAML rule
374    /// generation.
375    #[serde(default)]
376    range: Option<RangeInput>,
377
378    // -- Relational rule fields ------------------------------------------------
379    /// Relational: match if a descendant matches this rule.
380    #[serde(default)]
381    has: Option<Box<Value>>,
382    /// Relational: match if an ancestor matches this rule.
383    #[serde(default)]
384    inside: Option<Box<Value>>,
385    /// Relational: match if a preceding sibling matches this rule.
386    #[serde(default)]
387    follows: Option<Box<Value>>,
388    /// Relational: match if a following sibling matches this rule.
389    #[serde(default)]
390    precedes: Option<Box<Value>>,
391    /// Narrow meta-variable matches by additional constraints.
392    #[serde(default)]
393    constraints: Option<Map<String, Value>>,
394
395    // -- Composite rule fields -------------------------------------------------
396    /// Composite: reference a utility rule by name via `matches`.
397    #[serde(default)]
398    matches: Option<String>,
399    /// Composite: all sub-rules must match (conjunction).
400    #[serde(default)]
401    all: Option<Vec<Value>>,
402    /// Composite: any sub-rule must match (disjunction).
403    #[serde(default)]
404    any: Option<Vec<Value>>,
405    /// Composite: the sub-rule must not match (negation).
406    #[serde(default)]
407    not: Option<Box<Value>>,
408    /// Local utility rules defined inline for this query. Each key is a
409    /// utility rule id and each value is the rule object.
410    #[serde(default)]
411    utils: Option<Map<String, Value>>,
412
413    // -- Transform fields ------------------------------------------------------
414    /// Transform pipeline for meta-variable substitution. Each key is a
415    /// new variable name and each value defines the transform operation
416    /// (replace, substring, or convert). Transformed variables can be
417    /// referenced in `fix_config.template` via `$$$VAR_NAME`.
418    ///
419    /// Only valid for `query`, `count`, and `rewrite` workflows that use
420    /// YAML rule generation. Requires `lang` to be set.
421    #[serde(default)]
422    transform: Option<Map<String, Value>>,
423
424    // -- Scan-specific fields ---------------------------------------------------
425    /// Post-run severity filter for `scan` workflow. When present, only
426    /// findings whose severity matches one of the listed values are returned.
427    /// Valid values: `error`, `warning`, `info`, `hint`. This filters the
428    /// output after ast-grep runs; it does not override rule severities.
429    #[serde(default)]
430    severities: Option<Vec<String>>,
431
432    /// Control which ignore files ast-grep respects. Valid values:
433    /// `hidden`, `dot`, `exclude`, `global`, `parent`, `vcs`.
434    /// Only valid for `scan`, `query`, and `rewrite` workflows.
435    #[serde(default, alias = "no-ignore")]
436    no_ignore: Option<Vec<String>>,
437
438    /// Follow symbolic links while traversing directories.
439    /// Only valid for `scan`, `query`, and `rewrite` workflows.
440    #[serde(default)]
441    follow: Option<bool>,
442
443    /// Number of threads for ast-grep to use. 0 means auto.
444    /// Only valid for `scan` workflow. Max 256.
445    #[serde(default)]
446    threads: Option<u32>,
447
448    /// Output format for CI pipelines. Valid values: `github`, `sarif`.
449    /// Only valid for `scan` workflow. When set, the raw formatted output
450    /// is returned instead of the normal JSON stream.
451    #[serde(default)]
452    format: Option<String>,
453
454    /// Diagnostic report style. Valid values: `rich`, `medium`, `short`.
455    /// Only valid for `scan` workflow.
456    #[serde(default, alias = "report-style")]
457    report_style: Option<String>,
458
459    /// Number of context lines to show before each match. Mutually
460    /// exclusive with `context_lines`. Only valid for `query`, `scan`,
461    /// and `rewrite` workflows.
462    #[serde(default, alias = "before-lines")]
463    before_lines: Option<usize>,
464
465    /// Number of context lines to show after each match. Mutually
466    /// exclusive with `context_lines`. Only valid for `query`, `scan`,
467    /// and `rewrite` workflows.
468    #[serde(default, alias = "after-lines")]
469    after_lines: Option<usize>,
470
471    /// Built-in ast-grep rules to activate. Valid values:
472    /// `unused-suppression`, `no-suppress-all`. Each entry is activated
473    /// at the severity specified in the format `"rule-id:severity"`
474    /// (e.g. `"unused-suppression:error"`). If no severity is specified,
475    /// defaults to the rule's built-in severity.
476    /// Only valid for `scan` workflow.
477    #[serde(default, alias = "builtin-rules")]
478    builtin_rules: Option<Vec<String>>,
479
480    // -- New workflow fields ---------------------------------------------------
481    /// Subcommand for `workflow='new'`: `project`, `rule`, `test`, or `util`.
482    #[serde(default, rename = "new_subcommand")]
483    new_subcommand: Option<String>,
484
485    /// Name of the rule, test, or utility to create.
486    /// Required for `new` subcommands `rule`, `test`, and `util`.
487    #[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        // Validate no_ignore values.
547        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        // Validate format value.
560        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        // Validate report_style value.
571        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        // Validate builtin_rules values.
582        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        // Validate mutual exclusivity of context_lines vs before_lines/after_lines.
595        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                    // An+B formulas are validated by ast-grep itself.
688                }
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        // rule, test, and util require a name.
867        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        // rule and util require a language.
882        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        // Reject fields that don't apply to the new workflow.
892        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    /// Returns the effective rewrite template: the simple `rewrite` string,
1094    /// or the `fix_config.template` when a FixConfig is present.
1095    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/// Severity level for ast-grep scan findings.
1270///
1271/// ast-grep defines five severity levels:
1272/// - `error`: reports an error; causes `ast-grep scan` to exit non-zero
1273/// - `warning`: reports a warning
1274/// - `info`: reports an informational message
1275/// - `hint`: reports a hint (the default severity for ast-grep rules)
1276/// - `off`: disables the rule entirely
1277#[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    // Pure-Rust workflows that don't need the ast-grep binary.
1372    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    // When relational rules, composite rules, transforms, or constraints are
1467    // present, use YAML rule generation because these operators cannot be
1468    // expressed via CLI flags.
1469    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    // When --format is set (github/sarif), we skip --json and --include-metadata
1564    // because the output format changes and we return raw output instead.
1565    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    // --no-ignore flags.
1584    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    // --follow flag.
1591    if request.effective_follow() {
1592        command.arg("--follow");
1593    }
1594
1595    // --threads flag.
1596    if let Some(threads) = request.effective_threads() {
1597        command.arg("--threads").arg(threads.to_string());
1598    }
1599
1600    // --report-style flag.
1601    if let Some(style) = request.effective_report_style() {
1602        command.arg(format!("--report-style={style}"));
1603    }
1604
1605    // Built-in rules as severity override flags (e.g. --error=unused-suppression).
1606    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                // Activate at built-in default severity.
1614                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    // When --format is set, return the raw formatted output instead of
1645    // parsing as JSON stream (github/sarif formats are not JSON stream).
1646    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    // When FixConfig with expansion or transform is present, use the YAML
1738    // rule path because `sg run --rewrite` only supports simple string fixes.
1739    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    // Simple string rewrite via `sg run --rewrite`.
1750    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    // When nthChild, range, relational rules, composite rules, transforms, or
1835    // constraints are present, use YAML rule generation and count scan findings.
1836    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
1939/// Build a YAML rule string for an atomic count query.
1940fn 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    // Emit local utility rules if present.
1948    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    // Relational rules.
2036    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    // Constraints.
2042    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    // Composite rules.
2053    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    // Emit transform pipeline if present.
2103    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
2116/// Emit a relational rule field from a JSON value into YAML.
2117///
2118/// When the value is a bare string, it is emitted as `pattern: <value>` under
2119/// the field name (matching ast-grep's shorthand semantics where a string
2120/// relational rule means `{pattern: "..."}`).
2121fn 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
2136/// Recursively serialize a JSON value to YAML at the given indentation.
2137fn 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
2191/// Execute count via YAML rule generation (for nthChild/range/has/inside/constraints).
2192async 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
2292/// Execute a query via YAML rule generation when relational rules
2293/// or constraints are present.
2294async 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
2417/// Execute a FixConfig rewrite and return raw scan findings as rewrite-like
2418/// matches. The `replacement` field is set to the template string and
2419/// `replacement_offsets` is `None` because scan findings do not include
2420/// byte-offset replacement data.
2421async 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    // Convert scan findings to rewrite-like matches. The replacement is the
2520    // template string; byte offsets are not available from scan findings.
2521    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
2536/// Execute a FixConfig rewrite by generating a temporary YAML rule with
2537/// `fix` as a `FixConfig` object (template + expandStart/expandEnd) and
2538/// running `sg scan` against it.
2539///
2540/// This is necessary because `sg run --rewrite` only supports simple
2541/// string fixes. FixConfig with expandStart/expandEnd requires the YAML
2542/// rule file path.
2543async 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    // Build the YAML rule content.
2561    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    // Create a temporary directory with the rule file and sgconfig.yml.
2570    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    // Run `sg scan` with the temporary config.
2598    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    // Convert scan findings to rewrite-style output.
2641    Ok(build_fixconfig_rewrite_result(
2642        request,
2643        &search_path.display_path,
2644        findings,
2645    ))
2646}
2647
2648/// Build a YAML rule string for a FixConfig rewrite.
2649///
2650/// The rule has:
2651/// - `id`: a descriptive identifier
2652/// - `language`: the target language
2653/// - `severity: info` (rewrite, not a lint warning)
2654/// - `rule.pattern` or `rule.pattern` + `rule.selector`
2655/// - `fix`: a FixConfig object with `template` and optional
2656///   `expandStart`/`expandEnd`
2657/// - `transform`: optional transform pipeline for meta-variable substitution
2658fn 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    // Emit transform pipeline before fix so that transformed variables
2679    // can be referenced in the fix template.
2680    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
2710/// Append expand rule fields to the YAML string, indented at the correct level.
2711fn 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                // For object stopBy, render as inline JSON-ish YAML.
2728                // This handles cases like `stopBy: { kind: "," }` or
2729                // `stopBy: { regex: "," }`.
2730                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
2761/// Escape a string value for YAML output. Wraps in single quotes if the
2762/// value contains special YAML characters, and escapes internal single
2763/// quotes by doubling them.
2764fn 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
2805/// Build rewrite-style result from scan findings. Converts scan findings
2806/// into the same shape as rewrite results so callers get a consistent
2807/// response format regardless of the internal path taken.
2808fn 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            // The replacement is the FixConfig template. The actual expanded
2837            // replacement would require applying the rule in-process, which
2838            // ast-grep handles externally. We include the template as the
2839            // intended replacement and note the expansion config.
2840            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    // Group rewrites by file.
3239    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        // Sort by byte offset descending so we apply from end to start.
3255        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
3302/// Recursively collect rule summaries from YAML files in a directory.
3303async 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
3330/// Extract a rule summary from a YAML file's content.
3331fn 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        // Track whether we are inside the top-level `utils:` section.
3344        // We stay in the utils section until we hit a new top-level key
3345        // (a line at the same indentation as `utils:` that contains a colon).
3346        if in_utils {
3347            let indent = line.len() - line.trim_start().len();
3348            if indent <= utils_indent && !trimmed.is_empty() && !trimmed.starts_with('#') {
3349                // Hit a new top-level key; exit the utils section.
3350                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: &regex::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
3486/// Validate metavariable syntax in an ast-grep pattern.
3487fn 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    // Symmetric context only when before/after are not set (validated upstream).
3599    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    // Apply post-run severity filter when severities are specified.
3668    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
3957/// Returns true when a Go pattern looks like a bare function call that
3958/// tree-sitter-go would parse as a type conversion (e.g. `fmt.Println($A)`
3959/// or `json.Unmarshal($$$)`). These patterns need `context` + `selector`
3960/// to disambiguate.
3961fn looks_like_go_call_pattern(pattern: &str) -> bool {
3962    // Match patterns like `pkg.Func($$$)`, `Func($$$)`, or
3963    // `expr.Method($$$)` where the pattern starts with an identifier
3964    // chain followed by parenthesized arguments. Metavariable prefixes
3965    // (`$`, `$$`, `$$$`) are stripped before checking identifier validity
3966    // so patterns like `$A.$B($$$)` are recognized as call patterns.
3967    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 must look like an identifier chain: `Func`, `pkg.Func`,
3976    // `pkg.Sub.Method`, etc. Strip metavariable prefixes so `$A.$B`
3977    // is treated like `A.B`.
3978    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    // Match patterns like `class=$VAL`, `id=$ID`, `href=$URL` where the
3989    // pattern looks like an HTML attribute assignment without surrounding
3990    // element context.
3991    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    // Attribute name must be a valid HTML attribute name (letters, digits,
4000    // hyphens, underscores, colons for namespaced attrs).
4001    !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    // Match patterns like `<$TAG>`, `<div>`, `<$TAG $$$ATTRS>` that look
4009    // like HTML opening tags without a closing tag or body.
4010    let trimmed = pattern.trim();
4011    trimmed.starts_with('<')
4012        && trimmed.contains('>')
4013        && !trimmed.contains("</")
4014        && !trimmed.ends_with("/>")
4015}
4016
4017/// Returns true when a Java pattern looks like a bare type-qualified
4018/// identifier or field declaration fragment that tree-sitter-java would
4019/// fail to parse as standalone code. Common examples:
4020/// - `$MOD String $F` (modifier + type + name without surrounding class)
4021/// - `@Annotation` (bare annotation without surrounding declaration)
4022/// - `$TYPE $VAR;` fragments that need class-body context
4023fn looks_like_java_declaration_fragment(pattern: &str) -> bool {
4024    let trimmed = pattern.trim();
4025    // Bare annotation: `@Foo` or `@Foo($$$)`
4026    if trimmed.starts_with('@') {
4027        return true;
4028    }
4029    // Patterns with semicolons that look like field/variable declarations
4030    // without class context: `String $F;`, `private $TYPE $NAME;`
4031    if trimmed.ends_with(';') {
4032        // Contains a type-like identifier followed by a metavariable
4033        let inner = trimmed.trim_end_matches(';').trim();
4034        let parts: Vec<&str> = inner.split_whitespace().collect();
4035        if parts.len() >= 2 {
4036            // Last part should look like a metavariable or identifier
4037            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
4047/// Returns true when a Ruby pattern looks like a bare block or pipe fragment
4048/// that tree-sitter-ruby would fail to parse as standalone code. Common
4049/// examples:
4050/// - `{ |$V| $V.$METHOD }` (block with pipe parameters)
4051/// - `do |$V| $V.$METHOD end` (do-block with pipe parameters)
4052/// - `&:$METHOD` (bare symbol-to-proc)
4053fn 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    // Bare symbol-to-proc: `&:method_name`
4080    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    // Bare pipe block: `{ |$V| ... }` or `do |$V| ... end`
4088    if trimmed.starts_with('{') && trimmed.contains('|') {
4089        return true;
4090    }
4091    if trimmed.starts_with("do") && trimmed.contains('|') {
4092        return true;
4093    }
4094
4095    // Bare block body starting with pipe: `| $V | $V.$METHOD`
4096    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
4315/// Walk up from `start` looking for `sgconfig.yml` in ancestor directories.
4316async 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
4369/// Best-effort extraction of `ruleDirs` entries from a sgconfig.yml file.
4370/// Returns relative directory paths found under the `ruleDirs:` key.
4371async 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            // Handle inline array: ruleDirs: [rules, custom-rules]
4384            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                // Hit a new key, stop collecting
4412                in_rule_dirs = false;
4413            }
4414        }
4415    }
4416
4417    dirs
4418}
4419
4420/// Best-effort extraction of `testConfigs` entries from a sgconfig.yml file.
4421/// Returns a list of objects with `testDir` (required) and `snapshotDir` (optional).
4422async 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; // Inline array of objects is too complex for line-by-line parsing
4438            }
4439            continue;
4440        }
4441
4442        if !in_test_configs {
4443            continue;
4444        }
4445
4446        // List item start: "- "
4447        if trimmed.starts_with("- ") {
4448            // Flush previous item
4449            if let Some(item) = current_item.take() {
4450                configs.push(Value::Object(item));
4451            }
4452            current_item = Some(Map::new());
4453            // Check for inline key-value: "- testDir: tests"
4454            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        // A new top-level key (not a list item) ends the section
4464        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        // Key-value inside a list item (indented deeper than "- ")
4473        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    // Flush last item
4481    if let Some(item) = current_item {
4482        configs.push(Value::Object(item));
4483    }
4484
4485    configs
4486}
4487
4488/// Best-effort extraction of `utilDirs` entries from a sgconfig.yml file.
4489/// Returns relative directory paths found under the `utilDirs:` key.
4490async 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            // Handle inline array: utilDirs: [utils, shared]
4503            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                // Hit a new key, stop collecting
4531                in_util_dirs = false;
4532            }
4533        }
4534    }
4535
4536    dirs
4537}
4538
4539/// Best-effort extraction of `languageInjections` entries from a sgconfig.yml file.
4540/// Returns a list of objects with `host_language`, `rule_pattern` (or `rule_kind`),
4541/// and `injected` language name.
4542async 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        // Detect start of languageInjections section
4555        if trimmed.starts_with("languageInjections:") {
4556            in_injections = true;
4557            // Handle inline array (uncommon but possible)
4558            if trimmed.contains('[') {
4559                break; // Inline array of objects is too complex for line-by-line parsing
4560            }
4561            continue;
4562        }
4563
4564        if !in_injections {
4565            continue;
4566        }
4567
4568        // List item start: "- " (may be at 0-indent in YAML)
4569        if trimmed.starts_with("- ") {
4570            // Flush previous item
4571            if let Some(item) = current_item.take() {
4572                injections.push(Value::Object(item));
4573            }
4574            current_item = Some(Map::new());
4575            // Check for inline key-value: "- hostLanguage: js"
4576            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        // A new top-level key (not a list item) ends the section
4586        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        // Key-value inside a list item (indented deeper than "- ")
4595        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    // Flush last item
4603    if let Some(item) = current_item {
4604        injections.push(Value::Object(item));
4605    }
4606
4607    injections
4608}
4609
4610/// Best-effort extraction of `customLanguages` entries from a sgconfig.yml file.
4611/// Returns a JSON object mapping language names to their config (library_path, extensions).
4612async 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        // A new top-level key ends the section
4635        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        // Language entry at 2-space indent: "graphql:"
4648        let indent = line.len() - line.trim_start().len();
4649        if indent == 2 && trimmed.ends_with(':') && !trimmed.contains(' ') {
4650            // Flush previous language
4651            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        // Key-value inside a language entry
4660        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    // Flush last language
4668    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
4675/// Best-effort extraction of `languageGlobs` entries from a sgconfig.yml file.
4676/// Returns a JSON object mapping language names to their glob pattern arrays.
4677async 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        // A new top-level key ends the section
4700        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        // Language entry at 2-space indent: "tsx:"
4715        if indent == 2 && trimmed.ends_with(':') && !trimmed.starts_with('-') {
4716            // Flush previous language
4717            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        // Glob pattern entry: "- \"*.tsx\"" at 4-space indent
4725        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    // Flush last language
4739    if let Some(lang) = current_lang {
4740        globs.insert(lang, Value::Array(current_patterns));
4741    }
4742
4743    Value::Object(globs)
4744}
4745
4746/// Parse a simple YAML key-value pair like `hostLanguage: js` or `libraryPath: graphql.so`.
4747/// Returns (key, Value) where key is the trimmed left side and Value is the trimmed right side.
4748fn 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    // Skip keys with empty values -- these are typically parent keys
4757    // with nested content (e.g. `rule:` followed by indented sub-keys).
4758    if raw_value.is_empty() {
4759        return None;
4760    }
4761
4762    // Handle inline array: extensions: [graphql]
4763    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
4830/// Per-rule result parsed from `sg test` stdout.
4831struct TestRuleResult {
4832    rule_id: String,
4833    passed: bool,
4834    /// N = Noisy (false positive), M = Missing (false negative).
4835    markers: Vec<String>,
4836}
4837
4838/// Parse per-rule PASS/FAIL lines from `sg test` stdout.
4839///
4840/// The `sg test` output format is:
4841/// ```text
4842/// PASS rule-id .....
4843/// FAIL rule-id ...N..M
4844/// ```
4845/// where dots represent individual test cases and N/M markers indicate
4846/// Noisy or Missing failures within that rule.
4847fn 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
4876/// Parse failure detail blocks from `sg test` output.
4877///
4878/// The `sg test` output on failure contains blocks like:
4879/// ```text
4880/// ----------- Failure Details -----------
4881/// [Noisy] Expect rule-id to report no issue, but some issues found in:
4882///
4883///   <code snippet>
4884///
4885/// [Missing] Expect rule rule-id to report issues, but none found in:
4886///
4887///   <code snippet>
4888/// ```
4889fn 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
4918/// Extract the indented code snippet following a failure detail header.
4919/// Reads lines until a blank line or another `[`-prefixed header.
4920fn extract_failure_snippet<'a>(lines: &mut std::iter::Peekable<std::str::Lines<'a>>) -> String {
4921    let mut snippet_lines = Vec::new();
4922    // Skip blank lines between the header and the code snippet.
4923    while let Some(peeked) = lines.peek() {
4924        if peeked.trim().is_empty() {
4925            lines.next();
4926        } else {
4927            break;
4928        }
4929    }
4930    // Collect indented code lines.
4931    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}