Skip to content

Commit fc5fc95

Browse files
committed
feat: .ksh extension + shebang-based language detection for extension-less scripts (#235, #237)
Two parser improvements that expand code-review-graph's file coverage to extension-less Unix scripts and Korn shell files. Feature 1: .ksh extension → bash parser (#235) ----------------------------------------------- Register .ksh (Korn shell) with tree-sitter-bash alongside the existing .sh / .bash / .zsh entries shipped in v2.3.0. Korn shell is close enough to bash syntactically that tree-sitter-bash handles the structural features the graph captures correctly. Context: in the close comment on PR #230, @tirth8205 explicitly flagged this as worth adding: "The .ksh extension in particular looks worth adding — I didn't include it in #227." Tests: test_detects_language extended with .ksh assertion; test_ksh_extension_parses_as_bash — end-to-end regression test that copies sample.sh to a temp .ksh file, parses it, and asserts identical function set and edge counts. Feature 2: shebang-based language detection (#237) -------------------------------------------------- detect_language() was extension-only — any file with no extension returned None and was silently skipped. This misses a huge category of production files: git hooks, CI scripts, bin/ entry points, installers. New SHEBANG_INTERPRETER_TO_LANGUAGE table maps common interpreter basenames to languages already registered: bash/sh/zsh/ksh/dash/ash -> bash python/python2/python3/pypy/pypy3 -> python node/nodejs -> javascript ruby, perl, lua, Rscript, php New _detect_language_from_shebang(path) static method reads the first 256 bytes, handles direct form (#!/bin/bash), env indirection (#!/usr/bin/env bash), env -S flags, trailing flags (#!/bin/bash -e), CRLF, binary content, and strict UTF-8 decoding. detect_language() now falls back to the shebang probe for files with no extension (suffix == ""). Files with a known extension are never re-read — extension-based detection stays authoritative. Tests (16 new in test_parser.py): every interpreter mapping, env -S flag, trailing flags, missing shebang, empty file, binary content, unknown interpreter, extension-does-not-get-overridden, and end-to-end parse_file producing function nodes from an extension-less bash script. Files changed ------------- - code_review_graph/parser.py — .ksh mapping + SHEBANG_INTERPRETER_TO_LANGUAGE table + _detect_language_from_shebang() + detect_language() fallback - tests/test_multilang.py — .ksh detection + end-to-end ksh parsing test - tests/test_parser.py — 16 shebang detection tests
1 parent db2d2df commit fc5fc95

3 files changed

Lines changed: 288 additions & 1 deletion

File tree

code_review_graph/parser.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class EdgeInfo:
108108
".sh": "bash",
109109
".bash": "bash",
110110
".zsh": "bash",
111+
".ksh": "bash", # Korn shell — close enough to bash for tree-sitter-bash (#235)
111112
".ex": "elixir",
112113
".exs": "elixir",
113114
".ipynb": "notebook",
@@ -119,6 +120,41 @@ class EdgeInfo:
119120
".jl": "julia",
120121
}
121122

