Coverage for lintro / tools / definitions / markdownlint.py: 59%

113 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Markdownlint tool definition. 

2 

3Markdownlint-cli2 is a linter for Markdown files that checks for style 

4issues and best practices. It helps maintain consistent formatting 

5across documentation. 

6""" 

7 

8from __future__ import annotations 

9 

10import json 

11import os 

12import shutil 

13import subprocess # nosec B404 - used safely with shell disabled 

14import tempfile 

15from dataclasses import dataclass 

16from typing import Any 

17 

18from loguru import logger 

19 

20from lintro._tool_versions import get_min_version 

21from lintro.enums.doc_url_template import DocUrlTemplate 

22from lintro.enums.tool_name import ToolName 

23from lintro.enums.tool_type import ToolType 

24from lintro.models.core.tool_result import ToolResult 

25from lintro.parsers.markdownlint.markdownlint_parser import parse_markdownlint_output 

26from lintro.plugins.base import BaseToolPlugin 

27from lintro.plugins.protocol import ToolDefinition 

28from lintro.plugins.registry import register_tool 

29from lintro.tools.core.option_validators import validate_positive_int 

30from lintro.tools.core.timeout_utils import create_timeout_result 

31from lintro.utils.config import get_central_line_length 

32from lintro.utils.unified_config import DEFAULT_TOOL_PRIORITIES 

33 

34# Constants for Markdownlint configuration 

35MARKDOWNLINT_DEFAULT_TIMEOUT: int = 30 

36MARKDOWNLINT_DEFAULT_PRIORITY: int = DEFAULT_TOOL_PRIORITIES.get("markdownlint", 30) 

37MARKDOWNLINT_FILE_PATTERNS: list[str] = ["*.md", "*.markdown"] 

38 

39 

40@register_tool 

41@dataclass 

42class MarkdownlintPlugin(BaseToolPlugin): 

43 """Markdownlint Markdown linter plugin. 

44 

45 This plugin integrates markdownlint-cli2 with Lintro for checking 

46 Markdown files for style and formatting issues. 

47 """ 

48 

49 @property 

50 def definition(self) -> ToolDefinition: 

51 """Return the tool definition. 

52 

53 Returns: 

54 ToolDefinition containing tool metadata. 

55 """ 

56 return ToolDefinition( 

57 name="markdownlint", 

58 description=("Markdown linter for style checking and best practices"), 

59 can_fix=False, 

60 tool_type=ToolType.LINTER, 

61 file_patterns=MARKDOWNLINT_FILE_PATTERNS, 

62 priority=MARKDOWNLINT_DEFAULT_PRIORITY, 

63 conflicts_with=[], 

64 native_configs=[ 

65 ".markdownlint.json", 

66 ".markdownlint.yaml", 

67 ".markdownlint.yml", 

68 ".markdownlint-cli2.jsonc", 

69 ".markdownlint-cli2.yaml", 

70 ], 

71 version_command=["markdownlint-cli2", "--help"], 

72 min_version=get_min_version(ToolName.MARKDOWNLINT), 

73 default_options={ 

74 "timeout": MARKDOWNLINT_DEFAULT_TIMEOUT, 

75 "line_length": None, 

76 }, 

77 default_timeout=MARKDOWNLINT_DEFAULT_TIMEOUT, 

78 ) 

79 

80 def _verify_tool_version(self) -> ToolResult | None: 

81 """Verify that markdownlint-cli2 meets minimum version requirements. 

82 

83 Overrides base implementation to use the correct executable name. 

84 

85 Returns: 

86 Optional[ToolResult]: None if version check passes, or a skip result 

87 if it fails. 

