Add script detection, env shebang parser, and opt-in content guessing

This commit is contained in:
2026-04-03 13:55:54 -07:00
parent 63102473da
commit 37502c3083
8 changed files with 337 additions and 7 deletions

View File

@@ -24,6 +24,7 @@ uv run rp <file> # Rich-print a file
uv run rp --help # Show help uv run rp --help # Show help
uv run rp --version # Show version uv run rp --version # Show version
echo '{"a":1}' | uv run rp # Read from stdin echo '{"a":1}' | uv run rp # Read from stdin
cat patch.txt | uv run rp --guess-content # Enable heuristic content guessing
``` ```
### Testing ### Testing
@@ -92,6 +93,7 @@ def detect_type(
path: str | None = None, path: str | None = None,
content: str | None = None, content: str | None = None,
explicit_type: str | None = None, explicit_type: str | None = None,
guess_content: bool = False,
) -> str: ) -> str:
... ...
``` ```
@@ -152,6 +154,11 @@ When adding new file types:
2. Use Pygments lexer name as the value 2. Use Pygments lexer name as the value
3. For special filenames (e.g., `Dockerfile`), add to `_FILENAME_MAP` 3. For special filenames (e.g., `Dockerfile`), add to `_FILENAME_MAP`
Detection stays conservative by default: explicit type, filename map, extension map,
shebang, and JSON detection all run before falling back to `text`. Heuristic
Pygments content guessing is opt-in via `--guess-content` and should remain a
late fallback.
### Rendering ### Rendering
- `markdown` and `md` → use `rich.markdown.Markdown` - `markdown` and `md` → use `rich.markdown.Markdown`
- `json` → use `rich.json.JSON` with fallback to `Syntax` - `json` → use `rich.json.JSON` with fallback to `Syntax`
@@ -190,9 +197,10 @@ rich-print/
## Dependencies ## Dependencies
- `rich>=13.0` — Terminal rendering (Markdown, JSON, Syntax) - `rich>=13.0` — Terminal rendering (Markdown, JSON, Syntax)
- `pygments>=2.13.0` — Lexer detection and syntax lexers
- `typer>=0.12` — CLI framework - `typer>=0.12` — CLI framework
Runtime dependencies. Dev dependencies: `ruff>=0.9`, `mypy>=1.14`, `pytest>=8.0`, `pytest-cov>=5.0`. Runtime dependencies. Dev dependencies: `ruff>=0.9`, `mypy>=1.14`, `pytest>=8.0`, `pytest-cov>=5.0`, `types-pygments>=2.19`.
## Git Workflow ## Git Workflow

View File

@@ -49,6 +49,13 @@ rp --type python script.txt
rp -t json data.txt rp -t json data.txt
``` ```
### Guess from content
```bash
cat patch.txt | rp --guess-content
rp --guess-content snippet.txt
```
### Pager control ### Pager control
```bash ```bash

View File

@@ -22,6 +22,7 @@ classifiers = [
] ]
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"pygments>=2.13.0",
"rich>=13.0", "rich>=13.0",
"typer>=0.12", "typer>=0.12",
] ]
@@ -39,6 +40,7 @@ dev = [
"mypy>=1.14", "mypy>=1.14",
"pytest>=8.0", "pytest>=8.0",
"pytest-cov>=5.0", "pytest-cov>=5.0",
"types-pygments>=2.19.0.20260402",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]

View File

