Coverage for lintro / tools / definitions / oxfmt.py: 87%

142 statements  

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

1"""Oxfmt tool definition. 

2 

3Oxfmt is a fast JavaScript/TypeScript formatter (30x faster than Prettier). 

4It formats code with minimal configuration, enforcing a consistent code style. 

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.tool_name import ToolName 

17from lintro.enums.tool_type import ToolType 

18from lintro.models.core.tool_result import ToolResult 

19from lintro.parsers.oxfmt.oxfmt_issue import OxfmtIssue 

20from lintro.parsers.oxfmt.oxfmt_parser import parse_oxfmt_output 

21from lintro.plugins.base import BaseToolPlugin 

22from lintro.plugins.protocol import ToolDefinition 

23from lintro.plugins.registry import register_tool 

24from lintro.tools.core.option_validators import ( 

25 filter_none_options, 

26 validate_bool, 

27 validate_list, 

28 validate_str, 

29) 

30 

31# Constants for oxfmt configuration 

32OXFMT_DEFAULT_TIMEOUT: int = 30 

33OXFMT_DEFAULT_PRIORITY: int = 80 

34# Note: oxfmt (from oxc toolchain) supports JavaScript/TypeScript and Vue files. 

35# Unlike Prettier, it does not support Svelte, Astro, JSON, CSS, HTML, Markdown, etc. 

36OXFMT_FILE_PATTERNS: list[str] = [ 

37 "*.js", 

38 "*.mjs", 

39 "*.cjs", 

40 "*.jsx", 

41 "*.ts", 

42 "*.mts", 

43 "*.cts", 

44 "*.tsx", 

45 "*.vue", 

46] 

47 

48 

49@register_tool 

50@dataclass 

51class OxfmtPlugin(BaseToolPlugin): 

52 """Oxfmt code formatter plugin. 

53 

54 This plugin integrates oxfmt with Lintro for formatting 

55 JavaScript, TypeScript, and Vue files. 

56 """ 

57 

58 @property 

59 def definition(self) -> ToolDefinition: 

60 """Return the tool definition. 

61 

62 Returns: 

63 ToolDefinition containing tool metadata. 

64 """ 

65 return ToolDefinition( 

66 name="oxfmt", 

67 description=( 

68 "Fast JavaScript/TypeScript formatter (30x faster than Prettier)" 

69 ), 

70 can_fix=True, 

71 tool_type=ToolType.FORMATTER, 

72 file_patterns=OXFMT_FILE_PATTERNS, 

73 priority=OXFMT_DEFAULT_PRIORITY, 

74 conflicts_with=[], 

75 native_configs=[".oxfmtrc.json", ".oxfmtrc.jsonc"], 

76 version_command=["oxfmt", "--version"], 

77 min_version=get_min_version(ToolName.OXFMT), 

78 default_options={ 

79 "timeout": OXFMT_DEFAULT_TIMEOUT, 

80 "verbose_fix_output": False, 

81 }, 

82 default_timeout=OXFMT_DEFAULT_TIMEOUT, 

83 ) 

84 

85 def set_options( 

86 self, 

87 exclude_patterns: list[str] | None = None, 

88 include_venv: bool = False, 

89 verbose_fix_output: bool | None = None, 

90 config: str | None = None, 

91 ignore_path: str | None = None, 

92 **kwargs: Any, 

93 ) -> None: 

94 """Set oxfmt-specific options. 

95 

96 Args: 

97 exclude_patterns: List of patterns to exclude. 

98 include_venv: Whether to include virtual environment directories. 

99 verbose_fix_output: If True, include raw oxfmt output in fix(). 

100 config: Path to oxfmt config file (--config). 

101 ignore_path: Path to ignore file (--ignore-path). 

102 **kwargs: Other tool options. 

103 

104 Note: 

105 Formatting options (print_width, tab_width, use_tabs, semi, single_quote) 

106 are only supported via config file (.oxfmtrc.json), not CLI flags. 

107 """ 

108 validate_list(exclude_patterns, "exclude_patterns") 

109 validate_bool(verbose_fix_output, "verbose_fix_output") 

110 validate_str(config, "config") 

111 validate_str(ignore_path, "ignore_path") 

112 

113 if exclude_patterns is not None: 

114 self.exclude_patterns = exclude_patterns.copy() 

115 self.include_venv = include_venv 

116 

117 options = filter_none_options( 

118 verbose_fix_output=verbose_fix_output, 

119 config=config, 

120 ignore_path=ignore_path, 

121 ) 

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

123 

124 def _create_timeout_result( 

125 self, 

126 timeout_val: int, 

127 initial_issues: list[OxfmtIssue] | None = None, 

128 initial_count: int = 0, 

129 cwd: str | None = None, 

130 ) -> ToolResult: 

131 """Create a ToolResult for timeout scenarios. 

