From 37b6ba1c4f42fcc9f41186d28d9ed07227a385cb Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Sun, 23 Aug 2026 20:48:26 +0200 Subject: [PATCH] Verbatim-safe typography, ^-punctuation quoting, full-range ^UUUU^, polyglot html Typographic transforms (---, quote pairs, ~) no longer touch verbatim text: @c/@code/@source_listing content and ^'...'^ spans show exactly the characters written. "^" before any punctuation character quotes it in every target (the apostrophe excepted: ^' opens a literal span), with the new :resolve option on @@@target declaring per-target renderings. The ^UUUU^ code-point form accepts 4-6 hex digits, the full Unicode range. The html output and transform spellings are polyglot (XML-valid), in preparation for an EPUB target. New suites: transform_test, character_test (engine), typography_test (SKS). (from dev 07ce5ea86a0a) --- README.md | 2 +- com/ktext.cpp | 2 +- doc/edit/emacs/klammertext-indent.el | 3 +- doc/edit/emacs/klammertext-mode.el | 42 +- doc/edit/shared/klammertext_edit.py | 41 +- doc/edit/sublime/Klammertext.sublime-syntax | 17 +- doc/edit/vim/syntax/klammertext.vim | 10 +- .../syntaxes/klammertext.tmLanguage.json | 14 +- mac/argtype_registry.cpp | 22 +- mac/argument_set.cpp | 19 +- mac/argv.cpp | 36 +- mac/character.cpp | 20 +- mac/character.h | 6 +- mac/command.cpp | 15 +- mac/error.cpp | 3 +- mac/error.h | 40 +- mac/eval.cpp | 31 +- mac/eval_cpp.cpp | 2 +- mac/eval_python.cpp | 17 +- mac/file.cpp | 32 +- mac/katom.cpp | 2 +- mac/katom_list.cpp | 28 +- mac/klammer.cpp | 28 +- mac/klammerset_registry.cpp | 6 +- mac/ktype.h | 2 +- mac/locator.h | 8 + mac/machine.cpp | 80 ++-- mac/option_set_registry.cpp | 34 +- mac/target.cpp | 70 ++- mac/target.h | 19 + mac/target_registry.cpp | 17 +- mac/target_registry.h | 2 +- mac/util.cpp | 62 ++- sks/block/block.k | 156 +++++-- sks/block/block.py | 104 ++--- sks/block/css/block.css | 13 +- sks/code/code_block.py | 56 ++- sks/code/code_format.py | 403 ------------------ sks/color/color.py | 10 - sks/document/document.cpp | 4 +- sks/document/document.k | 17 + sks/document/document_class.cpp | 69 +-- sks/document/document_html.cpp | 50 +-- sks/document/heading.cpp | 98 ----- sks/font/css/font.css | 23 +- sks/font/font.py | 2 +- sks/form/page.k | 2 +- sks/kutil/klammer_base.py | 28 +- sks/kutil/kutil.py | 5 +- sks/link/link.py | 4 +- sks/list/list.py | 331 +------------- sks/section/css/section.css | 59 ++- sks/section/section.k | 3 + sks/target/css/target.css | 22 +- sks/target/html_util.py | 24 +- sks/target/latex_util.py | 34 +- sks/target/phases.py | 392 ++++------------- sks/target/target.k | 45 +- sks/tns/md_to_pdf.py | 4 +- tst/Makefile | 4 + tst/alone_test.sh | 10 +- tst/character_test.sh | 81 ++++ tst/command_option_test.sh | 18 +- tst/cond_test.sh | 4 +- tst/deftype_test.sh | 2 +- tst/editor/indent_literal_c.kt | 11 + tst/editor/indent_literal_c_expected.kt | 11 + tst/editor/vim_feature_test.vim | 16 + tst/editor_test.sh | 4 +- tst/escape_test.sh | 27 ++ tst/eval_test.sh | 22 + tst/option_set_test.sh | 6 +- tst/recursion_test.sh | 2 +- tst/state_test.sh | 121 ++++++ tst/target_list_test.sh | 2 +- tst/target_test.sh | 133 ++++++ tst/transform_test.sh | 109 +++++ 77 files changed, 1665 insertions(+), 1608 deletions(-) delete mode 100644 sks/code/code_format.py create mode 100755 tst/character_test.sh create mode 100644 tst/editor/indent_literal_c.kt create mode 100644 tst/editor/indent_literal_c_expected.kt create mode 100755 tst/state_test.sh create mode 100755 tst/target_test.sh create mode 100755 tst/transform_test.sh diff --git a/README.md b/README.md index f1a96e7..ed93487 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,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 `f34e66a4dbfb`. +This snapshot was assembled from development commit `07ce5ea86a0a`. ## License diff --git a/com/ktext.cpp b/com/ktext.cpp index 5e7a16d..92987dc 100644 --- a/com/ktext.cpp +++ b/com/ktext.cpp @@ -49,7 +49,7 @@ int main(int argc, char* argv[]) if (input_text.empty() && input_filenames.empty()) { throw Argument_error( - "You must specify input filenames and/or text", Locator()); + "You must specify input filenames and/or text", Locator::none()); } auto [target, output_dir, output_basename, output_filename, diff --git a/doc/edit/emacs/klammertext-indent.el b/doc/edit/emacs/klammertext-indent.el index 3debd17..1f6641b 100644 --- a/doc/edit/emacs/klammertext-indent.el +++ b/doc/edit/emacs/klammertext-indent.el @@ -133,8 +133,7 @@ same way the font-lock scanner steps over them." ((and (= len 1) (member name klammertext-literal-klammers)) ;; Verbatim interior: find the closing NAME@ by name. - (if (re-search-forward - (concat (regexp-quote name) "@") nil t) + (if (klammertext--search-literal-close name) (when (< pos (point)) (setq opaque t done t)) (setq opaque t done t))) ; never closed diff --git a/doc/edit/emacs/klammertext-mode.el b/doc/edit/emacs/klammertext-mode.el index c393990..3de0dea 100644 --- a/doc/edit/emacs/klammertext-mode.el +++ b/doc/edit/emacs/klammertext-mode.el @@ -131,15 +131,32 @@ Register one with `klammertext-add-literal-klammer', e.g. in your init file: ;; doc/edit/sublime/Klammertext.sublime-syntax ;; * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim ;; * the @NAME rule in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json -;; All are currently seeded with just "code". +;; All are currently seeded with "code" and "c" (@c is the inline form of +;; @code and took a literal argument 2026-08-16; the miscounted stack from an +;; unrecognized "@c ... c@" shifted a whole document's indentation by one). (defun klammertext-add-literal-klammer (name) "Register NAME as a klammer whose literal content must not be interpreted. NAME is the klammer name without the leading @ (e.g. \"code\")." (add-to-list 'klammertext-literal-klammers name)) +(defun klammertext--search-literal-close (name &optional bound) + "Move point past the exact close token NAME@ at or after point. +Return the position after the close, or nil if there is none before BOUND. +NAME@ preceded by a name character is verbatim content, not a close -- +\"basic@\" does not close @c, and \"barcode@\" does not close @code +\(the engine's close is a whole katom). Mirrors the shared core's +find_literal_close." + (let ((close (concat (regexp-quote name) "@")) (found nil)) + (while (and (setq found (re-search-forward close bound t)) + (let ((b (match-beginning 0))) + (and (> b (point-min)) + (klammertext--name-char-p (char-before b)))))) + found)) + ;; Seed the list through the same entry point future users will use. (klammertext-add-literal-klammer "code") +(klammertext-add-literal-klammer "c") ;; --- Helpers ----------------------------------------------------------- @@ -275,13 +292,12 @@ BEFORE is the character before POS." ;; BEFORE `klammertext--set-match', because `re-search-forward' ;; clobbers the match data. (if (member name klammertext-literal-klammers) - (let ((close (concat (regexp-quote name) "@"))) - (if (re-search-forward close nil t) - (let ((close-end (point))) - (put-text-property pos close-end 'font-lock-multiline t) - (goto-char (- close-end (length name) 1))) - (put-text-property pos (point-max) 'font-lock-multiline t) - (goto-char (point-max)))) + (if (klammertext--search-literal-close name) + (let ((close-end (point))) + (put-text-property pos close-end 'font-lock-multiline t) + (goto-char (- close-end (length name) 1))) + (put-text-property pos (point-max) 'font-lock-multiline t) + (goto-char (point-max))) (goto-char name-end)) ;; Set the match data for the opening LAST, so it survives to the ;; highlight step. @@ -478,8 +494,8 @@ delimiter (or skipped region) and return (POS . KIND) with KIND `open or (let ((name (buffer-substring-no-properties (1+ hit) (point)))) (cond ((member name klammertext-literal-klammers) ; literal span: skip - (let ((close (concat (regexp-quote name) "@"))) - (unless (re-search-forward close nil t) (goto-char (point-max))))) + (unless (klammertext--search-literal-close name) + (goto-char (point-max)))) ((eq (char-after) ?-)) ; @name-arg : no span (t (throw 'found (cons hit 'open)))))) (t ; name@ / bare @ : closing @@ -574,7 +590,7 @@ name, not by depth counting." The verbatim content is opaque, so we search for the literal close string." (save-excursion (goto-char (+ open-pos 1 (length name))) - (when (search-forward (concat name "@") nil t) + (when (klammertext--search-literal-close name) (1- (point))))) (defun klammertext--literal-match-backward (close-pos name) @@ -586,7 +602,11 @@ CLOSE-POS, or nil. Literal spans do not nest, so the nearest preceding real (let ((open-str (concat "@" name)) (result nil)) (while (and (not result) (search-backward open-str nil t)) (let ((op (point))) + ;; The name must end where the token ends: "@c" found inside + ;; "@caption" is not an opener of @c. (unless (or (eq (char-before op) ?@) ; @@NAME = definition + (klammertext--name-char-p + (char-after (+ op 1 (length name)))) (klammertext--escaped-p op)) (setq result op)))) result))) diff --git a/doc/edit/shared/klammertext_edit.py b/doc/edit/shared/klammertext_edit.py index 6eae53c..f246ff4 100644 --- a/doc/edit/shared/klammertext_edit.py +++ b/doc/edit/shared/klammertext_edit.py @@ -36,10 +36,10 @@ # * the Emacs defcustoms (klammertext-literal-klammers, -transparent-, # -code-, -align-klammers, -indent-offset, -align-cell-max, -align-row-max) # in doc/edit/emacs/klammertext-mode.el / -indent.el / -align.el -# * the '@code' rule + literal_code context in +# * the '@code'/'@c' rules + literal_code/literal_c contexts in # doc/edit/sublime/Klammertext.sublime-syntax -# * the '@code' verbatim region in doc/edit/vim/syntax/klammertext.vim -# * the '@code' rule in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json +# * the '@code'/'@c' verbatim regions in doc/edit/vim/syntax/klammertext.vim +# * the '@code'/'@c' rules in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json # # Installation note: editors locate this file either next to their own plugin # files (a vendored copy, placed there by doc/make_editing_zip.sh), as @@ -57,7 +57,7 @@ import sys # Klammer names whose content is a literal argument (verbatim interior, # closed by a named NAME@ delimiter). -LITERAL_KLAMMERS = set(["code"]) +LITERAL_KLAMMERS = set(["code", "c"]) # Klammers that contribute no indentation level (a @document's paragraphs # stay at the left margin). @@ -91,6 +91,19 @@ def name_char_p(ch): or ('0' <= ch <= '9') or ch == '_') +def find_literal_close(s, name, start): + """Index of the exact close token NAME@ at or after START, or -1. + The engine's close is a whole katom, so NAME@ preceded by a name + character is content, not a close -- "basic@" does not close @c, and + "barcode@" does not close @code. Single-letter literal names (@c) make + this guard essential rather than theoretical.""" + close = name + '@' + idx = s.find(close, start) + while idx > 0 and name_char_p(s[idx - 1]): + idx = s.find(close, idx + 1) + return idx + + def escaped_p(s, pos): """True if the char at POS is escaped by an odd run of ^ before it. In Klammertext ^# and ^@ are literal, so such a char is not a delimiter.""" @@ -188,9 +201,8 @@ def next_app_delim(s, i, limit): name = s[hit + 1:k] after = s[k] if k < n else None if name in LITERAL_KLAMMERS: # literal span: skip to its close - close = name + '@' - idx = s.find(close, k) - i = n if idx == -1 else idx + len(close) + idx = find_literal_close(s, name, k) + i = n if idx == -1 else idx + len(name) + 1 continue elif after == '-': # @name-arg : opens no span i = k @@ -318,7 +330,7 @@ def literal_match_forward(s, open_pos, name): """Index of the @ of the NAME@ that closes the literal @NAME at OPEN_POS, or None. The content is opaque, so search for the literal close string.""" start = open_pos + 1 + len(name) - idx = s.find(name + '@', start) + idx = find_literal_close(s, name, start) return idx + len(name) if idx != -1 else None @@ -333,7 +345,10 @@ def literal_match_backward(s, close_pos, name): if idx == -1: return None before = s[idx - 1] if idx > 0 else None - if before != '@' and not escaped_p(s, idx): + # The name must end where the token ends: "@c" found inside + # "@caption" is not an opener of @c. + follower = s[idx + len(open_str)] if idx + len(open_str) < len(s) else None + if before != '@' and not name_char_p(follower) and not escaped_p(s, idx): return idx end = idx @@ -446,7 +461,7 @@ def state_at(s, pos): i = k if run_len == 1 and name in LITERAL_KLAMMERS: # Verbatim interior: find the closing NAME@ by name. - idx = s.find(name + '@', k) + idx = find_literal_close(s, name, k) if idx == -1: # never closed return (stack, True) close_end = idx + len(name) + 1 @@ -584,7 +599,7 @@ def enclosing_span(s, pos, names): name = s[run_end:k] i = k if run_len == 1 and name in LITERAL_KLAMMERS: - idx = s.find(name + '@', k) + idx = find_literal_close(s, name, k) if idx == -1: break i = idx + len(name) + 1 @@ -692,7 +707,7 @@ def scan_lines(content): name = content[run_end:k] i = k if run_len == 1 and name in LITERAL_KLAMMERS: - idx = content.find(name + '@', k) + idx = find_literal_close(content, name, k) e = n if idx == -1 else idx + len(name) + 1 if line_index(max(hit, e - 1)) != line_index(hit): block_range(hit, e) @@ -886,7 +901,7 @@ def diagnostics(s): name = s[run_end:k] i = k if run_len == 1 and name in LITERAL_KLAMMERS: - idx = s.find(name + '@', k) + idx = find_literal_close(s, name, k) if idx == -1: probs.append({'start': hit, 'end': k, 'message': ("literal klammer @%s has no " diff --git a/doc/edit/sublime/Klammertext.sublime-syntax b/doc/edit/sublime/Klammertext.sublime-syntax index 90bb8b9..9475725 100644 --- a/doc/edit/sublime/Klammertext.sublime-syntax +++ b/doc/edit/sublime/Klammertext.sublime-syntax @@ -27,7 +27,7 @@ # caret, a leftover single ^ escapes the following character — # the '\^.' rule reproduces exactly that parity.) # -# Literal klammers: @code ... code@ interior is verbatim (no # or @ +# Literal klammers: @code ... code@ and @c ... c@ interior is verbatim (no # or @ # interpreted). To add another literal klammer 'foo', copy the # '@code' rule and the 'literal_code' context below, replacing # code -> foo. @@ -43,7 +43,7 @@ # * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim # * the @NAME rule in # doc/edit/vscode/syntaxes/klammertext.tmLanguage.json -# All are currently seeded with just 'code'. +# All are currently seeded with 'code' and 'c'. # # --------------------------------------------------------------------------- # How open vs. close is decided (the same rule the Emacs scanner uses): @@ -131,10 +131,13 @@ contexts: scope: punctuation.definition.comment.klammertext push: removal_line - # --- literal klammer: interior is verbatim (seeded default: @code) --- + # --- literal klammers: interior is verbatim (seeded: @code and @c) --- - match: '@code(?![A-Za-z0-9_])' scope: entity.name.function.begin.klammertext push: literal_code + - match: '@c(?![A-Za-z0-9_])' + scope: entity.name.function.begin.klammertext + push: literal_c # --- system / target commands @@@ --- - match: '@@@{{name}}' @@ -185,3 +188,11 @@ contexts: - match: 'code@' scope: entity.name.function.end.klammertext pop: true + + # @c ... c@ — the inline form of @code, same verbatim interior. The + # lookbehind keeps a word ending in c ("basic@") from closing the span — + # essential for a single-letter name. + literal_c: + - match: '(? foo (and mirror " it in the Emacs, Sublime, and VS Code artifacts; all are seeded with -" just 'code'). +" 'code' and 'c'). " " How open vs. close is decided (the same rule as every other integration): " a delimiter whose NAME follows the @-run (@name) is an OPENING; a bare @@ -76,12 +76,16 @@ syn match klammertextAppOpen /@\@1 foo (and mirror it in the Emacs, Sublime,", - "and Vim artifacts; all are seeded with just 'code').", + "and Vim artifacts; all are seeded with 'code' and 'c').", "", "Delimiter matching, indentation, alignment, and diagnostics are not", "tokenizer concerns — they come from the Klammertext language server", @@ -77,6 +77,16 @@ "0": { "name": "entity.name.function.end.klammertext" } } }, + { + "begin": "@c(?![A-Za-z0-9_])", + "beginCaptures": { + "0": { "name": "entity.name.function.begin.klammertext" } + }, + "end": "(? 1) { - throw Argument_error("Multiple katoms for positional argument: " + + throw Argument_error("Multiple katoms for positional argument:\n " + as_string(katoms.begin(), katoms.end(), true) + "\nPositional arguments are separated by the bar (|) character.", - katoms[0].m_loc, false); + katoms[0].m_loc); } Katom k = katoms[0]; std::string name = k.m_text; @@ -367,7 +367,7 @@ void Parameter_set::check_positional(const katom_lists& positional_arguments, co ss << " " << arg.m_name << "\n"; } //std::cout << ss.str(); - throw Argument_error(ss.str(), loc, false); + throw Argument_error(ss.str(), loc); } else if (positional_count < given_count) { //std::cout << "DESCRIBE\n"; //describe_parameters(); @@ -391,7 +391,7 @@ Parameter_set::check_optional(const katom_lists& optional_arguments, const Locat } if (std::ranges::count(optional_names_used, name) > 0) { throw Argument_error("Optional argument \":" + name + "\" already provided " - + "with a value of:\n" + values[name], loc, false); + + "with a value of:\n" + values[name], loc); } katom_list value_katoms(opt.begin()+1, opt.end()); std::string value = trim(to_string(value_katoms)); @@ -477,9 +477,12 @@ void Parameter_set::validate( std::stringstream ss {}; ss << "The value \"" << value << "\" given for the argument \"" << parameter.m_name << "\" does not match the \"" << argtype.m_name - << "\" argument type:\n\n" - << trim(argtype.m_desc) << "\n"; - throw Argument_error(ss.str(), loc, false); + << "\" argument type.\n\nThe \"" << argtype.m_name << "\" argument type describes " + // A .k description is prose: collapse its source layout (line + // breaks, continuation indentation) so it re-flows with the + // sentence -- indented .k lines would otherwise read as verbatim. + << collapse_whitespace(argtype.m_desc) << "\n"; + throw Argument_error(ss.str(), loc); } } @@ -520,7 +523,7 @@ std::string replace_arguments( } ss << "To prevent the \"*\" character from specifying an argument, " << "precede it with the \"^\" character."; - throw Argument_error(ss.str(), loc, false); + throw Argument_error(ss.str(), loc); } return result; } diff --git a/mac/argv.cpp b/mac/argv.cpp index 98aa0bc..b6421d3 100644 --- a/mac/argv.cpp +++ b/mac/argv.cpp @@ -257,7 +257,7 @@ void Argv::check_flags_and_options(const std::string& command, strings_t& words) for (auto w : not_defined) { ss << " " << w << "\n"; } - throw Argument_error(ss.str(), Locator(), false); + throw Argument_error(ss.str(), Locator::none()); } } @@ -288,7 +288,7 @@ void Argv::parse_flags(strings_t& words, string_map& named_args) for (auto w : not_defined) { ss << " " << w << "\n"; } - throw Argument_error(ss.str(), Locator(), false); + throw Argument_error(ss.str(), Locator::none()); } */ // std::cout << "Flags found: " << flag_args << "\n"; @@ -320,16 +320,29 @@ void Argv::parse_optional(strings_t& words, string_map& named_args) "The option " + flag_name(opt) + " needs a value: " + m_args[opt].m_syntax + ". Enter \"" + file_basename(command_name) + "\" for the list of arguments.", - Locator(), false); + Locator::none()); } std::string opt_arg = words[index] + " "; index++; std::regex opt_regex = m_args[opt].m_rgx; - // std::cout << "Regex match? " << std::regex_match(trim(opt_arg), opt_regex) << "\n"; - - while (index < words.size() && words[index][0] != '-' - // && std::regex_match(trim(opt_arg), opt_regex) + // Only a LIST-valued option may span several argv words + // (--klammersets a b c; --katoms type index). A single-valued + // option takes exactly ONE argv element -- argv boundaries are + // authoritative -- so a positional written after it is not + // swallowed into the value. The former unconditional run + // consumed everything up to the next dash-word: "-s 'text' + // doc.kt" folded doc.kt into the -s TEXT (and rendered its + // pathname), and "--klammersets none doc.kt" consumed the input + // filename, so ktext reported that none was given. -s must be + // usable in any argv position: its role with a file argument is + // to modify the interpretation of that file, and it is + // evaluated first regardless of where it is written. A list + // value stops at the "--" delimiter (it begins with '-'), which + // remains the escape for filenames after a list option. + bool list_valued = m_args[opt].m_rgx_symbol == "'list'" + || m_args[opt].m_rgx_symbol == "'katom_display'"; + while (list_valued && index < words.size() && words[index][0] != '-' && std::regex_match(trim(opt_arg + words[index]), opt_regex) ) { @@ -370,7 +383,7 @@ void Argv::parse_positional(const std::string& command, //strings_t words, } std::stringstream ss {}; ss << "The argument " << q_(req) << " was not found in:\n " << command; - throw Argument_error(ss.str(), Locator(), false); + throw Argument_error(ss.str(), Locator::none()); } named_args[req] = substring; // Drop the whitespace that separated this positional from the next. @@ -467,7 +480,7 @@ void Argv::check_flags(const string_map& arg_map, const std::string& command) for (const std::string& undef : undefined) { ss << " " << flag_name(undef); } - throw Argument_error(ss.str(), Locator()); + throw Argument_error(ss.str(), Locator::none()); } } @@ -508,7 +521,7 @@ void Argv::parse(int argc, char* argv[], bool full_parse) usage(file_basename(argv[0])); throw Argument_error( "Incorrect value for option \"" + name + "\":\n" + m_args[name].m_desc + "\n", - Locator(), false); + Locator()); } } } @@ -594,7 +607,8 @@ int Argv::as_verbosity(const std::string& name) (void)K::log(2, name); auto value = get(name); if (!std::regex_match(value, std::regex(regex_symbols["'verbosity'"]))) { - throw Argument_error("Argument \"" + value + "\" is not a verbosity level"); + throw Argument_error("Argument \"" + value + "\" is not a verbosity level", + Locator::none()); } return std::stoi(value); } diff --git a/mac/character.cpp b/mac/character.cpp index 23bea89..429a869 100644 --- a/mac/character.cpp +++ b/mac/character.cpp @@ -36,6 +36,9 @@ std::string utf8char(int cp) c[1] = ((cp>>12)&63)+128; c[2] = ((cp>>6)&63)+128; c[3]=(cp&63)+128; + } else { + // Reachable since ^UUUU^ accepts 6 hex digits: FFFFFF > 10FFFF. + return "Invalid Unicode: " + std::to_string(cp); } return std::string(c); } @@ -65,7 +68,13 @@ std::string unicode_hex_to_char(std::string s, int width=4) //, std::string mark std::string process_diacritics(std::string s) { (void)K::log(4); - std::regex diacritic_re("\\^([^\\s`'~@|^:*#])([" + diacritic_symbols + "])"); + // The base may not be whitespace, a digit, or ASCII punctuation (the + // four ranges !-/ :-@ [-` {-~): a diacritic sits on a letter. Without + // the exclusion, ^ before a quoted punctuation character followed by a + // mark character composed nonsense -- ^-- became a hyphen with a macron + // instead of a literal hyphen before a hyphen. A multi-byte (non-ASCII) + // base is unaffected: its bytes are outside every excluded range. + std::regex diacritic_re("\\^([^\\s0-9!-/:-@\\[-`{-~])([" + diacritic_symbols + "])"); std::string result {s}; std::sregex_iterator end {}; @@ -126,9 +135,14 @@ std::string process_unicode_codepoint(std::string s) //return std::regex_replace(s, unicode_re, hidehat + "$1" + hidehat); std::string result {s}; std::sregex_iterator end {}; - for (std::sregex_iterator p { s.begin(), s.end(), unicode_re }; p!= end; ++p) { + for (std::sregex_iterator p { s.begin(), s.end(), unicode_re }; p!= end; ++p) { std::regex hit_re { regex_escape((*p)[0].str()) }; - result = std::regex_replace(result, hit_re, unicode_hex_to_char((*p)[1].str())); + // Convert the captured hex DIRECTLY: unicode_hex_to_char() re-scans + // its argument at a fixed width, and its 4-digit default truncated a + // 5-digit code point to its first four digits (^13000^ rendered as + // U+1300 followed by a literal "0"). + result = std::regex_replace(result, hit_re, + utf8char(std::stoi((*p)[1].str(), nullptr, 16))); } return result; diff --git a/mac/character.h b/mac/character.h index 844cbf0..0e9b345 100644 --- a/mac/character.h +++ b/mac/character.h @@ -11,7 +11,11 @@ #include #include -const std::regex unicode_re(R"(\^(([0-9A-Fa-f]{5})|([0-9A-Fa-f]{4})|([0-9A-Fa-f]))\^)"); +// ^UUUU^ — a Unicode code point in hex, 4 to 6 digits (^263A^ is the BMP, +// ^13000^ EGYPTIAN HIEROGLYPH A001, ^10FFFD^ the top of the range), or a +// single digit. Lengths 2-3 are NOT accepted: two- and three-letter +// sequences of a-f collide with the ^s^-style mnemonic names. +const std::regex unicode_re(R"(\^(([0-9A-Fa-f]{4,6})|([0-9A-Fa-f]))\^)"); const std::regex unicode_hide_re(R"(=([0-9A-Fa-f]{2})=)"); const std::regex hex2_re(R"(([0-9A-Fa-f]{2}))"); const std::regex hex4_re(R"(([0-9A-Fa-f]{4}))"); diff --git a/mac/command.cpp b/mac/command.cpp index 1ec4a0d..0d64005 100644 --- a/mac/command.cpp +++ b/mac/command.cpp @@ -9,7 +9,16 @@ using namespace std::string_literals; fs::path construct_command_pathname(char* command) { - return fs::path(fs::current_path().string() + "/" + std::string(command)); + // The pseudo source file for command-line string input (-s, kdiag's + // input): a file IN THE CWD named after the command, so that a relative + // @read in string input resolves against the directory the user ran the + // command from -- the string-input analog of "a document's relative + // names resolve against the document". The basename matters: the + // former "/" string concatenation anchored resolution at + // the BINARY's directory whenever argv[0] was absolute + // (/usr/local/bin/ktext, a wrapper script), so every relative @read in + // -s input searched bin/ instead of the cwd. + return fs::current_path() / fs::path(command).filename(); } void set_verbose_level(int argc, char* argv[]) @@ -124,7 +133,7 @@ void load_klammersets(Machine& machine, const strings_t& symbols) "The klammerset \"none\" cannot be combined with other klammersets: " "it means that none is loaded. Give \"none\" alone, or name only the " "klammersets to load.", - Locator()); + Locator::none()); } if (symbols.empty()) { // Which klammerset was loaded, and from where, is a DERIVED value: the @@ -146,7 +155,7 @@ void load_klammersets(Machine& machine, const strings_t& symbols) continue; } std::string klammerset_filename = resolve_klammerset_symbol( - symbol, machine.m_state.value("K_input_dir"), Locator()).string(); + symbol, machine.m_state.value("K_input_dir"), Locator::none()).string(); // Symbol -> file is the search path's answer, and the search // path has three stages with shadowing: the file it landed on is // exactly what a user cannot read off "--klammersets x". diff --git a/mac/error.cpp b/mac/error.cpp index cb38f28..5171d99 100644 --- a/mac/error.cpp +++ b/mac/error.cpp @@ -23,8 +23,7 @@ void Error::print_message(const std::string& epilog) if (epilog != "") { m_desc += "\n\n" + epilog + "\n"; } - if (m_just) - m_desc = justify(m_desc, 80, 0); + m_desc = justify(m_desc, 80, 0); std::cerr << "\n" << red << command_name << " (" << m_type << " error)"; if (display_source(m_loc.m_filename)) { std::cerr << ":\n " << m_loc.m_filename; diff --git a/mac/error.h b/mac/error.h index c90bed3..202bbea 100644 --- a/mac/error.h +++ b/mac/error.h @@ -11,11 +11,10 @@ inline std::string command_pathname { "Pathname of command executed on the comma class Error : std::exception { public: Error(const std::string& error_type, const std::string& description, - const Locator& locator = Locator(), bool do_justify = true) + const Locator& locator = Locator()) : m_type(error_type) , m_desc(description) , m_loc(locator) - , m_just(do_justify) {} void print_message(const std::string& epilog=""); @@ -23,57 +22,56 @@ public: std::string m_type {}; std::string m_desc {}; Locator m_loc; // {}; - bool m_just { true }; }; class Parsing_error : public Error { public: explicit Parsing_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("parsing", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("parsing", description, locator) {}; }; class File_error : public Error { public: explicit File_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("file", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("file", description, locator) {}; }; class Target_error : public Error { public: explicit Target_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("target", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("target", description, locator) {}; }; class Klammerset_error : public Error { public: explicit Klammerset_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("klammerset", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("klammerset", description, locator) {}; }; class Definition_error : public Error { public: explicit Definition_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("definition", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("definition", description, locator) {}; }; class Argument_error : public Error { public: explicit Argument_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("argument", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("argument", description, locator) {}; }; class Environment_error : public Error { public: explicit Environment_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("environment", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("environment", description, locator) {}; }; // Klammer application nested deeper than the engine's limit. Raised by the @@ -83,13 +81,13 @@ public: class Recursion_error : public Error { public: explicit Recursion_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("recursion", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("recursion", description, locator) {}; }; class Internal_error : public Error { public: explicit Internal_error( - const std::string& description, const Locator& locator=Locator(), bool do_justify=true) - : Error("internal", description, locator, do_justify) {}; + const std::string& description, const Locator& locator=Locator()) + : Error("internal", description, locator) {}; }; diff --git a/mac/eval.cpp b/mac/eval.cpp index 1c03ab1..8784143 100644 --- a/mac/eval.cpp +++ b/mac/eval.cpp @@ -42,8 +42,8 @@ std::string shell(State state, std::string command, Locator loc) err_template.push_back('\0'); int err_fd = mkstemp(err_template.data()); if (err_fd == -1) { - throw Environment_error("Could not create a temporary file for the " - "command's error output", loc, false); + throw Environment_error( + "Could not create a temporary file for the command's error output", loc); } close(err_fd); err_path = err_template.data(); @@ -55,7 +55,7 @@ std::string shell(State state, std::string command, Locator loc) if (!pipe) { fs::remove(err_path); throw Environment_error( - "Could not run command:\n" + command, loc, false); + "Could not run command:\n " + command, loc); } char buffer[128]; std::string result = ""; @@ -87,7 +87,7 @@ std::string shell(State state, std::string command, Locator loc) // "environment", not "parsing": the failure is OUTSIDE Klammertext, // in the command the document invoked -- the same class as a missing // xelatex or an unset environment variable. - throw Environment_error(ss.str(), loc, false); + throw Environment_error(ss.str(), loc); } if (!error_output.empty()) { (void)K::log(1, "shell command wrote to stderr:", command, @@ -122,8 +122,8 @@ std::string run_haskell(const std::string& hsfile, Locator loc) err_template.push_back('\0'); int err_fd = mkstemp(err_template.data()); if (err_fd == -1) { - throw Environment_error("Could not create a temporary file for runghc's " - "error output", loc, false); + throw Environment_error( + "Could not create a temporary file for runghc's error output", loc); } close(err_fd); err_path = err_template.data(); @@ -132,7 +132,7 @@ std::string run_haskell(const std::string& hsfile, Locator loc) FILE* pipe = popen(command.c_str(), "r"); if (!pipe) { fs::remove(err_path); - throw Environment_error("Could not run runghc.", loc, false); + throw Environment_error("Could not run runghc.", loc); } char buffer[128]; std::string result = ""; @@ -154,12 +154,12 @@ std::string run_haskell(const std::string& hsfile, Locator loc) } ss << "."; if (!error_output.empty()) { - ss << "\nrunghc reported:\n" << error_output; + ss << "\n\nrunghc reported:\n " << error_output; } // A compile error arrives here, and so does a program that ran and then // exited nonzero; the message does not guess which, it shows what // runghc said. - throw Environment_error(ss.str(), loc, false); + throw Environment_error(ss.str(), loc); } if (!error_output.empty()) { // GHC's warnings, and anything the program itself wrote to stderr. @@ -175,9 +175,9 @@ std::string haskell(State state, std::string code, Locator loc) // "environment", not "parsing": a missing external command is the same // class as a missing xelatex, and "parsing error" misdescribes it. throw Environment_error( - "@eval with the :haskell argument requires runghc, which was not found in PATH.\n" + "@eval with the :haskell argument requires runghc, which was not found in PATH.\n\n" "Install it using GHCup; see https://www.haskell.org/ghcup/install/.", - loc, false); + loc); } code = state.subst(code); @@ -193,7 +193,7 @@ std::string haskell(State state, std::string code, Locator loc) int fd = mkstemp(tmppath.data()); if (fd < 0) { throw Environment_error( - "Could not create temporary file for Haskell evaluation.", loc, false); + "Could not create temporary file for Haskell evaluation.", loc); } std::string hsfile = std::string(tmppath.data()) + ".hs"; close(fd); @@ -202,8 +202,7 @@ std::string haskell(State state, std::string code, Locator loc) FILE* f = fopen(hsfile.c_str(), "w"); if (!f) { unlink(hsfile.c_str()); - throw Parsing_error( - "Could not write temporary Haskell file.", loc, false); + throw Parsing_error("Could not write temporary Haskell file.", loc); } fprintf(f, "%s\n", code.c_str()); fclose(f); @@ -222,7 +221,7 @@ void check_cpp_arguments(katom_list args, Locator loc) << "or\n" << " @eval :cpp @\n" << "In the first case, the library basename is used for the function name."; - throw Argument_error(ss.str(), loc, false); + throw Argument_error(ss.str(), loc); } } @@ -279,7 +278,7 @@ std::string Eval::eval_command(katom_iter begin, katom_iter end) if (dir.empty() || !fs::is_directory(dir)) { throw Argument_error( "The :cwd directory does not exist: \"" + dir + "\"", - begin->m_loc, false); + begin->m_loc); } cwd_guard.emplace(dir); first = resume; diff --git a/mac/eval_cpp.cpp b/mac/eval_cpp.cpp index 9181a40..533daf1 100644 --- a/mac/eval_cpp.cpp +++ b/mac/eval_cpp.cpp @@ -34,7 +34,7 @@ std::string Eval_cpp::eval(const fs::path& library_path, const std::string& func std::stringstream ss {}; ss << "Cannot load symbol " << function_name << " from library " << library_path << ":\n " << error_desc; - throw File_error(ss.str(), m_loc, false); + throw File_error(ss.str(), m_loc); } std::string result = func(m_machine); dlclose(handle); diff --git a/mac/eval_python.cpp b/mac/eval_python.cpp index 88faebc..1a6f0c5 100644 --- a/mac/eval_python.cpp +++ b/mac/eval_python.cpp @@ -40,6 +40,21 @@ Eval_python::Eval_python(Machine& machine, const Locator& loc) Py_DECREF(result_text); } import_module("inspect", false); + // The :after_apply phase dispatcher: a phase that declares a K + // parameter receives the state's class K; one that does not (a stdlib + // function like string.capwords, whose second parameter is a separator) + // is called with the text alone. Discrimination is by parameter NAME, + // not count -- capwords has two parameters and K is not one of them. + // Builtins whose signature inspect cannot read are treated as not + // wanting K. + PyRun_String( + "def K_phase_call(f, text, K):\n" + " try:\n" + " wants = 'K' in inspect.signature(f).parameters\n" + " except (ValueError, TypeError):\n" + " wants = False\n" + " return f(text, K=K) if wants else f(text)\n", + Py_file_input, m_globals, m_globals); if (!m_machine.m_state.m_frames.empty()) { PyRun_String(m_machine.m_state.python_code().c_str(), Py_file_input, m_globals, m_locals); } @@ -154,7 +169,7 @@ void Eval_python::import_module(const std::string& module_name, bool verify) if (!detail.empty()) { message += ":\n\n" + detail; } - throw Argument_error(message, m_loc, false); + throw Argument_error(message, m_loc); } // PyDict_SetItemString steals a reference, so we don't need to DECREF module // The dictionary will own the reference diff --git a/mac/file.cpp b/mac/file.cpp index 63624db..6fe203e 100644 --- a/mac/file.cpp +++ b/mac/file.cpp @@ -160,7 +160,6 @@ strings_t group_filename_tokens(const strings_t& tokens, const std::string& base // name exists. A name that never resolves is kept as given, so the // missing-file error downstream reports what the user wrote. size_t i = 0; - bool reported = false; // at most one "tried" report per list while (i < tokens.size()) { if (filename_exists(tokens[i], base_dir)) { result.push_back(tokens[i]); @@ -188,27 +187,14 @@ strings_t group_filename_tokens(const strings_t& tokens, const std::string& base } } if (!found) { - // Nothing resolved. The groupings that were TRIED are what - // the user needs at verbosity 0, because the downstream error - // names only the first token and cannot say why the others - // were not joined to it. Recorded on the name so the caller - // can report them with the missing-file error. - strings_t tried {}; - std::string acc2 = tokens[i]; - tried.push_back(q_(acc2)); - for (size_t k = i + 1; k < tokens.size(); ++k) { - acc2 += " " + tokens[k]; - tried.push_back(q_(acc2)); - } - // Once: the first unresolved token's list already shows - // every joining from that point on, and one report per - // leftover word buries it. - if (tried.size() > 1 && !reported) { - std::cerr << command_name << ": no file matches " - << q_(tokens[i]) << "; tried " - << join(tried, ", ") << "\n"; - reported = true; - } + // Nothing resolved: the token passes through as written and + // its DOWNSTREAM OWNER decides -- a bare :files word is the + // kt/ shortcut (which resolves or errors there), a missing + // file is a located error. A failed rescue itself says + // NOTHING at any verbosity (2026-08-22): every outcome is + // already either the -v 1 resolution line or an error that + // stops the run, so a tried-list adds noise to the first and + // nothing to the second. result.push_back(tokens[i]); ++i; } @@ -358,7 +344,7 @@ std::string find_file(const std::string& basename, strings_t search_path, bool e std::sort(search_path.begin(), search_path.end()); ss << "File with basename \"" << basename << "\" not found in search path:\n " << join(search_path, "\n "); - throw File_error(ss.str(), Locator(), false); + throw File_error(ss.str(), Locator()); } return pathname; } diff --git a/mac/katom.cpp b/mac/katom.cpp index 8550552..31e141b 100644 --- a/mac/katom.cpp +++ b/mac/katom.cpp @@ -131,7 +131,7 @@ void warn_unparsed_katoms(katom_list& katoms, bool warn) // commands issue has one format: it said "[warning]" where // everything else says "(warning)". warning("Word not parsed:\n " + k.m_text - + "\nTo include a special character (@, |, #, and ^), put " + + "\nTo include a punctuation character literally, put " "\"^\" before it.", k.m_loc); k.m_unparsed = false; diff --git a/mac/katom_list.cpp b/mac/katom_list.cpp index e822149..3fcd3bb 100644 --- a/mac/katom_list.cpp +++ b/mac/katom_list.cpp @@ -114,7 +114,7 @@ void missing_open(const Katom& k, bool error_exit) std::stringstream ss {}; ss << "A klammer ends without a beginning: " << k; if (error_exit) { - throw Parsing_error(ss.str(), k.m_loc, false); + throw Parsing_error(ss.str(), k.m_loc); } else { std::cout << " " << ss.str() << "\n"; } @@ -134,7 +134,7 @@ void missing_close(const katom_list& bounds, bool error_exit) ss << " " << k.m_loc << " " << k.m_src << "\n"; } if (error_exit) { - throw Parsing_error(ss.str(), bounds[0].m_loc, false); + throw Parsing_error(ss.str(), bounds[0].m_loc); } else { std::cout << ss.str() << "\n"; } @@ -148,7 +148,7 @@ void bad_close(const Katom& open, const Katom& close, bool error_exit) << " " << open.m_loc << " " << open << "\n" << " " << close.m_loc << " " << close; if (error_exit) { - throw Parsing_error(ss.str(), open.m_loc, false); + throw Parsing_error(ss.str(), open.m_loc); } else { std::cout << ss.str() << "\n"; } @@ -174,7 +174,7 @@ void check_named_katom_span(const Katom& begin, const Katom& end) ss << " A named end katom does not match:\n" << " " << begin.m_loc << " " << begin << "\n" << " " << end.m_loc << " " << end; - throw Parsing_error(ss.str(), end.m_loc, false); + throw Parsing_error(ss.str(), end.m_loc); } } } @@ -370,6 +370,24 @@ void hide_special_katoms(katom_list& katoms) definition_depth == 0 && code_depth == 0) { k.m_text = hide_structural_characters(k.m_text); } + // "^" before ANY punctuation character quotes it, not only the six + // Klammertext specials the katomizer knows: ^- is a literal hyphen + // (no dash transform), ^~ a literal tilde. The pair becomes the + // KTESC marker of the character here -- text-level, on writer-text + // katoms only, under the same span skips as the quoted specials + // above -- so code inside an @eval keeps its carets (grep '^-' + // reaches the shell intact) and ^'...'^ content (katom_t::literal, + // marked before this pass runs) stays exactly as typed. How the + // quoted character renders is the target's decision: the :escape + // and :resolve tables map its marker, and an unmapped marker + // decodes to the character itself. A katom this converts is no + // longer unparsed (the ^- warning would otherwise misfire). + if ((k.m_type == katom_t::word || k.m_type == katom_t::text) && + definition_depth == 0 && code_depth == 0) { + if (hide_quoted_punctuation(k.m_text)) { + k.m_unparsed = false; + } + } } } @@ -456,7 +474,7 @@ void check_bar_count(katom_iter begin, katom_iter end) ss << "Incorrectly formatted @cond klammer. There should only be one or two bar characters:\n" << " @cond | | | @"; - throw Argument_error(ss.str(), begin->m_loc, false); + throw Argument_error(ss.str(), begin->m_loc); } } diff --git a/mac/klammer.cpp b/mac/klammer.cpp index e325c21..b6a1bf5 100644 --- a/mac/klammer.cpp +++ b/mac/klammer.cpp @@ -20,7 +20,7 @@ parse_name(const Target_registry& targets, const Katom& name_katom) if (!std::regex_match(name_with_target, match, Klammer::name_re)) { throw Parsing_error( - "The klammer name \"" + name_with_target + "\" is not correctly defined. " + "The klammer name \"" + name_with_target + "\" is not correctly defined.\n\n" "The form is \"\" for general klammers or \".\" " "for a specialized target. Several targets that share one body are written as a " "comma-separated list: \".,\". The klammer " @@ -44,7 +44,7 @@ parse_name(const Target_registry& targets, const Katom& name_katom) if (!targets.has(target_name)) { throw Target_error( "The target \"" + target_name + "\" in klammer definition \"" + name_with_target + "\" " - "is not defined. Enter \"kdesc -t\" to see the targets defined by the Standard Klammer Set.", + "is not defined.\n\nEnter \"kdesc -t\" to see the targets defined by the Standard Klammer Set.", name_katom.m_loc); } if (is_in(target_name, seen)) { @@ -105,7 +105,7 @@ parse_definition_katoms( " @@[.] :: @@ instance (uses .k parameters)\n" " @@[.] ::: @@ override existing definition\n" " @@[.] :::: @@ default (can be overridden)", - begin->m_loc, false); + begin->m_loc); } katom_list parameter_katoms(begin, deftype); @@ -274,7 +274,7 @@ void Klammer::disallow_instances() //Klammer::components declaration) << plural("instance", icount) << " (defined by \"::\"), but " << "no declarations (defined by a \".k\" target)"; throw Definition_error( - error_list(ss.str(), instances), instances[0].loc, false); + error_list(ss.str(), instances), instances[0].loc); } } @@ -295,7 +295,7 @@ bool Klammer::copy_to_instances(const Target_registry& targets) << plural("instance", icount) << " (defined by \"::\"), but " << dcount << " "<< plural("definition", dcount) << " (defined by \":\")"; throw Definition_error( - error_list(ss.str(), definitions), definitions[0].loc, false); + error_list(ss.str(), definitions), definitions[0].loc); } else { copy_components(definitions[0].parameters, m_defs, targets); return true; @@ -336,7 +336,7 @@ void Klammer::check_for_multiple_general_klammers() error_list( "There is more than one general klammer (a klammer in which no target is defined)", general_klammers), - general_klammers[0].loc, false); + general_klammers[0].loc); } } @@ -362,7 +362,7 @@ void Klammer::check_for_declaration_and_definitions() ".\nWrite \"::\" instead of \":\" so the definition takes its parameters " "from the declaration", definitions), - declares[0].loc, false); + declares[0].loc); } } } @@ -384,7 +384,7 @@ void Klammer::copy_general_klammer_to_undefined(const Target_registry& targets) throw Definition_error( error_list("The general parameters are different than the defined parameters", general_klammers), - m_parameters.m_katoms[0].m_loc, false); + m_parameters.m_katoms[0].m_loc); } } const auto& [target, deftype, parameters, body, varmap, loc] = general_klammers[0]; @@ -470,19 +470,19 @@ void Klammer::no_declarations(const Target_registry& targets) // definitions by hand to find which one drifted. std::stringstream ss {}; ss << "The parameters of klammer \"" << m_name - << "\" are not the same for every target,\n" + << "\" are not the same for every target, " "and there is no declaration (.k) target to define them once:\n"; for (const auto& def : m_defs) { if (std::ranges::find(target_names, def.target) == target_names.end()) continue; std::string target = def.target; - target.resize(std::max(target.size(), size_t(6)), ' '); - ss << " " << target << " " << parameter_signature(def.parameters) - << "\n " << def.loc.desc() << "\n"; + target.resize(std::max(target.size(), size_t(4)), ' '); + ss << " Target: " << target << " Parameters: " << parameter_signature(def.parameters) + << "\n " << def.loc.desc() << "\n"; } ss << "Use a .k target to declare the parameters and describe the klammer,\n" "and \"::\" with no parameters for each target's definition."; - throw Definition_error(ss.str(), m_defs[0].loc, false); + throw Definition_error(ss.str(), m_defs[0].loc); } else { // std::cout << " All equal\n"; copy_components(m_defs[0].parameters, m_defs, targets); @@ -506,7 +506,7 @@ void Klammer::many_declarations(const std::vector& declares // std::cout << boldblack << "Many declarations\n" << black; throw Definition_error( error_list("More than one declaration (.k) klammer", declares), - declares[0].loc, false); + declares[0].loc); } diff --git a/mac/klammerset_registry.cpp b/mac/klammerset_registry.cpp index 8e94fcb..e54564e 100644 --- a/mac/klammerset_registry.cpp +++ b/mac/klammerset_registry.cpp @@ -206,9 +206,9 @@ fs::path resolve_klammerset_symbol( } throw Klammerset_error( "The klammerset \"" + symbol + "\" was not found. A symbol x names the " - "declaration file x/x.k in one of the search directories: " - + join(dirs, ", ") - + ". Enter \"kdesc --klammersets\" to list the available klammersets.", + "declaration file x/x.k in one of the search directories:\n " + + join(dirs, "\n ") + + "\nEnter \"kdesc --klammersets\" to list the available klammersets.", loc); } diff --git a/mac/ktype.h b/mac/ktype.h index 093e10d..8232195 100644 --- a/mac/ktype.h +++ b/mac/ktype.h @@ -192,7 +192,7 @@ std::vector katom_types { Ktype(katom_t::ws_newline, "ws-newline", ws_newline_s, "Remove all whitespace, leaving newlines (default: 1)"), Ktype(katom_t::special, "special-char", R"(\^[@|#^:*])", "Klammertext special character treated as regular text"), //Ktype(katom_t::nonascii, "non-ascii-char", R"(\^\w(?:.|\^))", "Non-ASCII character"), - Ktype(katom_t::nonascii, "non-ascii-char", R"((\^(\w(?:.|\^)))|(\^[A-Fa-f0-9]{1,5}\^))", "Non-ASCII character"), + Ktype(katom_t::nonascii, "non-ascii-char", R"((\^(\w(?:.|\^)))|(\^[A-Fa-f0-9]{1,6}\^))", "Non-ASCII character"), // Ktype(katom_t::word, "word", R"([a-z][a-z0-9_]*)", "Lower-case letters, numbers, or underscore"), // Ktype(katom_t::text, "text", diff --git a/mac/locator.h b/mac/locator.h index 95b6589..5963908 100644 --- a/mac/locator.h +++ b/mac/locator.h @@ -24,6 +24,14 @@ public: , m_chr(int(location.column())) {}; Locator(const fs::path& filename, int line, int chr); + // "No location": for errors with no document position -- command-line + // argument errors, lookups made on the command's behalf. NOT the same + // as Locator(): the default constructor captures the C++ CALL SITE via + // std::source_location (and a defaulted Locator parameter captures the + // CALLER), which is what leaked "argv.cpp, line 597" into user-facing + // errors (found by the error gallery, 2026-08-22). Locator() is for + // logging; errors either carry a real document location or this. + static Locator none() { return Locator(fs::path{}, -1, -1); } std::string str(bool relative = false) const; std::string desc(bool relative = false) const; std::string abbrev(bool include_chr=true) const; diff --git a/mac/machine.cpp b/mac/machine.cpp index 49cc787..5a373a6 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -61,10 +61,10 @@ public: if (apply_depth >= max_apply_depth) { std::stringstream ss {}; ss << "Klammer application nested more than " << max_apply_depth - << " levels deep while applying " << q_(name) << ".\n" + << " levels deep while applying " << q_(name) << "." << "A klammer that applies itself, directly or through a cycle " << "of klammers, does not terminate."; - throw Recursion_error(ss.str(), loc, false); + throw Recursion_error(ss.str(), loc); } ++apply_depth; } @@ -132,7 +132,7 @@ void check_bar_count(katom_iter begin, std::size_t count) ss << "Incorrectly formatted @cond klammer. There should only be one or two bar characters:\n" << " @cond | | | @"; - throw Argument_error(ss.str(), begin->m_loc, false); + throw Argument_error(ss.str(), begin->m_loc); } } @@ -749,24 +749,23 @@ katom_list Machine::apply_klammer( // remain in the klammer body substitution for final target-specific output. katom_list result(klammer.m_body[target].begin(), klammer.m_body[target].end()); auto varmap = klammer.m_varmap[target]; - m_state.open_frame("Arguments for klammer " + q_(klammer.m_name)); - m_state.set(values, klammer.m_parameters); - for (const auto& [name, indices] : varmap) { - std::regex arg("\\*" + name + "\\*"); - for (auto i : indices) { - result[i].m_text = std::regex_replace(result[i].m_text, arg, m_state.value(name)); - result[i].m_type = katom_t::text; - } - } // Escape target-specific characters (e.g. tex "&" -> "\&") in the writer - // text of a GENERAL klammer's body. Runs BEFORE process_katoms/apply() - // below expand the body, so that target-native markup pulled in by nested - // klammers (e.g. nl.tex -> "\newline") is left untouched -- only this - // klammer's own literal writer text is escaped here; nested klammers escape - // theirs when they are applied in turn. Bodies from target-specific - // definitions (m_body_generic[target] == false) are already in target form - // and skipped. KTESC markers are idempotent, so text already escaped at the - // top level passes through unchanged. Two kinds of body content are NOT + // text of a GENERAL klammer's body. Runs BEFORE the *arg* substitution + // below: an argument value is writer text already escaped at the top + // level (KTESC markers, idempotent) plus final target markup from + // klammers the writer nested in the argument, and neither may be escaped + // here -- escaping after substitution swept both, so a general klammer + // with a klammer-bearing argument emitted \textbackslash{}emph{...} + // (fixed 2026-08-19; tst/escape_test.sh cases 26-29). A karg katom is + // skipped by the type filter; a variable inside a mixed text katom + // survives because no target declares '*' or identifier characters as + // escapes. Also runs BEFORE process_katoms/apply() expand the body, so + // that target-native markup pulled in by nested klammers (e.g. nl.tex -> + // "\newline") is left untouched -- only this klammer's own literal writer + // text is escaped here; nested klammers escape theirs when they are + // applied in turn. Bodies from target-specific definitions + // (m_body_generic[target] == false) are already in target form and + // skipped. Two kinds of body content are NOT // writer text and must be skipped: // * ^'...'^ literal spans -- raw target markup the writer typed directly. // At this point they are typed literal_begin/literal_end with plain-text @@ -818,6 +817,16 @@ katom_list Machine::apply_klammer( } } } + m_state.open_frame("Arguments for klammer " + q_(klammer.m_name)); + m_state.set(values, klammer.m_parameters); + for (const auto& [name, indices] : varmap) { + std::regex arg("\\*" + name + "\\*"); + for (auto i : indices) { + result[i].m_text = std::regex_replace( + result[i].m_text, arg, m_state.value(name, true, result[i].m_loc)); + result[i].m_type = katom_t::text; + } + } process_katoms(result, klammer.m_name); apply(m_klammers, result, target); m_state.close_frame(); @@ -876,7 +885,18 @@ std::string Machine::run_phase_functions() // phase sees its predecessor's result in K_result. Eval E(*this, Locator()); if (!f.empty() && f[0] != ':') { - f += "(K_result)"; + // K reaches a phase only as a parameter: the state's + // "class K" exists in the EVAL's globals (python_code() + // defines it), but a phase function's body resolves names in + // its own MODULE's globals. Every phase signature has + // carried "K=None" for this since the calling convention was + // created; the call never passed it, which surfaced when + // txt_justify_blocks needed Target_txt_width (2026-08-22). + // K_phase_call (defined in Eval_python's globals) passes K + // only to a function that DECLARES a K parameter, so a + // stdlib phase (string.capwords, whose second parameter is a + // separator) keeps working. + f = "K_phase_call(" + f + ", K_result, K)"; } f = "@eval " + f + " @"; auto katoms = katomize(line_split(f), "phase"); @@ -916,7 +936,7 @@ std::string Machine::apply(const std::string& target_name, bool final_processing // Characters produced later by klammer bodies will not be escaped. // Skipped for sub-Machine apply() calls (e.g., from @eval), where the // text is already in target-specific form. - auto target = m_targets.get(target_name, Locator()); + auto target = m_targets.get(target_name, Locator::none()); if (escape_characters) escape_target_characters(target, m_katoms); @@ -935,18 +955,24 @@ std::string Machine::apply(const std::string& target_name, bool final_processing if (++apply_count > apply_round_limit) { std::stringstream ss {}; ss << "Klammer application did not reach a fixed point after " - << apply_round_limit << " rounds.\n" + << apply_round_limit << " rounds.\n\n" << "Each round applies every klammer present; a klammer whose " << "result contains further klammers starts another round."; - throw Recursion_error(ss.str(), Locator(), false); + throw Recursion_error(ss.str(), Locator()); } } + // Typographic transforms run on the katoms, not the joined string, so + // that ^'...'^ literal content (katom_t::literal) stays verbatim -- + // Target::transform() skips literal katoms. Verbatim text produced by + // an @eval renderer (@code, @c) is plain text by the time it is spliced + // back; the SKS protects it with KTESC markers (hide_typographic() in + // klammer_base.py), which are inert here and decode in resolve_escapes. + if (final_processing) { + target.transform(m_katoms); + } m_result = to_string(m_katoms.begin(), m_katoms.end()); if (final_processing) { - for (const auto& [old_str, new_str] : target.m_transforms) { - m_result = string_replace(m_result, old_str, new_str); - } m_result = target.resolve_escapes(m_result); m_result = run_phase_functions(); } diff --git a/mac/option_set_registry.cpp b/mac/option_set_registry.cpp index a2a5f41..94b9329 100644 --- a/mac/option_set_registry.cpp +++ b/mac/option_set_registry.cpp @@ -27,7 +27,7 @@ void Option_set_registry::add( if (deftype.m_initial_type == katom_t::klammer_instance) { throw Definition_error( "The option set " + q_(name) + " is declared with \"::\", which takes its " - "parameters from a \".k\" declaration. An option set IS a declaration: " + "parameters from a \".k\" declaration. An option set is a declaration; " "it declares its parameters itself, after \":\".", begin->m_loc); } @@ -68,16 +68,16 @@ void Option_set_registry::add( throw Definition_error( "The option set " + q_(name) + " declares the positional " + plural("parameter", static_cast(names.size())) + " " - + join(names, ", ") + ".\n" + + join(names, ", ") + ".\n\n" "An option set declares only optional parameters -- names written with " "a leading \":\".", - begin->m_loc, false); + begin->m_loc); } if (parameters.m_optional.empty()) { throw Definition_error( - "The option set " + q_(name) + " declares no parameters.\n" + "The option set " + q_(name) + " declares no parameters.\n\n" "The form is: @@" + name + ".o :name.argtype default ... : @@", - begin->m_loc, false); + begin->m_loc); } // The members as written: these katoms are what is spliced into the @@ -201,14 +201,14 @@ std::string definition_name(const std::string& klammer_name, const std::string& std::stringstream ss {}; if (option_sets.has(name) && target_name == Target_registry::optionset_name) { ss << "The option set " << q_(name) << " is used in the declaration of the option " - << "set " << q_(klammer_name) << ".\n" + << "set " << q_(klammer_name) << ".\n\n" << "An option set is used only in the parameter list of a \".k\" declaration, " << "so a set does not include another set: a klammer that needs two " << "vocabularies names two sets, and each set stays a vocabulary that can " << "be learned whole."; } else if (option_sets.has(name)) { ss << "The option set " << q_(name) << " is used in the parameter list of " - << definition_name(klammer_name, target_name) << ".\n" + << definition_name(klammer_name, target_name) << ".\n\n" << "An option set may be used only in the parameter list of a \".k\" " << "declaration, which is where a klammer's interface is declared once " << "for all of its targets. Declare " @@ -216,15 +216,15 @@ std::string definition_name(const std::string& klammer_name, const std::string& << "an instance (\"::\"), which inherits the declared parameters."; } else { ss << "The klammer " << q_(name) << " is applied in the parameter list of " - << definition_name(klammer_name, target_name) << ".\n" + << definition_name(klammer_name, target_name) << ".\n\n" << "A klammer application in a parameter list is not allowed: it is " << "resolved after the parameters are parsed, so the parameter list it " << "was meant to contribute is not there when the list is read. An " << "option set, declared with a \".o\" target, is how parameters are " - << "shared between klammers. Declared option sets: " + << "shared between klammers.\n\nDeclared option sets: " << option_sets.available() << "."; } - throw Definition_error(ss.str(), loc, false); + throw Definition_error(ss.str(), loc); } // The default written for each member at the use site: @@ -237,10 +237,10 @@ std::map use_site_defaults( if (!positional.empty() || active(rest)) { throw Definition_error( "The use of the option set " + q_(option_set.m_name) + - " gives a value that is not an option.\n" + " gives a value that is not an option.\n\n" "A set's names and types are fixed where the set is declared; only a " "default may be given where it is used, written as \":name value\".", - begin->m_loc, false); + begin->m_loc); } std::map defaults {}; for (const auto& option : optional) { @@ -248,17 +248,17 @@ std::map use_site_defaults( if (name.size() + 1 != option[0].m_text.size()) { throw Definition_error( "The use of the option set " + q_(option_set.m_name) + " gives a type for \":" - + name + "\".\n" + + name + "\".\n\n" "A set's names and types are declared where the set is; only a default " "may be given where it is used.", - option[0].m_loc, false); + option[0].m_loc); } const Parameter* member = option_set.find(name); if (member == nullptr) { throw Definition_error( "The option set " + q_(option_set.m_name) + " has no parameter \":" - + name + "\".\n It declares: " + option_set.member_names(), - option[0].m_loc, false); + + name + "\". It declares:\n " + option_set.member_names(), + option[0].m_loc); } if (defaults.count(name) > 0) { throw Definition_error( @@ -297,7 +297,7 @@ option_set_uses_t expand_option_sets( throw Definition_error( "The parameter \":" + name + "\" of " + q_(klammer_name) + " is declared twice:\n " + previous->second + "\n " + from, - loc, false); + loc); } origin[name] = from; }; diff --git a/mac/target.cpp b/mac/target.cpp index 959c555..8e85edc 100644 --- a/mac/target.cpp +++ b/mac/target.cpp @@ -24,16 +24,21 @@ void Target::add_transforms(const string_pairs& transforms) } } +// The typographic transform pass, run by Machine::apply() at final +// processing. Per katom rather than over the joined result string, so +// that ^'...'^ literal content (katom_t::literal) is never transformed -- +// verbatim text must show the characters the writer typed. A transform +// source therefore cannot match across a katom boundary, which is the +// correct reading: two hyphens separated by a klammer application were +// separated by the writer and are not a dash. void Target::transform(katom_list& katoms) const { (void)K::log(3); std::for_each( katoms.begin(), katoms.end(), [this] (Katom& k) { - // std::cout << "transform: " << k << "\n"; if (k.m_type != katom_t::literal) { for (const auto& [a, b] : this->m_transforms) { - // std::cout << " " << a << right_arrow << b << "\n"; k.m_text = string_replace(k.m_text, a, b); } } @@ -67,6 +72,14 @@ void Target::add_escapes(const std::string& escape_spec) } } +void Target::add_resolves(const std::string& resolve_spec) +{ + auto words = word_split(resolve_spec); + for (size_t i = 0; i + 1 < words.size(); i += 2) { + m_resolves.push_back({words[i], words[i+1]}); + } +} + std::string Target::escape_marker(const std::string& ch) { std::stringstream ss {}; @@ -94,11 +107,16 @@ std::string Target::unescape_text(std::string text) const std::string Target::resolve_escapes(std::string text) const { // Target-declared escapes first (marker -> declared replacement), then - // the generic decode for the remaining markers (marker -> the character - // itself: quoted Klammertext specials and literal-span content). + // the resolution-only entries (:resolve -- how a QUOTED character + // renders here), then the generic decode for the remaining markers + // (marker -> the character itself: quoted punctuation and literal-span + // content). for (const auto& [ch, repl] : m_escapes) { text = string_replace(text, escape_marker(ch), repl); } + for (const auto& [ch, repl] : m_resolves) { + text = string_replace(text, escape_marker(ch), repl); + } return ktesc_resolve(text); } @@ -130,6 +148,29 @@ std::string ktesc_resolve(std::string text) return text; } +bool hide_quoted_punctuation(std::string& s) +{ + // ASCII punctuation, EXCEPT "'" -- ^' opens a ^'...'^ literal span, the + // one documented exception to the rule (a literal apostrophe is ^0027^). + // Hand-rolled scan: no std::regex, this can run over large text. + static const std::string punct = R"pct(!"#$%&()*+,-./:;<=>?@[\]^_`{|}~)pct"; + if (s.find('^') == std::string::npos) return false; + std::string result {}; + bool changed = false; + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '^' && i + 1 < s.size() + && punct.find(s[i + 1]) != std::string::npos) { + result += Target::escape_marker(std::string(1, s[i + 1])); + ++i; + changed = true; + } else { + result += s[i]; + } + } + if (changed) s = result; + return changed; +} + std::string hide_structural_characters(const std::string& s) { std::string result {}; @@ -145,7 +186,26 @@ std::string hide_structural_characters(const std::string& s) void Target::add_after_apply(const std::string& function_specs) { for (const auto& f : regex_split(function_specs, std::regex(R"(\s+;\s+)"), true)) { - // msg() << "Add " << m_name << " after-apply: " << f << "\n"; + // A bare spec is ONE Python name (module.function): whitespace + // inside it means two specs were written without the " ; " + // separator, and the glued call would otherwise fail only at render + // time, as a Python SyntaxError located at "phase" rather than at + // this declaration (found 2026-08-22, the first time a target + // declared two phases). Mode-tagged specs (":cpp + // ") are exempt: the library is a filename, and filenames + // may contain spaces -- which is exactly why the list separator is + // ";" rather than whitespace. + if (!f.empty() && f[0] != ':' + && f.find_first_of(" \t\n") != std::string::npos) { + throw Definition_error( + "The :after_apply phase \"" + f + "\" contains whitespace. " + "A bare phase is a single Python name (module.function), and " + "several phases are separated by \" ; \":\n" + " :after_apply first.phase ; second.phase\n" + "(A mode-tagged phase -- \":cpp \" -- may " + "contain spaces; its library is a filename.)", + m_loc); + } m_after_apply.push_back(f); } } diff --git a/mac/target.h b/mac/target.h index f91041b..7a5fd0c 100644 --- a/mac/target.h +++ b/mac/target.h @@ -24,6 +24,7 @@ public: void transform(std::vector& katoms) const; void add_escapes(const std::string& escape_spec); + void add_resolves(const std::string& resolve_spec); std::string escape_text(std::string text) const; std::string unescape_text(std::string text) const; std::string resolve_escapes(std::string text) const; @@ -40,6 +41,13 @@ public: Locator m_loc {}; std::vector> m_transforms {}; std::vector> m_escapes {}; + // Resolution-only entries (:resolve): how a QUOTED character renders in + // this target. The resolve half of :escape without the escape half -- + // needed where the raw character must stay untouched in writer text + // (tex cannot escape "-" without destroying the --- convention) but the + // quoted character must not decode to its raw self (a decoded -- would + // re-form TeX's dash ligature). + std::vector> m_resolves {}; // Argtype_registry m_argtypes {}; @@ -60,3 +68,14 @@ std::string ktesc_resolve(std::string text); // sub-Machines and the @eval result read-back. Resolved by ktesc_resolve() // at final processing. std::string hide_structural_characters(const std::string& s); + +// Replace each ^P pair in s -- "^" before any ASCII punctuation character +// except "'" (which opens a ^'...'^ literal span) -- with the KTESC marker +// of P: "^" before a punctuation character quotes it, uniformly, not only +// the six Klammertext specials. A letter or digit is never special, so +// ^ keeps its existing meanings (diacritics, mnemonics, +// ^UUUU^ code points). Returns whether anything was replaced. Called from +// hide_special_katoms() on writer-text katoms OUTSIDE definition and +// @eval/@read/@cond spans -- code keeps its carets (grep '^-' must reach +// the shell intact), the same skip set as the quoted-special hiding. +bool hide_quoted_punctuation(std::string& s); diff --git a/mac/target_registry.cpp b/mac/target_registry.cpp index 67f513b..e720e00 100644 --- a/mac/target_registry.cpp +++ b/mac/target_registry.cpp @@ -14,7 +14,7 @@ std::string Target_registry::general_name = "*"; std::string Target_registry::optionset_name = "o"; Target_registry::Target_registry() - : m_parameters(Parameter_set("name | desc :after_apply :after_write :includes :escape | transforms.rest")) + : m_parameters(Parameter_set("name | desc :after_apply :after_write :includes :escape :resolve | transforms.rest")) { // Registration order is display order. The two that declare an interface // come first -- "k" a klammer's, "o" an option set's -- and then "*", the @@ -47,14 +47,25 @@ void Target_registry::add(std::vector::iterator begin, std::vector Target target(values["name"], values["desc"], begin->m_loc); target.add_transforms(values["transforms"]); target.add_escapes(values["escape"]); + target.add_resolves(values["resolve"]); target.add_after_apply(values["after_apply"]); target.m_includes = word_split(values["includes"]); - // Inherit escapes from included targets + // Inherit escapes AND typographic transforms from included targets: an + // including target renders through the included one's syntax (pdf + // through tex), so both tables apply there too. Inherited entries are + // appended after the including target's own, so a declaration can + // override an inherited pair by declaring its source first. for (const auto& included : target.m_includes) { if (m_targets.count(included)) { for (const auto& esc : m_targets[included].m_escapes) { target.m_escapes.push_back(esc); } + for (const auto& tr : m_targets[included].m_transforms) { + target.add_transform(tr.first, tr.second); + } + for (const auto& res : m_targets[included].m_resolves) { + target.m_resolves.push_back(res); + } } } // Registration (and the previous-definition check) goes through @@ -79,7 +90,7 @@ void Target_registry::check_for_previous_definition(const std::string& name, con if (has(name)) { const Target& current = m_targets.at(name); throw Target_error("Target \"" + name + "\" is already defined:\n " + current.m_loc.desc(), - loc, false); + loc); } } diff --git a/mac/target_registry.h b/mac/target_registry.h index abddcaf..bb988be 100644 --- a/mac/target_registry.h +++ b/mac/target_registry.h @@ -51,7 +51,7 @@ public: /* - std::string m_parameters_spec {"name | desc :after_apply :after_write :includes | transforms.rest"}; + std::string m_parameters_spec {"name | desc :after_apply :after_write :includes :escape :resolve | transforms.rest"}; Parameter_set m_parameters = katomize(line_split(m_parameters_spec), Locator().str()); */ diff --git a/mac/util.cpp b/mac/util.cpp index 3349ced..d728fa2 100644 --- a/mac/util.cpp +++ b/mac/util.cpp @@ -226,14 +226,66 @@ std::string justify_string(const std::string& s, unsigned int width=80, bool fre return result; } +// The error-message formatting contract (2026-08-21): a message carries no +// decisions about line breaks EXCEPT by indentation. +// * a line beginning with whitespace is VERBATIM -- an example, a pattern, +// a signature, a list entry -- emitted untouched: no folding, no +// wrapping, no "~" substitution (a pattern may contain a literal ~); +// * blank lines separate blocks (runs collapse to one); +// * everything else is prose: consecutive lines fold into one paragraph +// and are wrapped to the width. +// So prose stays machine-wrapped however the source hand-wraps it, and the +// one legitimate exception is marked in the one place it cannot be missed. +// This replaced a per-call do_justify flag on the Error constructors, whose +// decision lived apart from the text it governed. Line-based by hand: no +// std::regex over the whole message (the ambiguous-alternation hazard). std::string justify( const std::string& input_text, unsigned int text_width, unsigned int margin_width) { - std::string result {}; text_width -= margin_width; - for (const std::string& par : split_into_paragraphs(trim(input_text))) { - result += justify_string(par, text_width) + "\n\n"; + strings_t lines {}; + { + const std::string text = trim(input_text); + std::string::size_type from = 0; + while (from <= text.size()) { + auto nl = text.find('\n', from); + if (nl == std::string::npos) { + lines.push_back(text.substr(from)); + break; + } + lines.push_back(text.substr(from, nl - from)); + from = nl + 1; + } } + std::string result {}; + strings_t prose {}; + bool pending_blank = false; + auto emit = [&](const std::string& block) { + if (!result.empty()) { + result += pending_blank ? "\n\n" : "\n"; + } + result += block; + pending_blank = false; + }; + auto flush_prose = [&]() { + if (!prose.empty()) { + emit(justify_string(join(prose, " "), text_width)); + prose.clear(); + } + }; + for (const std::string& line : lines) { + if (trim(line).empty()) { // block separator + flush_prose(); + pending_blank = !result.empty(); + } else if (line[0] == ' ' || line[0] == '\t') { // verbatim line + flush_prose(); + emit(trim_right(line)); + } else { // prose + prose.push_back(trim(line)); + } + } + flush_prose(); + result += "\n"; if (margin_width > 0) { result = add_margin(result, margin_width); } @@ -367,8 +419,8 @@ std::vector> environment_variables(bool allo auto parts = regex_split(environ[i], std::regex("="), true); if (!allow_empty_definitions && parts.size() < 2) { throw Internal_error( - "Incorrect environment variable format:\n" + std::string(environ[i]), - Locator(), false); + "Incorrect environment variable format:\n " + std::string(environ[i]), + Locator()); } std::string name = parts[0]; parts.erase(parts.begin()); diff --git a/sks/block/block.k b/sks/block/block.k index 6b673ac..f6cc846 100644 --- a/sks/block/block.k +++ b/sks/block/block.k @@ -9,49 +9,77 @@ paragraph pass recognizes as a block and leaves alone, and \par in vertical mode is a no-op. ]# -@@par.k s : A paragraph of text @@ +# Paragraph + +@@par.k s : A paragraph of text. @@ + @@par.html ::