88 """ 

89 from lintro.tools.core.version_requirements import check_tool_version 

90 

91 # Use the correct command for markdownlint-cli2 

92 command = self._get_markdownlint_command() 

93 version_info = check_tool_version(self.definition.name, command) 

94 

95 if version_info.version_check_passed: 

96 return None # Version check passed 

97 

98 # Version check failed - return skip result with warning 

99 skip_message = ( 

100 f"Skipping {self.definition.name}: {version_info.error_message}. " 

101 f"Minimum required: {version_info.min_version}. " 

102 f"{version_info.install_hint}" 

103 ) 

104 

105 return ToolResult( 

106 name=self.definition.name, 

107 success=True, # Not an error, just skipping 

108 output=skip_message, 

109 issues_count=0, 

110 ) 

111 

112 def set_options( 

113 self, 

114 timeout: int | None = None, 

115 line_length: int | None = None, 

116 **kwargs: Any, 

117 ) -> None: 

118 """Set Markdownlint-specific options. 

119 

120 Args: 

121 timeout: Timeout in seconds (default: 30). 

122 line_length: Line length for MD013 rule. If not provided, uses 

123 central line_length from [tool.lintro] or falls back to Ruff's 

124 line-length setting. 

125 **kwargs: Other tool options. 

126 """ 

127 validate_positive_int(timeout, "timeout") 

128 

129 set_kwargs = dict(kwargs) 

130 if timeout is not None: 

131 set_kwargs["timeout"] = timeout 

132 

133 # Use provided line_length, or get from central config 

134 if line_length is None: 

135 line_length = get_central_line_length() 

136 

137 validate_positive_int(line_length, "line_length") 

138 if line_length is not None: 

139 set_kwargs["line_length"] = line_length 

140 

141 super().set_options(**set_kwargs) 

142 

143 def _get_markdownlint_command(self) -> list[str]: 

144 """Get the command to run markdownlint-cli2. 

145 

146 Returns: 

147 Command arguments for markdownlint-cli2. 

148 """ 

149 # Prefer direct executable if available (works better in Docker) 

150 if shutil.which("markdownlint-cli2"): 

151 return ["markdownlint-cli2"] 

152 # Fallback to bunx if direct executable not found 

153 if shutil.which("bunx"): 

154 return ["bunx", "markdownlint-cli2"] 

155 # Last resort - hope markdownlint-cli2 is in PATH 

156 return ["markdownlint-cli2"] 

157 

158 def _create_temp_markdownlint_config( 

159 self, 

160 line_length: int, 

161 ) -> str | None: 

162 """Create a temporary markdownlint-cli2 config with the specified line length. 

163 

164 Creates a temp file with MD013 rule configured. This avoids modifying 

165 the user's project files. 

166 

167 Args: 

168 line_length: Line length to configure for MD013 rule. 

169 

170 Returns: 

171 Path to the temporary config file, or None if creation failed. 

172 """ 

173 config_wrapper: dict[str, object] = { 

174 "config": { 

175 "MD013": { 

176 "line_length": line_length, 

177 "code_blocks": False, 

178 "tables": False, 

179 }, 

180 }, 

181 } 

182 

183 try: 

184 # Create a temp file that persists until explicitly deleted 

185 with tempfile.NamedTemporaryFile( 

186 mode="w", 

187 suffix=".markdownlint-cli2.jsonc", 

188 prefix="lintro-", 

189 delete=False, 

190 encoding="utf-8", 

191 ) as f: 

192 json.dump(config_wrapper, f, indent=2) 

193 temp_path = f.name 

194 

195 logger.debug( 

196 f"[MarkdownlintPlugin] Created temp config at {temp_path} " 

197 f"with line_length={line_length}", 

198 ) 

199 return temp_path 

200 

201 except (PermissionError, OSError) as e: 

202 logger.warning( 

203 f"[MarkdownlintPlugin] Could not create temp config file: {e}", 

204 ) 

205 return None 

206 

207 def doc_url(self, code: str) -> str | None: 

208 """Return markdownlint documentation URL for the given code. 

209 

210 Args: 

211 code: Markdownlint code (e.g., "MD013"). 

212 

213 Returns: 

214 URL to the markdownlint rule documentation. 

215 """ 

216 if code: 

217 return DocUrlTemplate.MARKDOWNLINT.format(code=code.lower()) 

218 return None 

219 

220 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

221 """Check files with Markdownlint. 

222 

223 Args: 

224 paths: List of file or directory paths to check. 

