diff --git a/cmd/ww/main.c b/cmd/ww/main.c index d51314ae..f7102f22 100644 --- a/cmd/ww/main.c +++ b/cmd/ww/main.c @@ -32,6 +32,7 @@ static const char *usage = " foo.ww literal file\n" " foo search cwd, -I dirs, then $WW_LIB for foo.ww or foo/foo.ww\n" " lib/foo directory: build lib/foo/foo.ww\n" +" lib/... every package under lib, recursively (test only)\n" " . build the cwd's .ww\n"; static char *self_dir; /* directory containing this binary */ @@ -1689,6 +1690,29 @@ do_test(int argc, char **argv) fprintf(stderr, "ww test: -S needs -o\n"); return 2; } + /* Go's ./... form: a trailing "..." element is a package-tree + * request for the coordinator, never a literal path — recognized + * before stat, with the directory-mode rejects. */ + size_t tlen = strlen(target); + if (strcmp(target, "...") == 0 || + (tlen >= 4 && strcmp(target + tlen - 4, "/...") == 0)) { + if (outstem[0]) { + fprintf(stderr, + "ww test: -c/-S/-o need a single test file\n"); + return 2; + } + if (workdir[0]) { + fprintf(stderr, + "ww test: -w needs a single test file\n"); + return 2; + } + if (pattern) { + fprintf(stderr, + "ww test: pattern needs a single test file\n"); + return 2; + } + return exec_package_tests(argc, argv, src, NULL, 0); + } struct stat st; if (stat(target, &st) != 0) { /* not a literal path — try module resolution and run as diff --git a/docs/test-system-v2.md b/docs/test-system-v2.md index bef7dd7b..de58c34a 100644 --- a/docs/test-system-v2.md +++ b/docs/test-system-v2.md @@ -188,7 +188,12 @@ and nonempty-version-constant assertions. `ww test` delegates directory package requests to the native package coordinator. The coordinator owns discovery, package grouping, same-package and external-package test composition, filtering, result aggregation, and its -internal temporary workspace. With `-c`, it publishes each exact +internal temporary workspace. A trailing `...` path element (`ww test lib/...`, +Go's `./...` form) is recognized by both driver stages before path resolution +and walks the tree rooted at the prefix: every subdirectory whose name does not +begin with `.` or `_` is descended with the same lstat/no-symlink discipline, +each test-bearing directory becomes one package run, and source-bearing +directories without tests report the usual `?` line. With `-c`, it publishes each exact `.test` binary and adjacent `.test.sepwork` tree in the package directory; those become caller-owned artifacts. Without `-c`, it removes the temporary binary and scratch with its workspace. The language @@ -306,7 +311,6 @@ timeout policy in this architecture. Carried over from the dissolved project plan; each is deliberate scope, not drift: -- Recursive package discovery (a `./...`-equivalent pattern) for `ww test`. - Package-level concurrency for the coordinator's builds (it is single-threaded by design today). - A package-level `-o` contract (single-file `ww test -o` exists; directory diff --git a/internal/wwpackage/package.ww b/internal/wwpackage/package.ww index c0ed54cc..ada97e7e 100644 --- a/internal/wwpackage/package.ww +++ b/internal/wwpackage/package.ww @@ -88,7 +88,7 @@ fn pkgfailpath(path: str, reason: str) void = { fn pkgusage() void = { let s: str = strings.concat( - "usage: wwtest package [-c] [-list] [-j N] [-I DIR] [-run|-filter GLOB] [-timeout-ms=N] [DIR] [-- GLOB ...]\n", + "usage: wwtest package [-c] [-list] [-j N] [-I DIR] [-run|-filter GLOB] [-timeout-ms=N] [DIR | DIR/...] [-- GLOB ...]\n", " *_test.ww is canonical; noncanonical files require an actual @test declaration\n", " -c retains the compiled package binaries; -j is reserved by sequential v1\n"); pkgput(os.STDERR_FILENO, s); @@ -240,10 +240,12 @@ fn pkgkeepfile(name: str) bool = { return true; }; -// The first vertical slice owns exactly one explicit package directory. It -// never recurses, and lstat is used both for the root and each candidate so a -// symlink cannot be used to cross that boundary (including DT_UNKNOWN files). -fn pkgdiscoverdir(path: str, st: *pkgdiscover) void = { +// One explicit package directory; with recurse (the DIR/... form) every +// subdirectory whose name does not begin with '.' or '_' is descended as +// well, Go's ./... convention. lstat is used for the root and each +// candidate so a symlink cannot be used to cross the boundary (including +// DT_UNKNOWN files); a symlinked subdirectory is skipped, not followed. +fn pkgdiscoverdir(path: str, st: *pkgdiscover, recurse: bool) void = { let rootstat: os.filestat; match (os.lstat(&rootstat, path)) { case void => void; @@ -307,6 +309,22 @@ fn pkgdiscoverdir(path: str, st: *pkgdiscover) void = { st.errors += 1; }; }; + } else if (recurse && name.len != 0 + && name[0] != '.' && name[0] != '_') { + let child: str = strings.concat(path, "/", name); + let fi: os.filestat; + match (os.lstat(&fi, child)) { + case void => { + if (pkgmodeis(fi.mode, os.mode.DIR)) { + pkgdiscoverdir(child, st, true); + }; + }; + case let e: os.oserror => { + pkgfailpath(child, + "cannot stat directory entry"); + st.errors += 1; + }; + }; }; off += reclen; }; @@ -690,15 +708,28 @@ export fn packagecommand(args: []str) int = { "wwtest package: -j is reserved; sequential package scheduling is active"); }; + // Go's ./... form: a trailing "..." path element walks the tree + // rooted at the prefix instead of one explicit directory. + let discoverroot: str = roots[0]; + let recurse: bool = false; + if (strings.compare(discoverroot, "...") == 0) { + discoverroot = "."; + recurse = true; + } else if (strings.hassuffix(discoverroot, "/...")) { + discoverroot = discoverroot[0:discoverroot.len - 4]; + if (discoverroot.len == 0) { discoverroot = "/"; }; + recurse = true; + }; + let ds: pkgdiscover; let discoveredpaths: []str = alloc([], 64u64)!; ds.paths = discoveredpaths; ds.errors = 0; - pkgdiscoverdir(roots[0], &ds); + pkgdiscoverdir(discoverroot, &ds, recurse); if (ds.errors != 0) { return 1; }; pkgsort(ds.paths); if (ds.paths.len == 0) { - pkgfailpath(roots[0], "directory contains no WW package sources"); + pkgfailpath(discoverroot, "directory contains no WW package sources"); return 1; }; diff --git a/selfhost/cmd/ww/main.ww b/selfhost/cmd/ww/main.ww index 4bbcb8c2..57772a6d 100644 --- a/selfhost/cmd/ww/main.ww +++ b/selfhost/cmd/ww/main.ww @@ -2044,7 +2044,7 @@ fn resolvemodule(selfdir: *u8, name: *u8, incs: *u8, isdir: *i32) *u8 = { // ---- Subcommand handlers ---------------------------------------------- fn writeusage(fd: i32) void = { - let s: str = "usage: ww [-V] [args...]\n -V print version and exit\n build [-S] [-w DIR] [-o FILE] [path] compile module; -S stops after package asm\n run [path] ... build then exec, passing extra args to the program\n test [-S -o STEM] [-w DIR] [options] [path] build/run tests; -S emits package asm\n version print version and exit\n\n path forms:\n foo.ww literal file\n foo search cwd, -I dirs, then $WW_LIB-equiv for foo.ww or foo/foo.ww\n lib/foo directory: build lib/foo/foo.ww\n . build the cwd's .ww\n"; + let s: str = "usage: ww [-V] [args...]\n -V print version and exit\n build [-S] [-w DIR] [-o FILE] [path] compile module; -S stops after package asm\n run [path] ... build then exec, passing extra args to the program\n test [-S -o STEM] [-w DIR] [options] [path] build/run tests; -S emits package asm\n version print version and exit\n\n path forms:\n foo.ww literal file\n foo search cwd, -I dirs, then $WW_LIB-equiv for foo.ww or foo/foo.ww\n lib/foo directory: build lib/foo/foo.ww\n lib/... every package under lib, recursively (test only)\n . build the cwd's .ww\n"; os.write(fd, s.ptr, s.len: u64); }; @@ -2672,6 +2672,34 @@ fn dotest(selfdir: *u8, argv: **u8, argc: i32, start: i32) i32 = { return 2; }; + // Go's ./... form: a trailing "..." element is a package-tree + // request for the coordinator, never a literal path — recognized + // before stat, with the directory-mode rejects. + let tlen: u64 = cstrlen(target); + let istree: bool = cstreqlit(target, "..."); + if (!istree && tlen >= 4u64) { + istree = target[tlen - 4u64] == '/' + && target[tlen - 3u64] == '.' + && target[tlen - 2u64] == '.' + && target[tlen - 1u64] == '.'; + }; + if (istree) { + if (outstem != nil) { + cerr("ww test: -c/-S/-o need a single test file\n"); + return 2; + }; + if (workdir != nil) { + cerr("ww test: -w needs a single test file\n"); + return 2; + }; + if (patarg != nil) { + cerr("ww test: pattern needs a single test file\n"); + return 2; + }; + return execpackagetests(selfdir, argv, argc, start, + targetindex, nil, false); + }; + let resolved: *u8 = target; let isdir: i32 = 0; let found: bool = false; diff --git a/test/package/package_test.ww b/test/package/package_test.ww index 648aa196..66a1bcb4 100644 --- a/test/package/package_test.ww +++ b/test/package/package_test.ww @@ -443,6 +443,102 @@ fn packagepath(relative: str) str = { clean(root); }; +// Go's ./... parity: a trailing "..." element walks the tree, one package +// run per test-bearing directory, dot- and underscore-prefixed directory +// names excluded. The excluded sentinels hold failing tests so a wrong +// descent turns the whole run red rather than passing silently. +@test fn recursive_tree_discovery() void = { + let root: str = fresh(); + let tree: str = strings.concat(root, "/tree"); + assert(os.mkdir(tree, 448i32) == 0); + assert(os.mkdir(strings.concat(tree, "/alpha"), 448i32) == 0); + assert(os.mkdir(strings.concat(tree, "/beta"), 448i32) == 0); + assert(os.mkdir(strings.concat(tree, "/beta/inner"), 448i32) == 0); + assert(os.mkdir(strings.concat(tree, "/gamma"), 448i32) == 0); + assert(os.mkdir(strings.concat(tree, "/.hidden"), 448i32) == 0); + assert(os.mkdir(strings.concat(tree, "/_skip"), 448i32) == 0); + writefile(strings.concat(tree, "/alpha/alpha.ww"), + "package alpha;\nexport fn value() int = { return 7; };\n"); + writefile(strings.concat(tree, "/alpha/alpha_test.ww"), + "package alpha_test;\nimport alpha;\n@test fn treealpha() void = { assert(alpha.value() == 7); };\n"); + writefile(strings.concat(tree, "/beta/inner/inner_test.ww"), + "package inner;\n@test fn treeinner() void = { assert(1 == 1); };\n"); + writefile(strings.concat(tree, "/gamma/gamma.ww"), + "package gamma;\nexport fn g() int = { return 1; };\n"); + writefile(strings.concat(tree, "/.hidden/hid_test.ww"), + "package hid;\n@test fn hidden() void = { assert(false); };\n"); + writefile(strings.concat(tree, "/_skip/skip_test.ww"), + "package skip;\n@test fn skipped() void = { assert(false); };\n"); + + let spec: str = strings.concat(tree, "/..."); + let outc: commandout; + let outw: commandout; + let treec: []str = [driver("ww"), "test", spec]; + let treew: []str = [driver("ww_ww"), "test", spec]; + runcommand(root, "tree-c", treec, + (30i64 * (time.second: i64)): time.duration, &outc); + runcommand(root, "tree-ww", treew, + (30i64 * (time.second: i64)): time.duration, &outw); + expectexit(&outc, 0); + expectexit(&outw, 0); + assert(same(outc.stdout, outw.stdout)); + assert(same(outc.stderr, outw.stderr)); + assert(has(outc.stdout, strings.concat("ok ", tree, + "/alpha [alpha_test, external]\n"))); + assert(has(outc.stdout, strings.concat("ok ", tree, + "/beta/inner [inner, same-package]\n"))); + assert(has(outc.stdout, strings.concat("? ", tree, + "/gamma [no tests]\n"))); + assert(!has(outc.stdout, ".hidden")); + assert(!has(outc.stdout, "_skip")); + + let patc: []str = [driver("ww"), "test", spec, "glob*"]; + let patw: []str = [driver("ww_ww"), "test", spec, "glob*"]; + runcommand(root, "tree-pattern-c", patc, time.second, &outc); + runcommand(root, "tree-pattern-ww", patw, time.second, &outw); + expectexit(&outc, 2); + expectexit(&outw, 2); + assert(same(outc.stderr, outw.stderr)); + assert(has(outc.stderr, "ww test: pattern needs a single test file\n")); + + let oc: []str = [driver("ww"), "test", "-o", + strings.concat(root, "/stem"), spec]; + let ow: []str = [driver("ww_ww"), "test", "-o", + strings.concat(root, "/stem"), spec]; + runcommand(root, "tree-o-c", oc, time.second, &outc); + runcommand(root, "tree-o-ww", ow, time.second, &outw); + expectexit(&outc, 2); + expectexit(&outw, 2); + assert(same(outc.stderr, outw.stderr)); + assert(has(outc.stderr, "ww test: -c/-S/-o need a single test file\n")); + + let wdc: []str = [driver("ww"), "test", "-w", root, spec]; + let wdw: []str = [driver("ww_ww"), "test", "-w", root, spec]; + runcommand(root, "tree-w-c", wdc, time.second, &outc); + runcommand(root, "tree-w-ww", wdw, time.second, &outw); + expectexit(&outc, 2); + expectexit(&outw, 2); + assert(same(outc.stderr, outw.stderr)); + assert(has(outc.stderr, "ww test: -w needs a single test file\n")); + + let bare: str = strings.concat(root, "/bare"); + assert(os.mkdir(bare, 448i32) == 0); + assert(os.mkdir(strings.concat(bare, "/sub"), 448i32) == 0); + let barec: []str = [driver("ww"), "test", + strings.concat(bare, "/...")]; + let barew: []str = [driver("ww_ww"), "test", + strings.concat(bare, "/...")]; + runcommand(root, "tree-bare-c", barec, + (10i64 * (time.second: i64)): time.duration, &outc); + runcommand(root, "tree-bare-ww", barew, + (10i64 * (time.second: i64)): time.duration, &outw); + expectexit(&outc, 1); + expectexit(&outw, 1); + assert(same(outc.stderr, outw.stderr)); + assert(has(outc.stderr, "directory contains no WW package sources\n")); + clean(root); +}; + fn runtimepath(relative: str) str = { return strings.concat(repo(), "/test/package/runtime/", relative); };