*s*

@@ -@@par.tex :: -\par -*s* -\par -@@ + +@@par.tex :: \par *s* \par @@ + # In plain text a paragraph is delimited by blank lines, which # phases.justify_blocks then fills; #/2 inserts them without depending on # the definition body's own whitespace surviving extraction. @@par.txt :: #/2*s*#/2 @@ + +# Non-breaking space + @@sp.k : Non-breaking space character @@ @@sp.html :: &^#160; @@ @@sp.tex :: ~ @@ -# TODO: Easy in LaTeX; how to handle in HTML and plain text? -# @@footnote.k s : Footnote (TBD) @@ -# @@footnote :: [*s*] @@ +# Indented block -@@indent.k s :w.int 3 :linebreak.bool false : Indented block @@ +@@indent.k s :left.int 8 :right.int -1 :ragged.bool false : +Indented block. The *left* and *right* values are the number of characters for +indentation (as roughtly defined by "ex"). By default, *right* is set to the +*left* value (with -1 as the sentinel). +@@ @@indent.html,tex,txt :: @eval block.Indent(K) eval@ @@ -@@quote.k s :w.int 1 :source : Quotation block @@ -@@quote.html :: -
+# Right justification + +@@right.k s :margin 0 :top_margin 2 : Right-justified text @@ + +@@right.html :: +
*s*
+@@ + +@@right.tex :: +{\setlength{\topsep}{0pt}\setlength{\partopsep}{0pt}\setlength{\parskip}{*top_margin*ex} +\begin{flushright} +\raggedleft\rightskip=*margin*ex *s* -
+\end{flushright}} @@ -@@quote.tex :: -\quoteblock{*s*}{*source*} +@@right.txt :: + @eval import textwrap; "\n".join([line.rjust(*Target_txt_width*, "~") + for line in textwrap.wrap("""*s*""", *Target_txt_width*)]) @ @@ -@@quote.txt :: - @eval block.block_indent(K) eval@ -@@ -@@note.k s :label Note :color 1.0,1.0,0.9 :bordercolor 0.2,0.2,0.2 :level.int 0 :width - : Rectangular block for a special note @@ +# Quote with attribution -@@note.html,tex :: @eval block.Note(K) eval@ @@ +@@quote.k text | attribution : A block quote with attribution. @@ +@@quote.html,tex :: + @indent *text* :left 8 @ + @right *attribution* :margin 8 + :top_margin @cond @eval "*K_target*" == "html" @ | -1 | 1 cond@ @ +@@ + + +# Highlighted note in text + +@@note.k s :label Note :color 1.0,1.0,0.9 :border_color 0.2,0.2,0.2 :level.int 0 :width.float 0.5 + : Rectangular block for an editorial note @@ + +@@note.html,tex :: @eval block.Note(K) @ @@ + + +# Center @@center.k s : Center text @@ @@ -62,42 +90,46 @@ vertical mode is a no-op. @@ @@center.html :: +

