Generator.vala 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. using GLib;
  2. namespace Spry.Cli {
  3. /**
  4. * File-shaping helpers for the spry CLI: template substitution, marker
  5. * block insertion, name normalisation and recursive copy/remove.
  6. */
  7. public class Generator : Object {
  8. /**
  9. * Replaces every `{{KEY}}` placeholder in `template` with the value
  10. * under `KEY` in `vars`.
  11. */
  12. public static string fill(string template, HashTable<string, string> vars) {
  13. var result = template;
  14. foreach (var key in vars.get_keys()) {
  15. result = result.replace("{{" + key + "}}", vars.lookup(key));
  16. }
  17. return result;
  18. }
  19. /**
  20. * Writes `contents` to `path`, creating parent directories as
  21. * needed. `mode` is applied after writing — pass 0600 for files
  22. * carrying key material (`web-config.json`) so other local users
  23. * cannot read them.
  24. */
  25. public static void write_file(string path, string contents, uint32 mode = 0644) throws Error {
  26. var parent = File.new_for_path(path).get_parent();
  27. if (parent != null && !((!)parent).query_exists()) {
  28. try {
  29. ((!)parent).make_directory_with_parents(null);
  30. } catch (Error e) {
  31. if (!FileUtils.test(path, FileTest.EXISTS)) {
  32. throw e;
  33. }
  34. }
  35. }
  36. var stream = new DataOutputStream(File.new_for_path(path).replace(null, false, FileCreateFlags.NONE));
  37. stream.put_string(contents);
  38. stream.close();
  39. File.new_for_path(path).set_attribute_uint32(FileAttribute.UNIX_MODE, mode,
  40. FileQueryInfoFlags.NONE, null);
  41. }
  42. /** Reads `path` in one string. */
  43. public static string read_file(string path) throws Error {
  44. string contents;
  45. if (!FileUtils.get_contents(path, out contents)) {
  46. throw cli_error("cannot read %s", path);
  47. }
  48. return contents;
  49. }
  50. /**
  51. * Appends `section` to `path` unless a line equal to `header` is
  52. * already present. The file is created when absent.
  53. */
  54. public static void append_section(string path, string header, string section) throws Error {
  55. var contents = "";
  56. if (FileUtils.test(path, FileTest.EXISTS)) {
  57. contents = read_file(path);
  58. if (contents.contains(header)) {
  59. return;
  60. }
  61. if (contents.length > 0 && !contents.has_suffix("\n")) {
  62. contents += "\n";
  63. }
  64. if (contents.length > 0) {
  65. contents += "\n";
  66. }
  67. }
  68. write_file(path, contents + section.strip() + "\n");
  69. }
  70. /**
  71. * Inserts `snippet` between the `begin`/`end` marker lines inside
  72. * `contents`, returning the updated text. Insertion is idempotent:
  73. * when the snippet's first non-empty line already appears between the
  74. * markers, `contents` is returned unchanged. Snippet lines are
  75. * indented to match the block's existing content (or the marker line
  76. * itself for an empty block); surrounding lines are never touched.
  77. */
  78. public static string insert_marked(string contents, string begin, string end, string snippet) throws Error {
  79. string[] lines = contents.split("\n", -1);
  80. int begin_index = -1;
  81. int end_index = -1;
  82. for (int i = 0; i < lines.length; i++) {
  83. if (begin_index < 0) {
  84. if (lines[i].strip() == begin) {
  85. begin_index = i;
  86. }
  87. } else if (lines[i].strip() == end) {
  88. end_index = i;
  89. break;
  90. }
  91. }
  92. if (begin_index < 0 || end_index < 0) {
  93. throw cli_error("marker block %s … %s not found — was this file generated by spry?", begin, end);
  94. }
  95. string[] snippet_lines = snippet.split("\n", -1);
  96. string first = "";
  97. foreach (var line in snippet_lines) {
  98. if (line.strip().length > 0) {
  99. first = line.strip();
  100. break;
  101. }
  102. }
  103. string indent = "";
  104. for (int i = end_index - 1; i > begin_index; i--) {
  105. var stripped = lines[i].strip();
  106. if (stripped.length > 0) {
  107. indent = lines[i].substring(0, lines[i].length - stripped.length);
  108. break;
  109. }
  110. }
  111. if (indent.length == 0) {
  112. var stripped = lines[begin_index].strip();
  113. indent = lines[begin_index].substring(0, lines[begin_index].length - stripped.length);
  114. }
  115. for (int i = begin_index + 1; i < end_index; i++) {
  116. if (lines[i].strip() == first) {
  117. return contents;
  118. }
  119. }
  120. var b = new StringBuilder();
  121. for (int i = 0; i < end_index; i++) {
  122. b.append(lines[i]).append("\n");
  123. }
  124. foreach (var line in snippet_lines) {
  125. b.append(line.length > 0 ? indent + line : "").append("\n");
  126. }
  127. for (int i = end_index; i < lines.length; i++) {
  128. b.append(lines[i]);
  129. if (i < lines.length - 1) {
  130. b.append("\n");
  131. }
  132. }
  133. return b.str;
  134. }
  135. /** True when `name` is a valid application/page/action name. */
  136. public static bool valid_name(string name) {
  137. if (name.length == 0 || !is_letter(name.data[0])) {
  138. return false;
  139. }
  140. foreach (var c in name.data) {
  141. if (!(is_word_byte(c) || c == '-' || c == '_')) {
  142. return false;
  143. }
  144. }
  145. return true;
  146. }
  147. private static bool is_letter(uint8 c) {
  148. return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
  149. }
  150. private static bool is_word_byte(uint8 c) {
  151. return is_letter(c) || (c >= '0' && c <= '9');
  152. }
  153. /** `user-management` → `UserManagement`. */
  154. public static string pascal_case(string name) {
  155. var result = new StringBuilder();
  156. var capitalize = true;
  157. foreach (var c in name.data) {
  158. if (c == '-' || c == '_') {
  159. capitalize = true;
  160. } else if (capitalize) {
  161. result.append_c((char) (c >= 'a' && c <= 'z' ? c - 32 : c));
  162. capitalize = false;
  163. } else {
  164. result.append_c((char) c);
  165. }
  166. }
  167. return result.str;
  168. }
  169. /** `UserManagement` → `user_management`. */
  170. public static string snake_case(string name) {
  171. return name.down().replace("-", "_");
  172. }
  173. /**
  174. * Copies the tree at `source` to `destination`, skipping build
  175. * output, VCS state and SQLite stores.
  176. */
  177. public static void copy_tree(string source, string destination) throws Error {
  178. copy_recursive(File.new_for_path(source), File.new_for_path(destination));
  179. }
  180. /** Recursively removes the tree at `path` (including `path` itself). */
  181. public static void remove_tree(string path) throws Error {
  182. var file = File.new_for_path(path);
  183. if (!file.query_exists()) {
  184. return;
  185. }
  186. remove_recursive(file);
  187. }
  188. private static void copy_recursive(File source, File destination) throws Error {
  189. var type = source.query_file_type(FileQueryInfoFlags.NONE);
  190. if (type == FileType.DIRECTORY) {
  191. if (!destination.query_exists()) {
  192. destination.make_directory_with_parents(null);
  193. }
  194. var enumerator = source.enumerate_children("standard::name", FileQueryInfoFlags.NONE);
  195. FileInfo info;
  196. while ((info = enumerator.next_file()) != null) {
  197. var name = info.get_name();
  198. if (skipped(name)) {
  199. continue;
  200. }
  201. var child = File.new_for_path(Path.build_filename(source.get_path(), name));
  202. var child_copy = File.new_for_path(Path.build_filename(destination.get_path(), name));
  203. copy_recursive(child, child_copy);
  204. }
  205. } else if (type == FileType.REGULAR) {
  206. var parent = destination.get_parent();
  207. if (parent != null && !((!)parent).query_exists()) {
  208. ((!)parent).make_directory_with_parents(null);
  209. }
  210. source.copy(destination, FileCopyFlags.OVERWRITE);
  211. }
  212. }
  213. private static void remove_recursive(File file) throws Error {
  214. var type = file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
  215. if (type == FileType.DIRECTORY) {
  216. var enumerator = file.enumerate_children("standard::name", FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
  217. FileInfo info;
  218. while ((info = enumerator.next_file()) != null) {
  219. remove_recursive(File.new_for_path(Path.build_filename(file.get_path(), info.get_name())));
  220. }
  221. }
  222. if (file.query_exists()) {
  223. file.delete();
  224. }
  225. }
  226. private static bool skipped(string name) {
  227. return name == "builddir" || name == ".git" || name == ".docker-build" || name.contains(".sqlite");
  228. }
  229. /** Builds a CLI error with a printf-style message. */
  230. public static Error cli_error(string format, ...) {
  231. return new Error(Quark.from_string("spry-cli"), 0, format, va_list());
  232. }
  233. }
  234. /**
  235. * Locates the installed statum tools and prepares the environment child
  236. * processes need (the statum tools silently load stale system libraries
  237. * when LD_LIBRARY_PATH is unset).
  238. */
  239. public class Tools : Object {
  240. /**
  241. * Resolves `name` to an executable path: `--tools-dir` when given,
  242. * else PATH, else ~/.local/bin.
  243. */
  244. public static string resolve(string name, string? tools_dir) throws Error {
  245. if (tools_dir != null) {
  246. var path = Path.build_filename(tools_dir, name);
  247. if (FileUtils.test(path, FileTest.IS_EXECUTABLE)) {
  248. return path;
  249. }
  250. throw Generator.cli_error("%s not found in --tools-dir %s", name, (!)tools_dir);
  251. }
  252. var found = Environment.find_program_in_path(name);
  253. if (found != null) {
  254. return (!)found;
  255. }
  256. var fallback = Path.build_filename(Environment.get_home_dir(), ".local", "bin", name);
  257. if (FileUtils.test(fallback, FileTest.IS_EXECUTABLE)) {
  258. return fallback;
  259. }
  260. throw Generator.cli_error("%s not found on PATH — pass --tools-dir", name);
  261. }
  262. /**
  263. * The library directory spawned tools should see: the `--libdir`
  264. * override, else the pkg-config libdir of spry-0.2, else
  265. * ~/.local/lib64.
  266. */
  267. public static string default_libdir(string? libdir) {
  268. if (libdir != null) {
  269. return (!)libdir;
  270. }
  271. try {
  272. var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE);
  273. var process = launcher.spawnv({ "pkg-config", "--variable=libdir", "spry-0.2" });
  274. string output;
  275. process.communicate_utf8(null, null, out output, null);
  276. if (process.get_successful()) {
  277. var value = (output ?? "").strip();
  278. if (value.length > 0 && FileUtils.test(value, FileTest.IS_DIR)) {
  279. return value;
  280. }
  281. }
  282. } catch {
  283. }
  284. return Path.build_filename(Environment.get_home_dir(), ".local", "lib64");
  285. }
  286. /**
  287. * Points `launcher`'s LD_LIBRARY_PATH/PKG_CONFIG_PATH/XDG_DATA_DIRS
  288. * at the Spry stack's library and share directories so spawned
  289. * processes load the freshly built libraries, resolve their
  290. * pkg-config packages and find the installed vapis — regardless of
  291. * the invoking shell's environment.
  292. */
  293. public static void apply_env(SubprocessLauncher launcher, string? libdir) {
  294. var dir = default_libdir(libdir);
  295. prepend(launcher, "LD_LIBRARY_PATH", dir);
  296. prepend(launcher, "PKG_CONFIG_PATH", Path.build_filename(dir, "pkgconfig"));
  297. prepend(launcher, "XDG_DATA_DIRS", Path.build_filename(Path.get_dirname(dir), "share"));
  298. }
  299. private static void prepend(SubprocessLauncher launcher, string name, string dir) {
  300. var existing = Environment.get_variable(name);
  301. var value = existing != null && existing.length > 0
  302. ? dir + Path.SEARCHPATH_SEPARATOR_S + existing
  303. : dir;
  304. launcher.setenv(name, value, true);
  305. }
  306. }
  307. }