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