Filenames with spaces; output beside the input file; warning fixes (from dev c08eb1bbfa4c)

- Filenames may contain spaces: quote on the command line; filename
  lists use a standalone "/" separator; unseparated names that do not
  exist are rejoined into names that do (announced); leading ~ expands.
- Without -o, output is written to the input file's directory; -o fully
  specifies the output directory and basename.
- "Word not parsed" warnings now appear without -v, only for text that
  survives removal, with correct line numbers.
- New tst/filename_test.sh regression suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 23:22:59 +02:00
parent d9c98ac86d
commit fd9a370af7
21 changed files with 430 additions and 34 deletions

View File

@@ -39,7 +39,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 `f6463478da4c`.
This snapshot was assembled from development commit `c08eb1bbfa4c`.
## License

View File

@@ -107,7 +107,7 @@ int main(int argc, char* argv[])
// (infrastructure) and load no klammer set.
if (args.given("font")) {
strings_t words {};
for (const std::string& w : word_split(args.get("font"))) {
for (const std::string& w : args.as_vector("font")) {
if (!w.empty()) {
words.push_back(w);
}
@@ -118,7 +118,7 @@ int main(int argc, char* argv[])
Machine M;
strings_t input_filenames = args.as_vector("input");
strings_t input_filenames = resolve_filename_list(args.get("input"));
std::cout << "input_filenames: " << input_filenames << "\n";
if (input_filenames.empty()) {
M.read(fs::path(M.m_state.value("KLAMMERTEXT_HOME") + "/sks/sks.k"));

View File

@@ -35,7 +35,11 @@ int main(int argc, char* argv[])
args.describe();
}
std::string input_text = args.as_string("s");
std::vector<std::string> input_filenames = args.as_vector("filenames");
// argv boundaries are authoritative (a shell-quoted "my file.kt" is
// one element); group_filename_tokens adds the standalone-"/" list
// form, the existence rescue for unquoted spaces, and ~ expansion.
std::vector<std::string> input_filenames =
group_filename_tokens(args.as_vector("filenames"));
if (input_text.empty() && input_filenames.empty()) {
throw Argument_error(
@@ -44,8 +48,8 @@ int main(int argc, char* argv[])
auto [target, output_dir, output_basename, output_filename,
write_files, display_only] =
parse_args(input_filenames, args.as_string("t"), args.as_string("o"),
args.as_bool("d"));
parse_args(input_filenames, args.as_string("t"),
expand_tilde(args.as_string("o")), args.as_bool("d"));
if (*(output_filename.end() - 1) == '*'
&& !display_only) {
@@ -61,7 +65,11 @@ int main(int argc, char* argv[])
M.m_state.set("K_output_dir", output_dir);
M.m_state.set("K_output_basename", output_basename);
M.m_state.set("K_stdout_only", display_only ? "true" : "false");
M.m_state.set("K_input_filenames", join(input_filenames, " "));
// The list form uses the standalone-"/" separator (filenames may
// contain spaces); K_input_filename is the single root input file.
M.m_state.set("K_input_filenames", join(input_filenames, " / "));
M.m_state.set("K_input_filename",
input_filenames.empty() ? "" : input_filenames[0]);
M.m_state.set("K_verbose_level", std::to_string(verbose_level));
if (!input_filenames.empty()) {

View File

@@ -175,6 +175,7 @@ void Argv::parse_vars(strings_t& words, string_map& named_args)
last++;
}
named_args[name] = join(strings_t(first, last), " ");
m_vectors[name] = strings_t(first, last);
words.erase(it, last);
}
}
@@ -369,6 +370,11 @@ Argv::classify_arguments(int argc, char* argv[], bool full_parse)
parse_optional(words, named_args);
parse_positional(argv_to_string(argc, argv), join(words, " "), named_args);
words.erase(std::remove(words.begin(), words.end(), Argv::delimiter), words.end());
if (m_req_names.size() == 1) {
// A single positional argument owns all remaining words; keep the
// original argv boundaries alongside the joined named_args value.
m_vectors[m_req_names[0]] = words;
}
// std::cout << "Named args:\n" << named_args << "\n";
return named_args;
}
@@ -556,7 +562,16 @@ std::string Argv::as_string(const std::string& name)
strings_t Argv::as_vector(const std::string& name)
{
(void)K::log(2, name);
return regex_split(get(name), std::regex(R"(\s+)"));
if (m_vectors.count(name) > 0) {
return m_vectors.at(name);
}
// No stored boundaries (e.g. an opt, whose value is a single argv
// word): the value is one element, spaces and all -- never re-split.
std::string value = get(name);
if (value.empty()) {
return {};
}
return { value };
}
std::pair<std::string, strings_t> Argv::as_input(const std::string& name, bool allow_empty)

View File

@@ -113,5 +113,10 @@ public:
std::vector<std::string> m_var_names {};
std::set<std::string> m_given {};
std::vector<std::string> m_hyphen_markers {};
// Original argv word boundaries for multi-word arguments (the single
// positional list and variadic --name options). as_vector() returns
// these, so a shell-quoted filename containing spaces stays one
// element; the space-joined m_value remains only for get()/describe().
std::map<std::string, std::vector<std::string>> m_vectors {};
long unsigned int m_syntax_size = 0;
};

View File

@@ -73,7 +73,9 @@ parse_args(
output_dir = file_directory(output_basename);
output_basename = file_basename(output_basename);
} else if (!input_filenames.empty()) {
output_dir = ""; // defaults to cwd via absolute_pathname below
// No -o: output goes next to the input file ("" -- an input with no
// directory component -- resolves to cwd below).
output_dir = file_directory(input_filenames[0]);
output_basename = file_basename(input_filenames[0]);
}
output_dir = absolute_pathname(output_dir);

View File

@@ -158,6 +158,7 @@ katom_list Eval::eval(katom_iter begin, katom_iter end)
std::string eval_result = eval_command(begin, end);
katom_list result {};
Machine M = m_machine;
M.m_warn_unparsed = false; // Result text is machine-generated (see machine.h)
size_t before = M.m_katoms.size();
M.read(eval_result);
// If the @eval produced more Klammertext -- the read-back result still

View File

@@ -115,6 +115,120 @@ bool file_exists(const std::string& pathname, bool error_if_not, bool is_directo
}
// Filename lists (see file.h): standalone-"/" separation, existence-rescue
// grouping, and tilde expansion.
std::string expand_tilde(const std::string& path)
{
if (path == "~" || (path.size() > 1 && path[0] == '~' && path[1] == '/')) {
std::string home = get_env_var("HOME");
if (!home.empty()) {
return home + path.substr(1);
}
}
return path;
}
static bool filename_exists(const std::string& name, const std::string& base_dir)
{
std::string resolved = expand_tilde(name);
if (!base_dir.empty() && !fs::path(resolved).is_absolute()) {
resolved = base_dir + "/" + resolved;
}
return fs::is_regular_file(resolved);
}
strings_t group_filename_tokens(const strings_t& tokens, const std::string& base_dir)
{
strings_t result {};
if (std::find(tokens.begin(), tokens.end(), "/") != tokens.end()) {
// Deterministic form: standalone "/" separates the filenames; the
// tokens between separators form one name. No existence checks.
strings_t group {};
for (const std::string& t : tokens) {
if (t == "/") {
if (!group.empty()) result.push_back(join(group));
group.clear();
} else {
group.push_back(t);
}
}
if (!group.empty()) result.push_back(join(group));
} else {
// Rescue: a token naming an existing file stands alone; one that
// does not is joined with following tokens until the accumulated
// 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;
while (i < tokens.size()) {
if (filename_exists(tokens[i], base_dir)) {
result.push_back(tokens[i]);
++i;
continue;
}
std::string acc = tokens[i];
size_t j = i + 1;
bool found = false;
while (j < tokens.size()) {
acc += " " + tokens[j];
++j;
if (filename_exists(acc, base_dir)) {
std::cerr << command_name << ": interpreting \""
<< acc << "\" as one filename\n";
result.push_back(acc);
i = j;
found = true;
break;
}
}
if (!found) {
result.push_back(tokens[i]);
++i;
}
}
}
for (std::string& name : result) {
name = expand_tilde(name);
}
return result;
}
strings_t resolve_filename_list(const std::string& text, const std::string& base_dir)
{
// Split on standalone "/" at the string level first, so a name's inner
// spacing survives exactly; without a separator, fall back to
// whitespace tokens and the rescue in group_filename_tokens().
auto standalone_slash = [&](size_t i) {
return text[i] == '/'
&& (i == 0 || std::isspace(static_cast<unsigned char>(text[i-1])))
&& (i + 1 == text.size() || std::isspace(static_cast<unsigned char>(text[i+1])));
};
bool has_separator = false;
for (size_t i = 0; i < text.size(); ++i) {
if (standalone_slash(i)) {
has_separator = true;
break;
}
}
if (has_separator) {
strings_t result {};
std::string part {};
for (size_t i = 0; i < text.size(); ++i) {
if (standalone_slash(i)) {
part = trim(part);
if (!part.empty()) result.push_back(expand_tilde(part));
part.clear();
} else {
part += text[i];
}
}
part = trim(part);
if (!part.empty()) result.push_back(expand_tilde(part));
return result;
}
return group_filename_tokens(word_split(text), base_dir);
}
std::string string_from_file(const std::string& pathname, bool strip_surrounding_whitespace)
{
std::regex klammertext_filename_re { R"(.*\.kt?)" };

View File

@@ -17,6 +17,18 @@ std::string relative_pathname(const std::string& filename, const std::string& ba
fs::path relative_to_cwd(const fs::path& input);
bool file_exists(const std::string& pathname, bool error_if_not=false, bool is_directory=false);
// Filenames may contain spaces. A filename LIST in a flat string or an
// argument vector is separated by a standalone "/" token (whitespace on both
// sides; never a legal input filename -- "/" alone is the root directory).
// Without a separator, whitespace-split tokens that do not name existing
// files are greedily rejoined with their neighbors into names that do (the
// rescue is announced). A leading "~/" (or bare "~") expands to $HOME.
std::string expand_tilde(const std::string& path);
std::vector<std::string> group_filename_tokens(
const std::vector<std::string>& tokens, const std::string& base_dir="");
std::vector<std::string> resolve_filename_list(
const std::string& text, const std::string& base_dir="");
std::string string_from_file(const std::string& pathname, bool strip_surrounding_whitespace=false);
void string_to_file(const std::string& pathname, std::string contents);
std::vector<std::string> get_files_in_directory(const std::string& dir);

View File

@@ -92,16 +92,39 @@ katom_list make_katoms_from_word(std::string s, const std::string& source_desc,
return klist;
}
}
if (verbose_level > 0) {
Katom k(s, katom_t::word, Locator(source_desc, line, chr));
k.m_unparsed = true; // Warning deferred to warn_unparsed_katoms()
return std::vector{ k };
}
}
// Warn about words that matched no katom type -- but only those that
// survive processing: text removed by #, ##, or #[...]#, replaced spans,
// and literal content (definition interiors, @code bodies) never warn.
// Called at the end of Machine::process_katoms(), after those passes have
// marked the katoms. Clears the flag after warning so repeated processing
// of the same katom list does not warn twice. With warn=false (the @eval
// read-back sub-Machine, whose katoms hold machine-generated result text)
// no warning is printed and every flag is cleared, so the katoms stay
// silent after they are spliced into the calling Machine's list.
void warn_unparsed_katoms(katom_list& katoms, bool warn)
{
for (Katom& k : katoms) {
if (!k.m_unparsed) continue;
if (!warn) {
k.m_unparsed = false;
continue;
}
if (k.m_type != katom_t::ignored
&& k.m_type != katom_t::replaced
&& k.m_type != katom_t::literal) {
std::cerr << command_name
<< " [warning]: Word not parsed in "
<< source_desc << ", line " << line+1 << ":\n"
<< " " << s << "\n"
<< k.m_loc.m_filename << ", line " << k.m_loc.m_line << ":\n"
<< " " << k.m_text << "\n"
<< "To include a special character (@, |, #, and ^), put \"^\" before it.\n";
//return std::vector{ std::make_shared<Katom>(s, Locator(), katom_t::word) };
//return std::vector{ std::make_shared<Katom>(s, katom_t::word, Locator(source_desc, line, chr)) };
k.m_unparsed = false;
}
return std::vector{ Katom(s, katom_t::word, Locator(source_desc, line, chr)) };
}
}

View File

@@ -24,6 +24,7 @@ public:
, m_type(other.m_type)
, m_initial_type(other.m_initial_type)
, m_display(other.m_display)
, m_unparsed(other.m_unparsed)
{}
// Copy assignment operator
@@ -36,6 +37,7 @@ public:
m_type = other.m_type;
m_initial_type = other.m_initial_type;
m_display = other.m_display;
m_unparsed = other.m_unparsed;
}
return *this;
}
@@ -68,6 +70,10 @@ public:
katom_t m_type;
katom_t m_initial_type;
std::string m_display {};
// Set when the word matched no katom type and fell back to katom_t::word.
// The warning is deferred to warn_unparsed_katoms(), after removal and
// literal marking, so removed text (comments, #[...]# blocks) never warns.
bool m_unparsed {};
};
bool active(const std::vector<Katom>& katoms);
@@ -97,6 +103,7 @@ std::vector<std::string> line_split(std::string s);
std::pair<std::string, std::vector<std::string>> line_split(fs::path pathname);
std::vector<Katom> katomize(const std::vector<std::string>& lines, const std::string& source_desc);
void warn_unparsed_katoms(std::vector<Katom>& katoms, bool warn = true);
void process_whitespace_modifiers(std::vector<Katom>& katoms);
std::vector<Katom> trim_whitespace(std::vector<Katom> katoms);

