Coverage for lintro / tools / definitions / taplo.py: 96%

154 statements  

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

1"""Taplo tool definition. 

2 

3Taplo is a TOML toolkit with linting and formatting capabilities. 

4It validates TOML syntax and can format TOML files consistently. 

5""" 

6 

7from __future__ import annotations 

8 

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

10from dataclasses import dataclass 

11from typing import Any 

12 

13from loguru import logger 

14 

15from lintro._tool_versions import get_min_version 

16from lintro.enums.doc_url_template import DocUrlTemplate 

17from lintro.enums.tool_name import ToolName 

18from lintro.enums.tool_type import ToolType 

19from lintro.models.core.tool_result import ToolResult 

20from lintro.parsers.taplo.taplo_issue import TaploIssue 

21from lintro.parsers.taplo.taplo_parser import parse_taplo_output 

22from lintro.plugins.base import BaseToolPlugin 

23from lintro.plugins.protocol import ToolDefinition 

24from lintro.plugins.registry import register_tool 

25from lintro.tools.core.option_validators import ( 

26 filter_none_options, 

27 validate_bool, 

28 validate_str, 

29) 

30 

31# Constants for Taplo configuration 

32TAPLO_DEFAULT_TIMEOUT: int = 30 

33TAPLO_DEFAULT_PRIORITY: int = 50 

34TAPLO_FILE_PATTERNS: list[str] = ["*.toml"] 

35 

36 

37@register_tool 

38@dataclass 

39class TaploPlugin(BaseToolPlugin): 

40 """Taplo TOML linter and formatter plugin. 

41 

42 This plugin integrates Taplo with Lintro for linting and formatting 

43 TOML files. 

44 """ 

45 

46 @property 

47 def definition(self) -> ToolDefinition: 

48 """Return the tool definition. 

49 

50 Returns: 

51 ToolDefinition containing tool metadata. 

52 """ 

53 return ToolDefinition( 

54 name="taplo", 

55 description="TOML toolkit with linting and formatting capabilities", 

56 can_fix=True, 

57 tool_type=ToolType.LINTER | ToolType.FORMATTER, 

58 file_patterns=TAPLO_FILE_PATTERNS, 

59 priority=TAPLO_DEFAULT_PRIORITY, 

60 conflicts_with=[], 

61 native_configs=["taplo.toml", ".taplo.toml"], 

62 version_command=["taplo", "--version"], 

63 min_version=get_min_version(ToolName.TAPLO), 

64 default_options={ 

65 "timeout": TAPLO_DEFAULT_TIMEOUT, 

66 "schema": None, 

67 "aligned_arrays": None, 

68 "aligned_entries": None, 

69 "array_trailing_comma": None, 

70 "indent_string": None, 

71 "reorder_keys": None, 

72 }, 

73 default_timeout=TAPLO_DEFAULT_TIMEOUT, 

74 ) 

75 

76 def set_options( 

77 self, 

78 schema: str | None = None, 

79 aligned_arrays: bool | None = None, 

80 aligned_entries: bool | None = None, 

81 array_trailing_comma: bool | None = None, 

82 indent_string: str | None = None, 

83 reorder_keys: bool | None = None, 

84 **kwargs: Any, 

85 ) -> None: 

86 """Set Taplo-specific options with validation. 

87 

88 Args: 

89 schema: Path or URL to JSON schema for validation. 

90 aligned_arrays: Align array entries. 

91 aligned_entries: Align table entries. 

92 array_trailing_comma: Add trailing comma in arrays. 

93 indent_string: Indentation string (default: 2 spaces). 

94 reorder_keys: Reorder keys alphabetically. 

95 **kwargs: Additional base options. 

96 """ 

97 validate_str(schema, "schema") 

98 validate_bool(aligned_arrays, "aligned_arrays") 

99 validate_bool(aligned_entries, "aligned_entries") 

100 validate_bool(array_trailing_comma, "array_trailing_comma") 

