Add script detection, env shebang parser, and opt-in content guessing
This commit is contained in:
@@ -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,
|
||||
|
||||
161
src/rp/detect.py
161
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"
|
||||
|
||||
Reference in New Issue
Block a user