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

@@ -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,

View File

@@ -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"