Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions marimo/_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1289,40 +1289,56 @@ def shell_completion() -> None:
type=bool,
help="Ignore files that are not recognizable as marimo notebooks.",
)
@click.option(
"--format",
"formatter",
default="full",
show_default=True,
type=click.Choice(["full", "json"], case_sensitive=False),
help="Output format for diagnostics.",
)
@click.argument("files", nargs=-1, type=click.UNPROCESSED)
def check(
fix: bool,
strict: bool,
verbose: bool,
unsafe_fixes: bool,
ignore_scripts: bool,
formatter: str,
files: tuple[str, ...],
) -> None:
if not files:
# If no files are provided, we lint the current directory
files = ("**/*.py", "**/*.md", "**/*.qmd")

# Pass click.echo directly as pipe for streaming output, or None
pipe = click.echo if verbose else None
# Pass click.echo directly as pipe for streaming output, or None for JSON
pipe = click.echo if verbose and formatter != "json" else None
linter = run_check(
files,
pipe=pipe,
fix=fix,
unsafe_fixes=unsafe_fixes,
ignore_scripts=ignore_scripts,
formatter=formatter,
)

# Get counts from linter (fix happens automatically during streaming)
fixed = linter.fixed_count
total_issues = linter.issues_count

# Final summary
if fixed > 0:
click.echo(f"Updated {fixed} file{'s' if fixed > 1 else ''}.")
if total_issues > 0:
click.echo(
f"Found {total_issues} issue{'s' if total_issues > 1 else ''}."
)
if formatter == "json":
# JSON output - let linter handle the collection and formatting
result = linter.get_json_result()
# Always output to stdout for JSON, regardless of errors
click.echo(json.dumps(result), err=False)
else:
# Get counts from linter (fix happens automatically during streaming)
fixed = linter.fixed_count
total_issues = linter.issues_count

# Final summary
if fixed > 0:
click.echo(f"Updated {fixed} file{'s' if fixed > 1 else ''}.")
if total_issues > 0:
click.echo(
f"Found {total_issues} issue{'s' if total_issues > 1 else ''}."
)

if linter.errored or (strict and (fixed > 0 or total_issues > 0)):
sys.exit(1)
Expand Down
3 changes: 3 additions & 0 deletions marimo/_lint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def run_check(
fix: bool = False,
unsafe_fixes: bool = False,
ignore_scripts: bool = False,
formatter: str = "full",
) -> Linter:
"""Run linting checks on files matching patterns (CLI entry point).

Expand All @@ -39,6 +40,7 @@ def run_check(
fix: Whether to fix files automatically
unsafe_fixes: Whether to enable unsafe fixes that may change behavior
ignore_scripts: Whether to ignore files not recognizable as marimo notebooks
formatter: Output format for diagnostics ("full" or "json")

Returns:
Linter with per-file status and diagnostics
Expand All @@ -51,6 +53,7 @@ def run_check(
fix_files=fix,
unsafe_fixes=unsafe_fixes,
ignore_scripts=ignore_scripts,
formatter=formatter,
)
linter.run_streaming(files_to_check)
return linter
Expand Down
20 changes: 13 additions & 7 deletions marimo/_lint/diagnostic.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,29 @@ def format(
"""Format the diagnostic for display.

Args:
filename: The filename where the diagnostic occurred (optional)
code_lines: Optional source code lines for context
formatter: The formatter to use ("full" is the only supported option)
formatter: The formatter to use ("full" or "json")

