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

1"""Prettier tool definition. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11import json 

12import os 

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

14from dataclasses import dataclass 

15from typing import Any 

16 

17from loguru import logger 

18 

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) 

33 

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] 

68 

69 

70@register_tool 

71@dataclass 

72class PrettierPlugin(BaseToolPlugin): 

73 """Prettier code formatter plugin. 

74 

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 """ 

78 

79 @property 

80 def definition(self) -> ToolDefinition: 

81 """Return the tool definition. 

82 

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 ) 

107 

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. 

115 

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

123 

124 options = filter_none_options( 

125 verbose_fix_output=verbose_fix_output, 

126 line_length=line_length, 

127 ) 

128 super().set_options(**options, **kwargs) 

129 

130 def _find_prettier_config(self, search_dir: str | None = None) -> str | None: 

131 """Locate prettier config file by walking up the directory tree. 

132 

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. 

136 

137 Args: 

138 search_dir: Directory to start searching from. If None, searches from 

139 current working directory. 

140 

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 

148 

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 

174 

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 

181 

182 return None 

183 

184 def _find_prettierignore(self, search_dir: str | None = None) -> str | None: 

185 """Locate .prettierignore file by walking up the directory tree. 

186 

187 Prettier searches upward from the file's directory to find .prettierignore, 

188 so we do the same to match native behavior. 

189 

190 Args: 

191 search_dir: Directory to start searching from. If None, searches from 

192 current working directory. 

193 

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 

200 

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 

209 

210 parent_dir = os.path.dirname(current_dir) 

211 if parent_dir == current_dir: 

212 break 

213 current_dir = parent_dir 

214 

215 return None 

216 

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. 

222 

223 Args: 

224 cwd: Working directory for the tool result. 

225 

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 ) 

241 

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. 

250 

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. 

256 

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 ) 

287 

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

289 """Check files with Prettier without making changes. 

290 

291 Args: 

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

293 options: Runtime options that override defaults. 

294 

295 Returns: 

296 ToolResult with check results. 

297 """ 

298 # Merge runtime options 

299 merged_options = dict(self.options) 

300 merged_options.update(options) 

301 

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] 

310 

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}") 

323 

324 # Resolve executable in a manner consistent with other tools 

325 cmd: list[str] = self._get_executable_command(tool_name="prettier") + [ 

326 "--check", 

327 ] 

328 

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 ) 

360 

361 cmd.extend(ctx.rel_files) 

362 logger.debug(f"[PrettierPlugin] Running: {' '.join(cmd)} (cwd={ctx.cwd})") 

363 

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 ) 

383 

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 

388 

389 # Standardize: suppress Prettier's informational output when no issues 

390 final_output: str | None = output 

391 if success: 

392 final_output = None 

393 

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 ) 

402 

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

404 """Format files with Prettier. 

405 

406 Args: 

407 paths: List of file or directory paths to format. 

408 options: Runtime options that override defaults. 

409 

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) 

416 

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] 

425 

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 ) 

454 

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 ) 

467 

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 ) 

487 

488 check_output: str = check_result[1] 

489 

490 # Parse initial issues 

491 initial_issues: list[PrettierIssue] = parse_prettier_output(output=check_output) 

492 initial_count: int = len(initial_issues) 

493 

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})") 

504 

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 ) 

529 

530 fix_output: str = fix_result[1] 

531 

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 ) 

557 

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) 

563 

564 # Calculate fixed issues 

565 fixed_count: int = max(0, initial_count - remaining_count) 

566 

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

571 

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

580 

581 elif remaining_count == 0 and fixed_count > 0: 

582 output_lines.append("All formatting issues were successfully auto-fixed") 

583 

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}") 

591 

592 final_output: str | None = "\n".join(output_lines) if output_lines else None 

593 

594 # Success means no remaining issues 

595 success: bool = remaining_count == 0 

596 

597 # Combine initial and remaining issues 

598 all_issues = (initial_issues or []) + (remaining_issues or []) 

599 

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 )