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

1"""Parser for markdownlint-cli2 output.""" 

2 

3import re 

4 

5from lintro.parsers.base_parser import collect_continuation_lines, strip_ansi_codes 

6from lintro.parsers.markdownlint.markdownlint_issue import MarkdownlintIssue 

7 

8 

9def _is_markdownlint_continuation(line: str) -> bool: 

10 """Check if a line is a continuation of a markdownlint message. 

11 

12 Continuation lines start with whitespace (indentation) and are non-empty. 

13 

14 Args: 

15 line: The line to check. 

16 

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() 

21 

22 

23def parse_markdownlint_output(output: str) -> list[MarkdownlintIssue]: 

24 """Parse markdownlint-cli2 output into a list of MarkdownlintIssue objects. 

25 

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: "..."] 

30 

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] 

38 

39 Args: 

40 output: The raw output from markdownlint-cli2 

41 

42 Returns: 

43 List of MarkdownlintIssue objects 

44 """ 

45 issues: list[MarkdownlintIssue] = [] 

46 

47 # Skip empty output 

48 if not output.strip(): 

49 return issues 

50 

51 # Strip ANSI codes for consistent parsing across environments 

52 output = strip_ansi_codes(output) 

53 

54 lines: list[str] = output.splitlines() 

55 

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 ) 

65 

66 i = 0 

67 while i < len(lines): 

68 line = lines[i] 

69 

70 # Skip empty lines 

71 if not line.strip(): 

72 i += 1 

73 continue 

74 

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 

85 

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() 

95 

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 ) 

102 

103 # Combine main message with continuation lines 

104 full_message = message.strip() 

105 if continuation: 

106 full_message = f"{full_message} {continuation}" 

107 

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 

121 

122 return issues