Skip to main content

lean_ctx/core/
signatures.rs

1//! Signature extraction — **regex fallback** layer.
2//!
3//! IMPORTANT for readers skimming the repo: this file is the *fallback*, not the
4//! primary engine. The default build extracts signatures with **tree-sitter** via
5//! declarative per-language queries in `crate::core::signatures_ts` (real ASTs,
6//! real multi-line spans, ~27 languages). [`extract_signatures`] tries that path
7//! first and only drops to the line-oriented regex extractors here when the
8//! `tree-sitter` feature is off or a parse yields nothing usable for a file.
9//!
10//! The regex extractors are intentionally conservative: single-line declarations,
11//! declaration-line spans only (`end_line == start_line`). Use
12//! [`extract_signatures_with_backend`] when you need to know — and surface —
13//! which backend actually produced a result (gitlab #981).
14
15use regex::Regex;
16
17macro_rules! static_regex {
18    ($pattern:expr_2021) => {{
19        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
20        RE.get_or_init(|| {
21            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
22        })
23    }};
24}
25
26#[derive(Debug, Clone)]
27pub struct Signature {
28    pub kind: &'static str,
29    pub name: String,
30    pub params: String,
31    pub return_type: String,
32    pub is_async: bool,
33    pub is_exported: bool,
34    pub indent: usize,
35    pub start_line: Option<usize>,
36    pub end_line: Option<usize>,
37}
38
39/// Exports not already named by `key_sigs`. Map-mode renderers list the API
40/// with full signatures + line ranges; repeating those same names in an
41/// `exports:` line is pure redundancy. Callers surface only the exports the API
42/// does not already cover (e.g. re-exports or const/type aliases that aren't
43/// captured as signatures), keeping map lossless while dropping duplicates
44/// (#361). Shared by the MCP, CLI, and benchmark map renderers.
45#[must_use]
46pub fn exports_not_in_signatures<'a>(
47    exports: &'a [String],
48    key_sigs: &[&Signature],
49) -> Vec<&'a str> {
50    let api_names: std::collections::HashSet<&str> =
51        key_sigs.iter().map(|s| s.name.as_str()).collect();
52    exports
53        .iter()
54        .map(String::as_str)
55        .filter(|e| !api_names.contains(e))
56        .collect()
57}
58
59impl Signature {
60    pub fn no_span() -> Self {
61        Self {
62            kind: "",
63            name: String::new(),
64            params: String::new(),
65            return_type: String::new(),
66            is_async: false,
67            is_exported: false,
68            indent: 0,
69            start_line: None,
70            end_line: None,
71        }
72    }
73
74    /// Plain, self-describing notation (GL #580): real keywords (`class`,
75    /// `trait`, `pub`) instead of abbreviations, so a vanilla agent without
76    /// injected rules reads the output correctly. Keyword tokens cost the
77    /// same as (or less than) the old `cl`/`⊛` forms.
78    pub fn to_compact(&self) -> String {
79        let export = if self.is_exported { "pub " } else { "" };
80        let async_prefix = if self.is_async { "async " } else { "" };
81
82        match self.kind {
83            "fn" | "method" => {
84                let ret = if self.return_type.is_empty() {
85                    String::new()
86                } else {
87                    format!(" → {}", self.return_type)
88                };
89                let indent = " ".repeat(self.indent);
90                format!(
91                    "{indent}fn {async_prefix}{export}{}({}){}",
92                    self.name, self.params, ret
93                )
94            }
95            "const" | "let" | "var" => {
96                let ty = if self.return_type.is_empty() {
97                    String::new()
98                } else {
99                    format!(":{}", self.return_type)
100                };
101                format!("{} {export}{}{ty}", self.kind, self.name)
102            }
103            // `kind` is already the source-language keyword (class, struct,
104            // interface, trait, type, enum) — use it verbatim.
105            _ => format!("{} {export}{}", self.kind, self.name),
106        }
107    }
108
109    pub fn to_tdd(&self) -> String {
110        let vis = if self.is_exported { "+" } else { "-" };
111        let a = if self.is_async { "~" } else { "" };
112
113        match self.kind {
114            "fn" | "method" => {
115                let ret = if self.return_type.is_empty() {
116                    String::new()
117                } else {
118                    format!("→{}", compact_type(&self.return_type))
119                };
120                let params = tdd_params(&self.params);
121                let indent = if self.indent > 0 { " " } else { "" };
122                format!("{indent}{a}λ{vis}{}({params}){ret}", self.name)
123            }
124            "class" | "struct" => format!("§{vis}{}", self.name),
125            "interface" | "trait" => format!("∂{vis}{}", self.name),
126            "type" => format!("τ{vis}{}", self.name),
127            "enum" => format!("ε{vis}{}", self.name),
128            "const" | "let" | "var" => {
129                let ty = if self.return_type.is_empty() {
130                    String::new()
131                } else {
132                    format!(":{}", compact_type(&self.return_type))
133                };
134                format!("ν{vis}{}{ty}", self.name)
135            }
136            _ => format!(
137                "{}{vis}{}",
138                self.kind.chars().next().unwrap_or('?'),
139                self.name
140            ),
141        }
142    }
143
144    /// Compact ` @Lstart[-end]` suffix for navigation-focused modes.
145    /// Returns an empty string when the span is unknown, so compression-first
146    /// modes that render the base `to_compact`/`to_tdd` stay byte-identical.
147    pub fn line_suffix(&self) -> String {
148        match (self.start_line, self.end_line) {
149            (Some(start), Some(end)) if start > 0 && end > start => format!(" @L{start}-{end}"),
150            (Some(start), _) if start > 0 => format!(" @L{start}"),
151            _ => String::new(),
152        }
153    }
154
155    /// `to_compact` plus a line-span suffix. Reserved for navigation modes
156    /// (`map`/`signatures`) where locating code outweighs the few extra tokens.
157    pub fn to_compact_located(&self) -> String {
158        format!("{}{}", self.to_compact(), self.line_suffix())
159    }
160
161    /// `to_tdd` plus a line-span suffix. Reserved for navigation modes.
162    pub fn to_tdd_located(&self) -> String {
163        format!("{}{}", self.to_tdd(), self.line_suffix())
164    }
165}
166
167/// One-line legend for TDD symbol notation (GL #580): outputs must be
168/// self-describing for vanilla agents without injected rules. Only the
169/// symbols actually present in `sigs` are explained, keeping the line well
170/// under the 15-token budget for typical files. Empty when nothing applies.
171pub fn tdd_legend<'a>(sigs: &[&'a Signature]) -> String {
172    if sigs.is_empty() {
173        return String::new();
174    }
175    let mut parts: Vec<&str> = Vec::new();
176    let has = |pred: &dyn Fn(&'a Signature) -> bool| sigs.iter().any(|s| pred(s));
177
178    if has(&|s| matches!(s.kind, "fn" | "method")) {
179        parts.push("λ=fn");
180    }
181    if has(&|s| matches!(s.kind, "class" | "struct")) {
182        parts.push("§=class");
183    }
184    if has(&|s| matches!(s.kind, "interface" | "trait")) {
185        parts.push("∂=trait");
186    }
187    if has(&|s| s.kind == "type") {
188        parts.push("τ=type");
189    }
190    if has(&|s| s.kind == "enum") {
191        parts.push("ε=enum");
192    }
193    if has(&|s| matches!(s.kind, "const" | "let" | "var")) {
194        parts.push("ν=val");
195    }
196    if has(&|s| s.is_exported) {
197        parts.push("+=pub");
198    }
199    if has(&|s| s.is_async) {
200        parts.push("~=async");
201    }
202    if parts.is_empty() {
203        String::new()
204    } else {
205        format!("[{}]", parts.join(" "))
206    }
207}
208
209fn fn_re() -> &'static Regex {
210    static_regex!(
211        r"^(\s*)(export\s+)?(async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*:\s*([^\{]+))?\s*\{?"
212    )
213}
214
215fn class_re() -> &'static Regex {
216    static_regex!(r"^(\s*)(export\s+)?(abstract\s+)?class\s+(\w+)")
217}
218
219fn iface_re() -> &'static Regex {
220    static_regex!(r"^(\s*)(export\s+)?interface\s+(\w+)")
221}
222
223fn type_re() -> &'static Regex {
224    static_regex!(r"^(\s*)(export\s+)?type\s+(\w+)")
225}
226
227fn const_re() -> &'static Regex {
228    static_regex!(r"^(\s*)(export\s+)?(const|let|var)\s+(\w+)(?:\s*:\s*(\w+))?")
229}
230
231fn rust_fn_re() -> &'static Regex {
232    static_regex!(
233        r"^(\s*)(pub\s+)?(async\s+)?fn\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*->\s*([^\{]+))?\s*\{?"
234    )
235}
236
237fn rust_struct_re() -> &'static Regex {
238    static_regex!(r"^(\s*)(pub\s+)?struct\s+(\w+)")
239}
240
241fn rust_enum_re() -> &'static Regex {
242    static_regex!(r"^(\s*)(pub\s+)?enum\s+(\w+)")
243}
244
245fn rust_trait_re() -> &'static Regex {
246    static_regex!(r"^(\s*)(pub\s+)?trait\s+(\w+)")
247}
248
249fn rust_impl_re() -> &'static Regex {
250    static_regex!(r"^(\s*)impl\s+(?:(\w+)\s+for\s+)?(\w+)")
251}
252
253use std::sync::atomic::{AtomicU64, Ordering};
254
255static TREE_SITTER_HITS: AtomicU64 = AtomicU64::new(0);
256static REGEX_FALLBACK_HITS: AtomicU64 = AtomicU64::new(0);
257
258/// Returns (tree_sitter_hits, regex_fallback_hits) since process start.
259pub fn signature_backend_stats() -> (u64, u64) {
260    (
261        TREE_SITTER_HITS.load(Ordering::Relaxed),
262        REGEX_FALLBACK_HITS.load(Ordering::Relaxed),
263    )
264}
265
266/// Which extractor produced a signature set: the primary tree-sitter AST path
267/// (`crate::core::signatures_ts`) or the line-oriented regex fallback in this
268/// module. Surfaced so navigation output (`ctx_outline`) can label its backend
269/// honestly and verifiably instead of merely *claiming* "via tree-sitter"
270/// (gitlab #981).
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum SigBackend {
273    TreeSitter,
274    Regex,
275}
276
277impl SigBackend {
278    #[must_use]
279    pub fn as_str(self) -> &'static str {
280        match self {
281            SigBackend::TreeSitter => "tree-sitter",
282            SigBackend::Regex => "regex",
283        }
284    }
285}
286
287/// Per-session blocklist for tree-sitter languages that panicked during parsing.
288/// Once a language panics, all subsequent calls for that extension skip straight
289/// to the regex fallback — no repeated catch_unwind overhead.
290#[cfg(feature = "tree-sitter")]
291mod ts_blocklist {
292    use std::collections::HashSet;
293    use std::sync::Mutex;
294
295    static BLOCKED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
296
297    fn lock() -> std::sync::MutexGuard<'static, Option<HashSet<String>>> {
298        match BLOCKED.lock() {
299            Ok(g) => g,
300            Err(poisoned) => poisoned.into_inner(),
301        }
302    }
303
304    pub(super) fn is_blocked(ext: &str) -> bool {
305        lock().as_ref().is_some_and(|set| set.contains(ext))
306    }
307
308    pub(super) fn block(ext: &str) {
309        lock()
310            .get_or_insert_with(HashSet::new)
311            .insert(ext.to_string());
312    }
313}
314
315#[cfg(feature = "tree-sitter")]
316use ts_blocklist::{block as block_ts_language, is_blocked as is_ts_language_blocked};
317
318pub fn extract_signatures(content: &str, file_ext: &str) -> Vec<Signature> {
319    let (sigs, backend) = extract_signatures_with_backend(content, file_ext);
320    let is_tree_sitter = matches!(backend, SigBackend::TreeSitter);
321    match backend {
322        SigBackend::TreeSitter => TREE_SITTER_HITS.fetch_add(1, Ordering::Relaxed),
323        SigBackend::Regex => REGEX_FALLBACK_HITS.fetch_add(1, Ordering::Relaxed),
324    };
325    // Persistent per-extension telemetry (GH #690 Phase 2): the tiering cut
326    // needs to know which grammars earn their binary bytes across sessions,
327    // which these process-lifetime atomics cannot answer.
328    crate::core::grammar_usage::record(file_ext, is_tree_sitter);
329    sigs
330}
331
332/// Like [`extract_signatures`] but also reports which backend produced the
333/// result. Does **not** touch the global backend counters, so callers that only
334/// want the label (e.g. per-file JSON outline) don't distort
335/// [`signature_backend_stats`]; the counted path stays [`extract_signatures`].
336pub fn extract_signatures_with_backend(
337    content: &str,
338    file_ext: &str,
339) -> (Vec<Signature>, SigBackend) {
340    #[cfg(feature = "tree-sitter")]
341    {
342        // A successful parse that yields no signatures (`Some(vec![])`) means the
343        // tree-sitter query set has a gap for this file's constructs. Fall through
344        // to the regex extractors instead of returning the empty result, which
345        // would otherwise suppress signatures the regex fallback can still
346        // recover (e.g. constructs the query does not capture).
347        //
348        // Wrapped in catch_unwind: tree-sitter grammars can panic on certain
349        // files (e.g. "range start index N out of range for slice of length 0").
350        // A single panic must not cascade — it falls through to the regex
351        // extractor, and the language is blocklisted for the rest of the session.
352        if !is_ts_language_blocked(file_ext) {
353            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
354                super::signatures_ts::extract_signatures_ts(content, file_ext)
355            })) {
356                Ok(Some(sigs)) if !sigs.is_empty() => {
357                    return (sigs, SigBackend::TreeSitter);
358                }
359                Err(_) => {
360                    block_ts_language(file_ext);
361                    eprintln!(
362                        "[lean-ctx] tree-sitter panicked for .{file_ext} — using regex fallback"
363                    );
364                }
365                _ => {}
366            }
367        }
368    }
369
370    let sigs = match file_ext {
371        "rs" => extract_rust_signatures(content),
372        "ts" | "tsx" | "js" | "jsx" | "svelte" | "vue" => extract_ts_signatures(content),
373        "py" => extract_python_signatures(content),
374        "go" => extract_go_signatures(content),
375        _ => extract_generic_signatures(content),
376    };
377    (sigs, SigBackend::Regex)
378}
379
380pub fn extract_file_map(path: &str, content: &str) -> String {
381    let ext = std::path::Path::new(path)
382        .extension()
383        .and_then(|e| e.to_str())
384        .unwrap_or("rs");
385    let dep_info = super::deps::extract_deps(content, ext);
386    let sigs = extract_signatures(content, ext);
387    let mut parts = Vec::new();
388    if !dep_info.imports.is_empty() {
389        parts.push(dep_info.imports.join(","));
390    }
391    let key_sigs: Vec<String> = sigs
392        .iter()
393        .filter(|s| s.is_exported || s.indent == 0)
394        .map(Signature::to_compact_located)
395        .collect();
396    if !key_sigs.is_empty() {
397        parts.push(key_sigs.join("\n"));
398    }
399    parts.join("\n")
400}
401
402fn extract_ts_signatures(content: &str) -> Vec<Signature> {
403    let mut sigs = Vec::new();
404
405    for (line_idx, line) in content.lines().enumerate() {
406        let line_no = line_idx + 1;
407        let trimmed = line.trim();
408        if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
409            continue;
410        }
411
412        if let Some(caps) = fn_re().captures(line) {
413            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
414            sigs.push(Signature {
415                kind: if indent > 0 { "method" } else { "fn" },
416                name: caps[4].to_string(),
417                params: compact_params(&caps[5]),
418                return_type: caps
419                    .get(6)
420                    .map_or(String::new(), |m| m.as_str().trim().to_string()),
421                is_async: caps.get(3).is_some(),
422                is_exported: caps.get(2).is_some(),
423                indent: if indent > 0 { 2 } else { 0 },
424                start_line: Some(line_no),
425                end_line: Some(line_no),
426            });
427        } else if let Some(caps) = class_re().captures(line) {
428            sigs.push(Signature {
429                kind: "class",
430                name: caps[4].to_string(),
431                params: String::new(),
432                return_type: String::new(),
433                is_async: false,
434                is_exported: caps.get(2).is_some(),
435                indent: 0,
436                start_line: Some(line_no),
437                end_line: Some(line_no),
438            });
439        } else if let Some(caps) = iface_re().captures(line) {
440            sigs.push(Signature {
441                kind: "interface",
442                name: caps[3].to_string(),
443                params: String::new(),
444                return_type: String::new(),
445                is_async: false,
446                is_exported: caps.get(2).is_some(),
447                indent: 0,
448                start_line: Some(line_no),
449                end_line: Some(line_no),
450            });
451        } else if let Some(caps) = type_re().captures(line) {
452            sigs.push(Signature {
453                kind: "type",
454                name: caps[3].to_string(),
455                params: String::new(),
456                return_type: String::new(),
457                is_async: false,
458                is_exported: caps.get(2).is_some(),
459                indent: 0,
460                start_line: Some(line_no),
461                end_line: Some(line_no),
462            });
463        } else if let Some(caps) = const_re().captures(line)
464            && caps.get(2).is_some()
465        {
466            sigs.push(Signature {
467                kind: "const",
468                name: caps[4].to_string(),
469                params: String::new(),
470                return_type: caps
471                    .get(5)
472                    .map_or(String::new(), |m| m.as_str().to_string()),
473                is_async: false,
474                is_exported: true,
475                indent: 0,
476                start_line: Some(line_no),
477                end_line: Some(line_no),
478            });
479        }
480    }
481
482    sigs
483}
484
485fn extract_rust_signatures(content: &str) -> Vec<Signature> {
486    let mut sigs = Vec::new();
487
488    for (line_idx, line) in content.lines().enumerate() {
489        let line_no = line_idx + 1;
490        let trimmed = line.trim();
491        if trimmed.starts_with("//") || trimmed.starts_with("///") {
492            continue;
493        }
494
495        if let Some(caps) = rust_fn_re().captures(line) {
496            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
497            sigs.push(Signature {
498                kind: if indent > 0 { "method" } else { "fn" },
499                name: caps[4].to_string(),
500                params: compact_params(&caps[5]),
501                return_type: caps
502                    .get(6)
503                    .map_or(String::new(), |m| m.as_str().trim().to_string()),
504                is_async: caps.get(3).is_some(),
505                is_exported: caps.get(2).is_some(),
506                indent: if indent > 0 { 2 } else { 0 },
507                start_line: Some(line_no),
508                end_line: Some(line_no),
509            });
510        } else if let Some(caps) = rust_struct_re().captures(line) {
511            sigs.push(Signature {
512                kind: "struct",
513                name: caps[3].to_string(),
514                params: String::new(),
515                return_type: String::new(),
516                is_async: false,
517                is_exported: caps.get(2).is_some(),
518                indent: 0,
519                start_line: Some(line_no),
520                end_line: Some(line_no),
521            });
522        } else if let Some(caps) = rust_enum_re().captures(line) {
523            sigs.push(Signature {
524                kind: "enum",
525                name: caps[3].to_string(),
526                params: String::new(),
527                return_type: String::new(),
528                is_async: false,
529                is_exported: caps.get(2).is_some(),
530                indent: 0,
531                start_line: Some(line_no),
532                end_line: Some(line_no),
533            });
534        } else if let Some(caps) = rust_trait_re().captures(line) {
535            sigs.push(Signature {
536                kind: "trait",
537                name: caps[3].to_string(),
538                params: String::new(),
539                return_type: String::new(),
540                is_async: false,
541                is_exported: caps.get(2).is_some(),
542                indent: 0,
543                start_line: Some(line_no),
544                end_line: Some(line_no),
545            });
546        } else if let Some(caps) = rust_impl_re().captures(line) {
547            let trait_name = caps.get(2).map(|m| m.as_str());
548            let type_name = &caps[3];
549            let name = if let Some(t) = trait_name {
550                format!("{t} for {type_name}")
551            } else {
552                type_name.to_string()
553            };
554            sigs.push(Signature {
555                kind: "impl",
556                name,
557                params: String::new(),
558                return_type: String::new(),
559                is_async: false,
560                is_exported: false,
561                indent: 0,
562                start_line: Some(line_no),
563                end_line: Some(line_no),
564            });
565        }
566    }
567
568    sigs
569}
570
571fn extract_python_signatures(content: &str) -> Vec<Signature> {
572    let mut sigs = Vec::new();
573    let py_fn = static_regex!(r"^(\s*)(async\s+)?def\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*(\w+))?");
574    let py_class = static_regex!(r"^(\s*)class\s+(\w+)");
575
576    for (line_idx, line) in content.lines().enumerate() {
577        let line_no = line_idx + 1;
578        if let Some(caps) = py_fn.captures(line) {
579            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
580            sigs.push(Signature {
581                kind: if indent > 0 { "method" } else { "fn" },
582                name: caps[3].to_string(),
583                params: compact_params(&caps[4]),
584                return_type: caps
585                    .get(5)
586                    .map_or(String::new(), |m| m.as_str().to_string()),
587                is_async: caps.get(2).is_some(),
588                is_exported: !caps[3].starts_with('_'),
589                indent: if indent > 0 { 2 } else { 0 },
590                start_line: Some(line_no),
591                end_line: Some(line_no),
592            });
593        } else if let Some(caps) = py_class.captures(line) {
594            sigs.push(Signature {
595                kind: "class",
596                name: caps[2].to_string(),
597                params: String::new(),
598                return_type: String::new(),
599                is_async: false,
600                is_exported: !caps[2].starts_with('_'),
601                indent: 0,
602                start_line: Some(line_no),
603                end_line: Some(line_no),
604            });
605        }
606    }
607
608    sigs
609}
610
611fn extract_go_signatures(content: &str) -> Vec<Signature> {
612    let mut sigs = Vec::new();
613    let go_fn = static_regex!(
614        r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\(([^)]*)\)(?:\s*(?:\(([^)]*)\)|(\w+)))?\s*\{"
615    );
616    let go_type = static_regex!(r"^type\s+(\w+)\s+(struct|interface)");
617
618    for (line_idx, line) in content.lines().enumerate() {
619        let line_no = line_idx + 1;
620        if let Some(caps) = go_fn.captures(line) {
621            let is_method = caps.get(2).is_some();
622            sigs.push(Signature {
623                kind: if is_method { "method" } else { "fn" },
624                name: caps[3].to_string(),
625                params: compact_params(&caps[4]),
626                return_type: caps
627                    .get(5)
628                    .or(caps.get(6))
629                    .map_or(String::new(), |m| m.as_str().to_string()),
630                is_async: false,
631                is_exported: caps[3].starts_with(char::is_uppercase),
632                indent: if is_method { 2 } else { 0 },
633                start_line: Some(line_no),
634                end_line: Some(line_no),
635            });
636        } else if let Some(caps) = go_type.captures(line) {
637            sigs.push(Signature {
638                kind: if &caps[2] == "struct" {
639                    "struct"
640                } else {
641                    "interface"
642                },
643                name: caps[1].to_string(),
644                params: String::new(),
645                return_type: String::new(),
646                is_async: false,
647                is_exported: caps[1].starts_with(char::is_uppercase),
648                indent: 0,
649                start_line: Some(line_no),
650                end_line: Some(line_no),
651            });
652        }
653    }
654
655    sigs
656}
657
658pub(crate) fn compact_params(params: &str) -> String {
659    if params.trim().is_empty() {
660        return String::new();
661    }
662    params
663        .split(',')
664        .map(|p| {
665            let p = p.trim();
666            if let Some((name, ty)) = p.split_once(':') {
667                let name = name.trim();
668                let ty = ty.trim();
669                let short = match ty {
670                    "string" | "String" | "&str" | "str" => ":s",
671                    "number" | "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" => ":n",
672                    "boolean" | "bool" => ":b",
673                    _ => return format!("{name}:{ty}"),
674                };
675                format!("{name}{short}")
676            } else {
677                p.to_string()
678            }
679        })
680        .collect::<Vec<_>>()
681        .join(", ")
682}
683
684fn compact_type(ty: &str) -> String {
685    match ty.trim() {
686        "String" | "string" | "&str" | "str" => "s".to_string(),
687        "bool" | "boolean" => "b".to_string(),
688        "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" | "number" => "n".to_string(),
689        "void" | "()" => "∅".to_string(),
690        other => {
691            if other.starts_with("Vec<") || other.starts_with("Array<") {
692                let inner = other
693                    .trim_start_matches("Vec<")
694                    .trim_start_matches("Array<")
695                    .trim_end_matches('>');
696                format!("[{}]", compact_type(inner))
697            } else if other.starts_with("Option<") || other.starts_with("Maybe<") {
698                let inner = other
699                    .trim_start_matches("Option<")
700                    .trim_start_matches("Maybe<")
701                    .trim_end_matches('>');
702                format!("?{}", compact_type(inner))
703            } else if other.starts_with("Result<") {
704                "R".to_string()
705            } else if other.starts_with("impl ") {
706                other.trim_start_matches("impl ").to_string()
707            } else {
708                other.to_string()
709            }
710        }
711    }
712}
713
714fn tdd_params(params: &str) -> String {
715    if params.trim().is_empty() {
716        return String::new();
717    }
718    params
719        .split(',')
720        .map(|p| {
721            let p = p.trim();
722            if p.starts_with('&') {
723                let rest = p.trim_start_matches("&mut ").trim_start_matches('&');
724                if let Some((name, ty)) = rest.split_once(':') {
725                    format!("&{}:{}", name.trim(), compact_type(ty))
726                } else {
727                    p.to_string()
728                }
729            } else if let Some((name, ty)) = p.split_once(':') {
730                format!("{}:{}", name.trim(), compact_type(ty))
731            } else if p == "self" || p == "&self" || p == "&mut self" {
732                "⊕".to_string()
733            } else {
734                p.to_string()
735            }
736        })
737        .collect::<Vec<_>>()
738        .join(",")
739}
740
741fn extract_generic_signatures(content: &str) -> Vec<Signature> {
742    let re_func = static_regex!(
743        r"^\s*(?:(?:public|private|protected|static|async|abstract|virtual|override|final|def|func|fun|fn)\s+)+(\w+)\s*\("
744    );
745    // PowerShell-style declarations: `function Verb-Noun {` — no paren required,
746    // hyphens allowed in the name. Also catches bash-like dialects the keyword+
747    // paren pattern above misses.
748    let re_ps_func = static_regex!(r"^\s*function\s+([\w-]+)");
749    let re_class = static_regex!(
750        r"^\s*(?:(?:public|private|protected|abstract|final|sealed|partial)\s+)*(?:class|struct|enum|interface|trait|module|object|record)\s+(\w+)"
751    );
752
753    let mut sigs = Vec::new();
754    for (line_idx, line) in content.lines().enumerate() {
755        let line_no = line_idx + 1;
756        let trimmed = line.trim();
757        if trimmed.is_empty()
758            || trimmed.starts_with("//")
759            || trimmed.starts_with('#')
760            || trimmed.starts_with("/*")
761            || trimmed.starts_with('*')
762        {
763            continue;
764        }
765        if let Some(caps) = re_class.captures(trimmed) {
766            sigs.push(Signature {
767                kind: "type",
768                name: caps[1].to_string(),
769                params: String::new(),
770                return_type: String::new(),
771                is_async: false,
772                is_exported: true,
773                indent: 0,
774                start_line: Some(line_no),
775                end_line: Some(line_no),
776            });
777        } else if let Some(caps) = re_func.captures(trimmed) {
778            sigs.push(Signature {
779                kind: "fn",
780                name: caps[1].to_string(),
781                params: String::new(),
782                return_type: String::new(),
783                is_async: trimmed.contains("async"),
784                is_exported: true,
785                indent: 0,
786                start_line: Some(line_no),
787                end_line: Some(line_no),
788            });
789        } else if let Some(caps) = re_ps_func.captures(trimmed) {
790            sigs.push(Signature {
791                kind: "fn",
792                name: caps[1].to_string(),
793                params: String::new(),
794                return_type: String::new(),
795                is_async: false,
796                is_exported: true,
797                indent: 0,
798                start_line: Some(line_no),
799                end_line: Some(line_no),
800            });
801        }
802    }
803    sigs
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    fn sample_fn() -> Signature {
811        Signature {
812            kind: "fn",
813            name: "run".to_string(),
814            params: "id:usize".to_string(),
815            return_type: "bool".to_string(),
816            is_async: false,
817            is_exported: true,
818            indent: 0,
819            start_line: None,
820            end_line: None,
821        }
822    }
823
824    #[test]
825    fn line_suffix_formats_known_spans() {
826        let mut sig = sample_fn();
827        assert_eq!(sig.line_suffix(), "");
828
829        sig.start_line = Some(42);
830        sig.end_line = Some(42);
831        assert_eq!(sig.line_suffix(), " @L42");
832
833        sig.end_line = Some(57);
834        assert_eq!(sig.line_suffix(), " @L42-57");
835    }
836
837    #[test]
838    fn base_renderers_stay_suffix_free() {
839        // Compression-first modes must never pay for line ranges, even when
840        // the span is known.
841        let mut sig = sample_fn();
842        sig.start_line = Some(3);
843        sig.end_line = Some(9);
844        assert_eq!(sig.to_compact(), "fn pub run(id:usize) → bool");
845        assert_eq!(sig.to_tdd(), "λ+run(id:n)→b");
846    }
847
848    #[test]
849    fn located_renderers_append_line_suffix() {
850        let mut sig = sample_fn();
851        // Unknown span → identical to the base renderer.
852        assert_eq!(sig.to_compact_located(), "fn pub run(id:usize) → bool");
853        assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b");
854
855        sig.start_line = Some(3);
856        sig.end_line = Some(5);
857        assert_eq!(
858            sig.to_compact_located(),
859            "fn pub run(id:usize) → bool @L3-5"
860        );
861        assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b @L3-5");
862    }
863
864    #[test]
865    fn plain_notation_is_self_describing() {
866        // GL #580: plain mode must read like source keywords, no decoder ring.
867        let mut sig = sample_fn();
868        sig.kind = "struct";
869        assert_eq!(sig.to_compact(), "struct pub run");
870        sig.kind = "trait";
871        assert_eq!(sig.to_compact(), "trait pub run");
872        sig.kind = "enum";
873        sig.is_exported = false;
874        assert_eq!(sig.to_compact(), "enum run");
875        sig.kind = "const";
876        sig.return_type = "u32".to_string();
877        assert_eq!(sig.to_compact(), "const run:u32");
878    }
879
880    #[test]
881    fn tdd_legend_explains_only_present_symbols() {
882        // GL #580: symbol outputs carry their own minimal legend.
883        let f = sample_fn();
884        let mut s = sample_fn();
885        s.kind = "struct";
886        s.is_exported = false;
887
888        let legend = tdd_legend(&[&f, &s]);
889        assert_eq!(legend, "[λ=fn §=class +=pub]");
890        // Stays comfortably under the 15-token budget for a typical file.
891        assert!(crate::core::tokens::count_tokens(&legend) <= 15, "{legend}");
892
893        assert_eq!(tdd_legend(&[]), "");
894    }
895
896    #[test]
897    fn regex_fallback_assigns_declaration_line_spans() {
898        let src = "\npublic class Service {}\n\npublic fn run() {\n}\n";
899        let sigs = extract_generic_signatures(src);
900
901        let service = sigs.iter().find(|s| s.name == "Service").unwrap();
902        assert_eq!(service.start_line, Some(2));
903        assert_eq!(service.end_line, Some(2));
904
905        let run = sigs.iter().find(|s| s.name == "run").unwrap();
906        assert_eq!(run.start_line, Some(4));
907        assert_eq!(run.end_line, Some(4));
908    }
909
910    // PowerShell in the generic regex fallback: `function Verb-Noun {` has no
911    // paren and a hyphenated name, which the old keyword+paren pattern missed
912    // entirely (limitation audit 2026-07-03). Covers non-tree-sitter builds.
913    #[test]
914    fn regex_fallback_matches_powershell_functions() {
915        let src = "function Get-CargoBinDir {\n}\nfunction Install($x) {\n}\n# function Commented-Out {\n";
916        let sigs = extract_generic_signatures(src);
917        let names: Vec<&str> = sigs.iter().map(|s| s.name.as_str()).collect();
918        assert!(names.contains(&"Get-CargoBinDir"), "got {names:?}");
919        assert!(names.contains(&"Install"), "got {names:?}");
920        assert!(
921            !names.contains(&"Commented-Out"),
922            "comment must be skipped; got {names:?}"
923        );
924    }
925}