use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Span};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkerCategory {
Todo,
Fixme,
Hack,
Stub,
Deferred,
}
impl MarkerCategory {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Todo => "todo",
Self::Fixme => "fixme",
Self::Hack => "hack",
Self::Stub => "stub",
Self::Deferred => "deferred",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Marker {
pub category: MarkerCategory,
pub text: String,
pub line: u32,
pub span: Span,
pub anchor: u32,
}
enum Mode {
Substr,
Word,
Phrase,
Annotation,
}
const RULES: &[(&str, MarkerCategory, Mode)] = &[
("todo!(", MarkerCategory::Stub, Mode::Substr),
("unimplemented!(", MarkerCategory::Stub, Mode::Substr),
("todo", MarkerCategory::Todo, Mode::Phrase),
("fixme", MarkerCategory::Fixme, Mode::Phrase),
("BUG", MarkerCategory::Fixme, Mode::Word),
("HACK", MarkerCategory::Hack, Mode::Word),
("XXX", MarkerCategory::Hack, Mode::Word),
("bug", MarkerCategory::Fixme, Mode::Annotation),
("hack", MarkerCategory::Hack, Mode::Annotation),
("xxx", MarkerCategory::Hack, Mode::Annotation),
("not yet implemented", MarkerCategory::Stub, Mode::Phrase),
("not implemented", MarkerCategory::Stub, Mode::Phrase),
("placeholder", MarkerCategory::Stub, Mode::Phrase),
("for now", MarkerCategory::Deferred, Mode::Phrase),
("deferred", MarkerCategory::Deferred, Mode::Phrase),
("follow-up", MarkerCategory::Deferred, Mode::Phrase),
("followup", MarkerCategory::Deferred, Mode::Phrase),
("tbd", MarkerCategory::Deferred, Mode::Phrase),
];
const MAX_TEXT: usize = 200;
const IGNORE_LINE: &str = "roteiro:ignore";
const IGNORE_FILE: &str = "roteiro:ignore-file";
#[must_use]
pub fn scan_markers(bytes: &[u8]) -> Vec<Marker> {
if contains_bytes(bytes, IGNORE_FILE.as_bytes()) {
return Vec::new();
}
let mut out = Vec::new();
let mut offset: u32 = 0;
for (idx, raw) in bytes.split(|&b| b == b'\n').enumerate() {
let raw_len = u32::try_from(raw.len()).unwrap_or(u32::MAX);
let decoded = String::from_utf8_lossy(raw);
let line = decoded.trim_end_matches('\r');
if line.contains(IGNORE_LINE) {
offset = offset.saturating_add(raw_len).saturating_add(1);
continue;
}
if let Some(category) = classify(line) {
let lead = u32::try_from(raw.iter().take_while(|b| b.is_ascii_whitespace()).count())
.unwrap_or(0);
out.push(Marker {
category,
text: cap_text(line),
line: u32::try_from(idx + 1).unwrap_or(u32::MAX),
span: Span::new(offset, offset.saturating_add(raw_len)),
anchor: offset.saturating_add(lead),
});
}
offset = offset.saturating_add(raw_len).saturating_add(1);
}
out
}
fn classify(line: &str) -> Option<MarkerCategory> {
for (needle, category, mode) in RULES {
let hit = match mode {
Mode::Substr => line.contains(needle),
Mode::Word => find_bounded(line, needle, false).is_some(),
Mode::Phrase => find_bounded(line, needle, true).is_some(),
Mode::Annotation => find_annotation(line, needle).is_some(),
};
if hit {
return Some(*category);
}
}
let t = line.trim_start();
if t.starts_with("- [ ]") || t.starts_with("* [ ]") || t.starts_with("+ [ ]") {
return Some(MarkerCategory::Deferred);
}
None
}
fn find_bounded(hay: &str, needle: &str, ci: bool) -> Option<usize> {
let lowered;
let h: &str = if ci {
lowered = hay.to_ascii_lowercase();
&lowered
} else {
hay
};
let bytes = h.as_bytes();
let mut from = 0;
while let Some(rel) = h[from..].find(needle) {
let start = from + rel;
let end = start + needle.len();
let before_ok = start == 0 || !is_word_byte(bytes[start - 1]);
let after_ok = end >= bytes.len() || !is_word_byte(bytes[end]);
if before_ok && after_ok {
return Some(start);
}
from = start + 1;
}
None
}
fn find_annotation(hay: &str, needle: &str) -> Option<usize> {
let lowered = hay.to_ascii_lowercase();
let bytes = lowered.as_bytes();
let mut from = 0;
while let Some(rel) = lowered[from..].find(needle) {
let start = from + rel;
let end = start + needle.len();
let before_ok = start == 0 || !is_word_byte(bytes[start - 1]);
let after_ok = end < bytes.len() && matches!(bytes[end], b':' | b'(');
if before_ok && after_ok {
return Some(start);
}
from = start + 1;
}
None
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn contains_bytes(hay: &[u8], needle: &[u8]) -> bool {
needle.len() <= hay.len() && hay.windows(needle.len()).any(|w| w == needle)
}
fn cap_text(line: &str) -> String {
let t = line.trim();
if t.chars().count() > MAX_TEXT {
let mut s: String = t.chars().take(MAX_TEXT).collect();
s.push('…');
s
} else {
t.to_owned()
}
}
pub fn augment(facts: &mut FactSet, path: &str, blob_id: &str, bytes: &[u8]) {
for m in scan_markers(bytes) {
let key = format!("marker:{path}#{}", m.line);
let container =
innermost_container(&facts.nodes, m.anchor).unwrap_or_else(|| format!("file:{path}"));
facts.nodes.push(Node {
key: key.clone(),
kind: NodeKind::Marker,
name: cap_name(&m.text),
path: Some(path.to_owned()),
lang: None,
blob_hash: Some(blob_id.to_owned()),
span: Some(m.span),
meta: serde_json::json!({
"category": m.category.as_str(),
"text": m.text,
"line": m.line,
}),
});
facts
.edges
.push(Edge::derived(container, key, EdgeKind::Contains));
}
}
fn innermost_container(nodes: &[Node], offset: u32) -> Option<String> {
nodes
.iter()
.filter_map(|n| n.span.map(|s| (n, s)))
.filter(|(_, s)| s.start <= offset && offset < s.end)
.min_by(|(a, sa), (b, sb)| (sa.end - sa.start, &a.key).cmp(&(sb.end - sb.start, &b.key)))
.map(|(n, _)| n.key.clone())
}
fn cap_name(text: &str) -> String {
const MAX_NAME: usize = 80;
if text.chars().count() > MAX_NAME {
let mut s: String = text.chars().take(MAX_NAME).collect();
s.push('…');
s
} else {
text.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::{MarkerCategory, augment, scan_markers};
use crate::{EdgeKind, FactSet, Node, NodeKind, Span};
fn categories(src: &str) -> Vec<(u32, MarkerCategory)> {
scan_markers(src.as_bytes())
.into_iter()
.map(|m| (m.line, m.category))
.collect()
}
#[test]
fn detects_each_category() {
let src = "\
// TODO wire this up
let x = todo!();
// FIXME off-by-one
// HACK relies on ordering
// this is a placeholder for now
- [ ] finish the docs
plain line, nothing here
";
let got = categories(src);
assert_eq!(got[0], (1, MarkerCategory::Todo));
assert_eq!(got[1], (2, MarkerCategory::Stub)); assert_eq!(got[2], (3, MarkerCategory::Fixme));
assert_eq!(got[3], (4, MarkerCategory::Hack));
assert_eq!(got[4], (5, MarkerCategory::Stub)); assert_eq!(got[5], (6, MarkerCategory::Deferred)); assert_eq!(got.len(), 6, "the plain line is not a marker");
}
#[test]
fn word_boundaries_avoid_false_positives() {
assert!(categories("mastodon Todos BUGFIX fixmelike").is_empty());
assert_eq!(categories("x // BUG here")[0].1, MarkerCategory::Fixme);
}
#[test]
fn tags_match_mixed_case() {
assert_eq!(categories("// Todo: wire it")[0].1, MarkerCategory::Todo);
assert_eq!(categories("// fixme this path")[0].1, MarkerCategory::Fixme);
assert_eq!(categories("decision TBD")[0].1, MarkerCategory::Deferred);
assert_eq!(categories("// HACK ordering")[0].1, MarkerCategory::Hack);
assert_eq!(categories("note a Bug: crash")[0].1, MarkerCategory::Fixme);
assert_eq!(
categories("// hack(perf): fast path")[0].1,
MarkerCategory::Hack
);
assert!(categories("we fixed a bug in the hack layer").is_empty());
}
#[test]
fn scanning_is_deterministic() {
let src = b"// TODO one\ncode\n// FIXME two\n";
assert_eq!(scan_markers(src), scan_markers(src));
}
#[test]
fn ignore_directives_suppress_line_and_file() {
let got = categories("// TODO real\n// TODO shush roteiro:ignore\n// FIXME real\n");
assert_eq!(got.len(), 2);
assert_eq!(got[0].0, 1);
assert_eq!(got[1].0, 3);
assert!(scan_markers(b"// TODO x\n// note: roteiro:ignore-file\n// FIXME y\n").is_empty());
}
#[test]
fn augment_attaches_to_innermost_symbol_then_file() {
let mut facts = FactSet::new()
.with_node(Node {
span: Some(Span::new(0, 100)),
..Node::new("file:a.rs", NodeKind::File, "a.rs")
})
.with_node(Node {
span: Some(Span::new(10, 40)),
..Node::new("sym:rust:a.rs#f", NodeKind::Fn, "f")
});
let bytes = b"aaaaaaaaaaaaaaaaaaa\n// FIXME inside fn f\n";
augment(&mut facts, "a.rs", "blob", bytes);
let marker = facts
.nodes
.iter()
.find(|n| n.kind == NodeKind::Marker)
.expect("a marker node");
assert_eq!(marker.meta["category"], "fixme");
let edge = facts
.edges
.iter()
.find(|e| e.kind == EdgeKind::Contains && e.dst == marker.key)
.expect("a contains edge");
assert_eq!(edge.src, "sym:rust:a.rs#f");
}
#[test]
fn augment_attaches_to_symbol_on_its_own_indented_line() {
let mut facts = FactSet::new()
.with_node(Node {
span: Some(Span::new(0, 80)),
..Node::new("file:a.rs", NodeKind::File, "a.rs")
})
.with_node(Node {
span: Some(Span::new(4, 40)),
..Node::new("sym:rust:a.rs#f", NodeKind::Fn, "f")
});
augment(&mut facts, "a.rs", "blob", b" fn f() { // TODO soon }\n");
let marker = facts
.nodes
.iter()
.find(|n| n.kind == NodeKind::Marker)
.unwrap();
let edge = facts
.edges
.iter()
.find(|e| e.kind == EdgeKind::Contains && e.dst == marker.key)
.unwrap();
assert_eq!(edge.src, "sym:rust:a.rs#f");
}
#[test]
fn augment_falls_back_to_file_when_no_symbol_encloses() {
let mut facts = FactSet::new().with_node(Node {
span: Some(Span::new(0, 100)),
..Node::new("file:a.rs", NodeKind::File, "a.rs")
});
augment(&mut facts, "a.rs", "blob", b"// TODO top of file\n");
let marker = facts
.nodes
.iter()
.find(|n| n.kind == NodeKind::Marker)
.unwrap();
let edge = facts
.edges
.iter()
.find(|e| e.kind == EdgeKind::Contains && e.dst == marker.key)
.unwrap();
assert_eq!(edge.src, "file:a.rs");
}
}