Skip to main content

lean_ctx/core/
signatures.rs

1use regex::Regex;
2
3macro_rules! static_regex {
4    ($pattern:expr) => {{
5        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6        RE.get_or_init(|| {
7            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8        })
9    }};
10}
11
12#[derive(Debug, Clone)]
13pub struct Signature {
14    pub kind: &'static str,
15    pub name: String,
16    pub params: String,
17    pub return_type: String,
18    pub is_async: bool,
19    pub is_exported: bool,
20    pub indent: usize,
21    pub start_line: Option<usize>,
22    pub end_line: Option<usize>,
23}
24
25/// Exports not already named by `key_sigs`. Map-mode renderers list the API
26/// with full signatures + line ranges; repeating those same names in an
27/// `exports:` line is pure redundancy. Callers surface only the exports the API
28/// does not already cover (e.g. re-exports or const/type aliases that aren't
29/// captured as signatures), keeping map lossless while dropping duplicates
30/// (#361). Shared by the MCP, CLI, and benchmark map renderers.
31#[must_use]
32pub fn exports_not_in_signatures<'a>(
33    exports: &'a [String],
34    key_sigs: &[&Signature],
35) -> Vec<&'a str> {
36    let api_names: std::collections::HashSet<&str> =
37        key_sigs.iter().map(|s| s.name.as_str()).collect();
38    exports
39        .iter()
40        .map(String::as_str)
41        .filter(|e| !api_names.contains(e))
42        .collect()
43}
44
45impl Signature {
46    pub fn no_span() -> Self {
47        Self {
48            kind: "",
49            name: String::new(),
50            params: String::new(),
51            return_type: String::new(),
52            is_async: false,
53            is_exported: false,
54            indent: 0,
55            start_line: None,
56            end_line: None,
57        }
58    }
59
60    /// Plain, self-describing notation (GL #580): real keywords (`class`,
61    /// `trait`, `pub`) instead of abbreviations, so a vanilla agent without
62    /// injected rules reads the output correctly. Keyword tokens cost the
63    /// same as (or less than) the old `cl`/`⊛` forms.
64    pub fn to_compact(&self) -> String {
65        let export = if self.is_exported { "pub " } else { "" };
66        let async_prefix = if self.is_async { "async " } else { "" };
67
68        match self.kind {
69            "fn" | "method" => {
70                let ret = if self.return_type.is_empty() {
71                    String::new()
72                } else {
73                    format!(" → {}", self.return_type)
74                };
75                let indent = " ".repeat(self.indent);
76                format!(
77                    "{indent}fn {async_prefix}{export}{}({}){}",
78                    self.name, self.params, ret
79                )
80            }
81            "const" | "let" | "var" => {
82                let ty = if self.return_type.is_empty() {
83                    String::new()
84                } else {
85                    format!(":{}", self.return_type)
86                };
87                format!("{} {export}{}{ty}", self.kind, self.name)
88            }
89            // `kind` is already the source-language keyword (class, struct,
90            // interface, trait, type, enum) — use it verbatim.
91            _ => format!("{} {export}{}", self.kind, self.name),
92        }
93    }
94
95    pub fn to_tdd(&self) -> String {
96        let vis = if self.is_exported { "+" } else { "-" };
97        let a = if self.is_async { "~" } else { "" };
98
99        match self.kind {
100            "fn" | "method" => {
101                let ret = if self.return_type.is_empty() {
102                    String::new()
103                } else {
104                    format!("→{}", compact_type(&self.return_type))
105                };
106                let params = tdd_params(&self.params);
107                let indent = if self.indent > 0 { " " } else { "" };
108                format!("{indent}{a}λ{vis}{}({params}){ret}", self.name)
109            }
110            "class" | "struct" => format!("§{vis}{}", self.name),
111            "interface" | "trait" => format!("∂{vis}{}", self.name),
112            "type" => format!("τ{vis}{}", self.name),
113            "enum" => format!("ε{vis}{}", self.name),
114            "const" | "let" | "var" => {
115                let ty = if self.return_type.is_empty() {
116                    String::new()
117                } else {
118                    format!(":{}", compact_type(&self.return_type))
119                };
120                format!("ν{vis}{}{ty}", self.name)
121            }
122            _ => format!(
123                "{}{vis}{}",
124                self.kind.chars().next().unwrap_or('?'),
125                self.name
126            ),
127        }
128    }
129
130    /// Compact ` @Lstart[-end]` suffix for navigation-focused modes.
131    /// Returns an empty string when the span is unknown, so compression-first
132    /// modes that render the base `to_compact`/`to_tdd` stay byte-identical.
133    pub fn line_suffix(&self) -> String {
134        match (self.start_line, self.end_line) {
135            (Some(start), Some(end)) if start > 0 && end > start => format!(" @L{start}-{end}"),
136            (Some(start), _) if start > 0 => format!(" @L{start}"),
137            _ => String::new(),
138        }
139    }
140
141    /// `to_compact` plus a line-span suffix. Reserved for navigation modes
142    /// (`map`/`signatures`) where locating code outweighs the few extra tokens.
143    pub fn to_compact_located(&self) -> String {
144        format!("{}{}", self.to_compact(), self.line_suffix())
145    }
146
147    /// `to_tdd` plus a line-span suffix. Reserved for navigation modes.
148    pub fn to_tdd_located(&self) -> String {
149        format!("{}{}", self.to_tdd(), self.line_suffix())
150    }
151}
152
153/// One-line legend for TDD symbol notation (GL #580): outputs must be
154/// self-describing for vanilla agents without injected rules. Only the
155/// symbols actually present in `sigs` are explained, keeping the line well
156/// under the 15-token budget for typical files. Empty when nothing applies.
157pub fn tdd_legend<'a>(sigs: &[&'a Signature]) -> String {
158    if sigs.is_empty() {
159        return String::new();
160    }
161    let mut parts: Vec<&str> = Vec::new();
162    let has = |pred: &dyn Fn(&'a Signature) -> bool| sigs.iter().any(|s| pred(s));
163
164    if has(&|s| matches!(s.kind, "fn" | "method")) {
165        parts.push("λ=fn");
166    }
167    if has(&|s| matches!(s.kind, "class" | "struct")) {
168        parts.push("§=class");
169    }
170    if has(&|s| matches!(s.kind, "interface" | "trait")) {
171        parts.push("∂=trait");
172    }
173    if has(&|s| s.kind == "type") {
174        parts.push("τ=type");
175    }
176    if has(&|s| s.kind == "enum") {
177        parts.push("ε=enum");
178    }
179    if has(&|s| matches!(s.kind, "const" | "let" | "var")) {
180        parts.push("ν=val");
181    }
182    if has(&|s| s.is_exported) {
183        parts.push("+=pub");
184    }
185    if has(&|s| s.is_async) {
186        parts.push("~=async");
187    }
188    if parts.is_empty() {
189        String::new()
190    } else {
191        format!("[{}]", parts.join(" "))
192    }
193}
194
195fn fn_re() -> &'static Regex {
196    static_regex!(
197        r"^(\s*)(export\s+)?(async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*:\s*([^\{]+))?\s*\{?"
198    )
199}
200
201fn class_re() -> &'static Regex {
202    static_regex!(r"^(\s*)(export\s+)?(abstract\s+)?class\s+(\w+)")
203}
204
205fn iface_re() -> &'static Regex {
206    static_regex!(r"^(\s*)(export\s+)?interface\s+(\w+)")
207}
208
209fn type_re() -> &'static Regex {
210    static_regex!(r"^(\s*)(export\s+)?type\s+(\w+)")
211}
212
213fn const_re() -> &'static Regex {
214    static_regex!(r"^(\s*)(export\s+)?(const|let|var)\s+(\w+)(?:\s*:\s*(\w+))?")
215}
216
217fn rust_fn_re() -> &'static Regex {
218    static_regex!(
219        r"^(\s*)(pub\s+)?(async\s+)?fn\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*->\s*([^\{]+))?\s*\{?"
220    )
221}
222
223fn rust_struct_re() -> &'static Regex {
224    static_regex!(r"^(\s*)(pub\s+)?struct\s+(\w+)")
225}
226
227fn rust_enum_re() -> &'static Regex {
228    static_regex!(r"^(\s*)(pub\s+)?enum\s+(\w+)")
229}
230
231fn rust_trait_re() -> &'static Regex {
232    static_regex!(r"^(\s*)(pub\s+)?trait\s+(\w+)")
233}
234
235fn rust_impl_re() -> &'static Regex {
236    static_regex!(r"^(\s*)impl\s+(?:(\w+)\s+for\s+)?(\w+)")
237}
238
239use std::sync::atomic::{AtomicU64, Ordering};
240
241static TREE_SITTER_HITS: AtomicU64 = AtomicU64::new(0);
242static REGEX_FALLBACK_HITS: AtomicU64 = AtomicU64::new(0);
243
244/// Returns (tree_sitter_hits, regex_fallback_hits) since process start.
245pub fn signature_backend_stats() -> (u64, u64) {
246    (
247        TREE_SITTER_HITS.load(Ordering::Relaxed),
248        REGEX_FALLBACK_HITS.load(Ordering::Relaxed),
249    )
250}
251
252pub fn extract_signatures(content: &str, file_ext: &str) -> Vec<Signature> {
253    #[cfg(feature = "tree-sitter")]
254    {
255        if let Some(sigs) = super::signatures_ts::extract_signatures_ts(content, file_ext) {
256            TREE_SITTER_HITS.fetch_add(1, Ordering::Relaxed);
257            return sigs;
258        }
259    }
260
261    REGEX_FALLBACK_HITS.fetch_add(1, Ordering::Relaxed);
262    match file_ext {
263        "rs" => extract_rust_signatures(content),
264        "ts" | "tsx" | "js" | "jsx" | "svelte" | "vue" => extract_ts_signatures(content),
265        "py" => extract_python_signatures(content),
266        "go" => extract_go_signatures(content),
267        _ => extract_generic_signatures(content),
268    }
269}
270
271pub fn extract_file_map(path: &str, content: &str) -> String {
272    let ext = std::path::Path::new(path)
273        .extension()
274        .and_then(|e| e.to_str())
275        .unwrap_or("rs");
276    let dep_info = super::deps::extract_deps(content, ext);
277    let sigs = extract_signatures(content, ext);
278    let mut parts = Vec::new();
279    if !dep_info.imports.is_empty() {
280        parts.push(dep_info.imports.join(","));
281    }
282    let key_sigs: Vec<String> = sigs
283        .iter()
284        .filter(|s| s.is_exported || s.indent == 0)
285        .map(Signature::to_compact_located)
286        .collect();
287    if !key_sigs.is_empty() {
288        parts.push(key_sigs.join("\n"));
289    }
290    parts.join("\n")
291}
292
293fn extract_ts_signatures(content: &str) -> Vec<Signature> {
294    let mut sigs = Vec::new();
295
296    for (line_idx, line) in content.lines().enumerate() {
297        let line_no = line_idx + 1;
298        let trimmed = line.trim();
299        if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
300            continue;
301        }
302
303        if let Some(caps) = fn_re().captures(line) {
304            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
305            sigs.push(Signature {
306                kind: if indent > 0 { "method" } else { "fn" },
307                name: caps[4].to_string(),
308                params: compact_params(&caps[5]),
309                return_type: caps
310                    .get(6)
311                    .map_or(String::new(), |m| m.as_str().trim().to_string()),
312                is_async: caps.get(3).is_some(),
313                is_exported: caps.get(2).is_some(),
314                indent: if indent > 0 { 2 } else { 0 },
315                start_line: Some(line_no),
316                end_line: Some(line_no),
317            });
318        } else if let Some(caps) = class_re().captures(line) {
319            sigs.push(Signature {
320                kind: "class",
321                name: caps[4].to_string(),
322                params: String::new(),
323                return_type: String::new(),
324                is_async: false,
325                is_exported: caps.get(2).is_some(),
326                indent: 0,
327                start_line: Some(line_no),
328                end_line: Some(line_no),
329            });
330        } else if let Some(caps) = iface_re().captures(line) {
331            sigs.push(Signature {
332                kind: "interface",
333                name: caps[3].to_string(),
334                params: String::new(),
335                return_type: String::new(),
336                is_async: false,
337                is_exported: caps.get(2).is_some(),
338                indent: 0,
339                start_line: Some(line_no),
340                end_line: Some(line_no),
341            });
342        } else if let Some(caps) = type_re().captures(line) {
343            sigs.push(Signature {
344                kind: "type",
345                name: caps[3].to_string(),
346                params: String::new(),
347                return_type: String::new(),
348                is_async: false,
349                is_exported: caps.get(2).is_some(),
350                indent: 0,
351                start_line: Some(line_no),
352                end_line: Some(line_no),
353            });
354        } else if let Some(caps) = const_re().captures(line) {
355            if caps.get(2).is_some() {
356                sigs.push(Signature {
357                    kind: "const",
358                    name: caps[4].to_string(),
359                    params: String::new(),
360                    return_type: caps
361                        .get(5)
362                        .map_or(String::new(), |m| m.as_str().to_string()),
363                    is_async: false,
364                    is_exported: true,
365                    indent: 0,
366                    start_line: Some(line_no),
367                    end_line: Some(line_no),
368                });
369            }
370        }
371    }
372
373    sigs
374}
375
376fn extract_rust_signatures(content: &str) -> Vec<Signature> {
377    let mut sigs = Vec::new();
378
379    for (line_idx, line) in content.lines().enumerate() {
380        let line_no = line_idx + 1;
381        let trimmed = line.trim();
382        if trimmed.starts_with("//") || trimmed.starts_with("///") {
383            continue;
384        }
385
386        if let Some(caps) = rust_fn_re().captures(line) {
387            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
388            sigs.push(Signature {
389                kind: if indent > 0 { "method" } else { "fn" },
390                name: caps[4].to_string(),
391                params: compact_params(&caps[5]),
392                return_type: caps
393                    .get(6)
394                    .map_or(String::new(), |m| m.as_str().trim().to_string()),
395                is_async: caps.get(3).is_some(),
396                is_exported: caps.get(2).is_some(),
397                indent: if indent > 0 { 2 } else { 0 },
398                start_line: Some(line_no),
399                end_line: Some(line_no),
400            });
401        } else if let Some(caps) = rust_struct_re().captures(line) {
402            sigs.push(Signature {
403                kind: "struct",
404                name: caps[3].to_string(),
405                params: String::new(),
406                return_type: String::new(),
407                is_async: false,
408                is_exported: caps.get(2).is_some(),
409                indent: 0,
410                start_line: Some(line_no),
411                end_line: Some(line_no),
412            });
413        } else if let Some(caps) = rust_enum_re().captures(line) {
414            sigs.push(Signature {
415                kind: "enum",
416                name: caps[3].to_string(),
417                params: String::new(),
418                return_type: String::new(),
419                is_async: false,
420                is_exported: caps.get(2).is_some(),
421                indent: 0,
422                start_line: Some(line_no),
423                end_line: Some(line_no),
424            });
425        } else if let Some(caps) = rust_trait_re().captures(line) {
426            sigs.push(Signature {
427                kind: "trait",
428                name: caps[3].to_string(),
429                params: String::new(),
430                return_type: String::new(),
431                is_async: false,
432                is_exported: caps.get(2).is_some(),
433                indent: 0,
434                start_line: Some(line_no),
435                end_line: Some(line_no),
436            });
437        } else if let Some(caps) = rust_impl_re().captures(line) {
438            let trait_name = caps.get(2).map(|m| m.as_str());
439            let type_name = &caps[3];
440            let name = if let Some(t) = trait_name {
441                format!("{t} for {type_name}")
442            } else {
443                type_name.to_string()
444            };
445            sigs.push(Signature {
446                kind: "class",
447                name,
448                params: String::new(),
449                return_type: String::new(),
450                is_async: false,
451                is_exported: false,
452                indent: 0,
453                start_line: Some(line_no),
454                end_line: Some(line_no),
455            });
456        }
457    }
458
459    sigs
460}
461
462fn extract_python_signatures(content: &str) -> Vec<Signature> {
463    let mut sigs = Vec::new();
464    let py_fn = static_regex!(r"^(\s*)(async\s+)?def\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*(\w+))?");
465    let py_class = static_regex!(r"^(\s*)class\s+(\w+)");
466
467    for (line_idx, line) in content.lines().enumerate() {
468        let line_no = line_idx + 1;
469        if let Some(caps) = py_fn.captures(line) {
470            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
471            sigs.push(Signature {
472                kind: if indent > 0 { "method" } else { "fn" },
473                name: caps[3].to_string(),
474                params: compact_params(&caps[4]),
475                return_type: caps
476                    .get(5)
477                    .map_or(String::new(), |m| m.as_str().to_string()),
478                is_async: caps.get(2).is_some(),
479                is_exported: !caps[3].starts_with('_'),
480                indent: if indent > 0 { 2 } else { 0 },
481                start_line: Some(line_no),
482                end_line: Some(line_no),
483            });
484        } else if let Some(caps) = py_class.captures(line) {
485            sigs.push(Signature {
486                kind: "class",
487                name: caps[2].to_string(),
488                params: String::new(),
489                return_type: String::new(),
490                is_async: false,
491                is_exported: !caps[2].starts_with('_'),
492                indent: 0,
493                start_line: Some(line_no),
494                end_line: Some(line_no),
495            });
496        }
497    }
498
499    sigs
500}
501
502fn extract_go_signatures(content: &str) -> Vec<Signature> {
503    let mut sigs = Vec::new();
504    let go_fn = static_regex!(
505        r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\(([^)]*)\)(?:\s*(?:\(([^)]*)\)|(\w+)))?\s*\{"
506    );
507    let go_type = static_regex!(r"^type\s+(\w+)\s+(struct|interface)");
508
509    for (line_idx, line) in content.lines().enumerate() {
510        let line_no = line_idx + 1;
511        if let Some(caps) = go_fn.captures(line) {
512            let is_method = caps.get(2).is_some();
513            sigs.push(Signature {
514                kind: if is_method { "method" } else { "fn" },
515                name: caps[3].to_string(),
516                params: compact_params(&caps[4]),
517                return_type: caps
518                    .get(5)
519                    .or(caps.get(6))
520                    .map_or(String::new(), |m| m.as_str().to_string()),
521                is_async: false,
522                is_exported: caps[3].starts_with(char::is_uppercase),
523                indent: if is_method { 2 } else { 0 },
524                start_line: Some(line_no),
525                end_line: Some(line_no),
526            });
527        } else if let Some(caps) = go_type.captures(line) {
528            sigs.push(Signature {
529                kind: if &caps[2] == "struct" {
530                    "struct"
531                } else {
532                    "interface"
533                },
534                name: caps[1].to_string(),
535                params: String::new(),
536                return_type: String::new(),
537                is_async: false,
538                is_exported: caps[1].starts_with(char::is_uppercase),
539                indent: 0,
540                start_line: Some(line_no),
541                end_line: Some(line_no),
542            });
543        }
544    }
545
546    sigs
547}
548
549pub(crate) fn compact_params(params: &str) -> String {
550    if params.trim().is_empty() {
551        return String::new();
552    }
553    params
554        .split(',')
555        .map(|p| {
556            let p = p.trim();
557            if let Some((name, ty)) = p.split_once(':') {
558                let name = name.trim();
559                let ty = ty.trim();
560                let short = match ty {
561                    "string" | "String" | "&str" | "str" => ":s",
562                    "number" | "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" => ":n",
563                    "boolean" | "bool" => ":b",
564                    _ => return format!("{name}:{ty}"),
565                };
566                format!("{name}{short}")
567            } else {
568                p.to_string()
569            }
570        })
571        .collect::<Vec<_>>()
572        .join(", ")
573}
574
575fn compact_type(ty: &str) -> String {
576    match ty.trim() {
577        "String" | "string" | "&str" | "str" => "s".to_string(),
578        "bool" | "boolean" => "b".to_string(),
579        "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" | "number" => "n".to_string(),
580        "void" | "()" => "∅".to_string(),
581        other => {
582            if other.starts_with("Vec<") || other.starts_with("Array<") {
583                let inner = other
584                    .trim_start_matches("Vec<")
585                    .trim_start_matches("Array<")
586                    .trim_end_matches('>');
587                format!("[{}]", compact_type(inner))
588            } else if other.starts_with("Option<") || other.starts_with("Maybe<") {
589                let inner = other
590                    .trim_start_matches("Option<")
591                    .trim_start_matches("Maybe<")
592                    .trim_end_matches('>');
593                format!("?{}", compact_type(inner))
594            } else if other.starts_with("Result<") {
595                "R".to_string()
596            } else if other.starts_with("impl ") {
597                other.trim_start_matches("impl ").to_string()
598            } else {
599                other.to_string()
600            }
601        }
602    }
603}
604
605fn tdd_params(params: &str) -> String {
606    if params.trim().is_empty() {
607        return String::new();
608    }
609    params
610        .split(',')
611        .map(|p| {
612            let p = p.trim();
613            if p.starts_with('&') {
614                let rest = p.trim_start_matches("&mut ").trim_start_matches('&');
615                if let Some((name, ty)) = rest.split_once(':') {
616                    format!("&{}:{}", name.trim(), compact_type(ty))
617                } else {
618                    p.to_string()
619                }
620            } else if let Some((name, ty)) = p.split_once(':') {
621                format!("{}:{}", name.trim(), compact_type(ty))
622            } else if p == "self" || p == "&self" || p == "&mut self" {
623                "⊕".to_string()
624            } else {
625                p.to_string()
626            }
627        })
628        .collect::<Vec<_>>()
629        .join(",")
630}
631
632fn extract_generic_signatures(content: &str) -> Vec<Signature> {
633    let re_func = static_regex!(
634        r"^\s*(?:(?:public|private|protected|static|async|abstract|virtual|override|final|def|func|fun|fn)\s+)+(\w+)\s*\("
635    );
636    let re_class = static_regex!(
637        r"^\s*(?:(?:public|private|protected|abstract|final|sealed|partial)\s+)*(?:class|struct|enum|interface|trait|module|object|record)\s+(\w+)"
638    );
639
640    let mut sigs = Vec::new();
641    for (line_idx, line) in content.lines().enumerate() {
642        let line_no = line_idx + 1;
643        let trimmed = line.trim();
644        if trimmed.is_empty()
645            || trimmed.starts_with("//")
646            || trimmed.starts_with('#')
647            || trimmed.starts_with("/*")
648            || trimmed.starts_with('*')
649        {
650            continue;
651        }
652        if let Some(caps) = re_class.captures(trimmed) {
653            sigs.push(Signature {
654                kind: "type",
655                name: caps[1].to_string(),
656                params: String::new(),
657                return_type: String::new(),
658                is_async: false,
659                is_exported: true,
660                indent: 0,
661                start_line: Some(line_no),
662                end_line: Some(line_no),
663            });
664        } else if let Some(caps) = re_func.captures(trimmed) {
665            sigs.push(Signature {
666                kind: "fn",
667                name: caps[1].to_string(),
668                params: String::new(),
669                return_type: String::new(),
670                is_async: trimmed.contains("async"),
671                is_exported: true,
672                indent: 0,
673                start_line: Some(line_no),
674                end_line: Some(line_no),
675            });
676        }
677    }
678    sigs
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn sample_fn() -> Signature {
686        Signature {
687            kind: "fn",
688            name: "run".to_string(),
689            params: "id:usize".to_string(),
690            return_type: "bool".to_string(),
691            is_async: false,
692            is_exported: true,
693            indent: 0,
694            start_line: None,
695            end_line: None,
696        }
697    }
698
699    #[test]
700    fn line_suffix_formats_known_spans() {
701        let mut sig = sample_fn();
702        assert_eq!(sig.line_suffix(), "");
703
704        sig.start_line = Some(42);
705        sig.end_line = Some(42);
706        assert_eq!(sig.line_suffix(), " @L42");
707
708        sig.end_line = Some(57);
709        assert_eq!(sig.line_suffix(), " @L42-57");
710    }
711
712    #[test]
713    fn base_renderers_stay_suffix_free() {
714        // Compression-first modes must never pay for line ranges, even when
715        // the span is known.
716        let mut sig = sample_fn();
717        sig.start_line = Some(3);
718        sig.end_line = Some(9);
719        assert_eq!(sig.to_compact(), "fn pub run(id:usize) → bool");
720        assert_eq!(sig.to_tdd(), "λ+run(id:n)→b");
721    }
722
723    #[test]
724    fn located_renderers_append_line_suffix() {
725        let mut sig = sample_fn();
726        // Unknown span → identical to the base renderer.
727        assert_eq!(sig.to_compact_located(), "fn pub run(id:usize) → bool");
728        assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b");
729
730        sig.start_line = Some(3);
731        sig.end_line = Some(5);
732        assert_eq!(
733            sig.to_compact_located(),
734            "fn pub run(id:usize) → bool @L3-5"
735        );
736        assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b @L3-5");
737    }
738
739    #[test]
740    fn plain_notation_is_self_describing() {
741        // GL #580: plain mode must read like source keywords, no decoder ring.
742        let mut sig = sample_fn();
743        sig.kind = "struct";
744        assert_eq!(sig.to_compact(), "struct pub run");
745        sig.kind = "trait";
746        assert_eq!(sig.to_compact(), "trait pub run");
747        sig.kind = "enum";
748        sig.is_exported = false;
749        assert_eq!(sig.to_compact(), "enum run");
750        sig.kind = "const";
751        sig.return_type = "u32".to_string();
752        assert_eq!(sig.to_compact(), "const run:u32");
753    }
754
755    #[test]
756    fn tdd_legend_explains_only_present_symbols() {
757        // GL #580: symbol outputs carry their own minimal legend.
758        let f = sample_fn();
759        let mut s = sample_fn();
760        s.kind = "struct";
761        s.is_exported = false;
762
763        let legend = tdd_legend(&[&f, &s]);
764        assert_eq!(legend, "[λ=fn §=class +=pub]");
765        // Stays comfortably under the 15-token budget for a typical file.
766        assert!(crate::core::tokens::count_tokens(&legend) <= 15, "{legend}");
767
768        assert_eq!(tdd_legend(&[]), "");
769    }
770
771    #[test]
772    fn regex_fallback_assigns_declaration_line_spans() {
773        let src = "\npublic class Service {}\n\npublic fn run() {\n}\n";
774        let sigs = extract_generic_signatures(src);
775
776        let service = sigs.iter().find(|s| s.name == "Service").unwrap();
777        assert_eq!(service.start_line, Some(2));
778        assert_eq!(service.end_line, Some(2));
779
780        let run = sigs.iter().find(|s| s.name == "run").unwrap();
781        assert_eq!(run.start_line, Some(4));
782        assert_eq!(run.end_line, Some(4));
783    }
784}