*s*
+

@@ -@@right.k s : Right-justified text @@ -@@right.html :: -TBD *s* -@@ - -@@right.tex :: -\begin{flushright} -*s* -\end{flushright} -@@ +# Newline @@nl.k : Newline character @@ -@@nl.html ::
@@ +@@nl.html ::
@@ @@nl.tex :: \newline @@ -@@nl.txt :: \n @@ +@@nl.txt :: @eval phases.txt_nl_marker() @ @@ + + +# New page @@newpage.k : Start new page @@ @@newpage.html :: @@ @@newpage.tex :: \newpage @@ @@newpage.txt :: @@ + +# Extend page + @@extendpage.k linecount : Extenad current page @@ @@extendpage.html :: @@ @@extendpage.tex :: \enlargethispage{*linecount*\baselineskip} @@ @@extendpage.txt :: @@ + +# Vertical space + @@vspace.k lines.float : Vertical space, in multiples of the current line height @@ @@vspace.tex :: \vspace{*lines*\baselineskip} @@ @@vspace.html ::
@@ -@@vspace.txt :: @eval "__VSPACE__" * round(*lines*) @ @@ +@@vspace.txt :: @eval phases.txt_vspace_marker() * round(*lines*) @ @@ + +# Vertical fill to end of page @@vfill.k : Fill the vertical space so that any following text is flush with the bottom @@ -112,6 +144,8 @@ only makes some vertical space. @@ @@vfill.txt :: @vspace 3 @ @@ +# Questions and answers + @@qa.k question | answer : Question and answer formatting @@ @@qa.html,tex,txt :: @b Q: @ *question* @@ -119,6 +153,9 @@ only makes some vertical space. @@ @b A: @ *answer* @@ + +# Absolute positioning + @@@argtype coords | x and y coordinates :pattern 'float'\s+'float' @@@ @@block.k : to.coords | content :width.float .5 :point.coords 0.0 0.0 @@ -126,18 +163,51 @@ only makes some vertical space. @@ @@block.tex :: @eval block.Block(K) eval@ @@ + +# Preserved line endings + @@lines.k s : Maintain line breaks @@ @@lines.html,tex,txt :: @eval block.Lines(K) eval@ @@ -@@twocolumns.k s : Format *s* in two columns @@ -@@twocolumns.tex :: -\begin{multicols}{2} + +# Multiple columns of text + +@@multicolumn.k s :n 2 :gap 3 : Format *s* in *n* columns (default: 2). The *gap* is in "ex" units. @@ + +@@multicolumn.tex :: +{\setlength{\columnsep}{*gap*ex} +\setlength{\multicolsep}{\parskip} +\begin{multicols}{*n*} *s* -\end{multicols} +\end{multicols}} @@ -@@left_right.k left | right : Text left- and right- justified on one line @@ - -@@left_right.tex :: - \makebox[\dimexpr\linewidth+\labelindent\relax]{*left*\hfill *right*} +@@multicolumn.html :: +

