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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ The diff command shows you:

### Auto-Detection

`snaplint` automatically detects your linter (flake8, mypy, pylint, or generic format) and creates appropriately named snapshots in `.snaplint/`:
`snaplint` automatically detects your linter (ruff, flake8, mypy, pylint, or generic format) and creates appropriately named snapshots in `.snaplint/`:

```bash
# Each linter gets its own snapshot
ruff check . --output-format concise | snaplint take-snapshot # → .snaplint/snapshot.ruff.json.gz
flake8 . | snaplint take-snapshot # → .snaplint/snapshot.flake8.json.gz
mypy . | snaplint take-snapshot # → .snaplint/snapshot.mypy.json.gz
pylint src/ | snaplint take-snapshot # → .snaplint/snapshot.pylint.json.gz
Expand Down Expand Up @@ -112,6 +113,7 @@ Add to `.pre-commit-config.yaml`:

`snaplint` automatically recognizes output from:

- **ruff** (use `--output-format concise`)
- **flake8** and compatible tools (flake9, etc.)
- **mypy**
- **pylint**
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "snaplint"
version = "0.6.0"
version = "1.0.0"
description = "Snapshot linter errors and track only new issues — perfect for incremental linter adoption on large codebases"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
84 changes: 63 additions & 21 deletions src/snaplint/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import re
import sys
from importlib.metadata import version as get_version
from pathlib import Path
Expand All @@ -11,17 +12,69 @@
from snaplint.render import render_diff
from snaplint.snapshot import build_snapshot_file, read_snapshot, write_snapshot

# Ruff summary line patterns (appear at end of output)
# Examples:
# "Found 8 errors."
# "[*] 6 fixable with the `--fix` option."
RUFF_SUMMARY_RE = re.compile(
r"^(Found \d+ errors?\.|\[\*\] \d+ fixable with the .--fix. option)"
Comment thread
ofekby marked this conversation as resolved.
Outdated
)


def _looks_like_lint_code(text: str) -> bool:
"""Check if text looks like a linter error code (e.g., F401, E501, W293).

A lint code typically starts with a letter and contains at least one digit.
"""
if not text or len(text) < 2:
return False
return text[0].isalpha() and any(c.isdigit() for c in text)


def _has_flake8_style_error(line: str) -> bool:
"""Check if a line matches flake8-style format: path:line:col: CODE message."""
parts = line.split(":")
if len(parts) < 4:
return False

try:
int(parts[1]) # line number
int(parts[2]) # column number
except (ValueError, IndexError):
return False

code_part = parts[3].strip()
Comment thread
ofekby marked this conversation as resolved.
if not code_part:
return False

first_word = code_part.split()[0] if code_part.split() else ""
return _looks_like_lint_code(first_word)


def _detect_linter_from_lines(lines: list[str]) -> str:
"""Detect the linter type from the first few lines of output.
"""Detect the linter type from the output lines.

Returns a linter name like 'ruff', 'flake8', 'mypy', 'pylint', or 'generic'.

Returns a linter name like 'flake8', 'mypy', 'pylint', or 'generic'.
Ruff is detected by its distinctive summary lines at the end:
- "Found X errors."
- "[*] X fixable with the --fix option."
"""
for line in lines[:10]: # Check first 10 lines
has_flake8_style_errors = False

for line in lines:
line = line.strip()
if not line:
continue

# Check for Ruff summary lines (most reliable detection)
if RUFF_SUMMARY_RE.match(line):
return "ruff"

# Check for [*] marker in error lines (Ruff auto-fix indicator)
if "[*]" in line:
return "ruff"

# Check for mypy patterns
if ": error:" in line or ": warning:" in line or ": note:" in line:
if "[" in line and "]" in line:
Expand All @@ -31,24 +84,13 @@ def _detect_linter_from_lines(lines: list[str]) -> str:
if "************* Module" in line:
return "pylint"

# Check for flake8 patterns (most common: path:line:col: CODE message)
parts = line.split(":")
if len(parts) >= 4:
try:
int(parts[1]) # line number
int(parts[2]) # column number
# Check if the next part starts with a code (letter + number)
code_part = parts[3].strip()
if code_part and len(code_part) > 0:
first_word = code_part.split()[0]
if first_word and len(first_word) >= 3:
# Check if it looks like a code
has_letter = first_word[0].isalpha()
has_digit = any(c.isdigit() for c in first_word)
if has_letter and has_digit:
return "flake8"
except (ValueError, IndexError):
pass
# Check for flake8/ruff style errors (path:line:col: CODE message)
if _has_flake8_style_error(line):
has_flake8_style_errors = True

# If we found flake8-style errors but no Ruff indicators, it's flake8
if has_flake8_style_errors:
return "flake8"

return "generic"

Expand Down
2 changes: 1 addition & 1 deletion src/snaplint/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class IssueLine(BaseModel):
model_config = ConfigDict(frozen=True)

original: StrictStr
tool: Literal["flake", "mypy", "pylint", "unknown"]
tool: Literal["flake", "ruff", "mypy", "pylint", "unknown"]
path: NormalizedPath
line: NonNegativeInt
column: NonNegativeInt = 0
Expand Down
26 changes: 21 additions & 5 deletions src/snaplint/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
r"(?P<code>[A-Z]+\d+) (?P<msg>.+)$"
)

# Ruff uses the same format as flake8 but often includes [*] marker
# for auto-fixable issues. Detection at the line level uses [*] marker;
# detection at the stream level uses summary lines.
RUFF_RE: Final[Pattern[str]] = re.compile(
r"^(?P<path>(?:\./)?[^:]+):(?P<line>\d+):(?P<col>\d+): "
r"(?P<code>[A-Z]+\d+) (?P<msg>.+)$"
)
Comment thread
ofekby marked this conversation as resolved.
Outdated

MYPY_RE: Final[Pattern[str]] = re.compile(
r"^(?P<path>[^:]+):(?P<line>\d+):(?:(?P<col>\d+):)? "
r"(?P<level>error|warning|note): (?P<msg>.+?)(?:\s+\[(?P<code>[a-z-]+)\])?$"
Expand Down Expand Up @@ -49,16 +57,24 @@ def _parse_line(line: str) -> IssueLine | None:
if not line:
return None

# Flake8
if match := FLAKE8_RE.match(line):
# Check for Ruff/Flake8 format (they share the same format)
# At the individual line level, we use [*] marker to detect Ruff
# For stream-level detection, see cli._detect_linter_from_lines
if match := RUFF_RE.match(line):
code = str(match.group("code"))
msg = match.group("msg")

# Ruff adds [*] marker for auto-fixable issues
is_ruff = "[*]" in msg
Comment thread
ofekby marked this conversation as resolved.
Outdated

return IssueLine(
original=line,
tool="flake",
tool="ruff" if is_ruff else "flake",
path=str(match.group("path")),
line=int(match.group("line")),
column=int(match.group("col")),
code=str(match.group("code")),
message=_normalize_message(match.group("msg")),
code=code,
message=_normalize_message(msg),
)

# Mypy
Expand Down
Loading