diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..51a2c0029 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Format source with oxfmt +449d17627fc405743130964a45b184b2fb6c6af0 diff --git a/.github/example-width.toml b/.github/example-width.toml new file mode 100644 index 000000000..60b7e17ae --- /dev/null +++ b/.github/example-width.toml @@ -0,0 +1,49 @@ +width = 80 +markdown = ["**/*.md"] +shared_digest = "28b74bae1f7a06731fea5a150cbde4430179b7788b7564ea1644c3951df8531b" +checker_digest = "eb87afceb3fc8f11ef5a478cd38f901ef3950e89ff94fbe0ca281c0cfc1a0e2e" +allow_ceiling = 8 + +[[exclude]] +glob = "notes/research/**" +reason = "dated research notes with pasted tool output and probes, kept as written; no reader copies code from them" + +[[allow]] +path = "site/src/content/docs/ports/py/examples/capture-pane-output.md" +line = " --with 'libtmux @ git+https://github.com/tmux-python/libtmux@9fdd083a8181a827889337b63cd4e6daea661a8c' \\" +reason = "one quoted PEP 508 requirement pinning libtmux to a commit; the URL and revision cannot break" + +[[allow]] +path = "site/src/content/docs/ports/py/guides/attaching-to-tmux.md" +line = " --with 'libtmux @ git+https://github.com/tmux-python/libtmux@9fdd083a8181a827889337b63cd4e6daea661a8c' \\" +reason = "one quoted PEP 508 requirement pinning libtmux to a commit; the URL and revision cannot break" + +[[allow]] +path = "WRITING.md" +line = "Session session = await server.CreateSessionAsync(new NewSessionRequest(name: \"work\"));" +reason = "the Bad example in the port block; it shows the width problem the Good example fixes" + +[[allow]] +path = "site/src/content/docs/ports/julia/examples/observe-output.md" +line = "with_server(; tmux=get(ENV, \"LIBTMUX_TEST_TMUX\", \"tmux\"), env=environment) do server" +reason = "a copy of libtmux-julia's examples/output_stream.jl at the revision site/test/fixtures/program-examples.json pins (6f62bd7), checked by sha256; libtmux-julia#7 fits the source, and this line follows when the pin moves" + +[[allow]] +path = "site/src/content/docs/ports/julia/examples/owned-capture.md" +line = " println(\"Captured \", string(pane.id), \": \", ncodeunits(screen), \" screen bytes\")" +reason = "a copy of libtmux-julia's examples/owned_capture.jl at the revision site/test/fixtures/program-examples.json pins (6f62bd7), checked by sha256; libtmux-julia#7 fits the source, and this line follows when the pin moves" + +[[allow]] +path = "site/src/content/docs/ports/julia/examples/shared-windows.md" +line = " only(windows(onlymatch(SessionWhere(name=\"ops\"), sessions(captured)))).ref" +reason = "a copy of libtmux-julia's examples/shared_windows.jl at the revision site/test/fixtures/program-examples.json pins (6f62bd7), checked by sha256; libtmux-julia#7 fits the source, and this line follows when the pin moves" + +[[allow]] +path = "site/src/content/docs/ports/julia/examples/shared-windows.md" +line = " selected = filter(PaneWhere(active=true, window=WindowWhere(name=\"api\")), panes(snap))" +reason = "a copy of libtmux-julia's examples/shared_windows.jl at the revision site/test/fixtures/program-examples.json pins (6f62bd7), checked by sha256; libtmux-julia#7 fits the source, and this line follows when the pin moves" + +[[allow]] +path = "site/src/content/docs/ports/julia/examples/shared-windows.md" +line = " only(Iterators.filter(p -> p.active && p.window.name == \"api\", panes(snap))).ref" +reason = "a copy of libtmux-julia's examples/shared_windows.jl at the revision site/test/fixtures/program-examples.json pins (6f62bd7), checked by sha256; libtmux-julia#7 fits the source, and this line follows when the pin moves" diff --git a/.github/workflows/shared-examples.yml b/.github/workflows/shared-examples.yml new file mode 100644 index 000000000..8ca9f7b78 --- /dev/null +++ b/.github/workflows/shared-examples.yml @@ -0,0 +1,36 @@ +name: shared-examples + +# The example rules are one text and one checker in every libtmux port. This +# job compares them with each port's default branch, so it needs the network +# and stays out of the local test loops. It runs on a change to the shared +# files or this gate, and weekly; a port that drifts on its own surfaces in +# the weekly run instead of failing unrelated pull requests here. + +on: + push: + branches: [main] + paths: &shared + - WRITING.md + - scripts/check_example_width.py + - scripts/check_shared_examples.py + - .github/workflows/shared-examples.yml + pull_request: + paths: *shared + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - run: python3 scripts/check_shared_examples.py --self-test + - run: python3 scripts/check_shared_examples.py + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..c95054231 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,21 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 120, + "ignorePatterns": [ + "pnpm-lock.yaml", + "site/src/data/**", + "site/public/**", + "_site/**", + "dist/**", + ".port-sources/**", + "infra/**", + "**/*.json", + "**/*.yml", + "**/*.yaml", + "**/*.cff", + "**/*.css", + "**/*.md", + "**/*.mdx" + ] +} diff --git a/AGENTS.md b/AGENTS.md index 971b36373..96e7e5e27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,10 @@ requested work. - Environment, Oxlint, formatting, tests, and pull requests: [CONTRIBUTING.md](CONTRIBUTING.md). -- Documentation, user-facing text, comments, and commits: [WRITING.md](WRITING.md). +- Documentation, user-facing text, comments, commits, and any code example + (example programs, console recipes, Markdown code blocks): + [WRITING.md](WRITING.md), and its [Documented examples that + run](WRITING.md#documented-examples-that-run) section for code a reader sees. Each guide is the single home for its subject. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b927d5875..b859b9c91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,15 +47,68 @@ Run lint across the workspace and build scripts: $ pnpm run lint ``` -The theme package retains Biome in its formatting script; it is not the -workspace lint gate. This command writes changes, so review its diff: +`oxfmt` formats the site's own TypeScript, JavaScript, and Astro source at +120 columns, with no semicolons and single quotes. It is pinned in the pnpm +catalog; `.oxfmtrc.json` lists what it skips. Markdown stays out because +`site/test/complete-examples.test.ts` pins fenced programs by hash. Check +formatting, or rewrite files in place and review the diff: ```console -$ pnpm --filter @libtmux/theme format +$ pnpm run format:check +``` + +```console +$ pnpm run format ``` ## Checks +Hold hand-written code fences to 80 columns. The check reads +`.github/example-width.toml`, which lists the surfaces it covers and each +line allowed to stay wider, with its reason: + +```console +$ python3 scripts/check_example_width.py +``` + +The `--self-test` flag plants a failure for every rule the check enforces, +plus clean controls. +The medium loop and the publication audit run both. + +The checker and the shared section of `WRITING.md` must match every other +libtmux port. CI compares them with each port's default branch weekly, and +on pushes to `main` and pull requests that change them or the comparison +itself. The comparison needs the network, so it is not part of the local +loops: + +```console +$ python3 scripts/check_shared_examples.py +``` + +A port with no checker or shared section fails, unless the script's +`PENDING` list names it with a reason and the date it was listed. Every +port is pending until its example-style change merges; a pending port that +already has both files fails until it leaves the list, and an entry older +than 30 days fails until it is merged or given a new reason and date. +To roll out a change to the shared text or checker, record the digests +the ports still carry in the script's `PREVIOUS` with the last day they +are accepted, so ports can merge one at a time. + +A port the script cannot read, such as a private repository, goes in +`NOT_FETCHED` with the place it is compared instead, and the script +prints a notice for it; every port is public, so the list is empty. The +script also lists the public, non-archived repositories of the `libtmux` +organisation through the GitHub REST API, and fails for any repository +outside `PORTS` whose `WRITING.md` carries the shared section, so a new +port cannot adopt it unseen. Set `GITHUB_TOKEN` to avoid the +unauthenticated rate limit. + +The script's `--self-test` runs without the network. It plants checker +and text drift, a missing file, a stale pending entry, an entry for an +unknown port, a pending port with drift or with both files, a previous +digest inside and past its window, a port it must not fetch, an unlisted +adopter, and an unlisted repository without the markers. + Run workspace type checks: ```console @@ -118,6 +171,12 @@ Install the pinned Chromium build for local browser checks with that build and its system dependencies before the publication audit. The standard ten-second browser timeout increases to twenty seconds on CI. +`site/test/code-block-width.outer.test.ts` opens a built page at 1024 and +1280 pixels and checks that its code blocks fit 80 columns. It measures in +Chrome, because the bundled Chromium draws the code font narrower than the +browsers readers use. It reads the assembled `_site`, so only the +publication audit runs it; the loops skip it. + Keep the complete assembly, all output suites, link audits, source/model freshness, and full browser matrix in the publication audit: diff --git a/WRITING.md b/WRITING.md index 5a21a5326..9523f05b2 100644 --- a/WRITING.md +++ b/WRITING.md @@ -167,10 +167,92 @@ project and `main.cpp`, installs the library with its public testing component, and builds against that installed prefix. The native source suite checks the same files against private tmux servers, including operation failure and cleanup. -Put explanatory comments on separate lines above the code they describe. -Limit example comments to 100 columns, including indentation; prefer shorter -lines that fit the code panel. Put long source links and attribution in prose -outside the code block. +Put long source links and attribution in prose outside the code block. + + + +An example is code written for a reader: a program under `examples/`, code in +a doc comment or docstring, and every fenced block in a README or docs page. +Shell blocks also follow [Code blocks](#code-blocks). + +The text between the shared markers is the same in every libtmux port. +Change it in all of them together. + +### Width + +- **Examples stay within 80 columns.** They render in fixed-width boxes that + scroll sideways, and 80 columns fits a libtmux.org code block in a + laptop-width window. Comments inside examples wrap at 80 too. +- **The width check enforces it.** It reads the tracked files that + `.github/example-width.toml` names and fails on a wider line. It measures + the whole source line, so code in a doc comment counts its indent and + comment marker. It skips output (a fence tagged `text`, and what a + `console` block prints), hidden setup lines, and a line that is only a URL; + an untagged fence counts as code. +- **A line that must stay wider is listed there with its reason.** An entry + that no longer matches a line fails the check, so no stale entry stays. +- **The formatter's width is the hard limit for all other source.** Example + directories set their formatter to 80 where the formatter takes a width. + +### Reaching 80 + +- **Change the code, not the line breaks.** A formatter rejoins any line that + fits its width. Name a sub-expression, use a short example name, hide setup + the reader does not need, or print less. +- **Break at the outermost level when a break is still needed:** after an + opening parenthesis with one argument per line, one call per line in a + chain, one field per line in a literal. +- **Put a comment on its own line above the code it explains.** Never trail + one after code in an example, unless the repository's example runner reads + it there, as with an assertion marker. +- **Break a long string at a word boundary,** never inside a tmux format + (`#{...}`) or an escape sequence; the joined text stays the same. +- **Continue a long command in a `console` block the way its shell does:** + `\` after a `$ ` prompt, a backtick after `PS> `, one flag per continuation + line. + +### What never breaks + +- **Output a test compares.** Wrapping it changes what the test expects. +- **A block copied from a source file.** Fix the width in the source, then + refresh the copy with the sync command the port block names, or by hand + where it names none; never fix the copy alone. +- **Marker lines and URLs,** which tools and readers take whole. + + + +### In this repository + +- **Hard limit:** oxfmt (`.oxfmtrc.json`, `printWidth`) formats the site's + own source and has no example directory to hold to 80. The width check is + `python3 scripts/check_example_width.py`. +- **Not formatted:** Markdown, which oxfmt skips because + `site/test/complete-examples.test.ts` pins fenced programs by sha256. Hold + every fence to 80 by hand. +- **Runs, compiles, exempt:** + [Documented examples that run](#documented-examples-that-run) decides how + each kind of fence runs; every kind is held to the width, including blocks + quoted by hand and plain fences that do not run. +- **Compared output and copied blocks:** output a program's run compares + never breaks. A program or console recipe pinned in + `site/test/fixtures/*-examples.json` changes only together with its new + hash or `shellRecipe` and a new native run on both recorded tmux versions. + Fix a quoted block in its port source, never on the page, and refresh the + `file=` quotes with `node scripts/gen-example-sources.mjs` once the port's + pinned revision moves. + +Bad, over 80: + +```csharp +Session session = await server.CreateSessionAsync(new NewSessionRequest(name: "work")); +``` + +Good, the request named in its own statement: + +```csharp +NewSessionRequest request = new(name: "work"); +Session session = await server.CreateSessionAsync(request); +``` ### Examples across ports @@ -322,6 +404,8 @@ Reserve GitHub alert syntax for GitHub-only material and only when a warning is necessary; the warning's text must stand alone. Put blank lines around lists and headings, and preserve existing anchors when restructuring prose. +### Code blocks + Code blocks are paste-and-run units: - Put one command in each block. An explicit `&&`, `;`, or `\` chain counts diff --git a/docs/ci.md b/docs/ci.md index 5d532def0..277e07520 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -228,7 +228,8 @@ jobs: uses: libtmux/docs/.github/workflows/port-docs.yml@ with: port: rs - tag-prefix: libtmux@ # stripped from a release tag; omit when tags are bare + # stripped from a release tag; omit when tags are bare + tag-prefix: libtmux@ source-ref: ${{ inputs.source-ref }} version: ${{ inputs.version }} version-kind: ${{ inputs.version-kind }} diff --git a/notes/upstream/libtmux-cxx-doxygen-comments.md b/notes/upstream/libtmux-cxx-doxygen-comments.md index 451e17fde..aeb5eee88 100755 --- a/notes/upstream/libtmux-cxx-doxygen-comments.md +++ b/notes/upstream/libtmux-cxx-doxygen-comments.md @@ -19,7 +19,9 @@ $ python3 -c " import re, glob tot = empty = 0 for f in glob.glob('xml/*.xml'): - for m in re.finditer(r'(.*?)', open(f).read(), re.S): + text = open(f).read() + pattern = r'(.*?)' + for m in re.finditer(pattern, text, re.S): tot += 1 empty += not m.group(1).strip() print(tot, empty)" diff --git a/package.json b/package.json index 2eb832492..ebe8e0d50 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "preview": "pnpm --filter @libtmux/site preview", "build:site": "./scripts/build-site.sh", "type-check": "pnpm run --recursive type-check", + "format": "oxfmt --write .", + "format:check": "oxfmt --check .", "lint": "pnpm run --recursive lint && oxlint scripts", "test": "node scripts/test-loop.mjs outer", "test:inner": "node scripts/test-loop.mjs inner", @@ -21,11 +23,11 @@ }, "packageManager": "pnpm@12.7.0", "devDependencies": { - "@biomejs/biome": "catalog:", "entities": "catalog:", "happy-dom": "catalog:", "mdast-util-from-markdown": "2.0.3", "mdast-util-to-markdown": "2.1.3", + "oxfmt": "catalog:", "oxlint": "catalog:", "typescript": "catalog:" } diff --git a/packages/api-model/src/builtins.ts b/packages/api-model/src/builtins.ts index e6d7f2668..ca6e4da55 100644 --- a/packages/api-model/src/builtins.ts +++ b/packages/api-model/src/builtins.ts @@ -25,8 +25,7 @@ const rust = (page: string) => `https://doc.rust-lang.org/std/${page}` const go = (page: string) => `https://pkg.go.dev/${page}` const cpp = (page: string) => `https://en.cppreference.com/w/cpp/${page}` const swift = (page: string) => `https://developer.apple.com/documentation/swift/${page}` -const mdn = (page: string) => - `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/${page}` +const mdn = (page: string) => `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/${page}` const ts = (page: string) => `https://www.typescriptlang.org/docs/handbook/2/${page}` /** Compiler-only utilities have no JavaScript global or DOM reference on MDN. */ @@ -63,48 +62,60 @@ const coroutines = (page: string) => `https://kotlinlang.org/api/kotlinx.corouti const catsEffect = (name: string) => `https://typelevel.org/cats-effect/api/3.x/cats/effect/kernel/${name}.html` /** Imports and unqualified references share the same verified destination. */ -const qualifiedKotlin = Object.fromEntries([ - ['kotlin.time.Duration', kotlin('kotlin.time/-duration')], - ['kotlin.time.toKotlinDuration', kotlin('kotlin.time').replace(/\/$/, '/to-kotlin-duration.html')], - ['kotlin.time.toJavaDuration', kotlin('kotlin.time').replace(/\/$/, '/to-java-duration.html')], - ['kotlinx.coroutines.CoroutineScope', coroutines('kotlinx.coroutines/-coroutine-scope/')], - ['kotlinx.coroutines.CoroutineDispatcher', coroutines('kotlinx.coroutines/-coroutine-dispatcher/')], - ['kotlinx.coroutines.Dispatchers', coroutines('kotlinx.coroutines/-dispatchers/')], - ['kotlinx.coroutines.Dispatchers.IO', coroutines('kotlinx.coroutines/-dispatchers/-i-o.html')], - ['kotlinx.coroutines.Dispatchers.Default', coroutines('kotlinx.coroutines/-dispatchers/-default.html')], - ['kotlinx.coroutines.NonCancellable', coroutines('kotlinx.coroutines/-non-cancellable/')], - ['kotlinx.coroutines.launch', coroutines('kotlinx.coroutines/launch.html')], - ['kotlinx.coroutines.coroutineScope', coroutines('kotlinx.coroutines/coroutine-scope.html')], - ['kotlinx.coroutines.withContext', coroutines('kotlinx.coroutines/with-context.html')], - ['kotlinx.coroutines.runInterruptible', coroutines('kotlinx.coroutines/run-interruptible.html')], - ['kotlinx.coroutines.flow.Flow', coroutines('kotlinx.coroutines.flow/-flow/')], - ['kotlinx.coroutines.flow.StateFlow', coroutines('kotlinx.coroutines.flow/-state-flow/')], - ['kotlinx.coroutines.flow.MutableStateFlow', coroutines('kotlinx.coroutines.flow/-mutable-state-flow/')], -].flatMap(([name, href]) => [[name!, href!], [name!.replace(/^kotlin(?:x\.coroutines(?:\.flow)?|\.time)\./, ''), href!]])) +const qualifiedKotlin = Object.fromEntries( + [ + ['kotlin.time.Duration', kotlin('kotlin.time/-duration')], + ['kotlin.time.toKotlinDuration', kotlin('kotlin.time').replace(/\/$/, '/to-kotlin-duration.html')], + ['kotlin.time.toJavaDuration', kotlin('kotlin.time').replace(/\/$/, '/to-java-duration.html')], + ['kotlinx.coroutines.CoroutineScope', coroutines('kotlinx.coroutines/-coroutine-scope/')], + ['kotlinx.coroutines.CoroutineDispatcher', coroutines('kotlinx.coroutines/-coroutine-dispatcher/')], + ['kotlinx.coroutines.Dispatchers', coroutines('kotlinx.coroutines/-dispatchers/')], + ['kotlinx.coroutines.Dispatchers.IO', coroutines('kotlinx.coroutines/-dispatchers/-i-o.html')], + ['kotlinx.coroutines.Dispatchers.Default', coroutines('kotlinx.coroutines/-dispatchers/-default.html')], + ['kotlinx.coroutines.NonCancellable', coroutines('kotlinx.coroutines/-non-cancellable/')], + ['kotlinx.coroutines.launch', coroutines('kotlinx.coroutines/launch.html')], + ['kotlinx.coroutines.coroutineScope', coroutines('kotlinx.coroutines/coroutine-scope.html')], + ['kotlinx.coroutines.withContext', coroutines('kotlinx.coroutines/with-context.html')], + ['kotlinx.coroutines.runInterruptible', coroutines('kotlinx.coroutines/run-interruptible.html')], + ['kotlinx.coroutines.flow.Flow', coroutines('kotlinx.coroutines.flow/-flow/')], + ['kotlinx.coroutines.flow.StateFlow', coroutines('kotlinx.coroutines.flow/-state-flow/')], + ['kotlinx.coroutines.flow.MutableStateFlow', coroutines('kotlinx.coroutines.flow/-mutable-state-flow/')], + ].flatMap(([name, href]) => [ + [name!, href!], + [name!.replace(/^kotlin(?:x\.coroutines(?:\.flow)?|\.time)\./, ''), href!], + ]), +) const qualifiedScala = Object.fromEntries([ ...['Async', 'Deferred', 'Outcome', 'Resource', 'Fiber', 'Ref'].flatMap((name) => - [name, `cats.effect.${name}`, `cats.effect.kernel.${name}`].map((alias) => [alias, catsEffect(name)])), + [name, `cats.effect.${name}`, `cats.effect.kernel.${name}`].map((alias) => [alias, catsEffect(name)]), + ), ...['Signal', 'SignallingRef'].flatMap((name) => - [name, `fs2.concurrent.${name}`].map((alias) => [alias, `https://javadoc.io/static/co.fs2/fs2-core_3/3.13.0/fs2/concurrent/${name}.html`])), + [name, `fs2.concurrent.${name}`].map((alias) => [ + alias, + `https://javadoc.io/static/co.fs2/fs2-core_3/3.13.0/fs2/concurrent/${name}.html`, + ]), + ), ]) -const qualifiedDotnetExceptions = Object.fromEntries([ - 'System.Exception', - 'System.ArgumentException', - 'System.ArgumentNullException', - 'System.ArgumentOutOfRangeException', - 'System.InvalidOperationException', - 'System.NotSupportedException', - 'System.ObjectDisposedException', - 'System.TimeoutException', - 'System.FormatException', - 'System.OperationCanceledException', - 'System.IO.InvalidDataException', - 'System.IO.IOException', - 'System.Text.RegularExpressions.RegexMatchTimeoutException', - 'System.Text.Json.JsonException', - 'System.Threading.Tasks.TaskCanceledException', -].flatMap((name) => [name, name.split('.').at(-1)!].map((alias) => [alias, dotnet(name.toLowerCase())]))) +const qualifiedDotnetExceptions = Object.fromEntries( + [ + 'System.Exception', + 'System.ArgumentException', + 'System.ArgumentNullException', + 'System.ArgumentOutOfRangeException', + 'System.InvalidOperationException', + 'System.NotSupportedException', + 'System.ObjectDisposedException', + 'System.TimeoutException', + 'System.FormatException', + 'System.OperationCanceledException', + 'System.IO.InvalidDataException', + 'System.IO.IOException', + 'System.Text.RegularExpressions.RegexMatchTimeoutException', + 'System.Text.Json.JsonException', + 'System.Threading.Tasks.TaskCanceledException', + ].flatMap((name) => [name, name.split('.').at(-1)!].map((alias) => [alias, dotnet(name.toLowerCase())])), +) export const BUILTINS: Record> = { julia: { @@ -113,48 +124,76 @@ export const BUILTINS: Record> = { 'Core.String': 'https://docs.julialang.org/en/v1/base/strings/#Core.String-Tuple%7BAbstractString%7D', }, powershell: { - ...Object.fromEntries(Object.entries({ - 'Get-Member': 'microsoft.powershell.utility', - 'Where-Object': 'microsoft.powershell.core', - 'Invoke-WebRequest': 'microsoft.powershell.utility', - 'Start-ThreadJob': 'threadjob', - 'Receive-Job': 'microsoft.powershell.core', - 'Set-Content': 'microsoft.powershell.management', - }).map(([name, module]) => [name, - `https://learn.microsoft.com/powershell/module/${module}/${name.toLowerCase()}?view=powershell-7.4`])), + ...Object.fromEntries( + Object.entries({ + 'Get-Member': 'microsoft.powershell.utility', + 'Where-Object': 'microsoft.powershell.core', + 'Invoke-WebRequest': 'microsoft.powershell.utility', + 'Start-ThreadJob': 'threadjob', + 'Receive-Job': 'microsoft.powershell.core', + 'Set-Content': 'microsoft.powershell.management', + }).map(([name, module]) => [ + name, + `https://learn.microsoft.com/powershell/module/${module}/${name.toLowerCase()}?view=powershell-7.4`, + ]), + ), FileInfo: dotnet('system.io.fileinfo'), ApplicationInfo: `${dotnet('system.management.automation.applicationinfo')}?view=powershellsdk-7.4.0`, - ...Object.fromEntries([ - 'ArgumentCompleter', 'ValidateCount', 'ValidateLength', 'ValidateNotNull', - 'ValidateNotNullOrEmpty', 'ValidatePattern', 'ValidateRange', 'ValidateSet', - ].map((name) => [`System.Management.Automation.${name}Attribute`, - `${dotnet(`system.management.automation.${name.toLowerCase()}attribute`)}?view=powershellsdk-7.4.0`])), - ...Object.fromEntries(['String', 'Int32', 'Int64', 'Double', 'Boolean', 'Object', 'IO.FileInfo', 'Collections.IDictionary'] - .map((name) => [`System.${name}`, dotnet(`system.${name.toLowerCase()}`)])), + ...Object.fromEntries( + [ + 'ArgumentCompleter', + 'ValidateCount', + 'ValidateLength', + 'ValidateNotNull', + 'ValidateNotNullOrEmpty', + 'ValidatePattern', + 'ValidateRange', + 'ValidateSet', + ].map((name) => [ + `System.Management.Automation.${name}Attribute`, + `${dotnet(`system.management.automation.${name.toLowerCase()}attribute`)}?view=powershellsdk-7.4.0`, + ]), + ), + ...Object.fromEntries( + ['String', 'Int32', 'Int64', 'Double', 'Boolean', 'Object', 'IO.FileInfo', 'Collections.IDictionary'].map( + (name) => [`System.${name}`, dotnet(`system.${name.toLowerCase()}`)], + ), + ), 'System.Nullable': dotnet('system.nullable-1'), 'System.Management.Automation.SwitchParameter': `${dotnet('system.management.automation.switchparameter')}?view=powershellsdk-7.4.0`, 'System.Management.Automation.ActionPreference': `${dotnet('system.management.automation.actionpreference')}?view=powershellsdk-7.4.0`, - CommonParameters: 'https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_commonparameters?view=powershell-7.4', + CommonParameters: + 'https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_commonparameters?view=powershell-7.4', }, kotlin: { ...qualifiedKotlin, suspend: 'https://kotlinlang.org/docs/coroutines-basics.html#suspending-functions', finally: 'https://kotlinlang.org/docs/exceptions.html#finally-block', - String: kotlin('kotlin/-string'), Boolean: kotlin('kotlin/-boolean'), - Int: kotlin('kotlin/-int'), Long: kotlin('kotlin/-long'), - Unit: kotlin('kotlin/-unit'), Any: kotlin('kotlin/-any'), - List: kotlin('kotlin.collections/-list'), Map: kotlin('kotlin.collections/-map'), + String: kotlin('kotlin/-string'), + Boolean: kotlin('kotlin/-boolean'), + Int: kotlin('kotlin/-int'), + Long: kotlin('kotlin/-long'), + Unit: kotlin('kotlin/-unit'), + Any: kotlin('kotlin/-any'), + List: kotlin('kotlin.collections/-list'), + Map: kotlin('kotlin.collections/-map'), Iterable: kotlin('kotlin.collections/-iterable'), Flow: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/', - StateFlow: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/', - 'collect()': 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/collect.html', + StateFlow: + 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/', + 'collect()': + 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/collect.html', 'singleOrNull()': 'https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.collections/single-or-null.html', }, scala: { ...qualifiedScala, - String: scala('scala/Predef$'), Boolean: scala('scala/Boolean'), - Int: scala('scala/Int'), Long: scala('scala/Long'), Unit: scala('scala/Unit'), - Option: scala('scala/Option'), Either: scala('scala/util/Either'), + String: scala('scala/Predef$'), + Boolean: scala('scala/Boolean'), + Int: scala('scala/Int'), + Long: scala('scala/Long'), + Unit: scala('scala/Unit'), + Option: scala('scala/Option'), + Either: scala('scala/util/Either'), Vector: scala('scala/collection/immutable/Vector'), List: scala('scala/collection/immutable/List'), IterableOnce: scala('scala/collection/IterableOnce'), @@ -170,8 +209,10 @@ export const BUILTINS: Record> = { option: fsharp('fsharp-core-fsharpoption-1'), list: fsharp('fsharp-collections-fsharplist-1'), seq: dotnet('system.collections.generic.ienumerable-1'), - string: dotnet('system.string'), bool: dotnet('system.boolean'), - int: dotnet('system.int32'), int64: dotnet('system.int64'), + string: dotnet('system.string'), + bool: dotnet('system.boolean'), + int: dotnet('system.int32'), + int64: dotnet('system.int64'), Task: dotnet('system.threading.tasks.task'), CancellationToken: dotnet('system.threading.cancellationtoken'), IReadOnlyList: dotnet('system.collections.generic.ireadonlylist-1'), @@ -366,7 +407,8 @@ export const BUILTINS: Record> = { ...TYPESCRIPT_UTILITY_TYPES, }, java: { - ExtensionContext: 'https://docs.junit.org/current/api/org.junit.jupiter.api/org/junit/jupiter/api/extension/ExtensionContext.html', + ExtensionContext: + 'https://docs.junit.org/current/api/org.junit.jupiter.api/org/junit/jupiter/api/extension/ExtensionContext.html', boolean: 'https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.2.5', int: 'https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.2.1', long: 'https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.2.1', @@ -377,9 +419,10 @@ export const BUILTINS: Record> = { // the subtype 5.3 added and LuaLS spells separately. `any` and `unknown` // are LuaLS annotation types, with no counterpart in the language. lua: Object.fromEntries([ - ...['nil', 'boolean', 'number', 'integer', 'string', 'function', 'table', 'thread', 'userdata'].map( - (name) => [name, 'https://www.lua.org/manual/5.4/manual.html#2.1'], - ), + ...['nil', 'boolean', 'number', 'integer', 'string', 'function', 'table', 'thread', 'userdata'].map((name) => [ + name, + 'https://www.lua.org/manual/5.4/manual.html#2.1', + ]), ...['any', 'unknown'].map((name) => [name, 'https://luals.github.io/wiki/annotations/#documenting-types']), ]), py: { @@ -394,8 +437,11 @@ export function builtinHref(port: string, name: string): string | undefined { if (!Object.hasOwn(BUILTINS, port)) return undefined const types = BUILTINS[port]! if (Object.hasOwn(types, name)) return types[name] - const nativeName = port === 'kotlin' - ? name.replace(/^kotlin\.(?:collections\.)?/, '') - : port === 'scala' ? name.replace(/^scala\.(?:Predef\.|collection\.(?:immutable\.)?|util\.)?/, '') : name + const nativeName = + port === 'kotlin' + ? name.replace(/^kotlin\.(?:collections\.)?/, '') + : port === 'scala' + ? name.replace(/^scala\.(?:Predef\.|collection\.(?:immutable\.)?|util\.)?/, '') + : name return Object.hasOwn(types, nativeName) ? types[nativeName] : undefined } diff --git a/packages/api-model/src/concepts.ts b/packages/api-model/src/concepts.ts index 4293449ac..15b1f3e6e 100644 --- a/packages/api-model/src/concepts.ts +++ b/packages/api-model/src/concepts.ts @@ -47,8 +47,10 @@ export const CONCEPTS: Record = { julia: 'LibTmuxWorkspace.apply', }, notes: { - powershell: 'Applies the exact reviewed `-Plan` after PowerShell confirmation. `-WhatIf` previews without applying it; application is detached.', - julia: 'Applies a prepared `WorkspacePlan` on an explicit `Server`, detached and under one deadline. Existing session names fail unless reuse is explicit.', + powershell: + 'Applies the exact reviewed `-Plan` after PowerShell confirmation. `-WhatIf` previews without applying it; application is detached.', + julia: + 'Applies a prepared `WorkspacePlan` on an explicit `Server`, detached and under one deadline. Existing session names fail unless reuse is explicit.', }, }, 'freeze-workspace': { @@ -257,7 +259,7 @@ export const CONCEPTS: Record = { }, }, 'rotate-panes': { - label: "Rotate panes within a window", + label: 'Rotate panes within a window', symbols: { py: 'libtmux.Window.rotate', ts: 'window.Window.rotate', @@ -331,7 +333,7 @@ export const CONCEPTS: Record = { }, }, 'window-session': { - label: "Find the session containing a window placement", + label: 'Find the session containing a window placement', parentObject: 'session', symbols: { kotlin: 'io.github.libtmux.kotlin.Window.session', @@ -1755,7 +1757,7 @@ export const CONCEPTS: Record = { cxx: '`Server::tmux_version` asks the executable with `tmux -V` without reaching a server', }, }, - 'snapshot': { + snapshot: { label: 'Capture the tmux hierarchy', symbols: { scala: 'io.github.libtmux.scaladsl.Server.snapshot', @@ -3688,21 +3690,34 @@ const LUA_CONCEPTS: Record = { // These Cats Effect operations forward to the corresponding Java handle; // their execution and ownership differ, while the tmux operation is shared. const SCALA_EFFECT_CONCEPTS: Record = { - server: 'Server', session: 'Session', window: 'Window', pane: 'Pane', - 'capture-pane': 'Pane.capture', 'send-keys': 'Pane.sendKeys', - 'split-pane': 'Pane.split', 'split-window': 'Window.split', - 'new-session': 'Server.newSession', 'new-window': 'Session.newWindow', - 'kill-server': 'Server.killServer', 'list-sessions': 'Server.sessions', - 'list-windows': 'Session.windows', 'list-panes': 'Window.panes', - 'list-server-windows': 'Server.windows', 'list-server-panes': 'Server.panes', - 'list-clients': 'Server.clients', snapshot: 'Server.snapshot', - 'pane-window': 'Pane.window', 'window-session': 'Window.session', - 'session-server': 'Session.server', 'window-server': 'Window.server', - 'pane-server': 'Pane.server', 'client-server': 'Client.server', + server: 'Server', + session: 'Session', + window: 'Window', + pane: 'Pane', + 'capture-pane': 'Pane.capture', + 'send-keys': 'Pane.sendKeys', + 'split-pane': 'Pane.split', + 'split-window': 'Window.split', + 'new-session': 'Server.newSession', + 'new-window': 'Session.newWindow', + 'kill-server': 'Server.killServer', + 'list-sessions': 'Server.sessions', + 'list-windows': 'Session.windows', + 'list-panes': 'Window.panes', + 'list-server-windows': 'Server.windows', + 'list-server-panes': 'Server.panes', + 'list-clients': 'Server.clients', + snapshot: 'Server.snapshot', + 'pane-window': 'Pane.window', + 'window-session': 'Window.session', + 'session-server': 'Session.server', + 'window-server': 'Window.server', + 'pane-server': 'Pane.server', + 'client-server': 'Client.server', 'client-session': 'Client.session', } for (const [id, symbol] of Object.entries(SCALA_EFFECT_CONCEPTS)) { - const variants = CONCEPTS[id]!.variants ??= {} + const variants = (CONCEPTS[id]!.variants ??= {}) variants.scala = [...(variants.scala ?? []), `io.github.libtmux.scaladsl.cats.${symbol}`] } for (const [id, symbol] of Object.entries(RUBY_CONCEPTS)) CONCEPTS[id]!.symbols.ruby = symbol @@ -3713,63 +3728,171 @@ for (const [id, symbol] of Object.entries(LUA_CONCEPTS)) CONCEPTS[id]!.symbols.l // Keep those distinctions with the mappings instead of inventing facade classes. const HIERARCHY_CONCEPTS: Record> = { server: { - powershell: { symbol: 'LibTmux\\New-TmuxServer', note: 'This cmdlet constructs a shared .NET `LibTmux.Server` handle without contacting tmux.' }, - julia: { symbol: 'LibTmux.Server', note: '`Server` describes an endpoint without contacting tmux. Read its hierarchy with `snapshot`.' }, - fsharp: { symbol: 'LibTmux.FSharp.Server', note: 'This F# module provides functions over the shared .NET `LibTmux.Server` type.' }, + powershell: { + symbol: 'LibTmux\\New-TmuxServer', + note: 'This cmdlet constructs a shared .NET `LibTmux.Server` handle without contacting tmux.', + }, + julia: { + symbol: 'LibTmux.Server', + note: '`Server` describes an endpoint without contacting tmux. Read its hierarchy with `snapshot`.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Server', + note: 'This F# module provides functions over the shared .NET `LibTmux.Server` type.', + }, }, session: { - powershell: { symbol: 'LibTmux\\Get-TmuxSession', note: 'This cmdlet reads and returns shared .NET `LibTmux.Session` objects. Child windows and panes require further acquisition.' }, - julia: { symbol: 'LibTmux.SessionSnapshot', variants: ['LibTmux.SessionRef'], note: '`SessionSnapshot` holds captured session data. Actions use its `SessionRef`, a typed identity bound to an observed daemon generation.' }, - fsharp: { symbol: 'LibTmux.FSharp.Session', note: 'This F# module builds deferred child queries from a shared .NET `LibTmux.Session` and its server association. `Query.list` acquires their results from tmux.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxSession', + note: 'This cmdlet reads and returns shared .NET `LibTmux.Session` objects. Child windows and panes require further acquisition.', + }, + julia: { + symbol: 'LibTmux.SessionSnapshot', + variants: ['LibTmux.SessionRef'], + note: '`SessionSnapshot` holds captured session data. Actions use its `SessionRef`, a typed identity bound to an observed daemon generation.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Session', + note: 'This F# module builds deferred child queries from a shared .NET `LibTmux.Session` and its server association. `Query.list` acquires their results from tmux.', + }, }, window: { - powershell: { symbol: 'LibTmux\\Get-TmuxWindow', note: 'This cmdlet reads shared .NET `LibTmux.Window` placements. Use `-Server` for all sessions or `-Session` for one session.' }, - julia: { symbol: 'LibTmux.WindowSnapshot', variants: ['LibTmux.WindowRef'], note: '`WindowSnapshot` holds captured physical-window data. `WindowLink` records a placement in a session; actions use `WindowRef`.' }, - fsharp: { symbol: 'LibTmux.FSharp.Window', note: 'This F# module identifies shared .NET `LibTmux.Window` placements and builds deferred pane queries. `Query.list` acquires their results from tmux.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxWindow', + note: 'This cmdlet reads shared .NET `LibTmux.Window` placements. Use `-Server` for all sessions or `-Session` for one session.', + }, + julia: { + symbol: 'LibTmux.WindowSnapshot', + variants: ['LibTmux.WindowRef'], + note: '`WindowSnapshot` holds captured physical-window data. `WindowLink` records a placement in a session; actions use `WindowRef`.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Window', + note: 'This F# module identifies shared .NET `LibTmux.Window` placements and builds deferred pane queries. `Query.list` acquires their results from tmux.', + }, }, pane: { - powershell: { symbol: 'LibTmux\\Get-TmuxPane', note: 'This cmdlet reads shared .NET `LibTmux.Pane` objects. Its `-Server`, `-Session`, and `-Window` parameter sets select the scope.' }, - julia: { symbol: 'LibTmux.PaneSnapshot', variants: ['LibTmux.PaneRef'], note: '`PaneSnapshot` holds captured pane data. Actions use its `PaneRef`; `PaneOccurrence` adds a window placement in a session.' }, - fsharp: { symbol: 'LibTmux.FSharp.Pane', note: 'This F# module reads captured fields and starts explicit operations on the shared .NET `LibTmux.Pane` type.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxPane', + note: 'This cmdlet reads shared .NET `LibTmux.Pane` objects. Its `-Server`, `-Session`, and `-Window` parameter sets select the scope.', + }, + julia: { + symbol: 'LibTmux.PaneSnapshot', + variants: ['LibTmux.PaneRef'], + note: '`PaneSnapshot` holds captured pane data. Actions use its `PaneRef`; `PaneOccurrence` adds a window placement in a session.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Pane', + note: 'This F# module reads captured fields and starts explicit operations on the shared .NET `LibTmux.Pane` type.', + }, }, 'list-sessions': { - powershell: { symbol: 'LibTmux\\Get-TmuxSession', note: 'Acquires sessions through `-Server`, with optional exact `-Id` and `-Name` selectors.' }, - julia: { symbol: 'LibTmux.sessions', note: '`sessions` reads membership from a captured `Snapshot` without contacting tmux.' }, - fsharp: { symbol: 'LibTmux.FSharp.Server.sessions', note: 'Builds a deferred session query on `LibTmux.Server`. `Query.list` acquires the results; it does not traverse the handle’s captured membership.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxSession', + note: 'Acquires sessions through `-Server`, with optional exact `-Id` and `-Name` selectors.', + }, + julia: { + symbol: 'LibTmux.sessions', + note: '`sessions` reads membership from a captured `Snapshot` without contacting tmux.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Server.sessions', + note: 'Builds a deferred session query on `LibTmux.Server`. `Query.list` acquires the results; it does not traverse the handle’s captured membership.', + }, }, 'list-windows': { - powershell: { symbol: 'LibTmux\\Get-TmuxWindow', note: 'Use the `-Session` parameter set to acquire placements in one session.' }, - julia: { symbol: 'LibTmux.windows', note: '`windows` with a captured `SessionSnapshot` reads distinct physical windows in that session. Use `windowlinks` for placements.' }, - fsharp: { symbol: 'LibTmux.FSharp.Session.windows', note: 'Builds a deferred window query from one `LibTmux.Session` and its server. `Query.list` acquires placements scoped to that session ID.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxWindow', + note: 'Use the `-Session` parameter set to acquire placements in one session.', + }, + julia: { + symbol: 'LibTmux.windows', + note: '`windows` with a captured `SessionSnapshot` reads distinct physical windows in that session. Use `windowlinks` for placements.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Session.windows', + note: 'Builds a deferred window query from one `LibTmux.Session` and its server. `Query.list` acquires placements scoped to that session ID.', + }, }, 'list-panes': { - powershell: { symbol: 'LibTmux\\Get-TmuxPane', note: 'Use the `-Window` parameter set to acquire panes in one window.' }, - julia: { symbol: 'LibTmux.panes', note: '`panes` with a captured `WindowSnapshot` reads panes in that window; missing coverage is an error.' }, - fsharp: { symbol: 'LibTmux.FSharp.Window.panes', note: 'Builds a deferred pane query from one `LibTmux.Window` and its server. `Query.list` acquires panes scoped to that window ID.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxPane', + note: 'Use the `-Window` parameter set to acquire panes in one window.', + }, + julia: { + symbol: 'LibTmux.panes', + note: '`panes` with a captured `WindowSnapshot` reads panes in that window; missing coverage is an error.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Window.panes', + note: 'Builds a deferred pane query from one `LibTmux.Window` and its server. `Query.list` acquires panes scoped to that window ID.', + }, }, 'list-session-panes': { - powershell: { symbol: 'LibTmux\\Get-TmuxPane', note: 'Use the `-Session` parameter set to acquire panes across that session’s windows.' }, - fsharp: { symbol: 'LibTmux.FSharp.Session.panes', note: 'Builds a deferred pane query from one `LibTmux.Session` and its server. `Query.list` acquires panes across that session’s windows.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxPane', + note: 'Use the `-Session` parameter set to acquire panes across that session’s windows.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Session.panes', + note: 'Builds a deferred pane query from one `LibTmux.Session` and its server. `Query.list` acquires panes across that session’s windows.', + }, }, 'list-server-windows': { - powershell: { symbol: 'LibTmux\\Get-TmuxWindow', note: 'Use the `-Server` parameter set to acquire window placements across all sessions.' }, - julia: { symbol: 'LibTmux.windows', note: '`windows` with a `Snapshot` reads every captured physical window. Use `windowlinks` for session placements.' }, - fsharp: { symbol: 'LibTmux.FSharp.Server.windows', note: 'Builds a deferred query for placements across all sessions. `Query.list` acquires the results; a linked window appears once for each session placement.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxWindow', + note: 'Use the `-Server` parameter set to acquire window placements across all sessions.', + }, + julia: { + symbol: 'LibTmux.windows', + note: '`windows` with a `Snapshot` reads every captured physical window. Use `windowlinks` for session placements.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Server.windows', + note: 'Builds a deferred query for placements across all sessions. `Query.list` acquires the results; a linked window appears once for each session placement.', + }, }, 'list-server-panes': { - powershell: { symbol: 'LibTmux\\Get-TmuxPane', note: 'Use the `-Server` parameter set to acquire panes across the server.' }, - julia: { symbol: 'LibTmux.panes', note: '`panes` with a `Snapshot` reads every captured physical pane. Use `paneoccurrences` for session placements.' }, - fsharp: { symbol: 'LibTmux.FSharp.Server.panes', note: 'Builds a deferred pane query on the shared .NET `LibTmux.Server`. `Query.list` acquires panes across that server.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxPane', + note: 'Use the `-Server` parameter set to acquire panes across the server.', + }, + julia: { + symbol: 'LibTmux.panes', + note: '`panes` with a `Snapshot` reads every captured physical pane. Use `paneoccurrences` for session placements.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Server.panes', + note: 'Builds a deferred pane query on the shared .NET `LibTmux.Server`. `Query.list` acquires panes across that server.', + }, }, 'list-clients': { - powershell: { symbol: 'LibTmux\\Get-TmuxClient', note: 'Acquires attached clients through `-Server` and returns shared .NET `LibTmux.Client` objects.' }, - julia: { symbol: 'LibTmux.clients', note: '`clients` reads attached clients from a captured `Snapshot` without contacting tmux.' }, - fsharp: { symbol: 'LibTmux.FSharp.Server.clients', note: 'Builds a deferred client query on the shared .NET `LibTmux.Server`. `Query.list` acquires attached clients.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxClient', + note: 'Acquires attached clients through `-Server` and returns shared .NET `LibTmux.Client` objects.', + }, + julia: { + symbol: 'LibTmux.clients', + note: '`clients` reads attached clients from a captured `Snapshot` without contacting tmux.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Server.clients', + note: 'Builds a deferred client query on the shared .NET `LibTmux.Server`. `Query.list` acquires attached clients.', + }, }, snapshot: { - powershell: { symbol: 'LibTmux\\Get-TmuxSnapshot', note: 'Acquires a replacement `LibTmux.Server` to `-Depth` (panes by default). Acquisition is not atomic; retained handles do not refresh.' }, - julia: { symbol: 'LibTmux.snapshot', note: 'Acquires a new `Snapshot` over an observation interval. Topology checks do not make it a transaction; earlier snapshots do not refresh.' }, - fsharp: { symbol: 'LibTmux.FSharp.Server.capture', note: 'Acquires a replacement shared .NET `LibTmux.Server` to the requested depth. Acquisition is not atomic; retained handles do not refresh.' }, + powershell: { + symbol: 'LibTmux\\Get-TmuxSnapshot', + note: 'Acquires a replacement `LibTmux.Server` to `-Depth` (panes by default). Acquisition is not atomic; retained handles do not refresh.', + }, + julia: { + symbol: 'LibTmux.snapshot', + note: 'Acquires a new `Snapshot` over an observation interval. Topology checks do not make it a transaction; earlier snapshots do not refresh.', + }, + fsharp: { + symbol: 'LibTmux.FSharp.Server.capture', + note: 'Acquires a replacement shared .NET `LibTmux.Server` to the requested depth. Acquisition is not atomic; retained handles do not refresh.', + }, }, } for (const [id, ports] of Object.entries(HIERARCHY_CONCEPTS)) { @@ -3780,16 +3903,22 @@ for (const [id, ports] of Object.entries(HIERARCHY_CONCEPTS)) { if (binding.variants) (concept.variants ??= {})[port] = binding.variants } } -CONCEPTS['list-session-panes']!.absent!.julia = 'Traverse `windows(session)` and `panes(window)` in the captured snapshot. `panes(snapshot)` has server-wide scope.' +CONCEPTS['list-session-panes']!.absent!.julia = + 'Traverse `windows(session)` and `panes(window)` in the captured snapshot. `panes(snapshot)` has server-wide scope.' for (const concept of Object.values(CONCEPTS)) { concept.absent ??= {} - if (!concept.symbols.powershell) concept.absent.powershell ??= 'No source-verified PowerShell equivalent is recorded for this operation.' - if (!concept.symbols.julia) concept.absent.julia ??= 'No source-verified Julia equivalent is recorded for this operation.' + if (!concept.symbols.powershell) + concept.absent.powershell ??= 'No source-verified PowerShell equivalent is recorded for this operation.' + if (!concept.symbols.julia) + concept.absent.julia ??= 'No source-verified Julia equivalent is recorded for this operation.' if (!concept.symbols.ruby) concept.absent.ruby = 'No source-verified Ruby equivalent is recorded for this operation.' if (!concept.symbols.lua) concept.absent.lua = 'No source-verified Lua equivalent is recorded for this operation.' - if (!concept.symbols.kotlin) concept.absent.kotlin = 'No source-verified Kotlin equivalent is recorded for this operation.' - if (!concept.symbols.scala) concept.absent.scala = 'No source-verified Scala equivalent is recorded for this operation.' - if (!concept.symbols.fsharp) concept.absent.fsharp = 'No source-verified F# equivalent is recorded for this operation.' + if (!concept.symbols.kotlin) + concept.absent.kotlin = 'No source-verified Kotlin equivalent is recorded for this operation.' + if (!concept.symbols.scala) + concept.absent.scala = 'No source-verified Scala equivalent is recorded for this operation.' + if (!concept.symbols.fsharp) + concept.absent.fsharp = 'No source-verified F# equivalent is recorded for this operation.' } /** Every concept naming this symbol, including concepts sharing an overload group. */ diff --git a/packages/api-model/src/doc/csharp.ts b/packages/api-model/src/doc/csharp.ts index 5d7cbd739..24063871e 100644 --- a/packages/api-model/src/doc/csharp.ts +++ b/packages/api-model/src/doc/csharp.ts @@ -40,8 +40,7 @@ function sections(xml: string, tag: string): { attrs: string; body: string }[] { return out } -const attr = (attrs: string, name: string): string | undefined => - new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1] +const attr = (attrs: string, name: string): string | undefined => new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1] /** * Tag soup to prose, keeping the marks a reader sees. diff --git a/packages/api-model/src/doc/javadoc.ts b/packages/api-model/src/doc/javadoc.ts index fd9e91c87..86e7afc82 100644 --- a/packages/api-model/src/doc/javadoc.ts +++ b/packages/api-model/src/doc/javadoc.ts @@ -46,7 +46,10 @@ export function javadocToMarkdown(raw: string): string { // libtmux-java's sits at the start of a line that a blank line already // precedes, so the tag is all that has to go. text = text.replace(/^([ \t]*)<\/?p>[ \t]*/gm, '$1') - text = text.replace(/^([ \t]*)([\s\S]*?)<\/h\2>[ \t]*$/gm, (_, i, l, t) => `${i}${'#'.repeat(Number(l))} ${t}`) + text = text.replace( + /^([ \t]*)([\s\S]*?)<\/h\2>[ \t]*$/gm, + (_, i, l, t) => `${i}${'#'.repeat(Number(l))} ${t}`, + ) text = text.replace(/^([ \t]*)
  • ([\s\S]*?)(?:<\/li>)?[ \t]*$/gm, '$1- $2') text = text.replace(/^[ \t]*<\/?[uo]l>[ \t]*$\n?/gm, '') text = text.replace(//g, ' \n') diff --git a/packages/api-model/src/doc/markdown.ts b/packages/api-model/src/doc/markdown.ts index 94d727fb3..505c9d922 100644 --- a/packages/api-model/src/doc/markdown.ts +++ b/packages/api-model/src/doc/markdown.ts @@ -226,9 +226,7 @@ export function parseMarkdownDocFull(raw: string, defaultLang = 'text'): ParsedM const rest = block[2] ?? '' const needsName = kind === 'param' || kind === 'throws' const [first, ...tail] = rest.split(/\s+/) - tag = needsName - ? { kind, name: first || undefined, text: tail } - : { kind, text: rest ? [rest] : [] } + tag = needsName ? { kind, name: first || undefined, text: tail } : { kind, text: rest ? [rest] : [] } continue } if (tag) { diff --git a/packages/api-model/src/doc/python.ts b/packages/api-model/src/doc/python.ts index 63b750749..621891d06 100644 --- a/packages/api-model/src/doc/python.ts +++ b/packages/api-model/src/doc/python.ts @@ -17,12 +17,8 @@ export function dedentDocstring(raw: string): string { s = s.replace(/("""|'''|"|')\s*$/, '') const lines = s.split('\n') const rest = lines.slice(1).filter((l) => l.trim()) - const indent = rest.length - ? Math.min(...rest.map((l) => l.length - l.trimStart().length)) - : 0 - return [lines[0]?.trim() ?? '', ...lines.slice(1).map((l) => l.slice(indent))] - .join('\n') - .trim() + const indent = rest.length ? Math.min(...rest.map((l) => l.length - l.trimStart().length)) : 0 + return [lines[0]?.trim() ?? '', ...lines.slice(1).map((l) => l.slice(indent))].join('\n').trim() } const SECTION = @@ -61,7 +57,16 @@ export function pythonDocBody(text: string): string { const fence = (code: string[], lang: string, indent = '') => { const body = code.join('\n').replace(/\n+$/, '') const ticks = '`'.repeat(Math.max(3, ...[...body.matchAll(/`+/g)].map((m) => m[0].length + 1))) - out.push('', `${indent}${ticks}${lang}`, body.split('\n').map((line) => `${indent}${line}`).join('\n'), `${indent}${ticks}`, '') + out.push( + '', + `${indent}${ticks}${lang}`, + body + .split('\n') + .map((line) => `${indent}${line}`) + .join('\n'), + `${indent}${ticks}`, + '', + ) } for (let i = 0; i < lines.length; i++) { const line = lines[i] @@ -79,7 +84,11 @@ export function pythonDocBody(text: string): string { const code = [line] while (i + 1 < lines.length && lines[i + 1].trim()) code.push(lines[++i]) const indent = line.length - line.trimStart().length - fence(code.map((entry) => entry.slice(indent)), 'python', ' '.repeat(indent)) + fence( + code.map((entry) => entry.slice(indent)), + 'python', + ' '.repeat(indent), + ) continue } const directive = /^(\s*)\.\.\s+(?:code-block|sourcecode|code)::\s*(\S*)\s*$/.exec(line) @@ -95,18 +104,28 @@ export function pythonDocBody(text: string): string { } if (start < lines.length && lines[start].length - lines[start].trimStart().length > indent) { let end = start - while (end < lines.length && (!lines[end].trim() || lines[end].length - lines[end].trimStart().length > indent)) end++ + while (end < lines.length && (!lines[end].trim() || lines[end].length - lines[end].trimStart().length > indent)) + end++ const code = lines.slice(start, end) - const margin = Math.min(...code.filter((entry) => entry.trim()).map((entry) => entry.length - entry.trimStart().length)) + const margin = Math.min( + ...code.filter((entry) => entry.trim()).map((entry) => entry.length - entry.trimStart().length), + ) if (literal && line.trim() !== '::') out.push(line.replace(/::\s*$/, ':')) - fence(code.map((entry) => entry.slice(margin)), directive?.[2] || 'text', ' '.repeat(indent)) + fence( + code.map((entry) => entry.slice(margin)), + directive?.[2] || 'text', + ' '.repeat(indent), + ) i = end - 1 continue } } out.push(line) } - return out.filter((entry, i) => entry !== '' || out[i - 1] !== '').join('\n').trim() + return out + .filter((entry, i) => entry !== '' || out[i - 1] !== '') + .join('\n') + .trim() } /** @@ -277,8 +296,17 @@ function directives(text: string): { const close = () => { if (open) { - const margin = Math.min(...open.body.filter((line) => line.trim()).map((line) => line.length - line.trimStart().length)) - found.push({ kind: open.kind, arg: open.arg, body: open.body.map((line) => line.slice(margin)).join('\n').trim() }) + const margin = Math.min( + ...open.body.filter((line) => line.trim()).map((line) => line.length - line.trimStart().length), + ) + found.push({ + kind: open.kind, + arg: open.arg, + body: open.body + .map((line) => line.slice(margin)) + .join('\n') + .trim(), + }) } open = undefined } @@ -363,8 +391,12 @@ export function parsePythonDoc(raw: string): ParsedDoc { })) const examplesText = (named.get('Examples') ?? []).join('\n') - const value = (kind: string) => dirs.filter((d) => d.kind === kind) - .map((d) => [d.arg, d.body].filter(Boolean).join(' — ').trim()).filter(Boolean).join('\n\n') || undefined + const value = (kind: string) => + dirs + .filter((d) => d.kind === kind) + .map((d) => [d.arg, d.body].filter(Boolean).join(' — ').trim()) + .filter(Boolean) + .join('\n\n') || undefined const deprecated = value('deprecated') const since = value('versionadded') const changed = value('versionchanged') diff --git a/packages/api-model/src/doc/roles.ts b/packages/api-model/src/doc/roles.ts index f174affcf..1636d9b70 100644 --- a/packages/api-model/src/doc/roles.ts +++ b/packages/api-model/src/doc/roles.ts @@ -92,8 +92,7 @@ const INTRA_DOC_RE = /\[`?([A-Za-z_][\w]*(?:::[\w]+)+)`?\](?!\()/g * * https://doc.rust-lang.org/rustdoc/write-documentation/linking-to-items-by-name.html */ -const INTRA_DOC_PAREN_RE = - /\[`?([^\]`]+)`?\]\(((?:crate|self|super|Self)?(?:::)?[A-Za-z_]\w*(?:::\w+)*)\)/g +const INTRA_DOC_PAREN_RE = /\[`?([^\]`]+)`?\]\(((?:crate|self|super|Self)?(?:::)?[A-Za-z_]\w*(?:::\w+)*)\)/g /** * A backticked path with no scope operator: `` [`Window`] ``. @@ -235,9 +234,7 @@ const SYNTAX: Record> = { */ export function docSummaryText(text: string, lang?: string): string { return tokenizeDoc(text, lang) - .map((span) => - span.kind === 'ref' || span.kind === 'citation' ? span.label : span.text, - ) + .map((span) => (span.kind === 'ref' || span.kind === 'citation' ? span.label : span.text)) .join('') } @@ -256,11 +253,17 @@ export function tokenizeDoc(text: string, lang?: string): DocSpan[] { const label = match[1] ? match[2] : match[3] const target = match[4].trim() if (syntax.has('documenter') && /^@ref(?:\s|$)/.test(target)) { - hits.push({ start: match.index, end: match.index + match[0].length, - span: { kind: 'ref', role: 'any', target: target.slice(4).trim() || label, label } }) + hits.push({ + start: match.index, + end: match.index + match[0].length, + span: { kind: 'ref', role: 'any', target: target.slice(4).trim() || label, label }, + }) } else if (/^(?:https?:\/\/|\/(?!\/)|#)/.test(target)) { - hits.push({ start: match.index, end: match.index + match[0].length, - span: { kind: 'link', text: label, href: target, ...(match[1] ? { code: true } : {}) } }) + hits.push({ + start: match.index, + end: match.index + match[0].length, + span: { kind: 'link', text: label, href: target, ...(match[1] ? { code: true } : {}) }, + }) } } } @@ -283,8 +286,11 @@ export function tokenizeDoc(text: string, lang?: string): DocSpan[] { const target = syntax.has('scaladoc') ? match[1] : (match[2] ?? match[1]) const label = syntax.has('scaladoc') ? (match[2] ?? match[1]) : match[1] if (!/^[\w.#]+$/.test(target)) continue - hits.push({ start: match.index, end: match.index + match[0].length, - span: { kind: 'ref', role: 'any', target, label } }) + hits.push({ + start: match.index, + end: match.index + match[0].length, + span: { kind: 'ref', role: 'any', target, label }, + }) } } @@ -372,26 +378,27 @@ export function tokenizeDoc(text: string, lang?: string): DocSpan[] { } } - if (syntax.has('rest')) for (const m of text.matchAll(ROLE_RE)) { - const role = m[1].replace(/^py:/, '') - const { target, label } = labelFor(m[2]) - if (!ROLES.has(m[1])) { - // A role this resolver does not handle — `:term:`, `:ref:`, `:doc:` — - // still has to claim its range. Skipping it outright leaves its - // backticks visible to the single-backtick fallback below, which then - // invents a symbol reference out of a glossary term: `:term:`winlinks - // `` was being reported as an unresolved symbol named - // `winlinks`. Emitting the label as literal text is both correct - // rendering and correct masking. - hits.push({ start: m.index, end: m.index + m[0].length, span: { kind: 'text', text: label } }) - continue + if (syntax.has('rest')) + for (const m of text.matchAll(ROLE_RE)) { + const role = m[1].replace(/^py:/, '') + const { target, label } = labelFor(m[2]) + if (!ROLES.has(m[1])) { + // A role this resolver does not handle — `:term:`, `:ref:`, `:doc:` — + // still has to claim its range. Skipping it outright leaves its + // backticks visible to the single-backtick fallback below, which then + // invents a symbol reference out of a glossary term: `:term:`winlinks + // `` was being reported as an unresolved symbol named + // `winlinks`. Emitting the label as literal text is both correct + // rendering and correct masking. + hits.push({ start: m.index, end: m.index + m[0].length, span: { kind: 'text', text: label } }) + continue + } + hits.push({ + start: m.index, + end: m.index + m[0].length, + span: { kind: 'ref', role, target, label }, + }) } - hits.push({ - start: m.index, - end: m.index + m[0].length, - span: { kind: 'ref', role, target, label }, - }) - } for (const m of text.matchAll(LITERAL_RE)) { hits.push({ start: m.index, @@ -433,13 +440,17 @@ export function tokenizeDoc(text: string, lang?: string): DocSpan[] { if (lang === 'powershell') { // Native help stores these references as plain text rather than Markdown. - const names = /(? hit.start < end && hit.end > start)) continue - hits.push({ start, end, span: { kind: 'ref', - role: match[0].startsWith('$') ? 'variable' : 'any', target: match[0], label: match[0] } }) + hits.push({ + start, + end, + span: { kind: 'ref', role: match[0].startsWith('$') ? 'variable' : 'any', target: match[0], label: match[0] }, + }) } } @@ -459,15 +470,21 @@ export function referencesIn(text: string): { role: string; target: string }[] { // Bodies may retain narrative code blocks. Their code and delimiter ticks // are literals, not cross-references or names to add to the search graph. let fence: string | undefined - const prose = text.split('\n').map((line) => { - if (fence) { - if (new RegExp(`^\\s*${fence[0]}{${fence.length},}\\s*$`).test(line)) fence = undefined - return '' - } - const start = /^\s*(`{3,}|~{3,})/.exec(line) - if (start) { fence = start[1]; return '' } - return line - }).join('\n') + const prose = text + .split('\n') + .map((line) => { + if (fence) { + if (new RegExp(`^\\s*${fence[0]}{${fence.length},}\\s*$`).test(line)) fence = undefined + return '' + } + const start = /^\s*(`{3,}|~{3,})/.exec(line) + if (start) { + fence = start[1] + return '' + } + return line + }) + .join('\n') return tokenizeDoc(prose) .filter((s): s is Extract => s.kind === 'ref') .map(({ role, target }) => ({ role, target })) diff --git a/packages/api-model/src/index.ts b/packages/api-model/src/index.ts index 78809ba65..adae8cae4 100644 --- a/packages/api-model/src/index.ts +++ b/packages/api-model/src/index.ts @@ -22,7 +22,15 @@ export * from './prose.ts' export { BUILTINS, builtinHref } from './builtins.ts' export * from './nav.ts' export { NAV } from './nav-config.ts' -export { MEMBER_TIERS, memberSignals, memberTier, compareMembers, membersOf, type MemberSignals, type MemberTier } from './member-order.ts' +export { + MEMBER_TIERS, + memberSignals, + memberTier, + compareMembers, + membersOf, + type MemberSignals, + type MemberTier, +} from './member-order.ts' export { parentInventory } from './parent-inventory.ts' diff --git a/packages/api-model/src/inventory.ts b/packages/api-model/src/inventory.ts index 03218e52c..b647e2ba0 100644 --- a/packages/api-model/src/inventory.ts +++ b/packages/api-model/src/inventory.ts @@ -96,13 +96,27 @@ const JS_ROLE: Partial> = { } const C_ROLE: Partial> = { - struct: 'struct', union: 'union', enum: 'enum', function: 'function', - typealias: 'type', attribute: 'var', constant: 'var', + struct: 'struct', + union: 'union', + enum: 'enum', + function: 'function', + typealias: 'type', + attribute: 'var', + constant: 'var', } function roleFor(language: string, kind: SymbolKind): string { - const domain = language === 'c' ? 'c' : DOMAIN[language as PortSlug] ?? 'std' - const table = domain === 'py' ? PY_ROLE : domain === 'cpp' ? CPP_ROLE : domain === 'js' ? JS_ROLE : domain === 'c' ? C_ROLE : undefined + const domain = language === 'c' ? 'c' : (DOMAIN[language as PortSlug] ?? 'std') + const table = + domain === 'py' + ? PY_ROLE + : domain === 'cpp' + ? CPP_ROLE + : domain === 'js' + ? JS_ROLE + : domain === 'c' + ? C_ROLE + : undefined // `std:label` is the honest home for a language Sphinx has no domain for. return `${domain}:${table?.[kind] ?? 'label'}` } @@ -125,7 +139,10 @@ export interface InventoryOptions { } /** Serialise a model as `objects.inv` bytes. */ -export function writeInventory(model: ApiModelBase & { port?: string; language?: string }, options: InventoryOptions): Buffer { +export function writeInventory( + model: ApiModelBase & { port?: string; language?: string }, + options: InventoryOptions, +): Buffer { const header = '# Sphinx inventory version 2\n' + `# Project: ${escape(options.project)}\n` + @@ -133,32 +150,38 @@ export function writeInventory(model: ApiModelBase & { port?: string; language?: '# The remainder of this file is compressed using zlib.\n' const lines: string[] = [] - const parentKinds = model.language === 'c' - ? new Map(model.symbols.map((symbol) => [symbol.id, symbol.kind])) : undefined - const symbolRole = (symbol: ApiSymbol) => model.language === 'c' && symbol.modifiers.includes('macro') ? 'c:macro' - : model.language === 'c' && symbol.parent && symbol.kind === 'constant' && parentKinds?.get(symbol.parent) === 'enum' - ? 'c:enumerator' - : model.language === 'c' && symbol.parent && ['attribute', 'property'].includes(symbol.kind) - ? 'c:member' - : roleFor(model.port ?? model.language ?? '', symbol.kind) - const cName = (symbol: ApiSymbol) => symbolRole(symbol) === 'c:enumerator' ? symbol.name : qualifiedNameOf(symbol) + const parentKinds = + model.language === 'c' ? new Map(model.symbols.map((symbol) => [symbol.id, symbol.kind])) : undefined + const symbolRole = (symbol: ApiSymbol) => + model.language === 'c' && symbol.modifiers.includes('macro') + ? 'c:macro' + : model.language === 'c' && + symbol.parent && + symbol.kind === 'constant' && + parentKinds?.get(symbol.parent) === 'enum' + ? 'c:enumerator' + : model.language === 'c' && symbol.parent && ['attribute', 'property'].includes(symbol.kind) + ? 'c:member' + : roleFor(model.port ?? model.language ?? '', symbol.kind) + const cName = (symbol: ApiSymbol) => (symbolRole(symbol) === 'c:enumerator' ? symbol.name : qualifiedNameOf(symbol)) const spellings = new Map() - if (model.language === 'c') for (const symbol of model.symbols) { - const key = `${symbolRole(symbol)}:${cName(symbol)}` - spellings.set(key, (spellings.get(key) ?? 0) + 1) - } + if (model.language === 'c') + for (const symbol of model.symbols) { + const key = `${symbolRole(symbol)}:${cName(symbol)}` + spellings.set(key, (spellings.get(key) ?? 0) + 1) + } // Sorted, so the file is byte-stable across runs and a diff means a real // change rather than a map iteration order. - const sorted = [...model.symbols].sort((a, b) => - (a.publicId ?? a.id).localeCompare(b.publicId ?? b.id), - ) + const sorted = [...model.symbols].sort((a, b) => (a.publicId ?? a.id).localeCompare(b.publicId ?? b.id)) for (const symbol of sorted) { const role = symbolRole(symbol) const spelling = cName(symbol) // Sphinx consumers ask for C spellings, not our declaration IDs. A // duplicated file-local name or an anonymous type keeps its scoped ID. - const name = model.language === 'c' && !/\s/.test(spelling) && spellings.get(`${role}:${spelling}`) === 1 - ? spelling : symbol.publicId ?? symbol.id + const name = + model.language === 'c' && !/\s/.test(spelling) && spellings.get(`${role}:${spelling}`) === 1 + ? spelling + : (symbol.publicId ?? symbol.id) // Records are whitespace-delimited, so a name containing whitespace is // not representable — Sphinx's reader mis-splits it and the entry is // silently lost. Two Java symbols disappeared exactly this way, because diff --git a/packages/api-model/src/languages/csharp-examples.ts b/packages/api-model/src/languages/csharp-examples.ts index f9d478b68..f277fb6cd 100644 --- a/packages/api-model/src/languages/csharp-examples.ts +++ b/packages/api-model/src/languages/csharp-examples.ts @@ -25,21 +25,29 @@ export function attachCompleteCSharpExamples( const raw = read('examples/api/manifest.json') if (raw === undefined) return const manifest = JSON.parse(raw) as Manifest - if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.examples) || !manifest.examples.length || - JSON.stringify(manifest.setupFiles) !== JSON.stringify(['global.json', 'examples/api/NuGet.config'])) { + if ( + manifest.schemaVersion !== 1 || + !Array.isArray(manifest.examples) || + !manifest.examples.length || + JSON.stringify(manifest.setupFiles) !== JSON.stringify(['global.json', 'examples/api/NuGet.config']) + ) { throw new Error('Invalid C# example manifest') } const programs = manifest.examples.filter((program) => program.profile === 'csharp') const profile = manifest.profiles?.csharp if (!programs.length && !profile) return if (!programs.length) throw new Error('C# example profile has no complete programs') - if (profile?.package !== 'LibTmux' || profile.projectFile !== 'examples/api/csharp/Example.csproj' || - profile.entrypoint !== 'Program.cs') throw new Error('Invalid C# example profile') + if ( + profile?.package !== 'LibTmux' || + profile.projectFile !== 'examples/api/csharp/Example.csproj' || + profile.entrypoint !== 'Program.cs' + ) + throw new Error('Invalid C# example profile') if (source.repo !== 'libtmux/libtmux-dotnet' || !/^[0-9a-f]{40}$/.test(source.revision)) { throw new Error('C# examples require the public repository and full source revision') } const codeFor = (path: string): string => { - if (path !== 'global.json' && !/^examples\/(?:[\w.-]+\/)*[\w.-]+$/.test(path) || path.split('/').includes('..')) { + if ((path !== 'global.json' && !/^examples\/(?:[\w.-]+\/)*[\w.-]+$/.test(path)) || path.split('/').includes('..')) { throw new Error(`Invalid C# example source path: ${path}`) } const code = read(path) @@ -58,46 +66,67 @@ export function attachCompleteCSharpExamples( if (!/^\d+\.\d+\.\d+$/.test(sdk ?? '')) throw new Error('C# examples require a pinned SDK') const ids = new Set() for (const program of programs) { - if (!/^csharp-[A-Za-z0-9]+$/.test(program.id) || ids.has(program.id) || - typeof program.title !== 'string' || !program.title.trim() || - typeof program.description !== 'string' || !program.description.trim() || - !/^examples\/LibTmux\.Examples\/Programs\/[A-Za-z0-9]+\.cs$/.test(program.sourceFile) || - typeof program.output !== 'string' || !program.output.trim() || - !program.output.endsWith('\n') || program.output.includes('\r') || - !Array.isArray(program.targets) || !program.targets.length) { + if ( + !/^csharp-[A-Za-z0-9]+$/.test(program.id) || + ids.has(program.id) || + typeof program.title !== 'string' || + !program.title.trim() || + typeof program.description !== 'string' || + !program.description.trim() || + !/^examples\/LibTmux\.Examples\/Programs\/[A-Za-z0-9]+\.cs$/.test(program.sourceFile) || + typeof program.output !== 'string' || + !program.output.trim() || + !program.output.endsWith('\n') || + program.output.includes('\r') || + !Array.isArray(program.targets) || + !program.targets.length + ) { throw new Error(`Invalid C# example metadata: ${program.id}`) } ids.add(program.id) - const blocks: NonNullable = [{ - lang: 'console', - intro: `${program.description.replace(/\n/g, ' ')} Use Linux or macOS with Git, tmux 3.2a or newer, .NET SDK ${sdk}, and the .NET 8 and 10 runtimes. In an empty directory, fetch the documented library revision:`, - code: `$ git clone https://github.com/${source.repo}.git libtmux-source && \\\n git -C libtmux-source checkout --detach ${source.revision}\n`, - }] - for (const file of setup) blocks.push({ - lang: file.lang, intro: `Save this complete consumer setup as ${file.path}:`, - sourceUrl: urlFor(file.sourceFile), code: codeFor(file.sourceFile), - }) - blocks.push({ - lang: 'csharp', - intro: 'Save this complete program as Program.cs. It creates a private server; await using disposes its owned scopes after success, cancellation or failure. The complete file contains explicit imports, a top-level async entry point and a cancellation deadline.', - sourceUrl: urlFor(program.sourceFile), code: codeFor(program.sourceFile), - }, { - lang: 'console', - intro: 'Build the exact source package and the saved consumer, then run both supported frameworks. The local package feed prevents an unrelated published package from satisfying the restore. Each program checks its result and exits unsuccessfully after an unhandled error:', - code: '$ export NUGET_PACKAGES="$PWD/packages" && \\\n' + - ' dotnet restore libtmux-source/src/LibTmux/LibTmux.csproj --locked-mode && \\\n' + - ' dotnet pack libtmux-source/src/LibTmux/LibTmux.csproj \\\n' + - ' --configuration Release --no-restore -p:ContinuousIntegrationBuild=true \\\n' + - ' --output "$PWD/libtmux-source/artifacts/api-example-packages" && \\\n' + - ' dotnet restore Example.csproj --configfile NuGet.config && \\\n' + - ' dotnet build Example.csproj --configuration Release --no-restore --warnaserror && \\\n' + - ' export LIBTMUX_TMUX="$(command -v tmux)" && \\\n' + - ' export TMUX_TMPDIR=/tmp/libtmux-dotnet-dev && \\\n' + - ' mkdir -p "$TMUX_TMPDIR" && \\\n' + - ' unset TMUX TMUX_PANE && \\\n' + - ' dotnet bin/Release/net8.0/Example.dll && \\\n' + - ' dotnet bin/Release/net10.0/Example.dll\n', - }, { lang: 'text', intro: 'Expected program output from each framework:', code: program.output }) + const blocks: NonNullable = [ + { + lang: 'console', + intro: `${program.description.replace(/\n/g, ' ')} Use Linux or macOS with Git, tmux 3.2a or newer, .NET SDK ${sdk}, and the .NET 8 and 10 runtimes. In an empty directory, fetch the documented library revision:`, + code: `$ git clone https://github.com/${source.repo}.git libtmux-source && \\\n git -C libtmux-source checkout --detach ${source.revision}\n`, + }, + ] + for (const file of setup) + blocks.push({ + lang: file.lang, + intro: `Save this complete consumer setup as ${file.path}:`, + sourceUrl: urlFor(file.sourceFile), + code: codeFor(file.sourceFile), + }) + blocks.push( + { + lang: 'csharp', + intro: + 'Save this complete program as Program.cs. It creates a private server; await using disposes its owned scopes after success, cancellation or failure. The complete file contains explicit imports, a top-level async entry point and a cancellation deadline.', + sourceUrl: urlFor(program.sourceFile), + code: codeFor(program.sourceFile), + }, + { + lang: 'console', + intro: + 'Build the exact source package and the saved consumer, then run both supported frameworks. The local package feed prevents an unrelated published package from satisfying the restore. Each program checks its result and exits unsuccessfully after an unhandled error:', + code: + '$ export NUGET_PACKAGES="$PWD/packages" && \\\n' + + ' dotnet restore libtmux-source/src/LibTmux/LibTmux.csproj --locked-mode && \\\n' + + ' dotnet pack libtmux-source/src/LibTmux/LibTmux.csproj \\\n' + + ' --configuration Release --no-restore -p:ContinuousIntegrationBuild=true \\\n' + + ' --output "$PWD/libtmux-source/artifacts/api-example-packages" && \\\n' + + ' dotnet restore Example.csproj --configfile NuGet.config && \\\n' + + ' dotnet build Example.csproj --configuration Release --no-restore --warnaserror && \\\n' + + ' export LIBTMUX_TMUX="$(command -v tmux)" && \\\n' + + ' export TMUX_TMPDIR=/tmp/libtmux-dotnet-dev && \\\n' + + ' mkdir -p "$TMUX_TMPDIR" && \\\n' + + ' unset TMUX TMUX_PANE && \\\n' + + ' dotnet bin/Release/net8.0/Example.dll && \\\n' + + ' dotnet bin/Release/net10.0/Example.dll\n', + }, + { lang: 'text', intro: 'Expected program output from each framework:', code: program.output }, + ) const targets = new Set() const pages = new Set() for (const target of program.targets) { @@ -107,10 +136,16 @@ export function attachCompleteCSharpExamples( } if (targets.has(target)) throw new Error(`Duplicate C# compiler target: ${target}`) targets.add(target) - const id = target.slice(2).replace(/\(.*$/, '').replace(/`{1,2}\d+/g, '') + const id = target + .slice(2) + .replace(/\(.*$/, '') + .replace(/`{1,2}\d+/g, '') const matches = symbols.filter((symbol) => symbol.id === id) - const kinds = target.startsWith('M:') ? ['method'] : target.startsWith('P:') - ? ['property'] : ['class', 'interface', 'struct', 'enum'] + const kinds = target.startsWith('M:') + ? ['method'] + : target.startsWith('P:') + ? ['property'] + : ['class', 'interface', 'struct', 'enum'] if (matches.length !== 1 || !kinds.includes(matches[0].kind)) { throw new Error(`C# example target must resolve once with its native kind: ${target}`) } diff --git a/packages/api-model/src/languages/cxx-examples.test.ts b/packages/api-model/src/languages/cxx-examples.test.ts index 9e326cccd..c7388d70d 100644 --- a/packages/api-model/src/languages/cxx-examples.test.ts +++ b/packages/api-model/src/languages/cxx-examples.test.ts @@ -4,15 +4,29 @@ import { attachCompleteCxxExamples } from './cxx-examples.ts' const source = { repo: 'libtmux/libtmux-cxx', revision: 'a'.repeat(40) } const symbol = (): ApiSymbol => ({ - id: 'libtmux::Server', name: 'Server', slug: 'libtmux-server', kind: 'class', - signatures: [], modifiers: [], source: { file: 'include/libtmux/server.hpp' }, + id: 'libtmux::Server', + name: 'Server', + slug: 'libtmux-server', + kind: 'class', + signatures: [], + modifiers: [], + source: { file: 'include/libtmux/server.hpp' }, doc: { summary: 'A tmux server address.', examples: [{ lang: 'cpp', code: 'const int old = 1;\n' }] }, }) const fixture = () => { const manifest = { - schemaVersion: 1, projectFile: 'examples/api/project/CMakeLists.txt', - programs: [{ id: 'server', symbols: ['libtmux::Server'], title: 'List sessions', - description: 'Read the server listing.', expectedOutput: 'api\n', file: 'examples/api/server.cpp' }], + schemaVersion: 1, + projectFile: 'examples/api/project/CMakeLists.txt', + programs: [ + { + id: 'server', + symbols: ['libtmux::Server'], + title: 'List sessions', + description: 'Read the server listing.', + expectedOutput: 'api\n', + file: 'examples/api/server.cpp', + }, + ], } const projectCode = 'cmake_minimum_required(VERSION 3.25)\nproject(example LANGUAGES CXX)\n' const programCode = '#include \nint main() { return 0; }\n' @@ -20,8 +34,7 @@ const fixture = () => { 'examples/api/project/CMakeLists.txt': projectCode, 'examples/api/server.cpp': programCode, } - const read = (path: string) => path === 'examples/api/api-examples.json' - ? JSON.stringify(manifest) : files[path] + const read = (path: string) => (path === 'examples/api/api-examples.json' ? JSON.stringify(manifest) : files[path]) return { manifest, files, read, projectCode, programCode } } @@ -36,9 +49,11 @@ describe('complete C++ API examples', () => { expect(blocks[3].code).toBe(projectCode) expect(blocks[4].code).toBe(programCode) expect(blocks[3].sourceUrl).toBe( - `https://github.com/${source.repo}/blob/${source.revision}/examples/api/project/CMakeLists.txt`) + `https://github.com/${source.repo}/blob/${source.revision}/examples/api/project/CMakeLists.txt`, + ) expect(blocks[4].sourceUrl).toBe( - `https://github.com/${source.repo}/blob/${source.revision}/examples/api/server.cpp`) + `https://github.com/${source.repo}/blob/${source.revision}/examples/api/server.cpp`, + ) expect(blocks[1].code).toContain(`git -C libtmux-source checkout ${source.revision}`) expect(blocks[2].code).toContain('-DLIBTMUX_BUILD_TESTING_LIBRARY=ON') expect(blocks[5].code).toContain('-DCMAKE_PREFIX_PATH="$PWD/libtmux-prefix"') @@ -53,19 +68,84 @@ describe('complete C++ API examples', () => { }) it.each([ - ['unknown target', (f: ReturnType) => { f.manifest.programs[0].symbols = ['libtmux::Unknown'] }], - ['duplicate target', (f: ReturnType) => { f.manifest.programs[0].symbols.push('libtmux::Server') }], - ['duplicate program', (f: ReturnType) => { f.manifest.programs.push(f.manifest.programs[0]) }], - ['missing program', (f: ReturnType) => { delete f.files[f.manifest.programs[0].file] }], - ['missing project', (f: ReturnType) => { delete f.files[f.manifest.projectFile] }], - ['partial file', (f: ReturnType) => { f.files[f.manifest.programs[0].file] = 'int main() {}' }], - ['hidden entry point', (f: ReturnType) => { f.files[f.manifest.programs[0].file] = 'void example() {}\n' }], - ['unsafe path', (f: ReturnType) => { f.manifest.programs[0].file = '../server.cpp' }], - ['unexpected project', (f: ReturnType) => { f.manifest.projectFile = 'CMakeLists.txt' }], - ['empty output', (f: ReturnType) => { f.manifest.programs[0].expectedOutput = '' }], - ['missing output newline', (f: ReturnType) => { f.manifest.programs[0].expectedOutput = 'api' }], - ['missing description', (f: ReturnType) => { f.manifest.programs[0].description = ' ' }], - ['unsupported schema', (f: ReturnType) => { f.manifest.schemaVersion = 2 }], + [ + 'unknown target', + (f: ReturnType) => { + f.manifest.programs[0].symbols = ['libtmux::Unknown'] + }, + ], + [ + 'duplicate target', + (f: ReturnType) => { + f.manifest.programs[0].symbols.push('libtmux::Server') + }, + ], + [ + 'duplicate program', + (f: ReturnType) => { + f.manifest.programs.push(f.manifest.programs[0]) + }, + ], + [ + 'missing program', + (f: ReturnType) => { + delete f.files[f.manifest.programs[0].file] + }, + ], + [ + 'missing project', + (f: ReturnType) => { + delete f.files[f.manifest.projectFile] + }, + ], + [ + 'partial file', + (f: ReturnType) => { + f.files[f.manifest.programs[0].file] = 'int main() {}' + }, + ], + [ + 'hidden entry point', + (f: ReturnType) => { + f.files[f.manifest.programs[0].file] = 'void example() {}\n' + }, + ], + [ + 'unsafe path', + (f: ReturnType) => { + f.manifest.programs[0].file = '../server.cpp' + }, + ], + [ + 'unexpected project', + (f: ReturnType) => { + f.manifest.projectFile = 'CMakeLists.txt' + }, + ], + [ + 'empty output', + (f: ReturnType) => { + f.manifest.programs[0].expectedOutput = '' + }, + ], + [ + 'missing output newline', + (f: ReturnType) => { + f.manifest.programs[0].expectedOutput = 'api' + }, + ], + [ + 'missing description', + (f: ReturnType) => { + f.manifest.programs[0].description = ' ' + }, + ], + [ + 'unsupported schema', + (f: ReturnType) => { + f.manifest.schemaVersion = 2 + }, + ], ])('rejects %s', (_name, change) => { const f = fixture() change(f) @@ -75,7 +155,11 @@ describe('complete C++ API examples', () => { it('rejects ambiguous identities, unpinned revisions and a foreign repository', () => { const { read } = fixture() expect(() => attachCompleteCxxExamples([symbol(), symbol()], read, source)).toThrow('resolve once') - expect(() => attachCompleteCxxExamples([symbol()], read, { ...source, revision: 'master' })).toThrow('full revision') - expect(() => attachCompleteCxxExamples([symbol()], read, { ...source, repo: 'other/repo' })).toThrow('C++ repository') + expect(() => attachCompleteCxxExamples([symbol()], read, { ...source, revision: 'master' })).toThrow( + 'full revision', + ) + expect(() => attachCompleteCxxExamples([symbol()], read, { ...source, repo: 'other/repo' })).toThrow( + 'C++ repository', + ) }) }) diff --git a/packages/api-model/src/languages/cxx-examples.ts b/packages/api-model/src/languages/cxx-examples.ts index 7c1315259..a12d56103 100644 --- a/packages/api-model/src/languages/cxx-examples.ts +++ b/packages/api-model/src/languages/cxx-examples.ts @@ -27,10 +27,11 @@ export function attachCompleteCxxExamples( const require = (condition: unknown, message: string): void => { if (!condition) throw new Error(`Invalid complete C++ examples: ${message}`) } - require(source.repo === 'libtmux/libtmux-cxx' && /^[a-f0-9]{40}$/.test(source.revision), - 'source must name the C++ repository and a full revision') - require(manifest?.schemaVersion === 1 && Array.isArray(manifest.programs) && manifest.programs.length, - 'unsupported or empty manifest') + require(source.repo === 'libtmux/libtmux-cxx' && + /^[a-f0-9]{40}$/.test(source.revision), 'source must name the C++ repository and a full revision') + require(manifest?.schemaVersion === 1 && + Array.isArray(manifest.programs) && + manifest.programs.length, 'unsupported or empty manifest') require(manifest.projectFile === 'examples/api/project/CMakeLists.txt', 'unexpected consumer project') const codeFor = (path: string): string => { const code = read(path) @@ -44,18 +45,22 @@ export function attachCompleteCxxExamples( const names = new Set() const targets = new Set() for (const example of manifest.programs) { - require(/^[a-z][a-z0-9-]*$/.test(example.id) && !names.has(example.id), - 'invalid or duplicate program name') + require(/^[a-z][a-z0-9-]*$/.test(example.id) && !names.has(example.id), 'invalid or duplicate program name') names.add(example.id) require(example.file === `examples/api/${example.id}.cpp`, 'unexpected program path') - require(typeof example.title === 'string' && example.title.trim() && - typeof example.description === 'string' && example.description.trim(), 'task description is missing') - require(typeof example.expectedOutput === 'string' && example.expectedOutput.trim() && - example.expectedOutput.endsWith('\n') && !example.expectedOutput.includes('\r'), - 'expected output is missing or incomplete') - require(Array.isArray(example.symbols) && example.symbols.length && - example.symbols.every((id) => typeof id === 'string' && /^libtmux::[A-Za-z_][\w:]*$/.test(id)), - 'API targets are missing') + require(typeof example.title === 'string' && + example.title.trim() && + typeof example.description === 'string' && + example.description.trim(), 'task description is missing') + require(typeof example.expectedOutput === 'string' && + example.expectedOutput.trim() && + example.expectedOutput.endsWith('\n') && + !example.expectedOutput.includes('\r'), 'expected output is missing or incomplete') + require(Array.isArray(example.symbols) && + example.symbols.length && + example.symbols.every( + (id) => typeof id === 'string' && /^libtmux::[A-Za-z_][\w:]*$/.test(id), + ), 'API targets are missing') const program = codeFor(example.file) require(program.includes('int main()'), 'program entry point is missing') const blocks: NonNullable = [ @@ -72,16 +77,20 @@ export function attachCompleteCxxExamples( { lang: 'cmake', intro: 'Save this complete consumer project as CMakeLists.txt:', - sourceUrl: urlFor(manifest.projectFile), code: project, + sourceUrl: urlFor(manifest.projectFile), + code: project, }, { lang: 'cpp', - intro: 'Save this complete program as main.cpp. `ScopedTmuxServer` owns a private daemon and stops it when the program exits. Ordinary applications use `Server` for their own socket; these examples use the fixture until setup moves to ordinary server objects.', - sourceUrl: urlFor(example.file), code: program, + intro: + 'Save this complete program as main.cpp. `ScopedTmuxServer` owns a private daemon and stops it when the program exits. Ordinary applications use `Server` for their own socket; these examples use the fixture until setup moves to ordinary server objects.', + sourceUrl: urlFor(example.file), + code: program, }, { lang: 'console', - intro: 'Build against the installed library and run the saved program. An operation error produces a failing exit status:', + intro: + 'Build against the installed library and run the saved program. An operation error produces a failing exit status:', code: '$ cmake -S . -B build -G Ninja \\\n -DCMAKE_PREFIX_PATH="$PWD/libtmux-prefix" \\\n -DCMAKE_CXX_COMPILER=clang++ \\\n -DCMAKE_CXX_FLAGS=-stdlib=libc++ && \\\n cmake --build build --parallel 2 && \\\n ./build/api_example\n', }, { lang: 'text', intro: 'Expected output:', code: example.expectedOutput }, diff --git a/packages/api-model/src/languages/doxygen.ts b/packages/api-model/src/languages/doxygen.ts index c0e23a742..894bb8d07 100644 --- a/packages/api-model/src/languages/doxygen.ts +++ b/packages/api-model/src/languages/doxygen.ts @@ -61,11 +61,14 @@ function textOf(xml: string): string { /** Code XML keeps literal whitespace and decodes each entity exactly once. */ function codeOf(xml: string): string { const entities: Record = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" } - return xml.replace(/<[^>]+>/g, '').replace(/&(lt|gt|amp|quot|apos|#\d+|#x[\da-f]+);/gi, (_, entity: string) => { - if (entity.startsWith('#x')) return String.fromCodePoint(Number.parseInt(entity.slice(2), 16)) - if (entity.startsWith('#')) return String.fromCodePoint(Number(entity.slice(1))) - return entities[entity] - }).trim() + return xml + .replace(/<[^>]+>/g, '') + .replace(/&(lt|gt|amp|quot|apos|#\d+|#x[\da-f]+);/gi, (_, entity: string) => { + if (entity.startsWith('#x')) return String.fromCodePoint(Number.parseInt(entity.slice(2), 16)) + if (entity.startsWith('#')) return String.fromCodePoint(Number(entity.slice(1))) + return entities[entity] + }) + .trim() } const syntheticCName = (name: string): boolean => /\[(?:struct|union)\]|__unnamed\d+__|@[\da-f]+/i.test(name) @@ -73,9 +76,12 @@ const syntheticCName = (name: string): boolean => /\[(?:struct|union)\]|__unname /** Doxygen names anonymous C records for identity; those names are not C syntax. */ function cDisplayName(name: string, kind?: string): string { const anonymous = syntheticCName(name) - const clean = name.replace(/\[(?:struct|union)\]\.?|__unnamed\d+__|@[\da-f]+/gi, '') - .replaceAll('::', '.').replace(/\.{2,}/g, '.').replace(/^\.|\.$/g, '') - return anonymous && kind ? clean ? `${clean} (anonymous ${kind})` : `anonymous ${kind}` : clean + const clean = name + .replace(/\[(?:struct|union)\]\.?|__unnamed\d+__|@[\da-f]+/gi, '') + .replaceAll('::', '.') + .replace(/\.{2,}/g, '.') + .replace(/^\.|\.$/g, '') + return anonymous && kind ? (clean ? `${clean} (anonymous ${kind})` : `anonymous ${kind}`) : clean } /** Reconstruct C declaration syntax from Doxygen's native declaration fields. */ @@ -85,12 +91,16 @@ function cDeclaration(block: string, nativeKind: string, name: string): string | const initializer = codeOf(tag(block, 'initializer') ?? '') if (nativeKind !== 'enum' && (syntheticCName(name) || syntheticCName(type))) return undefined if (nativeKind === 'enum') { - const values = [...block.matchAll(//g)].map((match) => - `${codeOf(tag(match[1], 'name') ?? '')}${tag(match[1], 'initializer') ? ` ${codeOf(tag(match[1], 'initializer') ?? '')}` : ''}`) + const values = [...block.matchAll(//g)].map( + (match) => + `${codeOf(tag(match[1], 'name') ?? '')}${tag(match[1], 'initializer') ? ` ${codeOf(tag(match[1], 'initializer') ?? '')}` : ''}`, + ) return `enum${name.startsWith('@') ? '' : ` ${name}`} { ${values.join(', ')} };` } if (nativeKind === 'define') { - const parameters = [...block.matchAll(/([\s\S]*?)<\/param>/g)].map((match) => codeOf(tag(match[1], 'defname') ?? '')) + const parameters = [...block.matchAll(/([\s\S]*?)<\/param>/g)].map((match) => + codeOf(tag(match[1], 'defname') ?? ''), + ) return `#define ${name}${parameters.length ? `(${parameters.join(', ')})` : ''}${initializer ? ` ${initializer}` : ''}` } const pointer = /\(\s*(\*+(?:\s*(?:const|volatile|restrict)\b)*)\s*\)/ @@ -162,8 +172,7 @@ const afterEnumValues = (block: string): string => { return last === -1 ? block : block.slice(last) } -const attr = (xml: string, name: string): string | undefined => - new RegExp(`${name}="([^"]*)"`).exec(xml)?.[1] +const attr = (xml: string, name: string): string | undefined => new RegExp(`${name}="([^"]*)"`).exec(xml)?.[1] /** C source locations distinguish a declaration from a same-file definition. * Doxygen 1.18 sometimes keeps a preceding callback's `line` for function @@ -171,21 +180,27 @@ const attr = (xml: string, name: string): string | undefined => * declaration. A cross-file body belongs to a different member record, and * repeated extern declarations are not definitions. */ -function cLocation(location: string, fallback: string, externDeclaration = false): { - source: { file: string; line: number }; isDefinition: boolean +function cLocation( + location: string, + fallback: string, + externDeclaration = false, +): { + source: { file: string; line: number } + isDefinition: boolean } { const field = (name: string) => new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(location)?.[1] const file = field('file') ?? fallback const declaredLine = Number(field('line') ?? 1) const bodyLine = Number(field('bodystart')) - const isDefinition = !externDeclaration && field('bodyfile') === file - && Number.isInteger(bodyLine) && bodyLine > 0 + const isDefinition = !externDeclaration && field('bodyfile') === file && Number.isInteger(bodyLine) && bodyLine > 0 return { source: { file, line: isDefinition ? bodyLine : declaredLine }, isDefinition } } /** Programlisting is native linked source, not a runtime or complete call graph. */ function cCalls( - documents: { xml: string }[], nativeIds: Map, seen: Map, + documents: { xml: string }[], + nativeIds: Map, + seen: Map, onDiagnostic?: (message: string) => void, ): void { const plain = (xml: string) => codeOf(`_${xml.replace(//g, ' ')}_`).slice(1, -1) @@ -196,34 +211,49 @@ function cCalls( if (!listing) continue const lines = [...listing.matchAll(/]*)>([\s\S]*?)<\/codeline>/g)].map((match) => { // Comments and literal contents cannot contain callable source tokens. - const codeXml = match[2].replace(/([\s\S]*?)<\/highlight>/g, - (part) => ' '.repeat(plain(part).length)) + const codeXml = match[2].replace( + /([\s\S]*?)<\/highlight>/g, + (part) => ' '.repeat(plain(part).length), + ) const refs = [...codeXml.matchAll(/]*)>([\s\S]*?)<\/ref>/g)].map((ref) => ({ - id: nativeIds.get(attr(ref[1], 'refid') ?? ''), name: plain(ref[2]), + id: nativeIds.get(attr(ref[1], 'refid') ?? ''), + name: plain(ref[2]), start: plain(codeXml.slice(0, ref.index)).length, end: plain(codeXml.slice(0, ref.index + ref[0].length)).length, })) - return { line: Number(attr(match[1], 'lineno')), code: plain(codeXml), refs, - preprocessor: //.test(codeXml) } + return { + line: Number(attr(match[1], 'lineno')), + code: plain(codeXml), + refs, + preprocessor: //.test(codeXml), + } }) const bodies = [...xml.matchAll(//g)].flatMap((member) => { if (attr(member[1], 'kind') !== 'function') return [] const symbol = seen.get(nativeIds.get(attr(member[1], 'id') ?? '') ?? '') const location = /]*)\/>/.exec(member[1])?.[1] ?? '' - const start = Number(attr(location, 'bodystart')), end = Number(attr(location, 'bodyend')) - return symbol && attr(location, 'bodyfile') === file && start > 0 && end >= start - ? [{ symbol, start, end }] : [] + const start = Number(attr(location, 'bodystart')), + end = Number(attr(location, 'bodyend')) + return symbol && attr(location, 'bodyfile') === file && start > 0 && end >= start ? [{ symbol, start, end }] : [] }) for (const { symbol, start, end } of bodies) { // Native body spans have line precision only. Two definitions can share // a line, so its first brace is not necessarily this function's body. - const conflicts = bodies.filter((body) => body.symbol.id !== symbol.id - && body.start <= end && start <= body.end) + const conflicts = bodies.filter((body) => body.symbol.id !== symbol.id && body.start <= end && start <= body.end) if (conflicts.length) { - onDiagnostic?.(`Omitted ambiguous function body ${symbol.id} at ${file}:${start}-${end}; overlaps ${conflicts.map((body) => body.symbol.id).sort().join(', ')}`) + onDiagnostic?.( + `Omitted ambiguous function body ${symbol.id} at ${file}:${start}-${end}; overlaps ${conflicts + .map((body) => body.symbol.id) + .sort() + .join(', ')}`, + ) continue } - let opened = false, parentheses = 0, brackets = 0, braces = 1, continuation = false + let opened = false, + parentheses = 0, + brackets = 0, + braces = 1, + continuation = false for (const row of lines) { if (row.line < start || row.line > end) continue if (row.preprocessor || continuation) { @@ -241,15 +271,26 @@ function cCalls( let limit = row.code.length for (let i = offset; i < row.code.length; i++) { if (row.code[i] === '{') braces++ - else if (row.code[i] === '}' && --braces === 0) { limit = i; break } + else if (row.code[i] === '}' && --braces === 0) { + limit = i + break + } } const macro = row.refs.some((ref) => ref.id && seen.get(ref.id)?.modifiers.includes('macro')) for (const ref of row.refs) { const target = ref.id ? seen.get(ref.id) : undefined - if (macro || parentheses !== 0 || brackets !== 0 || ref.start < offset - || ref.end > limit || target?.kind !== 'function' || target.name !== ref.name - || (target.modifiers.includes('static') && target.source.file !== file) - || !/^\s*\(/.test(row.code.slice(ref.end))) continue + if ( + macro || + parentheses !== 0 || + brackets !== 0 || + ref.start < offset || + ref.end > limit || + target?.kind !== 'function' || + target.name !== ref.name || + (target.modifiers.includes('static') && target.source.file !== file) || + !/^\s*\(/.test(row.code.slice(ref.end)) + ) + continue const prefix = row.code.slice(offset, ref.start) // Deliberately bounded: a direct statement, return, first condition, // or assignment RHS. Nested/continued expressions and macro arguments @@ -257,10 +298,14 @@ function cCalls( const expression = /^\s*(?:return\s*\(*\s*|(?:if|while|switch)\s*\(\s*!?\s*)?$/.test(prefix) const assignment = !/[();{}]/.test(prefix) && /(?])=(?!=)\s*$/.test(prefix) if (!expression && !assignment) continue - const references = symbol.references ??= [] + const references = (symbol.references ??= []) let edge = references.find((entry) => entry.kind === 'call' && entry.target === target.id) - if (!edge) { edge = { target: target.id, kind: 'call', sites: [] }; references.push(edge) } - if (!edge.sites!.some((site) => site.file === file && site.line === row.line)) edge.sites!.push({ file, line: row.line }) + if (!edge) { + edge = { target: target.id, kind: 'call', sites: [] } + references.push(edge) + } + if (!edge.sites!.some((site) => site.file === file && site.line === row.line)) + edge.sites!.push({ file, line: row.line }) } for (const char of row.code.slice(offset, limit)) { if (char === '(') parentheses++ @@ -274,7 +319,8 @@ function cCalls( } for (const symbol of seen.values()) { symbol.references?.sort((a, b) => a.kind.localeCompare(b.kind) || a.target.localeCompare(b.target)) - for (const edge of symbol.references ?? []) edge.sites?.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line) + for (const edge of symbol.references ?? []) + edge.sites?.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line) } } @@ -345,16 +391,21 @@ function cppSignature(block: string): string | undefined { const name = codeOf(tag(block, 'name') ?? '') const qualified = codeOf(tag(block, 'qualifiedname') ?? '') // Inside an owner's panel, use the in-class spelling from the declaration. - let declaration = qualified && definition.endsWith(qualified) - ? `${definition.slice(0, -qualified.length)}${name}${args}` : `${definition}${args}` + let declaration = + qualified && definition.endsWith(qualified) + ? `${definition.slice(0, -qualified.length)}${name}${args}` + : `${definition}${args}` for (const keyword of ['explicit', 'static', 'constexpr', 'consteval']) { - if (attr(block, keyword) === 'yes' && !new RegExp(`\\b${keyword}\\b`).test(definition)) declaration = `${keyword} ${declaration}` + if (attr(block, keyword) === 'yes' && !new RegExp(`\\b${keyword}\\b`).test(definition)) + declaration = `${keyword} ${declaration}` } - if (attr(block, 'nodiscard') === 'yes' && !definition.includes('[[nodiscard')) declaration = `[[nodiscard]] ${declaration}` + if (attr(block, 'nodiscard') === 'yes' && !definition.includes('[[nodiscard')) + declaration = `[[nodiscard]] ${declaration}` const template = cppTemplateParameters(block) const constraint = codeOf(tag(block, 'requiresclause') ?? '') if (constraint && !/\brequires\b/.test(args)) { - declaration = template.length ? `requires ${constraint}\n${declaration}` + declaration = template.length + ? `requires ${constraint}\n${declaration}` : declaration.replace(/(\s*=\s*(?:default|delete|0))?$/, (_, ending = '') => ` requires ${constraint}${ending}`) } if (template.length) declaration = `template <${template.join(', ')}>\n${declaration}` @@ -392,9 +443,7 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen const definitionIds = new Set() const pendingTypes = new Map() - const files = readdirSync(xmlDir).filter( - (f) => f.endsWith('.xml') && f !== 'index.xml' && f !== 'Doxyfile.xml', - ) + const files = readdirSync(xmlDir).filter((f) => f.endsWith('.xml') && f !== 'index.xml' && f !== 'Doxyfile.xml') const documents = files.sort().map((file) => ({ file, xml: readFileSync(join(xmlDir, file), 'utf8') })) if (c) { @@ -423,7 +472,7 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen const owner = normalizeCppName(textOf(tag(xml, 'compoundname') ?? '')) if (!owner) continue - const ownerId = c ? nativeIds.get(compoundId) ?? owner : owner + const ownerId = c ? (nativeIds.get(compoundId) ?? owner) : owner if (kind && compound[1] !== 'namespace') { // A compound's own descriptions sit at the end of ``, after // every `` and immediately before ``. Taking the @@ -447,17 +496,19 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen const sym: ApiSymbol = { id: ownerId, publicId: ownerId, - ...(c ? { qualifiedName: cDisplayName(owner, kind), namespace: '', parent: compoundParents.get(compoundId) } : {}), + ...(c + ? { qualifiedName: cDisplayName(owner, kind), namespace: '', parent: compoundParents.get(compoundId) } + : {}), name: c ? cDisplayName(cppUnqualifiedName(owner), kind) : cppUnqualifiedName(owner), kind, modifiers: [], - signatures: c && !owner.includes('::') && !syntheticCName(owner) ? [{ raw: `${kind} ${owner};`, params: [] }] : [], - doc: - brief || detail - ? { summary: brief, body: detail || undefined } - : undefined, + signatures: + c && !owner.includes('::') && !syntheticCName(owner) ? [{ raw: `${kind} ${owner};`, params: [] }] : [], + doc: brief || detail ? { summary: brief, body: detail || undefined } : undefined, extends: bases.length ? bases : undefined, - source: c ? cLocation(ownLocation, file).source : { file: location?.[1] ?? file, line: Number(location?.[2] ?? 1) }, + source: c + ? cLocation(ownLocation, file).source + : { file: location?.[1] ?? file, line: Number(location?.[2] ?? 1) }, } seen.set(ownerId, sym) symbols.push(sym) @@ -480,16 +531,25 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen // share one identity; static declarations remain local to their file. const local = attr(block, 'static') === 'yes' || nativeKind === 'define' || name.startsWith('@') const id = c - ? parent ? `${parent}::${name}` : `c:${nativeKind}:${local ? `${owner}:` : ''}${name}` + ? parent + ? `${parent}::${name}` + : `c:${nativeKind}:${local ? `${owner}:` : ''}${name}` : `${parent ?? owner}::${name}` const nativeId = attr(block, 'id') if (c && nativeId) nativeIds.set(nativeId, id) const nativeLocation = /]*)\/>/.exec(block)?.[1] ?? '' - const { source: nativeSource, isDefinition } = cLocation(nativeLocation, file, - attr(block, 'extern') === 'yes' && !tag(block, 'initializer')) + const { source: nativeSource, isDefinition } = cLocation( + nativeLocation, + file, + attr(block, 'extern') === 'yes' && !tag(block, 'initializer'), + ) const sourceFile = nativeSource.file if (c) { - pendingTypes.set(id, [...(pendingTypes.get(id) ?? []), codeOf(tag(block, 'type') ?? ''), codeOf(tag(block, 'argsstring') ?? '')]) + pendingTypes.set(id, [ + ...(pendingTypes.get(id) ?? []), + codeOf(tag(block, 'type') ?? ''), + codeOf(tag(block, 'argsstring') ?? ''), + ]) const refs = pendingReferences.get(id) ?? [] for (const typeBlock of block.matchAll(/([\s\S]*?)<\/type>/g)) { for (const ref of typeBlock[1].matchAll(/]*)>([\s\S]*?)<\/ref>/g)) { @@ -525,8 +585,11 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen ? { params: paramsOf(block, paramDocs(block), c), ...(templateParameters.length ? { typeParams: templateParameters } : {}), - ...(c ? { raw: `${codeOf(tag(block, 'definition') ?? '')}${codeOf(tag(block, 'argsstring') ?? '')}` } - : cppSignature(block) ? { raw: cppSignature(block) } : {}), + ...(c + ? { raw: `${codeOf(tag(block, 'definition') ?? '')}${codeOf(tag(block, 'argsstring') ?? '')}` } + : cppSignature(block) + ? { raw: cppSignature(block) } + : {}), returns: returnType || undefined, returnsDoc: returnDoc || undefined, } @@ -559,18 +622,24 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen const sym: ApiSymbol = { id, publicId: id, - ...(c ? { qualifiedName: parent ? cDisplayName(`${owner}::${name}`, syntheticCName(name) ? nativeKind : undefined) : cDisplayName(name, nativeKind), namespace: '' } : {}), + ...(c + ? { + qualifiedName: parent + ? cDisplayName(`${owner}::${name}`, syntheticCName(name) ? nativeKind : undefined) + : cDisplayName(name, nativeKind), + namespace: '', + } + : {}), name: c ? cDisplayName(name, /^(struct|union|enum)\b/.exec(returnType)?.[1] ?? nativeKind) : name, kind: memberKind === 'method' && !parent ? 'function' : memberKind, modifiers: c && nativeKind === 'define' ? [...modifiersOf(block), 'macro'] : modifiersOf(block), parent, signatures: signature ? [signature] : c ? [{ raw: cDeclaration(block, nativeKind, name), params: [] }] : [], type: signature ? undefined : returnType || undefined, - ...(c && tag(block, 'initializer') ? { value: codeOf(tag(block, 'initializer') ?? '').replace(/^=\s*/, '') } : {}), - doc: - brief || detail - ? { summary: brief, body: detail || undefined } - : undefined, + ...(c && tag(block, 'initializer') + ? { value: codeOf(tag(block, 'initializer') ?? '').replace(/^=\s*/, '') } + : {}), + doc: brief || detail ? { summary: brief, body: detail || undefined } : undefined, source: c ? nativeSource : { file: location?.[1] ?? file, line: Number(location?.[2] ?? 1) }, } if (c && isDefinition) definitionIds.add(id) @@ -595,18 +664,28 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen const valueSym: ApiSymbol = { id: valueId, publicId: valueId, - ...(c ? { qualifiedName: valueName, namespace: '', value: codeOf(tag(value, 'initializer') ?? '').replace(/^=\s*/, '') || undefined } : {}), + ...(c + ? { + qualifiedName: valueName, + namespace: '', + value: codeOf(tag(value, 'initializer') ?? '').replace(/^=\s*/, '') || undefined, + } + : {}), name: valueName, // No `variant` kind exists, and a named member of an enum is // what a constant is — the same choice the tree-sitter specs make. kind: 'constant', modifiers: [], parent: id, - signatures: c ? [{ raw: `${valueName}${tag(value, 'initializer') ? ` ${codeOf(tag(value, 'initializer') ?? '')}` : ''}`, params: [] }] : [], - doc: - valueBrief || valueDetail - ? { summary: valueBrief, body: valueDetail || undefined } - : undefined, + signatures: c + ? [ + { + raw: `${valueName}${tag(value, 'initializer') ? ` ${codeOf(tag(value, 'initializer') ?? '')}` : ''}`, + params: [], + }, + ] + : [], + doc: valueBrief || valueDetail ? { summary: valueBrief, body: valueDetail || undefined } : undefined, source: c ? { file: sourceFile } : { file: location?.[1] ?? file, line: Number(location?.[2] ?? 1) }, } if (c && attr(value, 'id')) nativeIds.set(attr(value, 'id')!, valueId) @@ -628,9 +707,15 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen // Preserve that uncertainty instead of claiming a compiler call edge. const kind = ref.kind const related = seen.get(target)! - if (kind === 'reference' && related.kind === 'function' - && related.modifiers.includes('static') && related.source.file !== symbol.source.file) { - options.onDiagnostic?.(`Omitted cross-file static reference ${symbol.id} -> ${target} (${ref.nativeId}); ${symbol.source.file} -> ${related.source.file}`) + if ( + kind === 'reference' && + related.kind === 'function' && + related.modifiers.includes('static') && + related.source.file !== symbol.source.file + ) { + options.onDiagnostic?.( + `Omitted cross-file static reference ${symbol.id} -> ${target} (${ref.nativeId}); ${symbol.source.file} -> ${related.source.file}`, + ) continue } unique.set(`${kind}:${target}`, { target, kind }) @@ -639,19 +724,34 @@ export function extractDoxygen(xmlDir: string, sourceRoot = '', options: Doxygen // Doxygen omits a ref when a C tag and a function share the name. // An explicit tag keyword resolves in C's tag namespace, never to the // same-named ordinary function or typedef. - const types = [...(pendingTypes.get(symbol.id) ?? []), symbol.type, ...symbol.signatures.flatMap((signature) => [signature.returns, ...signature.params.map((param) => param.type)])] - for (const type of types) for (const match of (type ?? '').matchAll(/\b(struct|union|enum)\s+([A-Za-z_]\w*)/g)) { - const target = `c:${match[1]}:${match[2]}` - if (!seen.has(target) || target === symbol.id) continue - unique.set(`type:${target}`, { target, kind: 'type' }) - ;(symbol.imports ??= {})[match[2]] = target - } - if (unique.size) symbol.references = [...unique.values()].sort((a, b) => a.kind.localeCompare(b.kind) || a.target.localeCompare(b.target)) - const displayType = (type: string | undefined) => type && syntheticCName(type) - ? `anonymous ${/\b(struct|union|enum)\b/.exec(type)?.[1] ?? 'type'}` : type + const types = [ + ...(pendingTypes.get(symbol.id) ?? []), + symbol.type, + ...symbol.signatures.flatMap((signature) => [ + signature.returns, + ...signature.params.map((param) => param.type), + ]), + ] + for (const type of types) + for (const match of (type ?? '').matchAll(/\b(struct|union|enum)\s+([A-Za-z_]\w*)/g)) { + const target = `c:${match[1]}:${match[2]}` + if (!seen.has(target) || target === symbol.id) continue + unique.set(`type:${target}`, { target, kind: 'type' }) + ;(symbol.imports ??= {})[match[2]] = target + } + if (unique.size) + symbol.references = [...unique.values()].sort( + (a, b) => a.kind.localeCompare(b.kind) || a.target.localeCompare(b.target), + ) + const displayType = (type: string | undefined) => + type && syntheticCName(type) ? `anonymous ${/\b(struct|union|enum)\b/.exec(type)?.[1] ?? 'type'}` : type if (symbol.type) symbol.type = displayType(symbol.type) for (const signature of symbol.signatures) { - if (syntheticCName(signature.returns ?? '') || signature.params.some((param) => syntheticCName(param.type ?? ''))) delete signature.raw + if ( + syntheticCName(signature.returns ?? '') || + signature.params.some((param) => syntheticCName(param.type ?? '')) + ) + delete signature.raw if (signature.returns) signature.returns = displayType(signature.returns) for (const param of signature.params) if (param.type) param.type = displayType(param.type) } diff --git a/packages/api-model/src/languages/fsharp-examples.ts b/packages/api-model/src/languages/fsharp-examples.ts index 0cc97fc20..e25a8436c 100644 --- a/packages/api-model/src/languages/fsharp-examples.ts +++ b/packages/api-model/src/languages/fsharp-examples.ts @@ -25,18 +25,26 @@ export function attachCompleteFSharpExamples( const raw = read('examples/api/manifest.json') if (raw === undefined) return const manifest = JSON.parse(raw) as Manifest - if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.examples) || !manifest.examples.length || - JSON.stringify(manifest.setupFiles) !== JSON.stringify(['global.json', 'examples/api/NuGet.config'])) { + if ( + manifest.schemaVersion !== 1 || + !Array.isArray(manifest.examples) || + !manifest.examples.length || + JSON.stringify(manifest.setupFiles) !== JSON.stringify(['global.json', 'examples/api/NuGet.config']) + ) { throw new Error('Invalid F# example manifest') } const profile = manifest.profiles?.fsharp - if (profile?.package !== 'LibTmux.FSharp' || profile.projectFile !== 'examples/api/fsharp/Example.fsproj' || - profile.entrypoint !== 'Program.fs') throw new Error('Invalid F# example profile') + if ( + profile?.package !== 'LibTmux.FSharp' || + profile.projectFile !== 'examples/api/fsharp/Example.fsproj' || + profile.entrypoint !== 'Program.fs' + ) + throw new Error('Invalid F# example profile') if (source.repo !== 'libtmux/libtmux-dotnet' || !/^[0-9a-f]{40}$/.test(source.revision)) { throw new Error('F# examples require the public repository and full source revision') } const codeFor = (path: string): string => { - if (path !== 'global.json' && !/^examples\/(?:[\w.-]+\/)*[\w.-]+$/.test(path) || path.split('/').includes('..')) { + if ((path !== 'global.json' && !/^examples\/(?:[\w.-]+\/)*[\w.-]+$/.test(path)) || path.split('/').includes('..')) { throw new Error(`Invalid F# example source path: ${path}`) } const code = read(path) @@ -56,49 +64,70 @@ export function attachCompleteFSharpExamples( const ids = new Set() for (const program of manifest.examples) { if (program.profile !== 'fsharp') continue - if (!/^fsharp-[A-Za-z0-9]+$/.test(program.id) || ids.has(program.id) || - typeof program.title !== 'string' || !program.title.trim() || - typeof program.description !== 'string' || !program.description.trim() || - !/^examples\/LibTmux\.FSharp\.Examples\/Programs\/[A-Za-z0-9]+\.fs$/.test(program.sourceFile) || - typeof program.output !== 'string' || !program.output.trim() || - !program.output.endsWith('\n') || program.output.includes('\r') || - !Array.isArray(program.targets) || !program.targets.length) { + if ( + !/^fsharp-[A-Za-z0-9]+$/.test(program.id) || + ids.has(program.id) || + typeof program.title !== 'string' || + !program.title.trim() || + typeof program.description !== 'string' || + !program.description.trim() || + !/^examples\/LibTmux\.FSharp\.Examples\/Programs\/[A-Za-z0-9]+\.fs$/.test(program.sourceFile) || + typeof program.output !== 'string' || + !program.output.trim() || + !program.output.endsWith('\n') || + program.output.includes('\r') || + !Array.isArray(program.targets) || + !program.targets.length + ) { throw new Error(`Invalid F# example metadata: ${program.id}`) } ids.add(program.id) - const blocks: NonNullable = [{ - lang: 'console', - intro: `${program.description.replace(/\n/g, ' ')} Use Linux or macOS with Git, tmux 3.2a or newer, .NET SDK ${sdk}, and the .NET 8 and 10 runtimes. In an empty directory, fetch the documented library revision:`, - code: `$ git clone https://github.com/${source.repo}.git libtmux-source && \\\n git -C libtmux-source checkout --detach ${source.revision}\n`, - }] - for (const file of setup) blocks.push({ - lang: file.lang, intro: `Save this complete consumer setup as ${file.path}:`, - sourceUrl: urlFor(file.sourceFile), code: codeFor(file.sourceFile), - }) - blocks.push({ - lang: 'fsharp', - intro: 'Save this complete program as Program.fs. It creates a private server with LibTmux.Server.CreateOwnedAsync; use! disposes owned scopes after success, cancellation or failure. The F# facade has no separate server constructor.', - sourceUrl: urlFor(program.sourceFile), code: codeFor(program.sourceFile), - }, { - lang: 'console', - intro: 'Build the exact source packages and the saved consumer, then run both supported frameworks. The local package feed prevents an unrelated published package from satisfying the restore. Each program checks its result and exits unsuccessfully after an unhandled error:', - code: '$ export NUGET_PACKAGES="$PWD/packages" && \\\n' + - ' dotnet restore libtmux-source/src/LibTmux.FSharp/LibTmux.FSharp.fsproj --locked-mode && \\\n' + - ' dotnet pack libtmux-source/src/LibTmux/LibTmux.csproj \\\n' + - ' --configuration Release --no-restore -p:ContinuousIntegrationBuild=true \\\n' + - ' --output "$PWD/libtmux-source/artifacts/api-example-packages" && \\\n' + - ' dotnet pack libtmux-source/src/LibTmux.FSharp/LibTmux.FSharp.fsproj \\\n' + - ' --configuration Release --no-restore -p:ContinuousIntegrationBuild=true \\\n' + - ' --output "$PWD/libtmux-source/artifacts/api-example-packages" && \\\n' + - ' dotnet restore Example.fsproj --configfile NuGet.config && \\\n' + - ' dotnet build Example.fsproj --configuration Release --no-restore --warnaserror && \\\n' + - ' export LIBTMUX_TMUX="$(command -v tmux)" && \\\n' + - ' export TMUX_TMPDIR=/tmp/libtmux-dotnet-dev && \\\n' + - ' mkdir -p "$TMUX_TMPDIR" && \\\n' + - ' unset TMUX TMUX_PANE && \\\n' + - ' dotnet bin/Release/net8.0/Example.dll && \\\n' + - ' dotnet bin/Release/net10.0/Example.dll\n', - }, { lang: 'text', intro: 'Expected program output from each framework:', code: program.output }) + const blocks: NonNullable = [ + { + lang: 'console', + intro: `${program.description.replace(/\n/g, ' ')} Use Linux or macOS with Git, tmux 3.2a or newer, .NET SDK ${sdk}, and the .NET 8 and 10 runtimes. In an empty directory, fetch the documented library revision:`, + code: `$ git clone https://github.com/${source.repo}.git libtmux-source && \\\n git -C libtmux-source checkout --detach ${source.revision}\n`, + }, + ] + for (const file of setup) + blocks.push({ + lang: file.lang, + intro: `Save this complete consumer setup as ${file.path}:`, + sourceUrl: urlFor(file.sourceFile), + code: codeFor(file.sourceFile), + }) + blocks.push( + { + lang: 'fsharp', + intro: + 'Save this complete program as Program.fs. It creates a private server with LibTmux.Server.CreateOwnedAsync; use! disposes owned scopes after success, cancellation or failure. The F# facade has no separate server constructor.', + sourceUrl: urlFor(program.sourceFile), + code: codeFor(program.sourceFile), + }, + { + lang: 'console', + intro: + 'Build the exact source packages and the saved consumer, then run both supported frameworks. The local package feed prevents an unrelated published package from satisfying the restore. Each program checks its result and exits unsuccessfully after an unhandled error:', + code: + '$ export NUGET_PACKAGES="$PWD/packages" && \\\n' + + ' dotnet restore libtmux-source/src/LibTmux.FSharp/LibTmux.FSharp.fsproj --locked-mode && \\\n' + + ' dotnet pack libtmux-source/src/LibTmux/LibTmux.csproj \\\n' + + ' --configuration Release --no-restore -p:ContinuousIntegrationBuild=true \\\n' + + ' --output "$PWD/libtmux-source/artifacts/api-example-packages" && \\\n' + + ' dotnet pack libtmux-source/src/LibTmux.FSharp/LibTmux.FSharp.fsproj \\\n' + + ' --configuration Release --no-restore -p:ContinuousIntegrationBuild=true \\\n' + + ' --output "$PWD/libtmux-source/artifacts/api-example-packages" && \\\n' + + ' dotnet restore Example.fsproj --configfile NuGet.config && \\\n' + + ' dotnet build Example.fsproj --configuration Release --no-restore --warnaserror && \\\n' + + ' export LIBTMUX_TMUX="$(command -v tmux)" && \\\n' + + ' export TMUX_TMPDIR=/tmp/libtmux-dotnet-dev && \\\n' + + ' mkdir -p "$TMUX_TMPDIR" && \\\n' + + ' unset TMUX TMUX_PANE && \\\n' + + ' dotnet bin/Release/net8.0/Example.dll && \\\n' + + ' dotnet bin/Release/net10.0/Example.dll\n', + }, + { lang: 'text', intro: 'Expected program output from each framework:', code: program.output }, + ) const targets = new Set() for (const target of program.targets) { // XML documentation addresses include kind, generic arity and parameter metadata. @@ -106,12 +135,18 @@ export function attachCompleteFSharpExamples( if (typeof target !== 'string' || !/^[MT]:LibTmux\.FSharp\.[\w.]+(?:`{1,2}\d+)?(?:\([^()\s]+\))?$/.test(target)) { throw new Error(`Invalid F# compiler target: ${target}`) } - const id = target.slice(2).replace(/\(.*$/, '').replace(/`{1,2}\d+$/, '') + const id = target + .slice(2) + .replace(/\(.*$/, '') + .replace(/`{1,2}\d+$/, '') if (targets.has(id)) throw new Error(`Duplicate F# example target: ${target}`) const matches = symbols.filter((symbol) => symbol.id === id) - if (matches.length !== 1 || (target.startsWith('M:') - ? !['function', 'method'].includes(matches[0].kind) - : !['module', 'class', 'struct', 'enum', 'typealias'].includes(matches[0].kind))) { + if ( + matches.length !== 1 || + (target.startsWith('M:') + ? !['function', 'method'].includes(matches[0].kind) + : !['module', 'class', 'struct', 'enum', 'typealias'].includes(matches[0].kind)) + ) { throw new Error(`F# example target must resolve once with its native kind: ${target}`) } targets.add(id) diff --git a/packages/api-model/src/languages/fsharp.ts b/packages/api-model/src/languages/fsharp.ts index 2d61e909a..cd3bf297a 100644 --- a/packages/api-model/src/languages/fsharp.ts +++ b/packages/api-model/src/languages/fsharp.ts @@ -29,9 +29,13 @@ interface FSharpArtifact { /** Convert the native compiler's public signature tree to the shared reference model. */ export function extractFSharpArtifact(input: unknown, revision?: string): ApiModel { const artifact = input as FSharpArtifact - if (artifact?.schema !== 1 || artifact.port !== 'fsharp' || - typeof artifact.compiler !== 'string' || !Array.isArray(artifact.declarations) || - !artifact.declarations.length) { + if ( + artifact?.schema !== 1 || + artifact.port !== 'fsharp' || + typeof artifact.compiler !== 'string' || + !Array.isArray(artifact.declarations) || + !artifact.declarations.length + ) { throw new Error('F# compiler artifact has an unsupported schema or no declarations') } const ids = new Set(artifact.declarations.map((item) => item.id)) @@ -70,7 +74,10 @@ export function extractFSharpArtifact(input: unknown, revision?: string): ApiMod const merged = new Map() for (const symbol of symbols) { const previous = merged.get(symbol.id) - if (!previous) { merged.set(symbol.id, symbol); continue } + if (!previous) { + merged.set(symbol.id, symbol) + continue + } // F# permits a type and its companion module to share a public name. if (previous.kind !== 'module' && symbol.kind !== 'module') { throw new Error(`Duplicate F# public declaration: ${symbol.id}`) @@ -80,7 +87,8 @@ export function extractFSharpArtifact(input: unknown, revision?: string): ApiMod previous.doc ??= symbol.doc } return { - port: 'fsharp', revision, + port: 'fsharp', + revision, extractor: `FSharp.Compiler.Service ${artifact.compiler} (public .fsi syntax)`, symbols: [...merged.values()], } @@ -92,7 +100,9 @@ export function extractFSharp(root: string, revision?: string): ApiModel { if (projects.length !== 1) throw new Error(`Expected one F# project in ${root}`) const script = fileURLToPath(new URL('../../scripts/extract-fsharp.fsx', import.meta.url)) const output = execFileSync('dotnet', ['fsi', '--warnaserror', '--exec', script, join(root, projects[0])], { - cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, + cwd: root, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'inherit'], }) return extractFSharpArtifact(JSON.parse(output), revision) diff --git a/packages/api-model/src/languages/go-examples.ts b/packages/api-model/src/languages/go-examples.ts index 510b96bbd..6faa4677c 100644 --- a/packages/api-model/src/languages/go-examples.ts +++ b/packages/api-model/src/languages/go-examples.ts @@ -11,9 +11,7 @@ export interface CompleteGoExample { } /** Read opt-in native examples without turning ordinary test helpers into API pages. */ -export async function readCompleteGoExamples( - files: { file: string; code: string }[], -): Promise { +export async function readCompleteGoExamples(files: { file: string; code: string }[]): Promise { const parser = await parserFor('go') const examples: CompleteGoExample[] = [] const seen = new Set() @@ -34,13 +32,15 @@ export async function readCompleteGoExamples( const name = declaration.childForFieldName('name')?.text ?? '' const match = /^Example([A-Z][A-Za-z0-9]*)(?:_([A-Z][A-Za-z0-9]*))?_complete$/.exec(name) if (!match) fail('expected ExampleName_complete or ExampleType_Method_complete') - if (declaration.childForFieldName('parameters')?.namedChildCount || - declaration.childForFieldName('result') || declaration.childForFieldName('type_parameters')) { + if ( + declaration.childForFieldName('parameters')?.namedChildCount || + declaration.childForFieldName('result') || + declaration.childForFieldName('type_parameters') + ) { fail('Example functions cannot have parameters, results, or type parameters') } const scope = basename(dirname(file)) - const packageName = root.namedChildren.find((node) => node?.type === 'package_clause') - ?.namedChildren[0]?.text + const packageName = root.namedChildren.find((node) => node?.type === 'package_clause')?.namedChildren[0]?.text if (packageName !== `${scope}_test`) fail('use an external test package beside its API') const symbol = `${scope}.${match![1]}${match![2] ? `.${match![2]}` : ''}` if (seen.has(symbol)) fail(`duplicate example for ${symbol}`) @@ -58,8 +58,15 @@ export async function readCompleteGoExamples( if (!intro) fail('a task description is required') const output = /\/\/ Output:([^\n]*)\n((?:[\t ]*\/\/[^\n]*\n)*)[\t ]*\}$/.exec(declaration.text) if (!output) fail('a final Output assertion is required') - const expected = [output![1].trim(), ...output![2].trimEnd().split('\n') - .map((line) => line.replace(/^[\t ]*\/\/ ?/, ''))].join('\n').trim() + const expected = [ + output![1].trim(), + ...output![2] + .trimEnd() + .split('\n') + .map((line) => line.replace(/^[\t ]*\/\/ ?/, '')), + ] + .join('\n') + .trim() if (!expected) fail('the Output assertion must describe an observable result') seen.add(symbol) examples.push({ symbol, file, code, intro, output: expected }) @@ -98,13 +105,15 @@ export function attachCompleteGoExamples( }, { lang: 'go', - intro: 'Save this complete example as example_test.go. It creates a private temporary socket and stops its server before removing the directory. Cleanup failure fails the example and retains the socket directory.', + intro: + 'Save this complete example as example_test.go. It creates a private temporary socket and stops its server before removing the directory. Cleanup failure fails the example and retains the socket directory.', sourceUrl: `https://github.com/${source.repo}/blob/${source.revision}/${example.file}`, code: example.code, }, { lang: 'console', - intro: 'Run the saved file. Go checks the Output comment and fails on different output or an operation or cleanup error:', + intro: + 'Run the saved file. Go checks the Output comment and fails on different output or an operation or cleanup error:', code: '$ GOWORK=off go test -count=1 -v example_test.go\n', }, ] diff --git a/packages/api-model/src/languages/julia.ts b/packages/api-model/src/languages/julia.ts index 41ec5899f..bc4554b5b 100644 --- a/packages/api-model/src/languages/julia.ts +++ b/packages/api-model/src/languages/julia.ts @@ -2,14 +2,28 @@ import { fromMarkdown } from 'mdast-util-from-markdown' import { toMarkdown } from 'mdast-util-to-markdown' import type { ApiModel, ApiProduct, ApiSymbol, DocBlock, Signature } from '../model.ts' -interface Source { file: string; line: number } -interface Documentation { signature: string; markdown: string; source: Source; fields: Record } +interface Source { + file: string + line: number +} +interface Documentation { + signature: string + markdown: string + source: Source + fields: Record +} interface Method { identity: string kind: 'function' | 'constructor' | 'call' signature: string sourceSignature: string | null - parameters: { name: string | null; type: string | null; default: string | null; keyword: boolean; variadic: boolean }[] + parameters: { + name: string | null + type: string | null + default: string | null + keyword: boolean + variadic: boolean + }[] typeParameters: string[] receiver: { name: string; type: string } | null module: string @@ -54,34 +68,48 @@ interface JuliaArtifact { function assertArtifact(input: unknown, revision?: string): JuliaArtifact { const artifact = input as JuliaArtifact - if (artifact?.schema !== 1 || artifact.port !== 'julia' || !Array.isArray(artifact.symbols) || - !Array.isArray(artifact.modules) || !Array.isArray(artifact.extensionMethods) || - !/^[a-f0-9]{40}$/.test(artifact.source?.revision ?? '')) { + if ( + artifact?.schema !== 1 || + artifact.port !== 'julia' || + !Array.isArray(artifact.symbols) || + !Array.isArray(artifact.modules) || + !Array.isArray(artifact.extensionMethods) || + !/^[a-f0-9]{40}$/.test(artifact.source?.revision ?? '') + ) { throw new Error('Julia documentation artifact has an unsupported schema or source identity') } - if (revision && artifact.source.revision !== revision) throw new Error('Julia artifact source revision differs from the selected checkout') + if (revision && artifact.source.revision !== revision) + throw new Error('Julia artifact source revision differs from the selected checkout') const ids = new Set() for (const symbol of artifact.symbols) { - if (ids.has(symbol.id) || symbol.id !== `${symbol.module}.${symbol.name}`) throw new Error(`Julia binding has a duplicate or unknown identity: ${symbol.id}`) + if (ids.has(symbol.id) || symbol.id !== `${symbol.module}.${symbol.name}`) + throw new Error(`Julia binding has a duplicate or unknown identity: ${symbol.id}`) ids.add(symbol.id) if (!symbol.source?.file || !symbol.source.line) throw new Error(`Julia binding lost its source: ${symbol.id}`) } for (const entry of artifact.modules) { const expected = [...entry.bindings, ...entry.supportingBindings].sort() - const actual = artifact.symbols.filter((symbol) => symbol.module === entry.module).map((symbol) => symbol.name).sort() - if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error(`Julia module inventory differs: ${entry.module}`) + const actual = artifact.symbols + .filter((symbol) => symbol.module === entry.module) + .map((symbol) => symbol.name) + .sort() + if (JSON.stringify(expected) !== JSON.stringify(actual)) + throw new Error(`Julia module inventory differs: ${entry.module}`) } for (const symbol of [...artifact.symbols, ...artifact.extensionMethods]) { - if (new Set(symbol.methods.map((method) => method.identity)).size !== symbol.methods.length) throw new Error(`Julia binding repeats a method: ${symbol.id}`) + if (new Set(symbol.methods.map((method) => method.identity)).size !== symbol.methods.length) + throw new Error(`Julia binding repeats a method: ${symbol.id}`) for (const method of symbol.methods) { if (!method.identity || !method.signature || !method.source?.file || !method.source.line || !method.kind) { throw new Error(`Julia method lost its signature or source: ${symbol.id}`) } - if (method.requiresExtension && !artifact.extensions.includes(method.module)) throw new Error(`Julia method requires an unloaded extension: ${method.module}`) + if (method.requiresExtension && !artifact.extensions.includes(method.module)) + throw new Error(`Julia method requires an unloaded extension: ${method.module}`) } } for (const symbol of artifact.symbols.filter((entry) => entry.kind === 'type' && entry.aliasOf)) { - if (!symbol.definition || !symbol.typeArguments.length || !ids.has(symbol.aliasOf!)) throw new Error(`Julia alias lost its definition or target: ${symbol.id}`) + if (!symbol.definition || !symbol.typeArguments.length || !ids.has(symbol.aliasOf!)) + throw new Error(`Julia alias lost its definition or target: ${symbol.id}`) } return artifact } @@ -95,7 +123,7 @@ function documentation(records: Documentation[], names: Set = new Set()) for (const node of tree.children) { if (node.type === 'heading') { if (node.depth <= argumentsDepth) argumentsDepth = 0 - const title = node.children.map((part) => 'value' in part ? part.value : '').join('') + const title = node.children.map((part) => ('value' in part ? part.value : '')).join('') if (/^(?:(?:Keyword|Positional)\s+)?Arguments$/i.test(title)) argumentsDepth = node.depth } if (!argumentsDepth || node.type !== 'list') continue @@ -103,13 +131,17 @@ function documentation(records: Documentation[], names: Set = new Set()) const first = item.children[0] if (first?.type !== 'paragraph') return true const [label, separator, ...rest] = first.children - if (label?.type !== 'inlineCode' || separator?.type !== 'text' || !/^\s*[:–-]\s*/.test(separator.value)) return true + if (label?.type !== 'inlineCode' || separator?.type !== 'text' || !/^\s*[:–-]\s*/.test(separator.value)) + return true const name = label.value.split(/::|=|\.\.\./)[0]!.trim() if (!names.has(name)) return true - const description = toMarkdown({ type: 'root', children: [ - { ...first, children: [{ ...separator, value: separator.value.replace(/^\s*[:–-]\s*/, '') }, ...rest] }, - ...item.children.slice(1), - ] }).trim() + const description = toMarkdown({ + type: 'root', + children: [ + { ...first, children: [{ ...separator, value: separator.value.replace(/^\s*[:–-]\s*/, '') }, ...rest] }, + ...item.children.slice(1), + ], + }).trim() if (!description) return true const previous = params.get(name) params.set(name, previous && previous !== description ? `${previous}\n\n${description}` : description) @@ -118,9 +150,16 @@ function documentation(records: Documentation[], names: Set = new Set()) } tree.children = tree.children.filter((node) => node.type !== 'list' || node.children.length > 0) // Once its list moved to Parameters, an empty Arguments heading adds no information. - tree.children = tree.children.filter((node, i, nodes) => !(node.type === 'heading' && - /^(?:(?:Keyword|Positional)\s+)?Arguments$/i.test(node.children.map((part) => 'value' in part ? part.value : '').join('')) && - (!nodes[i + 1] || (nodes[i + 1]!.type === 'heading' && (nodes[i + 1] as typeof node).depth <= node.depth)))) + tree.children = tree.children.filter( + (node, i, nodes) => + !( + node.type === 'heading' && + /^(?:(?:Keyword|Positional)\s+)?Arguments$/i.test( + node.children.map((part) => ('value' in part ? part.value : '')).join(''), + ) && + (!nodes[i + 1] || (nodes[i + 1]!.type === 'heading' && (nodes[i + 1] as typeof node).depth <= node.depth)) + ), + ) const text = params.size ? toMarkdown(tree).trim() : raw if (!text) return { params, doc: undefined } const prose = text.replace(/^```[^\n]*\n[\s\S]*?```\s*/, '') @@ -134,39 +173,66 @@ function documentation(records: Documentation[], names: Set = new Set()) export function extractJulia(input: unknown, expectedRevision?: string): ApiModel { const artifact = assertArtifact(input, expectedRevision) const source = (location: Source): ApiSymbol['source'] => ({ - file: location.file, line: location.line, repo: artifact.source.repository, - revision: artifact.source.revision, extractedRevision: artifact.source.revision, + file: location.file, + line: location.line, + repo: artifact.source.repository, + revision: artifact.source.revision, + extractedRevision: artifact.source.revision, }) const methodSignature = (method: Method): Signature => { - const parsed = documentation(method.documentation, new Set(method.parameters.flatMap((parameter) => parameter.name ? [parameter.name] : []))) + const parsed = documentation( + method.documentation, + new Set(method.parameters.flatMap((parameter) => (parameter.name ? [parameter.name] : []))), + ) return { - identity: method.identity, methodKind: method.kind, raw: method.signature, - params: method.parameters.map((parameter, index) => ({ - name: parameter.name && !parameter.name.startsWith('#') ? parameter.name : `argument${index + 1}`, - type: parameter.type ?? undefined, default: parameter.default ?? undefined, - keywordOnly: parameter.keyword, - variadic: parameter.variadic ? (parameter.keyword ? 'keyword' : 'positional') : undefined, - doc: parameter.name ? parsed.params.get(parameter.name) : undefined, - })), - typeParams: method.typeParameters, - receiver: method.receiver ?? undefined, - doc: parsed.doc, source: source(method.source), - extension: method.requiresExtension ? method.module : undefined, + identity: method.identity, + methodKind: method.kind, + raw: method.signature, + params: method.parameters.map((parameter, index) => ({ + name: parameter.name && !parameter.name.startsWith('#') ? parameter.name : `argument${index + 1}`, + type: parameter.type ?? undefined, + default: parameter.default ?? undefined, + keywordOnly: parameter.keyword, + variadic: parameter.variadic ? (parameter.keyword ? 'keyword' : 'positional') : undefined, + doc: parameter.name ? parsed.params.get(parameter.name) : undefined, + })), + typeParams: method.typeParameters, + receiver: method.receiver ?? undefined, + doc: parsed.doc, + source: source(method.source), + extension: method.requiresExtension ? method.module : undefined, } } const symbols: ApiSymbol[] = [] for (const entry of artifact.symbols) { const binding: ApiSymbol = { - id: entry.id, publicId: entry.id, name: entry.name, namespace: entry.module, - package: entry.package, product: entry.product, - kind: entry.kind === 'type' ? (entry.aliasOf ? 'typealias' : entry.abstract ? 'class' : 'struct') : entry.kind === 'macro' ? 'function' : entry.kind, - modifiers: [...(entry.abstract ? ['abstract' as const] : []), ...(entry.kind === 'macro' ? ['macro' as const] : [])], + id: entry.id, + publicId: entry.id, + name: entry.name, + namespace: entry.module, + package: entry.package, + product: entry.product, + kind: + entry.kind === 'type' + ? entry.aliasOf + ? 'typealias' + : entry.abstract + ? 'class' + : 'struct' + : entry.kind === 'macro' + ? 'function' + : entry.kind, + modifiers: [ + ...(entry.abstract ? ['abstract' as const] : []), + ...(entry.kind === 'macro' ? ['macro' as const] : []), + ], apiScope: entry.supporting ? 'supporting' : 'exported', signatures: entry.methods.map(methodSignature), doc: documentation(entry.documentation.filter((record) => record.signature === 'Union{}')).doc, - type: entry.kind === 'type' ? entry.definition ?? entry.type : entry.kind === 'constant' ? entry.type : undefined, + type: + entry.kind === 'type' ? (entry.definition ?? entry.type) : entry.kind === 'constant' ? entry.type : undefined, typeParams: entry.kind === 'type' ? entry.typeArguments : undefined, - value: entry.kind === 'constant' ? entry.definition ?? undefined : undefined, + value: entry.kind === 'constant' ? (entry.definition ?? undefined) : undefined, extends: entry.supertype && entry.supertype !== 'Any' && !entry.aliasOf ? [entry.supertype] : undefined, imports: entry.aliasOf ? { [entry.name]: entry.aliasOf } : undefined, source: source(entry.kind === 'function' && entry.methods.length === 1 ? entry.methods[0]!.source : entry.source), @@ -175,12 +241,23 @@ export function extractJulia(input: unknown, expectedRevision?: string): ApiMode // Aliases link to the underlying type; fields remain owned by that type. if (entry.aliasOf) continue for (const field of entry.fields) { - const fieldDoc = entry.documentation.map((record) => record.fields[field.name]).filter(Boolean).join('\n\n') + const fieldDoc = entry.documentation + .map((record) => record.fields[field.name]) + .filter(Boolean) + .join('\n\n') symbols.push({ - id: `${entry.id}.${field.name}`, publicId: `${entry.id}.${field.name}`, - name: field.name, namespace: entry.module, parent: entry.id, kind: 'property', - modifiers: entry.mutable ? [] : ['readonly'], type: field.type, signatures: [], - package: entry.package, product: entry.product, apiScope: binding.apiScope, + id: `${entry.id}.${field.name}`, + publicId: `${entry.id}.${field.name}`, + name: field.name, + namespace: entry.module, + parent: entry.id, + kind: 'property', + modifiers: entry.mutable ? [] : ['readonly'], + type: field.type, + signatures: [], + package: entry.package, + product: entry.product, + apiScope: binding.apiScope, doc: fieldDoc ? { summary: fieldDoc } : undefined, // Reflection gives field types but no field line; link to the containing source file. source: { ...source(entry.source), line: undefined }, @@ -191,9 +268,16 @@ export function extractJulia(input: unknown, expectedRevision?: string): ApiMode if (symbols.some((symbol) => symbol.id === entry.module)) continue const pkg = artifact.packages.find((candidate) => candidate.name === entry.package)! symbols.push({ - id: entry.module, publicId: entry.module, name: entry.module, kind: 'module', - modifiers: [], signatures: [], package: entry.package, product: entry.product, - apiScope: 'exported', source: source({ file: pkg.file, line: 1 }), + id: entry.module, + publicId: entry.module, + name: entry.module, + kind: 'module', + modifiers: [], + signatures: [], + package: entry.package, + product: entry.product, + apiScope: 'exported', + source: source({ file: pkg.file, line: 1 }), }) } for (const entry of artifact.extensionMethods) { @@ -206,22 +290,34 @@ export function extractJulia(input: unknown, expectedRevision?: string): ApiMode const owner = artifact.modules.find((candidate) => candidate.package === packageName) if (!owner) throw new Error(`Julia external method has an unknown package: ${packageName}`) symbols.push({ - id: `${packageName}.${entry.id}`, publicId: `${packageName}.${entry.id}`, - qualifiedName: entry.id, name: entry.id, namespace: owner.module, - kind: 'function', modifiers: [], signatures: methods.map(methodSignature), - package: packageName, product: owner.product, apiScope: 'exported', + id: `${packageName}.${entry.id}`, + publicId: `${packageName}.${entry.id}`, + qualifiedName: entry.id, + name: entry.id, + namespace: owner.module, + kind: 'function', + modifiers: [], + signatures: methods.map(methodSignature), + package: packageName, + product: owner.product, + apiScope: 'exported', doc: documentation(entry.documentation.filter((record) => record.signature === 'Union{}')).doc, source: source(methods[0]!.source), }) } } return { - port: 'julia', repo: artifact.source.repository, revision: artifact.source.revision, + port: 'julia', + repo: artifact.source.repository, + revision: artifact.source.revision, extractor: `@libtmux/api-model@0.0.1 (${artifact.exporter.name} v${artifact.exporter.version})`, nativeProvenance: { files: artifact.source.files, exporter: artifact.exporter }, sources: artifact.packages.map((pkg) => ({ - package: pkg.name, product: artifact.modules.find((entry) => entry.package === pkg.name)!.product, - version: pkg.version, repo: artifact.source.repository, revision: artifact.source.revision, + package: pkg.name, + product: artifact.modules.find((entry) => entry.package === pkg.name)!.product, + version: pkg.version, + repo: artifact.source.repository, + revision: artifact.source.revision, extractedRevision: artifact.source.revision, })), symbols, diff --git a/packages/api-model/src/languages/jvm-examples.ts b/packages/api-model/src/languages/jvm-examples.ts index eac0579f6..f4f203644 100644 --- a/packages/api-model/src/languages/jvm-examples.ts +++ b/packages/api-model/src/languages/jvm-examples.ts @@ -3,7 +3,10 @@ import { qualifiedNameOf } from '../modules.ts' type JvmPort = 'java' | 'kotlin' | 'scala' type Variant = 'java' | 'kotlin' | 'scala-direct' | 'scala-cats' -interface SourceFile { sourceFile: string; path: string } +interface SourceFile { + sourceFile: string + path: string +} interface Program extends SourceFile { id: string variant: Variant @@ -20,11 +23,16 @@ interface Manifest { } const languages: Record = { - java: 'java', kotlin: 'kotlin', 'scala-direct': 'scala', 'scala-cats': 'scala', + java: 'java', + kotlin: 'kotlin', + 'scala-direct': 'scala', + 'scala-cats': 'scala', } const setupLanguages: Record = { - 'settings.gradle.kts': 'kotlin', 'build.gradle.kts': 'kotlin', - 'gradle.properties': 'properties', 'run.sh': 'sh', + 'settings.gradle.kts': 'kotlin', + 'build.gradle.kts': 'kotlin', + 'gradle.properties': 'properties', + 'run.sh': 'sh', } /** Read every displayed file from the same committed source revision as its citation. */ @@ -58,47 +66,71 @@ export function attachCompleteJvmExamples( throw new Error(`Invalid JVM example variant: ${program.id}`) } if (program.language !== port) continue - if (typeof program.id !== 'string' || !program.id || ids.has(program.id) || - !Array.isArray(program.targets) || !program.targets.length || - !program.targets.every((target) => typeof target === 'string' && target.length && !/\s/.test(target)) || - typeof program.description !== 'string' || !program.description.trim() || - typeof program.expectedOutput !== 'string' || !program.expectedOutput.trim() || - !program.expectedOutput.endsWith('\n') || - !/^io\.github\.libtmux(?:\.[\w$]+)+$/.test(program.mainClass) || - !new RegExp(`^src/main/${port}/(?:[\\w]+/)*[\\w]+\\.${port === 'kotlin' ? 'kt' : port}$`).test(program.path)) { + if ( + typeof program.id !== 'string' || + !program.id || + ids.has(program.id) || + !Array.isArray(program.targets) || + !program.targets.length || + !program.targets.every((target) => typeof target === 'string' && target.length && !/\s/.test(target)) || + typeof program.description !== 'string' || + !program.description.trim() || + typeof program.expectedOutput !== 'string' || + !program.expectedOutput.trim() || + !program.expectedOutput.endsWith('\n') || + !/^io\.github\.libtmux(?:\.[\w$]+)+$/.test(program.mainClass) || + !new RegExp(`^src/main/${port}/(?:[\\w]+/)*[\\w]+\\.${port === 'kotlin' ? 'kt' : port}$`).test(program.path) + ) { throw new Error(`Invalid JVM example metadata: ${program.id}`) } ids.add(program.id) const files = manifest.variants[program.variant]?.files - if (!Array.isArray(files) || files.length !== 4 || new Set(files.map((file) => file.path)).size !== 4 || - !files.every((file) => Object.hasOwn(setupLanguages, file.path))) { + if ( + !Array.isArray(files) || + files.length !== 4 || + new Set(files.map((file) => file.path)).size !== 4 || + !files.every((file) => Object.hasOwn(setupLanguages, file.path)) + ) { throw new Error(`Invalid JVM example setup: ${program.variant}`) } - const blocks: NonNullable = [{ - lang: 'console', - intro: `${program.description.replace(/\n/g, ' ')} Use JDK 25, Git, and tmux 3.2a through 3.7c on Linux, with /bin/cat and /bin/sh available. In an empty directory, fetch the documented library revision:`, - code: `$ git clone https://github.com/${source.repo}.git libtmux-source && \\\n git -C libtmux-source checkout ${source.revision}\n`, - }] - for (const file of files) blocks.push({ - lang: setupLanguages[file.path], - intro: file.path === 'run.sh' - ? 'Save this launcher as run.sh. It creates and stops a private tmux server, including after program failure. A shutdown failure retains its socket directory and exits unsuccessfully.' - : `Save this consumer setup as ${file.path}:`, - sourceUrl: urlFor(file.sourceFile), code: codeFor(file.sourceFile), - }) - blocks.push({ - lang: port, - intro: `Save this complete program as ${program.path}. It opens an ordinary client for the launcher's server; closing the client releases its transport.`, - sourceUrl: urlFor(program.sourceFile), code: codeFor(program.sourceFile), - }, { - lang: 'console', - intro: 'Build the minimal consumer and run the saved program with the private fixture. An operation or cleanup error makes the command exit unsuccessfully:', - code: `$ ./libtmux-source/gradlew --project-dir . --console=plain --quiet \\\n installDist -PexampleMain=${program.mainClass} && \\\n sh run.sh build/install/api-example/bin/api-example\n`, - }, { lang: 'text', intro: 'Expected output:', code: program.expectedOutput }) + const blocks: NonNullable = [ + { + lang: 'console', + intro: `${program.description.replace(/\n/g, ' ')} Use JDK 25, Git, and tmux 3.2a through 3.7c on Linux, with /bin/cat and /bin/sh available. In an empty directory, fetch the documented library revision:`, + code: `$ git clone https://github.com/${source.repo}.git libtmux-source && \\\n git -C libtmux-source checkout ${source.revision}\n`, + }, + ] + for (const file of files) + blocks.push({ + lang: setupLanguages[file.path], + intro: + file.path === 'run.sh' + ? 'Save this launcher as run.sh. It creates and stops a private tmux server, including after program failure. A shutdown failure retains its socket directory and exits unsuccessfully.' + : `Save this consumer setup as ${file.path}:`, + sourceUrl: urlFor(file.sourceFile), + code: codeFor(file.sourceFile), + }) + blocks.push( + { + lang: port, + intro: `Save this complete program as ${program.path}. It opens an ordinary client for the launcher's server; closing the client releases its transport.`, + sourceUrl: urlFor(program.sourceFile), + code: codeFor(program.sourceFile), + }, + { + lang: 'console', + intro: + 'Build the minimal consumer and run the saved program with the private fixture. An operation or cleanup error makes the command exit unsuccessfully:', + code: `$ ./libtmux-source/gradlew --project-dir . --console=plain --quiet \\\n installDist -PexampleMain=${program.mainClass} && \\\n sh run.sh build/install/api-example/bin/api-example\n`, + }, + { lang: 'text', intro: 'Expected output:', code: program.expectedOutput }, + ) const targets = new Set() for (const target of program.targets) { if (targets.has(target)) throw new Error(`Duplicate JVM example target: ${target}`) - const matches = symbols.filter((symbol) => port === 'java' ? qualifiedNameOf(symbol) === target : symbol.id === target) + const matches = symbols.filter((symbol) => + port === 'java' ? qualifiedNameOf(symbol) === target : symbol.id === target, + ) if (matches.length !== 1) throw new Error(`JVM example target must resolve once: ${target}`) targets.add(target) const symbol = matches[0] diff --git a/packages/api-model/src/languages/jvm.ts b/packages/api-model/src/languages/jvm.ts index e51cf816e..bc8bd65ea 100644 --- a/packages/api-model/src/languages/jvm.ts +++ b/packages/api-model/src/languages/jvm.ts @@ -21,20 +21,31 @@ const visible = (node: Node) => { /** Public declarations only: method bodies and private containers never become API pages. */ function declarations(node: Node, language: Language): Node[] { - const types = language === 'kotlin' - ? /^(class_declaration|object_declaration|companion_object|function_declaration|property_declaration|type_alias|secondary_constructor|enum_entry)$/ - : /^(class_definition|trait_definition|object_definition|enum_definition|type_definition|function_definition|function_declaration|val_definition|var_definition|given_definition|extension_definition|export_declaration|simple_enum_case|full_enum_case)$/ - return childrenOf(node).flatMap((child) => child.type === 'enum_case_definitions' - ? declarations(child, language) : types.test(child.type) ? [child] : []) + const types = + language === 'kotlin' + ? /^(class_declaration|object_declaration|companion_object|function_declaration|property_declaration|type_alias|secondary_constructor|enum_entry)$/ + : /^(class_definition|trait_definition|object_definition|enum_definition|type_definition|function_definition|function_declaration|val_definition|var_definition|given_definition|extension_definition|export_declaration|simple_enum_case|full_enum_case)$/ + return childrenOf(node).flatMap((child) => + child.type === 'enum_case_definitions' ? declarations(child, language) : types.test(child.type) ? [child] : [], + ) } function documentation(node: Node, language: Language, signature: Signature, comments: Node[], source: string) { // A grammar can attach a trailing comment to the preceding expression's // body. Source adjacency, rather than sibling ownership, binds the comment. const before = comments.findLast((comment) => comment.endIndex <= node.startIndex) - if (!before || node.startPosition.row > before.endPosition.row + 1 || - source.slice(before.endIndex, node.startIndex).trim()) return undefined - const raw = before.text.slice(3, -2).split('\n').map((line) => line.replace(/^\s*\* ?/, '')).join('\n').trim() + if ( + !before || + node.startPosition.row > before.endPosition.row + 1 || + source.slice(before.endIndex, node.startIndex).trim() + ) + return undefined + const raw = before.text + .slice(3, -2) + .split('\n') + .map((line) => line.replace(/^\s*\* ?/, '')) + .join('\n') + .trim() // Keep native links intact; the inline tokenizer must distinguish them // from generic brackets inside code such as `Execution[F]`. const parsed = parseMarkdownDocFull(raw, language) @@ -45,35 +56,52 @@ function documentation(node: Node, language: Language, signature: Signature, com } function parameters(node: Node, language: Language, firstGroupOnly = false): Param[] { - const groups = node.type === 'primary_constructor' ? [node] : childrenOf(node).filter((child) => language === 'kotlin' - ? ['function_value_parameters', 'class_parameters'].includes(child.type) - : ['parameters', 'class_parameters'].includes(child.type)) - return (firstGroupOnly ? groups.slice(0, 1) : groups).flatMap((group) => childrenOf(group) - .filter((param) => /^(parameter|class_parameter)$/.test(param.type)) - .map((param): Param => { - const name = field(param, 'name') ?? named(param, 'simple_identifier', 'identifier') - const type = field(param, 'type') ?? param.children.find((child, index, all) => all[index - 1]?.type === ':') - const value = field(param, 'default_value') ?? field(param, 'value') ?? - param.children.find((child, index, all) => all[index - 1]?.type === '=') ?? - (param.nextSibling?.type === '=' ? param.nextSibling.nextSibling : undefined) - if (!name || !type) throw new Error(`Unparsed ${language} parameter: ${param.text}`) - return { - name: name.text, - type: compact(type.text), - ...(value ? { default: compact(value.text) } : {}), - ...(/\bvararg\b/.test(param.text) ? { variadic: 'positional' as const } : {}), - } - })) + const groups = + node.type === 'primary_constructor' + ? [node] + : childrenOf(node).filter((child) => + language === 'kotlin' + ? ['function_value_parameters', 'class_parameters'].includes(child.type) + : ['parameters', 'class_parameters'].includes(child.type), + ) + return (firstGroupOnly ? groups.slice(0, 1) : groups).flatMap((group) => + childrenOf(group) + .filter((param) => /^(parameter|class_parameter)$/.test(param.type)) + .map((param): Param => { + const name = field(param, 'name') ?? named(param, 'simple_identifier', 'identifier') + const type = field(param, 'type') ?? param.children.find((child, index, all) => all[index - 1]?.type === ':') + const value = + field(param, 'default_value') ?? + field(param, 'value') ?? + param.children.find((child, index, all) => all[index - 1]?.type === '=') ?? + (param.nextSibling?.type === '=' ? param.nextSibling.nextSibling : undefined) + if (!name || !type) throw new Error(`Unparsed ${language} parameter: ${param.text}`) + return { + name: name.text, + type: compact(type.text), + ...(value ? { default: compact(value.text) } : {}), + ...(/\bvararg\b/.test(param.text) ? { variadic: 'positional' as const } : {}), + } + }), + ) } /** Bound names only: nested arguments such as the `_` in `F[_]` are not binders. */ function typeParameters(node: Node, language: Language): string[] { const group = named(node, 'type_parameters') - return group ? childrenOf(group).flatMap((param) => { - const name = language === 'kotlin' ? named(param, 'type_identifier') - : param.type === 'identifier' ? param : /^(co|contra)variant_type_parameter$/.test(param.type) ? field(param, 'name') : undefined - return name ? [name.text] : [] - }) : [] + return group + ? childrenOf(group).flatMap((param) => { + const name = + language === 'kotlin' + ? named(param, 'type_identifier') + : param.type === 'identifier' + ? param + : /^(co|contra)variant_type_parameter$/.test(param.type) + ? field(param, 'name') + : undefined + return name ? [name.text] : [] + }) + : [] } /** Extract handwritten declarations and generated extensions in one symbol table. */ @@ -95,7 +123,10 @@ export async function extractJvm(port: Language, roots: string[], revision?: str const emit = (symbol: ApiSymbol) => { const previous = symbols.get(symbol.id) - if (!previous) { symbols.set(symbol.id, symbol); return } + if (!previous) { + symbols.set(symbol.id, symbol) + return + } const companion = previous.kind === 'module' || symbol.kind === 'module' if (companion) { // A Scala opaque type and its companion share the public name. Keep @@ -144,50 +175,97 @@ export async function extractJvm(port: Language, roots: string[], revision?: str // an internal implementation. A private subtree cannot contribute // declarations. Every error in public syntax still stops extraction. if (!visible(node)) return - if (node.isError || node.isMissing) throw new Error(`${file}:${node.startPosition.row + 1}: unparsed public ${port} syntax: ${node.text}`) - for (const child of childrenOf(node)) if (child.hasError || child.isError || child.isMissing) checkSyntax(child) + if (node.isError || node.isMissing) + throw new Error(`${file}:${node.startPosition.row + 1}: unparsed public ${port} syntax: ${node.text}`) + for (const child of childrenOf(node)) + if (child.hasError || child.isError || child.isMissing) checkSyntax(child) } checkSyntax(root) - const walk = (body: Node, parent?: string, receiver?: string, extensionHeader?: string, - extensionTypes: string[] = [], extensionReceiver?: Param): void => { + const walk = ( + body: Node, + parent?: string, + receiver?: string, + extensionHeader?: string, + extensionTypes: string[] = [], + extensionReceiver?: Param, + ): void => { for (const node of declarations(body, port)) { if (!visible(node)) continue if (node.type === 'export_declaration') { if (!parent || !named(node, 'namespace_wildcard')) { throw new Error(`Unsupported Scala export: ${node.text}`) } - const target = childrenOf(node).filter((child) => child.type === 'identifier').map((child) => child.text).join('.') + const target = childrenOf(node) + .filter((child) => child.type === 'identifier') + .map((child) => child.text) + .join('.') exports.push({ parent, target }) continue } if (node.type === 'extension_definition') { const params = parameters(node, port, true) - if (params.length !== 1 || !params[0].type) throw new Error(`Unsupported extension receiver: ${node.text}`) - const end = Math.max(...childrenOf(node).filter((child) => ['parameters', 'type_parameters'].includes(child.type)).map((child) => child.endIndex)) + if (params.length !== 1 || !params[0].type) + throw new Error(`Unsupported extension receiver: ${node.text}`) + const end = Math.max( + ...childrenOf(node) + .filter((child) => ['parameters', 'type_parameters'].includes(child.type)) + .map((child) => child.endIndex), + ) const header = node.text.slice(0, end - node.startIndex).trim() walk(node, parent, receiverId(params[0].type), header, typeParameters(node, port), params[0]) continue } const variable = named(node, 'variable_declaration') - const nameNode = field(node, 'name') ?? (variable && named(variable, 'simple_identifier')) ?? named(node, 'simple_identifier', 'type_identifier', 'identifier') - const name = nameNode?.text ?? (node.type === 'companion_object' ? 'Companion' - : node.type === 'given_definition' ? `given_${field(node, 'return_type')?.text.replace(/\W+/g, '_').replace(/_$/, '')}` : undefined) - if (!name) throw new Error(`Unnamed public ${port} declaration in ${file}:${node.startPosition.row + 1}: ${node.type}`) - const container = /^(class_declaration|object_declaration|companion_object|class_definition|trait_definition|object_definition|enum_definition)$/.test(node.type) - const kind: SymbolKind = /^(object_|companion_)/.test(node.type) ? 'module' - : /enum/.test(node.type) ? (container ? 'enum' : 'constant') - : /trait/.test(node.type) ? 'trait' - : container ? (node.children.some((child) => child?.type === 'interface') ? 'interface' : 'class') - : /type_(alias|definition)/.test(node.type) ? 'typealias' - : /function/.test(node.type) ? (parent || receiver ? 'method' : 'function') - : /given/.test(node.type) ? 'constant' : 'property' + const nameNode = + field(node, 'name') ?? + (variable && named(variable, 'simple_identifier')) ?? + named(node, 'simple_identifier', 'type_identifier', 'identifier') + const name = + nameNode?.text ?? + (node.type === 'companion_object' + ? 'Companion' + : node.type === 'given_definition' + ? `given_${field(node, 'return_type')?.text.replace(/\W+/g, '_').replace(/_$/, '')}` + : undefined) + if (!name) + throw new Error( + `Unnamed public ${port} declaration in ${file}:${node.startPosition.row + 1}: ${node.type}`, + ) + const container = + /^(class_declaration|object_declaration|companion_object|class_definition|trait_definition|object_definition|enum_definition)$/.test( + node.type, + ) + const kind: SymbolKind = /^(object_|companion_)/.test(node.type) + ? 'module' + : /enum/.test(node.type) + ? container + ? 'enum' + : 'constant' + : /trait/.test(node.type) + ? 'trait' + : container + ? node.children.some((child) => child?.type === 'interface') + ? 'interface' + : 'class' + : /type_(alias|definition)/.test(node.type) + ? 'typealias' + : /function/.test(node.type) + ? parent || receiver + ? 'method' + : 'function' + : /given/.test(node.type) + ? 'constant' + : 'property' let owner = parent let ownReceiver = receiver let receiverParam = extensionReceiver // Kotlin extensions put their receiver before the member name. if (port === 'kotlin' && !container && nameNode) { - const receiverNode = childrenOf(node).find((child) => /^(user_type|nullable_type|receiver_type)$/.test(child.type) && child.endIndex < nameNode.startIndex) + const receiverNode = childrenOf(node).find( + (child) => + /^(user_type|nullable_type|receiver_type)$/.test(child.type) && child.endIndex < nameNode.startIndex, + ) if (receiverNode) { ownReceiver = receiverId(receiverNode.text) receiverParam = { name: 'this', type: compact(receiverNode.text) } @@ -195,23 +273,37 @@ export async function extractJvm(port: Language, roots: string[], revision?: str } if (ownReceiver) owner = ownReceiver const id = `${owner ?? namespace}.${name}` - const block = field(node, 'body') ?? named(node, 'class_body', 'enum_class_body', 'function_body', 'template_body', 'enum_body', 'getter') + const block = + field(node, 'body') ?? + named(node, 'class_body', 'enum_class_body', 'function_body', 'template_body', 'enum_body', 'getter') const equal = node.children.find((child) => child?.type === '=') - const end = kind === 'typealias' ? node.endIndex : Math.min(block?.startIndex ?? node.endIndex, equal?.startIndex ?? node.endIndex) + const end = + kind === 'typealias' + ? node.endIndex + : Math.min(block?.startIndex ?? node.endIndex, equal?.startIndex ?? node.endIndex) let raw = source.slice(node.startIndex, end).trim() const constructor = named(node, 'primary_constructor') const scalaConstructorAccess = port === 'scala' && container ? named(node, 'access_modifier') : undefined - const privateConstructor = constructor && !visible(constructor) || - scalaConstructorAccess && /\b(private|protected)\b/.test(scalaConstructorAccess.text) + const privateConstructor = + (constructor && !visible(constructor)) || + (scalaConstructorAccess && /\b(private|protected)\b/.test(scalaConstructorAccess.text)) if (constructor && !visible(constructor)) { - raw = (source.slice(node.startIndex, constructor.startIndex) + source.slice(constructor.endIndex, end)).trim() + raw = ( + source.slice(node.startIndex, constructor.startIndex) + source.slice(constructor.endIndex, end) + ).trim() } if (scalaConstructorAccess && privateConstructor) { - const last = childrenOf(node).filter((child) => child.type === 'class_parameters').at(-1) - raw = (source.slice(node.startIndex, scalaConstructorAccess.startIndex) + - source.slice(last?.endIndex ?? scalaConstructorAccess.endIndex, end)).trim() + const last = childrenOf(node) + .filter((child) => child.type === 'class_parameters') + .at(-1) + raw = ( + source.slice(node.startIndex, scalaConstructorAccess.startIndex) + + source.slice(last?.endIndex ?? scalaConstructorAccess.endIndex, end) + ).trim() } - const returns = field(node, 'return_type') ?? (variable && variable.children.find((child, i, all) => all[i - 1]?.type === ':')) ?? + const returns = + field(node, 'return_type') ?? + (variable && variable.children.find((child, i, all) => all[i - 1]?.type === ':')) ?? node.children.find((child, i, all) => all[i - 1]?.type === ':') const signature: Signature = { raw: extensionHeader ? `${extensionHeader}\n${raw}` : raw, @@ -224,10 +316,15 @@ export async function extractJvm(port: Language, roots: string[], revision?: str // A private constructor is not a public callable signature. if (privateConstructor) signature.params = [] const symbol: ApiSymbol = { - id, name, kind: ownReceiver && kind === 'function' ? 'method' : kind, - parent: owner, signatures: [signature], imports: Object.fromEntries(imports), + id, + name, + kind: ownReceiver && kind === 'function' ? 'method' : kind, + parent: owner, + signatures: [signature], + imports: Object.fromEntries(imports), modifiers: /\bsuspend\b/.test(named(node, 'modifiers')?.text ?? '') ? ['async'] : [], - doc: documentation(node, port, signature, comments, source), source: { file, line: node.startPosition.row + 1 }, + doc: documentation(node, port, signature, comments, source), + source: { file, line: node.startPosition.row + 1 }, } if (ownReceiver) extensions.push(symbol) else emit(symbol) @@ -239,10 +336,16 @@ export async function extractJvm(port: Language, roots: string[], revision?: str const propName = field(prop, 'name')?.text const propType = field(prop, 'type')?.text if (!propName || !propType) throw new Error(`Unparsed public Scala property: ${prop.text}`) - emit({ id: `${id}.${propName}`, name: propName, kind: 'property', parent: id, - imports: Object.fromEntries(imports), modifiers: [], + emit({ + id: `${id}.${propName}`, + name: propName, + kind: 'property', + parent: id, + imports: Object.fromEntries(imports), + modifiers: [], signatures: [{ raw: prop.text, params: [], returns: propType }], - source: { file, line: prop.startPosition.row + 1 } }) + source: { file, line: prop.startPosition.row + 1 }, + }) } } } @@ -251,9 +354,15 @@ export async function extractJvm(port: Language, roots: string[], revision?: str if (!visible(prop) || !named(prop, 'binding_pattern_kind')) continue const propName = named(prop, 'simple_identifier')?.text if (!propName) throw new Error(`Unnamed constructor property: ${prop.text}`) - emit({ id: `${id}.${propName}`, name: propName, kind: 'property', parent: id, - modifiers: [], signatures: [{ raw: prop.text, params: [] }], - source: { file, line: prop.startPosition.row + 1 } }) + emit({ + id: `${id}.${propName}`, + name: propName, + kind: 'property', + parent: id, + modifiers: [], + signatures: [{ raw: prop.text, params: [] }], + source: { file, line: prop.startPosition.row + 1 }, + }) } } if (block) walk(block, id) @@ -261,14 +370,20 @@ export async function extractJvm(port: Language, roots: string[], revision?: str } } walk(root) - } finally { tree.delete() } + } finally { + tree.delete() + } } for (const entry of exports) { const members = [...symbols.values()].filter((symbol) => symbol.parent === entry.target) if (!members.length) throw new Error(`Scala export target has no public members: ${entry.target}`) for (const member of members) { - emit({ ...structuredClone(member), id: `${entry.parent}.${member.name}`, - parent: entry.parent, exportedFrom: member.id }) + emit({ + ...structuredClone(member), + id: `${entry.parent}.${member.name}`, + parent: entry.parent, + exportedFrom: member.id, + }) } } for (const extension of extensions) { @@ -282,5 +397,7 @@ export async function extractJvm(port: Language, roots: string[], revision?: str emit(extension) } return { port, revision, extractor: `@libtmux/api-model (${port} syntax)`, symbols: [...symbols.values()] } - } finally { parser.delete() } + } finally { + parser.delete() + } } diff --git a/packages/api-model/src/languages/lua-examples.ts b/packages/api-model/src/languages/lua-examples.ts index 894dfaea5..417ba48cf 100644 --- a/packages/api-model/src/languages/lua-examples.ts +++ b/packages/api-model/src/languages/lua-examples.ts @@ -17,10 +17,11 @@ export function attachCompleteLuaExamples( const require = (condition: unknown, message: string): void => { if (!condition) throw new Error(`Invalid complete Lua examples: ${message}`) } - require(source.repository === 'libtmux/libtmux-lua' && /^[a-f0-9]{40}$/.test(source.revision), - 'source must name the Lua repository and a full revision') - require(manifest?.schema === 1 && Array.isArray(manifest.examples) && manifest.examples.length, - 'unsupported or empty manifest') + require(source.repository === 'libtmux/libtmux-lua' && + /^[a-f0-9]{40}$/.test(source.revision), 'source must name the Lua repository and a full revision') + require(manifest?.schema === 1 && + Array.isArray(manifest.examples) && + manifest.examples.length, 'unsupported or empty manifest') require(/^5\.[1-5]\.\d+$/.test(manifest.setup?.lua ?? '') && /^\d+\.\d+\.\d+-\d+$/.test(manifest.setup?.luv ?? ''), 'runtime versions must be pinned') require(manifest.setup.launcher === 'examples/api/run.sh', 'unexpected launcher path') @@ -28,8 +29,10 @@ export function attachCompleteLuaExamples( const matches = files?.filter((file) => file.path === path) ?? [] require(matches.length === 1, `source file must resolve once: ${path}`) const code = matches[0].content - require(typeof code === 'string' && code.trim() && code.endsWith('\n') && !code.includes('\r'), - `source file must retain complete LF bytes: ${path}`) + require(typeof code === 'string' && + code.trim() && + code.endsWith('\n') && + !code.includes('\r'), `source file must retain complete LF bytes: ${path}`) return code } const launcher = read(manifest.setup.launcher) @@ -41,8 +44,9 @@ export function attachCompleteLuaExamples( ids.add(example.id) require(example.file === `examples/api/${example.id}.lua`, 'unexpected program path') require(typeof example.description === 'string' && example.description.trim(), 'task description is missing') - require(typeof example.stdout === 'string' && example.stdout.trim() && example.stdout.endsWith('\n'), - 'expected output is missing') + require(typeof example.stdout === 'string' && + example.stdout.trim() && + example.stdout.endsWith('\n'), 'expected output is missing') require(Array.isArray(example.symbols) && example.symbols.length, 'API targets are missing') const code = read(example.file) const name = `${example.id}.lua` @@ -50,7 +54,8 @@ export function attachCompleteLuaExamples( { lang: 'console', intro: `${example.description} Use Lua ${manifest.setup.lua}, LuaRocks, Git, tmux 3.2a or newer, a C compiler, and CMake on Linux. In a new directory, install the documented source and standalone runtime dependency:`, - code: `$ git clone https://github.com/${source.repository}.git libtmux-source &&\n` + + code: + `$ git clone https://github.com/${source.repository}.git libtmux-source &&\n` + ` git -C libtmux-source checkout ${source.revision} &&\n` + ` luarocks --tree ./rocks install luv ${manifest.setup.luv} &&\n` + ' (cd libtmux-source &&\n' + @@ -59,7 +64,8 @@ export function attachCompleteLuaExamples( }, { lang: 'sh', - intro: 'Save this complete launcher as run.sh. It starts a private tmux daemon and cleans up after success or failure. A failed daemon shutdown reports and retains its socket directory.', + intro: + 'Save this complete launcher as run.sh. It starts a private tmux daemon and cleans up after success or failure. A failed daemon shutdown reports and retains its socket directory.', sourceUrl: url(manifest.setup.launcher), code: launcher, }, diff --git a/packages/api-model/src/languages/lua.ts b/packages/api-model/src/languages/lua.ts index de9129d5d..047e4147a 100644 --- a/packages/api-model/src/languages/lua.ts +++ b/packages/api-model/src/languages/lua.ts @@ -67,8 +67,7 @@ function source(node: LuaNode, artifact: LuaArtifact) { } } -const argName = (argument?: LuaArg) => - typeof argument?.name === 'string' ? argument.name : argument?.name?.view +const argName = (argument?: LuaArg) => (typeof argument?.name === 'string' ? argument.name : argument?.name?.view) /** LuaLS prints an optional class as `(libtmux.PaneOptions)?`; the reference writes `libtmux.PaneOptions?`. */ const typeView = (view?: string) => view?.replace(/\(([^()]*)\)\?/g, '$1?') @@ -103,7 +102,10 @@ function signature(fn: LuaNode, receiver: boolean, raw: string | undefined): Sig ...(argument.rawdesc || argument.desc ? { doc: argument.rawdesc ?? argument.desc } : {}), } }) - const returns = fn.returns?.map((entry) => typeView(entry.view)).filter(Boolean).join(', ') + const returns = fn.returns + ?.map((entry) => typeView(entry.view)) + .filter(Boolean) + .join(', ') const written = `fun(${args.map((a) => `${argName(a)}: ${a.view}`).join(', ')})${returns ? `:${returns}` : ''}` return { raw: typeView(raw ?? fn.view ?? written), @@ -201,14 +203,16 @@ export function extractLua(input: unknown, expectedRevision?: string): ApiModel repo: artifact.source.repository, revision: artifact.source.revision, extractor: `@libtmux/api-model@0.0.1 (${artifact.exporter.name} v${artifact.exporter.version}; LuaLS ${artifact.exporter.luals})`, - sources: [{ - product: 'core', - package: artifact.package.name, - version: artifact.package.version, - repo: artifact.source.repository, - revision: artifact.source.revision, - extractedRevision: artifact.source.revision, - }], + sources: [ + { + product: 'core', + package: artifact.package.name, + version: artifact.package.version, + repo: artifact.source.repository, + revision: artifact.source.revision, + extractedRevision: artifact.source.revision, + }, + ], symbols, } } diff --git a/packages/api-model/src/languages/powershell.ts b/packages/api-model/src/languages/powershell.ts index c393edd6b..bf2af031f 100644 --- a/packages/api-model/src/languages/powershell.ts +++ b/packages/api-model/src/languages/powershell.ts @@ -1,7 +1,11 @@ import { posix } from 'node:path' import type { ApiModel, ApiProduct, ApiSymbol, Param, Signature } from '../model.ts' -interface Source { file: string; line: number; endLine?: number } +interface Source { + file: string + line: number + endLine?: number +} interface NativeParameter { name: string type: string @@ -17,7 +21,11 @@ interface NativeParameter { declaration: { source: Source; declaration: string; default: string | null } | null attributes: NonNullable['attributes'] } -interface ParameterSet { name: string; isDefault: boolean; parameters: NativeParameter[] } +interface ParameterSet { + name: string + isDefault: boolean + parameters: NativeParameter[] +} interface NativeCommand extends Omit, 'defaultParameterSet'> { defaultParameterSet: string | null id: string @@ -52,12 +60,17 @@ interface PowerShellArtifact { function assertArtifact(input: unknown, revision?: string): PowerShellArtifact { const artifact = input as PowerShellArtifact - if (artifact?.schema !== 1 || artifact.port !== 'powershell' || - !Array.isArray(artifact.modules) || !Array.isArray(artifact.commands) || - !/^[a-f0-9]{40}$/.test(artifact.source?.revision ?? '')) { + if ( + artifact?.schema !== 1 || + artifact.port !== 'powershell' || + !Array.isArray(artifact.modules) || + !Array.isArray(artifact.commands) || + !/^[a-f0-9]{40}$/.test(artifact.source?.revision ?? '') + ) { throw new Error('PowerShell documentation artifact has an unsupported schema or source identity') } - if (revision && artifact.source.revision !== revision) throw new Error('PowerShell artifact source revision differs from the selected checkout') + if (revision && artifact.source.revision !== revision) + throw new Error('PowerShell artifact source revision differs from the selected checkout') const ids = new Set() const modules = new Map(artifact.modules.map((entry) => [entry.name, entry])) for (const command of artifact.commands) { @@ -65,7 +78,10 @@ function assertArtifact(input: unknown, revision?: string): PowerShellArtifact { throw new Error(`PowerShell command has a duplicate or unknown identity: ${command.id}`) } ids.add(command.id) - if (!command.parameterSets.length || new Set(command.parameterSets.map((set) => set.name)).size !== command.parameterSets.length) { + if ( + !command.parameterSets.length || + new Set(command.parameterSets.map((set) => set.name)).size !== command.parameterSets.length + ) { throw new Error(`PowerShell command lost distinct parameter sets: ${command.id}`) } for (const set of command.parameterSets) { @@ -73,17 +89,28 @@ function assertArtifact(input: unknown, revision?: string): PowerShellArtifact { throw new Error(`PowerShell parameter set repeats a parameter: ${command.id}/${set.name}`) } for (const parameter of set.parameters) { - if (!parameter.type || typeof parameter.pipeline?.byValue !== 'boolean' || typeof parameter.pipeline?.byPropertyName !== 'boolean') { - throw new Error(`PowerShell parameter lost native binding metadata: ${command.id}/${set.name}/${parameter.name}`) + if ( + !parameter.type || + typeof parameter.pipeline?.byValue !== 'boolean' || + typeof parameter.pipeline?.byPropertyName !== 'boolean' + ) { + throw new Error( + `PowerShell parameter lost native binding metadata: ${command.id}/${set.name}/${parameter.name}`, + ) } } } - if (command.examples.some((example) => !example.code.trim())) throw new Error(`PowerShell command lost example code: ${command.id}`) + if (command.examples.some((example) => !example.code.trim())) + throw new Error(`PowerShell command lost example code: ${command.id}`) } for (const entry of artifact.modules) { const declared = [...entry.commands].sort() - const actual = artifact.commands.filter((command) => command.module === entry.name).map((command) => command.name).sort() - if (JSON.stringify(declared) !== JSON.stringify(actual)) throw new Error(`PowerShell module inventory differs: ${entry.name}`) + const actual = artifact.commands + .filter((command) => command.module === entry.name) + .map((command) => command.name) + .sort() + if (JSON.stringify(declared) !== JSON.stringify(actual)) + throw new Error(`PowerShell module inventory differs: ${entry.name}`) } return artifact } @@ -95,7 +122,10 @@ function splitTypes(value: string): string[] { for (let index = 0; index < value.length; index++) { if (value[index] === '[') depth++ else if (value[index] === ']') depth-- - else if (value[index] === ',' && depth === 0) { parts.push(value.slice(start, index)); start = index + 1 } + else if (value[index] === ',' && depth === 0) { + parts.push(value.slice(start, index)) + start = index + 1 + } } parts.push(value.slice(start)) return parts @@ -135,29 +165,43 @@ function parameter(entry: NativeParameter, source: (location: Source) => ApiSymb return { name: entry.name, type: powerShellType(entry.type), - doc: entry.description.trim() ? entry.description : entry.common ? commonParameterDescriptions[entry.name] : undefined, + doc: entry.description.trim() + ? entry.description + : entry.common + ? commonParameterDescriptions[entry.name] + : undefined, default: entry.documentedDefault && entry.documentedDefault !== 'None' ? entry.documentedDefault : undefined, keywordOnly: entry.position === null, binding: { - mandatory: entry.mandatory, position: entry.position ?? undefined, - switch: entry.switch, common: entry.common, dynamic: entry.dynamic, - aliases: entry.aliases, pipeline: entry.pipeline, - documentedDefault: entry.documentedDefault ?? undefined, nativeType: entry.type, + mandatory: entry.mandatory, + position: entry.position ?? undefined, + switch: entry.switch, + common: entry.common, + dynamic: entry.dynamic, + aliases: entry.aliases, + pipeline: entry.pipeline, + documentedDefault: entry.documentedDefault ?? undefined, + nativeType: entry.type, attributes: entry.attributes, - declaration: entry.declaration ? { - text: entry.declaration.declaration, initializer: entry.declaration.default ?? undefined, - source: source(entry.declaration.source), - } : undefined, + declaration: entry.declaration + ? { + text: entry.declaration.declaration, + initializer: entry.declaration.default ?? undefined, + source: source(entry.declaration.source), + } + : undefined, }, } } function syntax(command: NativeCommand, set: ParameterSet): string { - const parameters = set.parameters.filter((entry) => !entry.common).map((entry) => { - const name = entry.position === null ? `-${entry.name}` : `[-${entry.name}]` - const value = entry.switch ? name : `${name} <${powerShellType(entry.type)}>` - return entry.mandatory ? value : `[${value}]` - }) + const parameters = set.parameters + .filter((entry) => !entry.common) + .map((entry) => { + const name = entry.position === null ? `-${entry.name}` : `[-${entry.name}]` + const value = entry.switch ? name : `${name} <${powerShellType(entry.type)}>` + return entry.mandatory ? value : `[${value}]` + }) if (set.parameters.some((entry) => entry.common)) parameters.push('[]') return `${command.name} ${parameters.join(' ')}` } @@ -166,56 +210,100 @@ function syntax(command: NativeCommand, set: ParameterSet): string { export function extractPowerShell(input: unknown, expectedRevision?: string): ApiModel { const artifact = assertArtifact(input, expectedRevision) const source = (location: Source): ApiSymbol['source'] => ({ - file: location.file, line: location.line, repo: artifact.source.repository, - revision: artifact.source.revision, extractedRevision: artifact.source.revision, + file: location.file, + line: location.line, + repo: artifact.source.repository, + revision: artifact.source.revision, + extractedRevision: artifact.source.revision, }) const symbols: ApiSymbol[] = artifact.modules.map((entry) => ({ - id: entry.name, publicId: entry.name, name: entry.name, kind: 'module', modifiers: [], - signatures: [], product: entry.product, package: entry.name, apiScope: 'exported', - source: source(entry.source), doc: { summary: entry.description }, + id: entry.name, + publicId: entry.name, + name: entry.name, + kind: 'module', + modifiers: [], + signatures: [], + product: entry.product, + package: entry.name, + apiScope: 'exported', + source: source(entry.source), + doc: { summary: entry.description }, })) for (const entry of artifact.commands) { - const href = (target: string) => /^(?:https?:|#)/.test(target) ? target - : `https://github.com/${artifact.source.repository}/blob/${artifact.source.revision}/${posix.normalize(posix.join(posix.dirname(entry.help.file), target))}` - const prose = (text: string) => text.replace(/\[([^\]\n]+)\]\(([^\s)]+)\)/g, - (_match, label: string, target: string) => `[${label}](${href(target)})`) + const href = (target: string) => + /^(?:https?:|#)/.test(target) + ? target + : `https://github.com/${artifact.source.repository}/blob/${artifact.source.revision}/${posix.normalize(posix.join(posix.dirname(entry.help.file), target))}` + const prose = (text: string) => + text.replace( + /\[([^\]\n]+)\]\(([^\s)]+)\)/g, + (_match, label: string, target: string) => `[${label}](${href(target)})`, + ) const signatures: Signature[] = entry.parameterSets.map((set) => ({ - identity: `${entry.id}/${set.name}`, label: set.name, isDefault: set.isDefault, - raw: syntax(entry, set), params: set.parameters.map((item) => parameter({ ...item, description: prose(item.description) }, source)), + identity: `${entry.id}/${set.name}`, + label: set.name, + isDefault: set.isDefault, + raw: syntax(entry, set), + params: set.parameters.map((item) => parameter({ ...item, description: prose(item.description) }, source)), returns: entry.outputTypes.map(powerShellType).join(' | ') || undefined, - returnsDoc: entry.documentedOutputs.map((item) => `\`${powerShellType(item.type)}\`${item.description ? `: ${item.description}` : ''}`).join('\n\n') || undefined, + returnsDoc: + entry.documentedOutputs + .map((item) => `\`${powerShellType(item.type)}\`${item.description ? `: ${item.description}` : ''}`) + .join('\n\n') || undefined, source: source(entry.source), })) symbols.push({ - id: entry.id, publicId: entry.id, name: entry.name, kind: 'function', modifiers: [], - parent: entry.module, namespace: entry.module, signatures, - product: entry.product, package: entry.module, apiScope: 'exported', source: source(entry.source), + id: entry.id, + publicId: entry.id, + name: entry.name, + kind: 'function', + modifiers: [], + parent: entry.module, + namespace: entry.module, + signatures, + product: entry.product, + package: entry.module, + apiScope: 'exported', + source: source(entry.source), doc: { summary: prose(entry.synopsis), body: prose([entry.description, entry.notes ? `### Notes\n\n${entry.notes}` : ''].filter(Boolean).join('\n\n')), examples: entry.examples.map((example) => ({ - lang: 'powershell', code: example.code, + lang: 'powershell', + code: example.code, intro: prose([example.title, example.introduction].filter(Boolean).join('\n\n')), outro: prose(example.remarks) || undefined, sourceUrl: `https://github.com/${artifact.source.repository}/blob/${artifact.source.revision}/${entry.help.file}`, })), }, command: { - kind: entry.kind, aliases: entry.aliases, defaultParameterSet: entry.defaultParameterSet ?? undefined, - supportsShouldProcess: entry.supportsShouldProcess, supportsPaging: entry.supportsPaging, - supportsTransactions: entry.supportsTransactions, confirmImpact: entry.confirmImpact, - positionalBinding: entry.positionalBinding, inputTypes: entry.inputTypes.map((item) => ({ ...item, type: powerShellType(item.type) })), - outputTypes: entry.outputTypes.map(powerShellType), relatedLinks: entry.relatedLinks.map((link) => ({ ...link, href: link.href ? href(link.href) : '' })), + kind: entry.kind, + aliases: entry.aliases, + defaultParameterSet: entry.defaultParameterSet ?? undefined, + supportsShouldProcess: entry.supportsShouldProcess, + supportsPaging: entry.supportsPaging, + supportsTransactions: entry.supportsTransactions, + confirmImpact: entry.confirmImpact, + positionalBinding: entry.positionalBinding, + inputTypes: entry.inputTypes.map((item) => ({ ...item, type: powerShellType(item.type) })), + outputTypes: entry.outputTypes.map(powerShellType), + relatedLinks: entry.relatedLinks.map((link) => ({ ...link, href: link.href ? href(link.href) : '' })), }, }) } return { - port: 'powershell', repo: artifact.source.repository, revision: artifact.source.revision, + port: 'powershell', + repo: artifact.source.repository, + revision: artifact.source.revision, extractor: `@libtmux/api-model@0.0.1 (${artifact.exporter.name} v${artifact.exporter.version})`, nativeProvenance: { files: artifact.source.files, exporter: artifact.exporter }, sources: artifact.modules.map((entry) => ({ - product: entry.product, package: entry.name, version: entry.version, - repo: artifact.source.repository, revision: artifact.source.revision, extractedRevision: artifact.source.revision, + product: entry.product, + package: entry.name, + version: entry.version, + repo: artifact.source.repository, + revision: artifact.source.revision, + extractedRevision: artifact.source.revision, })), symbols, } diff --git a/packages/api-model/src/languages/python.ts b/packages/api-model/src/languages/python.ts index 7d7a6bcc2..02077e65b 100644 --- a/packages/api-model/src/languages/python.ts +++ b/packages/api-model/src/languages/python.ts @@ -73,9 +73,7 @@ function paramsOf(node: Node): Param[] { continue } const variadic = - p.type === 'list_splat_pattern' ? 'positional' - : p.type === 'dictionary_splat_pattern' ? 'keyword' - : undefined + p.type === 'list_splat_pattern' ? 'positional' : p.type === 'dictionary_splat_pattern' ? 'keyword' : undefined if (variadic === 'positional') keywordOnly = true const name = p.childForFieldName('name') ?? p.namedChild(0) ?? p @@ -183,9 +181,7 @@ function walk(node: Node, ctx: Ctx, parent: string | undefined): void { const decorators = decorated ? child.namedChildren.filter((n) => n?.type === 'decorator').map((n) => decoratorName(n!)) : [] - const modifiers = decorators - .map((d) => DECORATOR_MODIFIER[d]) - .filter((m): m is Modifier => Boolean(m)) + const modifiers = decorators.map((d) => DECORATOR_MODIFIER[d]).filter((m): m is Modifier => Boolean(m)) if (def.type === 'class_definition') { const name = def.childForFieldName('name')?.text ?? '?' @@ -220,8 +216,7 @@ function walk(node: Node, ctx: Ctx, parent: string | undefined): void { const raw = docstringOf(def.childForFieldName('body')) const parsed = raw ? parsePythonDoc(raw) : undefined const sig = parsed ? applyDoc(signatureOf(def), parsed) : signatureOf(def) - const kind: SymbolKind = - decorators.includes('property') ? 'property' : parent ? 'method' : 'function' + const kind: SymbolKind = decorators.includes('property') ? 'property' : parent ? 'method' : 'function' const isAsync = def.children.some((c) => c?.type === 'async' || c?.text === 'async') emit(ctx, { id, @@ -283,11 +278,7 @@ function walk(node: Node, ctx: Ctx, parent: string | undefined): void { } /** Extract one Python file. `module` is its dotted name, e.g. `libtmux.pane`. */ -export async function extractPython( - file: string, - module: string, - options: ExtractOptions = {}, -): Promise { +export async function extractPython(file: string, module: string, options: ExtractOptions = {}): Promise { const parser = await parserFor('python') const tree = parser.parse(readFileSync(file, 'utf8')) if (!tree) return [] diff --git a/packages/api-model/src/languages/ruby-examples.ts b/packages/api-model/src/languages/ruby-examples.ts index bdc31e64b..c7e1f61cc 100644 --- a/packages/api-model/src/languages/ruby-examples.ts +++ b/packages/api-model/src/languages/ruby-examples.ts @@ -36,12 +36,19 @@ export function attachCompleteRubyExamples( const targets = new Set() for (const program of selected) { const api = program.api - if (!/^examples\/(?:[\w-]+\/)*[\w-]+\.rb$/.test(program.path) || - !/^libtmux(?:-[a-z]+)*$/.test(program.gem) || - !api || !Array.isArray(api.symbols) || !api.symbols.length || - !api.symbols.every((id) => typeof id === 'string' && id.length && !/\s/.test(id)) || - typeof api.description !== 'string' || !api.description.trim() || - typeof api.output !== 'string' || !api.output.trim() || !api.output.endsWith('\n')) { + if ( + !/^examples\/(?:[\w-]+\/)*[\w-]+\.rb$/.test(program.path) || + !/^libtmux(?:-[a-z]+)*$/.test(program.gem) || + !api || + !Array.isArray(api.symbols) || + !api.symbols.length || + !api.symbols.every((id) => typeof id === 'string' && id.length && !/\s/.test(id)) || + typeof api.description !== 'string' || + !api.description.trim() || + typeof api.output !== 'string' || + !api.output.trim() || + !api.output.endsWith('\n') + ) { throw new Error(`Invalid Ruby API example metadata: ${program.path}`) } const code = files.get(program.path) @@ -66,7 +73,8 @@ export function attachCompleteRubyExamples( }, { lang: 'console', - intro: 'Install the runtime dependencies and run the saved program. An operation or cleanup error makes it exit unsuccessfully:', + intro: + 'Install the runtime dependencies and run the saved program. An operation or cleanup error makes it exit unsuccessfully:', code: `$ bundle config set --local path vendor/bundle && \\\n bundle install --jobs 2 && \\\n bundle exec ruby ${filename}\n`, }, { lang: 'text', intro: 'Expected output:', code: api.output }, diff --git a/packages/api-model/src/languages/ruby.ts b/packages/api-model/src/languages/ruby.ts index 66507d724..39820327a 100644 --- a/packages/api-model/src/languages/ruby.ts +++ b/packages/api-model/src/languages/ruby.ts @@ -84,7 +84,9 @@ function docBlock(text = '', contract?: { id: string; title: string }): DocBlock const [summary = '', ...body] = trimmed.split(/\n\s*\n/) return { summary: summary || `Behavior: ${contract?.title}.`, - body: [body.join('\n\n'), contract ? `Behavior contract: ${contract.title}.` : ''].filter(Boolean).join('\n\n') || undefined, + body: + [body.join('\n\n'), contract ? `Behavior contract: ${contract.title}.` : ''].filter(Boolean).join('\n\n') || + undefined, } } @@ -131,24 +133,35 @@ function rbsSignature(raw: string, tags: RubyTag[] = []): Signature { const close = open < 0 ? -1 : closingParen(raw, open) const arrow = close < 0 ? -1 : topLevelArrow(raw, close + 1) const docs = new Map(tags.filter((tag) => tag.tag === 'param' && tag.name).map((tag) => [tag.name!, tag.text])) - const params: Param[] = open < 0 || close < 0 ? [] : splitTopLevel(raw.slice(open + 1, close)).map((part, index) => { - const keyword = /^(\??)([A-Za-z_]\w*[!?=]?):\s*(.+)$/.exec(part) - if (keyword) { - return { - name: keyword[2], type: keyword[3], - ...(keyword[1] ? { default: 'nil' } : {}), - ...(docs.get(keyword[2]) ? { doc: docs.get(keyword[2]) } : {}), - } - } - if (part.startsWith('**')) return { name: 'kwargs', type: part.slice(2), variadic: 'keyword' as const } - if (part.startsWith('*')) return { name: 'args', type: part.slice(1), variadic: 'positional' as const } - const optional = part.startsWith('?') - return { name: `arg${index + 1}`, type: optional ? part.slice(1) : part, ...(optional ? { default: 'nil' } : {}) } - }) + const params: Param[] = + open < 0 || close < 0 + ? [] + : splitTopLevel(raw.slice(open + 1, close)).map((part, index) => { + const keyword = /^(\??)([A-Za-z_]\w*[!?=]?):\s*(.+)$/.exec(part) + if (keyword) { + return { + name: keyword[2], + type: keyword[3], + ...(keyword[1] ? { default: 'nil' } : {}), + ...(docs.get(keyword[2]) ? { doc: docs.get(keyword[2]) } : {}), + } + } + if (part.startsWith('**')) return { name: 'kwargs', type: part.slice(2), variadic: 'keyword' as const } + if (part.startsWith('*')) return { name: 'args', type: part.slice(1), variadic: 'positional' as const } + const optional = part.startsWith('?') + return { + name: `arg${index + 1}`, + type: optional ? part.slice(1) : part, + ...(optional ? { default: 'nil' } : {}), + } + }) const typeParams = raw.startsWith('[') ? splitTopLevel(raw.slice(1, raw.indexOf(']'))) : undefined - const raises = tags.filter((tag) => tag.tag === 'raise').map((tag) => ({ - type: tag.types?.join(' | ') || tag.name || 'StandardError', doc: tag.text, - })) + const raises = tags + .filter((tag) => tag.tag === 'raise') + .map((tag) => ({ + type: tag.types?.join(' | ') || tag.name || 'StandardError', + doc: tag.text, + })) const returnsDoc = tags.find((tag) => tag.tag === 'return')?.text?.trim() || undefined return { raw, diff --git a/packages/api-model/src/languages/rust-examples.ts b/packages/api-model/src/languages/rust-examples.ts index 5dad0aaf0..46610986f 100644 --- a/packages/api-model/src/languages/rust-examples.ts +++ b/packages/api-model/src/languages/rust-examples.ts @@ -37,12 +37,15 @@ export function attachCompleteRustExamples( if (!condition) throw new Error(`Invalid complete Rust examples: ${message}`) } const manifest = JSON.parse(raw) as Manifest - require(source.repo === 'libtmux/libtmux-rs' && /^[a-f0-9]{40}$/.test(source.revision), - 'source must name the Rust repository and a full revision') - require(manifest?.schemaVersion === 1 && manifest.language === 'rust' && - Array.isArray(manifest.examples) && manifest.examples.length, 'unsupported or empty manifest') + require(source.repo === 'libtmux/libtmux-rs' && + /^[a-f0-9]{40}$/.test(source.revision), 'source must name the Rust repository and a full revision') + require(manifest?.schemaVersion === 1 && + manifest.language === 'rust' && + Array.isArray(manifest.examples) && + manifest.examples.length, 'unsupported or empty manifest') require(manifest.nativeInventory === 'crates/libtmux/docs/public-api.txt' && - manifest.programFileName === 'src/main.rs' && manifest.runCommand === 'cargo run --quiet' && + manifest.programFileName === 'src/main.rs' && + manifest.runCommand === 'cargo run --quiet' && JSON.stringify(manifest.setupFiles) === JSON.stringify(setup), 'unexpected consumer setup') const codeFor = (file: string): string => { const code = read(file) @@ -63,34 +66,41 @@ export function attachCompleteRustExamples( value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) const tokio = table(dependencies) && table(dependencies.tokio) ? dependencies.tokio : undefined const features = tokio?.features - require(table(packageInfo) && packageInfo.name === 'libtmux-api-example' && - packageInfo.edition === '2024' && packageInfo.publish === false && table(workspace) && + require(table(packageInfo) && + packageInfo.name === 'libtmux-api-example' && + packageInfo.edition === '2024' && + packageInfo.publish === false && + table(workspace) && JSON.stringify(workspace.exclude) === JSON.stringify(['libtmux-source']) && - table(dependencies) && table(dependencies.libtmux) && + table(dependencies) && + table(dependencies.libtmux) && dependencies.libtmux.path === 'libtmux-source/crates/libtmux' && - typeof dependencies.tempfile === 'string' && dependencies.tempfile.trim() && - tokio && typeof tokio.version === 'string' && Array.isArray(features) && - ['macros', 'rt', 'time'].every((feature) => features.includes(feature)), - 'consumer dependencies or workspace boundary are missing') + typeof dependencies.tempfile === 'string' && + dependencies.tempfile.trim() && + tokio && + typeof tokio.version === 'string' && + Array.isArray(features) && + ['macros', 'rt', 'time'].every((feature) => + features.includes(feature), + ), 'consumer dependencies or workspace boundary are missing') const channel = table(toolchain) && typeof toolchain.channel === 'string' ? toolchain.channel : '' require(/^\d+\.\d+\.\d+$/.test(channel), 'toolchain must pin a Rust release') const native = new Map() - for (const match of codeFor(manifest.nativeInventory).matchAll( - /^(\w+) (libtmux::[\w:]+)(?=: |$)/gm)) { + for (const match of codeFor(manifest.nativeInventory).matchAll(/^(\w+) (libtmux::[\w:]+)(?=: |$)/gm)) { native.set(match[2], [...(native.get(match[2]) ?? []), match[1]]) } const isCore = (symbol: ApiSymbol) => - /(?:^|\/)crates\/libtmux\/src\//.test(symbol.source.file) && - (!symbol.product || symbol.product === 'core') + /(?:^|\/)crates\/libtmux\/src\//.test(symbol.source.file) && (!symbol.product || symbol.product === 'core') const resolve = (target: string): ApiSymbol => { const kinds = native.get(target) ?? [] require(kinds.length === 1, `native target must resolve once: ${target}`) const kind = kinds[0] === 'function' ? 'method' : kinds[0] require(['struct', 'enum', 'trait', 'method'].includes(kind), `unsupported native kind: ${target}`) const suffix = target.slice('libtmux::'.length).replaceAll('::', '.') - const matches = symbols.filter((symbol) => isCore(symbol) && symbol.kind === kind && - (symbol.id === suffix || symbol.id.endsWith(`.${suffix}`))) + const matches = symbols.filter( + (symbol) => isCore(symbol) && symbol.kind === kind && (symbol.id === suffix || symbol.id.endsWith(`.${suffix}`)), + ) require(matches.length === 1, `API target must resolve once with its native kind: ${target}`) const symbol = matches[0] if (kind === 'method') { @@ -103,41 +113,54 @@ export function attachCompleteRustExamples( const targets = new Set() const pending: { symbol: ApiSymbol; blocks: NonNullable }[] = [] for (const program of manifest.examples) { - require(typeof program.id === 'string' && /^rust-[a-z]+$/.test(program.id) && + require(typeof program.id === 'string' && + /^rust-[a-z]+$/.test(program.id) && !ids.has(program.id), 'invalid or duplicate program ID') ids.add(program.id) const name = program.id.slice('rust-'.length) require(program.cargoExample === `api_${name}` && program.sourceFile === `crates/libtmux/examples/api_${name}.rs`, 'unexpected program path') require([program.title, program.description, program.expectedOutput].every( - (value) => typeof value === 'string' && value.trim() && !/[\r\n]/.test(value)), - 'task description or expected output is missing') + (value) => typeof value === 'string' && value.trim() && !/[\r\n]/.test(value), + ), 'task description or expected output is missing') require(Array.isArray(program.targets) && program.targets.length, 'API targets are missing') const code = codeFor(program.sourceFile) - require(code.includes('#[tokio::main') && code.includes('async fn main()'), - 'program entry point is missing') - require(!/\binclude(?:_str|_bytes)?!|^\s*mod\s+\w+\s*;|use\s+(?:crate|super)::/m.test(code), - 'program depends on a hidden source helper') - const blocks: NonNullable = [{ - lang: 'console', - intro: `${program.title}. ${program.description} Use a Unix environment with Git, rustup, and tmux 3.2a or newer. The toolchain file below pins Rust ${channel}. In an empty directory, fetch the documented source:`, - code: `$ git clone https://github.com/${source.repo}.git libtmux-source &&\n git -C libtmux-source checkout --detach ${source.revision}\n`, - }, ...setup.map((file, index) => ({ - lang: 'toml', intro: `Save this complete consumer setup as ${file.name}:`, - sourceUrl: urlFor(file.sourceFile), code: setupCode[index], - })), { - lang: 'rust', - intro: 'Save this complete program as src/main.rs. It owns a private tmux socket, applies a ten-second task deadline, and reports both task and cleanup errors. Server::kill stops its daemon; Server::shutdown closes its client executor. Successful cleanup removes the temporary directory even when the task fails.', - sourceUrl: urlFor(program.sourceFile), code, - }, { - lang: 'console', - intro: 'Copy the complete files from the pinned checkout and run the consumer. This also creates src for the displayed program. Success exits with status 0; setup, operation, deadline or cleanup failures exit unsuccessfully and print diagnostics to stderr:', - code: '$ mkdir -p src &&\n' + - ' cp libtmux-source/crates/libtmux/examples/api/consumer.toml Cargo.toml &&\n' + - ' cp libtmux-source/rust-toolchain.toml rust-toolchain.toml &&\n' + - ` cp libtmux-source/${program.sourceFile} src/main.rs &&\n` + - ` ${manifest.runCommand}\n`, - }, { lang: 'text', intro: 'Expected output:', code: `${program.expectedOutput}\n` }] + require(code.includes('#[tokio::main') && code.includes('async fn main()'), 'program entry point is missing') + require(!/\binclude(?:_str|_bytes)?!|^\s*mod\s+\w+\s*;|use\s+(?:crate|super)::/m.test( + code, + ), 'program depends on a hidden source helper') + const blocks: NonNullable = [ + { + lang: 'console', + intro: `${program.title}. ${program.description} Use a Unix environment with Git, rustup, and tmux 3.2a or newer. The toolchain file below pins Rust ${channel}. In an empty directory, fetch the documented source:`, + code: `$ git clone https://github.com/${source.repo}.git libtmux-source &&\n git -C libtmux-source checkout --detach ${source.revision}\n`, + }, + ...setup.map((file, index) => ({ + lang: 'toml', + intro: `Save this complete consumer setup as ${file.name}:`, + sourceUrl: urlFor(file.sourceFile), + code: setupCode[index], + })), + { + lang: 'rust', + intro: + 'Save this complete program as src/main.rs. It owns a private tmux socket, applies a ten-second task deadline, and reports both task and cleanup errors. Server::kill stops its daemon; Server::shutdown closes its client executor. Successful cleanup removes the temporary directory even when the task fails.', + sourceUrl: urlFor(program.sourceFile), + code, + }, + { + lang: 'console', + intro: + 'Copy the complete files from the pinned checkout and run the consumer. This also creates src for the displayed program. Success exits with status 0; setup, operation, deadline or cleanup failures exit unsuccessfully and print diagnostics to stderr:', + code: + '$ mkdir -p src &&\n' + + ' cp libtmux-source/crates/libtmux/examples/api/consumer.toml Cargo.toml &&\n' + + ' cp libtmux-source/rust-toolchain.toml rust-toolchain.toml &&\n' + + ` cp libtmux-source/${program.sourceFile} src/main.rs &&\n` + + ` ${manifest.runCommand}\n`, + }, + { lang: 'text', intro: 'Expected output:', code: `${program.expectedOutput}\n` }, + ] for (const target of program.targets) { require(typeof target === 'string' && /^libtmux::[\w:]+$/.test(target), 'invalid native target') require(!targets.has(target), `duplicate API target: ${target}`) diff --git a/packages/api-model/src/languages/spec.ts b/packages/api-model/src/languages/spec.ts index 96b50a782..63a1fe0fc 100644 --- a/packages/api-model/src/languages/spec.ts +++ b/packages/api-model/src/languages/spec.ts @@ -146,11 +146,7 @@ function docCommentFor(node: Node, spec: LanguageSpec): string | undefined { // up through transparent wrappers is the difference between 16% and 80% of // TypeScript's symbols being documented — and nothing failed at 16%. let anchor: Node = node - while ( - !anchor.previousNamedSibling && - anchor.parent && - spec.transparent?.includes(anchor.parent.type) - ) { + while (!anchor.previousNamedSibling && anchor.parent && spec.transparent?.includes(anchor.parent.type)) { anchor = anchor.parent } let cursor: Node | null = anchor.previousNamedSibling @@ -217,8 +213,10 @@ function paramsOf(node: Node | null, spec: LanguageSpec): Param[] { } const name = child.childForFieldName('name') ?? child.childForFieldName('pattern') const type = child.childForFieldName('type') - const value = child.childForFieldName('value') ?? child.childForFieldName('default_value') - ?? child.namedChildren.find((node) => node?.type === 'equals_value_clause')?.namedChild(0) + const value = + child.childForFieldName('value') ?? + child.childForFieldName('default_value') ?? + child.namedChildren.find((node) => node?.type === 'equals_value_clause')?.namedChild(0) // Go groups parameters — `a, b string` is one node with two names — and a // receiver has no name at all. Falling back to the node's own text keeps // both readable instead of emitting an empty row. @@ -226,7 +224,7 @@ function paramsOf(node: Node | null, spec: LanguageSpec): Param[] { if (!label) continue out.push({ name: label.replace(/^[&*]+/, ''), - type: type ? spec.typeText?.(type) ?? type.text.replace(/\s+/g, ' ') : undefined, + type: type ? (spec.typeText?.(type) ?? type.text.replace(/\s+/g, ' ')) : undefined, default: value?.text, }) } @@ -236,16 +234,16 @@ function paramsOf(node: Node | null, spec: LanguageSpec): Param[] { function signatureOf(node: Node, spec: LanguageSpec): Signature { const f = { ...DEFAULT_FIELDS, ...spec.fields } const returns = - node.childForFieldName(f.returns) ?? - node.childForFieldName('result') ?? - node.childForFieldName('type') + node.childForFieldName(f.returns) ?? node.childForFieldName('result') ?? node.childForFieldName('type') return { params: paramsOf(node.childForFieldName(f.params), spec), // A grammar's return-type node often includes the syntax that introduces // it: TypeScript's `type_annotation` is `: string`, and all 240 of its // signatures rendered as `foo() → : string`. The arrow or colon is the // renderer's to draw. - returns: returns ? spec.typeText?.(returns) ?? returns.text.replace(/\s+/g, ' ').replace(/^\s*(?:->|:)\s*/, '') : undefined, + returns: returns + ? (spec.typeText?.(returns) ?? returns.text.replace(/\s+/g, ' ').replace(/^\s*(?:->|:)\s*/, '')) + : undefined, typeParams: node .childForFieldName('type_parameters') ?.namedChildren.map((n) => n?.text ?? '') @@ -346,8 +344,13 @@ function walk(node: Node, ctx: Ctx, parent: string | undefined): void { if (ctx.byId.has(propertyId)) continue const declaration = parameters.find((node) => node?.childForFieldName('name')?.text === parameter.name) emit(ctx, { - id: propertyId, name: parameter.name, kind: 'property', parent: id, - type: parameter.type, modifiers: [], signatures: [], + id: propertyId, + name: parameter.name, + kind: 'property', + parent: id, + type: parameter.type, + modifiers: [], + signatures: [], doc: parameter.doc ? { summary: parameter.doc } : undefined, source: { file: ctx.file, line: (declaration ?? child).startPosition.row + 1 }, }) @@ -366,8 +369,7 @@ function walk(node: Node, ctx: Ctx, parent: string | undefined): void { // `function_item`, so the spec cannot tell them apart — only the presence // of an enclosing `impl` or `trait` can, and that is known here. 85 Rust // functions in `mod` blocks were reported as ownerless methods. - const resolvedKind: SymbolKind = - memberKind === 'method' && !owner ? 'function' : memberKind + const resolvedKind: SymbolKind = memberKind === 'method' && !owner ? 'function' : memberKind // Built before the doc so C#'s `` and `` can be lifted // onto it: the comment carries them, and nothing else does. const memberSignatures = hasParams ? [signatureOf(child, spec)] : [] @@ -378,9 +380,11 @@ function walk(node: Node, ctx: Ctx, parent: string | undefined): void { modifiers, parent: owner, signatures: memberSignatures, - type: hasParams ? undefined : child.childForFieldName('type') && spec.typeText - ? spec.typeText(child.childForFieldName('type')!) - : (child.childForFieldName('type')?.text ?? spec.implicitType?.(child))?.replace(/\s+/g, ' '), + type: hasParams + ? undefined + : child.childForFieldName('type') && spec.typeText + ? spec.typeText(child.childForFieldName('type')!) + : (child.childForFieldName('type')?.text ?? spec.implicitType?.(child))?.replace(/\s+/g, ' '), value: hasParams ? undefined : child.childForFieldName('declarator')?.childForFieldName('value')?.text.replace(/\s+/g, ' '), @@ -401,11 +405,7 @@ function walk(node: Node, ctx: Ctx, parent: string | undefined): void { * The dialect is declared on the spec rather than tested for by grammar name, * so adding a port that documents in markup is a field rather than a branch. */ -function docFor( - raw: string | undefined, - spec: LanguageSpec, - signatures: Signature[], -): DocBlock | undefined { +function docFor(raw: string | undefined, spec: LanguageSpec, signatures: Signature[]): DocBlock | undefined { if (!raw) return undefined const parse = spec.docDialect === 'xml' @@ -437,18 +437,25 @@ export async function extractWithSpec( const parser = await parserFor(spec.grammar) const tree = parser.parse(readFileSync(file, 'utf8')) if (!tree) return [] - const packageName = spec.grammar === 'java' - ? tree.rootNode.namedChildren.find((node) => node?.type === 'package_declaration') - ?.namedChildren.find((node) => node?.type === 'scoped_identifier' || node?.type === 'identifier') - : undefined + const packageName = + spec.grammar === 'java' + ? tree.rootNode.namedChildren + .find((node) => node?.type === 'package_declaration') + ?.namedChildren.find((node) => node?.type === 'scoped_identifier' || node?.type === 'identifier') + : undefined const ctx: Ctx = { file, module, // Java packages come from declarations, not source paths or file names. - namespace: spec.grammar === 'java' - ? packageName?.type === 'identifier' ? packageName.text - : packageName?.descendantsOfType('identifier').flatMap((node) => node ? [node.text] : []).join('.') ?? '' - : undefined, + namespace: + spec.grammar === 'java' + ? packageName?.type === 'identifier' + ? packageName.text + : (packageName + ?.descendantsOfType('identifier') + .flatMap((node) => (node ? [node.text] : [])) + .join('.') ?? '') + : undefined, symbols: [], byId: new Map(), spec, diff --git a/packages/api-model/src/languages/specs.ts b/packages/api-model/src/languages/specs.ts index 8b9f7fdff..d06ce5179 100644 --- a/packages/api-model/src/languages/specs.ts +++ b/packages/api-model/src/languages/specs.ts @@ -38,7 +38,8 @@ const stripSlashDoc = (raw: string): string | undefined => { /** Remove syntax nodes, not comment-looking text inside string literal types. */ function typescriptType(node: Node): string { - const type = node.firstChild?.type === ':' ? node.namedChildren.find((child) => child?.type !== 'comment') ?? node : node + const type = + node.firstChild?.type === ':' ? (node.namedChildren.find((child) => child?.type !== 'comment') ?? node) : node let text = type.text const comments = type.descendantsOfType('comment').filter((node): node is Node => node !== null) for (const comment of comments.sort((a, b) => b.startIndex - a.startIndex)) { @@ -50,7 +51,9 @@ function typescriptType(node: Node): string { const rest = lines.slice(1).filter((line) => line.trim()) const indent = rest.length ? Math.min(...rest.map((line) => /^\s*/.exec(line)![0].length)) : 0 return [lines[0], ...lines.slice(1).map((line) => line.slice(Math.min(indent, /^\s*/.exec(line)![0].length)))] - .filter((line) => line.trim()).join('\n').trim() + .filter((line) => line.trim()) + .join('\n') + .trim() } /** Inline option descriptions belong to the parameter, with their field paths. */ @@ -67,8 +70,10 @@ function typescriptParameter(node: Node) { if (!field) continue const names = [...path, field] const previous = child.previousNamedSibling - const doc = previous?.type === 'comment' && previous.endPosition.row >= child.startPosition.row - 1 - ? stripBlockDoc(previous.text) : undefined + const doc = + previous?.type === 'comment' && previous.endPosition.row >= child.startPosition.row - 1 + ? stripBlockDoc(previous.text) + : undefined if (doc) descriptions.push(`- \`${names.join('.')}\`: ${doc.replaceAll('\n', '\n ')}`) const nested = child.childForFieldName('type') if (nested) visit(nested, names) @@ -126,8 +131,7 @@ export const TYPESCRIPT: LanguageSpec = { // carrying a page for a shape a caller cannot import. Exporting them is the // port's decision to make. A private function is named by nothing. isExported: (node) => - node.parent?.type !== 'program' || - (node.type !== 'function_declaration' && node.type !== 'function_signature'), + node.parent?.type !== 'program' || (node.type !== 'function_declaration' && node.type !== 'function_signature'), fields: { returns: 'return_type' }, typeText: typescriptType, parameter: typescriptParameter, @@ -140,11 +144,18 @@ function rustDocHidden(node: Node): boolean { const attribute = sibling.namedChildren.find((child) => child?.type === 'attribute') if (attribute?.namedChildren[0]?.text !== 'doc') continue const args = attribute.namedChildren.find((child) => child?.type === 'token_tree') - const tokens = args?.children.filter((child) => child && - child.type !== 'line_comment' && child.type !== 'block_comment') ?? [] - if (tokens.some((token, index) => token?.type === 'identifier' && token.text === 'hidden' && - ['(', ','].includes(tokens[index - 1]?.type ?? '') && - [')', ','].includes(tokens[index + 1]?.type ?? ''))) return true + const tokens = + args?.children.filter((child) => child && child.type !== 'line_comment' && child.type !== 'block_comment') ?? [] + if ( + tokens.some( + (token, index) => + token?.type === 'identifier' && + token.text === 'hidden' && + ['(', ','].includes(tokens[index - 1]?.type ?? '') && + [')', ','].includes(tokens[index + 1]?.type ?? ''), + ) + ) + return true } else if (sibling.type !== 'line_comment' && sibling.type !== 'block_comment') break } return false @@ -177,13 +188,7 @@ export const RUST: LanguageSpec = { // `variant` kind, and a named member of an enum is what a constant is. enum_variant: 'constant', }, - transparent: [ - 'declaration_list', - 'field_declaration_list', - 'source_file', - 'mod_item', - 'enum_variant_list', - ], + transparent: ['declaration_list', 'field_declaration_list', 'source_file', 'mod_item', 'enum_variant_list'], commentTypes: ['line_comment', 'block_comment'], attributeTypes: ['attribute_item'], // A module compiled only for tests is not API. Rust keeps its unit tests @@ -211,7 +216,7 @@ export const RUST: LanguageSpec = { .replace(/\bdyn\s+/g, '') .trim(), // Receivers have no name field: splitting `mut self` would name it `mut`. - parameter: (node) => node.type === 'self_parameter' ? { name: node.text } : undefined, + parameter: (node) => (node.type === 'self_parameter' ? { name: node.text } : undefined), // Trait methods and enum variants inherit their owner's visibility; the // walker rejects private owners before visiting their members. Impl blocks // have no visibility keyword, but inherent methods still require `pub`. @@ -219,8 +224,11 @@ export const RUST: LanguageSpec = { isExported: (node) => { if (node.type === 'trait_item' && rustDocHidden(node)) return false if (node.type === 'impl_item' || node.type === 'enum_variant') return true - if ((node.type === 'function_item' || node.type === 'function_signature_item') && - node.parent?.type === 'declaration_list' && node.parent.parent?.type === 'trait_item') { + if ( + (node.type === 'function_item' || node.type === 'function_signature_item') && + node.parent?.type === 'declaration_list' && + node.parent.parent?.type === 'trait_item' + ) { return !rustDocHidden(node) } return node.children.some((c) => c?.type === 'visibility_modifier' && c.text.trim() === 'pub') @@ -238,12 +246,18 @@ export const GO: LanguageSpec = { const_spec: 'constant', var_spec: 'attribute', }, - transparent: ['type_declaration', 'const_declaration', 'var_declaration', 'struct_type', 'interface_type', 'field_declaration_list'], + transparent: [ + 'type_declaration', + 'const_declaration', + 'var_declaration', + 'struct_type', + 'interface_type', + 'field_declaration_list', + ], commentTypes: ['comment'], // godoc has no marker: a `//` comment directly above a declaration is the // documentation. `//go:` directives are build metadata and are not. - stripDoc: (raw) => - raw.startsWith('//') && !raw.startsWith('//go:') ? raw.replace(/^\/\/\s?/, '') : undefined, + stripDoc: (raw) => (raw.startsWith('//') && !raw.startsWith('//go:') ? raw.replace(/^\/\/\s?/, '') : undefined), // `func (w Window) Panes()` — the owning type is in the `receiver` field, // not in any enclosing node. Pointers and aliases both appear; the type name // is what matters, so `*Window` and `Window` resolve to the same owner. @@ -251,7 +265,12 @@ export const GO: LanguageSpec = { if (node.type !== 'method_declaration') return undefined const decl = node.childForFieldName('receiver')?.namedChildren[0] const type = decl?.childForFieldName('type')?.text - return type?.replace(/^[*&]+/, '').replace(/\[.*$/, '').trim() || undefined + return ( + type + ?.replace(/^[*&]+/, '') + .replace(/\[.*$/, '') + .trim() || undefined + ) }, // `TreeSortIndex` under `TreeSortDefault TreeSortOrder = iota` repeats the // spec above it, type included. Without this only the first value of each @@ -299,9 +318,11 @@ export const JAVA: LanguageSpec = { // Keep existing internal records, but do not merge their signatures into // an advertised overload set. Interface methods are implicitly public. const name = node.childForFieldName('name')?.text - return !node.parent?.namedChildren.some((sibling) => - sibling?.type === node.type && sibling.childForFieldName('name')?.text === name && - sibling.children.some((child) => child?.type === 'modifiers' && /\b(public|protected)\b/.test(child.text)), + return !node.parent?.namedChildren.some( + (sibling) => + sibling?.type === node.type && + sibling.childForFieldName('name')?.text === name && + sibling.children.some((child) => child?.type === 'modifiers' && /\b(public|protected)\b/.test(child.text)), ) } return true @@ -311,8 +332,9 @@ export const JAVA: LanguageSpec = { export const CSHARP: LanguageSpec = { grammar: 'csharp', docDialect: 'xml', - positionalProperties: (node) => ['record_declaration', 'record_struct_declaration'].includes(node.type) - && Boolean(node.childForFieldName('parameters')), + positionalProperties: (node) => + ['record_declaration', 'record_struct_declaration'].includes(node.type) && + Boolean(node.childForFieldName('parameters')), containers: { class_declaration: 'class', interface_declaration: 'interface', diff --git a/packages/api-model/src/languages/swift-examples.test.ts b/packages/api-model/src/languages/swift-examples.test.ts index 64b3e12a4..b9ff64c06 100644 --- a/packages/api-model/src/languages/swift-examples.test.ts +++ b/packages/api-model/src/languages/swift-examples.test.ts @@ -4,16 +4,29 @@ import { attachCompleteSwiftExamples } from './swift-examples.ts' const source = { repo: 'libtmux/libtmux-swift', revision: 'a'.repeat(40) } const symbol = (): ApiSymbol => ({ - id: 'Server', name: 'Server', slug: 'server', kind: 'struct', signatures: [], modifiers: [], + id: 'Server', + name: 'Server', + slug: 'server', + kind: 'struct', + signatures: [], + modifiers: [], source: { file: 'Sources/LibTmux/Server.swift' }, doc: { summary: 'A tmux server address.', examples: [{ lang: 'swift', code: 'let old = true\n' }] }, }) const fixture = () => { const manifest = { - schemaVersion: 1, packageFile: 'Examples/Standalone/Package.swift', - examples: [{ name: 'ApiServer', symbols: ['Server'], title: 'List sessions', - description: 'Read the server listing.', expectedOutput: ['bootstrap'], - file: 'Examples/Sources/ApiServer/ApiServer.swift' }], + schemaVersion: 1, + packageFile: 'Examples/Standalone/Package.swift', + examples: [ + { + name: 'ApiServer', + symbols: ['Server'], + title: 'List sessions', + description: 'Read the server listing.', + expectedOutput: ['bootstrap'], + file: 'Examples/Sources/ApiServer/ApiServer.swift', + }, + ], } const packageCode = '// swift-tools-version: 6.2\nimport PackageDescription\n' const programCode = 'import LibTmux\n@main struct ApiServer { static func main() {} }\n' @@ -21,8 +34,7 @@ const fixture = () => { 'Examples/Standalone/Package.swift': packageCode, 'Examples/Sources/ApiServer/ApiServer.swift': programCode, } - const read = (path: string) => path === 'Examples/api-examples.json' - ? JSON.stringify(manifest) : files[path] + const read = (path: string) => (path === 'Examples/api-examples.json' ? JSON.stringify(manifest) : files[path]) return { manifest, files, read, packageCode, programCode } } @@ -35,12 +47,17 @@ describe('complete Swift API examples', () => { expect(target.doc?.examples?.[0].code).toBe('let old = true\n') expect(target.doc?.examples?.slice(1).map((block) => block.code)).toEqual([ `$ git clone https://github.com/${source.repo}.git libtmux-source && \\\n git -C libtmux-source checkout ${source.revision}\n`, - packageCode, programCode, '$ swift run --jobs 5 ApiExample\n', 'bootstrap\n', + packageCode, + programCode, + '$ swift run --jobs 5 ApiExample\n', + 'bootstrap\n', ]) expect(target.doc?.examples?.[2].sourceUrl).toBe( - `https://github.com/${source.repo}/blob/${source.revision}/Examples/Standalone/Package.swift`) + `https://github.com/${source.repo}/blob/${source.revision}/Examples/Standalone/Package.swift`, + ) expect(target.doc?.examples?.[3].sourceUrl).toBe( - `https://github.com/${source.repo}/blob/${source.revision}/Examples/Sources/ApiServer/ApiServer.swift`) + `https://github.com/${source.repo}/blob/${source.revision}/Examples/Sources/ApiServer/ApiServer.swift`, + ) }) it('leaves older source revisions without a manifest unchanged', () => { @@ -51,18 +68,78 @@ describe('complete Swift API examples', () => { }) it.each([ - ['unknown target', (f: ReturnType) => { f.manifest.examples[0].symbols = ['Unknown'] }], - ['duplicate target', (f: ReturnType) => { f.manifest.examples[0].symbols.push('Server') }], - ['duplicate program', (f: ReturnType) => { f.manifest.examples.push(f.manifest.examples[0]) }], - ['missing program', (f: ReturnType) => { delete f.files[f.manifest.examples[0].file] }], - ['missing package', (f: ReturnType) => { delete f.files[f.manifest.packageFile] }], - ['partial file', (f: ReturnType) => { f.files[f.manifest.examples[0].file] = 'import LibTmux' }], - ['unsafe path', (f: ReturnType) => { f.manifest.examples[0].file = '../Example.swift' }], - ['unexpected package', (f: ReturnType) => { f.manifest.packageFile = 'Package.swift' }], - ['empty output', (f: ReturnType) => { f.manifest.examples[0].expectedOutput = [] }], - ['multiline output item', (f: ReturnType) => { f.manifest.examples[0].expectedOutput = ['x\ny'] }], - ['missing description', (f: ReturnType) => { f.manifest.examples[0].description = ' ' }], - ['unsupported schema', (f: ReturnType) => { f.manifest.schemaVersion = 2 }], + [ + 'unknown target', + (f: ReturnType) => { + f.manifest.examples[0].symbols = ['Unknown'] + }, + ], + [ + 'duplicate target', + (f: ReturnType) => { + f.manifest.examples[0].symbols.push('Server') + }, + ], + [ + 'duplicate program', + (f: ReturnType) => { + f.manifest.examples.push(f.manifest.examples[0]) + }, + ], + [ + 'missing program', + (f: ReturnType) => { + delete f.files[f.manifest.examples[0].file] + }, + ], + [ + 'missing package', + (f: ReturnType) => { + delete f.files[f.manifest.packageFile] + }, + ], + [ + 'partial file', + (f: ReturnType) => { + f.files[f.manifest.examples[0].file] = 'import LibTmux' + }, + ], + [ + 'unsafe path', + (f: ReturnType) => { + f.manifest.examples[0].file = '../Example.swift' + }, + ], + [ + 'unexpected package', + (f: ReturnType) => { + f.manifest.packageFile = 'Package.swift' + }, + ], + [ + 'empty output', + (f: ReturnType) => { + f.manifest.examples[0].expectedOutput = [] + }, + ], + [ + 'multiline output item', + (f: ReturnType) => { + f.manifest.examples[0].expectedOutput = ['x\ny'] + }, + ], + [ + 'missing description', + (f: ReturnType) => { + f.manifest.examples[0].description = ' ' + }, + ], + [ + 'unsupported schema', + (f: ReturnType) => { + f.manifest.schemaVersion = 2 + }, + ], ])('rejects %s', (_name, change) => { const f = fixture() change(f) @@ -72,7 +149,11 @@ describe('complete Swift API examples', () => { it('rejects ambiguous identities, unpinned revisions and unexpected repositories', () => { const { read } = fixture() expect(() => attachCompleteSwiftExamples([symbol(), symbol()], read, source)).toThrow('resolve once') - expect(() => attachCompleteSwiftExamples([symbol()], read, { ...source, revision: 'master' })).toThrow('full revision') - expect(() => attachCompleteSwiftExamples([symbol()], read, { ...source, repo: 'other/repo' })).toThrow('Swift repository') + expect(() => attachCompleteSwiftExamples([symbol()], read, { ...source, revision: 'master' })).toThrow( + 'full revision', + ) + expect(() => attachCompleteSwiftExamples([symbol()], read, { ...source, repo: 'other/repo' })).toThrow( + 'Swift repository', + ) }) }) diff --git a/packages/api-model/src/languages/swift-examples.ts b/packages/api-model/src/languages/swift-examples.ts index 46b1ad494..9767b739a 100644 --- a/packages/api-model/src/languages/swift-examples.ts +++ b/packages/api-model/src/languages/swift-examples.ts @@ -27,10 +27,11 @@ export function attachCompleteSwiftExamples( const require = (condition: unknown, message: string): void => { if (!condition) throw new Error(`Invalid complete Swift examples: ${message}`) } - require(source.repo === 'libtmux/libtmux-swift' && /^[a-f0-9]{40}$/.test(source.revision), - 'source must name the Swift repository and a full revision') - require(manifest?.schemaVersion === 1 && Array.isArray(manifest.examples) && manifest.examples.length, - 'unsupported or empty manifest') + require(source.repo === 'libtmux/libtmux-swift' && + /^[a-f0-9]{40}$/.test(source.revision), 'source must name the Swift repository and a full revision') + require(manifest?.schemaVersion === 1 && + Array.isArray(manifest.examples) && + manifest.examples.length, 'unsupported or empty manifest') require(manifest.packageFile === 'Examples/Standalone/Package.swift', 'unexpected consumer package') const codeFor = (path: string): string => { const code = read(path) @@ -44,19 +45,21 @@ export function attachCompleteSwiftExamples( const names = new Set() const targets = new Set() for (const example of manifest.examples) { - require(/^Api[A-Z][A-Za-z0-9]*$/.test(example.name) && !names.has(example.name), - 'invalid or duplicate program name') + require(/^Api[A-Z][A-Za-z0-9]*$/.test(example.name) && + !names.has(example.name), 'invalid or duplicate program name') names.add(example.name) - require(example.file === `Examples/Sources/${example.name}/${example.name}.swift`, - 'unexpected program path') - require(typeof example.title === 'string' && example.title.trim() && - typeof example.description === 'string' && example.description.trim(), 'task description is missing') - require(Array.isArray(example.expectedOutput) && example.expectedOutput.length && + require(example.file === `Examples/Sources/${example.name}/${example.name}.swift`, 'unexpected program path') + require(typeof example.title === 'string' && + example.title.trim() && + typeof example.description === 'string' && + example.description.trim(), 'task description is missing') + require(Array.isArray(example.expectedOutput) && + example.expectedOutput.length && example.expectedOutput.every((line) => typeof line === 'string' && !/[\r\n]/.test(line)) && example.expectedOutput.some((line) => line.trim()), 'expected output is missing') - require(Array.isArray(example.symbols) && example.symbols.length && - example.symbols.every((id) => typeof id === 'string' && id && !/\s/.test(id)), - 'API targets are missing') + require(Array.isArray(example.symbols) && + example.symbols.length && + example.symbols.every((id) => typeof id === 'string' && id && !/\s/.test(id)), 'API targets are missing') const programCode = codeFor(example.file) const blocks: NonNullable = [ { @@ -72,13 +75,15 @@ export function attachCompleteSwiftExamples( }, { lang: 'swift', - intro: 'Create a Sources directory and save this complete program as Sources/Example.swift. The `withTmuxServer` fixture owns a private tmux server and stops it after success or failure. Ordinary application code uses `Server` for its own socket; these examples use the fixture until their setup moves to ordinary server objects.', + intro: + 'Create a Sources directory and save this complete program as Sources/Example.swift. The `withTmuxServer` fixture owns a private tmux server and stops it after success or failure. Ordinary application code uses `Server` for its own socket; these examples use the fixture until their setup moves to ordinary server objects.', sourceUrl: urlFor(example.file), code: programCode, }, { lang: 'console', - intro: 'Build and run the saved program. An operation or cleanup error produces a failing exit status. Set LIBTMUX_TMUX_BIN to an absolute executable path to select a tmux version:', + intro: + 'Build and run the saved program. An operation or cleanup error produces a failing exit status. Set LIBTMUX_TMUX_BIN to an absolute executable path to select a tmux version:', code: '$ swift run --jobs 5 ApiExample\n', }, { lang: 'text', intro: 'Expected output:', code: `${example.expectedOutput.join('\n')}\n` }, diff --git a/packages/api-model/src/languages/symbolgraph.ts b/packages/api-model/src/languages/symbolgraph.ts index 5cda217f5..793b12239 100644 --- a/packages/api-model/src/languages/symbolgraph.ts +++ b/packages/api-model/src/languages/symbolgraph.ts @@ -41,8 +41,7 @@ const PUBLIC_IDS: Record> = { LibTmux: { 'Server.newSession(named:startDirectory:windowName:width:height:environment:shell:)': 'Server.newSession(named:startDirectory:windowName:width:height:)', - 'Server.newWindow(in:named:startDirectory:at:environment:shell:)': - 'Server.newWindow(in:named:startDirectory:)', + 'Server.newWindow(in:named:startDirectory:at:environment:shell:)': 'Server.newWindow(in:named:startDirectory:)', 'Server.split(_:direction:size:startDirectory:environment:shell:)': 'Server.split(_:direction:size:startDirectory:)', 'Server.splitWindow(_:direction:size:startDirectory:environment:shell:)': @@ -79,7 +78,11 @@ interface RawSymbol { } const fragmentText = (fragments: Fragment[] | undefined): string | undefined => - fragments?.map((f) => f.spelling).join('').replace(/\s+/g, ' ').trim() || undefined + fragments + ?.map((f) => f.spelling) + .join('') + .replace(/\s+/g, ' ') + .trim() || undefined function modifiersOf(raw: RawSymbol): Modifier[] { const out: Modifier[] = [] @@ -152,10 +155,7 @@ function signatureOf(raw: RawSymbol): Signature | undefined { * protocol conformance. */ /** Conformance display names in stable order across compiler graph emissions. */ -function conformedNames( - conformances: Map>, - usr: string, -): string[] | undefined { +function conformedNames(conformances: Map>, usr: string): string[] | undefined { const found = conformances.get(usr) return found?.size ? [...found.values()].sort() : undefined } @@ -189,9 +189,11 @@ export function extractSymbolGraph(files: string[]): ApiSymbol[] { const selectors = new Set(graph.symbols.map((symbol) => symbol.pathComponents.join('.'))) const titleOf = new Map(graph.symbols.map((s) => [s.identifier.precise, s.pathComponents])) - const origins = new Map((graph.relationships ?? []) - .filter((relationship) => relationship.kind === 'memberOf' && relationship.sourceOrigin) - .map((relationship) => [relationship.source, relationship.sourceOrigin!.displayName])) + const origins = new Map( + (graph.relationships ?? []) + .filter((relationship) => relationship.kind === 'memberOf' && relationship.sourceOrigin) + .map((relationship) => [relationship.source, relationship.sourceOrigin!.displayName]), + ) for (const rel of graph.relationships ?? []) { if (rel.kind !== 'conformsTo') continue // A target outside this graph — every standard library protocol — is @@ -204,9 +206,7 @@ export function extractSymbolGraph(files: string[]): ApiSymbol[] { // for a local symbol — the form `BUILTINS.swift` and the symbol index // are both keyed on. const name = - titleOf.get(rel.target)?.join('.') ?? - rel.targetFallback?.replace(/^[A-Za-z_][A-Za-z0-9_]*\./, '') ?? - rel.target + titleOf.get(rel.target)?.join('.') ?? rel.targetFallback?.replace(/^[A-Za-z_][A-Za-z0-9_]*\./, '') ?? rel.target // The graph lists some conformances twice — `Sendable` and // `SendableMetatype` arrive once directly and once through another // protocol. Two distinct USRs never share a name, so the USR is the @@ -228,7 +228,10 @@ export function extractSymbolGraph(files: string[]): ApiSymbol[] { const previous = PUBLIC_IDS[graph.module?.name ?? '']?.[id] // An explicitly retained overload owns its URL; never merge distinct declarations. const publicId = previous && !selectors.has(previous) ? previous : id - const doc = raw.docComment?.lines.map((l) => l.text).join('\n').trim() + const doc = raw.docComment?.lines + .map((l) => l.text) + .join('\n') + .trim() const signature = signatureOf(raw) // Swift doc comments are Markdown, and this used to split on the first // blank line and keep the rest as one string — the same thing the spec @@ -239,7 +242,8 @@ export function extractSymbolGraph(files: string[]): ApiSymbol[] { const source: ApiSymbol['source'] = { file: (raw.location?.uri ?? '').replace(/^file:\/\//, ''), ...(raw.location?.uri && raw.location.position?.line !== undefined - ? { line: raw.location.position.line + 1 } : {}), + ? { line: raw.location.position.line + 1 } + : {}), } const inheritedFrom = source.file ? undefined : origins.get(raw.identifier.precise) diff --git a/packages/api-model/src/link.ts b/packages/api-model/src/link.ts index 51d92f6b4..d3b411d99 100644 --- a/packages/api-model/src/link.ts +++ b/packages/api-model/src/link.ts @@ -67,15 +67,20 @@ const PY_INTERSPHINX: Record = { const PY_DOCS = 'https://docs.python.org/3/' /** Syntax words in the native declarations rendered as complete signatures. */ -const SIGNATURE_KEYWORDS: Record> = Object.fromEntries(Object.entries({ - kotlin: 'public private protected internal class interface object fun val var suspend override abstract open final data sealed enum inline reified crossinline noinline vararg in out where companion constructor operator infix tailrec external expect actual by', - scala: 'def extension val var class trait object type given using implicit inline transparent opaque override abstract final sealed case lazy private protected open infix export derives end', - fsharp: 'module namespace type member static abstract override interface inherit let rec mutable inline internal private public of with get set new val and when', - ts: 'readonly keyof typeof infer extends unique', - swift: 'any', - julia: 'where struct mutable abstract primitive type function end', - cxx: 'alignas alignof auto class concept const consteval constexpr constinit decltype default delete enum explicit extern final friend inline mutable namespace noexcept operator override register requires static struct template thread_local typedef typename union using virtual volatile', -}).map(([port, words]) => [port, new Set(words.split(' '))])) +const SIGNATURE_KEYWORDS: Record> = Object.fromEntries( + Object.entries({ + kotlin: + 'public private protected internal class interface object fun val var suspend override abstract open final data sealed enum inline reified crossinline noinline vararg in out where companion constructor operator infix tailrec external expect actual by', + scala: + 'def extension val var class trait object type given using implicit inline transparent opaque override abstract final sealed case lazy private protected open infix export derives end', + fsharp: + 'module namespace type member static abstract override interface inherit let rec mutable inline internal private public of with get set new val and when', + ts: 'readonly keyof typeof infer extends unique', + swift: 'any', + julia: 'where struct mutable abstract primitive type function end', + cxx: 'alignas alignof auto class concept const consteval constexpr constinit decltype default delete enum explicit extern final friend inline mutable namespace noexcept operator override register requires static struct template thread_local typedef typename union using virtual volatile', + }).map(([port, words]) => [port, new Set(words.split(' '))]), +) /** Language help stays separate from links to API declarations. */ export interface KeywordHelp { @@ -88,14 +93,16 @@ const KEYWORD_HELP: Record> = { cxx: { nodiscard: { title: 'C++ nodiscard attribute', - description: 'Asks the compiler to warn when a result is discarded. An explicit cast to void acknowledges that the result is intentionally ignored.', + description: + 'Asks the compiler to warn when a result is discarded. An explicit cast to void acknowledges that the result is intentionally ignored.', href: 'https://eel.is/c++draft/dcl.attr.nodiscard', }, }, kotlin: { suspend: { title: 'Kotlin suspending functions', - description: 'Allows a function to call suspending operations and pause without blocking a thread. It does not start a coroutine by itself.', + description: + 'Allows a function to call suspending operations and pause without blocking a thread. It does not start a coroutine by itself.', href: builtinHref('kotlin', 'suspend')!, }, }, @@ -133,17 +140,50 @@ interface TypeSpan { * on one page. */ const PY_EXCEPTIONS = new Set([ - 'ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', - 'BlockingIOError', 'BrokenPipeError', 'BufferError', 'ChildProcessError', - 'ConnectionError', 'EOFError', 'FileExistsError', 'FileNotFoundError', - 'GeneratorExit', 'ImportError', 'IndentationError', 'IndexError', - 'InterruptedError', 'IsADirectoryError', 'KeyError', 'KeyboardInterrupt', - 'LookupError', 'MemoryError', 'NameError', 'NotADirectoryError', - 'NotImplementedError', 'OSError', 'OverflowError', 'PermissionError', - 'ProcessLookupError', 'RecursionError', 'ReferenceError', 'RuntimeError', - 'StopAsyncIteration', 'StopIteration', 'SyntaxError', 'SystemError', - 'SystemExit', 'TimeoutError', 'TypeError', 'UnboundLocalError', - 'UnicodeDecodeError', 'UnicodeError', 'ValueError', 'ZeroDivisionError', + 'ArithmeticError', + 'AssertionError', + 'AttributeError', + 'BaseException', + 'BlockingIOError', + 'BrokenPipeError', + 'BufferError', + 'ChildProcessError', + 'ConnectionError', + 'EOFError', + 'FileExistsError', + 'FileNotFoundError', + 'GeneratorExit', + 'ImportError', + 'IndentationError', + 'IndexError', + 'InterruptedError', + 'IsADirectoryError', + 'KeyError', + 'KeyboardInterrupt', + 'LookupError', + 'MemoryError', + 'NameError', + 'NotADirectoryError', + 'NotImplementedError', + 'OSError', + 'OverflowError', + 'PermissionError', + 'ProcessLookupError', + 'RecursionError', + 'ReferenceError', + 'RuntimeError', + 'StopAsyncIteration', + 'StopIteration', + 'SyntaxError', + 'SystemError', + 'SystemExit', + 'TimeoutError', + 'TypeError', + 'UnboundLocalError', + 'UnicodeDecodeError', + 'UnicodeError', + 'ValueError', + 'ZeroDivisionError', ]) /** @@ -156,10 +196,33 @@ const PY_EXCEPTIONS = new Set([ * where intersphinx sends it. */ const PY_STDLIB_MODULES = new Set([ - 'subprocess', 'dataclasses', 'pathlib', 'typing', 'logging', 'os', 'sys', - 'shutil', 'enum', 'abc', 'collections', 'functools', 'itertools', 'json', - 'time', 'datetime', 'warnings', 'contextlib', 'traceback', 're', 'io', - 'threading', 'asyncio', 'unittest', 'tempfile', 'textwrap', 'random', + 'subprocess', + 'dataclasses', + 'pathlib', + 'typing', + 'logging', + 'os', + 'sys', + 'shutil', + 'enum', + 'abc', + 'collections', + 'functools', + 'itertools', + 'json', + 'time', + 'datetime', + 'warnings', + 'contextlib', + 'traceback', + 're', + 'io', + 'threading', + 'asyncio', + 'unittest', + 'tempfile', + 'textwrap', + 'random', ]) export interface LinkTarget { @@ -242,10 +305,12 @@ export class SymbolIndex { aliases.push(s) this.byQualified.set(qualified, aliases) - const suffixes = new Set([pub, qualified].flatMap((name) => { - const parts = name.split('.') - return parts.slice(1).map((_, i) => parts.slice(i + 1).join('.')) - })) + const suffixes = new Set( + [pub, qualified].flatMap((name) => { + const parts = name.split('.') + return parts.slice(1).map((_, i) => parts.slice(i + 1).join('.')) + }), + ) for (const suffix of suffixes) { const list = this.bySuffix.get(suffix) ?? [] list.push(s) @@ -298,22 +363,13 @@ export class SymbolIndex { * from Go, Java and Rust pages: 1,889 links that sent a reader of one * language to another language's manual. */ - addInventory( - baseUrl: string, - entries: InventoryEntry[], - langs?: string[], - project?: string, - ): void { + addInventory(baseUrl: string, entries: InventoryEntry[], langs?: string[], project?: string): void { const byName = new Map() for (const entry of entries) if (!byName.has(entry.name)) byName.set(entry.name, entry) this.external.push({ baseUrl: baseUrl.replace(/\/*$/, '/'), byName, langs, project }) } - private pick( - candidates: ApiSymbol[] | undefined, - role: string, - context?: ApiSymbol, - ): ApiSymbol | undefined { + private pick(candidates: ApiSymbol[] | undefined, role: string, context?: ApiSymbol): ApiSymbol | undefined { if (!candidates?.length) return undefined const kinds = ROLE_KINDS[role] ?? [] const filtered = kinds.length ? candidates.filter((c) => kinds.includes(c.kind)) : candidates @@ -378,26 +434,41 @@ export class SymbolIndex { let clean = target.replace(/^[~.]/, '').replace(/\(\)$/, '') if (this.lang === 'powershell' && context) { const prefixed = /^[$-]/.test(clean) - const parameter = context.signatures.flatMap((signature) => signature.params) - .find((param) => param.name.toLowerCase() === (prefixed ? clean.slice(1) : clean).toLowerCase() - && (prefixed || param.binding?.common || ['WhatIf', 'Confirm'].includes(param.name))) - if (parameter) return { href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, parameter.name))}`, external: false } + const parameter = context.signatures + .flatMap((signature) => signature.params) + .find( + (param) => + param.name.toLowerCase() === (prefixed ? clean.slice(1) : clean).toLowerCase() && + (prefixed || param.binding?.common || ['WhatIf', 'Confirm'].includes(param.name)), + ) + if (parameter) + return { + href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, parameter.name))}`, + external: false, + } // These are ErrorRecord identifiers, not exception classes. Native help // owns their meaning; keep the destination pinned to the extracted help. if (/^Tmux\.[A-Za-z_]\w*$/.test(clean) && context.doc?.body) { const notes = context.doc.body.split(/^### Notes\s*$/m)[1] ?? '' const documented = [...notes.matchAll(/\bTmux\.[A-Za-z_]\w*/g)].some((match) => match[0] === clean) const revision = context.source.extractedRevision ?? context.source.revision - const help = context.doc.examples?.map((example) => example.sourceUrl).find((href) => - revision && href?.startsWith(`https://github.com/${context.source.repo}/blob/${revision}/docs/reference/`) - && href.endsWith(`/${context.name}.md`)) + const help = context.doc.examples + ?.map((example) => example.sourceUrl) + .find( + (href) => + revision && + href?.startsWith(`https://github.com/${context.source.repo}/blob/${revision}/docs/reference/`) && + href.endsWith(`/${context.name}.md`), + ) if (documented && help) return { href: `${help}#notes`, external: true, project: 'PowerShell command help' } } } - if (this.lang === 'powershell' && role === 'variable') return { - href: 'https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_variables?view=powershell-7.4', - external: true, project: 'PowerShell variables', - } + if (this.lang === 'powershell' && role === 'variable') + return { + href: 'https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_variables?view=powershell-7.4', + external: true, + project: 'PowerShell variables', + } if (this.lang === 'scala') clean = clean.replace(/^_root_\./, '').replace(/#/g, '.') if (this.lang === 'fsharp' || this.lang === 'csharp') clean = clean.replace(/`\d+/g, '') const [importHead, ...tail] = clean.split('.') @@ -405,12 +476,23 @@ export class SymbolIndex { if (imported) clean = [imported, ...tail].join('.') if (this.lang === 'scala') clean = clean.replace(/^_root_\./, '') - if (context && ['kotlin', 'scala', 'fsharp'].includes(this.lang ?? '') && !relative && - context.signatures.some((sig) => sig.params.some((param) => param.name === clean))) { - return { href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, clean))}`, external: false } + if ( + context && + ['kotlin', 'scala', 'fsharp'].includes(this.lang ?? '') && + !relative && + context.signatures.some((sig) => sig.params.some((param) => param.name === clean)) + ) { + return { + href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, clean))}`, + external: false, + } } if (context && ['kotlin', 'scala'].includes(this.lang ?? '')) { - for (let scope: ApiSymbol | undefined = context; scope; scope = scope.parent ? this.byDeclared.get(scope.parent) : undefined) { + for ( + let scope: ApiSymbol | undefined = context; + scope; + scope = scope.parent ? this.byDeclared.get(scope.parent) : undefined + ) { if (scope.signatures.some((sig) => sig.typeParams?.includes(clean))) { return { href: this.hrefFor(scope), external: false, symbol: scope } } @@ -489,10 +571,15 @@ export class SymbolIndex { // built-in table rather than after. for (const inv of this.external) { if (inv.langs && (!this.lang || !inv.langs.includes(this.lang))) continue - const hit = inv.byName.get(clean) ?? context?.namespaceImports?.toReversed() - .map((namespace) => inv.byName.get(`${namespace}.${clean}`)).find(Boolean) ?? + const hit = + inv.byName.get(clean) ?? + context?.namespaceImports + ?.toReversed() + .map((namespace) => inv.byName.get(`${namespace}.${clean}`)) + .find(Boolean) ?? (this.lang === 'julia' && !clean.includes('.') - ? inv.byName.get(`Core.${clean}`) ?? inv.byName.get(`Base.${clean}`) : undefined) + ? (inv.byName.get(`Core.${clean}`) ?? inv.byName.get(`Base.${clean}`)) + : undefined) if (hit) return { href: inv.baseUrl + hit.uri, external: true, project: inv.project } } @@ -554,9 +641,7 @@ export class SymbolIndex { linkDoc(spans: DocSpan[], context?: ApiSymbol): (DocSpan & { link?: LinkTarget })[] { return spans.map((span) => - span.kind === 'ref' - ? { ...span, link: this.resolve(span.target, span.role, context) } - : span, + span.kind === 'ref' ? { ...span, link: this.resolve(span.target, span.role, context) } : span, ) } @@ -576,17 +661,24 @@ export class SymbolIndex { // `::` is part of a name, not punctuation between two. Splitting there // turned `std::vector` into `std` and `vector`, so neither half matched // anything and C++ annotations rendered almost entirely plain. - const identifier = this.lang === 'julia' - ? String.raw`[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*!?` - : this.lang === 'powershell' ? String.raw`(?:[A-Za-z_][A-Za-z0-9_.]*\\)?[A-Za-z_][A-Za-z0-9_.-]*` - : String.raw`[A-Za-z_][A-Za-z0-9_.]*(?:::[A-Za-z_][A-Za-z0-9_.]*)*` - const rawString = this.lang === 'cxx' - ? String.raw`(?:u8|u|U|L)?R"(?[^ ()\\\t\v\f\r\n]{0,16})\([\s\S]*?\)\k"|` : '' + const identifier = + this.lang === 'julia' + ? String.raw`[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*!?` + : this.lang === 'powershell' + ? String.raw`(?:[A-Za-z_][A-Za-z0-9_.]*\\)?[A-Za-z_][A-Za-z0-9_.-]*` + : String.raw`[A-Za-z_][A-Za-z0-9_.]*(?:::[A-Za-z_][A-Za-z0-9_.]*)*` + const rawString = + this.lang === 'cxx' + ? String.raw`(?:u8|u|U|L)?R"(?[^ ()\\\t\v\f\r\n]{0,16})\([\s\S]*?\)\k"|` + : '' const pattern = String.raw`(?${rawString}\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|${identifier})|(?[^A-Za-z_"'/]+|["'/])` // F# type variables start with an apostrophe but do not close like strings. - const re = new RegExp(this.lang === 'fsharp' - ? pattern.replace('(?', String.raw`(?'[A-Za-z_][A-Za-z0-9_]*(?![A-Za-z0-9_'])|`) - : pattern, 'g') + const re = new RegExp( + this.lang === 'fsharp' + ? pattern.replace('(?', String.raw`(?'[A-Za-z_][A-Za-z0-9_]*(?![A-Za-z0-9_'])|`) + : pattern, + 'g', + ) for (const m of annotation.matchAll(re)) { const whole = m[0] const { ident, other } = m.groups! @@ -598,9 +690,12 @@ export class SymbolIndex { out.push({ text: ident }) continue } - if (this.lang === 'cxx' && ident === 'nodiscard' && - /\[\[\s*$/.test(annotation.slice(0, m.index)) && - /^\s*(?:\]\]|\()/.test(annotation.slice(m.index! + ident.length))) { + if ( + this.lang === 'cxx' && + ident === 'nodiscard' && + /\[\[\s*$/.test(annotation.slice(0, m.index)) && + /^\s*(?:\]\]|\()/.test(annotation.slice(m.index! + ident.length)) + ) { out.push({ text: ident, keyword: true, help: KEYWORD_HELP.cxx.nodiscard }) continue } @@ -611,9 +706,14 @@ export class SymbolIndex { } const parameter = annotation[m.index! - 1] === '-' && signature.params.find((param) => param.name === ident) if (parameter) { - out.push({ text: ident, declaration: true, link: { - href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, parameter.name))}`, external: false, - } }) + out.push({ + text: ident, + declaration: true, + link: { + href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, parameter.name))}`, + external: false, + }, + }) continue } } @@ -622,10 +722,18 @@ export class SymbolIndex { out.push({ text: ident, declaration: true }) continue } - if (signature.params.some((param) => param.name === ident) && /^\s*(?:[,)=]|\[)/.test(annotation.slice(m.index! + ident.length))) { - out.push({ text: ident, declaration: true, link: { - href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, ident))}`, external: false, - } }) + if ( + signature.params.some((param) => param.name === ident) && + /^\s*(?:[,)=]|\[)/.test(annotation.slice(m.index! + ident.length)) + ) { + out.push({ + text: ident, + declaration: true, + link: { + href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, ident))}`, + external: false, + }, + }) continue } } @@ -644,31 +752,53 @@ export class SymbolIndex { out.push({ text: ident, keyword: true, ...(help ? { help } : {}) }) continue } - if (signature && context && (ident === context.name || ident.endsWith(`.${context.name}`)) && - (/\b(?:def|fun|class|interface|trait|object|type|val|var|member|let)\s+$/.test(annotation.slice(0, m.index)) || - (this.lang === 'julia' && annotation.slice(0, m.index).trim() === '' && /^[{(]/.test(annotation.slice(m.index! + ident.length))))) { + if ( + signature && + context && + (ident === context.name || ident.endsWith(`.${context.name}`)) && + (/\b(?:def|fun|class|interface|trait|object|type|val|var|member|let)\s+$/.test(annotation.slice(0, m.index)) || + (this.lang === 'julia' && + annotation.slice(0, m.index).trim() === '' && + /^[{(]/.test(annotation.slice(m.index! + ident.length)))) + ) { const receiver = ident.slice(0, -context.name.length).replace(/\.$/, '') if (receiver) out.push(...this.linkType(receiver, context), { text: '.' }) out.push({ text: context.name, declaration: true }) continue } - if (signature && ( - ((signature.params.some((param) => param.name === ident) || signature.receiver?.name === ident) && + if ( + signature && + (((signature.params.some((param) => param.name === ident) || signature.receiver?.name === ident) && (/^\s*\??\s*:(?!:)/.test(annotation.slice(m.index! + ident.length)) || - (this.lang === 'julia' && /(?:^|[(,;])\s*$/.test(annotation.slice(0, m.index)) && + (this.lang === 'julia' && + /(?:^|[(,;])\s*$/.test(annotation.slice(0, m.index)) && /^\s*(?:::|[=,;)]|\.\.\.)/.test(annotation.slice(m.index! + ident.length))))) || - signature.typeParams?.includes(ident) - )) { + signature.typeParams?.includes(ident)) + ) { const parameter = this.lang === 'julia' && context && signature.params.some((param) => param.name === ident) - out.push({ text: ident, declaration: true, ...(parameter ? { link: { - href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, ident))}`, external: false, - } } : {}) }) + out.push({ + text: ident, + declaration: true, + ...(parameter + ? { + link: { + href: `${this.hrefFor(context).split('#')[0]}#${encodeURIComponent(parameterId(context, ident))}`, + external: false, + }, + } + : {}), + }) continue } // Object fields, callback parameters and tuple labels declare names; // the types after their colons still use ordinary reference resolution. - if (this.lang === 'ts' && /^\s*\??\s*:(?!:)/.test(annotation.slice(m.index! + ident.length)) && - /(?:^|[{[(;,])(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*)*(?:readonly\s+)?(?:\.\.\.)?$/.test(annotation.slice(0, m.index))) { + if ( + this.lang === 'ts' && + /^\s*\??\s*:(?!:)/.test(annotation.slice(m.index! + ident.length)) && + /(?:^|[{[(;,])(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*)*(?:readonly\s+)?(?:\.\.\.)?$/.test( + annotation.slice(0, m.index), + ) + ) { out.push({ text: ident, declaration: true }) continue } diff --git a/packages/api-model/src/member-order.ts b/packages/api-model/src/member-order.ts index eacce986b..346cbe626 100644 --- a/packages/api-model/src/member-order.ts +++ b/packages/api-model/src/member-order.ts @@ -71,9 +71,11 @@ export function memberSignals( // A member can realise several concepts; the broadest one speaks for it. for (const [variant, target] of [symbol, ...(concept.variants?.[port] ?? [])].entries()) { if (id.startsWith('list-') && !listingConcepts.has(target)) listingConcepts.set(target, id) - if (concept.parentObject) parentObjects.set(target, { - level: ['window', 'session', 'server'].indexOf(concept.parentObject), variant, - }) + if (concept.parentObject) + parentObjects.set(target, { + level: ['window', 'session', 'server'].indexOf(concept.parentObject), + variant, + }) if ((conceptPorts.get(target) ?? 0) < ports) { conceptPorts.set(target, ports) conceptIds.set(target, id) @@ -89,7 +91,11 @@ export function memberSignals( const idOf = (s: ApiSymbol) => s.publicId ?? s.id /** Include explicit public re-exports without inferring access from private bases. */ -export function membersOf(model: ApiModelBase & { port?: string }, owner: ApiSymbol, signals = memberSignals(model.port ?? '')): ApiSymbol[] { +export function membersOf( + model: ApiModelBase & { port?: string }, + owner: ApiSymbol, + signals = memberSignals(model.port ?? ''), +): ApiSymbol[] { const members = model.symbols.filter((symbol) => symbol.parent === owner.id) for (const id of signals.inheritedParents.get(idOf(owner)) ?? []) { const source = model.symbols.find((symbol) => idOf(symbol) === id) @@ -165,7 +171,8 @@ export function compareMembers(signals: MemberSignals): (a: ApiSymbol, b: ApiSym rank(a) - rank(b) || (memberTier(a, signals) === 'parent' ? signals.parentObjects.get(idOf(a))!.level - signals.parentObjects.get(idOf(b))!.level || - signals.parentObjects.get(idOf(a))!.variant - signals.parentObjects.get(idOf(b))!.variant : 0) || + signals.parentObjects.get(idOf(a))!.variant - signals.parentObjects.get(idOf(b))!.variant + : 0) || Number(Boolean(a.inheritedFrom)) - Number(Boolean(b.inheritedFrom)) || (signals.conceptPorts.get(idOf(b)) ?? 0) - (signals.conceptPorts.get(idOf(a)) ?? 0) || (signals.conceptOrder.get(idOf(a)) ?? Infinity) - (signals.conceptOrder.get(idOf(b)) ?? Infinity) || diff --git a/packages/api-model/src/mentions.ts b/packages/api-model/src/mentions.ts index 9ab2ee0c6..3a043ef16 100644 --- a/packages/api-model/src/mentions.ts +++ b/packages/api-model/src/mentions.ts @@ -47,10 +47,7 @@ function splitRow(line: string): string[] { return cells } -export function tableMentions( - markdown: string, - portByLabel: Record, -): TableMention[] { +export function tableMentions(markdown: string, portByLabel: Record): TableMention[] { const out: TableMention[] = [] const lines = markdown.split('\n') @@ -87,14 +84,33 @@ export interface ProseMention { } const FENCE_PORT: Record = { - python: 'py', py: 'py', typescript: 'ts', ts: 'ts', javascript: 'ts', js: 'ts', - rust: 'rs', rs: 'rs', go: 'go', java: 'java', csharp: 'csharp', cs: 'csharp', - cpp: 'cxx', 'c++': 'cxx', swift: 'swift', - powershell: 'powershell', pwsh: 'powershell', ps1: 'powershell', julia: 'julia', + python: 'py', + py: 'py', + typescript: 'ts', + ts: 'ts', + javascript: 'ts', + js: 'ts', + rust: 'rs', + rs: 'rs', + go: 'go', + java: 'java', + csharp: 'csharp', + cs: 'csharp', + cpp: 'cxx', + 'c++': 'cxx', + swift: 'swift', + powershell: 'powershell', + pwsh: 'powershell', + ps1: 'powershell', + julia: 'julia', } /** Inline references in prose, including port sections, tables, and existing links. */ -export function proseMentions(markdown: string, portByLabel: Record, portAt?: (offset: number) => string | undefined): ProseMention[] { +export function proseMentions( + markdown: string, + portByLabel: Record, + portAt?: (offset: number) => string | undefined, +): ProseMention[] { const out: ProseMention[] = [] const lines = markdown.split('\n') const context: { port?: string; before: string }[] = [] @@ -107,7 +123,10 @@ export function proseMentions(markdown: string, portByLabel: Record a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)), })) - .sort( - (a, b) => - a.name.split(/[.:]+/).length - b.name.split(/[.:]+/).length || - a.name.localeCompare(b.name), - ) + .sort((a, b) => a.name.split(/[.:]+/).length - b.name.split(/[.:]+/).length || a.name.localeCompare(b.name)) } diff --git a/packages/api-model/src/nav-config.ts b/packages/api-model/src/nav-config.ts index fb784f67c..d775e2063 100644 --- a/packages/api-model/src/nav-config.ts +++ b/packages/api-model/src/nav-config.ts @@ -16,7 +16,6 @@ import type { Bucket, Match, PortNav } from './nav.ts' * that hang off them, then the machinery, then the auxiliary trees. */ - const nameRe = (re: string): Match => ({ kind: 'name', re }) /** @@ -44,7 +43,10 @@ const words = (...is: string[]): Match => ({ { kind: 'anyOf', // `libtmux::pane::id` is a pane field whose own name says nothing. - of: [{ kind: 'word', is }, { kind: 'module', re: `(^|[.:/])(${is.join('|')})$` }], + of: [ + { kind: 'word', is }, + { kind: 'module', re: `(^|[.:/])(${is.join('|')})$` }, + ], }, ], }) @@ -83,7 +85,10 @@ const hierarchy = (re: string, pathRe: string, ...vocabulary: string[]): Match = nameRe(re), { kind: 'allOf', - of: [{ kind: 'path', re: pathRe }, { kind: 'not', of: nameRe(Object.values(ATTACHED_NAMES).join('|')) }], + of: [ + { kind: 'path', re: pathRe }, + { kind: 'not', of: nameRe(Object.values(ATTACHED_NAMES).join('|')) }, + ], }, words(...vocabulary), ], @@ -175,7 +180,16 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ match: { kind: 'allOf', of: [ - nameOrPath('Test|Fixture|Mock|Fake', 'test|fixture|mock|junit', 'test', 'tests', 'testing', 'fixture', 'mock', 'fake'), + nameOrPath( + 'Test|Fixture|Mock|Fake', + 'test|fixture|mock|junit', + 'test', + 'tests', + 'testing', + 'fixture', + 'mock', + 'fake', + ), { kind: 'not', of: { kind: 'allOf', of: [FREE, nameRe('^logger$')] } }, ], }, @@ -226,9 +240,10 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ of: [ { kind: 'anyOf', - of: ['Kind', 'Mode', 'Style', 'Action', 'Options', 'Result', 'Event'].map( - (suffix): Match => ({ kind: 'name', suffix }), - ), + of: ['Kind', 'Mode', 'Style', 'Action', 'Options', 'Result', 'Event'].map((suffix): Match => ({ + kind: 'name', + suffix, + })), }, // Suffixes that name a type. `normalizePlanWorkspaceOptions` is a // function that takes one. @@ -247,7 +262,11 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ nameOrPath( 'Control|Notification|Subscribe', '(^|/)control', - 'control', 'notification', 'notifications', 'subscribe', 'subscription', + 'control', + 'notification', + 'notifications', + 'subscribe', + 'subscription', ), freePath('(^|/)notification\\.hpp$'), ], @@ -287,7 +306,16 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ nameOrPath( 'Query|Filter|Predicate|Matcher|Criteria|Operator|Quantifier|Expr|Node$|Field$|^(Server|Session|Window|Pane|Client)Fields$', '(^|/)quer(y|ies)[./]|(^|/)matching|(^|/)filter', - 'query', 'queries', 'filter', 'predicate', 'matcher', 'criteria', 'where', 'relation', 'field', 'fields', + 'query', + 'queries', + 'filter', + 'predicate', + 'matcher', + 'criteria', + 'where', + 'relation', + 'field', + 'fields', ), // The query language's own files, whose helpers are `any_of`, // `children_of` and `WhereOf`. @@ -321,7 +349,13 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ // TypeScript's `paneStartDirectory` reads a workspace file's pane. { kind: 'allOf', - of: [FREE, { kind: 'path', re: '(^|/)(packages/|crates/tmux-)?workspace/|libtmux_consumers/|(^|/)packages/LibTmuxWorkspace/' }], + of: [ + FREE, + { + kind: 'path', + re: '(^|/)(packages/|crates/tmux-)?workspace/|libtmux_consumers/|(^|/)packages/LibTmuxWorkspace/', + }, + ], }, ], }, @@ -343,14 +377,23 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ label: 'Keys and bindings', match: nameOrPath(ATTACHED_NAMES.keys, '(^|/)(keys?|binding)', 'key', 'keys', 'binding'), }, - { id: 'buffers', label: 'Buffers', match: nameOrPath(ATTACHED_NAMES.buffers, '(^|/)buffers?[./]', 'buffer', 'buffers') }, + { + id: 'buffers', + label: 'Buffers', + match: nameOrPath(ATTACHED_NAMES.buffers, '(^|/)buffers?[./]', 'buffer', 'buffers'), + }, { id: 'layout', label: 'Layout and geometry', match: nameOrPath( ATTACHED_NAMES.layout, '(^|/)layouts?[./]', - 'layout', 'split', 'resize', 'direction', 'dimension', 'rotation', + 'layout', + 'split', + 'resize', + 'direction', + 'dimension', + 'rotation', ), }, { @@ -366,7 +409,12 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ { id: 'version', label: 'Versions', - match: nameOrPath(ATTACHED_NAMES.version, '(^|/)versions?[./]|(^|/)__about__\\.|(^|/)build_info\\.go$', 'version', 'release'), + match: nameOrPath( + ATTACHED_NAMES.version, + '(^|/)versions?[./]|(^|/)__about__\\.|(^|/)build_info\\.go$', + 'version', + 'release', + ), }, { id: 'commands', @@ -374,7 +422,14 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ match: nameOrPath( ATTACHED_NAMES.commands, '(^|/)(commands?|dispatch|transport|connection|process)|(^|/)libtmux/endpoint\\.rb$', - 'command', 'commands', 'cmd', 'dispatch', 'transport', 'connection', 'process', 'engine', + 'command', + 'commands', + 'cmd', + 'dispatch', + 'transport', + 'connection', + 'process', + 'engine', ), }, ] @@ -433,9 +488,7 @@ const COLLAPSED = new Set(['requests', 'errors', 'constants', 'mcp', 'testing', /** "Matches mine, and none of the rules that outrank mine." */ function disjoint(index: number): Match { const above = CHAIN.slice(0, index).map((c): Match => ({ kind: 'not', of: c.match })) - return above.length === 0 - ? CHAIN[index]!.match - : { kind: 'allOf', of: [CHAIN[index]!.match, ...above] } + return above.length === 0 ? CHAIN[index]!.match : { kind: 'allOf', of: [CHAIN[index]!.match, ...above] } } /** @@ -574,19 +627,23 @@ const OVERRIDES: Record }> = { julia: { unsettled: { LibTmux: 'package module spanning all task buckets; its exported declarations have their own placements', - 'LibTmux.EntityRef': 'target identity shared by every entity, owned by none; concrete references sit with their entity', + 'LibTmux.EntityRef': + 'target identity shared by every entity, owned by none; concrete references sit with their entity', 'LibTmux.TmuxID': 'typed tmux IDs shared by every entity; concrete ID aliases sit with their entity', 'LibTmux.entitykey': 'identity keys shared by references, captured entities, window links and pane occurrences', 'LibTmux.Base.==': 'one equality extension page for server identities and several entity ID and reference types', 'LibTmux.Base.hash': 'one hashing extension page for server identities and several entity ID and reference types', - 'LibTmux.Base.isequal': 'one equality extension page for server identities and several entity ID and reference types', + 'LibTmux.Base.isequal': + 'one equality extension page for server identities and several entity ID and reference types', 'LibTmux.Base.string': 'one string conversion extension page for several entity ID types', - 'LibTmux.Base.show': 'one display extension page shared by IDs, servers, snapshots, control connections and observation streams', + 'LibTmux.Base.show': + 'one display extension page shared by IDs, servers, snapshots, control connections and observation streams', }, }, lua: { unsettled: { - 'libtmux.Configurable': 'options and hooks shared by sessions, windows and panes; the concrete handle is the subclass', + 'libtmux.Configurable': + 'options and hooks shared by sessions, windows and panes; the concrete handle is the subclass', 'libtmux.Creation': 'creation receipt shared by several tmux objects', 'libtmux.Entity': 'base of the tmux entity hierarchy; the concrete entity is the subclass', 'libtmux.LinkDestination': 'window-link target shared by sessions and windows', @@ -606,8 +663,7 @@ const OVERRIDES: Record }> = { py: { unsettled: { 'libtmux._vendor._structures.InfinityType': 'vendored from packaging; version comparison, not tmux', - 'libtmux._vendor._structures.NegativeInfinityType': - 'vendored from packaging; version comparison, not tmux', + 'libtmux._vendor._structures.NegativeInfinityType': 'vendored from packaging; version comparison, not tmux', 'libtmux.constants._DefaultOptionScope': 'private sentinel for an option scope left unset', 'libtmux.neo.Obj': 'base of the neo object layer; the tmux entity is the subclass', 'libtmux.__all__': 'the package export list, not an API of its own', @@ -649,8 +705,7 @@ const OVERRIDES: Record }> = { 'config.Entry': 'workspace config entry; Workspaces covers the plan, not the file it parses', 'target.EndpointInputs': 'socket endpoint resolution; sits below Server rather than inside it', 'target.ResolvedEndpoint': 'socket endpoint resolution; sits below Server rather than inside it', - 'target.ResolvedSocketSelector': - 'socket endpoint resolution; sits below Server rather than inside it', + 'target.ResolvedSocketSelector': 'socket endpoint resolution; sits below Server rather than inside it', 'target.OsString': 'extractor leak: std::ffi::OsString is not declared by this crate', 'src.DesignNotes': 'extractor leak: a doc-only module surfaced as a struct', 'src.Findings': 'extractor leak: a doc-only module surfaced as a struct', @@ -681,10 +736,8 @@ const OVERRIDES: Record }> = { 'io.github.libtmux.FindSpec.FindSpec': 'target addressing; used by every entity, owned by none', 'io.github.libtmux.TargetIds.TargetIds': 'target addressing; used by every entity, owned by none', 'io.github.libtmux.batch.Batch.Batch': 'batch execution; no tmux object of its own', - 'io.github.libtmux.batch.OperationOutcome.OperationOutcome': - 'batch execution; no tmux object of its own', - 'io.github.libtmux.jackson.LibTmuxModels.LibTmuxModels': - 'Jackson serialisation registration, not tmux API', + 'io.github.libtmux.batch.OperationOutcome.OperationOutcome': 'batch execution; no tmux object of its own', + 'io.github.libtmux.jackson.LibTmuxModels.LibTmuxModels': 'Jackson serialisation registration, not tmux API', }, }, csharp: { @@ -716,8 +769,7 @@ const OVERRIDES: Record }> = { OutputWait: 'wait and watch plumbing', SubscriptionChange: 'wait and watch plumbing', RegexPattern: 'regex translation for query matching; below Queries rather than inside it', - RegexUnsupportedConstruct: - 'regex translation for query matching; below Queries rather than inside it', + RegexUnsupportedConstruct: 'regex translation for query matching; below Queries rather than inside it', }, }, } @@ -747,29 +799,51 @@ const LANGUAGE_PLACEMENTS: Record> = { snapshots: ['hascoverage', 'snapshotof', 'Base.getproperty', 'Base.propertynames'].map((name) => `LibTmux.${name}`), // Generic Base methods here belong only to ObservationStream. Keep its // pane and format event payloads in their existing domain buckets. - control: ['ObservationCursor', 'ObservationStream', 'observation_cursor', 'observe_output', - 'Base.IteratorSize', 'Base.eltype', 'Base.iterate', 'Base.take!'].map((name) => `LibTmux.${name}`), - pane: ['paneoccurrences', 'occurrencekey', 'paste_bytes', 'paste_text', 'TextDecoder', 'decode!', 'decode_text'] - .map((name) => `LibTmux.${name}`), + control: [ + 'ObservationCursor', + 'ObservationStream', + 'observation_cursor', + 'observe_output', + 'Base.IteratorSize', + 'Base.eltype', + 'Base.iterate', + 'Base.take!', + ].map((name) => `LibTmux.${name}`), + pane: [ + 'paneoccurrences', + 'occurrencekey', + 'paste_bytes', + 'paste_text', + 'TextDecoder', + 'decode!', + 'decode_text', + ].map((name) => `LibTmux.${name}`), window: ['LibTmux.windowlinks'], commands: ['run_batch', 'run_group'].map((name) => `LibTmux.${name}`), workspace: ['LibTmuxWorkspace.ConfigLimits'], // Native subtyping establishes these exceptions despite their names. errors: ['CrossServerReference', 'StaleReference', 'ObservationLost', 'UnsupportedCriterion', 'Base.showerror'] - .map((name) => `LibTmux.${name}`).concat('LibTmuxWorkspace.Base.showerror'), + .map((name) => `LibTmux.${name}`) + .concat('LibTmuxWorkspace.Base.showerror'), }, java: { commands: ['MessageLog', 'PrintedText', 'Prompt', 'Shell'].map((name) => `io.github.libtmux.${name}.${name}`), server: ['io.github.libtmux.IncarnationFence.IncarnationFence'], }, kotlin: { - commands: ['Batch', 'ExecutionPolicy', 'MessageLog', 'Prompt', 'Shell', 'retryIfSafe'].map((name) => `io.github.libtmux.kotlin.${name}`), + commands: ['Batch', 'ExecutionPolicy', 'MessageLog', 'Prompt', 'Shell', 'retryIfSafe'].map( + (name) => `io.github.libtmux.kotlin.${name}`, + ), server: ['io.github.libtmux.kotlin.LibTmuxDsl'], control: ['io.github.libtmux.Channel.await', 'io.github.libtmux.Channel.awaitReservingCapacity'], }, scala: { - snapshots: ['io.github.libtmux.scaladsl.live.LiveView', 'io.github.libtmux.scaladsl.streaming.Observation', - 'io.github.libtmux.scaladsl.cats.Instances', 'io.github.libtmux.scaladsl.cats.Observation'], + snapshots: [ + 'io.github.libtmux.scaladsl.live.LiveView', + 'io.github.libtmux.scaladsl.streaming.Observation', + 'io.github.libtmux.scaladsl.cats.Instances', + 'io.github.libtmux.scaladsl.cats.Observation', + ], commands: ['Batch', 'Shell', 'MessageLog', 'Prompt'].map((name) => `io.github.libtmux.scaladsl.cats.${name}`), control: ['io.github.libtmux.scaladsl.cats.Channel', 'io.github.libtmux.scaladsl.ox.Flows'], }, @@ -785,33 +859,146 @@ const LANGUAGE_PLACEMENTS: Record> = { /** Free functions and cmdlets need the same discovery-first order as object members. */ const LANGUAGE_ORDER: Record> = { - julia: Object.fromEntries(Object.entries({ - server: ['Server', 'open_server', 'with_server', 'from_env', 'OwnedServer', 'ServerIdentity'], - session: ['sessions', 'session', 'new_session', 'rename_session', 'kill_session', 'SessionRef', 'SessionID', 'SessionWhere'], - window: ['windows', 'window', 'new_window', 'split_window', 'select_window', 'rename_window', 'resize_window', 'link_window', 'unlink_window', 'move_window', 'swap_window', 'respawn_window', 'kill_window', 'WindowRef', 'WindowLinkRef', 'WindowID', 'WindowLink', 'WindowWhere', 'WindowLinkWhere'], - pane: ['panes', 'select_pane', 'resize_pane', 'move_pane', 'swap_pane', 'respawn_pane', 'kill_pane', 'PaneRef', 'PaneID', 'PaneOccurrence', 'PaneOutput', 'PaneWhere'], - client: ['clients', 'switch_client', 'detach_client', 'ClientRef', 'ClientID', 'ClientWhere'], - hooks: ['get_hook', 'set_hook', 'unset_hook', 'HookCommand'], - 'options-functions': ['get_option', 'set_option', 'unset_option'], - buffers: ['load_buffer', 'save_buffer', 'delete_buffer', 'BufferRef', 'BufferID'], - environment: ['get_environment', 'set_environment', 'unset_environment', 'remove_environment', 'EnvironmentValue'], - snapshots: ['snapshot', 'Snapshot', 'capture_pane', 'capture_bytes', 'SessionSnapshot', 'WindowSnapshot', 'PaneSnapshot', 'ClientSnapshot', 'InconsistentSnapshot'], - control: ['open_control', 'ControlConnection', 'notifications', 'capture_baseline', 'subscribe_format', 'control_signal', 'Base.isopen', 'Base.wait', 'Base.notify', 'ObservationBaseline', 'ControlSignal'], - commands: ['TmuxCommand', 'run_command', 'CancellationToken', 'cancel!', 'iscancelled', 'on_cancel', 'Base.close', 'CancellationSubscription', 'RequestCancelled', 'DeadlineExceeded', 'OutputLimitExceeded', 'TmuxNotFound'], - }).map(([bucket, names]) => [bucket, names.map((name) => `LibTmux.${name}`)])), - powershell: Object.fromEntries(Object.entries({ - server: ['New-TmuxServer', 'Get-TmuxServer', 'Connect-TmuxServer'], - session: ['Get-TmuxSession', 'Select-TmuxSession', 'New-TmuxSession', 'Enter-TmuxSession', 'Remove-TmuxSession'], - window: ['Get-TmuxWindow', 'Select-TmuxWindow', 'New-TmuxWindow', 'New-TmuxWindowLink', 'Set-TmuxWindowSize', 'Move-TmuxWindow', 'Remove-TmuxWindowLink', 'Remove-TmuxWindow'], - pane: ['Get-TmuxPane', 'Select-TmuxPane', 'Get-TmuxPaneContent', 'Split-TmuxPane', 'Send-TmuxText', 'Invoke-TmuxPaneCommand', 'Wait-TmuxPaneText', 'Update-TmuxPane', 'Set-TmuxPaneSize', 'Remove-TmuxPane'], - client: ['Get-TmuxClient', 'Get-TmuxClientAttachment', 'Update-TmuxClient'], - hooks: ['Get-TmuxHook', 'Set-TmuxHook', 'Invoke-TmuxHook', 'Remove-TmuxHook'], - 'options-functions': ['Get-TmuxOption', 'Set-TmuxOption', 'Remove-TmuxOption'], - environment: ['Get-TmuxEnvironment', 'Set-TmuxEnvironment', 'Remove-TmuxEnvironment'], - commands: ['New-TmuxCommand', 'Invoke-TmuxCommand'], - control: ['Connect-TmuxControl', 'Watch-TmuxEvent', 'Wait-TmuxChannel', 'Invoke-TmuxControlCommand', 'Disconnect-TmuxControl'], - queries: ['New-TmuxQuery', 'Get-TmuxQueryPlan', 'Invoke-TmuxQuery', 'ConvertTo-TmuxQueryJson'], - }).map(([bucket, names]) => [bucket, names.map((name) => `LibTmux\\${name}`)])), + julia: Object.fromEntries( + Object.entries({ + server: ['Server', 'open_server', 'with_server', 'from_env', 'OwnedServer', 'ServerIdentity'], + session: [ + 'sessions', + 'session', + 'new_session', + 'rename_session', + 'kill_session', + 'SessionRef', + 'SessionID', + 'SessionWhere', + ], + window: [ + 'windows', + 'window', + 'new_window', + 'split_window', + 'select_window', + 'rename_window', + 'resize_window', + 'link_window', + 'unlink_window', + 'move_window', + 'swap_window', + 'respawn_window', + 'kill_window', + 'WindowRef', + 'WindowLinkRef', + 'WindowID', + 'WindowLink', + 'WindowWhere', + 'WindowLinkWhere', + ], + pane: [ + 'panes', + 'select_pane', + 'resize_pane', + 'move_pane', + 'swap_pane', + 'respawn_pane', + 'kill_pane', + 'PaneRef', + 'PaneID', + 'PaneOccurrence', + 'PaneOutput', + 'PaneWhere', + ], + client: ['clients', 'switch_client', 'detach_client', 'ClientRef', 'ClientID', 'ClientWhere'], + hooks: ['get_hook', 'set_hook', 'unset_hook', 'HookCommand'], + 'options-functions': ['get_option', 'set_option', 'unset_option'], + buffers: ['load_buffer', 'save_buffer', 'delete_buffer', 'BufferRef', 'BufferID'], + environment: [ + 'get_environment', + 'set_environment', + 'unset_environment', + 'remove_environment', + 'EnvironmentValue', + ], + snapshots: [ + 'snapshot', + 'Snapshot', + 'capture_pane', + 'capture_bytes', + 'SessionSnapshot', + 'WindowSnapshot', + 'PaneSnapshot', + 'ClientSnapshot', + 'InconsistentSnapshot', + ], + control: [ + 'open_control', + 'ControlConnection', + 'notifications', + 'capture_baseline', + 'subscribe_format', + 'control_signal', + 'Base.isopen', + 'Base.wait', + 'Base.notify', + 'ObservationBaseline', + 'ControlSignal', + ], + commands: [ + 'TmuxCommand', + 'run_command', + 'CancellationToken', + 'cancel!', + 'iscancelled', + 'on_cancel', + 'Base.close', + 'CancellationSubscription', + 'RequestCancelled', + 'DeadlineExceeded', + 'OutputLimitExceeded', + 'TmuxNotFound', + ], + }).map(([bucket, names]) => [bucket, names.map((name) => `LibTmux.${name}`)]), + ), + powershell: Object.fromEntries( + Object.entries({ + server: ['New-TmuxServer', 'Get-TmuxServer', 'Connect-TmuxServer'], + session: ['Get-TmuxSession', 'Select-TmuxSession', 'New-TmuxSession', 'Enter-TmuxSession', 'Remove-TmuxSession'], + window: [ + 'Get-TmuxWindow', + 'Select-TmuxWindow', + 'New-TmuxWindow', + 'New-TmuxWindowLink', + 'Set-TmuxWindowSize', + 'Move-TmuxWindow', + 'Remove-TmuxWindowLink', + 'Remove-TmuxWindow', + ], + pane: [ + 'Get-TmuxPane', + 'Select-TmuxPane', + 'Get-TmuxPaneContent', + 'Split-TmuxPane', + 'Send-TmuxText', + 'Invoke-TmuxPaneCommand', + 'Wait-TmuxPaneText', + 'Update-TmuxPane', + 'Set-TmuxPaneSize', + 'Remove-TmuxPane', + ], + client: ['Get-TmuxClient', 'Get-TmuxClientAttachment', 'Update-TmuxClient'], + hooks: ['Get-TmuxHook', 'Set-TmuxHook', 'Invoke-TmuxHook', 'Remove-TmuxHook'], + 'options-functions': ['Get-TmuxOption', 'Set-TmuxOption', 'Remove-TmuxOption'], + environment: ['Get-TmuxEnvironment', 'Set-TmuxEnvironment', 'Remove-TmuxEnvironment'], + commands: ['New-TmuxCommand', 'Invoke-TmuxCommand'], + control: [ + 'Connect-TmuxControl', + 'Watch-TmuxEvent', + 'Wait-TmuxChannel', + 'Invoke-TmuxControlCommand', + 'Disconnect-TmuxControl', + ], + queries: ['New-TmuxQuery', 'Get-TmuxQueryPlan', 'Invoke-TmuxQuery', 'ConvertTo-TmuxQueryJson'], + }).map(([bucket, names]) => [bucket, names.map((name) => `LibTmux\\${name}`)]), + ), } /** Julia reads collections from an explicit snapshot, not from Server methods. */ @@ -823,8 +1010,7 @@ const LANGUAGE_SHORTCUTS: Record> = { function languageBucket(bucket: Bucket, port: string, root = bucket.id): Bucket { const placements = LANGUAGE_PLACEMENTS[port] ?? {} const ids = bucket.id === root ? placements[root] : undefined - const elsewhere = Object.entries(placements) - .flatMap(([id, symbols]) => id === root ? [] : symbols) + const elsewhere = Object.entries(placements).flatMap(([id, symbols]) => (id === root ? [] : symbols)) const match: Match = elsewhere.length ? { kind: 'allOf', of: [bucket.match, { kind: 'not', of: { kind: 'id', is: elsewhere } }] } : bucket.match @@ -838,7 +1024,23 @@ function languageBucket(bucket: Bucket, port: string, root = bucket.id): Bucket } export const NAV: Record = Object.fromEntries( - ['powershell', 'julia', 'py', 'ruby', 'lua', 'ts', 'rs', 'go', 'java', 'kotlin', 'scala', 'fsharp', 'csharp', 'cxx', 'swift'].map((port) => [ + [ + 'powershell', + 'julia', + 'py', + 'ruby', + 'lua', + 'ts', + 'rs', + 'go', + 'java', + 'kotlin', + 'scala', + 'fsharp', + 'csharp', + 'cxx', + 'swift', + ].map((port) => [ port, { port, diff --git a/packages/api-model/src/nav-sidecar.ts b/packages/api-model/src/nav-sidecar.ts index 3cb42f7fe..5af20b076 100644 --- a/packages/api-model/src/nav-sidecar.ts +++ b/packages/api-model/src/nav-sidecar.ts @@ -49,7 +49,9 @@ export function navSidecarFor(scope: string, model: ApiModelBase, nav: NavConfig // type alias has a page, and a sidebar that placed only types gave 1,282 of // those pages no row. const moduleIds = new Set(model.symbols.filter((symbol) => symbol.kind === 'module').map((symbol) => symbol.id)) - const topLevel = model.symbols.filter((s) => !s.parent || (['powershell', 'julia'].includes(scope) && moduleIds.has(s.parent))) + const topLevel = model.symbols.filter( + (s) => !s.parent || (['powershell', 'julia'].includes(scope) && moduleIds.has(s.parent)), + ) const conceptIds = Object.fromEntries( Object.entries(CONCEPTS) .map(([k, c]) => [k, c.symbols[scope]] as const) @@ -74,7 +76,8 @@ export function navSidecarFor(scope: string, model: ApiModelBase, nav: NavConfig const entries = (ids: string[], order: string[] = []) => { const symbols = [...new Set(ids)].map((id) => byId.get(id)).filter((sym): sym is ApiSymbol => sym !== undefined) const ranks = new Map([...new Set(order)].map((id, rank) => [id, rank])) - const ordered = symbols.filter((sym) => ranks.has(sym.publicId ?? sym.id)) + const ordered = symbols + .filter((sym) => ranks.has(sym.publicId ?? sym.id)) .sort((a, b) => ranks.get(a.publicId ?? a.id)! - ranks.get(b.publicId ?? b.id)!) const remaining = symbols.filter((sym) => !ranks.has(sym.publicId ?? sym.id)) const free = remaining @@ -93,8 +96,8 @@ export function navSidecarFor(scope: string, model: ApiModelBase, nav: NavConfig if (shortcuts.length !== new Set(b.shortcuts ?? []).size) { throw new Error(`${scope}: ${b.id} contains an unknown navigation shortcut`) } - const target = b.order && entries(compiled.assignments[b.id] ?? [], b.order) - .find((entry) => b.order!.includes(entry.id)) + const target = + b.order && entries(compiled.assignments[b.id] ?? [], b.order).find((entry) => b.order!.includes(entry.id)) return { id: b.id, label: b.label, diff --git a/packages/api-model/src/nav.ts b/packages/api-model/src/nav.ts index 4adbea80a..5f4c69948 100644 --- a/packages/api-model/src/nav.ts +++ b/packages/api-model/src/nav.ts @@ -301,7 +301,9 @@ export function compileNav(nav: PortNav, symbols: ApiSymbol[], ctx: MatchContext if (id in unsettled) diagnostics.staleUnsettled.push(id) // A parent and its own child both claiming is nesting working, not // ambiguity; two unrelated buckets claiming is the CapturePaneRequest case. - const roots = claimed.filter((n) => !claimed.some((o) => o !== n && n.path.join('/').startsWith(`${o.path.join('/')}/`))) + const roots = claimed.filter( + (n) => !claimed.some((o) => o !== n && n.path.join('/').startsWith(`${o.path.join('/')}/`)), + ) if (roots.length > 1) { diagnostics.ambiguous.push({ id, buckets: roots.map((r) => r.bucket.id) }) } diff --git a/packages/api-model/src/parent-inventory.ts b/packages/api-model/src/parent-inventory.ts index 90e3b639d..14ef18169 100644 --- a/packages/api-model/src/parent-inventory.ts +++ b/packages/api-model/src/parent-inventory.ts @@ -26,4 +26,3 @@ export function parentInventory(model: ApiModel, hrefFor: (symbol: ApiSymbol) => return best.length === 1 ? [best[0].entry] : [] }) } - diff --git a/packages/api-model/src/parser.ts b/packages/api-model/src/parser.ts index 753537f43..9a00a33f0 100644 --- a/packages/api-model/src/parser.ts +++ b/packages/api-model/src/parser.ts @@ -43,9 +43,11 @@ export async function loadLanguage(name: GrammarName): Promise { const hit = cache.get(name) if (hit) return hit // The older bundle predates Scala 3 opaque types, givens, and extensions. - const wasm = require.resolve(name === 'scala' - ? 'tree-sitter-scala/tree-sitter-scala.wasm' - : `tree-sitter-wasms/out/tree-sitter-${GRAMMAR[name]}.wasm`) + const wasm = require.resolve( + name === 'scala' + ? 'tree-sitter-scala/tree-sitter-scala.wasm' + : `tree-sitter-wasms/out/tree-sitter-${GRAMMAR[name]}.wasm`, + ) const lang = await Language.load(wasm) cache.set(name, lang) return lang diff --git a/packages/api-model/src/product-exports.ts b/packages/api-model/src/product-exports.ts index a66caf236..8ff274c2a 100644 --- a/packages/api-model/src/product-exports.ts +++ b/packages/api-model/src/product-exports.ts @@ -20,7 +20,13 @@ export function scopeProductSymbols(symbols: ApiSymbol[], context: ExportContext if (visited.has(file)) return visited.add(file) const text = context.readSource(file) - const names = new Set([...text.matchAll(/^export\s+(?:(?:async|declare|abstract|default)\s+)*(?:function|class|interface|type|const|let|enum)\s+(\w+)/gm)].map((match) => match[1])) + const names = new Set( + [ + ...text.matchAll( + /^export\s+(?:(?:async|declare|abstract|default)\s+)*(?:function|class|interface|type|const|let|enum)\s+(\w+)/gm, + ), + ].map((match) => match[1]), + ) exports.set(file, names) for (const match of text.matchAll(/^export\s+(?:type\s+)?(\*|\{[^}]+\})\s+from\s+['"]([^'"]+)['"]/gm)) { if (!match[2].startsWith('.')) continue @@ -30,7 +36,10 @@ export function scopeProductSymbols(symbols: ApiSymbol[], context: ExportContext const existing = exports.get(target) const named = existing instanceof Set ? existing : new Set() for (const part of match[1].slice(1, -1).split(',')) { - const name = part.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0] + const name = part + .trim() + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] if (name) named.add(name) } exports.set(target, named) @@ -42,10 +51,22 @@ export function scopeProductSymbols(symbols: ApiSymbol[], context: ExportContext for (const entry of context.entries) { exports.set(entry, '*') const text = context.readSource(entry) - for (const match of text.matchAll(/^pub mod (\w+)\s*;/gm)) exports.set(resolve(dirname(entry), `${match[1]}.rs`), '*') + for (const match of text.matchAll(/^pub mod (\w+)\s*;/gm)) + exports.set(resolve(dirname(entry), `${match[1]}.rs`), '*') for (const match of text.matchAll(/^pub use (\w+)::(\*|\{[^}]+\}|\w+)\s*;/gm)) { const target = resolve(dirname(entry), `${match[1]}.rs`) - exports.set(target, match[2] === '*' ? '*' : new Set(match[2].replace(/[{}]/g, '').split(',').map((name) => name.trim().split(/\s+as\s+/)[0]).filter(Boolean))) + exports.set( + target, + match[2] === '*' + ? '*' + : new Set( + match[2] + .replace(/[{}]/g, '') + .split(',') + .map((name) => name.trim().split(/\s+as\s+/)[0]) + .filter(Boolean), + ), + ) } } } @@ -58,12 +79,30 @@ export function scopeProductSymbols(symbols: ApiSymbol[], context: ExportContext if (context.port === 'ts' || context.port === 'rs') { const exported = exports.get(file) publicImport = exported === '*' || Boolean(exported?.has(symbol.name)) - if (context.port === 'rs' && context.entries.some((entry) => new RegExp(`^pub\\s+(?:struct|enum|trait|type|const|(?:async\\s+)?fn)\\s+${escape(symbol.name)}\\b`, 'm').test(context.readSource(entry)))) publicImport = true - if (context.port === 'rs' && new RegExp(`#\\[cfg\\(doctest\\)\\][\\s\\S]*?pub struct ${escape(symbol.name)}\\b`).test(source)) publicImport = false + if ( + context.port === 'rs' && + context.entries.some((entry) => + new RegExp( + `^pub\\s+(?:struct|enum|trait|type|const|(?:async\\s+)?fn)\\s+${escape(symbol.name)}\\b`, + 'm', + ).test(context.readSource(entry)), + ) + ) + publicImport = true + if ( + context.port === 'rs' && + new RegExp(`#\\[cfg\\(doctest\\)\\][\\s\\S]*?pub struct ${escape(symbol.name)}\\b`).test(source) + ) + publicImport = false } else if (context.port === 'java') { - publicImport = new RegExp(`\\bpublic\\s+(?:(?:final|abstract|sealed|static)\\s+)*(?:class|record|interface|enum)\\s+${escape(symbol.name)}\\b`).test(source) + publicImport = new RegExp( + `\\bpublic\\s+(?:(?:final|abstract|sealed|static)\\s+)*(?:class|record|interface|enum)\\s+${escape(symbol.name)}\\b`, + ).test(source) } else if (context.port === 'py') { - publicImport = !symbol.name.startsWith('_') && !/^(?:logger|log)$/.test(symbol.name) && !/(?:^|\/)(?!__init__\.py$)_(?:[^/]+)(?:\/|\.py$)/.test(file) + publicImport = + !symbol.name.startsWith('_') && + !/^(?:logger|log)$/.test(symbol.name) && + !/(?:^|\/)(?!__init__\.py$)_(?:[^/]+)(?:\/|\.py$)/.test(file) } else if (context.port === 'cxx') { publicImport = !/(?:^|::)detail(?:::|$)/.test(symbol.id) } @@ -85,7 +124,17 @@ export function scopeProductSymbols(symbols: ApiSymbol[], context: ExportContext let changed = true while (changed) { changed = false - const signatures = symbols.filter((symbol) => symbol.apiScope !== 'internal').map((symbol) => JSON.stringify({ type: symbol.type, signatures: symbol.signatures, extends: symbol.extends, value: symbol.kind === 'typealias' ? symbol.value : undefined })).join('\n') + const signatures = symbols + .filter((symbol) => symbol.apiScope !== 'internal') + .map((symbol) => + JSON.stringify({ + type: symbol.type, + signatures: symbol.signatures, + extends: symbol.extends, + value: symbol.kind === 'typealias' ? symbol.value : undefined, + }), + ) + .join('\n') for (const symbol of roots) { if (symbol.apiScope !== 'internal' || !TYPES.has(symbol.kind)) continue if (!new RegExp(`\\b${escape(symbol.name)}\\b`).test(signatures)) continue diff --git a/packages/api-model/src/project.ts b/packages/api-model/src/project.ts index e4073bba7..4f6ba7346 100644 --- a/packages/api-model/src/project.ts +++ b/packages/api-model/src/project.ts @@ -33,14 +33,7 @@ import { */ /** Kinds an extension block can attach members to. */ -const EXTENDABLE: ReadonlySet = new Set([ - 'class', - 'interface', - 'struct', - 'enum', - 'trait', - 'exception', -]) +const EXTENDABLE: ReadonlySet = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'exception']) /** * Fold an extension block onto the type it extends. @@ -137,8 +130,7 @@ function mergePartials(symbols: ApiSymbol[]): ApiSymbol[] { out.push(s) continue } - const union = (a?: string[], b?: string[]) => - a || b ? [...new Set([...(a ?? []), ...(b ?? [])])] : undefined + const union = (a?: string[], b?: string[]) => (a || b ? [...new Set([...(a ?? []), ...(b ?? [])])] : undefined) seen.extends = union(seen.extends, s.extends) seen.modifiers = union(seen.modifiers, s.modifiers) as ApiSymbol['modifiers'] @@ -147,8 +139,10 @@ function mergePartials(symbols: ApiSymbol[]): ApiSymbol[] { // a `partial class` is one scope however many files it is written across. // Keeping only the first signature would drop the others invisibly. const fresh = s.signatures.filter( - (sig) => !seen.signatures.some((k) => k.params.length === sig.params.length && - k.params.every((p, i) => p.type === sig.params[i]?.type)), + (sig) => + !seen.signatures.some( + (k) => k.params.length === sig.params.length && k.params.every((p, i) => p.type === sig.params[i]?.type), + ), ) if (fresh.length) { seen.signatures.push(...fresh) @@ -173,10 +167,7 @@ function mergePartials(symbols: ApiSymbol[]): ApiSymbol[] { * eight ports put them in four different places — `_test.go` beside the * source, `src/test/java`, `*.test.ts`, and a sibling `Tests/` project. */ -const LAYOUT: Record< - string, - { ext: string; skip: RegExp; moduleFrom?: 'file' | 'dir' | 'namespace' } -> = { +const LAYOUT: Record = { py: { ext: 'py', skip: /(^|\/)(tests?|conftest)\b|_test\.py$|^test_/ }, ts: { ext: 'ts', skip: /\.test\.ts$|\.spec\.ts$|(^|\/)__tests__\// }, rs: { ext: 'rs', skip: /(^|\/)tests?\// }, @@ -270,7 +261,7 @@ function sourceFiles(root: string, port: string): { file: string; module: string * its own docstring, not its parent's. */ function resolveInheritance(symbols: ApiSymbol[], stopList: string[]): ApiSymbol[] { - const byId = new Map(symbols.map((s) => [s.id, s])); + const byId = new Map(symbols.map((s) => [s.id, s])) // Classes are addressable by bare name as well as by id: a base is written // `class Pane(Obj)`, not `class Pane(libtmux.neo.Obj)`. const byName = new Map() @@ -315,10 +306,12 @@ function resolveInheritance(symbols: ApiSymbol[], stopList: string[]): ApiSymbol ...member, id: `${cls.id}.${member.name}`, parent: cls.id, - ...(cls.qualifiedName === undefined ? {} : { - qualifiedName: `${cls.qualifiedName}.${member.name}`, - namespace: cls.namespace, - }), + ...(cls.qualifiedName === undefined + ? {} + : { + qualifiedName: `${cls.qualifiedName}.${member.name}`, + namespace: cls.namespace, + }), inheritedFrom: member.inheritedFrom ?? baseSym.id, }) } @@ -443,9 +436,7 @@ export async function extractProject(opts: { // Before inheritance, so a base class is one type rather than several. const merged = mergePartials(mergeExtensions(symbols, spec?.extensionKind)) - const resolved = options.inheritedMembers - ? resolveInheritance(merged, options.inheritanceStopList) - : merged + const resolved = options.inheritedMembers ? resolveInheritance(merged, options.inheritanceStopList) : merged // Public paths last: inherited members need their owner to exist first, and // an inherited member's public path comes from the class it landed on, not // from the base it was declared in. Only Python re-exports through @@ -458,9 +449,7 @@ export async function extractProject(opts: { // Pruning happens after public ids resolve: the rule names the path a // consumer would import by, which is `publicId`, not the declaration path. const rules = options.excludePaths - const kept = rules.length - ? withPublic.filter((s) => !rules.some((re) => re.test(s.publicId ?? s.id))) - : withPublic + const kept = rules.length ? withPublic.filter((s) => !rules.some((re) => re.test(s.publicId ?? s.id))) : withPublic const owned = rehomeOrphans(kept) return { diff --git a/packages/api-model/src/prose.ts b/packages/api-model/src/prose.ts index 974dbcc1f..065d50d5a 100644 --- a/packages/api-model/src/prose.ts +++ b/packages/api-model/src/prose.ts @@ -37,7 +37,8 @@ export const OWNER_KINDS: ReadonlySet = new Set([ 'class', 'exception', 'interface', - 'struct', 'union', + 'struct', + 'union', 'trait', 'enum', 'module', @@ -153,8 +154,12 @@ export function portFromSentence(before: string): string | undefined { */ export function isLikelyReference(text: string): boolean { const t = text.trim() - return /\(/.test(t) || /[.:]|->/.test(t) || /^[A-Z][A-Za-z0-9_]*$/.test(t) - || /^(?:[\w.]+\\)?[A-Z][A-Za-z]+-[A-Za-z][\w]*$/.test(t) + return ( + /\(/.test(t) || + /[.:]|->/.test(t) || + /^[A-Z][A-Za-z0-9_]*$/.test(t) || + /^(?:[\w.]+\\)?[A-Z][A-Za-z]+-[A-Za-z][\w]*$/.test(t) + ) } /** @@ -270,9 +275,10 @@ export function decideMention( if (builtin) return { kind: 'link', port, href: builtin, title: `${text}: ${PORT_NAME[port]}`, external: true } } // A known language must never resolve a similarly named API in another port. - if (tried.length) return ambiguous - ? { kind: 'skip', why: 'ambiguous within the stated port; qualify the receiver to link it' } - : { kind: 'unresolved', why: 'not defined in the stated port', tried } + if (tried.length) + return ambiguous + ? { kind: 'skip', why: 'ambiguous within the stated port; qualify the receiver to link it' } + : { kind: 'unresolved', why: 'not defined in the stated port', tried } // Nothing said which language. One claimant is an answer; several are not. const claims: { port: string; decision: MentionDecision }[] = [] @@ -383,9 +389,10 @@ export function decideFilePath( if (hit?.ambiguous) return { kind: 'unresolved', why: `several files named this in ${port} — write the path out` } if (hit) return { kind: 'link', port, path: hit.path, dir: hit.dir } } - if (named || ctx.pagePort) return generic - ? { kind: 'skip', why: 'names a kind of file, not one path' } - : { kind: 'unresolved', why: 'not held by the stated port' } + if (named || ctx.pagePort) + return generic + ? { kind: 'skip', why: 'names a kind of file, not one path' } + : { kind: 'unresolved', why: 'not held by the stated port' } const all = Object.keys(trees).flatMap((port) => { const hit = claims(port) return hit && !hit.ambiguous ? [{ port, path: hit.path, dir: hit.dir }] : [] diff --git a/packages/api-model/src/resolver.ts b/packages/api-model/src/resolver.ts index ad5bd41de..5a6e8586c 100644 --- a/packages/api-model/src/resolver.ts +++ b/packages/api-model/src/resolver.ts @@ -30,7 +30,17 @@ export type Resolution = | { how: 'not-a-symbol'; why: string } /** Kinds that can own members, and therefore act as a scope. */ -const TYPE_KINDS = new Set(['class', 'struct', 'union', 'interface', 'enum', 'trait', 'exception', 'module', 'typealias']) +const TYPE_KINDS = new Set([ + 'class', + 'struct', + 'union', + 'interface', + 'enum', + 'trait', + 'exception', + 'module', + 'typealias', +]) /** * Ecosystems with no `objects.inv` to federate against. @@ -90,8 +100,7 @@ export const DEFAULT_TEMPLATES: UrlTemplate[] = [ project: 'Java SE', langs: ['java'], prefixes: ['Stream.', 'Optional.', 'List.', 'Map.', 'Set.', 'CompletableFuture.'], - href: (q) => - 'https://docs.oracle.com/en/java/javase/21/docs/api/index.html?q=' + encodeURIComponent(q), + href: (q) => 'https://docs.oracle.com/en/java/javase/21/docs/api/index.html?q=' + encodeURIComponent(q), }, ] @@ -107,7 +116,12 @@ export function notASymbol(text: string): string | undefined { if (/^(?:net|v)?\d+\.\d+(?:\.\d+)?(?:[a-z]|[-+][\w.-]+)?$/.test(text)) return 'a version' if (/^\w+(?:\.\w+)+:[\w.-]+(?::[\w.<>{}-]+)?$/.test(text)) return 'a dependency coordinate' if (/\.(?:tsv|html)$/.test(text)) return 'a filename' - if (/\.(py|ts|go|rs|java|kt|kts|scala|cs|fs|fsi|fsproj|properties|cpp|hpp|c|h|lua|rb|swift|jl|ps1|psm1|psd1|md|json|ya?ml|toml|sh)\b/.test(text)) return 'a filename' + if ( + /\.(py|ts|go|rs|java|kt|kts|scala|cs|fs|fsi|fsproj|properties|cpp|hpp|c|h|lua|rb|swift|jl|ps1|psm1|psd1|md|json|ya?ml|toml|sh)\b/.test( + text, + ) + ) + return 'a filename' if (/(^|\s)--?[A-Za-z]/.test(text)) return 'a command-line flag' if (/[=<>!]=|\s[=<>]\s/.test(text)) return 'an expression, not a reference' if (/^new\s/.test(text)) return 'a constructor call' @@ -118,19 +132,21 @@ export function notASymbol(text: string): string | undefined { /** Strip call syntax down to a dotted path. */ export function toPath(text: string): string[] { - return text - .replace(/^(await|try|new)\s+/, '') - .replace(/\(.*$/, '') - .replace(/`\d+/g, '') - .replace(/->/g, '.') - .replace(/::/g, '.') - // Lua's method call, `Session:new_window`. Only between two names, so a - // Swift selector's labels (already cut with the argument list) and a - // `{ key: value }` literal (refused by `notASymbol`) never reach it. - .replace(/(\w):(?=[A-Za-z_])/g, '$1.') - .replace(/\s+/g, '') - .split('.') - .filter(Boolean) + return ( + text + .replace(/^(await|try|new)\s+/, '') + .replace(/\(.*$/, '') + .replace(/`\d+/g, '') + .replace(/->/g, '.') + .replace(/::/g, '.') + // Lua's method call, `Session:new_window`. Only between two names, so a + // Swift selector's labels (already cut with the argument list) and a + // `{ key: value }` literal (refused by `notASymbol`) never reach it. + .replace(/(\w):(?=[A-Za-z_])/g, '$1.') + .replace(/\s+/g, '') + .split('.') + .filter(Boolean) + ) } interface Row { @@ -200,13 +216,14 @@ export class Resolver { this.byMember.set(key, list) } this.byQualified.set(model.port + ' ' + qualified, row) - } // Old anchors remain accepted, including identities that overlap a // constructor's native qualified name. for (const symbol of model.symbols) { this.byQualified.set(model.port + ' ' + (symbol.publicId ?? symbol.id), { - port: model.port, symbol, qualified: qualifiedNameOf(symbol), + port: model.port, + symbol, + qualified: qualifiedNameOf(symbol), }) } } @@ -218,12 +235,7 @@ export class Resolver { * See `SymbolIndex.addInventory` for why the scope is mandatory in spirit: * an unscoped CPython inventory answered for all eight ports. */ - addInventory( - project: string, - baseUrl: string, - entries: InventoryEntry[], - langs?: string[], - ): void { + addInventory(project: string, baseUrl: string, entries: InventoryEntry[], langs?: string[]): void { const map = new Map() for (const e of entries) if (!map.has(e.name)) map.set(e.name, e) this.inventories.push({ project, baseUrl: baseUrl.replace(/\/*$/, '/'), entries: map, langs }) @@ -255,9 +267,7 @@ export class Resolver { * Rust's three `Error` types — still refuse. */ private static preferTypeOverConstructor(rows: Row[]): Row[] { - const types = new Set( - rows.filter((r) => TYPE_KINDS.has(r.symbol.kind)).map((r) => r.symbol.id), - ) + const types = new Set(rows.filter((r) => TYPE_KINDS.has(r.symbol.kind)).map((r) => r.symbol.id)) if (!types.size) return rows const kept = rows.filter((r) => !r.symbol.parent || !types.has(r.symbol.parent)) return kept.length ? kept : rows @@ -284,8 +294,10 @@ export class Resolver { // Unqualified core prose uses the port's primary namespace. A nested // effect facade stays addressable through its explicit namespace. const primary = this.primary[port] - const primaryMember = primary && (!product || product === 'core') - ? this.byQualified.get(`${port} ${primary}.${parts.join('.')}`) : undefined + const primaryMember = + primary && (!product || product === 'core') + ? this.byQualified.get(`${port} ${primary}.${parts.join('.')}`) + : undefined if (primaryMember && primaryMember.symbol.apiScope !== 'internal') { return { how: 'scoped', symbol: primaryMember.symbol, port } } @@ -311,7 +323,10 @@ export class Resolver { // Prose names a variable after its type; both spellings occur. for (const cand of [receiver, receiver[0].toUpperCase() + receiver.slice(1)]) { const owners = this.members(port, cand).filter((r) => TYPE_KINDS.has(r.symbol.kind)) - const scoped = Resolver.preferProduct(candidates.filter((r) => owners.some((o) => r.symbol.parent === o.symbol.id)), product) + const scoped = Resolver.preferProduct( + candidates.filter((r) => owners.some((o) => r.symbol.parent === o.symbol.id)), + product, + ) if (scoped.length === 1) return { how: 'scoped', symbol: scoped[0].symbol, port } if (scoped.length > 1) { const prefix = this.primary[port] @@ -330,7 +345,9 @@ export class Resolver { .pop() ?.trim() if (!bare) continue - const owner = Resolver.preferProduct(this.members(port, bare), product).find((r) => TYPE_KINDS.has(r.symbol.kind)) + const owner = Resolver.preferProduct(this.members(port, bare), product).find((r) => + TYPE_KINDS.has(r.symbol.kind), + ) if (!owner) continue const hit = local.filter((r) => r.symbol.parent === owner.symbol.id) if (hit.length === 1) return { how: 'chained', symbol: hit[0].symbol, port } diff --git a/packages/api-model/test/builtins.test.ts b/packages/api-model/test/builtins.test.ts index 9470a0016..68a6f78de 100644 --- a/packages/api-model/test/builtins.test.ts +++ b/packages/api-model/test/builtins.test.ts @@ -15,14 +15,18 @@ describe('standard types in product signatures', () => { 'Set-Content': 'microsoft.powershell.management', } for (const [name, module] of Object.entries(commands)) { - expect(builtinHref('powershell', name)).toBe(`https://learn.microsoft.com/powershell/module/${module}/${name.toLowerCase()}?view=powershell-7.4`) + expect(builtinHref('powershell', name)).toBe( + `https://learn.microsoft.com/powershell/module/${module}/${name.toLowerCase()}?view=powershell-7.4`, + ) expect(builtinHref('csharp', name)).toBeUndefined() } for (const name of ['Get-InventedCommand', 'Stop', 'Boolean', 'Name', 'CaptureResult']) { expect(builtinHref('powershell', name)).toBeUndefined() } expect(builtinHref('powershell', 'FileInfo')).toBe('https://learn.microsoft.com/dotnet/api/system.io.fileinfo') - expect(builtinHref('powershell', 'ApplicationInfo')).toBe('https://learn.microsoft.com/dotnet/api/system.management.automation.applicationinfo?view=powershellsdk-7.4.0') + expect(builtinHref('powershell', 'ApplicationInfo')).toBe( + 'https://learn.microsoft.com/dotnet/api/system.management.automation.applicationinfo?view=powershellsdk-7.4.0', + ) }) it('resolves compiler utility types without claiming local declarations', () => { @@ -34,8 +38,12 @@ describe('standard types in product signatures', () => { expect(builtinHref('go', name)).toBeUndefined() } const local: ApiSymbol = { - id: 'example.Record', name: 'Record', kind: 'typealias', modifiers: [], - signatures: [], source: { file: 'example.ts' }, + id: 'example.Record', + name: 'Record', + kind: 'typealias', + modifiers: [], + signatures: [], + source: { file: 'example.ts' }, } const index = new SymbolIndex([local], () => '/reference/example-record/', 'ts') expect(index.resolve('Record')).toMatchObject({ href: '/reference/example-record/', external: false }) @@ -54,8 +62,10 @@ describe('standard types in product signatures', () => { }) it('does not inherit object properties as keyword help', () => { - expect(new SymbolIndex([], () => '#', 'kotlin').linkType('constructor')[0]) - .toEqual({ text: 'constructor', keyword: true }) + expect(new SymbolIndex([], () => '#', 'kotlin').linkType('constructor')[0]).toEqual({ + text: 'constructor', + keyword: true, + }) }) it('offers C++ nodiscard help only for the attribute', () => { @@ -64,11 +74,15 @@ describe('standard types in product signatures', () => { const spans = index.linkType(declaration) expect(spans.map((span) => span.text).join('')).toBe(declaration) expect(spans.find((span) => span.text === 'nodiscard')).toMatchObject({ - keyword: true, help: { href: 'https://eel.is/c++draft/dcl.attr.nodiscard' }, + keyword: true, + help: { href: 'https://eel.is/c++draft/dcl.attr.nodiscard' }, }) } for (const declaration of [ - 'nodiscard', 'void nodiscard();', '/* [[nodiscard]] */', '"[[nodiscard]]"', + 'nodiscard', + 'void nodiscard();', + '/* [[nodiscard]] */', + '"[[nodiscard]]"', `void f(const char* value = "don't [[nodiscard]]");`, String.raw`void f(const char* value = "say \"[[nodiscard]]\"");`, 'void f(const char* value = R"tag(say "[[nodiscard]]")tag");', @@ -84,12 +98,32 @@ describe('standard types in product signatures', () => { it.each([ ['kotlin', 'List', 'List', 'https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.collections/-list/'], ['scala', 'Option[String]', 'Option', 'https://www.scala-lang.org/api/3.x/scala/Option.html'], - ['fsharp', "Result<'T, 'Error>", 'Result', 'https://fsharp.github.io/fsharp-core-docs/reference/fsharp-core-fsharpresult-2.html'], + [ + 'fsharp', + "Result<'T, 'Error>", + 'Result', + 'https://fsharp.github.io/fsharp-core-docs/reference/fsharp-core-fsharpresult-2.html', + ], ['rs', 'Option', 'OsString', 'https://doc.rust-lang.org/std/ffi/struct.OsString.html'], ['csharp', 'IProgress?', 'IProgress', 'https://learn.microsoft.com/dotnet/api/system.iprogress-1'], - ['csharp', 'ReadOnlyMemory', 'ReadOnlyMemory', 'https://learn.microsoft.com/dotnet/api/system.readonlymemory-1'], - ['csharp', 'IEnumerator', 'IEnumerator', 'https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerator-1'], - ['csharp', 'IReadOnlyCollection', 'IReadOnlyCollection', 'https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlycollection-1'], + [ + 'csharp', + 'ReadOnlyMemory', + 'ReadOnlyMemory', + 'https://learn.microsoft.com/dotnet/api/system.readonlymemory-1', + ], + [ + 'csharp', + 'IEnumerator', + 'IEnumerator', + 'https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerator-1', + ], + [ + 'csharp', + 'IReadOnlyCollection', + 'IReadOnlyCollection', + 'https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlycollection-1', + ], ['cxx', 'std::function', 'double', 'https://en.cppreference.com/w/cpp/language/types'], ['swift', 'Data', 'Data', 'https://developer.apple.com/documentation/foundation/data'], ['swift', 'AsyncStream', 'AsyncStream', 'https://developer.apple.com/documentation/swift/asyncstream'], @@ -116,10 +150,12 @@ describe('standard types in product signatures', () => { expect(spans.map((span) => span.text).join('')).toBe('any Error') expect(spans.find((span) => span.text === 'any')).toEqual({ text: 'any', keyword: true }) expect(spans.find((span) => span.text === 'Error')?.link).toMatchObject({ - href: 'https://developer.apple.com/documentation/swift/error', external: true, + href: 'https://developer.apple.com/documentation/swift/error', + external: true, }) expect(swift.linkType('any MissingError').find((span) => span.text === 'MissingError')).toEqual({ - text: 'MissingError', link: undefined, + text: 'MissingError', + link: undefined, }) expect(builtinHref('swift', 'any')).toBeUndefined() expect(new SymbolIndex([], () => '#', 'go').linkType('any')[0].link?.href).toBe('https://pkg.go.dev/builtin#any') @@ -138,10 +174,21 @@ describe('standard types in product signatures', () => { it('uses Scala collections before bare aliases from the JDK inventory', () => { const index = new SymbolIndex([], () => '#', 'scala') - index.addInventory('https://docs.oracle.com/', ['Vector', 'java.util.Vector'].map((name) => ({ - name, type: 'class', priority: 1, uri: 'java/util/Vector.html', dispname: '-', - })), ['scala'], 'Java SE') - expect(index.resolve('Vector')?.href).toBe('https://www.scala-lang.org/api/3.x/scala/collection/immutable/Vector.html') + index.addInventory( + 'https://docs.oracle.com/', + ['Vector', 'java.util.Vector'].map((name) => ({ + name, + type: 'class', + priority: 1, + uri: 'java/util/Vector.html', + dispname: '-', + })), + ['scala'], + 'Java SE', + ) + expect(index.resolve('Vector')?.href).toBe( + 'https://www.scala-lang.org/api/3.x/scala/collection/immutable/Vector.html', + ) expect(index.resolve('java.util.Vector')?.href).toBe('https://docs.oracle.com/java/util/Vector.html') expect(index.resolve('def')).toBeUndefined() expect(index.resolve('extension')).toBeUndefined() @@ -162,7 +209,10 @@ describe('.NET exception references in F# and C#', () => { ['System.OperationCanceledException', 'system.operationcanceledexception'], ['System.IO.InvalidDataException', 'system.io.invaliddataexception'], ['System.IO.IOException', 'system.io.ioexception'], - ['System.Text.RegularExpressions.RegexMatchTimeoutException', 'system.text.regularexpressions.regexmatchtimeoutexception'], + [ + 'System.Text.RegularExpressions.RegexMatchTimeoutException', + 'system.text.regularexpressions.regexmatchtimeoutexception', + ], ['System.Text.Json.JsonException', 'system.text.json.jsonexception'], ['System.Threading.Tasks.TaskCanceledException', 'system.threading.tasks.taskcanceledexception'], ] as const @@ -179,25 +229,36 @@ describe('.NET exception references in F# and C#', () => { }) it('links the real F# tryFindClient Raises type without changing its spelling', () => { - const model = JSON.parse(readFileSync(new URL('../../../site/src/data/api/fsharp.json', import.meta.url), 'utf8')) as ApiModel + const model = JSON.parse( + readFileSync(new URL('../../../site/src/data/api/fsharp.json', import.meta.url), 'utf8'), + ) as ApiModel const symbol = model.symbols.find((entry) => entry.id === 'LibTmux.FSharp.Server.tryFindClient')! expect(symbol.signatures[0].raises).toEqual([ { type: 'System.ArgumentException', doc: 'The client name is null, empty or whitespace.' }, ]) const index = new SymbolIndex(model.symbols, (entry) => `#${entry.id}`, model.port) expect(index.linkType(symbol.signatures[0].raises![0].type, symbol)).toEqual([ - { text: 'System.ArgumentException', link: { - href: 'https://learn.microsoft.com/dotnet/api/system.argumentexception', external: true, - } }, + { + text: 'System.ArgumentException', + link: { + href: 'https://learn.microsoft.com/dotnet/api/system.argumentexception', + external: true, + }, + }, ]) }) it('leaves unknown namespaces and exceptions plain without borrowing another language', () => { for (const port of ['fsharp', 'csharp']) { const index = new SymbolIndex([], () => '#', port) - for (const name of ['System.MissingException', 'System.IO.ArgumentException', - 'Other.ArgumentException', 'System.Text.Json.OtherException', 'System.Exceptionish', - 'Newtonsoft.Json.JsonException']) { + for (const name of [ + 'System.MissingException', + 'System.IO.ArgumentException', + 'Other.ArgumentException', + 'System.Text.Json.OtherException', + 'System.Exceptionish', + 'Newtonsoft.Json.JsonException', + ]) { expect(builtinHref(port, name)).toBeUndefined() expect(index.linkType(name)).toEqual([{ text: name, link: undefined }]) } @@ -208,14 +269,24 @@ describe('.NET exception references in F# and C#', () => { }) it.each(['fsharp', 'csharp'])('retains a local exception definition before a short builtin in %s', (port) => { - const symbol = { id: 'LibTmux.ArgumentException', name: 'ArgumentException', kind: 'class', - modifiers: [], signatures: [], source: { file: 'example' }, slug: 'argument-exception' } satisfies ApiSymbol + const symbol = { + id: 'LibTmux.ArgumentException', + name: 'ArgumentException', + kind: 'class', + modifiers: [], + signatures: [], + source: { file: 'example' }, + slug: 'argument-exception', + } satisfies ApiSymbol const index = new SymbolIndex([symbol], () => '/reference/argument-exception/', port) expect(index.resolve('ArgumentException')).toMatchObject({ - href: '/reference/argument-exception/', external: false, symbol, + href: '/reference/argument-exception/', + external: false, + symbol, }) expect(index.resolve('System.ArgumentException')).toMatchObject({ - href: 'https://learn.microsoft.com/dotnet/api/system.argumentexception', external: true, + href: 'https://learn.microsoft.com/dotnet/api/system.argumentexception', + external: true, }) }) }) diff --git a/packages/api-model/test/concepts.test.ts b/packages/api-model/test/concepts.test.ts index 666339552..4901a03bc 100644 --- a/packages/api-model/test/concepts.test.ts +++ b/packages/api-model/test/concepts.test.ts @@ -42,10 +42,15 @@ describe('concept map', () => { expect(concept.symbols.powershell).toBe(id) const symbol = models.get('powershell')!.symbols.find((entry) => entry.id === id)! expect(symbol.product).toBe('workspace') - expect(symbol.command).toMatchObject({ kind: 'cmdlet', supportsShouldProcess: true, confirmImpact: 'High', - outputTypes: ['LibTmux.Workspace.WorkspaceResult'] }) + expect(symbol.command).toMatchObject({ + kind: 'cmdlet', + supportsShouldProcess: true, + confirmImpact: 'High', + outputTypes: ['LibTmux.Workspace.WorkspaceResult'], + }) expect(symbol.signatures[0].params.find((param) => param.name === 'Plan')).toMatchObject({ - type: 'LibTmux.Workspace.WorkspacePlan', binding: { mandatory: true, pipeline: { byValue: true } }, + type: 'LibTmux.Workspace.WorkspacePlan', + binding: { mandatory: true, pipeline: { byValue: true } }, }) expect(conceptsFor('powershell', id)).toContain(concept) expect(conceptsFor('powershell', 'LibTmux.Workspace\\Get-TmuxWorkspacePlan')).not.toContain(concept) @@ -75,9 +80,12 @@ describe('concept map', () => { const model = models.get(port) if (!model) continue const symbol = model.symbols.find((entry) => (entry.publicId ?? entry.id) === publicId) - expect(symbol?.signatures.some((signature) => - signature.params.some((parameter) => /\bSession\b/.test(parameter.type ?? '')), - ), `${port}: ${publicId}`).toBe(true) + expect( + symbol?.signatures.some((signature) => + signature.params.some((parameter) => /\bSession\b/.test(parameter.type ?? '')), + ), + `${port}: ${publicId}`, + ).toBe(true) } expect(CONCEPTS['freeze-workspace'].symbols.swift).toBeUndefined() }) @@ -124,7 +132,9 @@ describe('concept map', () => { expect(conceptsFor('julia', `LibTmux.${name}Ref`)).toContain(concept) } const windows = model.symbols.find((symbol) => symbol.id === CONCEPTS['list-windows'].symbols.julia)! - expect(windows.signatures.some((signature) => signature.raw?.includes('parent::LibTmux.SessionSnapshot'))).toBe(true) + expect(windows.signatures.some((signature) => signature.raw?.includes('parent::LibTmux.SessionSnapshot'))).toBe( + true, + ) expect(windows.signatures.some((signature) => signature.raw?.includes('snap::LibTmux.Snapshot'))).toBe(true) const panes = model.symbols.find((symbol) => symbol.id === CONCEPTS['list-panes'].symbols.julia)! expect(panes.signatures.some((signature) => signature.raw?.includes('win::LibTmux.WindowSnapshot'))).toBe(true) @@ -137,12 +147,18 @@ describe('concept map', () => { it.runIf(models.size)('maps PowerShell listing scopes to declared parameter sets', () => { const model = models.get('powershell')! for (const [concept, receiver] of [ - ['list-sessions', 'Server'], ['list-server-windows', 'Server'], - ['list-windows', 'Session'], ['list-server-panes', 'Server'], - ['list-session-panes', 'Session'], ['list-panes', 'Window'], + ['list-sessions', 'Server'], + ['list-server-windows', 'Server'], + ['list-windows', 'Session'], + ['list-server-panes', 'Server'], + ['list-session-panes', 'Session'], + ['list-panes', 'Window'], ]) { const symbol = model.symbols.find((symbol) => symbol.id === CONCEPTS[concept].symbols.powershell)! - expect(symbol.signatures.some((signature) => signature.raw?.includes(`[-${receiver}] `)), concept).toBe(true) + expect( + symbol.signatures.some((signature) => signature.raw?.includes(`[-${receiver}] `)), + concept, + ).toBe(true) expect(CONCEPTS[concept].notes?.powershell, concept).toContain(`\`-${receiver}\``) } }) @@ -174,11 +190,16 @@ describe('concept map', () => { it('preserves native handle scopes and recognises Cats Effect operations', () => { for (const port of ['kotlin', 'scala']) { for (const [concept, member] of Object.entries({ - 'split-pane': 'Pane.split', 'split-window': 'Window.split', - 'list-windows': 'Session.windows', 'list-panes': 'Window.panes', - 'list-server-windows': 'Server.windows', 'list-server-panes': 'Server.panes', + 'split-pane': 'Pane.split', + 'split-window': 'Window.split', + 'list-windows': 'Session.windows', + 'list-panes': 'Window.panes', + 'list-server-windows': 'Server.windows', + 'list-server-panes': 'Server.panes', })) { - expect(CONCEPTS[concept].symbols[port]).toBe(`io.github.libtmux.${port === 'scala' ? 'scaladsl' : port}.${member}`) + expect(CONCEPTS[concept].symbols[port]).toBe( + `io.github.libtmux.${port === 'scala' ? 'scaladsl' : port}.${member}`, + ) } } expect(conceptsFor('scala', 'io.github.libtmux.scaladsl.cats.Server.newSession')).toContain(CONCEPTS['new-session']) diff --git a/packages/api-model/test/coverage.test.ts b/packages/api-model/test/coverage.test.ts index d17d871b6..43fedefca 100644 --- a/packages/api-model/test/coverage.test.ts +++ b/packages/api-model/test/coverage.test.ts @@ -49,18 +49,14 @@ describeIfSource('Python extraction against gp-sphinx', () => { const pane = model.symbols.find((s) => s.publicId === 'libtmux.Pane' && s.kind === 'class') expect(pane, 'Pane class').toBeDefined() - const found = new Set( - model.symbols.filter((s) => s.parent === pane?.id).map((s) => s.publicId ?? s.id), - ) + const found = new Set(model.symbols.filter((s) => s.parent === pane?.id).map((s) => s.publicId ?? s.id)) const missing = expected.filter((id) => !found.has(id)) expect(missing, `missing ${missing.length} of ${expected.length}`).toEqual([]) }) it('resolves inherited dataclass fields from another module', () => { const pane = model.symbols.find((s) => s.publicId === 'libtmux.Pane') - const field = model.symbols.find( - (s) => s.parent === pane?.id && s.name === 'buffer_name', - ) + const field = model.symbols.find((s) => s.parent === pane?.id && s.name === 'buffer_name') expect(field?.inheritedFrom).toBe('libtmux.neo.Obj') expect(field?.publicId).toBe('libtmux.Pane.buffer_name') }) @@ -173,7 +169,10 @@ describeIfSource('id stability', () => { options: { privateMembers: true, specialMembers: true }, }) const key = (m: ApiModel) => - m.symbols.map((s) => `${s.id}\t${s.publicId}`).sort().join('\n') + m.symbols + .map((s) => `${s.id}\t${s.publicId}`) + .sort() + .join('\n') expect(key(twice)).toBe(key(once)) }, 120_000) @@ -185,9 +184,7 @@ describeIfSource('id stability', () => { }) // An inherited member is keyed on the class it lands on, so it stays put // even if the base it came from is renamed or re-homed. - const field = model.symbols.find( - (s) => s.publicId === 'libtmux.Pane.buffer_name', - ) + const field = model.symbols.find((s) => s.publicId === 'libtmux.Pane.buffer_name') expect(field?.id).toBe('libtmux.pane.Pane.buffer_name') expect(field?.inheritedFrom).toBe('libtmux.neo.Obj') }, 120_000) @@ -269,8 +266,13 @@ describe('methods belong to their types', () => { if (!methods.length) return const orphans = methods.filter((s) => !s.parent) const rate = 1 - orphans.length / methods.length - expect(rate, `${orphans.length} ownerless: ${orphans.slice(0, 3).map((s) => s.id).join(', ')}`) - .toBeGreaterThanOrEqual(0.95) + expect( + rate, + `${orphans.length} ownerless: ${orphans + .slice(0, 3) + .map((s) => s.id) + .join(', ')}`, + ).toBeGreaterThanOrEqual(0.95) }) it('attaches a Go method to its receiver type', () => { @@ -293,10 +295,11 @@ describe('the reference is pruned but not gutted', () => { const path = join(here, `../../site/src/data/api/${port}.json`) if (!existsSync(path)) continue const model = JSON.parse(readFileSync(path, 'utf8')) as ApiModel - const leaked = model.symbols.filter((s) => - /^_?(internal|_generated)\./.test(s.publicId ?? s.id), - ) - expect(leaked.map((s) => s.publicId), `${port} leaked internals`).toEqual([]) + const leaked = model.symbols.filter((s) => /^_?(internal|_generated)\./.test(s.publicId ?? s.id)) + expect( + leaked.map((s) => s.publicId), + `${port} leaked internals`, + ).toEqual([]) } }) diff --git a/packages/api-model/test/csharp-examples.test.ts b/packages/api-model/test/csharp-examples.test.ts index d82303b93..c42268efe 100644 --- a/packages/api-model/test/csharp-examples.test.ts +++ b/packages/api-model/test/csharp-examples.test.ts @@ -5,35 +5,66 @@ import type { ApiSymbol } from '../src/model.ts' const source = { repo: 'libtmux/libtmux-dotnet', revision: 'a'.repeat(40) } const fixture = () => { const program = { - id: 'csharp-ServerConstruction', profile: 'csharp', title: 'Connect to a server', + id: 'csharp-ServerConstruction', + profile: 'csharp', + title: 'Connect to a server', description: 'Discover a live server through a configured endpoint.', sourceFile: 'examples/LibTmux.Examples/Programs/ServerConstruction.cs', - targets: ['M:LibTmux.Server.ConnectAsync(LibTmux.ServerConnectionOptions,System.Threading.CancellationToken)', - 'M:LibTmux.Server.ConnectAsync(System.Threading.CancellationToken)', 'T:LibTmux.Server'], + targets: [ + 'M:LibTmux.Server.ConnectAsync(LibTmux.ServerConnectionOptions,System.Threading.CancellationToken)', + 'M:LibTmux.Server.ConnectAsync(System.Threading.CancellationToken)', + 'T:LibTmux.Server', + ], output: 'connected\n', } const manifest = { schemaVersion: 1, - profiles: { csharp: { package: 'LibTmux', projectFile: 'examples/api/csharp/Example.csproj', entrypoint: 'Program.cs' } }, - setupFiles: ['global.json', 'examples/api/NuGet.config'], examples: [program], + profiles: { + csharp: { package: 'LibTmux', projectFile: 'examples/api/csharp/Example.csproj', entrypoint: 'Program.cs' }, + }, + setupFiles: ['global.json', 'examples/api/NuGet.config'], + examples: [program], } const files = new Map([ ['global.json', '{"sdk":{"version":"10.0.302"}}\n'], ['examples/api/NuGet.config', '\n\n'], ['examples/api/csharp/Example.csproj', '\n'], - [program.sourceFile, '\n// Complete file, including every blank line.\nusing System;\nConsole.WriteLine("connected");\n\n'], + [ + program.sourceFile, + '\n// Complete file, including every blank line.\nusing System;\nConsole.WriteLine("connected");\n\n', + ], ]) const symbols: ApiSymbol[] = [ - { id: 'LibTmux.Server.ConnectAsync', name: 'ConnectAsync', kind: 'method', modifiers: ['overload'], - signatures: [{ params: [{ name: 'options', type: 'ServerConnectionOptions?' }, - { name: 'cancellationToken', type: 'CancellationToken' }] }, - { params: [{ name: 'cancellationToken', type: 'CancellationToken' }] }], - source: { file: 'Server.Identity.cs' }, slug: 'server-connectasync', - doc: { summary: 'Native method documentation.', examples: [{ lang: 'csharp', code: 'existing inline example' }] } }, - { id: 'LibTmux.Server', name: 'Server', kind: 'class', modifiers: [], signatures: [], - source: { file: 'Server.cs' }, slug: 'server', doc: { summary: 'Native type documentation.' } }, + { + id: 'LibTmux.Server.ConnectAsync', + name: 'ConnectAsync', + kind: 'method', + modifiers: ['overload'], + signatures: [ + { + params: [ + { name: 'options', type: 'ServerConnectionOptions?' }, + { name: 'cancellationToken', type: 'CancellationToken' }, + ], + }, + { params: [{ name: 'cancellationToken', type: 'CancellationToken' }] }, + ], + source: { file: 'Server.Identity.cs' }, + slug: 'server-connectasync', + doc: { summary: 'Native method documentation.', examples: [{ lang: 'csharp', code: 'existing inline example' }] }, + }, + { + id: 'LibTmux.Server', + name: 'Server', + kind: 'class', + modifiers: [], + signatures: [], + source: { file: 'Server.cs' }, + slug: 'server', + doc: { summary: 'Native type documentation.' }, + }, ] - const read = (path: string) => path === 'examples/api/manifest.json' ? JSON.stringify(manifest) : files.get(path) + const read = (path: string) => (path === 'examples/api/manifest.json' ? JSON.stringify(manifest) : files.get(path)) return { manifest, files, symbols, read } } @@ -51,8 +82,12 @@ describe('complete C# API programs', () => { expect(blocks).toHaveLength(7) expect(blocks[0].code).toContain(`checkout --detach ${source.revision}`) expect(blocks[0].intro).toContain('.NET SDK 10.0.302') - for (const [i, path] of ['global.json', 'examples/api/NuGet.config', - 'examples/api/csharp/Example.csproj', manifest.examples[0].sourceFile].entries()) { + for (const [i, path] of [ + 'global.json', + 'examples/api/NuGet.config', + 'examples/api/csharp/Example.csproj', + manifest.examples[0].sourceFile, + ].entries()) { expect(blocks[i + 1].code).toBe(files.get(path)) expect(blocks[i + 1].sourceUrl).toBe(`https://github.com/${source.repo}/blob/${source.revision}/${path}`) } @@ -71,8 +106,13 @@ describe('complete C# API programs', () => { it('retains separate complete programs sharing a page', () => { const { manifest, files, symbols, read } = fixture() const first = manifest.examples[0] - const second = { ...first, id: 'csharp-OtherConnection', - sourceFile: first.sourceFile.replace('ServerConstruction', 'OtherConnection'), targets: [first.targets[0]], output: 'second\n' } + const second = { + ...first, + id: 'csharp-OtherConnection', + sourceFile: first.sourceFile.replace('ServerConstruction', 'OtherConnection'), + targets: [first.targets[0]], + output: 'second\n', + } files.set(second.sourceFile, 'System.Console.WriteLine("second");\n') manifest.examples.push(second) attachCompleteCSharpExamples(symbols, read, source) @@ -86,8 +126,10 @@ describe('complete C# API programs', () => { it('maps generic owner and method arities while retaining native property and method kinds', () => { const { manifest, symbols, read } = fixture() - manifest.examples[0].targets = ['P:LibTmux.CapturedRelation`1.IsCaptured', - 'M:LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},LibTmux.Query.QueryDocument)'] + manifest.examples[0].targets = [ + 'P:LibTmux.CapturedRelation`1.IsCaptured', + 'M:LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},LibTmux.Query.QueryDocument)', + ] symbols[0].id = 'LibTmux.CapturedRelation.IsCaptured' symbols[0].kind = 'property' symbols[1].id = 'LibTmux.Query.QueryExtensions.Matching' @@ -108,8 +150,12 @@ describe('complete C# API programs', () => { }) it('rejects unknown source identities, ambiguity and wrong kinds without guessing a short name', () => { - for (const target of ['M:LibTmux.Other.ConnectAsync(System.Threading.CancellationToken)', - 'P:LibTmux.Server.ConnectAsync', 'T:LibTmux.Server.ConnectAsync', 'F:LibTmux.Server']) { + for (const target of [ + 'M:LibTmux.Other.ConnectAsync(System.Threading.CancellationToken)', + 'P:LibTmux.Server.ConnectAsync', + 'T:LibTmux.Server.ConnectAsync', + 'F:LibTmux.Server', + ]) { const { manifest, symbols, read } = fixture() manifest.examples[0].targets = [target] expect(() => attachCompleteCSharpExamples(symbols, read, source)).toThrow(/target/) @@ -133,20 +179,27 @@ describe('complete C# API programs', () => { } const invalid = fixture() invalid.manifest.examples[0].sourceFile = 'examples/../private.cs' - expect(() => attachCompleteCSharpExamples(invalid.symbols, invalid.read, source)).toThrow('Invalid C# example metadata') + expect(() => attachCompleteCSharpExamples(invalid.symbols, invalid.read, source)).toThrow( + 'Invalid C# example metadata', + ) }) it('requires the exact source revision, SDK, package and setup contract', () => { const invalid = fixture() - expect(() => attachCompleteCSharpExamples(invalid.symbols, invalid.read, { ...source, revision: 'main' })) - .toThrow('full source revision') + expect(() => attachCompleteCSharpExamples(invalid.symbols, invalid.read, { ...source, revision: 'main' })).toThrow( + 'full source revision', + ) invalid.manifest.setupFiles.pop() - expect(() => attachCompleteCSharpExamples(invalid.symbols, invalid.read, source)).toThrow('Invalid C# example manifest') + expect(() => attachCompleteCSharpExamples(invalid.symbols, invalid.read, source)).toThrow( + 'Invalid C# example manifest', + ) const sdk = fixture() sdk.files.set('global.json', '{"sdk":{"version":"latest"}}\n') expect(() => attachCompleteCSharpExamples(sdk.symbols, sdk.read, source)).toThrow('pinned SDK') const packageProfile = fixture() packageProfile.manifest.profiles.csharp.package = 'LibTmux.FSharp' - expect(() => attachCompleteCSharpExamples(packageProfile.symbols, packageProfile.read, source)).toThrow('Invalid C# example profile') + expect(() => attachCompleteCSharpExamples(packageProfile.symbols, packageProfile.read, source)).toThrow( + 'Invalid C# example profile', + ) }) }) diff --git a/packages/api-model/test/csharp.test.ts b/packages/api-model/test/csharp.test.ts index 34a9db1e1..d095a1a8c 100644 --- a/packages/api-model/test/csharp.test.ts +++ b/packages/api-model/test/csharp.test.ts @@ -19,8 +19,10 @@ async function extract(source: string) { } describe('C# positional records', () => { - it.each(['record', 'record class', 'record struct', 'readonly record struct'])('extracts %s properties, constructor and parameter documentation', async (declaration) => { - const symbols = await extract(`namespace LibTmux; + it.each(['record', 'record class', 'record struct', 'readonly record struct'])( + 'extracts %s properties, constructor and parameter documentation', + async (declaration) => { + const symbols = await extract(`namespace LibTmux; /// A command result. /// The exit status, or null before completion. /// Rendered lines, including stderr. @@ -28,19 +30,40 @@ public ${declaration} Result( int? ExitStatus, IReadOnlyList Output); `) - const owner = symbols.find((symbol) => symbol.id === 'LibTmux.Result')! - expect(owner.doc?.summary).toBe('A command result.') - expect(owner.signatures[0]?.params).toEqual([ - { name: 'ExitStatus', type: 'int?', doc: 'The exit status, or null before completion.' }, - { name: 'Output', type: 'IReadOnlyList', doc: 'Rendered lines, including stderr.' }, - ]) - expect(symbols.filter((symbol) => symbol.parent === owner.id).map((symbol) => ({ - name: symbol.name, type: symbol.type, kind: symbol.kind, line: symbol.source.line, summary: symbol.doc?.summary, - }))).toEqual([ - { name: 'ExitStatus', type: 'int?', kind: 'property', line: 6, summary: 'The exit status, or null before completion.' }, - { name: 'Output', type: 'IReadOnlyList', kind: 'property', line: 7, summary: 'Rendered lines, including stderr.' }, - ]) - }) + const owner = symbols.find((symbol) => symbol.id === 'LibTmux.Result')! + expect(owner.doc?.summary).toBe('A command result.') + expect(owner.signatures[0]?.params).toEqual([ + { name: 'ExitStatus', type: 'int?', doc: 'The exit status, or null before completion.' }, + { name: 'Output', type: 'IReadOnlyList', doc: 'Rendered lines, including stderr.' }, + ]) + expect( + symbols + .filter((symbol) => symbol.parent === owner.id) + .map((symbol) => ({ + name: symbol.name, + type: symbol.type, + kind: symbol.kind, + line: symbol.source.line, + summary: symbol.doc?.summary, + })), + ).toEqual([ + { + name: 'ExitStatus', + type: 'int?', + kind: 'property', + line: 6, + summary: 'The exit status, or null before completion.', + }, + { + name: 'Output', + type: 'IReadOnlyList', + kind: 'property', + line: 7, + summary: 'Rendered lines, including stderr.', + }, + ]) + }, + ) it('keeps explicitly declared properties and their documentation', async () => { const symbols = await extract(`public record Result(string Name) { @@ -63,10 +86,12 @@ public ${declaration} Result( System.StringComparison Comparison = System.StringComparison.Ordinal) { public void Read(int count = 4, bool follow = false) {} }`) - expect(symbols.find((symbol) => symbol.id === 'LibTmux.Result')!.signatures[0].params.map((param) => param.default)) - .toEqual(['null', 'false', 'true', '-1', '"pending"', 'System.StringComparison.Ordinal']) - expect(symbols.find((symbol) => symbol.id === 'LibTmux.Result.Read')!.signatures[0].params.map((param) => param.default)) - .toEqual(['4', 'false']) + expect( + symbols.find((symbol) => symbol.id === 'LibTmux.Result')!.signatures[0].params.map((param) => param.default), + ).toEqual(['null', 'false', 'true', '-1', '"pending"', 'System.StringComparison.Ordinal']) + expect( + symbols.find((symbol) => symbol.id === 'LibTmux.Result.Read')!.signatures[0].params.map((param) => param.default), + ).toEqual(['4', 'false']) }) it('does not turn ordinary class parameters or private records into public properties', async () => { diff --git a/packages/api-model/test/doxygen.test.ts b/packages/api-model/test/doxygen.test.ts index 8d08152a7..6768bced2 100644 --- a/packages/api-model/test/doxygen.test.ts +++ b/packages/api-model/test/doxygen.test.ts @@ -16,15 +16,20 @@ function extractCompound(xml: string) { describe('Doxygen public inheritance', () => { it('keeps native C++ overload spelling, defaulting, explicit conversion and trailing qualifiers', () => { - const symbols = extractCompound(`sample::Arguments + const symbols = + extractCompound(`sample::Arguments sample::Arguments::Argumentssample::Arguments::ArgumentsArguments()=default sample::Arguments::Argumentssample::Arguments::ArgumentsArguments(FlatArguments values)FlatArgumentsvalues const Value &const Value & sample::Arguments::getsample::Arguments::getget() const & noexcept `) - expect(symbols.find((symbol) => symbol.name === 'Arguments' && symbol.kind === 'method')?.signatures.map((signature) => signature.raw)).toEqual([ - 'Arguments()=default;', 'explicit Arguments(FlatArguments values);', - ]) - expect(symbols.find((symbol) => symbol.name === 'get')?.signatures[0].raw).toBe('const Value & get() const & noexcept;') + expect( + symbols + .find((symbol) => symbol.name === 'Arguments' && symbol.kind === 'method') + ?.signatures.map((signature) => signature.raw), + ).toEqual(['Arguments()=default;', 'explicit Arguments(FlatArguments values);']) + expect(symbols.find((symbol) => symbol.name === 'get')?.signatures[0].raw).toBe( + 'const Value & get() const & noexcept;', + ) }) it('preserves template constraints and nodiscard in C++ declarations', () => { @@ -38,11 +43,23 @@ describe('Doxygen public inheritance', () => { boolbool sample::Value::cancelledsample::Value::cancelledcancelled() const noexcept boolbool sample::Value::validsample::Value::validvalid() constEnabled<T> `) - expect(symbols.find((symbol) => symbol.kind === 'method' && symbol.name === 'Value')?.signatures[0].raw).toBe('template \nrequires (!std::same_as, bool>)\nValue(Integer item);') - expect(symbols.find((symbol) => symbol.kind === 'method' && symbol.name === 'Value')?.signatures[0]).toMatchObject({ typeParams: ['std::integral Integer'], params: [{ name: 'item', type: 'Integer' }], returns: undefined }) - expect(symbols.find((symbol) => symbol.kind === 'method' && symbol.name === 'Value')?.signatures[0].params).toHaveLength(1) - expect(symbols.find((symbol) => symbol.name === 'cancelled')?.signatures[0].raw).toBe('[[nodiscard]] bool cancelled() const noexcept;') - expect(symbols.find((symbol) => symbol.name === 'valid')?.signatures[0].raw).toBe('bool valid() const requires Enabled;') + expect(symbols.find((symbol) => symbol.kind === 'method' && symbol.name === 'Value')?.signatures[0].raw).toBe( + 'template \nrequires (!std::same_as, bool>)\nValue(Integer item);', + ) + expect(symbols.find((symbol) => symbol.kind === 'method' && symbol.name === 'Value')?.signatures[0]).toMatchObject({ + typeParams: ['std::integral Integer'], + params: [{ name: 'item', type: 'Integer' }], + returns: undefined, + }) + expect( + symbols.find((symbol) => symbol.kind === 'method' && symbol.name === 'Value')?.signatures[0].params, + ).toHaveLength(1) + expect(symbols.find((symbol) => symbol.name === 'cancelled')?.signatures[0].raw).toBe( + '[[nodiscard]] bool cancelled() const noexcept;', + ) + expect(symbols.find((symbol) => symbol.name === 'valid')?.signatures[0].raw).toBe( + 'bool valid() const requires Enabled;', + ) }) it('keeps public bases without exposing private or protected implementation bases', () => { @@ -102,23 +119,32 @@ describe('Doxygen C declarations', () => { } it('deduplicates external prototypes and definitions without inventing overloads', () => { - const symbols = extractC(` + const symbols = extractC( + ` tmux.h intint server_start(int) server_startintflags - `, ` + `, + ` server.c intint server_start(int flags) server_startintflags - `) + `, + ) expect(symbols).toHaveLength(1) - expect(symbols[0]).toMatchObject({ id: 'c:function:server_start', qualifiedName: 'server_start', namespace: '', - kind: 'function', modifiers: [], source: { file: 'server.c', line: 30 }, - signatures: [{ raw: 'int server_start(int flags)', params: [{ name: 'flags', type: 'int' }], returns: 'int' }] }) + expect(symbols[0]).toMatchObject({ + id: 'c:function:server_start', + qualifiedName: 'server_start', + namespace: '', + kind: 'function', + modifiers: [], + source: { file: 'server.c', line: 30 }, + signatures: [{ raw: 'int server_start(int flags)', params: [{ name: 'flags', type: 'int' }], returns: 'int' }], + }) }) it('uses a same-file callback definition line without promoting extern declarations', () => { @@ -139,20 +165,25 @@ describe('Doxygen C declarations', () => { `) expect(symbols.map((symbol) => symbol.source)).toEqual([ - { file: 'tmux.h', line: 1928 }, { file: 'tmux.h', line: 20 }, - { file: 'tmux.h', line: 30 }, { file: 'tmux.h', line: 40 }, + { file: 'tmux.h', line: 1928 }, + { file: 'tmux.h', line: 20 }, + { file: 'tmux.h', line: 30 }, + { file: 'tmux.h', line: 40 }, ]) - const definitions = extractC(` + const definitions = extractC( + ` a.h intcount - `, ` + `, + ` a.c intcount= 1 - `) + `, + ) expect(definitions).toHaveLength(1) expect(definitions[0]).toMatchObject({ source: { file: 'a.c', line: 3 }, value: '1' }) }) @@ -166,14 +197,17 @@ describe('Doxygen C declarations', () => { `) expect(symbols.map((symbol) => symbol.source)).toEqual([ - { file: 'tmux.h', line: 1940 }, { file: 'tmux.h', line: 1956 }, + { file: 'tmux.h', line: 1940 }, + { file: 'tmux.h', line: 1956 }, ]) }) it('projects only evidenced direct calls, preserving callbacks and file-local identities', () => { const ref = (id: string, name: string) => `${name}` - const row = (line: number, code: string) => `${code}` - const symbols = extractC(` + const row = (line: number, code: string) => + `${code}` + const symbols = extractC( + ` a.c inttick @@ -203,13 +237,15 @@ describe('Doxygen C declarations', () => { "${ref('tick-a', 'tick')}()" ${row(28, `${ref('tick-a', 'EXPANDED_NAME')}();`)} ${row(29, `${ref('external', 'external')}();`)}${row(30, `${ref('tick-a', 'tick')};`)}${row(35, '}')} - `, ` + `, + ` b.c ticktick ${row(2, `${ref('tick-b', 'tick')}(void)`)} ${row(3, `{ ${ref('tick-b', 'tick')}();`)}${row(4, `${ref('tick-a', 'tick')}();`)}${row(5, '}')} - `) + `, + ) const worker = symbols.find((symbol) => symbol.id === 'c:function:worker')! expect(worker.references?.filter((edge) => edge.kind === 'call')).toEqual([ { target: 'c:function:a.c:tick', kind: 'call', sites: [15, 17, 18, 19].map((line) => ({ file: 'a.c', line })) }, @@ -229,31 +265,49 @@ describe('Doxygen C declarations', () => { // Doxygen 1.18 gives both definitions the same line-only body span for: // int a(void) { return first(); } int b(void) { return second(); } const symbols = extractC(`a.c - ${[['first', 1], ['second', 2], ['a', 3], ['b', 3], ['alone', 4]].map(([name, line]) => ` + ${[ + ['first', 1], + ['second', 2], + ['a', 3], + ['b', 3], + ['alone', 4], + ] + .map( + ([name, line]) => ` int${name} ${name === 'a' ? 'first' : name === 'b' ? 'second' : ''} - `).join('')} + `, + ) + .join('')} int first(void) { return 1; } int second(void) { return 2; } int a(void) { return first(); } int b(void) { return second(); } int alone(void) { return second(); } `) - expect(symbols.find((symbol) => symbol.name === 'a')?.references).toEqual([{ kind: 'reference', target: 'c:function:first' }]) - expect(symbols.find((symbol) => symbol.name === 'b')?.references).toEqual([{ kind: 'reference', target: 'c:function:second' }]) + expect(symbols.find((symbol) => symbol.name === 'a')?.references).toEqual([ + { kind: 'reference', target: 'c:function:first' }, + ]) + expect(symbols.find((symbol) => symbol.name === 'b')?.references).toEqual([ + { kind: 'reference', target: 'c:function:second' }, + ]) expect(symbols.find((symbol) => symbol.name === 'alone')?.references).toEqual([ { kind: 'call', target: 'c:function:second', sites: [{ file: 'a.c', line: 4 }] }, ]) }) it('keeps same-named static functions in different files distinct and treats void as no parameters', () => { - const symbols = extractC(...['a.c', 'b.c'].map((file) => ` + const symbols = extractC( + ...['a.c', 'b.c'].map( + (file) => ` ${file} voidtickstatic void tick(void) void - `)) + `, + ), + ) expect(symbols.map((symbol) => symbol.id)).toEqual(['c:function:a.c:tick', 'c:function:b.c:tick']) expect(symbols.every((symbol) => symbol.signatures[0].params.length === 0)).toBe(true) }) @@ -264,27 +318,42 @@ describe('Doxygen C declarations', () => { struct pane *... `) - expect(symbols[0].signatures[0].params).toEqual([{ name: '', type: 'struct pane *', default: undefined, doc: undefined }, - { name: '', type: '...', default: undefined, doc: undefined }]) + expect(symbols[0].signatures[0].params).toEqual([ + { name: '', type: 'struct pane *', default: undefined, doc: undefined }, + { name: '', type: '...', default: undefined, doc: undefined }, + ]) }) it('retains tag namespaces, nested unions, fields and native type references', () => { - const symbols = extractC(` + const symbols = extractC( + ` args_value args_value::data - `, ` + `, + ` args_value::data char *string - `, ` + `, + ` tmux.h struct args_value * args_value - `) - expect(symbols.find((symbol) => symbol.id === 'c:union:args_value::data')).toMatchObject({ kind: 'union', parent: 'c:struct:args_value' }) - expect(symbols.find((symbol) => symbol.name === 'string')).toMatchObject({ parent: 'c:union:args_value::data', kind: 'attribute' }) - expect(symbols.find((symbol) => symbol.kind === 'function')).toMatchObject({ id: 'c:function:args_value', - imports: { args_value: 'c:struct:args_value' }, references: [{ target: 'c:struct:args_value', kind: 'type' }] }) + `, + ) + expect(symbols.find((symbol) => symbol.id === 'c:union:args_value::data')).toMatchObject({ + kind: 'union', + parent: 'c:struct:args_value', + }) + expect(symbols.find((symbol) => symbol.name === 'string')).toMatchObject({ + parent: 'c:union:args_value::data', + kind: 'attribute', + }) + expect(symbols.find((symbol) => symbol.kind === 'function')).toMatchObject({ + id: 'c:function:args_value', + imports: { args_value: 'c:struct:args_value' }, + references: [{ target: 'c:struct:args_value', kind: 'type' }], + }) expect(symbols.every((symbol) => !symbol.parent || symbols.some((owner) => owner.id === symbol.parent))).toBe(true) }) @@ -310,7 +379,9 @@ describe('Doxygen C declarations', () => { `) expect(symbols.find((symbol) => symbol.name === 'OK')).toMatchObject({ parent: 'c:enum:status', value: '2' }) - expect(symbols.find((symbol) => symbol.name === 'read')?.references).toEqual([{ target: 'c:enum:status::OK', kind: 'reference' }]) + expect(symbols.find((symbol) => symbol.name === 'read')?.references).toEqual([ + { target: 'c:enum:status::OK', kind: 'reference' }, + ]) }) }) @@ -318,19 +389,34 @@ describe('Doxygen C declaration spelling', () => { it('keeps function pointers, array extents, tag links and initializer literal bytes', () => { const dir = mkdtempSync(join(tmpdir(), 'libtmux-doxygen-c-spelling-')) try { - writeFileSync(join(dir, 'native.xml'), `item + writeFileSync( + join(dir, 'native.xml'), + `item void(*)callback(struct item *) unsigned intflag1 charlabel[32]= "two spaces &quot;" - `) - writeFileSync(join(dir, 'function.xml'), `a.c + `, + ) + writeFileSync( + join(dir, 'function.xml'), + `a.c struct item *item - `) + `, + ) const symbols = extractDoxygen(dir, '', { language: 'c' }) - expect(symbols.find((symbol) => symbol.name === 'callback')).toMatchObject({ qualifiedName: 'item.callback', signatures: [{ raw: 'void(*callback)(struct item *);', params: [] }] }) + expect(symbols.find((symbol) => symbol.name === 'callback')).toMatchObject({ + qualifiedName: 'item.callback', + signatures: [{ raw: 'void(*callback)(struct item *);', params: [] }], + }) expect(symbols.find((symbol) => symbol.name === 'flag')?.signatures[0].raw).toBe('unsigned int flag : 1;') - expect(symbols.find((symbol) => symbol.name === 'label')).toMatchObject({ signatures: [{ raw: 'char label[32] = "two spaces "";', params: [] }], value: '"two spaces ""' }) - expect(symbols.find((symbol) => symbol.kind === 'function')).toMatchObject({ imports: { item: 'c:struct:item' }, references: [{ kind: 'type', target: 'c:struct:item' }] }) + expect(symbols.find((symbol) => symbol.name === 'label')).toMatchObject({ + signatures: [{ raw: 'char label[32] = "two spaces "";', params: [] }], + value: '"two spaces ""', + }) + expect(symbols.find((symbol) => symbol.kind === 'function')).toMatchObject({ + imports: { item: 'c:struct:item' }, + references: [{ kind: 'type', target: 'c:struct:item' }], + }) expect(symbols.find((symbol) => symbol.kind === 'struct')?.signatures[0].raw).toBe('struct item;') } finally { rmSync(dir, { recursive: true, force: true }) @@ -342,10 +428,20 @@ describe('Doxygen C macro identity', () => { it('keeps macros distinct from enum values and native callable symbols', () => { const dir = mkdtempSync(join(tmpdir(), 'libtmux-doxygen-macro-')) try { - writeFileSync(join(dir, 'a.xml'), `a.h + writeFileSync( + join(dir, 'a.xml'), + `a.h CHECKx((x) != 0) - `) - expect(extractDoxygen(dir, '', { language: 'c' })).toMatchObject([{ id: 'c:define:a.h:CHECK', kind: 'constant', modifiers: ['macro'], signatures: [{ raw: '#define CHECK(x) ((x) != 0)', params: [] }] }]) + `, + ) + expect(extractDoxygen(dir, '', { language: 'c' })).toMatchObject([ + { + id: 'c:define:a.h:CHECK', + kind: 'constant', + modifiers: ['macro'], + signatures: [{ raw: '#define CHECK(x) ((x) != 0)', params: [] }], + }, + ]) } finally { rmSync(dir, { recursive: true, force: true }) } @@ -356,16 +452,41 @@ describe('Doxygen anonymous C display', () => { it('keeps native identities while excluding synthetic names from source syntax', () => { const dir = mkdtempSync(join(tmpdir(), 'libtmux-doxygen-anonymous-')) try { - writeFileSync(join(dir, 'a.xml'), `client::[struct].entry`) - writeFileSync(join(dir, 'b.xml'), `[struct].table`) - writeFileSync(join(dir, 'c.xml'), `clientclient::[struct].entry + writeFileSync( + join(dir, 'a.xml'), + `client::[struct].entry`, + ) + writeFileSync( + join(dir, 'b.xml'), + `[struct].table`, + ) + writeFileSync( + join(dir, 'c.xml'), + `clientclient::[struct].entry struct client::@123456entry - `) + `, + ) const symbols = extractDoxygen(dir, '', { language: 'c' }) - expect(symbols[0]).toMatchObject({ id: 'c:struct:client::[struct].entry', name: 'entry (anonymous struct)', qualifiedName: 'client.entry (anonymous struct)', signatures: [] }) - expect(symbols[1]).toMatchObject({ id: 'c:struct:[struct].table', name: 'table (anonymous struct)', signatures: [] }) - expect(symbols.find((symbol) => symbol.id === 'c:struct:client::entry')).toMatchObject({ type: 'anonymous struct', signatures: [{ raw: undefined, params: [] }] }) - expect(symbols.every((symbol) => !symbol.signatures.some((signature) => /@123456|\[struct\]/.test(signature.raw ?? '')))).toBe(true) + expect(symbols[0]).toMatchObject({ + id: 'c:struct:client::[struct].entry', + name: 'entry (anonymous struct)', + qualifiedName: 'client.entry (anonymous struct)', + signatures: [], + }) + expect(symbols[1]).toMatchObject({ + id: 'c:struct:[struct].table', + name: 'table (anonymous struct)', + signatures: [], + }) + expect(symbols.find((symbol) => symbol.id === 'c:struct:client::entry')).toMatchObject({ + type: 'anonymous struct', + signatures: [{ raw: undefined, params: [] }], + }) + expect( + symbols.every( + (symbol) => !symbol.signatures.some((signature) => /@123456|\[struct\]/.test(signature.raw ?? '')), + ), + ).toBe(true) } finally { rmSync(dir, { recursive: true, force: true }) } diff --git a/packages/api-model/test/extensions.test.ts b/packages/api-model/test/extensions.test.ts index 04612006f..cc941e18a 100644 --- a/packages/api-model/test/extensions.test.ts +++ b/packages/api-model/test/extensions.test.ts @@ -51,12 +51,7 @@ describe('an extension block belongs to the type it extends', () => { it('leaves a block alone when the name is ambiguous', () => { // Rust lets two modules declare different types called `Error`. Attaching // the block to either would be a guess. - const symbols = [ - sym('a.Error', 'enum'), - sym('b.Error', 'struct'), - sym('c.Error', 'class'), - member('c.Error.code'), - ] + const symbols = [sym('a.Error', 'enum'), sym('b.Error', 'struct'), sym('c.Error', 'class'), member('c.Error.code')] expect(mergeExtensions(symbols, 'class').map((s) => s.id)).toEqual(symbols.map((s) => s.id)) }) @@ -71,12 +66,7 @@ describe('an extension block belongs to the type it extends', () => { it('still leaves one alone when the name is ambiguous rather than absent', () => { // Absent and ambiguous are different: nothing to attach to versus more // than one candidate. Dropping the second would lose real members. - const symbols = [ - sym('a.Error', 'enum'), - sym('b.Error', 'struct'), - sym('c.Error', 'class'), - member('c.Error.code'), - ] + const symbols = [sym('a.Error', 'enum'), sym('b.Error', 'struct'), sym('c.Error', 'class'), member('c.Error.code')] expect(mergeExtensions(symbols, 'class').map((s) => s.id)).toEqual(symbols.map((s) => s.id)) }) diff --git a/packages/api-model/test/federation.test.ts b/packages/api-model/test/federation.test.ts index e032dc13a..8391015d6 100644 --- a/packages/api-model/test/federation.test.ts +++ b/packages/api-model/test/federation.test.ts @@ -79,10 +79,7 @@ describe('inventories are scoped to their own language', () => { for (const port of OTHER_PORTS) { for (const name of [...POACHED, 'Stream.filter()']) { const res = r.resolve(port, name) - expect( - res.how === 'federated' ? res.project : '', - `${port} resolved ${name}`, - ).not.toBe('Python') + expect(res.how === 'federated' ? res.project : '', `${port} resolved ${name}`).not.toBe('Python') } } }) @@ -152,9 +149,7 @@ describe('constructors do not contest their own type', () => { // The symbol graph names a method `unsetEnvironment(_:in:)`; prose writes // `unsetEnvironment`. Both must find it. const r = new Resolver([ - model('swift', [ - { id: 'TmuxServer.unsetEnvironment(_:in:)', name: 'unsetEnvironment(_:in:)', kind: 'method' }, - ]), + model('swift', [{ id: 'TmuxServer.unsetEnvironment(_:in:)', name: 'unsetEnvironment(_:in:)', kind: 'method' }]), ] as never) expect(r.resolve('swift', 'unsetEnvironment').how).toBe('unique') }) @@ -170,8 +165,7 @@ describe('constructors do not contest their own type', () => { * two characters. */ describe('module names', () => { - const sym = (id: string, name: string) => - ({ id, name, kind: 'class', signatures: [], docs: [] }) as never + const sym = (id: string, name: string) => ({ id, name, kind: 'class', signatures: [], docs: [] }) as never it('reads a module off a public id in every separator style', () => { expect(moduleOf(sym('libtmux.neo.Obj', 'Obj'))).toBe('libtmux.neo') @@ -222,8 +216,7 @@ describe('module names', () => { * uses for its anchor. */ describe('modules resolve as destinations', () => { - const sym = (id: string, name: string) => - ({ id, name, kind: 'class', signatures: [], docs: [] }) as never + const sym = (id: string, name: string) => ({ id, name, kind: 'class', signatures: [], docs: [] }) as never const r = new Resolver([ { port: 'py', @@ -353,9 +346,9 @@ describe('enum variants reach the model', () => { it.runIf(existsSync(join(here, '../../../site/src/data/api/rs.json')))( 'every enum with variants has them attached, not floating', () => { - const model = JSON.parse( - readFileSync(join(here, '../../../site/src/data/api/rs.json'), 'utf8'), - ) as { symbols: { id: string; kind: string; parent?: string }[] } + const model = JSON.parse(readFileSync(join(here, '../../../site/src/data/api/rs.json'), 'utf8')) as { + symbols: { id: string; kind: string; parent?: string }[] + } const enumIds = new Set(model.symbols.filter((s) => s.kind === 'enum').map((s) => s.id)) const attached = model.symbols.filter((s) => s.parent && enumIds.has(s.parent)) expect(attached.length).toBeGreaterThan(100) @@ -389,12 +382,7 @@ describe('JDK and DOM federation', () => { const resolver = () => { const r = new Resolver([]) if (jdk) { - r.addInventory( - 'Java SE', - 'https://docs.oracle.com/en/java/javase/21/docs/api/', - jdk.entries, - ['java'], - ) + r.addInventory('Java SE', 'https://docs.oracle.com/en/java/javase/21/docs/api/', jdk.entries, ['java']) } if (dom) r.addInventory('MDN', 'https://developer.mozilla.org/', dom.entries, ['ts']) return r @@ -417,9 +405,7 @@ describe('JDK and DOM federation', () => { expect(res.how).toBe('federated') // A language builtin, not a DOM interface. MDN files the two apart and so // must the inventory: `Web/API/Promise` is a page that does not exist. - expect(res.how === 'federated' && res.href).toContain( - 'Web/JavaScript/Reference/Global_Objects/Promise', - ) + expect(res.how === 'federated' && res.href).toContain('Web/JavaScript/Reference/Global_Objects/Promise') }) it.runIf(dom)('keeps DOM interfaces under Web/API', () => { diff --git a/packages/api-model/test/fsharp-examples.test.ts b/packages/api-model/test/fsharp-examples.test.ts index f820037c0..18279f478 100644 --- a/packages/api-model/test/fsharp-examples.test.ts +++ b/packages/api-model/test/fsharp-examples.test.ts @@ -5,16 +5,28 @@ import type { ApiSymbol } from '../src/model.ts' const source = { repo: 'libtmux/libtmux-dotnet', revision: 'a'.repeat(40) } const fixture = () => { const program = { - id: 'fsharp-SnapshotAccess', profile: 'fsharp', title: 'Read captured state', + id: 'fsharp-SnapshotAccess', + profile: 'fsharp', + title: 'Read captured state', description: 'Read captured state without confusing it with an uncaptured relation.', sourceFile: 'examples/LibTmux.FSharp.Examples/Programs/SnapshotAccess.fs', - targets: ['M:LibTmux.FSharp.Snapshot.relation``1(LibTmux.CapturedRelation{``0})', - 'T:LibTmux.FSharp.CaptureState`1'], output: 'captured\n', + targets: [ + 'M:LibTmux.FSharp.Snapshot.relation``1(LibTmux.CapturedRelation{``0})', + 'T:LibTmux.FSharp.CaptureState`1', + ], + output: 'captured\n', } const manifest = { schemaVersion: 1, - profiles: { fsharp: { package: 'LibTmux.FSharp', projectFile: 'examples/api/fsharp/Example.fsproj', entrypoint: 'Program.fs' } }, - setupFiles: ['global.json', 'examples/api/NuGet.config'], examples: [program], + profiles: { + fsharp: { + package: 'LibTmux.FSharp', + projectFile: 'examples/api/fsharp/Example.fsproj', + entrypoint: 'Program.fs', + }, + }, + setupFiles: ['global.json', 'examples/api/NuGet.config'], + examples: [program], } const files = new Map([ ['global.json', '{"sdk":{"version":"10.0.302"}}\n'], @@ -23,14 +35,31 @@ const fixture = () => { [program.sourceFile, '\n// Complete source file, including every blank line.\nopen LibTmux.FSharp\n\n'], ]) const symbols: ApiSymbol[] = [ - { id: 'LibTmux.FSharp.Snapshot.relation', name: 'relation', kind: 'function', modifiers: [], - signatures: [], source: { file: 'Library.fsi' }, slug: 'snapshot-relation', - doc: { summary: 'Native function documentation.', examples: [{ lang: 'fsharp', code: 'existing inline example' }] } }, - { id: 'LibTmux.FSharp.CaptureState', name: 'CaptureState', kind: 'enum', modifiers: [], - signatures: [], source: { file: 'Library.fsi' }, slug: 'capturestate', - doc: { summary: 'Native type documentation.' } }, + { + id: 'LibTmux.FSharp.Snapshot.relation', + name: 'relation', + kind: 'function', + modifiers: [], + signatures: [], + source: { file: 'Library.fsi' }, + slug: 'snapshot-relation', + doc: { + summary: 'Native function documentation.', + examples: [{ lang: 'fsharp', code: 'existing inline example' }], + }, + }, + { + id: 'LibTmux.FSharp.CaptureState', + name: 'CaptureState', + kind: 'enum', + modifiers: [], + signatures: [], + source: { file: 'Library.fsi' }, + slug: 'capturestate', + doc: { summary: 'Native type documentation.' }, + }, ] - const read = (path: string) => path === 'examples/api/manifest.json' ? JSON.stringify(manifest) : files.get(path) + const read = (path: string) => (path === 'examples/api/manifest.json' ? JSON.stringify(manifest) : files.get(path)) return { manifest, files, symbols, read } } @@ -48,8 +77,12 @@ describe('complete F# API programs', () => { expect(blocks).toHaveLength(7) expect(blocks[0].code).toContain(`checkout --detach ${source.revision}`) expect(blocks[0].intro).toContain('.NET SDK 10.0.302') - for (const [i, path] of ['global.json', 'examples/api/NuGet.config', - 'examples/api/fsharp/Example.fsproj', manifest.examples[0].sourceFile].entries()) { + for (const [i, path] of [ + 'global.json', + 'examples/api/NuGet.config', + 'examples/api/fsharp/Example.fsproj', + manifest.examples[0].sourceFile, + ].entries()) { expect(blocks[i + 1].code).toBe(files.get(path)) expect(blocks[i + 1].sourceUrl).toBe(`https://github.com/${source.repo}/blob/${source.revision}/${path}`) } @@ -65,8 +98,13 @@ describe('complete F# API programs', () => { it('preserves a second complete program on the same API page', () => { const { manifest, files, symbols, read } = fixture() const first = manifest.examples[0] - const second = { ...first, id: 'fsharp-LookupFailures', - sourceFile: first.sourceFile.replace('SnapshotAccess', 'LookupFailures'), targets: [first.targets[0]], output: 'failure kept\n' } + const second = { + ...first, + id: 'fsharp-LookupFailures', + sourceFile: first.sourceFile.replace('SnapshotAccess', 'LookupFailures'), + targets: [first.targets[0]], + output: 'failure kept\n', + } files.set(second.sourceFile, 'printfn "failure kept"\n') manifest.examples.push(second) attachCompleteFSharpExamples(symbols, read, source) @@ -89,7 +127,9 @@ describe('complete F# API programs', () => { for (const target of ['M:LibTmux.FSharp.Other.relation(System.String)', 'T:LibTmux.FSharp.Snapshot.relation']) { const { manifest, symbols, read } = fixture() manifest.examples[0].targets = [target] - expect(() => attachCompleteFSharpExamples(symbols, read, source)).toThrow('must resolve once with its native kind') + expect(() => attachCompleteFSharpExamples(symbols, read, source)).toThrow( + 'must resolve once with its native kind', + ) } const ambiguous = fixture() ambiguous.symbols.push(structuredClone(ambiguous.symbols[0])) @@ -97,7 +137,10 @@ describe('complete F# API programs', () => { }) it('rejects repeated targets, including two compiler signatures mapping to one source name', () => { - for (const target of [fixture().manifest.examples[0].targets[0], 'M:LibTmux.FSharp.Snapshot.relation(System.String)']) { + for (const target of [ + fixture().manifest.examples[0].targets[0], + 'M:LibTmux.FSharp.Snapshot.relation(System.String)', + ]) { const { manifest, symbols, read } = fixture() manifest.examples[0].targets.push(target) expect(() => attachCompleteFSharpExamples(symbols, read, source)).toThrow('Duplicate F# example target') @@ -112,15 +155,20 @@ describe('complete F# API programs', () => { } const invalid = fixture() invalid.manifest.examples[0].sourceFile = 'examples/../private.fs' - expect(() => attachCompleteFSharpExamples(invalid.symbols, invalid.read, source)).toThrow('Invalid F# example metadata') + expect(() => attachCompleteFSharpExamples(invalid.symbols, invalid.read, source)).toThrow( + 'Invalid F# example metadata', + ) }) it('requires the exact source revision, SDK and setup contract', () => { const invalid = fixture() - expect(() => attachCompleteFSharpExamples(invalid.symbols, invalid.read, { ...source, revision: 'main' })) - .toThrow('full source revision') + expect(() => attachCompleteFSharpExamples(invalid.symbols, invalid.read, { ...source, revision: 'main' })).toThrow( + 'full source revision', + ) invalid.manifest.setupFiles.pop() - expect(() => attachCompleteFSharpExamples(invalid.symbols, invalid.read, source)).toThrow('Invalid F# example manifest') + expect(() => attachCompleteFSharpExamples(invalid.symbols, invalid.read, source)).toThrow( + 'Invalid F# example manifest', + ) const sdk = fixture() sdk.files.set('global.json', '{"sdk":{"version":"latest"}}\n') expect(() => attachCompleteFSharpExamples(sdk.symbols, sdk.read, source)).toThrow('pinned SDK') diff --git a/packages/api-model/test/fsharp.test.ts b/packages/api-model/test/fsharp.test.ts index 5cc7d658f..f7b0c6b42 100644 --- a/packages/api-model/test/fsharp.test.ts +++ b/packages/api-model/test/fsharp.test.ts @@ -3,19 +3,41 @@ import { extractFSharpArtifact } from '../src/languages/fsharp.ts' import { ownersOf } from '../src/prose.ts' const declaration = { - id: 'LibTmux.FSharp.Filter', name: 'Filter', kind: 'class', parent: 'LibTmux.FSharp', - file: 'Query.fsi', line: 10, signature: "type Filter<'T>", parameters: [], returns: '', documentation: '', + id: 'LibTmux.FSharp.Filter', + name: 'Filter', + kind: 'class', + parent: 'LibTmux.FSharp', + file: 'Query.fsi', + line: 10, + signature: "type Filter<'T>", + parameters: [], + returns: '', + documentation: '', namespaces: ['LibTmux'], } -const artifact = { schema: 1, port: 'fsharp', compiler: '43.10.302.0', declarations: [ - declaration, - { ...declaration, kind: 'module', signature: 'module Filter', line: 15 }, - { ...declaration, id: 'LibTmux.FSharp.Filter.eq', name: 'eq', kind: 'function', parent: 'LibTmux.FSharp.Filter', - signature: "val eq: value: 'Value -> field: Field<'T, 'Value> -> Filter<'T>", - parameters: [{ name: 'value', type: "'Value", optional: false }, { name: 'field', type: "Field<'T, 'Value>", optional: false }], - returns: "Filter<'T>", documentation: 'Match a field.The expected value.', - }, -] } +const artifact = { + schema: 1, + port: 'fsharp', + compiler: '43.10.302.0', + declarations: [ + declaration, + { ...declaration, kind: 'module', signature: 'module Filter', line: 15 }, + { + ...declaration, + id: 'LibTmux.FSharp.Filter.eq', + name: 'eq', + kind: 'function', + parent: 'LibTmux.FSharp.Filter', + signature: "val eq: value: 'Value -> field: Field<'T, 'Value> -> Filter<'T>", + parameters: [ + { name: 'value', type: "'Value", optional: false }, + { name: 'field', type: "Field<'T, 'Value>", optional: false }, + ], + returns: "Filter<'T>", + documentation: 'Match a field.The expected value.', + }, + ], +} it('preserves F# currying and type/module identity for member navigation', () => { const model = extractFSharpArtifact(artifact, 'a'.repeat(40)) @@ -25,15 +47,21 @@ it('preserves F# currying and type/module identity for member navigation', () => expect(model.symbols[0].signatures.map((sig) => sig.raw)).toEqual(["type Filter<'T>", 'module Filter']) expect(model.symbols[1].signatures[0]).toMatchObject({ raw: artifact.declarations[2].signature, - params: [{ name: 'value', type: "'Value", doc: 'The expected value.' }, { name: 'field', type: "Field<'T, 'Value>" }], + params: [ + { name: 'value', type: "'Value", doc: 'The expected value.' }, + { name: 'field', type: "Field<'T, 'Value>" }, + ], returns: "Filter<'T>", }) }) it('rejects empty, unversioned, duplicate or incomplete F# compiler output', () => { for (const invalid of [ - {}, { ...artifact, declarations: [] }, { ...artifact, compiler: undefined }, + {}, + { ...artifact, declarations: [] }, + { ...artifact, compiler: undefined }, { ...artifact, declarations: [declaration, declaration] }, { ...artifact, declarations: [{ ...declaration, signature: '' }] }, - ]) expect(() => extractFSharpArtifact(invalid)).toThrow() + ]) + expect(() => extractFSharpArtifact(invalid)).toThrow() }) diff --git a/packages/api-model/test/go-examples.test.ts b/packages/api-model/test/go-examples.test.ts index fdc2f649e..2719504af 100644 --- a/packages/api-model/test/go-examples.test.ts +++ b/packages/api-model/test/go-examples.test.ts @@ -17,18 +17,27 @@ func ExampleServer_Sessions_complete() { } ` const symbol = (): ApiSymbol => ({ - id: 'tmux.Server.Sessions', name: 'Sessions', kind: 'method', parent: 'tmux.Server', - source: { file: 'tmux/hierarchy.go', line: 7 }, signatures: [], + id: 'tmux.Server.Sessions', + name: 'Sessions', + kind: 'method', + parent: 'tmux.Server', + source: { file: 'tmux/hierarchy.go', line: 7 }, + signatures: [], doc: { summary: 'Original contract.', examples: [{ lang: 'go', code: 'existing' }] }, }) describe('complete native Go examples', () => { it('attaches exact file bytes, description, dependency pin, and native run command', async () => { const examples = await readCompleteGoExamples([{ file, code }]) - expect(examples).toEqual([{ - symbol: 'tmux.Server.Sessions', file, code, output: 'sessions: 2', - intro: 'List sessions from the server.\nKeep the returned records for later inspection.', - }]) + expect(examples).toEqual([ + { + symbol: 'tmux.Server.Sessions', + file, + code, + output: 'sessions: 2', + intro: 'List sessions from the server.\nKeep the returned records for later inspection.', + }, + ]) const target = symbol() attachCompleteGoExamples([target], examples, source) expect(target.id).toBe('tmux.Server.Sessions') @@ -36,7 +45,9 @@ describe('complete native Go examples', () => { const blocks = target.doc!.examples! expect(blocks.map((block) => block.lang)).toEqual(['go', 'console', 'go', 'go', 'console']) expect(blocks[0].code).toBe('existing') - expect(blocks[1].code).toBe(`$ git clone https://github.com/libtmux/libtmux-go.git libtmux-source && \\\n git -C libtmux-source checkout ${source.revision}\n`) + expect(blocks[1].code).toBe( + `$ git clone https://github.com/libtmux/libtmux-go.git libtmux-source && \\\n git -C libtmux-source checkout ${source.revision}\n`, + ) expect(blocks[2].code).toContain('replace github.com/libtmux/libtmux-go => ./libtmux-source') expect(blocks[3].code).toBe(code) expect(blocks[3].sourceUrl).toBe(`https://github.com/${source.repo}/blob/${source.revision}/${file}`) @@ -45,18 +56,18 @@ describe('complete native Go examples', () => { }) it('uses native function and receiver-method names for attachment', async () => { - const examples = await readCompleteGoExamples([ - { file, code: code.replace('Server_Sessions', 'NewServer') }, - ]) + const examples = await readCompleteGoExamples([{ file, code: code.replace('Server_Sessions', 'NewServer') }]) expect(examples[0].symbol).toBe('tmux.NewServer') }) it('omits ordinary examples, tests, and non-test source files', async () => { - expect(await readCompleteGoExamples([ - { file, code: code.replace('_complete', '') }, - { file, code: code.replace('ExampleServer_Sessions_complete', 'TestServer') }, - { file: 'tmux/server.go', code }, - ])).toEqual([]) + expect( + await readCompleteGoExamples([ + { file, code: code.replace('_complete', '') }, + { file, code: code.replace('ExampleServer_Sessions_complete', 'TestServer') }, + { file: 'tmux/server.go', code }, + ]), + ).toEqual([]) }) it.each([ @@ -74,8 +85,12 @@ describe('complete native Go examples', () => { }) it('rejects duplicate examples and missing or ambiguous API targets', async () => { - await expect(readCompleteGoExamples([{ file, code }, { file: 'tmux/other_test.go', code }])) - .rejects.toThrow(/duplicate example/) + await expect( + readCompleteGoExamples([ + { file, code }, + { file: 'tmux/other_test.go', code }, + ]), + ).rejects.toThrow(/duplicate example/) const examples = await readCompleteGoExamples([{ file, code }]) expect(() => attachCompleteGoExamples([], examples, source)).toThrow(/resolve once/) expect(() => attachCompleteGoExamples([symbol(), symbol()], examples, source)).toThrow(/resolve once/) @@ -83,9 +98,11 @@ describe('complete native Go examples', () => { it('rejects mutable refs or missing dependency floors', async () => { const examples = await readCompleteGoExamples([{ file, code }]) - expect(() => attachCompleteGoExamples([symbol()], examples, { ...source, revision: 'main' })) - .toThrow(/full source revision/) - expect(() => attachCompleteGoExamples([symbol()], examples, { ...source, goVersion: '' })) - .toThrow(/module Go version/) + expect(() => attachCompleteGoExamples([symbol()], examples, { ...source, revision: 'main' })).toThrow( + /full source revision/, + ) + expect(() => attachCompleteGoExamples([symbol()], examples, { ...source, goVersion: '' })).toThrow( + /module Go version/, + ) }) }) diff --git a/packages/api-model/test/guarantees.test.ts b/packages/api-model/test/guarantees.test.ts index aa040174b..124a49191 100644 --- a/packages/api-model/test/guarantees.test.ts +++ b/packages/api-model/test/guarantees.test.ts @@ -69,20 +69,17 @@ count = sum(len(entries) for entries in inv.values()) names = sorted({name for entries in inv.values() for name in entries}) print(json.dumps({"count": count, "domains": sorted(inv), "sample": names[:3]})) ` - const out = execFileSync( - 'uv', - ['run', '--with', 'sphinx', 'python', '-c', script, file], - { encoding: 'utf8', timeout: 120_000 }, - ) + const out = execFileSync('uv', ['run', '--with', 'sphinx', 'python', '-c', script, file], { + encoding: 'utf8', + timeout: 120_000, + }) const result = JSON.parse(out.trim().split('\n').pop() ?? '{}') as { count: number domains: string[] sample: string[] } - expect(result.count, `${port}: Sphinx read a different number of entries`).toBe( - model.symbols.length, - ) + expect(result.count, `${port}: Sphinx read a different number of entries`).toBe(model.symbols.length) expect(result.domains.length, `${port}: no domains`).toBeGreaterThan(0) }) }) @@ -102,9 +99,7 @@ describe('names are representable', () => { it.each(PORTS)('%s has no whitespace in any name or id', (port) => { const model = load(port) if (!model) return - const bad = model.symbols.filter( - (s) => /\s/.test(s.name) || /\s/.test(s.id) || /\s/.test(s.publicId ?? ''), - ) + const bad = model.symbols.filter((s) => /\s/.test(s.name) || /\s/.test(s.id) || /\s/.test(s.publicId ?? '')) expect( bad.slice(0, 5).map((s) => `${s.kind} ${JSON.stringify(s.name)} (${s.id})`), `${port}: ${bad.length} names carry whitespace`, @@ -178,8 +173,9 @@ describe('the resolver keeps its rate', () => { } } const rate = 1 - failures.length / attempted.length - expect(rate, `${failures.length}/${attempted.length} unresolved:\n${failures.join('\n')}`) - .toBeGreaterThanOrEqual(0.85) + expect(rate, `${failures.length}/${attempted.length} unresolved:\n${failures.join('\n')}`).toBeGreaterThanOrEqual( + 0.85, + ) }) it('still refuses what it should refuse', () => { @@ -236,7 +232,10 @@ describe('doc comments give up their examples', () => { 'edition2024', ]) const wrong = blocks.filter((b) => attributes.has(b.lang)) - expect(wrong.slice(0, 3).map((b) => b.lang), `${port} mis-tagged blocks`).toEqual([]) + expect( + wrong.slice(0, 3).map((b) => b.lang), + `${port} mis-tagged blocks`, + ).toEqual([]) const auxiliary = new Set(['console', 'text', 'toml']) const application = blocks.filter((b) => !auxiliary.has(b.lang)) const own = application.filter((b) => b.lang === lang).length @@ -272,13 +271,18 @@ describe('each language gets its own spelling of a cross-reference', () => { spans.filter((s) => s.kind === 'ref').map((s) => (s as { target: string }).target) it('preserves authored PowerShell code links and keyboard keys', () => { - const href = 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_commonparameters?view=powershell-7.4#-erroraction' - const spans = tokenizeDoc(`Use [\`-ErrorAction Stop\`](${href}) after C-a or Enter.`, 'powershell') + const href = + 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_commonparameters?view=powershell-7.4#-erroraction' + const spans = tokenizeDoc( + `Use [\`-ErrorAction Stop\`](${href}) after C-a or Enter.`, + 'powershell', + ) expect(spans.filter((span) => span.kind === 'link')).toEqual([ { kind: 'link', text: '-ErrorAction Stop', href, code: true }, ]) expect(spans.filter((span) => span.kind === 'kbd')).toEqual([ - { kind: 'kbd', text: 'C-a' }, { kind: 'kbd', text: 'Enter' }, + { kind: 'kbd', text: 'C-a' }, + { kind: 'kbd', text: 'Enter' }, ]) expect(ref(spans)).toEqual([]) expect(tokenizeDoc('The command continues after the key is sent.', 'powershell')).toEqual([ @@ -287,29 +291,21 @@ describe('each language gets its own spelling of a cross-reference', () => { }) it('Go doc links resolve, Rust-style ones do not leak into Go', () => { - expect(ref(tokenizeDoc('See [GlobalWindowScope.Options] for this.', 'go'))).toEqual([ - 'GlobalWindowScope.Options', - ]) + expect(ref(tokenizeDoc('See [GlobalWindowScope.Options] for this.', 'go'))).toEqual(['GlobalWindowScope.Options']) // Bracketed prose is not a reference, and a Markdown link is not one either. expect(ref(tokenizeDoc('as noted [see below] and [text](http://x)', 'go'))).toEqual([]) }) it('Rust keeps its scope operator, and Go links do not fire there', () => { - expect(ref(tokenizeDoc('Returns [`Error::Unsupported`] below 3.3.', 'rs'))).toEqual([ - 'Error.Unsupported', - ]) + expect(ref(tokenizeDoc('Returns [`Error::Unsupported`] below 3.3.', 'rs'))).toEqual(['Error.Unsupported']) expect(ref(tokenizeDoc('the [Options] field', 'rs'))).toEqual([]) }) it('javadoc and TSDoc inline tags', () => { - expect(ref(tokenizeDoc('Use {@link Server#newSession} instead.', 'java'))).toEqual([ - 'Server.newSession', - ]) + expect(ref(tokenizeDoc('Use {@link Server#newSession} instead.', 'java'))).toEqual(['Server.newSession']) // `{@code x}` is a literal, the same thing reST spells with double backticks. const spans = tokenizeDoc('Pass {@code null} to clear.', 'java') - expect(spans.filter((s) => s.kind === 'code').map((s) => (s as { text: string }).text)).toEqual([ - 'null', - ]) + expect(spans.filter((s) => s.kind === 'code').map((s) => (s as { text: string }).text)).toEqual(['null']) }) it('rustdoc links to an item in scope, and through Markdown syntax', () => { @@ -324,16 +320,12 @@ describe('each language gets its own spelling of a cross-reference', () => { }) it('C# see-cref, with the addressing prefix removed', () => { - expect(ref(tokenizeDoc('See for more.', 'csharp'))).toEqual([ - 'LibTmux.Server', - ]) + expect(ref(tokenizeDoc('See for more.', 'csharp'))).toEqual(['LibTmux.Server']) }) it('a C# keyword is a literal, not a member', () => { const spans = tokenizeDoc('Or for the default.', 'csharp') - expect(spans.filter((s) => s.kind === 'code').map((s) => (s as { text: string }).text)).toEqual([ - 'null', - ]) + expect(spans.filter((s) => s.kind === 'code').map((s) => (s as { text: string }).text)).toEqual(['null']) }) it('bold reaches the page as bold, and prose keeps its asterisks', () => { @@ -341,7 +333,9 @@ describe('each language gets its own spelling of a cross-reference', () => { // like. Rendered as text they were literal asterisks, which is what the // reference showed once the doc comment behind `#[derive]` was readable. const strong = (t: string, l?: string) => - tokenizeDoc(t, l).filter((s) => s.kind === 'strong').map((s) => (s as { text: string }).text) + tokenizeDoc(t, l) + .filter((s) => s.kind === 'strong') + .map((s) => (s as { text: string }).text) expect(strong('**Connecting.** `new` takes the socket.', 'rs')).toEqual(['Connecting.']) // A single asterisk is emphasis, a glob and a multiplication sign, so it // is left alone. Both of these are prose. @@ -376,9 +370,7 @@ describe('a doc comment is read in the dialect it was written in', () => { * ``. Only javadoc's own vocabulary may be translated. */ it('javadoc HTML becomes the model, and other angle brackets survive', () => { - const md = javadocToMarkdown( - 'Takes a {@code List} at a path.\n\n

    Then waits.', - ) + const md = javadocToMarkdown('Takes a {@code List} at a path.\n\n

    Then waits.') expect(md).toContain('List') expect(md).toContain('') expect(md).not.toContain('

    ') @@ -387,9 +379,7 @@ describe('a doc comment is read in the dialect it was written in', () => { it('javadoc example blocks reach the renderer that draws examples', () => { const { doc } = parseJavadoc('Creates it.\n\n

    {@code\nvar s = server.newSession();\n}
    ') - expect(doc.examples?.map((e) => [e.lang, e.code])).toEqual([ - ['java', 'var s = server.newSession();'], - ]) + expect(doc.examples?.map((e) => [e.lang, e.code])).toEqual([['java', 'var s = server.newSession();']]) }) }) @@ -419,9 +409,7 @@ describe('doc comments give up their parameters', () => { const model = load('csharp') if (!model) return const leaked = model.symbols.filter((s) => - /<(summary|remarks|para|returns|example|list)\b/.test( - `${s.doc?.summary ?? ''}${s.doc?.body ?? ''}`, - ), + /<(summary|remarks|para|returns|example|list)\b/.test(`${s.doc?.summary ?? ''}${s.doc?.body ?? ''}`), ) expect(leaked.slice(0, 3).map((s) => s.publicId ?? s.id)).toEqual([]) }) @@ -491,11 +479,7 @@ describe('the mention index is computed, not observed', () => { }) it('ignores prose that is not a code span, and code that is not a name', () => { - const md = [ - '| Python | server.sessions |', - '| Go | `see the guide` |', - '| C++ | `-L socket-name` |', - ].join('\n') + const md = ['| Python | server.sessions |', '| Go | `see the guide` |', '| C++ | `-L socket-name` |'].join('\n') // Unbackticked prose is not a mention, and neither is a phrase with // spaces and no call parens — which covers both the sentence and the // command-line flag, so the resolver is never asked about either. diff --git a/packages/api-model/test/java.test.ts b/packages/api-model/test/java.test.ts index 83c088391..247cd76ce 100644 --- a/packages/api-model/test/java.test.ts +++ b/packages/api-model/test/java.test.ts @@ -61,7 +61,11 @@ describe('Java callable visibility', () => { public enum Mode { SAFE, FAST } `) expect(symbols.filter((symbol) => symbol.parent).map((symbol) => symbol.name)).toEqual([ - 'read', 'value', 'create', 'SAFE', 'FAST', + 'read', + 'value', + 'create', + 'SAFE', + 'FAST', ]) }) }) @@ -79,18 +83,40 @@ describe('Java source-qualified names', () => { } } `) - expect(symbols.map(({ id, qualifiedName, namespace, parent }) => ({ id, qualifiedName, namespace, parent }))).toEqual([ + expect( + symbols.map(({ id, qualifiedName, namespace, parent }) => ({ id, qualifiedName, namespace, parent })), + ).toEqual([ { id: 'example.Example', qualifiedName: 'org.org.api.Example', namespace: 'org.org.api', parent: undefined }, - { id: 'example.Example.Example', qualifiedName: 'org.org.api.Example.Example', namespace: 'org.org.api', parent: 'example.Example' }, - { id: 'example.Example.Builder', qualifiedName: 'org.org.api.Example.Builder', namespace: 'org.org.api', parent: 'example.Example' }, - { id: 'example.Example.Builder.Builder', qualifiedName: 'org.org.api.Example.Builder.Builder', namespace: 'org.org.api', parent: 'example.Example.Builder' }, - { id: 'example.Example.Builder.build', qualifiedName: 'org.org.api.Example.Builder.build', namespace: 'org.org.api', parent: 'example.Example.Builder' }, + { + id: 'example.Example.Example', + qualifiedName: 'org.org.api.Example.Example', + namespace: 'org.org.api', + parent: 'example.Example', + }, + { + id: 'example.Example.Builder', + qualifiedName: 'org.org.api.Example.Builder', + namespace: 'org.org.api', + parent: 'example.Example', + }, + { + id: 'example.Example.Builder.Builder', + qualifiedName: 'org.org.api.Example.Builder.Builder', + namespace: 'org.org.api', + parent: 'example.Example.Builder', + }, + { + id: 'example.Example.Builder.build', + qualifiedName: 'org.org.api.Example.Builder.build', + namespace: 'org.org.api', + parent: 'example.Example.Builder', + }, ]) expect(moduleOf(symbols.at(-1)!)).toBe('org.org.api') expect(qualifiedNameOf(symbols.at(-1)!)).toBe('org.org.api.Example.Builder.build') const model = { port: 'java' as const, extractor: 'test', symbols } - const hrefFor = (symbol: typeof symbols[number]) => `/reference/${symbol.id}/#${symbol.id}` + const hrefFor = (symbol: (typeof symbols)[number]) => `/reference/${symbol.id}/#${symbol.id}` const index = new SymbolIndex(symbols, hrefFor, 'java') const resolver = new Resolver([model]) for (const symbol of symbols) { @@ -102,11 +128,16 @@ describe('Java source-qualified names', () => { expect(index.resolve('Example', 'class', builder)?.symbol?.id).toBe('example.Example') expect(index.resolve('Builder', 'class', builder)?.symbol?.id).toBe('example.Example.Builder') const parents = parentInventory(model, hrefFor) - expect(parents.find((entry) => entry.name === 'org.org.api.Example.Builder.build')?.uri).toBe(hrefFor(builder).slice(1)) - expect(parents.find((entry) => entry.name === 'org.org.api.Example.Example')?.uri).toBe(hrefFor(symbols[1]).slice(1)) + expect(parents.find((entry) => entry.name === 'org.org.api.Example.Builder.build')?.uri).toBe( + hrefFor(builder).slice(1), + ) + expect(parents.find((entry) => entry.name === 'org.org.api.Example.Example')?.uri).toBe( + hrefFor(symbols[1]).slice(1), + ) const inventory = readInventory(writeInventory(model, { project: 'java', version: 'test', uriFor: hrefFor })) expect(inventory.entries.find((entry) => entry.name === builder.id)).toMatchObject({ - dispname: 'org.org.api.Example.Builder.build', uri: hrefFor(builder), + dispname: 'org.org.api.Example.Builder.build', + uri: hrefFor(builder), }) }) @@ -120,7 +151,11 @@ describe('Java source-qualified names', () => { }) it('keeps legacy type links when a constructor has the same canonical spelling', async () => { - const symbols = await extract('package example; public class Example { public Example() {} }', false, 'example.Example') + const symbols = await extract( + 'package example; public class Example { public Example() {} }', + false, + 'example.Example', + ) const index = new SymbolIndex(symbols, (symbol) => `/reference/${symbol.id}`, 'java') expect(index.resolve('example.Example')?.symbol?.kind).toBe('class') // This is the constructor's native name and an existing type's stable id. diff --git a/packages/api-model/test/julia.test.ts b/packages/api-model/test/julia.test.ts index 8caef8740..235eaef9f 100644 --- a/packages/api-model/test/julia.test.ts +++ b/packages/api-model/test/julia.test.ts @@ -11,13 +11,27 @@ describe('Julia native API adapter', () => { const input = structuredClone(fixture) const method = input.symbols.find((entry) => entry.id === 'LibTmux.from_env').methods[0] method.documentation[0].markdown = [ - '```julia', 'from_env(; env=ENV)', '```', '', - 'Describe the selected endpoint.', '', '# Arguments', '', + '```julia', + 'from_env(; env=ENV)', + '```', + '', + 'Describe the selected endpoint.', + '', + '# Arguments', + '', '- `env=ENV`: Read `TMUX` from this mapping.', - ' Use [`Server`](@ref) for explicit selection.', '', - ' A second paragraph stays with the parameter.', '', - '- `unknown`: Keep documentation that has no matching native parameter.', '', - '# Examples', '', '```julia', '# Arguments', '- `env`: not an argument description', '```', + ' Use [`Server`](@ref) for explicit selection.', + '', + ' A second paragraph stays with the parameter.', + '', + '- `unknown`: Keep documentation that has no matching native parameter.', + '', + '# Examples', + '', + '```julia', + '# Arguments', + '- `env`: not an argument description', + '```', ].join('\n') const actual = extractJulia(input).symbols.find((entry) => entry.id === 'LibTmux.from_env')! const parameter = actual.signatures[0]!.params.find((entry) => entry.name === 'env')! @@ -39,8 +53,14 @@ describe('Julia native API adapter', () => { const entry = input.symbols.find((entry) => entry.id === 'LibTmux.kill_session') for (const [i, method] of entry.methods.entries()) { const parameter = method.parameters.find((parameter) => parameter.name) - method.documentation = [{ signature: 'native', source: method.source, fields: {}, - markdown: `Remove the session.\n\n# Keyword arguments\n\n- \`${parameter.name}::Any\`: Description for method ${i}.` }] + method.documentation = [ + { + signature: 'native', + source: method.source, + fields: {}, + markdown: `Remove the session.\n\n# Keyword arguments\n\n- \`${parameter.name}::Any\`: Description for method ${i}.`, + }, + ] } const actual = extractJulia(input).symbols.find((symbol) => symbol.id === entry.id)! actual.signatures.forEach((method, i) => { @@ -53,7 +73,9 @@ describe('Julia native API adapter', () => { const expected = [...fixture.symbols, ...fixture.extensionMethods].flatMap((symbol) => symbol.methods) const actual = model.symbols.flatMap((symbol) => symbol.signatures) expect(actual).toHaveLength(expected.length) - expect(actual.map((signature) => signature.identity).sort()).toEqual(expected.map((method) => method.identity).sort()) + expect(actual.map((signature) => signature.identity).sort()).toEqual( + expected.map((method) => method.identity).sort(), + ) for (const method of expected) { expect(actual.find((signature) => signature.identity === method.identity)?.source).toMatchObject(method.source) } @@ -67,16 +89,30 @@ describe('Julia native API adapter', () => { expect(control.source?.file).toBe('src/control_topology.jl') expect(control.params.find((parameter) => parameter.name === 'kwargs')?.variadic).toBe('keyword') expect(binding('LibTmux.read_where_json').signatures[0]?.extension).toBe('LibTmuxJSONExt') - expect(binding('LibTmux.Filters.Contains').signatures[0]?.params.find((parameter) => parameter.name === 'case')).toMatchObject({ default: ':sensitive', keywordOnly: true, type: 'Symbol' }) + expect( + binding('LibTmux.Filters.Contains').signatures[0]?.params.find((parameter) => parameter.name === 'case'), + ).toMatchObject({ default: ':sensitive', keywordOnly: true, type: 'Symbol' }) }) it('retains callable instances, parameterized constructors, aliases and fields', () => { - expect(binding('LibTmux.Criterion').signatures[0]).toMatchObject({ methodKind: 'call', receiver: { name: 'q', type: 'LibTmux.Criterion' } }) + expect(binding('LibTmux.Criterion').signatures[0]).toMatchObject({ + methodKind: 'call', + receiver: { name: 'q', type: 'LibTmux.Criterion' }, + }) expect(binding('LibTmux.RowProjection').signatures[0]?.raw).toContain('RowProjection{R, S}') expect(binding('LibTmux.RowProjection').typeParams).toEqual(['R<:NamedTuple', 'S<:LibTmux.Selection']) - expect(binding('LibTmux.SessionID')).toMatchObject({ kind: 'typealias', type: 'TmuxID{:session}', typeParams: [':session'], source: { file: 'src/model.jl', line: 41 } }) + expect(binding('LibTmux.SessionID')).toMatchObject({ + kind: 'typealias', + type: 'TmuxID{:session}', + typeParams: [':session'], + source: { file: 'src/model.jl', line: 41 }, + }) expect(binding('LibTmux.TmuxID').apiScope).toBe('supporting') - expect(binding('LibTmux.Server.socket_path')).toMatchObject({ kind: 'property', parent: 'LibTmux.Server', modifiers: ['readonly'] }) + expect(binding('LibTmux.Server.socket_path')).toMatchObject({ + kind: 'property', + parent: 'LibTmux.Server', + modifiers: ['readonly'], + }) expect(binding('LibTmux.Server.socket_path').source.line).toBeUndefined() expect(binding('LibTmux.Base.IndexStyle').signatures).toHaveLength(3) expect(binding('LibTmux.Base.IteratorSize').signatures).toHaveLength(1) diff --git a/packages/api-model/test/jvm-examples.test.ts b/packages/api-model/test/jvm-examples.test.ts index 38e3bd8df..6fecec144 100644 --- a/packages/api-model/test/jvm-examples.test.ts +++ b/packages/api-model/test/jvm-examples.test.ts @@ -7,59 +7,90 @@ const variants = ['java', 'kotlin', 'scala-direct', 'scala-cats'] as const const fixture = () => { const files = new Map() const examples = variants.map((variant) => { - const language = variant.startsWith('scala') ? 'scala' : variant as 'java' | 'kotlin' + const language = variant.startsWith('scala') ? 'scala' : (variant as 'java' | 'kotlin') const extension = language === 'kotlin' ? 'kt' : language const name = variant.replace('-', '') const path = `examples/src/main/${language}/${name}/Connect.${extension}` files.set(path, `\npackage example\n// ${variant} whole program bytes\n\n`) - return { id: `${variant}-Connect`, variant, language, description: `Connect with ${variant}.`, - sourceFile: path, mainClass: `io.github.libtmux.examples.${name}.Connect`, - targets: [`${name}.Server`], expectedOutput: 'connected=true\n', path: `src/main/${language}/Connect.${extension}` } + return { + id: `${variant}-Connect`, + variant, + language, + description: `Connect with ${variant}.`, + sourceFile: path, + mainClass: `io.github.libtmux.examples.${name}.Connect`, + targets: [`${name}.Server`], + expectedOutput: 'connected=true\n', + path: `src/main/${language}/Connect.${extension}`, + } }) const manifest = { - schemaVersion: 1, examples, - variants: Object.fromEntries(variants.map((variant) => [variant, { files: - ['settings.gradle.kts', 'build.gradle.kts', 'gradle.properties', 'run.sh'].map((path) => { - const sourceFile = `examples/api/${variant}/${path}` - files.set(sourceFile, `${variant} ${path}\n`) - return { path, sourceFile } - }), - }])), + schemaVersion: 1, + examples, + variants: Object.fromEntries( + variants.map((variant) => [ + variant, + { + files: ['settings.gradle.kts', 'build.gradle.kts', 'gradle.properties', 'run.sh'].map((path) => { + const sourceFile = `examples/api/${variant}/${path}` + files.set(sourceFile, `${variant} ${path}\n`) + return { path, sourceFile } + }), + }, + ]), + ), } const symbols: ApiSymbol[] = examples.map((program) => ({ id: program.variant === 'java' ? 'java.Server.Server' : program.targets[0], publicId: program.variant === 'java' ? 'java.Server.Server' : program.targets[0], - qualifiedName: program.targets[0], name: 'Server', kind: 'class', modifiers: [], - signatures: [], slug: `${program.variant}-server`, source: { file: 'Server' }, + qualifiedName: program.targets[0], + name: 'Server', + kind: 'class', + modifiers: [], + signatures: [], + slug: `${program.variant}-server`, + source: { file: 'Server' }, doc: { summary: 'Existing native documentation.' }, })) - const read = (path: string) => path === 'examples/api/manifest.json' ? JSON.stringify(manifest) : files.get(path) + const read = (path: string) => (path === 'examples/api/manifest.json' ? JSON.stringify(manifest) : files.get(path)) return { files, manifest, symbols, read } } describe('complete JVM API programs', () => { - it.each(['java', 'kotlin', 'scala'] as const)('attaches only %s programs with every source-owned setup file', (port) => { - const { manifest, symbols, files, read } = fixture() - const identities = symbols.map(({ id, publicId, qualifiedName, slug }) => ({ id, publicId, qualifiedName, slug })) - attachCompleteJvmExamples(symbols, port, read, source) - expect(symbols.map(({ id, publicId, qualifiedName, slug }) => ({ id, publicId, qualifiedName, slug }))).toEqual(identities) - for (const [i, program] of manifest.examples.entries()) { - const blocks = symbols[i].doc?.examples - if (program.language !== port) { expect(blocks).toBeUndefined(); continue } - expect(symbols[i].doc?.summary).toBe('Existing native documentation.') - expect(blocks).toHaveLength(8) - const displayed = [...manifest.variants[program.variant].files, program] - displayed.forEach((file, index) => { - expect(blocks![index + 1].code).toBe(files.get(file.sourceFile)) - expect(blocks![index + 1].sourceUrl).toBe(`https://github.com/${source.repo}/blob/${source.revision}/${file.sourceFile}`) - expect(blocks![index + 1].intro).toContain(file.path) - }) - expect(blocks![5].lang).toBe(port) - expect(blocks![0].code).toContain(`git -C libtmux-source checkout ${source.revision}`) - expect(blocks![6].code).toBe(`$ ./libtmux-source/gradlew --project-dir . --console=plain --quiet \\\n installDist -PexampleMain=${program.mainClass} && \\\n sh run.sh build/install/api-example/bin/api-example\n`) - expect(blocks![7]).toEqual({ lang: 'text', intro: 'Expected output:', code: program.expectedOutput }) - } - }) + it.each(['java', 'kotlin', 'scala'] as const)( + 'attaches only %s programs with every source-owned setup file', + (port) => { + const { manifest, symbols, files, read } = fixture() + const identities = symbols.map(({ id, publicId, qualifiedName, slug }) => ({ id, publicId, qualifiedName, slug })) + attachCompleteJvmExamples(symbols, port, read, source) + expect(symbols.map(({ id, publicId, qualifiedName, slug }) => ({ id, publicId, qualifiedName, slug }))).toEqual( + identities, + ) + for (const [i, program] of manifest.examples.entries()) { + const blocks = symbols[i].doc?.examples + if (program.language !== port) { + expect(blocks).toBeUndefined() + continue + } + expect(symbols[i].doc?.summary).toBe('Existing native documentation.') + expect(blocks).toHaveLength(8) + const displayed = [...manifest.variants[program.variant].files, program] + displayed.forEach((file, index) => { + expect(blocks![index + 1].code).toBe(files.get(file.sourceFile)) + expect(blocks![index + 1].sourceUrl).toBe( + `https://github.com/${source.repo}/blob/${source.revision}/${file.sourceFile}`, + ) + expect(blocks![index + 1].intro).toContain(file.path) + }) + expect(blocks![5].lang).toBe(port) + expect(blocks![0].code).toContain(`git -C libtmux-source checkout ${source.revision}`) + expect(blocks![6].code).toBe( + `$ ./libtmux-source/gradlew --project-dir . --console=plain --quiet \\\n installDist -PexampleMain=${program.mainClass} && \\\n sh run.sh build/install/api-example/bin/api-example\n`, + ) + expect(blocks![7]).toEqual({ lang: 'text', intro: 'Expected output:', code: program.expectedOutput }) + } + }, + ) it('keeps older source revisions without a manifest unchanged', () => { const { symbols } = fixture() @@ -71,9 +102,15 @@ describe('complete JVM API programs', () => { it('keeps two distinct complete programs attached to the same API page', () => { const { symbols, manifest, files, read } = fixture() const first = manifest.examples[0] - const second = { ...first, id: 'java-Query', mainClass: first.mainClass.replace('Connect', 'Query'), - sourceFile: first.sourceFile.replace('Connect', 'Query'), path: first.path.replace('Connect', 'Query'), - description: 'Query sessions.', expectedOutput: 'matches=1\n' } + const second = { + ...first, + id: 'java-Query', + mainClass: first.mainClass.replace('Connect', 'Query'), + sourceFile: first.sourceFile.replace('Connect', 'Query'), + path: first.path.replace('Connect', 'Query'), + description: 'Query sessions.', + expectedOutput: 'matches=1\n', + } files.set(second.sourceFile, '\n// Complete query program\n\n') manifest.examples.push(second) attachCompleteJvmExamples(symbols, 'java', read, source) @@ -88,39 +125,57 @@ describe('complete JVM API programs', () => { it('requires an exact unique canonical Java target without renaming its public identity', () => { for (const symbols of [[], [...fixture().symbols, fixture().symbols[0]]]) { - expect(() => attachCompleteJvmExamples(symbols, 'java', fixture().read, source)).toThrow('must resolve once: java.Server') + expect(() => attachCompleteJvmExamples(symbols, 'java', fixture().read, source)).toThrow( + 'must resolve once: java.Server', + ) } const { manifest, symbols, read } = fixture() manifest.examples[0].targets = ['java.Server.Server'] - expect(() => attachCompleteJvmExamples(symbols, 'java', read, source)).toThrow('must resolve once: java.Server.Server') + expect(() => attachCompleteJvmExamples(symbols, 'java', read, source)).toThrow( + 'must resolve once: java.Server.Server', + ) }) it('rejects duplicate attachments and mismatched language variants', () => { const duplicate = fixture() duplicate.manifest.examples[0].targets.push('java.Server') - expect(() => attachCompleteJvmExamples(duplicate.symbols, 'java', duplicate.read, source)).toThrow('Duplicate JVM example target') + expect(() => attachCompleteJvmExamples(duplicate.symbols, 'java', duplicate.read, source)).toThrow( + 'Duplicate JVM example target', + ) const wrong = fixture() wrong.manifest.examples[0].language = 'kotlin' - expect(() => attachCompleteJvmExamples(wrong.symbols, 'java', wrong.read, source)).toThrow('Invalid JVM example variant') + expect(() => attachCompleteJvmExamples(wrong.symbols, 'java', wrong.read, source)).toThrow( + 'Invalid JVM example variant', + ) }) it('rejects incomplete setup, missing source bytes and unsafe commands or paths', () => { const setup = fixture() setup.manifest.variants.java.files.pop() - expect(() => attachCompleteJvmExamples(setup.symbols, 'java', setup.read, source)).toThrow('Invalid JVM example setup') + expect(() => attachCompleteJvmExamples(setup.symbols, 'java', setup.read, source)).toThrow( + 'Invalid JVM example setup', + ) const missing = fixture() missing.files.delete(missing.manifest.examples[0].sourceFile) - expect(() => attachCompleteJvmExamples(missing.symbols, 'java', missing.read, source)).toThrow('Missing complete JVM example file') + expect(() => attachCompleteJvmExamples(missing.symbols, 'java', missing.read, source)).toThrow( + 'Missing complete JVM example file', + ) const path = fixture() path.manifest.examples[0].sourceFile = 'examples/../private.txt' - expect(() => attachCompleteJvmExamples(path.symbols, 'java', path.read, source)).toThrow('Invalid JVM example source path') + expect(() => attachCompleteJvmExamples(path.symbols, 'java', path.read, source)).toThrow( + 'Invalid JVM example source path', + ) const command = fixture() command.manifest.examples[0].mainClass += '; echo injected' - expect(() => attachCompleteJvmExamples(command.symbols, 'java', command.read, source)).toThrow('Invalid JVM example metadata') + expect(() => attachCompleteJvmExamples(command.symbols, 'java', command.read, source)).toThrow( + 'Invalid JVM example metadata', + ) }) it('requires public source provenance for every displayed file', () => { const { symbols, read } = fixture() - expect(() => attachCompleteJvmExamples(symbols, 'java', read, { ...source, revision: 'main' })).toThrow('full source revision') + expect(() => attachCompleteJvmExamples(symbols, 'java', read, { ...source, revision: 'main' })).toThrow( + 'full source revision', + ) }) }) diff --git a/packages/api-model/test/jvm.test.ts b/packages/api-model/test/jvm.test.ts index 8f9c90252..7e77d099c 100644 --- a/packages/api-model/test/jvm.test.ts +++ b/packages/api-model/test/jvm.test.ts @@ -17,20 +17,26 @@ function fixture(file: string, code: string) { describe('native JVM declarations', () => { it('links Scala type parameters and extension receivers to their declarations', async () => { - const root = fixture('Box.scala', `package example + const root = fixture( + 'Box.scala', + `package example class Box[F[_], +A <: Other](val value: A) { def read: F[A] = TODO } extension [F[_]](self: Box[F, String]) { def map[B](value: B): F[B] = TODO } -`) +`, + ) const model = await extractJvm('scala', [root]) const box = model.symbols.find((s) => s.id === 'example.Box')! const read = model.symbols.find((s) => s.id === 'example.Box.read')! const map = model.symbols.find((s) => s.id === 'example.Box.map')! expect(box.signatures[0].typeParams).toEqual(['F', 'A']) - expect(map.signatures[0]).toMatchObject({ typeParams: ['F', 'B'], receiver: { name: 'self', type: 'Box[F, String]' } }) + expect(map.signatures[0]).toMatchObject({ + typeParams: ['F', 'B'], + receiver: { name: 'self', type: 'Box[F, String]' }, + }) const index = new SymbolIndex(model.symbols, (symbol) => `#${symbol.id}`, 'scala') expect(index.resolve('F', 'class', read)?.href).toBe('#example.Box') expect(index.resolve('B', 'class', map)?.href).toBe('#example.Box.map') @@ -42,27 +48,37 @@ extension [F[_]](self: Box[F, String]) { expect(spans.map((span) => span.text).join('')).toBe(signature.raw) }) it('links the types in an overload while leaving its bindings plain', async () => { - const root = fixture('Server.scala', `package example + const root = fixture( + 'Server.scala', + `package example class Server class Window class WindowId extension (self: Server) { def windows(id: WindowId): Vector[Window] = TODO } -`) +`, + ) const { symbols } = await extractJvm('scala', [root]) const method = symbols.find((s) => s.name === 'windows')! const index = new SymbolIndex(symbols, (symbol) => `#${symbol.id}`, 'scala') const signature = method.signatures[0] const spans = index.linkType(signature.raw!, method, signature) - expect(spans.filter((span) => span.link).map((span) => span.text)).toEqual(['Server', 'WindowId', 'Vector', 'Window']) + expect(spans.filter((span) => span.link).map((span) => span.text)).toEqual([ + 'Server', + 'WindowId', + 'Vector', + 'Window', + ]) expect(spans.map((span) => span.text).join('')).toBe(signature.raw) expect(index.resolve('id', 'any', method)?.href).toContain('.parameter.id') }) it.each(['kotlin', 'scala'] as const)('keeps %s links outside generic code', async (port) => { const doc = port === 'kotlin' ? '[the server][Handle]' : '[[Handle the server]]' - const root = fixture(port === 'kotlin' ? 'Handle.kt' : 'Handle.scala', - 'package example\n/** Uses `Execution[F]` and ' + doc + '. */\nclass Handle\n') + const root = fixture( + port === 'kotlin' ? 'Handle.kt' : 'Handle.scala', + 'package example\n/** Uses `Execution[F]` and ' + doc + '. */\nclass Handle\n', + ) const { symbols } = await extractJvm(port, [root]) const summary = symbols[0]!.doc!.summary expect(summary).toContain('`Execution[F]`') @@ -76,7 +92,9 @@ extension (self: Server) { }) it('collects Kotlin constructor properties, companion members and generated extensions', async () => { - const root = fixture('Pane.kt', `package example + const root = fixture( + 'Pane.kt', + `package example /** A pane in one server. */ public class Pane internal constructor(internal val java: JavaPane, public val server: Server) { @@ -95,21 +113,34 @@ public suspend fun Pane.capture(trim: Boolean = true): List = TODO() /** Read scrollback too. */ public suspend fun Pane.capture(start: Int): List = TODO() -`) - writeFileSync(join(root, 'Fields.kt'), `package example.query +`, + ) + writeFileSync( + join(root, 'Fields.kt'), + `package example.query import example.Pane as KotlinPane /** The pane id. */ public val KotlinPane.Companion.id: TextField get() = TODO() -`) +`, + ) const { symbols } = await extractJvm('kotlin', [root]) - expect(symbols.map((s) => s.id)).toEqual(expect.arrayContaining([ - 'example.Pane', 'example.Pane.server', 'example.Pane.Companion', - 'example.Pane.Companion.open', 'example.Pane.Companion.id', 'example.Pane.capture', - ])) + expect(symbols.map((s) => s.id)).toEqual( + expect.arrayContaining([ + 'example.Pane', + 'example.Pane.server', + 'example.Pane.Companion', + 'example.Pane.Companion.open', + 'example.Pane.Companion.id', + 'example.Pane.capture', + ]), + ) expect(symbols.some((s) => /\.java$|\.implementation$/.test(s.id))).toBe(false) - expect(symbols.find((s) => s.id === 'example.Pane')!.signatures[0]).toMatchObject({ raw: 'public class Pane', params: [] }) + expect(symbols.find((s) => s.id === 'example.Pane')!.signatures[0]).toMatchObject({ + raw: 'public class Pane', + params: [], + }) const capture = symbols.find((s) => s.id === 'example.Pane.capture')! const index = new SymbolIndex(symbols, (symbol) => `#${symbol.id}`, 'kotlin') const signature = capture.signatures[0] @@ -122,13 +153,16 @@ public val KotlinPane.Companion.id: TextField expect(capture.signatures[0]).toMatchObject({ raw: 'public suspend fun Pane.capture(trim: Boolean = true): List', params: [{ name: 'trim', type: 'Boolean', default: 'true', doc: 'remove trailing spaces' }], - returns: 'List', returnsDoc: 'the captured rows', + returns: 'List', + returnsDoc: 'the captured rows', }) expect(symbols.find((s) => s.id === 'example.Pane.Companion.id')!.parent).toBe('example.Pane.Companion') }) it('retains Scala 3 opaque companions, extension receivers, givens and enum cases', async () => { - const root = fixture('Pane.scala', `package example + const root = fixture( + 'Pane.scala', + `package example /** A pane handle. */ opaque type Pane = JavaPane @@ -161,7 +195,8 @@ object Fields { /** The command field used to build a predicate. */ def command: TextField[JavaPane] = TODO } -`) +`, + ) const { symbols } = await extractJvm('scala', [root]) const pane = symbols.find((s) => s.id === 'example.Pane')! expect(pane.kind).toBe('typealias') @@ -169,37 +204,57 @@ object Fields { expect(pane.modifiers).not.toContain('overload') expect(symbols.some((s) => s.name === 'wrap')).toBe(false) expect(symbols.find((s) => s.id === 'example.Pane.id')!.signatures[0]).toMatchObject({ - raw: 'extension (self: Pane)\ndef id: String', returns: 'String', + raw: 'extension (self: Pane)\ndef id: String', + returns: 'String', }) expect(symbols.find((s) => s.id === 'example.Pane.capture')!.parent).toBe('example.Pane') - expect(symbols.map((s) => s.id)).toEqual(expect.arrayContaining([ - 'example.Pane.given_CanEqual_Pane_Pane', 'example.Missing.NoMatch', 'example.Missing.Several', - ])) - expect(symbols.find((s) => s.id === 'example.Missing.Several')!.signatures[0].params) - .toEqual([{ name: 'count', type: 'Int', doc: undefined }]) - expect(symbols.find((s) => s.id === 'example.Box')!.signatures[0]) - .toMatchObject({ raw: 'class Box[T]', params: [] }) + expect(symbols.map((s) => s.id)).toEqual( + expect.arrayContaining([ + 'example.Pane.given_CanEqual_Pane_Pane', + 'example.Missing.NoMatch', + 'example.Missing.Several', + ]), + ) + expect(symbols.find((s) => s.id === 'example.Missing.Several')!.signatures[0].params).toEqual([ + { name: 'count', type: 'Int', doc: undefined }, + ]) + expect(symbols.find((s) => s.id === 'example.Box')!.signatures[0]).toMatchObject({ + raw: 'class Box[T]', + params: [], + }) expect(symbols.some((s) => s.id === 'example.Box.get')).toBe(true) - expect(symbols.find((s) => s.id === 'example.Box.server')) - .toMatchObject({ kind: 'property', parent: 'example.Box', signatures: [{ returns: 'Pane' }] }) + expect(symbols.find((s) => s.id === 'example.Box.server')).toMatchObject({ + kind: 'property', + parent: 'example.Box', + signatures: [{ returns: 'Pane' }], + }) expect(symbols.some((s) => s.id === 'example.Box.value')).toBe(false) - expect(symbols.find((s) => s.id === 'example.Pane.command')) - .toMatchObject({ parent: 'example.Pane', exportedFrom: 'example.Fields.command', - signatures: [{ raw: 'def command: TextField[JavaPane]', returns: 'TextField[JavaPane]' }] }) + expect(symbols.find((s) => s.id === 'example.Pane.command')).toMatchObject({ + parent: 'example.Pane', + exportedFrom: 'example.Fields.command', + signatures: [{ raw: 'def command: TextField[JavaPane]', returns: 'TextField[JavaPane]' }], + }) }) it('keeps a Scala using clause separate from the extension receiver', async () => { - const root = fixture('Handle.scala', `package example + const root = fixture( + 'Handle.scala', + `package example class Handle[F[_]] extension [F[_]](self: Handle[F])(using F: Effect[F]) { def capture: F[String] = TODO } -`) +`, + ) const { symbols } = await extractJvm('scala', [root]) - expect(symbols.find((symbol) => symbol.id === 'example.Handle.capture')) - .toMatchObject({ parent: 'example.Handle', signatures: [{ - raw: 'extension [F[_]](self: Handle[F])(using F: Effect[F])\ndef capture: F[String]', - }] }) + expect(symbols.find((symbol) => symbol.id === 'example.Handle.capture')).toMatchObject({ + parent: 'example.Handle', + signatures: [ + { + raw: 'extension [F[_]](self: Handle[F])(using F: Effect[F])\ndef capture: F[String]', + }, + ], + }) }) it.each(['kotlin', 'scala'] as const)('rejects broken public %s syntax', async (port) => { diff --git a/packages/api-model/test/lua-examples.test.ts b/packages/api-model/test/lua-examples.test.ts index c2d775166..3aaf0b802 100644 --- a/packages/api-model/test/lua-examples.test.ts +++ b/packages/api-model/test/lua-examples.test.ts @@ -13,11 +13,22 @@ const files = [ const manifest = (): LuaExampleManifest => ({ schema: 1, setup: { lua: '5.5.1', luv: '1.52.1-0', launcher: 'examples/api/run.sh' }, - examples: [{ id: 'connect', symbols: ['libtmux.Runtime:connect'], file: 'examples/api/connect.lua', - description: 'Connect to a tmux daemon.', stdout: 'connected\n' }], + examples: [ + { + id: 'connect', + symbols: ['libtmux.Runtime:connect'], + file: 'examples/api/connect.lua', + description: 'Connect to a tmux daemon.', + stdout: 'connected\n', + }, + ], }) const target = (): ApiSymbol => ({ - id: 'libtmux.Runtime:connect', name: 'connect', kind: 'method', modifiers: [], signatures: [], + id: 'libtmux.Runtime:connect', + name: 'connect', + kind: 'method', + modifiers: [], + signatures: [], source: { file: 'lua/libtmux/_internal/runtime.lua', revision: source.revision }, doc: { summary: 'Existing native documentation.' }, }) @@ -31,8 +42,12 @@ describe('complete Lua API examples from native artifacts', () => { expect(examples).toHaveLength(4) expect(examples[1].code).toBe(launcher) expect(examples[2].code).toBe(program) - expect(examples[1].sourceUrl).toBe(`https://github.com/${source.repository}/blob/${source.revision}/examples/api/run.sh`) - expect(examples[2].sourceUrl).toBe(`https://github.com/${source.repository}/blob/${source.revision}/examples/api/connect.lua`) + expect(examples[1].sourceUrl).toBe( + `https://github.com/${source.repository}/blob/${source.revision}/examples/api/run.sh`, + ) + expect(examples[2].sourceUrl).toBe( + `https://github.com/${source.repository}/blob/${source.revision}/examples/api/connect.lua`, + ) expect(examples[0].code).toContain(`git -C libtmux-source checkout ${source.revision}`) expect(examples[0].code).toContain('luarocks --tree ./rocks install luv 1.52.1-0') expect(examples[3].code).toBe('$ eval "$(luarocks --tree ./rocks path)" &&\n sh run.sh connect.lua\n') @@ -47,8 +62,11 @@ describe('complete Lua API examples from native artifacts', () => { }) it('rejects missing or ambiguous full-file payloads and changed line endings', () => { - for (const invalid of [files.slice(0, 1), [...files, files[0]], - files.map((file) => ({ ...file, content: file.content.replaceAll('\n', '\r\n') }))]) { + for (const invalid of [ + files.slice(0, 1), + [...files, files[0]], + files.map((file) => ({ ...file, content: file.content.replaceAll('\n', '\r\n') })), + ]) { expect(() => attachCompleteLuaExamples([target()], manifest(), invalid, source)).toThrow() } }) @@ -62,20 +80,34 @@ describe('complete Lua API examples from native artifacts', () => { it('rejects unsafe paths and absent runtime or output contracts', () => { for (const mutate of [ - (value: LuaExampleManifest) => { value.setup.launcher = '../run.sh' }, - (value: LuaExampleManifest) => { value.examples[0].file = '../../connect.lua' }, - (value: LuaExampleManifest) => { value.setup.lua = 'latest' }, - (value: LuaExampleManifest) => { value.setup.luv = 'latest' }, - (value: LuaExampleManifest) => { value.examples[0].stdout = '' }, - (value: LuaExampleManifest) => { value.examples[0].description = '' }, + (value: LuaExampleManifest) => { + value.setup.launcher = '../run.sh' + }, + (value: LuaExampleManifest) => { + value.examples[0].file = '../../connect.lua' + }, + (value: LuaExampleManifest) => { + value.setup.lua = 'latest' + }, + (value: LuaExampleManifest) => { + value.setup.luv = 'latest' + }, + (value: LuaExampleManifest) => { + value.examples[0].stdout = '' + }, + (value: LuaExampleManifest) => { + value.examples[0].description = '' + }, ]) { const value = manifest() mutate(value) expect(() => attachCompleteLuaExamples([target()], value, files, source)).toThrow() } - expect(() => attachCompleteLuaExamples([target()], manifest(), files, { ...source, revision: 'main' })) - .toThrow(/full revision/) - expect(() => attachCompleteLuaExamples([target()], manifest(), files, { ...source, repository: 'other/repo' })) - .toThrow(/Lua repository/) + expect(() => attachCompleteLuaExamples([target()], manifest(), files, { ...source, revision: 'main' })).toThrow( + /full revision/, + ) + expect(() => + attachCompleteLuaExamples([target()], manifest(), files, { ...source, repository: 'other/repo' }), + ).toThrow(/Lua repository/) }) }) diff --git a/packages/api-model/test/mcp-protocol.test.ts b/packages/api-model/test/mcp-protocol.test.ts index ddce65aff..b5fc3d622 100644 --- a/packages/api-model/test/mcp-protocol.test.ts +++ b/packages/api-model/test/mcp-protocol.test.ts @@ -20,8 +20,9 @@ createInterface({ input: process.stdin }).on('line', line => { describe('MCP protocol discovery', () => { it('normalizes object order while preserving tuple and enumeration order', () => { - expect(JSON.stringify(orderedProtocol({ z: { b: 1, a: 2 }, a: ['z', 'a'] }))) - .toBe('{"a":["z","a"],"z":{"a":2,"b":1}}') + expect(JSON.stringify(orderedProtocol({ z: { b: 1, a: 2 }, a: ['z', 'a'] }))).toBe( + '{"a":["z","a"],"z":{"a":2,"b":1}}', + ) }) it('collects paginated advertised contracts without invoking tools or unsupported capabilities', async () => { const protocol = await captureProtocol({ command: process.execPath, args: ['-e', server] }) @@ -33,13 +34,16 @@ describe('MCP protocol discovery', () => { }) it('bounds an unresponsive process', async () => { - await expect(captureProtocol({ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'], timeoutMs: 50 })) - .rejects.toThrow('MCP discovery timed out') + await expect( + captureProtocol({ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'], timeoutMs: 50 }), + ).rejects.toThrow('MCP discovery timed out') }) - it.each(['tools', 'prompts', 'resources', 'resourceTemplates'])('rejects a missing %s array instead of publishing an empty catalog', async (key) => { - const capability = key === 'resourceTemplates' ? 'resources' : key - const fixture = ` + it.each(['tools', 'prompts', 'resources', 'resourceTemplates'])( + 'rejects a missing %s array instead of publishing an empty catalog', + async (key) => { + const capability = key === 'resourceTemplates' ? 'resources' : key + const fixture = ` const { createInterface } = require('node:readline'); createInterface({ input: process.stdin }).on('line', line => { const message = JSON.parse(line); @@ -50,14 +54,20 @@ describe('MCP protocol discovery', () => { ? { resources: [] } : {}; console.log(JSON.stringify({ jsonrpc: '2.0', id: message.id, result })); });` - await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })) - .rejects.toThrow(`missing or invalid ${key} array`) - }) + await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })).rejects.toThrow( + `missing or invalid ${key} array`, + ) + }, + ) it('rejects an incomplete later page instead of publishing a partial catalog', async () => { - const fixture = server.replace("{ tools: [{ name: 'second', inputSchema: { type: 'object', properties: {} } }] }", '{}') - await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })) - .rejects.toThrow('missing or invalid tools array') + const fixture = server.replace( + "{ tools: [{ name: 'second', inputSchema: { type: 'object', properties: {} } }] }", + '{}', + ) + await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })).rejects.toThrow( + 'missing or invalid tools array', + ) }) it.each(['tools', 'prompts', 'resources', 'resourceTemplates'])('rejects an invalid %s registration', async (key) => { @@ -73,12 +83,14 @@ describe('MCP protocol discovery', () => { ? { resources: [] } : { ${key}: [{ description: 'Missing the registration identity.' }] }; console.log(JSON.stringify({ jsonrpc: '2.0', id: message.id, result })); });` - await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })) - .rejects.toThrow(`invalid ${key} registration`) + await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })).rejects.toThrow( + `invalid ${key} registration`, + ) }) it('follows an empty opaque pagination cursor', async () => { - const fixture = server.replace('message.params.cursor', "Object.hasOwn(message.params, 'cursor')") + const fixture = server + .replace('message.params.cursor', "Object.hasOwn(message.params, 'cursor')") .replace("nextCursor: 'page-two'", "nextCursor: ''") const protocol = await captureProtocol({ command: process.execPath, args: ['-e', fixture] }) expect(protocol.tools.map((tool) => tool.name)).toEqual(['first', 'second']) @@ -87,31 +99,38 @@ describe('MCP protocol discovery', () => { it('rejects a missing capabilities object while accepting an explicitly empty one', async () => { const empty = server.replace('capabilities: { tools: {} }', 'capabilities: {}') expect((await captureProtocol({ command: process.execPath, args: ['-e', empty] })).tools).toEqual([]) - await expect(captureProtocol({ command: process.execPath, args: ['-e', server.replace('capabilities: { tools: {} }', 'invalid: {}')] })) - .rejects.toThrow('missing or invalid capabilities object') + await expect( + captureProtocol({ + command: process.execPath, + args: ['-e', server.replace('capabilities: { tools: {} }', 'invalid: {}')], + }), + ).rejects.toThrow('missing or invalid capabilities object') }) it.each(['null', 'false', '[]'])('rejects a malformed advertised capability %s', async (value) => { const fixture = server.replace('capabilities: { tools: {} }', `capabilities: { tools: ${value} }`) - await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })) - .rejects.toThrow('invalid tools capability object') + await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })).rejects.toThrow( + 'invalid tools capability object', + ) }) it('requires the negotiated protocol and server identity', async () => { const fixture = server.replace("protocolVersion: '2025-11-25', serverInfo: { name: 'fixture', version: '1' },", '') - await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })) - .rejects.toThrow('missing or invalid protocolVersion or serverInfo') + await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })).rejects.toThrow( + 'missing or invalid protocolVersion or serverInfo', + ) }) it.each([-32601, -32603])('handles resource template discovery error %i', async (code) => { - const resources = server - .replace('tools: {}', 'resources: {}') - .replace("} else if (message.method === 'tools/list') {", `} else if (message.method === 'resources/list') { + const resources = server.replace('tools: {}', 'resources: {}').replace( + "} else if (message.method === 'tools/list') {", + `} else if (message.method === 'resources/list') { result = { resources: [{ name: 'capabilities', uri: 'tmux://capabilities' }] }; } else if (message.method === 'resources/templates/list') { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: ${code}, message: 'template discovery failed' } }) + '\\n'); return; - } else if (message.method === 'tools/list') {`) + } else if (message.method === 'tools/list') {`, + ) const discovery = captureProtocol({ command: process.execPath, args: ['-e', resources] }) if (code === -32601) { const protocol = await discovery @@ -139,7 +158,8 @@ describe('MCP protocol discovery', () => { : { resourceTemplates: [{ name: 'first', uriTemplate: 'tmux://first/{id}' }], nextCursor: '' }; console.log(JSON.stringify({ jsonrpc: '2.0', id: message.id, result })); });` - await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })) - .rejects.toThrow('Second page disappeared') + await expect(captureProtocol({ command: process.execPath, args: ['-e', fixture] })).rejects.toThrow( + 'Second page disappeared', + ) }) }) diff --git a/packages/api-model/test/member-order.test.ts b/packages/api-model/test/member-order.test.ts index fc4b26acc..4f5ba0dec 100644 --- a/packages/api-model/test/member-order.test.ts +++ b/packages/api-model/test/member-order.test.ts @@ -45,8 +45,10 @@ function violations(compare: Compare): string[] { for (const concept of LISTINGS) { const listing = byId.get(CONCEPTS[concept]!.symbols[port] ?? '') if (!listing?.parent) continue - const rank = [...children.get(listing.parent)!].sort(cmp) - .filter((member) => memberTier(member, signals) !== 'parent').indexOf(listing) + const rank = [...children.get(listing.parent)!] + .sort(cmp) + .filter((member) => memberTier(member, signals) !== 'parent') + .indexOf(listing) if (rank >= TOP) found.push(`${port}: ${concept} is #${rank + 1} on ${listing.parent}`) } for (const [owner, members] of children) { @@ -88,25 +90,50 @@ d('member order', () => { .filter((symbol) => ['Server', 'Session', 'Window', 'Pane', 'Client', 'Snapshot'].includes(symbol.name)) .map((owner) => ({ id: owner.id, members: symbols.filter((symbol) => symbol.parent === owner.id) })) // Cmdlets belong to their module; Julia's operations are free functions. - if (port === 'powershell') groups.push({ id: 'LibTmux', members: symbols.filter((symbol) => symbol.parent === 'LibTmux') }) - if (port === 'julia') groups.push({ - id: 'LibTmux free functions', - members: symbols.filter((symbol) => !symbol.parent && symbol.kind === 'function' && symbol.id.startsWith('LibTmux.')), - }) + if (port === 'powershell') + groups.push({ id: 'LibTmux', members: symbols.filter((symbol) => symbol.parent === 'LibTmux') }) + if (port === 'julia') + groups.push({ + id: 'LibTmux free functions', + members: symbols.filter( + (symbol) => !symbol.parent && symbol.kind === 'function' && symbol.id.startsWith('LibTmux.'), + ), + }) let checked = 0 for (const { id, members } of groups) { const ordered = members.toSorted(compareMembers(signals)) - const routine = members.filter((symbol) => ['New-TmuxSession', 'New-TmuxWindow', 'Remove-TmuxServer', 'Disconnect-TmuxControl', 'Base.close'].includes(symbol.name) || - /^(new_?session|new_?window|create_?session|create_?window|close|dispose|asjava|tostring|__enter__|kill_?server)$/.test( - symbol.name.replace(/\(.*$/, '').replace(/Async$/, '').toLowerCase())) - const priority = members.filter((symbol) => symbol.doc?.deprecated === undefined && !symbol.modifiers?.includes('deprecated') && ( - listingIds.has(symbol.publicId ?? symbol.id) || - /^(?:list_?|get)?(?:sessions|windows|panes|clients|buffers)$/.test(symbol.name.replace(/\(.*$/, '').replace(/Async$/, '').toLowerCase()) || - /^(search-|snapshot$)/.test(signals.conceptIds.get(symbol.publicId ?? symbol.id) ?? ''))) - for (const listing of priority) for (const helper of routine) { - expect(ordered.indexOf(listing), `${id}: ${listing.name} before ${helper.name}`).toBeLessThan(ordered.indexOf(helper)) - checked++ - } + const routine = members.filter( + (symbol) => + ['New-TmuxSession', 'New-TmuxWindow', 'Remove-TmuxServer', 'Disconnect-TmuxControl', 'Base.close'].includes( + symbol.name, + ) || + /^(new_?session|new_?window|create_?session|create_?window|close|dispose|asjava|tostring|__enter__|kill_?server)$/.test( + symbol.name + .replace(/\(.*$/, '') + .replace(/Async$/, '') + .toLowerCase(), + ), + ) + const priority = members.filter( + (symbol) => + symbol.doc?.deprecated === undefined && + !symbol.modifiers?.includes('deprecated') && + (listingIds.has(symbol.publicId ?? symbol.id) || + /^(?:list_?|get)?(?:sessions|windows|panes|clients|buffers)$/.test( + symbol.name + .replace(/\(.*$/, '') + .replace(/Async$/, '') + .toLowerCase(), + ) || + /^(search-|snapshot$)/.test(signals.conceptIds.get(symbol.publicId ?? symbol.id) ?? '')), + ) + for (const listing of priority) + for (const helper of routine) { + expect(ordered.indexOf(listing), `${id}: ${listing.name} before ${helper.name}`).toBeLessThan( + ordered.indexOf(helper), + ) + checked++ + } // F# currently exposes listPanes without creation or lifecycle helpers. if (priority.length && !routine.length) { expect(priority, id).toContain(ordered.find((member) => memberTier(member, signals) !== 'parent')) @@ -153,9 +180,15 @@ d('member order', () => { ] as const)('puts %s %s parents first, walking toward the server', (port, owner, expected) => { const signals = memberSignals(port) const api = model(port) - const members = membersOf(api, api.symbols.find((symbol) => symbol.id === owner)!, signals) + const members = membersOf( + api, + api.symbols.find((symbol) => symbol.id === owner)!, + signals, + ) expect(members.slice(0, expected.length).map((member) => member.name)).toEqual(expected) - expect(members.filter((member) => memberTier(member, signals) === 'parent').map((member) => member.name)).toEqual(expected) + expect(members.filter((member) => memberTier(member, signals) === 'parent').map((member) => member.name)).toEqual( + expected, + ) }) it.each(PORTS)('puts every mapped %s parent before collections and operations', (port) => { @@ -198,13 +231,21 @@ d('member order', () => { ]) { const symbol = api.symbols.find((entry) => entry.id === `LibTmux\\${cmdlet}`)! expect(symbol).toMatchObject({ kind: 'function', parent: 'LibTmux' }) - expect(symbol.signatures.some((signature) => signature.returns === `LibTmux.${result}` && - signature.params.some((param) => param.name === receiver && param.type === `LibTmux.${receiver}`))).toBe(true) + expect( + symbol.signatures.some( + (signature) => + signature.returns === `LibTmux.${result}` && + signature.params.some((param) => param.name === receiver && param.type === `LibTmux.${receiver}`), + ), + ).toBe(true) expect(api.symbols.some((entry) => entry.id === `LibTmux.${result}`)).toBe(false) const handle = model('csharp').symbols.find((entry) => entry.id === `LibTmux.${result}`)! expect(handle, result).toBeDefined() - expect(membersOf(model('csharp'), handle).some((entry) => entry.name === 'Server' && - memberTier(entry, memberSignals('csharp')) === 'parent')).toBe(true) + expect( + membersOf(model('csharp'), handle).some( + (entry) => entry.name === 'Server' && memberTier(entry, memberSignals('csharp')) === 'parent', + ), + ).toBe(true) } }) @@ -220,8 +261,9 @@ d('member order', () => { const symbol = api.symbols.find((entry) => entry.id === `LibTmux.${name}`)! expect(symbol.kind).toBe('function') expect(symbol.parent).toBeUndefined() - expect(symbol.signatures.map((signature) => signature.params[0].type).sort()) - .toEqual(receiverTypes.map((type) => `LibTmux.${type}`).sort()) + expect(symbol.signatures.map((signature) => signature.params[0].type).sort()).toEqual( + receiverTypes.map((type) => `LibTmux.${type}`).sort(), + ) for (const type of receiverTypes) { const owner = api.symbols.find((entry) => entry.id === `LibTmux.${type}`)! expect(owner, type).toBeDefined() @@ -232,11 +274,14 @@ d('member order', () => { it('does not promote scalar names, query fields, downward lookups, private or deprecated parents', () => { for (const [port, id] of [ - ['go', 'tmux.Client.Session'], ['go', 'tmux.Pane.SessionID'], - ['java', 'io.github.libtmux.Server.Server.session'], ['rs', 'session.Session.window'], + ['go', 'tmux.Client.Session'], + ['go', 'tmux.Pane.SessionID'], + ['java', 'io.github.libtmux.Server.Server.session'], + ['rs', 'session.Session.window'], ['kotlin', 'io.github.libtmux.kotlin.Window.Companion.session'], ['scala', 'io.github.libtmux.scaladsl.generated.WindowFields.session'], - ['ts', 'selection.PaneWhere.window'], ['swift', 'Pane.windowID'], + ['ts', 'selection.PaneWhere.window'], + ['swift', 'Pane.windowID'], ]) { const symbol = model(port).symbols.find((entry) => (entry.publicId ?? entry.id) === id)! expect(symbol, id).toBeDefined() @@ -245,7 +290,9 @@ d('member order', () => { const symbol = model('ts').symbols.find((entry) => entry.id === 'pane.Pane.server')! expect(memberTier({ ...symbol, modifiers: ['private'] }, memberSignals('ts'))).toBe('private') expect(memberTier({ ...symbol, apiScope: 'internal' }, memberSignals('ts'))).toBe('private') - expect(memberTier({ ...symbol, doc: { summary: '', deprecated: 'Use another handle.' } }, memberSignals('ts'))).toBe('deprecated') + expect( + memberTier({ ...symbol, doc: { summary: '', deprecated: 'Use another handle.' } }, memberSignals('ts')), + ).toBe('deprecated') }) it('retains the parent relation when a broader concept also names the symbol', () => { @@ -268,7 +315,9 @@ d('member order', () => { const privateReceiver = { ...owner, id: 'libtmux::PrivateRow', publicId: 'libtmux::PrivateRow' } expect(membersOf({ ...api, symbols: [...api.symbols, privateReceiver] }, privateReceiver)).toEqual([]) const override = { ...source, id: `${owner.id}::server`, publicId: `${owner.id}::server`, parent: owner.id } - expect(membersOf({ ...api, symbols: [...api.symbols, override] }, owner).filter((member) => member.name === 'server')).toEqual([override]) + expect( + membersOf({ ...api, symbols: [...api.symbols, override] }, owner).filter((member) => member.name === 'server'), + ).toEqual([override]) expect(JSON.stringify(api)).toBe(before) }) }) diff --git a/packages/api-model/test/mentions.test.ts b/packages/api-model/test/mentions.test.ts index a1bc28c81..6d3c5802e 100644 --- a/packages/api-model/test/mentions.test.ts +++ b/packages/api-model/test/mentions.test.ts @@ -30,7 +30,7 @@ describe('prose mentions', () => { }) it('keeps sentence context and table port labels', () => { - const source = "Python uses `Session.panes`.\n\n| Go | `Session.Panes` |" + const source = 'Python uses `Session.panes`.\n\n| Go | `Session.Panes` |' expect(proseMentions(source, LABELS).map(({ start: _start, end: _end, ...mention }) => mention)).toEqual([ { text: 'Session.panes', port: undefined, before: 'Python uses ', line: 1 }, { text: 'Session.Panes', port: 'go', before: '| Go | ', line: 3 }, @@ -39,8 +39,12 @@ describe('prose mentions', () => { it('ignores frontmatter and fenced examples but keeps a fence language for subsequent prose', () => { const source = [ - '---', 'description: "`Server`"', '---', - '~~~python', '`not_a_reference`', '~~~', + '---', + 'description: "`Server`"', + '---', + '~~~python', + '`not_a_reference`', + '~~~', 'Read `Server.sessions`.', '## Other material', 'Read `Server`.', diff --git a/packages/api-model/test/native-adapters.test.ts b/packages/api-model/test/native-adapters.test.ts index 2be306ce0..7d64258d9 100644 --- a/packages/api-model/test/native-adapters.test.ts +++ b/packages/api-model/test/native-adapters.test.ts @@ -17,29 +17,57 @@ describe('Ruby native documentation adapter', () => { ], namespaces: [ { - id: 'LibTmux::Pane', name: 'Pane', kind: 'class', package: 'libtmux', product: 'core', + id: 'LibTmux::Pane', + name: 'Pane', + kind: 'class', + package: 'libtmux', + product: 'core', documentation: 'A pane handle.\n\nBound to one server.', source: { path: 'gems/libtmux/lib/libtmux/pane.rb', line: 12 }, }, ], - aliases: [{ - id: 'LibTmux::Async::scope_diagnostics', name: 'scope_diagnostics', package: 'libtmux-async', - product: 'core', type: '{ closed: bool }', - source: { path: 'gems/libtmux-async/sig/libtmux-async.rbs', line: 7 }, - }], + aliases: [ + { + id: 'LibTmux::Async::scope_diagnostics', + name: 'scope_diagnostics', + package: 'libtmux-async', + product: 'core', + type: '{ closed: bool }', + source: { path: 'gems/libtmux-async/sig/libtmux-async.rbs', line: 7 }, + }, + ], symbols: [ { - id: 'LibTmux::Pane#active?', owner: 'LibTmux::Pane#active?', namespace: 'LibTmux::Pane', - name: 'active?', kind: 'method', method_kind: 'instance', package: 'libtmux', product: 'core', - visibility: 'public', signatures: ['() -> bool'], returns: ['bool'], documentation: 'Whether this pane is active.', - tags: [], contract: { id: 'pane-state', title: 'Pane state' }, + id: 'LibTmux::Pane#active?', + owner: 'LibTmux::Pane#active?', + namespace: 'LibTmux::Pane', + name: 'active?', + kind: 'method', + method_kind: 'instance', + package: 'libtmux', + product: 'core', + visibility: 'public', + signatures: ['() -> bool'], + returns: ['bool'], + documentation: 'Whether this pane is active.', + tags: [], + contract: { id: 'pane-state', title: 'Pane state' }, source: { path: 'gems/libtmux/lib/libtmux/pane.rb', line: 30 }, }, { - id: 'LibTmux::Pane.active?', owner: 'LibTmux::Pane.active?', namespace: 'LibTmux::Pane', - name: 'active?', kind: 'method', method_kind: 'singleton', package: 'libtmux', product: 'core', - visibility: 'public', signatures: ['(name: ::String, ?force: bool) -> ::LibTmux::Pane'], - returns: ['::LibTmux::Pane'], documentation: '', tags: [], + id: 'LibTmux::Pane.active?', + owner: 'LibTmux::Pane.active?', + namespace: 'LibTmux::Pane', + name: 'active?', + kind: 'method', + method_kind: 'singleton', + package: 'libtmux', + product: 'core', + visibility: 'public', + signatures: ['(name: ::String, ?force: bool) -> ::LibTmux::Pane'], + returns: ['::LibTmux::Pane'], + documentation: '', + tags: [], contract: { id: 'pane-state', title: 'Pane state' }, source: { path: 'gems/libtmux/lib/libtmux/pane.rb', line: 40 }, }, @@ -84,13 +112,20 @@ describe('Ruby native documentation adapter', () => { const input = { ...artifact, examples: { - manifest: { programs: [{ - id: 'pane_active', path: 'examples/api/active.rb', gem: 'libtmux', - api: { - symbols: ['LibTmux::Pane', 'LibTmux::Pane#active?'], - description: 'Read pane state.', output: 'active\n', - }, - }] }, + manifest: { + programs: [ + { + id: 'pane_active', + path: 'examples/api/active.rb', + gem: 'libtmux', + api: { + symbols: ['LibTmux::Pane', 'LibTmux::Pane#active?'], + description: 'Read pane state.', + output: 'active\n', + }, + }, + ], + }, files: [{ path: 'examples/api/active.rb', content: code }], }, } @@ -99,21 +134,26 @@ describe('Ruby native documentation adapter', () => { const symbol = model.symbols.find((entry) => entry.id === id)! expect(symbol.doc?.examples).toHaveLength(5) expect(symbol.doc!.examples![2]).toMatchObject({ - lang: 'ruby', code, + lang: 'ruby', + code, sourceUrl: `https://github.com/libtmux/libtmux-ruby/blob/${REVISION}/examples/api/active.rb`, }) expect(symbol.doc!.examples![4]).toMatchObject({ lang: 'text', code: 'active\n' }) } - expect(model.symbols.find((entry) => entry.id === 'LibTmux::Pane#active?')?.doc?.summary) - .toBe('Whether this pane is active.') - expect(model.symbols.find((entry) => entry.id === 'LibTmux::Pane.active?')?.doc?.examples) - .toBeUndefined() + expect(model.symbols.find((entry) => entry.id === 'LibTmux::Pane#active?')?.doc?.summary).toBe( + 'Whether this pane is active.', + ) + expect(model.symbols.find((entry) => entry.id === 'LibTmux::Pane.active?')?.doc?.examples).toBeUndefined() }) }) describe('Lua native documentation adapter', () => { const definition = (file: string, line: number) => ({ - file, start: [line, 0], finish: [line, 1], type: 'doc.class', visible: 'public', + file, + start: [line, 0], + finish: [line, 1], + type: 'doc.class', + visible: 'public', }) const artifact = { schema: 1, @@ -123,29 +163,53 @@ describe('Lua native documentation adapter', () => { package: { name: 'libtmux', version: '0.1.0alpha1-1', source_tag: 'v0.1.0alpha1' }, declarations: [ { - name: 'libtmux.query', type: 'type', view: 'libtmux.query', + name: 'libtmux.query', + type: 'type', + view: 'libtmux.query', defines: [definition('lua/libtmux/query.lua', 3)], - fields: [{ - name: 'where', file: 'lua/libtmux/query.lua', start: [20, 0], type: 'setfield', view: 'function', - rawdesc: 'Filter records.', visible: 'public', - extends: { - args: [{ name: 'rows', view: 'T[]' }, { name: 'criteria', view: 'libtmux.Where' }], - returns: [{ view: 'libtmux.Selection' }], - view: 'function libtmux.query.where(rows: T[], criteria: libtmux.Where) -> libtmux.Selection', + fields: [ + { + name: 'where', + file: 'lua/libtmux/query.lua', + start: [20, 0], + type: 'setfield', + view: 'function', + rawdesc: 'Filter records.', + visible: 'public', + extends: { + args: [ + { name: 'rows', view: 'T[]' }, + { name: 'criteria', view: 'libtmux.Where' }, + ], + returns: [{ view: 'libtmux.Selection' }], + view: 'function libtmux.query.where(rows: T[], criteria: libtmux.Where) -> libtmux.Selection', + }, }, - }], + ], }, { - name: 'libtmux.Request', type: 'type', view: 'libtmux.Request', + name: 'libtmux.Request', + type: 'type', + view: 'libtmux.Request', defines: [definition('lua/libtmux/_internal/runtime.lua', 100)], - fields: [{ - name: 'await', file: 'lua/libtmux/_internal/runtime.lua', start: [120, 0], type: 'doc.field', - view: 'fun(self: libtmux.Request, timeout?: number):T?, libtmux.Error?', rawdesc: 'Wait for completion.', - visible: 'public', extends: { - args: [{ name: 'self', view: 'libtmux.Request' }, { name: 'timeout', view: 'number?' }], - returns: [{ view: 'T?' }, { name: 'error', view: 'libtmux.Error?' }], + fields: [ + { + name: 'await', + file: 'lua/libtmux/_internal/runtime.lua', + start: [120, 0], + type: 'doc.field', + view: 'fun(self: libtmux.Request, timeout?: number):T?, libtmux.Error?', + rawdesc: 'Wait for completion.', + visible: 'public', + extends: { + args: [ + { name: 'self', view: 'libtmux.Request' }, + { name: 'timeout', view: 'number?' }, + ], + returns: [{ view: 'T?' }, { name: 'error', view: 'libtmux.Error?' }], + }, }, - }], + ], }, ], guides: [], @@ -184,38 +248,50 @@ describe('Lua native documentation adapter', () => { }) const typed = { ...artifact, - declarations: [{ - name: 'libtmux.Server', type: 'type', view: 'libtmux.Server', - defines: [definition('lua/libtmux/_internal/server.lua', 600)], - fields: [ - { - name: 'new_session', file: 'lua/libtmux/_internal/server.lua', start: [610, 0], type: 'doc.field', - view: 'fun(self: libtmux.Server, options?: libtmux.NewSessionOptions):libtmux.Request', - visible: 'public', - extends: { - type: 'doc.type', + declarations: [ + { + name: 'libtmux.Server', + type: 'type', + view: 'libtmux.Server', + defines: [definition('lua/libtmux/_internal/server.lua', 600)], + fields: [ + { + name: 'new_session', + file: 'lua/libtmux/_internal/server.lua', + start: [610, 0], + type: 'doc.field', view: 'fun(self: libtmux.Server, options?: libtmux.NewSessionOptions):libtmux.Request', - types: [{ - type: 'doc.type.function', - args: [arg('self', 'libtmux.Server'), arg('options', '(libtmux.NewSessionOptions)?')], - returns: [{ view: 'libtmux.Request' }], - }], + visible: 'public', + extends: { + type: 'doc.type', + view: 'fun(self: libtmux.Server, options?: libtmux.NewSessionOptions):libtmux.Request', + types: [ + { + type: 'doc.type.function', + args: [arg('self', 'libtmux.Server'), arg('options', '(libtmux.NewSessionOptions)?')], + returns: [{ view: 'libtmux.Request' }], + }, + ], + }, }, - }, - { - name: 'handle', file: 'lua/libtmux/_internal/server.lua', start: [620, 0], type: 'doc.field', - view: 'fun(self: libtmux.Server, record: libtmux.SnapshotSession)|fun(self: libtmux.Server, record: libtmux.SnapshotPane)', - visible: 'public', - extends: { - type: 'doc.type', - types: [ - { type: 'doc.type', types: [fn('libtmux.SnapshotSession', 'libtmux.Session')] }, - { type: 'doc.type', types: [fn('libtmux.SnapshotPane', 'libtmux.Pane')] }, - ], + { + name: 'handle', + file: 'lua/libtmux/_internal/server.lua', + start: [620, 0], + type: 'doc.field', + view: 'fun(self: libtmux.Server, record: libtmux.SnapshotSession)|fun(self: libtmux.Server, record: libtmux.SnapshotPane)', + visible: 'public', + extends: { + type: 'doc.type', + types: [ + { type: 'doc.type', types: [fn('libtmux.SnapshotSession', 'libtmux.Session')] }, + { type: 'doc.type', types: [fn('libtmux.SnapshotPane', 'libtmux.Pane')] }, + ], + }, }, - }, - ], - }], + ], + }, + ], } const model = extractLua(typed) const create = model.symbols.find((s) => s.id === 'libtmux.Server:new_session')! @@ -233,13 +309,21 @@ describe('Lua native documentation adapter', () => { it('credits a field LuaLS copied into a subclass to the class that declared it', () => { const field = (name: string, line: number) => ({ - name, file: 'lua/libtmux/_internal/entity.lua', start: [line, 0], type: 'doc.field', - view: `fun(self: libtmux.Entity)`, visible: 'public', + name, + file: 'lua/libtmux/_internal/entity.lua', + start: [line, 0], + type: 'doc.field', + view: `fun(self: libtmux.Entity)`, + visible: 'public', extends: { args: [{ name: 'self', view: 'libtmux.Entity' }], returns: [] }, }) const declare = (name: string, parent: string | undefined, fields: ReturnType[]) => ({ - name, type: 'type', view: name, - defines: [{ ...definition('lua/libtmux/_internal/entity.lua', 1), ...(parent ? { extends: [{ view: parent }] } : {}) }], + name, + type: 'type', + view: name, + defines: [ + { ...definition('lua/libtmux/_internal/entity.lua', 1), ...(parent ? { extends: [{ view: parent }] } : {}) }, + ], fields, }) const hierarchy = { @@ -248,19 +332,26 @@ describe('Lua native documentation adapter', () => { declare('libtmux.Entity', undefined, [field('snapshot', 10)]), declare('libtmux.Configurable', 'libtmux.Entity', [field('snapshot', 10), field('get_option', 20)]), declare('libtmux.Session', 'libtmux.Configurable', [ - field('snapshot', 10), field('get_option', 20), field('new_window', 30), + field('snapshot', 10), + field('get_option', 20), + field('new_window', 30), ]), ], } const inherited = (model: ReturnType) => - Object.fromEntries(model.symbols.filter((s) => s.parent === 'libtmux.Session').map((s) => [s.name, s.inheritedFrom])) + Object.fromEntries( + model.symbols.filter((s) => s.parent === 'libtmux.Session').map((s) => [s.name, s.inheritedFrom]), + ) expect(inherited(extractLua(hierarchy))).toEqual({ snapshot: 'libtmux.Entity', get_option: 'libtmux.Configurable', new_window: undefined, }) // A cycle in `extends` ends the walk instead of the stack. - hierarchy.declarations[0]!.defines[0] = { ...hierarchy.declarations[0]!.defines[0]!, extends: [{ view: 'libtmux.Session' }] } + hierarchy.declarations[0]!.defines[0] = { + ...hierarchy.declarations[0]!.defines[0]!, + extends: [{ view: 'libtmux.Session' }], + } expect(inherited(extractLua(hierarchy)).get_option).toBe('libtmux.Configurable') }) }) diff --git a/packages/api-model/test/nav-config.test.ts b/packages/api-model/test/nav-config.test.ts index b242eac18..b4ff27216 100644 --- a/packages/api-model/test/nav-config.test.ts +++ b/packages/api-model/test/nav-config.test.ts @@ -5,12 +5,7 @@ import { compileNav } from '../src/nav.ts' import { navSidecar } from '../src/nav-sidecar.ts' import type { ApiModel, ApiSymbol } from '../src/model.ts' -const symbol = ( - id: string, - name: string, - file: string, - apiScope: ApiSymbol['apiScope'] = 'exported', -): ApiSymbol => ({ +const symbol = (id: string, name: string, file: string, apiScope: ApiSymbol['apiScope'] = 'exported'): ApiSymbol => ({ id, name, kind: 'class', @@ -39,7 +34,14 @@ it('keeps explicit workspace helpers out of unrelated naming buckets', () => { const compiled = compileNav( NAV.cxx, [ - { ...symbol('libtmux::workspace::BuildEvent', 'BuildEvent', 'examples/workspace/include/libtmux_consumers/workspace.hpp'), kind: 'struct' }, + { + ...symbol( + 'libtmux::workspace::BuildEvent', + 'BuildEvent', + 'examples/workspace/include/libtmux_consumers/workspace.hpp', + ), + kind: 'struct', + }, symbol('libtmux::OtherEvent', 'OtherEvent', 'include/libtmux/events.hpp'), ], { conceptIds: {}, moduleOf: () => 'libtmux' }, @@ -65,18 +67,21 @@ it('groups F# command composition and dispatch failures by their task', () => { }) describe('native PowerShell and Julia navigation', () => { - const model = (port: string): ApiModel => JSON.parse( - readFileSync(new URL(`../../../site/src/data/api/${port}.json`, import.meta.url), 'utf8'), - ) + const model = (port: string): ApiModel => + JSON.parse(readFileSync(new URL(`../../../site/src/data/api/${port}.json`, import.meta.url), 'utf8')) it('places PowerShell command chains, literal pane input and owned event operations by task', () => { const nav = navSidecar('powershell', model('powershell'))! expect(nav.placement).toMatchObject({ - 'LibTmux\\Invoke-TmuxChain': 'commands', 'LibTmux\\Send-TmuxText': 'pane', - 'LibTmux\\Wait-TmuxChannel': 'control', 'LibTmux\\Watch-TmuxEvent': 'control', + 'LibTmux\\Invoke-TmuxChain': 'commands', + 'LibTmux\\Send-TmuxText': 'pane', + 'LibTmux\\Wait-TmuxChannel': 'control', + 'LibTmux\\Watch-TmuxEvent': 'control', }) expect(nav.assignments.server.slice(0, 3).map((entry) => entry.id)).toEqual([ - 'LibTmux\\New-TmuxServer', 'LibTmux\\Get-TmuxServer', 'LibTmux\\Connect-TmuxServer', + 'LibTmux\\New-TmuxServer', + 'LibTmux\\Get-TmuxServer', + 'LibTmux\\Connect-TmuxServer', ]) expect(nav.unplaced.map((entry) => entry.id)).toEqual(['LibTmux']) expect(nav.diagnostics.unmatched).toEqual([]) @@ -87,8 +92,18 @@ describe('native PowerShell and Julia navigation', () => { it('keeps Julia predicates, native interface extensions and workspace functions with their operations', () => { const nav = navSidecar('julia', model('julia'))! const expected = { - queries: ['Criterion', 'Selection', 'WireObject', 'Filters.OneOf', 'Filters.AllRelated', - 'RowProjection', 'Base.IndexStyle', 'Base.getindex', 'Tables.schema', 'Tables.rows'], + queries: [ + 'Criterion', + 'Selection', + 'WireObject', + 'Filters.OneOf', + 'Filters.AllRelated', + 'RowProjection', + 'Base.IndexStyle', + 'Base.getindex', + 'Tables.schema', + 'Tables.rows', + ], control: ['ObservationCursor', 'ObservationStream', 'observe_output', 'Base.iterate', 'Base.take!'], snapshots: ['snapshotof', 'hascoverage', 'Base.propertynames'], commands: ['run_batch', 'run_group'], @@ -106,14 +121,25 @@ describe('native PowerShell and Julia navigation', () => { expect(nav.placement['LibTmuxWorkspace.Base.showerror']).toBe('errors') expect(nav.assignments.server[0].id).toBe('LibTmux.Server') expect(nav.assignments.session[0].id).toBe('LibTmux.sessions') - expect(nav.buckets.slice(0, 5).map((bucket) => bucket.id)).toEqual(['server', 'session', 'window', 'pane', 'client']) + expect(nav.buckets.slice(0, 5).map((bucket) => bucket.id)).toEqual([ + 'server', + 'session', + 'window', + 'pane', + 'client', + ]) expect(nav.diagnostics.unmatched).toEqual([]) expect(nav.diagnostics.ambiguous).toEqual([]) expect(nav.diagnostics.staleUnsettled).toEqual([]) - expect(nav.unplaced.map((entry) => entry.id).sort()).toEqual([ - 'LibTmux', 'LibTmux.EntityRef', 'LibTmux.TmuxID', 'LibTmux.entitykey', - ...['==', 'hash', 'isequal', 'string', 'show'].map((name) => `LibTmux.Base.${name}`), - ].sort()) + expect(nav.unplaced.map((entry) => entry.id).sort()).toEqual( + [ + 'LibTmux', + 'LibTmux.EntityRef', + 'LibTmux.TmuxID', + 'LibTmux.entitykey', + ...['==', 'hash', 'isequal', 'string', 'show'].map((name) => `LibTmux.Base.${name}`), + ].sort(), + ) }) it.each(['powershell', 'julia'])('still reports a new unrelated public declaration in %s', (port) => { diff --git a/packages/api-model/test/nav-sidecar.test.ts b/packages/api-model/test/nav-sidecar.test.ts index 34151cac6..8e9bde976 100644 --- a/packages/api-model/test/nav-sidecar.test.ts +++ b/packages/api-model/test/nav-sidecar.test.ts @@ -5,63 +5,125 @@ import { NAV } from '../src/nav-config.ts' import { navSidecar, navSidecarFor, type NavConfig } from '../src/nav-sidecar.ts' const symbol = (id: string, kind: ApiSymbol['kind'], parent?: string): ApiSymbol => ({ - id, name: id, kind, parent, slug: id, modifiers: [], signatures: [], - apiScope: 'internal', source: { file: 'tmux.h' }, + id, + name: id, + kind, + parent, + slug: id, + modifiers: [], + signatures: [], + apiScope: 'internal', + source: { file: 'tmux.h' }, }) describe('explicit catalog navigation', () => { it('compiles native unions, free declarations and diagnostics without a library port', () => { - const model: ApiModelBase = { extractor: 'fixture', symbols: [ - symbol('read_value', 'function'), symbol('value', 'union'), - symbol('integer', 'attribute', 'value'), symbol('orphan', 'constant'), - ] } - const config: NavConfig = { buckets: [ - { id: 'types', label: 'Types', match: { kind: 'symbol', kinds: ['union'] } }, - { id: 'functions', label: 'Functions', match: { kind: 'symbol', kinds: ['function'] } }, - { id: 'missing', label: 'Missing', match: { kind: 'name', prefix: 'not_present' } }, - ] } + const model: ApiModelBase = { + extractor: 'fixture', + symbols: [ + symbol('read_value', 'function'), + symbol('value', 'union'), + symbol('integer', 'attribute', 'value'), + symbol('orphan', 'constant'), + ], + } + const config: NavConfig = { + buckets: [ + { id: 'types', label: 'Types', match: { kind: 'symbol', kinds: ['union'] } }, + { id: 'functions', label: 'Functions', match: { kind: 'symbol', kinds: ['function'] } }, + { id: 'missing', label: 'Missing', match: { kind: 'name', prefix: 'not_present' } }, + ], + } const actual = navSidecarFor('tmux:3.7c', model, config) expect(actual.port).toBe('tmux:3.7c') expect(actual.assignments.types.map((entry) => entry.id)).toEqual(['value']) expect(actual.assignments.functions.map((entry) => entry.id)).toEqual(['read_value']) expect(actual.placement).toEqual({ value: 'types', read_value: 'functions', orphan: '__unplaced' }) - expect(actual.diagnostics).toEqual({ unmatched: ['orphan'], deadBuckets: ['missing'], ambiguous: [], staleUnsettled: [] }) + expect(actual.diagnostics).toEqual({ + unmatched: ['orphan'], + deadBuckets: ['missing'], + ambiguous: [], + staleUnsettled: [], + }) expect(actual.unplaced.map((entry) => entry.id)).toEqual(['orphan']) expect(model).not.toHaveProperty('port') }) it('keeps union owners before free declarations and retains nested bucket shape', () => { - const model: ApiModelBase = { extractor: 'fixture', symbols: [symbol('a_function', 'function'), symbol('z_union', 'union')] } - const config: NavConfig = { buckets: [{ id: 'root', label: 'Root', match: { kind: 'path', re: 'tmux.h' }, children: [ - { id: 'child', label: 'Child', match: { kind: 'path', re: 'tmux.h' }, children: [ - { id: 'leaf', label: 'Leaf', match: { kind: 'path', re: 'tmux.h' } }, - ] }, - ] }] } + const model: ApiModelBase = { + extractor: 'fixture', + symbols: [symbol('a_function', 'function'), symbol('z_union', 'union')], + } + const config: NavConfig = { + buckets: [ + { + id: 'root', + label: 'Root', + match: { kind: 'path', re: 'tmux.h' }, + children: [ + { + id: 'child', + label: 'Child', + match: { kind: 'path', re: 'tmux.h' }, + children: [{ id: 'leaf', label: 'Leaf', match: { kind: 'path', re: 'tmux.h' } }], + }, + ], + }, + ], + } const actual = navSidecarFor('tmux', model, config) expect(actual.buckets[0].children?.[0].children?.[0].id).toBe('leaf') expect(actual.assignments.leaf.map((entry) => entry.id)).toEqual(['z_union', 'a_function']) }) it('ranks assigned exact ids before defaults without moving or duplicating declarations', () => { - const model: ApiModelBase = { extractor: 'fixture', symbols: [ - symbol('first', 'struct'), { ...symbol('native_second', 'union'), publicId: 'second' }, - symbol('a_function', 'function'), symbol('z_function', 'function'), symbol('a_constant', 'constant'), - { ...symbol('elsewhere', 'struct'), source: { file: 'other.c' } }, - ] } - const config: NavConfig = { buckets: [{ id: 'root', label: 'Root', match: { kind: 'path', re: '^tmux.h$' }, children: [ - { id: 'nested', label: 'Nested', match: { kind: 'path', re: '^tmux.h$' }, - order: ['z_function', 'second', 'z_function', 'missing', 'elsewhere'] }, - ] }, { id: 'other', label: 'Other', match: { kind: 'path', re: '^other.c$' } }] } + const model: ApiModelBase = { + extractor: 'fixture', + symbols: [ + symbol('first', 'struct'), + { ...symbol('native_second', 'union'), publicId: 'second' }, + symbol('a_function', 'function'), + symbol('z_function', 'function'), + symbol('a_constant', 'constant'), + { ...symbol('elsewhere', 'struct'), source: { file: 'other.c' } }, + ], + } + const config: NavConfig = { + buckets: [ + { + id: 'root', + label: 'Root', + match: { kind: 'path', re: '^tmux.h$' }, + children: [ + { + id: 'nested', + label: 'Nested', + match: { kind: 'path', re: '^tmux.h$' }, + order: ['z_function', 'second', 'z_function', 'missing', 'elsewhere'], + }, + ], + }, + { id: 'other', label: 'Other', match: { kind: 'path', re: '^other.c$' } }, + ], + } const original = structuredClone(config) const baseline = structuredClone(config) delete baseline.buckets[0].children![0].order const before = navSidecarFor('tmux', model, baseline) const actual = navSidecarFor('tmux', model, config) expect(before.assignments.nested.map((entry) => entry.id)).toEqual([ - 'first', 'second', 'a_constant', 'a_function', 'z_function', + 'first', + 'second', + 'a_constant', + 'a_function', + 'z_function', ]) expect(actual.assignments.nested.map((entry) => entry.id)).toEqual([ - 'z_function', 'second', 'first', 'a_constant', 'a_function', + 'z_function', + 'second', + 'first', + 'a_constant', + 'a_function', ]) expect(actual.assignments.other).toEqual(before.assignments.other) expect(actual.placement).toEqual(before.placement) @@ -70,11 +132,16 @@ describe('explicit catalog navigation', () => { }) it('keeps contextual shortcuts separate from canonical placement and rejects missing targets', () => { - const model: ApiModelBase = { extractor: 'fixture', symbols: [symbol('owner', 'struct'), symbol('list_items', 'function')] } - const config: NavConfig = { buckets: [ - { id: 'owner', label: 'Owner', match: { kind: 'id', is: 'owner' }, shortcuts: ['list_items'] }, - { id: 'items', label: 'Items', match: { kind: 'id', is: 'list_items' } }, - ] } + const model: ApiModelBase = { + extractor: 'fixture', + symbols: [symbol('owner', 'struct'), symbol('list_items', 'function')], + } + const config: NavConfig = { + buckets: [ + { id: 'owner', label: 'Owner', match: { kind: 'id', is: 'owner' }, shortcuts: ['list_items'] }, + { id: 'items', label: 'Items', match: { kind: 'id', is: 'list_items' } }, + ], + } const before = structuredClone(model) const actual = navSidecarFor('fixture', model, config) expect(actual.buckets[0].shortcuts?.map((entry) => entry.id)).toEqual(['list_items']) @@ -89,7 +156,9 @@ describe('explicit catalog navigation', () => { describe('existing port sidecars', () => { it.each(Object.keys(NAV))('keeps the generated %s sidecar byte-equivalent', (port) => { - const model = JSON.parse(readFileSync(new URL(`../../../site/src/data/api/${port}.json`, import.meta.url), 'utf8')) as ApiModel + const model = JSON.parse( + readFileSync(new URL(`../../../site/src/data/api/${port}.json`, import.meta.url), 'utf8'), + ) as ApiModel const expected = readFileSync(new URL(`../../../site/src/data/api/${port}.nav.json`, import.meta.url), 'utf8') expect(`${JSON.stringify(navSidecar(port, model))}\n`).toBe(expected) }) diff --git a/packages/api-model/test/nearest.test.ts b/packages/api-model/test/nearest.test.ts index d5a31f681..aa6a2061d 100644 --- a/packages/api-model/test/nearest.test.ts +++ b/packages/api-model/test/nearest.test.ts @@ -36,27 +36,38 @@ const linkFor = (idx: SymbolIndex, annotation: string, name: string, context?: A it('resolves an explicit source import before a same-named local declaration', () => { const idx = new SymbolIndex([sym('example.Server'), sym('other.Server')], (s) => `/${s.id}/`, 'kotlin') const from = { ...sym('example.connect', 'function'), imports: { Server: 'other.Server', JavaServer: 'java.Server' } } - idx.addInventory('https://example.org/', [{ name: 'java.Server', type: 'std:label', priority: 1, uri: 'server', dispname: '-' }], ['kotlin']) + idx.addInventory( + 'https://example.org/', + [{ name: 'java.Server', type: 'std:label', priority: 1, uri: 'server', dispname: '-' }], + ['kotlin'], + ) expect(linkFor(idx, 'Server', 'Server', from)?.href).toBe('/other.Server/') expect(linkFor(idx, 'JavaServer', 'JavaServer', from)?.href).toBe('https://example.org/server') }) it('links F# types around apostrophe-prefixed generic parameters', () => { const idx = new SymbolIndex([sym('Filter'), sym('Field')], (s) => `/${s.id}/`, 'fsharp') - expect(idx.linkType("Filter<'T> -> Field<'T, 'Value>").filter((span) => span.link).map((span) => span.text)) - .toEqual(['Filter', 'Field']) + expect( + idx + .linkType("Filter<'T> -> Field<'T, 'Value>") + .filter((span) => span.link) + .map((span) => span.text), + ).toEqual(['Filter', 'Field']) }) it('keeps Julia symbol literals separate from same-named APIs', () => { - const idx = new SymbolIndex([ - sym('LibTmux.TmuxID'), sym('LibTmux.session', 'function'), sym('LibTmux.window', 'function'), - ], (s) => `/${s.id}/`, 'julia') + const idx = new SymbolIndex( + [sym('LibTmux.TmuxID'), sym('LibTmux.session', 'function'), sym('LibTmux.window', 'function')], + (s) => `/${s.id}/`, + 'julia', + ) expect(linkFor(idx, 'session', 'session')?.href).toBe('/LibTmux.session/') for (const annotation of ['TmuxID{:session}', '::TmuxID{:window}', 'Tuple{:session, :window}']) { const spans = idx.linkType(annotation) expect(spans.map((span) => span.text).join('')).toBe(annotation) - expect(spans.filter((span) => span.link).map((span) => span.text)) - .toEqual(annotation.includes('TmuxID') ? ['TmuxID'] : []) + expect(spans.filter((span) => span.link).map((span) => span.text)).toEqual( + annotation.includes('TmuxID') ? ['TmuxID'] : [], + ) } }) @@ -64,15 +75,28 @@ it('links Julia types after annotations without linking declaration names', () = const server = sym('LibTmux.Server') const id = sym('LibTmux.TmuxID') const idx = new SymbolIndex([server, id], (s) => `/${s.id}/`, 'julia') - const signature = { raw: 'LibTmux.Server(value::LibTmux.TmuxID{K}; socket_path) where K', - params: [{ name: 'value', type: 'LibTmux.TmuxID{K}' }, { name: 'socket_path', keywordOnly: true }], typeParams: ['K'] } + const signature = { + raw: 'LibTmux.Server(value::LibTmux.TmuxID{K}; socket_path) where K', + params: [ + { name: 'value', type: 'LibTmux.TmuxID{K}' }, + { name: 'socket_path', keywordOnly: true }, + ], + typeParams: ['K'], + } const spans = idx.linkType(signature.raw, server, signature) expect(spans.map((span) => span.text).join('')).toBe(signature.raw) expect(spans.filter((span) => span.link).map((span) => span.link?.href)).toEqual([ - '/LibTmux.Server/#LibTmux.Server.parameter.value', '/LibTmux.TmuxID/', + '/LibTmux.Server/#LibTmux.Server.parameter.value', + '/LibTmux.TmuxID/', '/LibTmux.Server/#LibTmux.Server.parameter.socket_path', ]) - expect(spans.filter((span) => span.declaration).map((span) => span.text)).toEqual(['Server', 'value', 'K', 'socket_path', 'K']) + expect(spans.filter((span) => span.declaration).map((span) => span.text)).toEqual([ + 'Server', + 'value', + 'K', + 'socket_path', + 'K', + ]) expect(spans.find((span) => span.text === 'where')?.keyword).toBe(true) }) @@ -80,14 +104,20 @@ it('links Julia variadic parameters without consuming their splat punctuation', const command = sym('LibTmux.capture_baseline', 'function') const server = sym('LibTmux.Server') const idx = new SymbolIndex([command, server], (s) => `/${s.id}/`, 'julia') - const signature = { raw: 'capture_baseline(server::LibTmux.Server, args...; kwargs...)', - params: [{ name: 'server', type: 'LibTmux.Server' }, { name: 'args', variadic: 'positional' }, - { name: 'kwargs', variadic: 'keyword', keywordOnly: true }] } satisfies Signature + const signature = { + raw: 'capture_baseline(server::LibTmux.Server, args...; kwargs...)', + params: [ + { name: 'server', type: 'LibTmux.Server' }, + { name: 'args', variadic: 'positional' }, + { name: 'kwargs', variadic: 'keyword', keywordOnly: true }, + ], + } satisfies Signature const spans = idx.linkType(signature.raw, command, signature) expect(spans.map((span) => span.text).join('')).toBe(signature.raw) for (const name of ['server', 'args', 'kwargs']) { - expect(spans.find((span) => span.text === name)?.link?.href) - .toBe(`/LibTmux.capture_baseline/#LibTmux.capture_baseline.parameter.${name}`) + expect(spans.find((span) => span.text === name)?.link?.href).toBe( + `/LibTmux.capture_baseline/#LibTmux.capture_baseline.parameter.${name}`, + ) } expect(spans.find((span) => span.text === 'LibTmux.Server')?.link?.href).toBe('/LibTmux.Server/') expect(spans.filter((span) => span.text.includes('...')).every((span) => !span.link)).toBe(true) @@ -96,8 +126,12 @@ it('links Julia variadic parameters without consuming their splat punctuation', it('links Julia subtype bounds while leaving Symbol literals alone', () => { const field = sym('LibTmux.FormatField') const idx = new SymbolIndex([field, sym('LibTmux.session', 'function')], (s) => `/${s.id}/`, 'julia') - for (const annotation of ['AbstractVector{<:LibTmux.FormatField}', 'T<:LibTmux.FormatField', - 'T>:LibTmux.FormatField', 'Tuple{:session, <:LibTmux.FormatField}']) { + for (const annotation of [ + 'AbstractVector{<:LibTmux.FormatField}', + 'T<:LibTmux.FormatField', + 'T>:LibTmux.FormatField', + 'Tuple{:session, <:LibTmux.FormatField}', + ]) { const spans = idx.linkType(annotation) expect(spans.map((span) => span.text).join('')).toBe(annotation) expect(spans.filter((span) => span.link).map((span) => span.link?.href)).toEqual(['/LibTmux.FormatField/']) @@ -152,9 +186,7 @@ describe('a bare name with more than one candidate', () => { 'java', ) const from = sym('io.github.libtmux.SplitSpec.SplitSpec.Builder.percent', 'method') - expect(linkFor(java, 'Builder', 'Builder', from)?.href).toBe( - '/io.github.libtmux.SplitSpec.SplitSpec.Builder/', - ) + expect(linkFor(java, 'Builder', 'Builder', from)?.href).toBe('/io.github.libtmux.SplitSpec.SplitSpec.Builder/') }) it('stays plain when two candidates are equally near', () => { @@ -168,7 +200,9 @@ it('resolves C++ core and workspace types through their enclosing namespaces', ( const symbols = ['libtmux::Pane', 'libtmux::workspace::Pane'].map(cpp) const idx = new SymbolIndex(symbols, (symbol) => `/${symbol.id}/`, 'cxx') expect(linkFor(idx, 'Pane', 'Pane', cpp('libtmux::Window::split'))?.href).toBe('/libtmux::Pane/') - expect(linkFor(idx, 'Pane', 'Pane', cpp('libtmux::workspace::Window::panes'))?.href).toBe('/libtmux::workspace::Pane/') + expect(linkFor(idx, 'Pane', 'Pane', cpp('libtmux::workspace::Window::panes'))?.href).toBe( + '/libtmux::workspace::Pane/', + ) expect(linkFor(idx, 'Pane', 'Pane', cpp('other::Window::split'))).toBeUndefined() }) diff --git a/packages/api-model/test/powershell.test.ts b/packages/api-model/test/powershell.test.ts index 1e5a93d19..cc01f7cd4 100644 --- a/packages/api-model/test/powershell.test.ts +++ b/packages/api-model/test/powershell.test.ts @@ -10,7 +10,8 @@ describe('PowerShell native command adapter', () => { it('preserves both module inventories without publishing implementation classes', () => { expect(model.symbols.filter((symbol) => symbol.kind === 'function')).toHaveLength(6) expect(model.sources?.map((source) => [source.package, source.product])).toEqual([ - ['LibTmux', 'core'], ['LibTmux.Workspace', 'workspace'], + ['LibTmux', 'core'], + ['LibTmux.Workspace', 'workspace'], ]) expect(model.symbols.every((symbol) => !symbol.id.includes('PowerShell.Commands'))).toBe(true) expect(command('Get-TmuxWorkspace')).toMatchObject({ package: 'LibTmux.Workspace', product: 'workspace' }) @@ -21,7 +22,11 @@ describe('PowerShell native command adapter', () => { expect(pane.signatures.map((signature) => signature.label)).toEqual(['Server', 'Session', 'Window']) for (const signature of pane.signatures) { const owner = signature.params.find((param) => param.name === signature.label)! - expect(owner.binding).toMatchObject({ mandatory: true, position: 0, pipeline: { byValue: true, byPropertyName: false } }) + expect(owner.binding).toMatchObject({ + mandatory: true, + position: 0, + pipeline: { byValue: true, byPropertyName: false }, + }) expect(signature.raw).toContain(`[]`) expect(signature.returns).toBe('LibTmux.Pane') } @@ -31,12 +36,21 @@ describe('PowerShell native command adapter', () => { const control = command('Connect-TmuxControl') expect(control.command).toMatchObject({ kind: 'cmdlet', supportsShouldProcess: true }) expect(control.signatures[0]!.params.map((parameter) => parameter.name)).toContain('WhatIf') - expect(control.signatures[0]!.params.find((parameter) => parameter.name === 'ErrorAction')?.binding?.common).toBe(true) - const timeout = command('Invoke-TmuxPaneCommand').signatures[0]!.params.find((parameter) => parameter.name === 'Timeout')! + expect(control.signatures[0]!.params.find((parameter) => parameter.name === 'ErrorAction')?.binding?.common).toBe( + true, + ) + const timeout = command('Invoke-TmuxPaneCommand').signatures[0]!.params.find( + (parameter) => parameter.name === 'Timeout', + )! expect(timeout.default).toBe('30') const id = command('Get-TmuxPane').signatures[0]!.params.find((parameter) => parameter.name === 'Id')! - expect(id.binding?.attributes.some((attribute) => attribute.type.endsWith('ValidateNotNullOrEmptyAttribute'))).toBe(true) - expect(command('New-TmuxServer').signatures[0]!.params.find((parameter) => parameter.name === 'TmuxBinaryPath')?.binding?.declaration?.initializer).toBe('"tmux"') + expect(id.binding?.attributes.some((attribute) => attribute.type.endsWith('ValidateNotNullOrEmptyAttribute'))).toBe( + true, + ) + expect( + command('New-TmuxServer').signatures[0]!.params.find((parameter) => parameter.name === 'TmuxBinaryPath')?.binding + ?.declaration?.initializer, + ).toBe('"tmux"') }) it('describes engine-supplied common parameters without replacing authored help', () => { @@ -50,11 +64,16 @@ describe('PowerShell native command adapter', () => { const parameters = authored.commands[0].parameterSets[0].parameters parameters.find((param: { name: string }) => param.name === 'Verbose').description = 'Authored verbose behavior.' const normalized = extractPowerShell(authored).symbols.find((symbol) => symbol.id === authored.commands[0].id)! - expect(normalized.signatures[0]!.params.find((param) => param.name === 'Verbose')?.doc).toBe('Authored verbose behavior.') + expect(normalized.signatures[0]!.params.find((param) => param.name === 'Verbose')?.doc).toBe( + 'Authored verbose behavior.', + ) parameters.find((param: { name: string }) => param.name === 'Verbose').description = '' parameters.find((param: { name: string }) => param.name === 'Verbose').common = false - expect(extractPowerShell(authored).symbols.find((symbol) => symbol.id === authored.commands[0].id)! - .signatures[0]!.params.find((param) => param.name === 'Verbose')?.doc).toBeUndefined() + expect( + extractPowerShell(authored) + .symbols.find((symbol) => symbol.id === authored.commands[0].id)! + .signatures[0]!.params.find((param) => param.name === 'Verbose')?.doc, + ).toBeUndefined() }) it('keeps executable example code, following prose, errors and source provenance', () => { @@ -68,8 +87,11 @@ describe('PowerShell native command adapter', () => { }) it('renders nested reflected generic and array types without assembly metadata', () => { - expect(powerShellType('System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib, Version=8.0.0.0],[System.Collections.Generic.List`1[[LibTmux.Pane, LibTmux, Version=0.0.0.0]], System.Private.CoreLib]][]')) - .toBe('System.Collections.Generic.Dictionary[System.String, System.Collections.Generic.List[LibTmux.Pane]][]') + expect( + powerShellType( + 'System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib, Version=8.0.0.0],[System.Collections.Generic.List`1[[LibTmux.Pane, LibTmux, Version=0.0.0.0]], System.Private.CoreLib]][]', + ), + ).toBe('System.Collections.Generic.Dictionary[System.String, System.Collections.Generic.List[LibTmux.Pane]][]') expect(powerShellType('System.String[]')).toBe('System.String[]') }) diff --git a/packages/api-model/test/product-exports.test.ts b/packages/api-model/test/product-exports.test.ts index 09a7c793a..8369a6909 100644 --- a/packages/api-model/test/product-exports.test.ts +++ b/packages/api-model/test/product-exports.test.ts @@ -2,12 +2,22 @@ import { describe, expect, it } from 'vitest' import { scopeProductSymbols } from '../src/product-exports.ts' import type { ApiSymbol } from '../src/model.ts' -const declaration = (name: string, file: string, kind: ApiSymbol['kind'] = 'interface'): ApiSymbol => ({ id: name, name, kind, source: { file }, modifiers: [], signatures: [] }) +const declaration = (name: string, file: string, kind: ApiSymbol['kind'] = 'interface'): ApiSymbol => ({ + id: name, + name, + kind, + source: { file }, + modifiers: [], + signatures: [], +}) describe('package export boundaries', () => { it('keeps an exported signature type without advertising an internal helper', () => { const symbols = [ - { ...declaration('createServer', '/pkg/server.ts', 'function'), signatures: [{ params: [{ name: 'options', type: 'Startup' }] }] }, + { + ...declaration('createServer', '/pkg/server.ts', 'function'), + signatures: [{ params: [{ name: 'options', type: 'Startup' }] }], + }, declaration('Policy', '/pkg/policy.ts'), declaration('Startup', '/pkg/startup.ts'), declaration('ImplementationContext', '/pkg/startup.ts'), @@ -22,7 +32,11 @@ describe('package export boundaries', () => { }) it('distinguishes a Rust re-export from a private module and a doctest marker', () => { - const symbols = [declaration('Workspace', '/crate/config.rs'), declaration('Secret', '/crate/config.rs'), declaration('MacrosReadme', '/crate/lib.rs')] + const symbols = [ + declaration('Workspace', '/crate/config.rs'), + declaration('Secret', '/crate/config.rs'), + declaration('MacrosReadme', '/crate/lib.rs'), + ] const files: Record = { '/crate/lib.rs': 'mod config;\npub use config::{Workspace};\n#[cfg(doctest)]\npub struct MacrosReadme;', '/crate/config.rs': 'pub struct Workspace;\npub struct Secret;', @@ -45,8 +59,10 @@ describe('package export boundaries', () => { declaration('libtmux::workspace::build', '/workspace.hpp', 'function'), declaration('libtmux::workspace::detail::build_windows', '/workspace.hpp', 'function'), declaration('libtmux::workspace::detail::State', '/workspace.hpp', 'struct'), - { ...declaration('libtmux::workspace::detail::State::value', '/workspace.hpp', 'attribute'), - parent: 'libtmux::workspace::detail::State' }, + { + ...declaration('libtmux::workspace::detail::State::value', '/workspace.hpp', 'attribute'), + parent: 'libtmux::workspace::detail::State', + }, ] scopeProductSymbols(symbols, { port: 'cxx', entries: [], readSource: () => '' }) expect(symbols.map((symbol) => symbol.apiScope)).toEqual(['exported', 'internal', 'internal', 'internal']) diff --git a/packages/api-model/test/products.test.ts b/packages/api-model/test/products.test.ts index 37747fc58..7ad4edc50 100644 --- a/packages/api-model/test/products.test.ts +++ b/packages/api-model/test/products.test.ts @@ -4,23 +4,36 @@ import type { ApiModel, ApiSymbol } from '../src/model.ts' import { inheritProductFromOwners, sourceUrl, symbolsForProduct } from '../src/products.ts' const symbol: ApiSymbol = { - id: 'tmuxp.WorkspaceBuilder', name: 'WorkspaceBuilder', kind: 'class', - modifiers: [], signatures: [], product: 'workspace', + id: 'tmuxp.WorkspaceBuilder', + name: 'WorkspaceBuilder', + kind: 'class', + modifiers: [], + signatures: [], + product: 'workspace', source: { file: 'src/tmuxp/workspace/builder.py', line: 42, repo: 'tmux-python/tmuxp', revision: 'abc123' }, } -const model: ApiModel = { port: 'py', extractor: 'test', repo: 'tmux-python/libtmux', revision: 'def456', symbols: [symbol] } +const model: ApiModel = { + port: 'py', + extractor: 'test', + repo: 'tmux-python/libtmux', + revision: 'def456', + symbols: [symbol], +} describe('product reference provenance', () => { it('links generated sources within the selected documentation version', () => { const file = 'libtmux-scala/build/generated/GeneratedOperations.scala' const generated = { ...symbol, source: { file, line: 42 } } const sourceModel = { ...model, generatedSources: { [file]: 'extension (self: Pane)\ndef capture(): Unit' } } - expect(sourceUrl(sourceModel, generated, '/en/scala/v1.0.0/reference/')) - .toBe(`/en/scala/v1.0.0/reference/sources/${file}`) + expect(sourceUrl(sourceModel, generated, '/en/scala/v1.0.0/reference/')).toBe( + `/en/scala/v1.0.0/reference/sources/${file}`, + ) expect(sourceUrl(sourceModel, generated)).toBeUndefined() }) it('links a separate product repository and revision', () => { - expect(sourceUrl(model, symbol)).toBe('https://github.com/tmux-python/tmuxp/blob/abc123/src/tmuxp/workspace/builder.py#L42') + expect(sourceUrl(model, symbol)).toBe( + 'https://github.com/tmux-python/tmuxp/blob/abc123/src/tmuxp/workspace/builder.py#L42', + ) }) it('keeps legacy core links and omits unknown line numbers', () => { @@ -35,8 +48,20 @@ describe('product reference provenance', () => { }) it('assigns members without files to their owning product independent of order', () => { - const nested: ApiSymbol = { ...symbol, id: 'Workspace.Nested.value', parent: 'Workspace.Nested', product: 'core', source: { file: '' } } - const container: ApiSymbol = { ...symbol, id: 'Workspace.Nested', parent: 'Workspace', product: 'core', source: { file: '' } } + const nested: ApiSymbol = { + ...symbol, + id: 'Workspace.Nested.value', + parent: 'Workspace.Nested', + product: 'core', + source: { file: '' }, + } + const container: ApiSymbol = { + ...symbol, + id: 'Workspace.Nested', + parent: 'Workspace', + product: 'core', + source: { file: '' }, + } const owner: ApiSymbol = { ...symbol, id: 'Workspace', product: 'workspace' } const ownSource: ApiSymbol = { ...symbol, id: 'Workspace.external', parent: 'Workspace', product: 'mcp' } const symbols = [nested, container, owner, ownSource] @@ -49,13 +74,20 @@ describe('product reference provenance', () => { describe('generated product coverage', () => { for (const port of ['py', 'ruby', 'ts', 'rs', 'go', 'java', 'csharp', 'cxx', 'swift']) { it(`${port} has source-proven workspace and MCP declarations`, () => { - const generated = JSON.parse(readFileSync(new URL(`../../../site/src/data/api/${port}.json`, import.meta.url), 'utf8')) as ApiModel + const generated = JSON.parse( + readFileSync(new URL(`../../../site/src/data/api/${port}.json`, import.meta.url), 'utf8'), + ) as ApiModel for (const product of ['workspace', 'mcp'] as const) { const symbols = symbolsForProduct(generated, product) expect(symbols.length).toBeGreaterThan(0) const sources = generated.sources?.filter((source) => source.product === product) ?? [] for (const symbol of symbols) { - expect(sources.some((source) => source.repo === symbol.source.repo && source.extractedRevision === symbol.source.extractedRevision)).toBe(true) + expect( + sources.some( + (source) => + source.repo === symbol.source.repo && source.extractedRevision === symbol.source.extractedRevision, + ), + ).toBe(true) expect(symbol.source.file).not.toMatch(/^(?:\/|\.\.)/) } } @@ -63,7 +95,9 @@ describe('generated product coverage', () => { } it('keeps Lua MCP and workspace absent until those products are published', () => { - const generated = JSON.parse(readFileSync(new URL('../../../site/src/data/api/lua.json', import.meta.url), 'utf8')) as ApiModel + const generated = JSON.parse( + readFileSync(new URL('../../../site/src/data/api/lua.json', import.meta.url), 'utf8'), + ) as ApiModel expect(symbolsForProduct(generated, 'mcp')).toEqual([]) expect(symbolsForProduct(generated, 'workspace')).toEqual([]) expect(generated.sources?.map((source) => source.product)).toEqual(['core']) diff --git a/packages/api-model/test/prose-audit.test.ts b/packages/api-model/test/prose-audit.test.ts index d192b0ca1..ba1d5460d 100644 --- a/packages/api-model/test/prose-audit.test.ts +++ b/packages/api-model/test/prose-audit.test.ts @@ -11,19 +11,25 @@ import { extractWithSpec } from '../src/languages/spec.ts' import { GO } from '../src/languages/specs.ts' const root = fileURLToPath(new URL('../../../', import.meta.url)) -const audit = (...args: string[]) => spawnSync(process.execPath, ['scripts/check-api-links.mjs', ...args, '--json'], { cwd: root, encoding: 'utf8' }) +const audit = (...args: string[]) => + spawnSync(process.execPath, ['scripts/check-api-links.mjs', ...args, '--json'], { cwd: root, encoding: 'utf8' }) describe('product prose audit', () => { it('audits native commands and dependency types without accepting invented commands', () => { const scratch = mkdtempSync(join(tmpdir(), 'libtmux-powershell-audit-')) const file = join(scratch, 'topic.md') try { - writeFileSync(file, '---\ntitle: Snapshot\nport: powershell\n---\n\nUse `Get-TmuxSnapshot` with `LibTmux.Server`.\n') + writeFileSync( + file, + '---\ntitle: Snapshot\nport: powershell\n---\n\nUse `Get-TmuxSnapshot` with `LibTmux.Server`.\n', + ) expect(audit(file).status).toBe(0) writeFileSync(file, '---\ntitle: Snapshot\nport: powershell\n---\n\nUse `Invent-TmuxSnapshot`.\n') const result = audit(file) expect(result.status).toBe(1) - expect(JSON.parse(result.stdout).unresolved.map((entry: { text: string }) => entry.text)).toEqual(['Invent-TmuxSnapshot']) + expect(JSON.parse(result.stdout).unresolved.map((entry: { text: string }) => entry.text)).toEqual([ + 'Invent-TmuxSnapshot', + ]) } finally { rmSync(scratch, { recursive: true }) } @@ -41,7 +47,9 @@ describe('product prose audit', () => { writeFileSync(file, '---\ntitle: Capture\nport: ts\nproduct: mcp\n---\n\nUse `capture_pane()` for output.\n') const result = audit(file) expect(result.status).toBe(1) - expect(JSON.parse(result.stdout).unresolved.map((entry: { text: string }) => entry.text)).toEqual(['capture_pane()']) + expect(JSON.parse(result.stdout).unresolved.map((entry: { text: string }) => entry.text)).toEqual([ + 'capture_pane()', + ]) } finally { rmSync(scratch, { recursive: true }) } @@ -82,23 +90,41 @@ describe('product context in prose', () => { }) it('does not borrow another language when the page already names its port', () => { - const other = { port: 'py', version: '0', symbols: [{ id: 'Other', name: 'Other', kind: 'class', signatures: [] }] } as ApiModel + const other = { + port: 'py', + version: '0', + symbols: [{ id: 'Other', name: 'Other', kind: 'class', signatures: [] }], + } as ApiModel for (const product of [undefined, 'workspace'] as const) { - const result = decideMention('Other', { pagePort: 'go', product }, new Resolver([model, other]), { go: model, py: other }) + const result = decideMention('Other', { pagePort: 'go', product }, new Resolver([model, other]), { + go: model, + py: other, + }) expect(result.kind).toBe('unresolved') } }) it('links standard-library types through the existing language catalog', () => { const rust = { port: 'rs', version: '0', symbols: [] } as unknown as ApiModel - expect(decideMention('BTreeMap', { pagePort: 'rs' }, new Resolver([rust]), { rs: rust })) - .toMatchObject({ kind: 'link', port: 'rs', href: 'https://doc.rust-lang.org/std/collections/struct.BTreeMap.html', external: true }) + expect(decideMention('BTreeMap', { pagePort: 'rs' }, new Resolver([rust]), { rs: rust })).toMatchObject({ + kind: 'link', + port: 'rs', + href: 'https://doc.rust-lang.org/std/collections/struct.BTreeMap.html', + external: true, + }) }) it('uses the enclosing type to distinguish nested builders', () => { - const java = { port: 'java', version: '0', symbols: ['SessionSpec', 'WindowSpec'].map((name) => ({ - id: `io.example.${name}.${name}.Builder.environment`, name: 'environment', kind: 'method', signatures: [], - })) } as unknown as ApiModel + const java = { + port: 'java', + version: '0', + symbols: ['SessionSpec', 'WindowSpec'].map((name) => ({ + id: `io.example.${name}.${name}.Builder.environment`, + name: 'environment', + kind: 'method', + signatures: [], + })), + } as unknown as ApiModel const r = new Resolver([java]) const found = r.resolve('java', 'SessionSpec.Builder.environment(Map)') expect('symbol' in found && found.symbol.id).toBe('io.example.SessionSpec.SessionSpec.Builder.environment') @@ -107,15 +133,20 @@ describe('product context in prose', () => { it('classifies MCP resource URIs and newly authored filenames explicitly', () => { expect(notASymbol('tmux://sessions/{session_id}')).toBe('a protocol URI') - expect(decideFilePath('dev.yaml', { before: 'Save this description as ' }, {})).toEqual({ kind: 'skip', why: 'file created by the example' }) + expect(decideFilePath('dev.yaml', { before: 'Save this description as ' }, {})).toEqual({ + kind: 'skip', + why: 'file created by the example', + }) expect(decideFilePath('missing.yaml', { before: 'The implementation reads ' }, {}).kind).toBe('unresolved') }) it('does not resolve a missing source path against another port', () => { const trees = { rs: new Set(['src/server.rs']), py: new Set(['src/libtmux/pane.py']) } expect(decideFilePath('src/libtmux/pane.py', { pagePort: 'rs' }, trees).kind).toBe('unresolved') - expect(decideFilePath('src/libtmux/pane.py', { pagePort: 'rs', before: 'Python uses ' }, trees)) - .toMatchObject({ kind: 'link', port: 'py' }) + expect(decideFilePath('src/libtmux/pane.py', { pagePort: 'rs', before: 'Python uses ' }, trees)).toMatchObject({ + kind: 'link', + port: 'py', + }) expect(decideFilePath('src/libtmux/pane.py', {}, trees)).toMatchObject({ kind: 'link', port: 'py' }) }) }) @@ -124,7 +155,10 @@ it('extracts exported Go error variables with their source documentation', async const scratch = mkdtempSync(join(tmpdir(), 'libtmux-go-api-')) const file = join(scratch, 'workspace.go') try { - writeFileSync(file, 'package workspace\n\n// ErrInvalidWorkspace matches parse and validation failures.\nvar ErrInvalidWorkspace = errors.New("workspace: invalid workspace")\nvar privateError = errors.New("private")\n') + writeFileSync( + file, + 'package workspace\n\n// ErrInvalidWorkspace matches parse and validation failures.\nvar ErrInvalidWorkspace = errors.New("workspace: invalid workspace")\nvar privateError = errors.New("private")\n', + ) const symbols = await extractWithSpec(GO, file, 'workspace') expect(symbols.map((symbol) => symbol.id)).toEqual(['workspace.ErrInvalidWorkspace']) expect(symbols[0].doc?.summary).toContain('parse and validation failures') diff --git a/packages/api-model/test/python-docs.test.ts b/packages/api-model/test/python-docs.test.ts index 50b2730e3..1704bb355 100644 --- a/packages/api-model/test/python-docs.test.ts +++ b/packages/api-model/test/python-docs.test.ts @@ -14,7 +14,9 @@ const sessions = [...queryList.matchAll(/^>>>[^\n]*(?:\n[^\n]+)*/gm)].map((match describe('Python docstring structure', () => { it('keeps every source doctest and its output in place without an Examples heading', () => { - expect(createHash('sha256').update(queryList).digest('hex')).toBe('245583d7072b64ceb8b722252375b3ba3537e06070e8fabbe1425059f4393cf1') + expect(createHash('sha256').update(queryList).digest('hex')).toBe( + '245583d7072b64ceb8b722252375b3ba3537e06070e8fabbe1425059f4393cf1', + ) expect(sessions).toHaveLength(22) const doc = parsePythonDoc(queryList).doc const blocks = [...doc.body!.matchAll(/```python\n([\s\S]*?)\n```/g)].map((match) => match[1]) @@ -27,7 +29,9 @@ describe('Python docstring structure', () => { }) it('preserves nested lists, references, notes and literal indentation', () => { - const doc = parsePythonDoc('Summary.\n\n- :class:`Server`\n\n - :attr:`Server.sessions`\n\nA literal::\n\n first\n second\n\nAfter.\n\nNotes\n-----\n*Still experimental*.\n\n>>> 2 + 2\n4').doc + const doc = parsePythonDoc( + 'Summary.\n\n- :class:`Server`\n\n - :attr:`Server.sessions`\n\nA literal::\n\n first\n second\n\nAfter.\n\nNotes\n-----\n*Still experimental*.\n\n>>> 2 + 2\n4', + ).doc expect(doc.body).toContain('- :class:`Server`\n\n - :attr:`Server.sessions`') expect(doc.body).toContain('A literal:\n\n```text\nfirst\n second\n```') expect(doc.body).toContain('### Notes\n\n*Still experimental*.\n\n```python\n>>> 2 + 2\n4\n```') @@ -36,25 +40,38 @@ describe('Python docstring structure', () => { it('keeps code directives and longer fences literal, including directive-looking source', () => { const body = pythonDocBody('.. code-block:: python\n :linenos:\n\n # comment\n print("hi")\n\nEnd.') expect(body).toBe('```python\n# comment\nprint("hi")\n```\n\nEnd.') - const doc = parsePythonDoc('Summary.\n\n.. code-block:: text\n\n .. note:: literal\n ```\n\n.. warning:: A real warning.').doc + const doc = parsePythonDoc( + 'Summary.\n\n.. code-block:: text\n\n .. note:: literal\n ```\n\n.. warning:: A real warning.', + ).doc expect(doc.body).toContain('````text\n.. note:: literal\n```\n````') expect(doc.admonitions).toEqual([{ kind: 'warning', text: 'A real warning.' }]) }) it('keeps paragraphs and literal sessions inside their admonition', () => { - const doc = parsePythonDoc('Summary.\n\n.. note:: First paragraph.\n\n Second paragraph with :class:`Server`.\n\n >>> value = [\n ... 1,\n ... ]\n [1]\n\nAfter.').doc - expect(doc.admonitions).toEqual([{ kind: 'note', text: 'First paragraph.\n\nSecond paragraph with :class:`Server`.\n\n```python\n>>> value = [\n... 1,\n... ]\n[1]\n```' }]) + const doc = parsePythonDoc( + 'Summary.\n\n.. note:: First paragraph.\n\n Second paragraph with :class:`Server`.\n\n >>> value = [\n ... 1,\n ... ]\n [1]\n\nAfter.', + ).doc + expect(doc.admonitions).toEqual([ + { + kind: 'note', + text: 'First paragraph.\n\nSecond paragraph with :class:`Server`.\n\n```python\n>>> value = [\n... 1,\n... ]\n[1]\n```', + }, + ]) expect(doc.body).toBe('After.') }) it('does not mistake an argumentless admonition for a literal introducer', () => { - const doc = parsePythonDoc('Summary.\n\n.. warning::\n\n Use :class:`Server`.\n\n It owns the connection.').doc + const doc = parsePythonDoc( + 'Summary.\n\n.. warning::\n\n Use :class:`Server`.\n\n It owns the connection.', + ).doc expect(doc.body).toBeUndefined() expect(doc.admonitions).toEqual([{ kind: 'warning', text: 'Use :class:`Server`.\n\nIt owns the connection.' }]) }) it('retains every directive in Notes without leaking reST markers or an empty heading', () => { - const doc = parsePythonDoc('Summary.\n\nNotes\n-----\n.. versionchanged:: 2.0\n New behavior.\n\n.. versionchanged:: 1.0\n Earlier behavior.\n\n.. warning::\n Check :class:`Server`.').doc + const doc = parsePythonDoc( + 'Summary.\n\nNotes\n-----\n.. versionchanged:: 2.0\n New behavior.\n\n.. versionchanged:: 1.0\n Earlier behavior.\n\n.. warning::\n Check :class:`Server`.', + ).doc expect(doc.changed).toBe('2.0 — New behavior.\n\n1.0 — Earlier behavior.') expect(doc.admonitions).toEqual([{ kind: 'warning', text: 'Check :class:`Server`.' }]) expect(doc.body).toBeUndefined() @@ -62,16 +79,28 @@ describe('Python docstring structure', () => { it('indexes real prose references without indexing literal roles or fence delimiters', () => { expect(referencesIn(':class:`Server`\n\n````text\n:class:`Imaginary`\n```\n````\n\n:class:`Window`')).toEqual([ - { role: 'class', target: 'Server' }, { role: 'class', target: 'Window' }, + { role: 'class', target: 'Server' }, + { role: 'class', target: 'Window' }, ]) }) it('renders single emphasis without claiming globs, arithmetic or code', () => { expect(tokenizeDoc('*Experimental, unstable*.')).toEqual([ - { kind: 'emphasis', text: 'Experimental, unstable' }, { kind: 'text', text: '.' }, + { kind: 'emphasis', text: 'Experimental, unstable' }, + { kind: 'text', text: '.' }, ]) - for (const text of ['a * b and 2 * 3', 'glob *.py here', String.raw`\*literal\*`, '`*literal*`', '``*literal*``', '**Strong**']) { - expect(tokenizeDoc(text).filter((span) => span.kind === 'emphasis'), text).toEqual([]) + for (const text of [ + 'a * b and 2 * 3', + 'glob *.py here', + String.raw`\*literal\*`, + '`*literal*`', + '``*literal*``', + '**Strong**', + ]) { + expect( + tokenizeDoc(text).filter((span) => span.kind === 'emphasis'), + text, + ).toEqual([]) } expect(docSummaryText('A *small* **local** `list`.', 'py')).toBe('A small local list.') }) @@ -95,12 +124,19 @@ describe('source-scoped Python typing bindings', () => { const index = new SymbolIndex([symbol], (entry) => `#${entry.id}`, 'py') const spans = index.linkType('t.Generic[T]', symbol) expect(spans.map((span) => span.text).join('')).toBe('t.Generic[T]') - expect(spans.find((span) => span.text === 't.Generic')?.link?.href).toBe('https://docs.python.org/3/library/typing.html#typing.Generic') + expect(spans.find((span) => span.text === 't.Generic')?.link?.href).toBe( + 'https://docs.python.org/3/library/typing.html#typing.Generic', + ) expect(spans.find((span) => span.text === 'T')?.link).toBeUndefined() }) it('keeps aliases scoped to imports and invalidates rebound names', async () => { - for (const prefix of ['', 'import other as t\n', 'import typing as t\nt = other\n', 'import typing as t\nimport other as t\n']) { + for (const prefix of [ + '', + 'import other as t\n', + 'import typing as t\nt = other\n', + 'import typing as t\nimport other as t\n', + ]) { const symbols = await extract(`${prefix}class QueryList(t.Generic[T]):\n pass\n`) const symbol = symbols.at(-1)! const index = new SymbolIndex(symbols, (entry) => `#${entry.id}`, 'py') diff --git a/packages/api-model/test/raises.test.ts b/packages/api-model/test/raises.test.ts index 25149ded3..f02c66789 100644 --- a/packages/api-model/test/raises.test.ts +++ b/packages/api-model/test/raises.test.ts @@ -20,9 +20,7 @@ Raises :exc:\`libtmux.exc.BadSessionName\` When the name is invalid. `) - expect(parsed.raises).toEqual([ - { type: 'libtmux.exc.BadSessionName', doc: 'When the name is invalid.' }, - ]) + expect(parsed.raises).toEqual([{ type: 'libtmux.exc.BadSessionName', doc: 'When the name is invalid.' }]) }) it('shortens a target Sphinx would shorten', () => { diff --git a/packages/api-model/test/resolver.test.ts b/packages/api-model/test/resolver.test.ts index ea3937842..4322cdba1 100644 --- a/packages/api-model/test/resolver.test.ts +++ b/packages/api-model/test/resolver.test.ts @@ -133,13 +133,22 @@ d('resolver', () => { it('resolves most unlinked references in the real corpus', () => { const LABEL: Record = { - Python: 'py', Ruby: 'ruby', Lua: 'lua', TypeScript: 'ts', Rust: 'rs', Go: 'go', - Java: 'java', '.NET': 'csharp', 'C++': 'cxx', Swift: 'swift', + Python: 'py', + Ruby: 'ruby', + Lua: 'lua', + TypeScript: 'ts', + Rust: 'rs', + Go: 'go', + Java: 'java', + '.NET': 'csharp', + 'C++': 'cxx', + Swift: 'swift', } const docs = join(here, '../../../site/src/content/docs') const walk = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap((e) => - e.isDirectory() ? walk(join(dir, e.name)) : e.name.endsWith('.md') ? [join(dir, e.name)] : []) + e.isDirectory() ? walk(join(dir, e.name)) : e.name.endsWith('.md') ? [join(dir, e.name)] : [], + ) let linkable = 0 let resolved = 0 for (const f of walk(docs)) { diff --git a/packages/api-model/test/ruby-examples.test.ts b/packages/api-model/test/ruby-examples.test.ts index 94d911042..a02da99ae 100644 --- a/packages/api-model/test/ruby-examples.test.ts +++ b/packages/api-model/test/ruby-examples.test.ts @@ -6,13 +6,20 @@ const source = { repository: 'libtmux/libtmux-ruby', revision: 'a'.repeat(40) } const file = 'examples/api/server.rb' const code = '# frozen_string_literal: true\n\nrequire "libtmux"\n\nputs "ready"\n' const program = () => ({ - path: file, gem: 'libtmux', + path: file, + gem: 'libtmux', api: { symbols: ['LibTmux::Server.new'], description: 'Connect to a server.', output: 'ready\n' }, }) const bundle = () => ({ manifest: { programs: [program()] }, files: [{ path: file, content: code }] }) const symbol = (): ApiSymbol => ({ - id: 'LibTmux::Server.new', publicId: 'LibTmux::Server.new', name: 'new', kind: 'method', - modifiers: ['static'], parent: 'LibTmux::Server', package: 'libtmux', signatures: [], + id: 'LibTmux::Server.new', + publicId: 'LibTmux::Server.new', + name: 'new', + kind: 'method', + modifiers: ['static'], + parent: 'LibTmux::Server', + package: 'libtmux', + signatures: [], source: { file: 'gems/libtmux/lib/libtmux/server.rb', line: 45 }, doc: { summary: 'Connect to an endpoint.', examples: [{ lang: 'ruby', code: 'existing' }] }, }) @@ -26,12 +33,16 @@ describe('complete native Ruby examples', () => { const blocks = target.doc!.examples! expect(blocks.map((block) => block.lang)).toEqual(['ruby', 'console', 'ruby', 'ruby', 'console', 'text']) expect(blocks[0].code).toBe('existing') - expect(blocks[1].code).toBe(`$ git clone https://github.com/libtmux/libtmux-ruby.git libtmux-source && \\\n git -C libtmux-source checkout ${source.revision}\n`) + expect(blocks[1].code).toBe( + `$ git clone https://github.com/libtmux/libtmux-ruby.git libtmux-source && \\\n git -C libtmux-source checkout ${source.revision}\n`, + ) expect(blocks[2].code).toBe('source "https://rubygems.org"\n\ngem "libtmux", path: "libtmux-source/gems/libtmux"\n') expect(blocks[3].code).toBe(code) expect(blocks[3].sourceUrl).toBe(`https://github.com/${source.repository}/blob/${source.revision}/${file}`) expect(blocks[3].intro).toContain('server.rb') - expect(blocks[4].code).toBe('$ bundle config set --local path vendor/bundle && \\\n bundle install --jobs 2 && \\\n bundle exec ruby server.rb\n') + expect(blocks[4].code).toBe( + '$ bundle config set --local path vendor/bundle && \\\n bundle install --jobs 2 && \\\n bundle exec ruby server.rb\n', + ) expect(blocks[5].code).toBe('ready\n') }) @@ -45,13 +56,39 @@ describe('complete native Ruby examples', () => { it.each([ ['missing file', { manifest: { programs: [program()] }, files: [] }, /Missing complete/], - ['duplicate file', { ...bundle(), files: [{ path: file, content: code }, { path: file, content: code }] }, /Duplicate.*file/], + [ + 'duplicate file', + { + ...bundle(), + files: [ + { path: file, content: code }, + { path: file, content: code }, + ], + }, + /Duplicate.*file/, + ], ['empty file', { ...bundle(), files: [{ path: file, content: ' ' }] }, /Missing complete/], ['missing API metadata', { ...bundle(), manifest: { programs: [{ ...program(), api: null }] } }, /metadata/], - ['empty targets', { ...bundle(), manifest: { programs: [{ ...program(), api: { ...program().api, symbols: [] } }] } }, /metadata/], - ['missing output newline', { ...bundle(), manifest: { programs: [{ ...program(), api: { ...program().api, output: 'ready' } }] } }, /metadata/], - ['empty description', { ...bundle(), manifest: { programs: [{ ...program(), api: { ...program().api, description: ' ' } }] } }, /metadata/], - ['escaping path', { ...bundle(), manifest: { programs: [{ ...program(), path: 'examples/../server.rb' }] } }, /metadata/], + [ + 'empty targets', + { ...bundle(), manifest: { programs: [{ ...program(), api: { ...program().api, symbols: [] } }] } }, + /metadata/, + ], + [ + 'missing output newline', + { ...bundle(), manifest: { programs: [{ ...program(), api: { ...program().api, output: 'ready' } }] } }, + /metadata/, + ], + [ + 'empty description', + { ...bundle(), manifest: { programs: [{ ...program(), api: { ...program().api, description: ' ' } }] } }, + /metadata/, + ], + [ + 'escaping path', + { ...bundle(), manifest: { programs: [{ ...program(), path: 'examples/../server.rb' }] } }, + /metadata/, + ], ])('rejects %s', (_name, input, message) => { expect(() => attachCompleteRubyExamples([symbol()], input, source)).toThrow(message) }) @@ -59,17 +96,20 @@ describe('complete native Ruby examples', () => { it('rejects unknown, duplicate, and wrong-package declaration targets', () => { expect(() => attachCompleteRubyExamples([], bundle(), source)).toThrow(/resolve once/) expect(() => attachCompleteRubyExamples([symbol(), symbol()], bundle(), source)).toThrow(/resolve once/) - expect(() => attachCompleteRubyExamples([{ ...symbol(), package: 'libtmux-async' }], bundle(), source)) - .toThrow(/package differs/) + expect(() => attachCompleteRubyExamples([{ ...symbol(), package: 'libtmux-async' }], bundle(), source)).toThrow( + /package differs/, + ) const duplicate = bundle() duplicate.manifest.programs.push(program()) expect(() => attachCompleteRubyExamples([symbol()], duplicate, source)).toThrow(/Duplicate.*target/) }) it('rejects mutable revisions and invalid repository citations', () => { - expect(() => attachCompleteRubyExamples([symbol()], bundle(), { ...source, revision: 'master' })) - .toThrow(/full source revision/) - expect(() => attachCompleteRubyExamples([symbol()], bundle(), { ...source, repository: '../libtmux-ruby' })) - .toThrow(/repository/) + expect(() => attachCompleteRubyExamples([symbol()], bundle(), { ...source, revision: 'master' })).toThrow( + /full source revision/, + ) + expect(() => + attachCompleteRubyExamples([symbol()], bundle(), { ...source, repository: '../libtmux-ruby' }), + ).toThrow(/repository/) }) }) diff --git a/packages/api-model/test/rust-examples.test.ts b/packages/api-model/test/rust-examples.test.ts index 1fb287003..a23de69d7 100644 --- a/packages/api-model/test/rust-examples.test.ts +++ b/packages/api-model/test/rust-examples.test.ts @@ -4,42 +4,63 @@ import { attachCompleteRustExamples } from '../src/languages/rust-examples.ts' const source = { repo: 'libtmux/libtmux-rs', revision: 'a'.repeat(40) } const symbol = (id: string, kind: ApiSymbol['kind'], parent?: string): ApiSymbol => ({ - id, name: id.split('.').at(-1)!, slug: id.toLowerCase().replaceAll('.', '-'), kind, - signatures: [], modifiers: [], ...(parent ? { parent } : {}), + id, + name: id.split('.').at(-1)!, + slug: id.toLowerCase().replaceAll('.', '-'), + kind, + signatures: [], + modifiers: [], + ...(parent ? { parent } : {}), source: { file: 'crates/libtmux/src/server.rs' }, doc: { summary: 'Existing prose.', examples: [{ lang: 'rust', code: 'old();\n' }] }, }) function fixture() { - const symbols = [symbol('server.Server', 'struct'), + const symbols = [ + symbol('server.Server', 'struct'), symbol('server.Server.builder', 'method', 'server.Server'), symbol('options.OptionScope.Server', 'constant', 'options.OptionScope'), symbol('query.QueryIteratorExt', 'trait'), - symbol('query.QueryIteratorExt.exactly_one', 'method', 'query.QueryIteratorExt')] + symbol('query.QueryIteratorExt.exactly_one', 'method', 'query.QueryIteratorExt'), + ] const manifest = { - schemaVersion: 1, language: 'rust', nativeInventory: 'crates/libtmux/docs/public-api.txt', + schemaVersion: 1, + language: 'rust', + nativeInventory: 'crates/libtmux/docs/public-api.txt', setupFiles: [ { sourceFile: 'crates/libtmux/examples/api/consumer.toml', name: 'Cargo.toml' }, { sourceFile: 'rust-toolchain.toml', name: 'rust-toolchain.toml' }, - ], programFileName: 'src/main.rs', runCommand: 'cargo run --quiet', - examples: [{ id: 'rust-construction', title: 'Construct a server', description: 'Use a private socket.', - sourceFile: 'crates/libtmux/examples/api_construction.rs', cargoExample: 'api_construction', - targets: ['libtmux::Server', 'libtmux::Server::builder', 'libtmux::query::QueryIteratorExt::exactly_one'], - expectedOutput: 'done' }], + ], + programFileName: 'src/main.rs', + runCommand: 'cargo run --quiet', + examples: [ + { + id: 'rust-construction', + title: 'Construct a server', + description: 'Use a private socket.', + sourceFile: 'crates/libtmux/examples/api_construction.rs', + cargoExample: 'api_construction', + targets: ['libtmux::Server', 'libtmux::Server::builder', 'libtmux::query::QueryIteratorExt::exactly_one'], + expectedOutput: 'done', + }, + ], } const files: Record = { - 'crates/libtmux/docs/public-api.txt': 'struct libtmux::Server\n' + + 'crates/libtmux/docs/public-api.txt': + 'struct libtmux::Server\n' + 'function libtmux::Server::builder: fn() -> libtmux::ServerBuilder\n' + 'trait libtmux::query::QueryIteratorExt\n' + 'function libtmux::query::QueryIteratorExt::exactly_one: fn(mut self) -> Result\n', - 'crates/libtmux/examples/api/consumer.toml': '[package]\nname = "libtmux-api-example"\n' + + 'crates/libtmux/examples/api/consumer.toml': + '[package]\nname = "libtmux-api-example"\n' + 'edition = "2024"\npublish = false\n[workspace]\nexclude = ["libtmux-source"]\n' + '[dependencies]\nlibtmux = { path = "libtmux-source/crates/libtmux" }\n' + 'tempfile = "3.20"\ntokio = { version = "1.40", features = ["macros", "rt", "time"] }\n', 'rust-toolchain.toml': '[toolchain]\nchannel = "1.97.1"\n', - 'crates/libtmux/examples/api_construction.rs': 'use libtmux::Server;\n#[tokio::main(flavor = "current_thread")]\nasync fn main() {}\n', + 'crates/libtmux/examples/api_construction.rs': + 'use libtmux::Server;\n#[tokio::main(flavor = "current_thread")]\nasync fn main() {}\n', } - const read = (file: string) => file === 'crates/libtmux/examples/api/manifest.json' - ? JSON.stringify(manifest) : files[file] + const read = (file: string) => + file === 'crates/libtmux/examples/api/manifest.json' ? JSON.stringify(manifest) : files[file] return { symbols, manifest, files, read } } @@ -55,7 +76,9 @@ describe('complete Rust API programs', () => { expect(blocks[2].code).toBe(f.files['crates/libtmux/examples/api/consumer.toml']) expect(blocks[3].code).toBe(f.files['rust-toolchain.toml']) expect(blocks[4].code).toBe(f.files[f.manifest.examples[0].sourceFile]) - expect(blocks[4].sourceUrl).toBe(`https://github.com/${source.repo}/blob/${source.revision}/${f.manifest.examples[0].sourceFile}`) + expect(blocks[4].sourceUrl).toBe( + `https://github.com/${source.repo}/blob/${source.revision}/${f.manifest.examples[0].sourceFile}`, + ) expect(blocks[1].code).toContain(`checkout --detach ${source.revision}`) expect(blocks[5].code).toContain('mkdir -p src &&\n') expect(blocks[5].code).toContain('cargo run --quiet\n') @@ -73,30 +96,153 @@ describe('complete Rust API programs', () => { }) it.each([ - ['missing native target', (f: ReturnType) => { f.manifest.examples[0].targets.push('libtmux::Unknown') }], - ['duplicate native target', (f: ReturnType) => { f.manifest.examples[0].targets.push('libtmux::Server') }], - ['ambiguous inventory', (f: ReturnType) => { f.files[f.manifest.nativeInventory] += 'enum libtmux::Server\n' }], - ['missing model target', (f: ReturnType) => { f.symbols.splice(1, 1) }], - ['ambiguous model target', (f: ReturnType) => { f.symbols.push(symbol('other.Server', 'struct')) }], - ['wrong kind', (f: ReturnType) => { f.symbols[0].kind = 'enum' }], - ['wrong method owner', (f: ReturnType) => { f.symbols[4].parent = 'query.Other' }], - ['wrong source crate', (f: ReturnType) => { f.symbols[0].source.file = 'crates/tmux-workspace/src/server.rs' }], - ['wrong product', (f: ReturnType) => { f.symbols[0].product = 'workspace' }], - ['duplicate program', (f: ReturnType) => { f.manifest.examples.push(f.manifest.examples[0]) }], - ['missing Cargo setup', (f: ReturnType) => { delete f.files[f.manifest.setupFiles[0].sourceFile] }], - ['missing toolchain', (f: ReturnType) => { delete f.files['rust-toolchain.toml'] }], - ['malformed TOML', (f: ReturnType) => { f.files['rust-toolchain.toml'] += '[broken\n' }], - ['unpinned toolchain', (f: ReturnType) => { f.files['rust-toolchain.toml'] = '[toolchain]\nchannel = "stable"\n' }], - ['wrong dependency path', (f: ReturnType) => { const file = f.manifest.setupFiles[0].sourceFile; f.files[file] = f.files[file].replace('libtmux-source/crates/libtmux', '../hidden') }], - ['missing runtime dependency', (f: ReturnType) => { const file = f.manifest.setupFiles[0].sourceFile; f.files[file] = f.files[file].replace('"macros", ', '') }], - ['duplicate setup file', (f: ReturnType) => { f.manifest.setupFiles[1] = f.manifest.setupFiles[0] }], - ['unsafe program path', (f: ReturnType) => { f.manifest.examples[0].sourceFile = '../main.rs' }], - ['unexpected run command', (f: ReturnType) => { f.manifest.runCommand = 'cargo run --features test-support' }], - ['missing program', (f: ReturnType) => { delete f.files[f.manifest.examples[0].sourceFile] }], - ['partial file', (f: ReturnType) => { const file = f.manifest.examples[0].sourceFile; f.files[file] = f.files[file].trimEnd() }], - ['hidden helper', (f: ReturnType) => { f.files[f.manifest.examples[0].sourceFile] += 'mod helper;\n' }], - ['missing entrypoint', (f: ReturnType) => { f.files[f.manifest.examples[0].sourceFile] = 'fn example() {}\n' }], - ['empty output', (f: ReturnType) => { f.manifest.examples[0].expectedOutput = '' }], + [ + 'missing native target', + (f: ReturnType) => { + f.manifest.examples[0].targets.push('libtmux::Unknown') + }, + ], + [ + 'duplicate native target', + (f: ReturnType) => { + f.manifest.examples[0].targets.push('libtmux::Server') + }, + ], + [ + 'ambiguous inventory', + (f: ReturnType) => { + f.files[f.manifest.nativeInventory] += 'enum libtmux::Server\n' + }, + ], + [ + 'missing model target', + (f: ReturnType) => { + f.symbols.splice(1, 1) + }, + ], + [ + 'ambiguous model target', + (f: ReturnType) => { + f.symbols.push(symbol('other.Server', 'struct')) + }, + ], + [ + 'wrong kind', + (f: ReturnType) => { + f.symbols[0].kind = 'enum' + }, + ], + [ + 'wrong method owner', + (f: ReturnType) => { + f.symbols[4].parent = 'query.Other' + }, + ], + [ + 'wrong source crate', + (f: ReturnType) => { + f.symbols[0].source.file = 'crates/tmux-workspace/src/server.rs' + }, + ], + [ + 'wrong product', + (f: ReturnType) => { + f.symbols[0].product = 'workspace' + }, + ], + [ + 'duplicate program', + (f: ReturnType) => { + f.manifest.examples.push(f.manifest.examples[0]) + }, + ], + [ + 'missing Cargo setup', + (f: ReturnType) => { + delete f.files[f.manifest.setupFiles[0].sourceFile] + }, + ], + [ + 'missing toolchain', + (f: ReturnType) => { + delete f.files['rust-toolchain.toml'] + }, + ], + [ + 'malformed TOML', + (f: ReturnType) => { + f.files['rust-toolchain.toml'] += '[broken\n' + }, + ], + [ + 'unpinned toolchain', + (f: ReturnType) => { + f.files['rust-toolchain.toml'] = '[toolchain]\nchannel = "stable"\n' + }, + ], + [ + 'wrong dependency path', + (f: ReturnType) => { + const file = f.manifest.setupFiles[0].sourceFile + f.files[file] = f.files[file].replace('libtmux-source/crates/libtmux', '../hidden') + }, + ], + [ + 'missing runtime dependency', + (f: ReturnType) => { + const file = f.manifest.setupFiles[0].sourceFile + f.files[file] = f.files[file].replace('"macros", ', '') + }, + ], + [ + 'duplicate setup file', + (f: ReturnType) => { + f.manifest.setupFiles[1] = f.manifest.setupFiles[0] + }, + ], + [ + 'unsafe program path', + (f: ReturnType) => { + f.manifest.examples[0].sourceFile = '../main.rs' + }, + ], + [ + 'unexpected run command', + (f: ReturnType) => { + f.manifest.runCommand = 'cargo run --features test-support' + }, + ], + [ + 'missing program', + (f: ReturnType) => { + delete f.files[f.manifest.examples[0].sourceFile] + }, + ], + [ + 'partial file', + (f: ReturnType) => { + const file = f.manifest.examples[0].sourceFile + f.files[file] = f.files[file].trimEnd() + }, + ], + [ + 'hidden helper', + (f: ReturnType) => { + f.files[f.manifest.examples[0].sourceFile] += 'mod helper;\n' + }, + ], + [ + 'missing entrypoint', + (f: ReturnType) => { + f.files[f.manifest.examples[0].sourceFile] = 'fn example() {}\n' + }, + ], + [ + 'empty output', + (f: ReturnType) => { + f.manifest.examples[0].expectedOutput = '' + }, + ], ])('rejects %s without partially changing documentation', (_name, change) => { const f = fixture() change(f) @@ -107,7 +253,11 @@ describe('complete Rust API programs', () => { it('rejects an unpinned revision or foreign repository', () => { const f = fixture() - expect(() => attachCompleteRustExamples(f.symbols, f.read, { ...source, revision: 'master' })).toThrow('full revision') - expect(() => attachCompleteRustExamples(f.symbols, f.read, { ...source, repo: 'other/repo' })).toThrow('Rust repository') + expect(() => attachCompleteRustExamples(f.symbols, f.read, { ...source, revision: 'master' })).toThrow( + 'full revision', + ) + expect(() => attachCompleteRustExamples(f.symbols, f.read, { ...source, repo: 'other/repo' })).toThrow( + 'Rust repository', + ) }) }) diff --git a/packages/api-model/test/rust.test.ts b/packages/api-model/test/rust.test.ts index 16f99ad57..66090b960 100644 --- a/packages/api-model/test/rust.test.ts +++ b/packages/api-model/test/rust.test.ts @@ -45,11 +45,13 @@ describe('Rust trait member visibility', () => { expect(symbols.map(({ id, kind, parent }) => ({ id, kind, parent }))).toEqual([ { id: 'query.QueryIteratorExt', kind: 'trait', parent: undefined }, { - id: 'query.QueryIteratorExt.one_or_none', kind: 'method', + id: 'query.QueryIteratorExt.one_or_none', + kind: 'method', parent: 'query.QueryIteratorExt', }, { - id: 'query.QueryIteratorExt.matching', kind: 'method', + id: 'query.QueryIteratorExt.matching', + kind: 'method', parent: 'query.QueryIteratorExt', }, ]) @@ -60,10 +62,13 @@ describe('Rust trait member visibility', () => { }) expect(symbols[2]).toMatchObject({ doc: { summary: 'Keep matching items.' }, - signatures: [{ - params: [{ name: 'self' }, { name: 'predicate', type: 'P' }], - typeParams: ['P'], returns: 'Vec', - }], + signatures: [ + { + params: [{ name: 'self' }, { name: 'predicate', type: 'P' }], + typeParams: ['P'], + returns: 'Vec', + }, + ], source: { line: 6 }, }) }) @@ -83,15 +88,18 @@ describe('Rust trait member visibility', () => { pub fn public_free() {} ` const symbols = await extract(source) - expect(symbols.map((symbol) => symbol.id)).toEqual([ - 'query.Server', 'query.Server.visible', 'query.public_free', - ]) + expect(symbols.map((symbol) => symbol.id)).toEqual(['query.Server', 'query.Server.visible', 'query.public_free']) const all = await extract(source, true) for (const id of [ - 'query.Internal.hidden', 'query.Internal.default_hidden', - 'query.CrateInternal.hidden', 'query.ParentInternal.hidden', - 'query.Server.hidden', 'query.Server.crate_hidden', 'query.private_free', - ]) expect(all.some((symbol) => symbol.id === id)).toBe(true) + 'query.Internal.hidden', + 'query.Internal.default_hidden', + 'query.CrateInternal.hidden', + 'query.ParentInternal.hidden', + 'query.Server.hidden', + 'query.Server.crate_hidden', + 'query.private_free', + ]) + expect(all.some((symbol) => symbol.id === id)).toBe(true) }) it('omits explicitly hidden trait hooks without hiding adjacent documented methods', async () => { @@ -119,12 +127,19 @@ describe('Rust trait member visibility', () => { pub trait HiddenCombinedOwner { fn required(); } ` expect((await extract(source)).map((symbol) => symbol.id)).toEqual([ - 'query.Contract', 'query.Contract.visible_required', 'query.Contract.visible_default', + 'query.Contract', + 'query.Contract.visible_required', + 'query.Contract.visible_default', ]) - expect((await extract(source, true)).filter((symbol) => symbol.parent === 'query.Contract') - .map((symbol) => symbol.name)).toEqual([ - 'hidden_required', 'hidden_default', 'hidden_combined', 'hidden_last', - 'visible_required', 'visible_default', + expect( + (await extract(source, true)).filter((symbol) => symbol.parent === 'query.Contract').map((symbol) => symbol.name), + ).toEqual([ + 'hidden_required', + 'hidden_default', + 'hidden_combined', + 'hidden_last', + 'visible_required', + 'visible_default', ]) }) @@ -150,10 +165,12 @@ describe('Rust trait member visibility', () => { expect(params('typed')).toEqual([{ name: 'self', type: 'Box', default: undefined }]) expect(params('consume')).toEqual([{ name: 'mut self' }]) expect(params('borrow')).toEqual([{ name: '&mut self' }]) - expect(symbols.find((symbol) => symbol.name === 'exactly_one')?.signatures[0].returns) - .toBe('Result') - expect(symbols.find((symbol) => symbol.name === 'one_or_none')?.signatures[0].returns) - .toBe('Result, MultipleItemsError>') + expect(symbols.find((symbol) => symbol.name === 'exactly_one')?.signatures[0].returns).toBe( + 'Result', + ) + expect(symbols.find((symbol) => symbol.name === 'one_or_none')?.signatures[0].returns).toBe( + 'Result, MultipleItemsError>', + ) }) it('keeps fully qualified trait method identities and public links distinct', async () => { @@ -165,36 +182,46 @@ describe('Rust trait member visibility', () => { }) const model = await extractProject({ port: 'rs', root, revision: 'fixture-revision' }) expect(model.revision).toBe('fixture-revision') - const hrefFor = (symbol: typeof model.symbols[number]) => + const hrefFor = (symbol: (typeof model.symbols)[number]) => `/reference/${pageSlug(symbol.publicId ?? symbol.id)}/#${symbol.publicId}` const resolver = new Resolver([model]) const index = new SymbolIndex(model.symbols, hrefFor, 'rs') const parents = parentInventory(model, hrefFor) - const inventory = readInventory(writeInventory(model, { - project: 'rs', version: 'test', uriFor: hrefFor, - })) + const inventory = readInventory( + writeInventory(model, { + project: 'rs', + version: 'test', + uriFor: hrefFor, + }), + ) for (const id of [ - 'query.QueryIteratorExt.matching', 'query.QueryIteratorExt.exactly_one', + 'query.QueryIteratorExt.matching', + 'query.QueryIteratorExt.exactly_one', 'other.QueryIteratorExt.matching', ]) { const symbol = model.symbols.find((candidate) => candidate.id === id) expect(symbol).toMatchObject({ - id, publicId: id, parent: id.slice(0, id.lastIndexOf('.')), kind: 'method', + id, + publicId: id, + parent: id.slice(0, id.lastIndexOf('.')), + kind: 'method', }) expect(resolver.resolve('rs', id.replaceAll('.', '::'))?.symbol.id).toBe(id) expect(index.resolve(id)?.symbol?.id).toBe(id) - expect(index.linkText('[`crate::' + id.replaceAll('.', '::') + '`]')[0].link?.href) - .toBe(hrefFor(symbol!)) + expect(index.linkText('[`crate::' + id.replaceAll('.', '::') + '`]')[0].link?.href).toBe(hrefFor(symbol!)) expect(parents.find((entry) => entry.name === id)?.uri).toBe(hrefFor(symbol!).slice(1)) expect(inventory.entries.find((entry) => entry.name === id)?.uri).toBe(hrefFor(symbol!)) } expect(resolver.resolve('rs', 'Server')?.symbol.id).toBe('server.Server') expect(index.resolve('Server', 'class')?.symbol?.id).toBe('server.Server') - expect(parents.find((entry) => entry.name === 'Server')?.uri) - .toBe('reference/server-server/#server.Server') - expect(model.symbols.find((symbol) => symbol.id === 'server.Server.builder')) - .toMatchObject({ parent: 'server.Server', kind: 'method' }) - expect(model.symbols.find((symbol) => symbol.id === 'options.OptionScope.Server')) - .toMatchObject({ parent: 'options.OptionScope', kind: 'constant' }) + expect(parents.find((entry) => entry.name === 'Server')?.uri).toBe('reference/server-server/#server.Server') + expect(model.symbols.find((symbol) => symbol.id === 'server.Server.builder')).toMatchObject({ + parent: 'server.Server', + kind: 'method', + }) + expect(model.symbols.find((symbol) => symbol.id === 'options.OptionScope.Server')).toMatchObject({ + parent: 'options.OptionScope', + kind: 'constant', + }) }) }) diff --git a/packages/api-model/test/source-lines.test.ts b/packages/api-model/test/source-lines.test.ts index e830e85eb..a2c258733 100644 --- a/packages/api-model/test/source-lines.test.ts +++ b/packages/api-model/test/source-lines.test.ts @@ -8,9 +8,7 @@ import { mapLine, parseHunks } from '../src/source-lines.ts' */ describe('parseHunks', () => { it('reads an omitted count as one line', () => { - expect(parseHunks('@@ -3 +3 @@\n-old\n+new\n')).toEqual([ - { oldStart: 3, oldLines: 1, newStart: 3, newLines: 1 }, - ]) + expect(parseHunks('@@ -3 +3 @@\n-old\n+new\n')).toEqual([{ oldStart: 3, oldLines: 1, newStart: 3, newLines: 1 }]) }) it('reads several hunks in one diff', () => { diff --git a/packages/api-model/test/symbolgraph.test.ts b/packages/api-model/test/symbolgraph.test.ts index 3f84a9248..9fcc00d41 100644 --- a/packages/api-model/test/symbolgraph.test.ts +++ b/packages/api-model/test/symbolgraph.test.ts @@ -57,41 +57,66 @@ function graph(conformances: [string, string?][]): string[] { return [file] } -const basesOf = (files: string[]) => - extractSymbolGraph(files).find((s) => s.id === 'Thing')?.extends +const basesOf = (files: string[]) => extractSymbolGraph(files).find((s) => s.id === 'Thing')?.extends const compatibleSelectors = [ - ['LibTmux', 'Server.newSession(named:startDirectory:windowName:width:height:)', - 'Server.newSession(named:startDirectory:windowName:width:height:environment:shell:)'], - ['LibTmux', 'Server.newWindow(in:named:startDirectory:)', - 'Server.newWindow(in:named:startDirectory:at:environment:shell:)'], - ['LibTmux', 'Server.split(_:direction:size:startDirectory:)', - 'Server.split(_:direction:size:startDirectory:environment:shell:)'], - ['LibTmux', 'Server.splitWindow(_:direction:size:startDirectory:)', - 'Server.splitWindow(_:direction:size:startDirectory:environment:shell:)'], - ['TmuxWorkspace', 'PanePlan.init(shellCommands:startDirectory:)', - 'PanePlan.init(shellCommands:startDirectory:focus:environment:shell:sleepBefore:sleepAfter:)'], - ['TmuxWorkspace', 'WindowPlan.init(windowName:startDirectory:layout:panes:)', - 'WindowPlan.init(windowName:startDirectory:layout:panes:windowIndex:focus:environment:windowShell:)'], + [ + 'LibTmux', + 'Server.newSession(named:startDirectory:windowName:width:height:)', + 'Server.newSession(named:startDirectory:windowName:width:height:environment:shell:)', + ], + [ + 'LibTmux', + 'Server.newWindow(in:named:startDirectory:)', + 'Server.newWindow(in:named:startDirectory:at:environment:shell:)', + ], + [ + 'LibTmux', + 'Server.split(_:direction:size:startDirectory:)', + 'Server.split(_:direction:size:startDirectory:environment:shell:)', + ], + [ + 'LibTmux', + 'Server.splitWindow(_:direction:size:startDirectory:)', + 'Server.splitWindow(_:direction:size:startDirectory:environment:shell:)', + ], + [ + 'TmuxWorkspace', + 'PanePlan.init(shellCommands:startDirectory:)', + 'PanePlan.init(shellCommands:startDirectory:focus:environment:shell:sleepBefore:sleepAfter:)', + ], + [ + 'TmuxWorkspace', + 'WindowPlan.init(windowName:startDirectory:layout:panes:)', + 'WindowPlan.init(windowName:startDirectory:layout:panes:windowIndex:focus:environment:windowShell:)', + ], ] as const function selectorGraph(module: string, ids: string[]) { const dir = mkdtempSync(join(tmpdir(), 'symbolgraph-selector-')) dirs.push(dir) const file = join(dir, `${module}.symbols.json`) - writeFileSync(file, JSON.stringify({ - module: { name: module }, - symbols: ids.map((id) => ({ - identifier: { precise: `s:${id}` }, - kind: { identifier: 'swift.method' }, - pathComponents: id.split('.'), - names: { title: id.split('.').at(-1), subHeading: [{ kind: 'text', spelling: `func ${id}` }] }, - functionSignature: { parameters: [{ name: 'environment', declarationFragments: [ - { kind: 'text', spelling: 'environment: [String: String] = [:]' }, - ] }] }, - accessLevel: 'public', - })), - })) + writeFileSync( + file, + JSON.stringify({ + module: { name: module }, + symbols: ids.map((id) => ({ + identifier: { precise: `s:${id}` }, + kind: { identifier: 'swift.method' }, + pathComponents: id.split('.'), + names: { title: id.split('.').at(-1), subHeading: [{ kind: 'text', spelling: `func ${id}` }] }, + functionSignature: { + parameters: [ + { + name: 'environment', + declarationFragments: [{ kind: 'text', spelling: 'environment: [String: String] = [:]' }], + }, + ], + }, + accessLevel: 'public', + })), + }), + ) return extractSymbolGraph([file]) } @@ -99,10 +124,13 @@ describe('compatible Swift selectors', () => { it.each(compatibleSelectors)('keeps %s %s links with the current declaration', (module, previous, current) => { const symbols = selectorGraph(module, [current]) const [symbol] = symbols - expect(symbol).toMatchObject({ id: current, publicId: previous, qualifiedName: current, - name: current.split('.').at(-1), signatures: [{ params: [ - { name: 'environment', type: '[String: String] = [:]' }, - ] }] }) + expect(symbol).toMatchObject({ + id: current, + publicId: previous, + qualifiedName: current, + name: current.split('.').at(-1), + signatures: [{ params: [{ name: 'environment', type: '[String: String] = [:]' }] }], + }) const href = `/reference/${pageSlug(symbol.publicId!)}/#${symbol.publicId}` expect(href).toBe(`/reference/${pageSlug(previous)}/#${previous}`) const index = new SymbolIndex(symbols, () => href, 'swift') @@ -126,9 +154,7 @@ describe('symbol graph conformances', () => { it('names a standard library protocol rather than its USR', () => { // The long mangling form: module `s`, length-prefixed name, `P` for // protocol. `Copyable` is the same shape but is dropped as implicit. - expect( - basesOf(graph([['s:s12IdentifiableP', 'Swift.Identifiable']])), - ).toEqual(['Identifiable']) + expect(basesOf(graph([['s:s12IdentifiableP', 'Swift.Identifiable']]))).toEqual(['Identifiable']) }) it('names one mangled with a standard substitution', () => { @@ -198,14 +224,31 @@ describe('symbol graph source locations', () => { dirs.push(dir) const file = join(dir, 'Thing.symbols.json') const entry = { - identifier: { precise: 'generated-init' }, kind: { identifier: 'swift.init' }, - pathComponents: ['Thing', 'init(from:)'], names: { title: 'init(from:)' }, accessLevel: 'public', + identifier: { precise: 'generated-init' }, + kind: { identifier: 'swift.init' }, + pathComponents: ['Thing', 'init(from:)'], + names: { title: 'init(from:)' }, + accessLevel: 'public', } - const located = { ...entry, identifier: { precise: 'declared-init' }, location: { uri: 'file:///Sources/Thing.swift', position: { line: 12 } } } - writeFileSync(file, JSON.stringify({ - symbols: locatedFirst ? [located, entry] : [entry, located], - relationships: [{ kind: 'memberOf', source: 'generated-init', target: 'Thing', sourceOrigin: { identifier: 'Decodable-init', displayName: 'Decodable.init(from:)' } }], - })) + const located = { + ...entry, + identifier: { precise: 'declared-init' }, + location: { uri: 'file:///Sources/Thing.swift', position: { line: 12 } }, + } + writeFileSync( + file, + JSON.stringify({ + symbols: locatedFirst ? [located, entry] : [entry, located], + relationships: [ + { + kind: 'memberOf', + source: 'generated-init', + target: 'Thing', + sourceOrigin: { identifier: 'Decodable-init', displayName: 'Decodable.init(from:)' }, + }, + ], + }), + ) return file } @@ -230,7 +273,14 @@ describe('symbol graph throwing contracts', () => { const keyword = (spelling: string) => ({ kind: 'keyword', spelling }) const text = (spelling: string) => ({ kind: 'text', spelling }) const type = (spelling: string) => ({ kind: 'typeIdentifier', spelling }) - const head = [keyword('func'), text(' '), { kind: 'identifier', spelling: 'next' }, text('() '), keyword('async'), text(' ')] + const head = [ + keyword('func'), + text(' '), + { kind: 'identifier', spelling: 'next' }, + text('() '), + keyword('async'), + text(' '), + ] const returns = [type('Self'), text('.'), type('Element'), text('?')] const returned = [text(' -> '), ...returns] @@ -238,18 +288,29 @@ describe('symbol graph throwing contracts', () => { const dir = mkdtempSync(join(tmpdir(), 'symbolgraph-throws-')) dirs.push(dir) const file = join(dir, 'Iterator.symbols.json') - writeFileSync(file, JSON.stringify({ symbols: [{ - identifier: { precise: 'iterator-next' }, kind: { identifier: 'swift.method' }, - pathComponents: ['Iterator', 'next()'], accessLevel: 'public', - names: { title: 'next()', subHeading: fragments }, - functionSignature: { parameters: [], returns }, - }] })) + writeFileSync( + file, + JSON.stringify({ + symbols: [ + { + identifier: { precise: 'iterator-next' }, + kind: { identifier: 'swift.method' }, + pathComponents: ['Iterator', 'next()'], + accessLevel: 'public', + names: { title: 'next()', subHeading: fragments }, + functionSignature: { parameters: [], returns }, + }, + ], + }), + ) return extractSymbolGraph([file])[0].signatures[0] } it('records untyped throws without treating the returned type as an error', () => { expect(signature([...head, keyword('throws'), ...returned])).toEqual({ - params: [], returns: 'Self.Element?', raises: [{ type: 'any Error' }], + params: [], + returns: 'Self.Element?', + raises: [{ type: 'any Error' }], }) expect(signature([...head, keyword('throws')]).raises).toEqual([{ type: 'any Error' }]) expect(signature([...head, ...returned]).raises).toBeUndefined() @@ -259,20 +320,35 @@ describe('symbol graph throwing contracts', () => { ['TmuxError', [type('TmuxError')]], ['Self.Failure', [type('Self'), text('.'), type('Failure')]], ['any Error', [keyword('any'), text(' '), type('Error')]], - ['Failures.Box<(Int, String)>', [type('Failures'), text('.'), type('Box'), text('<('), type('Int'), text(', '), type('String'), text(')>')]], + [ + 'Failures.Box<(Int, String)>', + [type('Failures'), text('.'), type('Box'), text('<('), type('Int'), text(', '), type('String'), text(')>')], + ], ] as const)('preserves the complete balanced error type %s', (expected, fragments) => { - expect(signature([...head, keyword('throws'), text('('), ...fragments, text(')'), ...returned]).raises) - .toEqual([{ type: expected }]) + expect(signature([...head, keyword('throws'), text('('), ...fragments, text(')'), ...returned]).raises).toEqual([ + { type: expected }, + ]) }) it('does not attribute a throwing callback or returned function to its enclosing function', () => { - const callback = [keyword('func'), text(' map(('), type('Element'), text(') '), keyword('throws'), text(' -> '), type('Value'), text(') ')] + const callback = [ + keyword('func'), + text(' map(('), + type('Element'), + text(') '), + keyword('throws'), + text(' -> '), + type('Value'), + text(') '), + ] expect(signature([...callback, ...returned]).raises).toBeUndefined() - expect(signature([...callback, keyword('throws'), text('('), type('TmuxError'), text(')'), ...returned]).raises) - .toEqual([{ type: 'TmuxError' }]) + expect( + signature([...callback, keyword('throws'), text('('), type('TmuxError'), text(')'), ...returned]).raises, + ).toEqual([{ type: 'TmuxError' }]) expect(signature([...head, text(' -> () '), keyword('throws'), ...returned]).raises).toBeUndefined() - expect(signature([...callback, keyword('rethrows'), ...returned]).raises) - .toEqual([{ type: 'any Error', doc: 'Conditionally propagates errors (rethrows).' }]) + expect(signature([...callback, keyword('rethrows'), ...returned]).raises).toEqual([ + { type: 'any Error', doc: 'Conditionally propagates errors (rethrows).' }, + ]) }) it('does not invent a throwing contract from empty, incomplete or Never clauses', () => { @@ -283,9 +359,13 @@ describe('symbol graph throwing contracts', () => { it('keeps the integrated iterator and callback wrapper error contracts separate', () => { const model = JSON.parse(readFileSync(new URL('../../../site/src/data/api/swift.json', import.meta.url), 'utf8')) - const iterator = model.symbols.find((symbol: { id: string }) => symbol.id === 'ControlNotificationStream.Iterator.next()') - expect(iterator.signatures.map((signature: { raises: { type: string }[] }) => signature.raises)) - .toEqual([[{ type: 'Self.Failure' }], [{ type: 'TmuxError' }]]) + const iterator = model.symbols.find( + (symbol: { id: string }) => symbol.id === 'ControlNotificationStream.Iterator.next()', + ) + expect(iterator.signatures.map((signature: { raises: { type: string }[] }) => signature.raises)).toEqual([ + [{ type: 'Self.Failure' }], + [{ type: 'TmuxError' }], + ]) const wrapper = model.symbols.find((symbol: { id: string }) => symbol.id === 'withTmuxError(_:)') expect(wrapper.signatures[0].raises).toEqual([{ type: 'TmuxError' }]) }) diff --git a/packages/api-model/test/typescript.test.ts b/packages/api-model/test/typescript.test.ts index 2cdd52ef0..532f3619f 100644 --- a/packages/api-model/test/typescript.test.ts +++ b/packages/api-model/test/typescript.test.ts @@ -6,7 +6,9 @@ import { extractWithSpec } from '../src/languages/spec.ts' import { TYPESCRIPT } from '../src/languages/specs.ts' const dirs: string[] = [] -afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) async function extract(source: string) { const dir = mkdtempSync(join(tmpdir(), 'typescript-api-')) @@ -36,7 +38,9 @@ describe('TypeScript signatures and parameter documentation', () => { expect(signature.params[0]).toMatchObject({ name: 'tmux', type: 'Server' }) const options = signature.params[1] expect(options.default).toBe('{}') - expect(options.type).toBe(`{\n readonly callerEnvironment?: Readonly>;\n readonly environment?: Readonly>;\n readonly policy?: Policy;\n readonly startup?: ServerStartup;\n}`) + expect(options.type).toBe( + `{\n readonly callerEnvironment?: Readonly>;\n readonly environment?: Readonly>;\n readonly policy?: Policy;\n readonly startup?: ServerStartup;\n}`, + ) expect(options.doc).toContain('Controls the server.') expect(options.doc).toContain('- `callerEnvironment`: The host pane environment. See {@link Server}.') expect(options.doc).toContain('- `environment`: Policy input.') @@ -58,11 +62,17 @@ describe('TypeScript signatures and parameter documentation', () => { `) expect(symbols.find((symbol) => symbol.name === 'mode')?.type).toBe('"// literal spaces"') const signature = symbols.find((symbol) => symbol.name === 'open')!.signatures[0] - expect(signature.params[0]).toMatchObject({ name: 'options', optional: true, doc: '- `transport.url`: Remote address.' }) + expect(signature.params[0]).toMatchObject({ + name: 'options', + optional: true, + doc: '- `transport.url`: Remote address.', + }) expect(signature.params[0].type).toContain('"https://example.test/*value*/"') expect(signature.params[1]).toMatchObject({ name: 'keys', variadic: 'positional', type: 'string[]' }) expect(signature.returns).toBe('"/* literal */"') expect(symbols.find((symbol) => symbol.name === 'isText')!.signatures[0].returns).toBe('value is string') - expect(symbols.find((symbol) => symbol.name === 'assertText')!.signatures[0].returns).toBe('asserts value is string') + expect(symbols.find((symbol) => symbol.name === 'assertText')!.signatures[0].returns).toBe( + 'asserts value is string', + ) }) }) diff --git a/packages/theme/package.json b/packages/theme/package.json index f97c1836c..b7a2f77e8 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -15,7 +15,6 @@ "test:watch": "vitest watch", "test:types": "vitest run --typecheck", "lint": "oxlint --type-aware --tsconfig tsconfig.json --ignore-pattern '**/*.test.ts' --ignore-pattern '**/*.test-d.ts' src", - "format": "pnpm biome check . --write; biome format . --write", "update": "pnpm update", "ncu": "ncu", "clean": "rm -rf dist/ node_modules/.cache/", @@ -27,7 +26,6 @@ "tailwindcss": "^4.0.0" }, "devDependencies": { - "@biomejs/biome": "catalog:", "@types/chroma-js": "^3.1.2", "memfs": "^4.80.0", "oxlint": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df19eefce..86f92f485 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,15 +164,15 @@ settings: catalogs: default: - '@biomejs/biome': - specifier: 2.5.14 - version: 2.5.14 entities: specifier: 7.0.1 version: 7.0.1 happy-dom: specifier: ^20.14.5 version: 20.14.5 + oxfmt: + specifier: 0.70.0 + version: 0.70.0 oxlint: specifier: ^1.86.0 version: 1.86.0 @@ -202,9 +202,6 @@ importers: .: devDependencies: - '@biomejs/biome': - specifier: 'catalog:' - version: 2.5.14 entities: specifier: 'catalog:' version: 7.0.1 @@ -217,6 +214,9 @@ importers: mdast-util-to-markdown: specifier: 2.1.3 version: 2.1.3 + oxfmt: + specifier: 'catalog:' + version: 0.70.0 oxlint: specifier: 'catalog:' version: 1.86.0(oxlint-tsgolint@7.0.2003) @@ -270,9 +270,6 @@ importers: specifier: ^4.6.5 version: 4.6.5 devDependencies: - '@biomejs/biome': - specifier: 'catalog:' - version: 2.5.14 '@types/chroma-js': specifier: ^3.1.2 version: 3.1.2 @@ -547,63 +544,6 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.14': - resolution: {integrity: sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@2.5.14': - resolution: {integrity: sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@2.5.14': - resolution: {integrity: sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@2.5.14': - resolution: {integrity: sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-arm64@2.5.14': - resolution: {integrity: sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-linux-x64-musl@2.5.14': - resolution: {integrity: sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-x64@2.5.14': - resolution: {integrity: sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-win32-arm64@2.5.14': - resolution: {integrity: sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@2.5.14': - resolution: {integrity: sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - '@bruits/satteri-darwin-arm64@0.10.5': resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} cpu: [arm64] @@ -1196,6 +1136,128 @@ packages: '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + '@oxfmt/binding-android-arm-eabi@0.70.0': + resolution: {integrity: sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.70.0': + resolution: {integrity: sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.70.0': + resolution: {integrity: sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.70.0': + resolution: {integrity: sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.70.0': + resolution: {integrity: sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + resolution: {integrity: sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + resolution: {integrity: sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + resolution: {integrity: sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.70.0': + resolution: {integrity: sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + resolution: {integrity: sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + resolution: {integrity: sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + resolution: {integrity: sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + resolution: {integrity: sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.70.0': + resolution: {integrity: sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.70.0': + resolution: {integrity: sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.70.0': + resolution: {integrity: sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + resolution: {integrity: sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + resolution: {integrity: sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.70.0': + resolution: {integrity: sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint-tsgolint/darwin-arm64@7.0.2003': resolution: {integrity: sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==} cpu: [arm64] @@ -2657,6 +2719,19 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + oxfmt@0.70.0: + resolution: {integrity: sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + oxlint-tsgolint@7.0.2003: resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} hasBin: true @@ -2990,6 +3065,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} + engines: {node: ^20.0.0 || >=22.0.0} + tree-dump@1.1.0: resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} engines: {node: '>=10.0'} @@ -3626,41 +3705,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@biomejs/biome@2.5.14': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.14 - '@biomejs/cli-darwin-x64': 2.5.14 - '@biomejs/cli-linux-arm64': 2.5.14 - '@biomejs/cli-linux-arm64-musl': 2.5.14 - '@biomejs/cli-linux-x64': 2.5.14 - '@biomejs/cli-linux-x64-musl': 2.5.14 - '@biomejs/cli-win32-arm64': 2.5.14 - '@biomejs/cli-win32-x64': 2.5.14 - - '@biomejs/cli-darwin-arm64@2.5.14': - optional: true - - '@biomejs/cli-darwin-x64@2.5.14': - optional: true - - '@biomejs/cli-linux-arm64-musl@2.5.14': - optional: true - - '@biomejs/cli-linux-arm64@2.5.14': - optional: true - - '@biomejs/cli-linux-x64-musl@2.5.14': - optional: true - - '@biomejs/cli-linux-x64@2.5.14': - optional: true - - '@biomejs/cli-win32-arm64@2.5.14': - optional: true - - '@biomejs/cli-win32-x64@2.5.14': - optional: true - '@bruits/satteri-darwin-arm64@0.10.5': optional: true @@ -4170,6 +4214,63 @@ snapshots: '@oxc-project/types@0.151.0': {} + '@oxfmt/binding-android-arm-eabi@0.70.0': + optional: true + + '@oxfmt/binding-android-arm64@0.70.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.70.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.70.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.70.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.70.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.70.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.70.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.70.0': + optional: true + '@oxlint-tsgolint/darwin-arm64@7.0.2003': optional: true @@ -5896,6 +5997,30 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + oxfmt@0.70.0: + dependencies: + tinypool: 2.1.2 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.70.0 + '@oxfmt/binding-android-arm64': 0.70.0 + '@oxfmt/binding-darwin-arm64': 0.70.0 + '@oxfmt/binding-darwin-x64': 0.70.0 + '@oxfmt/binding-freebsd-x64': 0.70.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.70.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.70.0 + '@oxfmt/binding-linux-arm64-gnu': 0.70.0 + '@oxfmt/binding-linux-arm64-musl': 0.70.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-musl': 0.70.0 + '@oxfmt/binding-linux-s390x-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-musl': 0.70.0 + '@oxfmt/binding-openharmony-arm64': 0.70.0 + '@oxfmt/binding-win32-arm64-msvc': 0.70.0 + '@oxfmt/binding-win32-ia32-msvc': 0.70.0 + '@oxfmt/binding-win32-x64-msvc': 0.70.0 + oxlint-tsgolint@7.0.2003: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2003 @@ -6364,6 +6489,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinypool@2.1.2: {} + tree-dump@1.1.0(tslib@2.8.1): dependencies: tslib: 2.8.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c3e20822..9d18936f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,9 +2,9 @@ packages: - packages/* - site catalog: - '@biomejs/biome': 2.5.14 entities: 7.0.1 happy-dom: ^20.14.5 + oxfmt: 0.70.0 oxlint: ^1.86.0 tree-sitter-wasms: ^0.1.13 # Pinned exactly, not caret: the prebuilt grammars are ABI 13-14 and runtime diff --git a/scripts/add-intersphinx.mjs b/scripts/add-intersphinx.mjs index e1948fa57..962f972cf 100755 --- a/scripts/add-intersphinx.mjs +++ b/scripts/add-intersphinx.mjs @@ -50,7 +50,11 @@ for (const port of PORTS) { for (const dir of referenceDirs(siteDir, port)) { const inv = join(dir, 'objects.inv') if (!existsSync(inv)) continue - entries.push([`libtmux-${port}`, `${baseUrl.replace(/\/*$/, '')}/${dir.slice(siteDir.length).replace(/^\/+/, '')}/`, inv]) + entries.push([ + `libtmux-${port}`, + `${baseUrl.replace(/\/*$/, '')}/${dir.slice(siteDir.length).replace(/^\/+/, '')}/`, + inv, + ]) break } } diff --git a/scripts/audit-site.mjs b/scripts/audit-site.mjs index 3a17a6931..d9bd3877c 100644 --- a/scripts/audit-site.mjs +++ b/scripts/audit-site.mjs @@ -1,34 +1,50 @@ import { readdirSync, statSync, readFileSync } from 'node:fs' import { join, relative } from 'node:path' -const ROOT='/home/d/work/libtmux/docs/_site' -const pages=[] -;(function w(d){for(const e of readdirSync(d)){const p=join(d,e) - if(statSync(p).isDirectory()){if(!/^(_static|_sources|_images|pagefind|\.build-logs)$/.test(e))w(p)} - else if(e==='index.html')pages.push(p)}})(ROOT) -function content(h){ - const m=h.match(/]*role="main"[^>]*>([\s\S]*?)<\/article>/)||h.match(/]*>([\s\S]*?)<\/main>/) - let b=m?m[1]:h - b=b.replace(/<(script|style|nav|aside|footer|header)[^>]*>[\s\S]*?<\/\1>/g,'') - return b.replace(/<[^>]+>/g,' ').replace(/&[a-z]+;|&#\d+;/g,' ').replace(/\s+/g,' ').trim() +const ROOT = '/home/d/work/libtmux/docs/_site' +const pages = [] +;(function w(d) { + for (const e of readdirSync(d)) { + const p = join(d, e) + if (statSync(p).isDirectory()) { + if (!/^(_static|_sources|_images|pagefind|\.build-logs)$/.test(e)) w(p) + } else if (e === 'index.html') pages.push(p) + } +})(ROOT) +function content(h) { + const m = h.match(/]*role="main"[^>]*>([\s\S]*?)<\/article>/) || h.match(/]*>([\s\S]*?)<\/main>/) + let b = m ? m[1] : h + b = b.replace(/<(script|style|nav|aside|footer|header)[^>]*>[\s\S]*?<\/\1>/g, '') + return b + .replace(/<[^>]+>/g, ' ') + .replace(/&[a-z]+;|&#\d+;/g, ' ') + .replace(/\s+/g, ' ') + .trim() } -const rows=[]; let redirects=0 -for(const f of pages){ - const url='/'+relative(ROOT,f).replace(/index\.html$/,'') - const html=readFileSync(f,'utf8') - if(/You should have been redirected/.test(html)){redirects++;continue} - const text=content(html); const flags=[] - if(text.length<40)flags.push('EMPTY') - else if(text.length<250)flags.push('THIN') +const rows = [] +let redirects = 0 +for (const f of pages) { + const url = '/' + relative(ROOT, f).replace(/index\.html$/, '') + const html = readFileSync(f, 'utf8') + if (/You should have been redirected/.test(html)) { + redirects++ + continue + } + const text = content(html) + const flags = [] + if (text.length < 40) flags.push('EMPTY') + else if (text.length < 250) flags.push('THIN') // real rendered admonitions only, not identifiers containing "Error" - if(/class="admonition warning"|class="admonition error"/.test(html))flags.push('ADMONITION') - if(/Found multiple matches for file/.test(html))flags.push('breathe-ambiguous') - if(/Unable to resolve|Cannot find file|not found in doxygen/.test(html))flags.push('breathe-unresolved') - if(/\bTODO\b|\bFIXME\b|PLACEHOLDER/.test(text))flags.push('placeholder') - if(flags.length)rows.push({url,len:text.length,flags}) + if (/class="admonition warning"|class="admonition error"/.test(html)) flags.push('ADMONITION') + if (/Found multiple matches for file/.test(html)) flags.push('breathe-ambiguous') + if (/Unable to resolve|Cannot find file|not found in doxygen/.test(html)) flags.push('breathe-unresolved') + if (/\bTODO\b|\bFIXME\b|PLACEHOLDER/.test(text)) flags.push('placeholder') + if (flags.length) rows.push({ url, len: text.length, flags }) } -rows.sort((a,b)=>a.len-b.len) +rows.sort((a, b) => a.len - b.len) console.log(`pages ${pages.length} redirects ${redirects} flagged ${rows.length}\n`) -const by={};for(const r of rows)for(const f of r.flags)by[f]=(by[f]||0)+1 -for(const[k,v]of Object.entries(by).sort((a,b)=>b[1]-a[1]))console.log(` ${String(v).padStart(4)} ${k}`) +const by = {} +for (const r of rows) for (const f of r.flags) by[f] = (by[f] || 0) + 1 +for (const [k, v] of Object.entries(by).sort((a, b) => b[1] - a[1])) console.log(` ${String(v).padStart(4)} ${k}`) console.log('\n-- non-THIN-only findings --') -for(const r of rows.filter(r=>r.flags.some(f=>f!=='THIN')))console.log(` ${String(r.len).padStart(6)} ${r.flags.join(',').padEnd(28)} ${r.url}`) +for (const r of rows.filter((r) => r.flags.some((f) => f !== 'THIN'))) + console.log(` ${String(r.len).padStart(6)} ${r.flags.join(',').padEnd(28)} ${r.url}`) diff --git a/scripts/brand-native-pages.mjs b/scripts/brand-native-pages.mjs index 7f497e971..e19ff33d1 100644 --- a/scripts/brand-native-pages.mjs +++ b/scripts/brand-native-pages.mjs @@ -5,7 +5,8 @@ import { fileURLToPath } from 'node:url' import { branding, documentationTitle, jsonLd } from '../site/src/lib/branding.ts' import { PORT_BY_SLUG } from '../site/src/lib/ports.ts' -const escape = (text) => String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) +const escape = (text) => + String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) /** Add crawler-visible artwork while preserving native canonical/robots policy. */ export function nativeBrandHead(html, { port, pagePath, root = '/en' }) { @@ -16,7 +17,10 @@ export function nativeBrandHead(html, { port, pagePath, root = '/en' }) { const twitter = new URL(brand.asset('twitter-light.png'), origin).href // Native generators already escape title text. Retain those entities when // adding the project suffix, including in the social-title attributes. - const title = documentationTitle(/]*>([\s\S]*?)<\/title>/i.exec(html)?.[1].trim() || brand.projectName, brand.projectName) + const title = documentationTitle( + /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1].trim() || brand.projectName, + brand.projectName, + ) const titleAttribute = title.replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>') const attr = (text) => escape(text) const tags = [ @@ -42,7 +46,8 @@ export function nativeBrandHead(html, { port, pagePath, root = '/en' }) { ``, ``, ``, ].join('\n') - const branded = html.replace(/]*)>/i, (_match, attributes) => - ``) + const branded = html.replace( + /]*)>/i, + (_match, attributes) => + ``, + ) const withLogo = branded.replace(/]*>/gi, (tag) => { const classes = /\bclass=["']([^"']*)["']/i.exec(tag)?.[1].split(/\s+/) ?? [] if (!classes.includes('sidebar-logo')) return tag - return tag.replace(/\s(?:src|srcset)=["'][^"']*["']/gi, '') + return tag + .replace(/\s(?:src|srcset)=["'][^"']*["']/gi, '') .replace(/\s*\/?>$/, ` src="${attr(brand.asset('logo.svg'))}">`) }) return withLogo.replace(/(]*>)([\s\S]*?)(<\/head>)/i, (_match, open, head, close) => { const clean = head .replace(/]*>[\s\S]*?<\/title>/gi, '') .replace(/]*\brel=["'](?:shortcut icon|icon|apple-touch-icon|mask-icon|manifest)["'][^>]*>/gi, '') - .replace(/]*\b(?:name|property)=["'](?:og:title|og:site_name|twitter:title|og:image(?::[a-z_]+)?|twitter:image(?::alt)?|twitter:card|theme-color|msapplication-config)["'][^>]*>/gi, '') + .replace( + /]*\b(?:name|property)=["'](?:og:title|og:site_name|twitter:title|og:image(?::[a-z_]+)?|twitter:image(?::alt)?|twitter:card|theme-color|msapplication-config)["'][^>]*>/gi, + '', + ) .replace(/]*\bdata-libtmux-brand[^>]*>[\s\S]*?<\/script>/gi, '') return `${open}${clean.trimEnd()}\n${tags}\n${close}` }) @@ -77,7 +89,10 @@ export function brandNativePages(directory, port, root) { else if (entry.name.endsWith('.html')) { const before = readFileSync(path, 'utf8') const after = nativeBrandHead(before, { port, root, pagePath: relative(directory, path).replaceAll('\\', '/') }) - if (before !== after) { writeFileSync(path, after); count++ } + if (before !== after) { + writeFileSync(path, after) + count++ + } } } } diff --git a/scripts/check-api-fidelity.mjs b/scripts/check-api-fidelity.mjs index 959daab81..ee095a0db 100755 --- a/scripts/check-api-fidelity.mjs +++ b/scripts/check-api-fidelity.mjs @@ -73,9 +73,7 @@ function entriesIn(html) { } const raw = html.slice(i, end) if (raw.includes('gp-sphinx-api-header')) { - out.push( - Object.fromEntries([...raw.matchAll(/([a-z-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]])), - ) + out.push(Object.fromEntries([...raw.matchAll(/([a-z-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]))) } i = end } @@ -84,16 +82,23 @@ function entriesIn(html) { const failures = [] const summary = [] -const decode = (value) => value?.replace(/&(amp|lt|gt|quot|#39);/g, (_, entity) => - ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[entity]) +const decode = (value) => + value?.replace( + /&(amp|lt|gt|quot|#39);/g, + (_, entity) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[entity], + ) for (const port of PORTS) { const model = JSON.parse(readFileSync(join(repoRoot, `site/src/data/api/${port}.json`), 'utf8')) - const symbols = new Map(model.symbols.flatMap((symbol) => [ - [symbol.id, symbol], [symbol.publicId ?? symbol.id, symbol], - ])) - const pages = referenceDirs(root, port, { products: true }) - .flatMap((dir) => globSync('**/index.html', { cwd: dir }).map((page) => join(dir.slice(root.length + 1), page))) + const symbols = new Map( + model.symbols.flatMap((symbol) => [ + [symbol.id, symbol], + [symbol.publicId ?? symbol.id, symbol], + ]), + ) + const pages = referenceDirs(root, port, { products: true }).flatMap((dir) => + globSync('**/index.html', { cwd: dir }).map((page) => join(dir.slice(root.length + 1), page)), + ) if (!pages.length) { failures.push(`${port}: no reference pages built`) continue diff --git a/scripts/check-api-links.mjs b/scripts/check-api-links.mjs index 8269bed2f..3db514e60 100755 --- a/scripts/check-api-links.mjs +++ b/scripts/check-api-links.mjs @@ -19,13 +19,26 @@ import { existsSync, globSync, readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { Resolver, parentInventory, decideFilePath, decideMention, isLikelyReference, looksLikeApiMention, notASymbol, notApiReason, proseMentions, readInventory } from '../packages/api-model/src/index.ts' +import { + Resolver, + parentInventory, + decideFilePath, + decideMention, + isLikelyReference, + looksLikeApiMention, + notASymbol, + notApiReason, + proseMentions, + readInventory, +} from '../packages/api-model/src/index.ts' import { decideMcpMention } from '../site/src/lib/mcp-catalog.ts' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const { API_MODEL_PORTS: PORT_DEFS, PORT_BY_SLUG } = await import(`file://${resolve(root, 'site/src/lib/ports.ts')}`) const PORTS = PORT_DEFS.map((p) => p.slug) -const { KNOWN_PORTS, resolvePortBody, resolvePortContent } = await import(`file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}`) +const { KNOWN_PORTS, resolvePortBody, resolvePortContent } = await import( + `file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}` +) const SHARED = join(root, 'site/src/content/_workspace-shared') const CONTENT = join(root, 'site/src/content/docs') const started = Date.now() @@ -42,12 +55,19 @@ if (!Object.keys(models).length) { const resolver = new Resolver(Object.values(models)) for (const port of PORTS) { const identity = PORT_BY_SLUG[port] - const dependencies = [...(identity.parentLibrary ? [identity.parentLibrary.slug] : []), ...(identity.apiDependencies ?? [])] - for (const dependency of dependencies) resolver.addInventory('Library API', '', parentInventory(models[dependency], - (symbol) => `/reference/${dependency}/${symbol.slug}/`), [port]) + const dependencies = [ + ...(identity.parentLibrary ? [identity.parentLibrary.slug] : []), + ...(identity.apiDependencies ?? []), + ] + for (const dependency of dependencies) + resolver.addInventory( + 'Library API', + '', + parentInventory(models[dependency], (symbol) => `/reference/${dependency}/${symbol.slug}/`), + [port], + ) } - /* * The same federated inventories the linker loads, scoped the same way. * @@ -58,7 +78,12 @@ for (const port of PORTS) { */ const INVENTORIES = [ { file: 'python.inv', project: 'Python', baseUrl: 'https://docs.python.org/3/', langs: ['py'] }, - { file: 'jdk.inv', project: 'Java SE', baseUrl: 'https://docs.oracle.com/en/java/javase/21/docs/api/', langs: ['java', 'kotlin', 'scala'] }, + { + file: 'jdk.inv', + project: 'Java SE', + baseUrl: 'https://docs.oracle.com/en/java/javase/21/docs/api/', + langs: ['java', 'kotlin', 'scala'], + }, { file: 'dom.inv', project: 'MDN', baseUrl: 'https://developer.mozilla.org/', langs: ['ts'] }, { file: 'julia.inv', project: 'Julia', baseUrl: 'https://docs.julialang.org/en/v1/', langs: ['julia'] }, ] @@ -150,7 +175,9 @@ const sharedRaw = files.length ? new Map() : sharedWorkspaceRaw() const targets = files.length ? files.map((f) => resolve(f)) : [ - ...globSync('{topics,guides,concepts,examples,ports}/**/*.{md,mdx}', { cwd: CONTENT }).map((f) => join(CONTENT, f)), + ...globSync('{topics,guides,concepts,examples,ports}/**/*.{md,mdx}', { cwd: CONTENT }).map((f) => + join(CONTENT, f), + ), ...sharedRaw.keys(), ] @@ -161,22 +188,34 @@ const missingFiles = [] for (const file of targets) { const raw = sharedRaw.get(file) ?? readFileSync(file, 'utf8') const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1] ?? '' - const authoredPort = /^port:\s*['"]?([a-z]+)['"]?\s*$/m.exec(frontmatter)?.[1] - ?? /^ports\/([^/]+)\//.exec(file.replace(`${CONTENT}/`, ''))?.[1] + const authoredPort = + /^port:\s*['"]?([a-z]+)['"]?\s*$/m.exec(frontmatter)?.[1] ?? + /^ports\/([^/]+)\//.exec(file.replace(`${CONTENT}/`, ''))?.[1] // Match rehype-api-links: native wrapper guides have no shared API model. if (PORT_BY_SLUG[authoredPort]?.referenceKind === 'guide') continue - const product = /^product:\s*['"]?(core|workspace|mcp)['"]?\s*$/m.exec(frontmatter)?.[1] - ?? /^ports\/[^/]+\/(workspace|mcp)\//.exec(file.replace(`${CONTENT}/`, ''))?.[1] + const product = + /^product:\s*['"]?(core|workspace|mcp)['"]?\s*$/m.exec(frontmatter)?.[1] ?? + /^ports\/[^/]+\/(workspace|mcp)\//.exec(file.replace(`${CONTENT}/`, ''))?.[1] const selected = resolvePortContent(raw, authoredPort) - for (const { text, port: ctxPort, before, line, linked } of proseMentions(selected.body, PORT_BY_LABEL, selected.portAt)) { - if (linked) { tally.alreadyLinked++; continue } + for (const { text, port: ctxPort, before, line, linked } of proseMentions( + selected.body, + PORT_BY_LABEL, + selected.portAt, + )) { + if (linked) { + tally.alreadyLinked++ + continue + } const pagePort = authoredPort ?? ctxPort const protocol = decideMcpMention(text, { pagePort, product, before }) if (protocol) { if (protocol.kind === 'link') tally.willLink++ else if (protocol.kind === 'skip') tally.notASymbol++ - else { tally.unresolved++; unresolved.push({ file, line, text, why: protocol.why }) } + else { + tally.unresolved++ + unresolved.push({ file, line, text, why: protocol.why }) + } continue } @@ -184,25 +223,40 @@ for (const file of targets) { const d = decideFilePath(text, { before, pagePort }, trees) if (d.kind === 'link') tally.file++ else if (d.kind === 'skip') tally.notASymbol++ - else { tally.fileMissing++; missingFiles.push({ file, line, text, why: d.why }) } + else { + tally.fileMissing++ + missingFiles.push({ file, line, text, why: d.why }) + } continue } // The same two filters the linker applies, in the same order. This lint // exists to agree with the build; applying one fewer of them made it // report 124 where the build reported 61, which is the disagreement it // was written to prevent. - if (notASymbol(text) || !looksLikeApiMention(text)) { tally.notASymbol++; continue } - + if (notASymbol(text) || !looksLikeApiMention(text)) { + tally.notASymbol++ + continue + } - const decisions = (pagePort ? [pagePort] : PORTS) - .map((port) => decideMention(text, { pagePort: port, product, before }, resolver, models)) + const decisions = (pagePort ? [pagePort] : PORTS).map((port) => + decideMention(text, { pagePort: port, product, before }, resolver, models), + ) // `notApiReason` gates *reporting*, not linking — exactly as the plugin // does. A span it names still gets offered to the resolver, because a // `TMUX_TMPDIR` that happens to resolve is a link worth having; it simply // is not a dangling reference when it does not. if (decisions.some((decision) => decision.kind === 'link')) tally.willLink++ - else if (decisions.some((decision) => decision.kind === 'skip') || !isLikelyReference(text) || notApiReason(text) || EXCEPTIONS.has(text)) tally.notASymbol++ - else { tally.unresolved++; unresolved.push({ file, line, text }) } + else if ( + decisions.some((decision) => decision.kind === 'skip') || + !isLikelyReference(text) || + notApiReason(text) || + EXCEPTIONS.has(text) + ) + tally.notASymbol++ + else { + tally.unresolved++ + unresolved.push({ file, line, text }) + } } } diff --git a/scripts/check-canonicals.mjs b/scripts/check-canonicals.mjs index cd1dbbe01..d7b94e119 100644 --- a/scripts/check-canonicals.mjs +++ b/scripts/check-canonicals.mjs @@ -24,10 +24,11 @@ import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { referenceDirs } from './reference-trees.mjs' -const { PORTS: PORT_DEFS } = await import(`file://${join(dirname(fileURLToPath(import.meta.url)), '../site/src/lib/ports.ts')}`) +const { PORTS: PORT_DEFS } = await import( + `file://${join(dirname(fileURLToPath(import.meta.url)), '../site/src/lib/ports.ts')}` +) const PORTS = PORT_DEFS.map((p) => p.slug) - const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))) const defaultSite = join(repoRoot, '_site') const siteDir = process.argv.slice(2).find((a) => !a.startsWith('--')) ?? defaultSite @@ -61,10 +62,12 @@ if (siteDir === defaultSite && existsSync(lock)) { * publishing one prefix is its own default, and Python's two make `stable` * the canonical one. */ -const DEFAULTS = Object.fromEntries(PORTS.map((port) => { - const built = referenceDirs(siteDir, port).map((dir) => dir.split('/').at(-2)) - return [port, built.includes('stable') ? 'stable' : built[0]] -}).filter(([, version]) => version)) +const DEFAULTS = Object.fromEntries( + PORTS.map((port) => { + const built = referenceDirs(siteDir, port).map((dir) => dir.split('/').at(-2)) + return [port, built.includes('stable') ? 'stable' : built[0]] + }).filter(([, version]) => version), +) const roots = PORTS.flatMap((port) => referenceDirs(siteDir, port, { products: true })) if (!roots.length) { @@ -111,8 +114,9 @@ for (const file of pages) { const refresh = html.match(REFRESH) // Contextual glossaries render the shared definitions inside each sidebar. // Only these exact routes may use the existing shared glossary canonical. - const contextualGlossary = ['', 'workspace/', 'mcp/'].some((product) => - own === `/${port}/${version}/${product}reference/glossary/`) + const contextualGlossary = ['', 'workspace/', 'mcp/'].some( + (product) => own === `/${port}/${version}/${product}reference/glossary/`, + ) if (contextualGlossary && declared === '/reference/glossary/') { checked += 1 if (!glossaryCanonical || glossaryCanonical !== found[1] || refresh || REFRESH.test(glossaryHtml)) { @@ -129,19 +133,25 @@ for (const file of pages) { const targetFile = join(siteDir, declared, 'index.html') const target = existsSync(targetFile) ? readFileSync(targetFile, 'utf8') : '' const targetCanonical = target.match(CANONICAL) - const valid = / { for (const variant of ['valid', 'missing', 'foreign', 'origin', 'fallback', 'indexable', 'chain']) { const dir = site((p) => p) const old = '/ts/latest/reference/old-pane/' - const destination = variant === 'missing' ? '/ts/latest/reference/missing/' - : variant === 'foreign' ? '/py/latest/reference/pane/' : '/ts/latest/reference/pane/' + const destination = + variant === 'missing' + ? '/ts/latest/reference/missing/' + : variant === 'foreign' + ? '/py/latest/reference/pane/' + : '/ts/latest/reference/pane/' const href = variant === 'origin' ? `https://example.invalid${destination}` : destination mkdirSync(join(dir, old), { recursive: true }) - writeFileSync(join(dir, old, 'index.html'), ` + writeFileSync( + join(dir, old, 'index.html'), + ` ${variant === 'indexable' ? '' : ''} - Continue`) + Continue`, + ) if (variant === 'chain') { - writeFileSync(join(dir, destination, 'index.html'), ` + writeFileSync( + join(dir, destination, 'index.html'), + ` - Continue`) + Continue`, + ) } const { code, out } = run(dir) - check(`reference redirect ${variant}`, variant === 'valid' ? code === 0 : code !== 0, - `exited ${code}:\n${out}`) + check(`reference redirect ${variant}`, variant === 'valid' ? code === 0 : code !== 0, `exited ${code}:\n${out}`) rmSync(dir, { recursive: true, force: true }) } @@ -115,19 +130,35 @@ for (const prefix of ['', '/pr-139']) { const shared = join(dir, 'reference/glossary') if (variant !== 'missing') { mkdirSync(shared, { recursive: true }) - writeFileSync(join(shared, 'index.html'), ` - ${variant === 'redirect' ? '' : ''}`) + writeFileSync( + join(shared, 'index.html'), + ` + ${variant === 'redirect' ? '' : ''}`, + ) } - const scopes = variant === 'declaration' ? ['py/latest/reference/pane'] - : variant === 'nested' ? ['py/latest/reference/other/glossary'] - : ['py/latest/reference/glossary', 'ts/latest/workspace/reference/glossary', 'ts/latest/mcp/reference/glossary'] + const scopes = + variant === 'declaration' + ? ['py/latest/reference/pane'] + : variant === 'nested' + ? ['py/latest/reference/other/glossary'] + : [ + 'py/latest/reference/glossary', + 'ts/latest/workspace/reference/glossary', + 'ts/latest/mcp/reference/glossary', + ] for (const scope of scopes) { mkdirSync(join(dir, scope), { recursive: true }) - writeFileSync(join(dir, scope, 'index.html'), ``) + writeFileSync( + join(dir, scope, 'index.html'), + ``, + ) } const { code, out } = run(dir, { ...process.env, LIBTMUX_DOCS_LOCALES_ROOT: prefix }) - check(`shared glossary ${variant} (${prefix || 'production'})`, variant === 'valid' ? code === 0 : code !== 0, - `exited ${code}:\n${out}`) + check( + `shared glossary ${variant} (${prefix || 'production'})`, + variant === 'valid' ? code === 0 : code !== 0, + `exited ${code}:\n${out}`, + ) rmSync(dir, { recursive: true, force: true }) } } diff --git a/scripts/check-citations.mjs b/scripts/check-citations.mjs index ee3801ee7..436fea96b 100644 --- a/scripts/check-citations.mjs +++ b/scripts/check-citations.mjs @@ -144,7 +144,9 @@ if (asJson) { for (const c of checked) byPort[c.port] = (byPort[c.port] ?? 0) + 1 console.log( `check-citations: ${checked.length} cited paths across ${Object.keys(byPort).length} ports ` + - `(${Object.entries(byPort).map(([p, n]) => `${p}:${n}`).join(' ')})`, + `(${Object.entries(byPort) + .map(([p, n]) => `${p}:${n}`) + .join(' ')})`, ) for (const p of problems) { console.error(` MISSING ${p.file}:${p.line} [${p.kind}] ${p.port}: ${p.path}`) diff --git a/scripts/check-edge-extensions.mjs b/scripts/check-edge-extensions.mjs index 7f800bbcb..4b2588c30 100644 --- a/scripts/check-edge-extensions.mjs +++ b/scripts/check-edge-extensions.mjs @@ -104,7 +104,8 @@ if (missing.length || extensionless.length) { console.error(`check-edge-extensions: the edge function would redirect these away from their own files.`) for (const ext of missing) console.error(` .${ext.padEnd(12)} e.g. ${found.get(ext)}`) for (const path of extensionless) console.error(` no extension ${path}`) - if (missing.length) console.error(`\nAdd the missing extensions to ASSET_EXTENSIONS in ${fnPath.slice(repoRoot.length + 1)}.`) + if (missing.length) + console.error(`\nAdd the missing extensions to ASSET_EXTENSIONS in ${fnPath.slice(repoRoot.length + 1)}.`) if (extensionless.length) console.error(`\nRename extensionless files and their links to use an allowed extension.`) process.exit(1) } diff --git a/scripts/check-edge-extensions.negative.mjs b/scripts/check-edge-extensions.negative.mjs index 6c6aeb729..fe90fce9d 100644 --- a/scripts/check-edge-extensions.negative.mjs +++ b/scripts/check-edge-extensions.negative.mjs @@ -98,13 +98,18 @@ const check = (name, ok, detail) => { const license = join(dir, 'reference', 'py', 'LICENSE') writeFileSync(license, 'Copyright and license notice') const broken = run(dir, fn) - check('an extensionless file fails even when every extension is allowed', + check( + 'an extensionless file fails even when every extension is allowed', broken.code !== 0 && broken.out.includes('no extension') && broken.out.includes('/LICENSE'), - `exited ${broken.code}:\n${broken.out}`) + `exited ${broken.code}:\n${broken.out}`, + ) renameSync(license, `${license}.txt`) const corrected = run(dir, fn) - check('an allowed text extension makes the same notice routable', corrected.code === 0, - `exited ${corrected.code}:\n${corrected.out}`) + check( + 'an allowed text extension makes the same notice routable', + corrected.code === 0, + `exited ${corrected.code}:\n${corrected.out}`, + ) rmSync(dir, { recursive: true, force: true }) } @@ -117,7 +122,11 @@ const check = (name, ok, detail) => { const f = join(fnDir, 'nofn.js') writeFileSync(f, 'export default 1\n') const { code, out } = run(dir, f) - check('a function file with no table fails loudly', code !== 0 && out.includes('no ASSET_EXTENSIONS'), `exited ${code}:\n${out}`) + check( + 'a function file with no table fails loudly', + code !== 0 && out.includes('no ASSET_EXTENSIONS'), + `exited ${code}:\n${out}`, + ) rmSync(dir, { recursive: true, force: true }) } @@ -125,7 +134,11 @@ const check = (name, ok, detail) => { const dir = mkdtempSync(join(tmpdir(), 'check-edge-ext-absent-')) rmSync(dir, { recursive: true, force: true }) const { code, out } = run(dir, script) - check('an absent site directory fails rather than passing empty', code !== 0 && out.includes('no site at'), `exited ${code}:\n${out}`) + check( + 'an absent site directory fails rather than passing empty', + code !== 0 && out.includes('no site at'), + `exited ${code}:\n${out}`, + ) } if (failures) { diff --git a/scripts/check-edge-function-copy.mjs b/scripts/check-edge-function-copy.mjs index 58dc23740..aa608eeda 100755 --- a/scripts/check-edge-function-copy.mjs +++ b/scripts/check-edge-function-copy.mjs @@ -38,9 +38,7 @@ const expandHome = (p) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p) const ours = resolve(arg('--ours', join(root, 'infra', 'cloudfront-function.js'))) const tfConfig = expandHome(process.env.LIBTMUX_TF_CONFIG ?? '~/work/tf-config') -const theirs = resolve( - arg('--theirs', join(tfConfig, 'terraform', 'sites', 'libtmux.org', 'cloudfront-function.js')), -) +const theirs = resolve(arg('--theirs', join(tfConfig, 'terraform', 'sites', 'libtmux.org', 'cloudfront-function.js'))) if (!existsSync(ours)) { console.error(`check-edge-function-copy: no function at ${ours}`) @@ -75,6 +73,6 @@ console.error(` theirs: ${theirs}`) console.error(` first difference at line ${i + 1}:`) console.error(` ours: ${al[i] ?? ''}`) console.error(` theirs: ${bl[i] ?? ''}`) -console.error('\nCopy this repository\'s function over the Terraform one and commit it there:') +console.error("\nCopy this repository's function over the Terraform one and commit it there:") console.error(` cp ${ours} ${theirs}`) process.exit(1) diff --git a/scripts/check-edge-function-copy.negative.mjs b/scripts/check-edge-function-copy.negative.mjs index 6e8320dfb..64d7a393d 100755 --- a/scripts/check-edge-function-copy.negative.mjs +++ b/scripts/check-edge-function-copy.negative.mjs @@ -26,7 +26,7 @@ function run(args) { const dir = mkdtempSync(join(tmpdir(), 'edge-function-copy-')) const ours = join(dir, 'ours.js') const theirs = join(dir, 'theirs.js') -const BODY = "function handler(event) {\n var uri = event.request.uri\n return event.request\n}\n" +const BODY = 'function handler(event) {\n var uri = event.request.uri\n return event.request\n}\n' let failures = 0 const check = (name, ok, detail) => { @@ -49,11 +49,7 @@ writeFileSync(theirs, BODY) writeFileSync(theirs, BODY.replace('var uri = event.request.uri', 'var uri = event.request.uri // stale')) { const res = run(['--ours', ours, '--theirs', theirs]) - check( - 'a drifted copy is caught', - res.code === 1 && /drifted/.test(res.out), - `exit ${res.code}: ${res.out.trim()}`, - ) + check('a drifted copy is caught', res.code === 1 && /drifted/.test(res.out), `exit ${res.code}: ${res.out.trim()}`) check( 'the report names the first differing line', /first difference at line 2/.test(res.out), diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs index 05429a130..f007a4a18 100755 --- a/scripts/check-links.mjs +++ b/scripts/check-links.mjs @@ -72,9 +72,7 @@ const idsOf = (page) => { return ids.get(page) } -const pattern = all - ? /href="([^"#]*)(?:#([^"]*))?"/g - : /href="([^"#]*)#([^"]+)"\s+class="api-mention"/g +const pattern = all ? /href="([^"#]*)(?:#([^"]*))?"/g : /href="([^"#]*)#([^"]+)"\s+class="api-mention"/g /** * A URI scheme, which means the link leaves the site. @@ -127,9 +125,13 @@ function resolveHref(fromPage, href) { let checked = 0 const broken = [] -const recordBrokenLink = (page, path, anchor, why) => broken.push({ - page, path: copyText(path), anchor: copyText(anchor), why, -}) +const recordBrokenLink = (page, path, anchor, why) => + broken.push({ + page, + path: copyText(path), + anchor: copyText(anchor), + why, + }) for (const page of pages) { const html = readFileSync(join(root, page), 'utf8') for (const m of html.matchAll(pattern)) { @@ -150,8 +152,11 @@ for (const page of pages) { // A cache-busting query is not part of the path on a static site. const clean = path.replace(/\?.*$/, '') const resolved = resolveHref(page, clean) - const target = !clean ? page - : clean.endsWith('/') || resolved === '' ? `${resolved}/index.html`.replace(/^\//, '') : resolved + const target = !clean + ? page + : clean.endsWith('/') || resolved === '' + ? `${resolved}/index.html`.replace(/^\//, '') + : resolved const targetIds = idsOf(target) if (targetIds === undefined) recordBrokenLink(page, path, anchor, 'no page') else if (anchor && !hasAnchor(targetIds, anchor)) recordBrokenLink(page, path, anchor, 'no anchor') @@ -184,8 +189,7 @@ for (const [key, { count, from }] of targets.slice(0, 30)) { if (targets.length > 30) console.log(` ... and ${targets.length - 30} more targets`) if (vendored.length && broken.length && ours.length === 0) { console.log( - '\nall of them come from a vendored generator\'s own output; ' + - 'reported, but not this build\'s to fail on', + "\nall of them come from a vendored generator's own output; " + "reported, but not this build's to fail on", ) } process.exit(ours.length ? 1 : 0) diff --git a/scripts/check-nav.mjs b/scripts/check-nav.mjs index 7277a2495..411db04a2 100755 --- a/scripts/check-nav.mjs +++ b/scripts/check-nav.mjs @@ -94,8 +94,7 @@ const failures = [] const label = (nav, id) => nav.unplaced.find((s) => s.id === id)?.name ?? id /** Every bucket id in the tree, children included — what a page can render. */ -const renderable = (buckets) => - buckets.flatMap((b) => [b.id, ...renderable(b.children ?? [])]) +const renderable = (buckets) => buckets.flatMap((b) => [b.id, ...renderable(b.children ?? [])]) for (const nav of navs) { const d = nav.diagnostics diff --git a/scripts/check-nav.negative.mjs b/scripts/check-nav.negative.mjs index 46e071b45..475a9211b 100755 --- a/scripts/check-nav.negative.mjs +++ b/scripts/check-nav.negative.mjs @@ -44,10 +44,7 @@ const CASES = { // No `not` clauses: exactly the CapturePaneRequest case, where two // unrelated buckets both claim and declaration order decides. symbols: [sym('FooBar')], - buckets: [ - bucket('foo', { kind: 'name', prefix: 'Foo' }), - bucket('bar', { kind: 'name', suffix: 'Bar' }), - ], + buckets: [bucket('foo', { kind: 'name', prefix: 'Foo' }), bucket('bar', { kind: 'name', suffix: 'Bar' })], }, }, }, @@ -128,16 +125,12 @@ let failures = 0 for (const [name, spec] of Object.entries(CASES)) { const dir = mkdtempSync(join(tmpdir(), `check-nav-${name}-`)) try { - for (const [port, { symbols, buckets, unsettled, hideChildren, extraChild, model }] of Object.entries( - spec.ports, - )) { + for (const [port, { symbols, buckets, unsettled, hideChildren, extraChild, model }] of Object.entries(spec.ports)) { if (model) writeFileSync(join(dir, `${port}.json`), JSON.stringify({ port, symbols: model })) // The compiled nav knows about the child; the written tree will not. const withChild = extraChild ? buckets.map((b, i) => - i === 0 - ? { ...b, children: [{ id: extraChild.id, label: extraChild.id, match: extraChild.match }] } - : b, + i === 0 ? { ...b, children: [{ id: extraChild.id, label: extraChild.id, match: extraChild.match }] } : b, ) : buckets const nav = compileNav({ port, buckets: withChild, unsettled }, symbols, ctx) diff --git a/scripts/check-palette.mjs b/scripts/check-palette.mjs index 58b82b35f..ceef684ee 100755 --- a/scripts/check-palette.mjs +++ b/scripts/check-palette.mjs @@ -25,8 +25,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..') /* `--ceiling` points at a different record so `check-palette.negative.mjs` can * exercise --update without rewriting the ceiling this repo ships. */ const ceilingArg = process.argv.indexOf('--ceiling') -const CEILING_FILE = - ceilingArg === -1 ? join(root, 'scripts/palette-ceiling.json') : process.argv[ceilingArg + 1] +const CEILING_FILE = ceilingArg === -1 ? join(root, 'scripts/palette-ceiling.json') : process.argv[ceilingArg + 1] const PATTERN = /(?:bg|text|border|ring|divide|from|via|to)-(?:slate|gray|zinc|neutral|stone)-\d{2,3}/g const args = process.argv.slice(2) @@ -34,7 +33,10 @@ const dir = args.includes('--dir') ? args[args.indexOf('--dir') + 1] : join(root const files = execFileSync('rg', ['-l', '--glob', '*.{astro,ts,tsx,css}', PATTERN.source, dir], { encoding: 'utf8', -}).trim().split('\n').filter(Boolean) +}) + .trim() + .split('\n') + .filter(Boolean) /** Strip block comments and `//` lines so prose about the old classes is free. */ const stripComments = (src) => @@ -76,9 +78,7 @@ const { ceiling } = JSON.parse(readFileSync(CEILING_FILE, 'utf8')) if (hits.length > ceiling) { const byFile = new Map() for (const h of hits) byFile.set(h.file, (byFile.get(h.file) ?? 0) + 1) - console.error( - `check-palette: ${hits.length} hard-coded palette utilities, ceiling is ${ceiling}.\n`, - ) + console.error(`check-palette: ${hits.length} hard-coded palette utilities, ceiling is ${ceiling}.\n`) for (const [f, n] of [...byFile].sort((a, b) => b[1] - a[1]).slice(0, 10)) { console.error(` ${String(n).padStart(3)} ${f}`) } @@ -91,9 +91,7 @@ if (hits.length > ceiling) { } if (hits.length < ceiling) { - console.log( - `check-palette: ${hits.length} hard-coded palette utilities, below the ceiling of ${ceiling}.`, - ) + console.log(`check-palette: ${hits.length} hard-coded palette utilities, below the ceiling of ${ceiling}.`) console.log(`Lower it: node scripts/check-palette.mjs --update`) process.exit(1) } diff --git a/scripts/check-palette.negative.mjs b/scripts/check-palette.negative.mjs index 297ca16b8..d3ee86620 100755 --- a/scripts/check-palette.negative.mjs +++ b/scripts/check-palette.negative.mjs @@ -13,9 +13,7 @@ import { fileURLToPath } from 'node:url' const script = join(dirname(fileURLToPath(import.meta.url)), 'check-palette.mjs') const ceilingFile = join(dirname(fileURLToPath(import.meta.url)), 'palette-ceiling.json') -const { ceiling } = JSON.parse( - execFileSync('cat', [ceilingFile], { encoding: 'utf8' }), -) +const { ceiling } = JSON.parse(execFileSync('cat', [ceilingFile], { encoding: 'utf8' })) /** A source tree carrying exactly `n` hard-coded palette utilities. */ function tree(n) { @@ -86,7 +84,14 @@ function update(dir, start, force) { } const GUARDS = [ - { name: 'raising refused', n: ceiling + 5, start: ceiling, wantCode: 1, wantCeiling: ceiling, says: 'refusing to raise' }, + { + name: 'raising refused', + n: ceiling + 5, + start: ceiling, + wantCode: 1, + wantCeiling: ceiling, + says: 'refusing to raise', + }, { name: 'raising forced', n: ceiling + 5, start: ceiling, force: true, wantCode: 0, wantCeiling: ceiling + 5 }, { name: 'lowering allowed', n: ceiling - 5, start: ceiling, wantCode: 0, wantCeiling: ceiling - 5 }, ] @@ -106,7 +111,9 @@ for (const c of GUARDS) { failures++ } else { const how = c.force ? ' --force' : '' - console.log(`ok ${c.name.padEnd(18)} ${c.n} vs ceiling ${c.start}${how} -> exit ${code}, ceiling now ${recorded}`) + console.log( + `ok ${c.name.padEnd(18)} ${c.n} vs ceiling ${c.start}${how} -> exit ${code}, ceiling now ${recorded}`, + ) } } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/scripts/check-sidebar-refs.mjs b/scripts/check-sidebar-refs.mjs index 9df2be99b..b2eac9a66 100755 --- a/scripts/check-sidebar-refs.mjs +++ b/scripts/check-sidebar-refs.mjs @@ -54,13 +54,19 @@ function pageUnder(portDir) { return undefined } -const window = new Window({ settings: { enableJavaScriptEvaluation: false, disableCSSFileLoading: true, disableJavaScriptFileLoading: true } }) +const window = new Window({ + settings: { enableJavaScriptEvaluation: false, disableCSSFileLoading: true, disableJavaScriptFileLoading: true }, +}) const template = window.document.createElement('template') const labelOf = (element) => element.textContent.replace(/\s+/g, ' ').trim() const hrefOf = (link) => link.getAttribute('href') ?? '' const owned = (link) => hrefOf(link).startsWith(servedRoot) const hostOf = (href) => { - try { return new URL(href).hostname } catch { return undefined } + try { + return new URL(href).hostname + } catch { + return undefined + } } const failures = [] @@ -78,8 +84,9 @@ for (const port of PORTS) { failures.push(`${port}: expected one shared documentation picker, found ${pickers.length}`) continue } - const cores = [...pickers[0].querySelectorAll('[data-surface-group]')].filter((group) => - group.querySelector(':scope > summary strong')?.textContent.trim() === 'Core Library') + const cores = [...pickers[0].querySelectorAll('[data-surface-group]')].filter( + (group) => group.querySelector(':scope > summary strong')?.textContent.trim() === 'Core Library', + ) if (cores.length !== 1) { failures.push(`${port}: expected one Core Library group, found ${cores.length}`) continue @@ -91,11 +98,14 @@ for (const port of PORTS) { // The sampled page supplies the version; a valid link to another version // must not satisfy the current context's native-reference contract. const version = relative(join(SITE, port), file).split('/')[0] - const expected = version === 'concepts' ? new RegExp(`^/${port}/[^/]+/reference/$`) - : new RegExp(`^/${port}/${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/reference/$`) + const expected = + version === 'concepts' + ? new RegExp(`^/${port}/[^/]+/reference/$`) + : new RegExp(`^/${port}/${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/reference/$`) const ours = sections.filter((link) => owned(link) && expected.test(pathOf(hrefOf(link)))) if (ours.length !== 1) failures.push(`${port}: Core Library does not offer exactly one current native reference`) - else if (labelOf(ours[0]) !== 'API Reference') failures.push(`${port}: native reference is not labelled API Reference`) + else if (labelOf(ours[0]) !== 'API Reference') + failures.push(`${port}: native reference is not labelled API Reference`) const host = ECOSYSTEM[port] if (host) { @@ -126,15 +136,22 @@ for (const port of PORTS) { const path = pathOf(href).replace(/^\//, '') if (!existsSync(join(SITE, path, 'index.html'))) failures.push(`${port}: ${href} is linked but not built`) } - const refs = links.filter((link) => hrefOf(link).includes('/reference/') || - Object.values(ECOSYSTEM).includes(hostOf(hrefOf(link))) || /\/py\/[^/]+\/api\//.test(hrefOf(link))) + const refs = links + .filter( + (link) => + hrefOf(link).includes('/reference/') || + Object.values(ECOSYSTEM).includes(hostOf(hrefOf(link))) || + /\/py\/[^/]+\/api\//.test(hrefOf(link)), + ) .map((link) => ({ label: labelOf(link), external: Boolean(hostOf(hrefOf(link))) })) rows.push({ port, refs }) } for (const { port, refs } of rows) { - console.log(`check-sidebar-refs: ${port.padEnd(7)} ${refs.map((r) => `${r.label}${r.external ? ' ↗' : ''}`).join(' | ')}`) + console.log( + `check-sidebar-refs: ${port.padEnd(7)} ${refs.map((r) => `${r.label}${r.external ? ' ↗' : ''}`).join(' | ')}`, + ) } if (failures.length) { console.error(`\ncheck-sidebar-refs: ${failures.length} problem(s):`) diff --git a/scripts/check-source-links.mjs b/scripts/check-source-links.mjs index 403a3a7c6..a2edafb65 100755 --- a/scripts/check-source-links.mjs +++ b/scripts/check-source-links.mjs @@ -90,10 +90,14 @@ for (const [port, where] of Object.entries(CHECKOUT)) { const model = JSON.parse(readFileSync(modelPath, 'utf8')) const units = new Map() - const locations = model.symbols.flatMap((symbol) => [symbol, - ...symbol.signatures.filter((signature) => signature.source).map((signature, index) => ({ - id: `${symbol.id} signature ${index + 1}`, source: signature.source, - })), + const locations = model.symbols.flatMap((symbol) => [ + symbol, + ...symbol.signatures + .filter((signature) => signature.source) + .map((signature, index) => ({ + id: `${symbol.id} signature ${index + 1}`, + source: signature.source, + })), ]) for (const symbol of locations) { const repository = symbol.source.repo ?? model.repo @@ -103,74 +107,74 @@ for (const [port, where] of Object.entries(CHECKOUT)) { units.get(key).symbols.push(symbol) } for (const unit of units.values()) { - const repo = expand(PRODUCT_CHECKOUT[unit.repository] ?? where) - const rev = unit.revision - if (!rev) { - report(port, 'model records no revision, so every source link is unbuildable') - continue - } - - // 1. The commit has to exist here at all. - if (git(repo, 'rev-parse', '--verify', '--quiet', `${rev}^{commit}`) === undefined) { - report(port, `revision ${rev.slice(0, 8)} is not in ${where}`) - continue - } - - // 2. And it has to be reachable from a remote a reader can fetch. - const remotes = (git(repo, 'branch', '-r', '--contains', rev) ?? '') - .split('\n') - .map((l) => l.trim()) - .filter(Boolean) - .map((l) => l.split('/')[0]) - const publicRemotes = [...new Set(remotes)].filter((r) => !PRIVATE_REMOTES.has(r)) - if (!publicRemotes.length) { - report( - port, - `revision ${rev.slice(0, 8)} is on no public remote (${[...new Set(remotes)].join(', ') || 'no remote'}) — ` + - 'every source link for this port 404s', - ) - continue - } - - // 3. Every path has to exist at that commit, and every line has to be - // inside the file. A line past the end scrolls nowhere and is the shape - // a bad remap would take. - const lengths = new Map() - let missing = 0 - let past = 0 - let firstMissing = '' - let noLine = 0 - for (const s of unit.symbols) { - const file = s.source?.file - if (!file) continue - if (file.startsWith('..') || file.startsWith('/')) { - if (!firstMissing) firstMissing = file - missing++ + const repo = expand(PRODUCT_CHECKOUT[unit.repository] ?? where) + const rev = unit.revision + if (!rev) { + report(port, 'model records no revision, so every source link is unbuildable') continue } - if (!lengths.has(file)) { - const blob = git(repo, 'show', `${rev}:${file}`) - lengths.set(file, blob === undefined ? null : blob.split('\n').length) + + // 1. The commit has to exist here at all. + if (git(repo, 'rev-parse', '--verify', '--quiet', `${rev}^{commit}`) === undefined) { + report(port, `revision ${rev.slice(0, 8)} is not in ${where}`) + continue } - const n = lengths.get(file) - if (n === null) { - if (!firstMissing) firstMissing = file - missing++ + + // 2. And it has to be reachable from a remote a reader can fetch. + const remotes = (git(repo, 'branch', '-r', '--contains', rev) ?? '') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => l.split('/')[0]) + const publicRemotes = [...new Set(remotes)].filter((r) => !PRIVATE_REMOTES.has(r)) + if (!publicRemotes.length) { + report( + port, + `revision ${rev.slice(0, 8)} is on no public remote (${[...new Set(remotes)].join(', ') || 'no remote'}) — ` + + 'every source link for this port 404s', + ) continue } - if (s.source.line === undefined) noLine++ - else if (s.source.line < 1 || s.source.line > n) past++ - } - if (missing) report(port, `${missing} symbols name a path absent at ${rev.slice(0, 8)}, e.g. ${firstMissing}`) - if (past) report(port, `${past} symbols point past the end of their file at ${rev.slice(0, 8)}`) - if (!missing && !past) { - console.log( - `check-source-links: ${port} ${unit.repository} ${unit.symbols.length} declaration and method locations, ${lengths.size} files ` + - `at ${rev.slice(0, 8)} (${publicRemotes.join(', ')})` + - (noLine ? `, ${noLine} without a line` : ''), - ) - } + // 3. Every path has to exist at that commit, and every line has to be + // inside the file. A line past the end scrolls nowhere and is the shape + // a bad remap would take. + const lengths = new Map() + let missing = 0 + let past = 0 + let firstMissing = '' + let noLine = 0 + for (const s of unit.symbols) { + const file = s.source?.file + if (!file) continue + if (file.startsWith('..') || file.startsWith('/')) { + if (!firstMissing) firstMissing = file + missing++ + continue + } + if (!lengths.has(file)) { + const blob = git(repo, 'show', `${rev}:${file}`) + lengths.set(file, blob === undefined ? null : blob.split('\n').length) + } + const n = lengths.get(file) + if (n === null) { + if (!firstMissing) firstMissing = file + missing++ + continue + } + if (s.source.line === undefined) noLine++ + else if (s.source.line < 1 || s.source.line > n) past++ + } + + if (missing) report(port, `${missing} symbols name a path absent at ${rev.slice(0, 8)}, e.g. ${firstMissing}`) + if (past) report(port, `${past} symbols point past the end of their file at ${rev.slice(0, 8)}`) + if (!missing && !past) { + console.log( + `check-source-links: ${port} ${unit.repository} ${unit.symbols.length} declaration and method locations, ${lengths.size} files ` + + `at ${rev.slice(0, 8)} (${publicRemotes.join(', ')})` + + (noLine ? `, ${noLine} without a line` : ''), + ) + } } } diff --git a/scripts/check-type-links.mjs b/scripts/check-type-links.mjs index 9abd5e9a8..6afc0b069 100644 --- a/scripts/check-type-links.mjs +++ b/scripts/check-type-links.mjs @@ -28,10 +28,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..') /* `--ceiling` points at a different record so the negative test can drive this * script rather than a copy of its logic. */ const ceilingArg = process.argv.indexOf('--ceiling') -const CEILING_FILE = - ceilingArg === -1 - ? join(root, 'scripts/type-links-ceiling.json') - : process.argv[ceilingArg + 1] +const CEILING_FILE = ceilingArg === -1 ? join(root, 'scripts/type-links-ceiling.json') : process.argv[ceilingArg + 1] const { API_MODEL_PORTS: PORT_DEFS } = await import(`file://${join(root, 'site/src/lib/ports.ts')}`) const PORTS = PORT_DEFS.map((p) => p.slug) @@ -124,7 +121,9 @@ if (args.includes('--list')) { .slice(0, 8) .map(([n, c]) => `${n}(${c})`) .join(' ') - console.log(`${p.padEnd(7)} ${String(resolved).padStart(6)} linked ${String(unresolved).padStart(5)} plain ${share.padStart(5)}% linked`) + console.log( + `${p.padEnd(7)} ${String(resolved).padStart(6)} linked ${String(unresolved).padStart(5)} plain ${share.padStart(5)}% linked`, + ) console.log(` ${top}`) } process.exit(0) @@ -192,7 +191,9 @@ if (over.length) { console.error('check-type-links: more type names render plain than the ceiling allows.') for (const p of over) { const top = [...results[p].names.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5) - console.error(` ${p.padEnd(7)} ${ceiling[p]} -> ${results[p].unresolved} ${top.map(([n, c]) => `${n}(${c})`).join(' ')}`) + console.error( + ` ${p.padEnd(7)} ${ceiling[p]} -> ${results[p].unresolved} ${top.map(([n, c]) => `${n}(${c})`).join(' ')}`, + ) } console.error('\nSee them all: node scripts/check-type-links.mjs --list') process.exit(1) diff --git a/scripts/check-type-links.negative.mjs b/scripts/check-type-links.negative.mjs index 176e15530..8d7c721b9 100644 --- a/scripts/check-type-links.negative.mjs +++ b/scripts/check-type-links.negative.mjs @@ -33,9 +33,7 @@ function site({ n = 0, mangled = false } = {}) { const d = join(dir, p, 'latest', 'reference', 'thing') mkdirSync(d, { recursive: true }) const body = - linked('Server') + - Array.from({ length: n }, (_, i) => plain(`Plain${i}`)).join('') + - (mangled ? usr() : '') + linked('Server') + Array.from({ length: n }, (_, i) => plain(`Plain${i}`)).join('') + (mangled ? usr() : '') writeFileSync(join(d, 'index.html'), `${body}`) } return dir diff --git a/scripts/check-xrefs.mjs b/scripts/check-xrefs.mjs index ca9fabe17..2d82807a2 100755 --- a/scripts/check-xrefs.mjs +++ b/scripts/check-xrefs.mjs @@ -27,8 +27,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..') /* `--floor` points at a different record so `check-xrefs.negative.mjs` can * drive this script rather than a copy of its logic. */ const floorArg = process.argv.indexOf('--floor') -const FLOOR_FILE = - floorArg === -1 ? join(root, 'scripts/xref-floor.json') : process.argv[floorArg + 1] +const FLOOR_FILE = floorArg === -1 ? join(root, 'scripts/xref-floor.json') : process.argv[floorArg + 1] const { API_MODEL_PORTS: PORT_DEFS } = await import(`file://${join(root, 'site/src/lib/ports.ts')}`) const PORTS = PORT_DEFS.map((p) => p.slug) @@ -55,18 +54,22 @@ function countIn(dir) { // Core and companion references share one floor within a version. Count each // version separately: a healthy stable tree must not conceal broken latest // references, and building both versions must not double the recorded floor. -const versionCounts = Object.fromEntries(PORTS.map((port) => { - const versions = {} - for (const dir of referenceDirs(site, port, { products: true })) { - const version = relative(join(site, port), dir).split(sep)[0] - versions[version] = (versions[version] ?? 0) + countIn(dir) - } - return [port, versions] -})) -const counts = Object.fromEntries(PORTS.map((port) => { - const values = Object.values(versionCounts[port]) - return [port, values.length ? Math.min(...values) : 0] -})) +const versionCounts = Object.fromEntries( + PORTS.map((port) => { + const versions = {} + for (const dir of referenceDirs(site, port, { products: true })) { + const version = relative(join(site, port), dir).split(sep)[0] + versions[version] = (versions[version] ?? 0) + countIn(dir) + } + return [port, versions] + }), +) +const counts = Object.fromEntries( + PORTS.map((port) => { + const values = Object.values(versionCounts[port]) + return [port, values.length ? Math.min(...values) : 0] + }), +) if (args.includes('--update')) { /* @@ -122,8 +125,12 @@ const below = PORTS.filter((p) => counts[p] < floor[p]) for (const p of PORTS) { const f = floor[p] const mark = counts[p] < f ? 'FELL' : counts[p] > f ? 'up' : 'ok' - const versions = Object.entries(versionCounts[p]).map(([version, count]) => `${version}: ${count}`).join(', ') - console.log(`${p.padEnd(7)} ${String(counts[p]).padStart(7)} resolved (floor ${f}) ${mark} [${versions || 'no versions'}]`) + const versions = Object.entries(versionCounts[p]) + .map(([version, count]) => `${version}: ${count}`) + .join(', ') + console.log( + `${p.padEnd(7)} ${String(counts[p]).padStart(7)} resolved (floor ${f}) ${mark} [${versions || 'no versions'}]`, + ) } if (below.length) { diff --git a/scripts/check-xrefs.negative.mjs b/scripts/check-xrefs.negative.mjs index 0216e39e9..aa2743e7a 100755 --- a/scripts/check-xrefs.negative.mjs +++ b/scripts/check-xrefs.negative.mjs @@ -59,16 +59,28 @@ const CASES = [ */ { name: 'port unrecorded', built: 0, floor: 10, omit: ['go'], mustFail: true, says: 'no floor recorded for go' }, { - name: 'version masks fall', built: 10, floor: 10, mustFail: true, - setup: (dir) => { version(dir, 'py', 'latest', 2); version(dir, 'py', 'stable', 20) }, + name: 'version masks fall', + built: 10, + floor: 10, + mustFail: true, + setup: (dir) => { + version(dir, 'py', 'latest', 2) + version(dir, 'py', 'stable', 20) + }, says: 'resolution fell on py', }, { - name: 'both versions hold', built: 10, floor: 10, mustFail: false, + name: 'both versions hold', + built: 10, + floor: 10, + mustFail: false, setup: (dir) => version(dir, 'py', 'stable', 10), }, { - name: 'products share version', built: 10, floor: 10, mustFail: false, + name: 'products share version', + built: 10, + floor: 10, + mustFail: false, setup: (dir) => { version(dir, 'py', 'latest', 6) version(dir, 'py', 'latest/mcp', 4) @@ -127,13 +139,26 @@ const GUARDS = [ { name: 'lowering forced', built: 2, floor: 10, force: true, wantCode: 0, wantFloor: 2 }, { name: 'raising allowed', built: 10, floor: 2, wantCode: 0, wantFloor: 10 }, { - name: 'version lowering refused', built: 10, floor: 10, wantCode: 1, wantFloor: 10, - port: 'py', says: 'refusing to lower', - setup: (dir) => { version(dir, 'py', 'latest', 2); version(dir, 'py', 'stable', 20) }, + name: 'version lowering refused', + built: 10, + floor: 10, + wantCode: 1, + wantFloor: 10, + port: 'py', + says: 'refusing to lower', + setup: (dir) => { + version(dir, 'py', 'latest', 2) + version(dir, 'py', 'stable', 20) + }, }, { - name: 'version count not added', built: 10, floor: 10, wantCode: 0, wantFloor: 10, - port: 'py', setup: (dir) => version(dir, 'py', 'stable', 10), + name: 'version count not added', + built: 10, + floor: 10, + wantCode: 0, + wantFloor: 10, + port: 'py', + setup: (dir) => version(dir, 'py', 'stable', 10), }, ] @@ -154,7 +179,9 @@ for (const c of GUARDS) { failures++ } else { const how = c.force ? ' --force' : '' - console.log(`ok ${c.name.padEnd(16)} built ${c.built} vs floor ${c.floor}${how} -> exit ${code}, floor now ${recorded}`) + console.log( + `ok ${c.name.padEnd(16)} built ${c.built} vs floor ${c.floor}${how} -> exit ${code}, floor now ${recorded}`, + ) } } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/scripts/check_example_width.py b/scripts/check_example_width.py new file mode 100644 index 000000000..b29949890 --- /dev/null +++ b/scripts/check_example_width.py @@ -0,0 +1,3066 @@ +r"""Fail when code a reader sees in rendered documentation is too wide. + +Shared by every libtmux port; change it in all of them together. It +enforces the example rules in WRITING.md. Configuration lives in +``.github/example-width.toml``:: + + width = 80 + tab_width = 4 + markdown = ["**/*.md"] + sources = ["examples/**/*.rs"] + doc_comments = ["src/**/*.rs"] + shared_digest = "" + checker_digest = "" + allow_ceiling = 1 + + [[exclude]] + glob = "docs/decisions/**" + reason = "dated decision records, not reader-facing examples" + + [[formatter_copies]] + copy = "examples/rustfmt.toml" + root = "rustfmt.toml" + key = "max_width" + + [[allow]] + path = "README.md" + line = "the exact line, as it appears in the file" + reason = "why breaking it would make the example worse" + +It checks code in ``markdown`` files (fences, indented blocks, including +one four columns past a list item's text, and ``
    `` blocks; literal
    +and ``code-block`` blocks in a ``.rst`` one), every line of ``sources``
    +files, and the code inside doc comments of ``doc_comments`` files:
    +fences, read as a Markdown page reads them, ``>>>`` doctests, and reST
    +literal blocks in Python docstrings. Output is not checked: fences
    +tagged ``text``, and the lines a session prints (a ``console``,
    +``shell-session`` or ``pycon`` block, or a ``python`` fence that opens
    +with ``>>> ``). A command runs on through an open quote, heredoc, loop,
    +conditional or brace block and past a trailing ``\``, backtick, pipe,
    +``&&`` or ``||``; a ``$ `` command must continue with ``\`` and a
    +``PS> `` command with a backtick. An untagged Markdown fence is code,
    +and a ``text`` fence that opens with a ``$ `` or ``PS> `` prompt is a
    +console session. A line that is only a URL (after an optional comment, list or
    +quote marker) and a hidden line (a rustdoc ``# `` line, a doctest marked
    +``# doctest: +HIDE``) are skipped. Wide characters count as two columns;
    +a tab advances to the next multiple of 4.
    +
    +``width`` must be 80, the shared rule, and ``tab_width``, if set, 4.
    +Every key must be known, at the top level and inside each ``[[...]]``
    +entry, so a key written below a table header cannot be swallowed by that
    +entry; every entry must hold
    +the keys the check reads, and every value must have the type it reads.
    +Every glob must match a tracked file, and every exclusion a checked file
    +or a file under ``examples``;
    +every exclusion and allow entry must give a reason, and every allow
    +entry must match a line. Every tracked file under a directory named
    +``examples`` must be checked or excluded, so a new example file cannot
    +go unread. ``allow_ceiling`` must equal the number of allow entries, so
    +the list grows only through a reviewed change to that number and a fixed
    +line must lower it. Each formatter copy must equal its root file once
    +its ``key`` value is set aside, and the digests of this checker and of
    +WRITING.md's shared section must equal ``checker_digest`` and
    +``shared_digest``, both required, so a local edit to either fails until
    +every port changes together. A WRITING.md without both shared markers is
    +reported by name. AGENTS.md must hold a Markdown link that resolves to
    +this WRITING.md and the heading the shared section sits under, so an
    +agent is routed to the rules, and WRITING.md's
    +``### In this repository`` block must hold the four labelled bullets in
    +order, then one Bad and one Good example, with the Bad example still
    +past 80 columns.
    +
    +Run ``--self-test`` to prove the checker can fail. ``--digest`` prints the
    +SHA-256 of this file and of WRITING.md's shared example section (between
    +```` and ````), so a job
    +that reads every port can confirm they still agree.
    +"""
    +
    +from __future__ import annotations
    +
    +import argparse
    +import dataclasses
    +import hashlib
    +import json
    +import os
    +import pathlib
    +import re
    +import subprocess
    +import sys
    +import typing as t
    +import unicodedata
    +
    +try:
    +    import tomllib
    +except ModuleNotFoundError:  # tomllib is in the standard library from 3.11.
    +    sys.exit("check_example_width.py needs Python 3.11 or newer")
    +
    +CONFIG = pathlib.PurePosixPath(".github/example-width.toml")
    +KEYS = frozenset(
    +    {
    +        "width",
    +        "tab_width",
    +        "markdown",
    +        "sources",
    +        "doc_comments",
    +        "exclude",
    +        "allow",
    +        "allow_ceiling",
    +        "formatter_copies",
    +        "shared_digest",
    +        "checker_digest",
    +    }
    +)
    +# Keys each [[table]] entry holds.
    +TABLE_KEYS = {
    +    "exclude": frozenset({"glob", "reason"}),
    +    "allow": frozenset({"path", "line", "reason"}),
    +    "formatter_copies": frozenset({"copy", "root", "key"}),
    +}
    +GLOB_KEYS = ("markdown", "sources", "doc_comments")
    +# The reason the printed allow entry carries until someone writes a real one.
    +PLACEHOLDER = "why breaking this line would make the example worse"
    +# The type each top-level value must have; a TOML string where a list belongs
    +# would otherwise be read one character at a time.
    +VALUE_TYPES: dict[str, tuple[type, str]] = {
    +    "width": (int, "an integer"),
    +    "tab_width": (int, "an integer"),
    +    "allow_ceiling": (int, "an integer"),
    +    "shared_digest": (str, "a string"),
    +    "checker_digest": (str, "a string"),
    +}
    +CODE_DIRECTIVES = frozenset({"code-block", "code", "code-cell", "sourcecode"})
    +# gp-libs hides a doctest line carrying this directive from the rendered page.
    +HIDDEN = re.compile(r"^\s*(?:>>>|\.\.\.) .*# doctest: \+HIDE\b")
    +OUTPUT_TAGS = frozenset({"text"})
    +# Fences that mix prompted commands with the output they print.
    +SESSION_TAGS = frozenset({"console", "shell-session", "pycon"})
    +# Fences of shell script, with no prompts and no output.
    +SCRIPT_TAGS = frozenset({"sh", "bash", "zsh", "shell", "powershell", "pwsh", "ps1"})
    +# A Python fence that opens with a doctest prompt is a session too.
    +PYTHON_TAGS = frozenset({"python", "py", "python3"})
    +PROMPTS = ("$ ", "PS> ", ">>> ", "... ")
    +# Shell words that open and close a block a command continues through.
    +SHELL_OPEN = frozenset({"if", "for", "while", "until", "case", "select"})
    +SHELL_CLOSE = frozenset({"fi", "done", "esac"})
    +HEREDOC = re.compile(r"(?[A-Za-z_]\w*)\1")
    +# A reST line that opens an indented code block: a paragraph ending in
    +# ``::`` or a code directive. Other directives, such as toctree, hold no code.
    +REST_BLOCK = re.compile(
    +    r"^\s*(?!\.\. )\S.*::\s*$|^\s*\.\. (code-block|code|sourcecode)::"
    +)
    +FENCE = re.compile(r"^(?P\s*)(?P`{3,}|~{3,})\s*(?P[^`]*)$")
    +RUSTDOC_TAGS = frozenset({"rust", "no_run", "ignore", "should_panic", "compile_fail"})
    +SHARED = re.compile(
    +    r"\n(?P.*?)", re.DOTALL
    +)
    +DOC_LINE = re.compile(r"^\s*(?:///|//!|\*(?!/)|#'|--- ?)\s?")
    +# Files under examples/ with no text lines to measure.
    +BINARY = (
    +    ".png",
    +    ".jpg",
    +    ".jpeg",
    +    ".gif",
    +    ".ico",
    +    ".webp",
    +    ".pdf",
    +    ".woff",
    +    ".woff2",
    +    ".ttf",
    +    ".zip",
    +    ".gz",
    +    ".tar",
    +    ".wasm",
    +    ".so",
    +    ".dll",
    +    ".exe",
    +    ".jar",
    +    ".class",
    +    ".bin",
    +    ".dylib",
    +    ".a",
    +    ".o",
    +    ".db",
    +    ".sqlite",
    +)
    +# Comments a test compares or a copy step reads: Go's `// Output:` and
    +# `// docs:` lines, java's `// →` results, and region markers.
    +MARKER = re.compile(
    +    r"(?:Output:|Unordered output:|docs:|→|#?(?:end)?region\b)", re.IGNORECASE
    +)
    +# Shell and PowerShell scripts, whose lines continue the way a console command does.
    +SCRIPTS = (".sh", ".bash", ".zsh", ".ps1", ".psm1")
    +# Go marks doc-comment code with a tab after a bare //.
    +GO_DOC = re.compile(r"^\s*//\t")
    +DOCTEST = re.compile(r"(?:>>>|\.\.\.) ")
    +BLOCK_OPEN = re.compile(r"
    \s*\{@code|\s*$")
    +BLOCK_CLOSE = re.compile(r"\}\s*
    |^\s*") +URL = re.compile(r"^(?:(?:#+|//+!?|-+|\*|>|\n\n [x](y)\n- [z](w)\n") + 'a b \n\n- [z](w)\n' + >>> prose("```text\n[x](y)\n```\n") + '' + """ + text = unfenced(text) + code = {number for number, _ in markdown_lines(text)} + lines = text.splitlines(keepends=True) + text = "".join(line for n, line in enumerate(lines, 1) if n not in code) + text = re.sub(r"", "", text, flags=re.DOTALL) + span = r"(? list[str]: + r"""Return the targets of a Markdown text's inline and reference links. + + >>> links('[A](a.md#x "A"), [B]() and [C](c.md \'C\')\n[d]: d.md') + ['a.md#x', 'b.md', 'c.md', 'd.md'] + >>> links("![E](e.md) \\[F](f.md) [G\nH](g.md)") + ['g.md'] + """ + return [inline or reference for inline, reference in LINK.findall(text)] + + +def routing_problems(root: pathlib.Path) -> list[str]: + """Return a problem unless AGENTS.md links the shared section's heading. + + The heading is the last one of level one or two above the shared + markers: the section an agent routed from AGENTS.md should land in. + """ + writing = writing_path(root) + if writing is None: + return [] + text = writing.read_text(encoding="utf-8") + above = unfenced(text[: text.index("")]) + headings = [m[2] for m in HEADING.finditer(above) if len(m[1]) <= 2] + if not headings: + where = writing.relative_to(root).as_posix() + return [f"{where}: no heading above the shared section"] + anchor = slug(headings[-1]) + agents = root / "AGENTS.md" + where = f"{writing.relative_to(root).as_posix()}#{anchor}" + link = f"[{headings[-1]}]({where})" + if not agents.is_file(): + return [f"AGENTS.md is missing; add it with a link such as {link}"] + route = prose(agents.read_text(encoding="utf-8")) + for target in links(route): + path, _, fragment = target.partition("#") + # GitHub reads a leading slash as the repository root. + here = root / path.removeprefix("/") + if fragment == anchor and here.resolve() == writing.resolve(): + return [] + return [ + f"AGENTS.md: no link to {where}, the section with the example rules; " + + f"add one such as {link}" + ] + + +def port_block_problems(text: str) -> list[str]: + r"""Return how a WRITING.md's ``### In this repository`` block strays. + + >>> labels = "\n".join(label + " x" for label in PORT_LABELS) + >>> bad = "```text\n" + "x" * 81 + "\n```" + >>> head = f"### In this repository\n\n{labels}\n\n" + >>> block = head + f"Bad, over 80:\n\n{bad}\n\nGood, named:\n" + >>> port_block_problems(block) + [] + >>> [p.split(";")[0] for p in port_block_problems(block.replace("x" * 81, "x"))] + ['In this repository: the Bad example fits in 80 columns'] + >>> port_block_problems(block.replace("- **Not formatted:**", "- **Other:**")) + ['In this repository: no "- **Not formatted:**" bullet in its place'] + >>> port_block_problems("## Examples\n") + ['no "### In this repository" block'] + >>> port_block_problems(block + "\n" + block) + ['2 "### In this repository" blocks; keep one'] + """ + blocks = PORT_BLOCK.findall(unfenced(text)) + if len(blocks) > 1: + return [f'{len(blocks)} "### In this repository" blocks; keep one'] + match = PORT_BLOCK.search(unfenced(text)) + if match is None: + return ['no "### In this repository" block'] + lines = match["body"].splitlines() + problems = [] + at = 0 + for label in PORT_LABELS: + found = next((i for i, line in enumerate(lines) if line.startswith(label)), -1) + if found < at: + problems.append(f'In this repository: no "{label}" bullet in its place') + else: + at = found + for example in ("Bad, over 80", "Good"): + count = sum(line.startswith(example) for line in lines[at:]) + if count != 1: + problems.append(f'In this repository: {count} "{example}" examples, want 1') + # The fence after "Bad, over 80" is stripped above, so read it raw: a + # Bad example that a later edit brought within 80 no longer shows a fault. + raw = text[text.find("### In this repository") :].splitlines() + start = next( + (i for i, line in enumerate(raw) if line.startswith("Bad, over 80")), None + ) + if start is not None: + opens = [i for i in range(start + 1, len(raw)) if FENCE.match(raw[i])] + code = raw[opens[0] + 1 : opens[1]] if len(opens) > 1 else [] + if not any(columns(line.expandtabs(4)) > 80 for line in code): + problems.append( + "In this repository: the Bad example fits in 80 columns; " + "show a line that does not" + ) + return problems + + +# A comment after code, set off by two spaces as most style guides ask. +TRAILING = re.compile(r"\S\s{2,}(?://|#|--)\s+(?P.*)$") +# How a comment line starts in the languages the examples use. +COMMENTS = ("# ", "// ", "-- ", "/// ", "//! ", "/* ", "* ", "") + >>> fix_advice(html, "W.md")[:22] + 'Wrap the comment at 80' + >>> doc = Finding("a.rs", 2, 90, 80, "/// // a comment line in doc code", "doc") + >>> fix_advice(doc, "W.md")[:22] + 'Wrap the comment at 80' + >>> go = Finding("a.go", 2, 90, 80, "//\t// Wait for the marker line", "doc") + >>> pydoc = Finding("a.py", 2, 90, 80, " >>> # Wait for the marker", "doc") + >>> [fix_advice(f, "W.md")[:22] for f in (go, pydoc)] + ['Wrap the comment at 80', 'Wrap the comment at 80'] + >>> said = Finding("a.md", 2, 90, 80, ">>> # Wait for the marker", "session") + >>> data = Finding("a.md", 2, 90, 80, "# a heredoc line, not a comment", "session") + >>> fix_advice(said, "W.md")[:22] + 'Wrap the comment at 80' + >>> fix_advice(data, "W.md").split(":")[0] + 'Fix a wide line by changing the code, not the line breaks' + >>> doc = Finding("a.rs", 2, 90, 80, "/// let total = first + second;", "doc") + >>> fix_advice(doc, "W.md").split(":")[0], "copied" in fix_advice(doc, "W.md") + ('Fix a wide line by changing the code, not the line breaks', True) + >>> note = Finding("README.md", 3, 84, 80, "// wait until the pane prints it") + >>> "copied" in fix_advice(note, "W.md") + True + >>> line = "/// $ tmux new-session -d -s demo \\" + >>> doced = Finding("a.rs", 4, 90, 80, line, "doc:command") + >>> fix_advice(doced, "W.md").split(":")[0] + 'Split a wide command the way its shell continues' + >>> noted = Finding("a.rs", 5, 90, 80, "/// # Create a session first", "doc:note") + >>> fix_advice(noted, "W.md")[:24] + 'Move the comment out of ' + >>> bare_note = Finding("a.rs", 2, 90, 80, "/// # Create it", "doc:promptless") + >>> fix_advice(bare_note, "W.md")[:24] + 'Move the comment out of ' + >>> labelled = Finding("A.md", 3, 90, 80, "session.name(); // → demo", "") + >>> fix_advice(labelled, "W.md").split(": ")[0] + 'Keep the code and its marker on one line (W.md#what-never-breaks)' + >>> "shorten the code before the marker (W.md#reaching-80)" in fix_advice( + ... labelled, "W.md" + ... ) + True + >>> remark_copy = fix_advice(remark, "W.md").split(". ")[-1] + >>> remark_copy + 'If a Markdown block copies this file, re-copy it as W.md#in-this-repository says.' + >>> command = Finding("a.md", 1, 90, 80, " PS> Get-Item -Path x") + >>> fix_advice(command, "W.md") + 'Split a wide command the way its shell continues: W.md#reaching-80.' + """ + kind, text = finding.kind, finding.text.lstrip() + # A line in a doc-comment fence carries its Markdown kind after "doc:"; + # read it past its comment marker, as the Markdown line would be read. + if kind.startswith("doc:"): + marker = GO_DOC if GO_DOC.match(finding.text) else DOC_LINE + kind, text = kind[4:], marker.sub("", finding.text, count=1).lstrip() + reach = f"{rules}#reaching-80" + advice = f"Fix a wide line by changing the code, not the line breaks: {reach}." + # Code blocks keeps explanations out of a console block, in the prose. + prose = ( + f"Move the comment out of the block into the prose around it: " + f"{rules}#code-blocks." + ) + if kind == "note": + return prose + if kind == "promptless": + if text.startswith("# "): + return prose + return ( + "If it is a command, add the prompt its shell takes, `$ ` or `PS> `, " + f"and continue it the way that shell does ({reach}); if it is " + "output, tag the fence `text`." + ) + if kind == "command" or text.startswith(("$ ", "PS> ")): + dialect = "PS> " if text.startswith("PS> ") else "$ " + if scan(text, Shell(dialect))[3] < len(text): + return prose + if len(text.rstrip(" \\`").split()) < 2: + # One token has nowhere to split. + return advice + return f"Split a wide command the way its shell continues: {reach}." + if kind == "doc": + marker = GO_DOC if GO_DOC.match(finding.text) else DOC_LINE + text = marker.sub("", finding.text, count=1).lstrip() + # A doctest comment sits behind its prompt. A session line without one is + # heredoc or other data, where # starts no comment. + doctest = DOCTEST.match(text) if kind in ("doc", "session") else None + text = text[doctest.end() :] if doctest else text + commented = kind in ("", "source", "doc", "script") or bool(doctest) + # Markdown blocks and doc-comment fences can be copies of a source file, + # and a source file can be the original a block copies. + copied = ( + " In a block copied from a source file, fix the source, then follow the " + f"copy step that {rules}#in-this-repository gives." + if kind in ("", "doc", "script") + else " If a Markdown block copies this file, re-copy it as " + f"{rules}#in-this-repository says." + if kind == "source" + else "" + ) + if commented and text.startswith(COMMENTS): + if MARKER.match(text.split(None, 1)[1] if " " in text else ""): + return kept_whole(rules) + ": add this one to [[allow]] with its reason." + # Width says comments wrap at 80; What never breaks keeps compared + # output and marker lines whole. + return ( + f"Wrap the comment at 80 ({rules}#width); compared output and marker " + f"lines stay whole ({rules}#what-never-breaks).{copied}" + ) + # A marker after the code it labels, such as `name(); // → demo`, keeps + # its line whole too. + trailing = TRAILING.search(text) if commented else None + if trailing and MARKER.match(trailing["body"]): + return ( + f"Keep the code and its marker on one line ({rules}#what-never-breaks): " + f"shorten the code before the marker ({reach}); add it to [[allow]] only " + "if it still runs past 80." + ) + # A script line is a shell command, which breaks at its continuation. + script = kind == "script" or (kind == "source" and finding.path.endswith(SCRIPTS)) + if script and len(text.rstrip(" \\`").split()) > 1: + paste = ( + " A command a reader pastes goes in a `console` fence with its prompt " + f"({rules}#code-blocks)." + if kind == "script" + else "" + ) + return ( + f"{advice} If it is one command, it may instead continue the way its " + f"shell does.{paste}{copied}" + ) + if kind == "source": + return ( + f"If the port block names a formatter for this file, run it. {advice}" + + copied + ) + return advice + copied + + +# A line edited in place keeps most of its text; a stale entry and a wide line +# that share less are a deleted allowed line and a new wide one. +SIMILAR = 0.6 + + +def resemblance(finding: Finding, entry: dict[str, t.Any]) -> float: + """Return how alike a wide line and a stale entry's line are, 0 to 1. + + >>> line = Finding("a.md", 1, 90, 80, "print(totals, values)") + >>> round(resemblance(line, {"line": "print(total, values)"}), 2) + 0.98 + """ + return alike(finding.text, entry["line"]) + + +def alike(one: str, other: str) -> float: + """Return how alike two lines are, 0 to 1. + + >>> alike("abcd", "abce") + 0.75 + """ + import difflib + + return difflib.SequenceMatcher(None, one, other).ratio() + + +def edit_pairs( + findings: list[Finding], + stale: list[dict[str, t.Any]], + bad_path: str | None, + shortened: list[dict[str, t.Any]] | None = None, +) -> list[tuple[Finding, dict[str, t.Any]]]: + """Pair stale entries with the wide lines that kept most of their text. + + Each stale entry and each wide line joins at most one pair, closest + first, so two allowed lines edited in one file make two pairs; the rest + of the wide lines are new. + + >>> stale = [{"path": "a.md", "line": "alpha(" + "x" * 80 + ")"}] + >>> stale.append({"path": "a.md", "line": "beta(" + "y" * 80 + ")"}) + >>> one = Finding("a.md", 3, 90, 80, "alpha(" + "x" * 82 + ")") + >>> two = Finding("a.md", 7, 90, 80, "beta(" + "y" * 83 + ")") + >>> new = Finding("a.md", 9, 90, 80, "z" * 90) + >>> pairs = edit_pairs([two, new, one], stale, None) + >>> [(f.number, e["line"][:4]) for f, e in pairs] + [(7, 'beta'), (3, 'alph')] + >>> edit_pairs([one], stale, "a.md") + [] + >>> edit_pairs([one], stale, None, shortened=stale) + [] + """ + scored = sorted( + ( + (resemblance(f, e), i, j) + for i, f in enumerate(findings) + for j, e in enumerate(stale) + if f.path == e["path"] and f.path != bad_path and e not in (shortened or []) + ), + reverse=True, + ) + pairs, taken_lines, taken_entries = [], set(), set() + for score, i, j in scored: + if score < SIMILAR: + break + if i in taken_lines or j in taken_entries: + continue + taken_lines.add(i) + taken_entries.add(j) + pairs.append((i, findings[i], stale[j])) + return [(finding, entry) for _, finding, entry in sorted(pairs, key=lambda p: p[0])] + + +def edited_finding( + findings: list[Finding], stale: list[dict[str, t.Any]], bad_path: str | None +) -> Finding | None: + """Return the first wide line that is an edited allowed line, if any. + + >>> stale = [{"path": "a.md", "line": "print(total, " + "x" * 80 + ")"}] + >>> new = Finding("a.md", 2, 90, 80, "y" * 90) + >>> edit = Finding("a.md", 9, 95, 80, "print(totals, " + "x" * 80 + ")") + >>> edited_finding([new, edit], stale, None).number + 9 + >>> edited_finding([new, edit], stale, "a.md") is None + True + >>> far = Finding("a.md", 3, 92, 80, "print(total, " + "y" * 20 + "x" * 60 + ")") + >>> edited_finding([far, edit], stale, None).number + 9 + """ + pairs = edit_pairs(findings, stale, bad_path) + return pairs[0][0] if pairs else None + + +def shortened_entries( + root: pathlib.Path, + stale: list[dict[str, t.Any]], + width: int, + tab_width: int, +) -> list[dict[str, t.Any]]: + """Return stale entries whose line was shortened to fit since the last commit. + + Such an entry's file holds a line that fits, resembles the entry and is + not in the committed file. A wide line that also resembles the entry is + then new, not the allowed line edited, so it gets an entry of its own. + """ + found = [] + for entry in stale: + path = str(entry["path"]) + shown = subprocess.run( + ["git", "-C", str(root), "show", f"HEAD:{path}"], + capture_output=True, + text=True, + check=False, + ) + if shown.returncode != 0: + continue + before = {line.rstrip() for line in shown.stdout.splitlines()} + if any( + line not in before + and columns(line.expandtabs(tab_width)) <= width + and alike(line, str(entry["line"])) >= SIMILAR + for line in source_lines(root, entry) + ): + found.append(entry) + return found + + +def source_lines(root: pathlib.Path, entry: dict[str, t.Any]) -> set[str]: + """Return the stripped lines of an allow entry's file, or none if unreadable.""" + try: + text = (root / str(entry["path"])).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return set() + return {line.rstrip() for line in text.splitlines()} + + +def entry_name(entry: dict[str, t.Any], entries: list[dict[str, t.Any]]) -> str: + r"""Name an allow entry by its path, and by its line when the path has two. + + >>> one = {"path": "a.md", "line": "first line"} + >>> entry_name(one, [one]), entry_name(one, [one, {"path": "a.md", "line": "x"}]) + ('a.md', "a.md whose line begins 'first line'") + >>> indented = {"path": "a.md", "line": " first line"} + >>> entry_name(indented, [indented, one]) + "a.md whose line begins 'first line'" + >>> long = {"path": "a.md", "line": "print(total, first + second + third + fourth)"} + >>> entry_name(long, [long, one]) + "a.md whose line begins 'print(total, first +'" + >>> path = {"path": "a.ps1", "line": r"C:\Users\me\AppData\Local\Temp\run.ps1"} + >>> entry_name(path, [path, {"path": "a.ps1", "line": "x"}]) + "a.ps1 whose line begins 'C:\\Users\\me\\AppData\\Local\\Temp'" + """ + if sum(e.get("path") == entry["path"] for e in entries) < 2: + return str(entry["path"]) + text = entry["line"].strip() + if len(text) > 32: + # Cut at a space, or else before a backslash, so the quoted start + # never ends inside an escape. + space, slash = text.find(" ", 20), text.rfind("\\", 20, 33) + if 0 < space <= 40: + text = text[:space] + else: + text = text[:slash].rstrip("\\") if slash > 0 else text[:32] + return f"{entry['path']} whose line begins {quoted(text)}" + + +def quoted(text: str) -> str: + r"""Return a line in single quotes, as a TOML literal string writes it. + + A line that holds a single quote falls back to an escaped basic string. + + >>> print(quoted(r'"C:\Windows"'), quoted("it's")) + '"C:\Windows"' "it's" + """ + return f"'{text}'" if "'" not in text else literal(text) + + +def ceiling_hints( + findings: list[Finding], + stale: list[dict[str, t.Any]], + count: int, + bad_path: str | None = None, + hidden: list[dict[str, t.Any]] | None = None, + entries: list[dict[str, t.Any]] | None = None, + shortened: list[dict[str, t.Any]] | None = None, +) -> list[str]: + r"""Return what to change in the allow list after a failed run. + + A stale entry in a file that also has a wide line is an edited allowed + line, so its entry takes the new text. A stale entry for ``bad_path``, + the WRITING.md whose Bad example now fits, asks for a wide Bad line + again. Every other stale entry goes, and the last line gives the + ceiling all of that leaves. + + >>> wide = Finding("a.md", 3, 90, 80, "x" * 90) + >>> ceiling_hints([wide], [], 1) + ['Set allow_ceiling to 1 plus one for each line you add to [[allow]].'] + >>> edited = {"path": "a.md", "line": "x" * 88} + >>> for hint in ceiling_hints([wide], [edited], 1): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + allow_ceiling stays 1. + >>> gone = {"path": "b.md", "line": "old"} + >>> for hint in ceiling_hints([wide], [edited, gone], 2): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + Delete the stale allow entry for b.md. + Lower allow_ceiling to 1. + >>> kept = {"path": "b.md", "line": "kept"} + >>> ceiling_hints([], [gone], 2, entries=[gone, kept])[0] + "Delete the stale allow entry for b.md whose line begins 'old'." + >>> second = Finding("a.md", 5, 88, 80, "z" * 88) + >>> ceiling_hints([wide, second], [edited], 1)[-1] + 'Set allow_ceiling to 1 plus one for each line you add to [[allow]].' + >>> other = Finding("c.md", 7, 85, 80, "y" * 85) + >>> for hint in ceiling_hints([wide, other], [edited], 1): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + Set allow_ceiling to 1 plus one for each line you add to [[allow]]. + >>> both = [edited, {"path": "a.md", "line": "y" * 88}] + >>> lines = [wide, Finding("a.md", 5, 90, 80, "y" * 90)] + >>> for hint in ceiling_hints(lines, both, 2): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + a.md: an allowed line changed; put its new text in the stale entry. + allow_ceiling stays 2. + >>> kept = {"path": "a.md", "line": "x" * 90} + >>> for hint in ceiling_hints([], [kept], 1, hidden=[kept]): + ... print(hint.split(";")[0]) + a.md: the allowed line is still there but the check no longer reads it as wide + allow_ceiling stays 1. + >>> unrelated = {"path": "a.md", "line": "old"} + >>> for hint in ceiling_hints([wide], [unrelated], 1): + ... print(hint) + Delete the stale allow entry for a.md. + Set allow_ceiling to 0 plus one for each line you add to [[allow]]. + >>> bad = {"path": "WRITING.md", "line": "x" * 90} + >>> for hint in ceiling_hints([], [bad], 1, "WRITING.md"): + ... print(hint) + WRITING.md: restore a Bad example past 80; its [[allow]] entry quotes it. + allow_ceiling stays 1. + >>> other = Finding("WRITING.md", 9, 85, 80, "x" * 85) + >>> for hint in ceiling_hints([other], [bad], 1, "WRITING.md"): + ... print(hint) + WRITING.md: restore a Bad example past 80; its [[allow]] entry quotes it. + Set allow_ceiling to 1 plus one for each line you add to [[allow]]. + """ + pairs = edit_pairs(findings, stale, bad_path, shortened) + edited = [entry for _, entry in pairs] + hints = [ + f"{entry['path']}: an allowed line changed; put its new text in the " + "stale entry." + for entry in edited + ] + restored = [entry for entry in stale if entry["path"] == bad_path] + hints += [ + f"{bad_path}: restore a Bad example past 80; its [[allow]] entry quotes it." + ] * bool(restored) + # An allowed line still in its file was hidden by a config change, not + # fixed: deleting its entry would only weaken the gate. + hidden = [entry for entry in hidden or [] if entry in stale] + hints += [ + f"{entry_name(e, entries or stale)}: the allowed line is still there but " + "the check no longer reads it as wide; undo the change that hid it: a " + "glob, [[exclude]] or tab_width edit, or a fence tag or comment marker " + "around the line." + for e in hidden + ] + gone = [e for e in stale if e not in edited + restored + hidden] + hints += [ + f"Delete the stale allow entry for {entry_name(e, entries or stale)}." + for e in gone + ] + target = count - len(gone) + # Every wide line not paired with a stale entry is new. + if len(findings) > len(pairs): + hints.append( + f"Set allow_ceiling to {target} plus one for each line you add " + "to [[allow]]." + ) + elif target == count: + hints.append(f"allow_ceiling stays {count}.") + else: + hints.append(f"Lower allow_ceiling to {target}.") + return hints + + +def digests(root: pathlib.Path) -> list[str]: + """Return ``checker `` and ``shared `` lines. + + The shared digest covers the text between the markers in WRITING.md; + ``shared missing`` means no WRITING.md carries them. + """ + checker = hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest() + shared = "missing" + writing = writing_path(root) + if writing is not None: + match = SHARED.search(writing.read_text(encoding="utf-8")) + if match: + shared = hashlib.sha256(match["body"].encode()).hexdigest() + return [f"checker {checker}", f"shared {shared}"] + + +def digest_problems(root: pathlib.Path, config: dict[str, t.Any]) -> list[str]: + """Return digests that differ from the ones the configuration records. + + Each message names the file that changed. Both files are the same in + every port, so an intended change goes to all of them; an edit meant for + one repository is undone instead. + """ + actual = dict(line.split(" ", 1) for line in digests(root)) + here, top = pathlib.Path(__file__).resolve(), root.resolve() + script = here.relative_to(top) if here.is_relative_to(top) else here.name + writing = writing_file(root) + shared = writing.relative_to(root).as_posix() if writing else "WRITING.md" + where = {"checker": script, "shared": shared} + names = {"checker": "this checker", "shared": "the shared section"} + undo = { + "checker": f"it (`git checkout -- {script}`, or a copy from another port)", + "shared": ( + "the text between the shared markers, copied from " + f"`git show HEAD:{shared}` so port-block edits stay, or from another " + "port once the edit is committed" + ), + } + # A digest written below a [[...]] header or misspelled is reported as + # an unknown key already. + nested = { + key + for table in TABLE_KEYS + for entry in config.get(table, []) + if isinstance(entry, dict) + for key in entry + } | {meant(key) for key in set(config) - KEYS} + missing = [ + f"{CONFIG}: {name}_digest is missing; run `python3 {script} --digest` " + f"and set {name}_digest to the {name} value it prints" + for name in ("checker", "shared") + if name + "_digest" not in config and name + "_digest" not in nested + ] + return missing + [ + f"{where[name]}: {names[name]} has digest {actual[name][:12]}, but " + f"{name}_digest in {CONFIG} records {config[name + '_digest'][:12]}.\n" + f" To undo an edit meant for this repository only, restore {undo[name]}.\n" + " To change it everywhere, change libtmux/docs and each repository " + f"listed in {PORTS}, which also names where each keeps this checker; " + "run each copy with `--digest` and set checker_digest and " + "shared_digest to the values it prints." + for name in ("checker", "shared") + if name + "_digest" in config + and actual[name] not in (config[name + "_digest"], "missing") + ] + + +def self_test() -> int: + """Run the doctests above, plus planted failures and clean controls.""" + import contextlib + import doctest + import io + import tempfile + + failures, _ = doctest.testmod(sys.modules[__name__]) + + def expect(condition: bool, message: str) -> None: + nonlocal failures + if not condition: + print(f"self-test: {message}", file=sys.stderr) + failures += 1 + + with tempfile.TemporaryDirectory() as scratch: + root = pathlib.Path(scratch) + wide = "let total = " + " + ".join(["value"] * 12) + ";" + (root / "README.md").write_text(f"```rust\n{wide}\n```\n") + config: dict[str, t.Any] = {"width": 60, "markdown": ["README.md"]} + planted, _ = check(root, config, ["README.md"]) + expect(len(planted) == 1, "a planted wide line was not reported") + if planted: + pasted = tomllib.loads(allow_hint(planted[0]))["allow"][0] + expect(pasted["line"] == wide, "the allow hint does not round-trip") + excluded = dict(config, exclude=[{"glob": "README.md", "reason": "r"}]) + expect(not check(root, excluded, ["README.md"])[0], "an exclusion was read") + config["allow"] = [{"path": "README.md", "line": wide, "reason": "test"}] + clean, stale = check(root, config, ["README.md"]) + expect(not clean and not stale, "an allowed line was reported") + url = "https://example.com/" + "x" * 80 + hidden = ">>> setup = " + "x" * 70 + " # doctest: +HIDE" + (root / "skip.md").write_text(f"```\n{url}\n```\n```python\n{hidden}\n```\n") + skipped, _ = check(root, {"width": 80, "markdown": ["skip.md"]}, ["skip.md"]) + expect(not skipped, "a URL line or a hidden doctest line was reported") + body = "x" * 90 + (root / "here.md").write_text( + f"```console\n$ cat < one failed", + ) + (root / "page.rst").write_text(".. code-block:: sh\n\n " + "x" * 81 + "\n") + rst, _ = check(root, {"width": 80, "markdown": ["page.rst"]}, ["page.rst"]) + expect(len(rst) == 1, "a wide line in a reST code block passed") + config["allow"].append({"path": "README.md", "line": "gone", "reason": "x"}) + _, stale = check(root, config, ["README.md"]) + expect(len(stale) == 1, "a stale allow entry was not reported") + tabbed = "\t" + wide + (root / "main.go").write_text(tabbed + "\n") + config = {"width": 60, "sources": ["*.go"]} + config["allow"] = [{"path": "main.go", "line": tabbed, "reason": "test"}] + clean, stale = check(root, config, ["main.go"]) + expect(not clean and not stale, "a raw tabbed allow entry did not match") + (root / "tab.go").write_text("\t" + "x" * 78 + "\n") + tabbed_config = {"width": 80, "tab_width": 4, "sources": ["tab.go"]} + expanded, _ = check(root, tabbed_config, ["tab.go"]) + expect(len(expanded) == 1, "a tab counted as one column") + (root / "tab.go").write_text("\t" + "x" * 76 + "\n") + expanded, _ = check(root, tabbed_config, ["tab.go"]) + expect(not expanded, "a tabbed line within 80 was reported") + (root / "fmt.toml").write_text("edition = 1\nmax_width = 100\n") + (root / "copy.toml").write_text("edition = 1\nmax_width = 80\n") + pair = {"copy": "copy.toml", "root": "fmt.toml", "key": "max_width"} + copies = {"formatter_copies": [pair]} + expect(not copy_problems(root, copies), "a width-only copy was reported") + wider_copies = ( + "max_width = 120", + "max_width = 100 # was 80", + "# max_width = 80", + "# keep max_width = 80\nmax_width = 100", + "max_width = 80\nmax_width = 120", + "max_width = 80, max_width = 120", + ) + for wider in wider_copies: + (root / "copy.toml").write_text(f"edition = 1\n{wider}\n") + expect(bool(copy_problems(root, copies)), f"a copy with {wider!r} passed") + (root / "copy.toml").write_text( + "edition = 1\nmax_width = 80, max_width = 120\n" + ) + doubled = copy_problems(root, copies) + expect(bool(doubled) and "2 times" in doubled[0], "a doubled width passed") + (root / "copy.toml").write_text("edition = 1\nmax_width = 80 # was 120\n") + expect(not copy_problems(root, copies), "a commented width was read twice") + (root / "copy.toml").write_text( + "edition = 1\nmax_width = 80 # the root sets max_width = 100\n" + ) + expect( + not copy_problems(root, copies), "a width named in a comment was counted" + ) + (root / "copy.toml").write_text("edition = 1\nmax_width = 80\n") + (root / "copy.toml").write_text("edition = 2\nmax_width = 80\n") + expect(bool(copy_problems(root, copies)), "a drifted copy was not reported") + (root / "fmt.json").write_text('{\n "lineLength": 100, "indent": 2\n}\n') + (root / "copy.json").write_text('{\n "lineLength": 80, "indent": 9\n}\n') + same_line = {"copy": "copy.json", "root": "fmt.json", "key": "lineLength"} + expect( + bool(copy_problems(root, {"formatter_copies": [same_line]})), + "a setting changed on the width line passed", + ) + (root / "copy.json").write_text('{\n "lineLength": 80, "indent": 2\n}\n') + expect( + not copy_problems(root, {"formatter_copies": [same_line]}), + "a copy differing only in its width value was reported", + ) + expect(digests(root)[1] == "shared missing", "a missing section was digested") + shared = "\nrule\n\n" + (root / "WRITING.md").write_text(shared) + expect(digests(root)[1] != "shared missing", "a shared section was missed") + headless = routing_problems(root) + expect( + bool(headless) and "no heading" in headless[0], + "a shared section with no heading above passed", + ) + checker_now = digests(root)[0].split(" ", 1)[1] + recorded: dict[str, t.Any] = { + "checker_digest": checker_now, + "shared_digest": "0" * 64, + } + changed = digest_problems(root, recorded) + expect( + bool(changed) and "or from another port" in changed[0], + "a shared-section change was missed or offered only HEAD to restore", + ) + shared_now = digests(root)[1].split(" ", 1)[1] + recorded = {"checker_digest": checker_now, "shared_digest": shared_now} + expect(not digest_problems(root, recorded), "a matching digest was reported") + below = {"path": "a", "line": "b", "reason": "r"} + below["checker_digest"] = recorded.pop("checker_digest") + recorded["allow"] = [below] + expect( + not digest_problems(root, recorded), + "a digest below [[allow]] was also reported missing", + ) + del recorded["allow"] + recorded["checker_digset"] = below["checker_digest"] + expect( + not digest_problems(root, recorded), + "a misspelled digest key was also reported missing", + ) + del recorded["checker_digset"] + expect(bool(digest_problems(root, recorded)), "a missing digest passed") + (root / "WRITING.md").write_text("## Examples\n\n" + shared) + alone = root / "alone" / ".github" + alone.mkdir(parents=True) + section = "## Examples\n\n\n" + (alone / "WRITING.md").write_text(section) + broken = marker_problems(alone.parent) + expect( + bool(broken) and ".github/WRITING.md" in broken[0], + "a missing closing marker passed or named the wrong file", + ) + (alone / "WRITING.md").write_text(section + "x\n\n") + expect(not marker_problems(alone.parent), "balanced markers were reported") + body = " echo " + "x" * 80 + loop = f"```console\n$ for x in a; do\n{body}\ndone\n{'y' * 81}\n```\n" + (root / "loop.md").write_text(loop) + looped, _ = check(root, {"width": 80, "markdown": ["loop.md"]}, ["loop.md"]) + expect( + [finding.number for finding in looped] == [3], + "a wide loop body passed, or printed output was measured", + ) + expect(bool(routing_problems(root)), "a missing AGENTS.md link passed") + (root / "AGENTS.md").write_text("Code examples: WRITING.md#examples\n") + expect(bool(routing_problems(root)), "a bare anchor counted as a link") + (root / "AGENTS.md").write_text("[Examples](docs/WRITING.md#examples)\n") + expect(bool(routing_problems(root)), "a link to a missing file passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#other)\n") + expect(bool(routing_problems(root)), "a link to the wrong heading passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + expect(not routing_problems(root), "a linked section was reported") + (root / "AGENTS.md").write_text("[Examples]()\n") + expect(not routing_problems(root), "an angle-bracket link was reported") + (root / "AGENTS.md").write_text("[Examples][ex]\n\n[ex]: WRITING.md#examples\n") + expect(not routing_problems(root), "a reference-style link was reported") + (root / "AGENTS.md").write_text("[Examples](/WRITING.md#examples)\n") + expect(not routing_problems(root), "a root-relative link was reported") + for hidden_link in ( + "- Code examples:\n\n {}\n", + "```\n{}\n```\n", + "`{}`\n", + "\n", + "!{}\n", + "\\{}\n", + ): + link = "[Examples](WRITING.md#examples)" + (root / "AGENTS.md").write_text(hidden_link.format(link)) + expect(bool(routing_problems(root)), f"a link in {hidden_link!r} passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + (root / "AGENTS.md").unlink() + gone = routing_problems(root) + expect( + bool(gone) and "is missing" in gone[0], "a missing AGENTS.md was unnamed" + ) + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + labels = "\n".join(label + " x" for label in PORT_LABELS) + wide = "```text\n" + "x" * 81 + "\n```" + head = f"### In this repository\n\n{labels}\n\n" + block = head + f"Bad, over 80:\n\n{wide}\n\nGood, x:\n" + expect(not port_block_problems(block), "a complete port block was reported") + twice = block + f"\nBad, over 80:\n\n{wide}\n" + expect(bool(port_block_problems(twice)), "a second Bad example passed") + missing = block.replace(PORT_LABELS[2], "- **Other:**") + expect( + bool(port_block_problems(missing)), "a port block missing a label passed" + ) + fenced = "## Examples\n\n```sh\n# a comment\n```\n\n" + shared + (root / "WRITING.md").write_text(fenced) + expect(not routing_problems(root), "a fenced comment read as a heading") + nested = tomllib.loads( + 'allow_ceiling = 0\n[[exclude]]\nglob = "a"\nreason = "r"\n' + 'doc_comments = ["a"]\n' + ) + nested["markdown"] = ["a"] + expect( + any("doc_comments" in p for p in config_problems(nested, ["a"])), + "a key nested under [[exclude]] passed", + ) + del nested["exclude"][0]["doc_comments"] + expect(not config_problems(nested, ["a"]), "a clean [[exclude]] was reported") + nested["exclude"][0]["reason"] = " " + expect( + any("no reason" in p for p in config_problems(nested, ["a"])), + "an exclusion without a reason passed", + ) + table = {"exclude": {"glob": "a", "reason": "r"}} + expect(bool(type_problems(table)), "[exclude] written as one table passed") + typed = {"width": "80", "markdown": "**/*.md"} + expect(len(type_problems(typed)) == 2, "a string width or glob list passed") + listed = {"allow": [{"path": ["a"], "line": "b", "reason": "c"}]} + expect(bool(type_problems(listed)), "a list where a string belongs passed") + absent = {"copy": "gone.toml", "root": "fmt.toml", "key": "max_width"} + expect( + bool(copy_problems(root, {"formatter_copies": [absent]})), + "a missing formatter copy passed", + ) + expect(not type_problems({"width": 80, "markdown": ["a"]}), "good types failed") + misnamed: dict[str, t.Any] = { + "markdown": ["README.md"], + "exclude": [{"path": "README.md", "reason": "r"}], + "allow": [{"file": "README.md", "line": wide, "reason": "r"}], + "formatter_copies": [{"copy": "copy.toml", "root": "fmt.toml"}], + "allow_ceiling": 1, + } + found = " ".join(config_problems(misnamed, ["README.md"])) + expect( + all(f"has no {key}" in found for key in ("glob", "path", "key")), + "an entry missing a key the check reads passed", + ) + try: + check(root, misnamed, ["README.md"]) + copy_problems(root, misnamed) + except KeyError as error: + expect(False, f"an entry missing {error} crashed the check") + entry = {"path": "a", "line": "b", "reason": "c"} + ceiling: dict[str, t.Any] = {"allow": [entry, entry], "allow_ceiling": 1} + grown = config_problems(ceiling, ["a"]) + expect( + bool(grown) and "delete the new entry" in grown[0], + "a grown allow list passed or was not told to delete the new entry", + ) + ceiling = {"allow": [entry], "allow_ceiling": 2} + expect(bool(config_problems(ceiling, ["a"])), "a shrunk list kept its ceiling") + ceiling = {"allow": [entry], "allow_ceiling": 1} + expect(not config_problems(ceiling, ["a"]), "an exact ceiling was reported") + with tempfile.TemporaryDirectory() as scratch: + top = pathlib.Path(scratch) + git = ["git", "-C", scratch, "-c", "init.defaultBranch=main"] + subprocess.run([*git, "init", "-q"], check=True) + (top / ".github").mkdir() + (top / "README.md").write_text("x\n") + subprocess.run([*git, "add", "README.md"], check=True) + (top / "LINKED.md").symlink_to("README.md") + subprocess.run([*git, "add", "LINKED.md"], check=True) + expect( + tracked_files(top) == ["README.md"], + "a tracked symbolic link was read beside its target", + ) + + def run(config_text: str) -> str: + (top / CONFIG).write_text(config_text) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = main(["--root", scratch]) + expect(code == 1, "a broken config passed") + return out.getvalue() + + expect( + "fix the config first" in run('width = "80"\n'), + "a string width reached the width pass", + ) + expect( + "cannot read the config" in run("width = [\n"), + "a config that is not TOML went unreported", + ) + misspelled = ( + 'markdwn = ["*.md"]\nallow_ceiling = 1\n[[allow]]\n' + 'path = "README.md"\nline = "x"\nreason = "r"\n' + ) + expect( + "0 stale allow entries" in run(misspelled), + "a misspelled glob key made an allow entry look stale", + ) + allowed = "print(total, " + "x" * 80 + ")" + edited = allowed.replace("total", "totals") + (top / "README.md").write_text(f"```py\n{'y' * 90}\n{edited}\n```\n") + config_text = ( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + advice = run(config_text) + expect( + advice.count(f'line = "{edited}"') == 1 + and f'[[allow]]\npath = "README.md"\nline = "{"y" * 90}"' in advice, + "an edited allowed line was paired with another wide line, or the " + "new wide line got no entry", + ) + expect( + "\nREADME.md: an allowed line changed; its entry says why it stays" + in "\n" + advice + and "give it an entry of its own" in advice + and "Fix a wide line by changing the code" in advice, + "an edited allowed line was told to change its code", + ) + (top / "README.md").write_text(f"```go\n// Output: {'o' * 80}\n```\n") + marked = run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + marked.split("\n[[allow]]")[0].count("[[allow]]") == 1 + and "#what-never-breaks); add the line to [[allow]]" in marked, + "a marker line's advice named [[allow]] twice", + ) + (top / "README.md").write_text(f"```py\nprint({'w' * 80})\n```\n") + plain = "\n" + run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + "\nFix a wide line by changing the code" in plain + and "\nIf no change keeps the example clear, add the line to" in plain, + "a wide line's advice and its allow fallback were not on lines of " + "their own", + ) + with tempfile.TemporaryDirectory() as other: + repo = pathlib.Path(other) + sub = ["git", "-C", other, "-c", "user.name=t", "-c", "user.email=t@t"] + subprocess.run([*sub, "init", "-q"], check=True) + allowed_line = "print(total, " + "x" * 80 + ")" + (repo / "README.md").write_text(f"```py\n{allowed_line}\n```\n") + subprocess.run([*sub, "add", "README.md"], check=True) + subprocess.run([*sub, "commit", "-qm", "x"], check=True) + # The allowed line is shortened to fit, and a new wide line that + # resembles it is added: the entry goes, the new line gets its own. + fits = "print(total, " + "x" * 40 + ")" + new = "print(totals, " + "x" * 80 + ")" + (repo / "README.md").write_text(f"```py\n{fits}\n{new}\n```\n") + (repo / ".github").mkdir() + (repo / CONFIG).write_text( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed_line}"\nreason = "r"\n' + ) + out = io.StringIO() + with contextlib.redirect_stdout(out): + main(["--root", other]) + told = out.getvalue() + expect( + "an allowed line changed" not in told + and f"names a line shortened to fit in 80: '{allowed_line}'" in told + and "Delete the stale allow entry for README.md." in told + and "Set allow_ceiling to 0 plus one" in told, + "a new wide line beside a shortened allowed line was taken for " + "its edit", + ) + # A short similar line already committed is not a shortening: an + # edit to the allowed line still takes its entry. + (repo / "README.md").write_text(f"```py\n{fits}\n{allowed_line}\n```\n") + subprocess.run([*sub, "commit", "-qam", "y"], check=True) + edited_line = allowed_line.replace("total", "totals") + (repo / "README.md").write_text(f"```py\n{fits}\n{edited_line}\n```\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + main(["--root", other]) + expect( + "an allowed line changed" in out.getvalue(), + "an edited allowed line beside a committed short line lost its entry", + ) + (top / "lib.rs").write_text("/// ```console\n/// $ ls `\n/// -l\n/// ```\n") + subprocess.run([*git, "add", "lib.rs"], check=True) + shells = run('doc_comments = ["lib.rs"]\nallow_ceiling = 0\n') + expect( + "lib.rs:2: this `$ ` command is continued with a backtick" in shells, + "a doc-comment console fence continued the other shell's way passed", + ) + subprocess.run([*git, "rm", "-q", "--cached", "lib.rs"], check=True) + (top / "lib.rs").unlink() + labelled = f"session.name(); // → {'d' * 80}" + (top / "README.md").write_text(f"```java\n{labelled}\n```\n") + told = run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + "Keep the code and its marker on one line" in told + and told.split("\n[[allow]]")[0].count("[[allow]]") == 1, + "a trailing marker line's advice named [[allow]] twice", + ) + output = f"// Output: {'o' * 80}" + (top / "README.md").write_text(f"```go\n{edited}\n{output}\n```\n") + marked = run( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + expect( + "#what-never-breaks); add the first other wide line to [[allow]]" in marked, + "a marker line after an edited allowed line named [[allow]] twice", + ) + second = "print(count, " + "z" * 80 + ")" + moved = second.replace("count", "counts") + (top / "README.md").write_text(f"```py\n{edited}\n{moved}\n```\n") + both = run( + 'markdown = ["README.md"]\nallow_ceiling = 2\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + f'[[allow]]\npath = "README.md"\nline = "{second}"\nreason = "r"\n' + ) + expect( + "[[allow]]" not in both + and f'line = "{moved}"' in both + and "allow_ceiling stays 2" in both, + "two allowed lines edited in one file were not both paired", + ) + (top / "notes.md").write_text("x\n") + subprocess.run([*git, "add", "notes.md"], check=True) + (top / "README.md").write_text(f"```py\n{allowed}\n```\n") + unread = run( + 'markdown = ["notes.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + expect( + "no longer reads it as wide" in unread and "Delete" not in unread, + "an allowed line a glob change hid was offered for deletion", + ) + (top / "README.md").write_text("```py\nprint(1)\n```\n") + fits = run( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + 'path = "README.md"\nline = "print(1)"\nreason = "r"\n' + ) + expect( + "Delete the stale allow entry for README.md" in fits + and "names a line that now fits in 80" in fits + and "no longer reads" not in fits, + "an allowed line that now fits was not offered for deletion", + ) + (top / "README.md").write_text(f"```py\n{allowed}\n```\n") + (top / "examples").mkdir() + (top / "examples" / "new.txt").write_text("x\n") + expect( + "examples/new.txt: untracked, under examples/ but selected by no glob; " + '`git add` it, then add "examples/new.txt" to sources' + in run('markdown = ["README.md"]\nallow_ceiling = 0\nwidth = 1\n'), + "an untracked, unselected example file went unnamed", + ) + (top / "README.md").write_text("```console\n$ ls `\n -l\n```\n") + expect( + "is continued with a backtick" + in run('markdown = ["README.md"]\nallow_ceiling = 0\n'), + "a run left a `$ ` command continued with a backtick unreported", + ) + print("self-test:", "failed" if failures else "ok") + return 1 if failures else 0 + + +def main(argv: list[str] | None = None) -> int: + """Check the repository, or run the self-test.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--self-test", action="store_true", help="prove the checker can fail" + ) + parser.add_argument( + "--digest", + action="store_true", + help="print digests of this checker and the shared WRITING.md section", + ) + parser.add_argument( + "--root", type=pathlib.Path, help="repository root (default: git toplevel)" + ) + args = parser.parse_args(argv) + if args.self_test: + return self_test() + root = args.root or pathlib.Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + if args.digest: + for line in digests(root): + print(line) + return 0 + try: + config = tomllib.loads((root / CONFIG).read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as error: + print(f"{CONFIG}: cannot read the config: {error}") + print("1 setup problem; fix the config first.") + return 1 + wrong = type_problems(config) + if wrong: + for problem in wrong: + print(f"{CONFIG}: {problem}") + print(f"{plural(len(wrong), 'setup problem')}; fix the config first.") + return 1 + files = tracked_files(root) + writing = writing_path(root) + # Each problem names the file to change; routing problems name their own. + setup = config_problems(config, files) + problems = ( + [f"{CONFIG}: {problem}" for problem in setup] + + copy_problems(root, config) + + digest_problems(root, config) + + marker_problems(root) + + routing_problems(root) + ) + new = untracked_matches(config, tracked_files(root, others=True)) + chosen = [path for path in new if selected(config, path)] + shown = ", ".join(chosen[:3]) + (", ..." if len(chosen) > 3 else "") + notice = ( + f"Not checked until `git add`: {plural(len(chosen), 'untracked file')} " + f"the config selects ({shown})." + if chosen + else "" + ) + # An untracked file does not fail the run, but its wide lines are shown + # so a new example can be fixed before it is added. + pending, _ = check(root, config, chosen) if chosen and not setup else ([], []) + if pending: + they = "it fails" if len(chosen) == 1 else "they fail" + notice += f" Once added, {they} on {plural(len(pending), 'wide line')}: " + ( + "; ".join(str(finding) for finding in pending[:3]) + ) + notice = "\n".join( + [notice] * bool(notice) + + [ + f"{path}: untracked, under examples/ but selected by no glob; `git add` " + "it, then " + select_advice(path) + for path in new + if path not in chosen + ] + ) + bad_path = None + if writing: + where = writing.relative_to(root).as_posix() + text = writing.read_text(encoding="utf-8") + block = port_block_problems(text) + problems += [f"{where}: {problem}" for problem in block] + bad_path = where if any("Bad example fits" in p for p in block) else None + # A config problem such as a misspelled glob key would make allow entries + # look stale, so the width pass waits until the config is right. + findings, stale = check(root, config, files) if not setup else ([], []) + problems += prompt_problems(root, config, files) if not setup else [] + annotate = os.environ.get("GITHUB_ACTIONS") == "true" + for problem in problems: + print(problem) + for finding in findings: + print(finding) + if annotate: + print( + f"::error file={finding.path},line={finding.number}::" + f"{finding.columns} columns > {finding.width}" + ) + # A line still in its file is hidden only while it is still wide; one that + # now fits needs its entry deleted. + width, tab_width = config.get("width", 80), config.get("tab_width", 4) + present = [entry for entry in stale if entry["line"] in source_lines(root, entry)] + hidden = [ + entry + for entry in present + if columns(entry["line"].expandtabs(tab_width)) > width + ] + absent = [entry for entry in stale if entry not in present] + shortened = shortened_entries(root, absent, width, tab_width) if absent else [] + for entry in stale: + how = ( + "names a line the check no longer reads" + if entry in hidden + else f"names a line that now fits in {width}" + if entry in present + else f"names a line shortened to fit in {width}" + if entry in shortened + else "matches no line" + ) + print( + f"{CONFIG}: allow entry for {entry['path']} {how}: {quoted(entry['line'])}" + ) + if problems or findings or stale: + page = writing or writing_file(root) + rules = page.relative_to(root).as_posix() if page else "WRITING.md" + print( + f"{plural(len(problems), 'problem')}, " + f"{plural(len(findings), 'wide line')}, " + f"{plural(len(stale), 'stale allow entry', 'stale allow entries')}." + ) + if setup: + print(f"The width check runs once the problems in {CONFIG} are fixed.") + edited = "" + # Name an entry by its line when its file has two allow entries. + allowed = [e for e in config.get("allow", []) if isinstance(e, dict)] + if findings: + first = findings[0] + many = len(findings) > 1 + pairs = edit_pairs(findings, stale, bad_path, shortened) + if pairs: + # An allowed line changed: its entry already says why it stays + # whole, so it takes the new text. + first, edited = pairs[0][0], pairs[0][0].path + named = entry_name(pairs[0][1], allowed) + print( + f"{edited}: an allowed line changed; its entry says why it stays " + f"whole. Put its new text in the stale entry for {named} (if the " + "line is new instead, give it an entry of its own):" + ) + else: + lead = ( + f"The first of {len(findings)}, as an entry:" + if many + else "As an entry:" + ) + advice = fix_advice(first, rules) + if "[[allow]]" in advice and not advice.startswith(kept_whole(rules)): + # The advice already says when to allow the line. + print(advice) + print(lead) + elif advice.startswith(kept_whole(rules)): + # The rule itself says to allow it; say [[allow]] once. + print( + f"{kept_whole(rules)}; add the line to [[allow]] in " + f"{CONFIG} with its reason. {lead}" + ) + else: + print(advice) + print( + "If no change keeps the example clear, add the line to " + f"[[allow]] in {CONFIG} with a reason. {lead}" + ) + hint = allow_hint(first) + print(hint.splitlines()[2] if edited else hint) + for finding, entry in pairs[1:]: + print(f"In the stale entry for {entry_name(entry, allowed)}, put:") + print(allow_hint(finding).splitlines()[2]) + paired = [finding for finding, _ in pairs] + others = [finding for finding in findings if finding not in paired] + if edited and others: + advice = fix_advice(others[0], rules) + if advice.startswith(kept_whole(rules)): + print( + f"{kept_whole(rules)}; add the first other wide line to " + f"[[allow]] in {CONFIG} with its reason:" + ) + else: + print(advice) + print( + "If no change keeps the example clear, add the first " + f"other wide line to [[allow]] in {CONFIG}:" + ) + print(allow_hint(others[0])) + if findings or stale: + count = len(config.get("allow", [])) + for hint in ceiling_hints( + findings, stale, count, bad_path, hidden, allowed, shortened + ): + if not (edited and "an allowed line changed" in hint): + print(hint) + if notice: + print(notice) + return 1 + read = [g for key in GLOB_KEYS for g in config.get(key, [])] + skip = [entry["glob"] for entry in entries(config, "exclude")] + count = sum(matches(path, read) and not matches(path, skip) for path in files) + print(f"{count} files checked; no example is over {config.get('width', 80)}.") + if notice: + print(notice) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_shared_examples.py b/scripts/check_shared_examples.py new file mode 100644 index 000000000..2b63b033a --- /dev/null +++ b/scripts/check_shared_examples.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +"""Fail when a libtmux port's shared example rules differ from this repo's. + +Every port carries a copy of ``check_example_width.py`` and the text between +the ``shared:examples`` markers in WRITING.md. The rule is to change both in +all ports together. This script fetches each port's copy from the default +branch on raw.githubusercontent.com, digests it the way +``check_example_width.py --digest`` does, and compares the result with this +repository's own files. + +A port with a missing checker or shared section fails, unless it is listed in +``PENDING`` with the reason it cannot be read yet and the date it was listed; +a pending port passes with a notice. A pending port whose checker and shared +section are both present fails until it leaves ``PENDING``, a port listed for +more than ``PENDING_DAYS`` days fails, so the list cannot go stale quietly, +and a port whose file differs fails. Remove a port from ``PENDING`` when its +example-style change merges. + +To roll out a change to the shared text or the checker, record the digests +the ports still carry in ``PREVIOUS`` with the last day they are accepted: +a port on that version passes with a notice until then, so ports can merge +one at a time, and fails after it. + +``NOT_FETCHED`` lists a port this script cannot read, such as a private +repository whose raw files return 404 without a token, with the reason and +where that port is compared instead; this script prints a notice for it +rather than fetching. Every port is public, so the list is empty. + +A repository of the libtmux organisation that is neither listed nor this +one is looked up through the GitHub REST API (``GET /orgs/libtmux/repos``, +public and not archived, paginated; ``GITHUB_TOKEN`` is sent as a bearer +token when set). If its ``.github/WRITING.md`` or ``WRITING.md`` carries the +``shared:examples`` markers, the script fails with " carries the +shared example section but is not in PORTS", so a new adopter cannot go +uncompared. + +It needs the network, so it runs in CI only, never in the local test loops; +``--self-test`` needs none. +""" + +from __future__ import annotations + +import argparse +import datetime +import hashlib +import json +import os +import pathlib +import sys +import typing as t +import urllib.error +import urllib.request + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +# Importing the checker must not leave a __pycache__ beside it. +sys.dont_write_bytecode = True +import check_example_width as width # noqa: E402 + +CHECKER = "check_example_width.py" +# repository -> (directory holding the checker, path of WRITING.md) +PORTS: dict[str, tuple[str, str]] = { + "libtmux/libtmux-cxx": ("tools/docs", ".github/WRITING.md"), + "libtmux/libtmux-dotnet": ("eng/docs", ".github/WRITING.md"), + "libtmux/libtmux-go": ("internal/tools", ".github/WRITING.md"), + "libtmux/libtmux-java": ("tools", ".github/WRITING.md"), + "libtmux/libtmux-julia": ("dev", "WRITING.md"), + "libtmux/libtmux-lua": ("scripts", ".github/WRITING.md"), + "libtmux/libtmux-powershell": ("eng", ".github/WRITING.md"), + "libtmux/libtmux-rs": ("scripts", ".github/WRITING.md"), + "libtmux/libtmux-ruby": ("scripts", ".github/WRITING.md"), + "libtmux/libtmux-swift": ("Scripts", ".github/WRITING.md"), + "libtmux/libtmux-ts": ("scripts", ".github/WRITING.md"), + "tmux-python/libtmux": ("scripts", ".github/WRITING.md"), +} +# Ports this script never fetches, with the reason and where they are checked. +NOT_FETCHED: dict[str, str] = {} +# During a change to the shared text or checker: the digest ports may still +# carry for each file ("checker" or "shared section"), and the last day it is +# accepted. Empty between changes. +PREVIOUS: dict[str, tuple[str, datetime.date]] = { + # libtmux-powershell merged this checker before its marker-fallback fix. + "checker": ( + "f261e9a3ef44101ead5fe1ce03331716372aac828f7421ef8f06c83c987ee956", + datetime.date(2026, 11, 10), + ), + # libtmux-powershell merged the shared section before its copy-step wording. + "shared section": ( + "872d3f4cb1eb2b5cd9c5b7adc9cd140a153e4bbed3cb5bd0b7400a905e6dae4d", + datetime.date(2026, 11, 10), + ), +} +# Ports whose missing files are expected: the reason and the date listed. +PENDING_DAYS = 30 +_UNMERGED = ("example-style change not merged yet", datetime.date(2026, 10, 4)) +PENDING: dict[str, tuple[str, datetime.date]] = { + "libtmux/libtmux-cxx": _UNMERGED, + "libtmux/libtmux-dotnet": _UNMERGED, + "libtmux/libtmux-go": _UNMERGED, + "libtmux/libtmux-java": _UNMERGED, + "libtmux/libtmux-julia": _UNMERGED, + "libtmux/libtmux-lua": _UNMERGED, + "libtmux/libtmux-rs": _UNMERGED, + "libtmux/libtmux-ruby": _UNMERGED, + "libtmux/libtmux-swift": _UNMERGED, + "libtmux/libtmux-ts": _UNMERGED, + "tmux-python/libtmux": _UNMERGED, +} +OWN_REPO = "libtmux/docs" +ORG = "libtmux" +ORG_REPOS = "https://api.github.com/orgs/{org}/repos?type=public&per_page=100&page={page}" +WRITING_PATHS = (".github/WRITING.md", "WRITING.md") +RAW = "https://raw.githubusercontent.com/{repo}/HEAD/{path}" + +Fetch = t.Callable[[str, str], t.Optional[bytes]] +ListRepos = t.Callable[[], list[str]] + + +def fetch_raw(repo: str, path: str) -> bytes | None: + """Return a file from the repo's default branch, or None when absent.""" + request = urllib.request.Request(RAW.format(repo=repo, path=path)) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + except urllib.error.HTTPError as error: + if error.code == 404: + return None + raise + + +def list_org_repos() -> list[str]: + """Return ``org/name`` for each public, non-archived organisation repo.""" + headers = {"Accept": "application/vnd.github+json"} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + names: list[str] = [] + page = 1 + while True: + request = urllib.request.Request( + ORG_REPOS.format(org=ORG, page=page), headers=headers + ) + with urllib.request.urlopen(request, timeout=30) as response: + repos = json.load(response) + names += [r["full_name"] for r in repos if not r["archived"]] + if len(repos) < 100: + return names + page += 1 + + +def unlisted_adopters( + ports: dict[str, tuple[str, str]], + list_repos: ListRepos, + fetch: Fetch, + own_repo: str = OWN_REPO, +) -> list[str]: + """Return a failure for each unlisted repo that carries the markers.""" + failures: list[str] = [] + for repo in sorted(list_repos()): + if repo == own_repo or repo in ports: + continue + for path in WRITING_PATHS: + if shared_digest(fetch(repo, path)) is not None: + failures.append( + f"{repo} carries the shared example section " + "but is not in PORTS" + ) + break + return failures + + +def shared_digest(writing: bytes | None) -> str | None: + """Digest the shared section of a WRITING.md the way --digest does.""" + if writing is None: + return None + match = width.SHARED.search(writing.decode("utf-8")) + if not match: + return None + return hashlib.sha256(match["body"].encode()).hexdigest() + + +def checker_digest(checker: bytes | None) -> str | None: + return None if checker is None else hashlib.sha256(checker).hexdigest() + + +def local_digests(root: pathlib.Path) -> tuple[str | None, str | None]: + checker = root / "scripts" / CHECKER + writing = root / "WRITING.md" + return ( + checker_digest(checker.read_bytes()), + shared_digest(writing.read_bytes() if writing.is_file() else None), + ) + + +def compare( + own: tuple[str | None, str | None], + ports: dict[str, tuple[str, str]], + fetch: Fetch, + pending: dict[str, tuple[str, datetime.date]] | None = None, + not_fetched: dict[str, str] | None = None, + today: datetime.date | None = None, + previous: dict[str, tuple[str, datetime.date]] | None = None, +) -> tuple[list[str], list[str]]: + """Return (failures, notices) for every port against ``own``. + + A missing file is a failure unless the port is in ``pending``, and a + pending port with both files present is a failure too. A port in + ``not_fetched`` is never fetched and gives a notice. + """ + pending = pending or {} + not_fetched = not_fetched or {} + previous = previous or {} + today = today or datetime.datetime.now(datetime.UTC).date() + failures: list[str] = [ + f"{repo}: in PENDING but not in PORTS" for repo in sorted(set(pending) - set(ports)) + ] + notices: list[str] = [] + for repo, (_, since) in sorted(pending.items()): + if (today - since).days > PENDING_DAYS: + failures.append( + f"{repo}: pending since {since}, more than {PENDING_DAYS} days; " + "merge its example-style change or update the reason and date" + ) + for repo, (directory, writing_path) in sorted(ports.items()): + if repo in not_fetched: + notices.append(f"{repo}: not fetched ({not_fetched[repo]})") + continue + theirs = ( + checker_digest(fetch(repo, f"{directory}/{CHECKER}")), + shared_digest(fetch(repo, writing_path)), + ) + if repo in pending and None not in theirs: + failures.append(f"{repo}: both files present; remove it from PENDING") + continue + for label, mine, other, path in ( + ("checker", own[0], theirs[0], f"{directory}/{CHECKER}"), + ("shared section", own[1], theirs[1], writing_path), + ): + if other is None and repo in pending: + reason, since = pending[repo] + notices.append(f"{repo}: no {label} at {path} ({reason}, since {since})") + elif other is None: + failures.append(f"{repo}: no {label} at {path}, and not pending") + elif other != mine and label in previous and other == previous[label][0]: + until = previous[label][1] + said = f"{repo}: {label} in {path} is the previous version, accepted" + if today > until: + failures.append(f"{said} only until {until}") + else: + notices.append(f"{said} until {until}") + elif other != mine: + failures.append( + f"{repo}: {label} in {path} differs " + f"(theirs {other[:12]}, ours {str(mine)[:12]})" + ) + return failures, notices + + +def summary(failures: list[str]) -> str: + """Return the closing line of a failed run, naming what to change. + + >>> summary(["a/one: checker in x differs (theirs 1, ours 2)"]) + '1 file differs; change the shared rules in every port together' + >>> summary(["a: both files present; remove it from PENDING"] * 2) + '2 list entries are out of date; edit PORTS, PENDING or PREVIOUS as each line says' + """ + drift = sum(" differs " in f or " only until " in f for f in failures) + stale = len(failures) - drift + parts = [] + if drift: + files = "1 file differs" if drift == 1 else f"{drift} files differ" + parts.append(f"{files}; change the shared rules in every port together") + if stale: + entries = "1 list entry is" if stale == 1 else f"{stale} list entries are" + parts.append( + f"{entries} out of date; edit PORTS, PENDING or PREVIOUS as each line says" + ) + return ". ".join(parts) + + +def self_test() -> int: + """Plant a mismatch and a missing file, then clean controls.""" + problems: list[str] = [] + + def expect(ok: bool, what: str) -> None: + if not ok: + problems.append(what) + + marked = b"x\n\nrules\n\n" + own = (checker_digest(b"checker"), shared_digest(marked)) + expect(own[1] is not None, "markers were not found in the control text") + ports = {"a/one": ("scripts", "WRITING.md")} + + def serve(files: dict[str, bytes]) -> Fetch: + return lambda repo, path: files.get(path) + + clean = {"scripts/" + CHECKER: b"checker", "WRITING.md": marked} + failures, notices = compare(own, ports, serve(clean)) + expect(not failures and not notices, "a matching port was reported") + + drifted = dict(clean, **{"scripts/" + CHECKER: b"edited checker"}) + failures, _ = compare(own, ports, serve(drifted)) + expect(len(failures) == 1 and "checker" in failures[0], "checker drift passed") + + edited = dict(clean, **{"WRITING.md": marked.replace(b"rules", b"other")}) + failures, _ = compare(own, ports, serve(edited)) + expect(len(failures) == 1 and "shared" in failures[0], "text drift passed") + + failures, notices = compare(own, ports, serve({})) + expect(len(failures) == 2 and not notices, "a missing file passed") + + today = datetime.date(2026, 10, 4) + waiting = {"a/one": ("not merged", today)} + failures, notices = compare(own, ports, serve({}), waiting, today=today) + expect(not failures and len(notices) == 2, "a pending port failed") + + later = today + datetime.timedelta(days=PENDING_DAYS + 1) + failures, _ = compare(own, ports, serve({}), waiting, today=later) + expect(len(failures) == 1 and "pending since" in failures[0], "a stale entry passed") + + rolling = {"checker": (checker_digest(b"edited checker") or "", today)} + failures, notices = compare( + own, ports, serve(drifted), today=today, previous=rolling + ) + expect(not failures and len(notices) == 1, "the previous checker failed early") + failures, _ = compare(own, ports, serve(drifted), today=later, previous=rolling) + expect(len(failures) == 1 and "only until" in failures[0], "an expired version passed") + + stray = {"b/two": ("not merged", today)} + failures, _ = compare(own, ports, serve(clean), stray, today=today) + expect(failures == ["b/two: in PENDING but not in PORTS"], "an unknown entry passed") + + half = {"scripts/" + CHECKER: b"edited checker"} + failures, notices = compare(own, ports, serve(half), waiting, today=today) + expect( + len(failures) == 1 and "differs" in failures[0] and len(notices) == 1, + "a pending port with drift passed", + ) + + failures, notices = compare(own, ports, serve(clean), waiting, today=today) + expect( + len(failures) == 1 and "remove it from PENDING" in failures[0], + "a pending port with both files present passed", + ) + expect(not notices, "a pending port with both files gave a notice") + + def refuse(repo: str, path: str) -> bytes | None: + raise AssertionError(f"fetched {repo}") + + private = {"a/one": "private; checked by its own job"} + failures, notices = compare(own, ports, refuse, None, private) + expect(not failures and len(notices) == 1, "a not-fetched port failed") + expect("own job" in "".join(notices), "the not-fetched notice lost its reason") + + unmarked = dict(clean, **{"WRITING.md": b"no markers"}) + failures, notices = compare(own, ports, serve(unmarked)) + expect(len(failures) == 1 and not notices, "a port without markers passed") + + stranger = {"a/new": {".github/WRITING.md": marked}, "a/plain": {}} + + def stranger_fetch(repo: str, path: str) -> bytes | None: + return stranger[repo].get(path) + + def listing() -> list[str]: + return ["a/one", "libtmux/docs", "a/new", "a/plain"] + + failures = unlisted_adopters(ports, listing, stranger_fetch) + expect( + failures == ["a/new carries the shared example section but is not in PORTS"], + "an unlisted adopter passed, or a listed or unmarked repo failed", + ) + + import doctest + + if doctest.testmod(sys.modules[__name__]).failed: + problems.append("a doctest failed") + for problem in problems: + print(f"self-test: {problem}", file=sys.stderr) + if not problems: + print("self-test: ok") + return 1 if problems else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true", help="run without network") + parser.add_argument("--root", type=pathlib.Path, help="this repository's root") + args = parser.parse_args() + if args.self_test: + return self_test() + root = args.root or pathlib.Path(__file__).resolve().parent.parent + own = local_digests(root) + if None in own: + print("this repository lacks the checker or the shared section", file=sys.stderr) + return 1 + failures, notices = compare(own, PORTS, fetch_raw, PENDING, NOT_FETCHED, previous=PREVIOUS) + failures += unlisted_adopters(PORTS, list_org_repos, fetch_raw) + for notice in notices: + print(f"notice: {notice}") + for failure in failures: + print(f"error: {failure}", file=sys.stderr) + if failures: + print(summary(failures), file=sys.stderr) + return 1 + compared = [repo for repo in PORTS if repo not in PENDING and repo not in NOT_FETCHED] + print( + f"{len(compared)} port{'' if len(compared) == 1 else 's'} compared, " + f"{len(PENDING)} pending, " + f"{len(NOT_FETCHED)} not fetched, {len(notices)} notices" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/crawl-site.mjs b/scripts/crawl-site.mjs index 477c0fa24..f1b5baeea 100644 --- a/scripts/crawl-site.mjs +++ b/scripts/crawl-site.mjs @@ -4,23 +4,33 @@ const refs = new Map() const queue = ['/'] const SKIP = /\.(woff2?|ttf|eot|png|jpe?g|gif|svg|ico|css|js|json|xml|inv|txt|map|pf_meta|pf_index|pf_fragment|wasm)$/i -function note(t, f) { (refs.get(t) ?? refs.set(t, new Set()).get(t)).add(f) } +function note(t, f) { + ;(refs.get(t) ?? refs.set(t, new Set()).get(t)).add(f) +} while (queue.length) { const path = queue.shift() if (seen.has(path)) continue - let res, body = '' + let res, + body = '' try { res = await fetch(ROOT + path) seen.set(path, res.status) if ((res.headers.get('content-type') || '').includes('text/html')) body = await res.text() - } catch { seen.set(path, 'ERR'); continue } + } catch { + seen.set(path, 'ERR') + continue + } if (!body) continue for (const m of body.matchAll(/(?:href|src)="([^"]+)"/g)) { const raw = m[1] if (/^(https?:|mailto:|#|data:|javascript:)/i.test(raw)) continue let u - try { u = new URL(raw, ROOT + path) } catch { continue } + try { + u = new URL(raw, ROOT + path) + } catch { + continue + } if (u.origin !== ROOT) continue const p = u.pathname if (SKIP.test(p)) continue diff --git a/scripts/fetch-inventories.mjs b/scripts/fetch-inventories.mjs index ff2c2c8e6..215c48a4c 100644 --- a/scripts/fetch-inventories.mjs +++ b/scripts/fetch-inventories.mjs @@ -89,7 +89,9 @@ for (const [name, url] of Object.entries(SOURCES)) { const { project, entries } = readInventory(bytes) writeFileSync(out, bytes) writeSidecar(name, bytes) - console.log(`fetch-inventories: ${name} -> ${entries.length} entries (${project}) ${(bytes.length / 1024).toFixed(0)} KB`) + console.log( + `fetch-inventories: ${name} -> ${entries.length} entries (${project}) ${(bytes.length / 1024).toFixed(0)} KB`, + ) } catch (err) { console.error(`fetch-inventories: ${name} failed — ${err.message}`) failed++ diff --git a/scripts/gen-api-model.mjs b/scripts/gen-api-model.mjs index e4915181e..2e242edb2 100644 --- a/scripts/gen-api-model.mjs +++ b/scripts/gen-api-model.mjs @@ -75,13 +75,16 @@ function git(repo, ...args) { /** Read package versions from their owning manifest, independent of docs URL labels. */ function packageVersion(checkout, port, product) { - const read = (file) => existsSync(join(checkout, file)) ? readFileSync(join(checkout, file), 'utf8') : '' + const read = (file) => (existsSync(join(checkout, file)) ? readFileSync(join(checkout, file), 'utf8') : '') if (port === 'py') return /^version\s*=\s*"([^"]+)"/m.exec(read('pyproject.toml'))?.[1] - if (port === 'ts') return JSON.parse(read(`packages/${product === 'core' ? 'libtmux' : product}/package.json`)).version + if (port === 'ts') + return JSON.parse(read(`packages/${product === 'core' ? 'libtmux' : product}/package.json`)).version if (port === 'rs') { const crate = product === 'core' ? 'libtmux' : product === 'workspace' ? 'tmux-workspace' : 'tmux-mcp' - return /^version\s*=\s*"([^"]+)"/m.exec(read(`crates/${crate}/Cargo.toml`))?.[1] - ?? /^version\s*=\s*"([^"]+)"/m.exec(read('Cargo.toml'))?.[1] + return ( + /^version\s*=\s*"([^"]+)"/m.exec(read(`crates/${crate}/Cargo.toml`))?.[1] ?? + /^version\s*=\s*"([^"]+)"/m.exec(read('Cargo.toml'))?.[1] + ) } if (['java', 'kotlin', 'scala'].includes(port)) return /^libtmuxVersion=(.+)$/m.exec(read('gradle.properties'))?.[1] if (['csharp', 'fsharp'].includes(port)) { @@ -214,13 +217,17 @@ const PORTS = { }, powershell: { checkout: '~/work/libtmux/libtmux-powershell-docs-site', - root: '.', nativeArtifact: 'build/docs/api.json', - repo: 'libtmux/libtmux-powershell', options: {}, + root: '.', + nativeArtifact: 'build/docs/api.json', + repo: 'libtmux/libtmux-powershell', + options: {}, }, julia: { checkout: '~/work/libtmux/libtmux-julia-docs-site', - root: '.', nativeArtifact: 'docs/build/api.json', - repo: 'libtmux/libtmux-julia', options: {}, + root: '.', + nativeArtifact: 'docs/build/api.json', + repo: 'libtmux/libtmux-julia', + options: {}, }, lua: { checkout: '~/work/libtmux/libtmux-lua-docs', @@ -253,8 +260,13 @@ const PORTS = { }, java: { checkout: '~/work/libtmux/libtmux-java', - roots: ['libtmux/src/main/java', 'libtmux/build/generated/sources/fieldCatalog/java/main', - 'libtmux-workspace/src/main/java', 'libtmux-jackson/src/main/java', 'libtmux-junit5/src/main/java'], + roots: [ + 'libtmux/src/main/java', + 'libtmux/build/generated/sources/fieldCatalog/java/main', + 'libtmux-workspace/src/main/java', + 'libtmux-jackson/src/main/java', + 'libtmux-junit5/src/main/java', + ], generate: [':libtmux:generateFieldMetamodel'], generateWhen: 'build-logic/conventions/src/main/kotlin/libtmux.field-catalog.gradle.kts', repo: 'libtmux/libtmux-java', @@ -271,23 +283,35 @@ const PORTS = { roots: ['libtmux-kotlin/src/main/kotlin', 'libtmux-kotlin/build/generated/sources/operations/kotlin'], generate: [':libtmux-kotlin:generateOperationWrappers'], pathRoots: ['libtmux-kotlin/', 'docs/guide/kotlin.md', 'examples/src/main/kotlin/'], - repo: 'libtmux/libtmux-java', options: {}, + repo: 'libtmux/libtmux-java', + options: {}, }, scala: { checkout: '~/work/libtmux/libtmux-java', - roots: ['libtmux-scala/src/main/scala', 'libtmux-scala/build/generated/sources/catalog/scala', - 'libtmux-scala-cats/src/main/scala', 'libtmux-scala-cats/build/generated/sources/catalog/scala', - 'libtmux-scala-ox/src/main/scala'], + roots: [ + 'libtmux-scala/src/main/scala', + 'libtmux-scala/build/generated/sources/catalog/scala', + 'libtmux-scala-cats/src/main/scala', + 'libtmux-scala-cats/build/generated/sources/catalog/scala', + 'libtmux-scala-ox/src/main/scala', + ], generate: [':libtmux-scala:generateScalaSources', ':libtmux-scala-cats:generateScalaSources'], - pathRoots: ['libtmux-scala/', 'libtmux-scala-cats/', 'libtmux-scala-ox/', - 'docs/guide/scala/', 'examples/src/main/scala/'], - repo: 'libtmux/libtmux-java', options: {}, + pathRoots: [ + 'libtmux-scala/', + 'libtmux-scala-cats/', + 'libtmux-scala-ox/', + 'docs/guide/scala/', + 'examples/src/main/scala/', + ], + repo: 'libtmux/libtmux-java', + options: {}, }, fsharp: { checkout: '~/work/libtmux/libtmux-dotnet', root: 'src/LibTmux.FSharp', pathRoots: ['src/LibTmux.FSharp/', 'docs/fsharp/', 'examples/LibTmux.FSharp.'], - repo: 'libtmux/libtmux-dotnet', options: {}, + repo: 'libtmux/libtmux-dotnet', + options: {}, }, // C++ comes from the Doxygen XML this project's own build already produces: // tree-sitter has no preprocessor, so `LIBTMUX_NAMESPACE_BEGIN` derails the @@ -311,7 +335,8 @@ const PORTS = { dir: 'symbolgraph', from: 'Sources', what: 'symbol graph', - build: 'swift build --enable-all-traits -Xswiftc -emit-symbol-graph -Xswiftc -emit-symbol-graph-dir -Xswiftc "$PWD/symbolgraph"', + build: + 'swift build --enable-all-traits -Xswiftc -emit-symbol-graph -Xswiftc -emit-symbol-graph-dir -Xswiftc "$PWD/symbolgraph"', }, repo: 'libtmux/libtmux-swift', options: {}, @@ -319,13 +344,18 @@ const PORTS = { } const args = process.argv.slice(2) -const repositoryPaths = (tree, cfg) => tree.split('\n').filter((path) => - path && (!cfg.pathRoots || !path.includes('/') || cfg.pathRoots.some((prefix) => path.startsWith(prefix)))) +const repositoryPaths = (tree, cfg) => + tree + .split('\n') + .filter( + (path) => + path && (!cfg.pathRoots || !path.includes('/') || cfg.pathRoots.some((prefix) => path.startsWith(prefix))), + ) const only = args.includes('--port') ? args[args.indexOf('--port') + 1] : undefined const check = args.includes('--check') /** Feed configured upstream C sources through the same Doxygen extractor as C++. */ function generateTmuxModel() { - const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback + const value = (flag, fallback) => (args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback) const version = value('--version') const pins = JSON.parse(readFileSync(join(repoRoot, 'site/src/data/tmux/versions.json'), 'utf8')) const pin = pins.versions.find((entry) => entry.version === version) @@ -337,10 +367,14 @@ function generateTmuxModel() { if (!commit || !tree) throw new Error(`Missing pinned tmux Git object ${revision}`) const scratch = mkdtempSync(join(tmpdir(), 'libtmux-doxygen-c-')) const source = resolve(value('--source-root', join(scratch, 'source'))) - const run = (command, argv, cwd = source) => execFileSync(command, argv, { - cwd, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' }, - }) + const run = (command, argv, cwd = source) => + execFileSync(command, argv, { + cwd, + encoding: 'utf8', + maxBuffer: 1 << 28, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' }, + }) const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') const quote = (text) => `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` try { @@ -349,8 +383,21 @@ function generateTmuxModel() { const archive = execFileSync('git', ['-C', checkout, 'archive', commit], { maxBuffer: 1 << 28 }) execFileSync('tar', ['-x', '-C', source], { input: archive }) run('sh', ['autogen.sh']) - execFileSync('./configure', [], { cwd: source, encoding: 'utf8', maxBuffer: 1 << 26, - env: { ...process.env, CC: process.env.CC || 'clang-18', CFLAGS: '', CPPFLAGS: '', LDFLAGS: '', LIBS: '', LC_ALL: 'C', TZ: 'UTC' } }) + execFileSync('./configure', [], { + cwd: source, + encoding: 'utf8', + maxBuffer: 1 << 26, + env: { + ...process.env, + CC: process.env.CC || 'clang-18', + CFLAGS: '', + CPPFLAGS: '', + LDFLAGS: '', + LIBS: '', + LC_ALL: 'C', + TZ: 'UTC', + }, + }) } // A reused configured archive must still contain every exact tracked byte. const tracked = (git(checkout, 'ls-tree', '-r', commit) ?? '').split('\n').map((line) => { @@ -365,12 +412,25 @@ function generateTmuxModel() { if (actual !== blob) throw new Error(`Configured tmux input differs from ${commit}: ${file}`) sourceHashes[file] = sha256(bytes) } - const makeWords = (variable) => run('make', ['--no-print-directory', '-s', - `--eval=model-values: ; @printf "%s\\0" $(${variable})`, 'model-values']).split('\0').filter(Boolean) + const makeWords = (variable) => + run('make', [ + '--no-print-directory', + '-s', + `--eval=model-values: ; @printf "%s\\0" $(${variable})`, + 'model-values', + ]) + .split('\0') + .filter(Boolean) const compile = makeWords('COMPILE') const configuredSources = [...makeWords('dist_tmux_SOURCES'), ...makeWords('nodist_tmux_SOURCES')] const trackedPaths = new Set(tracked.map(({ file }) => file)) - const translationUnits = [...new Set(configuredSources.filter((file) => file.endsWith('.c') && trackedPaths.has(file) && !file.startsWith('compat/')))].sort() + const translationUnits = [ + ...new Set( + configuredSources.filter( + (file) => file.endsWith('.c') && trackedPaths.has(file) && !file.startsWith('compat/'), + ), + ), + ].sort() const headers = [...trackedPaths].filter((file) => !file.includes('/') && file.endsWith('.h')).sort() const inputs = [...translationUnits, ...headers].sort() const inputSet = new Set(inputs) @@ -382,26 +442,47 @@ function generateTmuxModel() { if (include) includePaths.push(include[1] || compile[++i]) } const doxygenVersion = run(doxygen, ['--version']).trim() - if (!/^1\.18\.0(?:\s|$)/.test(doxygenVersion)) throw new Error(`Expected tested Doxygen 1.18.0, got ${doxygenVersion}`) + if (!/^1\.18\.0(?:\s|$)/.test(doxygenVersion)) + throw new Error(`Expected tested Doxygen 1.18.0, got ${doxygenVersion}`) const config = [ - 'PROJECT_NAME = tmux', `OUTPUT_DIRECTORY = ${quote(scratch)}`, - `INPUT = ${inputs.map(quote).join(' ')}`, 'EXTENSION_MAPPING = c=C h=C', - 'OPTIMIZE_OUTPUT_FOR_C = YES', 'EXTRACT_ALL = YES', 'EXTRACT_STATIC = YES', - 'EXTRACT_LOCAL_CLASSES = YES', 'GENERATE_HTML = NO', 'GENERATE_LATEX = NO', - 'GENERATE_XML = YES', 'XML_PROGRAMLISTING = YES', 'SOURCE_BROWSER = YES', - 'REFERENCES_RELATION = YES', 'REFERENCED_BY_RELATION = YES', - 'ENABLE_PREPROCESSING = YES', 'MACRO_EXPANSION = YES', 'EXPAND_ONLY_PREDEF = NO', - 'SEARCH_INCLUDES = YES', `INCLUDE_PATH = ${[...new Set(includePaths)].map(quote).join(' ')}`, + 'PROJECT_NAME = tmux', + `OUTPUT_DIRECTORY = ${quote(scratch)}`, + `INPUT = ${inputs.map(quote).join(' ')}`, + 'EXTENSION_MAPPING = c=C h=C', + 'OPTIMIZE_OUTPUT_FOR_C = YES', + 'EXTRACT_ALL = YES', + 'EXTRACT_STATIC = YES', + 'EXTRACT_LOCAL_CLASSES = YES', + 'GENERATE_HTML = NO', + 'GENERATE_LATEX = NO', + 'GENERATE_XML = YES', + 'XML_PROGRAMLISTING = YES', + 'SOURCE_BROWSER = YES', + 'REFERENCES_RELATION = YES', + 'REFERENCED_BY_RELATION = YES', + 'ENABLE_PREPROCESSING = YES', + 'MACRO_EXPANSION = YES', + 'EXPAND_ONLY_PREDEF = NO', + 'SEARCH_INCLUDES = YES', + `INCLUDE_PATH = ${[...new Set(includePaths)].map(quote).join(' ')}`, `PREDEFINED = ${[...compile.filter((arg) => arg.startsWith('-D')).map((arg) => arg.slice(2)), '__attribute__(x)='].map(quote).join(' ')}`, - 'SKIP_FUNCTION_MACROS = YES', 'QUIET = YES', 'WARNINGS = YES', 'WARN_IF_UNDOCUMENTED = NO', - `WARN_LOGFILE = ${quote(join(scratch, 'warnings.log'))}`, 'STRIP_FROM_PATH = .', 'HAVE_DOT = NO', + 'SKIP_FUNCTION_MACROS = YES', + 'QUIET = YES', + 'WARNINGS = YES', + 'WARN_IF_UNDOCUMENTED = NO', + `WARN_LOGFILE = ${quote(join(scratch, 'warnings.log'))}`, + 'STRIP_FROM_PATH = .', + 'HAVE_DOT = NO', ].join('\n') writeFileSync(join(scratch, 'Doxyfile'), `${config}\n`) run(doxygen, [join(scratch, 'Doxyfile')]) const warnings = readFileSync(join(scratch, 'warnings.log'), 'utf8').replaceAll(source, '') if (/\berror:/i.test(warnings)) throw new Error(`Doxygen errors: ${warnings}`) const referenceDiagnostics = [] - const extracted = extractDoxygen(join(scratch, 'xml'), source, { language: 'c', onDiagnostic: (message) => referenceDiagnostics.push(message) }) + const extracted = extractDoxygen(join(scratch, 'xml'), source, { + language: 'c', + onDiagnostic: (message) => referenceDiagnostics.push(message), + }) const symbols = extracted.filter((symbol) => inputSet.has(symbol.source.file)) const ids = new Map(symbols.map((symbol) => [symbol.id, symbol])) if (ids.size !== symbols.length) throw new Error('Duplicate C declaration identity') @@ -410,22 +491,31 @@ function generateTmuxModel() { symbol.apiScope = 'internal' symbol.source = { ...symbol.source, repo: 'tmux/tmux', revision: commit } if (symbol.references) symbol.references = symbol.references.filter((ref) => ids.has(ref.target)) - if (symbol.imports) symbol.imports = Object.fromEntries(Object.entries(symbol.imports).filter(([, target]) => ids.has(target))) + if (symbol.imports) + symbol.imports = Object.fromEntries(Object.entries(symbol.imports).filter(([, target]) => ids.has(target))) symbol.slug = pageSlug(symbol.id) } const slugs = new Map() for (const symbol of symbols) slugs.set(symbol.slug, [...(slugs.get(symbol.slug) ?? []), symbol]) - for (const group of slugs.values()) if (group.length > 1) for (const symbol of group) symbol.slug += `-${shortHash(symbol.id)}` + for (const group of slugs.values()) + if (group.length > 1) for (const symbol of group) symbol.slug += `-${shortHash(symbol.id)}` if (new Set(symbols.map((symbol) => symbol.slug)).size !== symbols.length) throw new Error('C page slug collision') - const commands = symbols.filter((symbol) => symbol.type === 'const struct cmd_entry' && symbol.value).map((symbol) => { - const name = /\.name\s*=\s*"([^"\\]+)"/.exec(symbol.value)?.[1] - const exec = /\.exec\s*=\s*([A-Za-z_]\w*)/.exec(symbol.value)?.[1] - if (!name || !exec) throw new Error(`Unresolved command initializer ${symbol.id}`) - const callbacks = (symbol.references ?? []).map((ref) => ids.get(ref.target)).filter((target) => target?.kind === 'function' && target.name === exec) - if (callbacks.length !== 1) throw new Error(`Command ${name} callback must resolve once through native XML references`) - return { name, entry: symbol.id, callback: callbacks[0].id } - }).sort((a, b) => a.name.localeCompare(b.name)) - if (new Set(commands.map((command) => command.name)).size !== commands.length) throw new Error('Duplicate command name') + const commands = symbols + .filter((symbol) => symbol.type === 'const struct cmd_entry' && symbol.value) + .map((symbol) => { + const name = /\.name\s*=\s*"([^"\\]+)"/.exec(symbol.value)?.[1] + const exec = /\.exec\s*=\s*([A-Za-z_]\w*)/.exec(symbol.value)?.[1] + if (!name || !exec) throw new Error(`Unresolved command initializer ${symbol.id}`) + const callbacks = (symbol.references ?? []) + .map((ref) => ids.get(ref.target)) + .filter((target) => target?.kind === 'function' && target.name === exec) + if (callbacks.length !== 1) + throw new Error(`Command ${name} callback must resolve once through native XML references`) + return { name, entry: symbol.id, callback: callbacks[0].id } + }) + .sort((a, b) => a.name.localeCompare(b.name)) + if (new Set(commands.map((command) => command.name)).size !== commands.length) + throw new Error('Duplicate command name') const notices = {} for (const file of inputs) { const contents = readFileSync(join(source, file), 'utf8') @@ -434,34 +524,62 @@ function generateTmuxModel() { } const copying = readFileSync(join(source, 'COPYING'), 'utf8') const model = { - schemaVersion: 1, project: 'tmux', language: 'c', version, repo: 'tmux/tmux', revision: commit, + schemaVersion: 1, + project: 'tmux', + language: 'c', + version, + repo: 'tmux/tmux', + revision: commit, extractor: 'doxygen-c-v1', profile: { - producer: `Doxygen ${doxygenVersion}`, producerBinarySha256: sha256(readFileSync(doxygenPath)), configuredPlatform: run(compile[0], ['-dumpmachine']).trim(), - compiler: run(compile[0], ['--version']).split('\n')[0], arguments: compile.slice(1), - pin: revision, sourceCommit: commit, sourceTree: tree, translationUnits, inputs, + producer: `Doxygen ${doxygenVersion}`, + producerBinarySha256: sha256(readFileSync(doxygenPath)), + configuredPlatform: run(compile[0], ['-dumpmachine']).trim(), + compiler: run(compile[0], ['--version']).split('\n')[0], + arguments: compile.slice(1), + pin: revision, + sourceCommit: commit, + sourceTree: tree, + translationUnits, + inputs, exclusions: [ - { paths: configuredSources.filter((file) => file.endsWith('.c') && !inputSet.has(file)).sort(), reason: 'Generated parser and compatibility implementation are outside the source reference.' }, - { paths: [...trackedPaths].filter((file) => file.endsWith('.c') && !configuredSources.includes(file)).sort(), reason: 'Not selected by this configured platform.' }, + { + paths: configuredSources.filter((file) => file.endsWith('.c') && !inputSet.has(file)).sort(), + reason: 'Generated parser and compatibility implementation are outside the source reference.', + }, + { + paths: [...trackedPaths].filter((file) => file.endsWith('.c') && !configuredSources.includes(file)).sort(), + reason: 'Not selected by this configured platform.', + }, ], - macroExpansion: 'Native included definitions and configured Makefile -D flags; no synthetic feature definitions.', - ignoredDecorations: ['__attribute__(x)'], initializerLineLimit: 30, - spelling: 'Doxygen-normalized declarations, types and initializers, including expanded macros; not verbatim source slices. Anonymous synthetic types have no reconstructed raw declaration.', - references: 'Resolved native XML relationships and explicit C tag types between included symbols; external targets omitted. Call edges are a bounded syntactic projection of refid-linked programlisting inside native function body spans: direct statements, returns, first conditions and assignment RHS. Each call edge records source sites. Callback/address uses, macros, indirect calls, ambiguous or continued expressions remain general references; cross-file static function links are omitted and recorded as producer diagnostics; no runtime execution or complete call graph is claimed.', - sourceHashes, referenceDiagnostics: [...new Set(referenceDiagnostics)].sort(), diagnostics: warnings.trim() ? warnings.trim().split('\n') : [], + macroExpansion: + 'Native included definitions and configured Makefile -D flags; no synthetic feature definitions.', + ignoredDecorations: ['__attribute__(x)'], + initializerLineLimit: 30, + spelling: + 'Doxygen-normalized declarations, types and initializers, including expanded macros; not verbatim source slices. Anonymous synthetic types have no reconstructed raw declaration.', + references: + 'Resolved native XML relationships and explicit C tag types between included symbols; external targets omitted. Call edges are a bounded syntactic projection of refid-linked programlisting inside native function body spans: direct statements, returns, first conditions and assignment RHS. Each call edge records source sites. Callback/address uses, macros, indirect calls, ambiguous or continued expressions remain general references; cross-file static function links are omitted and recorded as producer diagnostics; no runtime execution or complete call graph is claimed.', + sourceHashes, + referenceDiagnostics: [...new Set(referenceDiagnostics)].sort(), + diagnostics: warnings.trim() ? warnings.trim().split('\n') : [], }, license: { file: 'COPYING', sha256: sha256(copying), text: copying, notices }, - commands, symbols, + commands, + symbols, } const out = join(repoRoot, 'site/src/data/tmux/api', `${version}.json`) const text = `${JSON.stringify(model)}\n` if (check) { - if (!existsSync(out) || readFileSync(out, 'utf8') !== text) throw new Error(`${out} stale; regenerate with the same configured toolchain`) + if (!existsSync(out) || readFileSync(out, 'utf8') !== text) + throw new Error(`${out} stale; regenerate with the same configured toolchain`) } else { mkdirSync(dirname(out), { recursive: true }) writeFileSync(out, text) } - console.log(`gen-api-model: tmux ${version}: ${symbols.length} shared symbols, ${commands.length} native command associations${check ? ' current' : ''}`) + console.log( + `gen-api-model: tmux ${version}: ${symbols.length} shared symbols, ${commands.length} native command associations${check ? ' current' : ''}`, + ) if (args.includes('--retain-output')) console.log(`Native Doxygen evidence: ${scratch}`) else rmSync(scratch, { recursive: true }) } catch (error) { @@ -471,7 +589,8 @@ function generateTmuxModel() { } if (args.includes('--project')) { - if (args[args.indexOf('--project') + 1] !== 'tmux' || only) throw new Error('Use --project tmux independently of --port') + if (args[args.indexOf('--project') + 1] !== 'tmux' || only) + throw new Error('Use --project tmux independently of --port') generateTmuxModel() process.exit(0) } @@ -484,9 +603,7 @@ if (args.includes('--project')) { */ const navOnly = args.includes('--nav') const skipNativeModelPortsIndex = args.indexOf('--skip-native-model-ports') -const skipNativeModelPortsValue = skipNativeModelPortsIndex === -1 - ? undefined - : args[skipNativeModelPortsIndex + 1] +const skipNativeModelPortsValue = skipNativeModelPortsIndex === -1 ? undefined : args[skipNativeModelPortsIndex + 1] if (skipNativeModelPortsIndex !== -1 && !check) { console.error('gen-api-model: --skip-native-model-ports requires --check') process.exit(2) @@ -495,7 +612,12 @@ if (skipNativeModelPortsIndex !== -1 && (!skipNativeModelPortsValue || skipNativ console.error('gen-api-model: --skip-native-model-ports requires a comma-separated port list') process.exit(2) } -const skipNativeModelPorts = new Set((skipNativeModelPortsValue ?? '').split(',').map((port) => port.trim()).filter(Boolean)) +const skipNativeModelPorts = new Set( + (skipNativeModelPortsValue ?? '') + .split(',') + .map((port) => port.trim()) + .filter(Boolean), +) for (const port of skipNativeModelPorts) { if (!(port in PORTS)) { console.error(`gen-api-model: --skip-native-model-ports names unknown port ${port}`) @@ -549,10 +671,8 @@ for (const [port, cfg] of Object.entries(PORTS)) { } const head = git(checkout, 'rev-parse', 'HEAD') - const selectedSource = process.env.LIBTMUX_DOCS_PORT === port - ? process.env.LIBTMUX_DOCS_SOURCE_SHA : undefined - const selectedRef = process.env.LIBTMUX_DOCS_PORT === port - ? process.env.LIBTMUX_DOCS_SOURCE_REF : undefined + const selectedSource = process.env.LIBTMUX_DOCS_PORT === port ? process.env.LIBTMUX_DOCS_SOURCE_SHA : undefined + const selectedRef = process.env.LIBTMUX_DOCS_PORT === port ? process.env.LIBTMUX_DOCS_SOURCE_REF : undefined if (selectedSource) { if (!/^[0-9a-f]{40}$/.test(selectedSource) || head !== selectedSource) { console.error(`gen-api-model: ${port} checkout HEAD ${head} does not match selected source ${selectedSource}`) @@ -560,7 +680,9 @@ for (const [port, cfg] of Object.entries(PORTS)) { } const resolved = selectedRef ? git(checkout, 'rev-parse', `${selectedRef}^{commit}`) : head if (resolved !== selectedSource) { - console.error(`gen-api-model: ${port} source ref ${selectedRef} resolves to ${resolved}, expected ${selectedSource}`) + console.error( + `gen-api-model: ${port} source ref ${selectedRef} resolves to ${resolved}, expected ${selectedSource}`, + ) process.exit(1) } } @@ -572,13 +694,12 @@ for (const [port, cfg] of Object.entries(PORTS)) { // Gradle owns dependency tracking; stale generated methods must never be // accepted simply because their output directory already exists. execFileSync(join(checkout, 'gradlew'), [...cfg.generate, '--console=plain'], { - cwd: checkout, stdio: 'inherit', + cwd: checkout, + stdio: 'inherit', }) } - const roots = (cfg.roots ?? [cfg.root]) - .map((r) => join(checkout, r)) - .filter((r) => existsSync(r)) + const roots = (cfg.roots ?? [cfg.root]).map((r) => join(checkout, r)).filter((r) => existsSync(r)) if (!roots.length) { console.error(`gen-api-model: no source roots exist for ${port}`) process.exit(1) @@ -633,89 +754,141 @@ for (const [port, cfg] of Object.entries(PORTS)) { if (port === 'go') { // Read committed bytes at the citation revision, never a newer working tree. const files = (git(checkout, 'ls-tree', '-r', '--name-only', revision, 'tmux') ?? '') - .split('\n').filter((file) => /^tmux\/[^/]+_test\.go$/.test(file)) + .split('\n') + .filter((file) => /^tmux\/[^/]+_test\.go$/.test(file)) .map((file) => ({ file, code: execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { - encoding: 'utf8', maxBuffer: 1 << 26, + encoding: 'utf8', + maxBuffer: 1 << 26, }), })) const examples = await readCompleteGoExamples(files) const goVersion = /^go (\d+\.\d+\.\d+)$/m.exec(git(checkout, 'show', `${revision}:go.mod`) ?? '')?.[1] if (examples.length && !goVersion) throw new Error('Missing Go version for complete examples') - if (examples.length) attachCompleteGoExamples(model.symbols, examples, { - repo: cfg.repo, revision, goVersion, - }) + if (examples.length) + attachCompleteGoExamples(model.symbols, examples, { + repo: cfg.repo, + revision, + goVersion, + }) } if (['java', 'kotlin', 'scala'].includes(port)) { - attachCompleteJvmExamples(model.symbols, port, (file) => { - try { - return execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { - encoding: 'utf8', maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'ignore'], - }) - } catch { - return undefined - } - }, { repo: cfg.repo, revision }) + attachCompleteJvmExamples( + model.symbols, + port, + (file) => { + try { + return execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { + encoding: 'utf8', + maxBuffer: 1 << 26, + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch { + return undefined + } + }, + { repo: cfg.repo, revision }, + ) } if (port === 'swift') { - attachCompleteSwiftExamples(model.symbols, (file) => { - try { - return execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { - encoding: 'utf8', maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'ignore'], - }) - } catch { - return undefined - } - }, { repo: cfg.repo, revision }) + attachCompleteSwiftExamples( + model.symbols, + (file) => { + try { + return execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { + encoding: 'utf8', + maxBuffer: 1 << 26, + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch { + return undefined + } + }, + { repo: cfg.repo, revision }, + ) } if (port === 'fsharp' || port === 'csharp') { const attach = port === 'fsharp' ? attachCompleteFSharpExamples : attachCompleteCSharpExamples - attach(model.symbols, (file) => { - try { - return execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { - encoding: 'utf8', maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'ignore'], - }) - } catch { - return undefined - } - }, { repo: cfg.repo, revision }) + attach( + model.symbols, + (file) => { + try { + return execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { + encoding: 'utf8', + maxBuffer: 1 << 26, + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch { + return undefined + } + }, + { repo: cfg.repo, revision }, + ) } if (port === 'cxx' || port === 'rs') { const attach = port === 'rs' ? attachCompleteRustExamples : attachCompleteCxxExamples - attach(model.symbols, (file) => { - try { - return execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { - encoding: 'utf8', maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'ignore'], - }) - } catch { - return undefined - } - }, { repo: cfg.repo, revision }) + attach( + model.symbols, + (file) => { + try { + return execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { + encoding: 'utf8', + maxBuffer: 1 << 26, + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch { + return undefined + } + }, + { repo: cfg.repo, revision }, + ) } const legacyIds = new Set(model.symbols.map((symbol) => symbol.id)) const sourceUnits = [{ checkout, repo: cfg.repo, revision, head, symbols: model.symbols }] const mcpRoots = { - ts: 'packages/mcp/src', rs: 'crates/tmux-mcp/src', go: 'mcp', - java: 'libtmux-mcp/src/main/java', csharp: 'src/LibTmux.Mcp', - } - const extras = cfg.nativeArtifact ? [] : port === 'py' - ? [ - { product: 'workspace', checkout: expand(process.env.LIBTMUX_DOCS_WORKSPACE_PY || '~/work/python/tmuxp'), root: 'src', repo: 'tmux-python/tmuxp', package: 'tmuxp' }, - { product: 'mcp', checkout: expand(process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp'), root: 'src', repo: 'tmux-python/libtmux-mcp', package: 'libtmux-mcp' }, - ] - : mcpRoots[port] ? [{ product: 'mcp', checkout, root: mcpRoots[port], repo: cfg.repo }] : [] + ts: 'packages/mcp/src', + rs: 'crates/tmux-mcp/src', + go: 'mcp', + java: 'libtmux-mcp/src/main/java', + csharp: 'src/LibTmux.Mcp', + } + const extras = cfg.nativeArtifact + ? [] + : port === 'py' + ? [ + { + product: 'workspace', + checkout: expand(process.env.LIBTMUX_DOCS_WORKSPACE_PY || '~/work/python/tmuxp'), + root: 'src', + repo: 'tmux-python/tmuxp', + package: 'tmuxp', + }, + { + product: 'mcp', + checkout: expand(process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp'), + root: 'src', + repo: 'tmux-python/libtmux-mcp', + package: 'libtmux-mcp', + }, + ] + : mcpRoots[port] + ? [{ product: 'mcp', checkout, root: mcpRoots[port], repo: cfg.repo }] + : [] for (const extra of extras) { const extraHead = git(extra.checkout, 'rev-parse', 'HEAD') - if (!extraHead || !existsSync(join(extra.checkout, extra.root))) throw new Error(`Missing ${port} ${extra.product} source`) + if (!extraHead || !existsSync(join(extra.checkout, extra.root))) + throw new Error(`Missing ${port} ${extra.product} source`) const extraRevision = publicRevision(extra.checkout, extraHead, port, extra.repo) const extracted = await extractProject({ - port, root: join(extra.checkout, extra.root), revision: extraRevision, + port, + root: join(extra.checkout, extra.root), + revision: extraRevision, options: { ...cfg.options, privateMembers: false, specialMembers: true }, }) // These crates use file-relative names. Prefix the additional package so @@ -741,11 +914,13 @@ for (const [port, cfg] of Object.entries(PORTS)) { `INPUT = ${quote(join(checkout, 'examples/workspace/include'))} ${quote(join(checkout, 'apps/mcp/include'))}`, `OUTPUT_DIRECTORY = ${quote(output)}`, `STRIP_FROM_PATH = ${quote(checkout)}`, - 'EXTRACT_ALL = YES', 'WARN_AS_ERROR = NO', + 'EXTRACT_ALL = YES', + 'WARN_AS_ERROR = NO', ].join('\n') execFileSync('doxygen', ['-'], { cwd: checkout, input: config, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) - const symbols = extractDoxygen(join(output, 'xml'), checkout) - .filter((symbol) => /^(examples\/workspace|apps\/mcp)\//.test(symbol.source.file)) + const symbols = extractDoxygen(join(output, 'xml'), checkout).filter((symbol) => + /^(examples\/workspace|apps\/mcp)\//.test(symbol.source.file), + ) sourceUnits[0].symbols.push(...symbols) rmSync(output, { recursive: true }) // Format the extracted declarations once. Static HTML, Markdown and previews @@ -753,18 +928,30 @@ for (const [port, cfg] of Object.entries(PORTS)) { const signatures = model.symbols.flatMap((symbol) => symbol.signatures).filter((signature) => signature.raw) const separator = (i) => `// libtmux-signature-${i}` const input = `struct ApiDeclarations {\n${signatures.map((signature, i) => `${separator(i)}\n${signature.raw}`).join('\n\n')}\n};\n` - const formatted = execFileSync(process.env.CLANG_FORMAT || 'clang-format', + const formatted = execFileSync( + process.env.CLANG_FORMAT || 'clang-format', ['--style=file', `--assume-filename=${join(checkout, 'include/libtmux.hpp')}`], - { cwd: checkout, input, encoding: 'utf8', maxBuffer: 1 << 26 }) + { cwd: checkout, input, encoding: 'utf8', maxBuffer: 1 << 26 }, + ) const body = formatted.replace(/^struct ApiDeclarations \{\n/, '').replace(/\n\};\s*$/, '') - const parts = body.replace(/^ /gm, '').split(/^\/\/ libtmux-signature-\d+[ \t]*\n/m).slice(1) + const parts = body + .replace(/^ /gm, '') + .split(/^\/\/ libtmux-signature-\d+[ \t]*\n/m) + .slice(1) if (parts.length !== signatures.length) throw new Error('C++ signature formatting lost a declaration boundary') - signatures.forEach((signature, i) => { signature.raw = parts[i].trim() }) + signatures.forEach((signature, i) => { + signature.raw = parts[i].trim() + }) } if (model.pruned) model.pruned.kept = model.symbols.length - const productOf = (file) => /(?:^|\/)(?:workspace|TmuxWorkspace|LibTmux\.Workspace|libtmux-workspace)(?:\/|$)|crates\/tmux-workspace\//.test(file) - ? 'workspace' - : /(?:^|\/)(?:mcp|LibTmuxMCP|LibTmux\.Mcp|libtmux-mcp)(?:\/|$)|crates\/tmux-mcp\//.test(file) ? 'mcp' : 'core' + const productOf = (file) => + /(?:^|\/)(?:workspace|TmuxWorkspace|LibTmux\.Workspace|libtmux-workspace)(?:\/|$)|crates\/tmux-workspace\//.test( + file, + ) + ? 'workspace' + : /(?:^|\/)(?:mcp|LibTmuxMCP|LibTmux\.Mcp|libtmux-mcp)(?:\/|$)|crates\/tmux-mcp\//.test(file) + ? 'mcp' + : 'core' const packages = { ts: { workspace: '@libtmux/workspace', mcp: '@libtmux/mcp' }, rs: { workspace: 'tmux-workspace', mcp: 'tmux-mcp' }, @@ -786,7 +973,13 @@ for (const [port, cfg] of Object.entries(PORTS)) { model.generatedSources[relativeFile] ??= readFileSync(join(unit.checkout, relativeFile), 'utf8') } symbol.product ??= productOf(relativeFile) - symbol.source = { ...symbol.source, file: relativeFile, repo: unit.repo, revision: unit.revision, extractedRevision: unit.head } + symbol.source = { + ...symbol.source, + file: relativeFile, + repo: unit.repo, + revision: unit.revision, + extractedRevision: unit.head, + } } inheritProductFromOwners(unit.symbols) for (const product of ['workspace', 'mcp']) { @@ -795,21 +988,34 @@ for (const [port, cfg] of Object.entries(PORTS)) { let entries = [] if (port === 'ts') { const manifest = JSON.parse(readFileSync(join(unit.checkout, `packages/${product}/package.json`), 'utf8')) - entries = Object.values(manifest.exports).flatMap((entry) => typeof entry === 'object' && entry.bun ? [resolve(unit.checkout, `packages/${product}`, entry.bun)] : []) + entries = Object.values(manifest.exports).flatMap((entry) => + typeof entry === 'object' && entry.bun ? [resolve(unit.checkout, `packages/${product}`, entry.bun)] : [], + ) } else if (port === 'rs') entries = [join(unit.checkout, `crates/tmux-${product}/src/lib.rs`)] - scopeProductSymbols(symbols, { port, root: unit.checkout, entries, readSource: (file) => readFileSync(file, 'utf8') }) + scopeProductSymbols(symbols, { + port, + root: unit.checkout, + entries, + readSource: (file) => readFileSync(file, 'utf8'), + }) } remapLines(unit.checkout, unit.symbols, unit.revision, unit.head) for (const product of new Set(unit.symbols.map((symbol) => symbol.product))) { model.sources.push({ - product, package: unit.package ?? packages[port]?.[product] ?? portBySlug[port].packageName, - repo: unit.repo, revision: unit.revision, extractedRevision: unit.head, + product, + package: unit.package ?? packages[port]?.[product] ?? portBySlug[port].packageName, + repo: unit.repo, + revision: unit.revision, + extractedRevision: unit.head, version: packageVersion(unit.checkout, port, product), }) } } if (cfg.nativeArtifact) { - const locations = model.symbols.flatMap((symbol) => [symbol, ...symbol.signatures.filter((signature) => signature.source)]) + const locations = model.symbols.flatMap((symbol) => [ + symbol, + ...symbol.signatures.filter((signature) => signature.source), + ]) for (const entry of locations) { entry.source = { ...entry.source, @@ -910,8 +1116,10 @@ for (const [port, cfg] of Object.entries(PORTS)) { const nav = navSidecar(port, model) const stalePaths = [ strip(read(`${port}.json`)) !== strip(text) && `${port}.json`, - tree && JSON.stringify(committedPaths && JSON.parse(committedPaths).paths) !== - JSON.stringify(repositoryPaths(tree, cfg)) && `${port}.paths.json`, + tree && + JSON.stringify(committedPaths && JSON.parse(committedPaths).paths) !== + JSON.stringify(repositoryPaths(tree, cfg)) && + `${port}.paths.json`, nav && read(`${port}.nav.json`) !== `${JSON.stringify(nav)}\n` && `${port}.nav.json`, ].filter(Boolean) if (stalePaths.length) { @@ -955,7 +1163,10 @@ for (const [port, cfg] of Object.entries(PORTS)) { const kinds = model.symbols.reduce((a, s) => ((a[s.kind] = (a[s.kind] ?? 0) + 1), a), {}) console.log( `gen-api-model: ${port} -> ${model.symbols.length} symbols ` + - `(${Object.entries(kinds).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(' ')}) ` + + `(${Object.entries(kinds) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k}:${v}`) + .join(' ')}) ` + `${(text.length / 1024 / 1024).toFixed(1)} MB` + (disambiguated ? ` [${disambiguated} slugs disambiguated]` : ''), ) diff --git a/scripts/gen-brand-css.mjs b/scripts/gen-brand-css.mjs index 76736ec7f..e7df6a696 100644 --- a/scripts/gen-brand-css.mjs +++ b/scripts/gen-brand-css.mjs @@ -6,27 +6,48 @@ import palettes from '../site/src/lib/brand-palettes.json' with { type: 'json' } // Both shells consume the same explicit sRGB shades, including without JS. const variables = (p, color) => ({ 'brand-color': color, - 'brand-solid': p.solid, 'brand-on-solid': p.onSolid, - 'button-bg-primary': p.solid, 'button-text-primary': p.onSolid, - 'theme-primary': p.link, 'theme-secondary': p.muted, 'theme-accent': p.link, - 'theme-background': p.background, 'theme-text': p.text, - 'background-color': p.background, 'text-color': p.text, - 'topnav-background': p.background, 'topnav-text-color': p.text, - 'topnav-link-color': p.muted, 'topnav-link-hover-color': p.text, - 'color-background-primary': p.background, 'color-background-secondary': p.surface, - 'color-background-border': p.border, 'color-api-background-hover': p.hover, - 'color-foreground-primary': p.text, 'color-foreground-secondary': p.muted, - 'color-foreground-muted': p.muted, 'color-link': p.link, - 'color-brand-primary': p.link, 'color-brand-content': p.link, + 'brand-solid': p.solid, + 'brand-on-solid': p.onSolid, + 'button-bg-primary': p.solid, + 'button-text-primary': p.onSolid, + 'theme-primary': p.link, + 'theme-secondary': p.muted, + 'theme-accent': p.link, + 'theme-background': p.background, + 'theme-text': p.text, + 'background-color': p.background, + 'text-color': p.text, + 'topnav-background': p.background, + 'topnav-text-color': p.text, + 'topnav-link-color': p.muted, + 'topnav-link-hover-color': p.text, + 'color-background-primary': p.background, + 'color-background-secondary': p.surface, + 'color-background-border': p.border, + 'color-api-background-hover': p.hover, + 'color-foreground-primary': p.text, + 'color-foreground-secondary': p.muted, + 'color-foreground-muted': p.muted, + 'color-link': p.link, + 'color-brand-primary': p.link, + 'color-brand-content': p.link, 'color-inline-code-background': p.surface, - 'lt-color-bg': p.background, 'lt-color-bg-secondary': p.surface, - 'lt-color-bg-hover': p.hover, 'lt-color-fg': p.text, - 'lt-color-fg-secondary': p.muted, 'lt-color-fg-muted': p.muted, - 'lt-color-border': p.border, 'lt-color-accent': p.link, - 'lt-color-link': p.link, 'lt-color-link-visited': p.link, + 'lt-color-bg': p.background, + 'lt-color-bg-secondary': p.surface, + 'lt-color-bg-hover': p.hover, + 'lt-color-fg': p.text, + 'lt-color-fg-secondary': p.muted, + 'lt-color-fg-muted': p.muted, + 'lt-color-border': p.border, + 'lt-color-accent': p.link, + 'lt-color-link': p.link, + 'lt-color-link-visited': p.link, 'lt-color-code-bg': p.surface, }) -const declarations = (p, color, mode) => `color-scheme: ${mode};\n${Object.entries(variables(p, color)).map(([key, value]) => ` --${key}: ${value};`).join('\n')}` +const declarations = (p, color, mode) => + `color-scheme: ${mode};\n${Object.entries(variables(p, color)) + .map(([key, value]) => ` --${key}: ${value};`) + .join('\n')}` let css = '/* Generated by scripts/gen-brand-css.mjs from brand-palettes.json. */\n' for (const [language, p] of Object.entries(palettes)) { const selector = `html[data-brand="${language}"]` diff --git a/scripts/gen-example-sources.mjs b/scripts/gen-example-sources.mjs index 0482b9a79..43f1f5285 100755 --- a/scripts/gen-example-sources.mjs +++ b/scripts/gen-example-sources.mjs @@ -38,7 +38,8 @@ for (const [owner, file] of [ ['lua', 'examples/quickstart.lua'], ['lua', 'examples/native_query.lua'], ['lua', 'examples/snapshot.lua'], -]) wanted.set(`${owner}:${file}`, { owner, file }) +]) + wanted.set(`${owner}:${file}`, { owner, file }) for (const md of markdownFiles(CONTENT)) { const text = readFileSync(md, 'utf8') for (const m of text.matchAll(/^```(\w+)([^\n]*)$/gm)) { @@ -56,13 +57,22 @@ const digest = (content) => createHash('sha256').update(content).digest('hex') export function cachedExample({ repository, revision, file, checkout, current }) { if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error(`Invalid example revision: ${revision}`) if (existsSync(join(checkout, '.git'))) { - const content = execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], - { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }) + const content = execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], { + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }) return { repository, revision, sha256: digest(content), content } } - if (current?.repository !== repository || current?.revision !== revision || - typeof current.content !== 'string' || current.sha256 !== digest(current.content)) { - throw new Error(`${repository}:${file}: no verified cache for ${revision}; provide its checkout and regenerate example sources`) + if ( + current?.repository !== repository || + current?.revision !== revision || + typeof current.content !== 'string' || + current.sha256 !== digest(current.content) + ) { + throw new Error( + `${repository}:${file}: no verified cache for ${revision}; provide its checkout and regenerate example sources`, + ) } return current } @@ -74,7 +84,8 @@ export function run() { const offline = new Set() for (const [key, { owner, file }] of [...wanted].sort((a, b) => a[0].localeCompare(b[0]))) { const modelPath = join(root, `site/src/data/api/${owner}.json`) - const provenance = existsSync(modelPath) ? JSON.parse(readFileSync(modelPath, 'utf8')) + const provenance = existsSync(modelPath) + ? JSON.parse(readFileSync(modelPath, 'utf8')) : JSON.parse(readFileSync(join(root, `site/src/data/port-guides/${owner}.json`), 'utf8')).source const repository = provenance.repository ?? provenance.repo const revision = provenance.revision @@ -83,10 +94,16 @@ export function run() { cache[key] = cachedExample({ repository, revision, file, checkout, current: previous[key] }) } const merged = `${JSON.stringify(cache, null, 2)}\n` - if (check && merged !== current) throw new Error(`${OUT}: stale example sources; run node scripts/gen-example-sources.mjs`) + if (check && merged !== current) + throw new Error(`${OUT}: stale example sources; run node scripts/gen-example-sources.mjs`) if (!check) writeFileSync(OUT, merged) - console.log(`gen-example-sources: ${check ? 'cache matches' : 'wrote'} ${Object.keys(cache).length} revision-bound example sources`) - if (offline.size) console.log(`gen-example-sources: cached sources for ${[...offline].join(', ')}; checksums and revisions checked, source checkouts unavailable`) + console.log( + `gen-example-sources: ${check ? 'cache matches' : 'wrote'} ${Object.keys(cache).length} revision-bound example sources`, + ) + if (offline.size) + console.log( + `gen-example-sources: cached sources for ${[...offline].join(', ')}; checksums and revisions checked, source checkouts unavailable`, + ) } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) run() diff --git a/scripts/gen-example-sources.negative.mjs b/scripts/gen-example-sources.negative.mjs index 971155a21..bdd63a9c8 100755 --- a/scripts/gen-example-sources.negative.mjs +++ b/scripts/gen-example-sources.negative.mjs @@ -21,7 +21,16 @@ try { writeFileSync(join(scratch, 'example.go'), '// uncommitted replacement\n') assert.deepEqual(cachedExample(request), initial) git('add', 'example.go') - git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', '-m', 'Later revision') + git( + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.invalid', + 'commit', + '--quiet', + '-m', + 'Later revision', + ) assert.deepEqual(cachedExample(request), initial) console.log('ok examples ignore a dirty working tree and a later HEAD') diff --git a/scripts/gen-mcp-protocol.mjs b/scripts/gen-mcp-protocol.mjs index 1be9413cf..6b33820d2 100644 --- a/scripts/gen-mcp-protocol.mjs +++ b/scripts/gen-mcp-protocol.mjs @@ -8,29 +8,45 @@ import { PORTS, productAvailable } from '../site/src/lib/ports.ts' import { captureProtocol } from './lib/mcp-protocol.mjs' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const expand = (path) => path.startsWith('~/') ? join(homedir(), path.slice(2)) : path +const expand = (path) => (path.startsWith('~/') ? join(homedir(), path.slice(2)) : path) const only = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : undefined const checking = process.argv.includes('--check') const sourceBound = process.argv.includes('--source-bound') if (only && !PORTS.some((port) => port.slug === only)) throw new Error(`Unknown MCP port: ${only}`) -if (sourceBound && (!only || process.env.LIBTMUX_DOCS_PORT !== only || !/^[0-9a-f]{40}$/.test(process.env.LIBTMUX_DOCS_SOURCE_SHA ?? ''))) { +if ( + sourceBound && + (!only || process.env.LIBTMUX_DOCS_PORT !== only || !/^[0-9a-f]{40}$/.test(process.env.LIBTMUX_DOCS_SOURCE_SHA ?? '')) +) { throw new Error('source-bound MCP discovery requires --port, matching LIBTMUX_DOCS_PORT and LIBTMUX_DOCS_SOURCE_SHA') } -const checkoutFor = (port) => expand(port.slug === 'py' - ? process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp' - : process.env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`] || port.worktree) +const checkoutFor = (port) => + expand( + port.slug === 'py' + ? process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp' + : process.env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`] || port.worktree, + ) const commands = { py: ['.venv/bin/python', '-m', 'libtmux_mcp'], ruby: [ - 'bundle', 'exec', 'libtmux-mcp', - '--socket-name', 'libtmux-docs-protocol', - '--endpoint', 'docs', - '--enable-tool', 'tmux_capture', - '--enable-tool', 'tmux_wait', - '--enable-tool', 'tmux_create', - '--enable-tool', 'tmux_send', - '--enable-tool', 'tmux_close', - '--enable-tool', 'tmux_run', + 'bundle', + 'exec', + 'libtmux-mcp', + '--socket-name', + 'libtmux-docs-protocol', + '--endpoint', + 'docs', + '--enable-tool', + 'tmux_capture', + '--enable-tool', + 'tmux_wait', + '--enable-tool', + 'tmux_create', + '--enable-tool', + 'tmux_send', + '--enable-tool', + 'tmux_close', + '--enable-tool', + 'tmux_run', ], ts: ['bun', 'packages/mcp/src/server.ts'], rs: ['target/debug/tmux-mcp'], @@ -38,8 +54,12 @@ const commands = { csharp: ['dotnet', 'src/LibTmux.Mcp/bin/Release/net10.0/LibTmux.Mcp.dll'], cxx: ['build/cxx-dev/apps/mcp/libtmux-mcp-server', '--socket-name', 'libtmux-docs-protocol'], swift: ['.build/debug/libtmux-mcp'], - julia: ['julia', '--startup-file=no', '-e', - 'using LibTmuxMCP; args = ["--socket-name", "libtmux-docs-protocol", "--allow-create"]; for name in LibTmuxMCP._TOOL_NAMES; append!(args, ["--tool", name]); end; exit(LibTmuxMCP.main(args))'], + julia: [ + 'julia', + '--startup-file=no', + '-e', + 'using LibTmuxMCP; args = ["--socket-name", "libtmux-docs-protocol", "--allow-create"]; for name in LibTmuxMCP._TOOL_NAMES; append!(args, ["--tool", name]); end; exit(LibTmuxMCP.main(args))', + ], } const builds = { rs: [['cargo', 'build', '--locked', '-p', 'tmux-mcp', '--bin', 'tmux-mcp', '--jobs', '2']], @@ -64,9 +84,25 @@ const selections = { csharp: { LIBTMUX_TOOLSETS: 'inspect,manage,execute,teardown' }, swift: { LIBTMUX_TOOLSETS: 'inspect,manage,execute,teardown' }, cxx: { LIBTMUX_TOOLSETS: 'inspect,manage,execute,teardown' }, - julia: { mode: 'explicit CLI options', tools: 'all registered tools via repeated --tool', allowCreate: '--allow-create' }, + julia: { + mode: 'explicit CLI options', + tools: 'all registered tools via repeated --tool', + allowCreate: '--allow-create', + }, } -const selectionVariables = [...new Set(Object.values(selections).flatMap(Object.keys)), 'LIBTMUX_TOOLS', 'LIBTMUX_EXCLUDE_TOOLS', 'LIBTMUX_MCP_TOOLS', 'LIBTMUX_SAFETY', 'TMUX_MCP_SAFETY', 'LIBTMUX_MCP_CAPABILITIES', 'LIBTMUX_MCP_PROMPTS_AS_TOOLS', 'LIBTMUX_MCP_LIVE', 'LIBTMUX_SOCKET_NAME', 'LIBTMUX_SOCKET_PATH'] +const selectionVariables = [ + ...new Set(Object.values(selections).flatMap(Object.keys)), + 'LIBTMUX_TOOLS', + 'LIBTMUX_EXCLUDE_TOOLS', + 'LIBTMUX_MCP_TOOLS', + 'LIBTMUX_SAFETY', + 'TMUX_MCP_SAFETY', + 'LIBTMUX_MCP_CAPABILITIES', + 'LIBTMUX_MCP_PROMPTS_AS_TOOLS', + 'LIBTMUX_MCP_LIVE', + 'LIBTMUX_SOCKET_NAME', + 'LIBTMUX_SOCKET_PATH', +] const sourceStatus = (checkout, slug) => { // Publication records the clean/dirty state before native generation. // Generated, untracked artifacts such as Swift's symbolgraph are not edits. @@ -89,19 +125,23 @@ const sourceRevision = (checkout, slug) => { // stale snapshot. Writing a snapshot set tolerates neither, because a // partial or unreliable set is worse than none. const relevant = PORTS.filter((port) => (!only || only === port.slug) && productAvailable(port, 'mcp')) -const checkoutStates = new Map(relevant.map((port) => { - const checkout = checkoutFor(port) - if (!existsSync(checkout)) return [port.slug, 'missing'] - const status = sourceStatus(checkout) - return [port.slug, status ? 'dirty' : 'ready'] -})) +const checkoutStates = new Map( + relevant.map((port) => { + const checkout = checkoutFor(port) + if (!existsSync(checkout)) return [port.slug, 'missing'] + const status = sourceStatus(checkout) + return [port.slug, status ? 'dirty' : 'ready'] + }), +) const missing = [...checkoutStates].filter(([, state]) => state === 'missing').map(([slug]) => slug) const dirty = [...checkoutStates].filter(([, state]) => state === 'dirty').map(([slug]) => slug) if (missing.length || dirty.length) { const note = `gen-mcp-protocol: ${[ missing.length && `no checkout for ${missing.join(', ')}`, dirty.length && `uncommitted changes in ${dirty.join(', ')}`, - ].filter(Boolean).join('; ')}` + ] + .filter(Boolean) + .join('; ')}` if (checking && !sourceBound) { console.log(`${note} — skipping the comparison`) process.exit(0) @@ -117,8 +157,11 @@ for (const port of relevant) { if (sourceBound) { const model = JSON.parse(readFileSync(join(root, 'site/src/data/api', `${port.slug}.json`), 'utf8')) const source = model.sources?.find((entry) => entry.product === 'mcp' && entry.repo === repository) - if (model.port !== port.slug || model.revision !== process.env.LIBTMUX_DOCS_SOURCE_SHA || - (source?.extractedRevision ?? source?.revision) !== revision) { + if ( + model.port !== port.slug || + model.revision !== process.env.LIBTMUX_DOCS_SOURCE_SHA || + (source?.extractedRevision ?? source?.revision) !== revision + ) { throw new Error(`${port.slug}: API model must describe the selected source and actual MCP checkout ${revision}`) } } @@ -130,7 +173,10 @@ for (const port of relevant) { const environment = { ...Object.fromEntries(selectionVariables.map((key) => [key, undefined])), ...(['ruby', 'julia'].includes(port.slug) ? {} : selection), - TMUX: undefined, TMUX_PANE: undefined, TMUX_TMPDIR: socketRoot, LIBTMUX_SOCKET: 'libtmux-docs-protocol', + TMUX: undefined, + TMUX_PANE: undefined, + TMUX_TMPDIR: socketRoot, + LIBTMUX_SOCKET: 'libtmux-docs-protocol', ...(port.slug === 'julia' && !override ? { JULIA_PROJECT: join(socketRoot, 'julia-env') } : {}), } let protocol @@ -139,22 +185,35 @@ for (const port of relevant) { const goBinary = join(socketRoot, 'libtmux-mcp') // Dependency downloads and compilation precede the protocol deadline. if (!override) { - if (port.slug === 'julia') execFileSync('julia', ['--startup-file=no', '-e', - 'using Pkg; Pkg.activate(ARGS[1]); Pkg.develop([Pkg.PackageSpec(path=ARGS[2]), Pkg.PackageSpec(path=joinpath(ARGS[2], "packages", "LibTmuxMCP"))]); Pkg.instantiate(); Pkg.precompile()', - environment.JULIA_PROJECT, checkout, - ], { cwd: checkout, stdio: 'inherit' }) + if (port.slug === 'julia') + execFileSync( + 'julia', + [ + '--startup-file=no', + '-e', + 'using Pkg; Pkg.activate(ARGS[1]); Pkg.develop([Pkg.PackageSpec(path=ARGS[2]), Pkg.PackageSpec(path=joinpath(ARGS[2], "packages", "LibTmuxMCP"))]); Pkg.instantiate(); Pkg.precompile()', + environment.JULIA_PROJECT, + checkout, + ], + { cwd: checkout, stdio: 'inherit' }, + ) for (const [build, ...buildArgs] of builds[port.slug] ?? []) { execFileSync(build, buildArgs, { cwd: checkout, stdio: 'inherit' }) } - if (port.slug === 'go') execFileSync('go', ['build', '-mod=readonly', '-o', goBinary, './cmd/libtmux-mcp'], { cwd, stdio: 'inherit' }) + if (port.slug === 'go') + execFileSync('go', ['build', '-mod=readonly', '-o', goBinary, './cmd/libtmux-mcp'], { cwd, stdio: 'inherit' }) } // These applications inspect a borrowed endpoint during startup. - if (['ruby', 'julia'].includes(port.slug)) execFileSync('tmux', [ - '-L', 'libtmux-docs-protocol', '-f', '/dev/null', 'new-session', '-d', '-s', 'docs', '/bin/cat', - ], { env: { ...process.env, ...environment }, stdio: 'inherit' }) + if (['ruby', 'julia'].includes(port.slug)) + execFileSync( + 'tmux', + ['-L', 'libtmux-docs-protocol', '-f', '/dev/null', 'new-session', '-d', '-s', 'docs', '/bin/cat'], + { env: { ...process.env, ...environment }, stdio: 'inherit' }, + ) const [command, ...args] = override ? JSON.parse(override) : port.slug === 'go' ? [goBinary] : commands[port.slug] protocol = await captureProtocol({ command, args, cwd, env: environment }) - if (sourceRevision(checkout, port.slug) !== revision) throw new Error(`${port.slug}: source revision changed during discovery`) + if (sourceRevision(checkout, port.slug) !== revision) + throw new Error(`${port.slug}: source revision changed during discovery`) } catch (error) { failure = error throw error @@ -170,21 +229,29 @@ for (const port of relevant) { } catch (error) { cleanupErrors.push(error) } - if (cleanupErrors.length) throw new AggregateError(failure ? [failure, ...cleanupErrors] : cleanupErrors, - `${port.slug}: MCP discovery cleanup failed`) + if (cleanupErrors.length) + throw new AggregateError( + failure ? [failure, ...cleanupErrors] : cleanupErrors, + `${port.slug}: MCP discovery cleanup failed`, + ) } const payload = { generated: 'scripts/gen-mcp-protocol.mjs', repo: repository, - revision, selection, protocol, + revision, + selection, + protocol, } const out = join(root, 'site/src/data/mcp-protocol', `${port.slug}.json`) const text = `${JSON.stringify(payload, null, 2)}\n` if (checking) { - if (!existsSync(out) || readFileSync(out, 'utf8') !== text) throw new Error(`${port.slug}: protocol snapshot is stale`) + if (!existsSync(out) || readFileSync(out, 'utf8') !== text) + throw new Error(`${port.slug}: protocol snapshot is stale`) } else { mkdirSync(dirname(out), { recursive: true }) writeFileSync(out, text) } - console.log(`gen-mcp-protocol: ${port.slug} ${protocol.tools.length} tools, ${protocol.resources.length} resources, ${protocol.resourceTemplates.length} resource templates, ${protocol.prompts.length} prompts`) + console.log( + `gen-mcp-protocol: ${port.slug} ${protocol.tools.length} tools, ${protocol.resources.length} resources, ${protocol.resourceTemplates.length} resource templates, ${protocol.prompts.length} prompts`, + ) } diff --git a/scripts/gen-mcp-tools.mjs b/scripts/gen-mcp-tools.mjs index bcfbd9dc9..fd05dc045 100644 --- a/scripts/gen-mcp-tools.mjs +++ b/scripts/gen-mcp-tools.mjs @@ -140,7 +140,10 @@ const portsModule = resolve(dirname(dirname(fileURLToPath(import.meta.url))), 's const { PORTS: PORT_DEFS, productAvailable } = await import(`file://${portsModule}`) const portBySlug = Object.fromEntries(PORT_DEFS.map((port) => [port.slug, port])) if (only && !portBySlug[only]) throw new Error(`Unknown MCP port: ${only}`) -if (sourceBound && (!only || process.env.LIBTMUX_DOCS_PORT !== only || !/^[0-9a-f]{40}$/.test(process.env.LIBTMUX_DOCS_SOURCE_SHA ?? ''))) { +if ( + sourceBound && + (!only || process.env.LIBTMUX_DOCS_PORT !== only || !/^[0-9a-f]{40}$/.test(process.env.LIBTMUX_DOCS_SOURCE_SHA ?? '')) +) { throw new Error('source-bound MCP catalog requires --port, matching LIBTMUX_DOCS_PORT and LIBTMUX_DOCS_SOURCE_SHA') } if (only && !productAvailable(portBySlug[only], 'mcp')) process.exit(0) @@ -148,14 +151,17 @@ for (const port of PORTS) { const definition = portBySlug[port.slug] const configured = process.env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`] || definition.worktree const oldRoot = definition.checkout - port.dir = port.slug === 'py' ? port.dir.replace('~/work/python/libtmux-mcp', process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp') : port.dir.replace(oldRoot, configured) - port.checkout = expand(port.slug === 'py' ? process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp' : configured) + port.dir = + port.slug === 'py' + ? port.dir.replace('~/work/python/libtmux-mcp', process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp') + : port.dir.replace(oldRoot, configured) + port.checkout = expand( + port.slug === 'py' ? process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp' : configured, + ) port.repo = port.slug === 'py' ? 'tmux-python/libtmux-mcp' : definition.repo if (port.serverDir) port.serverDir = join(port.checkout, 'src/libtmux_mcp') } -const declaredSlugs = new Set(PORT_DEFS - .filter((port) => productAvailable(port, 'mcp')) - .map((port) => port.slug)) +const declaredSlugs = new Set(PORT_DEFS.filter((port) => productAvailable(port, 'mcp')).map((port) => port.slug)) const coveredSlugs = new Set(PORTS.map((port) => port.slug)) const absentHere = [...declaredSlugs].filter((slug) => !coveredSlugs.has(slug)) const absentThere = [...coveredSlugs].filter((slug) => !declaredSlugs.has(slug)) @@ -166,11 +172,12 @@ if (absentHere.length || absentThere.length) { process.exit(1) } - function filesIn(dir, glob, exclude = []) { if (!existsSync(dir)) return [] - return globSync(glob, { cwd: dir, exclude }).map((file) => join(dir, file)) - .filter((file) => !file.includes('__pycache__') && statSync(file).isFile()).sort() + return globSync(glob, { cwd: dir, exclude }) + .map((file) => join(dir, file)) + .filter((file) => !file.includes('__pycache__') && statSync(file).isFile()) + .sort() } /** @@ -212,14 +219,18 @@ for (const port of relevant) { const head = git(port.checkout, 'rev-parse', 'HEAD') const apiModel = JSON.parse(readFileSync(join(repoRoot, 'site/src/data/api', `${port.slug}.json`), 'utf8')) const source = apiModel.sources?.find((entry) => entry.repo === port.repo && entry.product === 'mcp') - if (sourceBound && (apiModel.port !== port.slug || apiModel.revision !== process.env.LIBTMUX_DOCS_SOURCE_SHA || - (source?.extractedRevision ?? source?.revision) !== head)) { + if ( + sourceBound && + (apiModel.port !== port.slug || + apiModel.revision !== process.env.LIBTMUX_DOCS_SOURCE_SHA || + (source?.extractedRevision ?? source?.revision) !== head) + ) { throw new Error(`${port.slug}: API model must describe the selected source and actual MCP checkout ${head}`) } if (sourceBound && git(port.checkout, 'status', '--porcelain', '--untracked-files=no')) { throw new Error(`${port.slug}: MCP checkout has tracked source changes`) } - const revision = sourceBound ? head : source?.revision ?? head + const revision = sourceBound ? head : (source?.revision ?? head) const names = new Map() let defaultNames for (const file of filesIn(dir, port.glob, port.exclude)) { @@ -232,38 +243,65 @@ for (const port of relevant) { defaultNames = new Set([...defaults[1].matchAll(/"([a-z][a-z0-9_]*)"/g)].map((match) => match[1])) } const path = relative(port.checkout, file) - const hunks = head === revision ? [] : parseHunks(git(port.checkout, 'diff', '-U0', `${revision}..${head}`, '--', path)) + const hunks = + head === revision ? [] : parseHunks(git(port.checkout, 'diff', '-U0', `${revision}..${head}`, '--', path)) for (const match of (section?.[0] ?? text).matchAll(port.pattern)) { const wireName = port.capture ? port.capture(match) : match[1] const name = port.wirePrefix ? wireName.replace(new RegExp(`^${port.wirePrefix}`), '') : wireName const line = mapLine(hunks, text.slice(0, (section?.index ?? 0) + match.index).split('\n').length) - names.set(name, { wireName, source: { repo: port.repo, revision, extractedRevision: head, file: path, ...(line ? { line } : {}) } }) + names.set(name, { + wireName, + source: { repo: port.repo, revision, extractedRevision: head, file: path, ...(line ? { line } : {}) }, + }) } } const snapshotFile = join(repoRoot, 'site/src/data/mcp-protocol', `${port.slug}.json`) const snapshot = existsSync(snapshotFile) ? JSON.parse(readFileSync(snapshotFile, 'utf8')) : undefined - if (snapshot && (snapshot.revision !== head || snapshot.repo !== port.repo)) throw new Error(`${port.slug}: MCP protocol snapshot describes another source revision or repository`) - if (sourceBound && !snapshot) throw new Error(`${port.slug}: source-bound MCP catalog requires a runtime protocol snapshot`) + if (snapshot && (snapshot.revision !== head || snapshot.repo !== port.repo)) + throw new Error(`${port.slug}: MCP protocol snapshot describes another source revision or repository`) + if (sourceBound && !snapshot) + throw new Error(`${port.slug}: source-bound MCP catalog requires a runtime protocol snapshot`) const protocols = new Map((snapshot?.protocol.tools ?? []).map((tool) => [tool.name, tool])) - const registrations = [...names.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, registration]) => { - const tool = protocols.get(registration.wireName) - return { - name, ...registration, description: tool?.description ?? '', - schemaStatus: tool ? 'runtime' : 'unavailable', - ...(port.slug === 'ruby' ? { enabledByDefault: ['capabilities', 'snapshot'].includes(name) } : {}), - ...(defaultNames ? { enabledByDefault: defaultNames.has(name), enableOption: port.enableOption } : {}), - ...(tool ? { inputSchema: tool.inputSchema, outputSchema: tool.outputSchema, annotations: tool.annotations, meta: tool._meta } : {}), - } - }) - const unregistered = [...protocols.keys()].filter((name) => !registrations.some((registration) => registration.wireName === name)) - if (unregistered.length) throw new Error(`${port.slug}: runtime tools absent from source registrations: ${unregistered.join(', ')}`) + const registrations = [...names.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, registration]) => { + const tool = protocols.get(registration.wireName) + return { + name, + ...registration, + description: tool?.description ?? '', + schemaStatus: tool ? 'runtime' : 'unavailable', + ...(port.slug === 'ruby' ? { enabledByDefault: ['capabilities', 'snapshot'].includes(name) } : {}), + ...(defaultNames ? { enabledByDefault: defaultNames.has(name), enableOption: port.enableOption } : {}), + ...(tool + ? { + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations, + meta: tool._meta, + } + : {}), + } + }) + const unregistered = [...protocols.keys()].filter( + (name) => !registrations.some((registration) => registration.wireName === name), + ) + if (unregistered.length) + throw new Error(`${port.slug}: runtime tools absent from source registrations: ${unregistered.join(', ')}`) const unavailable = registrations.filter((entry) => entry.schemaStatus !== 'runtime' || !entry.inputSchema) - if (sourceBound && unavailable.length) throw new Error(`${port.slug}: runtime schemas missing for ${unavailable.map((entry) => entry.wireName).join(', ')}`) + if (sourceBound && unavailable.length) + throw new Error( + `${port.slug}: runtime schemas missing for ${unavailable.map((entry) => entry.wireName).join(', ')}`, + ) results[port.slug] = { - tools: [...names.keys()].sort(), registrations, + tools: [...names.keys()].sort(), + registrations, wirePrefix: port.wirePrefix ?? '', selectable: selectsToolsets(expand(port.serverDir ?? port.dir)), - source: relative(port.checkout, dir), repo: port.repo, revision, extractedRevision: head, + source: relative(port.checkout, dir), + repo: port.repo, + revision, + extractedRevision: head, ...(snapshot ? { selection: snapshot.selection, protocol: { ...snapshot.protocol, tools: undefined } } : {}), } } @@ -339,9 +377,7 @@ if (silent.length) { } const universe = [...new Set(slugs.flatMap((s) => results[s].tools))].sort() -const coverage = Object.fromEntries( - universe.map((t) => [t, slugs.filter((s) => results[s].tools.includes(t))]), -) +const coverage = Object.fromEntries(universe.map((t) => [t, slugs.filter((s) => results[s].tools.includes(t))])) const payload = { generated: 'scripts/gen-mcp-tools.mjs', diff --git a/scripts/gen-mentions.mjs b/scripts/gen-mentions.mjs index 9c11aa2b7..1841d641d 100755 --- a/scripts/gen-mentions.mjs +++ b/scripts/gen-mentions.mjs @@ -3,7 +3,16 @@ import { existsSync, globSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { basename, dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { Resolver, parentInventory, decideMention, isLikelyReference, notASymbol, notApiReason, proseMentions, pageSlug } from '../packages/api-model/src/index.ts' +import { + Resolver, + parentInventory, + decideMention, + isLikelyReference, + notASymbol, + notApiReason, + proseMentions, + pageSlug, +} from '../packages/api-model/src/index.ts' import { isLocale } from '../site/src/i18n/locales.ts' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -15,7 +24,9 @@ const check = process.argv.includes('--check') const { PORTS: PORT_DEFS } = await import(`file://${resolve(root, 'site/src/lib/ports.ts')}`) const PORTS = PORT_DEFS.map((p) => p.slug) -const { KNOWN_PORTS, resolvePortBody, resolvePortContent } = await import(`file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}`) +const { KNOWN_PORTS, resolvePortBody, resolvePortContent } = await import( + `file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}` +) /** The first column's label, as the prose writes it. */ const PORT_BY_LABEL = Object.fromEntries(PORT_DEFS.map((port) => [port.name, port.slug])) @@ -32,8 +43,13 @@ const models = Object.fromEntries(modelList.map((model) => [model.port, model])) const resolver = new Resolver(modelList) for (const port of PORT_DEFS) { const dependencies = [...(port.parentLibrary ? [port.parentLibrary.slug] : []), ...(port.apiDependencies ?? [])] - for (const dependency of dependencies) resolver.addInventory('Library API', '', parentInventory(models[dependency], - (symbol) => `/reference/${dependency}/${symbol.slug}/`), [port.slug]) + for (const dependency of dependencies) + resolver.addInventory( + 'Library API', + '', + parentInventory(models[dependency], (symbol) => `/reference/${dependency}/${symbol.slug}/`), + [port.slug], + ) } // Match the renderer's external names before considering a cross-port fallback. for (const [file, project, baseUrl, langs] of [ @@ -43,21 +59,38 @@ for (const [file, project, baseUrl, langs] of [ ['julia', 'Julia', 'https://docs.julialang.org/en/v1/', ['julia']], ]) { const inventory = JSON.parse(readFileSync(join(root, `site/src/data/inventories/${file}.entries.json`), 'utf8')) - resolver.addInventory(project, baseUrl, inventory.e.map(([name, uri]) => ({ - name, uri, type: 'std:label', priority: 1, dispname: '-', - })), langs) + resolver.addInventory( + project, + baseUrl, + inventory.e.map(([name, uri]) => ({ + name, + uri, + type: 'std:label', + priority: 1, + dispname: '-', + })), + langs, + ) } function frontmatterValue(source, key) { const front = /^---\n([\s\S]*?)\n---/.exec(source)?.[1] const value = front && new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(front)?.[1] if (!value) return undefined - try { return JSON.parse(value) } catch { return value.replace(/^['"]|['"]$/g, '') } + try { + return JSON.parse(value) + } catch { + return value.replace(/^['"]|['"]$/g, '') + } } function versionOf(port) { if (port === process.env.LIBTMUX_DOCS_PORT) return process.env.LIBTMUX_DOCS_VERSION || 'latest' - try { return JSON.parse(process.env.LIBTMUX_DOCS_PORT_DEFAULTS || '{}')[port] ?? 'latest' } catch { return 'latest' } + try { + return JSON.parse(process.env.LIBTMUX_DOCS_PORT_DEFAULTS || '{}')[port] ?? 'latest' + } catch { + return 'latest' + } } // Authored destinations carry ownership even when their labels are ambiguous. @@ -66,8 +99,8 @@ const authoredTargets = new Map() for (const model of modelList) { for (const symbol of model.symbols) { const slug = symbol.slug ?? pageSlug(symbol.publicId ?? symbol.id) - const product = symbol.product && symbol.product !== 'core' && symbol.apiScope !== 'internal' - ? `${symbol.product}/` : '' + const product = + symbol.product && symbol.product !== 'core' && symbol.apiScope !== 'internal' ? `${symbol.product}/` : '' const target = { port: model.port, symbol: symbol.id } authoredTargets.set(`/${model.port}/${versionOf(model.port)}/${product}reference/${slug}/`, target) if (!product) authoredTargets.set(`/reference/${model.port}/${slug}/`, target) @@ -82,7 +115,9 @@ function authoredTarget(href, page) { const parts = decodeURIComponent(url.pathname).split('/').filter(Boolean) if (isLocale(parts[0])) parts.shift() return authoredTargets.get(`/${parts.join('/')}/`) - } catch { return undefined } + } catch { + return undefined + } } /** `site/src/content/docs/topics/traversal.md` becomes `/topics/traversal/`. */ @@ -181,7 +216,11 @@ for (const { file, source } of [...realEntries, ...sharedWorkspaceEntries(realFi const product = frontmatterValue(source, 'product') ?? /^ports\/[^/]+\/(workspace|mcp)\//.exec(file)?.[1] const selected = resolvePortContent(source, authoredPort) - for (const { port: contextPort, text, line, before, linked, href } of proseMentions(selected.body, PORT_BY_LABEL, selected.portAt)) { + for (const { port: contextPort, text, line, before, linked, href } of proseMentions( + selected.body, + PORT_BY_LABEL, + selected.portAt, + )) { const pagePort = authoredPort ?? contextPort if (notASymbol(text)) continue let target @@ -210,15 +249,15 @@ for (const { file, source } of [...realEntries, ...sharedWorkspaceEntries(realFi } mentions.sort( - (a, b) => - a.port.localeCompare(b.port) || - a.symbol.localeCompare(b.symbol) || - a.page.localeCompare(b.page), + (a, b) => a.port.localeCompare(b.port) || a.symbol.localeCompare(b.symbol) || a.page.localeCompare(b.page), ) if (check) { const existing = existsSync(out) ? JSON.parse(readFileSync(out, 'utf8')) : undefined - if (JSON.stringify(existing?.mentions) !== JSON.stringify(mentions) || JSON.stringify(existing?.dangling) !== JSON.stringify(dangling)) { + if ( + JSON.stringify(existing?.mentions) !== JSON.stringify(mentions) || + JSON.stringify(existing?.dangling) !== JSON.stringify(dangling) + ) { console.error('gen-mentions: index missing or stale; run node scripts/gen-mentions.mjs') process.exit(1) } diff --git a/scripts/gen-registry.mjs b/scripts/gen-registry.mjs index adf6edbf8..e5ec73cdd 100644 --- a/scripts/gen-registry.mjs +++ b/scripts/gen-registry.mjs @@ -93,8 +93,7 @@ function isPrerelease(version, grammar) { } /** Newest first, under this port's grammar. */ -const newestFirst = (versions, grammar) => - [...versions].sort((a, b) => comparePackageVersions(a, b, grammar)) +const newestFirst = (versions, grammar) => [...versions].sort((a, b) => comparePackageVersions(a, b, grammar)) async function getJson(url, headers = {}) { const res = await fetch(url, { @@ -151,11 +150,12 @@ const PROBES = { const packages = {} for (const name of names) { const { missing, body } = await getJson(`https://rubygems.org/api/v1/versions/${name}.json`) - packages[name] = missing || !Array.isArray(body) - ? null - : body - .filter((release) => release.yanked_at === null || release.yanked_at === undefined) - .map((release) => release.number) + packages[name] = + missing || !Array.isArray(body) + ? null + : body + .filter((release) => release.yanked_at === null || release.yanked_at === undefined) + .map((release) => release.number) } return { versions: packages.libtmux, packages } }, @@ -184,9 +184,7 @@ const PROBES = { return (body.versions ?? []).filter((v) => !v.yanked).map((v) => v.num) }, async go() { - const { missing, body } = await getText( - 'https://proxy.golang.org/github.com/libtmux/libtmux-go/@v/list', - ) + const { missing, body } = await getText('https://proxy.golang.org/github.com/libtmux/libtmux-go/@v/list') if (missing || body === null) return null const versions = body.split('\n').filter(Boolean) return versions.length > 0 ? versions : null @@ -198,9 +196,15 @@ const PROBES = { if (missing || !body) return null return [...body.matchAll(/([^<]+)<\/version>/g)].map((m) => m[1]) }, - async kotlin(port) { return PROBES.java(port) }, - async scala(port) { return PROBES.java(port) }, - async fsharp(port) { return PROBES.csharp(port) }, + async kotlin(port) { + return PROBES.java(port) + }, + async scala(port) { + return PROBES.java(port) + }, + async fsharp(port) { + return PROBES.csharp(port) + }, async csharp(port) { const { missing, body } = await getJson( `https://api.nuget.org/v3-flatcontainer/${port.packageName.toLowerCase()}/index.json`, @@ -346,10 +350,7 @@ for (const port of PORTS) { const entry = classify(port, versions, tag) if (probe && !Array.isArray(probe) && probe.packages) { entry.packages = Object.fromEntries( - Object.entries(probe.packages).map(([name, packageVersions]) => [ - name, - classify(port, packageVersions, tag), - ]), + Object.entries(probe.packages).map(([name, packageVersions]) => [name, classify(port, packageVersions, tag)]), ) } ports[port.slug] = entry diff --git a/scripts/gen-registry.negative.mjs b/scripts/gen-registry.negative.mjs index 495bc1d7d..9484b9d82 100644 --- a/scripts/gen-registry.negative.mjs +++ b/scripts/gen-registry.negative.mjs @@ -21,7 +21,10 @@ const real = join(here, '..', 'site/src/data/registry.json') function run(file) { try { - const out = execFileSync('node', [script, '--check', '--offline', '--out', file], { encoding: 'utf8', stdio: 'pipe' }) + const out = execFileSync('node', [script, '--check', '--offline', '--out', file], { + encoding: 'utf8', + stdio: 'pipe', + }) return { code: 0, out } } catch (err) { return { code: err.status, out: `${err.stdout ?? ''}${err.stderr ?? ''}` } @@ -53,12 +56,18 @@ const check = (name, ok, detail) => { } const mutations = [ - ['a dropped port is caught', (s) => { - const data = JSON.parse(s) - delete data.ports.rs - return `${JSON.stringify(data, null, 2)}\n` - }], - ['a hand-edited version is caught', (s) => s.replace(/("rs": \{\n\s+"status": "\w+",\n\s+"version": ")([^"]+)"/, '$10.0.0-edited"')], + [ + 'a dropped port is caught', + (s) => { + const data = JSON.parse(s) + delete data.ports.rs + return `${JSON.stringify(data, null, 2)}\n` + }, + ], + [ + 'a hand-edited version is caught', + (s) => s.replace(/("rs": \{\n\s+"status": "\w+",\n\s+"version": ")([^"]+)"/, '$10.0.0-edited"'), + ], ['a file the generator would not write is caught', (s) => `${JSON.stringify(JSON.parse(s))}\n`], ] diff --git a/scripts/gen-tmux-manual.mjs b/scripts/gen-tmux-manual.mjs index 2babf47e7..83d7639ee 100644 --- a/scripts/gen-tmux-manual.mjs +++ b/scripts/gen-tmux-manual.mjs @@ -57,7 +57,8 @@ export function parseManual(html, commands, version) { link.append(code) } const sections = [...document.querySelectorAll('section.Sh > h1')].map((heading) => ({ - id: heading.id, title: compact(heading.textContent), + id: heading.id, + title: compact(heading.textContent), })) for (const heading of document.querySelectorAll('h1, h2')) { const replacement = document.createElement(heading.tagName === 'H1' ? 'h2' : 'h3') @@ -82,8 +83,12 @@ export function parseManual(html, commands, version) { } const related = { 'set-window-option': 'set-option', 'show-window-options': 'show-options' }[command.name] if (!related) throw new Error(`Command absent from the manual: ${command.name}`) - return { ...command, section: 'OPTIONS', summary: `Window-scoped form of ${related}.`, - html: `

    Use ${related} for the option semantics. This command selects window scope. Its accepted flags are listed above.

    ` } + return { + ...command, + section: 'OPTIONS', + summary: `Window-scoped form of ${related}.`, + html: `

    Use ${related} for the option semantics. This command selects window scope. Its accepted flags are listed above.

    `, + } }) const manual = document.body.innerHTML window.happyDOM.abort() @@ -94,7 +99,9 @@ function main() { const args = process.argv.slice(2) const option = (name) => args[args.indexOf(name) + 1] if (!args.includes('--source') || !args.includes('--binaries') || !args.includes('--mandoc')) { - throw new Error('Usage: gen-tmux-manual.mjs --source --binaries --mandoc [--check]') + throw new Error( + 'Usage: gen-tmux-manual.mjs --source --binaries --mandoc [--check]', + ) } const source = resolve(option('--source')) const binaries = resolve(option('--binaries')) @@ -112,27 +119,67 @@ function main() { delete env.TMUX_PANE let catalog try { - execFileSync(binary, ['-S', socket, '-f', '/dev/null', 'new-session', '-d', '-s', 'reference', '/bin/cat'], { env }) - catalog = execFileSync(binary, ['-S', socket, '-f', '/dev/null', 'list-commands', '-F', - '#{command_list_name}\t#{command_list_alias}\t#{command_list_usage}'], { encoding: 'utf8', env }) + execFileSync(binary, ['-S', socket, '-f', '/dev/null', 'new-session', '-d', '-s', 'reference', '/bin/cat'], { + env, + }) + catalog = execFileSync( + binary, + [ + '-S', + socket, + '-f', + '/dev/null', + 'list-commands', + '-F', + '#{command_list_name}\t#{command_list_alias}\t#{command_list_usage}', + ], + { encoding: 'utf8', env }, + ) } finally { if (existsSync(socket)) execFileSync(binary, ['-S', socket, 'kill-server'], { env }) rmSync(temporary, { recursive: true }) } - const declarations = execFileSync('git', ['-C', source, 'grep', '-n', '-E', '\\.name = "', revision, '--', 'cmd-*.c'], { encoding: 'utf8' }) - const sources = new Map([...declarations.matchAll(/^[^:]+:([^:]+):(\d+):.*\.name = "([^"]+)"/gm)] - .map((match) => [match[3], { sourceFile: match[1], sourceLine: Number(match[2]) }])) - const commands = catalog.trimEnd().split('\n').map((line) => { - const [name, alias, usage = ''] = line.split('\t') - if (!/^[a-z]+(?:-[a-z]+)*$/.test(name)) throw new Error(`Invalid command: ${name}`) - if (!sources.has(name)) throw new Error(`Missing source declaration: ${name}`) - return { name, alias, usage, ...sources.get(name) } + const declarations = execFileSync( + 'git', + ['-C', source, 'grep', '-n', '-E', '\\.name = "', revision, '--', 'cmd-*.c'], + { encoding: 'utf8' }, + ) + const sources = new Map( + [...declarations.matchAll(/^[^:]+:([^:]+):(\d+):.*\.name = "([^"]+)"/gm)].map((match) => [ + match[3], + { sourceFile: match[1], sourceLine: Number(match[2]) }, + ]), + ) + const commands = catalog + .trimEnd() + .split('\n') + .map((line) => { + const [name, alias, usage = ''] = line.split('\t') + if (!/^[a-z]+(?:-[a-z]+)*$/.test(name)) throw new Error(`Invalid command: ${name}`) + if (!sources.has(name)) throw new Error(`Missing source declaration: ${name}`) + return { name, alias, usage, ...sources.get(name) } + }) + const html = execFileSync(mandoc, ['-Thtml', '-Ofragment'], { + input: manual, + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, }) - const html = execFileSync(mandoc, ['-Thtml', '-Ofragment'], { input: manual, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }) - const license = manual.split('\n.Dd')[0].split('\n').filter((line) => line.startsWith('.\\"')).slice(2) - .map((line) => line.replace(/^\.\\" ?/, '')).join('\n').trim() - const model = { version, revision, manualSha256: digest(manual), catalogSha256: digest(catalog), license, - ...parseManual(html, commands, version) } + const license = manual + .split('\n.Dd')[0] + .split('\n') + .filter((line) => line.startsWith('.\\"')) + .slice(2) + .map((line) => line.replace(/^\.\\" ?/, '')) + .join('\n') + .trim() + const model = { + version, + revision, + manualSha256: digest(manual), + catalogSha256: digest(catalog), + license, + ...parseManual(html, commands, version), + } const target = join(dataDir, `${version}.json`) const output = `${JSON.stringify(model, null, 2)}\n` if (args.includes('--check')) { diff --git a/scripts/gen-versions.mjs b/scripts/gen-versions.mjs index b1728690e..37f6cb357 100755 --- a/scripts/gen-versions.mjs +++ b/scripts/gen-versions.mjs @@ -122,8 +122,20 @@ function deriveEntries(port) { // engine — the opposite of the truth. ...(stableTag ? [{ slug: 'stable', label: 'stable', kind: 'alias', resolvesTo: stableTag, supported: true }] : []), ...(preTag ? [{ slug: 'next', label: 'next', kind: 'alias', resolvesTo: preTag, supported: true }] : []), - ...tags.map((t) => ({ slug: t.name, label: t.name, kind: 'tag', supported: true, ...(t.date ? { published: t.date } : {}) })), - ...branches.map((b) => ({ slug: b.name, label: b.name, kind: 'branch', supported: true, ...(b.date ? { published: b.date } : {}) })), + ...tags.map((t) => ({ + slug: t.name, + label: t.name, + kind: 'tag', + supported: true, + ...(t.date ? { published: t.date } : {}), + })), + ...branches.map((b) => ({ + slug: b.name, + label: b.name, + kind: 'branch', + supported: true, + ...(b.date ? { published: b.date } : {}), + })), ] return { entries, defaultVersion: stableTag ? 'stable' : 'latest', note: undefined } } @@ -146,18 +158,14 @@ function mergeOverrides(manifest, overridesPath) { function main() { const opts = parseArgs(process.argv.slice(2)) if (opts.help) { - process.stdout.write( - 'Usage: node scripts/gen-versions.mjs [--out ] [--seed] [--overrides ]\n', - ) + process.stdout.write('Usage: node scripts/gen-versions.mjs [--out ] [--seed] [--overrides ]\n') return } const manifest = { schema: 1, ports: {}, defaultVersion: {} } for (const port of PORTS) { - const derived = opts.seed - ? { entries: seedEntries(), defaultVersion: 'latest' } - : deriveEntries(port) + const derived = opts.seed ? { entries: seedEntries(), defaultVersion: 'latest' } : deriveEntries(port) if (derived.note) process.stderr.write(`gen-versions: ${port.slug}: ${derived.note}\n`) manifest.ports[port.slug] = sortVersions(derived.entries, port.tagGrammar, port.tagPrefix) // Not unconditionally 'stable' any more: a port with no release has no diff --git a/scripts/inject-shell.mjs b/scripts/inject-shell.mjs index 5b5054a5a..7f10f8fa5 100644 --- a/scripts/inject-shell.mjs +++ b/scripts/inject-shell.mjs @@ -16,10 +16,16 @@ const selectedVersion = argument('--version') const locale = process.env.LIBTMUX_DOCS_LOCALE || 'en' const siteDir = existsSync(join(output, locale, 'index.html')) ? join(output, locale) : output const prefix = `${(process.env.LIBTMUX_DOCS_LOCALES_ROOT || '').replace(/\/+$/, '')}/${locale}/` -const failures = new Set(), checked = [], skipped = [], assets = new Map(), allAssets = new Set() +const failures = new Set(), + checked = [], + skipped = [], + assets = new Map(), + allAssets = new Set() const stripCssComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '') -const cssUrls = (css) => [...stripCssComments(css).matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/g)] - .map((match) => match[1] ?? match[2] ?? match[3]) +const cssUrls = (css) => + [...stripCssComments(css).matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/g)].map( + (match) => match[1] ?? match[2] ?? match[3], + ) function files(directory, suffix) { if (!existsSync(directory)) return [] @@ -55,7 +61,10 @@ function checkAsset(href, parent) { for (const port of PORTS.filter((entry) => entry.renderer === 'sphinx')) { const root = join(siteDir, port.slug) - if (!existsSync(root)) { skipped.push(`${port.slug}: no assembled native reference`); continue } + if (!existsSync(root)) { + skipped.push(`${port.slug}: no assembled native reference`) + continue + } for (const version of readdirSync(root)) { if (selectedVersion && version !== selectedVersion) continue assets.clear() @@ -69,10 +78,13 @@ for (const port of PORTS.filter((entry) => entry.renderer === 'sphinx')) { count++ const url = `https://libtmux.org${prefix}${relative(siteDir, file).replace(/index\.html$/, '')}` if (!html.includes('data-native-shell="')) failures.add(`${url}: compiled native shell is absent`) - if (/data-native-shell-export|data-native-boundary/.test(html)) failures.add(`${url}: temporary native export was published`) - if (/]*src=["'][^"']*(?:\/_shell\/shell\.js|(?:^|\/)spa-nav\.js)/.test(html)) failures.add(`${url}: competing native shell/router remains`) + if (/data-native-shell-export|data-native-boundary/.test(html)) + failures.add(`${url}: temporary native export was published`) + if (/]*src=["'][^"']*(?:\/_shell\/shell\.js|(?:^|\/)spa-nav\.js)/.test(html)) + failures.add(`${url}: competing native shell/router remains`) for (const tag of html.matchAll(/<(?:link|script|img)\b[^>]*>/g)) { - if (tag[0].startsWith(' entry.renderer === 'sphinx')) { for (const href of cssUrls(style[1])) checkAsset(href, url) } } - if (!count) { skipped.push(`${port.slug}/${version}: no native content`); continue } + if (!count) { + skipped.push(`${port.slug}/${version}: no native content`) + continue + } const styles = [...assets].filter(([file]) => file.endsWith('.css')).map(([, css]) => css) styles.push(...inlineStyles) const adapter = styles.find((css) => css.includes('html[data-native-shell]')) @@ -89,14 +104,20 @@ for (const port of PORTS.filter((entry) => entry.renderer === 'sphinx')) { if (adapter) { const css = stripCssComments(adapter) const generated = files(join(native, '_static'), '.css') - .filter((path) => !path.endsWith('/libtmux-org.css')).map((path) => readFileSync(path, 'utf8')).join('\n') - const nativeRules = [...css.matchAll(/[^{}]*html\[data-native-shell\][^{}]*\{([^{}]*)\}/g)].map((match) => match[1]).join('\n') + .filter((path) => !path.endsWith('/libtmux-org.css')) + .map((path) => readFileSync(path, 'utf8')) + .join('\n') + const nativeRules = [...css.matchAll(/[^{}]*html\[data-native-shell\][^{}]*\{([^{}]*)\}/g)] + .map((match) => match[1]) + .join('\n') for (const [name] of nativeRules.matchAll(/--color-[a-z0-9-]+(?=\s*:)/gi)) { - if (!generated.includes(name)) failures.add(`${port.slug}/${version}: native generator no longer declares ${name}`) + if (!generated.includes(name)) + failures.add(`${port.slug}/${version}: native generator no longer declares ${name}`) } const compiled = styles.join('\n') for (const [, name] of nativeRules.matchAll(/var\((--(?:lt-)?[a-z0-9-]+)/gi)) { - if (!compiled.includes(`${name}:`) && !css.includes(`${name}:`)) failures.add(`${port.slug}/${version}: shared CSS does not declare ${name}`) + if (!compiled.includes(`${name}:`) && !css.includes(`${name}:`)) + failures.add(`${port.slug}/${version}: shared CSS does not declare ${name}`) } } checked.push(`${port.slug}/${version}: ${count} native pages`) diff --git a/scripts/lib/mcp-protocol.mjs b/scripts/lib/mcp-protocol.mjs index 3fa001792..7da73baab 100644 --- a/scripts/lib/mcp-protocol.mjs +++ b/scripts/lib/mcp-protocol.mjs @@ -7,20 +7,36 @@ const isObject = (value) => value !== null && typeof value === 'object' && !Arra function validRegistration(item, key) { if (!isObject(item) || typeof item.name !== 'string') return false if (item.description !== undefined && typeof item.description !== 'string') return false - if (key === 'tools') return isObject(item.inputSchema) && item.inputSchema.type === 'object' && - (item.outputSchema === undefined || (isObject(item.outputSchema) && item.outputSchema.type === 'object')) + if (key === 'tools') + return ( + isObject(item.inputSchema) && + item.inputSchema.type === 'object' && + (item.outputSchema === undefined || (isObject(item.outputSchema) && item.outputSchema.type === 'object')) + ) if (key === 'resources') return typeof item.uri === 'string' if (key === 'resourceTemplates') return typeof item.uriTemplate === 'string' - return item.arguments === undefined || (Array.isArray(item.arguments) && item.arguments.every((arg) => - isObject(arg) && typeof arg.name === 'string' && - (arg.description === undefined || typeof arg.description === 'string') && - (arg.required === undefined || typeof arg.required === 'boolean'))) + return ( + item.arguments === undefined || + (Array.isArray(item.arguments) && + item.arguments.every( + (arg) => + isObject(arg) && + typeof arg.name === 'string' && + (arg.description === undefined || typeof arg.description === 'string') && + (arg.required === undefined || typeof arg.required === 'boolean'), + )) + ) } /** Schema object order is not semantic; some SDKs emit hash maps in random order. */ export function orderedProtocol(value) { if (Array.isArray(value)) return value.map(orderedProtocol) - if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, orderedProtocol(item)])) + if (value && typeof value === 'object') + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, orderedProtocol(item)]), + ) return value } @@ -30,7 +46,12 @@ export function orderedProtocol(value) { */ export async function captureProtocol({ command, args = [], cwd, env = {}, timeoutMs = 30000 }) { const grouped = process.platform !== 'win32' - const child = spawn(command, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], detached: grouped }) + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + detached: grouped, + }) const closed = new Promise((resolve) => child.once('close', resolve)) const stop = (signal) => { if (!child.pid) return @@ -45,7 +66,9 @@ export async function captureProtocol({ command, args = [], cwd, env = {}, timeo const unsupportedMethods = new Set() let nextId = 0 let stderr = '' - child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-4096) }) + child.stderr.on('data', (chunk) => { + stderr = (stderr + chunk).slice(-4096) + }) const lines = createInterface({ input: child.stdout }) const fail = (error) => { for (const request of pending.values()) request.reject(error) @@ -56,23 +79,31 @@ export async function captureProtocol({ command, args = [], cwd, env = {}, timeo child.on('exit', (code) => fail(new Error(`MCP process exited ${code}: ${stderr}`))) lines.on('line', (line) => { let message - try { message = JSON.parse(line) } catch { return } + try { + message = JSON.parse(line) + } catch { + return + } const request = pending.get(message.id) if (!request) return pending.delete(message.id) - if (message.error?.code === -32601 && request.method === 'resources/templates/list' && request.params?.cursor === undefined) { + if ( + message.error?.code === -32601 && + request.method === 'resources/templates/list' && + request.params?.cursor === undefined + ) { unsupportedMethods.add(request.method) request.resolve({ resourceTemplates: [] }) - } - else if (message.error) request.reject(new Error(JSON.stringify(message.error))) + } else if (message.error) request.reject(new Error(JSON.stringify(message.error))) else request.resolve(message.result) }) const send = (message) => child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`) - const request = (method, params) => new Promise((resolve, reject) => { - const id = ++nextId - pending.set(id, { resolve, reject, method, params }) - send({ id, method, ...(params ? { params } : {}) }) - }) + const request = (method, params) => + new Promise((resolve, reject) => { + const id = ++nextId + pending.set(id, { resolve, reject, method, params }) + send({ id, method, ...(params ? { params } : {}) }) + }) const timeout = setTimeout(() => { fail(new Error(`MCP discovery timed out: ${stderr}`)) stop('SIGKILL') @@ -85,7 +116,8 @@ export async function captureProtocol({ command, args = [], cwd, env = {}, timeo if (!result || !Array.isArray(result[key])) throw new Error(`${method}: missing or invalid ${key} array`) const invalid = result[key].findIndex((item) => !validRegistration(item, key)) if (invalid !== -1) throw new Error(`${method}: invalid ${key} registration at index ${invalid}`) - if (result.nextCursor !== undefined && typeof result.nextCursor !== 'string') throw new Error(`${method}: invalid nextCursor`) + if (result.nextCursor !== undefined && typeof result.nextCursor !== 'string') + throw new Error(`${method}: invalid nextCursor`) entries.push(...result[key]) cursor = result.nextCursor } while (cursor !== undefined) @@ -93,15 +125,20 @@ export async function captureProtocol({ command, args = [], cwd, env = {}, timeo } try { const initialized = await request('initialize', { - protocolVersion: '2025-11-25', capabilities: {}, + protocolVersion: '2025-11-25', + capabilities: {}, clientInfo: { name: 'libtmux-docs-reference', version: '1' }, }) const capabilities = initialized?.capabilities if (!isObject(capabilities)) { throw new Error('initialize: missing or invalid capabilities object') } - if (typeof initialized.protocolVersion !== 'string' || !isObject(initialized.serverInfo) || - typeof initialized.serverInfo.name !== 'string' || typeof initialized.serverInfo.version !== 'string') { + if ( + typeof initialized.protocolVersion !== 'string' || + !isObject(initialized.serverInfo) || + typeof initialized.serverInfo.name !== 'string' || + typeof initialized.serverInfo.version !== 'string' + ) { throw new Error('initialize: missing or invalid protocolVersion or serverInfo') } for (const family of ['tools', 'resources', 'prompts']) { diff --git a/scripts/merge-version-manifests.mjs b/scripts/merge-version-manifests.mjs index 1c20f949c..7c6bfde68 100644 --- a/scripts/merge-version-manifests.mjs +++ b/scripts/merge-version-manifests.mjs @@ -10,17 +10,45 @@ function fail(message) { } export function validateReceipt(value, port, version) { - const check = (condition, message) => { if (!condition) fail(message) } + const check = (condition, message) => { + if (!condition) fail(message) + } const DIGEST = /^[0-9a-f]{64}$/ const prefix = value?.destination?.prefix const parts = typeof prefix === 'string' ? prefix.split('/') : [] - check(parts.length === 4 && /^[a-z]{2}(?:-[A-Z]{2})?$/.test(parts[0]) && parts[1] === port && parts[2] === version && parts[3] === '' && !prefix.includes('..'), - 'publication receipt belongs to another destination') - check(value.destination.url === `https://libtmux.org/${prefix}` && value.build?.url === `/${prefix}build-provenance.json` && DIGEST.test(value.build?.sha256 ?? ''), 'invalid publication build/destination') - check(value.publisher?.repository === 'libtmux/docs' && /^[0-9a-f]{40}$/.test(value.publisher?.sha ?? ''), 'invalid publication publisher') + check( + parts.length === 4 && + /^[a-z]{2}(?:-[A-Z]{2})?$/.test(parts[0]) && + parts[1] === port && + parts[2] === version && + parts[3] === '' && + !prefix.includes('..'), + 'publication receipt belongs to another destination', + ) + check( + value.destination.url === `https://libtmux.org/${prefix}` && + value.build?.url === `/${prefix}build-provenance.json` && + DIGEST.test(value.build?.sha256 ?? ''), + 'invalid publication build/destination', + ) + check( + value.publisher?.repository === 'libtmux/docs' && /^[0-9a-f]{40}$/.test(value.publisher?.sha ?? ''), + 'invalid publication publisher', + ) check(['published', 'verified-existing'].includes(value.operation), 'invalid publication operation') - check(Number.isSafeInteger(value.artifact?.id) && value.artifact.id > 0 && typeof value.artifact.name === 'string' && DIGEST.test(value.artifact.sha256 ?? ''), 'invalid publication artifact') - check(/^https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/[0-9]+$/.test(value.run?.url ?? '') && Number.isSafeInteger(value.run?.attempt) && value.run.attempt > 0, 'invalid publication run') + check( + Number.isSafeInteger(value.artifact?.id) && + value.artifact.id > 0 && + typeof value.artifact.name === 'string' && + DIGEST.test(value.artifact.sha256 ?? ''), + 'invalid publication artifact', + ) + check( + /^https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/[0-9]+$/.test(value.run?.url ?? '') && + Number.isSafeInteger(value.run?.attempt) && + value.run.attempt > 0, + 'invalid publication run', + ) } function parseArgs(argv) { @@ -76,12 +104,12 @@ function main() { const directory = resolve(options.fragments) const fragments = existsSync(directory) ? readdirSync(directory, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((entry) => ({ - port: basename(entry.name, '.json'), - manifest: readManifest(resolve(directory, entry.name)), - })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((entry) => ({ + port: basename(entry.name, '.json'), + manifest: readManifest(resolve(directory, entry.name)), + })) : [] const result = merge(readManifest(resolve(options.base)), fragments) writeFileSync(resolve(options.out), `${JSON.stringify(result, null, 2)}\n`) diff --git a/scripts/native-shell-context.mjs b/scripts/native-shell-context.mjs index b984d9ba6..ca148f670 100644 --- a/scripts/native-shell-context.mjs +++ b/scripts/native-shell-context.mjs @@ -16,7 +16,8 @@ export function nativeArticle(html) { } function artifactFiles(directory, path = '') { - return readdirSync(join(directory, path), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)) + return readdirSync(join(directory, path), { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)) .flatMap((entry) => { const name = path ? `${path}/${entry.name}` : entry.name if (entry.isDirectory()) return artifactFiles(directory, name) @@ -26,8 +27,13 @@ function artifactFiles(directory, path = '') { } function localPath(value, field) { - if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || - /[?#\\]/.test(value) || value.split('/').some((part) => ['.', '..'].includes(decodeURIComponent(part)))) { + if ( + typeof value !== 'string' || + !value.startsWith('/') || + value.startsWith('//') || + /[?#\\]/.test(value) || + value.split('/').some((part) => ['.', '..'].includes(decodeURIComponent(part))) + ) { throw new Error(`Native ${field} is not a local absolute path: ${value}`) } return value @@ -40,7 +46,8 @@ export async function collectNativeContext(directory, { prefix, sphinxPort, vers const git = (...args) => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8' }).trim() const sha = git('rev-parse', 'HEAD') if (sourceSha && sourceSha !== sha) throw new Error(`Native source revision mismatch: ${sourceSha} != ${sha}`) - if (git('status', '--porcelain', '--untracked-files=no')) throw new Error(`Native source has tracked changes: ${checkout}`) + if (git('status', '--porcelain', '--untracked-files=no')) + throw new Error(`Native source has tracked changes: ${checkout}`) const root = localPath(`${prefix.replace(/\/+$/, '')}/`, 'prefix') if (!/^[\w.-]+$/.test(version)) throw new Error(`Invalid native version: ${version}`) const locale = root.split('/').filter(Boolean).at(-1) @@ -56,7 +63,9 @@ export async function collectNativeContext(directory, { prefix, sphinxPort, vers } const pages = [] const redirects = [] - const window = new Window({ settings: { disableJavaScriptEvaluation: true, disableCSSFileLoading: true, disableJavaScriptFileLoading: true } }) + const window = new Window({ + settings: { disableJavaScriptEvaluation: true, disableCSSFileLoading: true, disableJavaScriptFileLoading: true }, + }) try { for (const file of files.filter((path) => path.endsWith('.html') && !path.startsWith('_sources/'))) { const html = readFileSync(join(directory, file), 'utf8') @@ -73,7 +82,12 @@ export async function collectNativeContext(directory, { prefix, sphinxPort, vers let markdownHref let markdownSha256 if (entry) { - if (!sourcePath || sourcePath.startsWith('/') || /[\\\r\n]/.test(sourcePath) || sourcePath.split('/').some((part) => part === '..')) { + if ( + !sourcePath || + sourcePath.startsWith('/') || + /[\\\r\n]/.test(sourcePath) || + sourcePath.split('/').some((part) => part === '..') + ) { throw new Error(`Native page has no valid source path: ${file}`) } const committed = execFileSync('git', ['-C', checkout, 'show', `${sha}:${sourcePath}`]) @@ -89,12 +103,22 @@ export async function collectNativeContext(directory, { prefix, sphinxPort, vers throw new Error(`Native page has no docs.json entry: ${file}`) } pages.push({ - file, path, url: `${base}${path}`, sourcePath: `${port.slug}/${version}/api/${path}`, - source, markdownHref, markdownSha256, - htmlSha256: hashes[file], articleSha256: nativeHash(nativeArticle(html)), + file, + path, + url: `${base}${path}`, + sourcePath: `${port.slug}/${version}/api/${path}`, + source, + markdownHref, + markdownSha256, + htmlSha256: hashes[file], + articleSha256: nativeHash(nativeArticle(html)), hasTableOfContents: [...window.document.querySelectorAll('.toc-tree a[href^="#"]')].some((link) => { let id - try { id = decodeURIComponent(link.getAttribute('href').slice(1)) } catch { return false } + try { + id = decodeURIComponent(link.getAttribute('href').slice(1)) + } catch { + return false + } const target = id ? window.document.getElementById(id) : null return Boolean(target?.closest('article') && !target.matches('h1') && !target.querySelector(':scope > h1')) }), @@ -106,15 +130,23 @@ export async function collectNativeContext(directory, { prefix, sphinxPort, vers } if (metadata.size) throw new Error(`Native docs.json names missing pages: ${[...metadata.keys()].join(', ')}`) return { - schema: 1, port: port.slug, version, locale, root, base, + schema: 1, + port: port.slug, + version, + locale, + root, + base, source: { repository: port.repo, sha }, - artifactSha256: nativeHash(JSON.stringify(hashes)), pages, redirects, + artifactSha256: nativeHash(JSON.stringify(hashes)), + pages, + redirects, } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const [directory, prefix, sphinxPort, version, checkout, output, sourceSha] = process.argv.slice(2) - if (!output) throw new Error('Usage: native-shell-context.mjs DIRECTORY PREFIX PORT VERSION CHECKOUT OUTPUT [SOURCE_SHA]') + if (!output) + throw new Error('Usage: native-shell-context.mjs DIRECTORY PREFIX PORT VERSION CHECKOUT OUTPUT [SOURCE_SHA]') const manifest = await collectNativeContext(directory, { prefix, sphinxPort, version, checkout, sourceSha }) writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`) console.log(`Native context: ${manifest.pages.length} pages at ${manifest.base} from ${manifest.source.sha}`) diff --git a/scripts/normalise-parity.mjs b/scripts/normalise-parity.mjs index 7303dd155..0f480d55f 100644 --- a/scripts/normalise-parity.mjs +++ b/scripts/normalise-parity.mjs @@ -134,7 +134,10 @@ function readJson(path) { * source-evidence link path, so the three line up with each other and with .NET's own * `module` field wherever the file is one .NET's ledger covers too. */ function deriveModule(srcPath) { - const parts = srcPath.replace(/^src\//, '').replace(/\.py$/, '').split('/') + const parts = srcPath + .replace(/^src\//, '') + .replace(/\.py$/, '') + .split('/') if (parts.at(-1) === '__init__') parts.pop() return parts.join('.') } @@ -218,11 +221,10 @@ function goTestFunctionNames(checkout) { // just in tmuxq/. One ripgrep pass over the whole checkout, not per-citation: 1588 // entries citing a few hundred distinct functions is cheaper as a set-membership // check. - const out = execFileSync( - 'rg', - ['-o', '--no-filename', '--no-line-number', 'func (Test\\w+)', '-r', '$1', '.'], - { cwd: checkout, encoding: 'utf8' }, - ) + const out = execFileSync('rg', ['-o', '--no-filename', '--no-line-number', 'func (Test\\w+)', '-r', '$1', '.'], { + cwd: checkout, + encoding: 'utf8', + }) return new Set(out.split('\n').filter(Boolean)) } @@ -291,7 +293,10 @@ function parseJavaPythonApiTable(text) { const unparsed = [] for (const line of lines) { if (!line.startsWith('|')) continue - const cells = line.split('|').slice(1, -1).map((c) => c.trim()) + const cells = line + .split('|') + .slice(1, -1) + .map((c) => c.trim()) if (cells.length !== 8 || cells[0] === 'Python symbol' || cells[0].startsWith('---')) continue const treatment = cells[4].replace(/<[^>]*>/g, '').trim() const evidence = cells[7] @@ -320,7 +325,10 @@ function parseJavaTestMapCounts(path) { let dataRows = 0 for (const line of lines) { if (!line.startsWith('|')) continue - const cells = line.split('|').slice(1, -1).map((c) => c.trim()) + const cells = line + .split('|') + .slice(1, -1) + .map((c) => c.trim()) if (cells.length !== 6 || cells[0] === 'Python evidence' || cells[0].startsWith('---')) continue dataRows += 1 const status = cells[5] @@ -353,7 +361,12 @@ function parseJava(checkout) { const apiText = readFileSync(apiPath, 'utf8') const { rows, unparsed } = parseJavaPythonApiTable(apiText) const pythonRevision = parseJavaPythonRevision(apiText) - const KNOWN_TREATMENT = new Set(['direct translation', 'approved omission', 'consolidation', 'semantic Java adaptation']) + const KNOWN_TREATMENT = new Set([ + 'direct translation', + 'approved omission', + 'consolidation', + 'semantic Java adaptation', + ]) const byModule = {} const portTally = emptyTally() @@ -388,7 +401,10 @@ function parseJava(checkout) { 'on the missing PythonBehaviorParityTest class. They count as claimed in ' + 'the port total only. Server.attached_sessions has two evidence entries, ' + 'so row counts can exceed the number of distinct corrections.', - contractClassesFound: { PythonApiParityContract: apiContractFound, PythonBehaviorParityTest: behaviorContractExists }, + contractClassesFound: { + PythonApiParityContract: apiContractFound, + PythonBehaviorParityTest: behaviorContractExists, + }, unparsedSourceEvidence: unparsed, portTally: (() => { // Fold test-map.md's "deliberate Java correction" rows into the port level diff --git a/scripts/normalize-native-shell.mjs b/scripts/normalize-native-shell.mjs index 2f275f5af..24c21ff85 100644 --- a/scripts/normalize-native-shell.mjs +++ b/scripts/normalize-native-shell.mjs @@ -30,22 +30,61 @@ function adaptHighlight(directory) { /** Keep native widget behavior when Astro replaces the document body. */ function adaptWidgets(directory) { const assets = [ - ['doctools.js', '2992c09df91826a8d33e3b48b645ee77e457adcf7c712d776a814e1e2c448b43', [ - ['_ready(Documentation.init);', '_ready(Documentation.init);\ndocument.addEventListener("astro:after-swap", Documentation.initDomainIndexTable);'], - ]], - ['copybutton.js', '75381d09e0497b436ac3c7a1e690415e4f2ccf2b0cc5202fad70b3bdcb7bbf80', [ - ['const addCopyButtonToCodeCells = () => {', 'let nativeClipboard;\nconst addCopyButtonToCodeCells = () => {\n nativeClipboard?.destroy();'], - ["codeCell.insertAdjacentHTML('afterend', clipboardButton(id))", "if (!codeCell.nextElementSibling?.classList.contains('copybtn')) codeCell.insertAdjacentHTML('afterend', clipboardButton(id))"], - ["const clipboard = new ClipboardJS('.copybtn', {text: copyTargetText})", "const clipboard = nativeClipboard = new ClipboardJS('.copybtn', {text: copyTargetText})"], - ['runWhenDOMLoaded(addCopyButtonToCodeCells)', 'runWhenDOMLoaded(addCopyButtonToCodeCells)\ndocument.addEventListener("astro:page-load", addCopyButtonToCodeCells);\ndocument.addEventListener("astro:before-swap", () => nativeClipboard?.destroy());'], - ]], - ['design-tabs.js', 'de2467cfca5bb555043369f1eea82c9cd794e70fa0444f216aa4ebb18cefc9f0', [ - ['function ready() {', 'function ready() {\n sd_id_to_elements = {};'], - ['document.addEventListener("DOMContentLoaded", ready, false);', 'document.addEventListener("DOMContentLoaded", ready, false);\ndocument.addEventListener("astro:page-load", ready);'], - ['window.sessionStorage.getItem(\n storageKeyPrefix + group\n )', '(() => { try { return window.sessionStorage.getItem(storageKeyPrefix + group); } catch { return null; } })()'], - ['window.sessionStorage.setItem(storageKeyPrefix + group, tabParam);', 'try { window.sessionStorage.setItem(storageKeyPrefix + group, tabParam); } catch {}'], - ['window.sessionStorage.setItem(storageKeyPrefix + group, id);', 'try { window.sessionStorage.setItem(storageKeyPrefix + group, id); } catch {}'], - ]], + [ + 'doctools.js', + '2992c09df91826a8d33e3b48b645ee77e457adcf7c712d776a814e1e2c448b43', + [ + [ + '_ready(Documentation.init);', + '_ready(Documentation.init);\ndocument.addEventListener("astro:after-swap", Documentation.initDomainIndexTable);', + ], + ], + ], + [ + 'copybutton.js', + '75381d09e0497b436ac3c7a1e690415e4f2ccf2b0cc5202fad70b3bdcb7bbf80', + [ + [ + 'const addCopyButtonToCodeCells = () => {', + 'let nativeClipboard;\nconst addCopyButtonToCodeCells = () => {\n nativeClipboard?.destroy();', + ], + [ + "codeCell.insertAdjacentHTML('afterend', clipboardButton(id))", + "if (!codeCell.nextElementSibling?.classList.contains('copybtn')) codeCell.insertAdjacentHTML('afterend', clipboardButton(id))", + ], + [ + "const clipboard = new ClipboardJS('.copybtn', {text: copyTargetText})", + "const clipboard = nativeClipboard = new ClipboardJS('.copybtn', {text: copyTargetText})", + ], + [ + 'runWhenDOMLoaded(addCopyButtonToCodeCells)', + 'runWhenDOMLoaded(addCopyButtonToCodeCells)\ndocument.addEventListener("astro:page-load", addCopyButtonToCodeCells);\ndocument.addEventListener("astro:before-swap", () => nativeClipboard?.destroy());', + ], + ], + ], + [ + 'design-tabs.js', + 'de2467cfca5bb555043369f1eea82c9cd794e70fa0444f216aa4ebb18cefc9f0', + [ + ['function ready() {', 'function ready() {\n sd_id_to_elements = {};'], + [ + 'document.addEventListener("DOMContentLoaded", ready, false);', + 'document.addEventListener("DOMContentLoaded", ready, false);\ndocument.addEventListener("astro:page-load", ready);', + ], + [ + 'window.sessionStorage.getItem(\n storageKeyPrefix + group\n )', + '(() => { try { return window.sessionStorage.getItem(storageKeyPrefix + group); } catch { return null; } })()', + ], + [ + 'window.sessionStorage.setItem(storageKeyPrefix + group, tabParam);', + 'try { window.sessionStorage.setItem(storageKeyPrefix + group, tabParam); } catch {}', + ], + [ + 'window.sessionStorage.setItem(storageKeyPrefix + group, id);', + 'try { window.sessionStorage.setItem(storageKeyPrefix + group, id); } catch {}', + ], + ], + ], ] const digests = {} let changed = 0 @@ -57,16 +96,22 @@ function adaptWidgets(directory) { if (nativeHash(before) !== digest) throw new Error(`Native widget changed: ${file}`) const after = edits.reduce((value, [from, to]) => value.replaceAll(from, to), before) digests[name] = nativeHash(after) - if (after !== original) { writeFileSync(file, after); changed++ } + if (after !== original) { + writeFileSync(file, after) + changed++ + } } return { changed, digests } } function exportRegion(html, name) { const boundary = (edge) => `` - const start = boundary('start'), end = boundary('end') - if (html.split(start).length !== 2 || html.split(end).length !== 2) throw new Error(`Native export must contain one ${name} region`) - const from = html.indexOf(start) + start.length, to = html.indexOf(end) + const start = boundary('start'), + end = boundary('end') + if (html.split(start).length !== 2 || html.split(end).length !== 2) + throw new Error(`Native export must contain one ${name} region`) + const from = html.indexOf(start) + start.length, + to = html.indexOf(end) if (to < from) throw new Error(`Native export ${name} boundaries are reversed`) return html.slice(from, to) } @@ -83,8 +128,12 @@ function stripNativeTheme(html, file) { return html.replace(/]*>([\s\S]*?)<\/script>/gi, (tag, body) => { if (!/localStorage\.getItem\(["']theme["']\)/.test(body)) return tag const compact = body.replace(/\s+/g, '') - if (compact === 'document.body.dataset.theme=localStorage.getItem("theme")||"auto";' || - compact === '(function(){vart=localStorage.getItem("theme")||"auto";varr=t==="auto"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):t;document.documentElement.style.colorScheme=r;document.documentElement.classList.add("gp-sphinx-theme-pending");document.documentElement.classList.remove("no-js");functions(){if(document.body&&!document.body.dataset.theme)document.body.dataset.theme=t;}requestAnimationFrame(s);document.addEventListener("DOMContentLoaded",s);})();') return '' + if ( + compact === 'document.body.dataset.theme=localStorage.getItem("theme")||"auto";' || + compact === + '(function(){vart=localStorage.getItem("theme")||"auto";varr=t==="auto"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):t;document.documentElement.style.colorScheme=r;document.documentElement.classList.add("gp-sphinx-theme-pending");document.documentElement.classList.remove("no-js");functions(){if(document.body&&!document.body.dataset.theme)document.body.dataset.theme=t;}requestAnimationFrame(s);document.addEventListener("DOMContentLoaded",s);})();' + ) + return '' throw new Error(`Unrecognized native theme bootstrap: ${file}`) }) } @@ -93,10 +142,12 @@ function adaptNativePage(html, page, context, chrome, file, assetDigests) { if (!/<\/head>/i.test(html)) throw new Error(`Native page has no closing head: ${file}`) if (!/]*>/i.test(html) || !/<\/body>/i.test(html)) throw new Error(`Native page has no body: ${file}`) const article = nativeArticle(html) - if (nativeHash(article) !== page.articleSha256) throw new Error(`Native article changed after context collection: ${page.file}`) + if (nativeHash(article) !== page.articleSha256) + throw new Error(`Native article changed after context collection: ${page.file}`) const contextHash = nativeHash(JSON.stringify(context)) const alreadyNormalized = html.includes(`data-native-shell="${contextHash}"`) - if (!alreadyNormalized && nativeHash(html) !== page.htmlSha256) throw new Error(`Native page changed after context collection: ${page.file}`) + if (!alreadyNormalized && nativeHash(html) !== page.htmlSha256) + throw new Error(`Native page changed after context collection: ${page.file}`) const opening = /]*>/i.exec(html) const afterArticle = opening.index + opening[0].length + article.length // Transform only the shell; parsing and serializing the article would rewrite its HTML. @@ -109,28 +160,42 @@ function adaptNativePage(html, page, context, chrome, file, assetDigests) { .replace(/]*>html\.gp-sphinx-theme-pending[\s\S]*?<\/style>/g, '') .replace(/]*\bid=["']sphinx-fonts["'][^>]*>[\s\S]*?<\/style>/g, '') // Older gp-sphinx releases delete this conflicting extension asset but leave its tag. - .replace(/]*\bsrc=["']((?:\.\.\/)*_static\/tabs\.js(?:\?[^"']*)?)["'][^>]*>[\s\S]*?<\/script>/gi, - (tag, src) => existsSync(resolve(dirname(file), src.split('?')[0])) ? tag : '') + .replace( + /]*\bsrc=["']((?:\.\.\/)*_static\/tabs\.js(?:\?[^"']*)?)["'][^>]*>[\s\S]*?<\/script>/gi, + (tag, src) => (existsSync(resolve(dirname(file), src.split('?')[0])) ? tag : ''), + ) .replace(/]*\bas=["']font["'])[^>]*>/gi, '') .replace(/]*class=["']mobile-header["'][^>]*>[\s\S]*?<\/header>/g, '') .replace(/]*class=["']theme-toggle["'][^>]*>[\s\S]*?<\/button>/g, '') .replace(/]*class=["']page-source["'][^>]*>[\s\S]*?<\/div>/g, '') - .replace(/]*\bsrc=["']((?:[^"']*\/)?_static\/js\/spa-nav\.js(?:\?[^"']*)?)["'][^>]*>[\s\S]*?<\/script>/gi, (_tag, src) => { - const asset = resolve(dirname(file), src.split('?')[0]) - if (nativeHash(readFileSync(asset)) !== spaNavigationHash) throw new Error(`Native navigation bootstrap changed: ${asset}`) - return '' - }) - .replace(/]*\bsrc=["']((?:[^"']*\/)?_static\/scripts\/furo\.js(?:\?[^"']*)?)["'][^>]*>[\s\S]*?<\/script>/gi, (_tag, src) => { - const asset = resolve(dirname(file), src.split('?')[0]) - if (nativeHash(readFileSync(asset)) !== furoHash) throw new Error(`Native theme script changed: ${asset}`) - return '' - }) + .replace( + /]*\bsrc=["']((?:[^"']*\/)?_static\/js\/spa-nav\.js(?:\?[^"']*)?)["'][^>]*>[\s\S]*?<\/script>/gi, + (_tag, src) => { + const asset = resolve(dirname(file), src.split('?')[0]) + if (nativeHash(readFileSync(asset)) !== spaNavigationHash) + throw new Error(`Native navigation bootstrap changed: ${asset}`) + return '' + }, + ) + .replace( + /]*\bsrc=["']((?:[^"']*\/)?_static\/scripts\/furo\.js(?:\?[^"']*)?)["'][^>]*>[\s\S]*?<\/script>/gi, + (_tag, src) => { + const asset = resolve(dirname(file), src.split('?')[0]) + if (nativeHash(readFileSync(asset)) !== furoHash) throw new Error(`Native theme script changed: ${asset}`) + return '' + }, + ) result = stripNativeTheme(result, file) - result = result.replace(/\bsrc=(["'])((?:[^"']*\/)?_static\/([^/"'?]+\.js))(?:\?[^"']*)?\1/g, - (attribute, quote, src, name) => assetDigests[name] ? `src=${quote}${src}?v=${assetDigests[name].slice(0, 12)}${quote}` : attribute) + result = result.replace( + /\bsrc=(["'])((?:[^"']*\/)?_static\/([^/"'?]+\.js))(?:\?[^"']*)?\1/g, + (attribute, quote, src, name) => + assetDigests[name] ? `src=${quote}${src}?v=${assetDigests[name].slice(0, 12)}${quote}` : attribute, + ) // ClientRouter compares asset URLs before changing the document location. - result = result.replace(/\b(src|href)=(["'])(?:\.\.\/)*(_static\/[^"']+)\2/g, - (_attribute, name, quote, path) => `${name}=${quote}${escapeAttribute(context.base + path)}${quote}`) + result = result.replace( + /\b(src|href)=(["'])(?:\.\.\/)*(_static\/[^"']+)\2/g, + (_attribute, name, quote, path) => `${name}=${quote}${escapeAttribute(context.base + path)}${quote}`, + ) result = result.replace(/\bhref=(["'])(\/[^"']+\.html)\1/g, (attribute, quote, href) => { const target = context.pages.find((entry) => `/${entry.path?.replace(/\/$/, '')}.html` === href) return target ? `href=${quote}${escapeAttribute(target.url)}${quote}` : attribute @@ -140,18 +205,30 @@ function adaptNativePage(html, page, context, chrome, file, assetDigests) { let before = transform(html.slice(0, opening.index)) let after = transform(html.slice(afterArticle)) before = before.replace(/]*)>/i, (_tag, attributes) => { - const clean = attributes.replace(/\sdata-(?:native-shell|native-toc|theme|brand|brand-variant|shell-base)(?=[=\s]|$)(?:=["'][^"']*["'])?/gi, '') + const clean = attributes.replace( + /\sdata-(?:native-shell|native-toc|theme|brand|brand-variant|shell-base)(?=[=\s]|$)(?:=["'][^"']*["'])?/gi, + '', + ) const brand = PORT_BY_SLUG[context.port].logoLanguage return `` }) before = before .replace(/<\/head>/i, `${chrome.head}\n`) - .replace(/]*)>\s*/i, (_tag, attributes) => `\n${chrome.header}\n`) - .replace(/