magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
//! Bundled outline rules only: no CLI, config discovery, or custom extractors.
use super::embedded::{EmbeddedLanguage, MAX_FILES, MAX_SCAN_BYTES, MAX_TREE_NODES, read_source};
use super::*;
use ast_grep_core::{AstGrep, Language, tree_sitter::StrDoc};
use ast_grep_language::SupportLang;
use ast_grep_outline::{
    DEFAULT_OUTLINE_RULES,
    combined_extractor::CombinedExtractors,
    extractor::parse_outline_rules,
    model::OutlineEntry,
    options::{
        OutlineEntryDetail as Detail, OutlineExtractorOptions, OutlineFlagFilter as Flag,
        OutlineMemberOptions,
    },
};
use std::collections::HashMap;

fn options(args: &AstGrepArgs) -> anyhow::Result<OutlineExtractorOptions> {
    let view = args.view.as_deref().unwrap_or("digest");
    let items = args.items.as_deref().unwrap_or("structure");
    Ok(OutlineExtractorOptions {
        symbol_types: args
            .symbol_type
            .as_deref()
            .map(|types| {
                types
                    .split(',')
                    .map(|kind| serde_json::from_value(json!(kind.trim())))
                    .collect::<Result<Vec<_>, _>>()
            })
            .transpose()?,
        item_regex: args.name.as_deref().map(regex::Regex::new).transpose()?,
        imports: match items {
            "structure" => Flag::No,
            "imports" => Flag::Yes,
            _ => Flag::Any,
        },
        exported: if items == "exports" {
            Flag::Yes
        } else {
            Flag::Any
        },
        detail: if view == "names" && args.name.is_none() {
            Detail::Name
        } else {
            Detail::Signature
        },
        members: matches!(view, "digest" | "expanded").then_some(OutlineMemberOptions {
            public: if args.pub_members == Some(true) {
                Flag::Yes
            } else {
                Flag::Any
            },
            detail: if view == "expanded" {
                Detail::Signature
            } else {
                Detail::Name
            },
        }),
    })
}

fn entry_json(entry: &OutlineEntry<'_>) -> serde_json::Value {
    let range = &entry.range;
    json!({
        "name": entry.name, "symbolType": entry.symbol_type,
        "signature": entry.signature, "astKind": entry.ast_kind,
        "range": {
            "start": {"line": range.start.line + 1, "column": range.start.column + 1},
            "end": {"line": range.end.line + 1, "column": range.end.column + 1}
        }
    })
}