132 

133 Args: 

134 timeout_val: The timeout value that was exceeded. 

135 initial_issues: Optional list of issues found before timeout. 

136 initial_count: Optional count of initial issues. 

137 cwd: Working directory for the tool result. 

138 

139 Returns: 

140 ToolResult: ToolResult instance representing timeout failure. 

141 """ 

142 timeout_msg = ( 

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

144 "This may indicate:\n" 

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

146 " - Need to increase timeout via --tool-options oxfmt:timeout=N" 

147 ) 

148 timeout_issue = OxfmtIssue( 

149 file="execution", 

150 line=1, 

151 code="TIMEOUT", 

152 message=timeout_msg, 

153 column=1, 

154 ) 

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

156 combined_count = len(combined_issues) 

157 return ToolResult( 

158 name=self.definition.name, 

159 success=False, 

160 output=timeout_msg, 

161 issues_count=combined_count, 

162 issues=combined_issues, 

163 initial_issues_count=combined_count, 

164 fixed_issues_count=0, 

165 remaining_issues_count=combined_count, 

166 cwd=cwd, 

167 ) 

168 

169 def _build_oxfmt_args(self, options: dict[str, object]) -> list[str]: 

170 """Build CLI arguments from options. 

171 

172 Args: 

173 options: Options dict to build args from (use merged_options). 

174 

175 Returns: 

176 List of CLI arguments to pass to oxfmt. 

177 

178 Note: 

179 Formatting options (print_width, tab_width, use_tabs, semi, single_quote) 

180 are only supported via config file (.oxfmtrc.json), not CLI flags. 

181 """ 

182 args: list[str] = [] 

183 

184 # Config file override 

185 config = options.get("config") 

186 if config: 

187 args.extend(["--config", str(config)]) 

188 

189 # Ignore file path 

190 ignore_path = options.get("ignore_path") 

191 if ignore_path: 

192 args.extend(["--ignore-path", str(ignore_path)]) 

193 

194 return args 

195 

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

197 """Check files with oxfmt without making changes. 

198 

199 Args: 

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

201 options: Runtime options that override defaults. 

202 

203 Returns: 

204 ToolResult with check results. 

205 """ 

206 # Merge runtime options 

207 merged_options = dict(self.options) 

208 merged_options.update(options) 

209 

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

211 ctx = self._prepare_execution( 

212 paths, 

213 merged_options, 

214 no_files_message="No files to check.", 

215 ) 

216 if ctx.should_skip: 

217 assert ctx.early_result is not None 

218 return ctx.early_result 

219 

220 logger.debug( 

221 f"[OxfmtPlugin] Discovered {len(ctx.files)} files matching patterns: " 

222 f"{self.definition.file_patterns}", 

223 ) 

224 logger.debug( 

225 f"[OxfmtPlugin] Exclude patterns applied: {self.exclude_patterns}", 

226 ) 

227 if ctx.files: 

228 logger.debug( 

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

230 ) 

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

232 

233 # Resolve executable in a manner consistent with other tools 

234 # Use --list-different to get file paths that need formatting (one per line) 

235 # Note: --check and --list-different are mutually exclusive in oxfmt 

236 cmd: list[str] = self._get_executable_command(tool_name="oxfmt") + [ 

237 "--list-different", 

238 ] 

239 

240 # Add Lintro config injection args if available 

241 config_args = self._build_config_args() 

242 if config_args: 

243 cmd.extend(config_args) 

244 logger.debug("[OxfmtPlugin] Using Lintro config injection") 

245 

246 # Add oxfmt-specific CLI arguments from options 

247 oxfmt_args = self._build_oxfmt_args(merged_options) 

248 if oxfmt_args: 

249 cmd.extend(oxfmt_args) 

250 

251 cmd.extend(ctx.rel_files) 

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

253 

254 try: 

255 result = self._run_subprocess( 

256 cmd=cmd, 

257 timeout=ctx.timeout, 

258 cwd=ctx.cwd, 

259 ) 

260 except subprocess.TimeoutExpired: 

261 return self._create_timeout_result(timeout_val=ctx.timeout, cwd=ctx.cwd) 

262 

263 output: str = result[1] 

264 issues: list[OxfmtIssue] = parse_oxfmt_output(output=output) 

265 issues_count: int = len(issues) 

266 success: bool = issues_count == 0 

267 

268 # Standardize: suppress oxfmt's informational output when no issues 

269 final_output: str | None = output 

270 if success: 

271 final_output = None 

272 

273 return ToolResult( 

274 name=self.definition.name, 

275 success=success, 

276 output=final_output, 

277 issues_count=issues_count, 

278 issues=issues, 

279 cwd=ctx.cwd, 

280 ) 

281 

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

283 """Format files with oxfmt. 

284 

285 Args: 

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