101 validate_str(indent_string, "indent_string") 

102 validate_bool(reorder_keys, "reorder_keys") 

103 

104 options = filter_none_options( 

105 schema=schema, 

106 aligned_arrays=aligned_arrays, 

107 aligned_entries=aligned_entries, 

108 array_trailing_comma=array_trailing_comma, 

109 indent_string=indent_string, 

110 reorder_keys=reorder_keys, 

111 ) 

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

113 

114 def _build_format_args(self) -> list[str]: 

115 """Build formatting CLI arguments for Taplo. 

116 

117 Returns: 

118 CLI arguments for Taplo formatting options. 

119 """ 

120 args: list[str] = [] 

121 

122 if self.options.get("aligned_arrays"): 

123 args.append("--option=aligned_arrays=true") 

124 if self.options.get("aligned_entries"): 

125 args.append("--option=aligned_entries=true") 

126 if self.options.get("array_trailing_comma"): 

127 args.append("--option=array_trailing_comma=true") 

128 if self.options.get("indent_string"): 

129 args.append(f"--option=indent_string={self.options['indent_string']}") 

130 if self.options.get("reorder_keys"): 

131 args.append("--option=reorder_keys=true") 

132 

133 return args 

134 

135 def _build_lint_args(self) -> list[str]: 

136 """Build linting CLI arguments for Taplo. 

137 

138 Returns: 

139 CLI arguments for Taplo linting options. 

140 """ 

141 args: list[str] = [] 

142 

143 if self.options.get("schema"): 

144 args.extend(["--schema", str(self.options["schema"])]) 

145 

146 return args 

147 

148 def _handle_timeout_error( 

149 self, 

150 timeout_val: int, 

151 initial_count: int | None = None, 

152 initial_issues: list[TaploIssue] | None = None, 

153 ) -> ToolResult: 

154 """Handle timeout errors consistently. 

155 

156 Args: 

157 timeout_val: The timeout value that was exceeded. 

158 initial_count: Optional initial issues count for fix operations. 

159 initial_issues: Optional list of initial issues found before timeout. 

160 

161 Returns: 

162 Standardized timeout error result. 

163 """ 

164 timeout_msg = ( 

165 f"Taplo execution timed out ({timeout_val}s limit exceeded).\n\n" 

166 "This may indicate:\n" 

167 " - Large codebase taking too long to process\n" 

168 " - Need to increase timeout via --tool-options taplo:timeout=N" 

169 ) 

170 timeout_issue = TaploIssue( 

171 file="execution", 

172 line=0, 

173 column=0, 

174 level="error", 

175 code="TIMEOUT", 

176 message=f"Taplo execution timed out ({timeout_val}s limit exceeded)", 

177 ) 

178 if initial_count is not None and initial_count > 0: 

179 combined_issues = (initial_issues or []) + [timeout_issue] 

180 return ToolResult( 

181 name=self.definition.name, 

182 success=False, 

183 output=timeout_msg, 

184 issues_count=len(combined_issues), 

185 issues=combined_issues, 

186 initial_issues_count=initial_count, 

187 fixed_issues_count=0, 

188 remaining_issues_count=initial_count, 

189 ) 

190 return ToolResult( 

191 name=self.definition.name, 

192 success=False, 

193 output=timeout_msg, 

194 issues_count=1, 

195 issues=[timeout_issue], 

196 ) 

197 

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

199 """Return Taplo documentation URL. 

200 

201 Args: 

202 code: Taplo rule code (e.g., "invalid_value"). 

203 

204 Returns: 

205 URL to the Taplo documentation, or None if code is empty. 

206 """ 

207 if not code: 

208 return None 

209 return DocUrlTemplate.TAPLO 

210 

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

212 """Check TOML files using Taplo. 

213 

214 Runs both `taplo lint` for syntax errors and `taplo fmt --check` 

215 for formatting issues. 

216 

217 Args: 

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

219 options: Runtime options that override defaults. 

220 

221 Returns: 

222 ToolResult with check results. 

223 """ 

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

