← Commit history

feat(apm): add apm new and apm check, require src/main/ash module root; restore colored compiler errors

lennoxrose committed on Oct 18, 2026 at 01:58 UTC

ae4d728parenta7a5352

7 files changed+254−16

- apm new <name> scaffolds a module (ash.pkg, README, src/main/ash/<pkg>/<Class>.ash)
- apm check verifies module conventions (manifest fields, LICENSE/README, package matches directory)
- compiler only treats <module>/src/main/ash as a source root; apm install rejects modules without it
- front-end diagnostics are colored again on a TTY (honors NO_COLOR)

hearth/apm/src/C/cmd.cmodified

+200−0
@@ -5,6 +5,8 @@
55 #include <string.h>
66 #include <unistd.h>
77 #include <limits.h>
8+#include <dirent.h>
9+#include <stdarg.h>
810 #include "H/cmd.h"
911 #include "H/manifest.h"
1012 #include "H/install.h"
@@ -24,6 +26,204 @@ static void infer_project_name(char *out, int out_size) {
2426 out[len] = '\0';
2527 }
2628
29+// Package id for a module name: lowercase, every non-alphanumeric run becomes '_' ("My-Lib" -> "my_lib").
30+static void package_id(const char *name, char *out, int out_size) {
31+ int n = 0;
32+ for (const char *c = name; *c && n < out_size - 2; c++) {
33+ char ch = *c;
34+ if (ch >= 'A' && ch <= 'Z') ch = (char)(ch - 'A' + 'a');
35+ int word = (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9');
36+ if (word) out[n++] = ch;
37+ else if (n > 0 && out[n - 1] != '_') out[n++] = '_';
38+ }
39+ while (n > 0 && out[n - 1] == '_') n--;
40+ if (n == 0 || (out[0] >= '0' && out[0] <= '9')) { memmove(out + 1, out, (size_t)n); out[0] = '_'; n++; }
41+ out[n] = '\0';
42+}
43+
44+// Class name for a package id: "my_lib" -> "MyLib".
45+static void class_id(const char *pkg, char *out, int out_size) {
46+ int n = 0, up = 1;
47+ for (const char *c = pkg; *c && n < out_size - 1; c++) {
48+ if (*c == '_') { up = 1; continue; }
49+ out[n++] = (up && *c >= 'a' && *c <= 'z') ? (char)(*c - 'a' + 'A') : *c;
50+ up = 0;
51+ }
52+ out[n] = '\0';
53+}
54+
55+static int write_file(const char *path, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
56+static int write_file(const char *path, const char *fmt, ...) {
57+ FILE *f = fopen(path, "w");
58+ if (!f) { ui_err("could not write %s", path); return 0; }
59+ va_list ap;
60+ va_start(ap, fmt);
61+ vfprintf(f, fmt, ap);
62+ va_end(ap);
63+ fclose(f);
64+ return 1;
65+}
66+
67+// apm new <name>
68+// scaffolds ./<name>/ as an Ash module. Every module has the same shape: the source root is
69+// src/main/ash, and a type in package p is src/main/ash/p/<Type>.ash, so users write `import p.Type;`.
70+int cmd_new(const char *project_dir, int argc, char **argv) {
71+ if (argc != 1 || argv[0][0] == '\0' || strchr(argv[0], '/')) {
72+ ui_err("usage: apm new <name>");
73+ return 1;
74+ }
75+ const char *name = argv[0];
76+ char pkg[MANIFEST_NAME_LEN], cls[MANIFEST_NAME_LEN];
77+ package_id(name, pkg, sizeof(pkg));
78+ class_id(pkg, cls, sizeof(cls));
79+
80+ char root[1024], src[1100], path[1200];
81+ snprintf(root, sizeof(root), "%s/%s", project_dir, name);
82+ if (fsutil_dir_exists(root)) { ui_err("%s already exists", root); return 1; }
83+ snprintf(src, sizeof(src), "%s/src/main/ash/%s", root, pkg);
84+ if (!fsutil_mkdir_p(src)) { ui_err("could not create %s", src); return 1; }
85+
86+ Manifest m;
87+ manifest_init_default(&m, name);
88+ snprintf(m.main, sizeof(m.main), "%s.%s", pkg, cls);
89+ snprintf(m.description, sizeof(m.description), "TODO: describe %s", name);
90+ entry_set(&m.scripts, "build", "for f in $(find src/main/ash -name '*.ash'); do ASH_PATH=src/main/ash ashvm check \"$f\" || exit 1; done");
91+ if (!manifest_save(root, &m)) return 1;
92+
93+ snprintf(path, sizeof(path), "%s/%s.ash", src, cls);
94+ if (!write_file(path,
95+ "package %s;\n\n/// Entry point of %s.\npublic final class %s {\n"
96+ " private %s() { }\n\n public static String name() {\n return \"%s\";\n }\n}\n",
97+ pkg, name, cls, cls, name)) return 1;
98+ snprintf(path, sizeof(path), "%s/README.md", root);
99+ if (!write_file(path,
100+ "# %s\n\n```sh\napm add github:<user>/%s\n```\n\n```ash\nimport %s.%s;\n```\n",
101+ name, name, pkg, cls)) return 1;
102+ snprintf(path, sizeof(path), "%s/.gitignore", root);
103+ if (!write_file(path, "@ash-modules/\n")) return 1;
104+
105+ ui_ok("created %s (import %s.%s;)", root, pkg, cls);
106+ return 0;
107+}
108+
109+// ---- apm check --------------------------------------------------------------------------------
110+// Module conventions, so every Ash module looks the same:
111+// ash.pkg has name, version, description, license; README.md and LICENSE exist;
112+// sources live under src/main/ash; every .ash file is <Type>.ash (UpperCamelCase), declares
113+// `package a.b;` matching its directory a/b, and declares a type named like the file.
114+typedef struct { int problems; } Check;
115+
116+static void check_fail(Check *c, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
117+static void check_fail(Check *c, const char *fmt, ...) {
118+ char msg[1400];
119+ va_list ap;
120+ va_start(ap, fmt);
121+ vsnprintf(msg, sizeof(msg), fmt, ap);
122+ va_end(ap);
123+ ui_err("%s", msg);
124+ c->problems++;
125+}
126+
127+static int has_suffix(const char *s, const char *suffix) {
128+ size_t a = strlen(s), b = strlen(suffix);
129+ return a >= b && strcmp(s + a - b, suffix) == 0;
130+}
131+
132+static int file_exists(const char *dir, const char *name) {
133+ char p[1100];
134+ snprintf(p, sizeof(p), "%s/%s", dir, name);
135+ return access(p, F_OK) == 0;
136+}
137+
138+// first `package x.y;` of the file, as a path "x/y" ("" = none)
139+static void read_package(const char *path, char *out, int out_size) {
140+ out[0] = '\0';
141+ FILE *f = fopen(path, "r");
142+ if (!f) return;
143+ char line[512];
144+ while (fgets(line, sizeof(line), f)) {
145+ if (strncmp(line, "package ", 8) != 0) continue;
146+ char *semi = strchr(line, ';');
147+ if (!semi) break;
148+ *semi = '\0';
149+ snprintf(out, (size_t)out_size, "%s", line + 8);
150+ for (char *c = out; *c; c++) if (*c == '.') *c = '/';
151+ break;
152+ }
153+ fclose(f);
154+}
155+
156+static int file_declares_type(const char *path, const char *type) {
157+ FILE *f = fopen(path, "r");
158+ if (!f) return 0;
159+ char line[512], want[160];
160+ int found = 0;
161+ while (!found && fgets(line, sizeof(line), f)) {
162+ const char *kws[] = { "class ", "interface ", "enum ", "record " };
163+ for (int i = 0; i < 4 && !found; i++) {
164+ snprintf(want, sizeof(want), "%s%s", kws[i], type);
165+ const char *at = strstr(line, want);
166+ char after = at ? at[strlen(want)] : 0;
167+ if (at && (after == ' ' || after == '<' || after == '{' || after == '(' || after == '\n' || after == '\0')) found = 1;
168+ }
169+ }
170+ fclose(f);
171+ return found;
172+}
173+
174+static void check_tree(Check *c, const char *root, const char *rel) {
175+ char dir[1100];
176+ if (rel[0]) snprintf(dir, sizeof(dir), "%s/%s", root, rel); else snprintf(dir, sizeof(dir), "%s", root);
177+ DIR *d = opendir(dir);
178+ if (!d) return;
179+ for (struct dirent *e = readdir(d); e; e = readdir(d)) {
180+ if (e->d_name[0] == '.') continue;
181+ char sub[1100];
182+ snprintf(sub, sizeof(sub), "%s/%s", dir, e->d_name);
183+ char subrel[1100];
184+ if (rel[0]) snprintf(subrel, sizeof(subrel), "%s/%s", rel, e->d_name); else snprintf(subrel, sizeof(subrel), "%s", e->d_name);
185+ if (fsutil_dir_exists(sub)) { check_tree(c, root, subrel); continue; }
186+ if (!has_suffix(e->d_name, ".ash")) continue;
187+
188+ char type[160];
189+ snprintf(type, sizeof(type), "%s", e->d_name);
190+ type[strlen(type) - 4] = '\0';
191+ if (type[0] < 'A' || type[0] > 'Z') check_fail(c, "%s: file name must be UpperCamelCase (<Type>.ash)", subrel);
192+ char pkg[512];
193+ read_package(sub, pkg, sizeof(pkg));
194+ char expect[1100] = "";
195+ if (rel[0]) snprintf(expect, sizeof(expect), "%s", rel);
196+ if (strcmp(pkg, expect) != 0) check_fail(c, "%s: package must match its directory ('%s')", subrel, expect);
197+ else if (!file_declares_type(sub, type)) check_fail(c, "%s: must declare a type named %s", subrel, type);
198+ }
199+ closedir(d);
200+}
201+
202+int cmd_check(const char *project_dir, int argc, char **argv) {
203+ (void)argc; (void)argv;
204+ Check c = { 0 };
205+ Manifest m;
206+ if (!manifest_load(project_dir, &m)) {
207+ ui_err("no %s here -- run 'apm init' or 'apm new' first", MANIFEST_FILE);
208+ return 1;
209+ }
210+ if (!m.name[0]) check_fail(&c, "%s: missing name", MANIFEST_FILE);
211+ if (!m.version[0]) check_fail(&c, "%s: missing version", MANIFEST_FILE);
212+ if (!m.description[0]) check_fail(&c, "%s: missing description", MANIFEST_FILE);
213+ if (!m.license[0]) check_fail(&c, "%s: missing license (apm license <id>)", MANIFEST_FILE);
214+ if (!file_exists(project_dir, "README.md")) check_fail(&c, "missing README.md");
215+ if (!file_exists(project_dir, "LICENSE")) check_fail(&c, "missing LICENSE (apm license <id>)");
216+
217+ char root[1100];
218+ snprintf(root, sizeof(root), "%s/src/main/ash", project_dir);
219+ if (!fsutil_dir_exists(root)) check_fail(&c, "missing source root src/main/ash");
220+ else check_tree(&c, root, "");
221+
222+ if (c.problems) { ui_err("%d problem%s", c.problems, c.problems == 1 ? "" : "s"); return 1; }
223+ ui_ok("%s follows the module conventions", m.name);
224+ return 0;
225+}
226+
27227 int cmd_init(const char *project_dir, int argc, char **argv) {
28228 (void)argc; (void)argv;
29229 Manifest m;

hearth/apm/src/C/install.cmodified

+10−0
@@ -1,4 +1,5 @@
11 #include <stdio.h>
2+#include <stdlib.h>
23 #include "H/install.h"
34 #include "H/fsutil.h"
45 #include "H/git_fetch.h"
@@ -25,6 +26,15 @@ int install_dependency(const char *project_dir, const Dependency *dep) {
2526
2627 ui_info("fetching %s (%s)", dep->name, dep->value);
2728 if (!git_fetch_github(dep->value, dest)) return 0;
29+ char root[1100];
30+ snprintf(root, sizeof(root), "%s/src/main/ash", dest);
31+ if (!fsutil_dir_exists(root)) {
32+ ui_err("'%s' has no src/main/ash source root -- every Ash module must keep its sources there (apm new scaffolds one)", dep->name);
33+ char rm[1200];
34+ snprintf(rm, sizeof(rm), "rm -rf '%s'", dest);
35+ if (system(rm) != 0) ui_err("could not remove %s", dest);
36+ return 0;
37+ }
2838 ui_ok("%s installed", dep->name);
2939 return 1;
3040 }

hearth/apm/src/C/main.cmodified

+4−0
@@ -6,6 +6,8 @@
66 static void print_usage(void) {
77 printf("apm -- the Ash package manager (github: sources only, for now)\n\n");
88 printf("usage:\n");
9+ printf(" apm new <name> scaffold a module: ash.pkg + src/main/ash/<package>/<Class>.ash\n");
10+ printf(" apm check verify the module follows the Ash module conventions\n");
911 printf(" apm init\n");
1012 printf(" apm add github:<user>/<repo>[@ref] [--as <name>] [--dev]\n");
1113 printf(" apm install\n");
@@ -22,6 +24,8 @@ int main(int argc, char **argv) {
2224 char **rest_argv = argv + 2;
2325 const char *project_dir = ".";
2426
27+ if (strcmp(command, "new") == 0) return cmd_new(project_dir, rest_argc, rest_argv);
28+ if (strcmp(command, "check") == 0) return cmd_check(project_dir, rest_argc, rest_argv);
2529 if (strcmp(command, "init") == 0) return cmd_init(project_dir, rest_argc, rest_argv);
2630 if (strcmp(command, "add") == 0) return cmd_add(project_dir, rest_argc, rest_argv);
2731 if (strcmp(command, "install") == 0) return cmd_install(project_dir, rest_argc, rest_argv);

hearth/apm/src/H/cmd.hmodified

+2−0
@@ -4,6 +4,8 @@
44 // Each command takes the project directory ("." almost always, since
55 // apm is always run from a project's root) and the remaining argv
66 // past the subcommand name. Returns an exit code.
7+int cmd_new(const char *project_dir, int argc, char **argv);
8+int cmd_check(const char *project_dir, int argc, char **argv);
79 int cmd_init(const char *project_dir, int argc, char **argv);
810 int cmd_add(const char *project_dir, int argc, char **argv);
911 int cmd_install(const char *project_dir, int argc, char **argv);

hearth/apm/tests/scripts/smoke_test.shmodified

+16−9
@@ -22,17 +22,17 @@ cd "$SCRATCH"
2222 [ -f ash.pkg ] || fail "ash.pkg not created"
2323 grep -q "^name:" ash.pkg || fail "ash.pkg missing name"
2424
25-"$BIN" add github:octocat/Hello-World > /dev/null || fail "add"
26-grep -q "Hello-World: github:octocat/Hello-World" ash.pkg || fail "dependency not recorded"
27-[ -d @ash-modules/Hello-World ] || fail "dependency not installed"
25+"$BIN" add github:lennoxrose/ash-stdlib > /dev/null || fail "add"
26+grep -q "ash-stdlib: github:lennoxrose/ash-stdlib" ash.pkg || fail "dependency not recorded"
27+[ -d @ash-modules/ash-stdlib ] || fail "dependency not installed"
2828
2929 rm -rf @ash-modules
3030 "$BIN" install > /dev/null || fail "install"
31-[ -d @ash-modules/Hello-World ] || fail "install did not restore the dependency"
31+[ -d @ash-modules/ash-stdlib ] || fail "install did not restore the dependency"
3232
3333 "$BIN" install > /dev/null || fail "re-install (idempotency)"
3434
35-"$BIN" list | grep -q "Hello-World" || fail "list didn't show the dependency"
35+"$BIN" list | grep -q "ash-stdlib" || fail "list didn't show the dependency"
3636
3737 # full manifest: every field and section survives a load/save round trip (add rewrites ash.pkg)
3838 cat > ash.pkg <<'PKG'
@@ -48,7 +48,7 @@ main: demo.Main
4848 keywords: ash, demo
4949
5050 [dependencies]
51-Hello-World: github:octocat/Hello-World
51+ash-stdlib: github:lennoxrose/ash-stdlib
5252
5353 [libraries]
5454 m: system
@@ -58,13 +58,13 @@ vulkan: pkg-config:vulkan
5858 build: echo building > built.txt
5959 test: echo testing
6060 PKG
61-"$BIN" add github:octocat/Spoon-Knife --dev > /dev/null || fail "add --dev"
61+"$BIN" add github:lennoxrose/ash-stdlib --as stdlib-dev --dev > /dev/null || fail "add --dev"
6262 for want in "^description: A demo package" "^author: Ada <ada@example.com>" "^license: MIT" "^main: demo.Main" \
6363 "^keywords: ash, demo" "^m: system" "^vulkan: pkg-config:vulkan" "^build: echo building" \
64- "^\[dev-dependencies\]" "^Spoon-Knife: github:octocat/Spoon-Knife"; do
64+ "^\[dev-dependencies\]" "^stdlib-dev: github:lennoxrose/ash-stdlib"; do
6565 grep -q -- "$want" ash.pkg || fail "ash.pkg lost '$want' after add --dev"
6666 done
67-[ -d @ash-modules/Spoon-Knife ] || fail "dev dependency not installed"
67+[ -d @ash-modules/stdlib-dev ] || fail "dev dependency not installed"
6868 "$BIN" list | grep -q "vulkan: pkg-config:vulkan" || fail "list didn't show libraries"
6969
7070 "$BIN" build > /dev/null || fail "apm build"
@@ -84,6 +84,13 @@ grep -q "^build: echo building" ash.pkg || fail "ash.pkg lost other fields on li
8484 "$BIN" license | grep -q "OFL-1.1" || fail "license list"
8585 if "$BIN" license no-such-license > /dev/null 2>&1; then fail "unknown license should exit non-zero"; fi
8686
87+# new + check: scaffold follows the conventions once a license is chosen
88+"$BIN" new demo-mod > /dev/null || fail "apm new"
89+[ -f demo-mod/src/main/ash/demo_mod/DemoMod.ash ] || fail "apm new source file"
90+(cd demo-mod && if "$BIN" check > /dev/null 2>&1; then fail "check should flag the missing license"; fi
91+ "$BIN" license MIT --author t > /dev/null && "$BIN" check > /dev/null) || fail "apm check on scaffold"
92+if "$BIN" new demo-mod > /dev/null 2>&1; then fail "apm new over existing dir should fail"; fi
93+
8794 if "$BIN" bogus-command > /dev/null 2>&1; then fail "unknown command should exit non-zero"; fi
8895
8996 echo "apm smoke test: OK"

hearth/front/src/jcommon/C/jcommon.cmodified

+20−5
@@ -4,6 +4,7 @@
44 #include <stdio.h>
55 #include <stdlib.h>
66 #include <string.h>
7+#include <unistd.h>
78
89 void *j_alloc(size_t n) {
910 void *p = calloc(1, n ? n : 1);
@@ -108,10 +109,23 @@ static const char *find_source(const char *path) {
108109 return NULL;
109110 }
110111
112+#define COL_RED "\033[38;5;203m"
113+#define COL_CYAN "\033[38;5;117m"
114+#define COL_GRAY "\033[38;5;242m"
115+#define COL_RESET "\033[0m"
116+#define COL_BOLD "\033[1m"
117+
118+static int use_color(void) {
119+ return isatty(STDERR_FILENO) && !getenv("NO_COLOR");
120+}
121+
111122 static void report(const char *path, int line, int col, const char *fmt, va_list ap) {
112- fprintf(stderr, "error: ");
123+ int color = use_color();
124+ const char *bold = color ? COL_BOLD : "", *red = color ? COL_RED : "", *cyan = color ? COL_CYAN : "";
125+ const char *gray = color ? COL_GRAY : "", *reset = color ? COL_RESET : "";
126+ fprintf(stderr, "%s%serror%s%s: ", bold, red, reset, bold);
113127 vfprintf(stderr, fmt, ap);
114- fprintf(stderr, "\n --> %s:%d:%d\n", path ? path : "<unknown>", line, col);
128+ fprintf(stderr, "%s\n%s --> %s:%d:%d%s\n", reset, cyan, path ? path : "<unknown>", line, col, reset);
115129 const char *text = path ? find_source(path) : NULL;
116130 if (text && line > 0) {
117131 const char *p = text;
@@ -120,9 +134,9 @@ static void report(const char *path, int line, int col, const char *fmt, va_list
120134 }
121135 const char *end = p;
122136 while (*end && *end != '\n') end++;
123- fprintf(stderr, " |\n%3d| %.*s\n | ", line, (int)(end - p), p);
137+ fprintf(stderr, "%s |%s\n%s%3d|%s %.*s\n%s |%s ", gray, reset, gray, line, reset, (int)(end - p), p, gray, reset);
124138 for (int i = 1; i < col; i++) fputc(' ', stderr);
125- fprintf(stderr, "^\n");
139+ fprintf(stderr, "%s^%s\n", red, reset);
126140 }
127141 }
128142
@@ -144,7 +158,8 @@ void j_error_soft(const char *path, int line, int col, const char *fmt, ...) {
144158
145159 void j_abort_if_errors(void) {
146160 if (errors) {
147- fprintf(stderr, "%d error%s\n", errors, errors == 1 ? "" : "s");
161+ int color = use_color();
162+ fprintf(stderr, "%s%d error%s%s\n", color ? COL_BOLD COL_RED : "", errors, errors == 1 ? "" : "s", color ? COL_RESET : "");
148163 exit(1);
149164 }
150165 }

hearth/front/src/jtypes/C/classes.cmodified

+2−2
@@ -31,7 +31,7 @@ void jt_add_root(const char *dir) { j_list_push(&roots, j_strdup(dir)); }
3131 static int dir_exists(const char *d) { return access(d, F_OK) == 0; }
3232
3333 /* Packages installed by apm: each child C of <project>/@ash-modules and <project>/ash.libs is a
34- * source root, at C/src/main/ash when that exists and at C itself otherwise. */
34+ * source root at C/src/main/ash (every Ash module keeps its sources there). */
3535 static void add_package_roots(const char *project, const char *folder) {
3636 char *dir = j_fmt("%s/%s", project, folder);
3737 DIR *d = opendir(dir);
@@ -40,7 +40,7 @@ static void add_package_roots(const char *project, const char *folder) {
4040 if (e->d_name[0] == '.') continue;
4141 char *pkg = j_fmt("%s/%s", dir, e->d_name);
4242 char *main_dir = j_fmt("%s/src/main/ash", pkg);
43- jt_add_root(dir_exists(main_dir) ? main_dir : pkg);
43+ if (dir_exists(main_dir)) jt_add_root(main_dir);
4444 }
4545 closedir(d);
4646 }