pub(super) fn run(
    runtime: &ToolRuntime,
    args: &AstGrepArgs,
    resolved: &Path,
    cancellation: &AgentCancellation,
    timeout: Duration,
) -> anyhow::Result<ToolResult> {
    let started = Instant::now();
    let explicit = args
        .language
        .as_deref()
        .map(str::parse::<SupportLang>)
        .transpose()
        .map_err(|_| anyhow::anyhow!("unsupported ast-grep language"))?;
    let options = options(args)?;
    // Parent rules define top-level boundaries; filtering them during compilation
    // would promote their descendants to independent items.
    let extraction_options = OutlineExtractorOptions {
        symbol_types: None,
        item_regex: None,
        imports: Flag::Any,
        exported: Flag::Any,
        ..options.clone()
    };
    let mut compiled = HashMap::new();
    let mut state = AstGrepRunState::default();
    // Parse only fixed shipped data, once per invocation. No source files or
    // project configuration are involved in this bounded library call.
    let mut rules_by_language = HashMap::<_, Vec<_>>::new();
    cancellation.check()?;
    if started.elapsed() < timeout {
        for rule in parse_outline_rules::<SupportLang>(DEFAULT_OUTLINE_RULES)? {
            cancellation.check()?;
            rules_by_language
                .entry(rule.common().language)
                .or_default()
                .push(rule);
        }
    }
    let mut files = 0;
    let mut bytes = 0;
    let mut skipped = 0;
    let mut returned = 0;
    let limit = args.limit.unwrap_or(AST_GREP_DEFAULT_LIMIT);
    let allow_outside = runtime.ast_grep_absolute_paths
        && args
            .path
            .as_deref()
            .is_some_and(|p| Path::new(p).is_absolute());
    let mut visitor = |path: PathBuf| -> anyhow::Result<bool> {
        cancellation.check()?;
        if started.elapsed() >= timeout {
            state.timed_out = true;
            return Ok(false);
        }
        files += 1;
        if files > MAX_FILES || bytes >= MAX_SCAN_BYTES {
            state.workset_truncated = true;
            return Ok(false);
        }
        let Some(language) = explicit.or_else(|| SupportLang::from_path(&path)) else {
            return Ok(true);
        };
        if !rules_by_language.contains_key(&language) && !compiled.contains_key(&language) {
            return Ok(true);
        }
        let Some(path_text) = path.to_str() else {
            skipped += 1;
            state.workset_truncated = true;
            return Ok(true);
        };
        let path = runtime
            .resolve_existing_path(path_text, ExistingPathPolicy::ast_grep(allow_outside))?;
        let Some(src) = read_source(&path, &mut bytes) else {
            skipped += 1;
            state.workset_truncated = true;
            return Ok(true);
        };
        if src.contains('\0') {
            return Ok(true);
        }
        let lang = EmbeddedLanguage {
            language,
            cancellation: cancellation.clone(),
            started,
            timeout,
        };
        let doc = match lang.parse(src, MAX_TREE_NODES) {
            Ok(doc) => doc,
            Err(_) => {
                cancellation.check()?;
                skipped += 1;
                state.workset_truncated = true;
                state.timed_out |= lang.stopped();
                return Ok(!state.timed_out);
            }
        };
        if let std::collections::hash_map::Entry::Vacant(entry) = compiled.entry(language) {
            // Compile trusted, fixed rules only. Check before accepting output.
            let rules = rules_by_language.remove(&language).unwrap_or_default();
            entry.insert(CombinedExtractors::try_from_rules(
                rules,
                extraction_options.clone(),
                &Default::default(),
            )?);
        }
        cancellation.check()?;
        if lang.stopped() {
            state.timed_out = true;
            return Ok(false);
        }
        // Reuse the owned, checked tree without reparsing through LanguageExt.
        let root = AstGrep::doc(StrDoc {
            src: doc.src,
            tree: doc.tree,
            lang: language,
        });
        let mut items = compiled[&language].extract(root.root());
        loop {
            cancellation.check()?;
            if lang.stopped() {
                state.timed_out = true;
                return Ok(false);
            }
            // next() may traverse the entire remaining bounded tree, including
            // members. It cannot stop mid-call; never use an unbounded collect.
            let item = items.next();
            cancellation.check()?;
            if lang.stopped() {
                state.timed_out = true;
                return Ok(false);
            }
            let Some(item) = item else {
                break;
            };
            if !options.keep_item(&item) {
                continue;
            }
            if returned >= limit {
                state.workset_truncated = true;
                return Ok(false);
            }
            let mut value = entry_json(&item.entry);
            value["isImport"] = json!(item.is_import);
            value["isExported"] = json!(item.is_exported);
            if !item.members.is_empty() {
                let mut members = Vec::with_capacity(item.members.len());
                for member in item.members {
                    cancellation.check()?;
                    if lang.stopped() {
                        state.timed_out = true;
                        return Ok(false);
                    }
                    let mut value = entry_json(&member.entry);
                    value["isPublic"] = json!(member.is_public);
                    members.push(value);
                }
                value["members"] = json!(members);
            }
            let entry = serde_json::to_string(
                &json!({"path": path, "language": language.to_string(), "item": value}),
            )?;
            cancellation.check()?;
            if lang.stopped() {
                state.timed_out = true;
                return Ok(false);
            }
            if entry.len() + 1 > AST_GREP_STDOUT_MAX_BYTES.saturating_sub(state.stdout.len()) {
                state.stdout_truncated = true;
                state.workset_truncated = true;
                return Ok(false);
            }
            append_batch_output(&mut state.stdout, &entry);
            returned += 1;
        }
        Ok(true)
    };
    if resolved.is_dir() {
        let status = runtime.workspace_walker.visit_files_with_deadline(
            WorkspaceWalkOptions {
                root: resolved,
                skip_dirs: &[".git"],
                cancel_interval: 32,
            },
            Some(cancellation),
            Some(started + timeout),
            &mut visitor,
        )?;
        state.workset_truncated |= status.walk_errors > 0 || status.entries_omitted > 0;
    } else {
        visitor(resolved.to_path_buf())?;
    }
    cancellation.check()?;
    state.timed_out |= started.elapsed() >= timeout;
    let partial = state.workset_truncated || state.timed_out;
    Ok(ToolResult {
        tool_name: tool_name::AST_GREP.into(),
        success: !state.timed_out,
        content: state.stdout,
        metadata: json!({
            meta::ENGINE: "ast-grep", "execution": "embedded", "operation": "outline",
            "items": args.items.as_deref().unwrap_or("structure"), "view": args.view.as_deref().unwrap_or("digest"),
            "symbols_returned": returned, "files_skipped": skipped,
            meta::EXIT_CODE: if state.timed_out { -1 } else { 0 }, meta::TIMED_OUT: state.timed_out,
            meta::TRUNCATED: partial, "partial": partial,
            meta::STDOUT_TRUNCATED: state.stdout_truncated, "workset_truncated": state.workset_truncated,
            "line_base": 1, "column_base": 1
        }),
        display: ToolResultDisplay::default(),
    })
}