rto_spec/annotate.rs
1//! Scanning source files for `@rto:<id>` annotations, which link code back to
2//! the ADR that authored or governs it.
3//!
4//! An annotation is an `@rto:<id>` token on a *comment* line. Recognition is
5//! restricted to comment lines (a small set of prefixes covering the languages
6//! Roteiro ingests — see [`is_comment_line`]) so that example tokens inside
7//! string literals, such as test fixtures, are not mistaken for real
8//! annotations. The trade-off is that a trailing `code; // @rto:0001` after
9//! code on the same line is not recognised — annotations are expected on their
10//! own comment or doc-comment line.
11
12/// A `@rto:<id>` annotation found in a source file.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Annotation {
15 /// Repository-relative path of the file the annotation is in.
16 pub path: String,
17 /// The referenced ADR id.
18 pub adr_id: String,
19 /// 1-based line number.
20 pub line: usize,
21}
22
23/// The graph node key this annotation targets (`adr:<id>`).
24impl Annotation {
25 /// The ADR node key this annotation references.
26 #[must_use]
27 pub fn target_key(&self) -> String {
28 format!("adr:{}", self.adr_id)
29 }
30}
31
32const MARKER: &str = "@rto:";
33
34/// Whether a line is a comment, per a small set of prefixes covering the
35/// languages Roteiro ingests. Annotations are only recognised on comment lines
36/// so that example `@rto:<id>` tokens inside string literals (e.g. test
37/// fixtures) are not mistaken for real annotations. Shared with the `@lat:`
38/// backlink scanner in [`crate::lat`].
39pub(crate) fn is_comment_line(line: &str) -> bool {
40 let t = line.trim_start();
41 ["//", "#", "*", "/*", "<!--", ";", "--"]
42 .iter()
43 .any(|p| t.starts_with(p))
44}
45
46/// Find every `@rto:<id>` annotation on a comment line in `text`, tagged with
47/// `rel_path`.
48#[must_use]
49pub fn scan_annotations(rel_path: &str, text: &str) -> Vec<Annotation> {
50 let mut out = Vec::new();
51 for (i, line) in text.lines().enumerate() {
52 if !is_comment_line(line) {
53 continue;
54 }
55 // Strip inline code spans (any backtick run) so a documented example
56 // such as `@rto:0001` in a doc comment is not counted as a real
57 // annotation.
58 let stripped = crate::text::strip_code_spans(line);
59 let mut rest: &str = &stripped;
60 while let Some(pos) = rest.find(MARKER) {
61 let after = &rest[pos + MARKER.len()..];
62 let id: String = after
63 .chars()
64 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
65 .collect();
66 if !id.is_empty() {
67 out.push(Annotation {
68 path: rel_path.to_owned(),
69 adr_id: id.clone(),
70 line: i + 1,
71 });
72 }
73 // Advance past this marker (plus the id) to find more on one line.
74 rest = &after[id.len()..];
75 }
76 }
77 out
78}
79
80#[cfg(test)]
81mod tests {
82 use super::scan_annotations;
83
84 #[test]
85 fn finds_annotations_with_line_numbers() {
86 let src = "//! @rto:0001\nfn a() {}\n// see @rto:0042 and @rto:0007 here\n";
87 let anns = scan_annotations("src/lib.rs", src);
88 assert_eq!(anns.len(), 3);
89 assert_eq!(anns[0].adr_id, "0001");
90 assert_eq!(anns[0].line, 1);
91 assert_eq!(anns[0].target_key(), "adr:0001");
92 assert_eq!(anns[1].adr_id, "0042");
93 assert_eq!(anns[1].line, 3);
94 assert_eq!(anns[2].adr_id, "0007");
95 }
96
97 #[test]
98 fn ignores_bare_marker_without_id() {
99 assert!(scan_annotations("x.rs", "// @rto: nothing\n").is_empty());
100 }
101
102 #[test]
103 fn ignores_annotations_outside_comments() {
104 // An `@rto:` inside a string literal on a code line is not an annotation.
105 let src = "let s = \"@rto:9999\";\n// @rto:0001\n";
106 let anns = scan_annotations("src/x.rs", src);
107 assert_eq!(anns.len(), 1);
108 assert_eq!(anns[0].adr_id, "0001");
109 }
110
111 #[test]
112 fn ignores_examples_inside_code_spans() {
113 // `@rto:9999` written as a documentation example in backticks is not an
114 // annotation; a bare one on the same comment line still is.
115 let src = "//! see the `@rto:9999` example — real: @rto:0001\n";
116 let anns = scan_annotations("src/x.rs", src);
117 assert_eq!(anns.len(), 1);
118 assert_eq!(anns[0].adr_id, "0001");
119 }
120}