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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
17
mac/argv.cpp
17
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<std::string, strings_t> Argv::as_input(const std::string& name, bool allow_empty)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
114
mac/file.cpp
114
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<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?)" };
|
||||
|
||||
12
mac/file.h
12
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<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);
|
||||
|
||||
@@ -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<Katom>(s, Locator(), katom_t::word) };
|
||||
//return std::vector{ std::make_shared<Katom>(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)) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@@
|
||||
|
||||
@@ -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]
|
||||
|
||||
10
tst/Makefile
10
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
|
||||
|
||||
135
tst/filename_test.sh
Executable file
135
tst/filename_test.sh
Executable 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 ]
|
||||
Reference in New Issue
Block a user