From 36d73564c36468f76634d0fd8a08f653de60cc5c Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Tue, 18 Aug 2026 06:10:35 -0500 Subject: [PATCH] add: init, fill, and check so authors can ship complete help schemas Give developers a shaped help.sdl, a questionnaire, and a --strict CI gate (reusable workflow) instead of parse-success on empty docs. Co-authored-by: Cursor --- .github/workflows/check-schema.yml | 96 ++++ .github/workflows/ci.yml | 28 ++ CHANGELOG.md | 2 + README.adoc | 24 + .../2026-08-18 - init-fill-check.md | 7 + docs/modules/ROOT/pages/authoring.adoc | 37 ++ docs/modules/ROOT/pages/getting-started.adoc | 24 + docs/modules/ROOT/pages/index.adoc | 2 + embed/help.sdl | 15 +- examples/incomplete.sdl | 8 + examples/tar.sdl | 4 + help.sdl | 15 +- source/app.d | 20 + source/prohelp/check.d | 262 +++++++++++ source/prohelp/parser.d | 63 ++- source/prohelp/renderer.d | 25 ++ source/prohelp/scaffold.d | 419 ++++++++++++++++++ 17 files changed, 1037 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/check-schema.yml create mode 100644 changelog-details/2026-08-18 - init-fill-check.md create mode 100644 examples/incomplete.sdl create mode 100644 source/prohelp/check.d create mode 100644 source/prohelp/scaffold.d diff --git a/.github/workflows/check-schema.yml b/.github/workflows/check-schema.yml new file mode 100644 index 0000000..aacae60 --- /dev/null +++ b/.github/workflows/check-schema.yml @@ -0,0 +1,96 @@ +# Reusable ship gate: fail PRs when a help schema is empty or weak. +# +# Callers opt in. This does not run automatically in other repositories. +# +# jobs: +# prohelp: +# uses: dev-centr/prohelp/.github/workflows/check-schema.yml@main +# with: +# schema-path: help.sdl +# strict: true +# +name: Check help schema + +on: + workflow_call: + inputs: + schema-path: + description: Schema file relative to the caller repository root + type: string + default: help.sdl + extra-paths: + description: Optional extra schema paths, one per line + type: string + default: '' + strict: + description: Treat completeness warnings as failures + type: boolean + default: true + prohelp-repository: + description: GitHub repo that provides the prohelp checker + type: string + default: dev-centr/prohelp + prohelp-ref: + description: Git ref of prohelp to build (tag or branch) + type: string + default: main + workflow_dispatch: + inputs: + schema-path: + description: Schema file relative to this repository + type: string + default: help.sdl + extra-paths: + description: Optional extra schema paths, one per line + type: string + default: '' + strict: + description: Treat completeness warnings as failures + type: boolean + default: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout caller + uses: actions/checkout@v4 + + - name: Checkout prohelp + uses: actions/checkout@v4 + with: + repository: ${{ inputs.prohelp-repository || 'dev-centr/prohelp' }} + ref: ${{ inputs.prohelp-ref || 'main' }} + path: .prohelp-tool + + - name: Setup D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + + - name: Build prohelp CLI + run: dub build -c executable --build=release + working-directory: .prohelp-tool + + - name: Check schema(s) + shell: bash + env: + SCHEMA_PATH: ${{ inputs.schema-path }} + EXTRA_PATHS: ${{ inputs.extra-paths }} + STRICT: ${{ inputs.strict }} + run: | + BIN=.prohelp-tool/prohelp + FLAGS=() + if [ "$STRICT" = "true" ]; then + FLAGS+=(--strict) + fi + status=0 + "$BIN" check "$SCHEMA_PATH" "${FLAGS[@]}" || status=$? + if [ -n "$EXTRA_PATHS" ]; then + while IFS= read -r extra; do + extra="${extra//$'\r'/}" + [ -z "$extra" ] && continue + "$BIN" check "$extra" "${FLAGS[@]}" || status=$? + done <<< "$EXTRA_PATHS" + fi + exit "$status" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8980061..502b379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,34 @@ jobs: run: | if [ -f prohelp.exe ]; then BIN=./prohelp.exe; else BIN=./prohelp; fi "$BIN" --help || "$BIN" ? + "$BIN" check --help + "$BIN" init --help + "$BIN" fill --help + + - name: Check schemas + shell: bash + run: | + if [ -f prohelp.exe ]; then BIN=./prohelp.exe; else BIN=./prohelp; fi + "$BIN" check help.sdl --strict + "$BIN" check examples/tar.sdl --strict + tmp=$(mktemp -d) + "$BIN" init "$tmp/help.sdl" --name smoke + "$BIN" check "$tmp/help.sdl" + if "$BIN" check "$tmp/help.sdl" --strict; then + echo "expected init template to fail --strict" >&2 + exit 1 + fi + if "$BIN" check examples/incomplete.sdl --strict; then + echo "expected incomplete.sdl to fail --strict" >&2 + exit 1 + fi + "$BIN" fill "$tmp/help.sdl" --no-prompt \ + --set summary="Smoke test" \ + --set description="Init/fill/check smoke" \ + --set homepage="https://example.com" \ + --set docs="https://example.com/docs" \ + --set issues="https://example.com/issues" + "$BIN" check "$tmp/help.sdl" --strict - name: Smoke help shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 0952124..14bd086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This project maintains a structured release history with links to detailed relea ## Release History +* **2026-08-18** — [Init, fill, and check](changelog-details/2026-08-18%20-%20init-fill-check.md): `prohelp init` writes a shaped schema; `prohelp fill` is the questionnaire; `prohelp check [--strict]` reports completeness. Reusable workflow `.github/workflows/check-schema.yml` for downstream CI. Parser now keeps `example` tags and the preview renderer shows them. + * **2026-08-07** — Static box: frame glyphs always dim; section dividers share one width formula with content rows (display-column math, not UTF-8 byte length). * **2026-08-06** — Build: avoid dub stringImportPaths: ["."] (dub 1.41 expands . as Invalid variable: null); embed help.sdl via embed/ + docs/. * **2026-08-06** — Windows: set console UTF-8 + VT before printing; ASCII box/punctuation fallback when OutputCP is not UTF-8 (`PROHELP_ASCII` / `PROHELP_UNICODE`). Fixes CP437 mojibake (`Γöî` / `ΓÇö`) in PowerShell/conhost. diff --git a/README.adoc b/README.adoc index 449c727..5e915f9 100644 --- a/README.adoc +++ b/README.adoc @@ -92,6 +92,29 @@ prohelp examples/tar.sdl ? operations prohelp -f myapp/help.sdl ?:de,i ---- +=== Author a schema + +[source,powershell] +---- +prohelp init --name myapp # writes help.sdl immediately +prohelp fill # questionnaire (or init --fill) +prohelp check # report empty / weak fields +prohelp check --strict # fail for CI +prohelp help.sdl ? +---- + +Downstream repos opt into the ship gate: + +[source,yaml] +---- +jobs: + prohelp: + uses: dev-centr/prohelp/.github/workflows/check-schema.yml@main + with: + schema-path: help.sdl + strict: true +---- + === Shell `help` wrapper (recommended) [source,shell] @@ -125,6 +148,7 @@ void main(string[] args) { * **Hierarchical Globbing (`*`)**: Subtree glob extraction for targeted documentation chunks. * **Multilingual Localization**: Native OS locale auto-detection with explicit overrides. * **Sliding-scale Line Budgets**: Enforces strict screen boundaries during development. +* **Authoring loop**: `prohelp init` writes a shaped `help.sdl`; `prohelp fill` is the questionnaire; `prohelp check --strict` is the CI ship gate (reusable workflow included). See link:CHANGELOG.md[Changelog]. diff --git a/changelog-details/2026-08-18 - init-fill-check.md b/changelog-details/2026-08-18 - init-fill-check.md new file mode 100644 index 0000000..cb7a7f0 --- /dev/null +++ b/changelog-details/2026-08-18 - init-fill-check.md @@ -0,0 +1,7 @@ +# Init, fill, and check (2026-08-18) + +* `prohelp init` writes a blank-but-shaped `help.sdl` before any questions (`--fill` continues into the questionnaire). +* `prohelp fill` edits that file in a TTY, or via repeated `--set field=value`. +* `prohelp check` reports empty summaries, missing discovery URLs, empty sections, and line-budget slips. `--strict` fails the process so CI can block a ship. +* Downstream repos opt in with `uses: dev-centr/prohelp/.github/workflows/check-schema.yml`. +* SDL `example` tags are parsed and shown in Text Mode (they were previously ignored). diff --git a/docs/modules/ROOT/pages/authoring.adoc b/docs/modules/ROOT/pages/authoring.adoc index 355093e..177177c 100644 --- a/docs/modules/ROOT/pages/authoring.adoc +++ b/docs/modules/ROOT/pages/authoring.adoc @@ -88,3 +88,40 @@ Each missing essential field gets a DuckDuckGo link seeded with the binary name/ When **no** prohelp schema is found at all, prohelp prints a separate notice nudging a feature request upstream (also with a filled search link), then falls back to info / man / `--help`. Suppress notices with `PROHELP_QUIET=1`. + +== Init, fill, and check + +`prohelp init` writes a shaped `help.sdl` immediately (empty summaries and discovery URLs). It does not wait on a questionnaire. + +[source,shell] +---- +prohelp init --name myapp +prohelp init path/to/help.sdl --force +prohelp init --name myapp --fill # then the questionnaire +---- + +`prohelp fill` edits that file. In a TTY it asks for empty fields. In CI or pipes, pass `--set`: + +[source,shell] +---- +prohelp fill help.sdl --set summary="Does a thing" --set homepage=https://example.com +---- + +`prohelp check` prints completeness findings (empty summaries, missing `homepage`/`docs`/`issues`, empty sections, line budgets). Exit 0 while findings are warnings. + +`--strict` promotes those warnings to failures. That is the ship gate. Prohelp cannot enable it in someone else's pipeline; they add a job that calls the reusable workflow: + +[source,yaml] +---- +name: Prohelp +on: [push, pull_request] +jobs: + check: + uses: dev-centr/prohelp/.github/workflows/check-schema.yml@main + with: + schema-path: help.sdl + strict: true +---- + +Pin `@vX.Y.Z` when you depend on a release tag instead of `main`. + diff --git a/docs/modules/ROOT/pages/getting-started.adoc b/docs/modules/ROOT/pages/getting-started.adoc index 1e10782..d072a84 100644 --- a/docs/modules/ROOT/pages/getting-started.adoc +++ b/docs/modules/ROOT/pages/getting-started.adoc @@ -34,3 +34,27 @@ prohelp examples/help.adoc ? ---- See xref:authoring.adoc[Authoring schemas] for Markdown/AsciiDoc/CentrMark whole-document schemas and `content-ref` formats. + +== Create, fill, and check + +[source,powershell] +---- +prohelp init --name myapp +prohelp fill +prohelp check +prohelp check --strict +---- + +`init` writes a shaped `help.sdl` before any questions. `fill` is the questionnaire (`init --fill` runs both). `check` reports empty fields; `--strict` fails the process so CI can block a ship. + +Other GitHub repos can invoke the reusable workflow (they still have to add the job — Prohelp cannot reach into someone else's pipeline): + +[source,yaml] +---- +jobs: + prohelp: + uses: dev-centr/prohelp/.github/workflows/check-schema.yml@main + with: + schema-path: help.sdl + strict: true +---- diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index 701dcd4..b7c8006 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -16,4 +16,6 @@ For building CLI tools, use a framework and wire Prohelp in: The `prohelp` preview executable ships with built-in help and can interpret `help.sdl` as well as Markdown/AsciiDoc/CentrMark document schemas for authoring and testing. See xref:authoring.adoc[Authoring schemas]. +Create a schema with `prohelp init`, fill fields with `prohelp fill`, and use `prohelp check --strict` (or the reusable GitHub workflow) as the ship gate so empty help cannot merge unnoticed. + Moved from Dev-Centr into OpenShellOrg as the reusable progressive-help library and global `help` entry point. diff --git a/embed/help.sdl b/embed/help.sdl index 86db4f2..971ce73 100644 --- a/embed/help.sdl +++ b/embed/help.sdl @@ -25,9 +25,13 @@ command "prohelp" { section "usage" { summary "Integration syntax and execution patterns" - content "prohelp [schema.sdl] [help|?][:[locale][,mode]] [path...] [*]\nprohelp wrapper status|install|uninstall [--shell=...]\nprohelp --as-help [command]" + content "prohelp [schema.sdl] [help|?][:[locale][,mode]] [path...] [*]\nprohelp init [path] [--name NAME] [--force] [--fill]\nprohelp fill [path] [--set field=value]\nprohelp check [path] [--strict]\nprohelp wrapper status|install|uninstall [--shell=...]\nprohelp --as-help [command]" example "Built-in progressive help" "prohelp ?" + example "Write a starter schema" "prohelp init --name myapp" + example "Fill empty fields" "prohelp fill" + example "Report missing fields" "prohelp check" + example "Fail CI on weak schemas" "prohelp check --strict" example "Shell-help setup section" "prohelp ? shell-help" example "Install help() wrapper" "prohelp wrapper install" example "Preview another command's help schema" "prohelp examples/tar.sdl ?" @@ -39,6 +43,15 @@ command "prohelp" { content "Registers a `help` function that keeps builtin help for builtins and routes PATH topics through prohelp. See: prohelp ? shell-help\nWeb: https://openshellorg.github.io/prohelp/shell-help.html" } + section "authoring" { + summary "Create, fill, and check a help schema" + content "prohelp init writes a blank-but-shaped help.sdl immediately. prohelp fill is the questionnaire (also: init --fill). prohelp check reports empty fields; --strict is the CI ship gate. Other repos can call .github/workflows/check-schema.yml." + + example "Scaffold then fill" "prohelp init --name myapp --fill" + example "Non-interactive field set" "prohelp fill --set summary=\"Does a thing\" --set homepage=https://example.com" + example "Strict completeness" "prohelp check help.sdl --strict" + } + section "preview" { summary "Help schema interpreter mode" content "Pass a help.sdl file to virtualize that command's context. Rendering, navigation, and examples use the schema's command name (for example tar) instead of prohelp." diff --git a/examples/incomplete.sdl b/examples/incomplete.sdl new file mode 100644 index 0000000..2379307 --- /dev/null +++ b/examples/incomplete.sdl @@ -0,0 +1,8 @@ +command "incomplete" { + title "incomplete" + summary "" + description "" + homepage "" + docs "" + issues "" +} diff --git a/examples/tar.sdl b/examples/tar.sdl index 6769b5e..75ba72e 100644 --- a/examples/tar.sdl +++ b/examples/tar.sdl @@ -1,6 +1,10 @@ command "tar" { + title "GNU tar" summary "Tape archiver utility" description "An archiver utility used to combine multiple files into a single archive file, often referred to as a 'tarball'." + homepage "https://www.gnu.org/software/tar/" + docs "https://www.gnu.org/software/tar/manual/" + issues "https://savannah.gnu.org/bugs/?group=tar" // Multilingual support locale "de" { diff --git a/help.sdl b/help.sdl index 86db4f2..971ce73 100644 --- a/help.sdl +++ b/help.sdl @@ -25,9 +25,13 @@ command "prohelp" { section "usage" { summary "Integration syntax and execution patterns" - content "prohelp [schema.sdl] [help|?][:[locale][,mode]] [path...] [*]\nprohelp wrapper status|install|uninstall [--shell=...]\nprohelp --as-help [command]" + content "prohelp [schema.sdl] [help|?][:[locale][,mode]] [path...] [*]\nprohelp init [path] [--name NAME] [--force] [--fill]\nprohelp fill [path] [--set field=value]\nprohelp check [path] [--strict]\nprohelp wrapper status|install|uninstall [--shell=...]\nprohelp --as-help [command]" example "Built-in progressive help" "prohelp ?" + example "Write a starter schema" "prohelp init --name myapp" + example "Fill empty fields" "prohelp fill" + example "Report missing fields" "prohelp check" + example "Fail CI on weak schemas" "prohelp check --strict" example "Shell-help setup section" "prohelp ? shell-help" example "Install help() wrapper" "prohelp wrapper install" example "Preview another command's help schema" "prohelp examples/tar.sdl ?" @@ -39,6 +43,15 @@ command "prohelp" { content "Registers a `help` function that keeps builtin help for builtins and routes PATH topics through prohelp. See: prohelp ? shell-help\nWeb: https://openshellorg.github.io/prohelp/shell-help.html" } + section "authoring" { + summary "Create, fill, and check a help schema" + content "prohelp init writes a blank-but-shaped help.sdl immediately. prohelp fill is the questionnaire (also: init --fill). prohelp check reports empty fields; --strict is the CI ship gate. Other repos can call .github/workflows/check-schema.yml." + + example "Scaffold then fill" "prohelp init --name myapp --fill" + example "Non-interactive field set" "prohelp fill --set summary=\"Does a thing\" --set homepage=https://example.com" + example "Strict completeness" "prohelp check help.sdl --strict" + } + section "preview" { summary "Help schema interpreter mode" content "Pass a help.sdl file to virtualize that command's context. Rendering, navigation, and examples use the schema's command name (for example tar) instead of prohelp." diff --git a/source/app.d b/source/app.d index 843c3cc..805c962 100644 --- a/source/app.d +++ b/source/app.d @@ -4,10 +4,12 @@ import std.array; import std.algorithm; import std.string; import std.stdio; +import prohelp.check; import prohelp.config; import prohelp.dispatch; import prohelp.intercept; import prohelp.registration; +import prohelp.scaffold; import prohelp.wrapper; version (ProhelpExecutable) { @@ -83,6 +85,21 @@ void main(string[] argv) { exit(cast(ubyte) runAsHelp(tail[1 .. $])); } + if (tail.length && tail[0] == "init") { + import core.stdc.stdlib : exit; + exit(cast(ubyte) runInitCommand(tail[1 .. $])); + } + + if (tail.length && tail[0] == "fill") { + import core.stdc.stdlib : exit; + exit(cast(ubyte) runFillCommand(tail[1 .. $])); + } + + if (tail.length && tail[0] == "check") { + import core.stdc.stdlib : exit; + exit(cast(ubyte) runCheckCommand(tail[1 .. $])); + } + InterceptConfig config = parseCliConfig(tail); if (!config.isConfigured && tail.length > 0 && (tail[0] == "--schema" || tail[0] == "-f")) { @@ -102,6 +119,9 @@ void main(string[] argv) { } else { stderr.writeln("prohelp error: Unrecognized arguments."); stderr.writeln("Run 'prohelp ?' for built-in help, or:"); + stderr.writeln(" prohelp init [--name myapp] [--fill]"); + stderr.writeln(" prohelp fill [help.sdl]"); + stderr.writeln(" prohelp check [help.sdl] [--strict]"); stderr.writeln(" prohelp wrapper install"); stderr.writeln(" prohelp --as-help "); stderr.writeln(" prohelp path/to/help.sdl ?"); diff --git a/source/prohelp/check.d b/source/prohelp/check.d new file mode 100644 index 0000000..2564141 --- /dev/null +++ b/source/prohelp/check.d @@ -0,0 +1,262 @@ +module prohelp.check; + +import std.algorithm; +import std.array; +import std.conv : to; +import std.file; +import std.path; +import std.stdio; +import std.string; +import prohelp.config; +import prohelp.console; +import prohelp.nudge; +import prohelp.parser; + +enum FindingLevel { error, warning, info } + +struct Finding { + FindingLevel level; + string where; + string message; +} + +struct CheckReport { + string schemaPath; + Finding[] findings; + int errors; + int warnings; + int infos; + + bool passed(bool strict) const { + if (errors > 0) return false; + if (strict && warnings > 0) return false; + return true; + } +} + +/// Default schema lookup in the working directory. +string defaultSchemaPath() { + static immutable names = [ + "help.sdl", "help.md", "help.markdown", + "help.adoc", "help.asciidoc", "help.cmk" + ]; + foreach (name; names) { + if (exists(name) && isFile(name)) return name; + } + return "help.sdl"; +} + +bool looksEmpty(string value) { + auto t = value.strip(); + if (!t.length) return true; + auto lower = t.toLower(); + if (lower == "todo" || lower == "tbd" || lower == "n/a" || lower == "xxx") + return true; + if (lower.startsWith("todo ") || lower.startsWith("(required)") + || lower.startsWith("your ")) + return true; + return t.canFind("TODO"); +} + +Command loadSchemaForCheck(string path) { + if (isSdlSchemaPath(path)) + return parseHelpSDL(path, false); + return loadCommand(InterceptConfig.fromFile(path)); +} + +CheckReport checkSchemaFile(string path) { + CheckReport report; + report.schemaPath = path; + if (!exists(path) || !isFile(path)) { + add(report, FindingLevel.error, path, "schema file not found"); + return report; + } + Command cmd; + try { + cmd = loadSchemaForCheck(path); + } catch (Exception e) { + add(report, FindingLevel.error, path, e.msg); + return report; + } + checkCommand(report, cmd); + return report; +} + +void checkCommand(ref CheckReport report, Command cmd) { + if (!cmd.name.length) + add(report, FindingLevel.error, "command", "missing command name"); + + void requireField(string field, string value, FindingLevel level = FindingLevel.warning) { + if (looksEmpty(value)) + add(report, level, "command", field ~ " is empty — fill it so help is usable"); + } + + requireField("summary", cmd.summary); + requireField("description", cmd.description); + foreach (field; essentialMetaFields) { + string value; + if (field == "homepage") value = cmd.homepage; + else if (field == "docs") value = cmd.docsUrl; + else if (field == "issues") value = cmd.issuesUrl; + if (looksEmpty(value)) + add(report, FindingLevel.warning, "command", + "essential field `" ~ field ~ "` is empty (discovery / contact)"); + } + + if (!cmd.sections.length) + add(report, FindingLevel.warning, "command", + "no sections — readers cannot drill into topics"); + + int rootLines = commandRootLineCount(cmd); + if (rootLines > 20) { + add(report, FindingLevel.warning, "command", + "level 0 layout exceeds the 20-line budget (" ~ rootLines.to!string ~ " lines)"); + } else { + add(report, FindingLevel.info, "command", + "level 0 layout " ~ rootLines.to!string ~ "/20 lines"); + } + + foreach (lang, loc; cmd.locales) { + auto where = "locale '" ~ lang ~ "'"; + if (looksEmpty(loc.summary)) + add(report, FindingLevel.warning, where, "summary is empty"); + if (looksEmpty(loc.description)) + add(report, FindingLevel.warning, where, "description is empty"); + } + + foreach (sec; cmd.sections) + walkSection(report, sec, sec.name, 1); +} + +private void walkSection(ref CheckReport report, Section sec, string path, int displayLevel) { + auto where = "section '" ~ path ~ "'"; + if (looksEmpty(sec.summary)) + add(report, FindingLevel.warning, where, "summary is empty"); + + bool hasBody = sec.content.length > 0 || sec.contentRef.length > 0 + || sec.options.length > 0 || sec.examples.length > 0 + || sec.subsections.length > 0; + if (!hasBody) + add(report, FindingLevel.warning, where, + "empty — add content, options, examples, or child sections"); + + if (sec.subsections.length == 0 && sec.options.length == 0 + && looksEmpty(sec.content) && !sec.contentRef.length + && !sec.examples.length) { + // already flagged empty + } else if (sec.subsections.length == 0 && sec.options.length == 0 + && sec.examples.length == 0) { + add(report, FindingLevel.info, where, + "no options or examples — fine for prose, thin for a CLI leaf"); + } + + foreach (i, ex; sec.examples) { + if (looksEmpty(ex.command)) + add(report, FindingLevel.warning, where, + "example #" ~ (i + 1).to!string ~ " has no command string"); + } + + int lines = sec.calculateLineCount(displayLevel); + int budget = sectionLineBudget(displayLevel); + if (lines > budget) { + add(report, FindingLevel.warning, where, + "exceeds the " ~ budget.to!string ~ "-line budget (" ~ + lines.to!string ~ " lines)"); + } + + foreach (sub; sec.subsections) + walkSection(report, sub, path ~ " / " ~ sub.name, displayLevel + 1); +} + +private void add(ref CheckReport report, FindingLevel level, string where, string message) { + report.findings ~= Finding(level, where, message); + final switch (level) { + case FindingLevel.error: report.errors++; break; + case FindingLevel.warning: report.warnings++; break; + case FindingLevel.info: report.infos++; break; + } +} + +int printCheckReport(const CheckReport report, bool strict, bool color) { + prepareConsoleOutput(); + auto dim = color ? "\033[2m" : ""; + auto bold = color ? "\033[1m" : ""; + auto reset = color ? "\033[0m" : ""; + auto red = color ? "\033[31m" : ""; + auto yellow = color ? "\033[33m" : ""; + auto cyan = color ? "\033[36m" : ""; + + string label(FindingLevel level) { + final switch (level) { + case FindingLevel.error: return red ~ "error" ~ reset; + case FindingLevel.warning: return yellow ~ "warning" ~ reset; + case FindingLevel.info: return cyan ~ "info" ~ reset; + } + } + + writeln(bold, "prohelp check: ", reset, report.schemaPath); + foreach (f; report.findings) { + if (f.level == FindingLevel.info && !strict) + continue; + writeln(" ", label(f.level), ": ", f.where, " — ", f.message); + } + + write(dim, report.errors.to!string, " errors, ", + report.warnings.to!string, " warnings, ", + report.infos.to!string, " info", reset, "\n"); + + if (!strict && report.warnings > 0) { + writeln(dim, "Ship gate: prohelp check --strict (fails on warnings)", reset); + writeln(dim, "CI: uses: dev-centr/prohelp/.github/workflows/check-schema.yml", reset); + } + + if (report.passed(strict)) { + writeln(color ? "\033[32m" : "", "ok", reset, + strict ? " (strict)" : " (warnings are advisory)"); + return 0; + } + writeln(red, "failed", reset, strict ? " (strict)" : ""); + return 1; +} + +int runCheckCommand(string[] args) { + bool strict = false; + string path; + foreach (arg; args) { + if (arg == "--strict" || arg == "-s") { + strict = true; + continue; + } + if (arg == "--help" || arg == "-h" || arg == "?") { + writeln("Usage: prohelp check [path] [--strict]"); + writeln(" Default path: help.sdl (or help.md / help.adoc in cwd)"); + writeln(" --strict treat completeness warnings as failures (CI ship gate)"); + return 0; + } + if (arg.startsWith("-")) { + stderr.writeln("prohelp check: unknown flag ", arg); + return 2; + } + if (path.length) { + stderr.writeln("prohelp check: extra argument ", arg); + return 2; + } + path = arg; + } + if (!path.length) path = defaultSchemaPath(); + + auto report = checkSchemaFile(path); + import prohelp.renderer : isStdoutTTY; + return printCheckReport(report, strict, isStdoutTTY()); +} + +unittest { + auto cmd = new Command(); + cmd.name = "demo"; + CheckReport report; + report.schemaPath = "memory"; + checkCommand(report, cmd); + assert(report.warnings > 0); + assert(report.passed(false)); + assert(!report.passed(true)); +} diff --git a/source/prohelp/parser.d b/source/prohelp/parser.d index 43ff1f8..e15181a 100644 --- a/source/prohelp/parser.d +++ b/source/prohelp/parser.d @@ -16,6 +16,11 @@ public class Option { string dominance = "medium"; // "high", "medium", "low" } +public class Example { + string title; + string command; +} + public class Section { string name; string summary; @@ -25,6 +30,7 @@ public class Section { bool inlineExpand = false; Section[] subsections; Option[] options; + Example[] examples; // Line budget calculation based on simulated Text Mode output format int calculateLineCount(int level) { @@ -38,6 +44,13 @@ public class Section { count += 2; // Content + spacer } + if (examples.length > 0) { + count += 2; // header + spacer + foreach (ex; examples) { + count += ex.title.length ? 2 : 1; + } + } + if (subsections.length > 0) { count += 2; // Header + spacer count += subsections.length; @@ -104,14 +117,24 @@ public class Command { } } +/// Level 0 root page: header chrome plus one row per top-level section. +int commandRootLineCount(const Command cmd) { + return 6 + cast(int) cmd.sections.length; +} + +/// Display level 1 ≤ 40 lines; level 2+ ≤ 60. +int sectionLineBudget(int displayLevel) { + return (displayLevel <= 1) ? 40 : 60; +} + // Main parser function that reads help.sdl from disk. -public Command parseHelpSDL(string filename) { +public Command parseHelpSDL(string filename, bool warnBudgets = true) { import std.file : readText; - return parseHelpSDLContent(readText(filename), filename); + return parseHelpSDLContent(readText(filename), filename, warnBudgets); } // Parse help.sdl content from memory (embedded or interpreter preview). -public Command parseHelpSDLContent(string content, string sourceLabel) { +public Command parseHelpSDLContent(string content, string sourceLabel, bool warnBudgets = true) { Tag root; try { root = parseSource(content, sourceLabel); @@ -119,7 +142,7 @@ public Command parseHelpSDLContent(string content, string sourceLabel) { throw new Exception("prohelp schema parse error in '" ~ sourceLabel ~ "': " ~ e.msg); } - auto cmd = parseHelpSDLRoot(root, sourceLabel); + auto cmd = parseHelpSDLRoot(root, sourceLabel, warnBudgets); // Prefer directory of on-disk schemas; embedded labels fall back to cwd. if (sourceLabel.length && sourceLabel != "help.sdl" && !sourceLabel.startsWith("embedded")) { cmd.schemaDir = dirName(absolutePath(sourceLabel)); @@ -146,7 +169,7 @@ private void resolveContentRefs(Command cmd) { foreach (sec; cmd.sections) walk(sec); } -private Command parseHelpSDLRoot(Tag root, string sourceLabel) { +private Command parseHelpSDLRoot(Tag root, string sourceLabel, bool warnBudgets) { Tag cmdTag = root.getTag("command"); if (cmdTag is null) { throw new Exception("prohelp schema error: Root 'command' tag is missing in '" ~ sourceLabel ~ "'"); @@ -178,7 +201,7 @@ private Command parseHelpSDLRoot(Tag root, string sourceLabel) { parseLocale(child, cmd); } else if (child.name == "section") { auto sec = new Section(); - parseSection(child, sec, 0); + parseSection(child, sec, 0, warnBudgets); cmd.sections ~= sec; } } @@ -186,8 +209,8 @@ private Command parseHelpSDLRoot(Tag root, string sourceLabel) { if (!cmd.title.length) cmd.title = cmd.name; // Check sliding-scale line budgets for Level 0 - int rootLines = 6 + cast(int)cmd.sections.length; - if (rootLines > 20) { + int rootLines = commandRootLineCount(cmd); + if (warnBudgets && rootLines > 20) { stderr.writeln("prohelp warning: Level 0 root help page layout exceeds the 20-line single-screen budget (" ~ rootLines.to!string ~ " lines calculated). Consider merging categories or making sections inline."); } @@ -210,7 +233,7 @@ private void parseLocale(Tag locTag, Command cmd) { cmd.locales[lang] = info; } -private void parseSection(Tag secTag, Section sec, int level) { +private void parseSection(Tag secTag, Section sec, int level, bool warnBudgets) { if (secTag.values.length == 0 || secTag.values[0].peek!string() is null) { throw new Exception("prohelp schema error: 'section' tag must specify a string name."); } @@ -233,8 +256,10 @@ private void parseSection(Tag secTag, Section sec, int level) { sec.inlineExpand = child.values[0].get!bool(); } else if (child.name == "section") { auto sub = new Section(); - parseSection(child, sub, level + 1); + parseSection(child, sub, level + 1, warnBudgets); sec.subsections ~= sub; + } else if (child.name == "example") { + sec.examples ~= parseExample(child); } else if (child.name == "option") { sec.options ~= parseOption(child, "medium"); } else if (child.name == "dominance") { @@ -251,14 +276,28 @@ private void parseSection(Tag secTag, Section sec, int level) { // Validate progressive line budgets for deeper sections int calculatedLines = sec.calculateLineCount(level + 1); - int budget = (level == 0) ? 40 : 60; - if (calculatedLines > budget) { + int budget = sectionLineBudget(level + 1); + if (warnBudgets && calculatedLines > budget) { stderr.writeln("prohelp warning: Section '" ~ sec.name ~ "' (Level " ~ (level + 1).to!string ~ ") exceeds its " ~ budget.to!string ~ "-line budget (" ~ calculatedLines.to!string ~ " lines calculated). Please organize into deeper subsections."); } } +private Example parseExample(Tag exTag) { + auto ex = new Example(); + if (exTag.values.length >= 2) { + if (exTag.values[0].peek!string() !is null) + ex.title = exTag.values[0].get!string(); + if (exTag.values[1].peek!string() !is null) + ex.command = exTag.values[1].get!string(); + } else if (exTag.values.length == 1) { + if (exTag.values[0].peek!string() !is null) + ex.command = exTag.values[0].get!string(); + } + return ex; +} + private Option parseOption(Tag optTag, string dominance) { auto opt = new Option(); opt.dominance = dominance; diff --git a/source/prohelp/renderer.d b/source/prohelp/renderer.d index 378b8b5..2ded6eb 100644 --- a/source/prohelp/renderer.d +++ b/source/prohelp/renderer.d @@ -51,6 +51,16 @@ public bool isStdoutTTY() { } } +public bool isStdinTTY() { + version(Windows) { + HANDLE hIn = GetStdHandle(STD_INPUT_HANDLE); + DWORD mode; + return GetConsoleMode(hIn, &mode) != 0; + } else { + return isatty(STDIN_FILENO) != 0; + } +} + // Helper to strip style tags from formatted text public string stripStyles(string text) { auto colorTagRx = ctRegex!``; @@ -295,6 +305,21 @@ public string renderSectionBox(Command cmd, Section sec, string[] path, string l sb.put(framedRow(line, contentWidth, bx, enableColor)); } + if (sec.examples.length > 0) { + sb.put(framedDivider(" Examples ", contentWidth, bx, enableColor)); + foreach (ex; sec.examples) { + if (ex.title.length) { + foreach (line; wrapText(" " ~ ex.title, contentWidth)) + sb.put(framedRow(line, contentWidth, bx, enableColor)); + } + if (ex.command.length) { + auto prefix = ex.title.length ? " " : " "; + foreach (line; wrapText(prefix ~ ex.command, contentWidth)) + sb.put(framedRow(line, contentWidth, bx, enableColor)); + } + } + } + if (sec.subsections.length > 0) { sb.put(framedDivider( " Sections (Run: '" ~ cmd.name ~ " ?:
' to view) ", diff --git a/source/prohelp/scaffold.d b/source/prohelp/scaffold.d new file mode 100644 index 0000000..1d2f167 --- /dev/null +++ b/source/prohelp/scaffold.d @@ -0,0 +1,419 @@ +module prohelp.scaffold; + +import std.algorithm; +import std.array; +import std.conv : to; +import std.file; +import std.path; +import std.stdio; +import std.string; +import prohelp.check; +import prohelp.config; +import prohelp.parser; +import prohelp.renderer : isStdinTTY; + +private string sdlQuote(string s) { + auto escaped = s.replace(`\`, `\\`).replace(`"`, `\"`) + .replace("\r\n", "\n").replace("\n", `\n`); + return `"` ~ escaped ~ `"`; +} + +string emitHelpSdl(Command cmd) { + auto sb = appender!string(); + sb.put("command "); + sb.put(sdlQuote(cmd.name.length ? cmd.name : "myapp")); + sb.put(" {\n"); + + void field(string name, string value) { + sb.put(" "); + sb.put(name); + sb.put(" "); + sb.put(sdlQuote(value)); + sb.put("\n"); + } + + field("title", cmd.title.length ? cmd.title : cmd.name); + field("summary", cmd.summary); + field("description", cmd.description); + field("homepage", cmd.homepage); + field("docs", cmd.docsUrl); + field("issues", cmd.issuesUrl); + if (cmd.issuesAiUrl.length) + field("issues-ai", cmd.issuesAiUrl); + sb.put("\n"); + + auto langs = cmd.locales.keys.array; + langs.sort(); + foreach (lang; langs) { + auto loc = cmd.locales[lang]; + sb.put(" locale "); + sb.put(sdlQuote(lang)); + sb.put(" {\n"); + sb.put(" summary "); + sb.put(sdlQuote(loc.summary)); + sb.put("\n"); + sb.put(" description "); + sb.put(sdlQuote(loc.description)); + sb.put("\n }\n\n"); + } + + foreach (sec; cmd.sections) + emitSection(sb, sec, 1); + + sb.put("}\n"); + return sb.data; +} + +private void emitSection(ref Appender!string sb, Section sec, int depth) { + auto pad = replicate(" ", depth); + sb.put(pad); + sb.put("section "); + sb.put(sdlQuote(sec.name)); + sb.put(" {\n"); + auto inner = replicate(" ", depth + 1); + sb.put(inner); + sb.put("summary "); + sb.put(sdlQuote(sec.summary)); + sb.put("\n"); + if (sec.content.length) { + sb.put(inner); + sb.put("content "); + sb.put(sdlQuote(sec.content)); + sb.put("\n"); + } + if (sec.contentRef.length) { + sb.put(inner); + sb.put("content-ref "); + sb.put(sdlQuote(sec.contentRef)); + if (sec.contentFormat.length) { + sb.put(" {\n"); + sb.put(inner); + sb.put(" format "); + sb.put(sdlQuote(sec.contentFormat)); + sb.put("\n"); + sb.put(inner); + sb.put("}\n"); + } else { + sb.put("\n"); + } + } + if (sec.inlineExpand) { + sb.put(inner); + sb.put("inline true\n"); + } + foreach (ex; sec.examples) { + sb.put(inner); + sb.put("example "); + sb.put(sdlQuote(ex.title)); + sb.put(" "); + sb.put(sdlQuote(ex.command)); + sb.put("\n"); + } + + string[] tiers = ["high", "medium", "low"]; + foreach (tier; tiers) { + auto opts = sec.options.filter!(o => o.dominance == tier).array; + if (!opts.length) continue; + if (tier == "medium" && opts.length == sec.options.length + && sec.options.all!(o => o.dominance == "medium")) { + foreach (opt; opts) + emitOption(sb, inner, opt); + } else { + sb.put(inner); + sb.put("dominance "); + sb.put(sdlQuote(tier)); + sb.put(" {\n"); + auto optPad = inner ~ " "; + foreach (opt; opts) + emitOption(sb, optPad, opt); + sb.put(inner); + sb.put("}\n"); + } + } + + foreach (sub; sec.subsections) + emitSection(sb, sub, depth + 1); + + sb.put(pad); + sb.put("}\n"); +} + +private void emitOption(ref Appender!string sb, string pad, Option opt) { + sb.put(pad); + sb.put("option"); + foreach (flag; opt.flags) { + sb.put(" "); + sb.put(sdlQuote(flag)); + } + sb.put(" "); + sb.put(sdlQuote(opt.description)); + sb.put("\n"); +} + +Command starterCommand(string name) { + auto cmd = new Command(); + cmd.name = name.length ? name : "myapp"; + cmd.title = cmd.name; + cmd.summary = ""; + cmd.description = ""; + cmd.homepage = ""; + cmd.docsUrl = ""; + cmd.issuesUrl = ""; + + auto usage = new Section(); + usage.name = "usage"; + usage.summary = "How to invoke this command"; + usage.content = cmd.name ~ " [options]"; + auto ex = new Example(); + ex.title = "Show progressive help"; + ex.command = cmd.name ~ " ?"; + usage.examples ~= ex; + cmd.sections ~= usage; + return cmd; +} + +int runInitCommand(string[] args) { + string path; + string name; + bool force = false; + bool doFill = false; + + for (size_t i = 0; i < args.length; i++) { + auto arg = args[i]; + if (arg == "--force" || arg == "-f") { + force = true; + continue; + } + if (arg == "--fill" || arg == "--interactive") { + doFill = true; + continue; + } + if (arg == "--no-fill") { + doFill = false; + continue; + } + if (arg == "--name" && i + 1 < args.length) { + name = args[++i]; + continue; + } + if (arg.startsWith("--name=")) { + name = arg["--name=".length .. $]; + continue; + } + if (arg == "--help" || arg == "-h" || arg == "?") { + writeln("Usage: prohelp init [path] [--name NAME] [--force] [--fill]"); + writeln(" Writes a blank-but-shaped help.sdl, then optionally runs `prohelp fill`."); + writeln(" Default path: ./help.sdl"); + return 0; + } + if (arg.startsWith("-")) { + stderr.writeln("prohelp init: unknown flag ", arg); + return 2; + } + if (path.length) { + stderr.writeln("prohelp init: extra argument ", arg); + return 2; + } + path = arg; + } + + if (!path.length) path = "help.sdl"; + if (exists(path) && isDir(path)) + path = buildPath(path, "help.sdl"); + if (!name.length) + name = baseName(stripExtension(path)) == "help" + ? baseName(getcwd()) + : baseName(stripExtension(path)); + if (name == "." || name == "help" || !name.length) + name = "myapp"; + + if (exists(path) && !force) { + stderr.writeln("prohelp init: '", path, "' already exists. Pass --force to overwrite,"); + stderr.writeln(" or run: prohelp fill ", path); + return 2; + } + + auto dir = dirName(path); + if (dir.length && dir != "." && !exists(dir)) + mkdirRecurse(dir); + + auto text = emitHelpSdl(starterCommand(name)); + std.file.write(path, text); + writeln("Wrote ", path); + writeln("Next:"); + writeln(" prohelp fill ", path); + writeln(" prohelp check ", path); + writeln(" prohelp ", path, " ?"); + + if (doFill) + return runFillCommand([path]); + return 0; +} + +private string prompt(string label, string current) { + if (current.length) + stdout.write(label, " [", current, "]: "); + else + stdout.write(label, ": "); + stdout.flush(); + auto line = readln(); + if (line is null) return current; + line = line.strip(); + return line.length ? line : current; +} + +private bool promptYes(string label, bool defaultYes) { + stdout.write(label, defaultYes ? " [Y/n]: " : " [y/N]: "); + stdout.flush(); + auto line = readln(); + if (line is null) return defaultYes; + line = line.strip().toLower(); + if (!line.length) return defaultYes; + return line == "y" || line == "yes"; +} + +private void applySet(Command cmd, string key, string value) { + auto k = key.strip().toLower().replace("_", "-"); + if (k == "name" || k == "command") cmd.name = value; + else if (k == "title" || k == "full-name" || k == "app-name") cmd.title = value; + else if (k == "summary") cmd.summary = value; + else if (k == "description") cmd.description = value; + else if (k == "homepage" || k == "repo" || k == "repository") cmd.homepage = value; + else if (k == "docs" || k == "docs-url" || k == "documentation") cmd.docsUrl = value; + else if (k == "issues" || k == "issues-url") cmd.issuesUrl = value; + else if (k == "issues-ai" || k == "issues-ai-url" || k == "report") cmd.issuesAiUrl = value; + else throw new Exception("unknown --set field: " ~ key + ~ " (name, title, summary, description, homepage, docs, issues, issues-ai)"); +} + +private void fillInteractive(Command cmd) { + writeln("Fill empty fields. Enter keeps the current value. Ctrl+C to abort."); + cmd.name = prompt("Command name (binary)", cmd.name); + cmd.title = prompt("Title (full app name)", cmd.title.length ? cmd.title : cmd.name); + cmd.summary = prompt("Summary (one line)", cmd.summary); + cmd.description = prompt("Description", cmd.description); + cmd.homepage = prompt("Homepage / repo URL", cmd.homepage); + cmd.docsUrl = prompt("Docs URL", cmd.docsUrl); + cmd.issuesUrl = prompt("Issues URL", cmd.issuesUrl); + + foreach (sec; cmd.sections) { + writeln(); + writeln("Section '", sec.name, "':"); + sec.summary = prompt(" summary", sec.summary); + sec.content = prompt(" content (syntax / body)", sec.content); + if (!sec.examples.length && promptYes(" add an example?", true)) { + auto ex = new Example(); + ex.title = prompt(" example title", "Show progressive help"); + ex.command = prompt(" example command", cmd.name ~ " ?"); + sec.examples ~= ex; + } + } + + if (promptYes("Add another section?", false)) { + auto sec = new Section(); + sec.name = prompt(" section name", "options"); + sec.summary = prompt(" summary", ""); + sec.content = prompt(" content", ""); + if (sec.name.length) + cmd.sections ~= sec; + } +} + +int runFillCommand(string[] args) { + string path; + string[string] sets; + bool noPrompt = false; + + for (size_t i = 0; i < args.length; i++) { + auto arg = args[i]; + if (arg == "--no-prompt" || arg == "--non-interactive") { + noPrompt = true; + continue; + } + if (arg == "--help" || arg == "-h" || arg == "?") { + writeln("Usage: prohelp fill [path] [--set field=value] [--no-prompt]"); + writeln(" Questionnaire over an existing schema (created by `prohelp init`)."); + writeln(" --set can be repeated: --set summary=\"Does a thing\" --set homepage=https://…"); + return 0; + } + string rest; + if (arg.startsWith("--set=")) + rest = arg["--set=".length .. $]; + else if (arg == "--set" && i + 1 < args.length) + rest = args[++i]; + if (rest.length) { + auto eq = rest.indexOf('='); + if (eq <= 0) { + stderr.writeln("prohelp fill: --set needs field=value"); + return 2; + } + sets[rest[0 .. eq]] = rest[eq + 1 .. $]; + continue; + } + if (arg.startsWith("-")) { + stderr.writeln("prohelp fill: unknown flag ", arg); + return 2; + } + if (path.length) { + stderr.writeln("prohelp fill: extra argument ", arg); + return 2; + } + path = arg; + } + + if (!path.length) path = defaultSchemaPath(); + if (!exists(path) || !isFile(path)) { + stderr.writeln("prohelp fill: '", path, "' not found. Run: prohelp init ", path); + return 2; + } + if (!isSdlSchemaPath(path)) { + stderr.writeln("prohelp fill: questionnaire writes SDL. Convert ", path, " or init a help.sdl."); + return 2; + } + + Command cmd; + try { + cmd = parseHelpSDL(path, false); + } catch (Exception e) { + stderr.writeln(e.msg); + return 1; + } + + foreach (key, value; sets) { + try { + applySet(cmd, key, value); + } catch (Exception e) { + stderr.writeln("prohelp fill: ", e.msg); + return 2; + } + } + + bool wantPrompt = !noPrompt; + if (wantPrompt && !isStdinTTY()) { + if (!sets.length) { + stderr.writeln("prohelp fill: stdin is not a terminal."); + stderr.writeln(" Use --set field=value, or run in a TTY."); + return 2; + } + wantPrompt = false; + } + + if (wantPrompt) + fillInteractive(cmd); + + std.file.write(path, emitHelpSdl(cmd)); + writeln("Updated ", path); + writeln("Check it: prohelp check ", path); + writeln("Preview: prohelp ", path, " ?"); + return 0; +} + +unittest { + auto cmd = starterCommand("demo"); + auto text = emitHelpSdl(cmd); + assert(text.canFind(`command "demo"`)); + auto round = parseHelpSDLContent(text, "memory", false); + assert(round.name == "demo"); + assert(round.sections.length == 1); + assert(round.sections[0].examples.length == 1); +}