View File

@@ -249,6 +249,7 @@ void Machine::process_katoms(
if (read) expand_read_katoms(
katoms, source,
nonascii, literal, ignore, whitespace, klammers, eval, cond, read);
warn_unparsed_katoms(katoms, m_warn_unparsed);
// return katoms;
}

View File

@@ -87,6 +87,11 @@ public:
input_sources_t m_sources {};
std::string m_result {};
std::vector<Katom> m_katoms {};
// False on the sub-Machine that re-reads an @eval result (Eval::eval):
// result text is machine-generated -- a renderer's raw target markup
// (e.g. a LaTeX column spec "@{}...") legitimately fails katom parsing
// and must not produce "Word not parsed" warnings.
bool m_warn_unparsed = true;
};
/*

View File

@@ -78,7 +78,12 @@ std::string tex_to_pdf(Machine& machine)
for (auto ext : {"aux", "log", "out", "toc"}) {
fs::remove(outbase + "." + ext);
}
std::string command = "xelatex -interaction=batchmode -halt-on-error " + tex_filename;
// xelatex writes .pdf/.log/.aux/.toc into its cwd unless told otherwise;
// K_output_dir need not be cwd (it follows the input file, or -o).
// The embedded paths are quoted defensively: they derive from user
// filenames, which may contain spaces.
std::string command = "xelatex -interaction=batchmode -halt-on-error -output-directory=\""
+ machine.m_state.value("K_output_dir") + "\" \"" + tex_filename + "\"";
string_to_file(tex_filename, machine.m_result);
std::string xelatex_output = exec(command.c_str());
std::string xelatex_log = string_from_file(outbase + ".log");

View File

@@ -47,10 +47,14 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine)
m_logo = get("logo");
m_text = get("text");
m_files = word_split(get("files"));
// Filename lists: standalone-"/" separation, existence rescue for
// 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"));
m_css_text = get("css_text");
m_css_filenames = word_split(get("css_files"));
m_css_filenames = resolve_filename_list(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");
@@ -58,7 +62,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 = word_split(get("js_files"));
m_js_filenames = resolve_filename_list(get("js_files"), get("K_input_dir"));
m_include_sks_js = strbool(get("include_sks_js"), loc);
// font_dirs = word_split(get("font_dirs"));
@@ -99,7 +103,7 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine)
//use_pages_dir = strbool(get("use_pages_dir"), loc);
// m_toc_only = strbool(get("K_toc_only"), loc);
m_kt_root_filename = get("K_input_filenames");
m_kt_root_filename = get("K_input_filename");
m_no_cache = !strbool(get("cache"), loc);
@@ -138,8 +142,20 @@ fs::path parse_input_filename(std::string s, std::string input_dir)
if (p.extension() != ".kt") {
p += ".kt";
}
if (!file_exists(p)) {
p = input_dir + "/kt/" + p.string();
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.
fs::path in_input_dir = fs::path(input_dir) / p;
if (file_exists(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())) {
return in_kt_dir;
}
return p;
}

View File

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

View File

@@ -22,7 +22,7 @@ class Kargs:
self.number = K.cell_number
self.hpos = "none"
self.K_target = target
self.K_input_filenames = K.K_input_filenames
self.K_input_dir = K.K_input_dir
self.K_output_dir = K.K_output_dir
self.K_output_basename = K.K_output_basename
self.Image_output_dir = K.Image_output_dir
@@ -57,7 +57,7 @@ class Image_grid(klammer_base.Klammer_base):
self.basenames.append([e[0] for e in self.images[-1]])
self.captions.append([e[1] for e in self.images[-1]])
self.cache = image_cache.Image_cache(
os.path.dirname(os.path.abspath(self.K_input_filenames)),
self.K_input_dir,
self.Image_search_path,
verbose=False)

View File

@@ -69,3 +69,14 @@ An <id> is the value of the ^:id argument for an image.
^:pattern before(?^:\s+\d+)?^|after(?^:\s+\d+)?^|[-\w]+
@@@
@@@argtype filename_list |
one or more filenames. Filenames may contain spaces: a list is separated
by a standalone "/" (whitespace on both sides), e.g.
"chapter 1.kt / chapter 2.kt". Without the separator, the names are
separated by whitespace, and names that do not exist are rejoined with
their neighbors into names that do. A leading ~ expands to the home
directory.
:pattern [\s\S]*
:python_cast (lambda s: __import__("kutil").filename_list(s))
@@@

View File

@@ -219,3 +219,37 @@ def format_for_paragraphs(s):
return result
def filename_list(text):
"""Split a filename list; the Python twin of resolve_filename_list()
in mac/file.cpp (keep the two in sync). A standalone "/" (whitespace
on both sides) separates names, whose inner spacing is preserved;
without a separator, whitespace-separated tokens that do not name
existing files are rejoined with their neighbors into names that do.
A leading ~ expands to the home directory."""
text = text.strip()
if not text:
return []
parts = re.split(r'(?:^|(?<=\s))/(?:\s|$)', text)
if len(parts) > 1:
return [os.path.expanduser(p.strip()) for p in parts if p.strip()]
tokens = text.split()
result, i = [], 0
while i < len(tokens):
if os.path.isfile(os.path.expanduser(tokens[i])):
result.append(tokens[i])
i += 1
continue
acc, j, found = tokens[i], i + 1, False
while j < len(tokens):
acc += " " + tokens[j]
j += 1
if os.path.isfile(os.path.expanduser(acc)):
result.append(acc)
i = j
found = True
break
if not found:
result.append(tokens[i])
i += 1
return [os.path.expanduser(p) for p in result]

View File

@@ -1,9 +1,10 @@
# Klammertext distribution test suite (subset).
#
# Runs the three shell regression suites:
# Runs the four shell regression suites:
# cond_test.sh — @cond argument delimitation
# deftype_test.sh — the four klammer definition modes + redefinition table
# escape_test.sh — target character escaping and quoted specials
# filename_test.sh — filenames with spaces (quoting, " / " lists, rescue)
#
# Requires KLAMMERTEXT_HOME set and `ktext` on PATH (build it with `make -C com`).
@@ -12,3 +13,4 @@ test:
./cond_test.sh
./deftype_test.sh
./escape_test.sh
./filename_test.sh

135
tst/filename_test.sh Executable file
View File

@@ -0,0 +1,135 @@
#!/bin/bash
#
# filename_test.sh — Regression tests for filenames containing spaces.
#
# Filenames may contain spaces. The rules (see CLAUDE.md "Output directory
# policy" and the filename-list functions in mac/file.cpp):
# - argv boundaries are authoritative: a shell-quoted "my file.kt" is one
# filename (Argv stores the original argv vector; nothing re-splits it).
# - A filename LIST is separated by a standalone "/" token (whitespace on
# both sides) — never a legal input filename, since "/" alone is the
# root directory.
# - Rescue: without a separator, whitespace-split names that do not exist
# are greedily rejoined with their neighbors into names that do; the
# regrouping is announced on stderr.
# - A leading "~/" expands to $HOME (shells do not expand a quoted tilde).
#
# Engine tier: uses -k none; no SKS.
#
# Usage: ./filename_test.sh
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0
FAIL=0
KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
# Scratch input files
DIR=$(mktemp -d)
trap 'rm -rf "$DIR"' EXIT
mkdir "$DIR/my dir"
printf 'ALPHA\n' > "$DIR/my file.kt"
printf 'BETA\n' > "$DIR/b.kt"
printf 'GAMMA\n' > "$DIR/my dir/c.kt"
cd "$DIR" || exit 1
# check TEST_NAME EXPECTED_STDOUT EXPECTED_STDERR_SUBSTRING KTEXT_ARGS...
# EXPECTED_STDERR_SUBSTRING may be "" (no stderr requirement).
check() {
local test_name="$1" expected="$2" err_needle="$3"
shift 3
local output status errfile=$DIR/.stderr
output=$("$KTEXT" "$@" 2>"$errfile")
status=$?
output=$(printf '%s' "$output" | sed -e 's/[ \t]*$//' | grep -v '^$')
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $test_name — ktext exited $status"
echo " stderr: $(head -3 "$errfile")"
FAIL=$((FAIL + 1))
return
fi
if [ "$output" != "$expected" ]; then
echo "${red}FAIL${reset} $test_name"
echo " expected: [$expected]"
echo " got: [$output]"
FAIL=$((FAIL + 1))
return
fi
if [ -n "$err_needle" ] && ! grep -qF "$err_needle" "$errfile"; then
echo "${red}FAIL${reset} $test_name — expected stderr to contain [$err_needle]"
echo " stderr: $(head -3 "$errfile")"
FAIL=$((FAIL + 1))
return
fi
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
}
# check_error TEST_NAME EXPECTED_SUBSTRING KTEXT_ARGS...
# Expects a nonzero exit whose combined output contains EXPECTED_SUBSTRING.
check_error() {
local test_name="$1" needle="$2"
shift 2
local output status
output=$("$KTEXT" "$@" 2>&1)
status=$?
if [ $status -eq 0 ]; then
echo "${red}FAIL${reset} $test_name — expected an error, got exit 0"
FAIL=$((FAIL + 1))
return
fi
if echo "$output" | grep -qF "$needle"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name — expected output to contain [$needle]"
echo " output: $(echo "$output" | head -3)"
FAIL=$((FAIL + 1))
fi
}
echo "${bold}Filename-with-spaces tests${reset}"
echo "======================="
check "1. quoted filename with a space is one file" \
"ALPHA" "" \
"my file.kt" -d -k none
check "2. unquoted spaces rescued into an existing file (announced)" \
"ALPHA" "interpreting \"my file.kt\" as one filename" \
my file.kt -d -k none
check "3. standalone / separates a filename list" \
"ALPHA
BETA" "" \
my file.kt / b.kt -d -k none
check "4. space in a directory component" \
"GAMMA" "" \
"my dir/c.kt" -d -k none
check "5. quoted name that exists is never split (b.kt also exists)" \
"ALPHA" "" \
"my file.kt" -d -k none
HOME="$DIR" check "6. quoted ~/ expands to \$HOME inside ktext" \
"BETA" "" \
"~/b.kt" -d -k none
check "7. @read argument keeps its internal space" \
"ALPHA" "" \
-s '@read my file.kt @' -d -k none
check_error "8. unrescuable name is reported as written" \
"no such.kt" \
"no such.kt" -d -k none
echo
echo "======================="
echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}"
[ $FAIL -eq 0 ]