+

+ *s* +
+

@@ + + +# Text justified left and right on one line + +@@left_right.k parts.rest(2) : +Pairs of text left- and right- justified. First, left lines are written with a +bar (|) between each line. Then, two bars (||), and the right lines with a bar +betwen them. +@@ + +@@left_right.html,tex :: @eval block.Left_right(K) @ @@ + + +# Footnote + +@@footnote.k s : Footnote @@ +@@footnote.html :: *s* @@ +@@footnote.tex :: \unskip\footnote{*s*} @@ +@@footnote.txt :: __ #- @eval phases.txt_footnote_marker() @ *s* @eval phases.txt_footnote_marker() @ #- __ @@ + +# This doesn't work, either, so it isn't the @eval: +# @@footnote.txt :: __ #- marker *s* marker #- __ @@ diff --git a/sks/block/block.py b/sks/block/block.py index 50f63ef..6601f14 100644 --- a/sks/block/block.py +++ b/sks/block/block.py @@ -5,72 +5,60 @@ import pprint import klammer_base import kutil import latex_util +import html_util +from html_util import E +import color class Indent(klammer_base.Klammer_base): def __init__(self, K): super().__init__(K) - self.s = kutil.escape(self.s) + # Doesn't do anything now: + # self.s = kutil.escape(self.s) + if self.right < 0: + self.right = self.left + self.reduce = self.left + self.right def html(self): - return "FIX: INDENT " + self.s - + text_align = "justify" if not self.ragged else "left" + return E("div").body(self.s)\ + .sty("margin", f"0.5rem {self.right}ex 0.5rem {self.left}ex")\ + .sty("text-align", text_align)\ + .str() def tex(self): - tab = f"\\hspace*{{{self.w}ex}}" - result = "" - - if self.linebreak: - for e in self.s.split("\n"): - result += f"{tab}{e}\\\\\n" - result = result[:-3] - result = re.sub(r"\t", r"\\t", result) - else: - result = tab + latex_util.minipage( - "\\raggedright " + self.s, f"\\textwidth - {self.w}ex", center=False, vmargin="4pt") - #print(result) - return latex_util.block(result) + if self.ragged: + self.s = "\\raggedright " + self.s + result = rf"\hspace*{{{self.left}ex}}" + latex_util.minipage( + self.s, rf"\linewidth - {self.reduce}ex", center=False) + result = latex_util.block(result) + return result def txt(self): - indent = " " * self.w + indent = "~" * self.left # Removed in phases.justify_blocks + width = int(self.Target_txt_width) - self.left - self.right + 1 # Off-by one for textwrap result = self.s result = re.sub("KK0022", '"', result) - result = indent + f"\n{indent}".join(textwrap.wrap(result, width=70, break_on_hyphens=False)) - return f"@lit\n{result}\nlit@" + result = indent + f"\n{indent}".join(textwrap.wrap(result, width=width, break_on_hyphens=True)) + return result + class Note(klammer_base.Klammer_base): def __init__(self, K): super().__init__(K) def html(self): - color = ",".join([f"{float(e)*100}%" for e in self.color.split(",")]) - result = f'''
-{self.label}: {self.s} -
''' + result = E("div").cls("box")\ + .sty("width", f"{self.width*100}%")\ + .sty("background-color", html_util.color(self.color))\ + .sty("border-color", html_util.color(self.border_color))\ + .sty("margin-left", "auto")\ + .body(f"{self.label}: {self.s}")\ + .str() return result def tex(self): - if self.width: - width = r'{}\\textwidth'.format(self.width) - else: - #width = r'\\textwidth - 16pt - {}\\leftmargin'.format(self.level) - width = r'\\linewidth - \\leftmargin + 2pt' - - # The \par on each side comes from latex_util.block() below: an - # \fcolorbox is box material and must sit in vertical mode. - result = ''' -\\begingroup -COLOR\\setlength{\\fboxsep}{8pt} - \\fcolorbox{bordercolor}{localcolor}{ - \\parbox{WIDTH}{\\raggedright\\setlength{\\parskip}{8pt} - \\textbf{LABEL:} TEXT -}}\\endgroup -''' - result = re.sub('LABEL', self.label, result) - result = re.sub('TEXT', re.sub(r'\\', r'\\\\', self.s), result) - result = re.sub('COLOR', r'\\definecolor{{localcolor}}{{rgb}}{{{}}}\nCOLOR'.format(self.color), result) - result = re.sub('COLOR', r'\\definecolor{{bordercolor}}{{rgb}}{{{}}}\n'.format(self.bordercolor), result) - result = re.sub('WIDTH', width, result) - print(result) - return latex_util.block(result) + return latex_util.color_box( + f"\\textbf{{{self.label}:}} {self.s}", + self.width, self.color, self.border_color) class Block(klammer_base.Klammer_base): @@ -95,13 +83,12 @@ class Block(klammer_base.Klammer_base): class Lines(klammer_base.Klammer_base): def __init__(self, K): super().__init__(K) - self.s = kutil.escape(self.s) self.lines = self.s.split("\n") def html(self): result = "" for line in self.lines: - result += line + "
\n" + result += line + "
\n" return result def tex(self): @@ -109,3 +96,24 @@ class Lines(klammer_base.Klammer_base): def txt(self): return self.lines + +class Left_right(klammer_base.Klammer_base): + def __init__(self, K): + super().__init__(K) + self.rows = list(zip(self.parts[0], self.parts[1])) + + def html(self): + result = "" + for left, right in self.rows: + result += E("div").body( + E("span").cls("left_right").body(left).str() + \ + E("span").cls("left_right").sty("text-align", "right").body(right).str()).str(); + result = E("p").body(result).str() + return result + + def tex(self): + result = "" + for left, right in self.rows: + result += rf"\parbox{{\linewidth}}{{{left} \hfill {right}}}" + " \\\\\n" + result = result[:-3] + return result diff --git a/sks/block/css/block.css b/sks/block/css/block.css index f593b4c..0a5e53a 100644 --- a/sks/block/css/block.css +++ b/sks/block/css/block.css @@ -1,6 +1,6 @@ p { - margin: .5rem 0 .5rem 0; + margin: 1ex 0; } .quote { @@ -15,10 +15,8 @@ p { overflow: auto; } -.centered { - margin-left: auto; - margin-right: auto; - width: fit-content; +.center { + text-align: center; } .indent { @@ -49,3 +47,8 @@ p { .vfill { flex-grow: 1; } + +.left_right { + display: inline-block; + width: calc((100vw - 60px) / 2); +} diff --git a/sks/code/code_block.py b/sks/code/code_block.py index 045083a..c70b313 100644 --- a/sks/code/code_block.py +++ b/sks/code/code_block.py @@ -1,30 +1,20 @@ -if __name__ == "__main__": - import sys - sys.path.append("../kutil") - sys.path.append("../target") +# if __name__ == "__main__": +# import sys +# sys.path.append("../kutil") +# sys.path.append("../target") -import sys import re import klammer_base -import kutil import html_util from html_util import E import latex_util as L -import pprint -import phases + def escape_newlines(s): # A newline becomes the marker the html paragraph pass turns back into a # line break, so a verbatim source file keeps its lines. Used by Source. return re.sub("\n", " ___NL___ ", s) -def undash(s): - # Verbatim text must show the hyphens the writer typed: the target's - # "--"/"---" transforms have already run, so put them back. Used by - # Code_fragment. - result = re.sub("__MDASH__", "---", s) - return re.sub("__NDASH__", "--", result) - def is_comment(s): return s.strip().startswith("//") @@ -168,9 +158,17 @@ def html_line(text): tex_line -- a raw # in "#include" would start a text removal). The html entities introduce no Klammertext special, so the two passes cannot interfere. No target :escape entries apply here, so each - quoted special decodes back to its own character.""" - return quote_specials(html_escape_rgx.sub( - lambda m: html_escapes[m.group()], text)) + quoted special decodes back to its own character. + + Last, the typographically active characters are hidden as KTESC + markers (klammer_base.hide_typographic): the html target's transforms + run over the final result, and without this a "--check" in a listing + became an en-dash. The markers decode after the transform pass. + tex_line needs no such step -- the tex target declares no typographic + transforms (LaTeX applies its input conventions itself), and its ~ is + this code's own markup for a preserved space.""" + return klammer_base.hide_typographic(quote_specials(html_escape_rgx.sub( + lambda m: html_escapes[m.group()], text))) def html_block(code, comment): r"""One block: its lines beside its comment. @@ -192,11 +190,25 @@ def html_block(code, comment): return f'
{result}
\n' +def expand_whitespace_markers(text, K=None): + def count(count_match): + count = int(count_match) if count_match else 1 + return count + def replace_spaces(match): + return " " * count(match.group(1)) + def replace_newlines(match): + return "\n" * count(match.group(1)) + result = text + result = re.compile(r" *#- *", re.S).sub("", result) + result = re.compile(r" *#\+(\d*) *", re.S).sub(replace_spaces, result) + result = re.compile(r"\s*\#\/(\d*)\s*", re.S).sub(replace_newlines, result) + return result + class Code(klammer_base.Klammer_base): id = 0 def __init__(self, K): super().__init__(K) - self.text = phases.expand_whitespace_markers(self.text) + self.text = expand_whitespace_markers(self.text) def annotated(self, pairs): """Does any block of this listing carry a comment?""" @@ -332,7 +344,7 @@ class Code_fragment(klammer_base.Klammer_base): # ordinary string and the machine did the escaping; html() got away with # handling "<" by hand and tex() with nothing at all. def html(self): - return f'{html_line(undash(self.code_text.strip()))}' + return f'{html_line(self.code_text.strip())}' def tex(self): return f"{{\\tt {tex_line(self.code_text.strip())}}}" @@ -373,7 +385,7 @@ def extract_marked_region(src, marker, filename): class Source(Code): - """@source_listing -- a Code listing whose text comes from a FILE. + r"""@source_listing -- a Code listing whose text comes from a FILE. It IS a Code: @source_listing and @code differ only in where the text comes from, so they must render identically, and subclassing is what @@ -406,7 +418,7 @@ class Source(Code): except OSError as e: raise Exception( f'Cannot read the source listing "{self.filename}": {e.strerror}.\n' - f' A relative name resolves against the DOCUMENT\'s directory.') + f' A relative name resolves against the document\'s directory.') if self.marker: text = extract_marked_region(text, self.marker, self.filename) self.text = text diff --git a/sks/code/code_format.py b/sks/code/code_format.py deleted file mode 100644 index 1f4f034..0000000 --- a/sks/code/code_format.py +++ /dev/null @@ -1,403 +0,0 @@ -print("DEPRECATED") - -if __name__ == "__main__": - import sys - sys.path.append("../kutil") - sys.path.append("../target") - -import re -import klammer_base -import kutil -import html_util -from html_util import E -import latex_util as L -import pprint -import phases - -def escape_newlines(s): - return re.sub("\n", " ___NL___ ", s) - -# def literal_newline(s): -# def replace(match): -# before, after = match.groups() -# return f"{before}\\n{after}" -# backslash_pat = re.compile(r'(".*?)\n(.*?")', re.S) -# return backslash_pat.sub(replace, s) - -def is_comment(s): - return s.strip().startswith("//") - -def split_blocks(s): - print('-'*40) - print(s) - print('-'*40) - blocks = [] - in_code = True - block = "" - for line in s.rstrip().split("\n"): - if is_comment(line): - if in_code: - blocks.append(block) - block = line + "\n" - in_code = False - else: - block += line + "\n" - else: - if not in_code: - blocks.append(block) - block = line + "\n" - in_code = True - else: - block += line + "\n" - if block: - blocks.append(block) - for block in blocks: - print("B:") - print(block) - return blocks - -def parse_blocks(blocks): - box_comment_rgx = re.compile("\s*//(\d+)\s+.*", re.S) - i = 0 - while i < len(blocks): - match = box_comment_rgx.match(blocks[i]) - if match: - print(match.group(1)) - i += 1 - - - -def get_blocks(s): - comment_pat = re.compile(r"(\s*)//(\d+)\s+(.*)", re.S) - blocks = [] - lines = s.strip("\n").split("\n") - i = 0 - uncommented = "" - while i < len(lines): - match = comment_pat.match(lines[i]) - if match: - if uncommented: - blocks.append([uncommented.rstrip(), None]) - uncommented = "" - count = int(match.group(2)) - comment = match.group(3) - code = "" - j = 0 - i += 1 - while j < count: - line = re.sub("\n", "\\n", lines[i]) - code += line + "\n" - j += 1 - i += 1 - blocks.append([code.strip("\n"), comment]) - else: - uncommented += lines[i] + "\n" - i += 1 - if uncommented: - blocks.append([uncommented.rstrip(), None]) - return blocks - -def latex_spaces(s): - def replace(match): - s = match.group(0) - if False and len(s) == 1: - return "~" - else: - result = "~" * len(s) - result = f"\\hphantom{{{result}}}" - return result - space_pat = re.compile(" +", re.S) - return space_pat.sub(replace, s) - -def latex_unquote(s): - quoted = "asciicircum quotesingle asciigrave asciitilde asciitilde backslash".split() - quoted = [f"{{}}\text{e}{{}}" for e in quoted] - result = s - for q in quoted: - result = re.sub(q, "X", result) - result = re.sub(" ", "Y", result) - return result - -def longest_line(s): - result = "" - for line in latex_unquote(s).split("\n"): - if len(line) > len(result): - result = line - return result - -def literal_newline(s): - def replace(match): - before, after = match.groups() - return f"{before}\\n{after}" - backslash_pat = re.compile(r'(".*?)\n(.*?")', re.S) - return backslash_pat.sub(replace, s) - -class Code(klammer_base.Klammer_base): - id = 0 - def __init__(self, K): - super().__init__(K) - self.text = phases.expand_whitespace_markers(self.text) - - def html(self): - if self.K_target == "html": - self.text = literal_newline(self.text) - # Escape Klammertext special characters so they survive - # re-insertion into the katom stream after @eval - self.text = self.text.replace("^", "^^") - self.text = self.text.replace("#", "^#") - self.text = self.text.replace("@", "^@") - self.text = self.text.replace("|", "^|") - self.blocks = get_blocks(self.text) - result = '' - for text, comment in self.blocks: - border = "code_border" if comment else "code_no_border" - body = E("div").body(text).cls(f"code_text {border}").str(None) - if comment: - body += "\n" + E("div").body(comment).cls("code_comment").str() - result += E("div").body(body).cls("code_block").str() - if self.number or self.caption: - #result = html_util.add_caption( - # result, "Listing", self.number, self.caption, "i", "left", "top") - caption = kutil.caption_marker("Listing", self.caption) - result = f'
{caption}
{result}\n' - return result - - @staticmethod - def escape_latex(s): - """Escape LaTeX special characters in code text.""" - # Backslash must be first (before adding more backslashes) - s = s.replace("\\", "\\textbackslash{}") - s = s.replace("{", "\\{") - s = s.replace("}", "\\}") - s = s.replace("%", "\\%") - s = s.replace("$", "\\$") - s = s.replace("&", "\\&") - s = s.replace("_", "\\_") - s = s.replace("^", "\\textasciicircum{}") - s = s.replace("~", "\\textasciitilde{}") - s = s.replace("<", "\\textless{}") - s = s.replace(">", "\\textgreater{}") - return s - - def code_box(self, text): - kutil.msg(text) - result = "" - for line in text.rstrip().split("\n"): - indent = len(line) - len(line.lstrip()) - eline = ("~" * indent) + line[indent:] - print(indent, line) - print(eline) - result += eline + "\\\\\n" - result = result[:-3] - print("RESULT:") - print(result) - return result - - def tex(self): - parse_blocks(split_blocks(self.text)) - return "" - - - - - # Escape Klammertext special characters - self.text = self.text.replace("^", "^^") - self.text = self.text.replace("#", "^#") - self.text = self.text.replace("@", "^@") - self.text = self.text.replace("|", "^|") - # Escape LaTeX special characters in code text - self.text = Code.escape_latex(self.text) - self.blocks = get_blocks(self.text) - strutvis = "0pt" - start_strut = f"\\rule[0pt]{{{strutvis}}}{{12pt}}" - end_strut = f"\\rule[-6pt]{{{strutvis}}}{{12pt}}" - caption_strut = f"\\rule[-8pt]{{{strutvis}}}{{6pt}}" - indent = "8pt" - comment_sep = "10pt" - i = 0 - result = "" - count = len(self.blocks) - for text, comment in self.blocks: - print("TEXT:") - print(text) - print("COMMENT:") - print(comment) - - - - - text = " " + re.sub("\n", " \n ", text) + " " - longest = longest_line(text) - text = latex_spaces(text) - text = re.sub("\n", r"\\\\", text) - text = f"{start_strut}\\ttfamily {text}{end_strut}" - width = f"\\widthof{{\\ttfamily {longest}}}" - code = L.environment("minipage", text, width) + "\\\\\n" - print(code) - - #code = "\\asymbox{" + self.code_box(text) + "}" - #print(code) - - code = text - - if comment: - #width = f"\\linewidth - {width} - {indent} - {comment_sep}" - """ - width = f"\\linewidth - \widestline - {indent} - {comment_sep}" - code = f"\\fcolorbox{{Gray}}{{LightGray}}{{{code}}}" - code += f"\\rule{{{comment_sep}}}{{{strutvis}}}" \ - + L.environment("minipage", "\\sffamily\\small\\raggedright " + comment, width) - """ - code = "\\asymbox{" + self.code_box(text) + "}" + comment - - result += f"\\rule{{{indent}}}{{{strutvis}}}{code}" - if comment: - if i < count - 1 and self.blocks[i+1][1]: - result += "\\\\[4pt]" - i += 1 - if not self.blocks[count-1][1]: - result = result[:-4] - if self.number or self.caption: - caption = kutil.caption_marker("Listing", self.caption) - if self.blocks[0][1]: - caption += caption_strut - strut = f"\\rule{{{indent}}}{{{strutvis}}}" - result = f"{strut}\\emph{{\\it {caption}}}\\newline\n" + result + "\n" - result = f"\\hypertarget{{Reference-Listing-{Code.id}}}{{}}\n{result}" - Code.id += 1 - return result - -def undash(s): - result = s - result = re.sub("__MDASH__", "---", result) - result = re.sub("__NDASH__", "--", result) - return result - -class Code_fragment(klammer_base.Klammer_base): - def __init__(self, K): - super().__init__(K) - - def html(self): - #print(f"code: |{self.code_text}|") - result = self.code_text.strip() - result = undash(result) - #result = re.escape(result) - result = re.sub("<", "<", result) - result = re.sub(" ", " ", result) - #print(f"code: |{self.code_text}| -> |{result}|") - return f'{result}' - - def tex(self): - return f"{{\\tt {self.code_text.strip()}}}" - -def show(s): - print("-"*80) - print(s) - print("-"*80) - -class Source(klammer_base.Klammer_base): - def __init__(self, K): - super().__init__(K) - with open(self.filename) as fp: - self.src = fp.read() - - def tex(self): - result = self.src - # result = re.sub("#", "^#", result) - # result = re.sub("\\^", "\\^", result) - result = f"\\begin{{verbatim}}\n{result}\n\\end{{verbatim}}\n" - return result - - def html(self): - result = escape_newlines(self.src.strip()) + "\n" - result = re.sub("@", "^@", result) - result = E("div").body(result).cls("code_text").str() - return result - -# -------------------------------------------------------------------------------- - -if __name__ == "__main__": - s = """ -int main(int argc, char* argv[]) -{ - //1 One line commented - int count = 12; - //3 Two lines commented - for (int i = 0; i < count; i++) { - std::cout << "Counter: " << i << "\n"; - } - //1 A really long comment for one line. A really long comment for one line. A really long comment for one line. - std::cout << "End\n"; -} -""" - get_blocks(s); - - -# class Code(klammer_base.Klammer_base): -# def __init__(self, K): -# super().__init__(K) -# #self.show("Code") -# if self.filename and self.text: -# raise Exception("Both :text and :filename cannot be defined") -# if K.filename: -# with open(K.filename) as fp: -# self.src = fp.read() -# if K.pattern: -# rgx = re.compile(f".*?({K.pattern}).*", re.S) -# match = rgx.match(self.src) -# if match is None: -# raise Exception(f"Match fails for @source_code: {K.pattern}") -# self.src = match.group(1) -# self.src = kutil.protect_klammertext_special_characters(self.src) -# else: -# self.src = self.text - -# def html(self): -# #src = re.sub("\n", "", self.src) ? -# #result = f'
\n{self.src}\n
\n' -# result = self.src -# result = undash(result) -# result = f'
\n{result}\n
\n' -# return result - -# def tex(self): -# src = self.src -# src = re.sub(r"\\{", "{", src) -# src = re.sub(r"\\}", "}", src) -# result = f"\\begin{{lstlisting}}\n{src}\n\\end{{lstlisting}}\n" -# return result - -# def txt(self): -# return "x~ " + self.src - -# class Pathname(klammer_base.Klammer_base): -# def __init__(self, K): -# super().__init__(K) - -# def html(self): -# return f'{self.s}' - -# def tex(self): -# result = self.s -# def replace(match): -# return '\\{}'.format(match.group(1)) -# result = re.sub(r'\\', 'XXXBACKSLASHXXX', result) -# result = re.compile('\s*__UNSPACE__\s*', re.S).sub('', result) -# result = re.compile(r'([&${}%#_])').sub(replace, result) -# result = re.sub('\^', r'\\^{}', result) -# result = re.sub('~', r'\\~{}', result) -# result = re.sub(r'XXXBACKSLASHXXX', r'{\\textbackslash}', result) -# result = re.sub('\n', r'~\\\\\n', result.strip()) -# result = re.sub(' ', '$~$', result) -# result = re.sub("'", r"{\\textquotesingle}", result) -# result = re.sub('"', r'{\\textquotedbl}', result) -# result = re.sub('--', '{-}{-}', result) -# result = r'{{\normalfont\texttt{{{}}}}}'.format(result.strip()) -# result = re.sub(r'\{\\textbackslash\}\\#', '\\#', result) -# if self.small: -# result = '{{\\footnotesize{}}}'.format(result) -# return result - -# def txt(self): -# return f"'{self.s}'" - diff --git a/sks/color/color.py b/sks/color/color.py index 71d6ac6..d16f0be 100644 --- a/sks/color/color.py +++ b/sks/color/color.py @@ -25,13 +25,3 @@ class Color(klammer_base.Klammer_base): if self.text: result = '{{{} {}}}'.format(result, self.text) return result - -def hex_color(name, hex): - def c(h): - return float(eval('0x' + h)) / 255.0 - r = c(hex[:2]) - g = c(hex[2:4]) - b = c(hex[4:]) - return '\\definecolor{{{}}}{{rgb}}{{{:.3f},{:.3f},{:.3f}}}'.format( - name, r, g, b) - diff --git a/sks/document/document.cpp b/sks/document/document.cpp index 3b0650f..5a728f0 100644 --- a/sks/document/document.cpp +++ b/sks/document/document.cpp @@ -101,7 +101,7 @@ std::string tex_to_pdf(Machine& machine) ss << " " << line << "\n"; } ss << "Check log file: " << outbase << ".log"; - throw Definition_error(ss.str(), Locator(), false); + throw Definition_error(ss.str(), Locator()); } if (std::regex_search(xelatex_log, std::regex("Package rerunfilecheck Warning:"))) { (void)K::log(1, "Rerunning xelatex because document structure has changed"); @@ -115,7 +115,7 @@ std::string tex_to_pdf(Machine& machine) ss << " " << line << "\n"; } ss << "Check log file: " << outbase << ".log"; - throw Definition_error(ss.str(), Locator(), false); + throw Definition_error(ss.str(), Locator()); } } warn_wide_tables(xelatex_log); diff --git a/sks/document/document.k b/sks/document/document.k index d511a45..8d50b88 100644 --- a/sks/document/document.k +++ b/sks/document/document.k @@ -63,3 +63,20 @@ @@ @@document.html,tex :: @eval :cpp *KLAMMERTEXT_HOME*/sks/document/document document @ @@ + +# The following definition of @document.txt is not adequate (it ignores :files, +# for example), but it enables tests of other txt klammers for now. When one of +# the arguments is empty, the justifcation postprocess should remove multiple +# lines, but __VSPACE__ inserts a space character, which prevents the lines +# removal from the justification function. You can also see the extra space +# before the *subtitle* value. + +@@document.txt :: +@eval "*title*".upper() @ @nl@ +*subtitle* + +*author* @nl@ +*date* + +*text* +@@ diff --git a/sks/document/document_class.cpp b/sks/document/document_class.cpp index f52c493..e21bc9a 100644 --- a/sks/document/document_class.cpp +++ b/sks/document/document_class.cpp @@ -16,9 +16,9 @@ bool strbool(const std::string& s, const Locator& loc) std::vector values = {"false", "False", "0", "true", "True", "1"}; if (is_not_in(s, values)) { std::stringstream ss {}; - ss << "The value \"" << s << "\" is not a Boolean values. Possible values are:\n" + ss << "The value \"" << s << "\" is not a Boolean value. Possible values are:\n " << join(values, ", "); - throw Argument_error(ss.str(), loc, false); + throw Argument_error(ss.str(), loc); } bool result = (find(values.begin(), values.end(), s) - values.begin()) > 2; return result; @@ -51,10 +51,15 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine) // spaces, ~ expansion (resolve_filename_list in mac/file.cpp); the // existence checks resolve relative names against the input directory, // as parse_input_filename() will. - m_files = resolve_filename_list(get("files"), get("K_input_dir")); + // Filename-bearing values arrive ESCAPED for the target (the state + // stores escaped values so they flow correctly into output); a filename + // is programmatic use, so decode the markers first -- the C++ mirror of + // the Python-side unescape_ktesc() rule. Found 2026-08-22: under tex, + // ":files my_chapter" searched for "myKTESC005fKTESCchapter". + m_files = resolve_filename_list(ktesc_resolve(get("files")), get("K_input_dir")); m_css_text = get("css_text"); - m_css_filenames = resolve_filename_list(get("css_files"), get("K_input_dir")); + m_css_filenames = resolve_filename_list(ktesc_resolve(get("css_files")), get("K_input_dir")); m_include_sks_css = strbool(get("include_sks_css"), loc); frame_background_color = get("frame_background_color"); frame_text_color = get("frame_text_color"); @@ -62,7 +67,7 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine) nav_text_color = get("nav_text_color"); js_text = get("js_text"); - m_js_filenames = resolve_filename_list(get("js_files"), get("K_input_dir")); + m_js_filenames = resolve_filename_list(ktesc_resolve(get("js_files")), get("K_input_dir")); m_include_sks_js = strbool(get("include_sks_js"), loc); // font_dirs = word_split(get("font_dirs")); @@ -138,33 +143,49 @@ void Document_class::save_string_input_as_file() fs::path parse_input_filename(const std::string& s, const std::string& input_dir) { + // The two-class rule (2026-08-22), replacing a three-stage search whose + // second stage could quietly shadow a file beside the document: + // + // * a BARE WORD -- no directory separator, no extension -- is the kt/ + // SHORTCUT: ":files X" MEANS kt/X.kt in the root file's directory, + // and nothing else. Missing is an immediate error whose message + // teaches the convention, not a fallback. The kt/ directory is the + // conventional home for a document's input files, and putting the + // meaning entirely in the name lets several root documents share it. + // + // * anything else is a real PATHNAME, absolute or resolved against the + // input file's directory (K_input_dir), so a document renders + // identically wherever ktext is run from. + // + // Which file a name landed on is a DERIVED value, so "-v 1" reports it. fs::path p(s); + bool bare = s.find('/') == std::string::npos && p.extension().empty(); + if (bare) { + fs::path in_kt = fs::path(input_dir) / "kt" / (s + ".kt"); + if (!file_exists(in_kt.string())) { + throw Argument_error( + "The \":files\" name \"" + s + "\" is a bare word, which by " + "convention means the file kt/" + s + ".kt in the root " + "document's directory:\n" + " " + in_kt.string() + "\n" + "That file does not exist. Create it there, or write a real " + "pathname (a name with a directory or the \".kt\" extension) " + "to use a file elsewhere.", + Locator::none()); + } + // The resolved path itself shows kt/ -- no label needed. + (void)K::log(1, "Input file \"" + s + "\": " + in_kt.string()); + return in_kt; + } if (p.extension() != ".kt") { p += ".kt"; } if (p.is_absolute()) { return p; } - // A relative :files name resolves against the input file's directory - // (K_input_dir), so a document renders identically wherever ktext is - // run from; then the legacy kt/ subdirectory; a name found in neither - // is returned as given (cwd-relative) and errors downstream. - // Which of the three a name landed on is a DERIVED value -- the ".kt" may - // have been supplied, and the directory certainly was -- so "-v 1" reports - // it. A ":files chapter1" that quietly found kt/chapter1.kt rather than - // the file beside the document is exactly what the author cannot see. fs::path in_input_dir = fs::path(input_dir) / p; - if (file_exists(in_input_dir.string())) { - (void)K::log(1, "Input file \"" + s + "\": " + in_input_dir.string()); - return in_input_dir; - } - fs::path in_kt_dir = fs::path(input_dir) / "kt" / p; - if (file_exists(in_kt_dir.string())) { - (void)K::log(1, "Input file \"" + s + "\": " + in_kt_dir.string() - + " (found in the kt/ subdirectory)"); - return in_kt_dir; - } - return p; + (void)K::log(1, "Input file \"" + s + "\": " + in_input_dir.string()); + return in_input_dir; } void Document_class::write(const std::string& filename, const std::string& contents) diff --git a/sks/document/document_html.cpp b/sks/document/document_html.cpp index 0209fbb..5567cb6 100644 --- a/sks/document/document_html.cpp +++ b/sks/document/document_html.cpp @@ -113,38 +113,14 @@ std::string Document_class::font_definitions() ss << " --monospace: \"" << m_mono_font << "\", monospace;\n"; ss << "}\n"; } - // Emit scale factors so sans and mono fonts match the serif font. - // Three scaling methods (uncomment the desired one): - // x-height: serif_xh / other_xh (matches lowercase, like fontspec MatchLowercase) - // cap-height: serif_ch / other_ch (matches capitals) - // average: mean(serif_xh,serif_ch) / mean(other_xh,other_ch) (compromise) - float serif_xh = m_resolved_serif.xheight_ratio; - float serif_ch = m_resolved_serif.capheight_ratio; - float serif_avg = (serif_xh + serif_ch) / 2.0f; - if (serif_avg > 0.0f) { - auto scale = [&](const Resolved_font& other) -> std::string { - float other_avg = (other.xheight_ratio + other.capheight_ratio) / 2.0f; - if (other_avg > 0.0f && other_avg != serif_avg) { - char buf[16]; - // float ratio = serif_xh / other.xheight_ratio; // x-height - // float ratio = serif_ch / other.capheight_ratio; // cap-height - float ratio = serif_avg / other_avg; // average - std::snprintf(buf, sizeof(buf), "%.4f", ratio); - return buf; - } - return ""; - }; - std::string sans_scale = scale(m_resolved_sans); - std::string mono_scale = scale(m_resolved_mono); - if (!sans_scale.empty() || !mono_scale.empty()) { - ss << ":root {\n"; - if (!sans_scale.empty()) - ss << " --sans-serif-scale: " << sans_scale << ";\n"; - if (!mono_scale.empty()) - ss << " --monospace-scale: " << mono_scale << ";\n"; - ss << "}\n"; - } - } + // Font-size normalization across families is no longer emitted from + // here (2026-08-22): font.css declares "font-size-adjust: ex-height 0.5" + // on body, and the browser renders every font at the same x-height -- + // the same computation the former --sans-serif-scale/--monospace-scale + // factors did from build-time OS/2 metrics, but applied to EVERY family + // switch instead of the four CSS sites that remembered to multiply. + // The metric extraction in the font store remains (the tex path and + // kdesc --font still use it). // Global font scale: applied to body font-size if (m_font_scale != 1.0f) { char buf[16]; @@ -368,7 +344,15 @@ Document_class::insert_section_numbers(const std::string& marker, bool add_to_to { std::vector levels(9, 0); std::smatch match {}; - std::string pattern = R"(<([\w-]+)\s*(.*?)>(.*?)MARKER\s*\s*(.*?)<.*)"; + // The heading text (group 4) runs to the heading element's OWN closing + // tag (the \1 backreference), not to the first "<": a nested element in + // a section title -- @c's , an @i's -- would + // otherwise end the capture early and silently truncate the title in + // BOTH tables of contents (article's single_page_toc and book's + // navigation TOC read the same capture). Found 2026-08-22 via a @c in + // an @s2 title; heading.cpp's section_rgx already used the + // close-on-own-tag idiom. + std::string pattern = R"(<([\w-]+)\s*(.*?)>(.*?)MARKER\s*\s*(.*?).*)"; pattern = string_replace(pattern, "MARKER", marker); std::regex heading_rgx(pattern); std::vector> modified_components {}; diff --git a/sks/document/heading.cpp b/sks/document/heading.cpp index 79507d7..8c0f6fe 100644 --- a/sks/document/heading.cpp +++ b/sks/document/heading.cpp @@ -38,104 +38,6 @@ std::string levels_to_section(std::vector levels) int part_number = 1; int unnumbered_id = 0; -/* -std::tuple, std::vector, unsigned int> -add_section_numbers(const std::string& s, const std::string& basename, std::vector levels, unsigned int initial_id, - std::map& section_id_map) -{ - (void)K::log(3); - auto depth { levels.size() }; - std::vector headings {}; - std::string text { s }; - std::regex section_rgx (R"((.*?)(.*?))"); - std::regex part_rgx (R"((.*?)Part (\d+)\s*
\s*(.*?)\s*)"); - std::regex id_rgx(R"((.*?)id=\"([-\w]+)\"(.*))"); - - unsigned int id_number { initial_id }; - //std::string result {}; - std::stringstream result {}; - for (std::string line : regex_split(s, std::regex(R"(\n)"), false)) { - std::smatch match {}; - if (std::regex_match(line, match, part_rgx)) { - std::string pre { match[1] }; - std::string attr { match[2] }; - std::string level { match[3] }; - std::string title { match[4] }; - - std::string id_prefix { "_part_"}; - std::string id {}; - std::smatch id_match {}; - if (std::regex_match(attr, id_match, id_rgx)) { - id = id_match[2]; - } else { - id = id_prefix + std::to_string(part_number); - } - //title = "Part " + std::to_string(part_number) + " - " + title; - //std::string section = "Part " + level; - std::string section = "Part " + std::to_string(part_number); - elements_t part_title - { html::elt("kt-part", - { html::elt("span", section).attr("class", "sectionnumber"), - html::elt("span", trim(title)).attr("class", "sectiontitle") }) - .attr("id", id) - .attr("data-level", level) }; - result << pre << part_title << "\n"; - headings.push_back(Heading(0, basename, section, id, "0", title)); - part_number += 1; - } else if (std::regex_match(line, match, section_rgx)) { - std::string pre { match[1] }; - int level { std::stoi(match[2]) }; - std::string attr { match[3] }; - std::string title { match[4] }; - bool numbered = attr.find("numbered") != std::string::npos; - std::string section {}; - if (numbered) { - levels[level-1] = levels[level-1] + 1; - for (unsigned int li = level; li < depth; li++) - levels[li] = 0; - section = levels_to_section(levels); - } - std::string id {}; - std::smatch id_match {}; - if (std::regex_match(attr, id_match, id_rgx)) { - id = id_match[2]; - } else { - if (section.size() == 0) { - id = "_su_" + std::to_string(unnumbered_id++); - } else { - id = "_s_" + string_replace(section, ".", "_"); - } - } - std::string link_target = file_basename(basename) + link_delimiter + id; - section_id_map[title] = link_target; - - //

- // 1Image tests

- - elements_t title_parts {}; - if (numbered) { - title_parts.push_back(html::elt("span", trim(section)).attr("class", "sectionnumber")); - } - title_parts.push_back(html::elt("span", trim(title)).attr("class", "sectiontitle")); - - // msg() << "title_parts: " << title_parts << "\n"; - elements_t section_title - { html::elt("h"+std::to_string(level), title_parts) - .attr("id", id) - .attr("data-level", std::to_string(level))}; - // msg() << pre << section_title << "\n"; - result << pre << section_title << "\n"; - headings.push_back(Heading(level, basename, section, id, "0", title)); - } else { - result << line << "\n"; - } - } - //std::cout << "add_section_numbers: " << result.str() << " " - //<< headings.size() << " " << levels.size() << " " << id_number << "\n"; - return std::tuple(result.str(), headings, levels, id_number); -} -*/ - std::string make_html_table_of_contents(std::vector headings) { elements_t toc { html::elt("h1", "Contents") }; diff --git a/sks/font/css/font.css b/sks/font/css/font.css index 0bb694a..f0e5192 100644 --- a/sks/font/css/font.css +++ b/sks/font/css/font.css @@ -4,18 +4,23 @@ --serif: Libre Baskerville, serif; --sans-serif: Open Sans, sans-serif; --monospace: Inconsolata, monospace; - --sans-serif-scale: 1; - --monospace-scale: 1; } body { font-family: var(--serif); font-size: 1rem; + /* One normalization for every family switch: render each font so its + x-height is 0.5em (the design's anchor -- EB Garamond comes UP ~19%, + Open Sans down ~7%). Inherited, so headings, code, footnotes and any + future rule are covered with no per-site scale factors; this replaced + the --sans-serif-scale/--monospace-scale system (build-time metrics, + applied at four sites and silently missed everywhere else). The tex + target's equivalent is fontspec's MatchLowercase. */ + font-size-adjust: ex-height 0.5; } tt, .tt, pre { font-family: var(--monospace); - font-size: calc(1em * var(--monospace-scale)); } h1, h2, h3, h4, h5 { @@ -23,16 +28,11 @@ h1, h2, h3, h4, h5 { font-weight: normal; } -h1 { - font-size: calc(1.2rem * var(--sans-serif-scale)); -} - -h2, h3, h4, h5 { - font-size: calc(1.1rem * var(--sans-serif-scale)); -} +/* Heading SIZES live in sks/section/css/section.css (one ladder, one + place); this file assigns only the family. */ p { - line-height: 1.3; + line-height: 1.5; } .plain { @@ -50,7 +50,6 @@ p { .sansserif { font-family: var(--sans-serif); - font-size: calc(1em * var(--sans-serif-scale)); } .ritalic { diff --git a/sks/font/font.py b/sks/font/font.py index 9b8ec83..919b312 100644 --- a/sks/font/font.py +++ b/sks/font/font.py @@ -33,7 +33,7 @@ def html_fontify(text, font_symbol, font_size): result = text if cls: if font_symbol == "c": - result = re.sub(" ", " ", result) + result = re.sub(" ", " ", result) cls = f'class="{cls}"' sty = "" if font_size != 1: diff --git a/sks/form/page.k b/sks/form/page.k index bdd5387..8365278 100644 --- a/sks/form/page.k +++ b/sks/form/page.k @@ -1,7 +1,7 @@ @@page.html body :title : - + *title* \n\n\n{body}\n\n') diff --git a/tst/Makefile b/tst/Makefile index 5d3454f..308bca7 100644 --- a/tst/Makefile +++ b/tst/Makefile @@ -14,8 +14,12 @@ test: ./check_test.sh ./deftype_test.sh ./escape_test.sh + ./transform_test.sh + ./character_test.sh ./filename_test.sh ./alone_test.sh + ./state_test.sh + ./target_test.sh ./modulepath_test.sh ./klammerset_test.sh ./option_set_test.sh diff --git a/tst/alone_test.sh b/tst/alone_test.sh index 4cc31ef..84a063b 100755 --- a/tst/alone_test.sh +++ b/tst/alone_test.sh @@ -87,7 +87,7 @@ check_error() { FAIL=$((FAIL + 1)) return fi - if echo "$output" | grep -qF "$pattern"; then + if echo "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else @@ -233,6 +233,14 @@ check_eq \ "[]" \ --klammersets none -s '@@s :t.word : [*t*] @@ @s :t @' -d +echo +# --- The pattern itself is validated at definition time (gallery review) --- + +check_error \ + "22. an invalid :pattern is a definition-time error" \ + "is not a valid regular expression" \ + --klammersets none -s '@@@argtype bad | d :pattern ([ @@@' -d + echo echo "================================" echo "Passed: $PASS Failed: $FAIL" diff --git a/tst/character_test.sh b/tst/character_test.sh new file mode 100755 index 0000000..d7d725d --- /dev/null +++ b/tst/character_test.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# +# character_test.sh — Regression tests for the ^-character forms the engine +# resolves at read time: the ^UUUU^ Unicode code point (4-6 hex digits, +# since 2026-08-23 covering the full range to U+10FFFF), and the character +# tables (mnemonics, diacritics) it must not disturb. +# +# SKS-independent: character handling is katomizer/engine machinery +# (mac/ktype.h nonascii katom, mac/character.cpp), so everything runs with +# --klammersets none under the default (general) target. +# +# The 2026-08-23 changes these cases pin: +# - a 5-digit code point converts WHOLE: ^13000^ (EGYPTIAN HIEROGLYPH +# A001) rendered as U+1300 followed by a literal "0" before, because +# process_unicode_codepoint() re-scanned its capture at a fixed 4-digit +# width; +# - 6 digits are accepted (^10FFFD^ is the top of the range; the katom +# regex capped at {1,5}); +# - a value above U+10FFFF renders as the "Invalid Unicode" text, the +# same treatment as a surrogate. +# +# Note: expected strings use $'\xHH' UTF-8 BYTE escapes, not $'\U...' code +# points -- macOS ships bash 3.2, which does not expand \U (it failed there +# exactly as a naive check would). +# +# Usage: ./character_test.sh (needs KLAMMERTEXT_HOME set; ktext on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +KTEXT=ktext +K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} +ERR=$(mktemp) + +red=$'\033[31m' +green=$'\033[32m' +bold=$'\033[1m' +reset=$'\033[0m' + +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 NAME EXPECTED INPUT — render INPUT (no klammer set, default +# target); exit 0 and stdout == EXPECTED. +check() { + local name="$1" expected="$2" input="$3" + local out status + out=$("$KTEXT" --klammersets none -s "$input" -d 2>"$ERR"); status=$? + out=$(printf '%s' "$out" | trim) + if [ $status -ne 0 ]; then + echo "${red}FAIL${reset} $name — ktext exited $status" + head -3 "$ERR" | sed 's/^/ /'; FAIL=$((FAIL+1)); return + fi + 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}Character form tests (engine: ^UUUU^ code points and the tables)${reset}" +echo "=================================================================" +echo + +check " 1. 4-digit BMP code point" $'A ☺ B' 'A ^263A^ B' +check " 2. 5-digit code point, whole" $'A \xf0\x93\x80\x80 B' 'A ^13000^ B' +check " 3. 5-digit emoji" $'\xf0\x9f\x98\x80' '^1F600^' +check " 4. 6-digit top of the range" $'\xf4\x8f\xbf\xbd' '^10FFFD^' +check " 5. above U+10FFFF is invalid" 'Invalid Unicode: 16777215' '^FFFFFF^' +check " 6. surrogate is invalid" 'Invalid Unicode: 55296' '^D800^' +check " 7. mnemonic undisturbed" 'ß' '^s^' +# The diacritic tables compose base + COMBINING mark (a U+0308), not the +# precomposed letter -- the expected string is built the same way. +check " 8. diacritic undisturbed" $'Ma\xcc\x88dchen' 'M^a"dchen' +check " 9. code point beside quoted punctuation" $'☺ --' '^263A^ ^-^-' + +echo +echo "=================================================================" +echo "${bold}Results: $PASS passed, $FAIL failed${reset}" +rm -f "$ERR" +[ $FAIL -eq 0 ] && exit 0 || exit 1 diff --git a/tst/command_option_test.sh b/tst/command_option_test.sh index 3358227..a3161d9 100755 --- a/tst/command_option_test.sh +++ b/tst/command_option_test.sh @@ -49,7 +49,7 @@ error() { if [ $status -eq 0 ]; then echo "${red}FAIL${reset} $name — reported an error but exited 0"; FAIL=$((FAIL+1)); return fi - if printf '%s' "$out" | grep -qF -- "$pattern"; then + if printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF -- "$pattern"; then echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) else echo "${red}FAIL${reset} $name — expected [$pattern]" @@ -83,6 +83,20 @@ error " 4. ktext -t" "needs a value" ktext -s '@i-x' -t error " 5. the message names the option" "-v " kdesc -v error " 6. ... and how to get the usage" "for the list of arguments" kdesc -v +echo +echo "-- an option value that fails its pattern (gallery review) --" +error "21. a non-integer verbosity is an error" "is not a verbosity level" kdesc -v abc +# The location must be absent, not a C++ source file: the default Locator +# captures the throw site (argv.cpp, line N) unless the throw says +# Locator::none() -- the leak class the error gallery found 2026-08-22. +out=$(kdesc -v abc 2>&1) +if printf '%s' "$out" | grep -q '\.cpp, line'; then + echo "${red}FAIL${reset} 22. the error leaks a C++ source location" + echo " got: $(printf '%s' "$out" | grep '\.cpp, line' | head -1)"; FAIL=$((FAIL+1)) +else + echo "${green}PASS${reset} 22. no C++ source location in the error"; PASS=$((PASS+1)) +fi + echo echo "-- the same options WITH a value still work --" # NOT "kdesc -v 1": show_usage() treats exactly " -v " as a @@ -103,7 +117,7 @@ echo "-- two required positional arguments --" if [ -x "$TSTDIR/argv_test" ]; then out=$("$TSTDIR/argv_test" first second --bool 2>&1 | sed 's/\x1b\[[0-9;]*m//g') for expect in " : first" " : second"; do - if printf '%s' "$out" | grep -qF "$expect"; then + if printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$expect"; then echo "${green}PASS${reset} 13/14. positional [$expect]"; PASS=$((PASS+1)) else echo "${red}FAIL${reset} 13/14. positional [$expect] not parsed"; FAIL=$((FAIL+1)) diff --git a/tst/cond_test.sh b/tst/cond_test.sh index 2e62e39..ffc0988 100755 --- a/tst/cond_test.sh +++ b/tst/cond_test.sh @@ -79,7 +79,7 @@ check_contains() { FAIL=$((FAIL + 1)) return fi - if echo "$output" | grep -qF "$needle"; then + if echo "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$needle"; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else @@ -105,7 +105,7 @@ check_error() { FAIL=$((FAIL + 1)) return fi - if echo "$output" | grep -qF "$pattern"; then + if echo "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else diff --git a/tst/deftype_test.sh b/tst/deftype_test.sh index 004a394..b83ea1e 100755 --- a/tst/deftype_test.sh +++ b/tst/deftype_test.sh @@ -106,7 +106,7 @@ check_error() { if [ $status -eq 0 ]; then echo "${red}FAIL${reset} $name — expected an error but ktext succeeded"; FAIL=$((FAIL+1)); return fi - if echo "$out" | grep -qF "$pattern"; then + if echo "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) else echo "${red}FAIL${reset} $name — expected error to contain [$pattern]" diff --git a/tst/editor/indent_literal_c.kt b/tst/editor/indent_literal_c.kt new file mode 100644 index 0000000..2c89632 --- /dev/null +++ b/tst/editor/indent_literal_c.kt @@ -0,0 +1,11 @@ +@document :text + @i before @c @right c@ after @ @x@ + @note + inside the note + @ + @c content with basic@ inside c@ and after + @c + kept as-is # not a comment + c@ + after everything +@ diff --git a/tst/editor/indent_literal_c_expected.kt b/tst/editor/indent_literal_c_expected.kt new file mode 100644 index 0000000..1eb4c46 --- /dev/null +++ b/tst/editor/indent_literal_c_expected.kt @@ -0,0 +1,11 @@ +@document :text +@i before @c @right c@ after @ @x@ +@note + inside the note +@ +@c content with basic@ inside c@ and after +@c + kept as-is # not a comment + c@ +after everything +@ diff --git a/tst/editor/vim_feature_test.vim b/tst/editor/vim_feature_test.vim index 52377e3..c4121c1 100644 --- a/tst/editor/vim_feature_test.vim +++ b/tst/editor/vim_feature_test.vim @@ -81,6 +81,22 @@ call s:Check('abbreviated @name-', s:Syn(9, 1) ==# 'klammertextAppOpen' \ && s:Syn(9, 6) ==# '' && s:Syn(9, 7) ==# '', s:Syn(9, 6)) call s:Check('file marker ##', s:Syn(10, 1) ==# 'klammertextMarker', s:Syn(10, 1)) call s:Check('## removes to EOF', s:Syn(11, 3) ==# 'klammertextRemovedFile', s:Syn(11, 3)) + +" @c is the single-letter literal klammer (inline @code, 2026-08-16). A +" fresh buffer: the ## in the buffer above removes to EOF. The traps a +" single-letter name adds: a word ending in c ("basic@") must not close the +" region, and "@caption" must not open one. +enew! +call setline(1, [ + \ '@c y @ basic@ z c@ tail', + \ '@caption arg @']) +set filetype=klammertext +call s:Check('c verbatim open @c', s:Syn(1, 1) ==# 'klammertextAppOpen', s:Syn(1, 1)) +call s:Check('c interior @ verbatim', s:Syn(1, 6) ==# 'klammertextVerbatim', s:Syn(1, 6)) +call s:Check('basic@ does not close @c', s:Syn(1, 13) ==# 'klammertextVerbatim', s:Syn(1, 13)) +call s:Check('real close c@', s:Syn(1, 17) ==# 'klammertextAppClose', s:Syn(1, 17)) +call s:Check('text after c@ plain', s:Syn(1, 20) ==# '', s:Syn(1, 20)) +call s:Check('@caption opens @caption, not @c', s:Syn(2, 1) ==# 'klammertextAppOpen', s:Syn(2, 1)) else call add(s:results, 'SKIP syntax checks (this Vim lacks +syntax)') endif diff --git a/tst/editor_test.sh b/tst/editor_test.sh index 11d6305..5437821 100755 --- a/tst/editor_test.sh +++ b/tst/editor_test.sh @@ -10,7 +10,7 @@ # tst/ is the SKS-independent tier, and these suites check that the # implementations of Klammertext's structure agree with the canonical # formatting conventions AND with each other. The klammer names that appear -# in the fixtures (@ol, @table, @document, @code) are seeded configuration of +# in the fixtures (@ol, @table, @document, @code, @c) are seeded configuration of # the editor tools, not SKS dependencies; nothing here runs ktext or loads a # klammer set. # @@ -60,7 +60,7 @@ OUT="$(mktemp -d)" trap 'rm -rf "$OUT"' EXIT INDENT_FIXTURES="indent_list indent_document indent_table indent_untouched - indent_defs indent_escapes indent_named_close" + indent_defs indent_escapes indent_named_close indent_literal_c" ALIGN_FIXTURES="align_mixed align_empty_cells align_boundary align_colspan align_escapes align_too_wide" diff --git a/tst/escape_test.sh b/tst/escape_test.sh index 1270a03..56954dc 100755 --- a/tst/escape_test.sh +++ b/tst/escape_test.sh @@ -183,6 +183,33 @@ check_eq "25. a real literal klammer still works after a comment naming it" \ rm -f "$ERR" +echo +echo "-- 26-29: a general body escapes only its OWN literal text, never a --" +echo "-- substituted argument value (ordering defect fixed 2026-08-19) --" +# An argument value is writer text already escaped at the top level (KTESC +# markers, idempotent) PLUS final target markup from klammers the writer +# nested in the argument. apply_klammer used to substitute values first and +# escape the general body after, so that markup was swept as if it were the +# body's literal text: "@sig @b Andy @ @" emitted BSLtextbf{Andy}. The escape +# now runs before substitution. BOLD is a target-specific nested klammer +# (case 7/9 pattern) whose raw output must survive untouched. +BOLD='@@b.k s : d @@ @@b.t :: \textbf{*s*} @@' +check_eq "26. substituted klammer output NOT re-escaped" \ + 'Sincerely, \textbf{Andy}' --klammersets none -t t \ + -s "$T $BOLD @@sig name : Sincerely, *name* @@ @sig @b Andy @ @" +check_eq "27. ... while the body's own literal & still IS escaped" \ + 'Fish AMP \textbf{Chips}' --klammersets none -t t \ + -s "$T $BOLD @@sig2 name : Fish & *name* @@ @sig2 @b Chips @ @" +check_eq "28. writer specials in the argument still escaped (markers)" \ + 'Sincerely, a AMP b' --klammersets none -t t \ + -s "$T @@sig name : Sincerely, *name* @@ @sig a & b @" +# The variable shares one text katom with literal specials on both sides: +# the katom's literal "_"s must be escaped and the value spliced in raw +# afterwards -- the ordering this section exists to pin. +check_eq "29. mixed katom: literal specials escaped, spliced value raw" \ + 'AUND\textbf{Q}UNDB' --klammersets none -t t \ + -s "$T $BOLD @@w s : A_*s*_B @@ @w @b Q @ @" + echo echo "============================================" echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}" diff --git a/tst/eval_test.sh b/tst/eval_test.sh index b22d3b1..c88b274 100755 --- a/tst/eval_test.sh +++ b/tst/eval_test.sh @@ -219,6 +219,28 @@ main = putStrLn "partial" >> exitWith (ExitFailure 3) @' fi fi +echo +echo "-- the environment can be missing: the errors name what and how --" +# Added 2026-08-22 by the error-gallery coverage review: neither message had +# ever been printed by a test. +run '@eval :cpp nonexistent_gallery_lib @' +if [ $STATUS -ne 0 ] && grep -qF "Cannot open library" "$ERRF"; then + pass "19. :cpp names the library it could not open" +else + fail "19. exit $STATUS" "$(plain < "$ERRF" | head -2)" +fi +# :haskell without runghc: strip PATH so the case is deterministic whether or +# not GHC is installed; ktext itself is invoked by absolute path (which works +# since the 2026-08-21 construct_command_pathname fix). +env PATH=/nonexistent "$K/bin/ktext" --klammersets none \ + -s '@eval :haskell main = putStrLn "x" @' -d >"$OUTF" 2>"$ERRF" +hstatus=$? +if [ $hstatus -ne 0 ] && tr '\n' ' ' < "$ERRF" | grep -qF "requires runghc"; then + pass "20. :haskell without runghc says what to install" +else + fail "20. exit $hstatus" "$(plain < "$ERRF" | head -2)" +fi + echo echo "===========" echo "Results: ${PASS} passed, ${FAIL} failed" diff --git a/tst/option_set_test.sh b/tst/option_set_test.sh index b2ef9fb..8249e23 100755 --- a/tst/option_set_test.sh +++ b/tst/option_set_test.sh @@ -84,7 +84,7 @@ $source" -t fix -d 2>&1) FAIL=$((FAIL + 1)) return fi - if printf '%s' "$output" | tr '\n' ' ' | grep -qF "$needle"; then + if printf '%s' "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$needle"; then echo "${green}PASS${reset} $name" PASS=$((PASS + 1)) else @@ -121,7 +121,7 @@ $source" -t fix -d -v 1 2>&1) FAIL=$((FAIL + 1)) return fi - if printf '%s' "$output" | tr '\n' ' ' | grep -qF "$needle"; then + if printf '%s' "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$needle"; then echo "${green}PASS${reset} $name" PASS=$((PASS + 1)) else @@ -270,7 +270,7 @@ check_fails "22. a set with no parameters is rejected" \ '@@bad.o : nothing at all @@' check_fails '23. "::" has no meaning for a set' \ - 'An option set IS a declaration' \ + 'An option set is a declaration' \ '@@cap.o :: nope @@' # --- Collisions ----------------------------------------------------------- diff --git a/tst/recursion_test.sh b/tst/recursion_test.sh index 93a7d2f..0181a64 100755 --- a/tst/recursion_test.sh +++ b/tst/recursion_test.sh @@ -55,7 +55,7 @@ check_error() { FAIL=$((FAIL + 1)) return fi - if echo "$output" | grep -qF "$pattern"; then + if echo "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else diff --git a/tst/state_test.sh b/tst/state_test.sh new file mode 100755 index 0000000..2b03e42 --- /dev/null +++ b/tst/state_test.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# +# state_test.sh — The @@@state system command and *name* substitution. +# +# Added 2026-08-22 by the error-gallery coverage review, which found that no +# suite covered @@@state at all: its two error messages had never been +# printed by any test. What is pinned here, each by outcome: +# +# * :value sets a variable and *name* substitutes it in a klammer BODY +# (bodies are processed at application time). A top-level *name* of a +# document-declared variable does NOT substitute — the same read-time +# asymmetry @cond had before 2026-08-15; if that is ever changed, case 2 +# documents today's behaviour and should change with it. +# * :replace replaces an existing value. +# * Redefining WITHOUT :replace is an error that names the remedy. +# * An undefined variable's error lists the frames it searched. +# * A shell environment variable substitutes like any state variable. +# +# NOT to be confused with "state_test", the C++ diagnostic program built from +# state_test.cpp in this directory: that one constructs engine objects and +# prints what it gets, for a person to read, and asserts nothing (see the +# `smoke` target in tst/Makefile). This is the regression suite. The ".sh" +# is the only thing distinguishing them — the same collision as +# eval_test/eval_test.sh, documented there first. +# +# Usage: ./state_test.sh (needs ktext on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +KTEXT=ktext +export KT_STATE_PROBE="probe-value" # for the environment-variable case + +red=$'\033[31m' +green=$'\033[32m' +bold=$'\033[1m' +reset=$'\033[0m' + +# check_eq NAME EXPECTED KTEXT_ARGS... — trimmed stdout equals EXPECTED. +check_eq() { + local name="$1" expected="$2" + shift 2 + local out status + out=$("$KTEXT" "$@" 2>/dev/null) + status=$? + out=$(printf '%s' "$out" | sed -e 's/[ \t]*$//' | grep -v '^$') + if [ $status -eq 0 ] && [ "$out" = "$expected" ]; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name" + echo " expected: [$expected]"; echo " got: [$out] (exit $status)" + FAIL=$((FAIL+1)) + fi +} + +# check_error NAME PATTERN KTEXT_ARGS... — nonzero exit, PATTERN in the +# message. Wrap-insensitive: error prose is justified to 80 columns, so a +# phrase may wrap anywhere. +check_error() { + local name="$1" pattern="$2" + shift 2 + local out status + out=$("$KTEXT" "$@" 2>&1) + status=$? + if [ $status -eq 0 ]; then + echo "${red}FAIL${reset} $name — expected an error but ktext succeeded" + FAIL=$((FAIL+1)); return + fi + if printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name — expected error to contain [$pattern]" + echo " got: $(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | tr '\n' ' ' | head -c 200)" + FAIL=$((FAIL+1)) + fi +} + +echo "${bold}@@@state tests${reset}" +echo "==============" +echo + +echo "-- setting and substituting --" +check_eq " 1. :value sets; *name* substitutes in a body" \ + "[hello]" \ + --klammersets none -s '@@@state V :value hello @@@ @@f : [*V*] @@ @f@' -d + +# Documents TODAY'S behaviour: a top-level *name* of a document-declared +# variable is left as written (bodies substitute; the top level does not). +check_eq " 2. a top-level *name* does not substitute (current behaviour)" \ + "[*V*]" \ + --klammersets none -s '@@@state V :value hello @@@ [*V*]' -d + +check_eq " 3. an environment variable substitutes" \ + "[probe-value]" \ + --klammersets none -s '@@f : [*KT_STATE_PROBE*] @@ @f@' -d + +echo +echo "-- replacing --" +check_eq " 4. :replace replaces an existing value" \ + "[b]" \ + --klammersets none \ + -s '@@@state V :value a @@@ @@@state V :replace b @@@ @@f : [*V*] @@ @f@' -d + +check_error " 5. redefining without :replace is an error naming the remedy" \ + "Use ':replace ' to replace the current value" \ + --klammersets none -s '@@@state V :value a @@@ @@@state V :value b @@@' -d + +echo +echo "-- the undefined variable --" +check_error " 6. an undefined *name* in a body is a located error" \ + 'Variable "Missing" not defined' \ + --klammersets none -s '@@f : [*Missing*] @@ @f@' -d + +check_error " 7. ... that lists the frames it searched" \ + "(searched:" \ + --klammersets none -s '@@f : [*Missing*] @@ @f@' -d + +echo +echo "==============" +echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}" +[ $FAIL -eq 0 ] diff --git a/tst/target_list_test.sh b/tst/target_list_test.sh index 9719531..bb1eefb 100755 --- a/tst/target_list_test.sh +++ b/tst/target_list_test.sh @@ -109,7 +109,7 @@ check_error() { if [ $status -eq 0 ]; then echo "${red}FAIL${reset} $name — expected an error but ktext succeeded"; FAIL=$((FAIL+1)); return fi - if echo "$out" | grep -qF "$pattern"; then + if echo "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) else echo "${red}FAIL${reset} $name — expected error to contain [$pattern]" diff --git a/tst/target_test.sh b/tst/target_test.sh new file mode 100755 index 0000000..cc9b935 --- /dev/null +++ b/tst/target_test.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# +# target_test.sh — The @@@target system command's :after_apply phase list. +# +# Added 2026-08-22, when the txt target became the first to declare TWO +# phases and found the list separator undocumented and unguarded: two bare +# specs written without " ; " were glued into one Python call and failed at +# render time with a SyntaxError located at "phase, line 1" rather than at +# the declaration. What is pinned here: +# +# * several phases separated by " ; " run, in order, each seeing its +# predecessor's result; +# * a bare spec containing whitespace is a DEFINITION-TIME error naming +# the " ; " convention (the guard in Target::add_after_apply); +# * a mode-tagged spec (":cpp ") is exempt from the +# whitespace guard -- its library is a filename, and filenames may +# contain spaces, which is why the separator is ";" at all. +# +# SKS-independent: --klammersets none, an inline fixture target, and a +# fixture Python module placed next to the input file (the module-resolution +# rule adds the input file's directory to sys.path). +# +# NOT to be confused with "target_test", the C++ diagnostic program built +# from target_test.cpp in this directory (asserts nothing; see the `smoke` +# target in tst/Makefile). The ".sh" is the only distinguisher -- the third +# such collision, after eval_test and state_test. +# +# Usage: ./target_test.sh (needs ktext on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +KTEXT=ktext + +red=$'\033[31m' +green=$'\033[32m' +bold=$'\033[1m' +reset=$'\033[0m' + +DIR=$(mktemp -d) +trap 'rm -rf "$DIR"' EXIT + +# Two phases that mark their passage; order is observable in the output. +cat > "$DIR/phasefix.py" <<'EOF' +def one(text, K=None): + return text + "+ONE" + +def two(text, K=None): + return text + "+TWO" + +def width(text, K=None): + # K is the state's "class K"; a fixture state variable proves the phase + # receives it (attributes arrive as strings unless the argtype is typed). + return text + "|" + str(K.Fixture_width) +EOF + +check_eq() { # check_eq NAME EXPECTED INPUT_FILE_TEXT TARGET_DECL + local name="$1" expected="$2" body="$3" decl="$4" + printf '%s' "$body" > "$DIR/in.kt" + local out status + out=$("$KTEXT" "$DIR/in.kt" --klammersets none -s "$decl" -t t -d 2>/dev/null) + status=$? + out=$(printf '%s' "$out" | tr -d '\n') + if [ $status -eq 0 ] && [ "$out" = "$expected" ]; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name" + echo " expected: [$expected]"; echo " got: [$out] (exit $status)" + FAIL=$((FAIL+1)) + fi +} + +check_def_error() { # check_def_error NAME PATTERN TARGET_DECL + local name="$1" pattern="$2" decl="$3" + local out status + out=$("$KTEXT" --klammersets none -s "$decl hello" -t t -d 2>&1) + status=$? + if [ $status -eq 0 ]; then + echo "${red}FAIL${reset} $name — expected a definition error but ktext succeeded" + FAIL=$((FAIL+1)); return + fi + if printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name — expected error to contain [$pattern]" + echo " got: $(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | tr '\n' ' ' | head -c 200)" + FAIL=$((FAIL+1)) + fi +} + +echo "${bold}@@@target :after_apply tests${reset}" +echo "============================" +echo + +echo "-- several phases: ';'-separated, run in declared order --" +check_eq " 1. one phase runs" \ + "text+ONE" "text" \ + '@@@target t | test | :after_apply phasefix.one @@@' +check_eq " 2. two phases run in order (ONE then TWO)" \ + "text+ONE+TWO" "text" \ + '@@@target t | test | :after_apply phasefix.one ; phasefix.two @@@' +check_eq " 3. ... and reversing the list reverses the order" \ + "text+TWO+ONE" "text" \ + '@@@target t | test | :after_apply phasefix.two ; phasefix.one @@@' + +echo +echo "-- a phase receives the state as K --" +check_eq " 4. K. is readable inside a phase" \ + "text|42" "text" \ + '@@@state Fixture_width :value 42 @@@ @@@target t | test | :after_apply phasefix.width @@@' + +echo +echo "-- the missing separator is a DEFINITION-time error --" +check_def_error " 5. two bare specs without ';' are rejected at the declaration" \ + "separated by \" ; \"" \ + '@@@target t | test | :after_apply phasefix.one phasefix.two @@@' +check_def_error " 6. ... and the error names the offending spec" \ + "phasefix.one phasefix.two" \ + '@@@target t | test | :after_apply phasefix.one phasefix.two @@@' + +echo +echo "-- a mode-tagged spec may contain spaces (its library is a filename) --" +# Declaration-time only: the :cpp library is never dlopened unless the +# target renders, so declaring it under another name proves the exemption +# without needing a real library. +check_eq " 7. a ':cpp lib func' spec passes the declaration guard" \ + "text+ONE" "text" \ + '@@@target u | unused | :after_apply ^:cpp some lib func @@@ @@@target t | test | :after_apply phasefix.one @@@' + +echo +echo "============================" +echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}" +[ $FAIL -eq 0 ] diff --git a/tst/transform_test.sh b/tst/transform_test.sh new file mode 100755 index 0000000..f4c66db --- /dev/null +++ b/tst/transform_test.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# +# transform_test.sh — Regression tests for the Klammermachine's typographic +# TRANSFORM pass (the third positional of @@@target). +# +# Kept SKS-INDEPENDENT on purpose: a target is a Machine construct, so the +# fixture target is declared inline and no klammer set is loaded. The SKS's +# own transform tables (html, txt, tex) are exercised by +# sks/tst/typography_test.sh. +# +# What the pass IS: each pair replaces a source character sequence in the +# final output with its target spelling (the LaTeX input conventions carried +# to other targets: --- to an em dash, quote pairs, ~ to a non-breaking +# space). Since 2026-08-23 it runs PER KATOM at final processing, not over +# the joined result string, which is what these cases pin: +# +# - ordinary writer text -> transformed +# - ^'...'^ literal span (katom_t::literal) -> NEVER transformed +# - a KTESC marker in the text -> never matched; decodes after +# the pass (this is the contract hide_typographic() in the SKS's +# klammer_base.py relies on to protect @c/@code verbatim text) +# - a plain-text @eval result (a renderer) -> transformed like writer text +# - a source split across a katom boundary -> NOT transformed (the writer +# separated the characters structurally; they are not a dash) +# - :includes -> transforms are inherited, +# as escapes are +# - the pairs run in declared order +# +# The transform/escape interplay is also pinned: escapes become markers +# before klammer application, transforms run at final processing, markers +# resolve last — so the two mechanisms cannot corrupt each other's output. +# +# Usage: ./transform_test.sh (needs KLAMMERTEXT_HOME set; ktext on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +KTEXT=ktext +K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} +ERR=/tmp/transform_test_err.$$ + +red=$'\033[31m' +green=$'\033[32m' +bold=$'\033[1m' +reset=$'\033[0m' + +# strip leading/trailing blank lines and trailing 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 NAME EXPECTED KTEXT_ARGS... — exit 0 and stdout==EXPECTED. +check_eq() { + local name="$1" expected="$2"; shift 2 + local out status err + out=$("$KTEXT" "$@" 2>"$ERR"); status=$? + err=$(cat "$ERR") + out=$(printf '%s' "$out" | trim) + if [ $status -ne 0 ]; then + echo "${red}FAIL${reset} $name — ktext exited $status" + echo " stderr: $(echo "$err" | head -2)"; FAIL=$((FAIL+1)); return + fi + 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}Typographic transform tests (Machine mechanism, fixture target 't')${reset}" +echo "===================================================================" +echo + +# The fixture target: one escape (& -> AMP) and two transforms, whose +# replacements are distinct tokens that are easy to assert. +T='@@@target t | test target :escape & AMP | --- MDASH | -- NDASH @@@' + +check_eq " 1. writer text: --- transformed" 'a MDASH b' --klammersets none -t t -s "$T a --- b" +check_eq " 2. pairs in declared order: -- after ---" 'a NDASH b' --klammersets none -t t -s "$T a -- b" +check_eq " 3. literal span: NOT transformed" 'a -- b' --klammersets none -t t -s "$T ^'a -- b'^" +check_eq " 4. escape and transform coexist" 'x AMP y NDASH' --klammersets none -t t -s "$T x & y --" +# 5-6: the @eval sits in a klammer BODY, applied after the fixture target +# is extracted — a top-level @eval runs at read time, before same-input +# @@@ extraction (the documented pass-ordering caveat), and would not +# find target t. +check_eq " 5. renderer @eval result: transformed" 'aNDASHb' --klammersets none -t t -s "$T @@e.t : @eval \"a--b\" @ @@ @e@" +check_eq " 6. KTESC marker: skipped, then decoded" '--' --klammersets none -t t -s "$T @@e.t : @eval \"KTESC002dKTESC\" * 2 @ @@ @e@" +check_eq " 7. source across a katom boundary: NOT transformed" '- x -' --klammersets none -t t -s "$T @@g : x @@ - @g@ -" + +# 8. :includes — an including target inherits the included target's +# transforms (and escapes), so a target built over another renders the +# same writer conventions. +T2="$T @@@target t2 | over t :includes t @@@" +check_eq " 8. :includes inherits transforms" 'a NDASH b AMP c' --klammersets none -t t2 -s "$T2 a -- b & c" + +# 9-12: "^" before any punctuation character quotes it +# (hide_quoted_punctuation, 2026-08-23): the pair becomes the KTESC marker +# of the character, which the transform pass cannot match. The target +# decides the rendering — a :resolve entry, an :escape entry, or (neither) +# the raw character. Inside an @eval span the code keeps its carets. +check_eq " 9. quoted hyphens: no transform, decode raw" '--' --klammersets none -t t -s "$T ^-^-" +check_eq "10. :resolve maps a quoted character" 'LIT-' --klammersets none -t r -s '@@@target r | r :resolve - LIT- @@@ ^-' +check_eq "11. quoted char with an :escape entry takes its replacement" 'AMP' --klammersets none -t t -s "$T ^&" +check_eq "12. @eval code keeps its carets" '2' --klammersets none -t t -s "$T @@e.t : @eval 3 ^(1) @ @@ @e@" + +echo +echo "====================================================================" +echo "${bold}Results: $PASS passed, $FAIL failed${reset}" +rm -f "$ERR" +[ $FAIL -eq 0 ] && exit 0 || exit 1