225 ctx = self._prepare_execution(paths, options) 

226 if ctx.should_skip: 

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

228 

229 all_issues: list[TaploIssue] = [] 

230 all_outputs: list[str] = [] 

231 all_success: bool = True 

232 

233 # Run taplo lint for syntax errors 

234 lint_cmd: list[str] = self._get_executable_command(tool_name="taplo") + ["lint"] 

235 lint_cmd.extend(self._build_lint_args()) 

236 lint_cmd.extend(ctx.rel_files) 

237 

238 logger.debug( 

239 f"[TaploPlugin] Running lint: {' '.join(lint_cmd)} (cwd={ctx.cwd})", 

240 ) 

241 try: 

242 lint_success, lint_output = self._run_subprocess( 

243 cmd=lint_cmd, 

244 timeout=ctx.timeout, 

245 cwd=ctx.cwd, 

246 ) 

247 except subprocess.TimeoutExpired: 

248 return self._handle_timeout_error(ctx.timeout) 

249 

250 if not lint_success: 

251 all_success = False 

252 if lint_output: 

253 all_outputs.append(lint_output) 

254 lint_issues = parse_taplo_output(output=lint_output) 

255 all_issues.extend(lint_issues) 

256 

257 # Run taplo fmt --check for formatting issues 

258 fmt_cmd: list[str] = self._get_executable_command(tool_name="taplo") + [ 

259 "fmt", 

260 "--check", 

261 ] 

262 fmt_cmd.extend(self._build_format_args()) 

263 fmt_cmd.extend(ctx.rel_files) 

264 

265 logger.debug( 

266 f"[TaploPlugin] Running format check: {' '.join(fmt_cmd)} (cwd={ctx.cwd})", 

267 ) 

268 try: 

269 fmt_success, fmt_output = self._run_subprocess( 

270 cmd=fmt_cmd, 

271 timeout=ctx.timeout, 

272 cwd=ctx.cwd, 

273 ) 

274 except subprocess.TimeoutExpired: 

275 return self._handle_timeout_error(ctx.timeout) 

276 

277 if not fmt_success: 

278 all_success = False 

279 if fmt_output: 

280 all_outputs.append(fmt_output) 

281 # Format check output may contain file paths of files that need formatting 

282 fmt_issues = parse_taplo_output(output=fmt_output) 

283 all_issues.extend(fmt_issues) 

284 

285 count = len(all_issues) 

286 output = "\n".join(all_outputs) if all_outputs else None 

287 

288 return ToolResult( 

289 name=self.definition.name, 

290 success=(all_success and count == 0), 

291 output=output if count > 0 else None, 

292 issues_count=count, 

293 issues=all_issues, 

294 cwd=ctx.cwd, 

295 ) 

296 

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

298 """Format TOML files using Taplo. 

299 

300 Args: 

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

302 options: Runtime options that override defaults. 

303 

304 Returns: 

305 ToolResult with fix results. 

306 """ 

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

308 ctx = self._prepare_execution( 

309 paths, 

310 options, 

311 no_files_message="No TOML files to format.", 

312 ) 

313 if ctx.should_skip: 

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

315 

316 # Build check command for before/after comparison 

317 check_cmd: list[str] = self._get_executable_command(tool_name="taplo") + [ 

318 "fmt", 

319 "--check", 

320 ] 

321 check_cmd.extend(self._build_format_args()) 

322 check_cmd.extend(ctx.rel_files) 

323 

324 # Count initial formatting issues 

325 try: 

326 _, initial_output = self._run_subprocess( 

327 cmd=check_cmd, 

328 timeout=ctx.timeout, 

329 cwd=ctx.cwd, 

330 ) 

331 except subprocess.TimeoutExpired: 

332 return self._handle_timeout_error(timeout_val=ctx.timeout, initial_count=0) 

333 

334 initial_issues = parse_taplo_output(output=initial_output) 

