diff --git a/marimo/_cli/cli.py b/marimo/_cli/cli.py index 63da2580c66..87d3eb8c622 100644 --- a/marimo/_cli/cli.py +++ b/marimo/_cli/cli.py @@ -1289,6 +1289,14 @@ 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, @@ -1296,33 +1304,41 @@ def check( 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) diff --git a/marimo/_lint/__init__.py b/marimo/_lint/__init__.py index 1df953d7114..10e03e56369 100644 --- a/marimo/_lint/__init__.py +++ b/marimo/_lint/__init__.py @@ -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). @@ -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 @@ -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 diff --git a/marimo/_lint/diagnostic.py b/marimo/_lint/diagnostic.py index 95a2dec2c43..4c43bea8b10 100644 --- a/marimo/_lint/diagnostic.py +++ b/marimo/_lint/diagnostic.py @@ -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.""" diff --git a/marimo/_lint/formatters/__init__.py b/marimo/_lint/formatters/__init__.py new file mode 100644 index 00000000000..93eea1e4d69 --- /dev/null +++ b/marimo/_lint/formatters/__init__.py @@ -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", +] diff --git a/marimo/_lint/formatters/base.py b/marimo/_lint/formatters/base.py new file mode 100644 index 00000000000..4383df494b0 --- /dev/null +++ b/marimo/_lint/formatters/base.py @@ -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 diff --git a/marimo/_lint/formatter.py b/marimo/_lint/formatters/full.py similarity index 81% rename from marimo/_lint/formatter.py rename to marimo/_lint/formatters/full.py index 7525faf3cbf..887fa05cc25 100644 --- a/marimo/_lint/formatter.py +++ b/marimo/_lint/formatters/full.py @@ -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.""" @@ -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" diff --git a/marimo/_lint/formatters/json.py b/marimo/_lint/formatters/json.py new file mode 100644 index 00000000000..ab6188a911d --- /dev/null +++ b/marimo/_lint/formatters/json.py @@ -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 diff --git a/marimo/_lint/linter.py b/marimo/_lint/linter.py index c6688344a5c..524ebcf76a2 100644 --- a/marimo/_lint/linter.py +++ b/marimo/_lint/linter.py @@ -13,6 +13,7 @@ from marimo._cli.print import red from marimo._convert.converters import MarimoConvert from marimo._lint.diagnostic import Diagnostic, Severity +from marimo._lint.formatters import LintResultJSON from marimo._lint.rule_engine import EarlyStoppingConfig, RuleEngine from marimo._loggers import capture_output from marimo._schemas.serialization import NotebookSerialization @@ -85,6 +86,7 @@ def __init__( unsafe_fixes: bool = False, rules: list[LintRule] | None = None, ignore_scripts: bool = False, + formatter: str = "full", ): if rules is not None: self.rule_engine = RuleEngine(rules, early_stopping) @@ -94,6 +96,7 @@ def __init__( self.fix_files = fix_files self.unsafe_fixes = unsafe_fixes self.ignore_scripts = ignore_scripts + self.formatter = formatter self.files: list[FileStatus] = [] # Create rule lookup for unsafe fixes @@ -246,8 +249,7 @@ def _pipe_file_status(self, file_status: FileStatus) -> None: else: # Show diagnostics immediately as they're found for diagnostic in file_status.diagnostics: - self.pipe(diagnostic.format()) - self.pipe("") # Empty line for spacing + self.pipe(diagnostic.format(formatter=self.formatter)) @staticmethod def _generate_file_contents_from_notebook( @@ -359,3 +361,49 @@ async def fix(self, file_status: FileStatus) -> bool: return True return False + + def get_json_result(self) -> LintResultJSON: + """Get complete JSON result with diagnostics and summary.""" + from marimo._lint.formatters import ( + FileErrorJSON, + IssueJSON, + JSONFormatter, + ) + + json_formatter = JSONFormatter() + issues: list[IssueJSON] = [] + + for file_status in self.files: + if file_status.failed: + # Add file-level errors + error: FileErrorJSON = { + "type": "error", + "filename": file_status.file, + "error": file_status.message, + } + issues.append(error) + elif not file_status.skipped: + # Add diagnostics from successfully processed files + for diagnostic in file_status.diagnostics: + diagnostic_dict = json_formatter.to_json_dict( + diagnostic, file_status.file + ) + issues.append(diagnostic_dict) + + return LintResultJSON( + issues=issues, + summary={ + "total_files": len(self.files), + "files_with_issues": len( + [ + f + for f in self.files + if (f.diagnostics and not f.skipped and not f.failed) + or f.failed + ] + ), + "total_issues": self.issues_count, + "fixed_issues": self.fixed_count, + "errored": self.errored, + }, + ) diff --git a/tests/_lint/snapshots/cycle_dependencies_errors.txt b/tests/_lint/snapshots/cycle_dependencies_errors.txt index 00cfbf7e51c..f6cdccfa38e 100644 --- a/tests/_lint/snapshots/cycle_dependencies_errors.txt +++ b/tests/_lint/snapshots/cycle_dependencies_errors.txt @@ -13,4 +13,4 @@ critical[cycle-dependencies]: Cell is part of a circular dependency 20 | def _(y): 21 | z = y + 1 | ^ - 22 | return (z,) \ No newline at end of file + 22 | return (z,) diff --git a/tests/_lint/snapshots/empty_cells.txt b/tests/_lint/snapshots/empty_cells.txt index 7deb70e5cce..ecde583404a 100644 --- a/tests/_lint/snapshots/empty_cells.txt +++ b/tests/_lint/snapshots/empty_cells.txt @@ -4,27 +4,31 @@ warning[empty-cells]: Empty cell can be removed (contains only whitespace, comme 9 | return | ^ 10 | + warning[empty-cells]: Empty cell can be removed (contains only whitespace, comments, or pass) --> tests/_lint/test_files/empty_cells.py:12:0 12 | @app.cell 13 | def has_pass(): | ^ 14 | # This is just a comment + warning[empty-cells]: Empty cell can be removed (contains only whitespace, comments, or pass) --> tests/_lint/test_files/empty_cells.py:19:0 19 | @app.cell 20 | def has_comment(): | ^ 21 | # Only comment + warning[empty-cells]: Empty cell can be removed (contains only whitespace, comments, or pass) --> tests/_lint/test_files/empty_cells.py:26:0 26 | @app.cell 27 | def has_mix(): | ^ 28 | + warning[empty-cells]: Empty cell can be removed (contains only whitespace, comments, or pass) --> tests/_lint/test_files/empty_cells.py:34:0 34 | @app.cell 35 | def _empty_cell_with_just_whitespace(): | ^ - 36 | \ No newline at end of file + 36 | diff --git a/tests/_lint/snapshots/formatting.txt b/tests/_lint/snapshots/formatting.txt index 2368df0c762..3d3995ab08d 100644 --- a/tests/_lint/snapshots/formatting.txt +++ b/tests/_lint/snapshots/formatting.txt @@ -4,30 +4,35 @@ warning[general-formatting]: `marimo` is typically not imported with an alias. 3 | import marimo as mo # intentional to test import aliasing | ^ 4 | + warning[general-formatting]: Expected `__generated_with` assignment for marimo version number. --> tests/_lint/test_files/formatting.py:4:1 4 | 5 | statement = "not in a cell, so unexpected" | ^ 6 | + warning[general-formatting]: Unexpected statement, expected App initialization. --> tests/_lint/test_files/formatting.py:4:1 4 | 5 | statement = "not in a cell, so unexpected" | ^ 6 | + warning[general-formatting]: Unexpected statement, expected cell definitions. --> tests/_lint/test_files/formatting.py:9:1 9 | 10 | statement = "not in a cell, so unexpected" | ^ 11 | + warning[general-formatting]: Expected run guard statement --> tests/_lint/test_files/formatting.py 1 | """Example to test formatting issues.""" + warning[empty-cells]: Empty cell can be removed (contains only whitespace, comments, or pass) --> tests/_lint/test_files/formatting.py:13:0 13 | @app.cell 14 | def _(): | ^ - 15 | pass \ No newline at end of file + 15 | pass diff --git a/tests/_lint/snapshots/multiple_definitions_errors.txt b/tests/_lint/snapshots/multiple_definitions_errors.txt index 07814e15078..a3b1874ff05 100644 --- a/tests/_lint/snapshots/multiple_definitions_errors.txt +++ b/tests/_lint/snapshots/multiple_definitions_errors.txt @@ -9,4 +9,4 @@ critical[multiple-definitions]: Variable 'x' is defined in multiple cells 16 | x = 2 # This should trigger MR001 - multiple definitions | ^ 17 | return -hint: Variables must be unique across cells. Alternatively, they can be private with an underscore prefix (i.e. `_x`.) \ No newline at end of file +hint: Variables must be unique across cells. Alternatively, they can be private with an underscore prefix (i.e. `_x`.) diff --git a/tests/_lint/snapshots/setup_dependencies_errors.txt b/tests/_lint/snapshots/setup_dependencies_errors.txt index 061d3141df1..78b5586a886 100644 --- a/tests/_lint/snapshots/setup_dependencies_errors.txt +++ b/tests/_lint/snapshots/setup_dependencies_errors.txt @@ -4,9 +4,10 @@ critical[setup-cell-dependencies]: Setup cell cannot have dependencies 9 | y = x + 1 # This should trigger MR003 - setup cell dependencies | ^ 10 | + warning[general-formatting]: Unexpected statement, expected cell definitions. --> tests/_lint/test_files/setup_dependencies.py:5:1 5 | 6 | x = 1 | ^ - 7 | \ No newline at end of file + 7 | diff --git a/tests/_lint/snapshots/sql_parsing_errors.txt b/tests/_lint/snapshots/sql_parsing_errors.txt index a100c01f52a..4a29610f32e 100644 --- a/tests/_lint/snapshots/sql_parsing_errors.txt +++ b/tests/_lint/snapshots/sql_parsing_errors.txt @@ -3,4 +3,4 @@ warning[sql-parse-error]: Expecting ). Line 19, Col: 32. 36 | AND 37 | descendants NOT NULL | ^ - 38 | ) \ No newline at end of file + 38 | ) diff --git a/tests/_lint/snapshots/star_import_errors.txt b/tests/_lint/snapshots/star_import_errors.txt index 0d1e7706d67..08e0f742429 100644 --- a/tests/_lint/snapshots/star_import_errors.txt +++ b/tests/_lint/snapshots/star_import_errors.txt @@ -5,6 +5,7 @@ critical[invalid-syntax]: Importing symbols with `import *` is not allowed in ma | ^ 11 | result = sin(pi / 2) hint: Star imports are incompatible with marimo's reactive execution. Use 'import module' and access members with dot notation instead. See: https://docs.marimo.io/guides/understanding_errors/import_star/ + critical[invalid-syntax]: Importing symbols with `import *` is not allowed in marimo. --> tests/_lint/test_files/star_import.py:16:1 16 | def _(): @@ -12,10 +13,11 @@ critical[invalid-syntax]: Importing symbols with `import *` is not allowed in ma | ^ 18 | df = DataFrame({"a": [1, 2, 3]}) hint: Star imports are incompatible with marimo's reactive execution. Use 'import module' and access members with dot notation instead. See: https://docs.marimo.io/guides/understanding_errors/import_star/ + critical[invalid-syntax]: Importing symbols with `import *` is not allowed in marimo. --> tests/_lint/test_files/star_import.py:44:1 44 | # comments 45 | from os import * | ^ 46 | current_dir = getcwd() -hint: Star imports are incompatible with marimo's reactive execution. Use 'import module' and access members with dot notation instead. See: https://docs.marimo.io/guides/understanding_errors/import_star/ \ No newline at end of file +hint: Star imports are incompatible with marimo's reactive execution. Use 'import module' and access members with dot notation instead. See: https://docs.marimo.io/guides/understanding_errors/import_star/ diff --git a/tests/_lint/snapshots/syntax_errors.txt b/tests/_lint/snapshots/syntax_errors.txt index 93a44cb8452..5b4b110e8fd 100644 --- a/tests/_lint/snapshots/syntax_errors.txt +++ b/tests/_lint/snapshots/syntax_errors.txt @@ -4,10 +4,11 @@ critical[invalid-syntax]: name 'tickers' is assigned to before global declaratio 10 | global tickers # | ^ 11 | + critical[invalid-syntax]: 'return' outside function --> tests/_lint/test_files/syntax_errors.py:15:5 15 | if tickers is not None: 16 | return tickers | ^ 17 | return -hint: marimo cells are not normal Python functions; treat cell bodies as top-level code, or use `@app.function` to define a pure function. \ No newline at end of file +hint: marimo cells are not normal Python functions; treat cell bodies as top-level code, or use `@app.function` to define a pure function. diff --git a/tests/_lint/snapshots/unparsable_cell_errors.txt b/tests/_lint/snapshots/unparsable_cell_errors.txt index db5ac4479e8..017604a5bcc 100644 --- a/tests/_lint/snapshots/unparsable_cell_errors.txt +++ b/tests/_lint/snapshots/unparsable_cell_errors.txt @@ -3,4 +3,4 @@ critical[unparsable-cells]: Notebook contains unparsable code 14 | app._unparsable_cell(""" 15 | x = 1 + # Syntax error | ^ - 16 | """) \ No newline at end of file + 16 | """) diff --git a/tests/_lint/test_json_formatter.py b/tests/_lint/test_json_formatter.py new file mode 100644 index 00000000000..2cb0a8a3d78 --- /dev/null +++ b/tests/_lint/test_json_formatter.py @@ -0,0 +1,463 @@ +# Copyright 2025 Marimo. All rights reserved. +"""Unit tests for the JSON formatter.""" + +import json +from pathlib import Path + +from marimo._lint import run_check +from marimo._lint.diagnostic import Diagnostic, Severity +from marimo._lint.formatters import JSONFormatter + + +class TestJSONFormatter: + """Test the JSONFormatter class.""" + + def test_json_formatter_basic(self): + """Test basic JSON formatting of a diagnostic.""" + diagnostic = Diagnostic( + message="Test error message", + line=10, + column=5, + code="MB001", + name="test-error", + severity=Severity.BREAKING, + fixable=False, + fix="Test fix hint", + cell_id=["test-cell-id"], + ) + + formatter = JSONFormatter() + result = formatter.format(diagnostic, "test.py") + + # Parse JSON to verify it's valid + data = json.loads(result) + + assert data["type"] == "diagnostic" + assert data["message"] == "Test error message" + assert data["filename"] == "test.py" + assert data["line"] == 10 + assert data["column"] == 5 + assert data["severity"] == "breaking" + assert data["name"] == "test-error" + assert data["code"] == "MB001" + assert data["fixable"] is False + assert data["fix"] == "Test fix hint" + assert data["cell_id"] == ["test-cell-id"] + + def test_json_formatter_multiple_lines_columns(self): + """Test JSON formatting with multiple lines and columns.""" + diagnostic = Diagnostic( + message="Multiple location error", + line=[1, 5, 10], + column=[1, 3, 7], + code="MB002", + name="multiple-definitions", + severity=Severity.BREAKING, + fixable=False, + cell_id=["cell1", "cell2", "cell3"], + ) + + formatter = JSONFormatter() + result = formatter.format(diagnostic, "multi.py") + + data = json.loads(result) + + assert data["type"] == "diagnostic" + assert data["line"] == 1 # First line + assert data["column"] == 1 # First column + assert data["lines"] == [1, 5, 10] # All lines + assert data["columns"] == [1, 3, 7] # All columns + + def test_json_formatter_minimal_diagnostic(self): + """Test JSON formatting with minimal diagnostic information.""" + diagnostic = Diagnostic( + message="Minimal error", + line=1, + column=1, + ) + + formatter = JSONFormatter() + result = formatter.format(diagnostic, "minimal.py") + + data = json.loads(result) + + assert data["type"] == "diagnostic" + assert data["message"] == "Minimal error" + assert data["filename"] == "minimal.py" + assert data["line"] == 1 + assert data["column"] == 1 + # Optional fields should not be present if None + assert "code" not in data + assert "name" not in data + assert "severity" not in data + assert "fixable" not in data + assert "fix" not in data + assert "cell_id" not in data + + def test_json_formatter_with_unsafe_fixable(self): + """Test JSON formatting with unsafe fixable diagnostic.""" + diagnostic = Diagnostic( + message="Unsafe fix available", + line=5, + column=2, + code="MF001", + name="empty-cell", + severity=Severity.FORMATTING, + fixable="unsafe", + fix="Remove empty cell", + ) + + formatter = JSONFormatter() + result = formatter.format(diagnostic, "unsafe.py") + + data = json.loads(result) + + assert data["type"] == "diagnostic" + assert data["fixable"] == "unsafe" + assert data["severity"] == "formatting" + + +class TestJSONFormatterIntegration: + """Integration tests for JSON formatter with run_check.""" + + def test_run_check_json_format_clean_file(self, tmp_path): + """Test run_check with JSON format on a clean file.""" + tmpdir = tmp_path + notebook_file = Path(tmpdir) / "clean.py" + notebook_content = """import marimo + +__generated_with = "0.15.0" +app = marimo.App() + + +@app.cell +def __(): + x = 1 + return (x,) + + +if __name__ == "__main__": + app.run() +""" + notebook_file.write_text(notebook_content) + + linter = run_check((str(notebook_file),), formatter="json") + result = linter.get_json_result() + + assert "issues" in result + assert "summary" in result + # May have some diagnostics, but check structure is correct + assert isinstance(result["issues"], list) + assert result["summary"]["total_files"] == 1 + assert result["summary"]["fixed_issues"] == 0 + + def test_run_check_json_format_with_issues(self, tmp_path): + """Test run_check with JSON format on a file with issues.""" + tmpdir = tmp_path + notebook_file = Path(tmpdir) / "issues.py" + # Create notebook with duplicate variable definition + notebook_content = """import marimo + +__generated_with = "0.15.0" +app = marimo.App() + +@app.cell +def __(): + import marimo as mo + return + +@app.cell +def __(): + import marimo as mo # Duplicate definition + return +""" + notebook_file.write_text(notebook_content) + + linter = run_check((str(notebook_file),), formatter="json") + result = linter.get_json_result() + + assert "issues" in result + assert "summary" in result + assert len(result["issues"]) > 0 + + # Check first diagnostic structure + diagnostic = result["issues"][0] + assert diagnostic["type"] == "diagnostic" + assert "message" in diagnostic + assert "filename" in diagnostic + assert "line" in diagnostic + assert "column" in diagnostic + assert "severity" in diagnostic + assert "name" in diagnostic + assert "code" in diagnostic + + assert diagnostic["filename"] == str(notebook_file) + assert diagnostic["severity"] == "breaking" + assert "multiple" in diagnostic["message"].lower() + + # Check summary + summary = result["summary"] + assert summary["total_files"] == 1 + assert summary["files_with_issues"] == 1 + assert summary["total_issues"] > 0 + assert summary["errored"] is True + + def test_run_check_json_format_multiple_files(self, tmp_path): + """Test run_check with JSON format on multiple files.""" + tmpdir = tmp_path + # Clean file + clean_file = Path(tmpdir) / "clean.py" + clean_file.write_text("""import marimo + +__generated_with = "0.15.0" +app = marimo.App() + +@app.cell +def __(): + x = 1 + return (x,) +""") + + # File with issues + issues_file = Path(tmpdir) / "issues.py" + issues_file.write_text("""import marimo + +__generated_with = "0.15.0" +app = marimo.App() + +@app.cell +def __(): + import marimo as mo + return + +@app.cell +def __(): + import marimo as mo # Duplicate + return +""") + + # Empty file (should be skipped) + empty_file = Path(tmpdir) / "empty.py" + empty_file.write_text("") + + pattern = str(Path(tmpdir) / "*.py") + linter = run_check((pattern,), formatter="json") + result = linter.get_json_result() + + summary = result["summary"] + assert summary["total_files"] == 3 # All three files processed + # At least the issues.py file has problems, possibly others + assert summary["files_with_issues"] >= 1 + assert summary["total_issues"] > 0 + + # Check that issues contain diagnostics + issues = result["issues"] + assert len(issues) > 0 + + # Should have at least some diagnostics from the issues file + issues_from_file = [ + d for d in issues if str(issues_file) in d["filename"] + ] + assert len(issues_from_file) > 0 + + def test_json_result_is_valid_json(self, tmp_path): + """Test that the complete JSON result is valid JSON.""" + tmpdir = tmp_path + notebook_file = Path(tmpdir) / "test.py" + notebook_content = """import marimo + +__generated_with = "0.15.0" +app = marimo.App() + +@app.cell +def __(): + x = 1 + y = 1 # Same variable name in different lines + return + +@app.cell +def __(): + x = 2 # Multiple definition + return +""" + notebook_file.write_text(notebook_content) + + linter = run_check((str(notebook_file),), formatter="json") + result = linter.get_json_result() + + # Convert to JSON string and parse it back to verify validity + json_string = json.dumps(result) + parsed_back = json.loads(json_string) + + assert parsed_back == result + assert "issues" in parsed_back + assert "summary" in parsed_back + assert isinstance(parsed_back["issues"], list) + assert isinstance(parsed_back["summary"], dict) + + def test_json_formatter_handles_unicode(self): + """Test that JSON formatter handles unicode characters properly.""" + diagnostic = Diagnostic( + message="Error with unicode: 测试 émoji 🚀", + line=1, + column=1, + code="MB001", + name="unicode-test", + severity=Severity.BREAKING, + ) + + formatter = JSONFormatter() + result = formatter.format(diagnostic, "unicode_test.py") + + # Should not raise an exception + data = json.loads(result) + assert "测试 émoji 🚀" in data["message"] + + def test_diagnostic_format_method_json(self): + """Test diagnostic.format() method with JSON formatter.""" + diagnostic = Diagnostic( + message="Test diagnostic format method", + line=5, + column=10, + code="MB001", + name="test-format", + severity=Severity.BREAKING, + filename="test.py", + ) + + result = diagnostic.format(formatter="json") + data = json.loads(result) + + assert data["type"] == "diagnostic" + assert data["message"] == "Test diagnostic format method" + assert data["filename"] == "test.py" + assert data["line"] == 5 + assert data["column"] == 10 + assert data["code"] == "MB001" + + def test_diagnostic_format_method_full(self): + """Test diagnostic.format() method with full formatter.""" + diagnostic = Diagnostic( + message="Test diagnostic format method", + line=5, + column=10, + code="MB001", + name="test-format", + severity=Severity.BREAKING, + filename="test.py", + ) + + result = diagnostic.format(formatter="full") + + # Should be the full formatted string with colors and context + assert "critical[test-format]" in result + assert "Test diagnostic format method" in result + assert "test.py:5:10" in result + + def test_json_formatter_empty_diagnostics_list(self, tmp_path): + """Test JSON result structure when no diagnostics are found.""" + tmpdir = tmp_path + # Create a file that doesn't exist to get empty results + linter = run_check(("nonexistent/**/*.py",), formatter="json") + result = linter.get_json_result() + + assert result["issues"] == [] + assert result["summary"]["total_files"] == 0 + assert result["summary"]["files_with_issues"] == 0 + assert result["summary"]["total_issues"] == 0 + assert result["summary"]["fixed_issues"] == 0 + assert result["summary"]["errored"] is False + + def test_json_format_file_not_found_error(self): + """Test JSON format handling of missing files.""" + linter = run_check(("nonexistent_file.py",), formatter="json") + result = linter.get_json_result() + + assert len(result["issues"]) == 1 + error = result["issues"][0] + assert error["type"] == "error" + assert error["filename"] == "nonexistent_file.py" + assert "File not found" in error["error"] + + # Check summary includes the error + assert result["summary"]["total_files"] == 1 + assert result["summary"]["files_with_issues"] == 1 + assert result["summary"]["errored"] is True + + def test_json_format_syntax_error(self, tmp_path): + """Test JSON format handling of syntax errors.""" + tmpdir = tmp_path + broken_file = Path(tmpdir) / "broken.py" + broken_file.write_text("import marimo\ndef broken(\n pass") + + linter = run_check((str(broken_file),), formatter="json") + result = linter.get_json_result() + + assert len(result["issues"]) == 1 + error = result["issues"][0] + assert error["type"] == "error" + assert error["filename"] == str(broken_file) + assert "Failed to parse" in error["error"] + + # Check summary includes the error + assert result["summary"]["total_files"] == 1 + assert result["summary"]["files_with_issues"] == 1 + assert result["summary"]["errored"] is True + + def test_json_format_mixed_diagnostics_and_errors(self, tmp_path): + """Test JSON format with both diagnostics and file errors.""" + tmpdir = tmp_path + # Working file with linting issues + working_file = Path(tmpdir) / "working.py" + working_file.write_text("""import marimo + +__generated_with = "0.16.1" +app = marimo.App() + +@app.cell +def __(): + import marimo as mo + return + +@app.cell +def __(): + import marimo as mo # Duplicate + return + +if __name__ == "__main__": + app.run() +""") + + # Broken file + broken_file = Path(tmpdir) / "broken.py" + broken_file.write_text("import marimo\ndef broken(\n pass") + + linter = run_check( + (str(working_file), str(broken_file), "missing.py"), + formatter="json", + ) + result = linter.get_json_result() + + # Should have 3 issues: 1 diagnostic + 2 errors + assert len(result["issues"]) == 3 + + # Check diagnostic + diagnostic_issues = [ + i for i in result["issues"] if i["type"] == "diagnostic" + ] + assert len(diagnostic_issues) == 1 + assert diagnostic_issues[0]["severity"] == "breaking" + assert "multiple" in diagnostic_issues[0]["message"].lower() + + # Check errors + error_issues = [i for i in result["issues"] if i["type"] == "error"] + assert len(error_issues) == 2 + + filenames = [e["filename"] for e in error_issues] + assert str(broken_file) in filenames + assert "missing.py" in filenames + + # Check summary + assert result["summary"]["total_files"] == 3 + assert result["summary"]["files_with_issues"] == 3 + assert result["summary"]["errored"] is True