diff --git a/README.md b/README.md index 84ebd20..54454bf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/com/kdesc.cpp b/com/kdesc.cpp index a3d6720..0168177 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -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")); diff --git a/com/ktext.cpp b/com/ktext.cpp index 91c9019..c361865 100644 --- a/com/ktext.cpp +++ b/com/ktext.cpp @@ -35,7 +35,11 @@ int main(int argc, char* argv[]) args.describe(); } std::string input_text = args.as_string("s"); - std::vector 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 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()) { diff --git a/mac/argv.cpp b/mac/argv.cpp index 283a4c1..43cfa32 100644 --- a/mac/argv.cpp +++ b/mac/argv.cpp @@ -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 Argv::as_input(const std::string& name, bool allow_empty) diff --git a/mac/argv.h b/mac/argv.h index 40a84f2..bfa5103 100644 --- a/mac/argv.h +++ b/mac/argv.h @@ -113,5 +113,10 @@ public: std::vector m_var_names {}; std::set m_given {}; std::vector 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> m_vectors {}; long unsigned int m_syntax_size = 0; }; diff --git a/mac/command.cpp b/mac/command.cpp index db350a4..9e27635 100644 --- a/mac/command.cpp +++ b/mac/command.cpp @@ -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); diff --git a/mac/eval.cpp b/mac/eval.cpp index 90db731..3ceba7a 100644 --- a/mac/eval.cpp +++ b/mac/eval.cpp @@ -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 diff --git a/mac/file.cpp b/mac/file.cpp index 9dca48d..c0972ec 100644 --- a/mac/file.cpp +++ b/mac/file.cpp @@ -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(text[i-1]))) + && (i + 1 == text.size() || std::isspace(static_cast(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?)" }; diff --git a/mac/file.h b/mac/file.h index a4c8ec4..d69666d 100644 --- a/mac/file.h +++ b/mac/file.h @@ -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 group_filename_tokens( + const std::vector& tokens, const std::string& base_dir=""); +std::vector 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 get_files_in_directory(const std::string& dir); diff --git a/mac/katom.cpp b/mac/katom.cpp index b7bb86b..5599021 100644 --- a/mac/katom.cpp +++ b/mac/katom.cpp @@ -92,16 +92,39 @@ katom_list make_katoms_from_word(std::string s, const std::string& source_desc, return klist; } } - if (verbose_level > 0) { - std::cerr << command_name - << " [warning]: Word not parsed in " - << source_desc << ", line " << line+1 << ":\n" - << " " << s << "\n" - << "To include a special character (@, |, #, and ^), put \"^\" before it.\n"; - //return std::vector{ std::make_shared(s, Locator(), katom_t::word) }; - //return std::vector{ std::make_shared(s, katom_t::word, Locator(source_desc, line, chr)) }; + 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 " + << 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"; + k.m_unparsed = false; } - return std::vector{ Katom(s, katom_t::word, Locator(source_desc, line, chr)) }; } } diff --git a/mac/katom.h b/mac/katom.h index 7a2ec11..2e1d8f2 100644 --- a/mac/katom.h +++ b/mac/katom.h @@ -16,7 +16,7 @@ public: Katom(const std::string& src, katom_t type, Locator loc); // Copy constructor - Katom(const Katom& other) + Katom(const Katom& other) : m_index(other.m_index) , m_text(other.m_text) , m_src(other.m_src) @@ -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& katoms); @@ -97,6 +103,7 @@ std::vector line_split(std::string s); std::pair> line_split(fs::path pathname); std::vector katomize(const std::vector& lines, const std::string& source_desc); +void warn_unparsed_katoms(std::vector& katoms, bool warn = true); void process_whitespace_modifiers(std::vector& katoms); std::vector trim_whitespace(std::vector katoms); diff --git a/mac/machine.cpp b/mac/machine.cpp index b481aec..5cbbaf0 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -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; } diff --git a/mac/machine.h b/mac/machine.h index 0ae7335..3e938d2 100644 --- a/mac/machine.h +++ b/mac/machine.h @@ -87,6 +87,11 @@ public: input_sources_t m_sources {}; std::string m_result {}; std::vector 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; }; /* diff --git a/sks/document/document.cpp b/sks/document/document.cpp index 4eb92c3..da803f0 100644 --- a/sks/document/document.cpp +++ b/sks/document/document.cpp @@ -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"); diff --git a/sks/document/document_class.cpp b/sks/document/document_class.cpp index 0012cac..aed9cad 100644 --- a/sks/document/document_class.cpp +++ b/sks/document/document_class.cpp @@ -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; } diff --git a/sks/image/image.py b/sks/image/image.py index 383e374..45b71a9 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -31,7 +31,7 @@ class Image(klammer_base.Klammer_base): self.basename = klammer_base.unescape_ktesc(self.basename) self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename) if self.file_error: - self.file_error_message = f'\nERROR: File "{self.K_input_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}" diff --git a/sks/image/image_grid.py b/sks/image/image_grid.py index 34c20cf..a7bde9e 100644 --- a/sks/image/image_grid.py +++ b/sks/image/image_grid.py @@ -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) diff --git a/sks/kutil/kutil.k b/sks/kutil/kutil.k index aabe8f5..addb282 100644 --- a/sks/kutil/kutil.k +++ b/sks/kutil/kutil.k @@ -69,3 +69,14 @@ An 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)) +@@@ diff --git a/sks/kutil/kutil.py b/sks/kutil/kutil.py index 05d620e..2e4b66e 100644 --- a/sks/kutil/kutil.py +++ b/sks/kutil/kutil.py @@ -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] diff --git a/tst/Makefile b/tst/Makefile index c6b44ca..bed511c 100644 --- a/tst/Makefile +++ b/tst/Makefile @@ -1,9 +1,10 @@ # Klammertext distribution test suite (subset). # -# Runs the three 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 +# 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 diff --git a/tst/filename_test.sh b/tst/filename_test.sh new file mode 100755 index 0000000..9fee2e4 --- /dev/null +++ b/tst/filename_test.sh @@ -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 ]