335 initial_count = len(initial_issues) 

336 

337 # Also check for lint errors (syntax issues that formatting won't fix) 

338 lint_cmd: list[str] = self._get_executable_command(tool_name="taplo") + ["lint"] 

339 lint_cmd.extend(self._build_lint_args()) 

340 lint_cmd.extend(ctx.rel_files) 

341 

342 try: 

343 _, lint_output = self._run_subprocess( 

344 cmd=lint_cmd, 

345 timeout=ctx.timeout, 

346 cwd=ctx.cwd, 

347 ) 

348 except subprocess.TimeoutExpired: 

349 return self._handle_timeout_error( 

350 timeout_val=ctx.timeout, 

351 initial_count=initial_count, 

352 ) 

353 

354 lint_issues = parse_taplo_output(output=lint_output) 

355 initial_issues.extend(lint_issues) 

356 initial_count = len(initial_issues) 

357 

358 # Apply formatting with taplo fmt 

359 fix_cmd: list[str] = self._get_executable_command(tool_name="taplo") + ["fmt"] 

360 fix_cmd.extend(self._build_format_args()) 

361 fix_cmd.extend(ctx.rel_files) 

362 

363 logger.debug(f"[TaploPlugin] Fixing: {' '.join(fix_cmd)} (cwd={ctx.cwd})") 

364 try: 

365 _, _ = self._run_subprocess( 

366 cmd=fix_cmd, 

367 timeout=ctx.timeout, 

368 cwd=ctx.cwd, 

369 ) 

370 except subprocess.TimeoutExpired: 

371 return self._handle_timeout_error( 

372 timeout_val=ctx.timeout, 

373 initial_count=initial_count, 

374 ) 

375 

376 # Check for remaining formatting issues 

377 try: 

378 final_success, final_output = self._run_subprocess( 

379 cmd=check_cmd, 

380 timeout=ctx.timeout, 

381 cwd=ctx.cwd, 

382 ) 

383 except subprocess.TimeoutExpired: 

384 return self._handle_timeout_error( 

385 timeout_val=ctx.timeout, 

386 initial_count=initial_count, 

387 ) 

388 

389 remaining_format_issues = parse_taplo_output(output=final_output) 

390 

391 # Re-check lint errors (these won't be fixed by formatting) 

392 try: 

393 _, final_lint_output = self._run_subprocess( 

394 cmd=lint_cmd, 

395 timeout=ctx.timeout, 

396 cwd=ctx.cwd, 

397 ) 

398 except subprocess.TimeoutExpired: 

399 return self._handle_timeout_error( 

400 timeout_val=ctx.timeout, 

401 initial_count=initial_count, 

402 ) 

403 

404 remaining_lint_issues = parse_taplo_output(output=final_lint_output) 

405 

406 all_remaining_issues = remaining_format_issues + remaining_lint_issues 

407 remaining_count = len(all_remaining_issues) 

408 fixed_count = max(0, initial_count - remaining_count) 

409 

410 # Build summary 

411 summary: list[str] = [] 

412 if fixed_count > 0: 

413 summary.append(f"Fixed {fixed_count} issue(s)") 

414 if remaining_count > 0: 

415 summary.append( 

416 f"Found {remaining_count} issue(s) that cannot be auto-fixed", 

417 ) 

418 elif remaining_count == 0 and fixed_count > 0: 

419 summary.append("All issues were successfully auto-fixed") 

420 final_summary = "\n".join(summary) if summary else "No fixes applied." 

421 

422 return ToolResult( 

423 name=self.definition.name, 

424 success=(remaining_count == 0), 

425 output=final_summary, 

426 issues_count=remaining_count, 

427 issues=all_remaining_issues, 

428 initial_issues=initial_issues if initial_issues else None, 

429 initial_issues_count=initial_count, 

430 fixed_issues_count=fixed_count, 

431 remaining_issues_count=remaining_count, 

432 cwd=ctx.cwd, 

433 )