diff --git a/AGENTS.md b/AGENTS.md index 915d173..5089557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ uv run rp # Rich-print a file uv run rp --help # Show help uv run rp --version # Show version echo '{"a":1}' | uv run rp # Read from stdin +cat patch.txt | uv run rp --guess-content # Enable heuristic content guessing ``` ### Testing @@ -92,6 +93,7 @@ def detect_type( path: str | None = None, content: str | None = None, explicit_type: str | None = None, + guess_content: bool = False, ) -> str: ... ``` @@ -152,6 +154,11 @@ When adding new file types: 2. Use Pygments lexer name as the value 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 - `markdown` and `md` → use `rich.markdown.Markdown` - `json` → use `rich.json.JSON` with fallback to `Syntax` @@ -190,9 +197,10 @@ rich-print/ ## Dependencies - `rich>=13.0` — Terminal rendering (Markdown, JSON, Syntax) +- `pygments>=2.13.0` — Lexer detection and syntax lexers - `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 diff --git a/README.md b/README.md index f0dc286..ee48cb5 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,13 @@ rp --type python script.txt rp -t json data.txt ``` +### Guess from content + +```bash +cat patch.txt | rp --guess-content +rp --guess-content snippet.txt +``` + ### Pager control ```bash diff --git a/pyproject.toml b/pyproject.toml index 7157280..3b32008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ ] requires-python = ">=3.12" dependencies = [ + "pygments>=2.13.0", "rich>=13.0", "typer>=0.12", ] @@ -39,6 +40,7 @@ dev = [ "mypy>=1.14", "pytest>=8.0", "pytest-cov>=5.0", + "types-pygments>=2.19.0.20260402", ] [tool.pytest.ini_options] diff --git a/src/rp/cli.py b/src/rp/cli.py index c29b593..8a151cd 100644 --- a/src/rp/cli.py +++ b/src/rp/cli.py @@ -65,6 +65,13 @@ def main( help="Use pager for output. Default: auto-detect.", ), ] = 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[ bool | None, typer.Option( @@ -84,6 +91,7 @@ def main( theme: Pygments color theme. line_numbers: Show line numbers. Default: auto. pager: Use pager for output. Default: auto-detect. + guess_content: Use heuristic content-based type detection as a fallback. version: Show version and exit. """ console = Console() @@ -114,7 +122,12 @@ def main( console.print("[dim]Empty input, nothing to display.[/dim]") 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( theme=theme, diff --git a/src/rp/detect.py b/src/rp/detect.py index fd3dc00..a53bb47 100644 --- a/src/rp/detect.py +++ b/src/rp/detect.py @@ -3,8 +3,13 @@ from __future__ import annotations import json +import shlex +from numbers import Real from pathlib import Path +from pygments.lexers import guess_lexer +from pygments.util import ClassNotFound + EXTENSION_MAP: dict[str, str] = { ".md": "markdown", ".json": "json", @@ -52,13 +57,153 @@ EXTENSION_MAP: dict[str, str] = { _FILENAME_MAP: dict[str, str] = { "Dockerfile": "docker", "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( path: str | None = None, content: str | None = None, explicit_type: str | None = None, + guess_content: bool = False, ) -> str: """Detect the file type for syntax highlighting. @@ -66,13 +211,16 @@ def detect_type( 1. Explicit type if provided 2. Filename-based mapping (for example, Dockerfile) 3. Extension-based mapping - 4. JSON if content starts with '{' or '[' and parses successfully - 5. Fallback to 'text' + 4. Shebang-based mapping for script content + 5. JSON if content starts with '{' or '[' and parses successfully + 6. Heuristic content guessing when enabled + 7. Fallback to 'text' Args: path: File path for filename and extension detection. content: File content for JSON detection. explicit_type: Override auto-detection with explicit type. + guess_content: Enable heuristic content-based lexer guessing. Returns: Pygments lexer name for the detected file type. @@ -90,6 +238,10 @@ def detect_type( return EXTENSION_MAP[suffix] if content is not None: + shebang_type = _detect_shebang(content) + if shebang_type is not None: + return shebang_type + stripped = content.strip() if stripped and ( (stripped[0] == "{" and stripped[-1] == "}") @@ -101,4 +253,9 @@ def detect_type( except (json.JSONDecodeError, ValueError): pass + if guess_content: + pygments_type = _detect_pygments_content(content) + if pygments_type is not None: + return pygments_type + return "text" diff --git a/tests/test_cli.py b/tests/test_cli.py index 6e6576c..2490e32 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -74,6 +74,7 @@ class TestFileInput: path=str(file), content="x = 1\n", explicit_type=None, + guess_content=False, ) def test_render_called_with_correct_options(self, tmp_file) -> None: @@ -180,6 +181,7 @@ class TestStdinInput: path=None, content="hello\n", explicit_type=None, + guess_content=False, ) def test_stdin_shows_tty_prompt(self) -> None: @@ -216,6 +218,7 @@ class TestTypeOverride: _, kwargs = mock_detect_type.call_args assert kwargs["explicit_type"] == "json" + assert kwargs["guess_content"] is False def test_explicit_type_short_flag(self, tmp_file) -> None: file = tmp_file("def foo(): pass\n", suffix=".txt") @@ -236,6 +239,28 @@ class TestThemeOption: 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: """Tests for the line number flags.""" diff --git a/tests/test_detect.py b/tests/test_detect.py index 0393a33..b000712 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -70,7 +70,13 @@ class TestExtensionMap: assert detect_type(path="archive.tar.gz") == "text" 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( ("suffix", "expected"), @@ -80,8 +86,73 @@ class TestExtensionMap: 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: - """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: assert detect_type(content=sample_json) == "json" @@ -104,9 +175,31 @@ class TestContentJsonDetection: def test_content_with_valid_path_takes_priority(self) -> None: 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: - """Priority 5: fallback to 'text'.""" + """Priority 7: fallback to 'text'.""" def test_no_args(self) -> None: assert detect_type() == "text" diff --git a/uv.lock b/uv.lock index c8e832e..f3d0a14 100644 --- a/uv.lock +++ b/uv.lock @@ -342,6 +342,7 @@ name = "rp" version = "0.2.3" source = { editable = "." } dependencies = [ + { name = "pygments" }, { name = "rich" }, { name = "typer" }, ] @@ -352,10 +353,12 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "types-pygments" }, ] [package.metadata] requires-dist = [ + { name = "pygments", specifier = ">=2.13.0" }, { name = "rich", specifier = ">=13.0" }, { name = "typer", specifier = ">=0.12" }, ] @@ -366,6 +369,7 @@ dev = [ { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.9" }, + { name = "types-pygments", specifier = ">=2.19.0.20260402" }, ] [[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" }, ] +[[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]] name = "typing-extensions" version = "4.15.0"