1use crate::lint_context::{LineInfo, LintContext};
36
37#[derive(Debug, Clone)]
39pub struct FilteredLine<'a> {
40 pub line_num: usize,
42 pub line_info: &'a LineInfo,
44 pub content: &'a str,
46}
47
48#[derive(Debug, Clone, Default)]
64pub struct LineFilterConfig {
65 pub skip_front_matter: bool,
67 pub skip_code_blocks: bool,
69 pub skip_html_blocks: bool,
71 pub skip_html_comments: bool,
73 pub skip_mkdocstrings: bool,
75 pub skip_esm_blocks: bool,
77}
78
79impl LineFilterConfig {
80 #[must_use]
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 #[must_use]
91 pub fn skip_front_matter(mut self) -> Self {
92 self.skip_front_matter = true;
93 self
94 }
95
96 #[must_use]
101 pub fn skip_code_blocks(mut self) -> Self {
102 self.skip_code_blocks = true;
103 self
104 }
105
106 #[must_use]
111 pub fn skip_html_blocks(mut self) -> Self {
112 self.skip_html_blocks = true;
113 self
114 }
115
116 #[must_use]
121 pub fn skip_html_comments(mut self) -> Self {
122 self.skip_html_comments = true;
123 self
124 }
125
126 #[must_use]
131 pub fn skip_mkdocstrings(mut self) -> Self {
132 self.skip_mkdocstrings = true;
133 self
134 }
135
136 #[must_use]
141 pub fn skip_esm_blocks(mut self) -> Self {
142 self.skip_esm_blocks = true;
143 self
144 }
145
146 fn should_filter(&self, line_info: &LineInfo) -> bool {
148 (self.skip_front_matter && line_info.in_front_matter)
149 || (self.skip_code_blocks && line_info.in_code_block)
150 || (self.skip_html_blocks && line_info.in_html_block)
151 || (self.skip_html_comments && line_info.in_html_comment)
152 || (self.skip_mkdocstrings && line_info.in_mkdocstrings)
153 || (self.skip_esm_blocks && line_info.in_esm_block)
154 }
155}
156
157pub struct FilteredLinesIter<'a> {
159 ctx: &'a LintContext<'a>,
160 config: LineFilterConfig,
161 current_index: usize,
162 content_lines: Vec<&'a str>,
163}
164
165impl<'a> FilteredLinesIter<'a> {
166 fn new(ctx: &'a LintContext<'a>, config: LineFilterConfig) -> Self {
168 Self {
169 ctx,
170 config,
171 current_index: 0,
172 content_lines: ctx.content.lines().collect(),
173 }
174 }
175}
176
177impl<'a> Iterator for FilteredLinesIter<'a> {
178 type Item = FilteredLine<'a>;
179
180 fn next(&mut self) -> Option<Self::Item> {
181 let lines = &self.ctx.lines;
182
183 while self.current_index < lines.len() {
184 let idx = self.current_index;
185 self.current_index += 1;
186
187 if self.config.should_filter(&lines[idx]) {
189 continue;
190 }
191
192 let line_content = self.content_lines.get(idx).copied().unwrap_or("");
194
195 return Some(FilteredLine {
197 line_num: idx + 1, line_info: &lines[idx],
199 content: line_content,
200 });
201 }
202
203 None
204 }
205}
206
207pub trait FilteredLinesExt {
212 fn filtered_lines(&self) -> FilteredLinesBuilder<'_>;
231
232 fn content_lines(&self) -> FilteredLinesIter<'_>;
255}
256
257pub struct FilteredLinesBuilder<'a> {
259 ctx: &'a LintContext<'a>,
260 config: LineFilterConfig,
261}
262
263impl<'a> FilteredLinesBuilder<'a> {
264 fn new(ctx: &'a LintContext<'a>) -> Self {
265 Self {
266 ctx,
267 config: LineFilterConfig::new(),
268 }
269 }
270
271 #[must_use]
273 pub fn skip_front_matter(mut self) -> Self {
274 self.config = self.config.skip_front_matter();
275 self
276 }
277
278 #[must_use]
280 pub fn skip_code_blocks(mut self) -> Self {
281 self.config = self.config.skip_code_blocks();
282 self
283 }
284
285 #[must_use]
287 pub fn skip_html_blocks(mut self) -> Self {
288 self.config = self.config.skip_html_blocks();
289 self
290 }
291
292 #[must_use]
294 pub fn skip_html_comments(mut self) -> Self {
295 self.config = self.config.skip_html_comments();
296 self
297 }
298
299 #[must_use]
301 pub fn skip_mkdocstrings(mut self) -> Self {
302 self.config = self.config.skip_mkdocstrings();
303 self
304 }
305
306 #[must_use]
308 pub fn skip_esm_blocks(mut self) -> Self {
309 self.config = self.config.skip_esm_blocks();
310 self
311 }
312}
313
314impl<'a> IntoIterator for FilteredLinesBuilder<'a> {
315 type Item = FilteredLine<'a>;
316 type IntoIter = FilteredLinesIter<'a>;
317
318 fn into_iter(self) -> Self::IntoIter {
319 FilteredLinesIter::new(self.ctx, self.config)
320 }
321}
322
323impl<'a> FilteredLinesExt for LintContext<'a> {
324 fn filtered_lines(&self) -> FilteredLinesBuilder<'_> {
325 FilteredLinesBuilder::new(self)
326 }
327
328 fn content_lines(&self) -> FilteredLinesIter<'_> {
329 FilteredLinesIter::new(self, LineFilterConfig::new().skip_front_matter())
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::config::MarkdownFlavor;
337
338 #[test]
339 fn test_filtered_line_structure() {
340 let content = "# Title\n\nContent";
341 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
342
343 let line = ctx.content_lines().next().unwrap();
344 assert_eq!(line.line_num, 1);
345 assert_eq!(line.content, "# Title");
346 assert!(!line.line_info.in_front_matter);
347 }
348
349 #[test]
350 fn test_skip_front_matter_yaml() {
351 let content = "---\ntitle: Test\nurl: http://example.com\n---\n\n# Content\n\nMore content";
352 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
353
354 let lines: Vec<_> = ctx.content_lines().collect();
355 assert_eq!(lines.len(), 4);
357 assert_eq!(lines[0].line_num, 5); assert_eq!(lines[0].content, "");
359 assert_eq!(lines[1].line_num, 6);
360 assert_eq!(lines[1].content, "# Content");
361 assert_eq!(lines[2].line_num, 7);
362 assert_eq!(lines[2].content, "");
363 assert_eq!(lines[3].line_num, 8);
364 assert_eq!(lines[3].content, "More content");
365 }
366
367 #[test]
368 fn test_skip_front_matter_toml() {
369 let content = "+++\ntitle = \"Test\"\nurl = \"http://example.com\"\n+++\n\n# Content";
370 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
371
372 let lines: Vec<_> = ctx.content_lines().collect();
373 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
375 assert_eq!(lines[1].line_num, 6);
376 assert_eq!(lines[1].content, "# Content");
377 }
378
379 #[test]
380 fn test_skip_front_matter_json() {
381 let content = "{\n\"title\": \"Test\",\n\"url\": \"http://example.com\"\n}\n\n# Content";
382 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
383
384 let lines: Vec<_> = ctx.content_lines().collect();
385 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
387 assert_eq!(lines[1].line_num, 6);
388 assert_eq!(lines[1].content, "# Content");
389 }
390
391 #[test]
392 fn test_skip_code_blocks() {
393 let content = "# Title\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nContent";
394 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
395
396 let lines: Vec<_> = ctx.filtered_lines().skip_code_blocks().into_iter().collect();
397
398 assert!(lines.iter().any(|l| l.content == "# Title"));
403 assert!(lines.iter().any(|l| l.content == "Content"));
404 assert!(!lines.iter().any(|l| l.content == "let x = 1;"));
406 assert!(!lines.iter().any(|l| l.content == "let y = 2;"));
407 }
408
409 #[test]
410 fn test_no_filters() {
411 let content = "---\ntitle: Test\n---\n\n# Content";
412 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
413
414 let lines: Vec<_> = ctx.filtered_lines().into_iter().collect();
416 assert_eq!(lines.len(), ctx.lines.len());
417 }
418
419 #[test]
420 fn test_multiple_filters() {
421 let content = "---\ntitle: Test\n---\n\n# Title\n\n```rust\ncode\n```\n\nContent";
422 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
423
424 let lines: Vec<_> = ctx
425 .filtered_lines()
426 .skip_front_matter()
427 .skip_code_blocks()
428 .into_iter()
429 .collect();
430
431 assert!(lines.iter().any(|l| l.content == "# Title"));
433 assert!(lines.iter().any(|l| l.content == "Content"));
434 assert!(!lines.iter().any(|l| l.content == "title: Test"));
435 assert!(!lines.iter().any(|l| l.content == "code"));
436 }
437
438 #[test]
439 fn test_line_numbering_is_1_indexed() {
440 let content = "First\nSecond\nThird";
441 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
442
443 let lines: Vec<_> = ctx.content_lines().collect();
444 assert_eq!(lines[0].line_num, 1);
445 assert_eq!(lines[0].content, "First");
446 assert_eq!(lines[1].line_num, 2);
447 assert_eq!(lines[1].content, "Second");
448 assert_eq!(lines[2].line_num, 3);
449 assert_eq!(lines[2].content, "Third");
450 }
451
452 #[test]
453 fn test_content_lines_convenience_method() {
454 let content = "---\nfoo: bar\n---\n\nContent";
455 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
456
457 let lines: Vec<_> = ctx.content_lines().collect();
459 assert!(!lines.iter().any(|l| l.content.contains("foo")));
460 assert!(lines.iter().any(|l| l.content == "Content"));
461 }
462
463 #[test]
464 fn test_empty_document() {
465 let content = "";
466 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
467
468 let lines: Vec<_> = ctx.content_lines().collect();
469 assert_eq!(lines.len(), 0);
470 }
471
472 #[test]
473 fn test_only_front_matter() {
474 let content = "---\ntitle: Test\n---";
475 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
476
477 let lines: Vec<_> = ctx.content_lines().collect();
478 assert_eq!(
479 lines.len(),
480 0,
481 "Document with only front matter should have no content lines"
482 );
483 }
484
485 #[test]
486 fn test_builder_pattern_ergonomics() {
487 let content = "# Title\n\n```\ncode\n```\n\nContent";
488 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
489
490 let _lines: Vec<_> = ctx
492 .filtered_lines()
493 .skip_front_matter()
494 .skip_code_blocks()
495 .skip_html_blocks()
496 .into_iter()
497 .collect();
498
499 }
501
502 #[test]
503 fn test_filtered_line_access_to_line_info() {
504 let content = "# Title\n\nContent";
505 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
506
507 for line in ctx.content_lines() {
508 assert!(!line.line_info.in_front_matter);
510 assert!(!line.line_info.in_code_block);
511 }
512 }
513
514 #[test]
515 fn test_skip_mkdocstrings() {
516 let content = r#"# API Documentation
517
518::: mymodule.MyClass
519 options:
520 show_root_heading: true
521 show_source: false
522
523Some regular content here.
524
525::: mymodule.function
526 options:
527 show_signature: true
528
529More content."#;
530 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
531 let lines: Vec<_> = ctx.filtered_lines().skip_mkdocstrings().into_iter().collect();
532
533 assert!(
535 lines.iter().any(|l| l.content.contains("# API Documentation")),
536 "Should include lines outside mkdocstrings blocks"
537 );
538 assert!(
539 lines.iter().any(|l| l.content.contains("Some regular content")),
540 "Should include content between mkdocstrings blocks"
541 );
542 assert!(
543 lines.iter().any(|l| l.content.contains("More content")),
544 "Should include content after mkdocstrings blocks"
545 );
546
547 assert!(
549 !lines.iter().any(|l| l.content.contains("::: mymodule")),
550 "Should exclude mkdocstrings marker lines"
551 );
552 assert!(
553 !lines.iter().any(|l| l.content.contains("show_root_heading")),
554 "Should exclude mkdocstrings option lines"
555 );
556 assert!(
557 !lines.iter().any(|l| l.content.contains("show_signature")),
558 "Should exclude all mkdocstrings option lines"
559 );
560
561 assert_eq!(lines[0].line_num, 1, "First line should be line 1");
563 }
564
565 #[test]
566 fn test_skip_esm_blocks() {
567 let content = r#"import {Chart} from './components.js'
568import {Table} from './table.js'
569export const year = 2023
570
571# Last year's snowfall
572
573Content about snowfall data.
574
575import {Footer} from './footer.js'
576
577More content."#;
578 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
579 let lines: Vec<_> = ctx.filtered_lines().skip_esm_blocks().into_iter().collect();
580
581 assert!(
583 lines.iter().any(|l| l.content.contains("# Last year's snowfall")),
584 "Should include markdown headings"
585 );
586 assert!(
587 lines.iter().any(|l| l.content.contains("Content about snowfall")),
588 "Should include markdown content"
589 );
590 assert!(
591 lines.iter().any(|l| l.content.contains("More content")),
592 "Should include content after ESM blocks"
593 );
594
595 assert!(
597 !lines.iter().any(|l| l.content.contains("import {Chart}")),
598 "Should exclude import statements at top of file"
599 );
600 assert!(
601 !lines.iter().any(|l| l.content.contains("import {Table}")),
602 "Should exclude all import statements at top of file"
603 );
604 assert!(
605 !lines.iter().any(|l| l.content.contains("export const year")),
606 "Should exclude export statements at top of file"
607 );
608 assert!(
610 lines.iter().any(|l| l.content.contains("import {Footer}")),
611 "Should include import statements after markdown content (not in ESM block)"
612 );
613
614 let heading_line = lines
616 .iter()
617 .find(|l| l.content.contains("# Last year's snowfall"))
618 .unwrap();
619 assert_eq!(heading_line.line_num, 5, "Heading should be on line 5");
620 }
621
622 #[test]
623 fn test_all_filters_combined() {
624 let content = r#"---
625title: Test
626---
627
628# Title
629
630```
631code
632```
633
634<!-- HTML comment here -->
635
636::: mymodule.Class
637 options:
638 show_root_heading: true
639
640<div>
641HTML block
642</div>
643
644Content"#;
645 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
646
647 let lines: Vec<_> = ctx
648 .filtered_lines()
649 .skip_front_matter()
650 .skip_code_blocks()
651 .skip_html_blocks()
652 .skip_html_comments()
653 .skip_mkdocstrings()
654 .into_iter()
655 .collect();
656
657 assert!(
659 lines.iter().any(|l| l.content == "# Title"),
660 "Should include markdown headings"
661 );
662 assert!(
663 lines.iter().any(|l| l.content == "Content"),
664 "Should include markdown content"
665 );
666
667 assert!(
669 !lines.iter().any(|l| l.content == "title: Test"),
670 "Should exclude front matter"
671 );
672 assert!(
673 !lines.iter().any(|l| l.content == "code"),
674 "Should exclude code block content"
675 );
676 assert!(
677 !lines.iter().any(|l| l.content.contains("HTML comment")),
678 "Should exclude HTML comments"
679 );
680 assert!(
681 !lines.iter().any(|l| l.content.contains("::: mymodule")),
682 "Should exclude mkdocstrings blocks"
683 );
684 assert!(
685 !lines.iter().any(|l| l.content.contains("show_root_heading")),
686 "Should exclude mkdocstrings options"
687 );
688 assert!(
689 !lines.iter().any(|l| l.content.contains("HTML block")),
690 "Should exclude HTML blocks"
691 );
692 }
693}