Document language for dates; @eval pathname resolution and :cwd (from dev bc7cd62b6f68)

@date/@datetime gain :lang (German: "16. Juni 1910") over a document-wide
Language state variable, and :number for the numeric form (en 6/16/1910,
de 16.06.1910 per DIN 5008).  @eval finds Python modules next to the file
that names them regardless of the cwd, and the new :cwd option runs an
eval in a chosen working directory (@source_file uses it to resolve
against the document).  Two new test suites ship in tst/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 20:25:49 +02:00
parent f84603ee19
commit 61b21d1397
12 changed files with 309 additions and 15 deletions

View File

@@ -41,7 +41,7 @@ are regenerated on each release — patches cannot be merged directly.
Report problems (or send patches) to the author; accepted changes are Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot. applied to the development tree and appear in a following snapshot.
This snapshot was assembled from development commit `f84517152b7f`. This snapshot was assembled from development commit `bc7cd62b6f68`.
## License ## License

View File

@@ -7,6 +7,7 @@
#include "katom.h" #include "katom.h"
#include "file.h" #include "file.h"
#include <algorithm> #include <algorithm>
#include <optional>
#include <unistd.h> #include <unistd.h>
std::string shell(State state, std::string command, Locator loc) std::string shell(State state, std::string command, Locator loc)
@@ -114,12 +115,64 @@ void check_cpp_arguments(katom_list args, Locator loc)
} }
// Save the process working directory, change to DIR, and restore on
// destruction (exception-safe), so an @eval's :cwd cannot leak into the
// rest of the run. NOTE: the cwd is process-global state; if input files
// are ever processed in parallel, this needs rethinking.
class Cwd_guard
{
public:
explicit Cwd_guard(const std::string& dir)
: m_saved(fs::current_path())
{
fs::current_path(dir);
}
~Cwd_guard()
{
std::error_code ec;
fs::current_path(m_saved, ec); // never throw from a destructor
}
Cwd_guard(const Cwd_guard&) = delete;
Cwd_guard& operator=(const Cwd_guard&) = delete;
private:
fs::path m_saved;
};
std::string Eval::eval_command(katom_iter begin, katom_iter end) std::string Eval::eval_command(katom_iter begin, katom_iter end)
{ {
(void)K::log(3, *begin, *(end - 1)); (void)K::log(3, *begin, *(end - 1));
//msg() << "in Eval::eval:\n" << ktype << kall << kindex << std::pair(begin, end) << "\n"; //msg() << "in Eval::eval:\n" << ktype << kall << kindex << std::pair(begin, end) << "\n";
katom_iter first = after_whitespace(begin + 1); katom_iter first = after_whitespace(begin + 1);
std::string eval_result = "[unevaluated]"; std::string eval_result = "[unevaluated]";
// :cwd DIR — run the eval (any mode) with DIR as the working directory,
// restoring the process cwd afterwards. The default is the directory
// ktext was started in (unchanged behavior). DIR may hold state
// substitutions (:cwd *K_input_dir* is the document's directory) and,
// per the filenames-with-spaces convention, whitespace-separated tokens
// are joined until they name an existing directory.
std::optional<Cwd_guard> cwd_guard {};
if (first->m_text == ":cwd") {
katom_iter tok = after_whitespace(first + 1);
std::string dir {};
katom_iter cursor = tok;
katom_iter resume = tok;
while (cursor < end - 1 && !cursor->m_text.starts_with(":")) {
dir = m_machine.m_state.subst(as_string(tok, cursor + 1, true));
resume = after_whitespace(cursor + 1);
if (fs::is_directory(dir)) break;
cursor = resume;
}
if (dir.empty() || !fs::is_directory(dir)) {
throw Argument_error(
"The :cwd directory does not exist: \"" + dir + "\"",
begin->m_loc, false);
}
cwd_guard.emplace(dir);
first = resume;
}
std::string first_word = first->m_text; std::string first_word = first->m_text;
int offset = first_word[0] == ':' ? 1 : 0; int offset = first_word[0] == ':' ? 1 : 0;
std::string command = as_string(first + offset, end - 1, true); std::string command = as_string(first + offset, end - 1, true);
@@ -145,6 +198,18 @@ std::string Eval::eval_command(katom_iter begin, katom_iter end)
lib_text = string_replace(lib_text, "*KLAMMERTEXT_HOME*", khome); lib_text = string_replace(lib_text, "*KLAMMERTEXT_HOME*", khome);
} }
fs::path libpath(lib_text + ".so"); fs::path libpath(lib_text + ".so");
// A relative library name not found from the cwd is searched in the
// directories of the files the Machine has read (same rule as the
// Python module path: the library lives next to the file using it).
if (libpath.is_relative() && !fs::exists(fs::absolute(libpath))) {
for (const auto& dir : m_machine.m_state.m_search_dirs) {
fs::path candidate = fs::path(dir) / libpath;
if (fs::exists(candidate)) {
libpath = candidate;
break;
}
}
}
libpath = fs::absolute(libpath); libpath = fs::absolute(libpath);
std::string funcname = args.size() == 4 ? libpath.stem().string() : args[3].m_text; std::string funcname = args.size() == 4 ? libpath.stem().string() : args[3].m_text;
Eval_cpp E_cpp(m_machine, begin->m_loc); Eval_cpp E_cpp(m_machine, begin->m_loc);

