1use std::ops::Range;
6
7#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
14pub struct Fix {
15 pub description: String,
17 pub replacement: Option<String>,
19 pub start: Position,
21 pub end: Position,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
38pub struct Position {
39 pub line: usize,
41 pub column: usize,
43}
44
45impl Position {
46 pub const fn line_start(line: usize) -> Self {
48 Self { line, column: 1 }
49 }
50
51 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 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 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 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 return None;
142 }
143 line += 1;
144 line_start = next_line_start;
145 }
146 }
147}
148
149impl Fix {
150 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
267pub struct Violation {
268 pub rule_id: String,
270 pub rule_name: String,
272 pub message: String,
274 pub line: usize,
276 pub column: usize,
278 pub severity: Severity,
280 pub fix: Option<Fix>,
282}
283
284#[derive(
286 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
287)]
288pub enum Severity {
289 Info,
291 Warning,
293 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 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, 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}