commit 9b35e1c78fdd1faa1f627adcc8c28f8968535910 map588 <map588@pitt.edu> 2026-09-27 02:20:27 -0400 Add make check and a CI workflow make check parses shell templates with sh -n and shellcheck, compiles the C templates, reads the Lisp templates with sbcl when installed, runs the Python tests, and confirms INDEX.md is current. The test suite now checks that every template starts with a summary comment and uses a reference parser for the snippet round trip; the old regex misread an escaped backslash before a tab stop. The pushd template exits the subshell when cd fails.
.github/workflows/check.yml | 15 ++++++++++ .gitignore | 1 + Makefile | 41 +++++++++++++++++++++++++- README.md | 10 +++++-- bin/tmpl | 2 +- sh/pushd-popd-directory-switch | 2 +- test_convert_to_luasnip.py | 66 ++++++++++++++++++++++++++++++++++-------- tests/read-forms.lisp | 9 ++++++ 8 files changed, 129 insertions(+), 17 deletions(-)
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..4bdbe90 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,15 @@ +name: check + +on: [push, pull_request] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: sudo apt-get update && sudo apt-get install -y shellcheck sbcl + - run: python3 -m pip install pytest hypothesis + - run: make check diff --git a/.gitignore b/.gitignore index 93df652..36327b1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ CLAUDE.md __pycache__/ .hypothesis/ .venv/ +.check.* diff --git a/Makefile b/Makefile index 56929bf..0988704 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,48 @@ # Maintenance targets for the tmpl repository. .POSIX: -.PHONY: index +.PHONY: index check check-sh check-c check-lisp check-py check-index + +PYTHON = python3 + +# Replace every placeholder with a word so the checkers see valid syntax. +FILL = sed 's/<+[^>]*+>/x/g' # Rebuild INDEX.md from the templates and their summary comments. index: { printf '# Index\n\nGenerated by `make index`. Do not edit by hand.\n\n| Template | Summary |\n|---|---|\n'; \ bin/tmpl -l | awk -F '\t' '{ printf "| `%s` | %s |\n", $$1, $$2 }'; } > INDEX.md + +check: check-sh check-c check-lisp check-py check-index + +# Parse every shell template with sh -n and shellcheck. +check-sh: + @status=0; \ + for f in $$(grep -l '<+' $$(find sh -type f) openrc.init bin/tmpl); do \ + $(FILL) "$$f" > .check.sh; \ + sh -n .check.sh || { echo "sh -n: $$f"; status=1; }; \ + shellcheck -s sh -S warning .check.sh || { echo "shellcheck: $$f"; status=1; }; \ + done; rm -f .check.sh; exit $$status + +# Compile every C template with the placeholders replaced by a statement. +check-c: + @status=0; \ + for f in $$(grep -l '<+' c/*.c); do \ + sed 's/<+[^>]*+>/;/g' "$$f" | cc -fsyntax-only -x c - || { echo "cc: $$f"; status=1; }; \ + done; exit $$status + +# Read every Common Lisp template. Skip when sbcl is not installed. +check-lisp: + @command -v sbcl >/dev/null || { echo "check-lisp: sbcl not installed, skipped"; exit 0; }; \ + status=0; \ + for f in $$(grep -l '<+' cl/*); do \ + $(FILL) "$$f" > .check.lisp; \ + sbcl --script tests/read-forms.lisp .check.lisp || { echo "read: $$f"; status=1; }; \ + done; rm -f .check.lisp; exit $$status + +check-py: + $(PYTHON) -m pytest -q + +# INDEX.md must match the templates. +check-index: index + git diff --exit-code -- INDEX.md diff --git a/README.md b/README.md index ed33127..fd2e8ba 100644 --- a/README.md +++ b/README.md @@ -107,12 +107,18 @@ The snippet prefix is the path with dashes: `c/main.c` is `c-main`, template's summary comment. Type the prefix, press Tab, and Tab again to move between the placeholders. -Tests: +# Checks + ```sh python3 -m pip install pytest hypothesis -python3 -m pytest +make check ``` +`make check` parses the shell templates with `sh -n` and `shellcheck`, +compiles the C templates, reads the Lisp templates with `sbcl` when it is +installed, runs the Python tests, and confirms `INDEX.md` is current. The +GitHub Actions workflow runs the same target. + # POSIX shell tricks Many of the shell tricks come from https://www.etalabs.net/sh_tricks.html, diff --git a/bin/tmpl b/bin/tmpl index e683f49..07442ec 100755 --- a/bin/tmpl +++ b/bin/tmpl @@ -22,7 +22,7 @@ usage() { list_paths() { find "$root" -type f \ ! -path '*/.*' ! -path '*/__pycache__/*' \ - ! -path "$root/bin/*" \ + ! -path "$root/bin/*" ! -path "$root/tests/*" \ ! -name '*.py' ! -name '*.md' ! -name Makefile \ | sed "s|^$root/||" | sort } diff --git a/sh/pushd-popd-directory-switch b/sh/pushd-popd-directory-switch index d5224e3..f70ec34 100644 --- a/sh/pushd-popd-directory-switch +++ b/sh/pushd-popd-directory-switch @@ -8,6 +8,6 @@ # posix: use a subshell for fake pushd/popd ( # pushd - cd <++> + cd <++> || exit <++> ) # popd diff --git a/test_convert_to_luasnip.py b/test_convert_to_luasnip.py index 6a3eed5..aa710b5 100644 --- a/test_convert_to_luasnip.py +++ b/test_convert_to_luasnip.py @@ -1,10 +1,9 @@ """Tests for convert_to_luasnip.""" import json -import re from pathlib import Path -from hypothesis import given +from hypothesis import given, settings from hypothesis import strategies as st import convert_to_luasnip as conv @@ -12,29 +11,57 @@ import convert_to_luasnip as conv PLACEHOLDER = "<++>" -def unescape(text: str) -> str: - """Reverse VSCode snippet escaping. Reference for the round trip.""" - return re.sub(r"\\([\\$}])", r"\1", text) +def parse_snippet(body: str): + """Reference parser for VSCode snippet text. + + Return the source text with placeholders restored, and the list of tab + stop numbers in order. This is the inverse of convert_placeholders. + """ + out, stops, i = [], [], 0 + while i < len(body): + c = body[i] + if c == "\\" and i + 1 < len(body) and body[i + 1] in "\\$}": + out.append(body[i + 1]) + i += 2 + elif body.startswith("${", i): + i += 2 + inner = [] + while body[i] != "}": + if body[i] == "\\" and body[i + 1] in "\\$}": + inner.append(body[i + 1]) + i += 2 + else: + inner.append(body[i]) + i += 1 + i += 1 + num, _, name = "".join(inner).partition(":") + stops.append(num) + out.append(f"<+{name}+>" if name else PLACEHOLDER) + else: + assert c not in "$}", f"unescaped {c!r} at {i} in {body!r}" + out.append(c) + i += 1 + return "".join(out), stops text_without_placeholder = st.text().filter(lambda s: "<+" not in s) +@settings(max_examples=500) @given(text_without_placeholder) def test_escape_round_trip(text): body, count = conv.convert_placeholders(text) assert count == 0 - assert unescape(body) == text + assert parse_snippet(body) == (text, []) +@settings(max_examples=500) @given(st.lists(text_without_placeholder, min_size=1, max_size=6)) def test_placeholder_count_matches_tab_stops(chunks): source = PLACEHOLDER.join(chunks) body, count = conv.convert_placeholders(source) assert count == len(chunks) - 1 - stops = re.findall(r"(?<!\\)\$\{(\d+)\}", body) - assert stops == [str(i) for i in range(1, count + 1)] - assert unescape(re.sub(r"(?<!\\)\$\{\d+\}", PLACEHOLDER, body)) == source + assert parse_snippet(body) == (source, [str(i) for i in range(1, count + 1)]) def test_shell_variable_is_escaped(): @@ -98,10 +125,12 @@ def test_named_placeholder_becomes_named_tab_stop(): assert count == 2 +@settings(max_examples=500) @given(st.text(alphabet=st.characters(blacklist_characters="+>"), min_size=1, max_size=8)) -def test_named_placeholder_name_is_escaped(name): - body, _ = conv.convert_placeholders(f"<+{name}+>") - assert body == "${1:" + conv.escape_snippet_text(name) + "}" +def test_named_placeholder_round_trip(name): + body, count = conv.convert_placeholders(f"<+{name}+>") + assert count == 1 + assert parse_snippet(body) == (f"<+{name}+>", ["1"]) def test_description_comes_from_head_comment(tmp_path): @@ -127,3 +156,16 @@ def test_description_falls_back_to_path(tmp_path): g = root / "sh" / "cfile" g.write_text("#include <stdio.h>\n<++>\n") assert conv.get_description(g, root) == "sh/cfile template" + + +REPO = Path(__file__).resolve().parent + + +def test_every_repo_template_has_a_summary(): + """Every file with a placeholder starts with a summary comment. JSON has no comments.""" + missing = [] + for lang, snippets in conv.collect_templates_by_language(REPO).items(): + for snippet in snippets: + if lang != "json" and snippet["description"].endswith(" template"): + missing.append(snippet["prefix"]) + assert missing == [] diff --git a/tests/read-forms.lisp b/tests/read-forms.lisp new file mode 100644 index 0000000..dd5d7cf --- /dev/null +++ b/tests/read-forms.lisp @@ -0,0 +1,9 @@ +;; Read every form in the file named on the command line. Exit 1 on a read error. +(let ((path (second sb-ext:*posix-argv*))) + (handler-case + (with-open-file (in path) + (loop for form = (read in nil in) + until (eq form in))) + (error (e) + (format *error-output* "~a: ~a~%" path e) + (sb-ext:exit :code 1))))