225 options: Runtime options that override defaults. 

226 

227 Returns: 

228 ToolResult with check results. 

229 """ 

230 # Use shared preparation for version check, path validation, file discovery 

231 ctx = self._prepare_execution(paths, options) 

232 if ctx.should_skip: 

233 return ctx.early_result # type: ignore[return-value] 

234 

235 logger.debug( 

236 f"[MarkdownlintPlugin] Discovered {len(ctx.files)} files matching " 

237 f"patterns: {self.definition.file_patterns}", 

238 ) 

239 if ctx.files: 

240 logger.debug( 

241 f"[MarkdownlintPlugin] Files to check (first 10): {ctx.files[:10]}", 

242 ) 

243 logger.debug(f"[MarkdownlintPlugin] Working directory: {ctx.cwd}") 

244 

245 # Build command 

246 cmd: list[str] = self._get_markdownlint_command() 

247 

248 # Track temp config for cleanup 

249 temp_config_path: str | None = None 

250 

251 # Try Lintro config injection first 

252 config_args = self._build_config_args() 

253 if config_args: 

254 cmd.extend(config_args) 

255 logger.debug("[MarkdownlintPlugin] Using Lintro config injection") 

256 else: 

257 # Fallback: Apply line_length configuration if set 

258 line_length_opt = self.options.get("line_length") 

259 if line_length_opt is not None: 

260 line_length_val = ( 

261 int(line_length_opt) 

262 if isinstance(line_length_opt, int) 

263 else int(str(line_length_opt)) 

264 ) 

265 temp_config_path = self._create_temp_markdownlint_config( 

266 line_length=line_length_val, 

267 ) 

268 if temp_config_path: 

269 cmd.extend(["--config", temp_config_path]) 

270 

271 cmd.extend(ctx.rel_files) 

272 

273 logger.debug( 

274 f"[MarkdownlintPlugin] Running: {' '.join(cmd)} (cwd={ctx.cwd})", 

275 ) 

276 

277 try: 

278 success, output = self._run_subprocess( 

279 cmd=cmd, 

280 timeout=ctx.timeout, 

281 cwd=ctx.cwd, 

282 ) 

283 except subprocess.TimeoutExpired: 

284 timeout_result = create_timeout_result( 

285 tool=self, 

286 timeout=ctx.timeout, 

287 cmd=cmd, 

288 ) 

289 return ToolResult( 

290 name=self.definition.name, 

291 success=timeout_result.success, 

292 output=timeout_result.output, 

293 issues_count=timeout_result.issues_count, 

294 ) 

295 finally: 

296 # Clean up temp config file if created 

297 if temp_config_path: 

298 try: 

299 os.unlink(temp_config_path) 

300 logger.debug( 

301 "[MarkdownlintPlugin] Cleaned up temp config: " 

302 f"{temp_config_path}", 

303 ) 

304 except OSError as e: 

305 logger.debug( 

306 f"[MarkdownlintPlugin] Failed to clean up temp config: {e}", 

307 ) 

308 

309 # Parse output 

310 issues = parse_markdownlint_output(output=output) 

311 issues_count: int = len(issues) 

312 success_flag: bool = success and issues_count == 0 

313 

314 # Suppress output when no issues found 

315 final_output: str | None = output 

316 if success_flag: 

317 final_output = None 

318 

319 return ToolResult( 

320 name=self.definition.name, 

321 success=success_flag, 

322 output=final_output, 

323 issues_count=issues_count, 

324 issues=issues, 

325 ) 

326 

327 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

328 """Markdownlint cannot fix issues, only report them. 

329 

330 Args: 

331 paths: List of file or directory paths to fix. 

332 options: Runtime options that override defaults. 

333 

334 Returns: 

335 ToolResult: Never returns, always raises NotImplementedError. 

336 

337 Raises: 

338 NotImplementedError: Markdownlint is a linter only and cannot fix issues. 

339 """ 

340 raise NotImplementedError( 

341 "Markdownlint cannot fix issues; use a Markdown formatter" 

342 " for formatting.", 

343 )