From 61b21d1397ce11a73baba41cc07c2af80a5c169f Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Mon, 27 Jul 2026 20:25:49 +0200 Subject: [PATCH] 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 --- README.md | 2 +- mac/eval.cpp | 65 ++++++++++++++++++++++ mac/machine.cpp | 4 ++ mac/state.cpp | 17 +++++- mac/state.h | 6 +++ sks/code/code.k | 4 +- sks/date/date.k | 19 ++++--- sks/date/date.py | 65 ++++++++++++++++++++-- sks/image/image.py | 4 +- sks/kutil/kutil.k | 14 +++++ tst/Makefile | 4 +- tst/modulepath_test.sh | 120 +++++++++++++++++++++++++++++++++++++++++ 12 files changed, 309 insertions(+), 15 deletions(-) create mode 100755 tst/modulepath_test.sh diff --git a/README.md b/README.md index 321b842..649cf70 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/mac/eval.cpp b/mac/eval.cpp index 3ceba7a..5d9d95b 100644 --- a/mac/eval.cpp +++ b/mac/eval.cpp @@ -7,6 +7,7 @@ #include "katom.h" #include "file.h" #include +#include #include 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) { (void)K::log(3, *begin, *(end - 1)); //msg() << "in Eval::eval:\n" << ktype << kall << kindex << std::pair(begin, end) << "\n"; katom_iter first = after_whitespace(begin + 1); 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 {}; + 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; int offset = first_word[0] == ':' ? 1 : 0; 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); } 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); std::string funcname = args.size() == 4 ? libpath.stem().string() : args[3].m_text; Eval_cpp E_cpp(m_machine, begin->m_loc); diff --git a/mac/machine.cpp b/mac/machine.cpp index 5cbbaf0..9434dc1 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -269,6 +269,9 @@ katom_list Machine::process( void Machine::read(const fs::path& pathname) { (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()); std::string text = m_state.subst(trim_right(string_from_file(pathname))); katom_list katoms = process(text, pathname); @@ -328,6 +331,7 @@ void Machine::expand_read_katoms( std::for_each(begin, end, mark_as_replaced); 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())); katom_list ks = katomize(line_split(text), input_filename); // ks = diff --git a/mac/state.cpp b/mac/state.cpp index b37969b..6023567 100644 --- a/mac/state.cpp +++ b/mac/state.cpp @@ -259,6 +259,16 @@ std::vector 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() { (void)K::log(3); @@ -267,7 +277,12 @@ std::string State::python_code() std::stringstream ss {}; 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"); if (!python_files.empty()) { ss << "sys.path.append('" << d << "')\n"; diff --git a/mac/state.h b/mac/state.h index 75232b1..2d17e0c 100644 --- a/mac/state.h +++ b/mac/state.h @@ -77,10 +77,16 @@ public: void parse_state_katoms(std::vector::iterator begin, std::vector::iterator end, katom_list katoms); std::vector all_names(); + void add_search_dir(const std::string& dir); std::string python_code(); std::string describe(bool show_environment=false, int margin_size=2) const; std::vector 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 m_search_dirs {}; // @@@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 :value :append :replace :delim :desc"); diff --git a/sks/code/code.k b/sks/code/code.k index 2adee4d..ac6aaab 100644 --- a/sks/code/code.k +++ b/sks/code/code.k @@ -12,7 +12,9 @@ A word or phrase displayed verbatim in a line @@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) @ @@ #[ diff --git a/sks/date/date.k b/sks/date/date.k index 43c17b8..84b51fc 100755 --- a/sks/date/date.k +++ b/sks/date/date.k @@ -1,14 +1,21 @@ -@@date.k :days.int 0 : - Date formatted as "16 June 1910", offset by ^:days days from today - (^:days 1 is tomorrow, ^:days -1 is yesterday) +@@date.k :days.int 0 :lang.language :number.bool false : + Date formatted as "16 June 1910" (^:lang de: "16. Juni 1910"), offset by + ^: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.tex :: @eval date.date(K) eval@ @@ @@date.txt :: @eval date.date(K) eval@ @@ -@@datetime.k :days.int 0 : - Date and time formatted as "16 June 1910, 13:10", offset by ^:days days - from today (^:days 1 is tomorrow, ^:days -1 is yesterday) +@@datetime.k :days.int 0 :lang.language :number.bool false : + Date and time formatted as "16 June 1910, 13:10" (^:lang de: + "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.tex :: @eval date.datetime(K) eval@ @@ diff --git a/sks/date/date.py b/sks/date/date.py index 7b4586f..a033d38 100644 --- a/sks/date/date.py +++ b/sks/date/date.py @@ -1,17 +1,76 @@ # Implementation of the @date and @datetime klammers (date.k). Each takes # 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 +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): 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): - 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): - 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) diff --git a/sks/image/image.py b/sks/image/image.py index 45b71a9..369bb72 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -31,7 +31,7 @@ class Image(klammer_base.Klammer_base): 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) 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 if width: self.width = width @@ -42,7 +42,7 @@ class Image(klammer_base.Klammer_base): self.rel_fraction = self.pwidth / self.rel_pwidth 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): img_dir = f"{self.K_output_dir}/{self.K_output_basename}/{self.Image_output_dir}" diff --git a/sks/kutil/kutil.k b/sks/kutil/kutil.k index addb282..573ecc1 100644 --- a/sks/kutil/kutil.k +++ b/sks/kutil/kutil.k @@ -80,3 +80,17 @@ An is the value of the ^:id argument for an image. :pattern [\s\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 @@@ diff --git a/tst/Makefile b/tst/Makefile index 2d7af5d..b3fad84 100644 --- a/tst/Makefile +++ b/tst/Makefile @@ -1,10 +1,11 @@ # Klammertext distribution test suite (subset). # -# Runs the five shell regression suites: +# Runs the six 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) +# modulepath_test.sh — @eval finds modules beside the file that names them # editor_test.sh — editor support (doc/edit): indentation and table # alignment; needs python3, uses Emacs when installed # @@ -16,4 +17,5 @@ test: ./deftype_test.sh ./escape_test.sh ./filename_test.sh + ./modulepath_test.sh ./editor_test.sh diff --git a/tst/modulepath_test.sh b/tst/modulepath_test.sh new file mode 100755 index 0000000..04bb59e --- /dev/null +++ b/tst/modulepath_test.sh @@ -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 ]