An output policy for the three commands, and @cond as a true special form

A snapshot of the development tree.  The substantial changes since the last one:

COMMAND OUTPUT POLICY.  The three commands display text in exactly three cases,
and each owns a stream: LOGGING under "-v" greater than 0 and an ERROR before
termination go to STDERR; OUTPUT THE USER ASKED FOR goes to STDOUT.  For ktext
that output is a document, so "ktext doc.kt -d | ..." is now safe -- logging
used to share the stream and land inside the document.  A bare command prints
its usage and succeeds rather than failing.  Colour is emitted only to a
terminal, per stream, and NO_COLOR is honoured.

"-v 1" reports every decision whose outcome you could not have read off your own
input: the klammerset that was loaded and from which file, a font's directory, a
":files" name's file, how "-o" was expanded.  Higher levels are the trace.

The commands no longer warn and continue: an anomaly is an error, described with
its location.  Two exceptions remain, each for a stated reason -- a condition
that is expected and temporary by design, and a judgment that is a heuristic
rather than exact.

@cond IS NOW A TRUE SPECIAL FORM, resolved at APPLICATION time rather than when
the file is read.  Two consequences for a writer:

  * a state variable reaches the predicate.  "@@@state Flag :value true @@@
    @cond *Flag* | T | F @" renders "T"; it used to see the literal "*Flag*" and
    silently take the false branch.  The document now behaves like a klammer
    body, whose arguments are bound before its conditionals are decided.
  * nothing in a discarded branch happens -- it is not read, not evaluated, not
    expanded.  An @eval in the branch not taken used to run anyway.

Its predicate relation is total and strict: true, True, 1; false, False, 0, and
empty; anything else is an error at the @cond rather than silently false.

@eval REACHING OUTSIDE.  ":shell" and ":haskell" now keep the command's standard
error out of the document (it appears under "-v 1") and treat a nonzero exit as
an error naming what the command reported.  A command that exits nonzero on
purpose -- "grep" finding no match -- says so with "|| true".

KLAMMER SETS.  Several combine: "--klammersets a b c" loads all three in the
order given, sharing one namespace, with the definition modes deciding
collisions.  "none" means none and may not be combined with other symbols.  A
klammerset with symbol X is declared in a file X/X.k, which is what lets two
sets require the same third set without loading it twice.

TESTS.  Four new suites: the kdiag command's interface, the @eval primitive's
contract with the outside world, and verbosity at both tiers.  Three suites
that could not run on macOS at all now do.

Assembled from dev commit 6c8ee6c22fca.
This commit is contained in:
2026-08-16 01:37:59 +02:00
parent 59c1599bc9
commit 240cff4278
84 changed files with 2523 additions and 494 deletions

View File

@@ -23,6 +23,14 @@ KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
FIX=$K/tst/klammerset
# The fixtures are laid out as <symbol>/<symbol>.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'
@@ -127,23 +135,23 @@ echo
# that base.k declares, so base.k must have been read first.
check_eq "1. :files load in order (target before klammer)" \
"Hello World" \
-k "$FIX/decl.k" -s '@greet World @' -t fix
--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" \
"--" \
-k "$FIX/decl.k" -s '@dash@' -t fix
--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" \
-k "$FIX/decl.k" -s '@after@' -t fix
--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" -k "$FIX/decl.k" -s '@greet Elsewhere @' -t fix) 2>/dev/null | trim )
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))
@@ -157,45 +165,83 @@ fi
# 5. A filename with a space in the :files list (standalone "/" separator).
check_eq "5. spacey filename in :files" \
"SPACEY" \
-k "$FIX/spacey.k" -s '@spacey@' -t fix
--klammersets sp -s '@spacey@' -t fix
# --- The already-loaded guard ------------------------------------------------
# --- 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
# 6. A second declaration of an already-registered symbol is skipped, not an
# error: wrapper.k requires decl.k (registers "kit") and then decl2.k
# (re-declares "kit"); the wrapper still loads and kit's klammers work.
check_eq "6. duplicate declaration is skipped, not an error" \
"Hello Again" \
-k "$FIX/wrapper.k" -s '@greet Again @' -t fix
# 7. ...and the skipped declaration's files are NOT loaded.
check_fails "7. skipped declaration loads none of its files" \
"only_dup" \
-k "$FIX/wrapper.k" -s '@only_dup@' -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 -----------------------------------------------------------
# 8. -m lists the registered klammersets.
check_contains "8. -m shows the klammerset symbol" \
"kit" \
-k "$FIX/decl.k" -s 'x' -t fix -m
# 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
}
check_contains "9. -m shows the required klammerset too" \
# 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" \
-k "$FIX/decl.k" -s 'x' -t fix -m
"@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" \
-k "$FIX/bad_file.k" -s 'x' -t fix
--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" \
-k none -s '@@@klammerset 9bad | Bad symbol @@@' -d
--klammersets none -s '@@@klammerset 9bad | Bad symbol @@@' -d
# --- The search path (symbol -> <dir>/<symbol>/<symbol>.k) -------------------
@@ -217,10 +263,10 @@ printf '@@@klammerset locset | Shadow candidate @@@\n@@@target fixL | Fixture @@
# 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" -k locset -t fixL -d
"$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@' -k locset -t fixL) 2>/dev/null | trim )
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))
@@ -231,7 +277,7 @@ else
fi
# 14. Stage 2: the KLAMMERTEXT_KLAMMERSETS directories.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" -s '@env_k@' -k envset -t fixE 2>/dev/null | trim)
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))
@@ -242,7 +288,7 @@ else
fi
# 15. Shadowing: the document-local set wins over the installed one.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" "$DOCDIR/doc.kt" -k locset -t fixL -d 2>/dev/null | trim)
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))
@@ -257,26 +303,170 @@ fi
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"
check_eq "16. :requires accepts a symbol (declaring-dir stage)" \
"LOCAL" \
-k "$DOCDIR/kit2/kit2.k" -s '@local_k@' -t fixL
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 <symbol>/<symbol>.k convention).
output=$(kdesc --klammerset 2>/dev/null)
output=$(kdesc --klammersets 2>/dev/null)
if printf '%s' "$output" | grep -q 'sks/sks\.k'; then
echo "${green}PASS${reset} 17. kdesc --klammerset lists sks from KLAMMERTEXT_HOME"
echo "${green}PASS${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 17. kdesc --klammerset lists sks from KLAMMERTEXT_HOME"
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 naming the search directories.
check_fails "18. unknown symbol names the search path" \
# 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' -k nosuchset -d
-s 'x' --klammersets nosuchset -d
check_fails "18a. ... naming the <symbol>/<symbol>.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 "======================="