← 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
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 is69
+// 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), declares113
+// `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, since55
// apm is always run from a project's root) and the remaining argv66
// 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-modules3030
"$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.Main4848
keywords: ash, demo4949
5050
[dependencies]51
-Hello-World: github:octocat/Hello-World51
+ash-stdlib: github:lennoxrose/ash-stdlib5252
5353
[libraries]5454
m: system@@ -58,13 +58,13 @@ vulkan: pkg-config:vulkan5858
build: echo building > built.txt5959
test: echo testing6060
PKG61
-"$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"; do64
+ "^\[dev-dependencies\]" "^stdlib-dev: github:lennoxrose/ash-stdlib"; do6565
grep -q -- "$want" ash.pkg || fail "ash.pkg lost '$want' after add --dev"6666
done67
-[ -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 li8484
"$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"; fi8686
87
+# new + check: scaffold follows the conventions once a license is chosen88
+"$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"; fi91
+ "$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"; fi93
+8794
if "$BIN" bogus-command > /dev/null 2>&1; then fail "unknown command should exit non-zero"; fi8895
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_list120134
}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 a34
- * 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
}