diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..d5b9823 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +paths: + .github/workflows/docs.yml: + ignore: + # actionlint 1.7.12 predates GitHub Actions' queue: max support. + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/docs-preview-cleanup.yml b/.github/workflows/docs-preview-cleanup.yml new file mode 100644 index 0000000..e2f0829 --- /dev/null +++ b/.github/workflows/docs-preview-cleanup.yml @@ -0,0 +1,35 @@ +name: docs-preview-cleanup + +on: + pull_request_target: + types: [closed] + +permissions: + contents: read + id-token: write + +concurrency: + group: docs-preview-lua-pr-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + cleanup: + runs-on: ubuntu-latest + environment: docs-preview-cleanup + steps: + - uses: aws-actions/configure-aws-credentials@4a1596c86fd706dc0521e32f0121ad1a6d1adb74 # v6.0.0 + with: + role-to-assume: ${{ secrets.LIBTMUX_DOCS_PREVIEW_CLEANUP_ROLE_ARN }} + aws-region: us-east-1 + + - name: Delete this Lua preview + env: + BUCKET: ${{ secrets.LIBTMUX_DOCS_BUCKET }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + [[ "$PR" =~ ^[0-9]+$ ]] || { + echo 'pull request number must be numeric' >&2 + exit 1 + } + aws s3 rm "s3://$BUCKET/en/lua/pr-$PR/" --recursive diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..5d93b66 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,276 @@ +name: docs + +on: + pull_request: + push: + branches: + - master + - 'v*.x' + tags: + - 'v*' + workflow_dispatch: + inputs: + source-ref: + description: Exact source ref to build + required: true + default: master + version: + description: URL version slug + required: true + default: latest + version-kind: + description: Version policy + required: true + type: choice + options: [trunk, branch, tag, alias, pr] + default: trunk + is-default: + description: Make this version canonical + required: true + type: boolean + default: false + resolves-to: + description: Immutable target for an alias + required: false + default: '' + publish: + description: Publish after the exact build passes + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: docs-deploy-${{ github.repository }} + queue: max + +jobs: + identity: + runs-on: ubuntu-latest + outputs: + source-ref: ${{ steps.identity.outputs.source_ref }} + source-repository: ${{ steps.identity.outputs.source_repository }} + matrix: ${{ steps.identity.outputs.matrix }} + should-publish: ${{ steps.identity.outputs.should_publish }} + steps: + - id: identity + env: + EVENT: ${{ github.event_name }} + REPOSITORY: ${{ github.repository }} + SHA: ${{ github.sha }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + INPUT_SOURCE_REF: ${{ inputs.source-ref }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_KIND: ${{ inputs.version-kind }} + INPUT_DEFAULT: ${{ inputs.is-default }} + INPUT_RESOLVES_TO: ${{ inputs.resolves-to }} + INPUT_PUBLISH: ${{ inputs.publish }} + run: | + set -euo pipefail + source_ref='' + source_repository="$REPOSITORY" + matrix='' + + case "$EVENT" in + pull_request) + source_ref="$PR_HEAD_SHA" + source_repository="$PR_HEAD_REPOSITORY" + publish=false + [[ "$source_repository" == "$REPOSITORY" ]] && publish=true + matrix=$(jq -cn \ + --arg version "pr-$PR_NUMBER" \ + --argjson publish "$publish" \ + '{include: [{version: $version, kind: "pr", isDefault: false, resolvesTo: "", environment: "docs-preview", publish: $publish}]}') + ;; + push) + source_ref="$SHA" + if [[ "$REF_TYPE" == tag ]]; then + [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+((alpha|beta|rc)[0-9]+)?$ ]] || { + echo "unsupported Lua documentation tag: $REF_NAME" >&2 + exit 1 + } + alias=stable + alias_default=true + if [[ "$REF_NAME" =~ (alpha|beta|rc)[0-9]+$ ]]; then + alias=next + alias_default=false + fi + matrix=$(jq -cn \ + --arg tag "$REF_NAME" \ + --arg alias "$alias" \ + --argjson alias_default "$alias_default" \ + '{include: [ + {version: $tag, kind: "tag", isDefault: false, resolvesTo: "", environment: "docs", publish: true}, + {version: $alias, kind: "alias", isDefault: $alias_default, resolvesTo: $tag, environment: "docs", publish: true} + ]}') + elif [[ "$REF_NAME" == master ]]; then + matrix='{"include":[{"version":"latest","kind":"trunk","isDefault":true,"resolvesTo":"","environment":"docs","publish":true}]}' + elif [[ "$REF_NAME" =~ ^v[0-9]+\.x$ ]]; then + matrix=$(jq -cn --arg version "$REF_NAME" \ + '{include: [{version: $version, kind: "branch", isDefault: false, resolvesTo: "", environment: "docs", publish: true}]}') + else + echo "unsupported documentation branch: $REF_NAME" >&2 + exit 1 + fi + ;; + workflow_dispatch) + source_ref="$INPUT_SOURCE_REF" + [[ "$INPUT_VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { + echo "invalid version slug: $INPUT_VERSION" >&2 + exit 1 + } + case "$INPUT_KIND" in + trunk|branch|tag|alias|pr) ;; + *) echo "invalid version kind: $INPUT_KIND" >&2; exit 1 ;; + esac + if [[ "$INPUT_KIND" == alias && -z "$INPUT_RESOLVES_TO" ]]; then + echo 'an alias requires resolves-to' >&2 + exit 1 + fi + if [[ "$INPUT_KIND" != alias && -n "$INPUT_RESOLVES_TO" ]]; then + echo 'resolves-to applies only to aliases' >&2 + exit 1 + fi + if [[ "$INPUT_KIND" == pr ]]; then + [[ "$INPUT_VERSION" =~ ^pr-[0-9]+$ && "$INPUT_DEFAULT" == false ]] || { + echo 'PR previews require pr-N and cannot be default' >&2 + exit 1 + } + environment=docs-preview + else + environment=docs + fi + matrix=$(jq -cn \ + --arg version "$INPUT_VERSION" \ + --arg kind "$INPUT_KIND" \ + --arg resolves "$INPUT_RESOLVES_TO" \ + --arg environment "$environment" \ + --argjson is_default "$INPUT_DEFAULT" \ + --argjson publish "$INPUT_PUBLISH" \ + '{include: [{version: $version, kind: $kind, isDefault: $is_default, resolvesTo: $resolves, environment: $environment, publish: $publish}]}') + ;; + *) echo "unsupported event: $EVENT" >&2; exit 1 ;; + esac + + { + echo "source_ref=$source_ref" + echo "source_repository=$source_repository" + echo "matrix=$matrix" + echo "should_publish=$(jq -r 'any(.include[]; .publish)' <<<"$matrix")" + } >> "$GITHUB_OUTPUT" + + build: + needs: identity + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.identity.outputs.matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ needs.identity.outputs.source-repository }} + ref: ${{ needs.identity.outputs.source-ref }} + fetch-depth: 0 + persist-credentials: false + + - id: source + env: + SELECTED_REF: ${{ needs.identity.outputs.source-ref }} + run: | + set -euo pipefail + sha=$(git rev-parse HEAD) + selected=$(git rev-parse "$SELECTED_REF^{commit}") + [[ "$sha" == "$selected" ]] || { + echo "selected source resolved to $selected, checkout is $sha" >&2 + exit 1 + } + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: .docs-generator + persist-credentials: false + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: libtmux/docs + ref: bc24b1c570a15898b643c3650857fef61e769371 + path: .site + persist-credentials: false + + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.9 + + - name: Bootstrap the pinned LuaLS exporter + run: python .docs-generator/scripts/bootstrap_native.py luals + + - name: Export the selected Lua source + run: | + python .docs-generator/scripts/export-docs \ + --source "$GITHUB_WORKSPACE" \ + --luals "$GITHUB_WORKSPACE/.docs-generator/.cache/tools/luals-3.19.1/bin/lua-language-server" \ + --output "$GITHUB_WORKSPACE/docs/_build" + + - uses: pnpm/action-setup@f520eceda224fe1a4aed5a2a27a194379a409996 # v6.0.0 + with: + package_json_file: .site/package.json + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '26' + cache: pnpm + cache-dependency-path: .site/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + working-directory: .site + + - name: Build the selected Lua documentation tree + working-directory: .site + env: + LIBTMUX_DOCS_PORT: lua + LIBTMUX_DOCS_VERSION: ${{ matrix.version }} + LIBTMUX_DOCS_VERSION_KIND: ${{ matrix.kind }} + LIBTMUX_DOCS_IS_DEFAULT: ${{ matrix.isDefault }} + LIBTMUX_DOCS_RESOLVES_TO: ${{ matrix.resolvesTo }} + LIBTMUX_DOCS_SOURCE_REF: ${{ needs.identity.outputs.source-ref }} + LIBTMUX_DOCS_SOURCE_SHA: ${{ steps.source.outputs.sha }} + LIBTMUX_DOCS_CHECKOUT_LUA: ${{ github.workspace }} + run: ./scripts/build-site.sh --ports lua --versions "${{ matrix.version }}" --skip-refs --skip-pagefind + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docs-lua-${{ matrix.version }} + path: .site/_site/en/lua/${{ matrix.version }} + if-no-files-found: error + retention-days: 1 + + publish: + needs: [identity, build] + if: ${{ !cancelled() && needs.build.result == 'success' && needs.identity.outputs.should-publish == 'true' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.identity.outputs.matrix) }} + permissions: + contents: read + id-token: write + uses: libtmux/docs/.github/workflows/reusable-deploy.yml@bc24b1c570a15898b643c3650857fef61e769371 + with: + path-prefix: lua/${{ matrix.version }} + artifact: docs-lua-${{ matrix.version }} + version-kind: ${{ matrix.kind }} + port: lua + version: ${{ matrix.version }} + label: ${{ matrix.version }} + is-default: ${{ matrix.isDefault }} + resolves-to: ${{ matrix.resolvesTo }} + environment: ${{ matrix.environment }} + secrets: + role-arn: ${{ matrix.environment == 'docs-preview' && secrets.LIBTMUX_DOCS_PREVIEW_ROLE_ARN || secrets.LIBTMUX_DOCS_ROLE_ARN }} + bucket: ${{ secrets.LIBTMUX_DOCS_BUCKET }} + distribution: ${{ secrets.LIBTMUX_DOCS_DISTRIBUTION }} diff --git a/.gitignore b/.gitignore index f0887f5..212eac7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /build/ /dist/ /coverage/ +/docs/_build/ __pycache__/ *.pyc @@ -14,7 +15,7 @@ __pycache__/ .vscode/ # Local environment and tool state. -/.cache/ +/.cache /.direnv/ /.envrc.local .env diff --git a/examples/quickstart.lua b/examples/quickstart.lua new file mode 100644 index 0000000..bf2efae --- /dev/null +++ b/examples/quickstart.lua @@ -0,0 +1,44 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(err, 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket") + +local result = must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + + -- docs:begin main + local created = must( + server + :new_session({ name = "quickstart", window_name = "main", argv = { "/bin/sh" } }) + :await() + ) + local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await()) + local split = + must(logs.pane:split({ direction = "right", percent = 40, argv = { "/bin/cat" } }):await()) + + -- The pane signals through the same tmux binary; a bare `tmux` there may be + -- another build that cannot reach this server. + local marker = "libtmux-lua-quickstart" + local signal = ("printf 'libtmux ready\\n'; '%s' wait-for -S %s"):format(binary, marker) + must(created.pane:send_text(signal):await()) + must(created.pane:send_keys({ "Enter" }):await()) + must(server:command({ "wait-for", marker }, { timeout = 10000 }):await()) + + local capture = must(created.pane:capture({ history_lines = 20 }):await()) + assert(must(capture:text()):find("libtmux ready", 1, true), "pane output was not captured") + print(created.session:reference().id, logs.window:reference().id, split.pane:reference().id) + + must(created.session:kill():await()) + -- docs:end main + must(server:close():await()) + return true +end)) + +assert(result) diff --git a/scripts/check.py b/scripts/check.py index 28b5116..c790ce3 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -9,6 +9,7 @@ import subprocess import sys import time +from urllib.parse import unquote if __package__: from .runtime_config import clean_environment, executable_path, identify @@ -57,13 +58,36 @@ def lua_environment(lua): def docs(): files = [ROOT / "AGENTS.md", *ROOT.glob("README*.md"), *ROOT.glob(".github/*.md"), *ROOT.glob("docs/**/*.md")] + files = [path for path in files if "_build" not in path.parts] + anchors = {} + + def markdown_anchors(path): + if path not in anchors: + counts = {} + found = set(re.findall(r']+>|[`*_~]", "", heading).lower() + slug = re.sub(r"[^\w\- ]", "", plain) + slug = re.sub(r"\s+", "-", slug.strip()) + duplicate = counts.get(slug, 0) + counts[slug] = duplicate + 1 + found.add(f"{slug}-{duplicate}" if duplicate else slug) + anchors[path] = found + return anchors[path] + for path in files: for target in re.findall(r"\]\(([^)]+)\)", path.read_text()): if target.startswith(("https:", "http:", "#", "mailto:")): - continue - destination = target.split("#", 1)[0] - if destination and not (path.parent / destination).exists(): + destination = path if target.startswith("#") else None + else: + destination_name = target.split("#", 1)[0] + destination = path.parent / destination_name if destination_name else path + if destination and not destination.exists(): raise SystemExit(f"Broken link in {path.relative_to(ROOT)}: {target}") + if destination and destination.suffix == ".md" and "#" in target: + fragment = unquote(target.split("#", 1)[1]) + if fragment and fragment not in markdown_anchors(destination): + raise SystemExit(f"Broken anchor in {path.relative_to(ROOT)}: {target}") if not (ROOT / "CLAUDE.md").is_symlink() or os.readlink(ROOT / "CLAUDE.md") != "AGENTS.md": raise SystemExit("CLAUDE.md must remain a relative symlink to AGENTS.md") run(["git", "diff", "--check"], timeout=5) @@ -116,6 +140,7 @@ def execute(gate): if not executable.exists(): raise SystemExit("Missing pinned LuaLS 3.19.1; see CONTRIBUTING.md") run([str(executable), "--check=.", "--checklevel=Warning", "--logpath=.cache/luals"], env=env) + run([sys.executable, "scripts/export-docs", "--luals", str(executable), "--check"], env=env) run([sys.executable, "scripts/check_editor.py"], env=env, timeout=20) elif gate == "mid": run([sys.executable, "-m", "unittest", "discover", "-s", "tests/tooling", "-v"], timeout=5) diff --git a/scripts/export-docs b/scripts/export-docs new file mode 100755 index 0000000..8739b77 --- /dev/null +++ b/scripts/export-docs @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Export the public LuaLS model, source guides, and examples as deterministic JSON.""" + +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ROOT +EXPECTED_MODULES = { + "libtmux.query": { + "NULL", "compile", "count", "decode_json", "encode_json", "exists", "filter", + "first", "iter", "one", "one_or_nil", "select", "to_table", "where", + }, + "libtmux.runtime.luv": {"run"}, + "libtmux.runtime.nvim": {"start"}, +} +EXPECTED_TYPES = { + "libtmux.Buffer", "libtmux.Client", "libtmux.Configurable", "libtmux.Entity", + "libtmux.Pane", "libtmux.Reference", "libtmux.Request", "libtmux.Runtime", "libtmux.Selection", + "libtmux.Server", "libtmux.Session", "libtmux.Snapshot", "libtmux.Window", + "libtmux.WindowLink", +} + + +class ExportError(RuntimeError): + pass + + +def source_revision(): + result = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=SOURCE, check=True, capture_output=True, text=True, + ) + revision = result.stdout.strip() + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ExportError("could not resolve source revision") + return revision + + +def tool_version(executable): + result = subprocess.run([str(executable), "--version"], check=True, capture_output=True, text=True) + match = re.search(r"\d+\.\d+\.\d+", result.stdout + result.stderr) + if not match: + raise ExportError("could not resolve LuaLS version") + return match.group(0) + + +def normalize(value, key=None): + if isinstance(value, dict): + result = {} + for child_key, child in value.items(): + if child_key == "DOC": + continue + result[child_key] = normalize(child, child_key) + if isinstance(result.get("file"), str): + result["file"] = "lua/" + result["file"].removeprefix("lua/") + return result + if isinstance(value, list): + if key in {"start", "finish"} and len(value) == 2 and all(isinstance(item, int) for item in value): + return [value[0] + 1, value[1]] + return [normalize(item) for item in value] + return value + + +def declaration_sort_key(item): + return ( + item.get("name", ""), item.get("type", ""), item.get("file", ""), + item.get("start", [0, 0])[0], + ) + + +def public_declarations(raw): + declarations = [] + for entry in raw: + name = entry.get("name", "") + if not name.startswith("libtmux."): + continue + item = normalize(entry) + item["fields"] = sorted( + (field for field in item.get("fields", []) if not field.get("name", "").startswith("_")), + key=declaration_sort_key, + ) + item["defines"] = sorted(item.get("defines", []), key=declaration_sort_key) + declarations.append(item) + return sorted(declarations, key=lambda item: item["name"]) + + +def rock_metadata(): + rockspecs = sorted((SOURCE / "rockspecs").glob("libtmux-*.rockspec")) + if not rockspecs: + raise ExportError("could not find libtmux rockspec") + release = next((path for path in rockspecs if "-scm-" not in path.name), rockspecs[-1]) + rockspec = release.read_text() + version = re.search(r'^version = "([^"]+)"', rockspec, re.MULTILINE) + tag = re.search(r'^\s+tag = "([^"]+)"', rockspec, re.MULTILINE) + if not version or not tag: + raise ExportError("could not read rock version and source tag") + return {"name": "libtmux", "version": version.group(1), "source_tag": tag.group(1)} + + +def content_files(patterns): + paths = sorted({path for pattern in patterns for path in SOURCE.glob(pattern) if path.is_file()}) + return [{"path": path.relative_to(SOURCE).as_posix(), "content": path.read_text()} for path in paths] + + +def validate(payload): + declarations = {entry["name"]: entry for entry in payload["declarations"]} + missing = EXPECTED_TYPES - declarations.keys() + if missing: + raise ExportError(f"missing public types: {', '.join(sorted(missing))}") + for module, expected in EXPECTED_MODULES.items(): + entry = declarations.get(module) + if not entry: + raise ExportError(f"missing public module: {module}") + actual = {field["name"] for field in entry.get("fields", [])} + if actual != expected: + raise ExportError( + f"public module coverage differs for {module}: " + f"missing={sorted(expected - actual)} extra={sorted(actual - expected)}" + ) + for entry in payload["declarations"]: + for node in [*entry.get("defines", []), *entry.get("fields", [])]: + path = node.get("file") + if path and (not path.startswith("lua/") or "/tests/" in path): + raise ExportError(f"invalid declaration source: {path}") + if node.get("name", "").startswith("_"): + raise ExportError(f"private helper exported: {entry['name']}.{node['name']}") + text = json.dumps(payload, sort_keys=True) + if re.search(r"/(?:home|Users)/", text): + raise ExportError("machine-specific path in export") + if "luals.config" in text or "package.loaded" in text: + raise ExportError("tool configuration leaked into export") + + +def build_payload(luals): + with tempfile.TemporaryDirectory(prefix="libtmux-lua-docs-") as temporary: + temporary = Path(temporary) + output = temporary / "output" + log = temporary / "log" + annotated = temporary / "source" + shutil.copytree(SOURCE / "lua", annotated / "lua") + for relative, module in { + "libtmux/query.lua": "libtmux.query", + "libtmux/runtime/luv.lua": "libtmux.runtime.luv", + "libtmux/runtime/nvim.lua": "libtmux.runtime.nvim", + }.items(): + path = annotated / "lua" / relative + text = path.read_text() + marker = f"---@class {module}" + if marker not in text: + text, count = re.subn(r"^(local M = \{\})$", f"{marker}\\n\\1", text, count=1, flags=re.MULTILINE) + if count != 1: + raise ExportError(f"could not annotate public module {module}") + path.write_text(text) + output.mkdir() + log.mkdir() + command = [ + str(luals), f"--doc={annotated / 'lua'}", f"--doc_out_path={output}", + f"--configpath={ROOT / '.luarc.json'}", f"--logpath={log}", + ] + result = subprocess.run(command, cwd=SOURCE, capture_output=True, text=True) + if result.returncode: + raise ExportError(f"LuaLS export failed: {result.stdout}{result.stderr}") + raw = json.loads((output / "doc.json").read_text()) + + payload = { + "schema": 1, + "port": "lua", + "source": {"repository": "libtmux/libtmux-lua", "revision": source_revision()}, + "exporter": {"name": "scripts/export-docs", "version": 1, "luals": tool_version(luals)}, + "package": rock_metadata(), + "declarations": public_declarations(raw), + "guides": content_files(["README.md", "docs/**/*.md"]), + "examples": content_files(["examples/**/*.lua"]), + } + validate(payload) + return payload + + +def write_bundle(output, name, files): + directory = output / name + shutil.rmtree(directory, ignore_errors=True) + for entry in files: + target = directory / entry["path"] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(entry["content"]) + + +def main(): + global SOURCE + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=ROOT) + parser.add_argument("--luals", type=Path, default=ROOT / ".cache/tools/luals-3.19.1/bin/lua-language-server") + parser.add_argument("--output", type=Path, default=ROOT / "docs/_build") + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + SOURCE = args.source.resolve() + if not (SOURCE / "lua/libtmux/init.lua").is_file(): + raise SystemExit(f"Invalid source checkout: {SOURCE}") + if not args.luals.is_file(): + raise SystemExit("Missing pinned LuaLS 3.19.1; see CONTRIBUTING.md") + try: + payload = build_payload(args.luals) + if not args.check: + args.output.mkdir(parents=True, exist_ok=True) + (args.output / "api.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + write_bundle(args.output, "guides", payload["guides"]) + write_bundle(args.output, "examples", payload["examples"]) + print( + f"Documentation export: {len(payload['declarations'])} declarations; " + f"{sum(len(item.get('fields', [])) for item in payload['declarations'])} fields" + ) + except (ExportError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + raise SystemExit(str(error)) from error + + +if __name__ == "__main__": + main() diff --git a/scripts/package.py b/scripts/package.py index d63dbae..66e44bc 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -7,6 +7,7 @@ import json import os from pathlib import Path +import re import shutil import subprocess import sys @@ -129,8 +130,13 @@ def live_example(prefix, version, *, lua, cwd, env): raise RuntimeError("installed snapshot example returned unexpected output") if list(fixture.path.glob("libtmux-lua-pin-*")): raise RuntimeError("installed snapshot example leaked its socket alias") + quickstart = run([lua, "quickstart.lua"], cwd=cwd, env=child_env) + if not re.fullmatch(r"\$\d+\t@\d+\t%\d+\n", quickstart): + raise RuntimeError(f"installed quickstart returned unexpected output: {quickstart!r}") + if fixture.run("has-session", "-t", "quickstart", check=False).returncode == 0: + raise RuntimeError("installed quickstart left its session running") fixture.run("has-session", "-t", "$0") - print("PASS installed public snapshot example and borrowed-server cleanup", flush=True) + print("PASS installed public snapshot/quickstart examples and borrowed-server cleanup", flush=True) def source_rock(spec, work, env, *, rocks, public_tag): @@ -285,6 +291,7 @@ def rocks(arguments, *, cwd, override_env=None): shutil.copyfile(ROOT / "tests/unit/imports.lua", work / "imports.lua") shutil.copyfile(ROOT / "examples/native_query.lua", work / "native_query.lua") shutil.copyfile(ROOT / "examples/snapshot.lua", work / "snapshot.lua") + shutil.copyfile(ROOT / "examples/quickstart.lua", work / "quickstart.lua") artifacts = work / "artifacts" artifacts.mkdir() dependency_rocks = {} diff --git a/tests/tooling/test_docs_export.py b/tests/tooling/test_docs_export.py new file mode 100644 index 0000000..8b046bf --- /dev/null +++ b/tests/tooling/test_docs_export.py @@ -0,0 +1,49 @@ +import json +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +class DocsExportTest(unittest.TestCase): + def test_export_annotations_are_isolated_from_runtime_modules(self): + modules = { + "lua/libtmux/query.lua": "libtmux.query", + "lua/libtmux/runtime/luv.lua": "libtmux.runtime.luv", + "lua/libtmux/runtime/nvim.lua": "libtmux.runtime.nvim", + } + exporter = (ROOT / "scripts/export-docs").read_text() + for relative, name in modules.items(): + self.assertNotIn(f"---@class {name}", (ROOT / relative).read_text(), relative) + self.assertIn(f'"{relative.removeprefix("lua/")}": "{name}"', exporter) + + def test_exporter_is_a_deterministic_json_entrypoint(self): + exporter = ROOT / "scripts/export-docs" + self.assertTrue(exporter.is_file()) + source = exporter.read_text() + self.assertIn("sort_keys=True", source) + self.assertIn('"schema": 1', source) + self.assertIn('parser.add_argument("--source"', source) + self.assertIn("shutil.copytree(SOURCE / \"lua\"", source) + self.assertIn("if marker not in text", source) + + def test_every_handle_class_is_a_required_export(self): + entity = (ROOT / "lua/libtmux/_internal/entity.lua").read_text() + classes = re.findall(r"^---@class (libtmux\.\w+)", entity, re.MULTILINE) + self.assertIn("libtmux.Session", classes) + exporter = (ROOT / "scripts/export-docs").read_text() + self.assertEqual([name for name in classes if f'"{name}"' not in exporter], []) + + def test_committed_product_scaffolds_are_not_export_inputs(self): + exporter = ROOT / "scripts/export-docs" + if not exporter.is_file(): + self.fail("scripts/export-docs is missing") + source = exporter.read_text() + self.assertNotIn("packages/mcp", source) + self.assertNotIn("packages/workspace", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tooling/test_docs_workflow.py b/tests/tooling/test_docs_workflow.py new file mode 100644 index 0000000..96b7a3d --- /dev/null +++ b/tests/tooling/test_docs_workflow.py @@ -0,0 +1,17 @@ +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +class DocsWorkflowTest(unittest.TestCase): + def test_preview_publication_uses_the_preview_role(self): + workflow = (ROOT / ".github/workflows/docs.yml").read_text() + + self.assertIn( + "role-arn: ${{ matrix.environment == 'docs-preview' && " + "secrets.LIBTMUX_DOCS_PREVIEW_ROLE_ARN || " + "secrets.LIBTMUX_DOCS_ROLE_ARN }}", + workflow, + )