287 options: Runtime options that override defaults. 

288 

289 Returns: 

290 ToolResult: Result object with counts and messages. 

291 """ 

292 # Merge runtime options 

293 merged_options = dict(self.options) 

294 merged_options.update(options) 

295 

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

297 ctx = self._prepare_execution( 

298 paths, 

299 merged_options, 

300 no_files_message="No files to format.", 

301 ) 

302 if ctx.should_skip: 

303 assert ctx.early_result is not None 

304 return ctx.early_result 

305 

306 # Get Lintro config injection args 

307 config_args = self._build_config_args() 

308 

309 # Add oxfmt-specific CLI arguments from options 

310 oxfmt_args = self._build_oxfmt_args(merged_options) 

311 

312 # Check for issues first using --list-different 

313 # Note: --check and --list-different are mutually exclusive in oxfmt 

314 check_cmd: list[str] = self._get_executable_command(tool_name="oxfmt") + [ 

315 "--list-different", 

316 ] 

317 if config_args: 

318 check_cmd.extend(config_args) 

319 if oxfmt_args: 

320 check_cmd.extend(oxfmt_args) 

321 check_cmd.extend(ctx.rel_files) 

322 logger.debug( 

323 f"[OxfmtPlugin] Checking: {' '.join(check_cmd)} (cwd={ctx.cwd})", 

324 ) 

325 

326 try: 

327 check_result = self._run_subprocess( 

328 cmd=check_cmd, 

329 timeout=ctx.timeout, 

330 cwd=ctx.cwd, 

331 ) 

332 except subprocess.TimeoutExpired: 

333 return self._create_timeout_result(timeout_val=ctx.timeout, cwd=ctx.cwd) 

334 

335 check_output: str = check_result[1] 

336 

337 # Parse initial issues 

338 initial_issues: list[OxfmtIssue] = parse_oxfmt_output(output=check_output) 

339 initial_count: int = len(initial_issues) 

340 

341 # Now fix the issues 

342 fix_cmd: list[str] = self._get_executable_command(tool_name="oxfmt") + [ 

343 "--write", 

344 ] 

345 if config_args: 

346 fix_cmd.extend(config_args) 

347 if oxfmt_args: 

348 fix_cmd.extend(oxfmt_args) 

349 fix_cmd.extend(ctx.rel_files) 

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

351 

352 try: 

353 fix_result = self._run_subprocess( 

354 cmd=fix_cmd, 

355 timeout=ctx.timeout, 

356 cwd=ctx.cwd, 

357 ) 

358 except subprocess.TimeoutExpired: 

359 return self._create_timeout_result( 

360 timeout_val=ctx.timeout, 

361 initial_issues=initial_issues, 

362 initial_count=initial_count, 

363 cwd=ctx.cwd, 

364 ) 

365 

366 fix_output: str = fix_result[1] 

367 

368 # Check for remaining issues after fixing 

369 try: 

370 final_check_result = self._run_subprocess( 

371 cmd=check_cmd, 

372 timeout=ctx.timeout, 

373 cwd=ctx.cwd, 

374 ) 

375 except subprocess.TimeoutExpired: 

376 return self._create_timeout_result( 

377 timeout_val=ctx.timeout, 

378 initial_issues=initial_issues, 

379 initial_count=initial_count, 

380 cwd=ctx.cwd, 

381 ) 

382 

383 final_check_output: str = final_check_result[1] 

384 remaining_issues: list[OxfmtIssue] = parse_oxfmt_output( 

385 output=final_check_output, 

386 ) 

387 remaining_count: int = len(remaining_issues) 

388 

389 # Calculate fixed issues 

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

391 

392 # Build output message 

393 output_lines: list[str] = [] 

394 if fixed_count > 0: 

395 output_lines.append(f"Fixed {fixed_count} formatting issue(s)") 

396 

397 if remaining_count > 0: 

398 output_lines.append( 

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

400 ) 

401 for issue in remaining_issues[:5]: 

402 output_lines.append(f" {issue.file} - {issue.message}") 

403 if len(remaining_issues) > 5: 

404 output_lines.append(f" ... and {len(remaining_issues) - 5} more") 

405 elif fixed_count > 0: 

406 # remaining_count == 0 is implied by the elif 

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

408 

409 # Add verbose raw formatting output only when explicitly requested 

410 if ( 

411 merged_options.get("verbose_fix_output", False) 

412 and fix_output 

413 and fix_output.strip() 

414 ): 

415 output_lines.append(f"Formatting output:\n{fix_output}") 

416 

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

418 

419 # Success means no remaining issues 

420 success: bool = remaining_count == 0 

421 

422 return ToolResult( 

423 name=self.definition.name, 

424 success=success, 

425 output=final_output, 

426 issues_count=remaining_count, 

427 issues=remaining_issues or [], 

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 )