#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Annotation {
pub path: String,
pub adr_id: String,
pub line: usize,
}
impl Annotation {
#[must_use]
pub fn target_key(&self) -> String {
format!("adr:{}", self.adr_id)
}
}
const MARKER: &str = "@rto:";
fn is_comment_line(line: &str) -> bool {
let t = line.trim_start();
["//", "#", "*", "/*", "<!--", ";", "--"]
.iter()
.any(|p| t.starts_with(p))
}
#[must_use]
pub fn scan_annotations(rel_path: &str, text: &str) -> Vec<Annotation> {
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
if !is_comment_line(line) {
continue;
}
let stripped = crate::text::strip_code_spans(line);
let mut rest: &str = &stripped;
while let Some(pos) = rest.find(MARKER) {
let after = &rest[pos + MARKER.len()..];
let id: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect();
if !id.is_empty() {
out.push(Annotation {
path: rel_path.to_owned(),
adr_id: id.clone(),
line: i + 1,
});
}
rest = &after[id.len()..];
}
}
out
}
#[cfg(test)]
mod tests {
use super::scan_annotations;
#[test]
fn finds_annotations_with_line_numbers() {
let src = "//! @rto:0001\nfn a() {}\n// see @rto:0042 and @rto:0007 here\n";
let anns = scan_annotations("src/lib.rs", src);
assert_eq!(anns.len(), 3);
assert_eq!(anns[0].adr_id, "0001");
assert_eq!(anns[0].line, 1);
assert_eq!(anns[0].target_key(), "adr:0001");
assert_eq!(anns[1].adr_id, "0042");
assert_eq!(anns[1].line, 3);
assert_eq!(anns[2].adr_id, "0007");
}
#[test]
fn ignores_bare_marker_without_id() {
assert!(scan_annotations("x.rs", "// @rto: nothing\n").is_empty());
}
#[test]
fn ignores_annotations_outside_comments() {
let src = "let s = \"@rto:9999\";\n// @rto:0001\n";
let anns = scan_annotations("src/x.rs", src);
assert_eq!(anns.len(), 1);
assert_eq!(anns[0].adr_id, "0001");
}
#[test]
fn ignores_examples_inside_code_spans() {
let src = "//! see the `@rto:9999` example — real: @rto:0001\n";
let anns = scan_annotations("src/x.rs", src);
assert_eq!(anns.len(), 1);
assert_eq!(anns[0].adr_id, "0001");
}
}