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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Oxfmt tool definition.
3Oxfmt is a fast JavaScript/TypeScript formatter (30x faster than Prettier).
4It formats code with minimal configuration, enforcing a consistent code style.
5"""
7from __future__ import annotations
9import subprocess # nosec B404 - used safely with shell disabled
10from dataclasses import dataclass
11from typing import Any
13from loguru import logger
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)
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]
49@register_tool
50@dataclass
51class OxfmtPlugin(BaseToolPlugin):
52 """Oxfmt code formatter plugin.
54 This plugin integrates oxfmt with Lintro for formatting
55 JavaScript, TypeScript, and Vue files.
56 """
58 @property
59 def definition(self) -> ToolDefinition:
60 """Return the tool definition.
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 )
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.
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.
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")
113 if exclude_patterns is not None:
114 self.exclude_patterns = exclude_patterns.copy()
115 self.include_venv = include_venv
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)
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.
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.
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 )
169 def _build_oxfmt_args(self, options: dict[str, object]) -> list[str]:
170 """Build CLI arguments from options.
172 Args:
173 options: Options dict to build args from (use merged_options).
175 Returns:
176 List of CLI arguments to pass to oxfmt.
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] = []
184 # Config file override
185 config = options.get("config")
186 if config:
187 args.extend(["--config", str(config)])
189 # Ignore file path
190 ignore_path = options.get("ignore_path")
191 if ignore_path:
192 args.extend(["--ignore-path", str(ignore_path)])
194 return args
196 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
197 """Check files with oxfmt without making changes.
199 Args:
200 paths: List of file or directory paths to check.
201 options: Runtime options that override defaults.
203 Returns:
204 ToolResult with check results.
205 """
206 # Merge runtime options
207 merged_options = dict(self.options)
208 merged_options.update(options)
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
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}")
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 ]
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")
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)
251 cmd.extend(ctx.rel_files)
252 logger.debug(f"[OxfmtPlugin] Running: {' '.join(cmd)} (cwd={ctx.cwd})")
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)
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
268 # Standardize: suppress oxfmt's informational output when no issues
269 final_output: str | None = output
270 if success:
271 final_output = None
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 )
282 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
283 """Format files with oxfmt.
285 Args:
286 paths: List of file or directory paths to format.
287 options: Runtime options that override defaults.
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)
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
306 # Get Lintro config injection args
307 config_args = self._build_config_args()
309 # Add oxfmt-specific CLI arguments from options
310 oxfmt_args = self._build_oxfmt_args(merged_options)
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 )
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)
335 check_output: str = check_result[1]
337 # Parse initial issues
338 initial_issues: list[OxfmtIssue] = parse_oxfmt_output(output=check_output)
339 initial_count: int = len(initial_issues)
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})")
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 )
366 fix_output: str = fix_result[1]
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 )
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)
389 # Calculate fixed issues
390 fixed_count: int = max(0, initial_count - remaining_count)
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)")
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")
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}")
417 final_output: str | None = "\n".join(output_lines) if output_lines else None
419 # Success means no remaining issues
420 success: bool = remaining_count == 0
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 )