Recently Written · git

tmpl

Insanely simple templates

git clone https://github.com/equwal/tmpl

Log | Files | Refs


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))))