Returns:
Formatted diagnostic string
"""
from marimo._lint.formatter import FullFormatter
from marimo._lint.formatters import (
DiagnosticFormatter,
FullFormatter,
JSONFormatter,
)

actual_filename = self.filename or "unknown"

if formatter == "full":
fmt = FullFormatter()
# Use stored filename if available, then parameter, then "unknown"
actual_filename = self.filename or "unknown"
return fmt.format(self, actual_filename, code_lines)
fmt: DiagnosticFormatter = FullFormatter()
elif formatter == "json":
fmt = JSONFormatter()
else:
raise ValueError(f"Unsupported formatter: {formatter}")

return fmt.format(self, actual_filename, code_lines)

@property
def sorted_lines(self) -> tuple[tuple[int], tuple[int]]:
"""Get sorted line numbers as a list."""
Expand Down
24 changes: 24 additions & 0 deletions marimo/_lint/formatters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2025 Marimo. All rights reserved.
"""Formatters for diagnostic output."""

from marimo._lint.formatters.base import DiagnosticFormatter
from marimo._lint.formatters.full import FullFormatter
from marimo._lint.formatters.json import (
DiagnosticJSON,
FileErrorJSON,
IssueJSON,
JSONFormatter,
LintResultJSON,
SummaryJSON,
)

__all__ = [
"DiagnosticFormatter",
"FullFormatter",
"JSONFormatter",
"DiagnosticJSON",
"FileErrorJSON",
"IssueJSON",
"LintResultJSON",
"SummaryJSON",
]
24 changes: 24 additions & 0 deletions marimo/_lint/formatters/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2025 Marimo. All rights reserved.
"""Base formatter interface."""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from marimo._lint.diagnostic import Diagnostic


class DiagnosticFormatter(ABC):
"""Abstract base class for formatting diagnostics."""

@abstractmethod
def format(
self,
diagnostic: Diagnostic,
filename: str,
code_lines: list[str] | None = None,
) -> str:
"""Format a diagnostic for display."""
pass
31 changes: 5 additions & 26 deletions marimo/_lint/formatter.py → marimo/_lint/formatters/full.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,19 @@
# Copyright 2024 Marimo. All rights reserved.
# Copyright 2025 Marimo. All rights reserved.
"""Full formatter for rich terminal output."""

from __future__ import annotations

import os
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

from marimo._cli.print import bold, cyan, light_blue, red, yellow
from marimo._lint.diagnostic import Severity
from marimo._lint.formatters.base import DiagnosticFormatter

if TYPE_CHECKING:
from marimo._lint.diagnostic import Diagnostic


class DiagnosticFormatter(ABC):
"""Abstract base class for formatting diagnostics."""

@abstractmethod
def format(
self,
diagnostic: Diagnostic,
filename: str,
code_lines: list[str] | None = None,
) -> str:
"""Format a diagnostic for display.

Args:
diagnostic: The diagnostic to format
filename: The filename where the diagnostic occurred
code_lines: Optional source code lines for context

Returns:
Formatted diagnostic string
"""
pass


class FullFormatter(DiagnosticFormatter):
"""Full formatter that shows diagnostics with code context and colors."""

Expand Down Expand Up @@ -110,4 +89,4 @@ def format(

if diagnostic.fix:
context_lines.append(light_blue("hint: ") + bold(diagnostic.fix))
return f"{header}\n" + "\n".join(context_lines)
return f"{header}\n" + "\n".join(context_lines) + "\n"
107 changes: 107 additions & 0 deletions marimo/_lint/formatters/json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Copyright 2025 Marimo. All rights reserved.
"""JSON formatter and types for lint output."""

from __future__ import annotations

import json
from typing import TYPE_CHECKING, Literal, TypedDict, Union

from marimo._lint.formatters.base import DiagnosticFormatter
from marimo._types.ids import CellId_t

if TYPE_CHECKING:
from marimo._lint.diagnostic import Diagnostic


class DiagnosticJSON(TypedDict, total=False):
"""Typed structure for diagnostic JSON output."""

# Required fields
type: Literal["diagnostic"]
message: str
filename: str
line: int
column: int

# Optional fields
lines: list[int]
columns: list[int]
severity: Literal["formatting", "runtime", "breaking"]
name: str
code: str
fixable: Union[bool, Literal["unsafe"]]
fix: str
cell_id: list[CellId_t]


class FileErrorJSON(TypedDict):
"""Typed structure for file-level errors."""

type: Literal["error"]
filename: str
error: str


class SummaryJSON(TypedDict):
"""Typed structure for summary JSON output."""

total_files: int
files_with_issues: int
total_issues: int
fixed_issues: int
errored: bool


# Union type for all issue types
IssueJSON = Union[DiagnosticJSON, FileErrorJSON]


class LintResultJSON(TypedDict):
"""Typed structure for complete lint result JSON output."""

issues: list[IssueJSON]
summary: SummaryJSON


class JSONFormatter(DiagnosticFormatter):
"""JSON formatter that outputs diagnostics as structured JSON."""

def format(
self,
diagnostic: Diagnostic,
filename: str,
code_lines: list[str] | None = None, # noqa: ARG002
) -> str:
"""Format the diagnostic as JSON."""
return json.dumps(
self.to_json_dict(diagnostic, filename), ensure_ascii=False
)

def to_json_dict(
self, diagnostic: Diagnostic, filename: str
) -> DiagnosticJSON:
"""Convert diagnostic to typed JSON dictionary."""
lines, columns = diagnostic.sorted_lines

# Build complete dict with all fields
result = {
"type": "diagnostic",
"message": diagnostic.message,
"filename": filename,
"line": lines[0] if lines else 0,
"column": columns[0] if columns else 0,
"lines": list(lines) if len(lines) > 1 else None,
"columns": list(columns) if len(columns) > 1 else None,
"severity": diagnostic.severity.value
if diagnostic.severity
else None,
"name": diagnostic.name,
"code": diagnostic.code,
"fixable": diagnostic.fixable,
"fix": diagnostic.fix,
"cell_id": diagnostic.cell_id,
}

# Filter out None values and return as typed dict
filtered = {k: v for k, v in result.items() if v is not None}
return DiagnosticJSON(filtered) # type: ignore
Loading
Loading