use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ClaimStatus {
Confirmed,
Qualified,
Superseded,
Contradicted,
}
impl ClaimStatus {
pub fn from_str_ci(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"confirmed" => Some(Self::Confirmed),
"qualified" => Some(Self::Qualified),
"superseded" => Some(Self::Superseded),
"contradicted" => Some(Self::Contradicted),
_ => None,
}
}
fn expects_reference(self) -> bool {
matches!(self, Self::Superseded | Self::Contradicted)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaimMark {
pub status: ClaimStatus,
pub line: usize,
pub reference: Option<String>,
pub note: Option<String>,
}
struct Attributes {
reference: Option<String>,
note: Option<String>,
}
enum Comment {
NotClaim,
Malformed,
Mark {
status: ClaimStatus,
reference: Option<String>,
note: Option<String>,
},
}
fn classify(inner: &str) -> Comment {
let trimmed = inner.trim();
let Some(rest) = trimmed.strip_prefix("claim:") else {
return Comment::NotClaim;
};
let (status_tok, attr_span) = match rest.find(char::is_whitespace) {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
let Some(status) = ClaimStatus::from_str_ci(status_tok) else {
return Comment::Malformed;
};
let Attributes { reference, note } = parse_attributes(attr_span);
Comment::Mark {
status,
reference,
note,
}
}
fn parse_attributes(span: &str) -> Attributes {
let mut reference = None;
let mut note = None;
let bytes = span.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_whitespace() {
i += 1;
continue;
}
if let Some(after) = span[i..].strip_prefix("note=\"") {
let value_start = i + "note=\"".len();
match after.find('"') {
Some(rel_end) => {
note = Some(span[value_start..value_start + rel_end].to_string());
i = value_start + rel_end + 1;
}
None => {
note = Some(span[value_start..].to_string());
break;
}
}
} else if span[i..].starts_with("ref=") {
let value_start = i + "ref=".len();
let rel_end = span[value_start..]
.find(char::is_whitespace)
.unwrap_or(span.len() - value_start);
let value = &span[value_start..value_start + rel_end];
if !value.is_empty() {
reference = Some(value.to_string());
}
i = value_start + rel_end;
} else {
let rel_end = span[i..]
.find(char::is_whitespace)
.unwrap_or(span.len() - i);
i += rel_end.max(1);
}
}
Attributes { reference, note }
}
fn scan_comments(content: &str) -> impl Iterator<Item = (usize, &str)> {
content.lines().enumerate().flat_map(|(idx, line)| {
let mut found = Vec::new();
let mut rest = line;
let mut consumed = 0usize;
while let Some(open) = rest.find("<!--") {
let after_open = &rest[open + 4..];
let Some(close) = after_open.find("-->") else {
break;
};
let inner = &after_open[..close];
found.push((idx + 1, inner));
let advance = open + 4 + close + 3;
consumed += advance;
rest = &line[consumed..];
}
found.into_iter()
})
}
pub fn extract_claim_marks(content: &str) -> Vec<ClaimMark> {
let mut marks = Vec::new();
for (line, inner) in scan_comments(content) {
if let Comment::Mark {
status,
reference,
note,
} = classify(inner)
{
marks.push(ClaimMark {
status,
line,
reference,
note,
});
}
}
marks
}
pub fn strip_claim_marks(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for segment in text.split_inclusive('\n') {
let (body, eol) = match segment.strip_suffix('\n') {
Some(b) => (b, "\n"),
None => (segment, ""),
};
out.push_str(&strip_claim_comments_in_line(body));
out.push_str(eol);
}
out
}
fn strip_claim_comments_in_line(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(open) = rest.find("<!--") {
let after_open = &rest[open + 4..];
let Some(close) = after_open.find("-->") else {
break;
};
let inner = &after_open[..close];
let full_end = open + 4 + close + 3;
if matches!(classify(inner), Comment::NotClaim) {
out.push_str(&rest[..full_end]);
} else {
out.push_str(&rest[..open]);
}
rest = &rest[full_end..];
}
out.push_str(rest);
out
}
pub fn marks_to_canonical_json(marks: &[ClaimMark]) -> Option<String> {
if marks.is_empty() {
return None;
}
Some(serde_json::to_string(marks).expect("ClaimMark is plain data; serialization cannot fail"))
}
pub fn lint_claim_marks(content: &str) -> Vec<String> {
let mut warnings = Vec::new();
for (line, inner) in scan_comments(content) {
match classify(inner) {
Comment::NotClaim => {}
Comment::Malformed => warnings.push(format!(
"line {line}: claim comment has an unrecognized status; expected one of \
confirmed/qualified/superseded/contradicted (it was ignored as a claim mark)"
)),
Comment::Mark {
status, reference, ..
} => {
if status.expects_reference() && reference.is_none() {
warnings.push(format!(
"line {line}: {} claim mark is missing a `ref=` pointer",
status_word(status)
));
}
}
}
}
warnings
}
fn status_word(status: ClaimStatus) -> &'static str {
match status {
ClaimStatus::Confirmed => "confirmed",
ClaimStatus::Qualified => "qualified",
ClaimStatus::Superseded => "superseded",
ClaimStatus::Contradicted => "contradicted",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_one_of_each_status_with_body_relative_lines() {
let body = "# H\n\
confirmed claim.<!--claim:confirmed-->\n\
qualified claim.<!--claim:qualified-->\n\
superseded claim.<!--claim:superseded ref=old.md-->\n\
contradicted claim.<!--claim:contradicted ref=https://x/rfc-->\n";
let marks = extract_claim_marks(body);
assert_eq!(
marks,
vec![
ClaimMark {
status: ClaimStatus::Confirmed,
line: 2,
reference: None,
note: None,
},
ClaimMark {
status: ClaimStatus::Qualified,
line: 3,
reference: None,
note: None,
},
ClaimMark {
status: ClaimStatus::Superseded,
line: 4,
reference: Some("old.md".into()),
note: None,
},
ClaimMark {
status: ClaimStatus::Contradicted,
line: 5,
reference: Some("https://x/rfc".into()),
note: None,
},
]
);
}
#[test]
fn unknown_status_is_ignored_not_a_mark() {
let body = "claim.<!--claim:bananas-->\nreal.<!--claim:confirmed-->\n";
let marks = extract_claim_marks(body);
assert_eq!(
marks,
vec![ClaimMark {
status: ClaimStatus::Confirmed,
line: 2,
reference: None,
note: None,
}],
"unknown status must not produce a mark"
);
}
#[test]
fn multiple_marks_on_distinct_lines_are_ordered_by_line() {
let body = "a<!--claim:contradicted ref=z-->\n\
b<!--claim:confirmed-->\n\
c<!--claim:qualified-->\n";
let lines: Vec<usize> = extract_claim_marks(body).iter().map(|m| m.line).collect();
assert_eq!(lines, vec![1, 2, 3], "marks must be ordered by line");
}
#[test]
fn multiple_marks_on_same_line_both_captured_in_order() {
let body = "first<!--claim:confirmed--> then<!--claim:qualified-->\n";
let marks = extract_claim_marks(body);
assert_eq!(marks.len(), 2);
assert_eq!(marks[0].status, ClaimStatus::Confirmed);
assert_eq!(marks[0].line, 1);
assert_eq!(marks[1].status, ClaimStatus::Qualified);
assert_eq!(marks[1].line, 1);
}
#[test]
fn ref_and_note_both_parse() {
let body =
"x<!--claim:contradicted ref=https://example.com/rfc note=\"repealed in v3\"-->\n";
let marks = extract_claim_marks(body);
assert_eq!(
marks[0].reference.as_deref(),
Some("https://example.com/rfc")
);
assert_eq!(marks[0].note.as_deref(), Some("repealed in v3"));
}
#[test]
fn note_only_parses_without_ref() {
let body = "y<!--claim:qualified note=\"only on macOS\"-->\n";
let marks = extract_claim_marks(body);
assert_eq!(marks[0].reference, None);
assert_eq!(marks[0].note.as_deref(), Some("only on macOS"));
}
#[test]
fn note_with_spaces_and_internal_punctuation_is_captured_whole() {
let body = "z<!--claim:qualified note=\"holds, mostly: on x86 only\"-->\n";
let marks = extract_claim_marks(body);
assert_eq!(
marks[0].note.as_deref(),
Some("holds, mostly: on x86 only"),
"the entire quoted span is the note"
);
}
#[test]
fn status_is_case_insensitive() {
for raw in ["CONFIRMED", "Confirmed", "cOnFiRmEd"] {
let body = format!("x<!--claim:{raw}-->\n");
let marks = extract_claim_marks(&body);
assert_eq!(
marks,
vec![ClaimMark {
status: ClaimStatus::Confirmed,
line: 1,
reference: None,
note: None,
}],
"raw={raw:?}"
);
}
}
#[test]
fn from_str_ci_returns_none_for_unknown() {
assert_eq!(ClaimStatus::from_str_ci("draft"), None);
assert_eq!(ClaimStatus::from_str_ci(""), None);
assert_eq!(
ClaimStatus::from_str_ci(" superseded "),
Some(ClaimStatus::Superseded)
);
}
#[test]
fn strip_removes_claim_comments_and_preserves_line_count() {
let text = "intro<!--claim:confirmed-->\n\
middle line\n\
end<!--claim:superseded ref=x-->\n";
let stripped = strip_claim_marks(text);
assert_eq!(
stripped, "intro\nmiddle line\nend\n",
"claim comments removed, surrounding prose intact"
);
assert_eq!(
stripped.lines().count(),
text.lines().count(),
"line count must be preserved so citations stay valid"
);
assert!(
!stripped.contains("<!--claim:"),
"no raw claim comment may remain"
);
}
#[test]
fn strip_leaves_ordinary_html_comments_intact() {
let text = "before <!-- ordinary note --> after<!--claim:confirmed-->\n";
let stripped = strip_claim_marks(text);
assert_eq!(
stripped, "before <!-- ordinary note --> after\n",
"non-claim comments must survive; only the claim comment is removed"
);
}
#[test]
fn ordinary_comment_produces_no_marks_and_no_warnings() {
let text = "body <!-- just a note -->\nmore <!-- revisit later -->\n";
assert!(extract_claim_marks(text).is_empty());
assert!(lint_claim_marks(text).is_empty());
assert_eq!(
strip_claim_marks(text),
text,
"no claim comment → unchanged"
);
}
#[test]
fn strip_preserves_crlf_and_missing_final_newline() {
let text = "a<!--claim:confirmed-->\r\nb<!--claim:qualified-->";
let stripped = strip_claim_marks(text);
assert_eq!(
stripped, "a\r\nb",
"CRLF and the absent trailing newline are preserved"
);
}
#[test]
fn canonical_json_is_stable_shape_and_lowercase_status() {
let marks = vec![ClaimMark {
status: ClaimStatus::Superseded,
line: 7,
reference: Some("old.md".into()),
note: Some("moved".into()),
}];
assert_eq!(
marks_to_canonical_json(&marks).expect("non-empty"),
r#"[{"status":"superseded","line":7,"reference":"old.md","note":"moved"}]"#
);
}
#[test]
fn canonical_json_none_on_empty_slice() {
assert_eq!(marks_to_canonical_json(&[]), None);
}
#[test]
fn canonical_json_round_trips_back_to_marks() {
let marks = vec![
ClaimMark {
status: ClaimStatus::Confirmed,
line: 1,
reference: None,
note: None,
},
ClaimMark {
status: ClaimStatus::Contradicted,
line: 4,
reference: Some("https://x".into()),
note: Some("nope".into()),
},
];
let json = marks_to_canonical_json(&marks).expect("non-empty");
let back: Vec<ClaimMark> = serde_json::from_str(&json).expect("round-trip");
assert_eq!(back, marks);
}
#[test]
fn lint_warns_on_superseded_missing_ref() {
let warnings = lint_claim_marks("x<!--claim:superseded-->\n");
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("superseded"), "{warnings:?}");
assert!(warnings[0].contains("ref="), "{warnings:?}");
assert!(warnings[0].contains("line 1"), "{warnings:?}");
}
#[test]
fn lint_warns_on_contradicted_missing_ref() {
let warnings = lint_claim_marks("x<!--claim:contradicted-->\n");
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("contradicted"), "{warnings:?}");
}
#[test]
fn lint_silent_when_superseded_has_ref() {
assert!(
lint_claim_marks("x<!--claim:superseded ref=old.md-->\n").is_empty(),
"a ref present must clear the advisory"
);
}
#[test]
fn lint_does_not_require_ref_for_confirmed_or_qualified() {
assert!(lint_claim_marks("a<!--claim:confirmed-->\n").is_empty());
assert!(lint_claim_marks("b<!--claim:qualified-->\n").is_empty());
}
#[test]
fn lint_warns_on_malformed_unknown_status() {
let warnings = lint_claim_marks("x<!--claim:bananas-->\n");
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("unrecognized status"), "{warnings:?}");
}
#[test]
fn lint_silent_on_ordinary_comment() {
assert!(lint_claim_marks("body <!-- not a claim -->\n").is_empty());
}
#[test]
fn empty_status_is_malformed_no_mark_and_lint_warns() {
let text = "intro<!--claim:-->\nmore\n";
assert!(
extract_claim_marks(text).is_empty(),
"empty status must not produce a mark"
);
let warnings = lint_claim_marks(text);
assert_eq!(
warnings.len(),
1,
"empty status must warn as malformed: {warnings:?}"
);
assert!(
warnings[0].contains("unrecognized status"),
"warning must name the problem: {}",
warnings[0]
);
let stripped = strip_claim_marks(text);
assert!(
!stripped.contains("<!--claim:"),
"malformed claim comment must be stripped: {stripped:?}"
);
assert_eq!(
stripped.lines().count(),
text.lines().count(),
"strip must preserve line count"
);
}
#[test]
fn unterminated_comment_produces_no_mark_no_warning_and_survives_strip() {
let text = "text<!--claim:confirmed\nmore\n";
assert!(
extract_claim_marks(text).is_empty(),
"unterminated comment must not yield a mark"
);
assert!(
lint_claim_marks(text).is_empty(),
"unterminated comment must not lint-warn"
);
assert_eq!(
strip_claim_marks(text),
text,
"unterminated comment must survive strip unchanged"
);
}
#[test]
fn ref_with_empty_value_yields_none_reference() {
let body = "x<!--claim:superseded ref= note=\"reason\"-->\n";
let marks = extract_claim_marks(body);
assert_eq!(marks.len(), 1, "must still produce a mark");
assert_eq!(marks[0].status, ClaimStatus::Superseded);
assert_eq!(
marks[0].reference, None,
"empty ref= must leave reference None"
);
assert_eq!(marks[0].note.as_deref(), Some("reason"));
let warnings = lint_claim_marks(body);
assert_eq!(
warnings.len(),
1,
"empty ref= is treated as absent: {warnings:?}"
);
assert!(warnings[0].contains("ref="), "{}", warnings[0]);
}
#[test]
fn embedded_close_delimiter_in_note_truncates_and_leaves_tail_in_body() {
let text = "text<!--claim:contradicted note=\"repealed --> see X\"-->\n";
let marks = extract_claim_marks(text);
assert_eq!(
marks.len(),
1,
"the truncated comment still parses one mark"
);
assert_eq!(marks[0].status, ClaimStatus::Contradicted);
assert_eq!(
marks[0].note.as_deref(),
Some("repealed"),
"note is truncated at the first `-->`, not the author's intended close quote"
);
let stripped = strip_claim_marks(text);
assert_eq!(
stripped, "text see X\"-->\n",
"the post-`-->` tail is body text and must survive strip verbatim"
);
assert_eq!(
stripped.lines().count(),
text.lines().count(),
"strip must preserve line count"
);
}
}