16#include <nlohmann/json.hpp>
18#include <system_error>
31std::filesystem::path config_file_for(
const std::filesystem::path& root_dir) {
32 return root_dir /
".notex" /
"notex.json";
35void write_text_file(
const std::filesystem::path& path,
36 const std::string& content) {
37 std::filesystem::create_directories(path.parent_path());
38 std::ofstream stream(path);
52std::string resolve_class_path(
const std::filesystem::path& target_dir) {
53 if (std::filesystem::exists(target_dir /
"settings" /
"notex.cls")) {
54 return "settings/notex";
60 PLOG_WARNING <<
"No NoTeX installation found for '"
61 << target_dir.string()
62 <<
"'; scaffolding will reference "
63 "\\documentclass{notex}.";
65 "No NoTeX installation found; the generated project will "
66 "reference \\documentclass{notex}. Run 'notex install' "
70 PLOG_WARNING <<
"Could not resolve the TeX environment for '"
71 << target_dir.string() <<
"': " << e.what();
73 "Could not resolve the TeX environment; the generated project "
74 "will reference \\documentclass{notex}. Run 'notex install' "
84constexpr std::array<std::string_view, 20> kCleanFileSuffixes = {
110constexpr std::array<std::string_view, 1> kCleanDirectoryPrefixes = {
114bool is_clean_target_file(std::string_view filename) {
115 for (
const std::string_view suffix : kCleanFileSuffixes) {
116 if (filename.ends_with(suffix))
return true;
121bool is_clean_target_directory(std::string_view name) {
122 for (
const std::string_view prefix : kCleanDirectoryPrefixes) {
123 if (name.starts_with(prefix))
return true;
128std::string_view trim(std::string_view text) {
129 const std::size_t begin = text.find_first_not_of(
" \t");
130 if (begin == std::string_view::npos)
return {};
131 const std::size_t end = text.find_last_not_of(
" \t");
132 return text.substr(begin, end - begin + 1);
135bool is_documentclass_line(
const std::string& line) {
136 return trim(line).starts_with(
"\\documentclass");
139bool is_end_document_line(
const std::string& line) {
140 return trim(line) ==
"\\end{document}";
143std::string subfile_line_for(std::string_view stem) {
144 return "\\subfile{sections/" + std::string(stem) +
"}";
147bool is_subfile_line_for(
const std::string& line, std::string_view stem) {
148 return trim(line) == subfile_line_for(stem);
151bool is_any_subfile_line(
const std::string& line) {
152 return trim(line).starts_with(
"\\subfile{sections/");
155bool is_biblatex_usepackage_line(
const std::string& line) {
156 const std::string_view trimmed = trim(line);
157 return trimmed.starts_with(
"\\usepackage") &&
158 trimmed.find(
"{biblatex}") != std::string_view::npos;
161bool is_addbibresource_line(
const std::string& line) {
162 return trim(line).starts_with(
"\\addbibresource");
165bool is_printbibliography_line(
const std::string& line) {
166 return trim(line).starts_with(
"\\printbibliography");
169bool is_bibliographystyle_line(
const std::string& line) {
170 return trim(line).starts_with(
"\\bibliographystyle");
173bool is_bibliography_command_line(
const std::string& line) {
174 return trim(line).starts_with(
"\\bibliography{");
182void ensure_line(
Document& doc,
const std::string& text,
184 bool after_documentclass) {
185 if (!doc.find_all(already_present).empty())
return;
187 if (after_documentclass) {
188 const std::size_t anchor =
189 doc.find_unique(is_documentclass_line,
"a unique \\documentclass line");
190 doc.insert_line(anchor + 1, text);
192 const std::size_t anchor =
193 doc.find_unique(is_end_document_line,
"a unique \\end{document} line");
194 doc.insert_line(anchor, text);
200void remove_matching_lines(
Document& doc,
202 const std::vector<std::size_t> matches = doc.find_all(predicate);
203 for (
auto it = matches.rbegin(); it != matches.rend(); ++it) {
204 doc.remove_line(*it);
213std::vector<std::pair<int, std::string>> scan_sections(
214 const std::filesystem::path& sections_dir) {
215 std::vector<std::pair<int, std::string>> numbered;
217 if (!std::filesystem::is_directory(sections_dir, ec))
return numbered;
219 for (
const auto& entry :
220 std::filesystem::directory_iterator(sections_dir, ec)) {
221 if (ec || !entry.is_regular_file())
continue;
222 const std::string stem = entry.path().stem().string();
223 const std::size_t underscore = stem.find(
'_');
224 if (underscore == std::string::npos)
continue;
227 const auto result = std::from_chars(
228 stem.data(), stem.data() + underscore, number);
229 if (result.ec != std::errc())
continue;
230 numbered.emplace_back(number, stem);
232 std::sort(numbered.begin(), numbered.end());
236int highest_section_number(
const std::filesystem::path& sections_dir) {
237 const auto numbered = scan_sections(sections_dir);
238 return numbered.empty() ? 0 : numbered.back().first;
241std::vector<std::string> section_stems_sorted(
242 const std::filesystem::path& sections_dir) {
243 std::vector<std::string> stems;
244 for (
auto& [number, stem] : scan_sections(sections_dir)) {
245 stems.push_back(std::move(stem));
252std::optional<std::string> subfile_target(
const std::string& line) {
253 const std::string_view trimmed = trim(line);
254 constexpr std::string_view kPrefix =
"\\subfile{sections/";
255 if (!trimmed.starts_with(kPrefix))
return std::nullopt;
256 const std::size_t end = trimmed.find(
'}', kPrefix.size());
257 if (end == std::string_view::npos)
return std::nullopt;
258 return std::string(trimmed.substr(kPrefix.size(), end - kPrefix.size()));
261std::optional<std::filesystem::path> find_section_file(
262 const std::filesystem::path& sections_dir,
int number) {
263 const std::string prefix = std::to_string(number) +
"_";
265 if (!std::filesystem::is_directory(sections_dir, ec))
return std::nullopt;
267 for (
const auto& entry :
268 std::filesystem::directory_iterator(sections_dir, ec)) {
269 if (ec || !entry.is_regular_file())
continue;
270 if (entry.path().filename().string().starts_with(prefix)) {
280 const std::filesystem::path& start_dir) {
281 std::filesystem::path current =
282 std::filesystem::absolute(start_dir).lexically_normal();
284 PLOG_DEBUG <<
"Scanning upward for .notex/ starting at '"
285 << current.string() <<
"'.";
287 if (std::filesystem::is_directory(current /
".notex")) {
288 PLOG_DEBUG <<
"Found project root at '" << current.string()
292 const std::filesystem::path parent = current.parent_path();
293 if (parent == current) {
294 PLOG_DEBUG <<
"No .notex/ directory found above '"
295 << start_dir.string() <<
"'.";
302ProjectConfig Manager::load_config(
const std::filesystem::path& config_file) {
303 std::ifstream stream(config_file);
305 throw ConfigError(
"could not open '" + config_file.string() +
"'");
308 nlohmann::json parsed;
311 }
catch (
const nlohmann::json::exception& e) {
312 throw ConfigError(
"malformed '" + config_file.string() +
318 config.schema_version = parsed.value(
"schema_version", 1);
319 config.notex_version = parsed.value(
"notex_version", std::string());
320 config.project_type = parsed.value(
"project_type", std::string());
321 config.main_file = parsed.value(
"main_file", std::string(
"main.tex"));
322 config.installation_type =
323 parsed.value(
"installation_type", std::string());
324 config.theme = parsed.value(
"theme", std::string());
325 config.bibliography_file =
326 parsed.value(
"bibliography_file", std::string());
327 }
catch (
const nlohmann::json::exception& e) {
328 throw ConfigError(
"malformed '" + config_file.string() +
332 PLOG_DEBUG <<
"Loaded project config from '" << config_file.string()
339 if (!root.has_value()) {
341 "no NoTeX project found in '" + start_dir.string() +
342 "' or any parent directory (missing .notex/); run 'notex "
347 config_ = load_config(config_file_for(root_dir_));
355 json[
"schema_version"] =
config.schema_version;
356 json[
"notex_version"] =
config.notex_version;
357 json[
"project_type"] =
config.project_type;
358 json[
"main_file"] =
config.main_file;
359 json[
"installation_type"] =
config.installation_type;
360 json[
"theme"] =
config.theme;
361 json[
"bibliography_file"] =
config.bibliography_file;
363 const std::filesystem::path config_file = config_file_for(
root_dir);
364 std::filesystem::create_directories(config_file.parent_path());
365 std::ofstream stream(config_file);
367 throw ConfigError(
"could not write '" + config_file.string() +
"'");
369 stream << json.dump(2) <<
'\n';
370 PLOG_DEBUG <<
"Wrote project config to '" << config_file.string()
376 const std::filesystem::path main_file = target_dir /
"main.tex";
377 const bool already_project =
378 std::filesystem::is_directory(target_dir /
".notex") ||
379 std::filesystem::exists(main_file);
381 if (already_project && !force) {
383 "'" + target_dir.string() +
384 "' already looks like a NoTeX project (main.tex or .notex/ "
385 "already exists); pass --force to overwrite it.");
388 const std::string class_path = resolve_class_path(target_dir);
395 write_text_file(target_dir /
"sections" / (stem +
".tex"),
400 config.notex_version = PROJECT_VERSION;
402 config.main_file =
"main.tex";
406 <<
"-file project at '" << target_dir.string() <<
"'.";
415 if (!std::filesystem::is_directory(start_dir, ec)) {
return report; }
417 PLOG_DEBUG <<
"Scanning '" << start_dir.string()
418 <<
"' for build artefacts" << (dry_run ?
" (dry-run)" :
"")
422 std::filesystem::directory_options::skip_permission_denied;
424 std::filesystem::recursive_directory_iterator(start_dir, options, ec);
425 const auto end = std::filesystem::recursive_directory_iterator();
427 while (!ec && it != end) {
428 const std::filesystem::path path = it->path();
429 const std::string filename = path.filename().string();
431 if (it->is_directory(ec) && is_clean_target_directory(filename)) {
432 PLOG_DEBUG <<
"Matched artefact directory '" << path.string()
434 report.removed_directories.push_back(path);
435 if (!dry_run) { std::filesystem::remove_all(path, ec); }
436 it.disable_recursion_pending();
437 }
else if (it->is_regular_file(ec) && is_clean_target_file(filename)) {
438 PLOG_DEBUG <<
"Matched artefact file '" << path.string() <<
"'.";
439 report.removed_files.push_back(path);
440 if (!dry_run) { std::filesystem::remove(path, ec); }
446 PLOG_DEBUG <<
"Clean scan of '" << start_dir.string() <<
"' found "
452 constexpr std::string_view kPrefix =
"notex-theme-";
453 constexpr std::string_view kSuffix =
".tex";
455 std::vector<std::string> themes;
457 if (file.name.starts_with(kPrefix) && file.name.ends_with(kSuffix)) {
458 themes.emplace_back(file.name.substr(
459 kPrefix.size(), file.name.size() - kPrefix.size() - kSuffix.size()));
462 std::sort(themes.begin(), themes.end());
468 if (std::find(themes.begin(), themes.end(), std::string(theme)) ==
470 std::string available;
471 for (std::size_t i = 0; i < themes.size(); ++i) {
472 if (i != 0) available +=
", ";
473 available += themes[i];
476 "' is not a valid theme; available themes: " +
484 config_.theme = std::string(theme);
491 if (config_.project_type ==
"mono") {
493 is_end_document_line,
"a unique \\end{document} line");
495 anchor, {
"\\section{" + std::string(title) +
"}",
"",
""});
500 const std::filesystem::path sections_dir = root_dir_ /
"sections";
501 const int number = highest_section_number(sections_dir) + 1;
503 write_text_file(sections_dir / (stem +
".tex"),
506 const std::optional<std::size_t> last_subfile =
508 if (!last_subfile.has_value()) {
510 "could not find an existing \\subfile{sections/...} line in '" +
511 (root_dir_ / config_.main_file).string() +
512 "' to insert the new section after; the document may have "
513 "been restructured by hand.");
515 doc.
insert_line(*last_subfile + 1, subfile_line_for(stem));
520 if (config_.project_type !=
"multi") {
522 "removing a section is only supported in multi-file projects.");
525 const std::filesystem::path sections_dir = root_dir_ /
"sections";
526 const std::optional<std::filesystem::path> section_file =
527 find_section_file(sections_dir, number);
528 if (!section_file.has_value()) {
529 throw UsageError(
"no section numbered " + std::to_string(number) +
530 " was found in '" + sections_dir.string() +
"'.");
534 "Remove section '" + section_file->filename().string() +
535 "' and its \\subfile line? This cannot be undone.",
538 PLOG_WARNING <<
"Section removal cancelled by the user for '"
539 << section_file->string() <<
"'.";
544 const std::string stem = section_file->stem().string();
546 const std::vector<std::size_t> matches = doc.
find_all(
547 [&](
const std::string& line) {
return is_subfile_line_for(line, stem); });
548 if (matches.size() > 1) {
550 "found " + std::to_string(matches.size()) +
551 " \\subfile lines referencing '" + stem +
"' in '" +
552 (root_dir_ / config_.main_file).string() +
553 "', expected at most one; please resolve the ambiguity by "
556 if (matches.empty()) {
557 PLOG_WARNING <<
"No \\subfile line referencing '" << stem
558 <<
"' was found; only the section file will be "
560 ui::warning(
"No \\subfile line referencing '" + stem +
561 "' was found; only the section file will be removed.");
567 std::filesystem::remove(*section_file);
572 const std::string bib_filename = config_.bibliography_file.empty()
574 : config_.bibliography_file;
575 const std::filesystem::path bib_path = root_dir_ / bib_filename;
576 if (!std::filesystem::exists(bib_path)) {
581 const bool uses_biblatex = !doc.
find_all(is_biblatex_usepackage_line).empty();
582 const std::string bib_stem =
583 std::filesystem::path(bib_filename).stem().string();
586 ensure_line(doc,
"\\addbibresource{" + bib_filename +
"}",
587 is_addbibresource_line,
true);
588 ensure_line(doc,
"\\printbibliography", is_printbibliography_line,
591 ensure_line(doc,
"\\bibliographystyle{unsrturl}",
592 is_bibliographystyle_line,
false);
593 ensure_line(doc,
"\\bibliography{" + bib_stem +
"}",
594 is_bibliography_command_line,
false);
598 config_.bibliography_file = bib_filename;
604 remove_matching_lines(doc, is_addbibresource_line);
605 remove_matching_lines(doc, is_printbibliography_line);
606 remove_matching_lines(doc, is_bibliographystyle_line);
607 remove_matching_lines(doc, is_bibliography_command_line);
610 if (!config_.bibliography_file.empty()) {
611 const std::filesystem::path bib_path =
612 root_dir_ / config_.bibliography_file;
613 if (std::filesystem::exists(bib_path) &&
614 ui::confirm(
"Also delete '" + bib_path.string() +
"'?",
false)) {
615 std::filesystem::remove(bib_path);
619 config_.bibliography_file.clear();
624 std::vector<std::filesystem::path> files;
626 const std::filesystem::path main_path = root_dir_ / config_.main_file;
627 if (std::filesystem::exists(main_path)) { files.push_back(main_path); }
629 if (config_.project_type ==
"multi") {
631 for (
const auto& entry : std::filesystem::directory_iterator(
632 root_dir_ /
"sections", ec)) {
633 std::error_code file_ec;
634 if (entry.is_regular_file(file_ec) && !file_ec) {
635 files.push_back(entry.path());
640 if (!config_.bibliography_file.empty()) {
641 const std::filesystem::path bib_path =
642 root_dir_ / config_.bibliography_file;
643 if (std::filesystem::exists(bib_path)) { files.push_back(bib_path); }
650 if (config_.project_type !=
"multi")
return {};
652 const std::filesystem::path main_path = root_dir_ / config_.main_file;
653 if (!std::filesystem::exists(main_path))
return {};
656 std::vector<std::string> referenced;
657 for (
const std::string& line : doc.
lines()) {
658 if (
const auto target = subfile_target(line); target.has_value()) {
659 referenced.push_back(*target);
663 std::vector<std::string> orphans;
664 for (
const std::string& stem :
665 section_stems_sorted(root_dir_ /
"sections")) {
666 if (std::find(referenced.begin(), referenced.end(), stem) ==
668 orphans.push_back(stem);
677 "' from the template? The current version will "
678 "be backed up to '" +
679 config_.main_file +
".bak'.",
681 PLOG_WARNING <<
"Reset cancelled by the user for '"
682 << (root_dir_ / config_.main_file).
string() <<
"'.";
687 const std::filesystem::path main_path = root_dir_ / config_.main_file;
688 if (std::filesystem::exists(main_path)) {
689 std::filesystem::copy_file(
690 main_path, root_dir_ / (config_.main_file +
".bak"),
691 std::filesystem::copy_options::overwrite_existing);
694 const std::string class_path = resolve_class_path(root_dir_);
695 if (config_.project_type ==
"mono") {
698 std::vector<std::string> stems =
699 section_stems_sorted(root_dir_ /
"sections");
700 if (stems.empty()) { stems.push_back(
"1_introduction"); }
708 const std::string prompt =
710 ?
"Delete the entire project at '" + root_dir_.string() +
711 "', including every file? This cannot be undone."
712 :
"Remove NoTeX's scaffolding (.notex/, settings/, fonts/, "
713 "build artefacts) from '" +
715 "'? User files (the main file, sections/, the "
716 "bibliography) are kept.";
718 PLOG_WARNING <<
"Deletion cancelled by the user for '"
719 << root_dir_.string() <<
"'.";
725 std::filesystem::remove_all(root_dir_);
731 std::filesystem::remove_all(root_dir_ /
".notex", ec);
732 std::filesystem::remove_all(root_dir_ /
"settings", ec);
733 std::filesystem::remove_all(root_dir_ /
"fonts", ec);
738 if (key ==
"main_file") {
739 config_.main_file = std::string(value);
740 }
else if (key ==
"theme") {
741 config_.theme = std::string(value);
742 }
else if (key ==
"bibliography_file") {
743 config_.bibliography_file = std::string(value);
746 "' is not a settable key; expected one of "
747 "'main_file', 'theme', 'bibliography_file'.");
Accessor over the LaTeX template and font files embedded into the binary.
Raised when notex.json is missing, malformed, or fails to write.
Loads a .tex file as a sequence of lines and offers editing primitives anchored on recognisable landm...
void set_documentclass_option(std::string_view option, const std::vector< std::string > &mutually_exclusive_group)
Rewrites the document's unique \documentclass line so that its bracketed options contain option inste...
static Document load(const std::filesystem::path &path)
Loads path as a sequence of lines.
std::optional< std::size_t > find_last(const LinePredicate &predicate) const
std::function< bool(const std::string &)> LinePredicate
std::size_t find_unique(const LinePredicate &predicate, std::string_view description) const
Locates the single line matching predicate.
std::vector< std::size_t > find_all(const LinePredicate &predicate) const
void insert_lines(std::size_t index, std::vector< std::string > new_lines)
Inserts new_lines, in order, before the current line index.
void insert_line(std::size_t index, std::string text)
Inserts text as a new line before the current line index.
void remove_line(std::size_t index)
Removes the line at index.
const std::vector< std::string > & lines() const noexcept
void save() const
Writes the current lines back to disk atomically.
Raised when the surrounding TeX environment cannot be resolved.
Represents the surrounding system as NoTeX perceives it: where TeX expects user files to live,...
Raised when a filesystem operation (copy, write, remove) fails.
bool delete_scaffolding(bool remove_all=false, bool force=false)
Removes NoTeX's own scaffolding from the project.
const std::filesystem::path & root_dir() const noexcept
void add_bibliography()
Adds a bibliography.
std::vector< std::string > orphan_sections() const
static CleanReport clean(const std::filesystem::path &start_dir, bool dry_run=false)
Recursively removes known LaTeX build artefacts from start_dir.
std::vector< std::filesystem::path > project_files() const
void set_config_value(std::string_view key, std::string_view value)
Sets a single whitelisted metadata key directly.
static std::optional< std::filesystem::path > find_project_root(const std::filesystem::path &start_dir)
Searches upward from start_dir for a .notex/ directory, without requiring one to exist.
void save() const
Persists the current metadata back to .notex/notex.json.
void add_section(std::string_view title)
Adds a new section titled title.
void remove_bibliography()
Removes the bibliography commands added by add_bibliography() from the main file.
void set_theme(std::string_view theme)
Switches the project's theme.
Manager(std::filesystem::path start_dir=std::filesystem::current_path())
Locates and loads the project that contains start_dir.
static void write_config(const std::filesystem::path &root_dir, const ProjectConfig &config)
Writes config as <root_dir>/.notex/notex.json, creating <root_dir>/.notex/ first if it does not alrea...
bool reset(bool force=false)
Regenerates the main file from the template, after backing up the previous one to <main_file>....
static std::vector< std::string > available_themes()
static Manager init(const std::filesystem::path &target_dir, templates::ProjectType project_type=templates::ProjectType::MULTI, bool force=false)
Creates a new NoTeX project inside target_dir.
const ProjectConfig & config() const noexcept
bool remove_section(int number)
Removes the section numbered number.
Raised when a command that requires a project is run outside one.
Raised when the command line supplied by the user is invalid.
Document: a small, safe line-oriented editor for a single .tex file.
Environment: discovers where TeX lives and whether NoTeX is installed.
Exception hierarchy and process exit codes used throughout NoTeX.
Logging utility with plog initialization.
Manager: represents and operates on a single NoTeX project.
const std::vector< EmbeddedFile > & latex_files()
std::string section(int number, std::string_view title)
Generates a numbered section subfile.
std::string mono_main(std::string_view class_path)
Generates the entry file for a single-file project.
std::string multi_main(std::string_view class_path, const std::vector< std::string > §ion_stems={"1_introduction"})
Generates the entry file for a multi-file project, referencing section_stems in order.
std::string bibliography_starter()
std::string section_stem(int number, std::string_view title)
Computes the filename stem (no directory, no extension) for a numbered section.
@ MONO
A single main.tex file.
std::string_view to_string(ProjectType type)
void warning(std::string_view message)
bool confirm(std::string_view prompt, bool default_answer=false)
Asks the user to confirm an action.
@ NONE
Neither a local nor a global installation was found.
Styled terminal output and interactive confirmation prompts.
Outcome of a Manager::clean() call.
std::size_t total_removed() const noexcept
Persisted metadata for a single NoTeX project, stored as JSON in .notex/notex.json.