Coverage for lintro / parsers / markdownlint / markdownlint_parser.py: 100%
33 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Parser for markdownlint-cli2 output."""
3import re
5from lintro.parsers.base_parser import collect_continuation_lines, strip_ansi_codes
6from lintro.parsers.markdownlint.markdownlint_issue import MarkdownlintIssue
9def _is_markdownlint_continuation(line: str) -> bool:
10 """Check if a line is a continuation of a markdownlint message.
12 Continuation lines start with whitespace (indentation) and are non-empty.
14 Args:
15 line: The line to check.
17 Returns:
18 True if the line is a continuation (starts with whitespace and non-empty).
19 """
20 return bool(line.strip()) and line[0].isspace()
23def parse_markdownlint_output(output: str) -> list[MarkdownlintIssue]:
24 """Parse markdownlint-cli2 output into a list of MarkdownlintIssue objects.
26 Markdownlint-cli2 default formatter outputs lines like:
27 file:line:column MD###/rule-name Message [Context: "..."]
28 or
29 file:line MD###/rule-name Message [Context: "..."]
31 Example outputs:
32 dir/about.md:1:1 MD021/no-multiple-space-closed-atx Multiple spaces
33 inside hashes on closed atx style heading [Context: "# About #"]
34 dir/about.md:4 MD032/blanks-around-lists Lists should be surrounded
35 by blank lines [Context: "1. List"]
36 viewme.md:3:10 MD009/no-trailing-spaces Trailing spaces
37 [Expected: 0 or 2; Actual: 1]
39 Args:
40 output: The raw output from markdownlint-cli2
42 Returns:
43 List of MarkdownlintIssue objects
44 """
45 issues: list[MarkdownlintIssue] = []
47 # Skip empty output
48 if not output.strip():
49 return issues
51 # Strip ANSI codes for consistent parsing across environments
52 output = strip_ansi_codes(output)
54 lines: list[str] = output.splitlines()
56 # Pattern for markdownlint-cli2 default formatter:
57 # file:line[:column] [error] MD###/rule-name Message [Context: "..."]
58 # Column is optional, "error" keyword is optional, and Context is optional
59 # Also handles variations like: file:line MD### Message
60 # [Expected: ...; Actual: ...]
61 pattern: re.Pattern[str] = re.compile(
62 r"^([^:]+):(\d+)(?::(\d+))?\s+(?:error\s+)?(MD\d+)(?:/[^:\s]+)?(?::\s*)?"
63 r"(.+?)(?:\s+\[(?:Context|Expected|Actual):.*?\])?$",
64 )
66 i = 0
67 while i < len(lines):
68 line = lines[i]
70 # Skip empty lines
71 if not line.strip():
72 i += 1
73 continue
75 # Skip metadata lines (version, Finding, Linting, Summary)
76 stripped_line = line.strip()
77 if (
78 stripped_line.startswith("markdownlint-cli2")
79 or stripped_line.startswith("Finding:")
80 or stripped_line.startswith("Linting:")
81 or stripped_line.startswith("Summary:")
82 ):
83 i += 1
84 continue
86 # Try to match the pattern on the current line
87 match: re.Match[str] | None = pattern.match(stripped_line)
88 if match:
89 filename: str
90 line_num: str
91 column: str | None
92 code: str
93 message: str
94 filename, line_num, column, code, message = match.groups()
96 # Collect continuation lines using the shared utility
97 continuation, next_idx = collect_continuation_lines(
98 lines,
99 i + 1,
100 _is_markdownlint_continuation,
101 )
103 # Combine main message with continuation lines
104 full_message = message.strip()
105 if continuation:
106 full_message = f"{full_message} {continuation}"
108 issues.append(
109 MarkdownlintIssue(
110 file=filename,
111 line=int(line_num),
112 column=int(column) if column else 0,
113 code=code,
114 message=full_message,
115 ),
116 )
117 i = next_idx
118 else:
119 # Line doesn't match pattern, skip it
120 i += 1
122 return issues