Recursion guard and static klammer checking
A klammer that reaches itself, directly or through a cycle, expanded until the C++ stack was exhausted: the process died from SIGSEGV with no message and no location. The former limit guarded only the top-level fixed-point iteration, never the descent through klammer application. A depth guard now raises a recursion error naming the klammer and where it was applied. The same loop's termination test moves from "the katom list stopped growing" to "a pass applied no klammer", since a klammer whose body expands to nothing is a reduction that adds no katoms; exceeding the round limit is now an error rather than a message followed by rendering a document with live klammers still in it. ktext --check locates every klammer application written in a document or in a klammer body and checks name existence, argument count, option names, and target coverage without applying anything, reporting all problems at once. This is possible because Klammertext has no catcodes: katom structure is fixed when a file is read, so a klammer body has a determinate shape before it is expanded. The check therefore reaches what the engine cannot -- the branch of a @cond that is not selected, and bodies a given render never enters. @cond's set of truth values is an open language question, so its meaning is unchanged here; an unrecognized predicate now warns, giving its value and location. tst/ gains recursion_test.sh (7 cases) and check_test.sh (19 cases), and this snapshot's test Makefile is generated from the shipped suite list so the two cannot drift apart. (from dev c27e63802406) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
tst/Makefile
16
tst/Makefile
@@ -1,22 +1,16 @@
|
||||
# Klammertext distribution test suite (subset).
|
||||
#
|
||||
# Runs the eight shell regression suites:
|
||||
# cond_test.sh — @cond argument delimitation
|
||||
# deftype_test.sh — the four klammer definition modes + redefinition table
|
||||
# escape_test.sh — target character escaping and quoted specials
|
||||
# filename_test.sh — filenames with spaces (quoting, " / " lists, rescue)
|
||||
# alone_test.sh — an optional argument's three values (default, the
|
||||
# argument type's :alone value, a written value)
|
||||
# modulepath_test.sh — @eval finds modules beside the file that names them
|
||||
# klammerset_test.sh — the @@@klammerset system command and its search path
|
||||
# editor_test.sh — editor support (doc/edit): indentation and table
|
||||
# alignment; needs python3, uses Emacs when installed
|
||||
# Runs the shell regression suites shipped with this snapshot (the list is
|
||||
# generated from the distribution manifest, so it cannot drift from the files
|
||||
# actually shipped). Each suite's own header comment says what it covers.
|
||||
#
|
||||
# Requires KLAMMERTEXT_HOME set and `ktext` on PATH (build it with `make -C com`).
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
./cond_test.sh
|
||||
./recursion_test.sh
|
||||
./check_test.sh
|
||||
./deftype_test.sh
|
||||
./escape_test.sh
|
||||
./filename_test.sh
|
||||
|
||||
204
tst/check_test.sh
Executable file
204
tst/check_test.sh
Executable file
@@ -0,0 +1,204 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# check_test.sh — Static checking of klammer applications ("ktext --check").
|
||||
#
|
||||
# The engine applies klammers as it meets them, so it can only complain about
|
||||
# what it reaches. Two things it therefore never reaches:
|
||||
#
|
||||
# * the branch of a @cond that is not selected. @cond is a non-strict
|
||||
# special form, so an undefined klammer or a wrong argument count sitting
|
||||
# in the unselected branch is invisible -- and stays invisible until the
|
||||
# day the predicate flips.
|
||||
# * a klammer body that this render does not use, including a body defined
|
||||
# for a target other than the one being built.
|
||||
#
|
||||
# Klammertext can check both without running anything, because katom structure
|
||||
# is fixed when a file is read: there are no catcodes, so nothing later can
|
||||
# change how text already read divides into katoms. Every application written
|
||||
# literally in a document or a klammer body can be located and checked against
|
||||
# the registry. That is what check_machine() (mac/check.cpp) does and what
|
||||
# these tests pin down.
|
||||
#
|
||||
# What the checker deliberately does not see is also tested: klammers produced
|
||||
# by @eval at run time, and the interiors of @eval/@read argument spans and of
|
||||
# literal parameters, which are code, filenames, and raw text -- not
|
||||
# applications.
|
||||
#
|
||||
# Engine tier: no klammer set (-k none), every klammer defined inline.
|
||||
#
|
||||
# Usage: ./check_test.sh
|
||||
# Exit code: 0 if all tests pass, 1 otherwise.
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
KTEXT=ktext
|
||||
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
|
||||
|
||||
red=$'\033[31m'
|
||||
green=$'\033[32m'
|
||||
bold=$'\033[1m'
|
||||
reset=$'\033[0m'
|
||||
|
||||
# check_finds TEST_NAME PATTERN KTEXT_ARGS...
|
||||
# --check must exit nonzero and report PATTERN.
|
||||
check_finds() {
|
||||
local test_name="$1"
|
||||
local pattern="$2"
|
||||
shift 2
|
||||
|
||||
local output status
|
||||
output=$("$KTEXT" --check "$@" 2>&1)
|
||||
status=$?
|
||||
|
||||
if [ $status -eq 0 ]; then
|
||||
echo "${red}FAIL${reset} $test_name — expected a diagnostic, got none"
|
||||
FAIL=$((FAIL + 1))
|
||||
return
|
||||
fi
|
||||
if echo "$output" | grep -qF "$pattern"; then
|
||||
echo "${green}PASS${reset} $test_name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "${red}FAIL${reset} $test_name — expected report to contain [$pattern]"
|
||||
echo " output: $(echo "$output" | head -4)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# check_clean TEST_NAME KTEXT_ARGS...
|
||||
# --check must exit 0 and report no diagnostics.
|
||||
check_clean() {
|
||||
local test_name="$1"
|
||||
shift
|
||||
|
||||
local output status
|
||||
output=$("$KTEXT" --check "$@" 2>&1)
|
||||
status=$?
|
||||
|
||||
if [ $status -eq 0 ] && echo "$output" | grep -q "0 diagnostics"; then
|
||||
echo "${green}PASS${reset} $test_name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "${red}FAIL${reset} $test_name — expected a clean check"
|
||||
echo " output: $(echo "$output" | head -4)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# check_count TEST_NAME N KTEXT_ARGS...
|
||||
# --check must report exactly N diagnostics.
|
||||
check_count() {
|
||||
local test_name="$1"
|
||||
local expected="$2"
|
||||
shift 2
|
||||
|
||||
local output got
|
||||
output=$("$KTEXT" --check "$@" 2>&1)
|
||||
got=$(echo "$output" | sed -nE 's/^([0-9]+) diagnostics?,.*/\1/p')
|
||||
|
||||
if [ "$got" = "$expected" ]; then
|
||||
echo "${green}PASS${reset} $test_name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "${red}FAIL${reset} $test_name — expected $expected diagnostics, got ${got:-none}"
|
||||
echo " output: $(echo "$output" | head -4)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
GREET='@@greet name : Hello, *name*. @@'
|
||||
|
||||
echo "${bold}Static klammer checking tests${reset}"
|
||||
echo "============================="
|
||||
echo
|
||||
|
||||
# --- What the renderer cannot reach ---
|
||||
|
||||
check_finds " 1. undefined klammer in an unselected @cond branch (in a body)" \
|
||||
"@nosuch is not defined" \
|
||||
-k none -s "@@pick p : @cond *p* | @nosuch x @ | ok @ @@ @pick false @"
|
||||
|
||||
check_finds " 2. wrong arity in an unselected @cond branch (in a body)" \
|
||||
"is given 3" \
|
||||
-k none -s "$GREET @@pick p : @cond *p* | @greet a | b | c @ | ok @ @@ @pick false @"
|
||||
|
||||
# A @cond written at the top level of a DOCUMENT is resolved when the file is
|
||||
# read, so its unselected branch is gone before anything can be checked. This
|
||||
# test records that limitation rather than asserting the behavior is right; see
|
||||
# notes/Klammertext_improvements.md, "When @cond is resolved".
|
||||
check_clean " 2a. LIMITATION: a top-level @cond branch is resolved before checking" \
|
||||
-k none -s "@cond false | @nosuch x @ | ok @"
|
||||
|
||||
check_finds " 3. undefined klammer in a body that is never applied" \
|
||||
"@nosuch is not defined" \
|
||||
-k none -s "@@unused : @nosuch x @ @@ nothing applies it"
|
||||
|
||||
check_finds " 4. the body it was found in is named" \
|
||||
"in body of @unused" \
|
||||
-k none -s "@@unused : @nosuch x @ @@ nothing applies it"
|
||||
|
||||
# --- Arity ---
|
||||
|
||||
check_finds " 5. too few positional arguments" \
|
||||
"needs 2 positional arguments but is given 1" \
|
||||
-k none -s '@@pair a | b : *a**b* @@ @pair x @'
|
||||
|
||||
check_finds " 6. too many positional arguments" \
|
||||
"takes 1 positional argument but is given 2" \
|
||||
-k none -s "$GREET @greet a | b @"
|
||||
|
||||
check_finds " 7. undefined optional argument" \
|
||||
'has no optional argument ":nope"' \
|
||||
-k none -s "$GREET @greet a :nope 1 @"
|
||||
|
||||
check_finds " 8. the accepted optional arguments are listed" \
|
||||
"It accepts: :loud" \
|
||||
-k none -s '@@greet name :loud : *name* @@ @greet a :nope 1 @'
|
||||
|
||||
check_clean " 9. a rest argument accepts extra positional arguments" \
|
||||
-k none -s '@@many a | rest.rest : *a* @@ @many x | y | z @'
|
||||
|
||||
# --- Nesting. A bar or an option name belonging to a nested klammer is not
|
||||
# this klammer's; the checker counts at depth 0, as @cond does. ---
|
||||
|
||||
check_clean "10. nested klammer's bars are not counted as the outer's" \
|
||||
-k none -s '@@frac a | b : *a*/*b* @@ @@one x : [*x*] @@ @one @frac 1 | 2 @ @'
|
||||
|
||||
check_clean "11. nested klammer's option name is not counted as the outer's" \
|
||||
-k none -s '@@inner a :flag : *a* @@ @@outer x : [*x*] @@ @outer @inner q :flag y @ @'
|
||||
|
||||
# --- Target coverage ---
|
||||
|
||||
check_finds "12. klammer not defined for a target" \
|
||||
'is not defined for the target "tex"' \
|
||||
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@only.html : H @@ @only@'
|
||||
|
||||
check_clean "13. defined for every target is clean" \
|
||||
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@both : B @@ @both@'
|
||||
|
||||
# --- What the checker deliberately does not see ---
|
||||
|
||||
# In a body, so the @eval is not evaluated at read time: what is being tested
|
||||
# is that the checker does not read the eval's ARGUMENT as an application.
|
||||
check_clean "14. @eval argument content is code, not applications" \
|
||||
-k none -s '@@w : @eval len("@nosuch") @ @@'
|
||||
|
||||
check_clean "15. a literal parameter's content is raw text" \
|
||||
-k none -s '@@lit t.literal : *t* @@ @lit @nosuch x @ lit@'
|
||||
|
||||
# --- Reporting ---
|
||||
|
||||
check_count "16. a target-independent fault is reported once, not per target" \
|
||||
1 \
|
||||
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@g : @nosuch@ @@'
|
||||
|
||||
check_clean "17. a correct document checks clean" \
|
||||
-k none -s "$GREET @greet World @"
|
||||
|
||||
check_clean "18. the Standard Klammer Set checks clean" \
|
||||
-s 'x'
|
||||
|
||||
echo
|
||||
echo "============================="
|
||||
echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}"
|
||||
[ $FAIL -eq 0 ]
|
||||
139
tst/recursion_test.sh
Executable file
139
tst/recursion_test.sh
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# recursion_test.sh — The klammer application recursion guard.
|
||||
#
|
||||
# Before the guard, a klammer that applied itself -- directly or through a
|
||||
# cycle -- descended until the C++ stack was exhausted. The process died with
|
||||
# SIGSEGV: no message, no location, no indication of which klammer was at
|
||||
# fault, and a core dump. For a language whose premise is that users define
|
||||
# their own klammers, that was the worst available failure mode.
|
||||
#
|
||||
# Machine::apply_klammer() now carries a depth guard (Depth_guard in
|
||||
# mac/machine.cpp) that raises a Recursion_error naming the klammer and its
|
||||
# location. Separately, the top-level fixed-point loop in Machine::apply()
|
||||
# ends when a pass applies no klammer -- rather than when the katom list stops
|
||||
# growing -- and exceeding its round limit is an error rather than a message
|
||||
# followed by rendering a document with live klammers still in it.
|
||||
#
|
||||
# These are engine tests: no klammer set is loaded (-k none) and every klammer
|
||||
# used is defined inline as a fixture.
|
||||
#
|
||||
# Usage: ./recursion_test.sh
|
||||
# Exit code: 0 if all tests pass, 1 otherwise.
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
KTEXT=ktext
|
||||
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
|
||||
|
||||
red=$'\033[31m'
|
||||
green=$'\033[32m'
|
||||
bold=$'\033[1m'
|
||||
reset=$'\033[0m'
|
||||
|
||||
# check_error TEST_NAME PATTERN KTEXT_ARGS...
|
||||
# Runs ktext, expects a NONZERO exit status and PATTERN in the message.
|
||||
# A signal death (exit >= 128) is called out separately: that is the exact
|
||||
# regression this suite exists to prevent, and reporting it as "some error"
|
||||
# would hide it.
|
||||
check_error() {
|
||||
local test_name="$1"
|
||||
local pattern="$2"
|
||||
shift 2
|
||||
|
||||
local output status
|
||||
output=$("$KTEXT" "$@" 2>&1)
|
||||
status=$?
|
||||
|
||||
if [ $status -ge 128 ]; then
|
||||
echo "${red}FAIL${reset} $test_name — ktext died from signal $((status - 128))"
|
||||
FAIL=$((FAIL + 1))
|
||||
return
|
||||
fi
|
||||
if [ $status -eq 0 ]; then
|
||||
echo "${red}FAIL${reset} $test_name — expected an error but ktext succeeded"
|
||||
FAIL=$((FAIL + 1))
|
||||
return
|
||||
fi
|
||||
if echo "$output" | grep -qF "$pattern"; then
|
||||
echo "${green}PASS${reset} $test_name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "${red}FAIL${reset} $test_name — expected error to contain [$pattern]"
|
||||
echo " output: $(echo "$output" | head -4)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# check_eq TEST_NAME EXPECTED KTEXT_ARGS...
|
||||
check_eq() {
|
||||
local test_name="$1"
|
||||
local expected="$2"
|
||||
shift 2
|
||||
|
||||
local output status
|
||||
output=$("$KTEXT" "$@" 2>/dev/null)
|
||||
status=$?
|
||||
output=$(printf '%s' "$output" | tr -d '\n' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
|
||||
|
||||
if [ $status -ne 0 ]; then
|
||||
echo "${red}FAIL${reset} $test_name — ktext exited $status"
|
||||
FAIL=$((FAIL + 1))
|
||||
return
|
||||
fi
|
||||
if [ "$output" = "$expected" ]; then
|
||||
echo "${green}PASS${reset} $test_name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "${red}FAIL${reset} $test_name"
|
||||
echo " expected: [$expected]"
|
||||
echo " got: [$output]"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "${bold}Klammer recursion guard tests${reset}"
|
||||
echo "============================="
|
||||
echo
|
||||
|
||||
# --- Non-termination is an error, not a crash ---
|
||||
|
||||
check_error " 1. direct self-recursion is caught" \
|
||||
"does not terminate" \
|
||||
-k none -s '@@f : x @f@ @@ @f@' -d
|
||||
|
||||
check_error " 2. the offending klammer is named" \
|
||||
'applying "f"' \
|
||||
-k none -s '@@f : x @f@ @@ @f@' -d
|
||||
|
||||
check_error " 3. mutual recursion is caught" \
|
||||
"does not terminate" \
|
||||
-k none -s '@@a : ( @b@ ) @@ @@b : [ @a@ ] @@ @a@' -d
|
||||
|
||||
check_error " 4. self-recursion through an argument is caught" \
|
||||
"does not terminate" \
|
||||
-k none -s '@@w t : < *t* > @@ @@r : @w @r@ @ @@ @r@' -d
|
||||
|
||||
# --- Terminating nesting is untouched ---
|
||||
|
||||
check_eq " 5. deep but finite nesting still reduces" \
|
||||
"<<<<<x>>>>>" \
|
||||
-k none -s '@@w t : <*t*> @@ @w @w @w @w @w x @ @ @ @ @' -d
|
||||
|
||||
check_eq " 6. a chain of klammers generating klammers reduces" \
|
||||
"END" \
|
||||
-k none -s '@@k1 : @k2@ @@ @@k2 : @k3@ @@ @@k3 : @k4@ @@ @@k4 : @k5@ @@ @@k5 : @k6@ @@ @@k6 : @k7@ @@ @@k7 : @k8@ @@ @@k8 : END @@ @k1@' -d
|
||||
|
||||
# --- The fixed point ends on "nothing was applied", not "nothing was added" ---
|
||||
#
|
||||
# A klammer whose body is empty reduces without adding katoms. Under the old
|
||||
# size-comparison test such a klammer looked like no progress at all.
|
||||
|
||||
check_eq " 7. a klammer with an empty body reduces" \
|
||||
"a b" \
|
||||
-k none -s '@@nothing : @@ a @nothing@ b' -d
|
||||
|
||||
echo
|
||||
echo "============================="
|
||||
echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}"
|
||||
[ $FAIL -eq 0 ]
|
||||
Reference in New Issue
Block a user