Skip to main content

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}