mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:47:04 +01:00
The detection regex matched only `name() {`. bash also accepts
`function name() {` and `function name {`, and pacman_wrapper.sh uses the
keyword form for 17 of its 30 functions — so a split of that file would have
left every one of them behind in the entry script while the tool reported
success. Silent, and exactly the kind of half-done split the verifier is
meant to catch afterwards rather than the extractor causing.
Parentheses stay mandatory in the non-keyword branch. Making them optional
everywhere also matches brace groups and control constructs, which would
sweep non-function code into the library — the opposite failure, and worse.
Nine negative tests pin that boundary.
The function name now comes from a named capture group rather than
splitting on "(", which returned "function name" for the keyword form and
would have broken both --functions selection and the verifier's name matching.
Verified against the already-split files first: cco_state.sh still yields 13
functions and leechblock_fetch.sh 3, so the completed splits are unaffected.
pacman_wrapper.sh goes from 13 detected to 31.
First tests for this script: 20 cases covering every accepted spelling,
brace matching across nested bodies, and single-line functions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3PzGc75gBcdxP3PkHXGcV
150 lines
6.1 KiB
Python
Executable File
150 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Move a shell script's top-level functions into a sourced library, verbatim.
|
|
|
|
Line-range slicing is the obvious approach and it is wrong: these scripts
|
|
interleave top-level commands between their function definitions, so a range
|
|
slice sweeps those commands into the library, where they run at source time
|
|
and in the wrong order. This walks the file brace-by-brace instead, lifts only
|
|
the function blocks (with the comment directly above each), and leaves every
|
|
other line exactly where it was.
|
|
|
|
Verify the result with meta/scripts/verify_shell_split.sh.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
|
|
# Both bash spellings: `name() {` and `function name() {`. Accepting only the
|
|
# first silently skipped 17 of the 30 functions in pacman_wrapper.sh, leaving
|
|
# them in the entry script while reporting success -- a split that looks done
|
|
# and is not.
|
|
#
|
|
# `()` is required unless the `function` keyword is present. Making the parens
|
|
# optional in both branches would match plain brace groups and control blocks
|
|
# (`while read -r x; do ... done {`-style constructs and `if ... {` in other
|
|
# dialects), sweeping non-function code into the library.
|
|
_FUNC_RE = re.compile(
|
|
r"^(?:function\s+(?P<kwname>[a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\(\))?"
|
|
r"|(?P<name>[a-zA-Z_][a-zA-Z0-9_]*)\s*\(\))\s*\{"
|
|
)
|
|
|
|
|
|
def find_function_blocks(lines: list[str]) -> list[tuple[int, int, str]]:
|
|
"""Return (start, end, name) for each top-level function, brace-matched."""
|
|
blocks: list[tuple[int, int, str]] = []
|
|
index = 0
|
|
while index < len(lines):
|
|
match = _FUNC_RE.match(lines[index])
|
|
if match:
|
|
# Take the name from the regex, not from splitting on "(": the
|
|
# `function name()` form would otherwise yield "function name".
|
|
name = match.group("kwname") or match.group("name")
|
|
start, depth = index, 0
|
|
while index < len(lines):
|
|
depth += lines[index].count("{") - lines[index].count("}")
|
|
# A one-line `name() { ...; }` closes on its opening line, so
|
|
# the depth check must accept index == start.
|
|
if depth <= 0:
|
|
break
|
|
index += 1
|
|
blocks.append((start, index, name))
|
|
index += 1
|
|
return blocks
|
|
|
|
|
|
def split_script(
|
|
path: Path, lib_path: Path, names: list[str] | None, header: str
|
|
) -> tuple[int, int]:
|
|
"""Move the named functions (or all of them) into *lib_path*."""
|
|
lines = path.read_text().split("\n")
|
|
before_len = len(lines)
|
|
blocks = find_function_blocks(lines)
|
|
if names:
|
|
blocks = [b for b in blocks if b[2] in names]
|
|
missing = set(names) - {b[2] for b in blocks}
|
|
if missing:
|
|
sys.exit(f"no such top-level function(s): {', '.join(sorted(missing))}")
|
|
if not blocks:
|
|
sys.exit(f"no top-level functions to move out of {path}")
|
|
|
|
parts: list[str] = []
|
|
drop: set[int] = set()
|
|
for start, end, _name in blocks:
|
|
head = start
|
|
while head - 1 >= 0 and lines[head - 1].lstrip().startswith("#"):
|
|
head -= 1
|
|
parts.append("\n".join(lines[head : end + 1]))
|
|
drop.update(range(head, end + 1))
|
|
|
|
kept = [ln for n, ln in enumerate(lines) if n not in drop]
|
|
rel = lib_path.relative_to(path.parent)
|
|
source_line = '. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/' + str(rel) + '"'
|
|
out: list[str] = []
|
|
inserted = False
|
|
for line in kept:
|
|
out.append(line)
|
|
# `set -e # comment` is common here, so match the directive, not the line.
|
|
if not inserted and re.match(r"^set -[euo pipefail]+\b", line.strip()):
|
|
out += ["", f"# shellcheck source={rel}", source_line]
|
|
inserted = True
|
|
|
|
# Both files are written only once the whole split is known to be
|
|
# expressible. Writing the library before this point and then exiting on a
|
|
# missing anchor left the functions defined in BOTH files, so the entry
|
|
# script came out longer than it started -- a duplicate-definition split
|
|
# that still lints clean and still passes a function-body hash check.
|
|
# A split that does not shrink the entry script did not move anything; it
|
|
# duplicated. Refuse rather than write, since the result lints clean and
|
|
# passes a function-body hash check while defining every function twice.
|
|
if len(out) >= before_len:
|
|
sys.exit(
|
|
f"refusing to write: {path} would go from {before_len} to {len(out)} "
|
|
f"lines, so nothing was actually moved out of it"
|
|
)
|
|
|
|
lib_path.parent.mkdir(parents=True, exist_ok=True)
|
|
lib_path.write_text(header.rstrip() + "\n\n" + "\n\n".join(parts) + "\n")
|
|
path.write_text("\n".join(out))
|
|
|
|
if not inserted:
|
|
sys.stderr.write(
|
|
f"warning: no `set -e` line in {path} to anchor the source directive.\n"
|
|
f" The functions were moved and {lib_path.name} was written, "
|
|
f"but nothing sources it yet.\n"
|
|
f' Add this by hand, after the existing `. "$SCRIPT_DIR/..."` '
|
|
f"line:\n"
|
|
f" # shellcheck source={rel}\n"
|
|
f' . "$SCRIPT_DIR/{rel}"\n'
|
|
)
|
|
return len(out), len(lib_path.read_text().split("\n"))
|
|
|
|
|
|
def main() -> None:
|
|
"""Extract functions from a script into a library beside it."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("script", type=Path)
|
|
parser.add_argument("library", type=Path)
|
|
parser.add_argument(
|
|
"--functions",
|
|
help="comma-separated function names to move (default: all)",
|
|
)
|
|
parser.add_argument(
|
|
"--header",
|
|
default="#!/usr/bin/env bash\n# Helpers sourced by the entry script.",
|
|
help="header comment for the generated library",
|
|
)
|
|
args = parser.parse_args()
|
|
names = args.functions.split(",") if args.functions else None
|
|
entry_len, lib_len = split_script(args.script, args.library, names, args.header)
|
|
sys.stdout.write(
|
|
f"{args.script}: {entry_len} lines\n{args.library}: {lib_len} lines\n"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|