From 63102473dab347fc8ed04efa058ca46531e291ba Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Fri, 3 Apr 2026 03:32:00 -0700 Subject: [PATCH 1/5] Add Git workflow and update docs --- AGENTS.md | 87 +++++++++++++++++++++++++++++++++++++++++++------------ README.md | 15 ++++++++++ 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e1751b..915d173 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,9 @@ echo '{"a":1}' | uv run rp # Read from stdin ``` ### Testing -This project does not yet have automated tests. When adding tests: + +Run tests with pytest: + ```bash # Run all tests uv run pytest @@ -35,8 +37,11 @@ uv run pytest # Run a single test file uv run pytest tests/test_detect.py +# Run a single test class +uv run pytest tests/test_detect.py::TestExplicitType + # Run a single test function -uv run pytest tests/test_detect.py::test_detect_json_from_content +uv run pytest tests/test_detect.py::TestExplicitType::test_explicit_overrides_everything # Run with verbose output uv run pytest -v @@ -160,32 +165,76 @@ Version metadata must stay in sync between `pyproject.toml` and `src/rp/__init__ ## Project Structure ``` -rich-viewer/ -├── pyproject.toml # Project config, dependencies, entry point -├── uv.lock # Lockfile (commit this) +rich-print/ +├── pyproject.toml # Project config, dependencies, entry point +├── uv.lock # Lockfile (commit this) ├── .gitignore ├── README.md -├── AGENTS.md # This file -└── src/ - └── rp/ - ├── __init__.py # Version metadata - ├── cli.py # Typer CLI entry point - ├── detect.py # File type detection - ├── render.py # Rendering logic - ├── pager.py # Pager integration - └── py.typed # PEP 561 marker +├── AGENTS.md # This file +├── src/ +│ └── rp/ +│ ├── __init__.py # Version metadata +│ ├── cli.py # Typer CLI entry point +│ ├── detect.py # File type detection +│ ├── render.py # Rendering logic +│ ├── pager.py # Pager integration +│ └── py.typed # PEP 561 marker +└── tests/ + ├── conftest.py + ├── test_cli.py + ├── test_detect.py + ├── test_render.py + └── test_pager.py ``` ## Dependencies + - `rich>=13.0` — Terminal rendering (Markdown, JSON, Syntax) - `typer>=0.12` — CLI framework -Runtime dependencies. Dev dependencies: `ruff>=0.9`, `mypy>=1.14`. +Runtime dependencies. Dev dependencies: `ruff>=0.9`, `mypy>=1.14`, `pytest>=8.0`, `pytest-cov>=5.0`. -## Git Conventions -- Write clear, imperative commit messages -- Reference issues/PRs when applicable -- Keep commits focused (one logical change per commit) +## Git Workflow + +### Branches + +- **Long-lived branches** + - `main` — stable release branch; only updated via merges from `dev` + - `dev` — integration branch for day-to-day development +- **Short-lived branches** + - `feature/` — new features + - `fix/` — bug fixes + - `docs/` — documentation changes + - `chore/` — maintenance, tooling, CI + - `refactor/` — refactoring without behavior change + - `test/` — adding or improving tests + +### Branching Rules + +- Start normal work from the latest `dev` +- Keep branches short-lived and focused on one logical change +- Do not commit directly to `main` or `dev` unless explicitly requested +- Delete the short-lived branch after it is merged + +### Pull Requests + +- Open normal PRs into `dev`, not `main` +- Use **squash merge** for PRs into `dev` +- Ensure CI passes before merging +- Keep PRs focused; split unrelated changes into separate PRs + +### Release Flow + +- Merge `dev` into `main` when changes are ready to release +- Tag releases from `main` with version numbers (e.g., `v1.2.3`) +- If a hotfix is made directly against `main`, merge it back to `dev` + +### Agent Expectations + +- Unless the user says otherwise, assume new work should target `dev` +- If a branch must be created, follow the naming rules above +- Do not create or push branches automatically unless the user asks +- Do not commit changes unless the user explicitly asks ## Notes for Agents - This is a small, focused CLI tool — prefer simplicity over abstraction diff --git a/README.md b/README.md index d536232..f0dc286 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,14 @@ rp --version uv sync ``` +### Testing + +```bash +uv run pytest # Run all tests +uv run pytest -v # Verbose output +uv run pytest tests/test_detect.py # Single file +``` + ### Linting & Type Checking ```bash @@ -86,6 +94,13 @@ uv run mypy src/ uv build ``` +## Workflow + +- `main` — stable release branch +- `dev` — integration branch for day-to-day work +- Short-lived branches: `feature/`, `fix/`, `docs/` +- Open PRs into `dev` (squash merge); merge `dev` to `main` for releases + ## License MIT From 37502c30832d9e9dcc11031b0affb313b0f8f511 Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Fri, 3 Apr 2026 13:55:54 -0700 Subject: [PATCH 2/5] Add script detection, env shebang parser, and opt-in content guessing --- AGENTS.md | 10 ++- README.md | 7 ++ pyproject.toml | 2 + src/rp/cli.py | 15 +++- src/rp/detect.py | 161 ++++++++++++++++++++++++++++++++++++++++++- tests/test_cli.py | 25 +++++++ tests/test_detect.py | 99 +++++++++++++++++++++++++- uv.lock | 25 +++++++ 8 files changed, 337 insertions(+), 7 deletions(-) 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" From 93c777cf6a19bb889f218044d9fbc7647449338e Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Fri, 3 Apr 2026 19:09:24 -0700 Subject: [PATCH 3/5] Add opt-in debug logging across the CLI pipeline Thread RP_DEBUG-driven logging through input, detection, rendering, and pager decisions so CLI behavior is inspectable without polluting stdout. Add focused coverage for debug wiring, env parsing, and stderr-only output. --- src/rp/cli.py | 6 +++++ src/rp/debug.py | 40 +++++++++++++++++++++++++++++ src/rp/detect.py | 51 ++++++++++++++++++++++++++++++++----- src/rp/pager.py | 26 ++++++++++++++++--- src/rp/render.py | 29 +++++++++++++++++++-- tests/test_cli.py | 34 +++++++++++++++++++++++++ tests/test_debug.py | 60 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_detect.py | 24 ++++++++++++++++++ tests/test_pager.py | 21 ++++++++++++++++ tests/test_render.py | 16 ++++++++++++ 10 files changed, 296 insertions(+), 11 deletions(-) create mode 100644 src/rp/debug.py create mode 100644 tests/test_debug.py diff --git a/src/rp/cli.py b/src/rp/cli.py index 8a151cd..4501c47 100644 --- a/src/rp/cli.py +++ b/src/rp/cli.py @@ -10,6 +10,7 @@ import typer from rich.console import Console from rp import __author__, __license__, __version__ +from rp.debug import env_debug_enabled, log_debug, make_debug_logger from rp.detect import detect_type from rp.render import RenderOptions, render @@ -95,13 +96,16 @@ def main( version: Show version and exit. """ console = Console() + debug = make_debug_logger(Console(stderr=True), env_debug_enabled()) if file is None or str(file) == "-": + log_debug(debug, "input: reading from stdin") if sys.stdin.isatty(): console.print("[dim]Reading from stdin. Press Ctrl+D to end.[/dim]") content = sys.stdin.read() file_path = None else: + log_debug(debug, f"input: reading file {str(file)!r}") if not file.exists(): console.print(f"[red]Error:[/red] File not found: {file}") raise typer.Exit(code=1) @@ -127,12 +131,14 @@ def main( content=content, explicit_type=file_type, guess_content=guess_content, + debug=debug, ) options = RenderOptions( theme=theme, line_numbers=line_numbers, pager=pager, + debug=debug, ) render(content, file_type, console, options) diff --git a/src/rp/debug.py b/src/rp/debug.py new file mode 100644 index 0000000..d15d574 --- /dev/null +++ b/src/rp/debug.py @@ -0,0 +1,40 @@ +"""Debug logging helpers for rp.""" + +from __future__ import annotations + +import os +from collections.abc import Callable, Mapping + +from rich.console import Console +from rich.text import Text + +DEBUG_ENV_VAR = "RP_DEBUG" +_FALSE_DEBUG_VALUES = {"", "0", "false", "no", "off"} + +type DebugLogger = Callable[[str], None] + + +def env_debug_enabled(env: Mapping[str, str] | None = None) -> bool: + """Return whether debug logging is enabled via ``RP_DEBUG``.""" + source = os.environ if env is None else env + value = source.get(DEBUG_ENV_VAR) + if value is None: + return False + return value.strip().lower() not in _FALSE_DEBUG_VALUES + + +def make_debug_logger(console: Console, enabled: bool) -> DebugLogger | None: + """Return a console-backed debug logger when debug mode is enabled.""" + if not enabled: + return None + + def _log(message: str) -> None: + console.print(Text.assemble(("[rp debug] ", "dim"), message)) + + return _log + + +def log_debug(debug: DebugLogger | None, message: str) -> None: + """Emit a debug line when a debug logger is configured.""" + if debug is not None: + debug(message) diff --git a/src/rp/detect.py b/src/rp/detect.py index a53bb47..ba5d832 100644 --- a/src/rp/detect.py +++ b/src/rp/detect.py @@ -10,6 +10,8 @@ from pathlib import Path from pygments.lexers import guess_lexer from pygments.util import ClassNotFound +from rp.debug import DebugLogger, log_debug + EXTENSION_MAP: dict[str, str] = { ".md": "markdown", ".json": "json", @@ -182,11 +184,15 @@ def _detect_shebang(content: str) -> str | None: return _SHEBANG_MAP.get(program) -def _detect_pygments_content(content: str) -> str | None: +def _detect_pygments_content( + content: str, + debug: DebugLogger | None = None, +) -> str | None: """Return a lexer guessed from content when Pygments is confident.""" try: lexer = guess_lexer(content) except ClassNotFound: + log_debug(debug, "type: Pygments could not guess a lexer from content") return None aliases: list[str] = getattr(lexer, "aliases", []) @@ -194,8 +200,29 @@ def _detect_pygments_content(content: str) -> str | 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: + + guessed = alias if alias is not None else lexer.__class__.__name__ + log_debug(debug, f"type: content guess -> lexer={guessed!r}, score={score:.3f}") + + if alias is None: + log_debug(debug, "type: rejected content guess because the lexer has no alias") return None + + if alias == "text": + log_debug( + debug, + "type: rejected content guess because the lexer resolved to plain text", + ) + return None + + if score < 0.5: + log_debug( + debug, + "type: rejected content guess because the score is below 0.500", + ) + return None + + log_debug(debug, f"type: inferred {alias!r} from heuristic content guessing") return alias @@ -204,6 +231,7 @@ def detect_type( content: str | None = None, explicit_type: str | None = None, guess_content: bool = False, + debug: DebugLogger | None = None, ) -> str: """Detect the file type for syntax highlighting. @@ -221,25 +249,32 @@ def detect_type( content: File content for JSON detection. explicit_type: Override auto-detection with explicit type. guess_content: Enable heuristic content-based lexer guessing. + debug: Optional debug logger for inference details. Returns: Pygments lexer name for the detected file type. """ if explicit_type is not None: + log_debug(debug, f"type: using explicit override {explicit_type!r}") return explicit_type if path is not None: p = Path(path) name = p.name if name in _FILENAME_MAP: - return _FILENAME_MAP[name] + detected = _FILENAME_MAP[name] + log_debug(debug, f"type: inferred {detected!r} from filename {name!r}") + return detected suffix = p.suffix.lower() if suffix in EXTENSION_MAP: - return EXTENSION_MAP[suffix] + detected = EXTENSION_MAP[suffix] + log_debug(debug, f"type: inferred {detected!r} from extension {suffix!r}") + return detected if content is not None: shebang_type = _detect_shebang(content) if shebang_type is not None: + log_debug(debug, f"type: inferred {shebang_type!r} from shebang") return shebang_type stripped = content.strip() @@ -249,13 +284,17 @@ def detect_type( ): try: json.loads(stripped) + log_debug(debug, "type: inferred 'json' from JSON content") return "json" except (json.JSONDecodeError, ValueError): - pass + log_debug(debug, "type: JSON-like content failed to parse") if guess_content: - pygments_type = _detect_pygments_content(content) + pygments_type = _detect_pygments_content(content, debug=debug) if pygments_type is not None: return pygments_type + else: + log_debug(debug, "type: skipped heuristic content guessing") + log_debug(debug, "type: fell back to 'text'") return "text" diff --git a/src/rp/pager.py b/src/rp/pager.py index 6f5331a..deca72a 100644 --- a/src/rp/pager.py +++ b/src/rp/pager.py @@ -9,8 +9,10 @@ import subprocess from rich.pager import Pager, SystemPager +from rp.debug import DebugLogger, log_debug -def _mouse_capable_less() -> str | None: + +def _mouse_capable_less(debug: DebugLogger | None = None) -> str | None: """Return a ``less`` path that likely supports ``--mouse``. Mouse support was added in ``less`` 543. This probe is best-effort: it @@ -24,6 +26,7 @@ def _mouse_capable_less() -> str | None: """ less = shutil.which("less") if less is None: + log_debug(debug, "pager: 'less' was not found") return None try: @@ -31,16 +34,21 @@ def _mouse_capable_less() -> str | None: [less, "--version"], capture_output=True, text=True, timeout=5 ) except (subprocess.TimeoutExpired, OSError): + log_debug(debug, "pager: failed to probe the installed 'less' binary") return None version_str = result.stdout or result.stderr or "" match = re.search(r"\bless\s+(\d+)\b", version_str, re.IGNORECASE) if match is None: + log_debug(debug, "pager: could not parse the installed 'less' version") return None - if int(match.group(1)) < 543: + version = int(match.group(1)) + if version < 543: + log_debug(debug, f"pager: installed 'less' {version} is too old for --mouse") return None + log_debug(debug, f"pager: using 'less' {version} from {less!r}") return less @@ -53,29 +61,41 @@ class MousePager(Pager): trigger a second pager session. """ + def __init__(self, debug: DebugLogger | None = None) -> None: + """Initialize the pager with an optional debug logger.""" + self._debug = debug + def show(self, content: str) -> None: """Display rendered content in ``less -R --mouse`` when possible. Args: content: Fully rendered text from Rich. """ - less = _mouse_capable_less() + less = _mouse_capable_less(self._debug) if less is None: + log_debug(self._debug, "pager: falling back to Rich's SystemPager") SystemPager().show(content) return try: + log_debug(self._debug, "pager: launching 'less -R --mouse'") result = subprocess.run( [less, "-R", "--mouse"], input=content, text=True, ) except OSError: + log_debug(self._debug, "pager: failed to launch 'less', using SystemPager") SystemPager().show(content) return if result.returncode in (-signal.SIGINT, 128 + signal.SIGINT): + log_debug(self._debug, "pager: interrupted by user") return if result.returncode != 0: + log_debug( + self._debug, + f"pager: 'less' exited with status {result.returncode}, using SystemPager", + ) SystemPager().show(content) diff --git a/src/rp/render.py b/src/rp/render.py index 26fbf29..d74133b 100644 --- a/src/rp/render.py +++ b/src/rp/render.py @@ -10,6 +10,7 @@ from rich.markdown import Markdown from rich.padding import Padding from rich.syntax import Syntax +from rp.debug import DebugLogger, log_debug from rp.pager import MousePager @@ -21,11 +22,13 @@ class RenderOptions: theme: Pygments color theme name. line_numbers: Show line numbers. ``None`` enables them for code only. pager: Use pager for output. ``None`` auto-detects terminal output. + debug: Optional debug logger for render and pager decisions. """ theme: str = "monokai" line_numbers: bool | None = None pager: bool | None = None + debug: DebugLogger | None = None _MARKDOWN_TYPES: set[str] = {"markdown", "md"} @@ -49,12 +52,27 @@ def render( line_numbers = options.line_numbers if line_numbers is None: line_numbers = file_type not in _MARKDOWN_TYPES and file_type not in _JSON_TYPES + log_debug( + options.debug, + f"render: auto-set line_numbers={line_numbers} for {file_type!r}", + ) use_pager = options.pager if use_pager is None: import sys - use_pager = sys.stdout.isatty() + stdout_is_tty = sys.stdout.isatty() + use_pager = stdout_is_tty + log_debug( + options.debug, + f"pager: auto-detected {'enabled' if use_pager else 'disabled'} " + f"because stdout is {'a' if stdout_is_tty else 'not a'} TTY", + ) + else: + log_debug( + options.debug, + f"pager: explicitly {'enabled' if use_pager else 'disabled'}", + ) renderable: RenderableType if file_type in _MARKDOWN_TYPES: @@ -63,6 +81,7 @@ def render( try: renderable = RichJSON(content) except (SyntaxError, ValueError): + log_debug(options.debug, "render: invalid JSON, falling back to syntax") renderable = Syntax( content, "json", theme=options.theme, line_numbers=line_numbers ) @@ -72,6 +91,10 @@ def render( content, file_type, theme=options.theme, line_numbers=line_numbers ) except Exception: + log_debug( + options.debug, + f"render: unknown lexer {file_type!r}, falling back to plain text", + ) renderable = Syntax( content, "text", theme=options.theme, line_numbers=line_numbers ) @@ -79,7 +102,9 @@ def render( padded = Padding(renderable, (1, 2)) if use_pager: - with console.pager(pager=MousePager(), styles=True): + log_debug(options.debug, "pager: opening pager") + with console.pager(pager=MousePager(debug=options.debug), styles=True): console.print(padded) else: + log_debug(options.debug, "pager: writing directly to the console") console.print(padded) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2490e32..ea94f46 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -75,6 +75,7 @@ class TestFileInput: content="x = 1\n", explicit_type=None, guess_content=False, + debug=None, ) def test_render_called_with_correct_options(self, tmp_file) -> None: @@ -182,8 +183,41 @@ class TestStdinInput: content="hello\n", explicit_type=None, guess_content=False, + debug=None, ) + def test_debug_env_enables_debug_logger(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", env={"RP_DEBUG": "1"} + ) + + _, kwargs = mock_detect_type.call_args + assert kwargs["debug"] is not None + + def test_debug_output_stays_on_stderr(self, tmp_file) -> None: + file = tmp_file("# Hello\n", suffix=".md") + + result = runner.invoke(app, [str(file), "--no-pager"], env={"RP_DEBUG": "1"}) + normalized_stderr = result.stderr.replace("\n", "") + + assert result.exit_code == 0 + assert "[rp debug]" not in result.stdout + assert "Hello" in result.stdout + assert "[rp debug] input: reading file" in result.stderr + assert repr(str(file)) in normalized_stderr + assert ( + "[rp debug] type: inferred 'markdown' from extension '.md'" in result.stderr + ) + assert ( + "[rp debug] render: auto-set line_numbers=False for 'markdown'" + in result.stderr + ) + assert "[rp debug] pager: explicitly disabled" in result.stderr + assert "[rp debug] pager: writing directly to the console" in result.stderr + def test_stdin_shows_tty_prompt(self) -> None: """When stdin is a TTY and no input is piped, show the reading hint.""" with patch("rp.cli.sys.stdin.isatty", return_value=True): diff --git a/tests/test_debug.py b/tests/test_debug.py new file mode 100644 index 0000000..3277a81 --- /dev/null +++ b/tests/test_debug.py @@ -0,0 +1,60 @@ +"""Tests for debug helpers.""" + +from __future__ import annotations + +import io + +from rich.console import Console + +from rp.debug import DEBUG_ENV_VAR, env_debug_enabled, log_debug, make_debug_logger + + +class TestEnvDebugEnabled: + """Environment-based debug mode toggles.""" + + def test_disabled_by_default(self) -> None: + assert env_debug_enabled({}) is False + + def test_false_like_values_disable_debug(self) -> None: + for value in ["", "0", "false", "no", "off"]: + assert env_debug_enabled({DEBUG_ENV_VAR: value}) is False + + def test_false_like_values_are_trimmed_and_case_insensitive(self) -> None: + for value in [" False ", "\tNO\n", " Off "]: + assert env_debug_enabled({DEBUG_ENV_VAR: value}) is False + + def test_truthy_values_enable_debug(self) -> None: + assert env_debug_enabled({DEBUG_ENV_VAR: "1"}) is True + assert env_debug_enabled({DEBUG_ENV_VAR: "true"}) is True + + def test_truthy_values_are_trimmed_and_case_insensitive(self) -> None: + for value in [" TRUE ", " Yes ", "\ton\n"]: + assert env_debug_enabled({DEBUG_ENV_VAR: value}) is True + + +class TestMakeDebugLogger: + """Console-backed debug logger output.""" + + def test_logs_visible_prefix(self) -> None: + stream = io.StringIO() + console = Console(file=stream, force_terminal=False) + logger = make_debug_logger(console, enabled=True) + + assert logger is not None + logger("hello") + + assert stream.getvalue() == "[rp debug] hello\n" + + +class TestLogDebug: + """Nil-safe debug logger forwarding.""" + + def test_noop_when_logger_is_none(self) -> None: + log_debug(None, "hello") + + def test_forwards_to_logger(self) -> None: + messages: list[str] = [] + + log_debug(messages.append, "hello") + + assert messages == ["hello"] diff --git a/tests/test_detect.py b/tests/test_detect.py index b000712..021f9c4 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -198,6 +198,30 @@ class TestGuessContent: assert detect_type(content=sample_json, guess_content=True) == "json" +class TestDebugLogging: + """Debug logging describes inference decisions.""" + + def test_logs_extension_source(self) -> None: + messages: list[str] = [] + + assert detect_type(path="script.py", debug=messages.append) == "python" + assert messages == ["type: inferred 'python' from extension '.py'"] + + def test_logs_content_guess_and_score(self) -> None: + messages: list[str] = [] + diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n" + + assert ( + detect_type(content=diff, guess_content=True, debug=messages.append) + == "diff" + ) + assert any( + "type: content guess -> lexer='diff', score=" in message + for message in messages + ) + assert "type: inferred 'diff' from heuristic content guessing" in messages + + class TestFallback: """Priority 7: fallback to 'text'.""" diff --git a/tests/test_pager.py b/tests/test_pager.py index 799e5a0..02ca196 100644 --- a/tests/test_pager.py +++ b/tests/test_pager.py @@ -25,6 +25,14 @@ class TestMouseCapableLess: with patch("rp.pager.shutil.which", return_value=None): assert _mouse_capable_less() is None + def test_logs_less_not_found(self) -> None: + messages: list[str] = [] + + with patch("rp.pager.shutil.which", return_value=None): + assert _mouse_capable_less(messages.append) is None + + assert messages == ["pager: 'less' was not found"] + def test_returns_none_when_less_too_old(self) -> None: with patch("rp.pager.shutil.which", return_value="/usr/bin/less"): mock_result = MagicMock() @@ -99,6 +107,19 @@ class TestMousePager: mock_instance.show.assert_called_once_with("content") + def test_logs_system_pager_fallback(self) -> None: + messages: list[str] = [] + pager = MousePager(debug=messages.append) + + with patch("rp.pager._mouse_capable_less", return_value=None): + with patch("rp.pager.SystemPager") as mock_system_pager: + mock_instance = MagicMock() + mock_system_pager.return_value = mock_instance + + pager.show("content") + + assert messages == ["pager: falling back to Rich's SystemPager"] + def test_uses_less_when_available(self) -> None: pager = MousePager() mock_result = MagicMock() diff --git a/tests/test_render.py b/tests/test_render.py index d9c92fe..4781a14 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -35,6 +35,10 @@ class TestRenderOptions: assert opts.line_numbers is True assert opts.pager is False + def test_default_debug_is_none(self) -> None: + opts = RenderOptions() + assert opts.debug is None + class TestRenderMarkdown: """Markdown rendering.""" @@ -238,6 +242,18 @@ class TestPagerBehavior: assert len(capture_console.file.getvalue()) > 0 mock_pager.assert_not_called() + def test_logs_auto_pager_decision( + self, capture_console, sample_python: str + ) -> None: + messages: list[str] = [] + opts = RenderOptions(pager=None, debug=messages.append) + + with patch("sys.stdout.isatty", return_value=False): + render(sample_python, "python", capture_console, opts) + + assert "pager: auto-detected disabled because stdout is not a TTY" in messages + assert "pager: writing directly to the console" in messages + class TestPadding: """Rendered output is wrapped in padding.""" From d94cf2df309006327703f66ccb9bf104165bb78b Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Fri, 3 Apr 2026 19:11:59 -0700 Subject: [PATCH 4/5] Run CI on dev pushes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e92749a..7a73366 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: branches: [main] From 0f93d3a095864f00b835321c49daeb38fc8ce47e Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Sat, 4 Apr 2026 05:23:28 -0700 Subject: [PATCH 5/5] Add watch mode for live file monitoring Add --watch flag that polls a file and redraws the terminal when its content changes. Useful for monitoring log files, configuration files, or any file that changes over time. Features: - Polls file every 1 second for changes - Disables pager (pager would block the watch loop) - File-only: rejects stdin or - input - Error recovery: retries when file is missing or temporarily unreadable - State comparison: only redraws when content actually changes --- README.md | 10 +++ src/rp/cli.py | 163 +++++++++++++++++++++++++++++++++++++++------- tests/test_cli.py | 106 ++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ee48cb5..405bf86 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Rich print files — markdown, JSON, code, and more — beautifully in your term - **JSON formatting** with syntax highlighting - **Pager integration** with mouse scrolling support - **Stdin support** for piping content +- **Watch mode** for live file redraws ## Installation @@ -65,6 +66,15 @@ rp --no-pager file.py # Disable pager The pager prefers `less -R --mouse` for mouse scrolling when available, otherwise falls back to the system pager. +### Watch a file + +```bash +rp --watch README.md +rp --watch --type json data.txt +``` + +`--watch` is file-only. It does not support stdin or `-`, disables the pager, redraws the screen when the file changes, and keeps retrying if the file becomes missing or temporarily unreadable. + ### Show version ```bash diff --git a/src/rp/cli.py b/src/rp/cli.py index 4501c47..434d105 100644 --- a/src/rp/cli.py +++ b/src/rp/cli.py @@ -2,7 +2,9 @@ from __future__ import annotations +from collections.abc import Callable import sys +import time from pathlib import Path from typing import Annotated @@ -10,10 +12,12 @@ import typer from rich.console import Console from rp import __author__, __license__, __version__ -from rp.debug import env_debug_enabled, log_debug, make_debug_logger +from rp.debug import DebugLogger, env_debug_enabled, log_debug, make_debug_logger from rp.detect import detect_type from rp.render import RenderOptions, render +_WATCH_POLL_INTERVAL = 1.0 + app = typer.Typer( name="rp", help="Rich print files — markdown, JSON, code, and more — beautifully in your terminal.", @@ -36,6 +40,103 @@ def version_callback(value: bool) -> None: raise typer.Exit() +def _read_file_content(file: Path) -> str: + """Read a UTF-8 text file for rendering.""" + if not file.exists(): + raise FileNotFoundError(f"File not found: {file}") + if file.is_dir(): + raise IsADirectoryError(f"Is a directory: {file}") + + try: + return file.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"Cannot read binary file: {file}") from exc + + +def _render_content( + *, + console: Console, + debug: DebugLogger | None, + content: str, + file_path: str | None, + file_type: str | None, + guess_content: bool, + theme: str, + line_numbers: bool | None, + pager: bool | None, +) -> None: + """Detect type and render content with the standard pipeline.""" + detected_type = detect_type( + path=file_path, + content=content, + explicit_type=file_type, + guess_content=guess_content, + debug=debug, + ) + + options = RenderOptions( + theme=theme, + line_numbers=line_numbers, + pager=pager, + debug=debug, + ) + render(content, detected_type, console, options) + + +def _watch_file( + *, + file: Path, + console: Console, + debug: DebugLogger | None, + file_type: str | None, + guess_content: bool, + theme: str, + line_numbers: bool | None, + sleep: Callable[[float], None] | None = None, +) -> None: + """Poll a file and redraw when its visible state changes.""" + if sleep is None: + sleep = time.sleep + + previous_state: tuple[str, str] | None = None + + while True: + try: + content = _read_file_content(file) + except ( + FileNotFoundError, + IsADirectoryError, + PermissionError, + OSError, + ValueError, + ) as exc: + state = ("error", str(exc)) + if state != previous_state: + log_debug(debug, f"watch: detected error state {state[1]!r}") + console.clear(home=True) + console.print(f"[red]Error:[/red] {state[1]}") + previous_state = state + else: + state = ("content", content) + if state != previous_state: + log_debug(debug, f"watch: rendering update for {str(file)!r}") + console.clear(home=True) + _render_content( + console=console, + debug=debug, + content=content, + file_path=str(file), + file_type=file_type, + guess_content=guess_content, + theme=theme, + line_numbers=line_numbers, + pager=False, + ) + previous_state = state + + sleep(_WATCH_POLL_INTERVAL) + + @app.command() def main( file: Annotated[ @@ -66,6 +167,13 @@ def main( help="Use pager for output. Default: auto-detect.", ), ] = None, + watch: Annotated[ + bool, + typer.Option( + "--watch/--no-watch", + help="Watch a file for changes and redraw when it updates.", + ), + ] = False, guess_content: Annotated[ bool, typer.Option( @@ -92,12 +200,17 @@ def main( theme: Pygments color theme. line_numbers: Show line numbers. Default: auto. pager: Use pager for output. Default: auto-detect. + watch: Watch a file for changes and redraw when it updates. guess_content: Use heuristic content-based type detection as a fallback. version: Show version and exit. """ console = Console() debug = make_debug_logger(Console(stderr=True), env_debug_enabled()) + if watch and (file is None or str(file) == "-"): + console.print("[red]Error:[/red] --watch only supports file input.") + raise typer.Exit(code=1) + if file is None or str(file) == "-": log_debug(debug, "input: reading from stdin") if sys.stdin.isatty(): @@ -106,18 +219,28 @@ def main( file_path = None else: log_debug(debug, f"input: reading file {str(file)!r}") - if not file.exists(): - console.print(f"[red]Error:[/red] File not found: {file}") - raise typer.Exit(code=1) - if file.is_dir(): - console.print(f"[red]Error:[/red] Is a directory: {file}") - raise typer.Exit(code=1) + if watch: + log_debug(debug, f"watch: polling {str(file)!r}") + _watch_file( + file=file, + console=console, + debug=debug, + file_type=file_type, + guess_content=guess_content, + theme=theme, + line_numbers=line_numbers, + ) + raise typer.Exit() + try: - content = file.read_text(encoding="utf-8") - except UnicodeDecodeError: - console.print(f"[red]Error:[/red] Cannot read binary file: {file}") - raise typer.Exit(code=1) - except (PermissionError, OSError) as e: + content = _read_file_content(file) + except ( + FileNotFoundError, + IsADirectoryError, + PermissionError, + OSError, + ValueError, + ) as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(code=1) file_path = str(file) @@ -126,21 +249,17 @@ 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, - guess_content=guess_content, + _render_content( + console=console, debug=debug, - ) - - options = RenderOptions( + content=content, + file_path=file_path, + file_type=file_type, + guess_content=guess_content, theme=theme, line_numbers=line_numbers, pager=pager, - debug=debug, ) - render(content, file_type, console, options) if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index ea94f46..7ee4d12 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest from typer.testing import CliRunner from rp import __version__ @@ -171,6 +172,18 @@ class TestStdinInput: assert result.exit_code == 0 + def test_watch_rejects_stdin(self) -> None: + result = runner.invoke(app, ["--watch"], input="hello\n") + + assert result.exit_code == 1 + assert "--watch only supports file input" in result.output + + def test_watch_rejects_dash_input(self) -> None: + result = runner.invoke(app, ["-", "--watch"], input="hello\n") + + assert result.exit_code == 1 + assert "--watch only supports file input" in result.output + def test_stdin_passes_none_path(self) -> None: """When reading from stdin, detect_type should receive path=None.""" with patch("rp.cli.detect_type") as mock_detect_type: @@ -311,3 +324,96 @@ class TestLineNumbersOption: result = runner.invoke(app, [str(file), "--no-line-numbers", "--no-pager"]) assert result.exit_code == 0 + + +class TestWatchMode: + """Tests for the watch mode loop.""" + + def test_watch_disables_pager(self, tmp_file) -> None: + file = tmp_file("x = 1\n", suffix=".py") + + with patch("rp.cli.detect_type", return_value="python"): + with patch("rp.cli.time.sleep", side_effect=KeyboardInterrupt): + with patch("rp.cli.render") as mock_render: + with pytest.raises(KeyboardInterrupt): + main(file=file, pager=True, watch=True) + + call_args = mock_render.call_args + assert call_args is not None + options = call_args[0][3] + assert options.pager is False + + def test_watch_rerenders_on_file_change(self, tmp_file) -> None: + file = tmp_file("x = 1\n", suffix=".py") + sleep_calls = 0 + + def fake_sleep(_: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls == 1: + file.write_text("x = 2\n", encoding="utf-8") + return + raise KeyboardInterrupt + + with patch("rp.cli.detect_type", return_value="python"): + with patch("rp.cli.time.sleep", side_effect=fake_sleep): + with patch("rp.cli.render") as mock_render: + with pytest.raises(KeyboardInterrupt): + main(file=file, watch=True) + + assert [call.args[0] for call in mock_render.call_args_list] == [ + "x = 1\n", + "x = 2\n", + ] + + def test_watch_retries_after_missing_file_and_recovers(self, tmp_file) -> None: + file = tmp_file("x = 1\n", suffix=".py") + sleep_calls = 0 + + def fake_sleep(_: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls == 1: + file.unlink() + return + if sleep_calls == 2: + file.write_text("x = 3\n", encoding="utf-8") + return + raise KeyboardInterrupt + + with patch("rp.cli.detect_type", return_value="python"): + with patch("rp.cli.time.sleep", side_effect=fake_sleep): + with patch("rp.cli.Console.print") as mock_print: + with patch("rp.cli.render") as mock_render: + with pytest.raises(KeyboardInterrupt): + main(file=file, watch=True) + + assert [call.args[0] for call in mock_render.call_args_list] == [ + "x = 1\n", + "x = 3\n", + ] + mock_print.assert_any_call(f"[red]Error:[/red] File not found: {file}") + + def test_watch_recovers_when_file_is_missing_at_startup( + self, tmp_path: Path + ) -> None: + file = tmp_path / "startup.py" + sleep_calls = 0 + + def fake_sleep(_: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls == 1: + file.write_text("x = 4\n", encoding="utf-8") + return + raise KeyboardInterrupt + + with patch("rp.cli.detect_type", return_value="python"): + with patch("rp.cli.time.sleep", side_effect=fake_sleep): + with patch("rp.cli.Console.print") as mock_print: + with patch("rp.cli.render") as mock_render: + with pytest.raises(KeyboardInterrupt): + main(file=file, watch=True) + + assert [call.args[0] for call in mock_render.call_args_list] == ["x = 4\n"] + mock_print.assert_any_call(f"[red]Error:[/red] File not found: {file}")