@@ -65,6 +65,13 @@ def main(
help="Use pager for output. Default: auto-detect.", help="Use pager for output. Default: auto-detect.",
), ),
] = None, ] = None,
guess_content: Annotated[
bool,
typer.Option(
"--guess-content/--no-guess-content",
help="Use heuristic content-based type detection as a fallback.",
),
] = False,
version: Annotated[ version: Annotated[
bool | None, bool | None,
typer.Option( typer.Option(
@@ -84,6 +91,7 @@ def main(
theme: Pygments color theme. theme: Pygments color theme.
line_numbers: Show line numbers. Default: auto. line_numbers: Show line numbers. Default: auto.
pager: Use pager for output. Default: auto-detect. pager: Use pager for output. Default: auto-detect.
guess_content: Use heuristic content-based type detection as a fallback.
version: Show version and exit. version: Show version and exit.
""" """
console = Console() console = Console()
@@ -114,7 +122,12 @@ def main(
console.print("[dim]Empty input, nothing to display.[/dim]") console.print("[dim]Empty input, nothing to display.[/dim]")
raise typer.Exit() raise typer.Exit()
file_type = detect_type(path=file_path, content=content, explicit_type=file_type) file_type = detect_type(
path=file_path,
content=content,
explicit_type=file_type,
guess_content=guess_content,
)
options = RenderOptions( options = RenderOptions(
theme=theme, theme=theme,

View File

@@ -3,8 +3,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shlex
from numbers import Real
from pathlib import Path from pathlib import Path
from pygments.lexers import guess_lexer
from pygments.util import ClassNotFound
EXTENSION_MAP: dict[str, str] = { EXTENSION_MAP: dict[str, str] = {
".md": "markdown", ".md": "markdown",
".json": "json", ".json": "json",
@@ -52,13 +57,153 @@ EXTENSION_MAP: dict[str, str] = {
_FILENAME_MAP: dict[str, str] = { _FILENAME_MAP: dict[str, str] = {
"Dockerfile": "docker", "Dockerfile": "docker",
"Makefile": "make", "Makefile": "make",
".bashrc": "bash",
".zshrc": "bash",
".profile": "bash",
} }
_SHEBANG_MAP: dict[str, str] = {
"python": "python",
"python3": "python",
"python2": "python",
"sh": "bash",
"bash": "bash",
"zsh": "bash",
}
_ENV_OPTIONS_WITH_ARG = {
"-C",
"-u",
"--argv0",
"--block-signal",
"--chdir",
"--default-signal",
"--ignore-signal",
"--unset",
}
_ENV_LONG_OPTIONS_WITH_ARG = {
opt.lstrip("-") for opt in _ENV_OPTIONS_WITH_ARG if opt.startswith("--")
}
def _is_env_assignment(part: str) -> bool:
"""Return whether a token looks like an env-style variable assignment."""
name, separator, _ = part.partition("=")
return (
bool(separator)
and bool(name)
and (name[0].isalpha() or name[0] == "_")
and all(char.isalnum() or char == "_" for char in name[1:])
)
def _detect_env_program(parts: list[str]) -> str | None:
"""Return the actual program name from an env-based shebang."""
remaining = parts[1:]
while remaining:
part = remaining.pop(0)
if part.startswith("--split-string="):
try:
remaining = shlex.split(part.partition("=")[2]) + remaining
except ValueError:
return None
continue
if part in {"-S", "--split-string"}:
if not remaining:
return None
try:
remaining = shlex.split(remaining.pop(0)) + remaining
except ValueError:
return None
continue
if part.startswith("-S"):
try:
remaining = shlex.split(part[2:]) + remaining
except ValueError:
return None
continue
if part == "--":
if not remaining:
return None
return Path(remaining[0]).name
if part in _ENV_OPTIONS_WITH_ARG:
if not remaining:
return None
remaining.pop(0)
continue
if any(part.startswith(f"{option}=") for option in _ENV_LONG_OPTIONS_WITH_ARG):
continue
if part.startswith("-"):
continue
if _is_env_assignment(part):
continue
return Path(part).name
return None
def _detect_shebang(content: str) -> str | None:
"""Return a lexer from the first shebang line when recognized."""
lines = content.splitlines()
first_line = lines[0] if lines else content
if not first_line.startswith("#!"):
return None
command = first_line[2:].strip()
if not command:
return None
try:
parts = shlex.split(command)
except ValueError:
return None
if not parts:
return None
program = Path(parts[0]).name
if program == "env":
env_program = _detect_env_program(parts)
if env_program is None:
return None
program = env_program
return _SHEBANG_MAP.get(program)
def _detect_pygments_content(content: str) -> str | None:
"""Return a lexer guessed from content when Pygments is confident."""
try:
lexer = guess_lexer(content)
except ClassNotFound:
return None
aliases: list[str] = getattr(lexer, "aliases", [])
alias = aliases[0] if aliases else None
analyse_text = getattr(lexer, "analyse_text", None)
score_value = analyse_text(content) if callable(analyse_text) else 0.0
score = float(score_value) if isinstance(score_value, Real) else 0.0
if alias == "text" or score < 0.5:
return None
return alias
def detect_type( def detect_type(
path: str | None = None, path: str | None = None,
content: str | None = None, content: str | None = None,
explicit_type: str | None = None, explicit_type: str | None = None,
guess_content: bool = False,
) -> str: ) -> str:
"""Detect the file type for syntax highlighting. """Detect the file type for syntax highlighting.
@@ -66,13 +211,16 @@ def detect_type(
1. Explicit type if provided 1. Explicit type if provided
2. Filename-based mapping (for example, Dockerfile) 2. Filename-based mapping (for example, Dockerfile)
3. Extension-based mapping 3. Extension-based mapping
4. JSON if content starts with '{' or '[' and parses successfully 4. Shebang-based mapping for script content
5. Fallback to 'text' 5. JSON if content starts with '{' or '[' and parses successfully
6. Heuristic content guessing when enabled
7. Fallback to 'text'
Args: Args:
path: File path for filename and extension detection. path: File path for filename and extension detection.
content: File content for JSON detection. content: File content for JSON detection.
explicit_type: Override auto-detection with explicit type. explicit_type: Override auto-detection with explicit type.
guess_content: Enable heuristic content-based lexer guessing.
Returns: Returns:
Pygments lexer name for the detected file type. Pygments lexer name for the detected file type.
@@ -90,6 +238,10 @@ def detect_type(
return EXTENSION_MAP[suffix] return EXTENSION_MAP[suffix]
if content is not None: if content is not None:
shebang_type = _detect_shebang(content)
if shebang_type is not None:
return shebang_type
stripped = content.strip() stripped = content.strip()
if stripped and ( if stripped and (
(stripped[0] == "{" and stripped[-1] == "}") (stripped[0] == "{" and stripped[-1] == "}")
@@ -101,4 +253,9 @@ def detect_type(
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
pass pass
if guess_content:
pygments_type = _detect_pygments_content(content)
if pygments_type is not None:
return pygments_type
return "text" return "text"

View File

@@ -74,6 +74,7 @@ class TestFileInput:
path=str(file), path=str(file),
content="x = 1\n", content="x = 1\n",
explicit_type=None, explicit_type=None,
guess_content=False,
) )
def test_render_called_with_correct_options(self, tmp_file) -> None: def test_render_called_with_correct_options(self, tmp_file) -> None:
@@ -180,6 +181,7 @@ class TestStdinInput:
path=None, path=None,
content="hello\n", content="hello\n",
explicit_type=None, explicit_type=None,
guess_content=False,
) )
def test_stdin_shows_tty_prompt(self) -> None: def test_stdin_shows_tty_prompt(self) -> None:
@@ -216,6 +218,7 @@ class TestTypeOverride:
_, kwargs = mock_detect_type.call_args _, kwargs = mock_detect_type.call_args
assert kwargs["explicit_type"] == "json" assert kwargs["explicit_type"] == "json"
assert kwargs["guess_content"] is False
def test_explicit_type_short_flag(self, tmp_file) -> None: def test_explicit_type_short_flag(self, tmp_file) -> None:
file = tmp_file("def foo(): pass\n", suffix=".txt") file = tmp_file("def foo(): pass\n", suffix=".txt")
@@ -236,6 +239,28 @@ class TestThemeOption:
assert result.exit_code == 0 assert result.exit_code == 0
class TestGuessContentOption:
"""Tests for the guess-content flag."""
def test_guess_content_is_forwarded(self) -> None:
with patch("rp.cli.detect_type") as mock_detect_type:
mock_detect_type.return_value = "diff"
with patch("rp.cli.render"):
runner.invoke(app, ["--guess-content", "--no-pager"], input="hello\n")
_, kwargs = mock_detect_type.call_args
assert kwargs["guess_content"] is True
def test_guess_content_disabled_by_default(self) -> None:
with patch("rp.cli.detect_type") as mock_detect_type:
mock_detect_type.return_value = "text"
with patch("rp.cli.render"):
runner.invoke(app, ["--no-pager"], input="hello\n")
_, kwargs = mock_detect_type.call_args
assert kwargs["guess_content"] is False
class TestLineNumbersOption: class TestLineNumbersOption:
"""Tests for the line number flags.""" """Tests for the line number flags."""

View File

@@ -70,7 +70,13 @@ class TestExtensionMap:
assert detect_type(path="archive.tar.gz") == "text" assert detect_type(path="archive.tar.gz") == "text"
def test_hidden_file(self) -> None: def test_hidden_file(self) -> None:
assert detect_type(path=".bashrc") == "text" assert detect_type(path=".bashrc") == "bash"
def test_zshrc(self) -> None:
assert detect_type(path=".zshrc") == "bash"
def test_profile(self) -> None:
assert detect_type(path=".profile") == "bash"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("suffix", "expected"), ("suffix", "expected"),
@@ -80,8 +86,73 @@ class TestExtensionMap:
assert detect_type(path=f"example{suffix}") == expected assert detect_type(path=f"example{suffix}") == expected
class TestShebangDetection:
"""Priority 4: detect common scripts from shebangs."""
def test_env_python3_shebang(self) -> None:
assert detect_type(content="#!/usr/bin/env python3\nprint('hi')\n") == "python"
def test_env_python3_with_assignment(self) -> None:
assert (
detect_type(content="#!/usr/bin/env FOO=1 python3\nprint('hi')\n")
== "python"
)
def test_env_ignore_environment_before_program(self) -> None:
assert detect_type(content="#!/usr/bin/env -i bash\necho hi\n") == "bash"
def test_env_split_string_shebang(self) -> None:
assert (
detect_type(content="#!/usr/bin/env -S python3 -u\nprint('hi')\n")
== "python"
)
def test_env_compact_split_string_shebang(self) -> None:
assert (
detect_type(content="#!/usr/bin/env -Spython3 -u\nprint('hi')\n")
== "python"
)
def test_env_quoted_split_string_shebang(self) -> None:
assert (
detect_type(
content='#!/usr/bin/env --split-string="python3 -u"\nprint("hi")\n'
)
== "python"
)
def test_env_long_option_with_equals(self) -> None:
assert (
detect_type(content="#!/usr/bin/env --ignore-signal=TERM bash\necho hi\n")
== "bash"
)
def test_direct_python_shebang(self) -> None:
assert detect_type(content="#!/usr/bin/python3\nprint('hi')\n") == "python"
def test_bash_shebang(self) -> None:
assert detect_type(content="#!/bin/bash\necho hi\n") == "bash"
def test_sh_shebang(self) -> None:
assert detect_type(content="#!/bin/sh\necho hi\n") == "bash"
def test_zsh_shebang(self) -> None:
assert detect_type(content="#!/bin/zsh\necho hi\n") == "bash"
def test_extension_wins_over_shebang(self) -> None:
assert (
detect_type(path="script.py", content="#!/bin/bash\necho hi\n") == "python"
)
def test_filename_wins_over_shebang(self) -> None:
assert detect_type(path=".bashrc", content="#!/usr/bin/env python3\n") == "bash"
def test_unrecognized_shebang_falls_through(self) -> None:
assert detect_type(content="#!/usr/bin/env ruby\nputs 'hi'\n") == "text"
class TestContentJsonDetection: class TestContentJsonDetection:
"""Priority 4: detect JSON from content when path gives no clue.""" """Priority 5: detect JSON from content when path gives no clue."""
def test_json_object(self, sample_json: str) -> None: def test_json_object(self, sample_json: str) -> None:
assert detect_type(content=sample_json) == "json" assert detect_type(content=sample_json) == "json"
@@ -104,9 +175,31 @@ class TestContentJsonDetection:
def test_content_with_valid_path_takes_priority(self) -> None: def test_content_with_valid_path_takes_priority(self) -> None:
assert detect_type(path="script.py", content='{"a": 1}') == "python" assert detect_type(path="script.py", content='{"a": 1}') == "python"
def test_shebang_takes_priority_over_json(self) -> None:
assert detect_type(content='#!/usr/bin/env python3\n{"a": 1}\n') == "python"
class TestGuessContent:
"""Priority 6: optional heuristic content guessing."""
def test_guess_content_disabled_by_default(self) -> None:
diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n"
assert detect_type(content=diff) == "text"
def test_guess_content_detects_diff(self) -> None:
diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n"
assert detect_type(content=diff, guess_content=True) == "diff"
def test_guess_content_rejects_low_confidence_false_positive(self) -> None:
python_snippet = "def f():\n return 1\n"
assert detect_type(content=python_snippet, guess_content=True) == "text"
def test_json_still_wins_over_guess_content(self, sample_json: str) -> None:
assert detect_type(content=sample_json, guess_content=True) == "json"
class TestFallback: class TestFallback:
"""Priority 5: fallback to 'text'.""" """Priority 7: fallback to 'text'."""
def test_no_args(self) -> None: def test_no_args(self) -> None:
assert detect_type() == "text" assert detect_type() == "text"

25
uv.lock generated
View File

@@ -342,6 +342,7 @@ name = "rp"
version = "0.2.3" version = "0.2.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "pygments" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { name = "typer" },
] ]
@@ -352,10 +353,12 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-pygments" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "pygments", specifier = ">=2.13.0" },
{ name = "rich", specifier = ">=13.0" }, { name = "rich", specifier = ">=13.0" },
{ name = "typer", specifier = ">=0.12" }, { name = "typer", specifier = ">=0.12" },
] ]
@@ -366,6 +369,7 @@ dev = [
{ name = "pytest", specifier = ">=8.0" }, { name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" }, { name = "pytest-cov", specifier = ">=5.0" },
{ name = "ruff", specifier = ">=0.9" }, { name = "ruff", specifier = ">=0.9" },
{ name = "types-pygments", specifier = ">=2.19.0.20260402" },
] ]
[[package]] [[package]]
@@ -417,6 +421,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
] ]
[[package]]
name = "types-docutils"
version = "0.22.3.20260322"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" },
]
[[package]]
name = "types-pygments"
version = "2.19.0.20260402"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-docutils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/a8/5834c55d900ce31b31367eedb82e664347dfa551a957b74a0ce0cd9f4f9a/types_pygments-2.19.0.20260402.tar.gz", hash = "sha256:bd26e1f662c9a3b8ea56668ddc099b809ffd54931bee97b15853bc4a8e5d4250", size = 18808, upload-time = "2026-04-02T04:21:13.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/f2/b23659df4219fc4f39a45ede527cc209d961c7ff957678536a7a9d9ac9c5/types_pygments-2.19.0.20260402-py3-none-any.whl", hash = "sha256:5b0d863cec1c43ba38c946fb6e89d389c1cf287806f72360336fba4482f8daeb", size = 25672, upload-time = "2026-04-02T04:21:11.916Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"