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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Markdownlint tool definition.
3Markdownlint-cli2 is a linter for Markdown files that checks for style
4issues and best practices. It helps maintain consistent formatting
5across documentation.
6"""
8from __future__ import annotations
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
18from loguru import logger
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
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"]
40@register_tool
41@dataclass
42class MarkdownlintPlugin(BaseToolPlugin):
43 """Markdownlint Markdown linter plugin.
45 This plugin integrates markdownlint-cli2 with Lintro for checking
46 Markdown files for style and formatting issues.
47 """
49 @property
50 def definition(self) -> ToolDefinition:
51 """Return the tool definition.
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 )
80 def _verify_tool_version(self) -> ToolResult | None:
81 """Verify that markdownlint-cli2 meets minimum version requirements.
83 Overrides base implementation to use the correct executable name.
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
91 # Use the correct command for markdownlint-cli2
92 command = self._get_markdownlint_command()
93 version_info = check_tool_version(self.definition.name, command)
95 if version_info.version_check_passed:
96 return None # Version check passed
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 )
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 )
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.
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")
129 set_kwargs = dict(kwargs)
130 if timeout is not None:
131 set_kwargs["timeout"] = timeout
133 # Use provided line_length, or get from central config
134 if line_length is None:
135 line_length = get_central_line_length()
137 validate_positive_int(line_length, "line_length")
138 if line_length is not None:
139 set_kwargs["line_length"] = line_length
141 super().set_options(**set_kwargs)
143 def _get_markdownlint_command(self) -> list[str]:
144 """Get the command to run markdownlint-cli2.
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"]
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.
164 Creates a temp file with MD013 rule configured. This avoids modifying
165 the user's project files.
167 Args:
168 line_length: Line length to configure for MD013 rule.
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 }
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
195 logger.debug(
196 f"[MarkdownlintPlugin] Created temp config at {temp_path} "
197 f"with line_length={line_length}",
198 )
199 return temp_path
201 except (PermissionError, OSError) as e:
202 logger.warning(
203 f"[MarkdownlintPlugin] Could not create temp config file: {e}",
204 )
205 return None
207 def doc_url(self, code: str) -> str | None:
208 """Return markdownlint documentation URL for the given code.
210 Args:
211 code: Markdownlint code (e.g., "MD013").
213 Returns:
214 URL to the markdownlint rule documentation.
215 """
216 if code:
217 return DocUrlTemplate.MARKDOWNLINT.format(code=code.lower())
218 return None
220 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
221 """Check files with Markdownlint.
223 Args:
224 paths: List of file or directory paths to check.
225 options: Runtime options that override defaults.
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]
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}")
245 # Build command
246 cmd: list[str] = self._get_markdownlint_command()
248 # Track temp config for cleanup
249 temp_config_path: str | None = None
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])
271 cmd.extend(ctx.rel_files)
273 logger.debug(
274 f"[MarkdownlintPlugin] Running: {' '.join(cmd)} (cwd={ctx.cwd})",
275 )
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 )
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
314 # Suppress output when no issues found
315 final_output: str | None = output
316 if success_flag:
317 final_output = None
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 )
327 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
328 """Markdownlint cannot fix issues, only report them.
330 Args:
331 paths: List of file or directory paths to fix.
332 options: Runtime options that override defaults.
334 Returns:
335 ToolResult: Never returns, always raises NotImplementedError.
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 )