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
287pub fn extract_signatures(content: &str, file_ext: &str) -> Vec<Signature> {
288    let (sigs, backend) = extract_signatures_with_backend(content, file_ext);
289    let is_tree_sitter = matches!(backend, SigBackend::TreeSitter);
290    match backend {
291        SigBackend::TreeSitter => TREE_SITTER_HITS.fetch_add(1, Ordering::Relaxed),
292        SigBackend::Regex => REGEX_FALLBACK_HITS.fetch_add(1, Ordering::Relaxed),
293    };
294    // Persistent per-extension telemetry (GH #690 Phase 2): the tiering cut
295    // needs to know which grammars earn their binary bytes across sessions,
296    // which these process-lifetime atomics cannot answer.
297    crate::core::grammar_usage::record(file_ext, is_tree_sitter);
298    sigs
299}
300
301/// Like [`extract_signatures`] but also reports which backend produced the
302/// result. Does **not** touch the global backend counters, so callers that only
303/// want the label (e.g. per-file JSON outline) don't distort
304/// [`signature_backend_stats`]; the counted path stays [`extract_signatures`].
305pub fn extract_signatures_with_backend(
306    content: &str,
307    file_ext: &str,
308) -> (Vec<Signature>, SigBackend) {
309    #[cfg(feature = "tree-sitter")]
310    {
311        // A successful parse that yields no signatures (`Some(vec![])`) means the
312        // tree-sitter query set has a gap for this file's constructs. Fall through
313        // to the regex extractors instead of returning the empty result, which
314        // would otherwise suppress signatures the regex fallback can still
315        // recover (e.g. constructs the query does not capture).
316        if let Some(sigs) = super::signatures_ts::extract_signatures_ts(content, file_ext)
317            && !sigs.is_empty()
318        {
319            return (sigs, SigBackend::TreeSitter);
320        }
321    }
322
323    let sigs = match file_ext {
324        "rs" => extract_rust_signatures(content),
325        "ts" | "tsx" | "js" | "jsx" | "svelte" | "vue" => extract_ts_signatures(content),
326        "py" => extract_python_signatures(content),
327        "go" => extract_go_signatures(content),
328        _ => extract_generic_signatures(content),
329    };
330    (sigs, SigBackend::Regex)
331}
332
333pub fn extract_file_map(path: &str, content: &str) -> String {
334    let ext = std::path::Path::new(path)
335        .extension()
336        .and_then(|e| e.to_str())
337        .unwrap_or("rs");
338    let dep_info = super::deps::extract_deps(content, ext);
339    let sigs = extract_signatures(content, ext);
340    let mut parts = Vec::new();
341    if !dep_info.imports.is_empty() {
342        parts.push(dep_info.imports.join(","));
343    }
344    let key_sigs: Vec<String> = sigs
345        .iter()
346        .filter(|s| s.is_exported || s.indent == 0)
347        .map(Signature::to_compact_located)
348        .collect();
349    if !key_sigs.is_empty() {
350        parts.push(key_sigs.join("\n"));
351    }
352    parts.join("\n")
353}
354
355fn extract_ts_signatures(content: &str) -> Vec<Signature> {
356    let mut sigs = Vec::new();
357
358    for (line_idx, line) in content.lines().enumerate() {
359        let line_no = line_idx + 1;
360        let trimmed = line.trim();
361        if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
362            continue;
363        }
364
365        if let Some(caps) = fn_re().captures(line) {
366            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
367            sigs.push(Signature {
368                kind: if indent > 0 { "method" } else { "fn" },
369                name: caps[4].to_string(),
370                params: compact_params(&caps[5]),
371                return_type: caps
372                    .get(6)
373                    .map_or(String::new(), |m| m.as_str().trim().to_string()),
374                is_async: caps.get(3).is_some(),
375                is_exported: caps.get(2).is_some(),
376                indent: if indent > 0 { 2 } else { 0 },
377                start_line: Some(line_no),
378                end_line: Some(line_no),
379            });
380        } else if let Some(caps) = class_re().captures(line) {
381            sigs.push(Signature {
382                kind: "class",
383                name: caps[4].to_string(),
384                params: String::new(),
385                return_type: String::new(),
386                is_async: false,
387                is_exported: caps.get(2).is_some(),
388                indent: 0,
389                start_line: Some(line_no),
390                end_line: Some(line_no),
391            });
392        } else if let Some(caps) = iface_re().captures(line) {
393            sigs.push(Signature {
394                kind: "interface",
395                name: caps[3].to_string(),
396                params: String::new(),
397                return_type: String::new(),
398                is_async: false,
399                is_exported: caps.get(2).is_some(),
400                indent: 0,
401                start_line: Some(line_no),
402                end_line: Some(line_no),
403            });
404        } else if let Some(caps) = type_re().captures(line) {
405            sigs.push(Signature {
406                kind: "type",
407                name: caps[3].to_string(),
408                params: String::new(),
409                return_type: String::new(),
410                is_async: false,
411                is_exported: caps.get(2).is_some(),
412                indent: 0,
413                start_line: Some(line_no),
414                end_line: Some(line_no),
415            });
416        } else if let Some(caps) = const_re().captures(line)
417            && caps.get(2).is_some()
418        {
419            sigs.push(Signature {
420                kind: "const",
421                name: caps[4].to_string(),
422                params: String::new(),
423                return_type: caps
424                    .get(5)
425                    .map_or(String::new(), |m| m.as_str().to_string()),
426                is_async: false,
427                is_exported: true,
428                indent: 0,
429                start_line: Some(line_no),
430                end_line: Some(line_no),
431            });
432        }
433    }
434
435    sigs
436}
437
438fn extract_rust_signatures(content: &str) -> Vec<Signature> {
439    let mut sigs = Vec::new();
440
441    for (line_idx, line) in content.lines().enumerate() {
442        let line_no = line_idx + 1;
443        let trimmed = line.trim();
444        if trimmed.starts_with("//") || trimmed.starts_with("///") {
445            continue;
446        }
447
448        if let Some(caps) = rust_fn_re().captures(line) {
449            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
450            sigs.push(Signature {
451                kind: if indent > 0 { "method" } else { "fn" },
452                name: caps[4].to_string(),
453                params: compact_params(&caps[5]),
454                return_type: caps
455                    .get(6)
456                    .map_or(String::new(), |m| m.as_str().trim().to_string()),
457                is_async: caps.get(3).is_some(),
458                is_exported: caps.get(2).is_some(),
459                indent: if indent > 0 { 2 } else { 0 },
460                start_line: Some(line_no),
461                end_line: Some(line_no),
462            });
463        } else if let Some(caps) = rust_struct_re().captures(line) {
464            sigs.push(Signature {
465                kind: "struct",
466                name: caps[3].to_string(),
467                params: String::new(),
468                return_type: String::new(),
469                is_async: false,
470                is_exported: caps.get(2).is_some(),
471                indent: 0,
472                start_line: Some(line_no),
473                end_line: Some(line_no),
474            });
475        } else if let Some(caps) = rust_enum_re().captures(line) {
476            sigs.push(Signature {
477                kind: "enum",
478                name: caps[3].to_string(),
479                params: String::new(),
480                return_type: String::new(),
481                is_async: false,
482                is_exported: caps.get(2).is_some(),
483                indent: 0,
484                start_line: Some(line_no),
485                end_line: Some(line_no),
486            });
487        } else if let Some(caps) = rust_trait_re().captures(line) {
488            sigs.push(Signature {
489                kind: "trait",
490                name: caps[3].to_string(),
491                params: String::new(),
492                return_type: String::new(),
493                is_async: false,
494                is_exported: caps.get(2).is_some(),
495                indent: 0,
496                start_line: Some(line_no),
497                end_line: Some(line_no),
498            });
499        } else if let Some(caps) = rust_impl_re().captures(line) {
500            let trait_name = caps.get(2).map(|m| m.as_str());
501            let type_name = &caps[3];
502            let name = if let Some(t) = trait_name {
503                format!("{t} for {type_name}")
504            } else {
505                type_name.to_string()
506            };
507            sigs.push(Signature {
508                kind: "impl",
509                name,
510                params: String::new(),
511                return_type: String::new(),
512                is_async: false,
513                is_exported: false,
514                indent: 0,
515                start_line: Some(line_no),
516                end_line: Some(line_no),
517            });
518        }
519    }
520
521    sigs
522}
523
524fn extract_python_signatures(content: &str) -> Vec<Signature> {
525    let mut sigs = Vec::new();
526    let py_fn = static_regex!(r"^(\s*)(async\s+)?def\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*(\w+))?");
527    let py_class = static_regex!(r"^(\s*)class\s+(\w+)");
528
529    for (line_idx, line) in content.lines().enumerate() {
530        let line_no = line_idx + 1;
531        if let Some(caps) = py_fn.captures(line) {
532            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
533            sigs.push(Signature {
534                kind: if indent > 0 { "method" } else { "fn" },
535                name: caps[3].to_string(),
536                params: compact_params(&caps[4]),
537                return_type: caps
538                    .get(5)
539                    .map_or(String::new(), |m| m.as_str().to_string()),
540                is_async: caps.get(2).is_some(),
541                is_exported: !caps[3].starts_with('_'),
542                indent: if indent > 0 { 2 } else { 0 },
543                start_line: Some(line_no),
544                end_line: Some(line_no),
545            });
546        } else if let Some(caps) = py_class.captures(line) {
547            sigs.push(Signature {
548                kind: "class",
549                name: caps[2].to_string(),
550                params: String::new(),
551                return_type: String::new(),
552                is_async: false,
553                is_exported: !caps[2].starts_with('_'),
554                indent: 0,
555                start_line: Some(line_no),
556                end_line: Some(line_no),
557            });
558        }
559    }
560
561    sigs
562}
563
564fn extract_go_signatures(content: &str) -> Vec<Signature> {
565    let mut sigs = Vec::new();
566    let go_fn = static_regex!(
567        r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\(([^)]*)\)(?:\s*(?:\(([^)]*)\)|(\w+)))?\s*\{"
568    );
569    let go_type = static_regex!(r"^type\s+(\w+)\s+(struct|interface)");
570
571    for (line_idx, line) in content.lines().enumerate() {
572        let line_no = line_idx + 1;
573        if let Some(caps) = go_fn.captures(line) {
574            let is_method = caps.get(2).is_some();
575            sigs.push(Signature {
576                kind: if is_method { "method" } else { "fn" },
577                name: caps[3].to_string(),
578                params: compact_params(&caps[4]),
579                return_type: caps
580                    .get(5)
581                    .or(caps.get(6))
582                    .map_or(String::new(), |m| m.as_str().to_string()),
583                is_async: false,
584                is_exported: caps[3].starts_with(char::is_uppercase),
585                indent: if is_method { 2 } else { 0 },
586                start_line: Some(line_no),
587                end_line: Some(line_no),
588            });
589        } else if let Some(caps) = go_type.captures(line) {
590            sigs.push(Signature {
591                kind: if &caps[2] == "struct" {
592                    "struct"
593                } else {
594                    "interface"
595                },
596                name: caps[1].to_string(),
597                params: String::new(),
598                return_type: String::new(),
599                is_async: false,
600                is_exported: caps[1].starts_with(char::is_uppercase),
601                indent: 0,
602                start_line: Some(line_no),
603                end_line: Some(line_no),
604            });
605        }
606    }
607
608    sigs
609}
610
611pub(crate) fn compact_params(params: &str) -> String {
612    if params.trim().is_empty() {
613        return String::new();
614    }
615    params
616        .split(',')
617        .map(|p| {
618            let p = p.trim();
619            if let Some((name, ty)) = p.split_once(':') {
620                let name = name.trim();
621                let ty = ty.trim();
622                let short = match ty {
623                    "string" | "String" | "&str" | "str" => ":s",
624                    "number" | "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" => ":n",
625                    "boolean" | "bool" => ":b",
626                    _ => return format!("{name}:{ty}"),
627                };
628                format!("{name}{short}")
629            } else {
630                p.to_string()
631            }
632        })
633        .collect::<Vec<_>>()
634        .join(", ")
635}
636
637fn compact_type(ty: &str) -> String {
638    match ty.trim() {
639        "String" | "string" | "&str" | "str" => "s".to_string(),
640        "bool" | "boolean" => "b".to_string(),
641        "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" | "number" => "n".to_string(),
642        "void" | "()" => "∅".to_string(),
643        other => {
644            if other.starts_with("Vec<") || other.starts_with("Array<") {
645                let inner = other
646                    .trim_start_matches("Vec<")
647                    .trim_start_matches("Array<")
648                    .trim_end_matches('>');
649                format!("[{}]", compact_type(inner))
650            } else if other.starts_with("Option<") || other.starts_with("Maybe<") {
651                let inner = other
652                    .trim_start_matches("Option<")
653                    .trim_start_matches("Maybe<")
654                    .trim_end_matches('>');
655                format!("?{}", compact_type(inner))
656            } else if other.starts_with("Result<") {
657                "R".to_string()
658            } else if other.starts_with("impl ") {
659                other.trim_start_matches("impl ").to_string()
660            } else {
661                other.to_string()
662            }
663        }
664    }
665}
666
667fn tdd_params(params: &str) -> String {
668    if params.trim().is_empty() {
669        return String::new();
670    }
671    params
672        .split(',')
673        .map(|p| {
674            let p = p.trim();
675            if p.starts_with('&') {
676                let rest = p.trim_start_matches("&mut ").trim_start_matches('&');
677                if let Some((name, ty)) = rest.split_once(':') {
678                    format!("&{}:{}", name.trim(), compact_type(ty))
679                } else {
680                    p.to_string()
681                }
682            } else if let Some((name, ty)) = p.split_once(':') {
683                format!("{}:{}", name.trim(), compact_type(ty))
684            } else if p == "self" || p == "&self" || p == "&mut self" {
685                "⊕".to_string()
686            } else {
687                p.to_string()
688            }
689        })
690        .collect::<Vec<_>>()
691        .join(",")
692}
693
694fn extract_generic_signatures(content: &str) -> Vec<Signature> {
695    let re_func = static_regex!(
696        r"^\s*(?:(?:public|private|protected|static|async|abstract|virtual|override|final|def|func|fun|fn)\s+)+(\w+)\s*\("
697    );
698    // PowerShell-style declarations: `function Verb-Noun {` — no paren required,
699    // hyphens allowed in the name. Also catches bash-like dialects the keyword+
700    // paren pattern above misses.
701    let re_ps_func = static_regex!(r"^\s*function\s+([\w-]+)");
702    let re_class = static_regex!(
703        r"^\s*(?:(?:public|private|protected|abstract|final|sealed|partial)\s+)*(?:class|struct|enum|interface|trait|module|object|record)\s+(\w+)"
704    );
705
706    let mut sigs = Vec::new();
707    for (line_idx, line) in content.lines().enumerate() {
708        let line_no = line_idx + 1;
709        let trimmed = line.trim();
710        if trimmed.is_empty()
711            || trimmed.starts_with("//")
712            || trimmed.starts_with('#')
713            || trimmed.starts_with("/*")
714            || trimmed.starts_with('*')
715        {
716            continue;
717        }
718        if let Some(caps) = re_class.captures(trimmed) {
719            sigs.push(Signature {
720                kind: "type",
721                name: caps[1].to_string(),
722                params: String::new(),
723                return_type: String::new(),
724                is_async: false,
725                is_exported: true,
726                indent: 0,
727                start_line: Some(line_no),
728                end_line: Some(line_no),
729            });
730        } else if let Some(caps) = re_func.captures(trimmed) {
731            sigs.push(Signature {
732                kind: "fn",
733                name: caps[1].to_string(),
734                params: String::new(),
735                return_type: String::new(),
736                is_async: trimmed.contains("async"),
737                is_exported: true,
738                indent: 0,
739                start_line: Some(line_no),
740                end_line: Some(line_no),
741            });
742        } else if let Some(caps) = re_ps_func.captures(trimmed) {
743            sigs.push(Signature {
744                kind: "fn",
745                name: caps[1].to_string(),
746                params: String::new(),
747                return_type: String::new(),
748                is_async: false,
749                is_exported: true,
750                indent: 0,
751                start_line: Some(line_no),
752                end_line: Some(line_no),
753            });
754        }
755    }
756    sigs
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    fn sample_fn() -> Signature {
764        Signature {
765            kind: "fn",
766            name: "run".to_string(),
767            params: "id:usize".to_string(),
768            return_type: "bool".to_string(),
769            is_async: false,
770            is_exported: true,
771            indent: 0,
772            start_line: None,
773            end_line: None,
774        }
775    }
776
777    #[test]
778    fn line_suffix_formats_known_spans() {
779        let mut sig = sample_fn();
780        assert_eq!(sig.line_suffix(), "");
781
782        sig.start_line = Some(42);
783        sig.end_line = Some(42);
784        assert_eq!(sig.line_suffix(), " @L42");
785
786        sig.end_line = Some(57);
787        assert_eq!(sig.line_suffix(), " @L42-57");
788    }
789
790    #[test]
791    fn base_renderers_stay_suffix_free() {
792        // Compression-first modes must never pay for line ranges, even when
793        // the span is known.
794        let mut sig = sample_fn();
795        sig.start_line = Some(3);
796        sig.end_line = Some(9);
797        assert_eq!(sig.to_compact(), "fn pub run(id:usize) → bool");
798        assert_eq!(sig.to_tdd(), "λ+run(id:n)→b");
799    }
800
801    #[test]
802    fn located_renderers_append_line_suffix() {
803        let mut sig = sample_fn();
804        // Unknown span → identical to the base renderer.
805        assert_eq!(sig.to_compact_located(), "fn pub run(id:usize) → bool");
806        assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b");
807
808        sig.start_line = Some(3);
809        sig.end_line = Some(5);
810        assert_eq!(
811            sig.to_compact_located(),
812            "fn pub run(id:usize) → bool @L3-5"
813        );
814        assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b @L3-5");
815    }
816
817    #[test]
818    fn plain_notation_is_self_describing() {
819        // GL #580: plain mode must read like source keywords, no decoder ring.
820        let mut sig = sample_fn();
821        sig.kind = "struct";
822        assert_eq!(sig.to_compact(), "struct pub run");
823        sig.kind = "trait";
824        assert_eq!(sig.to_compact(), "trait pub run");
825        sig.kind = "enum";
826        sig.is_exported = false;
827        assert_eq!(sig.to_compact(), "enum run");
828        sig.kind = "const";
829        sig.return_type = "u32".to_string();
830        assert_eq!(sig.to_compact(), "const run:u32");
831    }
832
833    #[test]
834    fn tdd_legend_explains_only_present_symbols() {
835        // GL #580: symbol outputs carry their own minimal legend.
836        let f = sample_fn();
837        let mut s = sample_fn();
838        s.kind = "struct";
839        s.is_exported = false;
840
841        let legend = tdd_legend(&[&f, &s]);
842        assert_eq!(legend, "[λ=fn §=class +=pub]");
843        // Stays comfortably under the 15-token budget for a typical file.
844        assert!(crate::core::tokens::count_tokens(&legend) <= 15, "{legend}");
845
846        assert_eq!(tdd_legend(&[]), "");
847    }
848
849    #[test]
850    fn regex_fallback_assigns_declaration_line_spans() {
851        let src = "\npublic class Service {}\n\npublic fn run() {\n}\n";
852        let sigs = extract_generic_signatures(src);
853
854        let service = sigs.iter().find(|s| s.name == "Service").unwrap();
855        assert_eq!(service.start_line, Some(2));
856        assert_eq!(service.end_line, Some(2));
857
858        let run = sigs.iter().find(|s| s.name == "run").unwrap();
859        assert_eq!(run.start_line, Some(4));
860        assert_eq!(run.end_line, Some(4));
861    }
862
863    // PowerShell in the generic regex fallback: `function Verb-Noun {` has no
864    // paren and a hyphenated name, which the old keyword+paren pattern missed
865    // entirely (limitation audit 2026-07-03). Covers non-tree-sitter builds.
866    #[test]
867    fn regex_fallback_matches_powershell_functions() {
868        let src = "function Get-CargoBinDir {\n}\nfunction Install($x) {\n}\n# function Commented-Out {\n";
869        let sigs = extract_generic_signatures(src);
870        let names: Vec<&str> = sigs.iter().map(|s| s.name.as_str()).collect();
871        assert!(names.contains(&"Get-CargoBinDir"), "got {names:?}");
872        assert!(names.contains(&"Install"), "got {names:?}");
873        assert!(
874            !names.contains(&"Commented-Out"),
875            "comment must be skipped; got {names:?}"
876        );
877    }
878}