1use std::collections::{BTreeMap, BTreeSet};
16use std::sync::LazyLock;
17
18use serde::{Deserialize, Serialize};
19
20use crate::attrs::parse_attrs;
21use crate::error::{Diagnostic, Fix, FixSafety, Severity, TextEdit};
22use crate::parse::{closing_directive_depth, directive_name_start, opening_directive};
23use crate::types::{AttrValue, Block, DocType, FrontMatter, Span, SurfDoc};
24
25#[derive(Debug, Clone, Deserialize)]
31pub struct RuleMeta {
32 pub layer: String,
34 pub severity: Severity,
36 pub fixable: bool,
38 #[serde(default)]
40 pub fix_safety: Option<String>,
41 pub message: String,
43 pub description: String,
45}
46
47#[derive(Debug, Deserialize)]
48struct RegistryMeta {
49 #[allow(dead_code)]
50 spec_version: String,
51 total_rules: usize,
52 #[allow(dead_code)]
53 registry_updated: String,
54}
55
56#[derive(Debug, Deserialize)]
57struct RegistryFile {
58 meta: RegistryMeta,
59 rules: BTreeMap<String, RuleMeta>,
60}
61
62static RULE_REGISTRY: LazyLock<RegistryFile> = LazyLock::new(|| {
63 toml::from_str(include_str!("../spec/rules.toml"))
64 .expect("spec/rules.toml is embedded at compile time and must parse as valid TOML — covered by spec_compliance tests")
65});
66
67pub fn rule_registry() -> &'static BTreeMap<String, RuleMeta> {
69 &RULE_REGISTRY.rules
70}
71
72pub fn rule_registry_total() -> usize {
74 RULE_REGISTRY.meta.total_rules
75}
76
77pub const PARSE_RULE_IDS: &[&str] = &["P001", "P002", "P003", "P005", "P006"];
80
81fn registry_severity(id: &str) -> Severity {
84 rule_registry()
85 .get(id)
86 .map_or(Severity::Warning, |m| m.severity)
87}
88
89fn diag(id: &str, message: String, span: Option<Span>) -> Diagnostic {
91 diag_fix(id, message, span, None)
92}
93
94fn diag_fix(id: &str, message: String, span: Option<Span>, fix: Option<Fix>) -> Diagnostic {
96 Diagnostic {
97 severity: registry_severity(id),
98 message,
99 span,
100 code: Some(id.to_string()),
101 fix,
102 }
103}
104
105fn rule_fix(id: &str, description: String, edits: Vec<TextEdit>) -> Fix {
108 let safety = match rule_registry()
109 .get(id)
110 .and_then(|m| m.fix_safety.as_deref())
111 {
112 Some("suggested") => FixSafety::Suggested,
113 _ => FixSafety::Safe,
114 };
115 Fix {
116 edits,
117 safety,
118 description,
119 }
120}
121
122#[derive(Debug, Deserialize)]
127struct BlocksSpecFile {
128 blocks: BTreeMap<String, toml::Value>,
129}
130
131static KNOWN_BLOCK_NAMES: LazyLock<BTreeSet<String>> = LazyLock::new(|| {
132 let spec: BlocksSpecFile = toml::from_str(include_str!("../spec/blocks.toml"))
133 .expect("spec/blocks.toml is embedded at compile time and must parse as valid TOML — covered by spec_compliance tests");
134 spec.blocks.into_keys().collect()
135});
136
137pub fn known_block_names() -> &'static BTreeSet<String> {
141 &KNOWN_BLOCK_NAMES
142}
143
144pub const EXTRA_KNOWN_BLOCK_NAMES: &[&str] = &[
150 "action-items",
151 "column",
152 "deck",
153 "deploy_urls",
154 "info-card",
155 "slide",
156];
157
158pub trait LintRule {
164 fn id(&self) -> &'static str;
166 fn check(&self, doc: &SurfDoc, source: &str) -> Vec<Diagnostic>;
171}
172
173pub fn all_rules() -> Vec<Box<dyn LintRule>> {
175 vec![
176 Box::new(SectionBlockUsed),
177 Box::new(TableBlockUsed),
178 Box::new(CurlyAttrBraces),
179 Box::new(DirectiveNotOnOwnLine),
180 Box::new(NestingColonMismatch),
181 Box::new(MissingSummary),
182 Box::new(SummaryNotNearTop),
183 Box::new(UnknownBlockName),
184 Box::new(MissingRequiredFrontMatter),
185 Box::new(FrontMatterEnumCase),
186 ]
187}
188
189struct ScanLine<'a> {
195 idx: usize,
197 offset: usize,
199 raw: &'a str,
201 trimmed: &'a str,
203 literal: bool,
206}
207
208impl ScanLine<'_> {
209 fn span(&self) -> Span {
210 Span {
211 start_line: self.idx + 1,
212 end_line: self.idx + 1,
213 start_offset: self.offset,
214 end_offset: self.offset + self.raw.len(),
215 }
216 }
217
218 fn indent(&self) -> usize {
220 self.raw.len() - self.raw.trim_start().len()
221 }
222
223 fn sub_span(&self, start: usize, end: usize) -> Span {
226 Span {
227 start_line: self.idx + 1,
228 end_line: self.idx + 1,
229 start_offset: self.offset + start,
230 end_offset: self.offset + end,
231 }
232 }
233
234 fn replace_edit(&self, replacement: impl Into<String>) -> TextEdit {
236 TextEdit {
237 span: self.span(),
238 replacement: replacement.into(),
239 }
240 }
241
242 fn delete_edit(&self, source_len: usize) -> TextEdit {
244 let end = self.offset + self.raw.len();
245 let end = if end < source_len { end + 1 } else { end };
246 TextEdit {
247 span: Span {
248 start_line: self.idx + 1,
249 end_line: self.idx + 1,
250 start_offset: self.offset,
251 end_offset: end,
252 },
253 replacement: String::new(),
254 }
255 }
256}
257
258fn front_matter_close_line(lines: &[&str]) -> Option<usize> {
262 if lines.first().map(|l| l.trim()) != Some("---") {
263 return None;
264 }
265 lines
266 .iter()
267 .enumerate()
268 .skip(1)
269 .find(|(_, l)| l.trim() == "---")
270 .map(|(i, _)| i)
271}
272
273const LITERAL_BLOCK_NAMES: &[&str] = &["code", "output"];
276
277fn scan_lines(source: &str) -> Vec<ScanLine<'_>> {
279 let lines: Vec<&str> = source.split('\n').collect();
280 let fm_end = front_matter_close_line(&lines);
281 let mut out = Vec::with_capacity(lines.len());
282 let mut offset = 0usize;
283 let mut in_fence = false;
284 let mut literal_depth: Option<usize> = None;
286
287 for (idx, raw) in lines.iter().enumerate() {
288 let trimmed = raw.trim();
289 let in_front_matter = fm_end.is_some_and(|end| idx <= end);
290 let mut literal = in_front_matter;
291
292 if !in_front_matter {
293 if let Some(depth) = literal_depth {
294 if closing_directive_depth(trimmed) == Some(depth) {
295 literal_depth = None;
297 } else {
298 literal = true;
299 }
300 } else if in_fence {
301 literal = true;
302 if trimmed.starts_with("```") {
303 in_fence = false;
304 }
305 } else if trimmed.starts_with("```") {
306 in_fence = true;
307 literal = true;
308 } else if let Some((depth, name, _)) = opening_directive(trimmed)
309 && LITERAL_BLOCK_NAMES.contains(&name.as_str())
310 {
311 literal_depth = Some(depth);
312 }
313 }
314
315 out.push(ScanLine {
316 idx,
317 offset,
318 raw,
319 trimmed,
320 literal,
321 });
322 offset += raw.len() + 1;
323 }
324 out
325}
326
327fn matching_closer_idx(scan: &[ScanLine<'_>], opener_idx: usize, depth: usize) -> Option<usize> {
336 let mut stack = vec![depth];
337 for (i, line) in scan.iter().enumerate().skip(opener_idx + 1) {
338 if line.literal {
339 continue;
340 }
341 if let Some(d) = closing_directive_depth(line.trimmed) {
342 if let Some(pos) = stack.iter().rposition(|&s| s == d) {
343 stack.truncate(pos);
344 if stack.is_empty() {
345 return Some(i);
346 }
347 }
348 continue;
349 }
350 if let Some((d, _, _)) = opening_directive(line.trimmed) {
351 stack.push(d);
352 }
353 }
354 None
355}
356
357fn section_heading(attrs_str: &str) -> Option<String> {
364 let attrs = parse_attrs(attrs_str).ok()?;
365 let title = match attrs.get("title").or_else(|| attrs.get("headline"))? {
366 AttrValue::String(s) => s.clone(),
367 AttrValue::Number(n) => n.to_string(),
368 _ => return None,
369 };
370 if title.trim().is_empty() {
371 return None;
372 }
373 let level = match attrs.get("level") {
374 Some(AttrValue::Number(n)) if n.fract() == 0.0 && (1.0..=6.0).contains(n) => *n as usize,
375 _ => 2,
376 };
377 Some(format!("{} {}", "#".repeat(level), title))
378}
379
380struct SectionBlockUsed;
389
390fn section_heading_with_tail(attrs_str: &str, tail: &str) -> Option<String> {
395 if !attrs_str.is_empty() {
396 return match (section_heading(attrs_str), tail.is_empty()) {
397 (Some(heading), true) => Some(heading),
398 (Some(heading), false) => Some(format!("{heading}\n{tail}")),
400 (None, true) => None,
401 (None, false) => Some(format!("## {tail}")),
403 };
404 }
405 if tail.starts_with('[') && crate::parse::find_attr_close(tail) == Some(tail.len() - 1) {
406 return section_heading(tail);
407 }
408 if tail.is_empty() || tail.starts_with('{') {
409 return None;
410 }
411 Some(format!("## {tail}"))
412}
413
414impl LintRule for SectionBlockUsed {
415 fn id(&self) -> &'static str {
416 "L001"
417 }
418
419 fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
420 let scan = scan_lines(source);
421 let mut out = Vec::new();
422 for (i, line) in scan.iter().enumerate() {
423 if line.literal {
424 continue;
425 }
426 let Some((depth, name, attrs_str)) = opening_directive(line.trimmed) else {
427 continue;
428 };
429 if name != "section" {
430 continue;
431 }
432 let tail = line.trimmed
433 [directive_name_start(line.trimmed, depth) + name.len() + attrs_str.len()..]
434 .trim();
435 let mut edits = Vec::new();
436 let description = match section_heading_with_tail(&attrs_str, tail) {
437 Some(heading) => {
438 let d = format!(
439 "replace '::section' with '{}'",
440 heading.split('\n').next().unwrap_or(&heading)
441 );
442 edits.push(line.replace_edit(heading));
443 d
444 }
445 None => {
446 edits.push(line.delete_edit(source.len()));
447 "remove '::section' wrapper without a title".to_string()
448 }
449 };
450 if let Some(ci) = matching_closer_idx(&scan, i, depth) {
451 edits.push(scan[ci].delete_edit(source.len()));
452 }
453 out.push(diag_fix(
454 "L001",
455 "'::section' block used — prefer a markdown '##' heading".to_string(),
456 Some(line.span()),
457 Some(rule_fix("L001", description, edits)),
458 ));
459 }
460 out
461 }
462}
463
464struct TableBlockUsed;
468
469impl LintRule for TableBlockUsed {
470 fn id(&self) -> &'static str {
471 "L002"
472 }
473
474 fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
475 let scan = scan_lines(source);
476 let mut out = Vec::new();
477 for (i, line) in scan.iter().enumerate() {
478 if line.literal {
479 continue;
480 }
481 let Some((depth, name, _)) = opening_directive(line.trimmed) else {
482 continue;
483 };
484 if name != "table" {
485 continue;
486 }
487 let mut edits = vec![line.delete_edit(source.len())];
488 if let Some(ci) = matching_closer_idx(&scan, i, depth) {
489 edits.push(scan[ci].delete_edit(source.len()));
490 }
491 out.push(diag_fix(
492 "L002",
493 "'::table' block used — prefer a markdown pipe table".to_string(),
494 Some(line.span()),
495 Some(rule_fix(
496 "L002",
497 "remove the '::table' wrapper, keeping the table content".to_string(),
498 edits,
499 )),
500 ));
501 }
502 out
503 }
504}
505
506fn find_brace_close(s: &str) -> Option<usize> {
514 let mut depth: i32 = 0;
515 let mut in_quote = false;
516 let mut escaped = false;
517 for (i, b) in s.bytes().enumerate() {
518 if in_quote {
519 if escaped {
520 escaped = false;
521 } else if b == b'\\' {
522 escaped = true;
523 } else if b == b'"' {
524 in_quote = false;
525 }
526 continue;
527 }
528 match b {
529 b'"' => in_quote = true,
530 b'{' => depth += 1,
531 b'}' => {
532 depth -= 1;
533 if depth == 0 {
534 return Some(i);
535 }
536 }
537 _ => {}
538 }
539 }
540 None
541}
542
543struct CurlyAttrBraces;
548
549impl LintRule for CurlyAttrBraces {
550 fn id(&self) -> &'static str {
551 "L003"
552 }
553
554 fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
555 let mut out = Vec::new();
556 for line in scan_lines(source) {
557 if line.literal {
558 continue;
559 }
560 if let Some((depth, name, _)) = opening_directive(line.trimmed) {
561 let name_at = directive_name_start(line.trimmed, depth);
562 let rest = &line.trimmed[name_at + name.len()..];
563 if rest.starts_with('{') {
564 let open = line.indent() + name_at + name.len();
566 let fix = find_brace_close(rest).map(|close_rel| {
567 let close = open + close_rel;
568 rule_fix(
569 "L003",
570 "rewrite {curly} attribute braces to [square] brackets".to_string(),
571 vec![
572 TextEdit {
573 span: line.sub_span(open, open + 1),
574 replacement: "[".to_string(),
575 },
576 TextEdit {
577 span: line.sub_span(close, close + 1),
578 replacement: "]".to_string(),
579 },
580 ],
581 )
582 });
583 out.push(diag_fix(
584 "L003",
585 format!(
586 "Block attributes use {{curly}} braces — use [square] brackets: ::{name}[...]"
587 ),
588 Some(line.span()),
589 fix,
590 ));
591 }
592 }
593 }
594 out
595 }
596}
597
598struct DirectiveNotOnOwnLine;
610
611fn break_line_edit(line: &ScanLine<'_>, from: usize) -> TextEdit {
614 let rest = &line.raw[from..];
615 let ws = rest.len() - rest.trim_start().len();
616 TextEdit {
617 span: line.sub_span(from, from + ws),
618 replacement: "\n".to_string(),
619 }
620}
621
622fn l004_opener_fix(
633 line: &ScanLine<'_>,
634 name: &str,
635 attrs_str: &str,
636 tail_at: usize,
637 tail: &str,
638) -> Fix {
639 let gap_start = line.indent() + tail_at;
640 let ws = tail.len() - tail.trim_start().len();
641 let bracket = tail.trim_start();
642 if attrs_str.is_empty()
643 && ws > 0
644 && bracket.starts_with('[')
645 && let Some(close) = crate::parse::find_attr_close(bracket)
646 {
647 let mut edits = vec![TextEdit {
649 span: line.sub_span(gap_start, gap_start + ws),
650 replacement: String::new(),
651 }];
652 if !bracket[close + 1..].trim().is_empty() {
654 edits.push(break_line_edit(line, gap_start + ws + close + 1));
655 }
656 return rule_fix(
657 "L004",
658 format!("attach the '[...]' attributes to '::{name}'"),
659 edits,
660 );
661 }
662 rule_fix(
663 "L004",
664 format!("move the content after '::{name}' to its own line"),
665 vec![break_line_edit(line, gap_start)],
666 )
667}
668
669impl LintRule for DirectiveNotOnOwnLine {
670 fn id(&self) -> &'static str {
671 "L004"
672 }
673
674 fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
675 let mut out = Vec::new();
676 for line in scan_lines(source) {
677 if line.literal {
678 continue;
679 }
680 if let Some((depth, name, attrs_str)) = opening_directive(line.trimmed) {
681 let name_at = directive_name_start(line.trimmed, depth);
682 let rest = &line.trimmed[name_at + name.len()..];
683 if rest.starts_with('{') {
684 continue;
686 }
687 if name == "section" {
688 continue;
693 }
694 let tail_at = name_at + name.len() + attrs_str.len();
695 let tail = &line.trimmed[tail_at..];
696 if !tail.trim().is_empty() {
697 let fix = l004_opener_fix(&line, &name, &attrs_str, tail_at, tail);
698 out.push(diag_fix(
699 "L004",
700 format!(
701 "Block directive '::{name}' has content on the same line — openers and closers must be on their own line"
702 ),
703 Some(line.span()),
704 Some(fix),
705 ));
706 }
707 } else {
708 let colons = line.trimmed.chars().take_while(|&c| c == ':').count();
711 if colons >= 2 {
712 let rest = &line.trimmed[colons..];
713 if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
714 let fix = rule_fix(
715 "L004",
716 "move the content after '::' to its own line".to_string(),
717 vec![break_line_edit(&line, line.indent() + colons)],
718 );
719 out.push(diag_fix(
720 "L004",
721 "Block directive '::' has content on the same line — openers and closers must be on their own line".to_string(),
722 Some(line.span()),
723 Some(fix),
724 ));
725 }
726 }
727 }
728 }
729 out
730 }
731}
732
733struct NestingColonMismatch;
747
748impl LintRule for NestingColonMismatch {
749 fn id(&self) -> &'static str {
750 "L005"
751 }
752
753 fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
754 let mut out = Vec::new();
755 let mut stack: Vec<usize> = Vec::new();
756 for line in scan_lines(source) {
757 if line.literal {
758 continue;
759 }
760 if let Some(close_depth) = closing_directive_depth(line.trimmed) {
761 if let Some(pos) = stack.iter().rposition(|&d| d == close_depth) {
762 stack.truncate(pos);
763 } else {
764 let colon_start = line.indent();
767 let fix = match stack.last() {
768 Some(&top) => rule_fix(
769 "L005",
770 format!(
771 "rewrite closer to {top} colons to match the innermost open block"
772 ),
773 vec![TextEdit {
774 span: line.sub_span(colon_start, colon_start + close_depth),
775 replacement: ":".repeat(top),
776 }],
777 ),
778 None => rule_fix(
779 "L005",
780 "remove closing directive that matches no open block".to_string(),
781 vec![line.delete_edit(source.len())],
782 ),
783 };
784 out.push(diag_fix(
785 "L005",
786 format!(
787 "Nesting colon count inconsistent with actual depth: closing directive with {close_depth} colons does not match any open block"
788 ),
789 Some(line.span()),
790 Some(fix),
791 ));
792 }
793 continue;
794 }
795 if let Some((depth, _, _)) = opening_directive(line.trimmed) {
796 let allowed = stack.last().map_or(2, |&d| d + 1);
797 if depth > allowed {
798 let colon_start = line.indent();
799 let fix = rule_fix(
800 "L005",
801 format!("reduce opener colons from {depth} to {allowed}"),
802 vec![TextEdit {
803 span: line.sub_span(colon_start, colon_start + depth),
804 replacement: ":".repeat(allowed),
805 }],
806 );
807 out.push(diag_fix(
808 "L005",
809 format!(
810 "Nesting colon count inconsistent with actual depth: block opened with {depth} colons but nesting depth here allows at most {allowed}"
811 ),
812 Some(line.span()),
813 Some(fix),
814 ));
815 }
816 stack.push(depth);
817 }
818 }
819 out
820 }
821}
822
823const SUMMARY_DOC_TYPES: &[DocType] = &[DocType::Doc, DocType::Plan, DocType::Guide];
829
830const SUMMARY_TOP_LINES: usize = 30;
833
834fn doc_type_name(doc_type: DocType) -> &'static str {
835 match doc_type {
836 DocType::Doc => "doc",
837 DocType::Plan => "plan",
838 DocType::Guide => "guide",
839 DocType::Conversation => "conversation",
840 DocType::Agent => "agent",
841 DocType::Preference => "preference",
842 DocType::Report => "report",
843 DocType::Proposal => "proposal",
844 DocType::Incident => "incident",
845 DocType::Review => "review",
846 DocType::App => "app",
847 DocType::Manifest => "manifest",
848 DocType::Website => "website",
849 DocType::Web => "web",
850 DocType::Deck => "deck",
851 DocType::Slides => "slides",
852 DocType::Presentation => "presentation",
853 DocType::Paper => "paper",
854 }
855}
856
857fn first_summary(doc: &SurfDoc) -> Option<&Span> {
859 doc.blocks.iter().find_map(|b| match b {
860 Block::Summary { span, .. } => Some(span),
861 _ => None,
862 })
863}
864
865const SUMMARY_STUB_BODY: &str = "TODO: add a one-paragraph summary.";
867
868fn summary_stub_fix(source: &str) -> Option<Fix> {
871 let lines: Vec<&str> = source.split('\n').collect();
872 let fm_end = front_matter_close_line(&lines)?;
874 let mut anchor = fm_end;
875 for (i, raw) in lines.iter().enumerate().skip(fm_end + 1) {
876 if raw.trim().is_empty() {
877 continue;
878 }
879 if raw.trim_start().starts_with("# ") {
880 anchor = i;
881 }
882 break;
883 }
884 let after_anchor: usize = lines[..=anchor].iter().map(|l| l.len() + 1).sum();
886 let (offset, text) = if after_anchor > source.len() {
887 (
889 source.len(),
890 format!("\n\n::summary\n{SUMMARY_STUB_BODY}\n::"),
891 )
892 } else {
893 (
894 after_anchor,
895 format!("\n::summary\n{SUMMARY_STUB_BODY}\n::\n"),
896 )
897 };
898 Some(rule_fix(
899 "L010",
900 "insert a '::summary' stub block near the top of the document".to_string(),
901 vec![TextEdit {
902 span: Span {
903 start_line: anchor + 2,
904 end_line: anchor + 2,
905 start_offset: offset,
906 end_offset: offset,
907 },
908 replacement: text,
909 }],
910 ))
911}
912
913struct MissingSummary;
918
919impl LintRule for MissingSummary {
920 fn id(&self) -> &'static str {
921 "L010"
922 }
923
924 fn check(&self, doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
925 let Some(doc_type) = doc.front_matter.as_ref().and_then(|fm| fm.doc_type) else {
926 return Vec::new();
927 };
928 if !SUMMARY_DOC_TYPES.contains(&doc_type) || first_summary(doc).is_some() {
929 return Vec::new();
930 }
931 vec![diag_fix(
932 "L010",
933 format!(
934 "Document of type '{}' has no '::summary' block",
935 doc_type_name(doc_type)
936 ),
937 None,
938 summary_stub_fix(source),
939 )]
940 }
941}
942
943struct SummaryNotNearTop;
946
947impl LintRule for SummaryNotNearTop {
948 fn id(&self) -> &'static str {
949 "L011"
950 }
951
952 fn check(&self, doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
953 let Some(span) = first_summary(doc) else {
954 return Vec::new();
955 };
956 let lines: Vec<&str> = source.split('\n').collect();
957 let body_first_line = front_matter_close_line(&lines).map_or(1, |i| i + 2);
959 let body_position = span.start_line.saturating_sub(body_first_line) + 1;
961 if body_position <= SUMMARY_TOP_LINES {
962 return Vec::new();
963 }
964 vec![diag(
965 "L011",
966 format!(
967 "'::summary' block starts at line {} — should appear within the first {} lines after front matter",
968 span.start_line, SUMMARY_TOP_LINES
969 ),
970 Some(*span),
971 )]
972 }
973}
974
975fn levenshtein(a: &str, b: &str) -> usize {
981 let a: Vec<char> = a.chars().collect();
982 let b: Vec<char> = b.chars().collect();
983 let mut prev: Vec<usize> = (0..=b.len()).collect();
984 let mut cur = vec![0usize; b.len() + 1];
985 for (i, ca) in a.iter().enumerate() {
986 cur[0] = i + 1;
987 for (j, cb) in b.iter().enumerate() {
988 let cost = usize::from(ca != cb);
989 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
990 }
991 std::mem::swap(&mut prev, &mut cur);
992 }
993 prev[b.len()]
994}
995
996fn did_you_mean<'a>(name: &str, known: &'a BTreeSet<String>) -> Option<&'a str> {
999 let mut best: Option<(&str, usize)> = None;
1000 let mut tie = false;
1001 for cand in known {
1002 if cand.len().abs_diff(name.len()) > 2 {
1003 continue;
1004 }
1005 let d = levenshtein(name, cand);
1006 if d > 2 {
1007 continue;
1008 }
1009 match best {
1010 None => best = Some((cand, d)),
1011 Some((_, bd)) if d < bd => {
1012 best = Some((cand, d));
1013 tie = false;
1014 }
1015 Some((_, bd)) if d == bd => tie = true,
1016 _ => {}
1017 }
1018 }
1019 if tie { None } else { best.map(|(c, _)| c) }
1020}
1021
1022struct UnknownBlockName;
1024
1025impl LintRule for UnknownBlockName {
1026 fn id(&self) -> &'static str {
1027 "L020"
1028 }
1029
1030 fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
1031 let known = known_block_names();
1032 let mut out = Vec::new();
1033 for line in scan_lines(source) {
1034 if line.literal {
1035 continue;
1036 }
1037 if let Some((depth, name, _)) = opening_directive(line.trimmed) {
1038 if name == "table"
1040 || known.contains(&name)
1041 || EXTRA_KNOWN_BLOCK_NAMES.contains(&name.as_str())
1042 {
1043 continue;
1044 }
1045 let (suggestion, fix) = match did_you_mean(&name, known) {
1047 Some(s) => {
1048 let name_start = line.indent() + depth;
1049 let fix = rule_fix(
1050 "L020",
1051 format!("rename '::{name}' to '::{s}'"),
1052 vec![TextEdit {
1053 span: line.sub_span(name_start, name_start + name.len()),
1054 replacement: s.to_string(),
1055 }],
1056 );
1057 (format!(" — did you mean '::{s}'?"), Some(fix))
1058 }
1059 None => (String::new(), None),
1060 };
1061 out.push(diag_fix(
1062 "L020",
1063 format!("Unknown block '::{name}'{suggestion}"),
1064 Some(line.span()),
1065 fix,
1066 ));
1067 }
1068 }
1069 out
1070 }
1071}
1072
1073fn derived_updated_fix(source: &str) -> Option<Fix> {
1083 let lines: Vec<&str> = source.split('\n').collect();
1084 let fm_end = front_matter_close_line(&lines)?;
1085 let mut offset = lines[0].len() + 1; for raw in lines.iter().take(fm_end).skip(1) {
1087 let line_offset = offset;
1088 offset += raw.len() + 1;
1089 if raw.starts_with(char::is_whitespace) {
1090 continue;
1091 }
1092 let Some((key, value)) = raw.split_once(':') else {
1093 continue;
1094 };
1095 if key.trim() != "created" || value.trim().is_empty() {
1096 continue;
1097 }
1098 let insert_at = line_offset + raw.len() + 1;
1101 let line_no = source[..insert_at].matches('\n').count() + 1;
1102 return Some(rule_fix(
1103 "L030",
1104 format!("insert 'updated:{value}' derived from the created field"),
1105 vec![TextEdit {
1106 span: Span {
1107 start_line: line_no,
1108 end_line: line_no,
1109 start_offset: insert_at,
1110 end_offset: insert_at,
1111 },
1112 replacement: format!("updated:{value}\n"),
1113 }],
1114 ));
1115 }
1116 None
1117}
1118
1119struct MissingRequiredFrontMatter;
1129
1130impl LintRule for MissingRequiredFrontMatter {
1131 fn id(&self) -> &'static str {
1132 "L030"
1133 }
1134
1135 fn check(&self, doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
1136 let Some(fm) = doc.front_matter.as_ref() else {
1137 return Vec::new();
1138 };
1139 let Some(doc_type) = fm.doc_type else {
1140 return Vec::new();
1141 };
1142 let missing: Vec<&str> = match doc_type {
1143 DocType::Plan => {
1144 let mut m = Vec::new();
1145 if fm.status.is_none() {
1146 m.push("status");
1147 }
1148 if fm.created.is_none() {
1149 m.push("created");
1150 }
1151 m
1152 }
1153 DocType::Guide => {
1154 let mut m = Vec::new();
1155 if fm.confidence.is_none() {
1156 m.push("confidence");
1157 }
1158 if fm.updated.is_none() {
1159 m.push("updated");
1160 }
1161 m
1162 }
1163 _ => Vec::new(),
1164 };
1165 missing
1166 .into_iter()
1167 .map(|field| {
1168 let fix = if field == "updated" && fm.created.is_some() {
1169 derived_updated_fix(source)
1170 } else {
1171 None
1172 };
1173 diag_fix(
1174 "L030",
1175 format!(
1176 "Document of type '{}' is missing required front matter field: {field}",
1177 doc_type_name(doc_type)
1178 ),
1179 None,
1180 fix,
1181 )
1182 })
1183 .collect()
1184 }
1185}
1186
1187const FM_ENUM_FIELDS: &[(&str, &[&str])] = &[
1193 (
1194 "type",
1195 &[
1196 "doc",
1197 "guide",
1198 "conversation",
1199 "plan",
1200 "agent",
1201 "preference",
1202 "report",
1203 "proposal",
1204 "incident",
1205 "review",
1206 "app",
1207 "manifest",
1208 "website",
1209 "deck",
1210 "slides",
1211 ],
1212 ),
1213 ("status", &["draft", "active", "closed", "archived"]),
1214 (
1215 "scope",
1216 &[
1217 "personal",
1218 "workspace-private",
1219 "workspace",
1220 "repo",
1221 "public",
1222 ],
1223 ),
1224 ("confidence", &["low", "medium", "high"]),
1225];
1226
1227struct FrontMatterEnumCase;
1231
1232impl LintRule for FrontMatterEnumCase {
1233 fn id(&self) -> &'static str {
1234 "L031"
1235 }
1236
1237 fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
1238 let lines: Vec<&str> = source.split('\n').collect();
1239 let Some(fm_end) = front_matter_close_line(&lines) else {
1240 return Vec::new();
1241 };
1242 let mut out = Vec::new();
1243 let mut offset = lines[0].len() + 1; for (idx, raw) in lines.iter().enumerate().take(fm_end).skip(1) {
1245 let line_offset = offset;
1246 offset += raw.len() + 1;
1247 if raw.starts_with(char::is_whitespace) {
1249 continue;
1250 }
1251 let Some((key, value)) = raw.split_once(':') else {
1252 continue;
1253 };
1254 let key = key.trim();
1255 let Some((_, valid)) = FM_ENUM_FIELDS.iter().find(|(k, _)| *k == key) else {
1256 continue;
1257 };
1258 let raw_value = value;
1259 let value = value.trim().trim_matches('"').trim_matches('\'');
1260 if value.is_empty() || valid.contains(&value) {
1261 continue;
1262 }
1263 let normalized = value.trim().to_lowercase();
1264 if let Some(expected) = valid.iter().find(|v| **v == normalized) {
1265 let colon_at = raw.len() - raw_value.len();
1270 let ws = raw_value.len() - raw_value.trim_start().len();
1271 let val_start = line_offset + colon_at + ws;
1272 let val_end = line_offset + colon_at + raw_value.trim_end().len();
1273 let fix = rule_fix(
1274 "L031",
1275 format!("normalize '{key}' value to '{expected}'"),
1276 vec![TextEdit {
1277 span: Span {
1278 start_line: idx + 1,
1279 end_line: idx + 1,
1280 start_offset: val_start,
1281 end_offset: val_end,
1282 },
1283 replacement: (*expected).to_string(),
1284 }],
1285 );
1286 out.push(diag_fix(
1287 "L031",
1288 format!(
1289 "Front matter field '{key}' has value '{value}' — should be '{expected}'"
1290 ),
1291 Some(Span {
1292 start_line: idx + 1,
1293 end_line: idx + 1,
1294 start_offset: line_offset,
1295 end_offset: line_offset + raw.len(),
1296 }),
1297 Some(fix),
1298 ));
1299 }
1300 }
1301 out
1302 }
1303}
1304
1305#[derive(Debug, Clone, Serialize, Deserialize)]
1311pub struct CheckReport {
1312 pub diagnostics: Vec<Diagnostic>,
1314 pub error_count: usize,
1316 pub warning_count: usize,
1318 pub info_count: usize,
1320 pub fixable_count: usize,
1322}
1323
1324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1327pub struct LintConfig {
1328 pub severity_overrides: BTreeMap<String, Severity>,
1330 pub disabled_rules: BTreeSet<String>,
1332 #[serde(default)]
1337 pub extra_frontmatter_values: BTreeMap<String, BTreeSet<String>>,
1338}
1339
1340pub fn check(input: &str) -> CheckReport {
1342 check_with(input, &LintConfig::default())
1343}
1344
1345pub fn check_with(input: &str, cfg: &LintConfig) -> CheckReport {
1347 let result = crate::parse::parse(input);
1348 let mut diagnostics = result.diagnostics;
1349 attach_parse_fixes(&mut diagnostics, &result.doc.source);
1350 let front_matter_unparseable = diagnostics
1358 .iter()
1359 .any(|d| matches!(d.code.as_deref(), Some("P002") | Some("P005")));
1360 let mut schema = result.doc.validate();
1361 if front_matter_unparseable {
1362 schema.retain(|d| !matches!(d.code.as_deref(), Some("V001") | Some("V002")));
1363 }
1364 diagnostics.extend(schema);
1365 if !cfg.extra_frontmatter_values.is_empty() {
1368 diagnostics.retain(|d| {
1369 d.code.as_deref() != Some("P005")
1370 || !cfg.extra_frontmatter_values.iter().any(|(field, values)| {
1371 values
1372 .iter()
1373 .any(|v| d.message == crate::parse::p005_message(field, v))
1374 })
1375 });
1376 }
1377 for rule in all_rules() {
1378 if cfg.disabled_rules.contains(rule.id()) {
1379 continue;
1380 }
1381 diagnostics.extend(rule.check(&result.doc, &result.doc.source));
1382 }
1383
1384 diagnostics.retain(|d| {
1385 d.code
1386 .as_deref()
1387 .is_none_or(|code| !cfg.disabled_rules.contains(code))
1388 });
1389 for d in &mut diagnostics {
1390 if let Some(severity) = d
1391 .code
1392 .as_deref()
1393 .and_then(|c| cfg.severity_overrides.get(c))
1394 {
1395 d.severity = *severity;
1396 }
1397 }
1398
1399 diagnostics.sort_by(|a, b| {
1400 let ka = a.span.map_or(0, |s| s.start_offset);
1401 let kb = b.span.map_or(0, |s| s.start_offset);
1402 ka.cmp(&kb).then_with(|| a.code.cmp(&b.code))
1403 });
1404
1405 let count = |sev: Severity| diagnostics.iter().filter(|d| d.severity == sev).count();
1406 CheckReport {
1407 error_count: count(Severity::Error),
1408 warning_count: count(Severity::Warning),
1409 info_count: count(Severity::Info),
1410 fixable_count: diagnostics.iter().filter(|d| d.fix.is_some()).count(),
1411 diagnostics,
1412 }
1413}
1414
1415fn attach_parse_fixes(diagnostics: &mut [Diagnostic], source: &str) {
1429 let lines: Vec<&str> = source.split('\n').collect();
1430 let mut lint_stack = lint_depth_stack_at_eof(source);
1438 for d in diagnostics {
1439 match d.code.as_deref() {
1440 Some("P001") => d.fix = p001_eof_closer_fix(d, source, &lines, &mut lint_stack),
1441 Some("P002") => d.fix = p002_close_front_matter_fix(d, source, &lines),
1442 _ => {}
1443 }
1444 }
1445}
1446
1447fn lint_depth_stack_at_eof(source: &str) -> Vec<usize> {
1450 let mut stack: Vec<usize> = Vec::new();
1451 for line in scan_lines(source) {
1452 if line.literal {
1453 continue;
1454 }
1455 if let Some(d) = closing_directive_depth(line.trimmed) {
1456 if let Some(pos) = stack.iter().rposition(|&s| s == d) {
1457 stack.truncate(pos);
1458 }
1459 continue;
1460 }
1461 if let Some((d, _, _)) = opening_directive(line.trimmed) {
1462 stack.push(d);
1463 }
1464 }
1465 stack
1466}
1467
1468fn p001_eof_closer_fix(
1472 d: &Diagnostic,
1473 source: &str,
1474 lines: &[&str],
1475 lint_stack: &mut Vec<usize>,
1476) -> Option<Fix> {
1477 let span = d.span?;
1478 if span.end_offset != source.len() {
1482 return None;
1483 }
1484 let last = lines.get(span.end_line.saturating_sub(1))?;
1485 if closing_directive_depth(last.trim()).is_some() {
1486 return None;
1487 }
1488 let opener = lines.get(span.start_line.saturating_sub(1))?;
1489 let (depth, name, _) = opening_directive(opener.trim())?;
1490 if lint_stack.last() != Some(&depth) {
1494 return None;
1495 }
1496 lint_stack.pop();
1497 let closer = ":".repeat(depth);
1498 let text = if source.is_empty() || source.ends_with('\n') {
1499 format!("{closer}\n")
1500 } else {
1501 format!("\n{closer}\n")
1502 };
1503 let line_no = lines.len();
1504 Some(Fix {
1505 edits: vec![TextEdit {
1506 span: Span {
1507 start_line: line_no,
1508 end_line: line_no,
1509 start_offset: source.len(),
1510 end_offset: source.len(),
1511 },
1512 replacement: text,
1513 }],
1514 safety: FixSafety::Safe,
1515 description: format!("append the missing '{closer}' closer for '::{name}' at end of file"),
1516 })
1517}
1518
1519fn p002_close_front_matter_fix(d: &Diagnostic, source: &str, lines: &[&str]) -> Option<Fix> {
1522 if !d.message.contains("never closed") {
1525 return None;
1526 }
1527 let blank = lines
1528 .iter()
1529 .enumerate()
1530 .skip(1)
1531 .find(|(_, l)| l.trim().is_empty())
1532 .map(|(i, _)| i)?;
1533 if blank < 2 {
1534 return None; }
1536 let yaml = lines[1..blank].join("\n");
1537 if serde_yaml::from_str::<FrontMatter>(&yaml).is_err() {
1538 return None;
1539 }
1540 let offset: usize = lines[..blank].iter().map(|l| l.len() + 1).sum();
1541 if offset > source.len() {
1542 return None;
1543 }
1544 Some(Fix {
1545 edits: vec![TextEdit {
1546 span: Span {
1547 start_line: blank + 1,
1548 end_line: blank + 1,
1549 start_offset: offset,
1550 end_offset: offset,
1551 },
1552 replacement: "---\n".to_string(),
1553 }],
1554 safety: FixSafety::Safe,
1555 description: "close the front matter with '---' before the first blank line".to_string(),
1556 })
1557}
1558
1559pub const MAX_FIX_ITERATIONS: usize = 10;
1565
1566#[derive(Debug, Clone, Serialize, Deserialize)]
1568pub struct AppliedFix {
1569 pub code: String,
1571 pub description: String,
1573}
1574
1575#[derive(Debug, Clone, Serialize, Deserialize)]
1577pub struct SkippedFix {
1578 pub code: String,
1580 pub description: String,
1582 pub reason: String,
1584}
1585
1586#[derive(Debug, Clone, Serialize, Deserialize)]
1588pub struct FixOutcome {
1589 pub source: String,
1592 pub applied: Vec<AppliedFix>,
1594 pub skipped: Vec<SkippedFix>,
1597 pub iterations: usize,
1600}
1601
1602fn edits_conflict(a: &TextEdit, b: &TextEdit) -> bool {
1606 let (a, b) = (&a.span, &b.span);
1607 a.start_offset == b.start_offset
1608 || (a.start_offset < b.end_offset && b.start_offset < a.end_offset)
1609}
1610
1611fn edit_span_is_valid(e: &TextEdit, source: &str) -> bool {
1614 let s = &e.span;
1615 s.start_offset <= s.end_offset
1616 && s.end_offset <= source.len()
1617 && source.is_char_boundary(s.start_offset)
1618 && source.is_char_boundary(s.end_offset)
1619}
1620
1621fn run_fix_pass(
1628 source: &str,
1629 safety: FixSafety,
1630 cfg: &LintConfig,
1631) -> (Option<String>, Vec<AppliedFix>, Vec<SkippedFix>) {
1632 let report = check_with(source, cfg);
1633 let mut candidates: Vec<(&Diagnostic, &Fix)> = report
1634 .diagnostics
1635 .iter()
1636 .filter_map(|d| d.fix.as_ref().map(|f| (d, f)))
1637 .filter(|(_, f)| f.safety <= safety)
1638 .collect();
1639 candidates.sort_by(|(da, fa), (db, fb)| {
1642 let key = |f: &Fix| {
1643 f.edits
1644 .iter()
1645 .map(|e| e.span.start_offset)
1646 .min()
1647 .unwrap_or(0)
1648 };
1649 key(fb).cmp(&key(fa)).then_with(|| {
1650 let span_start = |d: &Diagnostic| d.span.map_or(0, |s| s.start_offset);
1651 span_start(db).cmp(&span_start(da))
1652 })
1653 });
1654
1655 let mut accepted: Vec<TextEdit> = Vec::new();
1656 let mut applied = Vec::new();
1657 let mut skipped = Vec::new();
1658 'candidate: for (d, f) in candidates {
1659 let code = d.code.clone().unwrap_or_default();
1660 let skip = |reason: &str, skipped: &mut Vec<SkippedFix>| {
1661 skipped.push(SkippedFix {
1662 code: code.clone(),
1663 description: f.description.clone(),
1664 reason: reason.to_string(),
1665 });
1666 };
1667 if f.edits.is_empty() {
1668 skip("fix carries no edits", &mut skipped);
1669 continue;
1670 }
1671 for e in &f.edits {
1672 if !edit_span_is_valid(e, source) {
1673 skip("edit span is out of bounds for this source", &mut skipped);
1674 continue 'candidate;
1675 }
1676 }
1677 for (i, e) in f.edits.iter().enumerate() {
1678 if accepted.iter().any(|a| edits_conflict(a, e))
1679 || f.edits[..i].iter().any(|a| edits_conflict(a, e))
1680 {
1681 skip(
1682 "overlaps an edit from an already-accepted fix",
1683 &mut skipped,
1684 );
1685 continue 'candidate;
1686 }
1687 }
1688 accepted.extend(f.edits.iter().cloned());
1689 applied.push(AppliedFix {
1690 code,
1691 description: f.description.clone(),
1692 });
1693 }
1694
1695 if accepted.is_empty() {
1696 return (None, applied, skipped);
1697 }
1698 accepted.sort_by(|a, b| {
1700 b.span
1701 .start_offset
1702 .cmp(&a.span.start_offset)
1703 .then(b.span.end_offset.cmp(&a.span.end_offset))
1704 });
1705 let mut out = source.to_string();
1706 for e in &accepted {
1707 out.replace_range(e.span.start_offset..e.span.end_offset, &e.replacement);
1708 }
1709 (Some(out), applied, skipped)
1710}
1711
1712pub fn apply_fixes(input: &str, safety: FixSafety) -> FixOutcome {
1722 apply_fixes_with(input, safety, &LintConfig::default())
1723}
1724
1725pub fn apply_fixes_with(input: &str, safety: FixSafety, cfg: &LintConfig) -> FixOutcome {
1729 let mut source = input.replace("\r\n", "\n");
1730 let mut applied = Vec::new();
1731 let mut skipped = Vec::new();
1732 let mut iterations = 0usize;
1733 while iterations < MAX_FIX_ITERATIONS {
1734 let (next, mut pass_applied, pass_skipped) = run_fix_pass(&source, safety, cfg);
1735 skipped = pass_skipped;
1736 match next {
1737 Some(s) => {
1738 iterations += 1;
1739 source = s;
1740 applied.append(&mut pass_applied);
1741 }
1742 None => break,
1743 }
1744 }
1745 FixOutcome {
1746 source,
1747 applied,
1748 skipped,
1749 iterations,
1750 }
1751}
1752
1753pub fn apply_fixes_once(input: &str, safety: FixSafety) -> FixOutcome {
1757 let source = input.replace("\r\n", "\n");
1758 let (next, applied, skipped) = run_fix_pass(&source, safety, &LintConfig::default());
1759 let iterations = usize::from(next.is_some());
1760 FixOutcome {
1761 source: next.unwrap_or(source),
1762 applied,
1763 skipped,
1764 iterations,
1765 }
1766}
1767
1768pub const JSON_SCHEMA_VERSION: u32 = 1;
1776
1777pub fn report_to_json(path: Option<&str>, report: &CheckReport) -> serde_json::Value {
1785 let mut obj = serde_json::Map::new();
1786 if let Some(path) = path {
1787 obj.insert("path".into(), serde_json::Value::String(path.to_owned()));
1788 }
1789 obj.insert(
1790 "diagnostics".into(),
1791 serde_json::to_value(&report.diagnostics).expect("Diagnostic always serializes"),
1792 );
1793 obj.insert("error_count".into(), report.error_count.into());
1794 obj.insert("warning_count".into(), report.warning_count.into());
1795 obj.insert("info_count".into(), report.info_count.into());
1796 obj.insert("fixable_count".into(), report.fixable_count.into());
1797 serde_json::Value::Object(obj)
1798}
1799
1800pub fn reports_to_json(files: &[(Option<&str>, &CheckReport)]) -> serde_json::Value {
1811 let mut errors = 0usize;
1812 let mut warnings = 0usize;
1813 let mut infos = 0usize;
1814 let mut fixable = 0usize;
1815 let json_files: Vec<serde_json::Value> = files
1816 .iter()
1817 .map(|(path, report)| {
1818 errors += report.error_count;
1819 warnings += report.warning_count;
1820 infos += report.info_count;
1821 fixable += report.fixable_count;
1822 report_to_json(*path, report)
1823 })
1824 .collect();
1825 serde_json::json!({
1826 "schema_version": JSON_SCHEMA_VERSION,
1827 "files": json_files,
1828 "summary": {
1829 "files": files.len(),
1830 "error_count": errors,
1831 "warning_count": warnings,
1832 "info_count": infos,
1833 "fixable_count": fixable,
1834 },
1835 })
1836}
1837
1838#[cfg(test)]
1843mod tests {
1844 use super::*;
1845 use pretty_assertions::assert_eq;
1846
1847 fn run_rule(rule: &dyn LintRule, input: &str) -> Vec<Diagnostic> {
1849 let result = crate::parse::parse(input);
1850 rule.check(&result.doc, &result.doc.source)
1851 }
1852
1853 fn codes(diags: &[Diagnostic]) -> Vec<&str> {
1854 diags.iter().filter_map(|d| d.code.as_deref()).collect()
1855 }
1856
1857 #[test]
1860 fn registry_loads_with_expected_rules() {
1861 let reg = rule_registry();
1862 for id in ["P001", "P002", "P003", "L001", "L005", "L020", "L031"] {
1863 assert!(reg.contains_key(id), "registry missing {id}");
1864 }
1865 assert_eq!(rule_registry_total(), reg.len());
1866 }
1867
1868 #[test]
1869 fn registry_severity_matches_defaults() {
1870 assert_eq!(registry_severity("P002"), Severity::Error);
1871 assert_eq!(registry_severity("P001"), Severity::Warning);
1872 assert_eq!(registry_severity("L011"), Severity::Info);
1873 }
1874
1875 #[test]
1876 fn known_block_names_loaded_from_spec() {
1877 let known = known_block_names();
1878 assert!(known.contains("callout"));
1879 assert!(known.contains("section"));
1880 assert!(!known.contains("table"));
1881 }
1882
1883 #[test]
1886 fn l001_fires_on_section_block() {
1887 let diags = run_rule(&SectionBlockUsed, "::section[title=Intro]\nText.\n::\n");
1888 assert_eq!(codes(&diags), vec!["L001"]);
1889 assert_eq!(diags[0].span.unwrap().start_line, 1);
1890 }
1891
1892 #[test]
1893 fn l001_silent_on_headings() {
1894 let diags = run_rule(&SectionBlockUsed, "## Intro\n\nText.\n");
1895 assert!(diags.is_empty());
1896 }
1897
1898 #[test]
1899 fn l002_fires_on_table_block() {
1900 let diags = run_rule(&TableBlockUsed, "::table\n| a | b |\n::\n");
1901 assert_eq!(codes(&diags), vec!["L002"]);
1902 }
1903
1904 #[test]
1907 fn l003_fires_on_curly_attrs() {
1908 let diags = run_rule(&CurlyAttrBraces, "::callout{type=info}\nHi\n::\n");
1909 assert_eq!(codes(&diags), vec!["L003"]);
1910 assert!(diags[0].message.contains("[square]"));
1911 }
1912
1913 #[test]
1914 fn l003_silent_on_square_attrs() {
1915 let diags = run_rule(&CurlyAttrBraces, "::callout[type=info]\nHi\n::\n");
1916 assert!(diags.is_empty());
1917 }
1918
1919 #[test]
1920 fn l003_silent_inside_code_block() {
1921 let input = "::code[lang=surf]\n::callout{type=info}\n::\n";
1922 let diags = run_rule(&CurlyAttrBraces, input);
1923 assert!(diags.is_empty(), "code block content is literal: {diags:?}");
1924 }
1925
1926 #[test]
1927 fn l003_silent_inside_markdown_fence() {
1928 let input = "```\n::callout{type=info}\n```\n";
1929 let diags = run_rule(&CurlyAttrBraces, input);
1930 assert!(diags.is_empty(), "fenced content is literal: {diags:?}");
1931 }
1932
1933 #[test]
1936 fn l004_fires_on_opener_with_trailing_content() {
1937 let diags = run_rule(
1938 &DirectiveNotOnOwnLine,
1939 "::callout[type=info] Watch out!\n::\n",
1940 );
1941 assert_eq!(codes(&diags), vec!["L004"]);
1942 }
1943
1944 #[test]
1945 fn l004_fires_on_closer_with_trailing_content() {
1946 let diags = run_rule(
1948 &DirectiveNotOnOwnLine,
1949 "::callout[type=info]\nHi\n:: 42 done\n",
1950 );
1951 assert_eq!(codes(&diags), vec!["L004"]);
1952 let none = run_rule(
1955 &DirectiveNotOnOwnLine,
1956 "::callout[type=info]\nHi\n:: done\n::\n",
1957 );
1958 assert!(none.is_empty(), "tolerant opener misread as closer: {none:?}");
1959 }
1960
1961 #[test]
1962 fn l004_silent_on_clean_directives() {
1963 let diags = run_rule(&DirectiveNotOnOwnLine, "::callout[type=info]\nHi\n::\n");
1964 assert!(diags.is_empty());
1965 }
1966
1967 #[test]
1968 fn l004_defers_curly_openers_to_l003() {
1969 let diags = run_rule(&DirectiveNotOnOwnLine, "::callout{type=info}\nHi\n::\n");
1970 assert!(diags.is_empty());
1971 }
1972
1973 #[test]
1976 fn l005_fires_on_overdeep_opener() {
1977 let diags = run_rule(&NestingColonMismatch, ":::column\nLeft\n:::\n");
1978 assert_eq!(codes(&diags), vec!["L005"]);
1979 assert!(diags[0].message.contains("at most 2"));
1980 }
1981
1982 #[test]
1983 fn l005_fires_on_orphan_closer() {
1984 let diags = run_rule(&NestingColonMismatch, "::callout\nHi\n::\n::\n");
1985 assert_eq!(codes(&diags), vec!["L005"]);
1986 assert!(diags[0].message.contains("does not match any open block"));
1987 }
1988
1989 #[test]
1990 fn l005_silent_on_correct_nesting() {
1991 let input = "::columns\n:::column\nLeft\n:::\n:::column\nRight\n:::\n::\n";
1992 let diags = run_rule(&NestingColonMismatch, input);
1993 assert!(diags.is_empty(), "{diags:?}");
1994 }
1995
1996 #[test]
1997 fn l005_silent_on_same_depth_leaf_children() {
1998 let input = "::page[route=\"/\"]\n::cta[label=\"Go\" href=\"/x\"]\n::hero-image[src=\"a.jpg\"]\n::\n";
2000 let diags = run_rule(&NestingColonMismatch, input);
2001 assert!(diags.is_empty(), "{diags:?}");
2002 }
2003
2004 #[test]
2007 fn l010_fires_on_plan_without_summary() {
2008 let input = "---\ntitle: T\ntype: plan\n---\n# Heading\n";
2009 let diags = run_rule(&MissingSummary, input);
2010 assert_eq!(codes(&diags), vec!["L010"]);
2011 assert!(diags[0].message.contains("'plan'"));
2012 }
2013
2014 #[test]
2015 fn l010_silent_when_summary_present() {
2016 let input = "---\ntitle: T\ntype: doc\n---\n::summary\nShort.\n::\n";
2017 let diags = run_rule(&MissingSummary, input);
2018 assert!(diags.is_empty());
2019 }
2020
2021 #[test]
2022 fn l010_silent_on_other_doc_types() {
2023 let input = "---\ntitle: T\ntype: report\n---\n# Heading\n";
2024 let diags = run_rule(&MissingSummary, input);
2025 assert!(diags.is_empty());
2026 }
2027
2028 #[test]
2029 fn l011_fires_on_late_summary() {
2030 let filler = "Filler line.\n".repeat(35);
2031 let input = format!("---\ntitle: T\ntype: doc\n---\n{filler}::summary\nShort.\n::\n");
2032 let diags = run_rule(&SummaryNotNearTop, &input);
2033 assert_eq!(codes(&diags), vec!["L011"]);
2034 assert_eq!(diags[0].severity, Severity::Info);
2035 }
2036
2037 #[test]
2038 fn l011_silent_on_early_summary() {
2039 let input = "---\ntitle: T\ntype: doc\n---\n\n::summary\nShort.\n::\n";
2040 let diags = run_rule(&SummaryNotNearTop, input);
2041 assert!(diags.is_empty());
2042 }
2043
2044 #[test]
2047 fn levenshtein_basics() {
2048 assert_eq!(levenshtein("callout", "callout"), 0);
2049 assert_eq!(levenshtein("callouts", "callout"), 1);
2050 assert_eq!(levenshtein("colout", "callout"), 2);
2051 assert_eq!(levenshtein("table", "tabs"), 2);
2052 }
2053
2054 #[test]
2055 fn l020_fires_with_suggestion() {
2056 let diags = run_rule(&UnknownBlockName, "::callouts[type=info]\nHi\n::\n");
2057 assert_eq!(codes(&diags), vec!["L020"]);
2058 assert!(
2059 diags[0].message.contains("did you mean '::callout'?"),
2060 "{}",
2061 diags[0].message
2062 );
2063 }
2064
2065 #[test]
2066 fn l020_fires_without_suggestion_when_no_close_match() {
2067 let diags = run_rule(&UnknownBlockName, "::zzqqxx\nHi\n::\n");
2068 assert_eq!(codes(&diags), vec!["L020"]);
2069 assert!(!diags[0].message.contains("did you mean"));
2070 }
2071
2072 #[test]
2073 fn l020_silent_on_known_planned_and_alias_names() {
2074 let input = "::countdown[date=2026-12-31]\n::\n::columns\n:::column\nx\n:::\n::\n::action-items\n- do it\n::\n";
2077 let diags = run_rule(&UnknownBlockName, input);
2078 assert!(diags.is_empty(), "{diags:?}");
2079 }
2080
2081 #[test]
2082 fn l020_defers_table_to_l002() {
2083 let diags = run_rule(&UnknownBlockName, "::table\n| a |\n::\n");
2084 assert!(diags.is_empty());
2085 }
2086
2087 #[test]
2090 fn l030_fires_on_plan_missing_status_and_created() {
2091 let input = "---\ntitle: T\ntype: plan\n---\nBody.\n";
2092 let diags = run_rule(&MissingRequiredFrontMatter, input);
2093 assert_eq!(codes(&diags), vec!["L030", "L030"]);
2094 assert!(diags.iter().any(|d| d.message.contains("status")));
2095 assert!(diags.iter().any(|d| d.message.contains("created")));
2096 }
2097
2098 #[test]
2099 fn l030_fires_on_guide_missing_confidence_and_updated() {
2100 let input = "---\ntitle: T\ntype: guide\n---\nBody.\n";
2101 let diags = run_rule(&MissingRequiredFrontMatter, input);
2102 assert_eq!(codes(&diags), vec!["L030", "L030"]);
2103 assert!(diags.iter().any(|d| d.message.contains("confidence")));
2104 assert!(diags.iter().any(|d| d.message.contains("updated")));
2105 }
2106
2107 #[test]
2108 fn l030_silent_on_complete_plan_and_plain_doc() {
2109 let plan =
2110 "---\ntitle: T\ntype: plan\nstatus: active\ncreated: \"2026-06-06\"\n---\nBody.\n";
2111 assert!(run_rule(&MissingRequiredFrontMatter, plan).is_empty());
2112 let doc = "---\ntitle: T\ntype: doc\n---\nBody.\n";
2114 assert!(run_rule(&MissingRequiredFrontMatter, doc).is_empty());
2115 }
2116
2117 #[test]
2120 fn l031_fires_on_case_mismatch() {
2121 let input = "---\ntitle: T\ntype: doc\nstatus: Active\n---\nBody.\n";
2122 let diags = run_rule(&FrontMatterEnumCase, input);
2123 assert_eq!(codes(&diags), vec!["L031"]);
2124 assert!(diags[0].message.contains("'Active'"));
2125 assert!(diags[0].message.contains("'active'"));
2126 assert_eq!(diags[0].span.unwrap().start_line, 4);
2127 }
2128
2129 #[test]
2130 fn l031_fires_on_quoted_case_mismatch_in_type() {
2131 let input = "---\ntitle: T\ntype: \"Plan\"\n---\nBody.\n";
2132 let diags = run_rule(&FrontMatterEnumCase, input);
2133 assert_eq!(codes(&diags), vec!["L031"]);
2134 assert!(diags[0].message.contains("'plan'"));
2135 }
2136
2137 #[test]
2138 fn l031_silent_on_valid_and_unfixable_values() {
2139 let valid = "---\ntitle: T\ntype: doc\nstatus: active\n---\n";
2141 assert!(run_rule(&FrontMatterEnumCase, valid).is_empty());
2142 let invalid = "---\ntitle: T\ntype: doc\nstatus: bogus\n---\n";
2144 assert!(run_rule(&FrontMatterEnumCase, invalid).is_empty());
2145 }
2146
2147 #[test]
2150 fn check_merges_all_three_layers_sorted() {
2151 let input = "::wibble\nHi\n::\n\n::callout[type=info]\nUnclosed";
2154 let report = check(input);
2155 let fired = codes(&report.diagnostics);
2156 assert!(fired.contains(&"P001"), "{fired:?}");
2157 assert!(fired.contains(&"V001"), "{fired:?}");
2158 assert!(fired.contains(&"L020"), "{fired:?}");
2159 let offsets: Vec<usize> = report
2161 .diagnostics
2162 .iter()
2163 .map(|d| d.span.map_or(0, |s| s.start_offset))
2164 .collect();
2165 let mut sorted = offsets.clone();
2166 sorted.sort_unstable();
2167 assert_eq!(offsets, sorted);
2168 assert_eq!(
2169 report.error_count + report.warning_count + report.info_count,
2170 report.diagnostics.len()
2171 );
2172 assert_eq!(
2174 report.fixable_count,
2175 report
2176 .diagnostics
2177 .iter()
2178 .filter(|d| d.fix.is_some())
2179 .count()
2180 );
2181 assert!(report.fixable_count >= 1, "P001 EOF fix expected");
2182 }
2183
2184 #[test]
2185 fn check_clean_doc_is_empty() {
2186 let input =
2187 "---\ntitle: T\ntype: doc\n---\n\n::summary\nShort.\n::\n\n# Heading\n\nBody text.\n";
2188 let report = check(input);
2189 assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
2190 assert_eq!(report.error_count, 0);
2191 assert_eq!(report.warning_count, 0);
2192 assert_eq!(report.info_count, 0);
2193 }
2194
2195 #[test]
2196 fn check_with_disables_rules() {
2197 let input =
2198 "---\ntitle: T\ntype: plan\nstatus: active\ncreated: \"2026-06-06\"\n---\nBody.\n";
2199 assert_eq!(codes(&check(input).diagnostics), vec!["L010"]);
2200 let cfg = LintConfig {
2201 disabled_rules: ["L010".to_string()].into_iter().collect(),
2202 ..LintConfig::default()
2203 };
2204 let report = check_with(input, &cfg);
2205 assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
2206 }
2207
2208 #[test]
2209 fn check_with_overrides_severity() {
2210 let input = "---\ntitle: T\ntype: doc\n---\nBody.\n";
2211 let cfg = LintConfig {
2212 severity_overrides: [("L010".to_string(), Severity::Error)]
2213 .into_iter()
2214 .collect(),
2215 ..LintConfig::default()
2216 };
2217 let report = check_with(input, &cfg);
2218 let l010 = report
2219 .diagnostics
2220 .iter()
2221 .find(|d| d.code.as_deref() == Some("L010"))
2222 .expect("L010 fires");
2223 assert_eq!(l010.severity, Severity::Error);
2224 assert_eq!(report.error_count, 1);
2225 }
2226
2227 #[test]
2230 fn check_suppresses_v001_v002_cascade_on_p005() {
2231 let input = "---\ntitle: T\ntype: checkpoint\n---\nBody.\n";
2235 let report = check(input);
2236 assert_eq!(codes(&report.diagnostics), vec!["P005"], "{report:?}");
2237 assert_eq!(report.error_count, 0);
2238 assert_eq!(report.warning_count, 1);
2239 }
2240
2241 #[test]
2242 fn check_suppresses_v001_v002_cascade_on_p002() {
2243 let input = "---\ntitle: T\ntype: doc\n\nBody.\n";
2245 let report = check(input);
2246 assert_eq!(codes(&report.diagnostics), vec!["P002"], "{report:?}");
2247 }
2248
2249 #[test]
2250 fn check_keeps_v001_v002_when_front_matter_truly_absent() {
2251 let report = check("# Just a heading\n\nBody.\n");
2253 let fired = codes(&report.diagnostics);
2254 assert!(fired.contains(&"V001"), "{fired:?}");
2255 assert!(fired.contains(&"V002"), "{fired:?}");
2256 }
2257
2258 #[test]
2261 fn p005_fires_for_unknown_enum_value_as_warning() {
2262 let report = check("---\ntitle: T\ntype: checkpoint\n---\nBody.\n");
2263 let p005 = report
2264 .diagnostics
2265 .iter()
2266 .find(|d| d.code.as_deref() == Some("P005"))
2267 .expect("P005 fires");
2268 assert_eq!(p005.severity, Severity::Warning);
2269 assert_eq!(
2270 p005.message,
2271 "Front matter field 'type' has unrecognized value 'checkpoint'"
2272 );
2273 assert!(
2274 report
2275 .diagnostics
2276 .iter()
2277 .all(|d| d.code.as_deref() != Some("P002")),
2278 "P002 must not fire for valid YAML with out-of-enum values"
2279 );
2280 }
2281
2282 #[test]
2283 fn p002_stays_error_for_genuinely_broken_yaml() {
2284 let report = check("---\ntitle: [unclosed\n---\nBody.\n");
2285 let p002 = report
2286 .diagnostics
2287 .iter()
2288 .find(|d| d.code.as_deref() == Some("P002"))
2289 .expect("P002 fires on broken YAML");
2290 assert_eq!(p002.severity, Severity::Error);
2291 assert!(
2292 report
2293 .diagnostics
2294 .iter()
2295 .all(|d| d.code.as_deref() != Some("P005"))
2296 );
2297 }
2298
2299 #[test]
2300 fn check_with_extra_frontmatter_values_suppresses_p005() {
2301 let input = "---\ntitle: T\ntype: checkpoint\n---\nBody.\n";
2302 let cfg = LintConfig {
2303 extra_frontmatter_values: [(
2304 "type".to_string(),
2305 ["checkpoint".to_string()].into_iter().collect(),
2306 )]
2307 .into_iter()
2308 .collect(),
2309 ..LintConfig::default()
2310 };
2311 let report = check_with(input, &cfg);
2312 assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
2314 let other = check_with("---\ntitle: T\ntype: spec\n---\nBody.\n", &cfg);
2316 assert_eq!(codes(&other.diagnostics), vec!["P005"]);
2317 }
2318
2319 #[test]
2320 fn check_report_serializes_to_json() {
2321 let report = check("---\ntitle: T\ntype: doc\n---\nBody.\n");
2322 let json = serde_json::to_string(&report).expect("CheckReport serializes");
2323 assert!(json.contains("\"diagnostics\""));
2324 assert!(json.contains("\"fixable_count\""));
2325 }
2326
2327 #[test]
2328 fn all_rules_have_unique_ids() {
2329 let rules = all_rules();
2330 let ids: BTreeSet<&str> = rules.iter().map(|r| r.id()).collect();
2331 assert_eq!(ids.len(), rules.len());
2332 }
2333
2334 fn fixed_safe(input: &str) -> String {
2339 apply_fixes(input, FixSafety::Safe).source
2340 }
2341
2342 fn fixed_suggested(input: &str) -> String {
2343 apply_fixes(input, FixSafety::Suggested).source
2344 }
2345
2346 #[test]
2347 fn fix_p001_appends_closer_at_eof() {
2348 assert_eq!(
2349 fixed_safe("::callout[type=info]\nUnclosed"),
2350 "::callout[type=info]\nUnclosed\n::\n"
2351 );
2352 assert_eq!(
2353 fixed_safe("::callout[type=info]\nUnclosed\n"),
2354 "::callout[type=info]\nUnclosed\n::\n"
2355 );
2356 }
2357
2358 #[test]
2359 fn fix_p001_nested_closers_land_inside_out() {
2360 let out = apply_fixes("::columns\n:::column\nLeft", FixSafety::Safe);
2361 assert_eq!(out.source, "::columns\n:::column\nLeft\n:::\n::\n");
2362 assert_eq!(
2363 out.iterations, 2,
2364 "one closer per pass (same-offset edits conflict)"
2365 );
2366 assert!(
2367 check(&out.source)
2368 .diagnostics
2369 .iter()
2370 .all(|d| d.code.as_deref() != Some("P001"))
2371 );
2372 }
2373
2374 #[test]
2375 fn fix_p001_skips_mid_document_orphans() {
2376 let input = "::a\n:::inner\nx\n::\n";
2380 let report = check(input);
2381 let p001 = report
2382 .diagnostics
2383 .iter()
2384 .find(|d| d.code.as_deref() == Some("P001"))
2385 .expect("orphan fires P001");
2386 assert!(
2387 p001.fix.is_none(),
2388 "mid-document orphan must not get the EOF fix"
2389 );
2390 }
2391
2392 #[test]
2393 fn fix_p002_closes_front_matter_before_first_blank_line() {
2394 let input = "---\ntitle: T\ntype: doc\n\nBody text.\n";
2395 assert_eq!(
2396 fixed_safe(input),
2397 "---\ntitle: T\ntype: doc\n---\n\nBody text.\n"
2398 );
2399 }
2400
2401 #[test]
2402 fn fix_p002_no_fix_when_captured_lines_are_not_yaml() {
2403 let input = "---\ntitle: T\nnot yaml at all ::: {\n\nBody.\n";
2404 let report = check(input);
2405 let p002 = report
2406 .diagnostics
2407 .iter()
2408 .find(|d| d.code.as_deref() == Some("P002"))
2409 .expect("P002 fires");
2410 assert!(p002.fix.is_none());
2411 assert_eq!(fixed_safe(input), input, "nothing applicable to fix");
2412 }
2413
2414 #[test]
2415 fn fix_l001_replaces_section_with_heading() {
2416 assert_eq!(
2417 fixed_safe("::section[title=\"Background\"]\nText body.\n::\n"),
2418 "## Background\nText body.\n"
2419 );
2420 }
2421
2422 #[test]
2423 fn fix_l001_honours_level_attr() {
2424 assert_eq!(
2425 fixed_safe("::section[title=\"Deep\" level=3]\nText.\n::\n"),
2426 "### Deep\nText.\n"
2427 );
2428 }
2429
2430 #[test]
2431 fn fix_l001_strips_untitled_section() {
2432 assert_eq!(fixed_safe("::section\nText.\n::\n"), "Text.\n");
2433 }
2434
2435 #[test]
2436 fn fix_l002_strips_table_wrapper() {
2437 assert_eq!(
2438 fixed_safe("::table\n| a | b |\n|---|---|\n| 1 | 2 |\n::\n"),
2439 "| a | b |\n|---|---|\n| 1 | 2 |\n"
2440 );
2441 }
2442
2443 #[test]
2444 fn fix_l003_rewrites_curly_braces_to_square() {
2445 assert_eq!(
2446 fixed_safe("::callout{type=info}\nHi\n::\n"),
2447 "::callout[type=info]\nHi\n::\n"
2448 );
2449 }
2450
2451 #[test]
2452 fn fix_l003_no_fix_for_unmatched_brace() {
2453 let input = "::callout{type=info\nHi\n::\n";
2454 let out = apply_fixes(input, FixSafety::Safe);
2455 assert_eq!(out.source, input);
2456 assert!(out.applied.is_empty());
2457 }
2458
2459 #[test]
2460 fn fix_l004_moves_opener_tail_to_own_line() {
2461 assert_eq!(
2462 fixed_safe("::callout[type=info] Watch out!\n::\n"),
2463 "::callout[type=info]\nWatch out!\n::\n"
2464 );
2465 }
2466
2467 #[test]
2468 fn fix_l004_moves_closer_tail_to_own_line() {
2469 assert_eq!(
2472 fixed_safe("::callout[type=info]\nHi\n:: 42 done\n"),
2473 "::callout[type=info]\nHi\n::\n42 done\n"
2474 );
2475 }
2476
2477 #[test]
2478 fn fix_l004_attaches_displaced_attr_block() {
2479 assert_eq!(
2483 fixed_safe("::callout [type=info]\nHi\n::\n"),
2484 "::callout[type=info]\nHi\n::\n"
2485 );
2486 }
2487
2488 #[test]
2489 fn fix_l004_attaches_attrs_and_breaks_trailing_content() {
2490 assert_eq!(
2492 fixed_safe("::callout [type=info] Watch out!\n::\n"),
2493 "::callout[type=info]\nWatch out!\n::\n"
2494 );
2495 }
2496
2497 #[test]
2498 fn fix_l004_plain_tail_still_breaks_line() {
2499 assert_eq!(
2501 fixed_safe("::callout[type=info] note [1] here\n::\n"),
2502 "::callout[type=info]\nnote [1] here\n::\n"
2503 );
2504 }
2505
2506 #[test]
2507 fn fix_l001_l004_composition_yields_heading() {
2508 assert_eq!(
2511 fixed_safe("::section Executive Summary\nBody.\n::\n"),
2512 "## Executive Summary\nBody.\n"
2513 );
2514 }
2515
2516 #[test]
2517 fn fix_l001_composes_displaced_title_attrs() {
2518 assert_eq!(
2521 fixed_safe("::section [title=\"Background\"]\nBody.\n::\n"),
2522 "## Background\nBody.\n"
2523 );
2524 }
2525
2526 #[test]
2527 fn fix_p001_l005_fence_oscillation_converges() {
2528 let input = "```\n::cta[label=x]\n```\n::\n";
2534 let out = apply_fixes(input, FixSafety::Safe);
2535 assert!(
2536 out.iterations <= 2,
2537 "fixpoint within 2 passes, got {} ({:?})",
2538 out.iterations,
2539 out.applied
2540 );
2541 assert_eq!(out.source, "```\n::cta[label=x]\n```\n");
2543 let again = apply_fixes(&out.source, FixSafety::Safe);
2545 assert_eq!(again.source, out.source);
2546 assert_eq!(again.iterations, 0);
2547 }
2548
2549 #[test]
2550 fn fix_p001_no_eof_closer_for_opener_inside_fence() {
2551 let input = "```\n::cta[label=x]\n```\n";
2554 let report = check(input);
2555 let p001 = report
2556 .diagnostics
2557 .iter()
2558 .find(|d| d.code.as_deref() == Some("P001"))
2559 .expect("P001 fires (parser is not fence-aware)");
2560 assert!(p001.fix.is_none(), "no closer fix for a fenced opener");
2561 let out = apply_fixes(input, FixSafety::Safe);
2562 assert_eq!(out.source, input);
2563 assert_eq!(out.iterations, 0);
2564 }
2565
2566 #[test]
2567 fn fix_l005_recomputes_overdeep_opener_then_closer() {
2568 let out = apply_fixes(":::column\nLeft\n:::\n", FixSafety::Safe);
2572 assert_eq!(out.source, "::column\nLeft\n::\n");
2573 assert_eq!(out.iterations, 3);
2574 }
2575
2576 #[test]
2577 fn fix_l005_removes_orphan_closer_with_nothing_open() {
2578 assert_eq!(fixed_safe("::callout\nHi\n::\n::\n"), "::callout\nHi\n::\n");
2579 }
2580
2581 #[test]
2582 fn fix_l030_derives_updated_from_created() {
2583 let input =
2584 "---\ntitle: T\ntype: guide\nconfidence: high\ncreated: \"2026-06-01\"\n---\nBody.\n";
2585 let fixed = fixed_safe(input);
2586 assert_eq!(
2587 fixed,
2588 "---\ntitle: T\ntype: guide\nconfidence: high\ncreated: \"2026-06-01\"\nupdated: \"2026-06-01\"\n---\nBody.\n"
2589 );
2590 }
2591
2592 #[test]
2593 fn fix_l030_detection_only_when_not_derivable() {
2594 let input = "---\ntitle: T\ntype: plan\n---\nBody.\n";
2596 let out = apply_fixes(input, FixSafety::Safe);
2597 assert_eq!(out.source, input);
2598 assert!(out.applied.is_empty());
2599 let report = check(input);
2600 assert!(
2601 report
2602 .diagnostics
2603 .iter()
2604 .filter(|d| d.code.as_deref() == Some("L030"))
2605 .all(|d| d.fix.is_none())
2606 );
2607 }
2608
2609 #[test]
2610 fn fix_l031_normalizes_enum_case() {
2611 assert_eq!(
2612 fixed_safe("---\ntitle: T\ntype: doc\nstatus: Active\n---\nBody.\n"),
2613 "---\ntitle: T\ntype: doc\nstatus: active\n---\nBody.\n"
2614 );
2615 }
2616
2617 #[test]
2618 fn fix_l031_strips_quotes_when_normalizing() {
2619 assert_eq!(
2620 fixed_safe(
2621 "---\ntitle: T\ntype: \"Plan\"\nstatus: active\ncreated: \"2026-06-06\"\n---\n::summary\nS.\n::\n"
2622 ),
2623 "---\ntitle: T\ntype: plan\nstatus: active\ncreated: \"2026-06-06\"\n---\n::summary\nS.\n::\n"
2624 );
2625 }
2626
2627 #[test]
2628 fn fix_l010_suggested_inserts_summary_stub_after_heading() {
2629 let input = "---\ntitle: T\ntype: doc\n---\n\n# Doc\n\nBody.\n";
2630 assert_eq!(
2631 fixed_safe(input),
2632 input,
2633 "L010 is suggested-tier — Safe must not touch it"
2634 );
2635 let fixed = fixed_suggested(input);
2636 assert_eq!(
2637 fixed,
2638 "---\ntitle: T\ntype: doc\n---\n\n# Doc\n\n::summary\nTODO: add a one-paragraph summary.\n::\n\nBody.\n"
2639 );
2640 assert!(
2641 check(&fixed)
2642 .diagnostics
2643 .iter()
2644 .all(|d| d.code.as_deref() != Some("L010"))
2645 );
2646 }
2647
2648 #[test]
2649 fn fix_l020_suggested_renames_to_unique_match() {
2650 let input = "::callouts[type=info]\nHi\n::\n";
2651 assert_eq!(fixed_safe(input), input, "L020 is suggested-tier");
2652 assert_eq!(fixed_suggested(input), "::callout[type=info]\nHi\n::\n");
2653 }
2654
2655 #[test]
2656 fn fix_l020_no_fix_without_unique_suggestion() {
2657 let report = check("::zzqqxx\nHi\n::\n");
2658 let l020 = report
2659 .diagnostics
2660 .iter()
2661 .find(|d| d.code.as_deref() == Some("L020"))
2662 .expect("L020 fires");
2663 assert!(l020.fix.is_none());
2664 }
2665
2666 #[test]
2671 fn fix_l003_splices_correctly_after_multibyte_lines() {
2672 let input = "# Café ☕ 日本語\n\n::callout{type=info}\n🎉 Bienvenue à 東京!\n::\n";
2673 assert_eq!(
2674 fixed_safe(input),
2675 "# Café ☕ 日本語\n\n::callout[type=info]\n🎉 Bienvenue à 東京!\n::\n"
2676 );
2677 }
2678
2679 #[test]
2680 fn fix_l031_with_multibyte_title_above() {
2681 let input = "---\ntitle: \"日本語のタイトル 🚀\"\ntype: doc\nstatus: Active\n---\nBody.\n";
2682 assert_eq!(
2683 fixed_safe(input),
2684 "---\ntitle: \"日本語のタイトル 🚀\"\ntype: doc\nstatus: Active\n---\nBody.\n"
2685 .replace("status: Active", "status: active")
2686 );
2687 }
2688
2689 #[test]
2690 fn fix_l001_with_emoji_title() {
2691 assert_eq!(
2692 fixed_safe("::section[title=\"🚀 Launch — 発射\"]\nText.\n::\n"),
2693 "## 🚀 Launch — 発射\nText.\n"
2694 );
2695 }
2696
2697 #[test]
2698 fn fix_l004_with_multibyte_tail() {
2699 assert_eq!(
2700 fixed_safe("::callout[type=info] ☕ café first\n::\n"),
2701 "::callout[type=info]\n☕ café first\n::\n"
2702 );
2703 }
2704
2705 #[test]
2710 fn fixable_count_counts_attached_fixes() {
2711 let report = check("::callout{type=info}\nHi\n::\n");
2712 assert_eq!(report.fixable_count, 1);
2713 let clean = check("---\ntitle: T\ntype: doc\n---\n\n::summary\nS.\n::\n");
2714 assert_eq!(clean.fixable_count, 0);
2715 }
2716
2717 #[test]
2718 fn apply_fixes_clean_input_is_untouched() {
2719 let input = "---\ntitle: T\ntype: doc\n---\n\n::summary\nShort.\n::\n\n# H\n\nBody.\n";
2720 let out = apply_fixes(input, FixSafety::Suggested);
2721 assert_eq!(out.source, input);
2722 assert_eq!(out.iterations, 0);
2723 assert!(out.applied.is_empty());
2724 assert!(out.skipped.is_empty());
2725 }
2726
2727 #[test]
2728 fn apply_fixes_normalises_crlf_like_parse() {
2729 let out = apply_fixes("::callout{type=info}\r\nHi\r\n::\r\n", FixSafety::Safe);
2730 assert_eq!(out.source, "::callout[type=info]\nHi\n::\n");
2731 }
2732
2733 #[test]
2734 fn apply_fixes_once_applies_a_single_pass() {
2735 let input = ":::column\nLeft\n:::\n";
2736 let once = apply_fixes_once(input, FixSafety::Safe);
2737 assert_eq!(once.source, "::column\nLeft\n:::\n");
2738 assert_eq!(once.iterations, 1);
2739 let mut cur = once.source;
2741 loop {
2742 let next = apply_fixes_once(&cur, FixSafety::Safe);
2743 if next.iterations == 0 {
2744 break;
2745 }
2746 cur = next.source;
2747 }
2748 assert_eq!(cur, apply_fixes(input, FixSafety::Safe).source);
2749 assert_eq!(cur, "::column\nLeft\n::\n");
2750 }
2751
2752 #[test]
2753 fn apply_fixes_reports_applied_codes() {
2754 let out = apply_fixes(
2755 "::callout{type=info}\nHi\n::\n\n::section[title=\"S\"]\nT.\n::\n",
2756 FixSafety::Safe,
2757 );
2758 let codes: BTreeSet<&str> = out.applied.iter().map(|a| a.code.as_str()).collect();
2759 assert_eq!(codes, ["L001", "L003"].into_iter().collect());
2760 }
2761
2762 #[test]
2763 fn edits_conflict_rules() {
2764 let e = |s: usize, t: usize| TextEdit {
2765 span: Span {
2766 start_line: 1,
2767 end_line: 1,
2768 start_offset: s,
2769 end_offset: t,
2770 },
2771 replacement: String::new(),
2772 };
2773 assert!(edits_conflict(&e(0, 5), &e(3, 8)), "overlapping ranges");
2774 assert!(edits_conflict(&e(4, 4), &e(4, 4)), "same-point insertions");
2775 assert!(
2776 edits_conflict(&e(2, 6), &e(2, 2)),
2777 "insertion at replace start"
2778 );
2779 assert!(
2780 !edits_conflict(&e(0, 4), &e(4, 8)),
2781 "adjacent ranges are fine"
2782 );
2783 assert!(
2784 !edits_conflict(&e(4, 4), &e(0, 4)),
2785 "insertion at range end is fine"
2786 );
2787 }
2788
2789 const JSON_FIXTURE: &str = "---\ntitle: \"T\"\ntype: report\nstatus: active\n---\n\n# Doc\n\n::section[title=\"Background\"]\nBody text.\n::\n";
2793
2794 #[test]
2795 fn report_to_json_per_file_shape() {
2796 let report = check(JSON_FIXTURE);
2797 assert!(report.fixable_count > 0, "fixture must have a fixable diag");
2798 let file = report_to_json(Some("a.surf"), &report);
2799 assert_eq!(file["path"], "a.surf");
2800 assert_eq!(file["error_count"], report.error_count);
2801 assert_eq!(file["warning_count"], report.warning_count);
2802 assert_eq!(file["info_count"], report.info_count);
2803 assert_eq!(file["fixable_count"], report.fixable_count);
2804 let diags = file["diagnostics"].as_array().expect("diagnostics array");
2805 assert_eq!(diags.len(), report.diagnostics.len());
2806 }
2807
2808 #[test]
2809 fn report_to_json_omits_path_when_none() {
2810 let report = check("# Plain doc\n");
2811 let file = report_to_json(None, &report);
2812 assert!(
2813 file.get("path").is_none(),
2814 "path key must be omitted for in-memory input"
2815 );
2816 assert!(file.get("diagnostics").is_some());
2817 assert!(file.get("error_count").is_some());
2818 }
2819
2820 #[test]
2821 fn report_to_json_carries_fix_payload_and_span() {
2822 let report = check(JSON_FIXTURE);
2823 let file = report_to_json(Some("a.surf"), &report);
2824 let diags = file["diagnostics"].as_array().unwrap();
2825 let l001 = diags
2826 .iter()
2827 .find(|d| d["code"] == "L001")
2828 .expect("L001 diagnostic present");
2829 assert_eq!(l001["severity"], "warning");
2830 let span = &l001["span"];
2831 assert!(span["start_line"].as_u64().unwrap() > 0);
2832 assert!(span.get("start_offset").is_some());
2833 assert!(span.get("end_offset").is_some());
2834 let fix = &l001["fix"];
2835 assert_eq!(fix["safety"], "safe");
2836 assert!(fix.get("description").is_some());
2837 let edits = fix["edits"].as_array().expect("fix edits array");
2838 assert!(!edits.is_empty());
2839 assert!(edits[0].get("span").is_some());
2840 assert!(edits[0].get("replacement").is_some());
2841 }
2842
2843 #[test]
2844 fn reports_to_json_envelope_shape_and_summary() {
2845 let with_warning = check(JSON_FIXTURE);
2846 let clean =
2847 check("---\ntitle: \"T\"\ntype: report\nstatus: active\n---\n\n# Doc\n\nClean body.\n");
2848 let envelope =
2849 reports_to_json(&[(Some("a.surf"), &with_warning), (Some("b.surf"), &clean)]);
2850 assert_eq!(envelope["schema_version"], JSON_SCHEMA_VERSION);
2851 let files = envelope["files"].as_array().expect("files array");
2852 assert_eq!(files.len(), 2);
2853 assert_eq!(files[0]["path"], "a.surf");
2854 assert_eq!(files[1]["path"], "b.surf");
2855 let summary = &envelope["summary"];
2856 assert_eq!(summary["files"], 2);
2857 assert_eq!(
2858 summary["error_count"],
2859 with_warning.error_count + clean.error_count
2860 );
2861 assert_eq!(
2862 summary["warning_count"],
2863 with_warning.warning_count + clean.warning_count
2864 );
2865 assert_eq!(
2866 summary["info_count"],
2867 with_warning.info_count + clean.info_count
2868 );
2869 assert_eq!(
2870 summary["fixable_count"],
2871 with_warning.fixable_count + clean.fixable_count
2872 );
2873 }
2874
2875 #[test]
2876 fn reports_to_json_empty_input() {
2877 let envelope = reports_to_json(&[]);
2878 assert_eq!(envelope["schema_version"], JSON_SCHEMA_VERSION);
2879 assert_eq!(envelope["files"].as_array().unwrap().len(), 0);
2880 assert_eq!(envelope["summary"]["files"], 0);
2881 assert_eq!(envelope["summary"]["error_count"], 0);
2882 }
2883}