View File

@@ -269,6 +269,9 @@ katom_list Machine::process(
void Machine::read(const fs::path& pathname) void Machine::read(const fs::path& pathname)
{ {
(void)K::log(3, pathname.string()); (void)K::log(3, pathname.string());
// A file's directory joins the @eval search path (Python modules and
// :cpp libraries live next to the file that uses them).
m_state.add_search_dir(fs::absolute(pathname).parent_path().string());
m_state.open_frame("Machine state: " + pathname.string()); m_state.open_frame("Machine state: " + pathname.string());
std::string text = m_state.subst(trim_right(string_from_file(pathname))); std::string text = m_state.subst(trim_right(string_from_file(pathname)));
katom_list katoms = process(text, pathname); katom_list katoms = process(text, pathname);
@@ -328,6 +331,7 @@ void Machine::expand_read_katoms(
std::for_each(begin, end, mark_as_replaced); std::for_each(begin, end, mark_as_replaced);
input_filename = fs::canonical(input_filename); input_filename = fs::canonical(input_filename);
m_state.add_search_dir(input_filename.parent_path().string());
std::string text = trim_right(string_from_file(input_filename.string())); std::string text = trim_right(string_from_file(input_filename.string()));
katom_list ks = katomize(line_split(text), input_filename); katom_list ks = katomize(line_split(text), input_filename);
// ks = // ks =

View File

@@ -259,6 +259,16 @@ std::vector<std::string> State::all_names()
} }
void State::add_search_dir(const std::string& dir)
{
if (!dir.empty()
&& std::find(m_search_dirs.begin(), m_search_dirs.end(), dir)
== m_search_dirs.end()) {
m_search_dirs.push_back(dir);
}
}
std::string State::python_code() std::string State::python_code()
{ {
(void)K::log(3); (void)K::log(3);
@@ -267,7 +277,12 @@ std::string State::python_code()
std::stringstream ss {}; std::stringstream ss {};
ss << "import sys\n"; ss << "import sys\n";
for (auto d : sks_dirs()) { strings_t python_dirs = sks_dirs();
// The directories of the files this Machine has read: a module next to
// the file whose @eval names it is found regardless of the cwd.
python_dirs.insert(python_dirs.end(),
m_search_dirs.begin(), m_search_dirs.end());
for (auto d : python_dirs) {
auto python_files = pathnames_with_extension(d, "py"); auto python_files = pathnames_with_extension(d, "py");
if (!python_files.empty()) { if (!python_files.empty()) {
ss << "sys.path.append('" << d << "')\n"; ss << "sys.path.append('" << d << "')\n";

View File

@@ -77,10 +77,16 @@ public:
void parse_state_katoms(std::vector<Katom>::iterator begin, std::vector<Katom>::iterator end, katom_list katoms); void parse_state_katoms(std::vector<Katom>::iterator begin, std::vector<Katom>::iterator end, katom_list katoms);
std::vector<std::string> all_names(); std::vector<std::string> all_names();
void add_search_dir(const std::string& dir);
std::string python_code(); std::string python_code();
std::string describe(bool show_environment=false, int margin_size=2) const; std::string describe(bool show_environment=false, int margin_size=2) const;
std::vector<Frame> m_frames {}; std::vector<Frame> m_frames {};
// Directories of the files the Machine has read (input files, klammer
// sets, @read targets), in reading order: @eval finds Python modules
// and :cpp libraries next to the file that uses them (see python_code()
// and Eval::eval_command).
std::vector<std::string> m_search_dirs {};
// @@@state Image_search_path :set :append :replace :argtype :desc // @@@state Image_search_path :set :append :replace :argtype :desc
// Parameter_set m_parameters = Parameter_set("name :set :append :replace :argtype :delim :desc"); // Parameter_set m_parameters = Parameter_set("name :set :append :replace :argtype :delim :desc");
Parameter_set m_parameters = Parameter_set("name :value :append :replace :delim :desc"); Parameter_set m_parameters = Parameter_set("name :value :append :replace :delim :desc");

View File

@@ -12,7 +12,9 @@ A word or phrase displayed verbatim in a line
@@c :: @eval code_format.Code_fragment(K) eval@ @@c :: @eval code_format.Code_fragment(K) eval@
@@ @@
@@source_file filename : @eval code_format.Source(K) @ @@ # :cwd makes the filename resolve against the DOCUMENT's directory, not
# the directory ktext happens to run in.
@@source_file filename : @eval :cwd *K_input_dir* code_format.Source(K) @ @@
#[ #[

View File

@@ -1,14 +1,21 @@
@@date.k :days.int 0 : @@date.k :days.int 0 :lang.language :number.bool false :
Date formatted as "16 June 1910", offset by ^:days days from today Date formatted as "16 June 1910" (^:lang de: "16. Juni 1910"), offset by
(^:days 1 is tomorrow, ^:days -1 is yesterday) ^:days days from today (^:days 1 is tomorrow, ^:days -1 is yesterday).
^:lang selects the language of the month name and the date form; without
it the document-wide Language state variable applies (default en).
^:number true gives the numeric form, which is language-specific in
order, separator, and padding: "6/16/1910" (en, month first),
"16.06.1910" (de, day first, zero-padded per DIN 5008)
@@ @@
@@date.html :: @eval date.date(K) eval@ @@ @@date.html :: @eval date.date(K) eval@ @@
@@date.tex :: @eval date.date(K) eval@ @@ @@date.tex :: @eval date.date(K) eval@ @@
@@date.txt :: @eval date.date(K) eval@ @@ @@date.txt :: @eval date.date(K) eval@ @@
@@datetime.k :days.int 0 : @@datetime.k :days.int 0 :lang.language :number.bool false :
Date and time formatted as "16 June 1910, 13:10", offset by ^:days days Date and time formatted as "16 June 1910, 13:10" (^:lang de:
from today (^:days 1 is tomorrow, ^:days -1 is yesterday) "16. Juni 1910, 13:10"), offset by ^:days days from today (^:days 1 is
tomorrow, ^:days -1 is yesterday). ^:lang and ^:number select the
language and the numeric form as for ^@date
@@ @@
@@datetime.html :: @eval date.datetime(K) eval@ @@ @@datetime.html :: @eval date.datetime(K) eval@ @@
@@datetime.tex :: @eval date.datetime(K) eval@ @@ @@datetime.tex :: @eval date.datetime(K) eval@ @@

View File

@@ -1,17 +1,76 @@
# Implementation of the @date and @datetime klammers (date.k). Each takes # Implementation of the @date and @datetime klammers (date.k). Each takes
# the current date and time, offset by the :days argument (positive is # the current date and time, offset by the :days argument (positive is
# future, negative is past), and formats it. # future, negative is past), and formats it in the requested language.
#
# Language selection is two-level: the klammer's :lang argument overrides
# the document-wide Language state variable (declared in sks/kutil/kutil.k,
# default en; set it for a whole document with @@@state Language :value de).
#
# :number selects the purely numeric form instead — which is language-
# specific in order, separator, and padding: en 5/15/1955 (month first,
# unpadded), de 15.05.1955 (day first, zero-padded per DIN 5008).
#
# Languages are OWN TABLES, deliberately not locales: strftime's %B follows
# the process-wide C locale, which must be generated on the host (the
# containers ship minimal locale support) and is global mutable state in the
# embedded interpreter — the same reason @table's :decimal is implemented by
# character translation. Adding a language is adding one LANGUAGES entry:
# twelve month names and the date pattern (day/month/year; the day carries
# no leading zero). The time suffix ", HH:MM" is language-independent.
import datetime as dt import datetime as dt
LANGUAGES = {
'en': {
'months': ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November',
'December'],
'date': '{day} {month} {year}', # 16 June 1910
'number': '{m}/{day}/{year}', # 6/16/1910
},
'de': {
'months': ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November',
'Dezember'],
'date': '{day}. {month} {year}', # 16. Juni 1910
'number': '{dd}.{mm}.{year}', # 16.06.1910 (DIN 5008)
},
}
def offset(days): def offset(days):
return dt.datetime.now() + dt.timedelta(days=days or 0) return dt.datetime.now() + dt.timedelta(days=days or 0)
def language(K):
"""The language table for K: :lang argument, else the Language state
variable, else English. Returns (table, error_text)."""
code = getattr(K, 'lang', '') or getattr(K, 'Language', '') or 'en'
if code not in LANGUAGES:
return None, ('ERROR: @date/@datetime: unknown language "%s" '
'(available: %s)'
% (code, ', '.join(sorted(LANGUAGES))))
return LANGUAGES[code], None
def format_date(t, lang, number=False):
pattern = lang['number'] if number else lang['date']
return pattern.format(day=t.day, m=t.month,
dd='%02d' % t.day, mm='%02d' % t.month,
month=lang['months'][t.month - 1],
year=t.year)
def date(K): def date(K):
return offset(K.days).strftime("%d %B %Y").lstrip("0") lang, error = language(K)
if error:
return error
return format_date(offset(K.days), lang, K.number)
def datetime(K): def datetime(K):
return offset(K.days).strftime("%d %B %Y, %H:%M").lstrip("0") lang, error = language(K)
if error:
return error
t = offset(K.days)
return format_date(t, lang, K.number) + ', %02d:%02d' % (t.hour, t.minute)

View File

@@ -31,7 +31,7 @@ class Image(klammer_base.Klammer_base):
self.basename = klammer_base.unescape_ktesc(self.basename) self.basename = klammer_base.unescape_ktesc(self.basename)
self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename) self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename)
if self.file_error: if self.file_error:
self.file_error_message = f'\nERROR: File "{self.K_input_filename}" not found' self.file_error_message = f'\nERROR: Image "{self.basename}" not found'
self.as_string = as_string self.as_string = as_string
if width: if width:
self.width = width self.width = width
@@ -42,7 +42,7 @@ class Image(klammer_base.Klammer_base):
self.rel_fraction = self.pwidth / self.rel_pwidth self.rel_fraction = self.pwidth / self.rel_pwidth
if self.file_error: if self.file_error:
self.file_error_message = f'\nERROR: File "{self.K_input_filename}" not found' self.file_error_message = f'\nERROR: Image "{self.rel}" (the :rel image) not found'
def html(self): def html(self):
img_dir = f"{self.K_output_dir}/{self.K_output_basename}/{self.Image_output_dir}" img_dir = f"{self.K_output_dir}/{self.K_output_basename}/{self.Image_output_dir}"

View File

@@ -80,3 +80,17 @@ An <id> is the value of the ^:id argument for an image.
:pattern [\s\S]* :pattern [\s\S]*
:python_cast (lambda s: __import__("kutil").filename_list(s)) :python_cast (lambda s: __import__("kutil").filename_list(s))
@@@ @@@
@@@argtype language |
an ISO 639-1 language code (two lowercase letters) selecting the language
of text a klammer generates, e.g. en (English) or de (German). The
languages actually available are listed by the klammer that uses the
argument (^@date and ^@datetime); an unknown code reports them.
:pattern [a-z][a-z]
@@@
# The document-wide language for generated text. A klammer's own :lang
# argument overrides it; see the language argtype above. Consumers today:
# @date and @datetime (month names and date form). Set it for a whole
# document with @@@state Language :value de @@@
@@@state Language :desc Language (ISO 639-1) for generated text :value en @@@

View File

@@ -1,10 +1,11 @@
# Klammertext distribution test suite (subset). # Klammertext distribution test suite (subset).
# #
# Runs the five shell regression suites: # Runs the six shell regression suites:
# cond_test.sh — @cond argument delimitation # cond_test.sh — @cond argument delimitation
# deftype_test.sh — the four klammer definition modes + redefinition table # deftype_test.sh — the four klammer definition modes + redefinition table
# escape_test.sh — target character escaping and quoted specials # escape_test.sh — target character escaping and quoted specials
# filename_test.sh — filenames with spaces (quoting, " / " lists, rescue) # filename_test.sh — filenames with spaces (quoting, " / " lists, rescue)
# modulepath_test.sh — @eval finds modules beside the file that names them
# editor_test.sh — editor support (doc/edit): indentation and table # editor_test.sh — editor support (doc/edit): indentation and table
# alignment; needs python3, uses Emacs when installed # alignment; needs python3, uses Emacs when installed
# #
@@ -16,4 +17,5 @@ test:
./deftype_test.sh ./deftype_test.sh
./escape_test.sh ./escape_test.sh
./filename_test.sh ./filename_test.sh
./modulepath_test.sh
./editor_test.sh ./editor_test.sh

120
tst/modulepath_test.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/bin/bash
#
# modulepath_test.sh — @eval pathname resolution (Klammermachine engine
# tier): module resolution and the :cwd option.
#
# A Python module lives NEXT TO THE FILE whose @eval names it: the directory
# of every file the Machine reads — input files, klammer sets, @read targets
# — joins the Python sys.path (after the SKS directories), so resolution is
# independent of the cwd. Before 2026-07-27 modules were found only via
# PYTHONPATH or the cwd, so `ktext /path/doc.kt` worked from the document's
# directory and failed from anywhere else.
#
# The same directories are searched for a relative `:cpp` library not found
# from the cwd (Eval::eval_command); that path is not exercised here because
# it would mean compiling a DSO inside the test — the directory list is the
# same one these cases prove.
#
# Pass-ordering caveat (deliberate, untested here): at one nesting level
# @eval is processed before @read expands, so a TOP-LEVEL @eval sees only
# files read earlier. A klammer DEFINED in a read file is applied later and
# always sees its own directory — the realistic case, tested below.
#
# Engine tier: -k none, fixtures created inline in a temp tree, no SKS.
# Usage: ./modulepath_test.sh (ktext on PATH)
PASS=0
FAIL=0
red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
mkdir -p "$T/seta" "$T/setb" "$T/elsewhere"
printf 'def hello():\n return "module-next-to-read-file"\n' > "$T/seta/pmod.py"
printf '@@desc : @eval pmod.hello() @ @@#-\n' > "$T/seta/defs.k"
printf 'def here():\n return "module-next-to-input"\n' > "$T/setb/imod.py"
printf '@read %s/seta/defs.k @#-@desc@ / @eval imod.here() @\n' "$T" > "$T/setb/doc.kt"
printf '@eval nosuch_module_xyz.f() @\n' > "$T/setb/missing.kt"
check() { # check NAME EXPECTED ARGS...
local name="$1" expected="$2"; shift 2
local out
out=$(ktext "$@" -k none -d 2>/dev/null)
if [ "$out" = "$expected" ]; then
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $name"
echo " expected: [$expected]"
echo " got: [$out]"; FAIL=$((FAIL + 1))
fi
}
echo "${bold}@eval module resolution tests${reset}"
echo "======================="
echo
cd "$T/elsewhere"
check " 1. modules beside the @read file and the input file (absolute path)" \
"module-next-to-read-file / module-next-to-input" \
"$T/setb/doc.kt"
check " 2. the same, input given as a relative path" \
"module-next-to-read-file / module-next-to-input" \
"../setb/doc.kt"
name=" 3. an unknown module still reports cleanly"
err=$(ktext "$T/setb/missing.kt" -k none -d 2>&1 >/dev/null)
case "$err" in
*'Cannot import module "nosuch_module_xyz"'*)
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1)) ;;
*)
echo "${red}FAIL${reset} $name"
echo " stderr: [$(echo "$err" | head -3)]"; FAIL=$((FAIL + 1)) ;;
esac
# --- the :cwd option --------------------------------------------------------
# @eval :cwd DIR runs the eval (any mode) with DIR as the working directory
# and restores the process cwd afterwards; *K_input_dir* names the
# document's directory; whitespace-separated tokens are joined until they
# name an existing directory (the filenames-with-spaces convention).
printf 'hello from data file\n' > "$T/setb/data.txt"
printf '@eval :cwd *K_input_dir* :shell cat data.txt @\n' > "$T/setb/shellcwd.kt"
check " 4. :cwd *K_input_dir* for :shell, run from elsewhere" \
"hello from data file" "$T/setb/shellcwd.kt"
printf '@eval :cwd *K_input_dir* os.getcwd() @\n' > "$T/setb/pycwd.kt"
check " 5. :cwd changes the Python working directory" \
"$T/setb" "$T/setb/pycwd.kt"
printf '@eval :cwd *K_input_dir* :shell true @ / @eval :shell pwd @\n' > "$T/setb/restore.kt"
check " 6. the cwd is restored after the eval" \
"/ $T/elsewhere" "$T/setb/restore.kt"
mkdir -p "$T/sp ace"
printf 'spaced\n' > "$T/sp ace/f.txt"
printf '@eval :cwd %s/sp ace :shell cat f.txt @\n' "$T" > "$T/setb/spaces.kt"
check " 7. a :cwd directory containing spaces" \
"spaced" "$T/setb/spaces.kt"
name=" 8. a nonexistent :cwd is a clean argument error"
printf '@eval :cwd /nonexistent_kt_dir :shell true @\n' > "$T/setb/bad.kt"
err=$(ktext "$T/setb/bad.kt" -k none -d 2>&1 >/dev/null)
case "$err" in
*'The :cwd directory does not exist'*)
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1)) ;;
*)
echo "${red}FAIL${reset} $name"
echo " stderr: [$(echo "$err" | head -2)]"; FAIL=$((FAIL + 1)) ;;
esac
echo
echo "======================="
echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}"
[ $FAIL -eq 0 ]