using GLib; namespace Spry.Cli { /** * File-shaping helpers for the spry CLI: template substitution, marker * block insertion, name normalisation and recursive copy/remove. */ public class Generator : Object { /** * Replaces every `{{KEY}}` placeholder in `template` with the value * under `KEY` in `vars`. */ public static string fill(string template, HashTable vars) { var result = template; foreach (var key in vars.get_keys()) { result = result.replace("{{" + key + "}}", vars.lookup(key)); } return result; } /** * Writes `contents` to `path`, creating parent directories as * needed. `mode` is applied after writing — pass 0600 for files * carrying key material (`web-config.json`) so other local users * cannot read them. */ public static void write_file(string path, string contents, uint32 mode = 0644) throws Error { var parent = File.new_for_path(path).get_parent(); if (parent != null && !((!)parent).query_exists()) { try { ((!)parent).make_directory_with_parents(null); } catch (Error e) { if (!FileUtils.test(path, FileTest.EXISTS)) { throw e; } } } var stream = new DataOutputStream(File.new_for_path(path).replace(null, false, FileCreateFlags.NONE)); stream.put_string(contents); stream.close(); File.new_for_path(path).set_attribute_uint32(FileAttribute.UNIX_MODE, mode, FileQueryInfoFlags.NONE, null); } /** Reads `path` in one string. */ public static string read_file(string path) throws Error { string contents; if (!FileUtils.get_contents(path, out contents)) { throw cli_error("cannot read %s", path); } return contents; } /** * Appends `section` to `path` unless a line equal to `header` is * already present. The file is created when absent. */ public static void append_section(string path, string header, string section) throws Error { var contents = ""; if (FileUtils.test(path, FileTest.EXISTS)) { contents = read_file(path); if (contents.contains(header)) { return; } if (contents.length > 0 && !contents.has_suffix("\n")) { contents += "\n"; } if (contents.length > 0) { contents += "\n"; } } write_file(path, contents + section.strip() + "\n"); } /** * Inserts `snippet` between the `begin`/`end` marker lines inside * `contents`, returning the updated text. Insertion is idempotent: * when the snippet's first non-empty line already appears between the * markers, `contents` is returned unchanged. Snippet lines are * indented to match the block's existing content (or the marker line * itself for an empty block); surrounding lines are never touched. */ public static string insert_marked(string contents, string begin, string end, string snippet) throws Error { string[] lines = contents.split("\n", -1); int begin_index = -1; int end_index = -1; for (int i = 0; i < lines.length; i++) { if (begin_index < 0) { if (lines[i].strip() == begin) { begin_index = i; } } else if (lines[i].strip() == end) { end_index = i; break; } } if (begin_index < 0 || end_index < 0) { throw cli_error("marker block %s … %s not found — was this file generated by spry?", begin, end); } string[] snippet_lines = snippet.split("\n", -1); string first = ""; foreach (var line in snippet_lines) { if (line.strip().length > 0) { first = line.strip(); break; } } string indent = ""; for (int i = end_index - 1; i > begin_index; i--) { var stripped = lines[i].strip(); if (stripped.length > 0) { indent = lines[i].substring(0, lines[i].length - stripped.length); break; } } if (indent.length == 0) { var stripped = lines[begin_index].strip(); indent = lines[begin_index].substring(0, lines[begin_index].length - stripped.length); } for (int i = begin_index + 1; i < end_index; i++) { if (lines[i].strip() == first) { return contents; } } var b = new StringBuilder(); for (int i = 0; i < end_index; i++) { b.append(lines[i]).append("\n"); } foreach (var line in snippet_lines) { b.append(line.length > 0 ? indent + line : "").append("\n"); } for (int i = end_index; i < lines.length; i++) { b.append(lines[i]); if (i < lines.length - 1) { b.append("\n"); } } return b.str; } /** True when `name` is a valid application/page/action name. */ public static bool valid_name(string name) { if (name.length == 0 || !is_letter(name.data[0])) { return false; } foreach (var c in name.data) { if (!(is_word_byte(c) || c == '-' || c == '_')) { return false; } } return true; } private static bool is_letter(uint8 c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } private static bool is_word_byte(uint8 c) { return is_letter(c) || (c >= '0' && c <= '9'); } /** `user-management` → `UserManagement`. */ public static string pascal_case(string name) { var result = new StringBuilder(); var capitalize = true; foreach (var c in name.data) { if (c == '-' || c == '_') { capitalize = true; } else if (capitalize) { result.append_c((char) (c >= 'a' && c <= 'z' ? c - 32 : c)); capitalize = false; } else { result.append_c((char) c); } } return result.str; } /** `UserManagement` → `user_management`. */ public static string snake_case(string name) { return name.down().replace("-", "_"); } /** * Copies the tree at `source` to `destination`, skipping build * output, VCS state and SQLite stores. */ public static void copy_tree(string source, string destination) throws Error { copy_recursive(File.new_for_path(source), File.new_for_path(destination)); } /** Recursively removes the tree at `path` (including `path` itself). */ public static void remove_tree(string path) throws Error { var file = File.new_for_path(path); if (!file.query_exists()) { return; } remove_recursive(file); } private static void copy_recursive(File source, File destination) throws Error { var type = source.query_file_type(FileQueryInfoFlags.NONE); if (type == FileType.DIRECTORY) { if (!destination.query_exists()) { destination.make_directory_with_parents(null); } var enumerator = source.enumerate_children("standard::name", FileQueryInfoFlags.NONE); FileInfo info; while ((info = enumerator.next_file()) != null) { var name = info.get_name(); if (skipped(name)) { continue; } var child = File.new_for_path(Path.build_filename(source.get_path(), name)); var child_copy = File.new_for_path(Path.build_filename(destination.get_path(), name)); copy_recursive(child, child_copy); } } else if (type == FileType.REGULAR) { var parent = destination.get_parent(); if (parent != null && !((!)parent).query_exists()) { ((!)parent).make_directory_with_parents(null); } source.copy(destination, FileCopyFlags.OVERWRITE); } } private static void remove_recursive(File file) throws Error { var type = file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS); if (type == FileType.DIRECTORY) { var enumerator = file.enumerate_children("standard::name", FileQueryInfoFlags.NOFOLLOW_SYMLINKS); FileInfo info; while ((info = enumerator.next_file()) != null) { remove_recursive(File.new_for_path(Path.build_filename(file.get_path(), info.get_name()))); } } if (file.query_exists()) { file.delete(); } } private static bool skipped(string name) { return name == "builddir" || name == ".git" || name == ".docker-build" || name.contains(".sqlite"); } /** Builds a CLI error with a printf-style message. */ public static Error cli_error(string format, ...) { return new Error(Quark.from_string("spry-cli"), 0, format, va_list()); } } /** * Locates the installed statum tools and prepares the environment child * processes need (the statum tools silently load stale system libraries * when LD_LIBRARY_PATH is unset). */ public class Tools : Object { /** * Resolves `name` to an executable path: `--tools-dir` when given, * else PATH, else ~/.local/bin. */ public static string resolve(string name, string? tools_dir) throws Error { if (tools_dir != null) { var path = Path.build_filename(tools_dir, name); if (FileUtils.test(path, FileTest.IS_EXECUTABLE)) { return path; } throw Generator.cli_error("%s not found in --tools-dir %s", name, (!)tools_dir); } var found = Environment.find_program_in_path(name); if (found != null) { return (!)found; } var fallback = Path.build_filename(Environment.get_home_dir(), ".local", "bin", name); if (FileUtils.test(fallback, FileTest.IS_EXECUTABLE)) { return fallback; } throw Generator.cli_error("%s not found on PATH — pass --tools-dir", name); } /** * The library directory spawned tools should see: the `--libdir` * override, else the pkg-config libdir of spry-0.2, else * ~/.local/lib64. */ public static string default_libdir(string? libdir) { if (libdir != null) { return (!)libdir; } try { var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE); var process = launcher.spawnv({ "pkg-config", "--variable=libdir", "spry-0.2" }); string output; process.communicate_utf8(null, null, out output, null); if (process.get_successful()) { var value = (output ?? "").strip(); if (value.length > 0 && FileUtils.test(value, FileTest.IS_DIR)) { return value; } } } catch { } return Path.build_filename(Environment.get_home_dir(), ".local", "lib64"); } /** * Points `launcher`'s LD_LIBRARY_PATH/PKG_CONFIG_PATH/XDG_DATA_DIRS * at the Spry stack's library and share directories so spawned * processes load the freshly built libraries, resolve their * pkg-config packages and find the installed vapis — regardless of * the invoking shell's environment. */ public static void apply_env(SubprocessLauncher launcher, string? libdir) { var dir = default_libdir(libdir); prepend(launcher, "LD_LIBRARY_PATH", dir); prepend(launcher, "PKG_CONFIG_PATH", Path.build_filename(dir, "pkgconfig")); prepend(launcher, "XDG_DATA_DIRS", Path.build_filename(Path.get_dirname(dir), "share")); } private static void prepend(SubprocessLauncher launcher, string name, string dir) { var existing = Environment.get_variable(name); var value = existing != null && existing.length > 0 ? dir + Path.SEARCHPATH_SEPARATOR_S + existing : dir; launcher.setenv(name, value, true); } } }