Skip to main content

mdbook_lint_core/
violation.rs

1//! Violation types for mdbook-lint
2//!
3//! This module contains the core types for representing linting violations.
4
5use std::ops::Range;
6
7/// A suggested fix for a violation.
8///
9/// `start` and `end` form an exact, half-open range: the character at `start`
10/// is replaced and the character at `end` is not. Equal positions represent
11/// an insertion. Line terminators are never included implicitly; a range that
12/// consumes a line terminator ends at column 1 of the following line.
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
14pub struct Fix {
15    /// Description of what the fix does
16    pub description: String,
17    /// The replacement text (None means delete)
18    pub replacement: Option<String>,
19    /// Inclusive start position of the text to replace
20    pub start: Position,
21    /// Exclusive end position of the text to replace
22    pub end: Position,
23}
24
25/// A position in a document.
26///
27/// Lines and columns are 1-based. Columns count Unicode scalar values in the
28/// line's content; they are not UTF-8 byte offsets, grapheme clusters, or
29/// display cells. A line containing `"café"` therefore ends at column 5.
30///
31/// The end-of-line column is immediately before the complete line terminator.
32/// Column 1 of the following line is immediately after it. Consequently CRLF
33/// is atomic: no valid `Position` can point between `\r` and `\n`.
34///
35/// EOF is represented by the final line's end column when the document has no
36/// trailing terminator, or by column 1 of the next line when it does.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
38pub struct Position {
39    /// Line number (1-based)
40    pub line: usize,
41    /// Column number (1-based)
42    pub column: usize,
43}
44
45impl Position {
46    /// Return column 1 of `line`.
47    pub const fn line_start(line: usize) -> Self {
48        Self { line, column: 1 }
49    }
50
51    /// Return the position immediately after `line_content` and before its
52    /// terminator, if any.
53    pub fn line_end(line: usize, line_content: &str) -> Self {
54        Self {
55            line,
56            column: line_content.chars().count() + 1,
57        }
58    }
59
60    /// Convert a zero-based UTF-8 byte offset within one line to a scalar-based
61    /// document position.
62    ///
63    /// Returns `None` if `byte_offset` is outside `line_content` or is not a
64    /// UTF-8 character boundary.
65    pub fn from_byte_offset_in_line(
66        line: usize,
67        line_content: &str,
68        byte_offset: usize,
69    ) -> Option<Self> {
70        if line == 0
71            || byte_offset > line_content.len()
72            || !line_content.is_char_boundary(byte_offset)
73        {
74            return None;
75        }
76
77        Some(Self {
78            line,
79            column: line_content[..byte_offset].chars().count() + 1,
80        })
81    }
82
83    /// Resolve this position to a zero-based UTF-8 byte offset in `content`.
84    ///
85    /// Invalid line or column numbers, including positions inside CRLF, return
86    /// `None`.
87    pub fn to_byte_offset(self, content: &str) -> Option<usize> {
88        if self.line == 0 || self.column == 0 {
89            return None;
90        }
91
92        let (line_start, line_end) = logical_line_bounds(content, self.line)?;
93        let target = self.column - 1;
94        let line_content = &content[line_start..line_end];
95
96        for (scalar_index, (byte_offset, _)) in line_content.char_indices().enumerate() {
97            if scalar_index == target {
98                return Some(line_start + byte_offset);
99            }
100        }
101
102        (line_content.chars().count() == target).then_some(line_end)
103    }
104
105    /// Convert a zero-based UTF-8 byte offset in `content` to a document
106    /// position.
107    ///
108    /// Offsets outside the document, in the middle of a UTF-8 scalar, or
109    /// between the two bytes of a CRLF terminator return `None`.
110    pub fn from_byte_offset(content: &str, byte_offset: usize) -> Option<Self> {
111        if byte_offset > content.len() || !content.is_char_boundary(byte_offset) {
112            return None;
113        }
114
115        let mut line = 1;
116        let mut line_start = 0;
117        loop {
118            let newline = content[line_start..]
119                .find('\n')
120                .map(|relative| line_start + relative);
121            let line_end = match newline {
122                Some(newline_offset)
123                    if content.as_bytes().get(newline_offset.wrapping_sub(1)) == Some(&b'\r') =>
124                {
125                    newline_offset - 1
126                }
127                Some(newline_offset) => newline_offset,
128                None => content.len(),
129            };
130
131            if byte_offset >= line_start && byte_offset <= line_end {
132                return Some(Self {
133                    line,
134                    column: content[line_start..byte_offset].chars().count() + 1,
135                });
136            }
137
138            let next_line_start = newline? + 1;
139            if byte_offset < next_line_start {
140                // The only representable gap is between '\r' and '\n'.
141                return None;
142            }
143            line += 1;
144            line_start = next_line_start;
145        }
146    }
147}
148
149impl Fix {
150    /// Construct an exact insertion at `position`.
151    pub fn insertion(
152        description: impl Into<String>,
153        replacement: impl Into<String>,
154        position: Position,
155    ) -> Self {
156        Self {
157            description: description.into(),
158            replacement: Some(replacement.into()),
159            start: position,
160            end: position,
161        }
162    }
163
164    /// Construct a replacement for one complete source line.
165    ///
166    /// `replacement` contains line content only. When `line_ending` is present,
167    /// the helper appends that exact terminator and makes the range end at
168    /// column 1 of the following line. With `None`, the range ends before EOF
169    /// and no terminator is added.
170    pub fn line_replacement(
171        description: impl Into<String>,
172        replacement: impl Into<String>,
173        line: usize,
174        original_line: &str,
175        line_ending: Option<&str>,
176    ) -> Self {
177        let mut replacement = replacement.into();
178        let end = if let Some(line_ending) = line_ending {
179            replacement.push_str(line_ending);
180            Position::line_start(line + 1)
181        } else {
182            Position::line_end(line, original_line)
183        };
184
185        Self {
186            description: description.into(),
187            replacement: Some(replacement),
188            start: Position::line_start(line),
189            end,
190        }
191    }
192
193    /// Construct a replacement spanning complete source lines.
194    ///
195    /// This is the multi-line counterpart to [`Self::line_replacement`]. The
196    /// replacement contains its internal line terminators but not the terminator
197    /// of `end_line`; that final terminator is supplied separately so its
198    /// inclusion is explicit and CRLF can be preserved.
199    pub fn line_range_replacement(
200        description: impl Into<String>,
201        replacement: impl Into<String>,
202        start_line: usize,
203        end_line: usize,
204        original_end_line: &str,
205        end_line_ending: Option<&str>,
206    ) -> Self {
207        let mut replacement = replacement.into();
208        let end = if let Some(line_ending) = end_line_ending {
209            replacement.push_str(line_ending);
210            Position::line_start(end_line + 1)
211        } else {
212            Position::line_end(end_line, original_end_line)
213        };
214
215        Self {
216            description: description.into(),
217            replacement: Some(replacement),
218            start: Position::line_start(start_line),
219            end,
220        }
221    }
222
223    /// Resolve the exact half-open fix range to UTF-8 byte offsets.
224    ///
225    /// This is the canonical conversion for library embedders. The returned
226    /// range is validated for ordering, document bounds, UTF-8 boundaries, and
227    /// CRLF atomicity.
228    pub fn byte_range(&self, content: &str) -> Option<Range<usize>> {
229        let start = self.start.to_byte_offset(content)?;
230        let end = self.end.to_byte_offset(content)?;
231        (start <= end).then_some(start..end)
232    }
233}
234
235fn logical_line_bounds(content: &str, target_line: usize) -> Option<(usize, usize)> {
236    if target_line == 0 {
237        return None;
238    }
239
240    let mut line = 1;
241    let mut line_start = 0;
242    loop {
243        let newline = content[line_start..]
244            .find('\n')
245            .map(|relative| line_start + relative);
246        let line_end = match newline {
247            Some(newline_offset)
248                if content.as_bytes().get(newline_offset.wrapping_sub(1)) == Some(&b'\r') =>
249            {
250                newline_offset - 1
251            }
252            Some(newline_offset) => newline_offset,
253            None => content.len(),
254        };
255
256        if line == target_line {
257            return Some((line_start, line_end));
258        }
259
260        line_start = newline? + 1;
261        line += 1;
262    }
263}
264
265/// A violation found during linting
266#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
267pub struct Violation {
268    /// Rule identifier (e.g., "MD001")
269    pub rule_id: String,
270    /// Human-readable rule name (e.g., "heading-increment")
271    pub rule_name: String,
272    /// Description of the violation
273    pub message: String,
274    /// Line number (1-based)
275    pub line: usize,
276    /// Column number (1-based)
277    pub column: usize,
278    /// Severity level
279    pub severity: Severity,
280    /// Optional fix for this violation
281    pub fix: Option<Fix>,
282}
283
284/// Severity levels for violations
285#[derive(
286    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
287)]
288pub enum Severity {
289    /// Informational message
290    Info,
291    /// Warning that should be addressed
292    Warning,
293    /// Error that must be fixed
294    Error,
295}
296
297impl std::fmt::Display for Severity {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        match self {
300            Severity::Info => write!(f, "info"),
301            Severity::Warning => write!(f, "warning"),
302            Severity::Error => write!(f, "error"),
303        }
304    }
305}
306
307impl std::fmt::Display for Violation {
308    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309        write!(
310            f,
311            "{}:{}:{}: {}/{}: {}",
312            self.line, self.column, self.severity, self.rule_id, self.rule_name, self.message
313        )
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_severity_display() {
323        assert_eq!(format!("{}", Severity::Info), "info");
324        assert_eq!(format!("{}", Severity::Warning), "warning");
325        assert_eq!(format!("{}", Severity::Error), "error");
326    }
327
328    #[test]
329    fn test_severity_ordering() {
330        assert!(Severity::Info < Severity::Warning);
331        assert!(Severity::Warning < Severity::Error);
332        assert!(Severity::Info < Severity::Error);
333    }
334
335    #[test]
336    fn test_violation_creation() {
337        let violation = Violation {
338            rule_id: "MD001".to_string(),
339            rule_name: "heading-increment".to_string(),
340            message: "Heading levels should only increment by one level at a time".to_string(),
341            line: 5,
342            column: 1,
343            severity: Severity::Warning,
344            fix: None,
345        };
346
347        assert_eq!(violation.rule_id, "MD001");
348        assert_eq!(violation.rule_name, "heading-increment");
349        assert_eq!(violation.line, 5);
350        assert_eq!(violation.column, 1);
351        assert_eq!(violation.severity, Severity::Warning);
352        assert_eq!(violation.fix, None);
353    }
354
355    #[test]
356    fn test_violation_display() {
357        let violation = Violation {
358            rule_id: "MD013".to_string(),
359            rule_name: "line-length".to_string(),
360            message: "Line too long".to_string(),
361            line: 10,
362            column: 81,
363            severity: Severity::Error,
364            fix: None,
365        };
366
367        let expected = "10:81:error: MD013/line-length: Line too long";
368        assert_eq!(format!("{violation}"), expected);
369    }
370
371    #[test]
372    fn test_violation_equality() {
373        let violation1 = Violation {
374            rule_id: "MD001".to_string(),
375            rule_name: "heading-increment".to_string(),
376            message: "Test message".to_string(),
377            line: 1,
378            column: 1,
379            severity: Severity::Warning,
380            fix: None,
381        };
382
383        let violation2 = Violation {
384            rule_id: "MD001".to_string(),
385            rule_name: "heading-increment".to_string(),
386            message: "Test message".to_string(),
387            line: 1,
388            column: 1,
389            severity: Severity::Warning,
390            fix: None,
391        };
392
393        let violation3 = Violation {
394            rule_id: "MD002".to_string(),
395            rule_name: "first-heading-h1".to_string(),
396            message: "Different message".to_string(),
397            line: 2,
398            column: 1,
399            severity: Severity::Error,
400            fix: None,
401        };
402
403        assert_eq!(violation1, violation2);
404        assert_ne!(violation1, violation3);
405    }
406
407    #[test]
408    fn test_violation_clone() {
409        let original = Violation {
410            rule_id: "MD040".to_string(),
411            rule_name: "fenced-code-language".to_string(),
412            message: "Fenced code blocks should have a language specified".to_string(),
413            line: 15,
414            column: 3,
415            severity: Severity::Info,
416            fix: None,
417        };
418
419        let cloned = original.clone();
420        assert_eq!(original, cloned);
421    }
422
423    #[test]
424    fn test_violation_debug() {
425        let violation = Violation {
426            rule_id: "MD025".to_string(),
427            rule_name: "single-h1".to_string(),
428            message: "Multiple top level headings in the same document".to_string(),
429            line: 20,
430            column: 1,
431            severity: Severity::Warning,
432            fix: None,
433        };
434
435        let debug_str = format!("{violation:?}");
436        assert!(debug_str.contains("MD025"));
437        assert!(debug_str.contains("single-h1"));
438        assert!(debug_str.contains("Multiple top level headings"));
439        assert!(debug_str.contains("line: 20"));
440        assert!(debug_str.contains("column: 1"));
441        assert!(debug_str.contains("Warning"));
442    }
443
444    #[test]
445    fn test_all_severity_variants() {
446        let severities = [Severity::Info, Severity::Warning, Severity::Error];
447
448        for severity in &severities {
449            let violation = Violation {
450                rule_id: "TEST".to_string(),
451                rule_name: "test-rule".to_string(),
452                message: "Test message".to_string(),
453                line: 1,
454                column: 1,
455                severity: *severity,
456                fix: None,
457            };
458
459            // Test that display format includes severity
460            let display_str = format!("{violation}");
461            assert!(display_str.contains(&format!("{severity}")));
462        }
463    }
464
465    #[test]
466    fn test_violation_with_fix() {
467        let fix = Fix {
468            description: "Replace tab with spaces".to_string(),
469            replacement: Some("    ".to_string()),
470            start: Position {
471                line: 5,
472                column: 10,
473            },
474            end: Position {
475                line: 5,
476                column: 11,
477            },
478        };
479
480        let violation = Violation {
481            rule_id: "MD010".to_string(),
482            rule_name: "no-hard-tabs".to_string(),
483            message: "Hard tab found".to_string(),
484            line: 5,
485            column: 10,
486            severity: Severity::Warning,
487            fix: Some(fix.clone()),
488        };
489
490        assert_eq!(violation.fix, Some(fix));
491        assert!(violation.fix.is_some());
492
493        let fix_ref = violation.fix.as_ref().unwrap();
494        assert_eq!(fix_ref.description, "Replace tab with spaces");
495        assert_eq!(fix_ref.replacement, Some("    ".to_string()));
496        assert_eq!(fix_ref.start.line, 5);
497        assert_eq!(fix_ref.start.column, 10);
498        assert_eq!(fix_ref.end.line, 5);
499        assert_eq!(fix_ref.end.column, 11);
500    }
501
502    #[test]
503    fn test_fix_delete_operation() {
504        let fix = Fix {
505            description: "Remove extra newlines".to_string(),
506            replacement: None, // None means delete
507            start: Position {
508                line: 10,
509                column: 1,
510            },
511            end: Position {
512                line: 12,
513                column: 1,
514            },
515        };
516
517        assert_eq!(fix.replacement, None);
518        assert_eq!(fix.description, "Remove extra newlines");
519    }
520
521    #[test]
522    fn position_columns_count_unicode_scalars() {
523        let content = "é🙂x\nβ";
524
525        assert_eq!(
526            Position { line: 1, column: 1 }.to_byte_offset(content),
527            Some(0)
528        );
529        assert_eq!(
530            Position { line: 1, column: 2 }.to_byte_offset(content),
531            Some(2)
532        );
533        assert_eq!(
534            Position { line: 1, column: 3 }.to_byte_offset(content),
535            Some(6)
536        );
537        assert_eq!(
538            Position { line: 1, column: 4 }.to_byte_offset(content),
539            Some(7)
540        );
541        assert_eq!(
542            Position { line: 2, column: 1 }.to_byte_offset(content),
543            Some(8)
544        );
545        assert_eq!(
546            Position { line: 2, column: 2 }.to_byte_offset(content),
547            Some(10)
548        );
549
550        assert_eq!(
551            Position::from_byte_offset(content, 6),
552            Some(Position { line: 1, column: 3 })
553        );
554        assert_eq!(
555            Position::from_byte_offset(content, 8),
556            Some(Position { line: 2, column: 1 })
557        );
558        assert_eq!(Position::from_byte_offset(content, 1), None);
559    }
560
561    #[test]
562    fn positions_keep_crlf_atomic() {
563        let content = "a\r\né";
564
565        assert_eq!(Position::line_end(1, "a").to_byte_offset(content), Some(1));
566        assert_eq!(Position::line_start(2).to_byte_offset(content), Some(3));
567        assert_eq!(
568            Position::from_byte_offset(content, 1),
569            Some(Position { line: 1, column: 2 })
570        );
571        assert_eq!(Position::from_byte_offset(content, 2), None);
572        assert_eq!(
573            Position::from_byte_offset(content, 3),
574            Some(Position { line: 2, column: 1 })
575        );
576    }
577
578    #[test]
579    fn fix_byte_ranges_are_exact_and_half_open() {
580        let content = "é🙂x\n";
581        let fix = Fix {
582            description: "replace emoji".to_string(),
583            replacement: Some("!".to_string()),
584            start: Position { line: 1, column: 2 },
585            end: Position { line: 1, column: 3 },
586        };
587
588        assert_eq!(fix.byte_range(content), Some(2..6));
589
590        let insertion = Fix::insertion("insert", "!", Position { line: 1, column: 2 });
591        assert_eq!(insertion.byte_range(content), Some(2..2));
592    }
593
594    #[test]
595    fn line_replacement_has_explicit_terminator_intent() {
596        let lf = Fix::line_replacement("replace", "new", 1, "old", Some("\n"));
597        assert_eq!(lf.replacement.as_deref(), Some("new\n"));
598        assert_eq!(lf.start, Position::line_start(1));
599        assert_eq!(lf.end, Position::line_start(2));
600        assert_eq!(lf.byte_range("old\nnext"), Some(0..4));
601
602        let crlf = Fix::line_replacement("replace", "new", 1, "old", Some("\r\n"));
603        assert_eq!(crlf.replacement.as_deref(), Some("new\r\n"));
604        assert_eq!(crlf.end, Position::line_start(2));
605        assert_eq!(crlf.byte_range("old\r\nnext"), Some(0..5));
606
607        let eof = Fix::line_replacement("replace", "new", 1, "old", None);
608        assert_eq!(eof.replacement.as_deref(), Some("new"));
609        assert_eq!(eof.end, Position::line_end(1, "old"));
610        assert_eq!(eof.byte_range("old"), Some(0..3));
611    }
612
613    #[test]
614    fn eof_positions_cover_empty_and_terminated_documents() {
615        assert_eq!(Position::line_start(1).to_byte_offset(""), Some(0));
616        assert_eq!(
617            Position::from_byte_offset("", 0),
618            Some(Position::line_start(1))
619        );
620        assert_eq!(Position::line_start(2).to_byte_offset("x\n"), Some(2));
621        assert_eq!(
622            Position::from_byte_offset("x\n", 2),
623            Some(Position::line_start(2))
624        );
625        assert_eq!(Position::line_start(2).to_byte_offset("x"), None);
626    }
627}