@ 261101.7

261026.46 · 864eceebfe26de4778a05da0701b069d152085a3

Check the usage text with docopt-ng

The help text claimed docopt notation, but nothing parsed it. docopt rejected the usage outright: two description lines began with a dash and read as option definitions. It also rejected www --debug, since --debug, --no-bug, --cache and --disk appeared in no usage line.

The usage lines now name those options where they take effect, and [--] before DIR, which may start with a dash. TestUsageIsDocopt runs every command line TestParseArgs accepts through docopt-ng, fetched by uv run --script from the checker's inline metadata. It skips where uv is missing or cannot fetch.

reading declarations

README.md

@@ -49,7 +49,7 @@ To reach the server by name, add a line such as `127.31.1.1 www` to `/etc/hosts` - `-V`, `--version`: print when the binary was built and the commit it came from, as the stamp says it, eg `www built 2026-10-21 12:49, from 1aa282c+modified` - `-h`, `--help`: print the options -Options follow GNU style. Short ones combine, as in `-pa 127.31.1.2`, and options may follow the directory. Optional values go joined, as in `--share=192.168.1.5`; with a space the address would be read as the directory, and www says so. `-f` and `-n` mean force and dry run in every tool of the author's, so `--foreground` and `--no-open` have no short form. The help text is written in docopt notation, except that docopt has no way to write the optional `=HOST`.+Options follow GNU style. Short ones combine, as in `-pa 127.31.1.2`, and options may follow the directory. Optional values go joined, as in `--share=192.168.1.5`; with a space the address would be read as the directory, and www says so. `-f` and `-n` mean force and dry run in every tool of the author's, so `--foreground` and `--no-open` have no short form. The help text is written in docopt notation, except that docopt has no way to write the optional `=HOST`. `make test` checks it with docopt-ng, run through `uv`, and skips the check where `uv` is missing.  `--list` and `--kill` print [keyval](https://unixfile.github.io/keyval/): three lines per tree, sorted, with every index the same width. The binary line is missing for a server that started before www recorded it. 

main.go

@@ -84,13 +84,18 @@ type config struct {  // usageText is written in docopt notation, except that --share takes its // HOST optionally, as GNU optional arguments do, which docopt cannot say.+// TestUsageIsDocopt checks it with docopt-ng. A pattern goes on over+// indented lines. var usageText = strings.NewReplacer("@NAME@", progName, "@ENV@", envPrefix).Replace(`Usage:-  @NAME@ [-p] [--foreground] [--no-open] [-a ADDR] [DIR]-  @NAME@ [-p] [--foreground] [--no-open] --share[=HOST] [--secret] [DIR]-  @NAME@ --restart [-f] [--no-open] [-a ADDR | --share[=HOST]] [DIR]-  @NAME@ -l [-a ADDR | --share[=HOST]] [DIR]+  @NAME@ [-p] [--foreground] [--no-open] [-a ADDR] [--debug] [--no-bug]+      [--cache=SIZE] [--disk=SIZE] [--] [DIR]+  @NAME@ [-p] [--foreground] [--no-open] --share[=HOST] [--secret] [--debug]+      [--no-bug] [--cache=SIZE] [--disk=SIZE] [--] [DIR]+  @NAME@ --restart [-f] [--no-open] [--debug] [--no-bug]+      [-a ADDR | --share[=HOST]] [--] [DIR]+  @NAME@ -l [-a ADDR | --share[=HOST]] [--] [DIR]   @NAME@ --kill [-a ADDR | --share[=HOST]]-  @NAME@ --commit=REV [DIR]+  @NAME@ --commit=REV [--] [DIR]   @NAME@ -h | --help   @NAME@ -V | --version @@ -128,8 +133,8 @@ Options:                         it drops the repos read longest ago [default: 2G]   --restart             replace every server this binary started with one                         running it now, serving the same trees, and start-                        one for its persistent trees that have none; with-                        -a or --share, only the server on that address,+                        one for its persistent trees that have none; with an+                        address from -a or --share, only the server there,                         whichever binary runs it; with DIR, serve it too and                         open it unless a page of it was open   -f, --force           with --restart, take the servers and persistent@@ -137,9 +142,9 @@ Options:   -l, --list            print each served tree's directory, URL and the                         binary that serves it as keyval; with DIR, only the                         trees at or below it-  --kill                stop every server and print the trees it served as-                        --list does; with -a or --share, only the server on-                        that address+  --kill                stop every server and print the trees it served,+                        as with --list; with an address from -a or --share,+                        only the server there   --commit=REV          print as keyval the commit REV names in DIR's repo:                         its full hash, its stardate and its subject; REV is                         a stardate, eg 261024.4, a hash or any name git

main_test.go

@@ -1,8 +1,11 @@ package main  import (+	"errors" 	"net/netip" 	"os"+	"os/exec"+	"path/filepath" 	"slices" 	"strings" 	"testing"@@ -11,40 +14,7 @@ import ( // Options parse the GNU way: short ones combine, values sit joined or apart, // and options may follow the directory. func TestParseArgs(t *testing.T) {-	ok := []struct {-		args []string-		want config-	}{-		{nil, config{dir: "."}},-		{[]string{"--foreground", "--debug"}, config{dir: ".", fg: true, debug: true}},-		{[]string{"--cache=64M"}, config{dir: ".", cache: "64M"}},-		{[]string{"--cache", "0"}, config{dir: ".", cache: "0"}},-		{[]string{"docs", "--foreground", "--no-open"}, config{dir: "docs", fg: true, noopen: true}},-		{[]string{"-a", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},-		{[]string{"-a127.31.1.2:9000"}, config{dir: ".", addr: "127.31.1.2:9000"}},-		{[]string{"-pa", "127.31.1.2"}, config{dir: ".", persist: true, addr: "127.31.1.2"}},-		{[]string{"--addr", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},-		{[]string{"--addr=127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},-		{[]string{"--", "-f"}, config{dir: "-f"}},-		{[]string{"-h"}, config{dir: ".", help: true}},-		{[]string{"--help"}, config{dir: ".", help: true}},-		{[]string{"--version"}, config{dir: ".", version: true}},-		{[]string{"-V"}, config{dir: ".", version: true}},-		{[]string{"--restart", "-a", "127.31.1.2"}, config{addr: "127.31.1.2", restart: true}},-		{[]string{"--restart", "-a", "127.31.1.2", "."}, config{dir: ".", addr: "127.31.1.2", restart: true}},-		{[]string{"-l"}, config{list: true}},-		{[]string{"--list", "docs"}, config{dir: "docs", list: true}},-		{[]string{"--share=0.0.0.0"}, config{dir: ".", sharing: true, share: "0.0.0.0"}},-		{[]string{"--share"}, config{dir: ".", sharing: true}},-		{[]string{"--share", "docs"}, config{dir: "docs", sharing: true}},-		{[]string{"--share=10.0.0.1:9000", "--secret", "docs"}, config{dir: "docs", sharing: true, share: "10.0.0.1:9000", secret: true}},-		{[]string{"--restart", "--share=10.0.0.1"}, config{sharing: true, share: "10.0.0.1", restart: true}},-		{[]string{"--kill"}, config{kill: true}},-		{[]string{"--commit=261024.4"}, config{dir: ".", commit: "261024.4"}},-		{[]string{"--commit", "HEAD", "docs"}, config{dir: "docs", commit: "HEAD"}},-		{[]string{"--kill", "--share"}, config{sharing: true, kill: true}},-	}-	for _, tc := range ok {+	for _, tc := range parseOK { 		got, err := parseArgs(tc.args) 		if err != nil || got != tc.want { 			t.Errorf("%q: %+v, %v; want %+v", tc.args, got, err, tc.want)@@ -93,6 +63,85 @@ func TestParseArgs(t *testing.T) { 	} } +// parseOK holds command lines www takes, and what each parses to.+var parseOK = []struct {+	args []string+	want config+}{+	{nil, config{dir: "."}},+	{[]string{"--foreground", "--debug"}, config{dir: ".", fg: true, debug: true}},+	{[]string{"--cache=64M"}, config{dir: ".", cache: "64M"}},+	{[]string{"--cache", "0"}, config{dir: ".", cache: "0"}},+	{[]string{"docs", "--foreground", "--no-open"}, config{dir: "docs", fg: true, noopen: true}},+	{[]string{"-a", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},+	{[]string{"-a127.31.1.2:9000"}, config{dir: ".", addr: "127.31.1.2:9000"}},+	{[]string{"-pa", "127.31.1.2"}, config{dir: ".", persist: true, addr: "127.31.1.2"}},+	{[]string{"--addr", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},+	{[]string{"--addr=127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},+	{[]string{"--", "-f"}, config{dir: "-f"}},+	{[]string{"-h"}, config{dir: ".", help: true}},+	{[]string{"--help"}, config{dir: ".", help: true}},+	{[]string{"--version"}, config{dir: ".", version: true}},+	{[]string{"-V"}, config{dir: ".", version: true}},+	{[]string{"--restart", "-a", "127.31.1.2"}, config{addr: "127.31.1.2", restart: true}},+	{[]string{"--restart", "-a", "127.31.1.2", "."}, config{dir: ".", addr: "127.31.1.2", restart: true}},+	{[]string{"--restart", "--debug", "--no-bug"}, config{restart: true, debug: true, noBug: true}},+	{[]string{"--disk=4G", "--no-bug", "docs"}, config{dir: "docs", disk: "4G", noBug: true}},+	{[]string{"-l"}, config{list: true}},+	{[]string{"--list", "docs"}, config{dir: "docs", list: true}},+	{[]string{"--share=0.0.0.0"}, config{dir: ".", sharing: true, share: "0.0.0.0"}},+	{[]string{"--share"}, config{dir: ".", sharing: true}},+	{[]string{"--share", "docs"}, config{dir: "docs", sharing: true}},+	{[]string{"--share=10.0.0.1:9000", "--secret", "docs"}, config{dir: "docs", sharing: true, share: "10.0.0.1:9000", secret: true}},+	{[]string{"--restart", "--share=10.0.0.1"}, config{sharing: true, share: "10.0.0.1", restart: true}},+	{[]string{"--kill"}, config{kill: true}},+	{[]string{"--commit=261024.4"}, config{dir: ".", commit: "261024.4"}},+	{[]string{"--commit", "HEAD", "docs"}, config{dir: "docs", commit: "HEAD"}},+	{[]string{"--kill", "--share"}, config{sharing: true, kill: true}},+}++// The usage text is docopt, and docopt takes every command line in+// parseOK. docopt-ng runs through uv, and the test skips without it.+// docopt has no option with an optional value, so --share[=HOST] is+// checked as --share.+func TestUsageIsDocopt(t *testing.T) {+	uv, err := exec.LookPath("uv")+	if err != nil {+		t.Skip("uv is not installed")+	}+	usage := filepath.Join(t.TempDir(), "usage")+	if err := os.WriteFile(usage, []byte(strings.ReplaceAll(usageText, "--share[=HOST]", "--share")), 0o600); err != nil {+		t.Fatal(err)+	}+	var lines strings.Builder+	for _, tc := range parseOK {+		args := make([]string, len(tc.args))+		for i, a := range tc.args {+			if strings.HasPrefix(a, "--share=") {+				a = "--share"+			}+			args[i] = shellQuote(a)+		}+		lines.WriteString(strings.Join(args, " ") + "\n")+	}+	cmd := exec.Command(uv, "run", "--quiet", "--script", "testdata/docopt-check.py", usage)+	cmd.Stdin = strings.NewReader(lines.String())+	out, err := cmd.CombinedOutput()+	var ee *exec.ExitError+	switch {+	case err == nil:+	case errors.As(err, &ee) && ee.ExitCode() == 22:+		t.Errorf("docopt-ng:\n%s", out)+	default:+		t.Skipf("uv could not run docopt-ng: %v\n%s", err, out)+	}+}++// shellQuote quotes s for shlex.split.+func shellQuote(s string) string {+	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"+}+ // A tree is within a directory at or above it, never beside it. func TestWithin(t *testing.T) { 	for _, tc := range []struct {

testdata/docopt-check.py

new file mode 100644@@ -0,0 +1,40 @@+# /// script+# requires-python = ">=3.9"+# dependencies = ["docopt-ng==0.9.0"]+# ///+"""Check command lines against a usage text with docopt-ng.++Usage:+  docopt-check.py USAGE < LINES++Reads USAGE, a file of docopt usage text, then one command line per line+on stdin, split as the shell would. Prints E22 for each line docopt+rejects and exits 22 if any was rejected or the usage text is malformed.+"""+import shlex+import sys++import docopt+++def main():+    if len(sys.argv) != 2:+        sys.stderr.write("E22: usage: docopt-check.py USAGE < LINES\n")+        return 22+    with open(sys.argv[1]) as f:+        usage = f.read()+    failed = False+    for line in sys.stdin:+        line = line.rstrip("\n")+        try:+            docopt.docopt(usage, shlex.split(line), default_help=False)+        except docopt.DocoptLanguageError as e:+            sys.stderr.write("E22: the usage text is not docopt: %s\n" % e)+            return 22+        except SystemExit:+            sys.stderr.write("E22: docopt rejects: %s\n" % line)+            failed = True+    return 22 if failed else 0+++sys.exit(main())