Coverage for lintro / tools / definitions / prettier.py: 72%
209 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"""Prettier tool definition.
3Prettier is an opinionated code formatter for CSS, HTML, JSON, YAML, Markdown,
4GraphQL, and Astro. JavaScript/TypeScript files are handled by oxfmt for better
5performance. Prettier enforces a consistent code style by parsing and
6re-printing code.
7"""
9from __future__ import annotations
11import json
12import os
13import subprocess # nosec B404 - used safely with shell disabled
14from dataclasses import dataclass
15from typing import Any
17from loguru import logger
19from lintro._tool_versions import get_min_version
20from lintro.enums.tool_name import ToolName
21from lintro.enums.tool_type import ToolType
22from lintro.models.core.tool_result import ToolResult
23from lintro.parsers.prettier.prettier_issue import PrettierIssue
24from lintro.parsers.prettier.prettier_parser import parse_prettier_output
25from lintro.plugins.base import BaseToolPlugin
26from lintro.plugins.protocol import ToolDefinition
27from lintro.plugins.registry import register_tool
28from lintro.tools.core.option_validators import (
29 filter_none_options,
30 validate_bool,
31 validate_positive_int,
32)
34# Constants for Prettier configuration
35PRETTIER_DEFAULT_TIMEOUT: int = 30
36PRETTIER_DEFAULT_PRIORITY: int = 80
37# Note: JS/TS/Vue files are handled by oxfmt (faster).
38# Prettier handles file types that oxfmt doesn't support.
39PRETTIER_CONFIG_FILENAMES: tuple[str, ...] = (
40 ".prettierrc",
41 ".prettierrc.json",
42 ".prettierrc.json5",
43 ".prettierrc.yaml",
44 ".prettierrc.yml",
45 ".prettierrc.js",
46 ".prettierrc.cjs",
47 ".prettierrc.mjs",
48 ".prettierrc.toml",
49 "prettier.config.js",
50 "prettier.config.cjs",
51 "prettier.config.mjs",
52 "prettier.config.ts",
53 "prettier.config.cts",
54 "prettier.config.mts",
55)
56PRETTIER_FILE_PATTERNS: list[str] = [
57 "*.css",
58 "*.scss",
59 "*.less",
60 "*.html",
61 "*.json",
62 "*.yaml",
63 "*.yml",
64 "*.md",
65 "*.graphql",
66 "*.astro",
67]
70@register_tool
71@dataclass
72class PrettierPlugin(BaseToolPlugin):
73 """Prettier code formatter plugin.
75 This plugin integrates Prettier with Lintro for formatting CSS, HTML,
76 JSON, YAML, Markdown, GraphQL, and Astro files. JS/TS files are handled by oxfmt.
77 """
79 @property
80 def definition(self) -> ToolDefinition:
81 """Return the tool definition.
83 Returns:
84 ToolDefinition containing tool metadata.
85 """
86 return ToolDefinition(
87 name="prettier",
88 description=(
89 "Code formatter for CSS, HTML, JSON, YAML, Markdown, GraphQL, "
90 "and Astro (JS/TS handled by oxfmt for better performance)"
91 ),
92 can_fix=True,
93 tool_type=ToolType.FORMATTER,
94 file_patterns=PRETTIER_FILE_PATTERNS,
95 priority=PRETTIER_DEFAULT_PRIORITY,
96 conflicts_with=[],
97 native_configs=list(PRETTIER_CONFIG_FILENAMES),
98 version_command=["prettier", "--version"],
99 min_version=get_min_version(ToolName.PRETTIER),
100 default_options={
101 "timeout": PRETTIER_DEFAULT_TIMEOUT,
102 "verbose_fix_output": False,
103 "line_length": None,
104 },
105 default_timeout=PRETTIER_DEFAULT_TIMEOUT,
106 )
108 def set_options(
109 self,
110 verbose_fix_output: bool | None = None,
111 line_length: int | None = None,
112 **kwargs: Any,
113 ) -> None:
114 """Set Prettier-specific options.
116 Args:
117 verbose_fix_output: If True, include raw Prettier output in fix().
118 line_length: Print width for prettier (maps to --print-width).
119 **kwargs: Other tool options.
120 """
121 validate_bool(verbose_fix_output, "verbose_fix_output")
122 validate_positive_int(line_length, "line_length")
124 options = filter_none_options(
125 verbose_fix_output=verbose_fix_output,
126 line_length=line_length,
127 )
128 super().set_options(**options, **kwargs)
130 def _find_prettier_config(self, search_dir: str | None = None) -> str | None:
131 """Locate prettier config file by walking up the directory tree.
133 Prettier searches upward from the file's directory to find config files,
134 so we do the same to match native behavior and ensure config is found
135 even when cwd is a subdirectory.
137 Args:
138 search_dir: Directory to start searching from. If None, searches from
139 current working directory.
141 Returns:
142 str | None: Path to config file if found, None otherwise.
143 """
144 config_paths = [*PRETTIER_CONFIG_FILENAMES, "package.json"]
145 # Search upward from search_dir (or cwd) to find config, just like prettier
146 start_dir = os.path.abspath(search_dir) if search_dir else os.getcwd()
147 current_dir = start_dir
149 # Walk upward from the directory to find config
150 # Stop at filesystem root to avoid infinite loop
151 while True:
152 for config_name in config_paths:
153 config_path = os.path.join(current_dir, config_name)
154 if os.path.exists(config_path):
155 # For package.json, check if it contains prettier config
156 if config_name == "package.json":
157 try:
158 with open(config_path, encoding="utf-8") as f:
159 pkg_data = json.load(f)
160 if "prettier" not in pkg_data:
161 continue
162 except (
163 json.JSONDecodeError,
164 FileNotFoundError,
165 PermissionError,
166 ):
167 # Skip invalid or unreadable package.json files
168 continue
169 logger.debug(
170 f"[PrettierPlugin] Found config file: {config_path} "
171 f"(searched from {start_dir})",
172 )
173 return config_path
175 # Move up one directory
176 parent_dir = os.path.dirname(current_dir)
177 # Stop if we've reached the filesystem root (parent == current)
178 if parent_dir == current_dir:
179 break
180 current_dir = parent_dir
182 return None
184 def _find_prettierignore(self, search_dir: str | None = None) -> str | None:
185 """Locate .prettierignore file by walking up the directory tree.
187 Prettier searches upward from the file's directory to find .prettierignore,
188 so we do the same to match native behavior.
190 Args:
191 search_dir: Directory to start searching from. If None, searches from
192 current working directory.
194 Returns:
195 str | None: Path to .prettierignore file if found, None otherwise.
196 """
197 ignore_filename = ".prettierignore"
198 start_dir = os.path.abspath(search_dir) if search_dir else os.getcwd()
199 current_dir = start_dir
201 while True:
202 ignore_path = os.path.join(current_dir, ignore_filename)
203 if os.path.exists(ignore_path):
204 logger.debug(
205 f"[PrettierPlugin] Found .prettierignore: {ignore_path} "
206 f"(searched from {start_dir})",
207 )
208 return ignore_path
210 parent_dir = os.path.dirname(current_dir)
211 if parent_dir == current_dir:
212 break
213 current_dir = parent_dir
215 return None
217 def _create_not_found_result(
218 self,
219 cwd: str | None = None,
220 ) -> ToolResult:
221 """Create a ToolResult for when Prettier is not found.
223 Args:
224 cwd: Working directory for the tool result.
226 Returns:
227 ToolResult: ToolResult instance representing Prettier not found.
228 """
229 return ToolResult(
230 name=self.definition.name,
231 success=False,
232 output=(
233 "Prettier not found.\n\n"
234 "Please ensure prettier is installed:\n"
235 " - Run 'npm install -g prettier' or 'bun add -g prettier'\n"
236 " - Or install locally: 'npm install prettier'"
237 ),
238 issues_count=0,
239 cwd=cwd,
240 )
242 def _create_timeout_result(
243 self,
244 timeout_val: int,
245 initial_issues: list[PrettierIssue] | None = None,
246 initial_count: int = 0,
247 cwd: str | None = None,
248 ) -> ToolResult:
249 """Create a ToolResult for timeout scenarios.
251 Args:
252 timeout_val: The timeout value that was exceeded.
253 initial_issues: Optional list of issues found before timeout.
254 initial_count: Optional count of initial issues.
255 cwd: Working directory for the tool result.
257 Returns:
258 ToolResult: ToolResult instance representing timeout failure.
259 """
260 timeout_msg = (
261 f"Prettier execution timed out ({timeout_val}s limit exceeded).\n\n"
262 "This may indicate:\n"
263 " - Large codebase taking too long to process\n"
264 " - Need to increase timeout via --tool-options prettier:timeout=N"
265 )
266 timeout_issue = PrettierIssue(
267 file="execution",
268 line=0,
269 code="TIMEOUT",
270 message=timeout_msg,
271 column=0,
272 )
273 combined_issues = (initial_issues or []) + [timeout_issue]
274 remaining_count = len(combined_issues)
275 # Maintain invariant: initial = fixed + remaining
276 return ToolResult(
277 name=self.definition.name,
278 success=False,
279 output=timeout_msg,
280 issues_count=remaining_count,
281 issues=combined_issues,
282 initial_issues_count=remaining_count,
283 fixed_issues_count=0,
284 remaining_issues_count=remaining_count,
285 cwd=cwd,
286 )
288 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
289 """Check files with Prettier without making changes.
291 Args:
292 paths: List of file or directory paths to check.
293 options: Runtime options that override defaults.
295 Returns:
296 ToolResult with check results.
297 """
298 # Merge runtime options
299 merged_options = dict(self.options)
300 merged_options.update(options)
302 # Use shared preparation for version check, path validation, file discovery
303 ctx = self._prepare_execution(
304 paths,
305 merged_options,
306 no_files_message="No files to check.",
307 )
308 if ctx.should_skip:
309 return ctx.early_result # type: ignore[return-value]
311 logger.debug(
312 f"[PrettierPlugin] Discovered {len(ctx.files)} files matching patterns: "
313 f"{self.definition.file_patterns}",
314 )
315 logger.debug(
316 f"[PrettierPlugin] Exclude patterns applied: {self.exclude_patterns}",
317 )
318 if ctx.files:
319 logger.debug(
320 f"[PrettierPlugin] Files to check (first 10): {ctx.files[:10]}",
321 )
322 logger.debug(f"[PrettierPlugin] Working directory: {ctx.cwd}")
324 # Resolve executable in a manner consistent with other tools
325 cmd: list[str] = self._get_executable_command(tool_name="prettier") + [
326 "--check",
327 ]
329 # Add Lintro config injection args (--no-config, --config)
330 config_args = self._build_config_args()
331 if config_args:
332 cmd.extend(config_args)
333 logger.debug("[PrettierPlugin] Using Lintro config injection")
334 else:
335 # Fallback: Find config and ignore files by walking up from cwd
336 found_config = self._find_prettier_config(search_dir=ctx.cwd)
337 if found_config:
338 logger.debug(
339 f"[PrettierPlugin] Found config: {found_config} (auto-detecting)",
340 )
341 else:
342 logger.debug(
343 "[PrettierPlugin] No prettier config file found (using defaults)",
344 )
345 # Apply line_length as --print-width if set and no config found
346 line_length = self.options.get("line_length")
347 if line_length:
348 cmd.extend(["--print-width", str(line_length)])
349 logger.debug(
350 "[PrettierPlugin] Using --print-width=%s from options",
351 line_length,
352 )
353 # Find .prettierignore by walking up from cwd
354 prettierignore_path = self._find_prettierignore(search_dir=ctx.cwd)
355 if prettierignore_path:
356 logger.debug(
357 f"[PrettierPlugin] Found .prettierignore: {prettierignore_path} "
358 "(auto-detecting)",
359 )
361 cmd.extend(ctx.rel_files)
362 logger.debug(f"[PrettierPlugin] Running: {' '.join(cmd)} (cwd={ctx.cwd})")
364 try:
365 result = self._run_subprocess(
366 cmd=cmd,
367 timeout=ctx.timeout,
368 cwd=ctx.cwd,
369 )
370 except subprocess.TimeoutExpired:
371 return self._create_timeout_result(timeout_val=ctx.timeout, cwd=ctx.cwd)
372 except (OSError, ValueError, RuntimeError, FileNotFoundError) as e:
373 if isinstance(e, FileNotFoundError):
374 return self._create_not_found_result(cwd=ctx.cwd)
375 logger.error(f"Failed to run prettier: {e}")
376 return ToolResult(
377 name=self.definition.name,
378 success=False,
379 output=f"Prettier execution failed: {e}",
380 issues_count=0,
381 cwd=ctx.cwd,
382 )
384 output: str = result[1]
385 issues: list[PrettierIssue] = parse_prettier_output(output=output)
386 issues_count: int = len(issues)
387 success: bool = issues_count == 0
389 # Standardize: suppress Prettier's informational output when no issues
390 final_output: str | None = output
391 if success:
392 final_output = None
394 return ToolResult(
395 name=self.definition.name,
396 success=success,
397 output=final_output,
398 issues_count=issues_count,
399 issues=issues,
400 cwd=ctx.cwd,
401 )
403 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
404 """Format files with Prettier.
406 Args:
407 paths: List of file or directory paths to format.
408 options: Runtime options that override defaults.
410 Returns:
411 ToolResult: Result object with counts and messages.
412 """
413 # Merge runtime options
414 merged_options = dict(self.options)
415 merged_options.update(options)
417 # Use shared preparation for version check, path validation, file discovery
418 ctx = self._prepare_execution(
419 paths,
420 merged_options,
421 no_files_message="No files to format.",
422 )
423 if ctx.should_skip:
424 return ctx.early_result # type: ignore[return-value]
426 # Get Lintro config injection args (--no-config, --config)
427 config_args = self._build_config_args()
428 fallback_args: list[str] = []
429 if not config_args:
430 # Fallback: Find config and ignore files by walking up from cwd
431 found_config = self._find_prettier_config(search_dir=ctx.cwd)
432 if found_config:
433 logger.debug(
434 f"[PrettierPlugin] Found config: {found_config} (auto-detecting)",
435 )
436 else:
437 logger.debug(
438 "[PrettierPlugin] No prettier config file found (using defaults)",
439 )
440 # Apply line_length as --print-width if set and no config found
441 line_length = self.options.get("line_length")
442 if line_length:
443 fallback_args.extend(["--print-width", str(line_length)])
444 logger.debug(
445 "[PrettierPlugin] Using --print-width=%s from options",
446 line_length,
447 )
448 prettierignore_path = self._find_prettierignore(search_dir=ctx.cwd)
449 if prettierignore_path:
450 logger.debug(
451 f"[PrettierPlugin] Found .prettierignore: {prettierignore_path} "
452 "(auto-detecting)",
453 )
455 # Check for issues first
456 check_cmd: list[str] = self._get_executable_command(tool_name="prettier") + [
457 "--check",
458 ]
459 if config_args:
460 check_cmd.extend(config_args)
461 elif fallback_args:
462 check_cmd.extend(fallback_args)
463 check_cmd.extend(ctx.rel_files)
464 logger.debug(
465 f"[PrettierPlugin] Checking: {' '.join(check_cmd)} (cwd={ctx.cwd})",
466 )
468 try:
469 check_result = self._run_subprocess(
470 cmd=check_cmd,
471 timeout=ctx.timeout,
472 cwd=ctx.cwd,
473 )
474 except subprocess.TimeoutExpired:
475 return self._create_timeout_result(timeout_val=ctx.timeout, cwd=ctx.cwd)
476 except (OSError, ValueError, RuntimeError, FileNotFoundError) as e:
477 if isinstance(e, FileNotFoundError):
478 return self._create_not_found_result(cwd=ctx.cwd)
479 logger.error(f"Failed to run prettier: {e}")
480 return ToolResult(
481 name=self.definition.name,
482 success=False,
483 output=f"Prettier execution failed: {e}",
484 issues_count=0,
485 cwd=ctx.cwd,
486 )
488 check_output: str = check_result[1]
490 # Parse initial issues
491 initial_issues: list[PrettierIssue] = parse_prettier_output(output=check_output)
492 initial_count: int = len(initial_issues)
494 # Now fix the issues
495 fix_cmd: list[str] = self._get_executable_command(tool_name="prettier") + [
496 "--write",
497 ]
498 if config_args:
499 fix_cmd.extend(config_args)
500 elif fallback_args:
501 fix_cmd.extend(fallback_args)
502 fix_cmd.extend(ctx.rel_files)
503 logger.debug(f"[PrettierPlugin] Fixing: {' '.join(fix_cmd)} (cwd={ctx.cwd})")
505 try:
506 fix_result = self._run_subprocess(
507 cmd=fix_cmd,
508 timeout=ctx.timeout,
509 cwd=ctx.cwd,
510 )
511 except subprocess.TimeoutExpired:
512 return self._create_timeout_result(
513 timeout_val=ctx.timeout,
514 initial_issues=initial_issues,
515 initial_count=initial_count,
516 cwd=ctx.cwd,
517 )
518 except (OSError, ValueError, RuntimeError, FileNotFoundError) as e:
519 if isinstance(e, FileNotFoundError):
520 return self._create_not_found_result(cwd=ctx.cwd)
521 logger.error(f"Failed to run prettier: {e}")
522 return ToolResult(
523 name=self.definition.name,
524 success=False,
525 output=f"Prettier execution failed: {e}",
526 issues_count=0,
527 cwd=ctx.cwd,
528 )
530 fix_output: str = fix_result[1]
532 # Check for remaining issues after fixing
533 try:
534 final_check_result = self._run_subprocess(
535 cmd=check_cmd,
536 timeout=ctx.timeout,
537 cwd=ctx.cwd,
538 )
539 except subprocess.TimeoutExpired:
540 return self._create_timeout_result(
541 timeout_val=ctx.timeout,
542 initial_issues=initial_issues,
543 initial_count=initial_count,
544 cwd=ctx.cwd,
545 )
546 except (OSError, ValueError, RuntimeError, FileNotFoundError) as e:
547 if isinstance(e, FileNotFoundError):
548 return self._create_not_found_result(cwd=ctx.cwd)
549 logger.error(f"Failed to run prettier: {e}")
550 return ToolResult(
551 name=self.definition.name,
552 success=False,
553 output=f"Prettier execution failed: {e}",
554 issues_count=0,
555 cwd=ctx.cwd,
556 )
558 final_check_output: str = final_check_result[1]
559 remaining_issues: list[PrettierIssue] = parse_prettier_output(
560 output=final_check_output,
561 )
562 remaining_count: int = len(remaining_issues)
564 # Calculate fixed issues
565 fixed_count: int = max(0, initial_count - remaining_count)
567 # Build output message
568 output_lines: list[str] = []
569 if fixed_count > 0:
570 output_lines.append(f"Fixed {fixed_count} formatting issue(s)")
572 if remaining_count > 0:
573 output_lines.append(
574 f"Found {remaining_count} issue(s) that cannot be auto-fixed",
575 )
576 for issue in remaining_issues[:5]:
577 output_lines.append(f" {issue.file} - {issue.message}")
578 if len(remaining_issues) > 5:
579 output_lines.append(f" ... and {len(remaining_issues) - 5} more")
581 elif remaining_count == 0 and fixed_count > 0:
582 output_lines.append("All formatting issues were successfully auto-fixed")
584 # Add verbose raw formatting output only when explicitly requested
585 if (
586 self.options.get("verbose_fix_output", False)
587 and fix_output
588 and fix_output.strip()
589 ):
590 output_lines.append(f"Formatting output:\n{fix_output}")
592 final_output: str | None = "\n".join(output_lines) if output_lines else None
594 # Success means no remaining issues
595 success: bool = remaining_count == 0
597 # Combine initial and remaining issues
598 all_issues = (initial_issues or []) + (remaining_issues or [])
600 return ToolResult(
601 name=self.definition.name,
602 success=success,
603 output=final_output,
604 issues_count=remaining_count,
605 issues=all_issues,
606 initial_issues_count=initial_count,
607 fixed_issues_count=fixed_count,
608 remaining_issues_count=remaining_count,
609 initial_issues=initial_issues if initial_issues else None,
610 cwd=ctx.cwd,
611 )