Skip to main content

lean_ctx/tools/ctx_outline/
mod.rs

1//! `ctx_outline` — fast, syntax-aware code outline (a "table of contents").
2//!
3//! Backed by tree-sitter (primary, via [`crate::core::signatures_ts`]) with a
4//! conservative regex fallback. Three navigation questions, one primitive:
5//! - a single **file** → its shape,
6//! - a **directory** → the folder surface (per-file symbols),
7//! - a `match`/`kind`-filtered slice → focused detail.
8//!
9//! Output can be the compact text outline (default) or deterministic JSON
10//! (`format=json`, byte-stable per #498) that labels the extraction `backend`
11//! per file, so the "syntax-aware" claim is verifiable rather than asserted
12//! (gitlab #981).
13
14mod dir;
15mod json;
16#[cfg(test)]
17mod tests;
18
19use crate::core::signatures::{SigBackend, Signature, extract_signatures_with_backend};
20use crate::core::tokens::count_tokens;
21use crate::tools::CrpMode;
22
23/// Knobs for an outline run. `kind` filters by symbol kind (`fn|struct|class|…`
24/// or `all`), `name_match` keeps only symbols whose name contains the substring
25/// (case-insensitive), `as_json` switches to the deterministic JSON renderer.
26#[derive(Debug, Clone, Default)]
27pub struct OutlineOpts<'a> {
28    pub kind: Option<&'a str>,
29    pub name_match: Option<&'a str>,
30    pub as_json: bool,
31}
32
33/// Per-file extracted + filtered symbols, shared by the directory text and JSON
34/// renderers. `rel` is the path relative to the outlined directory (forward
35/// slashes for deterministic, OS-independent output).
36struct FileSymbols {
37    rel: String,
38    ext: String,
39    backend: SigBackend,
40    sigs: Vec<Signature>,
41}
42
43/// Outline a file or directory. Returns `(rendered_output, original_tokens)`,
44/// where `original_tokens` is the full-read baseline used for savings reporting.
45#[must_use]
46pub fn run(path: &str, opts: &OutlineOpts) -> (String, usize) {
47    // Path containment is enforced upstream by the resolution layer
48    // (`require_resolved_path` → `resolve_path`), the sole caller of `run` on the
49    // live MCP path: an escaping path (absolute, `..`, or a symlink whose target
50    // leaves the project root) is rejected before we are reached. An in-tree
51    // symlink therefore arrives already resolved to its real, in-jail target and
52    // is outlined like any other file — so we deliberately do not second-guess it
53    // here with a misleading "skipped for security" message that never fires on
54    // the live path. `metadata()` (not `symlink_metadata()`) follows the link.
55    let p = std::path::Path::new(path);
56    match p.metadata() {
57        Ok(m) if m.is_dir() => dir::outline_dir(path, opts),
58        Ok(_) => outline_file(path, opts),
59        Err(e) => (format!("ERROR: Cannot read {path}: {e}"), 0),
60    }
61}
62
63fn outline_file(path: &str, opts: &OutlineOpts) -> (String, usize) {
64    let content = match std::fs::read_to_string(path) {
65        Ok(c) => c,
66        Err(e) => return (format!("ERROR: Cannot read {path}: {e}"), 0),
67    };
68    let full_tokens = count_tokens(&content);
69    let ext = ext_of(path);
70    let (sigs, backend) = extract_signatures_with_backend(&content, ext);
71    let filtered = filter_signatures(&sigs, opts);
72
73    if opts.as_json {
74        return (json::file_json(path, ext, backend, &filtered), full_tokens);
75    }
76
77    if filtered.is_empty() {
78        return (no_match_message(path, opts), 0);
79    }
80
81    let crp = CrpMode::effective();
82    let mut outline = filtered
83        .iter()
84        .map(|s| render_one(s, crp))
85        .collect::<Vec<_>>()
86        .join("\n");
87    if crp.is_tdd() {
88        let legend = crate::core::signatures::tdd_legend(&filtered);
89        if !legend.is_empty() {
90            outline = format!("{legend}\n{outline}");
91        }
92    }
93    // Located symbols are addressable as stable handles (#607): one self-
94    // describing hint, not a per-line handle that would just repeat name+span.
95    outline.push('\n');
96    outline.push_str(crate::core::handle::USAGE_HINT);
97
98    let sent = count_tokens(&outline);
99    let savings = crate::core::protocol::format_savings(full_tokens, sent);
100    (format!("{outline}\n{savings}"), full_tokens)
101}
102
103/// Render one signature for the text outline. Navigation modes earn their line
104/// span (`@Lstart-end`): the whole point of an outline is to locate the next
105/// read, so unlike the compression-first renderers we always include it.
106fn render_one(s: &Signature, crp: CrpMode) -> String {
107    if crp.is_tdd() {
108        s.to_tdd_located()
109    } else {
110        s.to_compact_located()
111    }
112}
113
114/// Apply the `kind` then `name_match` filters, preserving source order.
115fn filter_signatures<'a>(sigs: &'a [Signature], opts: &OutlineOpts) -> Vec<&'a Signature> {
116    let kind = opts.kind.map(str::to_lowercase);
117    let name = opts.name_match.map(str::to_lowercase);
118    sigs.iter()
119        .filter(|s| match &kind {
120            None => true,
121            Some(k) if k == "all" => true,
122            Some(k) => s.kind.eq_ignore_ascii_case(k),
123        })
124        .filter(|s| match &name {
125            None => true,
126            Some(n) => s.name.to_lowercase().contains(n.as_str()),
127        })
128        .collect()
129}
130
131fn no_match_message(path: &str, opts: &OutlineOpts) -> String {
132    match (opts.kind, opts.name_match) {
133        (_, Some(m)) => format!("No symbols matching '{m}' in {path}"),
134        (Some(k), None) if !k.eq_ignore_ascii_case("all") => format!("No '{k}' symbols in {path}"),
135        _ => format!("No symbols found in {path}"),
136    }
137}
138
139fn ext_of(path: &str) -> &str {
140    std::path::Path::new(path)
141        .extension()
142        .and_then(|e| e.to_str())
143        .unwrap_or("")
144}