1use regex::Regex;
2
3macro_rules! static_regex {
4 ($pattern:expr_2021) => {{
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#[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 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 _ => 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 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 pub fn to_compact_located(&self) -> String {
144 format!("{}{}", self.to_compact(), self.line_suffix())
145 }
146
147 pub fn to_tdd_located(&self) -> String {
149 format!("{}{}", self.to_tdd(), self.line_suffix())
150 }
151}
152
153pub 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
244pub 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)
261 && !sigs.is_empty()
262 {
263 TREE_SITTER_HITS.fetch_add(1, Ordering::Relaxed);
264 return sigs;
265 }
266 }
267
268 REGEX_FALLBACK_HITS.fetch_add(1, Ordering::Relaxed);
269 match file_ext {
270 "rs" => extract_rust_signatures(content),
271 "ts" | "tsx" | "js" | "jsx" | "svelte" | "vue" => extract_ts_signatures(content),
272 "py" => extract_python_signatures(content),
273 "go" => extract_go_signatures(content),
274 _ => extract_generic_signatures(content),
275 }
276}
277
278pub fn extract_file_map(path: &str, content: &str) -> String {
279 let ext = std::path::Path::new(path)
280 .extension()
281 .and_then(|e| e.to_str())
282 .unwrap_or("rs");
283 let dep_info = super::deps::extract_deps(content, ext);
284 let sigs = extract_signatures(content, ext);
285 let mut parts = Vec::new();
286 if !dep_info.imports.is_empty() {
287 parts.push(dep_info.imports.join(","));
288 }
289 let key_sigs: Vec<String> = sigs
290 .iter()
291 .filter(|s| s.is_exported || s.indent == 0)
292 .map(Signature::to_compact_located)
293 .collect();
294 if !key_sigs.is_empty() {
295 parts.push(key_sigs.join("\n"));
296 }
297 parts.join("\n")
298}
299
300fn extract_ts_signatures(content: &str) -> Vec<Signature> {
301 let mut sigs = Vec::new();
302
303 for (line_idx, line) in content.lines().enumerate() {
304 let line_no = line_idx + 1;
305 let trimmed = line.trim();
306 if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
307 continue;
308 }
309
310 if let Some(caps) = fn_re().captures(line) {
311 let indent = caps.get(1).map_or(0, |m| m.as_str().len());
312 sigs.push(Signature {
313 kind: if indent > 0 { "method" } else { "fn" },
314 name: caps[4].to_string(),
315 params: compact_params(&caps[5]),
316 return_type: caps
317 .get(6)
318 .map_or(String::new(), |m| m.as_str().trim().to_string()),
319 is_async: caps.get(3).is_some(),
320 is_exported: caps.get(2).is_some(),
321 indent: if indent > 0 { 2 } else { 0 },
322 start_line: Some(line_no),
323 end_line: Some(line_no),
324 });
325 } else if let Some(caps) = class_re().captures(line) {
326 sigs.push(Signature {
327 kind: "class",
328 name: caps[4].to_string(),
329 params: String::new(),
330 return_type: String::new(),
331 is_async: false,
332 is_exported: caps.get(2).is_some(),
333 indent: 0,
334 start_line: Some(line_no),
335 end_line: Some(line_no),
336 });
337 } else if let Some(caps) = iface_re().captures(line) {
338 sigs.push(Signature {
339 kind: "interface",
340 name: caps[3].to_string(),
341 params: String::new(),
342 return_type: String::new(),
343 is_async: false,
344 is_exported: caps.get(2).is_some(),
345 indent: 0,
346 start_line: Some(line_no),
347 end_line: Some(line_no),
348 });
349 } else if let Some(caps) = type_re().captures(line) {
350 sigs.push(Signature {
351 kind: "type",
352 name: caps[3].to_string(),
353 params: String::new(),
354 return_type: String::new(),
355 is_async: false,
356 is_exported: caps.get(2).is_some(),
357 indent: 0,
358 start_line: Some(line_no),
359 end_line: Some(line_no),
360 });
361 } else if let Some(caps) = const_re().captures(line)
362 && caps.get(2).is_some()
363 {
364 sigs.push(Signature {
365 kind: "const",
366 name: caps[4].to_string(),
367 params: String::new(),
368 return_type: caps
369 .get(5)
370 .map_or(String::new(), |m| m.as_str().to_string()),
371 is_async: false,
372 is_exported: true,
373 indent: 0,
374 start_line: Some(line_no),
375 end_line: Some(line_no),
376 });
377 }
378 }
379
380 sigs
381}
382
383fn extract_rust_signatures(content: &str) -> Vec<Signature> {
384 let mut sigs = Vec::new();
385
386 for (line_idx, line) in content.lines().enumerate() {
387 let line_no = line_idx + 1;
388 let trimmed = line.trim();
389 if trimmed.starts_with("//") || trimmed.starts_with("///") {
390 continue;
391 }
392
393 if let Some(caps) = rust_fn_re().captures(line) {
394 let indent = caps.get(1).map_or(0, |m| m.as_str().len());
395 sigs.push(Signature {
396 kind: if indent > 0 { "method" } else { "fn" },
397 name: caps[4].to_string(),
398 params: compact_params(&caps[5]),
399 return_type: caps
400 .get(6)
401 .map_or(String::new(), |m| m.as_str().trim().to_string()),
402 is_async: caps.get(3).is_some(),
403 is_exported: caps.get(2).is_some(),
404 indent: if indent > 0 { 2 } else { 0 },
405 start_line: Some(line_no),
406 end_line: Some(line_no),
407 });
408 } else if let Some(caps) = rust_struct_re().captures(line) {
409 sigs.push(Signature {
410 kind: "struct",
411 name: caps[3].to_string(),
412 params: String::new(),
413 return_type: String::new(),
414 is_async: false,
415 is_exported: caps.get(2).is_some(),
416 indent: 0,
417 start_line: Some(line_no),
418 end_line: Some(line_no),
419 });
420 } else if let Some(caps) = rust_enum_re().captures(line) {
421 sigs.push(Signature {
422 kind: "enum",
423 name: caps[3].to_string(),
424 params: String::new(),
425 return_type: String::new(),
426 is_async: false,
427 is_exported: caps.get(2).is_some(),
428 indent: 0,
429 start_line: Some(line_no),
430 end_line: Some(line_no),
431 });
432 } else if let Some(caps) = rust_trait_re().captures(line) {
433 sigs.push(Signature {
434 kind: "trait",
435 name: caps[3].to_string(),
436 params: String::new(),
437 return_type: String::new(),
438 is_async: false,
439 is_exported: caps.get(2).is_some(),
440 indent: 0,
441 start_line: Some(line_no),
442 end_line: Some(line_no),
443 });
444 } else if let Some(caps) = rust_impl_re().captures(line) {
445 let trait_name = caps.get(2).map(|m| m.as_str());
446 let type_name = &caps[3];
447 let name = if let Some(t) = trait_name {
448 format!("{t} for {type_name}")
449 } else {
450 type_name.to_string()
451 };
452 sigs.push(Signature {
453 kind: "class",
454 name,
455 params: String::new(),
456 return_type: String::new(),
457 is_async: false,
458 is_exported: false,
459 indent: 0,
460 start_line: Some(line_no),
461 end_line: Some(line_no),
462 });
463 }
464 }
465
466 sigs
467}
468
469fn extract_python_signatures(content: &str) -> Vec<Signature> {
470 let mut sigs = Vec::new();
471 let py_fn = static_regex!(r"^(\s*)(async\s+)?def\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*(\w+))?");
472 let py_class = static_regex!(r"^(\s*)class\s+(\w+)");
473
474 for (line_idx, line) in content.lines().enumerate() {
475 let line_no = line_idx + 1;
476 if let Some(caps) = py_fn.captures(line) {
477 let indent = caps.get(1).map_or(0, |m| m.as_str().len());
478 sigs.push(Signature {
479 kind: if indent > 0 { "method" } else { "fn" },
480 name: caps[3].to_string(),
481 params: compact_params(&caps[4]),
482 return_type: caps
483 .get(5)
484 .map_or(String::new(), |m| m.as_str().to_string()),
485 is_async: caps.get(2).is_some(),
486 is_exported: !caps[3].starts_with('_'),
487 indent: if indent > 0 { 2 } else { 0 },
488 start_line: Some(line_no),
489 end_line: Some(line_no),
490 });
491 } else if let Some(caps) = py_class.captures(line) {
492 sigs.push(Signature {
493 kind: "class",
494 name: caps[2].to_string(),
495 params: String::new(),
496 return_type: String::new(),
497 is_async: false,
498 is_exported: !caps[2].starts_with('_'),
499 indent: 0,
500 start_line: Some(line_no),
501 end_line: Some(line_no),
502 });
503 }
504 }
505
506 sigs
507}
508
509fn extract_go_signatures(content: &str) -> Vec<Signature> {
510 let mut sigs = Vec::new();
511 let go_fn = static_regex!(
512 r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\(([^)]*)\)(?:\s*(?:\(([^)]*)\)|(\w+)))?\s*\{"
513 );
514 let go_type = static_regex!(r"^type\s+(\w+)\s+(struct|interface)");
515
516 for (line_idx, line) in content.lines().enumerate() {
517 let line_no = line_idx + 1;
518 if let Some(caps) = go_fn.captures(line) {
519 let is_method = caps.get(2).is_some();
520 sigs.push(Signature {
521 kind: if is_method { "method" } else { "fn" },
522 name: caps[3].to_string(),
523 params: compact_params(&caps[4]),
524 return_type: caps
525 .get(5)
526 .or(caps.get(6))
527 .map_or(String::new(), |m| m.as_str().to_string()),
528 is_async: false,
529 is_exported: caps[3].starts_with(char::is_uppercase),
530 indent: if is_method { 2 } else { 0 },
531 start_line: Some(line_no),
532 end_line: Some(line_no),
533 });
534 } else if let Some(caps) = go_type.captures(line) {
535 sigs.push(Signature {
536 kind: if &caps[2] == "struct" {
537 "struct"
538 } else {
539 "interface"
540 },
541 name: caps[1].to_string(),
542 params: String::new(),
543 return_type: String::new(),
544 is_async: false,
545 is_exported: caps[1].starts_with(char::is_uppercase),
546 indent: 0,
547 start_line: Some(line_no),
548 end_line: Some(line_no),
549 });
550 }
551 }
552
553 sigs
554}
555
556pub(crate) fn compact_params(params: &str) -> String {
557 if params.trim().is_empty() {
558 return String::new();
559 }
560 params
561 .split(',')
562 .map(|p| {
563 let p = p.trim();
564 if let Some((name, ty)) = p.split_once(':') {
565 let name = name.trim();
566 let ty = ty.trim();
567 let short = match ty {
568 "string" | "String" | "&str" | "str" => ":s",
569 "number" | "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" => ":n",
570 "boolean" | "bool" => ":b",
571 _ => return format!("{name}:{ty}"),
572 };
573 format!("{name}{short}")
574 } else {
575 p.to_string()
576 }
577 })
578 .collect::<Vec<_>>()
579 .join(", ")
580}
581
582fn compact_type(ty: &str) -> String {
583 match ty.trim() {
584 "String" | "string" | "&str" | "str" => "s".to_string(),
585 "bool" | "boolean" => "b".to_string(),
586 "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" | "number" => "n".to_string(),
587 "void" | "()" => "∅".to_string(),
588 other => {
589 if other.starts_with("Vec<") || other.starts_with("Array<") {
590 let inner = other
591 .trim_start_matches("Vec<")
592 .trim_start_matches("Array<")
593 .trim_end_matches('>');
594 format!("[{}]", compact_type(inner))
595 } else if other.starts_with("Option<") || other.starts_with("Maybe<") {
596 let inner = other
597 .trim_start_matches("Option<")
598 .trim_start_matches("Maybe<")
599 .trim_end_matches('>');
600 format!("?{}", compact_type(inner))
601 } else if other.starts_with("Result<") {
602 "R".to_string()
603 } else if other.starts_with("impl ") {
604 other.trim_start_matches("impl ").to_string()
605 } else {
606 other.to_string()
607 }
608 }
609 }
610}
611
612fn tdd_params(params: &str) -> String {
613 if params.trim().is_empty() {
614 return String::new();
615 }
616 params
617 .split(',')
618 .map(|p| {
619 let p = p.trim();
620 if p.starts_with('&') {
621 let rest = p.trim_start_matches("&mut ").trim_start_matches('&');
622 if let Some((name, ty)) = rest.split_once(':') {
623 format!("&{}:{}", name.trim(), compact_type(ty))
624 } else {
625 p.to_string()
626 }
627 } else if let Some((name, ty)) = p.split_once(':') {
628 format!("{}:{}", name.trim(), compact_type(ty))
629 } else if p == "self" || p == "&self" || p == "&mut self" {
630 "⊕".to_string()
631 } else {
632 p.to_string()
633 }
634 })
635 .collect::<Vec<_>>()
636 .join(",")
637}
638
639fn extract_generic_signatures(content: &str) -> Vec<Signature> {
640 let re_func = static_regex!(
641 r"^\s*(?:(?:public|private|protected|static|async|abstract|virtual|override|final|def|func|fun|fn)\s+)+(\w+)\s*\("
642 );
643 let re_class = static_regex!(
644 r"^\s*(?:(?:public|private|protected|abstract|final|sealed|partial)\s+)*(?:class|struct|enum|interface|trait|module|object|record)\s+(\w+)"
645 );
646
647 let mut sigs = Vec::new();
648 for (line_idx, line) in content.lines().enumerate() {
649 let line_no = line_idx + 1;
650 let trimmed = line.trim();
651 if trimmed.is_empty()
652 || trimmed.starts_with("//")
653 || trimmed.starts_with('#')
654 || trimmed.starts_with("/*")
655 || trimmed.starts_with('*')
656 {
657 continue;
658 }
659 if let Some(caps) = re_class.captures(trimmed) {
660 sigs.push(Signature {
661 kind: "type",
662 name: caps[1].to_string(),
663 params: String::new(),
664 return_type: String::new(),
665 is_async: false,
666 is_exported: true,
667 indent: 0,
668 start_line: Some(line_no),
669 end_line: Some(line_no),
670 });
671 } else if let Some(caps) = re_func.captures(trimmed) {
672 sigs.push(Signature {
673 kind: "fn",
674 name: caps[1].to_string(),
675 params: String::new(),
676 return_type: String::new(),
677 is_async: trimmed.contains("async"),
678 is_exported: true,
679 indent: 0,
680 start_line: Some(line_no),
681 end_line: Some(line_no),
682 });
683 }
684 }
685 sigs
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691
692 fn sample_fn() -> Signature {
693 Signature {
694 kind: "fn",
695 name: "run".to_string(),
696 params: "id:usize".to_string(),
697 return_type: "bool".to_string(),
698 is_async: false,
699 is_exported: true,
700 indent: 0,
701 start_line: None,
702 end_line: None,
703 }
704 }
705
706 #[test]
707 fn line_suffix_formats_known_spans() {
708 let mut sig = sample_fn();
709 assert_eq!(sig.line_suffix(), "");
710
711 sig.start_line = Some(42);
712 sig.end_line = Some(42);
713 assert_eq!(sig.line_suffix(), " @L42");
714
715 sig.end_line = Some(57);
716 assert_eq!(sig.line_suffix(), " @L42-57");
717 }
718
719 #[test]
720 fn base_renderers_stay_suffix_free() {
721 let mut sig = sample_fn();
724 sig.start_line = Some(3);
725 sig.end_line = Some(9);
726 assert_eq!(sig.to_compact(), "fn pub run(id:usize) → bool");
727 assert_eq!(sig.to_tdd(), "λ+run(id:n)→b");
728 }
729
730 #[test]
731 fn located_renderers_append_line_suffix() {
732 let mut sig = sample_fn();
733 assert_eq!(sig.to_compact_located(), "fn pub run(id:usize) → bool");
735 assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b");
736
737 sig.start_line = Some(3);
738 sig.end_line = Some(5);
739 assert_eq!(
740 sig.to_compact_located(),
741 "fn pub run(id:usize) → bool @L3-5"
742 );
743 assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b @L3-5");
744 }
745
746 #[test]
747 fn plain_notation_is_self_describing() {
748 let mut sig = sample_fn();
750 sig.kind = "struct";
751 assert_eq!(sig.to_compact(), "struct pub run");
752 sig.kind = "trait";
753 assert_eq!(sig.to_compact(), "trait pub run");
754 sig.kind = "enum";
755 sig.is_exported = false;
756 assert_eq!(sig.to_compact(), "enum run");
757 sig.kind = "const";
758 sig.return_type = "u32".to_string();
759 assert_eq!(sig.to_compact(), "const run:u32");
760 }
761
762 #[test]
763 fn tdd_legend_explains_only_present_symbols() {
764 let f = sample_fn();
766 let mut s = sample_fn();
767 s.kind = "struct";
768 s.is_exported = false;
769
770 let legend = tdd_legend(&[&f, &s]);
771 assert_eq!(legend, "[λ=fn §=class +=pub]");
772 assert!(crate::core::tokens::count_tokens(&legend) <= 15, "{legend}");
774
775 assert_eq!(tdd_legend(&[]), "");
776 }
777
778 #[test]
779 fn regex_fallback_assigns_declaration_line_spans() {
780 let src = "\npublic class Service {}\n\npublic fn run() {\n}\n";
781 let sigs = extract_generic_signatures(src);
782
783 let service = sigs.iter().find(|s| s.name == "Service").unwrap();
784 assert_eq!(service.start_line, Some(2));
785 assert_eq!(service.end_line, Some(2));
786
787 let run = sigs.iter().find(|s| s.name == "run").unwrap();
788 assert_eq!(run.start_line, Some(4));
789 assert_eq!(run.end_line, Some(4));
790 }
791}