Skip to main content

lean_ctx_server/tools/ctx_outline/
mod.rs

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