123+
# Shebang interpreter → language mapping for extension-less Unix scripts.
124+
# Each key is the **basename** of the interpreter path as it appears after
125+
# ``#!`` (or after ``#!/usr/bin/env``). Only languages already registered
126+
# above are listed — this file strictly routes extension-less scripts, it
127+
# does NOT introduce new languages on its own. See issue #237.
128+
SHEBANG_INTERPRETER_TO_LANGUAGE: dict[str, str] = {
129+
# POSIX / bash-compatible shells — all routed through tree-sitter-bash
130+
"bash": "bash",
131+
"sh": "bash",
132+
"zsh": "bash",
133+
"ksh": "bash",
134+
"dash": "bash",
135+
"ash": "bash",
136+
# Python (every common variant)
137+
"python": "python",
138+
"python2": "python",
139+
"python3": "python",
140+
"pypy": "python",
141+
"pypy3": "python",
142+
# JavaScript via Node
143+
"node": "javascript",
144+
"nodejs": "javascript",
145+
# Ruby / Perl / Lua / R / PHP
146+
"ruby": "ruby",
147+
"perl": "perl",
148+
"lua": "lua",
149+
"Rscript": "r",
150+
"php": "php",
151+
}
152+
153+
# Maximum bytes to read from the head of a file when probing for a shebang.
154+
# 256 is enough for any reasonable shebang line (``#!/usr/bin/env python3 -u\n``
155+
# is ~30 chars) while keeping the worst-case read tiny even on fat binaries.
156+
_SHEBANG_PROBE_BYTES = 256
157+
122158
# Tree-sitter node type mappings per language
123159
# Maps (language) -> dict of semantic role -> list of TS node types
124160
_CLASS_TYPES: dict[str, list[str]] = {
@@ -383,7 +419,88 @@ def _get_parser(self, language: str): # type: ignore[arg-type]
383419
return self._parsers[language]
384420

385421
def detect_language(self, path: Path) -> Optional[str]:
386-
return EXTENSION_TO_LANGUAGE.get(path.suffix.lower())
422+
"""Map a file path to its language name.
423+
424+
Extension-based lookup is tried first. For extension-less files
425+
(typical for Unix scripts like ``bin/myapp`` or ``.git/hooks/pre-commit``)
426+
we fall back to reading the first line for a shebang. Files that
427+
already have a known extension are never re-read — shebang probing
428+
only runs when the extension lookup returns ``None`` **and** the path
429+
has no suffix at all. See issue #237.
430+
"""
431+
suffix = path.suffix.lower()
432+
lang = EXTENSION_TO_LANGUAGE.get(suffix)
433+
if lang is not None:
434+
return lang
435+
# Only probe shebang for files without any extension — "README", "LICENSE",
436+
# and other extension-less text files also fall here, but the probe is a
437+
# cheap 256-byte read that returns None when no shebang is found.
438+
if suffix == "":
439+
return self._detect_language_from_shebang(path)
440+
return None
441+
442+
@staticmethod
443+
def _detect_language_from_shebang(path: Path) -> Optional[str]:
444+
"""Inspect the first line of ``path`` for a shebang interpreter.
445+
446+
Returns the mapped language name or ``None`` if the file has no
447+
shebang, is unreadable, or names an interpreter we don't map.
448+
449+
Accepted shapes::
450+
451+
#!/bin/bash
452+
#!/usr/bin/env python3
453+
#!/usr/bin/env -S node --experimental-vm-modules
454+
#!/usr/bin/bash -e
455+
456+
Only the basename of the interpreter is consulted. Trailing flags
457+
after the interpreter are ignored. Windows-style ``\r\n`` line
458+
endings are handled. Binary files read as garbage bytes simply
459+
fail the ``#!`` prefix check and return ``None``.
460+
"""
461+
try:
462+
with path.open("rb") as fh:
463+
head = fh.read(_SHEBANG_PROBE_BYTES)
464+
except (OSError, PermissionError):
465+
return None
466+
if not head.startswith(b"#!"):
467+
return None
468+
469+
# Take just the first line, stripped of leading "#!" and any
470+
# surrounding whitespace. Split on NUL to defend against accidental
471+
# binary content following a ``#!`` prefix.
472+
first_line = head.split(b"\n", 1)[0].split(b"\0", 1)[0]
473+
try:
474+
line = first_line[2:].decode("utf-8", errors="strict").strip()
475+
except UnicodeDecodeError:
476+
return None
477+
if not line:
478+
return None
479+
480+
tokens = line.split()
481+
if not tokens:
482+
return None
483+
484+
first = tokens[0]
485+
# `/usr/bin/env` indirection: the interpreter is the next token.
486+
# `/usr/bin/env -S node --flag` is also valid — skip any leading
487+
# ``-`` options after env.
488+
if first.endswith("/env") or first == "env":
489+
interpreter_token: Optional[str] = None
490+
for tok in tokens[1:]:
491+
if tok.startswith("-"):
492+
# ``-S`` takes no argument in most envs; skip and continue.
493+
continue
494+
interpreter_token = tok
495+
break
496+
if interpreter_token is None:
497+
return None
498+
interpreter = interpreter_token.rsplit("/", 1)[-1]
499+
else:
500+
# Direct form: ``#!/bin/bash`` or ``#!/usr/local/bin/python3``.
501+
interpreter = first.rsplit("/", 1)[-1]
502+
503+
return SHEBANG_INTERPRETER_TO_LANGUAGE.get(interpreter)
387504

388505
def parse_file(self, path: Path) -> tuple[list[NodeInfo], list[EdgeInfo]]:
389506
"""Parse a single file and return extracted nodes and edges."""

tests/test_multilang.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,39 @@ def test_detects_language(self):
10871087
assert self.parser.detect_language(Path("build.sh")) == "bash"
10881088
assert self.parser.detect_language(Path("build.bash")) == "bash"
10891089
assert self.parser.detect_language(Path("run.zsh")) == "bash"
1090+
# Regression for #235 — Korn shell (.ksh) should parse as bash.
1091+
assert self.parser.detect_language(Path("legacy.ksh")) == "bash"
1092+
1093+
def test_ksh_extension_parses_as_bash(self, tmp_path):
1094+
"""Regression for #235: a real .ksh file is parsed through the bash
1095+
grammar end-to-end and produces the same structural nodes/edges
1096+
as an equivalent .sh file."""
1097+
fixture_source = (FIXTURES / "sample.sh").read_text(encoding="utf-8")
1098+
ksh_copy = tmp_path / "legacy.ksh"
1099+
ksh_copy.write_text(fixture_source, encoding="utf-8")
1100+
1101+
ksh_nodes, ksh_edges = self.parser.parse_file(ksh_copy)
1102+
1103+
# Language tagging: every node must be "bash".
1104+
assert ksh_nodes, "parser produced zero nodes for .ksh file"
1105+
for n in ksh_nodes:
1106+
assert n.language == "bash"
1107+
1108+
# Same function set as the .sh fixture.
1109+
ksh_funcs = {n.name for n in ksh_nodes if n.kind == "Function"}
1110+
sh_funcs = {n.name for n in self.nodes if n.kind == "Function"}
1111+
assert ksh_funcs == sh_funcs, (
1112+
f".ksh and .sh produced different function sets: "
1113+
f"sh-only={sh_funcs - ksh_funcs}, ksh-only={ksh_funcs - sh_funcs}"
1114+
)
1115+
1116+
# Same structural-edge totals by kind.
1117+
def by_kind(edges):
1118+
counts: dict[str, int] = {}
1119+
for e in edges:
1120+
counts[e.kind] = counts.get(e.kind, 0) + 1
1121+
return counts
1122+
assert by_kind(ksh_edges) == by_kind(self.edges)
10901123

10911124
def test_nodes_have_bash_language(self):
10921125
for n in self.nodes:

tests/test_parser.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,143 @@ def test_detect_language_typescript(self):
2121
def test_detect_language_unknown(self):
2222
assert self.parser.detect_language(Path("foo.txt")) is None
2323

24+
# --- Shebang detection for extension-less Unix scripts (#237) ---
25+
26+
def _write_shebang_file(self, tmp_path: Path, name: str, content: str) -> Path:
27+
"""Helper: write an extension-less file with ``content`` and return its path."""
28+
p = tmp_path / name
29+
p.write_text(content, encoding="utf-8")
30+
return p
31+
32+
def test_detect_shebang_bin_bash(self, tmp_path):
33+
p = self._write_shebang_file(
34+
tmp_path, "deploy", "#!/bin/bash\nfoo() { echo hi; }\n",
35+
)
36+
assert self.parser.detect_language(p) == "bash"
37+
38+
def test_detect_shebang_bin_sh_routed_to_bash(self, tmp_path):
39+
"""/bin/sh scripts are parsed through the bash grammar."""
40+
p = self._write_shebang_file(
41+
tmp_path, "install-hook", "#!/bin/sh\necho hello\n",
42+
)
43+
assert self.parser.detect_language(p) == "bash"
44+
45+
def test_detect_shebang_env_bash(self, tmp_path):
46+
p = self._write_shebang_file(
47+
tmp_path, "runner", "#!/usr/bin/env bash\nfoo() { echo hi; }\n",
48+
)
49+
assert self.parser.detect_language(p) == "bash"
50+
51+
def test_detect_shebang_env_python3(self, tmp_path):
52+
p = self._write_shebang_file(
53+
tmp_path, "myapp",
54+
"#!/usr/bin/env python3\ndef main():\n pass\n",
55+
)
56+
assert self.parser.detect_language(p) == "python"
57+
58+
def test_detect_shebang_direct_python(self, tmp_path):
59+
p = self._write_shebang_file(
60+
tmp_path, "tool", "#!/usr/bin/python3\nprint('hi')\n",
61+
)
62+
assert self.parser.detect_language(p) == "python"
63+
64+
def test_detect_shebang_node(self, tmp_path):
65+
p = self._write_shebang_file(
66+
tmp_path, "cli", "#!/usr/bin/env node\nconsole.log(1);\n",
67+
)
68+
assert self.parser.detect_language(p) == "javascript"
69+
70+
def test_detect_shebang_env_dash_s_flag(self, tmp_path):
71+
"""``#!/usr/bin/env -S node --flag`` (Linux -S) resolves to the interpreter."""
72+
p = self._write_shebang_file(
73+
tmp_path, "esm-tool",
74+
"#!/usr/bin/env -S node --experimental-vm-modules\n"
75+
"console.log('esm');\n",
76+
)
77+
assert self.parser.detect_language(p) == "javascript"
78+
79+
def test_detect_shebang_ruby(self, tmp_path):
80+
p = self._write_shebang_file(
81+
tmp_path, "rake-task", "#!/usr/bin/env ruby\nputs 1\n",
82+
)
83+
assert self.parser.detect_language(p) == "ruby"
84+
85+
def test_detect_shebang_perl(self, tmp_path):
86+
p = self._write_shebang_file(
87+
tmp_path, "cgi-script", "#!/usr/bin/env perl\nprint 1;\n",
88+
)
89+
assert self.parser.detect_language(p) == "perl"
90+
91+
def test_detect_shebang_with_trailing_flags(self, tmp_path):
92+
"""``#!/bin/bash -e`` still maps to bash (flags ignored)."""
93+
p = self._write_shebang_file(
94+
tmp_path, "strict", "#!/bin/bash -e\nfoo() { echo hi; }\n",
95+
)
96+
assert self.parser.detect_language(p) == "bash"
97+
98+
def test_detect_shebang_missing_returns_none(self, tmp_path):
99+
"""Extension-less text files without a shebang return None, not bash."""
100+
p = self._write_shebang_file(
101+
tmp_path, "README", "# just a readme, no shebang\nsome content\n",
102+
)
103+
assert self.parser.detect_language(p) is None
104+
105+
def test_detect_shebang_empty_file_returns_none(self, tmp_path):
106+
p = tmp_path / "EMPTY"
107+
p.write_bytes(b"")
108+
assert self.parser.detect_language(p) is None
109+
110+
def test_detect_shebang_binary_content_returns_none(self, tmp_path):
111+
"""A garbage-byte first line that happens not to start with ``#!``
112+
must not raise and must return None."""
113+
p = tmp_path / "binary-blob"
114+
p.write_bytes(b"\x00\x01\x02\x03 garbage bytes not a shebang\n")
115+
assert self.parser.detect_language(p) is None
116+
117+
def test_detect_shebang_unknown_interpreter_returns_none(self, tmp_path):
118+
"""A valid shebang to an interpreter we don't route is treated as
119+
'unknown language' — same as an unmapped extension."""
120+
p = self._write_shebang_file(
121+
tmp_path, "ocaml-script", "#!/usr/bin/env ocaml\nlet x = 1\n",
122+
)
123+
assert self.parser.detect_language(p) is None
124+
125+
def test_detect_shebang_does_not_override_extension(self, tmp_path):
126+
"""A file with a known extension must still use extension-based
127+
detection, even if its first line is a misleading shebang."""
128+
p = tmp_path / "script.py"
129+
p.write_text("#!/bin/bash\nprint('hi')\n", encoding="utf-8")
130+
# .py wins over the bash shebang — non-intuitive-looking content
131+
# in a .py file must not fool the detector.
132+
assert self.parser.detect_language(p) == "python"
133+
134+
def test_parse_shebang_script_produces_function_nodes(self, tmp_path):
135+
"""End-to-end regression: an extension-less bash script is not only
136+
detected but also fully parsed into structural nodes via parse_file.
137+
"""
138+
script = (
139+
"#!/usr/bin/env bash\n"
140+
"greet() {\n"
141+
' echo "hi $1"\n'
142+
"}\n"
143+
"main() {\n"
144+
" greet world\n"
145+
"}\n"
146+
"main\n"
147+
)
148+
p = self._write_shebang_file(tmp_path, "deploy", script)
149+
150+
nodes, edges = self.parser.parse_file(p)
151+
152+
# We at least got the File node plus both functions.
153+
assert len(nodes) >= 3
154+
funcs = [n for n in nodes if n.kind == "Function"]
155+
func_names = {f.name for f in funcs}
156+
assert "greet" in func_names
157+
assert "main" in func_names
158+
for n in nodes:
159+
assert n.language == "bash"
160+
24161
def test_parse_python_file(self):
25162
nodes, edges = self.parser.parse_file(FIXTURES / "sample_python.py")
26163

0 commit comments

Comments
 (0)