#!/bin/bash # # klammerset_test.sh — Regression tests for the @@@klammerset system command. # # @@@klammerset declares a Klammerset: a named, logically related group of # klammer definitions. The declaration is operative — processing it reads # the :requires files and then the :files, in list order, at the point of the # declaration; relative names resolve against the declaring file's directory, # never the cwd. A repeated declaration of an already-registered symbol is # skipped (loaded once), which is what makes :requires idempotent. The # klammers themselves live in the Machine's flat Klammer_registry; the # Klammerset holds metadata and the file list only. # # Engine tier: no SKS. Fixtures live in tst/klammerset/ and define their own # target ("fix") inline. # # Usage: ./klammerset_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} FIX=$K/tst/klammerset # The fixtures are laid out as /.k under $FIX, because since # 2026-08-14 the command line takes klammerset SYMBOLS only -- a set that # cannot be named by one cannot be loaded. Putting $FIX on the search path is # how the suite makes its own fixtures nameable, and it also exercises stage 2 # (KLAMMERTEXT_KLAMMERSETS) for both the command line and ":requires". # Tests that need a different path set it themselves on the command. export KLAMMERTEXT_KLAMMERSETS=$FIX red=$'\033[31m' green=$'\033[32m' bold=$'\033[1m' reset=$'\033[0m' # strip leading/trailing blank lines and surrounding whitespace trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR && line[f]=="") f++; l=NR; while (l>=1 && line[l]=="") l--; for (i=f;i<=l;i++) print line[i] }'; } # check_eq TEST_NAME EXPECTED KTEXT_ARGS... # Runs ktext, expects exit status 0, and compares trimmed stdout to EXPECTED. check_eq() { local test_name="$1" local expected="$2" shift 2 local output status output=$("$KTEXT" "$@" 2>/tmp/klammerset_test_err.$$) status=$? output=$(printf '%s' "$output" | trim) if [ $status -ne 0 ]; then echo "${red}FAIL${reset} $test_name — ktext exited $status" echo " stderr: $(head -3 /tmp/klammerset_test_err.$$)" 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 } # check_contains TEST_NAME SUBSTRING KTEXT_ARGS... # Runs ktext, expects exit status 0, and checks that the combined output # contains SUBSTRING. check_contains() { local test_name="$1" local needle="$2" shift 2 local output status output=$("$KTEXT" "$@" 2>&1) status=$? if [ $status -ne 0 ]; then echo "${red}FAIL${reset} $test_name — ktext exited $status" echo " output: $(echo "$output" | head -3)" FAIL=$((FAIL + 1)) return fi if printf '%s' "$output" | grep -qF "$needle"; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} $test_name" echo " expected output to contain: [$needle]" echo " got: $(echo "$output" | head -5)" FAIL=$((FAIL + 1)) fi } # check_fails TEST_NAME SUBSTRING KTEXT_ARGS... # Runs ktext, expects a NONZERO exit status, and checks that the combined # output contains SUBSTRING. check_fails() { local test_name="$1" local needle="$2" shift 2 local output status output=$("$KTEXT" "$@" 2>&1) status=$? if [ $status -eq 0 ]; then echo "${red}FAIL${reset} $test_name — expected an error, ktext exited 0" FAIL=$((FAIL + 1)) return fi if printf '%s' "$output" | grep -qF "$needle"; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} $test_name" echo " expected error output to contain: [$needle]" echo " got: $(echo "$output" | head -5)" FAIL=$((FAIL + 1)) fi } echo "${bold}@@@klammerset tests${reset}" echo "=======================" echo # --- Loading --------------------------------------------------------------- # 1. :files load in list order: klammers.k defines @greet for the target # that base.k declares, so base.k must have been read first. check_eq "1. :files load in order (target before klammer)" \ "Hello World" \ --klammersets kit -s '@greet World @' -t fix # 2. :requires loads the dependency before the set's own files. check_eq "2. :requires loads the dependency" \ "--" \ --klammersets kit -s '@dash@' -t fix # 3. Program order: a definition AFTER the declaration in the declaring file # is available (there is no :text argument; the declaring file's own # content plays that role). check_eq "3. trailing definition in the declaring file" \ "AFTER" \ --klammersets kit -s '@after@' -t fix # 4. Relative :files names resolve against the DECLARING file's directory, # not the cwd (run from an unrelated directory). output=$( (cd /tmp && "$KTEXT" --klammersets kit -s '@greet Elsewhere @' -t fix) 2>/dev/null | trim ) if [ "$output" = "Hello Elsewhere" ]; then echo "${green}PASS${reset} 4. :files resolve against the declaring file's directory" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} 4. :files resolve against the declaring file's directory" echo " expected: [Hello Elsewhere]" echo " got: [$output]" FAIL=$((FAIL + 1)) fi # 5. A filename with a space in the :files list (standalone "/" separator). check_eq "5. spacey filename in :files" \ "SPACEY" \ --klammersets sp -s '@spacey@' -t fix # --- A klammerset is identified by where it is ------------------------------- # # Symbol X must be declared in X/X.k (Andy, 2026-08-15). The convention already # governed symbol RESOLUTION; requiring it of the DECLARATION makes the symbol a # function of the path, which is what lets the already-loaded guard run before a # file is opened (cases 23-23b below) instead of after it has been re-executed. # # It also continues the reasoning behind symbols-only on the command line: if # every klammerset is reachable by symbol the set of all of them is ENUMERABLE; # if every declaration is in X/X.k each one is also IDENTIFIABLE from where it # sits, so a symbol names one file and one file declares one symbol. # # "wrap" is the fixture that violates it: wrap/dup.k declares the symbol "kit". # It formerly tested that such a duplicate was tolerated and SKIPPED; the # duplicate is now impossible instead, which is the stronger guarantee. check_fails "6. a declaration outside X/X.k is an error" \ 'must be declared in a file named "kit/kit.k"' \ --klammersets wrap -s '@greet Again @' -t fix check_fails "6a. ... and the error names the file it is actually in" \ 'dup.k' \ --klammersets wrap -s '@greet Again @' -t fix # 7. EXEMPT: a document may declare a klammerset -- that is how a designer # writes one, and kdesc's provenance filter depends on it. Such a set is # local to the document: nothing can ":requires" it, so it has no identity to # protect and needs no file named after it. check_eq "7. a klammerset declared in a document is exempt" \ "ok" \ --klammersets none -s '@@@klammerset mine | Local to this document @@@ ok' # --- Introspection ----------------------------------------------------------- # The machine state moved from "ktext -m" to "kdiag --machine" in the # 2026-08-14 argument redesign: the Machine's internals are the programmer's # question, and kdiag is the programmer's command. kdiag's input is optional, # so the state can be shown without a document at all. kdiag_contains() { local test_name="$1" expected="$2"; shift 2 local output output=$(kdiag "$@" 2>/dev/null) if printf '%s' "$output" | grep -qF -- "$expected"; then echo "${green}PASS${reset} $test_name"; PASS=$((PASS + 1)) else echo "${red}FAIL${reset} $test_name" echo " expected to contain: [$expected]" FAIL=$((FAIL + 1)) fi } # 8. --machine lists the registered klammersets. kdiag has no --klammersets: # a klammerset reaches it the way anything else does, by being read in the # input. That is deliberate -- the symbol mechanism is a higher-level # convenience, and kdiag must still work when symbol resolution is what # broke. (The :requires inside kit.k still uses a symbol, resolved by the # Machine, so this exercises both paths at once.) kdiag_contains "8. --machine shows the klammerset symbol" \ "kit" \ "@read $FIX/kit/kit.k @" --process --machine kdiag_contains "9. --machine shows the required klammerset too" \ "Utility klammers for engine tests" \ "@read $FIX/kit/kit.k @" --process --machine # --- Errors -------------------------------------------------------------------- # 10. A listed file that does not exist is a clean klammerset error. check_fails "10. missing file in :files" \ "missing.k" \ --klammersets broken -s 'x' -t fix # 11. A symbol must be an identifier (starts with a letter; letters, digits, # underscores). check_fails "11. invalid symbol rejected" \ "not valid" \ --klammersets none -s '@@@klammerset 9bad | Bad symbol @@@' -d # --- The search path (symbol -> //.k) ------------------- # Runtime fixtures: a document directory holding a local klammerset, and a # separate directory serving as a KLAMMERTEXT_KLAMMERSETS stage. DOCDIR=$(mktemp -d /tmp/klammerset_doc.XXXXXX) ENVDIR=$(mktemp -d /tmp/klammerset_env.XXXXXX) trap 'rm -rf "$DOCDIR" "$ENVDIR" /tmp/klammerset_test_err.$$' EXIT mkdir -p "$DOCDIR/locset" "$ENVDIR/envset" "$ENVDIR/locset" printf '@@@klammerset locset | Document-local set @@@\n@@@target fixL | Fixture @@@\n@@local_k.fixL : LOCAL @@\n' \ > "$DOCDIR/locset/locset.k" printf '@local_k@\n' > "$DOCDIR/doc.kt" printf '@@@klammerset envset | Installed set @@@\n@@@target fixE | Fixture @@@\n@@env_k.fixE : ENV @@\n' \ > "$ENVDIR/envset/envset.k" printf '@@@klammerset locset | Shadow candidate @@@\n@@@target fixL | Fixture @@@\n@@local_k.fixL : ENV-SHADOWED @@\n' \ > "$ENVDIR/locset/locset.k" # 12. Stage 1 for a file input: the document's directory. check_eq "12. symbol resolves in the document's directory" \ "LOCAL" \ "$DOCDIR/doc.kt" --klammersets locset -t fixL -d # 13. Stage 1 for string input: the cwd stands in for the document. output=$( (cd "$DOCDIR" && "$KTEXT" -s '@local_k@' --klammersets locset -t fixL) 2>/dev/null | trim ) if [ "$output" = "LOCAL" ]; then echo "${green}PASS${reset} 13. cwd stands in for the document (string input)" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} 13. cwd stands in for the document (string input)" echo " expected: [LOCAL] got: [$output]" FAIL=$((FAIL + 1)) fi # 14. Stage 2: the KLAMMERTEXT_KLAMMERSETS directories. output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" -s '@env_k@' --klammersets envset -t fixE 2>/dev/null | trim) if [ "$output" = "ENV" ]; then echo "${green}PASS${reset} 14. symbol resolves in KLAMMERTEXT_KLAMMERSETS" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} 14. symbol resolves in KLAMMERTEXT_KLAMMERSETS" echo " expected: [ENV] got: [$output]" FAIL=$((FAIL + 1)) fi # 15. Shadowing: the document-local set wins over the installed one. output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" "$DOCDIR/doc.kt" --klammersets locset -t fixL -d 2>/dev/null | trim) if [ "$output" = "LOCAL" ]; then echo "${green}PASS${reset} 15. document-local set shadows the installed one" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} 15. document-local set shadows the installed one" echo " expected: [LOCAL] got: [$output]" FAIL=$((FAIL + 1)) fi # 16. :requires by symbol, resolved with the declaring directory as the # local stage (the required set sits inside the declaring set's dir). mkdir -p "$DOCDIR/kit2/locset" cp "$DOCDIR/locset/locset.k" "$DOCDIR/kit2/locset/locset.k" printf '@@@klammerset kit2 | Requires by symbol :requires locset @@@\n' > "$DOCDIR/kit2/kit2.k" output=$(KLAMMERTEXT_KLAMMERSETS=$DOCDIR "$KTEXT" --klammersets kit2 \ -s '@local_k@' -t fixL 2>/dev/null | trim) if [ "$output" = "LOCAL" ]; then echo "${green}PASS${reset} 16. :requires accepts a symbol (declaring-dir stage)" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} 16. :requires accepts a symbol (declaring-dir stage)" echo " expected: [LOCAL] got: [$output]" FAIL=$((FAIL + 1)) fi # 17. Stage 3: $KLAMMERTEXT_HOME — the kdesc listing enumerates sks # (sks/sks.k already satisfies the /.k convention). output=$(kdesc --klammersets 2>/dev/null) if printf '%s' "$output" | grep -q 'sks/sks\.k'; then echo "${green}PASS${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME" echo " got: $(echo "$output" | head -3)" FAIL=$((FAIL + 1)) fi # 18. An unknown symbol is a clean error, and it names both the convention and # the directories searched -- without those a user cannot tell whether the # name or the location is wrong. This is the failure mode the symbol-only # rule makes common: there is no longer a pathname to fall back on. check_fails "18. unknown symbol is a clean error" \ "was not found" \ -s 'x' --klammersets nosuchset -d check_fails "18a. ... naming the /.k convention" \ "x/x.k" \ -s 'x' --klammersets nosuchset -d check_fails "18b. ... and the directories searched" \ "$FIX" \ -s 'x' --klammersets nosuchset -d # 19. A pathname is no longer accepted: only symbols resolve. The error must # say so rather than reporting a missing file. check_fails "19. a pathname is rejected as a symbol" \ "not found" \ -s 'x' --klammersets "$FIX/kit/kit.k" -d # 20. One bad symbol among good ones fails the whole load rather than # silently loading the rest. check_fails "20. a bad symbol among good ones still fails" \ "was not found" \ -s 'x' --klammersets kit nosuchset -t fix -d # --- Several klammersets combine --------------------------------------------- # # The headline claim of "--klammersets" (plural), stated in the ktext usage and # in "kdesc --klammersets help": the symbols are a LIST, loaded in the order # given, and the klammers of all of them share ONE flat namespace -- membership # is provenance, not containment. Where two sets define the same klammer the # definition modes decide, so a set of house overrides is loaded after the set # it adjusts and a set of defaults before it. # # The fixtures: "base" declares the target and the ".k", "housea" and "houseb" # each require base and each override @who with ":::". Both requiring base is # what lets either be written first, so command-line order is the only variable. # 21. One namespace: each set's own klammers are available whatever the order. check_eq "21. combined sets share one namespace" \ "ONLY-A ONLY-B" \ --klammersets housea houseb -s '@a_only@ @b_only@' -t fx check_eq "21a. ... in either order" \ "ONLY-A ONLY-B" \ --klammersets houseb housea -s '@a_only@ @b_only@' -t fx # 22. Order decides: two ":::" overrides of the same klammer, so the set # written LAST is the one whose definition survives. check_eq "22. the last set written wins an override" \ "HOUSE-B" \ --klammersets housea houseb -s '@who@' -t fx check_eq "22a. ... and reversing the order reverses the outcome" \ "HOUSE-A" \ --klammersets houseb housea -s '@who@' -t fx # 23. :requires is loaded once even though both sets ask for it -- the # already-loaded guard -- so the base definition is there exactly once. check_eq "23. a set required by both is loaded once" \ "BASE" \ --klammersets base -s '@who@' -t fx # 23a. A DIAMOND: two sets requiring a third. "needs_a" and "needs_b" both # require "selfdef", whose declaring file holds its own @@@target -- which # is legal and normal, since there is deliberately no :text option and the # declaring file's content plays that role. # # This failed until 2026-08-15 with "Target fy is already defined", # pointing at a line the author wrote once. The already-loaded guard sat # in Klammerset_registry::add, which runs only after a file has been read # and its declaration reached -- by which time the second read had # re-executed the declaring file's own definitions. The guard now runs # BEFORE the read, which is possible only because X is declared in X/X.k # and the symbol is therefore known from the path. check_eq "23a. a diamond :requires loads the shared set once" \ "SELFDEF" \ --klammersets needs_a needs_b -s '@sd@' -t fy check_eq "23b. ... and requiring it once is unchanged" \ "SELFDEF" \ --klammersets needs_a -s '@sd@' -t fy check_eq "23c. ... as is naming the same set twice on the command line" \ "SELFDEF" \ --klammersets selfdef selfdef -s '@sd@' -t fy # 23d. The search path is FROZEN for the run. A path decides WHICH FILE a # symbol means -- identity, not value -- so if a document could extend it # mid-run, "which klammerset is X" would depend on evaluation order and # "kdesc --klammersets" could not be a complete answer. It could: # the embedded Python shares the process, so an @eval doing os.environ # changed what a later getenv returned. The environment is now read once. LATE=$(mktemp -d /tmp/klammerset_late.XXXXXX) mkdir -p "$LATE/late" printf '@@@klammerset late | Appeared mid-run @@@\n@@@target lt | T @@@\n@@l.lt : LATE @@\n' \ > "$LATE/late/late.k" # The @eval still sets the variable -- what changed is that resolution no # longer consults it -- so the symbol must remain unfindable. out=$(KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets none -d \ -s "@eval os.environ.__setitem__('KLAMMERTEXT_KLAMMERSETS','$LATE') or '' @" 2>&1) if printf '%s' "$out" | grep -q "was not found" || \ ! KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets late -s 'x' -d >/dev/null 2>&1; then echo "${green}PASS${reset} 23d. an @eval cannot extend the klammerset search path" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} 23d. the search path moved during the run" FAIL=$((FAIL + 1)) fi rm -rf "$LATE" # --- "none" is the absence of a klammerset, not the name of one --------------- # # It therefore says nothing about what should be loaded alongside it. Written # first it used to discard the rest of the list silently -- the user asked for # a set, got none, and was told only that some target was undefined; written # last it was looked up as a symbol and reported missing. Neither reading is # guessed at now: "none" is accepted only alone. # 24. "none" alone still loads nothing, which is what it is for. check_eq "24. none alone loads no klammerset" \ "x" \ --klammersets none -s 'x' -d check_fails "25. none before another symbol is an error" \ "cannot be combined" \ --klammersets none kit -s 'x' -t fix -d check_fails "25a. none after another symbol is an error too" \ "cannot be combined" \ --klammersets kit none -s 'x' -t fix -d # 26. The rule belongs to load_klammersets(), the single loader all three # commands share -- so kdesc obeys it as well. It did not: kdesc decided # what "none" meant a second time, and so accepted "none sks" and loaded # neither. Deciding it twice is how the two commands came to disagree. out=$(kdesc --klammersets none sks -k table 2>&1); status=$? if [ $status -ne 0 ] && printf '%s' "$out" | grep -qF "cannot be combined"; then echo "${green}PASS${reset} 26. kdesc applies the same none rule" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} 26. kdesc applies the same none rule — exit $status" echo " got: $(printf '%s' "$out" | head -2)" FAIL=$((FAIL + 1)) fi echo echo "=======================" echo "Results: ${green}$PASS passed${reset}, $([ $FAIL -gt 0 ] && echo "${red}$FAIL failed${reset}" || echo "0 failed")" [ $FAIL -eq 0 ]