Skip to main content

lean_ctx_syntax/
signatures.rs

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