diff --git a/.claude/skills/ci-prep/SKILL.md b/.claude/skills/ci-prep/SKILL.md new file mode 100644 index 0000000..81716e0 --- /dev/null +++ b/.claude/skills/ci-prep/SKILL.md @@ -0,0 +1,107 @@ +--- +name: ci-prep +description: Prepares the current branch for CI by running the exact same steps locally and fixing issues. If CI is already failing, fetches the GH Actions logs first to diagnose. Use before pushing, when CI is red, or when the user says "fix ci". +argument-hint: "[--failing] [optional job name to focus on]" +--- + + + +# CI Prep + +Prepare the current state for CI. If CI is already failing, fetch and analyze the logs first. + +## Arguments + +- `--failing` — Indicates a GitHub Actions run is already failing. When present, you MUST execute **Step 1** before doing anything else. +- Any other argument is treated as a job name to focus on (but all failures are still reported). + +If `--failing` is NOT passed, skip directly to **Step 2**. + +## Step 1 — Fetch failed CI logs (only when `--failing`) + +You MUST do this before any other work. + +```bash +BRANCH=$(git branch --show-current) +PR_JSON=$(gh pr list --head "$BRANCH" --state open --json number,title,url --limit 1) +``` + +If the JSON array is empty, **stop immediately**: +> No open PR found for branch `$BRANCH`. Create a PR first. + +Otherwise fetch the logs: + +```bash +PR_NUMBER=$(echo "$PR_JSON" | jq -r '.[0].number') +gh pr checks "$PR_NUMBER" +RUN_ID=$(gh run list --branch "$BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId') +gh run view "$RUN_ID" +gh run view "$RUN_ID" --log-failed +``` + +Read **every line** of `--log-failed` output. For each failure note the exact file, line, and error message. If a job name argument was provided, prioritize that job but still report all failures. + +## Step 2 — Analyze the CI workflow + +1. Find the CI workflow file. Look in `.github/workflows/` for `ci.yml`, `build.yml`, `test.yml`, `checks.yml`, `main.yml`, `pull_request.yml`, or any workflow triggered on `pull_request` or `push`. +2. Read the workflow file completely. Parse every job and every step. +3. Extract the ordered list of commands the CI actually runs (e.g., `make lint`, `make fmt-check`, `make test`, `make coverage-check`, `make build`, or whatever the workflow specifies — it may use `npm`, `cargo`, `dotnet`, raw shell commands, or anything else). +4. Note any environment variables, matrix strategies, or conditional steps that affect execution. + +**Do NOT assume the steps are `make lint`, `make test`, `make coverage-check`, `make build`.** The actual CI may run different commands, in a different order, with different targets. Extract what the CI *actually does*. + +## Step 3 — Run each CI step locally, in order + +Work through failures in this priority order: + +1. **Formatting** — run auto-formatters first to clear noise +2. **Compilation errors** — must compile before lint/test +3. **Lint violations** — fix the code pattern +4. **Runtime / test failures** — fix source code to satisfy the test + +For each command extracted from the CI workflow: + +1. Run the command exactly as CI would run it (adjusting only for local environment differences like not needing `actions/checkout`). +2. If the step fails, **stop and fix the issues** before continuing to the next step. +3. After fixing, re-run the same step to confirm it passes. +4. Move to the next step only after the current one succeeds. + +### Hard constraints + +- **NEVER modify test files** — fix the source code, not the tests +- **NEVER add suppressions** (`#[allow(...)]`, `// eslint-disable`, `#pragma warning disable`) +- **NEVER use `any` in TypeScript** to silence type errors +- **NEVER delete or ignore failing tests** +- **NEVER remove assertions** + +If stuck on the same failure after 5 attempts, ask the user for help. + +## Step 4 — Report + +- List every step that was run and its result (pass/fail/fixed). +- If any step could not be fixed, report what failed and why. +- Confirm whether the branch is ready to push. + +## Step 5 — Commit/Push (only when `--failing`) + +Once all CI steps pass locally: + +1. Commit, but DO NOT MARK THE COMMIT WITH YOU AS AN AUTHOR!!! Only the user authors the commit! +2. Push +3. Monitor until completion or failure +4. Upon failure, go back to Step 1 + +## Rules + +- **Always read the CI workflow first.** Never assume what commands CI runs. +- Do not push if any step fails (unless `--failing` and all steps now pass) +- Fix issues found in each step before moving to the next +- Never skip steps or suppress errors +- If the CI workflow has multiple jobs, run all of them (respecting dependency order) +- Skip steps that are CI-infrastructure-only (checkout, setup-node/python/rust actions, cache steps, artifact uploads) — focus on the actual build/test/lint commands + +## Success criteria + +- Every command that CI runs has been executed locally and passed +- All fixes are applied to the working tree +- The CI passes successfully (if you are correcting and existing failure) diff --git a/.claude/skills/code-dedup/SKILL.md b/.claude/skills/code-dedup/SKILL.md new file mode 100644 index 0000000..51d179b --- /dev/null +++ b/.claude/skills/code-dedup/SKILL.md @@ -0,0 +1,110 @@ +--- +name: code-dedup +description: Searches for duplicate code, duplicate tests, and dead code, then safely merges or removes them. Use when the user says "deduplicate", "find duplicates", "remove dead code", "DRY up", or "code dedup". Requires test coverage — refuses to touch untested code. +--- + + + +# Code Dedup + +Carefully search for duplicate code, duplicate tests, and dead code across the repo. Merge duplicates and delete dead code — but only when test coverage proves the change is safe. + +## Prerequisites — hard gate + +Before touching ANY code, verify these conditions. If any fail, stop and report why. + +1. Run `make test` — all tests must pass. If tests fail, stop. Do not dedup a broken codebase. +2. Run `make coverage-check` — coverage must meet the repo's threshold. If it doesn't, stop. +3. Verify the project uses **static typing**. The Napper repo is fully statically typed (F#, TypeScript strict mode, Rust). Proceed. + +## Steps + +Copy this checklist and track progress: + +``` +Dedup Progress: +- [ ] Step 1: Prerequisites passed (tests green, coverage met, typed) +- [ ] Step 2: Dead code scan complete +- [ ] Step 3: Duplicate code scan complete +- [ ] Step 4: Duplicate test scan complete +- [ ] Step 5: Changes applied +- [ ] Step 6: Verification passed (tests green, coverage stable) +``` + +### Step 1 — Inventory test coverage + +Before deciding what to touch, understand what is tested. + +1. Run `make test` and `make coverage-check` to confirm green baseline +2. Note the current coverage percentage — this is the floor. It must not drop. +3. Identify which files/modules have coverage and which do not. Only files WITH coverage are candidates for dedup. + +### Step 2 — Scan for dead code + +Search for code that is never called, never imported, never referenced. + +1. Look for unused exports, unused functions, unused classes, unused variables +2. Use language-appropriate tools where available: + - Rust: the compiler already warns on dead code — check `make lint` output + - TypeScript: check for `noUnusedLocals`/`noUnusedParameters` in tsconfig, look for unexported functions with zero references + - F#/C#: analyzer warnings for unused members +3. For each candidate: **grep the entire codebase** for references (including tests, scripts, configs). Only mark as dead if truly zero references. +4. List all dead code found with file paths and line numbers. Do NOT delete yet. + +### Step 3 — Scan for duplicate code + +Search for code blocks that do the same thing in multiple places. + +1. Look for functions/methods with identical or near-identical logic +2. Look for copy-pasted blocks (same structure, maybe different variable names) +3. Look for multiple implementations of the same algorithm or pattern +4. Check across module boundaries — duplicates often hide in different packages/crates/projects. **For Napper specifically, check whether F# logic in Napper.Cli, Napper.Lsp, or Napper.VsCode could move into Napper.Core.** +5. For each duplicate pair: note both locations, what they do, and how they differ (if at all) +6. List all duplicates found. Do NOT merge yet. + +### Step 4 — Scan for duplicate tests + +Search for tests that verify the same behavior. + +1. Look for test functions with identical assertions against the same code paths +2. Look for test fixtures/helpers that are duplicated across test files +3. Look for integration tests that fully cover what a unit test also covers (keep the integration test, mark the unit test as redundant per CLAUDE.md rules) +4. List all duplicate tests found. Do NOT delete yet. + +### Step 5 — Apply changes (one at a time) + +For each change, follow this cycle: **change → test → verify coverage → continue or revert**. + +#### 5a. Remove dead code +- Delete dead code identified in Step 2 +- After each deletion: run `make test` and `make coverage-check` +- If tests fail or coverage drops: **revert immediately** and investigate +- Dead code removal should never break tests or drop coverage + +#### 5b. Merge duplicate code +- For each duplicate pair: extract the shared logic into a single function/module +- Update all call sites to use the shared version +- After each merge: run `make test` and `make coverage-check` +- If tests fail: **revert immediately**. The duplicates may have subtle differences you missed. +- If coverage drops: the shared code must have equivalent test coverage. Add tests if needed before proceeding. + +#### 5c. Remove duplicate tests +- Delete the redundant test (keep the more thorough one) +- After each deletion: run `make coverage-check` +- If coverage drops: **revert immediately**. The "duplicate" test was covering something the other wasn't. + +### Step 6 — Final verification + +1. Run `make test` — all tests must still pass +2. Run `make coverage-check` — coverage must be >= the baseline from Step 1 +3. Run `make lint` and `make fmt-check` — code must be clean +4. Report: what was removed, what was merged, final coverage vs baseline + +## Rules + +- **No test coverage = do not touch.** If a file has no tests covering it, leave it alone entirely. You cannot safely dedup what you cannot verify. +- **Coverage must not drop.** If removing or merging code causes coverage to decrease, revert and investigate. The coverage floor from Step 1 is sacred. +- **One change at a time.** Make one dedup change, run tests, verify coverage. Never batch multiple dedup changes before testing. +- **When in doubt, leave it.** If two code blocks look similar but you're not 100% sure they're functionally identical, leave both. False dedup is worse than duplication. +- **Preserve public API surface.** Do not change function signatures, class names, or module exports that external code depends on. Internal refactoring only. +- **Three similar lines is fine.** Do not create abstractions for trivial duplication. The cure must not be worse than the disease. Only dedup when the shared logic is substantial (>10 lines) or when there are 3+ copies. diff --git a/.claude/skills/fix-bug/SKILL.md b/.claude/skills/fix-bug/SKILL.md index 0bb15ce..1111d3b 100644 --- a/.claude/skills/fix-bug/SKILL.md +++ b/.claude/skills/fix-bug/SKILL.md @@ -5,6 +5,8 @@ argument-hint: "[bug description]" allowed-tools: Read, Grep, Glob, Edit, Write, Bash --- + + # Bug Fix Skill — Test-First Workflow You MUST follow this exact workflow. Do NOT skip steps. Do NOT fix the bug before writing a failing test. diff --git a/.claude/skills/spec-check/SKILL.md b/.claude/skills/spec-check/SKILL.md new file mode 100644 index 0000000..e8ac580 --- /dev/null +++ b/.claude/skills/spec-check/SKILL.md @@ -0,0 +1,300 @@ +--- +name: spec-check +description: Audit spec/plan documents against the codebase. Ensures every spec section has implementing code, tests, and matching logic. Use when the user says "check specs", "spec audit", or "verify specs". +argument-hint: "[optional spec ID or filename filter]" +--- + + + +# spec-check + +> **Portable skill.** This skill adapts to the current repository. The agent MUST inspect the repo structure and use judgment to apply these instructions appropriately. + +Audit spec/plan documents against the codebase. Ensures every spec section has implementing code, tests, and that the code logic matches the spec. + +## Arguments + +- `$ARGUMENTS` — optional spec name or ID to check (e.g., `AUTH-TOKEN-VERIFY` or `repo-standards`). If empty, check ALL specs. Spec IDs are descriptive slugs, NEVER numbered (see Step 1). + +## Instructions + +Follow these steps exactly. Be strict and pedantic. Stop on the first failure. + +--- + +### Step 1: Validate spec ID structure + +Before checking code/test references, verify that the specs themselves are well-formed. + +1. Find all spec documents (see locations in Step 2). +2. Extract every section ID using the regex `\[([A-Z][A-Z0-9]*(-[A-Z0-9]+)+)\]`. +3. **Flag invalid IDs:** + - Numbered IDs (`[SPEC-001]`, `[REQ-003]`, `[CI-004]`) — must be renamed to descriptive hierarchical slugs. + - Single-word IDs (`[TIMEOUT]`) — must have a group prefix. + - IDs with trailing numbers (`[FEAT-AUTH-01]`) — the number is meaningless, remove it. +4. **Check group clustering:** The first word of each ID is its group. All sections in the same group MUST appear together (adjacent) in the document. If they're scattered, flag it. +5. **Check for missing IDs:** Any heading that defines a requirement or behavior should have an ID. Flag headings in spec files that look like they define behavior but lack an ID. + +If any ID violations are found, report them all and **STOP**: +``` +SPEC ID VIOLATIONS: + +- docs/specs/AUTH-SPEC.md line 12: [SPEC-001] → rename to descriptive ID (e.g., [AUTH-LOGIN]) +- docs/specs/AUTH-SPEC.md line 30: [AUTH-TOKEN-VERIFY] and [AUTH-LOGIN] are not adjacent (scattered group) +- docs/specs/CI-SPEC.md line 5: "## Coverage thresholds" has no spec ID + +Fix spec IDs first, then re-run spec-check. +``` + +If all IDs are valid, proceed to Step 2. + +--- + +### Step 2: Find all spec/plan documents + +Search for markdown files that contain spec sections with IDs. Look in these locations: + +- `docs/*.md` +- `docs/**/*.md` +- `SPEC.md` +- `PLAN.md` +- `specs/*.md` + +Use Glob to find candidate files, then use Grep to confirm they contain spec IDs. + +**Spec ID patterns** — IDs appear in square brackets, typically at the start of a heading or section line. Match this regex pattern: + +``` +\[([A-Z][A-Z0-9]*(-[A-Z0-9]+)+)\] +``` + +Spec IDs are **hierarchical descriptive slugs, NEVER numbered.** The format is `[GROUP-TOPIC]` or `[GROUP-TOPIC-DETAIL]`. The first word is the **group** — all sections sharing the same group MUST appear together in the spec's table of contents. IDs are uppercase, hyphen-separated, unique across the repo, and MUST NOT contain sequential numbers. + +The hierarchy depth varies by repo: two words for simple repos (`[AUTH-LOGIN]`), three for most (`[AUTH-TOKEN-VERIFY]`), four for complex domains (`[AUTH-OAUTH-REFRESH-FLOW]`). The hierarchy mirrors the spec document's heading structure. + +Examples of valid spec IDs (note how groups cluster): +- `[AUTH-LOGIN]`, `[AUTH-TOKEN-VERIFY]`, `[AUTH-TOKEN-REFRESH]` — all in the AUTH group +- `[CI-TIMEOUT]`, `[CI-LINT]`, `[CI-RELEASE]` — all in the CI group +- `[LINT-ESLINT]`, `[LINT-RUFF]` — all in the LINT group +- `[FEAT-DARK-MODE]`, `[FEAT-SEARCH-FILTER]` — all in the FEAT group + +Examples of INVALID spec IDs: +- `[SPEC-001]` — numbered, meaningless +- `[FEAT-AUTH-01]` — trailing number +- `[REQ-003]` — sequential index, no group hierarchy +- `[CI-004]` — numbered, tells the reader nothing +- `[TIMEOUT]` — no group prefix, ungrouped + +For each file, extract every spec ID and its associated section title (the heading text after the ID) and the full section content (everything until the next heading of equal or higher level). + +--- + +### Step 3: Filter specs + +- If `$ARGUMENTS` is non-empty, filter the discovered specs: + - If it matches a spec ID exactly (e.g., `AUTH-TOKEN-VERIFY`), check only that spec. + - If it matches a partial name (e.g., `repo-standards`), check all specs in files whose path contains that string. +- If `$ARGUMENTS` is empty, process ALL discovered specs. + +If filtering produces zero specs, report an error: +``` +ERROR: No specs found matching "$ARGUMENTS". Discovered spec files: [list them] +``` + +--- + +### Step 4: Check each spec section + +For EACH spec section that has an ID, perform checks A, B, and C below. **Stop on the first failure.** + +#### Check A: Code references the spec ID + +Search the entire codebase for the spec ID string, **excluding** these directories: +- `docs/` +- `node_modules/` +- `.git/` +- `*.md` files (markdown is docs, not code) + +Use Grep with the literal spec ID (e.g., `[AUTH-TOKEN-VERIFY]`) to find references in code files. + +Code files should contain comments referencing the spec ID. The search must catch **all** comment styles across languages: + +**C-style `//` comments** (JavaScript, TypeScript, Rust, C#, F#, Java, Kotlin, Go, Swift, Dart): +- `// Implements [AUTH-TOKEN-VERIFY]` +- `// [AUTH-TOKEN-VERIFY]` +- `// Tests [AUTH-TOKEN-VERIFY]` (also counts as a code reference) +- `/// Implements [AUTH-TOKEN-VERIFY]` (doc comments) + +**Hash `#` comments** (Python, Ruby, Shell/Bash, YAML, TOML): +- `# Implements [AUTH-TOKEN-VERIFY]` +- `# [AUTH-TOKEN-VERIFY]` +- `# Tests [AUTH-TOKEN-VERIFY]` + +**HTML/XML comments** (HTML, CSS, SVG, XML, XAML, JSX templates): +- `` +- `` + +**ML-style comments** (F#, OCaml): +- `(* Implements [AUTH-TOKEN-VERIFY] *)` + +**Lua comments:** +- `-- Implements [AUTH-TOKEN-VERIFY]` + +**CSS comments:** +- `/* Implements [AUTH-TOKEN-VERIFY] */` + +**The key rule:** any comment in any language containing the exact spec ID string (e.g., `[AUTH-TOKEN-VERIFY]`) counts as a valid code reference. The Grep search uses the literal spec ID string, so it naturally matches all comment styles. Do NOT restrict the search to specific comment prefixes — just search for the spec ID string itself. + +**If NO code files reference the spec ID:** + +``` +SPEC VIOLATION: [AUTH-TOKEN-VERIFY] "Section Title" has no implementing code. + +Every spec section must have at least one code file that references it via a comment +containing the spec ID (e.g., `// Implements [AUTH-TOKEN-VERIFY]`). + +ACTION REQUIRED: Add a comment referencing [AUTH-TOKEN-VERIFY] in the file(s) that implement +this spec section, then re-run spec-check. +``` + +**STOP HERE. Do not continue to other checks.** + +#### Check B: Tests reference the spec ID + +Search test files for the spec ID. Test files are found in: +- `test/` +- `tests/` +- `**/*.test.*` +- `**/*.spec.*` +- `**/*_test.*` +- `**/test_*.*` +- `**/*Tests.*` +- `**/*Test.*` + +Use Grep to search these locations for the literal spec ID string. + +Tests should contain the spec ID in comments, test names, or annotations. The search must catch **all** test frameworks across languages: + +**JavaScript/TypeScript** (Jest, Mocha, Vitest, Playwright): +- `// Tests [AUTH-TOKEN-VERIFY]` +- `describe('[AUTH-TOKEN-VERIFY] Authentication flow', () => ...)` +- `test('[AUTH-TOKEN-VERIFY] should verify token', () => ...)` +- `it('[AUTH-TOKEN-VERIFY] verifies token', () => ...)` + +**Rust:** +- `// Tests [AUTH-TOKEN-VERIFY]` +- `#[test] // Tests [AUTH-TOKEN-VERIFY]` + +**C#** (xUnit, NUnit, MSTest): +- `// Tests [AUTH-TOKEN-VERIFY]` +- `[Fact] // Tests [AUTH-TOKEN-VERIFY]` +- `[Test] // Tests [AUTH-TOKEN-VERIFY]` +- `[TestMethod] // Tests [AUTH-TOKEN-VERIFY]` + +**F#** (xUnit, Expecto): +- `// Tests [AUTH-TOKEN-VERIFY]` +- `[] // Tests [AUTH-TOKEN-VERIFY]` +- `testCase "[AUTH-TOKEN-VERIFY] description" <| fun () ->` + +**The key rule:** same as Check A — search for the literal spec ID string in test files. Any occurrence of the exact spec ID in a test file counts. Do NOT restrict to specific patterns — just search for the spec ID string itself. + +**If NO test files reference the spec ID:** + +``` +SPEC VIOLATION: [AUTH-TOKEN-VERIFY] "Section Title" has no tests. + +Every spec section must have corresponding tests that reference the spec ID. + +ACTION REQUIRED: Add tests for [AUTH-TOKEN-VERIFY] with a comment or test name containing +the spec ID, then re-run spec-check. +``` + +**STOP HERE. Do not continue to other checks.** + +#### Check C: Code logic matches the spec + +This is the most critical check. You must: + +1. **Read the spec section content carefully.** Understand exactly what behavior, logic, ordering, conditions, and constraints the spec describes. + +2. **Read the implementing code.** Use the references found in Check A to locate the implementing files. Read the relevant functions/sections. + +3. **Compare spec vs. code.** Be SENSITIVE and PEDANTIC. Check for: + - **Ordering violations** — If the spec says A happens before B, the code must do A before B. + - **Missing conditions** — If the spec says "only when X", the code must have that condition. + - **Extra behavior** — If the code does something the spec doesn't mention, flag it only if it contradicts the spec. + - **Wrong logic** — If the spec says "greater than" but code uses "greater than or equal", that's a violation. + - **Missing steps** — If the spec describes 5 steps but code only implements 3, that's a violation. + - **Wrong defaults** — If the spec says "default to X" but code defaults to Y, that's a violation. + +4. **If the code deviates from the spec**, report a detailed error: + +``` +SPEC VIOLATION: [AUTH-TOKEN-VERIFY] Code does not match spec. + +SPEC SAYS: +> "The authentication flow must verify the token expiry before checking permissions" +> (from docs/specs/AUTH-SPEC.md, line 42) + +CODE DOES: +> `if (hasPermission(user)) { verifyToken(token); }` (src/auth.ts:42) + +DEVIATION: The code checks permissions BEFORE verifying token expiry. +The spec explicitly requires token expiry verification FIRST. + +ACTION REQUIRED: Reorder the logic in src/auth.ts to verify token expiry +before checking permissions, as specified in [AUTH-TOKEN-VERIFY]. +``` + +**STOP HERE. Do not continue to other specs.** + +5. **If the code matches the spec**, this check passes. Move to the next spec. + +--- + +### Step 5: Report results + +#### On failure (any check fails): + +Output ONLY the first violation found. Use the exact error format shown above. Do not summarize other specs. Do not offer to fix the code. Just report the violation. + +End with: +``` +spec-check FAILED. Fix the violation above and re-run. +``` + +#### On success (all specs pass): + +Output a summary table: + +``` +spec-check PASSED. All specs verified. + +| Spec ID | Title | Code References | Test References | Logic Match | +|----------------|--------------------------|-----------------|-----------------|-------------| +| [AUTH-TOKEN-VERIFY] | Authentication flow | src/auth.ts | tests/auth.test.ts | PASS | +| [RATE-LIMIT-CONFIG] | Rate limiting | src/rate.ts | tests/rate.test.ts | PASS | +| ... | ... | ... | ... | ... | + +Checked N spec sections across M files. All have implementing code, tests, and matching logic. +``` + +--- + +## Search strategy summary + +1. **Validate spec IDs:** Check all IDs are hierarchical, descriptive, grouped, and non-numbered +2. **Find spec files:** Glob for `docs/**/*.md`, `SPEC.md`, `PLAN.md`, `specs/**/*.md` +3. **Extract spec IDs:** Grep for `\[[A-Z][A-Z0-9]*(-[A-Z0-9]+)+\]` in those files +4. **Find code refs:** Grep for the literal spec ID in all files, excluding `docs/`, `node_modules/`, `.git/`, `*.md` +5. **Find test refs:** Grep for the literal spec ID in test directories and test file patterns +6. **Read and compare:** Read the spec section content and the implementing code, compare logic + +## Key principles + +- **Fail fast.** Stop on the first violation. One fix at a time. +- **Be pedantic.** If the spec says it, the code must do it. No "close enough". +- **Quote everything.** Always quote the spec text and the code in error messages so the developer sees exactly what's wrong. +- **Be actionable.** Every error must tell the developer what file to change and what to do. +- **Exclude docs from code search.** Markdown files are documentation, not implementation. Only search actual code files for spec references. +- **No numbered IDs.** Spec IDs are hierarchical descriptive slugs (`[AUTH-TOKEN-VERIFY]`), NEVER sequential numbers (`[SPEC-001]`). The first word is the group — sections sharing a group must be adjacent in the TOC. If you encounter numbered or ungrouped IDs, flag them as a violation. diff --git a/.claude/skills/submit-pr/SKILL.md b/.claude/skills/submit-pr/SKILL.md index c6cb432..24b0c86 100644 --- a/.claude/skills/submit-pr/SKILL.md +++ b/.claude/skills/submit-pr/SKILL.md @@ -1,63 +1,39 @@ --- name: submit-pr -description: Create and submit a GitHub pull request using the diff against main +description: Creates a pull request with a well-structured description after verifying CI passes. Use when the user asks to submit, create, or open a pull request. disable-model-invocation: true -allowed-tools: Bash(git *), Bash(gh *) --- -# Submit Pull Request + -Create a GitHub pull request for the current branch. +# Submit PR -## Steps - -1. Get the diff against the latest LOCAL main branch commit: - -``` -git diff main...HEAD -``` - -2. Read the diff output carefully. Do NOT look at commit messages. The diff is the only source of truth for what changed. - -3. Check if there's a related GitHub issue. Look for issue references in the branch name (e.g. `42-fix-bug` or `issue-42`). If found, fetch the issue title: - -``` -gh issue view --json title -q .title -``` - -4. Write the PR content using the project's PR template - -You read the file at .github/PULL_REQUEST_TEMPLATE.md - -Keep content TIGHT. Don't add waffle. +Create a pull request for the current branch with a well-structured description. -5. Construct the PR title: -- If an issue number was found: `#: ` -- Otherwise: `` -- Keep under 70 characters - -6. Commit changes and push the current branch if needed: - -``` -git push -u origin HEAD -``` - -DO NOT include yourself as a a coauthor! - -7. Create the PR using `gh`: - -``` -gh pr create --title "" --body "$(cat <<'EOF' -# TLDR; -<tldr content> - -# Details -<details content> - -# How do the tests prove the change works -<test description> -EOF -)" -``` +## Steps -8. Return the PR URL to the user. +1. Run `make ci` — must pass completely before creating PR +2. **Generate the diff against main.** Run `git diff main...HEAD > /tmp/pr-diff.txt` to capture the full diff between the current branch and the head of main. This is the ONLY source of truth for what the PR contains. **Warning:** the diff can be very large. If the diff file exceeds context limits, process it in chunks (e.g., read sections with `head`/`tail` or split by file) rather than trying to load it all at once. +3. **Derive the PR title and description SOLELY from the diff.** Read the diff output and summarize what changed. Ignore commit messages, branch names, and any other metadata — only the actual code/content diff matters. +4. Write PR body using the template in `.github/pull_request_template.md` +5. Fill in (based on the diff analysis from step 3): + - TLDR: one sentence + - What Was Added: new files, features, deps + - What Was Changed/Deleted: modified behaviour + - How Tests Prove It Works: specific test names or output + - Spec/Doc Changes: if any + - Breaking Changes: yes/no + description +6. Use `gh pr create` with the filled template + +## Rules + +- Never create a PR if `make ci` fails +- PR description must be specific and tight — no vague placeholders +- Link to the relevant GitHub issue if one exists +- DO NOT include yourself as a co-author + +## Success criteria + +- `make ci` passed +- PR created with `gh pr create` +- PR URL returned to user diff --git a/.claude/skills/upgrade-packages/SKILL.md b/.claude/skills/upgrade-packages/SKILL.md new file mode 100644 index 0000000..a3a2683 --- /dev/null +++ b/.claude/skills/upgrade-packages/SKILL.md @@ -0,0 +1,144 @@ +--- +name: upgrade-packages +description: Upgrade all dependencies/packages to their latest versions for the detected language(s). Use when the user says "upgrade packages", "update dependencies", "bump versions", "update packages", or "upgrade deps". +argument-hint: "[--check-only] [--major] [package-name]" +--- + +<!-- agent-pmo:74cf183 --> + +# Upgrade Packages + +Upgrade all project dependencies to their latest compatible (or latest major, if `--major`) versions. + +This repo uses F# (.NET/NuGet), TypeScript (npm), and Rust (cargo). + +## Arguments + +- `--check-only` — List outdated packages without upgrading. Stop after Step 2. +- `--major` — Include major version bumps (breaking changes). Without this flag, stay within semver-compatible ranges. +- Any other argument is treated as a specific package name to upgrade (instead of all packages). + +## Step 1 — Detect language and package manager + +Inspect the repo root and subdirectories for manifest files: + +| Manifest file | Language | Package manager | +|---|---|---| +| `Cargo.toml` | Rust | cargo | +| `package.json` | Node.js / TypeScript | npm | +| `*.csproj` / `*.fsproj` / `*.sln` | F# | NuGet (dotnet) | +| `Directory.Build.props` | F# | NuGet (dotnet) | + +All three are present in this repo. Process each in order. + +**If you cannot detect any manifest file, stop and tell the user.** + +## Step 2 — List outdated packages + +Run the appropriate command to list what's outdated BEFORE upgrading anything. Show the user what will change. + +### F# / .NET (NuGet) +```bash +dotnet list package --outdated +``` +For transitive dependencies too: `dotnet list package --outdated --include-transitive` + +**Read the docs:** https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-list-package + +### Node.js (npm) +```bash +npm outdated +``` +**Read the docs:** https://docs.npmjs.com/cli/v10/commands/npm-update + +### Rust (cargo) +```bash +cargo outdated # install: cargo install cargo-outdated +cargo update --dry-run +``` +**Read the docs:** https://doc.rust-lang.org/cargo/commands/cargo-update.html + +If `--check-only` was passed, **stop here** and report the outdated list. + +## Step 3 — Read the official upgrade docs + +**Before running any upgrade command, you MUST fetch and read the official documentation URL listed above for the detected package manager.** Use WebFetch to retrieve the page. This ensures you use the correct flags and understand the behavior. Do not guess at flags or options from memory. + +## Step 4 — Upgrade packages + +Run the upgrade. If a specific package name was given as an argument, upgrade only that package. + +### F# / .NET (NuGet) +There is NO single `dotnet upgrade-all` command. You must upgrade each package individually: +```bash +# For each outdated package from Step 2: +dotnet add <project.csproj> package <PackageName> # upgrades to latest +# Or with specific version: +dotnet add <project.csproj> package <PackageName> --version <version> +``` +For `Directory.Build.props`, edit the version numbers directly in the XML. + +**Read the docs:** https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-add-package + +Alternatively, use the dotnet-outdated global tool: +```bash +dotnet tool install --global dotnet-outdated-tool +dotnet outdated --upgrade +``` +**Read the docs:** https://github.com/dotnet-outdated/dotnet-outdated + +### Node.js (npm) +```bash +npm update # semver-compatible (within package.json ranges) +# --major flag: +npx npm-check-updates -u && npm install # bump package.json to latest majors +``` + +### Rust (cargo) +```bash +cargo update # semver-compatible updates +# --major flag: +cargo update --breaking # major version bumps (cargo 1.84+) +``` +For workspace members, run from workspace root. + +## Step 5 — Verify the upgrade + +After upgrading, run the project's build and test suite to confirm nothing broke: + +```bash +make ci +``` + +If tests fail: +1. Read the failure output carefully +2. Check the changelog / migration guide for the upgraded packages (fetch the release notes URL if available) +3. Fix breaking changes in the code +4. Re-run tests +5. If stuck after 3 attempts on the same failure, report it to the user with the error details and the package that caused it + +## Step 6 — Report + +Provide a summary: + +- Packages upgraded (old version -> new version) +- Packages skipped (and why, e.g., major version bump without `--major` flag) +- Build/test result after upgrade +- Any breaking changes that were fixed +- Any packages that could not be upgraded (with error details) + +## Rules + +- **Always list outdated packages first** before upgrading anything +- **Always read the official docs** for the package manager before running upgrade commands +- **Always run tests after upgrading** to catch breakage immediately +- **Never remove packages** unless they were explicitly deprecated and replaced +- **Never downgrade packages** unless rolling back a broken upgrade +- **Never modify lockfiles manually** (package-lock.json, Cargo.lock, etc.) — let the package manager regenerate them +- **Commit nothing** — leave changes in the working tree for the user to review + +## Success criteria + +- All outdated packages upgraded to latest compatible (or latest major if `--major`) +- `make ci` passes +- User has a clear summary of what changed diff --git a/.claude/skills/website-audit/SKILL.md b/.claude/skills/website-audit/SKILL.md new file mode 100644 index 0000000..02a6af8 --- /dev/null +++ b/.claude/skills/website-audit/SKILL.md @@ -0,0 +1,181 @@ +--- +name: website-audit +description: Audits a website for SEO, AI search performance, structured data, mobile usability, broken links, and social media cards. Fixes issues found. Use when the user mentions "audit website", "SEO", "fix search ranking", "AI search", "structured data", "social media cards", or "website performance". +--- + +<!-- agent-pmo:74cf183 --> + +# Website Audit + +Performs a comprehensive website audit and fixes issues affecting search visibility and AI discoverability. + +Copy this checklist and track your progress: + +``` +Audit Progress: +- [ ] Step 1: Read guidelines +- [ ] Step 2: Audit AI search readiness +- [ ] Step 3: Audit SEO and keywords +- [ ] Step 4: Audit crawling and indexing +- [ ] Step 5: Audit broken links and canonicalization +- [ ] Step 6: Audit mobile usability +- [ ] Step 7: Audit structured data +- [ ] Step 8: Audit social media cards +- [ ] Step 9: Audit For Unsubstantiated Claims +- [ ] Step 10: Audit Design Compliance +- [ ] Step 11: Test with Playwright +- [ ] Step 12: Report findings +``` + +- Check the outputted HTML/CSS/JavaScript AFTER the website is generated by the static content generator. - Don't just check the static content before the website is generated. +- Fix issues at the core where the static content templates are stored - not in the outputted HTML (e.g. _site) +- Never manually edit the generated website content directly +- ENSURE THE FOOTER HAS A copyright link to nimblesite.co + +## Step 1 — Read guidelines + +Fetch and read each of these before auditing. These are the authoritative references for every step that follows. + +- [Google's guidance on using generative AI content](https://developers.google.com/search/docs/fundamentals/using-gen-ai-content) +- [Top ways to ensure content performs well in Google's AI experiences](https://developers.google.com/search/blog/2025/05/succeeding-in-ai-search) +- [SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide) + +If the repo has a business plan doc, take it into account + +Identify the website source files in the repo. Determine the framework (static site generator, Next.js, Hugo, etc.) so you know where to find templates, metadata, and content. + +## Step 2 — Audit AI search readiness + +Apply the guidance from the AI search article. Check: + +1. **Content quality** — Is content original, expert-level, and comprehensive? Flag thin or duplicated pages. +2. **Clear structure** — Do pages use descriptive headings, lists, and concise answers to likely questions? +3. **Entity clarity** — Are key terms, products, and concepts defined clearly so AI can extract them? +4. **Freshness signals** — Are dates, update timestamps, and authorship present? + +Fix issues directly in the source files. For each fix, note what changed and why. + +## Step 3 — Audit SEO and keywords + +1. Search [Google Trends](https://trends.google.com/home) for trending keywords related to the website's content. +2. Review each page's `<title>`, `<meta name="description">`, and `<h1>` tags. +3. Check for keyword opportunities — can trending terms be naturally inserted into headings, descriptions, or body content? +4. Verify each page has a unique, descriptive title (50-60 chars) and meta description (150-160 chars). +5. Check image `alt` attributes describe the image content and include relevant keywords where natural. + +Apply the [SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide) principles. Fix issues directly. + +## Step 4 — Audit crawling and indexing + +Reference: [Overview of crawling and indexing topics](https://developers.google.com/search/docs/crawling-indexing) + +1. **robots.txt** — Locate and review it. Verify it doesn't block important pages. Reference: [robots.txt spec](https://developers.google.com/search/docs/crawling-indexing/robots-txt) +2. **Sitemap** — Locate the sitemap (or sitemap index). Verify all important pages are listed and no dead URLs are included. Reference: [Sitemap guidelines](https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps) +3. **Meta robots tags** — Check for unintended `noindex` or `nofollow` directives on pages that should be indexed. + +Note: robots.txt and sitemaps are often auto-generated. If so, check the generator config rather than the output file. + +## Step 5 — Audit broken links and canonicalization + +Reference: [What is canonicalization](https://developers.google.com/search/docs/crawling-indexing/canonicalization) + +1. Check all internal links resolve to valid pages (no 404s). +2. Verify `<link rel="canonical">` tags are present and point to the correct URL. +3. Check for duplicate content accessible via multiple URLs (with/without trailing slash, www vs non-www). +4. Verify redirects use 301 (permanent) not 302 (temporary) where appropriate. + +## Step 6 — Audit mobile usability + +Reference: [Mobile-first indexing best practices](https://developers.google.com/search/docs/crawling-indexing/mobile/mobile-sites-mobile-first-indexing) + +1. Verify the `<meta name="viewport">` tag is present and correctly configured. +2. Check that content is identical between mobile and desktop (mobile-first indexing requires this). +3. Verify touch targets are adequately sized (min 48x48px). +4. Check font sizes are readable without zooming (min 16px body text). + +## Step 7 — Audit structured data + +Reference: [Structured data guidelines](https://developers.google.com/search/docs/appearance/structured-data/sd-policies) + +1. Check for existing JSON-LD `<script type="application/ld+json">` blocks. +2. Verify the structured data matches the page content (no misleading markup). +3. Add missing structured data where appropriate: + - **Organization/Person** on the homepage + - **Article/BlogPosting** on blog posts (with author, datePublished, dateModified) + - **BreadcrumbList** for navigation + - **FAQ** for pages with question/answer content +4. Validate JSON-LD syntax is correct. + +## Step 8 — Audit social media cards + +Reference: [Implementing Social Media Preview Cards](https://documentation.platformos.com/use-cases/implementing-social-media-preview-cards) + +Check every page template includes: + +**Open Graph (Facebook/LinkedIn):** +- `og:title`, `og:description`, `og:image`, `og:url`, `og:type` + +**Twitter Card:** +- `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` + +Verify `og:image` dimensions are at least 1200x630px. Fix missing or incomplete tags. + +## Step 9 - Audit For Unsubstantiated Claims + +Ensure that all claims are backed up with a link to a reputable source. As an example, this claim isn't valid as content unless it links to an authority that found this through research + +> Research shows teams with strong DevEx perform 4-5x better across speed, quality, and engagement + +Search for the authoritative URL and add a link to the URL. If it is not available, change the claim to something that can be substatiated. + +## Step 10 — Audit Design Compliance + +Read the design system docs and view the design screens in the designsystem folder. + +## Step 11 — Test with Playwright + +Build and run the website locally using `make website-run` (or the project's equivalent dev server command). + +**Desktop tests (1280x720):** + +1. Navigate to the homepage — take a screenshot. +2. Navigate to each major section — verify pages load without errors. +3. Check the browser console for JavaScript errors. +4. Verify all navigation links work. + +**Mobile tests (375x667, iPhone SE):** + +1. Resize the browser to mobile dimensions. +2. Navigate to the homepage — take a screenshot. +3. Verify the layout is responsive (no horizontal overflow, readable text). +4. Test navigation menu (hamburger menu if applicable). + +If any page fails to load or has console errors, fix the issue and retest. + +## Step 12 — Report findings + +Summarize the audit results: + +``` +## Website Audit Report + +### Fixed +- [List each issue fixed with file and line reference] + +### Warnings (manual review needed) +- [Issues that need human judgment] + +### Passed +- [Areas that passed audit with no issues] + +### Screenshots +- [Reference Playwright screenshots taken] +``` + +## Rules + +- **Fix issues directly** — don't just report them. Only flag issues as warnings when they require human judgment (e.g., content tone, keyword selection). +- **One step at a time** — complete each step before moving to the next. +- **Preserve existing content** — improve structure and metadata without rewriting the author's voice. +- **No keyword stuffing** — keywords must read naturally in context. +- **Respect the framework** — edit templates/configs, not generated output files. diff --git a/.clinerules/00-read-instructions.md b/.clinerules/00-read-instructions.md new file mode 100644 index 0000000..b4c9bd7 --- /dev/null +++ b/.clinerules/00-read-instructions.md @@ -0,0 +1,8 @@ +# Single Source of Truth + +@CLAUDE.md + +Read the file above in full before writing any code. All project rules, +coding standards, hard constraints, build commands, and architecture +notes live there. Do not add rules to this file — keep everything in +`CLAUDE.md` so there is exactly one set of instructions to maintain. diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..addcc98 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,5 @@ +@CLAUDE.md + +All project rules, coding standards, hard constraints, build commands, +and architecture notes live in `CLAUDE.md` at the repository root. +Read that file in full before writing any code. Do not add rules here. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4f5288b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +{ + "_agent_pmo": "74cf183", + "name": "Napper (F# / Rust / TypeScript)", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0", + "features": { + "ghcr.io/devcontainers/features/node:1": { "version": "22" }, + "ghcr.io/devcontainers/features/rust:1": { "profile": "default" } + }, + "remoteUser": "vscode", + "postCreateCommand": "make setup", + "customizations": { + "vscode": { + "extensions": [ + "Ionide.Ionide-fsharp", + "ms-dotnettools.csharp", + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "usernamehw.errorlens" + ], + "settings": { + "editor.formatOnSave": true, + "[fsharp]": { "editor.defaultFormatter": "Ionide.Ionide-fsharp" }, + "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer" }, + "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "FSharp.analyzers": true, + "FSharp.dotNetRoot": "/usr/share/dotnet", + "rust-analyzer.check.command": "clippy", + "rust-analyzer.check.extraArgs": ["--", "-D", "warnings"], + "eslint.validate": ["typescript", "javascript"] + } + } + } +} diff --git a/.editorconfig b/.editorconfig index fd62b83..470d5eb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,20 +1,49 @@ root = true +# ============================================================ +# Universal settings +# ============================================================ [*] +charset = utf-8 +end_of_line = lf indent_style = space indent_size = 4 -end_of_line = lf -charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true +max_line_length = 120 + +# ============================================================ +# Web / config formats — 2-space indent +# ============================================================ +[*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}] +indent_size = 2 + +[*.{json,jsonc}] +indent_size = 2 + +[*.{yml,yaml}] +indent_size = 2 + +[*.{css,scss,less,html,htm,svelte,vue}] +indent_size = 2 -[*.{fs,fsx}] +[*.{md,mdx}] +indent_size = 2 +trim_trailing_whitespace = false + +# ============================================================ +# Language-specific +# ============================================================ +[*.rs] indent_size = 4 +max_line_length = 100 -# F# compiler diagnostics — all unused things are errors +[*.{fs,fsx,fsi}] +indent_size = 4 +dotnet_diagnostic.FS0025.severity = error +dotnet_diagnostic.FS0026.severity = error +dotnet_diagnostic.FS0067.severity = error dotnet_diagnostic.FS1182.severity = error - -# Opt-in warnings elevated to errors dotnet_diagnostic.FS3388.severity = error dotnet_diagnostic.FS3389.severity = error dotnet_diagnostic.FS3390.severity = error @@ -24,14 +53,9 @@ dotnet_diagnostic.FS3559.severity = error dotnet_diagnostic.FS3560.severity = error dotnet_diagnostic.FS3582.severity = error -[*.ts] -indent_size = 2 - -[*.json] -indent_size = 2 +[Makefile] +indent_style = tab +indent_size = 4 -[*.{yml,yaml}] +[*.sh] indent_size = 2 - -[*.rs] -indent_size = 4 diff --git a/.fantomasignore b/.fantomasignore new file mode 100644 index 0000000..cedbacb --- /dev/null +++ b/.fantomasignore @@ -0,0 +1,6 @@ +# Fantomas ignore — gitignore-style globs. +# Generated source: Types.Generated.fs is emitted by `make generate-types` from +# Types.td (typeDiagram DSL). typeDiagram emits unformatted F#, so style-checking +# it is meaningless and would break `fantomas --check` in CI. The file is gitignored +# and rebuilt from its canonical source, so its layout is never reviewed. +src/Napper.Core/Types.Generated.fs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..addcc98 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,5 @@ +@CLAUDE.md + +All project rules, coding standards, hard constraints, build commands, +and architecture notes live in `CLAUDE.md` at the repository root. +Read that file in full before writing any code. Do not add rules here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9734233 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +<!-- agent-pmo:74cf183 --> +## TLDR +<!-- One sentence: what does this PR do? --> + +## Details +<!-- New functionality, new files, new dependencies. What changed? --> + +## How Do The Automated Tests Prove It Works? +<!-- Name specific tests or describe what the test output demonstrates. --> +<!-- "Tests pass" is not acceptable. Be specific. --> diff --git a/.github/workflows/PULL_REQUEST_TEMPLATE.MD b/.github/workflows/PULL_REQUEST_TEMPLATE.MD deleted file mode 100644 index 04d29ee..0000000 --- a/.github/workflows/PULL_REQUEST_TEMPLATE.MD +++ /dev/null @@ -1,5 +0,0 @@ -# TLDR; - -# Details - -# How do the tests prove the changes work? \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..995a533 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,342 @@ +# agent-pmo:74cf183 +name: CI + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: src/Napper.VsCode/package-lock.json + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.fsproj') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Cache Cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src/Napper.Zed/target + key: ${{ runner.os }}-cargo-${{ hashFiles('src/Napper.Zed/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Install dependencies + working-directory: src/Napper.VsCode + run: npm ci + + - name: Validate Shipwright manifest + run: npx --yes @nimblesite/shipwright-validate-manifest --schema schemas/shipwright.schema.json src/Napper.VsCode/shipwright.json + + - name: Restore dotnet tools + run: dotnet tool restore + + - name: Restore dotnet packages + run: dotnet restore + + # Types.Generated.fs is gitignored and rebuilt from Types.td (typeDiagram DSL, + # the canonical source of truth). A fresh checkout has no generated file, so the + # F# build below fails (FS0225) unless we regenerate it first. typediagram is + # pinned to the version that ships F# support (Nimblesite/typeDiagram#36). + - name: Generate F# types (typeDiagram) + run: | + npm install -g typediagram@0.9.0 + make generate-types + + - name: Format check (Fantomas) + run: dotnet fantomas --check src/ + + - name: Format check (Prettier) + working-directory: src/Napper.VsCode + run: npm run format:check + + - name: Format check (cargo fmt) + run: cargo fmt --manifest-path src/Napper.Zed/Cargo.toml -- --check + + - name: Lint F# (warnings as errors) + run: dotnet build --no-restore --nologo -warnaserror + + - name: Lint TypeScript (ESLint) + working-directory: src/Napper.VsCode + run: npm run lint + + - name: Lint Rust (clippy) + run: cargo clippy --manifest-path src/Napper.Zed/Cargo.toml + + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: lint + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: src/Napper.VsCode/package-lock.json + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.fsproj') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Cache Cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src/Napper.Zed/target + key: ${{ runner.os }}-cargo-${{ hashFiles('src/Napper.Zed/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Install ReportGenerator + run: dotnet tool install --global dotnet-reportgenerator-globaltool + + - name: Install dotnet-script + run: dotnet tool install -g dotnet-script + + - name: Restore tools + run: dotnet tool restore + + - name: Restore + run: dotnet restore + + # Regenerate the gitignored Types.Generated.fs before any F# compile (see lint job). + - name: Generate F# types (typeDiagram) + run: | + npm install -g typediagram@0.9.0 + make generate-types + + - name: Build (warnings as errors) + run: dotnet build --no-restore --nologo -warnaserror + + - name: Install VS Code extension dependencies + working-directory: src/Napper.VsCode + run: npm ci + + - name: Install NativeAOT prerequisites + run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + + - name: Build CLI, compile extension & tests + working-directory: src/Napper.VsCode + run: npm run pretest + + - name: TypeScript unit tests with coverage + working-directory: src/Napper.VsCode + run: npm run test:unit + + - name: Add CLI to PATH + run: echo "${{ github.workspace }}/src/Napper.VsCode/bin" >> "$GITHUB_PATH" + + - name: Shipwright version-contract gate + run: | + set -euo pipefail + napper --version + napper --version | grep -Eq '^napper [0-9]+\.[0-9]+\.[0-9]+' + napper --version --json | node -e 'const d=JSON.parse(require("fs").readFileSync(0,"utf8")); if(d.manifestVersion!==1||d.name!=="napper"||d.kind!=="cli"||d.language!=="dotnet"){console.error("bad version json",d);process.exit(1)} console.log("version json OK")' + + - name: TypeScript E2E tests + working-directory: src/Napper.VsCode + run: xvfb-run --auto-servernum npm test + + - name: F# tests with coverage + run: make test-fsharp + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Rust tests with coverage + working-directory: src/Napper.Zed + run: cargo tarpaulin --out xml html --output-dir ../../coverage/rust/report --skip-clean + + - name: Check coverage thresholds + run: make _coverage_check + + - name: Upload TypeScript coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: typescript-coverage + path: src/Napper.VsCode/coverage/ + retention-days: 7 + + - name: Upload F# coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: fsharp-coverage + path: coverage/fsharp/report/ + retention-days: 7 + + - name: Upload DotHttp coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: dothttp-coverage + path: coverage/dothttp/report/ + retention-days: 7 + + - name: Upload Napper.Lsp coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: lsp-coverage + path: coverage/lsp/report/ + retention-days: 7 + + - name: Upload Rust coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: rust-coverage + path: coverage/rust/report/ + retention-days: 7 + + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: src/Napper.VsCode/package-lock.json + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.fsproj') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Install VS Code extension dependencies + working-directory: src/Napper.VsCode + run: npm ci + + - name: Compile extension + working-directory: src/Napper.VsCode + run: npx webpack --mode production + + - name: Package universal VSIX + working-directory: src/Napper.VsCode + run: npx @vscode/vsce package --no-dependencies --skip-license + + - name: Upload VSIX + uses: actions/upload-artifact@v4 + with: + name: vsix + path: src/Napper.VsCode/*.vsix + retention-days: 7 + + aot-smoke: + name: NativeAOT smoke (linux-x64) + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: lint + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # Regenerate the gitignored Types.Generated.fs before publishing (see lint job). + - name: Generate F# types (typeDiagram) + run: | + npm install -g typediagram@0.9.0 + make generate-types + + # Guards the [cli-aot-migration] contract on every PR: the LSP and CLI must + # publish AND RUN under NativeAOT. A reflection regression compiles fine but + # crashes at runtime, so we exercise the real native binary here. + - name: Install NativeAOT prerequisites + run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + + - name: Publish CLI (NativeAOT) + run: dotnet publish src/Napper.Cli/Napper.Cli.fsproj -r linux-x64 -p:PublishAot=true -o out/aot --nologo + + - name: Smoke test the native binary (CLI + LSP) + run: | + set -euo pipefail + chmod +x out/aot/napper + # CLI works with zero .NET runtime present. + out/aot/napper --version + # The LSP answers an initialize handshake over stdio (no reflection crash). + BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},"rootUri":""}}' + LEN=$(printf '%s' "$BODY" | wc -c) + OUT=$( { printf 'Content-Length: %s\r\n\r\n%s' "$LEN" "$BODY"; printf 'Content-Length: 44\r\n\r\n{"jsonrpc":"2.0","method":"exit","params":{}}'; } | out/aot/napper lsp ) + echo "$OUT" + echo "$OUT" | grep -q '"name":"napper-lsp"' || { echo "::error::LSP initialize did not return capabilities under AOT"; exit 1; } + + build-website: + name: Website Build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + working-directory: website + run: npm ci + + - name: Build + working-directory: website + run: npx eleventy diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-pages.yml similarity index 64% rename from .github/workflows/deploy-website.yml rename to .github/workflows/deploy-pages.yml index e47b805..9f91814 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,10 +1,6 @@ -name: Deploy Website +name: Deploy Pages on: - push: - branches: [main] - paths: - - "website/**" workflow_dispatch: permissions: @@ -14,17 +10,21 @@ permissions: concurrency: group: pages - cancel-in-progress: true + cancel-in-progress: false jobs: build: + name: Build site runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: website/package-lock.json - name: Install dependencies working-directory: website @@ -34,18 +34,20 @@ jobs: working-directory: website run: npx eleventy - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 with: - path: website/_site + path: website/_site/ deploy: + name: Deploy needs: build runs-on: ubuntu-latest + timeout-minutes: 10 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - name: Deploy to GitHub Pages + - uses: actions/deploy-pages@v4 id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml deleted file mode 100644 index 4c219cc..0000000 --- a/.github/workflows/pr.yml +++ /dev/null @@ -1,291 +0,0 @@ -name: PR Checks - -on: - pull_request: - branches: [main] - -jobs: - lint-and-test-ts: - name: TypeScript Lint & Tests - runs-on: ubuntu-latest - defaults: - run: - working-directory: src/Napper.VsCode - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: src/Napper.VsCode/package-lock.json - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "10.0.x" - - - name: Cache NuGet packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.fsproj') }} - restore-keys: ${{ runner.os }}-nuget- - - - name: Install dependencies - run: npm ci - - - name: Format check - run: npm run format:check - - - name: Lint - run: npm run lint - - - name: Build CLI, compile extension & tests - run: npm run pretest - - - name: Unit tests with coverage - run: npm run test:unit - - - name: Add CLI to PATH - run: echo "${{ github.workspace }}/src/Napper.VsCode/bin" >> "$GITHUB_PATH" - - - name: E2E tests - run: xvfb-run --auto-servernum npm test - - - name: Extract TypeScript coverage percentage - id: ts-coverage - run: | - COVERAGE=$(npx c8 report --reporter text 2>/dev/null | grep 'All files' | awk '{print $4}' || echo "0") - echo "coverage=$COVERAGE" >> "$GITHUB_OUTPUT" - - - name: Check TypeScript coverage threshold - run: | - ACTUAL="${{ steps.ts-coverage.outputs.coverage }}" - THRESHOLD="${{ vars.TS_COVERAGE_THRESHOLD }}" - echo "TypeScript coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)" - if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then - echo "No threshold set — skipping" - exit 0 - fi - if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then - echo "::error::TypeScript coverage ${ACTUAL}% is below threshold ${THRESHOLD}%" - exit 1 - fi - - - name: Upload TypeScript coverage - if: always() - uses: actions/upload-artifact@v4 - with: - name: typescript-coverage - path: src/Napper.VsCode/coverage/ - - test-fsharp: - name: F# Build & Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "10.0.x" - - - name: Cache NuGet packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.fsproj') }} - restore-keys: ${{ runner.os }}-nuget- - - - name: Install ReportGenerator - run: dotnet tool install --global dotnet-reportgenerator-globaltool - - - name: Install dotnet-script - run: dotnet tool install -g dotnet-script - - - name: Restore tools - run: dotnet tool restore - - - name: Format check (Fantomas) - run: dotnet fantomas --check src/ - - - name: Restore - run: dotnet restore - - - name: Build (warnings are errors) - run: dotnet build --no-restore --nologo -warnaserror - - - name: Test with coverage - run: make test-fsharp - - - name: Extract Napper.Core coverage percentage - id: napcore-coverage - run: | - COVERAGE=$(grep -oP 'Line coverage: \K[0-9.]+' coverage/fsharp/report/Summary.txt || echo "0") - echo "coverage=$COVERAGE" >> "$GITHUB_OUTPUT" - - - name: Check Napper.Core coverage threshold - run: | - ACTUAL="${{ steps.napcore-coverage.outputs.coverage }}" - THRESHOLD="${{ vars.FSHARP_COVERAGE_THRESHOLD }}" - echo "Napper.Core coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)" - if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then - echo "No threshold set — skipping" - exit 0 - fi - if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then - echo "::error::Napper.Core coverage ${ACTUAL}% is below threshold ${THRESHOLD}%" - exit 1 - fi - - - name: Extract DotHttp coverage percentage - id: dothttp-coverage - run: | - COVERAGE=$(grep -oP 'Line coverage: \K[0-9.]+' coverage/dothttp/report/Summary.txt || echo "0") - echo "coverage=$COVERAGE" >> "$GITHUB_OUTPUT" - - - name: Check DotHttp coverage threshold - run: | - ACTUAL="${{ steps.dothttp-coverage.outputs.coverage }}" - THRESHOLD="${{ vars.DOTHTTP_COVERAGE_THRESHOLD }}" - echo "DotHttp coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)" - if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then - echo "No threshold set — skipping" - exit 0 - fi - if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then - echo "::error::DotHttp coverage ${ACTUAL}% is below threshold ${THRESHOLD}%" - exit 1 - fi - - - name: Upload F# coverage - if: always() - uses: actions/upload-artifact@v4 - with: - name: fsharp-coverage - path: coverage/fsharp/report/ - - - name: Upload DotHttp coverage - if: always() - uses: actions/upload-artifact@v4 - with: - name: dothttp-coverage - path: coverage/dothttp/report/ - - - name: Extract Napper.Lsp coverage percentage - id: lsp-coverage - run: | - if [ -f coverage/lsp/report/Summary.txt ]; then - COVERAGE=$(grep -oP 'Line coverage: \K[0-9.]+' coverage/lsp/report/Summary.txt || echo "0") - else - COVERAGE="0" - fi - echo "coverage=$COVERAGE" >> "$GITHUB_OUTPUT" - - - name: Check Napper.Lsp coverage threshold - run: | - ACTUAL="${{ steps.lsp-coverage.outputs.coverage }}" - THRESHOLD="${{ vars.LSP_COVERAGE_THRESHOLD }}" - echo "Napper.Lsp coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)" - if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then - echo "No threshold set — skipping" - exit 0 - fi - if [ "$ACTUAL" = "0" ] && grep -q 'Assemblies: 0' coverage/lsp/report/Summary.txt 2>/dev/null; then - echo "LSP tests are integration tests (subprocess) — skipping coverage threshold" - exit 0 - fi - if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then - echo "::error::Napper.Lsp coverage ${ACTUAL}% is below threshold ${THRESHOLD}%" - exit 1 - fi - - - name: Upload Napper.Lsp coverage - if: always() - uses: actions/upload-artifact@v4 - with: - name: lsp-coverage - path: coverage/lsp/report/ - - test-rust: - name: Rust Build & Tests - runs-on: ubuntu-latest - defaults: - run: - working-directory: src/Napper.Zed - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - - name: Cache Cargo registry and build - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - src/Napper.Zed/target - key: ${{ runner.os }}-cargo-${{ hashFiles('src/Napper.Zed/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- - - - name: Format check - run: cargo fmt -- --check - - - name: Clippy - run: cargo clippy - - - name: Install cargo-tarpaulin - run: cargo install cargo-tarpaulin - - - name: Test with coverage - run: cargo tarpaulin --out xml html --output-dir ../../coverage/rust/report --skip-clean - - - name: Extract Rust coverage percentage - id: rust-coverage - run: | - COVERAGE=$(grep -oP 'line-rate="\K[0-9.]+' ../../coverage/rust/report/cobertura.xml 2>/dev/null || echo "0") - COVERAGE_PCT=$(echo "$COVERAGE * 100" | bc -l | xargs printf "%.2f") - echo "coverage=$COVERAGE_PCT" >> "$GITHUB_OUTPUT" - - - name: Check Rust coverage threshold - run: | - ACTUAL="${{ steps.rust-coverage.outputs.coverage }}" - THRESHOLD="${{ vars.RUST_COVERAGE_THRESHOLD }}" - echo "Rust coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)" - if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then - echo "No threshold set — skipping" - exit 0 - fi - if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then - echo "::error::Rust coverage ${ACTUAL}% is below threshold ${THRESHOLD}%" - exit 1 - fi - - - name: Upload Rust coverage - if: always() - uses: actions/upload-artifact@v4 - with: - name: rust-coverage - path: coverage/rust/report/ - - build-website: - name: Website Build - runs-on: ubuntu-latest - defaults: - run: - working-directory: website - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: website/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Build - run: npx eleventy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 538a1c2..d707e1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,44 +1,263 @@ name: Release +# Tag-triggered Shipwright release. Implements [SWR-REL-WORKFLOW], [SWR-REL-GITHUB]. +# +# Pipeline: validate tag -> CI gate (on 0.0.0-dev source) -> per-platform NativeAOT +# build + per-platform VSIX -> package archives -> GitHub Release + Marketplace + +# Homebrew + Scoop -> website. +# +# DEPLOYMENT CONTRACT: the PRIMARY artifact is a self-contained NativeAOT native binary +# (zero .NET runtime on the user's machine) bundled inside the per-platform VSIX and +# shipped via GitHub Releases / Homebrew / Scoop. A `dotnet tool` NuGet package is a +# SECONDARY, best-effort channel (publish-nuget): it is non-blocking and is NOT a +# dependency of any release/marketplace/brew/scoop job, so it can never stop a release. +# .NET is a BUILD-time dependency only. + on: push: - tags: ["v*"] - workflow_dispatch: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" permissions: contents: write jobs: - bump-versions: + # ── Validate the tag and derive the version ──────────────── [SWR-REL-VERSION] + validate-tag: + name: Validate tag + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.parse.outputs.version }} + tag: ${{ steps.parse.outputs.tag }} + steps: + - name: Parse and validate tag + id: parse + shell: bash + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Tag '$TAG' must match vMAJOR.MINOR.PATCH[-prerelease] (e.g. v0.11.0)" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + echo "Releasing $TAG (version ${TAG#v})" + + # ── CI gate: lint + test + build + manifest validation on SOURCE (0.0.0-dev) ── + # No publish step runs until this passes. Implements [SWR-REL-WORKFLOW] gate, + # [SWR-GATE-CI]. + gate: + name: CI gate + needs: validate-tag runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + - uses: actions/setup-node@v4 + with: + node-version: 22 + # Types.Generated.fs is gitignored and rebuilt from Types.td (typeDiagram DSL). + # A fresh checkout has none, so the F# build fails (FS0225) without this. + - name: Generate F# types (typeDiagram) + run: | + npm install -g typediagram@0.9.0 + bash scripts/generate-types.sh + - name: Restore + run: dotnet restore + - name: Lint + build (warnings as errors) + run: dotnet build --no-restore --nologo -warnaserror + - name: Validate deployment manifest + run: npx --yes @nimblesite/shipwright-validate-manifest --schema schemas/shipwright.schema.json src/Napper.VsCode/shipwright.json + - name: Shipwright version-contract + stamper tests + # Deterministic, network-free gate: proves the --version contract and the + # release stamper. The full functional/e2e suite (which hits external + # services) runs on every PR to main per [SWR-REL-PRERELEASE-CI]; a transient + # third-party outage must never block a tagged release. + run: dotnet test src/Napper.Core.Tests --no-build --nologo --filter "FullyQualifiedName~VersionContract" + + # ── Per-platform NativeAOT binary + per-platform VSIX ────── [SWR-VSIX-CI-MATRIX] + # NativeAOT cannot cross-compile across OS/arch, so each leg builds on a runner + # whose OS+arch matches the target. The binary it produces needs no .NET runtime. + build: + name: Build ${{ matrix.platform }} + needs: [validate-tag, gate] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { platform: darwin-arm64, rid: osx-arm64, os: macos-15, archive: tar.gz, npm_config_arch: arm64 } + - { platform: darwin-x64, rid: osx-x64, os: macos-15-intel, archive: tar.gz, npm_config_arch: x64 } + - { platform: linux-x64, rid: linux-x64, os: ubuntu-latest, archive: tar.gz, npm_config_arch: x64 } + - { platform: linux-arm64, rid: linux-arm64, os: ubuntu-24.04-arm, archive: tar.gz, npm_config_arch: arm64 } + - { platform: win32-x64, rid: win-x64, os: windows-latest, archive: zip, npm_config_arch: x64 } + - { platform: win32-arm64, rid: win-arm64, os: windows-11-arm, archive: zip, npm_config_arch: arm } + env: + VERSION: ${{ needs.validate-tag.outputs.version }} + TAG: ${{ needs.validate-tag.outputs.tag }} steps: - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 with: - ref: main - token: ${{ secrets.GITHUB_TOKEN }} + dotnet-version: "10.0.x" - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: src/Napper.VsCode/package-lock.json + + - name: Install Linux NativeAOT prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + + # Regenerate the gitignored Types.Generated.fs before any F# compile. Uses bash + # (Linux/macOS/Windows-git-bash) so it works on every native runner. + - name: Generate F# types (typeDiagram) + shell: bash + run: | + npm install -g typediagram@0.9.0 + bash scripts/generate-types.sh + + - name: Stamp version from tag + shell: bash + run: dotnet fsi scripts/stamp-version.fsx --tag "$TAG" + + - name: Publish NativeAOT binary (${{ matrix.rid }}) + shell: bash + run: | + dotnet publish src/Napper.Cli/Napper.Cli.fsproj \ + -r ${{ matrix.rid }} \ + -p:PublishAot=true \ + -o out/${{ matrix.rid }} \ + --nologo - - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - name: Verify binary version contract + shell: bash + run: | + set -euo pipefail + exe=""; [ "${{ runner.os }}" = "Windows" ] && exe=".exe" + BIN="out/${{ matrix.rid }}/napper$exe" + ACTUAL="$("$BIN" --version | head -1 | tr -d '\r')" + echo "napper --version -> $ACTUAL" + if [ "$ACTUAL" != "napper ${VERSION}" ]; then + echo "::error::binary version '$ACTUAL' != 'napper ${VERSION}'" + exit 1 + fi + "$BIN" --version --json | node -e ' + const d = JSON.parse(require("fs").readFileSync(0, "utf8")); + const v = process.env.VERSION; + const want = { manifestVersion: 1, name: "napper", version: v, kind: "cli", language: "dotnet" }; + for (const [k, val] of Object.entries(want)) { + if (d[k] !== val) { console.error(`--version --json ${k}=${d[k]} expected ${val}`); process.exit(1); } + } + console.log("--version --json: OK"); + ' + + - name: Prove binary needs no .NET runtime (clean room) + shell: bash + run: | + set -euo pipefail + exe=""; [ "${{ runner.os }}" = "Windows" ] && exe=".exe" + BIN="$PWD/out/${{ matrix.rid }}/napper$exe" + if [ "${{ runner.os }}" = "Linux" ]; then + # Pristine Ubuntu with ZERO .NET installed — the real end-user gate. + # If the AOT binary secretly needed a runtime, this is where it epic-fails. + docker run --rm -v "$PWD/out/${{ matrix.rid }}:/b" ubuntu:24.04 /b/napper --version + elif [ "${{ runner.os }}" = "macOS" ]; then + # Empty environment: no PATH, no DOTNET_ROOT — must still run standalone. + env -i "$BIN" --version + else + echo "clean-room run skipped on Windows (system DLL resolution is PATH-independent)" + fi + echo "clean-room: binary ran with no .NET runtime present" + + - name: Stage raw binary for archiving + shell: bash + run: | + set -euo pipefail + exe=""; [ "${{ runner.os }}" = "Windows" ] && exe=".exe" + mkdir -p rawbin + cp "out/${{ matrix.rid }}/napper$exe" "rawbin/napper$exe" - - name: Bump versions and push - run: make bump-version VERSION="$VERSION" COMMIT=true + - name: Upload raw binary + uses: actions/upload-artifact@v4 + with: + name: rawbin-${{ matrix.rid }} + path: rawbin/* + if-no-files-found: error - build-vsix: - needs: [bump-versions] + # ── Per-platform VSIX packaging (decoupled from the native build) ─── [SWR-VSIX-PACKAGE] + # vsce packaging only ZIPS the staged native binary + manifest; it never executes the + # target binary, so it is fully cross-platform and runs entirely on Linux. Packaging + # here (instead of on each native runner) sidesteps the win32-arm npm toolchain gap — + # @vscode/vsce-sign ships no win32-arm build, so `npm ci` fails outright on a Windows + # ARM runner — and the Windows file-lock (EPERM) flakiness, while still producing one + # correctly-targeted VSIX per platform. Implements [SWR-VSIX-CI-MATRIX], [SWR-VSIX-VERIFY]. + package-vsix: + name: Package VSIX ${{ matrix.platform }} + needs: [validate-tag, build] + # Ship whatever platforms built: one flaky native leg drops only its own VSIX. + if: ${{ !cancelled() && needs.build.result != 'skipped' }} runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - { platform: darwin-arm64, rid: osx-arm64 } + - { platform: darwin-x64, rid: osx-x64 } + - { platform: linux-x64, rid: linux-x64 } + - { platform: linux-arm64, rid: linux-arm64 } + - { platform: win32-x64, rid: win-x64 } + - { platform: win32-arm64, rid: win-arm64 } + env: + VERSION: ${{ needs.validate-tag.outputs.version }} + TAG: ${{ needs.validate-tag.outputs.tag }} steps: - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 with: - ref: main + dotnet-version: "10.0.x" - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: src/Napper.VsCode/package-lock.json + + # The VSIX manifest version comes from package.json (and the bundled + # shipwright.json expectedVersion), so the source carriers MUST be stamped from + # the tag before packaging — otherwise the Marketplace VSIX would ship 0.0.0-dev. + # Same first-class stamper used by the native legs ([SWR-VERSION-BUILD-STAMPING]). + - name: Stamp version from tag + run: dotnet fsi scripts/stamp-version.fsx --tag "$TAG" + + # The native binary was built on its own OS/arch runner; pull just that one. + # A missing leg (e.g. an ARM-runner outage) drops only its VSIX, never the others. + - name: Download native binary for ${{ matrix.platform }} + uses: actions/download-artifact@v4 + with: + name: rawbin-${{ matrix.rid }} + path: rawbin + + - name: Stage binary into the extension + shell: bash + run: | + set -euo pipefail + exe=""; case "${{ matrix.platform }}" in win32-*) exe=".exe";; esac + mkdir -p "src/Napper.VsCode/bin/${{ matrix.platform }}" + cp "rawbin/napper$exe" "src/Napper.VsCode/bin/${{ matrix.platform }}/napper$exe" + chmod +x "src/Napper.VsCode/bin/${{ matrix.platform }}/napper$exe" || true - name: Install extension dependencies working-directory: src/Napper.VsCode @@ -48,112 +267,348 @@ jobs: working-directory: src/Napper.VsCode run: npx webpack --mode production - - name: Package universal VSIX + - name: Package per-platform VSIX working-directory: src/Napper.VsCode - run: npx @vscode/vsce package --no-dependencies --skip-license + run: npx @vscode/vsce package --no-dependencies --skip-license --target ${{ matrix.platform }} + + - name: Verify VSIX contents + shell: bash + run: | + set -euo pipefail + exe=""; case "${{ matrix.platform }}" in win32-*) exe=".exe";; esac + VSIX="$(ls src/Napper.VsCode/*.vsix | head -1)" + echo "VSIX: $VSIX" + # The packaged file name carries the stamped version: napper-<ver>.vsix. + case "$VSIX" in *"$VERSION"*) : ;; *) echo "::error::VSIX '$VSIX' does not carry version $VERSION"; exit 1;; esac + unzip -l "$VSIX" > vsix-contents.txt + cat vsix-contents.txt + grep -q "shipwright.json" vsix-contents.txt \ + || { echo "::error::shipwright.json missing from VSIX"; exit 1; } + grep -Fq "bin/${{ matrix.platform }}/napper$exe" vsix-contents.txt \ + || { echo "::error::bin/${{ matrix.platform }}/napper$exe missing from VSIX"; exit 1; } + # No foreign-platform binaries may ship in a per-platform VSIX. + if grep -E "bin/(darwin|linux|win32)-[a-z0-9]+/" vsix-contents.txt \ + | grep -vq "bin/${{ matrix.platform }}/"; then + echo "::error::VSIX contains a foreign-platform binary directory"; exit 1 + fi + echo "VSIX content verification: OK" - name: Upload VSIX uses: actions/upload-artifact@v4 with: - name: vsix + name: vsix-${{ matrix.platform }} path: src/Napper.VsCode/*.vsix + if-no-files-found: error - build-cli: - needs: [bump-versions] - strategy: - matrix: - include: - - rid: osx-arm64 - asset: napper-osx-arm64 - - rid: osx-x64 - asset: napper-osx-x64 - - rid: linux-x64 - asset: napper-linux-x64 - - rid: win-x64 - asset: napper-win-x64.exe + # ── Package CLI assets uniformly on Linux ───────────────── [SWR-REL-GITHUB] + # Produces, per platform: the raw binary, an archive (.tar.gz / .zip), a per-archive + # .sha256 sidecar, and a combined checksums-sha256.txt. + package-cli: + name: Package CLI assets + needs: [validate-tag, build] + # Ship whatever platforms built: a single flaky leg (e.g. an ARM runner outage) + # must not block the release. Runs unless the gate failed (build skipped). + if: ${{ !cancelled() && needs.build.result != 'skipped' }} runs-on: ubuntu-latest + timeout-minutes: 10 + env: + TAG: ${{ needs.validate-tag.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: - ref: main - - - uses: actions/setup-dotnet@v4 + path: rawbins + pattern: rawbin-* + - name: Build archives, raw assets, and checksums + shell: bash + run: | + set -euo pipefail + mkdir -p assets + for dir in rawbins/rawbin-*; do + rid="${dir#rawbins/rawbin-}" + if [[ "$rid" == win-* ]]; then + cp "$dir/napper.exe" "assets/napper-$rid.exe" + stage="$(mktemp -d)"; cp "$dir/napper.exe" "$stage/napper.exe" + (cd "$stage" && zip -q -9 "$GITHUB_WORKSPACE/assets/napper-$TAG-$rid.zip" napper.exe) + else + cp "$dir/napper" "assets/napper-$rid"; chmod +x "assets/napper-$rid" + stage="$(mktemp -d)"; cp "$dir/napper" "$stage/napper"; chmod +x "$stage/napper" + tar -C "$stage" -czf "assets/napper-$TAG-$rid.tar.gz" napper + fi + done + # Per-archive .sha256 sidecars + a combined manifest. + cd assets + for f in napper-"$TAG"-*.tar.gz napper-"$TAG"-*.zip; do + [ -e "$f" ] || continue + sha256sum "$f" > "$f.sha256" + done + sha256sum napper-* > checksums-sha256.txt + ls -la + echo "── checksums-sha256.txt ──"; cat checksums-sha256.txt + - uses: actions/upload-artifact@v4 with: - dotnet-version: "10.0.x" + name: cli-assets + path: assets/* + if-no-files-found: error - - name: Publish CLI (${{ matrix.rid }}) - run: | - dotnet publish src/Napper.Cli/Napper.Cli.fsproj \ - -r ${{ matrix.rid }} \ - --self-contained \ - -p:PublishTrimmed=true \ - -p:PublishSingleFile=true \ - -o out/${{ matrix.rid }} \ - --nologo + # ── GitHub Release with all CLI assets + per-platform VSIXs ──── [SWR-REL-GITHUB] + release: + name: Create GitHub Release + needs: [validate-tag, package-cli, package-vsix] + if: ${{ !cancelled() && needs.package-vsix.result != 'skipped' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + TAG: ${{ needs.validate-tag.outputs.tag }} + steps: + - name: Download CLI assets + uses: actions/download-artifact@v4 + with: + path: assets + name: cli-assets + - name: Download per-platform VSIXs + uses: actions/download-artifact@v4 + with: + path: assets + pattern: vsix-* + merge-multiple: true + - name: List release assets + run: ls -la assets + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.TAG }} + files: assets/* + generate_release_notes: true + draft: false + prerelease: ${{ contains(env.TAG, '-') }} - - name: Prepare asset + # ── Publish per-platform VSIXs to the VS Code Marketplace ─────── [SWR-VSIX-PUBLISH] + publish-marketplace: + name: Publish to VS Code Marketplace + needs: [validate-tag, package-vsix] + if: ${{ !cancelled() && needs.package-vsix.result != 'skipped' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # Turn the Marketplace's opaque "TF400813: user aaaaaaaa-... not authorized" (what + # you get from an empty/blank PAT) into an actionable, operator-facing error. The + # GitHub Release + Homebrew + Scoop do NOT depend on this job, so a missing token + # never blocks the native-binary release — only the Marketplace publish waits. + - name: Require Marketplace credential + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} run: | - if [ "${{ matrix.rid }}" = "win-x64" ]; then - mv out/${{ matrix.rid }}/napper.exe ${{ matrix.asset }} - else - mv out/${{ matrix.rid }}/napper ${{ matrix.asset }} - chmod +x ${{ matrix.asset }} + set -euo pipefail + if [ -z "${VSCE_PAT:-}" ]; then + echo "::error title=VSCE_PAT secret is not set::Add a VS Code Marketplace Personal Access Token as the repo secret VSCE_PAT, then re-run, to publish the per-platform VSIXs. The GitHub Release (with all VSIX + CLI assets), Homebrew, and Scoop already shipped independently. Token guide: https://code.visualstudio.com/api/working-with-extensions/publishing-extension#get-a-personal-access-token" + exit 1 fi - - - name: Upload CLI binary - uses: actions/upload-artifact@v4 + echo "VSCE_PAT present — proceeding with Marketplace publish." + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Download all per-platform VSIXs + uses: actions/download-artifact@v4 with: - name: cli-${{ matrix.rid }} - path: ${{ matrix.asset }} + path: vsix-artifacts + pattern: vsix-* + merge-multiple: true + - name: Publish all platforms in one atomic call + run: npx @vscode/vsce publish --packagePath $(find vsix-artifacts -name '*.vsix' | tr '\n' ' ') + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + # ── SECONDARY, best-effort dotnet-tool NuGet package ────────────────────────── + # continue-on-error + NOT a dependency of release / marketplace / brew / scoop, so a + # NuGet failure (key, outage, pack issue) can NEVER block the release. The NativeAOT + # native binary + VSIX remain the primary, .NET-free deployment. publish-nuget: - needs: [bump-versions] + name: Publish dotnet tool to NuGet (best-effort) + needs: [validate-tag, gate] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + timeout-minutes: 10 + continue-on-error: true + env: + VERSION: ${{ needs.validate-tag.outputs.version }} steps: - uses: actions/checkout@v4 - with: - ref: main - - uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.x" - - - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" - - - name: Pack dotnet tool + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Generate F# types (typeDiagram) run: | - dotnet pack src/Napper.Cli/Napper.Cli.fsproj \ - -c Release \ - -p:Version=${{ env.VERSION }} \ - --nologo - - - name: Push to NuGet + npm install -g typediagram@0.9.0 + bash scripts/generate-types.sh + - name: Pack dotnet tool (version from tag) + run: dotnet pack src/Napper.Cli/Napper.Cli.fsproj -c Release -p:Version="$VERSION" --nologo + - name: Push to NuGet (skip if already published) run: | - dotnet nuget push src/Napper.Cli/nupkg/napper.${{ env.VERSION }}.nupkg \ - --api-key ${{ secrets.NUGET_API_KEY }} \ - --source https://api.nuget.org/v3/index.json + dotnet nuget push "src/Napper.Cli/nupkg/napper.${VERSION}.nupkg" \ + --api-key "${{ secrets.NIMBLESITE_NUGET_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate - release: - needs: [build-vsix, build-cli, publish-nuget] + # ── Homebrew tap: hashes come from the build's .sha256 sidecars ──────────────── + update-homebrew: + name: Update Homebrew Formula + needs: [validate-tag, release] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') + timeout-minutes: 10 + env: + TAG: ${{ needs.validate-tag.outputs.tag }} + VERSION: ${{ needs.validate-tag.outputs.version }} steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 + - name: Checkout homebrew-tap + uses: actions/checkout@v4 with: - path: assets - merge-multiple: true + repository: Nimblesite/homebrew-tap + token: ${{ secrets.BREW_SCOOP_PAT }} + - name: Read SHA256s from release sidecars + shell: bash + run: | + set -euo pipefail + BASE="https://github.com/Nimblesite/napper/releases/download/${TAG}" + sidecar() { curl -fsSL "$BASE/napper-${TAG}-$1.tar.gz.sha256" | cut -d ' ' -f 1; } + { + echo "SHA256_MACOS_ARM64=$(sidecar osx-arm64)" + echo "SHA256_MACOS_X64=$(sidecar osx-x64)" + echo "SHA256_LINUX_X64=$(sidecar linux-x64)" + echo "SHA256_LINUX_ARM64=$(sidecar linux-arm64)" + } >> "$GITHUB_ENV" + - name: Write formula + shell: bash + run: | + set -euo pipefail + mkdir -p Formula + cat > Formula/napper.rb <<FORMULA + # typed: false + # frozen_string_literal: true - - name: Generate SHA256 checksums - working-directory: assets - run: sha256sum * > ../checksums-sha256.txt + class Napper < Formula + desc "CLI-first, test-oriented HTTP API testing tool. Send requests, run assertions, manage environments." + homepage "https://napperapi.dev" + license "MIT" + version "${VERSION}" - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + on_macos do + on_arm do + url "https://github.com/Nimblesite/napper/releases/download/${TAG}/napper-${TAG}-osx-arm64.tar.gz" + sha256 "${SHA256_MACOS_ARM64}" + end + on_intel do + url "https://github.com/Nimblesite/napper/releases/download/${TAG}/napper-${TAG}-osx-x64.tar.gz" + sha256 "${SHA256_MACOS_X64}" + end + end + + on_linux do + on_arm do + url "https://github.com/Nimblesite/napper/releases/download/${TAG}/napper-${TAG}-linux-arm64.tar.gz" + sha256 "${SHA256_LINUX_ARM64}" + end + on_intel do + url "https://github.com/Nimblesite/napper/releases/download/${TAG}/napper-${TAG}-linux-x64.tar.gz" + sha256 "${SHA256_LINUX_X64}" + end + end + + def install + bin.install "napper" + end + + test do + assert_match version.to_s, shell_output("\#{bin}/napper --version") + end + end + FORMULA + cat Formula/napper.rb + - name: Commit and push + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/napper.rb + git diff --cached --quiet && { echo "No changes"; exit 0; } + git commit -m "Update napper to ${TAG}" + git push + + # ── Scoop bucket: hash from the build's .sha256 sidecar, JSON via a real serializer ── + update-scoop: + name: Update Scoop Manifest + needs: [validate-tag, release] + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + TAG: ${{ needs.validate-tag.outputs.tag }} + VERSION: ${{ needs.validate-tag.outputs.version }} + steps: + - name: Checkout scoop-bucket + uses: actions/checkout@v4 with: - generate_release_notes: true - files: | - assets/* - checksums-sha256.txt + repository: Nimblesite/scoop-bucket + token: ${{ secrets.BREW_SCOOP_PAT }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Read SHA256 from release sidecar + shell: bash + run: | + set -euo pipefail + BASE="https://github.com/Nimblesite/napper/releases/download/${TAG}" + echo "SHA256=$(curl -fsSL "$BASE/napper-${TAG}-win-x64.zip.sha256" | cut -d ' ' -f 1)" >> "$GITHUB_ENV" + echo "ASSET_URL=$BASE/napper-${TAG}-win-x64.zip" >> "$GITHUB_ENV" + - name: Write manifest + shell: bash + run: | + set -euo pipefail + mkdir -p bucket + node - <<'NODE' + const { mkdirSync, writeFileSync } = require("node:fs"); + const manifest = { + version: process.env.VERSION, + description: "CLI-first, test-oriented HTTP API testing tool. Send requests, run assertions, manage environments.", + homepage: "https://napperapi.dev", + license: "MIT", + architecture: { "64bit": { url: process.env.ASSET_URL, hash: process.env.SHA256, bin: "napper.exe" } }, + checkver: { github: "https://github.com/Nimblesite/napper" }, + autoupdate: { + architecture: { + "64bit": { url: "https://github.com/Nimblesite/napper/releases/download/v$version/napper-v$version-win-x64.zip" } + } + } + }; + mkdirSync("bucket", { recursive: true }); + writeFileSync("bucket/napper.json", `${JSON.stringify(manifest, null, 2)}\n`); + NODE + cat bucket/napper.json + - name: Commit and push + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add bucket/napper.json + git diff --cached --quiet && { echo "No changes"; exit 0; } + git commit -m "Update napper to ${TAG}" + git push + + # ── Refresh the website after the release assets exist ── + # Ordered after brew/scoop, but gated only on the GitHub Release itself so a + # transient Homebrew/Scoop failure can never skip the production website push. + deploy-website: + name: Deploy Website + needs: [release, update-homebrew, update-scoop] + if: ${{ !cancelled() && needs.release.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + steps: + - name: Trigger Pages deploy + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh workflow run deploy-pages.yml --repo ${{ github.repository }} --ref main diff --git a/.gitignore b/.gitignore index 50b27fb..47a63b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,57 +1,108 @@ +# ============================================================================= # OS +# ============================================================================= .DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Thumbs.db +ehthumbs.db +Desktop.ini + +# ============================================================================= +# IDE / Editor +# ============================================================================= +.idea/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ +*.sublime-project +*.sublime-workspace + +# ============================================================================= +# Secrets / Local Overrides +# ============================================================================= +.env +.env.local +.env.*.local +*.local +*.secret +*.pem +*.key +!*.pub.key +.napenv.local + +# ============================================================================= +# Temporary +# ============================================================================= +tmp/ +temp/ +scratch/ -# .NET build output +# ============================================================================= +# Coverage Artifacts +# ============================================================================= +coverage/ +lcov.info +*.profraw +*.profdata +htmlcov/ +.coverage +coverage.xml +coverage.out +coverage-summary.json +TestResults/ +mutants.out/ + +# ============================================================================= +# F# / .NET +# ============================================================================= bin/ obj/ publish/ +.ionide/ -# Node / TypeScript build output +# ============================================================================= +# TypeScript / Node +# ============================================================================= node_modules/ dist/ out/ +build/ +*.vsix +*.tgz +.npm/ +.cache/ +.vscode-test/ +.vscode-test-web/ +.nyc_output/ + +# ============================================================================= +# Rust +# ============================================================================= +src/Napper.Zed/target/ +*.wasm -# VSCode extension -src/Napper.VsCode/node_modules/ -src/Napper.VsCode/dist/ -src/Napper.VsCode/out/ -src/Napper.VsCode/*.vsix -src/Napper.VsCode/.vscode-test/ - -# IDE settings -.vscode/ - -# Secrets -.napenv.local - -# Test output -coverage/ -TestResults/ - -# Tools -.too_many_cooks/ +# ============================================================================= +# Tool Caches +# ============================================================================= .commandtree/ -.playwright-mcp/ - -# Generated files +.deslop-cache/ +.ghissues/ + +# ============================================================================= +# Generated Files +# ============================================================================= +# Napper.Core ADTs generated from Types.td (typeDiagram). Run `make generate-types`. +src/Napper.Core/Types.Generated.fs website/_site/ examples/httpbin/advanced-report.html - -# Cached test specs -tests/Napper.Core.Tests/.spec-cache/ - examples/httpbin/all-methods-report.html - -src/Napper.Zed/target/ - -src/Napper.Zed/extension.wasm - -src/Napper.Zed/grammars/nap.wasm - -src/Napper.Zed/grammars/napenv.wasm - -*.wasm - +tests/Napper.Core.Tests/.spec-cache/ scripts/logs/ -src/Napper.VsCode/.nyc_output/ +scripts/.too_many_cooks/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..5ededd5 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "_agent_pmo": "74cf183", + "recommendations": [ + "nimblesite.commandtree", + "nimblesite.too-many-cooks", + "nimblesite.typeDiagram" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c26afea --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "basilisk.testExplorer.enabled": true, + "basilisk.uv.enabled": true, + "workbench.colorCustomizations": { + "titleBar.activeBackground": "#1B4965", + "titleBar.activeForeground": "#FFFFFF", + "titleBar.inactiveBackground": "#163d52", + "titleBar.inactiveForeground": "#FFFFFFcc" + } +} diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000..addcc98 --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,5 @@ +@CLAUDE.md + +All project rules, coding standards, hard constraints, build commands, +and architecture notes live in `CLAUDE.md` at the repository root. +Read that file in full before writing any code. Do not add rules here. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e9ba20a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# Agent Instructions + +@CLAUDE.md + +Read the file above in full before writing any code. All project rules, +coding standards, hard constraints, build commands, and architecture +notes live there. + +This file exists so that tools which look for `AGENTS.md` (OpenAI Codex, +Cline, Cursor, Windsurf, and others) automatically pick up the same +instructions that Claude Code uses. + +Do NOT add rules here. Keep everything in `CLAUDE.md` so there is +exactly one set of instructions to maintain. diff --git a/Claude.md b/Claude.md index 753fb76..b9dbe77 100644 --- a/Claude.md +++ b/Claude.md @@ -1,10 +1,8 @@ -## Too Many Cooks +<!-- agent-pmo:74cf183 --> -You are working with many other agents. Make sure there is effective cooperation +## Too Many Cooks -- Register on TMC immediately -- Don't edit files that are locked; lock files when editing -- COMMUNICATE REGULARLY AND COORDINATE WITH OTHERS THROUGH MESSAGES +⚠️ NEVER KILL VSCODE PROCESSES ## Coding Rules @@ -23,16 +21,13 @@ You are working with many other agents. Make sure there is effective cooperation - **Keep files under 450 LOC and functions under 20 LOC** - **No commented-out code** - Delete it - **No placeholders** - If incomplete, leave LOUD compilation error with TODO +- **Spec IDs are hierarchical, descriptive, and non-numeric.** Every spec section MUST have a unique ID in the format `[GROUP-TOPIC]` or `[GROUP-TOPIC-DETAIL]` (e.g., `[CLI-PARSE-NAP]`, `[LSP-COMPLETION-VARS]`, `[HTTP-REQ-HEADERS]`). The first word is the **group** — all sections in the same group MUST be adjacent in the spec's TOC. NEVER use sequential numbers like `[SPEC-001]`. All code, tests, and design docs that implement a spec section MUST reference its ID in a comment (e.g., `// Implements [LSP-COMPLETION-VARS]`). ### Rust - -- We will soon be inserting an LSP so keep the code loose enough that this will be easy - Keep files under 500 LOC - Run fmt and clippy regularly!!! ### Typescript - -- We will soon be inserting an LSP so keep the code loose enough that this will be easy - **TypeScript strict mode** - No `any`, no implicit types, turn all lints up to error - **Regularly run the linter** - Fix lint errors IMMEDIATELY - **Decouple providers from the VSCODE SDK** - No vscode sdk use within the providers @@ -40,7 +35,6 @@ You are working with many other agents. Make sure there is effective cooperation - **No throwing** - Only return `Result<T,E>` ### F# - - **⚠️ MAXIMUM CODE SHARING — NON-NEGOTIABLE** - All F# projects (Napper.Cli, Napper.Lsp, future consumers) MUST share logic through `Napper.Core`. If code could live in `Napper.Core`, it MUST live in `Napper.Core`. NEVER duplicate parsing, types, environment resolution, logging, or any domain logic across projects. Before writing ANY new module in a consumer project, check if it belongs in `Napper.Core` first. - **Idiomatic F#** - **Move content out of the fsproj files and into Directory.Build.props** @@ -48,19 +42,25 @@ You are working with many other agents. Make sure there is effective cooperation - **Turn on F# analyzers** - Strict rules to enforce F# best practice - **Prefer moving config from fsproj -> buildprops** avoid project config across projects -## Testing +### Type Models -⚠️ NEVER KILL VSCODE PROCESSES +- All models are declared with [typeDiagram markup syntax](https://typediagram.dev/docs/language-reference.html) +- Use the [typeDiagram code generator](https://typediagram.dev/docs/cli.html) to generat the F# ADTS. +- If you have any issues with typeDiagram, log bugs on the [gh repo](https://github.com/Nimblesite/typeDiagram). + +## Testing #### Rules - **Prefer e2e tests over unit tests** - only unit tests for isolating bugs - Separate e2e tests from unit tests by file. They should not be in the same file together. - **Add more assertions** - No, that's not enough. Add more!!! +- Multiple user interactions per test, multiple assertions per user interaction - Prefer adding assertions to existing tests rather than adding new tests - NEVER remove assertions - FAILING TEST = ✅ OK. TEST THAT DOESN'T ENFORCE BEHAVIOR = ⛔️ ILLEGAL -- Unit tests are for isolating issues +- Unit tests are for isolating issues only +- FAKE TESTS ARE ILLEGAL **A "fake test" is any test that passes without actually verifying behavior. These are STRICTLY FORBIDDEN:** ### Automated (E2E) Testing @@ -74,15 +74,6 @@ You are working with many other agents. Make sure there is effective cooperation - The test VSIX must call the actual, real CLI. - VSIX tests run in actual VS Code window -**Illegal VSIX testing patterns** - -- - ❌ Calling internal methods like provider.updateTasks() -- - ❌ Calling provider.refresh() directly -- - ❌ Manipulating internal state directly -- - ❌ Using any method not exposed via VS Code commands -- - ❌ Using commands that should just happen as part of normal use. e.g.: `await vscode.commands.executeCommand('commandtree.refresh');` -- - ❌ `executeCommand('commandtree.addToQuick', item)` - TAP the item via the DOM!!! - ### Test First Process - Write test that fails because of bug/missing feature @@ -98,34 +89,6 @@ You are working with many other agents. Make sure there is effective cooperation 2. Fail if the feature is broken 3. Test the full flow, not just side effects like config files -### ⛔️ FAKE TESTS ARE ILLEGAL - -**A "fake test" is any test that passes without actually verifying behavior. These are STRICTLY FORBIDDEN:** - -```typescript -// ❌ ILLEGAL - asserts true unconditionally -assert.ok(true, "Should work"); - -// ❌ ILLEGAL - no assertion on actual behavior -try { - await doSomething(); -} catch {} -assert.ok(true, "Did not crash"); - -// ❌ ILLEGAL - only checks config file, not actual UI/view behavior -writeConfig({ quick: ["task1"] }); -const config = readConfig(); -assert.ok(config.quick.includes("task1")); // This doesn't test the FEATURE - -// ❌ ILLEGAL - empty catch with success assertion -try { - await command(); -} catch { - /* swallow */ -} -assert.ok(true, "Command ran"); -``` - ## Specs Structure The `specs/` directory contains the product specification, split by concern and by CLI vs IDE extension: @@ -145,6 +108,12 @@ Plan files end with a TODO checklist. Specs describe _what_, plans describe _how Extensions target **VSCode and Zed** as primary IDEs (Neovim future). All extensions shell out to the Nap CLI — no IDE re-implements HTTP logic. A portable **Nap Language Server (LSP)** provides completions, diagnostics, and hover across all IDEs. +You are working with many other agents. Make sure there is effective cooperation + +- Register on TMC immediately +- Don't edit files that are locked; lock files when editing +- COMMUNICATE REGULARLY AND COORDINATE WITH OTHERS THROUGH MESSAGES + ## Critical Docs ### Zed SDK @@ -164,11 +133,5 @@ Extensions target **VSCode and Zed** as primary IDEs (Neovim future). All extens ### Website -https://developers.google.com/search/blog/2025/05/succeeding-in-ai-search -https://developers.google.com/search/docs/fundamentals/seo-starter-guide - -https://studiohawk.com.au/blog/how-to-optimise-ai-overviews/ -https://about.ads.microsoft.com/en/blog/post/october-2025/optimizing-your-content-for-inclusion-in-ai-search-answers - -Never stamp commits with this. You ARE NOT THE COAUTHOR!!! -Co-Authored-By: C*** <noreply@anthropic.com> \ No newline at end of file +Minimize CSS classes +CSS Budget: 1.5k LOC \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index b139a83..e7fb00a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,10 @@ <Project> <PropertyGroup> - <Version>0.11.0</Version> + <!-- Source-controlled version MUST stay 0.0.0-dev. Real versions are stamped + from the git tag at release time by scripts/stamp-version.fsx + (Implements [SWR-VERSION-BUILD-STAMPING]). NEVER hard-code a release version here. --> + <Version>0.0.0-dev</Version> <TargetFramework>net10.0</TargetFramework> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <WarningLevel>5</WarningLevel> @@ -11,7 +14,7 @@ <Authors>MelbourneDeveloper</Authors> <Copyright>Copyright (c) MelbourneDeveloper 2026</Copyright> <PackageProjectUrl>https://napperapi.dev</PackageProjectUrl> - <RepositoryUrl>https://github.com/MelbourneDeveloper/napper</RepositoryUrl> + <RepositoryUrl>https://github.com/Nimblesite/napper</RepositoryUrl> <RepositoryType>git</RepositoryType> <PackageLicenseExpression>MIT</PackageLicenseExpression> </PropertyGroup> diff --git a/Makefile b/Makefile index 0b9ed07..39edffa 100644 --- a/Makefile +++ b/Makefile @@ -1,416 +1,298 @@ -.PHONY: build-all build-cli build-extension build-vsix build-zed bump-version clean-install dump-cli-help install-binaries package-vsix test-fsharp test-rust test-vsix test clean format lint +# ============================================================================= +# Standard Makefile — Napper +# ============================================================================= +# agent-pmo:74cf183 -SHELL := /usr/bin/env bash -.SHELLFLAGS := -euo pipefail -c +.PHONY: build test lint fmt clean ci setup package-vsix test-fsharp build-zed stamp generate-types -# --- Platform detection --- -ARCH := $(shell uname -m) -OS := $(shell uname -s) +# --- Cross-platform support --- +ifeq ($(OS),Windows_NT) + SHELL := powershell.exe + .SHELLFLAGS := -NoProfile -Command + _RM = Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + _MKDIR = New-Item -ItemType Directory -Force + HOME ?= $(USERPROFILE) +else + SHELL := /usr/bin/env bash + .SHELLFLAGS := -euo pipefail -c + _RM = rm -rf + _MKDIR = mkdir -p +endif -ifeq ($(OS),Darwin) - ifeq ($(ARCH),arm64) - NAP_RID ?= osx-arm64 - else ifeq ($(ARCH),x86_64) - NAP_RID ?= osx-x64 +# --- Platform detection for .NET RID and Shipwright/vsce target --- +ifeq ($(OS),Windows_NT) + _NAP_RID ?= win-x64 + _DTK_PLATFORM := win32-x64 +else + _ARCH := $(shell uname -m) + _UNAME_S := $(shell uname -s) + ifeq ($(_UNAME_S),Darwin) + ifeq ($(filter arm64,$(_ARCH)),arm64) + _NAP_RID ?= osx-arm64 + _DTK_PLATFORM := darwin-arm64 + else + _NAP_RID ?= osx-x64 + _DTK_PLATFORM := darwin-x64 + endif else - $(error Unsupported arch: $(ARCH)) + _NAP_RID ?= linux-x64 + _DTK_PLATFORM := linux-x64 endif -else ifeq ($(OS),Linux) - NAP_RID ?= linux-x64 -else - $(error Unsupported OS: $(OS)) endif -EXT_BIN := src/Napper.VsCode/bin -LOG_DIR := .commandtree/logs -FSHARP_COVERAGE_DIR := coverage/fsharp -DOTHTTP_COVERAGE_DIR := coverage/dothttp -LSP_COVERAGE_DIR := coverage/lsp -TS_COVERAGE_DIR := coverage/typescript -RUST_COVERAGE_DIR := coverage/rust +_EXT_BIN := src/Napper.VsCode/bin/$(_DTK_PLATFORM) +_LOG_DIR := .commandtree/logs +_COV := coverage +_FSHARP_COV := $(_COV)/fsharp +_DOTHTTP_COV := $(_COV)/dothttp +_LSP_COV := $(_COV)/lsp +_TS_COV := $(_COV)/typescript +_RUST_COV := $(_COV)/rust -# ============================================================ -# Build targets -# ============================================================ +# Type-model generation: Types.td (typeDiagram DSL) is the canonical source of +# truth for the Napper.Core ADTs; Types.Generated.fs is gitignored and rebuilt +# by `make generate-types`. See REPO rule "Type Models" + Nimblesite/typeDiagram#36. +_TYPES_TD := src/Napper.Core/Types.td +_TYPES_GEN := src/Napper.Core/Types.Generated.fs -build-cli: - @echo "==> Building CLI for $(NAP_RID)..." - dotnet publish src/Napper.Cli/Napper.Cli.fsproj \ - -r "$(NAP_RID)" \ - --self-contained \ - -p:PublishTrimmed=true \ - -p:PublishSingleFile=true \ - -o "out/$(NAP_RID)" \ - --nologo - @echo "==> CLI built → out/$(NAP_RID)/" - @mkdir -p "$(EXT_BIN)" - cp "out/$(NAP_RID)/napper" "$(EXT_BIN)/napper" - @echo "==> Copied CLI → $(EXT_BIN)/" - @mkdir -p "$(HOME)/.local/bin" - cp "out/$(NAP_RID)/napper" "$(HOME)/.local/bin/napper" - chmod +x "$(HOME)/.local/bin/napper" - @echo "==> Installed CLI → ~/.local/bin/napper" - @EXPECTED_VERSION=$$(sed -n 's/.*<Version>\(.*\)<\/Version>.*/\1/p' Directory.Build.props); \ - ACTUAL_VERSION=$$("out/$(NAP_RID)/napper" --version); \ - if [ "$$ACTUAL_VERSION" != "$$EXPECTED_VERSION" ]; then \ - echo "ERROR: Version mismatch — expected $$EXPECTED_VERSION, got $$ACTUAL_VERSION"; \ - exit 1; \ - fi; \ - echo "==> CLI version verified: $$ACTUAL_VERSION" +# Runs dotnet test + reportgenerator for one project. +# $(1)=project dir $(2)=coverage dir $(3)=log name +define _dotnet_test + $(_RM) "$(2)" && $(_MKDIR) "$(2)" + dotnet test $(1) --nologo \ + --settings $(1)/coverage.runsettings \ + --results-directory "$(2)/raw" \ + --logger "console;verbosity=detailed" \ + -- RunConfiguration.FailFastEnabled=true \ + 2>&1 | tee "$(_LOG_DIR)/$(3).log" + reportgenerator \ + -reports:"$(2)/raw/*/coverage.cobertura.xml" \ + -targetdir:"$(2)/report" \ + -reporttypes:"Html;TextSummary;Cobertura;lcov" +endef -build-extension: - @echo "==> Compiling VSCode extension..." - cd src/Napper.VsCode && npm ci && npx webpack --mode production - @echo "==> Extension compiled" +# Checks one coverage result against coverage-thresholds.json. +# Exits non-zero immediately (FAIL FAST) if coverage < threshold. +# Ratchets threshold up to floor(coverage)-1 when coverage improves. +# $(1)=project key $(2)=summary file $(3)=label +define _cov_check + @{ \ + t=$$(jq -r '.projects["$(1)"].threshold // .default_threshold' coverage-thresholds.json); \ + if [ -f "$(2)" ]; then \ + c=$$(awk '/Line coverage:/ {gsub(/%/,""); print $$3}' "$(2)" 2>/dev/null || echo "0"); \ + echo " $(3): $${c}% (threshold $${t}%)"; \ + if [ $$(echo "$${c} < $${t}" | bc -l) -eq 1 ]; then \ + echo " *** FAIL: $(3) coverage $${c}% is below threshold $${t}% — ABORTING ***"; \ + exit 1; \ + fi; \ + new_t=$$(( $$(echo "scale=0; $${c}/1" | bc) - 1 )); \ + if [ $$(echo "$${new_t} > $${t}" | bc -l) -eq 1 ]; then \ + tmp=$$(mktemp); \ + jq --argjson nt "$${new_t}" '.projects["$(1)"].threshold = $$nt' coverage-thresholds.json > "$${tmp}" && mv "$${tmp}" coverage-thresholds.json; \ + echo " RATCHET: $(3) threshold -> $${new_t}%"; \ + else \ + echo " OK"; \ + fi; \ + else echo " $(3): no data (skipping)"; fi; \ + } +endef + +# ============================================================================= +# Standard Targets +# +# The 7 portfolio-wide targets. See REPO-STANDARDS-SPEC [MAKE-TARGETS]. +# Repo-specific targets live in their own section below. +# ============================================================================= + +# build: compile/assemble all shippable artifacts (CLI native binary + extension bundle). +build: generate-types _build_cli _build_extension -build-vsix: build-cli build-extension - @echo "==> Packaging universal VSIX..." - cd src/Napper.VsCode && npx @vscode/vsce package --no-dependencies --skip-license - @echo "==> VSIX packaged (universal — no CLI bundled)" - @VSIX_FILE=$$(ls -1 src/Napper.VsCode/*.vsix 2>/dev/null | head -1); \ - [ -n "$$VSIX_FILE" ] && echo " VSIX: $$VSIX_FILE"; \ - echo " CLI installed at: ~/.local/bin/napper (for local use)" +test: generate-types _test_fsharp _test_rust _test_vsix _coverage_check -package-vsix: build-extension - @echo "==> Packaging universal VSIX..." - cd src/Napper.VsCode && npx @vscode/vsce package --no-dependencies --skip-license - @echo "==> VSIX packaged" +lint: generate-types + dotnet build --nologo -warnaserror + cd src/Napper.VsCode && npm run lint + cargo clippy --manifest-path src/Napper.Zed/Cargo.toml + +fmt: + dotnet fantomas src/ + cd src/Napper.VsCode && npx prettier --write "src/**/*.ts" + cargo fmt --manifest-path src/Napper.Zed/Cargo.toml clean: - @echo "==> Cleaning all build artifacts..." - rm -rf out/ - rm -rf src/Napper.Core/bin/ src/Napper.Core/obj/ - rm -rf src/Napper.Cli/bin/ src/Napper.Cli/obj/ - rm -rf tests/Napper.Core.Tests/bin/ tests/Napper.Core.Tests/obj/ - rm -rf src/Napper.VsCode/bin/ - rm -rf src/Napper.VsCode/dist/ - rm -rf src/Napper.VsCode/out/ - rm -f src/Napper.VsCode/*.vsix - rm -rf coverage/ - @echo "==> Clean complete" + $(_RM) out/ $(_COV)/ + $(_RM) src/Napper.Core/bin/ src/Napper.Core/obj/ + $(_RM) src/Napper.Cli/bin/ src/Napper.Cli/obj/ + $(_RM) src/Napper.VsCode/bin/ src/Napper.VsCode/dist/ src/Napper.VsCode/out/ + $(_RM) src/Napper.VsCode/*.vsix + +ci: lint test build + +setup: + dotnet tool restore && dotnet restore + cd src/Napper.VsCode && npm ci + cd website && npm ci + rustup component add clippy rustfmt 2>/dev/null || true + dotnet tool install --global dotnet-reportgenerator-globaltool 2>/dev/null || true + +# stamp: write a release version into every source version carrier +# (Directory.Build.props, the extension package.json, and shipwright.json) using +# structured parsers. Implements [SWR-VERSION-BUILD-STAMPING]. Source stays at +# 0.0.0-dev; only the release/runner working tree is stamped — never committed. +# make stamp VERSION=1.2.3 (or) make stamp TAG=v1.2.3 +stamp: + dotnet fsi scripts/stamp-version.fsx $(if $(TAG),--tag $(TAG),--version $(VERSION)) -build-all: clean build-cli - @echo "==> Building VS Code extension..." - cd src/Napper.VsCode && npm ci && npx webpack --mode production && npm run compile:tests - @echo "==> Extension compiled" - @echo "==> Packaging VSIX (universal)..." - cd src/Napper.VsCode && npx @vscode/vsce package --no-dependencies --skip-license - @VSIX_FILE=$$(ls -1 src/Napper.VsCode/*.vsix 2>/dev/null | head -1); \ - echo ""; \ - echo "==> BUILD COMPLETE"; \ - echo " CLI: ~/.local/bin/napper"; \ - echo " CLI: $(EXT_BIN)/napper"; \ - [ -n "$$VSIX_FILE" ] && echo " VSIX: $$VSIX_FILE"; \ - echo ""; \ - napper --help | head -1 +# ============================================================================= +# Repo-Specific Targets +# +# Specific to this repo; NOT part of the standard 7. Preserved during +# remediation per REPO-STANDARDS-SPEC [MAKE-TARGETS]. +# ============================================================================= +# package-vsix: build then package the platform VSIX and verify its contents. +package-vsix: clean build + cd src/Napper.VsCode && npx @vscode/vsce package --no-dependencies --skip-license --target $(_DTK_PLATFORM) + @VSIX=$$(ls src/Napper.VsCode/*.vsix 2>/dev/null | head -1); \ + [ -n "$$VSIX" ] || { echo "ERROR: no VSIX file found"; exit 1; }; \ + echo "==> Verifying VSIX contents: $$VSIX"; \ + unzip -l "$$VSIX" > /tmp/vsix-contents.txt; \ + grep -q "shipwright.json" /tmp/vsix-contents.txt || { echo "ERROR: shipwright.json missing from VSIX"; exit 1; }; \ + grep -q "bin/$(_DTK_PLATFORM)/napper" /tmp/vsix-contents.txt || { echo "ERROR: bin/$(_DTK_PLATFORM)/napper missing from VSIX"; exit 1; }; \ + echo " shipwright.json: OK"; \ + echo " bin/$(_DTK_PLATFORM)/napper: OK"; \ + echo "==> VSIX packaged and verified" + +# test-fsharp: F#-only test subset (consumed by CI's F# coverage step). +test-fsharp: generate-types _test_fsharp + +# generate-types: regenerate Napper.Core ADTs from the typeDiagram source of truth. +# Types.td is canonical and checked in; Types.Generated.fs is gitignored and +# rebuilt here. The preamble adds the namespace and the one host-type bridge +# (typeDiagram opaque Duration -> BCL TimeSpan) that the DSL cannot express. +# Requires `typediagram` with F# support (Nimblesite/typeDiagram#36). +generate-types: + @command -v typediagram >/dev/null 2>&1 || { echo "ERROR: typediagram not on PATH (needs F# support, Nimblesite/typeDiagram#36)"; exit 1; } + @printf '%s\n' \ + '// <auto-generated> DO NOT EDIT. Rebuilt by: make generate-types' \ + '// Canonical source of truth: src/Napper.Core/Types.td (typeDiagram DSL).' \ + '// See https://github.com/Nimblesite/typeDiagram/issues/36' \ + '' \ + 'namespace Napper.Core' \ + '' \ + '// Host-type bridge: the typeDiagram opaque type Duration maps to BCL TimeSpan.' \ + 'type Duration = System.TimeSpan' \ + '' > "$(_TYPES_GEN)" + @typediagram --to fsharp "$(_TYPES_TD)" >> "$(_TYPES_GEN)" + @echo "==> Generated $(_TYPES_GEN) from $(_TYPES_TD)" + +# build-zed: build the Zed extension wasm (requires the tree-sitter CLI). build-zed: - @echo "==> Checking prerequisites..." - @command -v cargo &>/dev/null || { echo "ERROR: cargo not found. Install Rust: https://rustup.rs"; exit 1; } - @command -v tree-sitter &>/dev/null || { echo "ERROR: tree-sitter CLI not found. Install: npm install -g tree-sitter-cli"; exit 1; } + @command -v cargo &>/dev/null || { echo "ERROR: cargo not found"; exit 1; } + @command -v tree-sitter &>/dev/null || { echo "ERROR: tree-sitter not found"; exit 1; } @if ! rustup target list --installed 2>/dev/null | grep -q wasm32-wasi; then \ - echo "==> Adding wasm32-wasip1 target..."; \ rustup target add wasm32-wasip1; \ fi - @echo "==> Generating Tree-sitter parsers..." - @for grammar in nap naplist napenv; do \ - echo " $$grammar"; \ - (cd src/Napper.Zed/grammars/tree-sitter-$$grammar && tree-sitter generate); \ + @for g in nap naplist napenv; do \ + (cd src/Napper.Zed/grammars/tree-sitter-$$g && tree-sitter generate); \ done - @echo "==> Building Rust extension (WASM)..." cd src/Napper.Zed && cargo build --release --target wasm32-wasip1 - @echo "==> Running clippy..." cd src/Napper.Zed && cargo clippy --target wasm32-wasip1 - @echo "==> Build complete" - @echo "" - @echo "To test in Zed:" - @echo " 1. Open Zed" - @echo " 2. Run: zed: install dev extension" - @echo " 3. Select: $$(pwd)/src/Napper.Zed" -# ============================================================ -# Version management -# ============================================================ +# ============================================================================= +# Private helpers +# ============================================================================= -# Usage: make bump-version VERSION=0.2.0 [COMMIT=true] -bump-version: -ifndef VERSION - $(error Usage: make bump-version VERSION=x.y.z [COMMIT=true]) -endif - @echo "==> Bumping all projects to v$(VERSION)" - sed -i.bak 's|<Version>.*</Version>|<Version>$(VERSION)</Version>|' Directory.Build.props - rm -f Directory.Build.props.bak - @echo " Directory.Build.props → $(VERSION)" - cd src/Napper.VsCode && npm version "$(VERSION)" --no-git-tag-version --allow-same-version - @echo " src/Napper.VsCode/package.json → $(VERSION)" - @if [ -f Cargo.toml ]; then \ - sed -i.bak 's/^version = ".*"/version = "$(VERSION)"/' Cargo.toml; \ - rm -f Cargo.toml.bak; \ - echo " Cargo.toml → $(VERSION)"; \ - fi - @echo "==> All projects bumped to v$(VERSION)" -ifeq ($(COMMIT),true) - @echo "==> Committing and pushing version bump..." - @if [ -n "$${CI:-}" ]; then \ - git config user.name "github-actions[bot]"; \ - git config user.email "github-actions[bot]@users.noreply.github.com"; \ - fi - git add Directory.Build.props src/Napper.VsCode/package.json src/Napper.VsCode/package-lock.json - @[ -f Cargo.toml ] && git add Cargo.toml || true - git commit -m "release: update version to v$(VERSION)" - git push - @echo "==> Committed and pushed v$(VERSION)" -endif - -# ============================================================ -# Install -# ============================================================ - -install-binaries: build-cli - @echo "==> Binaries installed:" - @echo " CLI: ~/.local/bin/napper" - @echo " CLI: $(EXT_BIN)/napper" - -clean-install-vsix: build-all - @VSIX_FILE=$$(ls -1 src/Napper.VsCode/*.vsix 2>/dev/null | head -1); \ - if [ -z "$$VSIX_FILE" ]; then \ - echo "ERROR: No VSIX file found after build"; \ - exit 1; \ - fi; \ - echo "==> Installing VSIX: $$VSIX_FILE"; \ - code --install-extension "src/Napper.VsCode/$$VSIX_FILE" --force - @echo "" - @echo "==> DONE — restart VS Code to load the new extension" +# NativeAOT publish per [CLI-AOT-MIGRATION]: a single statically-linked native +# binary per RID, zero runtime deps. The LSP (napper lsp) ships inside it. +_build_cli: + dotnet publish src/Napper.Cli/Napper.Cli.fsproj \ + -r "$(_NAP_RID)" \ + -p:PublishAot=true \ + -o "out/$(_NAP_RID)" --nologo + @$(_MKDIR) "$(_EXT_BIN)" + cp "out/$(_NAP_RID)/napper" "$(_EXT_BIN)/napper" + chmod +x "$(_EXT_BIN)/napper" + @# Verify the AOT binary honors the version contract [SWR-VERSION-CLI-OUTPUT]. + @# Glob-match the plain text output — never regex/sed over the props XML. + @ACTUAL=$$("out/$(_NAP_RID)/napper" --version); \ + case "$$ACTUAL" in \ + "napper "?*) echo " napper --version: $$ACTUAL" ;; \ + *) echo "ERROR: bad --version output: '$$ACTUAL' (expected 'napper <semver>')"; exit 1 ;; \ + esac -# ============================================================ -# Test targets -# ============================================================ +_build_extension: + cd src/Napper.VsCode && npm ci && npx webpack --mode production -test-fsharp: - @echo "=========================================" - @echo " Napper.Core Tests + Coverage" - @echo "=========================================" - mkdir -p "$(LOG_DIR)" - rm -rf "$(FSHARP_COVERAGE_DIR)" - mkdir -p "$(FSHARP_COVERAGE_DIR)" - @echo "==> Running Napper.Core tests with coverage..." - dotnet test src/Napper.Core.Tests --nologo \ - --settings src/Napper.Core.Tests/coverage.runsettings \ - --results-directory "$(FSHARP_COVERAGE_DIR)/raw" \ - --logger "console;verbosity=detailed" \ - -- RunConfiguration.FailFastEnabled=true 2>&1 | tee "$(LOG_DIR)/test-fsharp-core.log" - @echo "==> Generating Napper.Core coverage report..." - reportgenerator \ - -reports:"$(FSHARP_COVERAGE_DIR)/raw/*/coverage.cobertura.xml" \ - -targetdir:"$(FSHARP_COVERAGE_DIR)/report" \ - -reporttypes:"Html;TextSummary;Cobertura;lcov" - @echo "" - @echo "=== Napper.Core Coverage Summary ===" - @cat "$(FSHARP_COVERAGE_DIR)/report/Summary.txt" - @echo "" - @echo "=========================================" - @echo " DotHttp Tests + Coverage" - @echo "=========================================" - rm -rf "$(DOTHTTP_COVERAGE_DIR)" - mkdir -p "$(DOTHTTP_COVERAGE_DIR)" - @echo "==> Running DotHttp tests with coverage..." - dotnet test src/DotHttp.Tests --nologo \ - --settings src/DotHttp.Tests/coverage.runsettings \ - --results-directory "$(DOTHTTP_COVERAGE_DIR)/raw" \ - --logger "console;verbosity=detailed" \ - -- RunConfiguration.FailFastEnabled=true 2>&1 | tee "$(LOG_DIR)/test-dothttp.log" - @echo "==> Generating DotHttp coverage report..." - reportgenerator \ - -reports:"$(DOTHTTP_COVERAGE_DIR)/raw/*/coverage.cobertura.xml" \ - -targetdir:"$(DOTHTTP_COVERAGE_DIR)/report" \ - -reporttypes:"Html;TextSummary;Cobertura;lcov" - @echo "" - @echo "=== DotHttp Coverage Summary ===" - @cat "$(DOTHTTP_COVERAGE_DIR)/report/Summary.txt" - @echo "" - @echo "=========================================" - @echo " Napper.Lsp Tests + Coverage" - @echo "=========================================" - rm -rf "$(LSP_COVERAGE_DIR)" - mkdir -p "$(LSP_COVERAGE_DIR)" - @echo "==> Running Napper.Lsp tests with coverage..." - dotnet test src/Napper.Lsp.Tests --nologo \ - --settings src/Napper.Lsp.Tests/coverage.runsettings \ - --results-directory "$(LSP_COVERAGE_DIR)/raw" \ - --logger "console;verbosity=detailed" \ - -- RunConfiguration.FailFastEnabled=true 2>&1 | tee "$(LOG_DIR)/test-lsp.log" - @echo "==> Generating Napper.Lsp coverage report..." - reportgenerator \ - -reports:"$(LSP_COVERAGE_DIR)/raw/*/coverage.cobertura.xml" \ - -targetdir:"$(LSP_COVERAGE_DIR)/report" \ - -reporttypes:"Html;TextSummary;Cobertura;lcov" - @echo "" - @echo "=== Napper.Lsp Coverage Summary ===" - @cat "$(LSP_COVERAGE_DIR)/report/Summary.txt" +_test_fsharp: + $(_MKDIR) "$(_LOG_DIR)" + $(call _dotnet_test,src/Napper.Core.Tests,$(_FSHARP_COV),test-fsharp-core) + $(call _dotnet_test,src/DotHttp.Tests,$(_DOTHTTP_COV),test-dothttp) + $(call _dotnet_test,src/Napper.Lsp.Tests,$(_LSP_COV),test-lsp) -test-rust: - @echo "=========================================" - @echo " Rust Tests + Coverage (Napper.Zed)" - @echo "=========================================" - mkdir -p "$(LOG_DIR)" - rm -rf "$(RUST_COVERAGE_DIR)" - mkdir -p "$(RUST_COVERAGE_DIR)" - @echo "==> Running Rust checks..." - cargo fmt --manifest-path src/Napper.Zed/Cargo.toml -- --check 2>&1 | tee "$(LOG_DIR)/test-rust-fmt.log" - cargo clippy --manifest-path src/Napper.Zed/Cargo.toml 2>&1 | tee "$(LOG_DIR)/test-rust-clippy.log" - @echo "==> Running Rust tests with coverage..." - cd src/Napper.Zed && cargo tarpaulin --out html lcov xml --output-dir "../../$(RUST_COVERAGE_DIR)/report" --skip-clean 2>&1 | tee "../../$(LOG_DIR)/test-rust.log" - @echo "" - @echo "=== Rust Coverage Summary ===" - @LINE_RATE=$$(sed -n 's/.*line-rate="\([0-9.]*\)".*/\1/p' "$(RUST_COVERAGE_DIR)/report/cobertura.xml" 2>/dev/null | head -1); \ - LINE_RATE=$${LINE_RATE:-0}; \ - echo " Line coverage: $$(echo "$$LINE_RATE * 100" | bc -l | xargs printf "%.1f")%" +_test_rust: + $(_MKDIR) "$(_LOG_DIR)" "$(_RUST_COV)" + cd src/Napper.Zed && cargo tarpaulin \ + --out html lcov xml \ + --output-dir "../../$(_RUST_COV)/report" \ + --skip-clean 2>&1 | tee "../../$(_LOG_DIR)/test-rust.log" -test-vsix: build-cli build-extension - @echo "=========================================" - @echo " TypeScript Tests + Coverage" - @echo "=========================================" - mkdir -p "$(LOG_DIR)" - rm -rf "$(TS_COVERAGE_DIR)" - mkdir -p "$(TS_COVERAGE_DIR)" +_test_vsix: _build_cli _build_extension + $(_MKDIR) "$(_LOG_DIR)" "$(_TS_COV)" cd src/Napper.VsCode && npm run compile && npm run compile:tests - @echo "==> Running unit tests..." - cd src/Napper.VsCode && NODE_V8_COVERAGE="../../$(TS_COVERAGE_DIR)/tmp" \ - npx mocha out/test/unit/**/*.test.js --ui tdd --timeout 5000 2>&1 | tee "../../$(LOG_DIR)/test-vsix-unit.log" - @echo "==> Running e2e tests..." - cd src/Napper.VsCode && NODE_V8_COVERAGE="../../$(TS_COVERAGE_DIR)/tmp" \ - npx vscode-test 2>&1 | tee "../../$(LOG_DIR)/test-vsix-e2e.log" - @echo "==> Generating combined TypeScript coverage report..." + cd src/Napper.VsCode && NODE_V8_COVERAGE="../../$(_TS_COV)/tmp" \ + npx mocha out/test/unit/**/*.test.js --ui tdd --timeout 5000 \ + 2>&1 | tee "../../$(_LOG_DIR)/test-vsix-unit.log" + cd src/Napper.VsCode && NODE_V8_COVERAGE="../../$(_TS_COV)/tmp" \ + npx vscode-test 2>&1 | tee "../../$(_LOG_DIR)/test-vsix-e2e.log" cd src/Napper.VsCode && npx c8 report \ - --temp-directory "../../$(TS_COVERAGE_DIR)/tmp" \ - --report-dir "../../$(TS_COVERAGE_DIR)/report" \ - --reporter html --reporter text --reporter lcov 2>&1 | tee "../../$(LOG_DIR)/test-vsix-coverage.log" - -test: test-fsharp test-rust test-vsix - @echo "" - @echo "=========================================" - @echo " Coverage Reports" - @echo "=========================================" - @echo " Napper.Core: $(FSHARP_COVERAGE_DIR)/report/index.html" - @echo " DotHttp: $(DOTHTTP_COVERAGE_DIR)/report/index.html" - @echo " Rust: $(RUST_COVERAGE_DIR)/report/index.html" - @echo " TypeScript: $(TS_COVERAGE_DIR)/report/index.html" - @echo "=========================================" - -# ============================================================ -# Format & Lint -# ============================================================ - -format: - @echo "==> F# (Fantomas)..." - dotnet fantomas src/ - @echo "==> TypeScript (Prettier)..." - cd src/Napper.VsCode && npx prettier --write "src/**/*.ts" - @echo "==> Rust (cargo fmt)..." - cargo fmt --manifest-path src/Napper.Zed/Cargo.toml - @echo "==> All projects formatted" - -lint: - @echo "==> F# build (warnings as errors)..." - dotnet build --nologo -warnaserror - @echo "==> TypeScript (ESLint)..." - cd src/Napper.VsCode && npm run lint - @echo "==> Rust (clippy)..." - cargo clippy --manifest-path src/Napper.Zed/Cargo.toml - @echo "==> All projects linted" - -# ============================================================ -# Docs -# ============================================================ + --temp-directory "../../$(_TS_COV)/tmp" \ + --report-dir "../../$(_TS_COV)/report" \ + --reporter html --reporter text --reporter lcov \ + 2>&1 | tee "../../$(_LOG_DIR)/test-vsix-coverage.log" -dump-cli-help: - @CLI_PATH=$$(command -v napper 2>/dev/null || true); \ - if [ -z "$$CLI_PATH" ]; then \ - echo "napper not found on PATH — building first..."; \ - $(MAKE) build-cli; \ - CLI_PATH="$(HOME)/.local/bin/napper"; \ - fi; \ - echo "==> Capturing CLI help output from $$CLI_PATH..."; \ - HELP_OUTPUT=$$($$CLI_PATH help 2>&1); \ - mkdir -p docs; \ - { \ - echo '# Nap CLI Reference'; \ - echo ''; \ - echo '> Auto-generated from `nap help`. Run `make dump-cli-help` to regenerate.'; \ - echo ''; \ - echo '## Help Output'; \ - echo ''; \ - echo '```'; \ - echo "$$HELP_OUTPUT"; \ - echo '```'; \ - echo ''; \ - echo '## Commands'; \ - echo ''; \ - echo '### `nap run <file|folder>`'; \ - echo ''; \ - echo 'Run a `.nap` file, `.naplist` playlist, or an entire folder of requests.'; \ - echo ''; \ - echo '```sh'; \ - echo '# Single request'; \ - echo 'nap run ./users/get-user.nap'; \ - echo ''; \ - echo '# With variable overrides'; \ - echo 'nap run ./users/get-user.nap --var userId=99'; \ - echo ''; \ - echo '# Run all .nap files in a folder (sorted by filename)'; \ - echo 'nap run ./users/'; \ - echo ''; \ - echo '# Run a playlist'; \ - echo 'nap run ./smoke.naplist'; \ - echo ''; \ - echo '# With a named environment'; \ - echo 'nap run ./smoke.naplist --env staging'; \ - echo ''; \ - echo '# Output as JUnit XML (for CI)'; \ - echo 'nap run ./smoke.naplist --output junit'; \ - echo ''; \ - echo '# Output as JSON'; \ - echo 'nap run ./smoke.naplist --output json'; \ - echo '```'; \ - echo ''; \ - echo '### `nap check <file>`'; \ - echo ''; \ - echo 'Validate the syntax of a `.nap` or `.naplist` file without executing it.'; \ - echo ''; \ - echo '```sh'; \ - echo 'nap check ./users/get-user.nap'; \ - echo 'nap check ./smoke.naplist'; \ - echo '```'; \ - echo ''; \ - echo '### `nap generate openapi <spec> --output-dir <dir>`'; \ - echo ''; \ - echo 'Generate `.nap` files from an OpenAPI specification.'; \ - echo ''; \ - echo '```sh'; \ - echo 'nap generate openapi ./openapi.json --output-dir ./tests'; \ - echo 'nap generate openapi ./openapi.json --output-dir ./tests --output json'; \ - echo '```'; \ - echo ''; \ - echo '### `nap help`'; \ - echo ''; \ - echo 'Display the help message. Also available as `--help` or `-h`.'; \ - echo ''; \ - echo '## Options'; \ - echo ''; \ - echo '| Option | Description |'; \ - echo '|---------------------|---------------------------------------------------|'; \ - echo '| `--env <name>` | Load a named environment file (`.napenv.<name>`) |'; \ - echo '| `--var <key=value>` | Override a variable (repeatable) |'; \ - echo '| `--output <format>` | Output format: `pretty` (default), `junit`, `json`, `ndjson` |'; \ - echo '| `--output-dir <dir>`| Output directory for generate command |'; \ - echo '| `--verbose` | Enable debug-level logging |'; \ - echo ''; \ - echo '## Exit Codes'; \ - echo ''; \ - echo '| Code | Meaning |'; \ - echo '|------|--------------------------------------------------|'; \ - echo '| 0 | All assertions passed |'; \ - echo '| 1 | One or more assertions failed |'; \ - echo '| 2 | Runtime error (network, script error, parse error) |'; \ - } > docs/cli-reference.md; \ - echo "==> Written to docs/cli-reference.md" +_coverage_check: + @echo "==> Coverage check..." + $(call _cov_check,src/Napper.Core.Tests,$(_FSHARP_COV)/report/Summary.txt,Napper.Core) + $(call _cov_check,src/DotHttp.Tests,$(_DOTHTTP_COV)/report/Summary.txt,DotHttp) + $(call _cov_check,src/Napper.Lsp.Tests,$(_LSP_COV)/report/Summary.txt,Napper.Lsp) + @{ \ + t=$$(jq -r '.projects["src/Napper.Zed"].threshold // .default_threshold' coverage-thresholds.json); \ + if [ -f "$(_RUST_COV)/report/cobertura.xml" ]; then \ + lr=$$(sed -n 's/.*line-rate="\([0-9.]*\)".*/\1/p' "$(_RUST_COV)/report/cobertura.xml" | head -1); \ + c=$$(echo "$${lr:-0} * 100" | bc -l | xargs printf "%.1f"); \ + echo " Rust: $${c}% (threshold $${t}%)"; \ + if [ $$(echo "$${c} < $${t}" | bc -l) -eq 1 ]; then \ + echo " *** FAIL: Rust coverage $${c}% is below threshold $${t}% — ABORTING ***"; \ + exit 1; \ + fi; \ + new_t=$$(( $$(echo "scale=0; $${c}/1" | bc) - 1 )); \ + if [ $$(echo "$${new_t} > $${t}" | bc -l) -eq 1 ]; then \ + tmp=$$(mktemp); \ + jq --argjson nt "$${new_t}" '.projects["src/Napper.Zed"].threshold = $$nt' coverage-thresholds.json > "$${tmp}" && mv "$${tmp}" coverage-thresholds.json; \ + echo " RATCHET: Rust threshold -> $${new_t}%"; \ + else \ + echo " OK"; \ + fi; \ + else echo " Rust: no data (skipping)"; fi; \ + } + @{ \ + t=$$(jq -r '.projects["src/Napper.VsCode"].threshold // .default_threshold' coverage-thresholds.json); \ + if [ -f "$(_TS_COV)/report/index.html" ]; then \ + c=$$(cd src/Napper.VsCode && npx c8 report --reporter text 2>/dev/null | grep 'All files' | awk '{print $$4}' | tr -d '%' || echo "0"); \ + echo " TypeScript: $${c}% (threshold $${t}%)"; \ + if [ $$(echo "$${c} < $${t}" | bc -l) -eq 1 ]; then \ + echo " *** FAIL: TypeScript coverage $${c}% is below threshold $${t}% — ABORTING ***"; \ + exit 1; \ + fi; \ + new_t=$$(( $$(echo "scale=0; $${c}/1" | bc) - 1 )); \ + if [ $$(echo "$${new_t} > $${t}" | bc -l) -eq 1 ]; then \ + tmp=$$(mktemp); \ + jq --argjson nt "$${new_t}" '.projects["src/Napper.VsCode"].threshold = $$nt' coverage-thresholds.json > "$${tmp}" && mv "$${tmp}" coverage-thresholds.json; \ + echo " RATCHET: TypeScript threshold -> $${new_t}%"; \ + else \ + echo " OK"; \ + fi; \ + else echo " TypeScript: no data (skipping)"; fi; \ + } + @echo "==> Coverage OK" diff --git a/README.md b/README.md index e7e74a3..2dabb82 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,16 @@ <p align="center"> <strong>API Testing, Supercharged.</strong><br> - Napper is a free, open-source API testing tool that runs from the command line and integrates natively with VS Code. + Napper is a free, open-source API testing tool for anyone testing APIs. It runs from the command line and edits natively in VS Code, Zed, and any editor via a portable language server. Define HTTP requests as plain text <code>.nap</code> files, add declarative assertions, chain them into test suites, and run everything in CI/CD with JUnit output. - As simple as curl for quick requests. As powerful as F# and C# for full test suites. + As simple as curl for quick requests. As powerful as your own code — script in JavaScript, Python, F#, or C#. </p> <p align="center"> <a href="https://marketplace.visualstudio.com/items?itemName=Nimblesite.napper">VS Code Marketplace</a> · <a href="https://napperapi.dev">Website</a> · <a href="https://napperapi.dev/docs/">Documentation</a> · - <a href="https://github.com/MelbourneDeveloper/napper/releases">Releases</a> + <a href="https://github.com/Nimblesite/napper/releases">Releases</a> </p> --- @@ -30,9 +30,9 @@ Everything you need for API testing. Nothing you don't. -- **CLI First** (`cli-run`) — The command line is the product. Run requests, execute test suites, and integrate with CI/CD pipelines from your terminal. -- **VS Code Native** (`vscode-extension`) — Full extension with syntax highlighting (`vscode-syntax`), request explorer (`vscode-explorer`), environment switching (`vscode-env-switcher`), and Test Explorer integration (`vscode-test-explorer`). Never leave your editor. -- **F# and C# Scripting** (`script-fsx`, `script-csx`) — Full power of F# and C# for pre/post request hooks. Extract tokens, build dynamic payloads, orchestrate complex flows with the entire .NET ecosystem. +- **CLI First** (`cli-run`) — The command line is the product. Run requests, execute test suites, and integrate with CI/CD pipelines from your terminal. Napper ships as a self-contained **native binary** — not a .NET DLL — with zero runtime dependencies. +- **Editor-Native, LSP-Powered** (`vscode-extension`, `lsp`) — First-class extensions for VS Code and Zed, plus a portable language server that brings completions, diagnostics, and hover to any editor. Syntax highlighting (`vscode-syntax`), request explorer (`vscode-explorer`), environment switching (`vscode-env-switcher`), and Test Explorer integration (`vscode-test-explorer`). Never leave your editor. +- **Script in Any Language** (`script-js`, `script-py`, `script-fsx`, `script-csx`) — Write pre/post hooks and orchestration in JavaScript, Python, F#, or C# — whatever your team already runs. Real runtimes (Node.js, Python 3, .NET), full ecosystem access (npm, PyPI, NuGet), no sandbox. `.fsx` and `.csx` are genuinely lovely, but never required. - **Declarative Assertions** (`nap-assert`) — Assert on status codes (`assert-status`), JSON paths (`assert-equals`, `assert-exists`), headers (`assert-contains`), and response times (`assert-lt`) with a clean, readable syntax. No scripting required for simple checks. - **Composable Playlists** (`naplist-file`) — Chain requests into test suites with `.naplist` files. Nest playlists (`naplist-nested`), reference folders (`naplist-folder-step`), pass variables between steps (`naplist-var-scope`). - **OpenAPI Import** (`openapi-generate`) — Generate test files from any OpenAPI spec. Point it at a file, and Napper creates `.nap` files with requests, headers, bodies, and assertions. Optionally enhance with AI via GitHub Copilot (`vscode-openapi-ai`). @@ -60,10 +60,10 @@ The CLI is a self-contained binary with **no runtime dependencies**. | Platform | Download | |----------|----------| -| macOS (Apple Silicon) | [`napper-osx-arm64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-osx-arm64) | -| macOS (Intel) | [`napper-osx-x64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-osx-x64) | -| Linux (x64) | [`napper-linux-x64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64) | -| Windows (x64) | [`napper-win-x64.exe`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-win-x64.exe) | +| macOS (Apple Silicon) | [`napper-osx-arm64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-osx-arm64) | +| macOS (Intel) | [`napper-osx-x64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-osx-x64) | +| Linux (x64) | [`napper-linux-x64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64) | +| Windows (x64) | [`napper-win-x64.exe`](https://github.com/Nimblesite/napper/releases/latest/download/napper-win-x64.exe) | **macOS / Linux:** ```sh @@ -74,20 +74,20 @@ napper --version **Install script (macOS / Linux):** ```sh -curl -fsSL https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.sh | bash ``` **Install script (Windows PowerShell):** ```powershell -irm https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.ps1 | iex +irm https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.ps1 | iex ``` **Build from source** (requires .NET SDK + `make`): ```sh -git clone https://github.com/MelbourneDeveloper/napper.git && cd napper && make install-binaries +git clone https://github.com/Nimblesite/napper.git && cd napper && make install-binaries ``` -> **Note:** F# (`.fsx`) and C# (`.csx`) script hooks require the [.NET 10 SDK](https://dotnet.microsoft.com/download). Plain `.nap` and `.naplist` files need nothing extra. +> **Note:** Script hooks need a runtime only for the language you write in — JavaScript (`.js`) needs [Node.js 18+](https://nodejs.org/), Python (`.py`) needs [Python 3.9+](https://www.python.org/downloads/), and F# (`.fsx`) / C# (`.csx`) need the [.NET 10 SDK](https://dotnet.microsoft.com/download). Plain `.nap` and `.naplist` files need nothing extra. The JS and Python SDKs are bundled — no `npm install` / `pip install` required. See the [full installation guide](https://napperapi.dev/docs/installation/) for VSIX manual install, troubleshooting, and macOS Gatekeeper notes. @@ -181,6 +181,8 @@ napper run ./tests/ --env staging --output junit | `.napenv` | `env-base` | Environment variables (base config, checked into git) | `.napenv` | | `.napenv.local` | `env-local` | Local secrets (gitignored) | `.napenv.local` | | `.napenv.<name>` | `env-named` | Named environment | `.napenv.staging` | +| `.js` / `.mjs` | `script-js` | JavaScript scripts (Node.js) for pre/post hooks and orchestration | `setup.js` | +| `.py` | `script-py` | Python scripts (Python 3) for pre/post hooks and orchestration | `setup.py` | | `.fsx` | `script-fsx` | F# scripts for pre/post hooks and orchestration | `setup.fsx` | | `.csx` | `script-csx` | C# scripts for pre/post hooks and orchestration | `setup.csx` | @@ -315,11 +317,11 @@ Options: | Feature | Napper | Postman | Bruno | .http files | |---------|--------|---------|-------|-------------| | CLI-first design | Yes | No | GUI-first | No CLI | -| VS Code integration | Native | Separate app | Separate app | Built-in | +| Editor integration | VS Code, Zed & LSP | Separate app | Separate app | VS Code only | | Git-friendly files | Yes | JSON blobs | Yes | Yes | | OpenAPI import | URL + file + AI | Import only | Import only | No | | Assertions | Declarative + scripts | JS scripts | JS scripts | None | -| Full scripting language | F# + C# (.fsx/.csx) | Sandboxed JS | Sandboxed JS | None | +| Full scripting language | JS, Python, F#, C# | Sandboxed JS | Sandboxed JS | None | | CI/CD output formats | JUnit, JSON, NDJSON | Via Newman | Via CLI | None | | Test Explorer | Native | No | No | No | | Free & open source | Yes | Freemium | Yes | Yes | diff --git a/coverage-thresholds.json b/coverage-thresholds.json new file mode 100644 index 0000000..67d3b12 --- /dev/null +++ b/coverage-thresholds.json @@ -0,0 +1,25 @@ +{ + "_agent_pmo": "74cf183", + "_doc": "Single source of truth for code coverage thresholds. See REPO-STANDARDS-SPEC.md §3.3 [COVERAGE-THRESHOLDS-JSON]. NO GitHub repo variables. NO env vars. This file is read by the internal `_coverage_check` recipe inside `make test`. `make test` exits non-zero if measured coverage < threshold. Thresholds are monotonically increasing — only ratchet UP, never down.", + "default_threshold": 80, + "projects": { + "src/Napper.Core.Tests": { + "threshold": 84, + "include": "[Napper.Core]*" + }, + "src/DotHttp.Tests": { + "threshold": 89, + "include": "[DotHttp]*" + }, + "src/Napper.Lsp.Tests": { + "threshold": 99, + "include": "[Napper.Lsp]*" + }, + "src/Napper.VsCode": { + "threshold": 98 + }, + "src/Napper.Zed": { + "threshold": 80 + } + } +} diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..a2b54ec --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8" ?> +<RunSettings> + <DataCollectionRunSettings> + <DataCollectors> + <DataCollector friendlyName="XPlat Code Coverage"> + <Configuration> + <Format>json,lcov,opencover,cobertura</Format> + <Exclude>[*]*.Generated*,[*]*.g.*</Exclude> + <ExcludeByFile>**/obj/**/*,**/bin/**/*,**/Migrations/**/*</ExcludeByFile> + <ExcludeByAttribute> + DebuggerNonUserCode,DebuggerHidden,EditorBrowsable, + ExcludeFromCodeCoverage,GeneratorAttribute,CompilerGenerated + </ExcludeByAttribute> + <SkipAutoProps>true</SkipAutoProps> + <DeterministicReport>true</DeterministicReport> + </Configuration> + </DataCollector> + </DataCollectors> + </DataCollectionRunSettings> +</RunSettings> diff --git a/specs/CLI-PLAN.md b/docs/plans/CLI-PLAN.md similarity index 67% rename from specs/CLI-PLAN.md rename to docs/plans/CLI-PLAN.md index c266a84..ccb2881 100644 --- a/specs/CLI-PLAN.md +++ b/docs/plans/CLI-PLAN.md @@ -77,12 +77,13 @@ nap/ ### Phase 4 — Polish & Distribution -- **NuGet package for `dotnet tool install` (PRIMARY channel)** — set `<PackAsTool>true</PackAsTool>` and `<ToolCommandName>napper</ToolCommandName>` in `Nap.Cli.fsproj`, publish to nuget.org. This is the primary distribution method — no code signing needed, no SmartScreen warnings on Windows, immediate availability. The VSIX extension auto-installs via `dotnet tool install -g napper --version X.X.X`. -- Standalone native binary (NativeAOT or single-file publish) — secondary channel for users without .NET SDK -- Homebrew formula -- Winget / Chocolatey / Scoop packages (future) +- **Standalone NativeAOT native binary (PRIMARY channel)** — `-p:PublishAot=true`, a single statically-linked binary per RID with zero .NET runtime dependency ([`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration)). Shipped via GitHub Releases and bundled in the per-platform VSIX. +- Homebrew formula + Scoop bucket (consume the GitHub Release binary) +- `install.sh` / `install.ps1` (direct download + SHA-256 verify) +- **`dotnet tool` NuGet package (SECONDARY, optional, best-effort)** — for .NET users; the only channel that needs the .NET SDK. Published by the non-blocking `publish-nuget` job; never blocks a release. +- Winget / Chocolatey packages (future) - `nap new` scaffolding commands -- Language-extensible script runner plugin model +- Language-extensible script runner model — JavaScript & Python via the shared context protocol, see [SCRIPTING-LANGUAGES-PLAN.md](./SCRIPTING-LANGUAGES-PLAN.md) --- @@ -91,7 +92,7 @@ nap/ - **GraphQL support** — a `[request.graphql]` block with query/variables sub-keys. - **WebSocket / SSE testing** — separate request type, different assertion model. - **Mock server mode** — `nap mock ./collection/` serves a mock based on expected responses. -- **Script language plugins** — `.py`, `.js` runners as opt-in packages. +- **More script languages** — JavaScript & Python are specified and planned in [SCRIPTING-LANGUAGES-PLAN.md](./SCRIPTING-LANGUAGES-PLAN.md) (`script-js`, `script-py`); `.ts` (Deno/`tsx`) is the next candidate. - **Secret manager integration** — pull `{{token}}` from 1Password, AWS Secrets Manager, etc. at runtime. - **HTML report output** — `--output html` for a shareable test report. @@ -120,10 +121,10 @@ nap/ - [ ] `ctx.Set` for cross-step variable passing ### Phase 4 — Polish & Distribution -- [ ] `dotnet tool install` — set `PackAsTool` in fsproj, publish to nuget.org (PRIMARY) -- [ ] VSIX auto-installs CLI via `dotnet tool install -g napper --version X.X.X` -- [ ] Standalone native binary (NativeAOT or single-file publish) — secondary -- [ ] Homebrew formula -- [ ] Winget / Chocolatey / Scoop packages +- [x] Standalone native binary via **NativeAOT** (`-p:PublishAot=true`) per [`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration) — single statically-linked binary per RID, zero .NET runtime dependency. The `napper lsp` language server ships inside it (AOT-safe System.Text.Json transport, no reflection). This is the **primary** CLI distribution artifact. +- [x] VSIX bundles the per-platform native binary (`bin/${platform}/napper`); extension never resolves via `dotnet tool` +- [x] `dotnet tool` NuGet package kept as a **secondary, non-blocking** channel (`publish-nuget`) +- [x] Homebrew formula + Scoop bucket (consume the GitHub Release binary) +- [ ] Winget / Chocolatey packages - [ ] `nap new` scaffolding commands -- [ ] Language-extensible script runner plugin model +- [ ] Language-extensible script runner model — JavaScript & Python (see [SCRIPTING-LANGUAGES-PLAN.md](./SCRIPTING-LANGUAGES-PLAN.md)) diff --git a/specs/HTTP-FILES-PLAN.md b/docs/plans/HTTP-FILES-PLAN.md similarity index 100% rename from specs/HTTP-FILES-PLAN.md rename to docs/plans/HTTP-FILES-PLAN.md diff --git a/specs/IDE-EXTENSION-PLAN.md b/docs/plans/IDE-EXTENSION-PLAN.md similarity index 61% rename from specs/IDE-EXTENSION-PLAN.md rename to docs/plans/IDE-EXTENSION-PLAN.md index 5c61ad1..555a2dd 100644 --- a/specs/IDE-EXTENSION-PLAN.md +++ b/docs/plans/IDE-EXTENSION-PLAN.md @@ -19,7 +19,7 @@ ### Phase 3 — LSP Cutover -Connect the VSCode extension to `napper-lsp` via `vscode-languageclient`. The LSP itself is a separate project — see **[LSP Plan](./LSP-PLAN.md)**. +Connect the VSCode extension to the language server via `vscode-languageclient`. The language server is the **`napper lsp` subcommand** of the resolved `napper` binary — same binary the install resolver already put on PATH ([`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)). No separate discovery, no separate version pin. See **[LSP Plan](./LSP-PLAN.md)**. This phase **deletes duplicated TypeScript parsing code** and replaces it with LSP calls. After this phase, the VSIX is a thin UI shell — it renders data from the LSP, it does NOT parse `.nap` files itself. @@ -32,13 +32,13 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L - Curl generation (TS) → use `napper/curlCommand` from LSP **Wire up:** -- `vscode-languageclient` to launch `napper-lsp` over stdio +- `vscode-languageclient` configured with `command: <resolvedNapperPath>`, `args: ['lsp']` — spawn the same binary as a subprocess in LSP mode - Environment switcher (status bar + quick-pick — data from LSP `napper/environments`) - Hover, completions, diagnostics (provided by LSP) ### Phase 4 — Polish & Distribution -- **CLI installation via `dotnet tool install`** — replace raw binary download with `dotnet tool install -g napper --version X.X.X`. Version is read from the extension's own `package.json`. Eliminates Windows SmartScreen warnings and custom HTTP download code. +- **CLI install resolver** — implement [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition); delete `cliInstaller.ts`. - Split editor layout (request panel webview) - New request guided flow - OpenAPI generation command @@ -61,7 +61,7 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L ### Phase 3 — LSP Cutover - [ ] Add `vscode-languageclient` dependency -- [ ] Wire up to launch `napper-lsp` over stdio on activation +- [ ] Wire up to launch `<resolvedNapperPath> lsp` over stdio on activation (use the install resolver's resolved path; no separate LSP discovery) - [ ] Delete `extractHttpMethod` — use documentSymbol - [ ] Delete `parseMethodAndUrl` — use `napper/requestInfo` - [ ] Delete `parsePlaylistStepPaths` — use documentSymbol @@ -73,17 +73,25 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L - [ ] Run ALL existing VSIX e2e tests — must pass ### Phase 4 — Polish & Distribution -- [ ] Replace raw binary download with `dotnet tool install -g napper --version X.X.X` -- [ ] Delete custom HTTP download code (`cliInstaller.ts` download/redirect logic) + +- CLI install rewrite — **done** (Shipwright-based bundling; see [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition) and `release.yml`). + +Other Phase 4: - [ ] Split editor layout (request panel webview) - [ ] New request guided flow - [ ] OpenAPI generation command - [ ] Publish to VS Code Marketplace and Open VSX Registry +### Phase 5 — AOT collapse (blocked on [`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration)) + +- [ ] Drop steps 2–4 of [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition); replace step 5 with `brew install napper` / `scoop install napper` +- [ ] Drop the `vscode-cli-acq-pm-prompt` path + --- ## Related Specs -- [LSP Specification](./LSP-SPEC.md) — Language server capabilities +- [LSP Specification](../specs/LSP-SPEC.md) — Language server capabilities - [LSP Plan](./LSP-PLAN.md) — LSP implementation phases and TODO -- [IDE Extension Spec](./IDE-EXTENSION-SPEC.md) — Feature matrix and shared behaviour +- [IDE Extension Spec](../specs/IDE-EXTENSION-SPEC.md) — Feature matrix and shared behaviour +- [IDE Extension Spec — CLI acquisition](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition) — Shipwright-based VSIX CLI install (the install plan is complete) diff --git a/specs/LSP-PLAN.md b/docs/plans/LSP-PLAN.md similarity index 53% rename from specs/LSP-PLAN.md rename to docs/plans/LSP-PLAN.md index f639b06..2ab36d2 100644 --- a/specs/LSP-PLAN.md +++ b/docs/plans/LSP-PLAN.md @@ -1,20 +1,22 @@ # Nap Language Server — Implementation Plan -The LSP is a **thin F# project** (`Napper.Lsp`) that references `Napper.Core` directly. It contains ONLY LSP protocol adapters — all parsing, types, environment resolution, and logging come from `Napper.Core`, the same shared library used by `Napper.Cli`. **Zero duplicated domain logic. Period.** +The LSP is **a subcommand of `napper`**, not a separate binary. The F# project `Napper.Lsp` is a library (no `OutputType=Exe`, no `Program.fs`) referenced by `Napper.Cli`. When the user runs `napper lsp`, the CLI entry point hands stdio to the LSP layer. **One binary, one install, one version.** See [`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary). + +LSP handler code contains ONLY protocol adapters — all parsing, types, environment resolution, and logging come from `Napper.Core`, the same shared library used by every CLI subcommand. **Zero duplicated domain logic. Period.** --- ## ⛔️ DO NOT BREAK EXISTING FUNCTIONALITY -**The LSP is a PARALLEL project.** It does NOT touch the existing VSIX, CLI, or tests until the cutover phase. +The LSP layer is built incrementally inside the existing solution. It does NOT touch the existing VSIX, CLI subcommands, or tests except via the explicit `napper lsp` subcommand wire-up. -- **DO NOT modify any existing TypeScript files in `src/Napper.VsCode/`** -- **DO NOT modify any existing F# files in `src/Napper.Core/` or `src/Napper.Cli/`** (unless adding new public functions for LSP consumption — and those MUST NOT change existing signatures or behaviour) -- **DO NOT modify or delete any existing tests** -- **ALL existing tests MUST continue to pass at all times** -- **The cutover happens ONLY after the LSP is stable and its own tests pass** +- **DO NOT modify any existing TypeScript files in `src/Napper.VsCode/`** outside the LSP cutover phase. +- **DO NOT modify any existing F# files in `src/Napper.Core/` or `src/Napper.Cli/`** beyond (a) adding the `lsp` subcommand dispatch in `Napper.Cli/Program.fs` and (b) adding new public functions in `Napper.Core` for LSP consumption. Existing signatures and behaviour stay untouched. +- **DO NOT modify or delete any existing tests**. +- **ALL existing tests MUST continue to pass at all times**. +- **The cutover happens ONLY after the LSP layer is stable and its own tests pass**. -If you need to add a function to `Napper.Core` for the LSP, that's fine — but it's an ADDITION, not a modification. Existing code stays untouched. +If you need to add a function to `Napper.Core` for the LSP, that's fine — but it's an ADDITION, not a modification. --- @@ -24,20 +26,20 @@ The goal is to **move logic OUT of TypeScript/Rust and INTO F#**. The VSIX curre ```mermaid graph LR - subgraph "Phase 1-2: Build LSP (parallel)" - LSP[Napper.Lsp project] -->|references| CORE[Napper.Core] + subgraph "Phase 1-2: Build LSP layer (parallel)" + LSP[Napper.Lsp library] -->|references| CORE[Napper.Core] + CLI[Napper.Cli] -->|references| LSP LSPT[Napper.Lsp.Tests] -->|tests| LSP end subgraph "Existing (UNTOUCHED)" - CLI[Napper.Cli] -->|references| CORE VSIX[Napper.VsCode VSIX] TESTS[All existing tests] end subgraph "Phase 3: Cutover" - VSIX2[VSIX wires up<br/>vscode-languageclient] -->|stdio| LSP2[napper-lsp binary] - ZED[Zed extension] -->|stdio| LSP2 + VSIX2[VSIX wires up<br/>vscode-languageclient] -->|spawns 'napper lsp', stdio| NAPPER[napper binary] + ZED[Zed extension] -->|spawns 'napper lsp', stdio| NAPPER end ``` @@ -65,11 +67,11 @@ graph TB ZED_RS[Zed Rust<br/>would need same logic] --> FILES end - subgraph "AFTER: Single source of truth in LSP" - VS2[VSCode — thin UI shell] -->|LSP requests| LSP[napper-lsp F#] - ZED2[Zed — thin UI shell] -->|LSP requests| LSP - NV2[Neovim — thin UI shell] -->|LSP requests| LSP - LSP -->|calls| CORE[Napper.Core<br/>Parser.fs / Environment.fs] + subgraph "AFTER: Single source of truth in the napper binary" + VS2[VSCode — thin UI shell] -->|spawns 'napper lsp'| NAPPER[napper binary<br/>LSP subcommand] + ZED2[Zed — thin UI shell] -->|spawns 'napper lsp'| NAPPER + NV2[Neovim — thin UI shell] -->|spawns 'napper lsp'| NAPPER + NAPPER -->|calls| CORE[Napper.Core<br/>Parser.fs / Environment.fs] CORE --> FILES2[.nap / .naplist / .napenv files] end ``` @@ -78,19 +80,24 @@ graph TB ## Project Structure +`Napper.Lsp` is a **library** (no `OutputType=Exe`, no `Program.fs`) referenced by `Napper.Cli`. The single executable is `napper`. The CLI entry point dispatches `napper lsp` to a `Napper.Lsp.Server.start` function that takes over stdio. + ``` src/Napper.Lsp/ -├── Napper.Lsp.fsproj # References Napper.Core, depends on Ionide.LanguageServerProtocol -├── Client.fs # LSP client wrapper for notifications back to IDE -├── Server.fs # LSP server — lifecycle, document sync, symbols, custom requests -├── Workspace.fs # Workspace state: open documents, loaded environments -└── Program.fs # Entry point: stdio transport, server init +├── Napper.Lsp.fsproj # Library. References Napper.Core, depends on Ionide.LanguageServerProtocol +├── Client.fs # LSP client wrapper for notifications back to IDE +├── Server.fs # LSP server — lifecycle, document sync, symbols, custom requests +└── Workspace.fs # Workspace state: open documents, loaded environments + +src/Napper.Cli/ +├── Napper.Cli.fsproj # References Napper.Core AND Napper.Lsp +└── Program.fs # Entry point. 'napper lsp' subcommand calls Napper.Lsp.Server.start ``` ```mermaid graph TD - PROGRAM[Program.fs<br/>Entry point + stdio] --> SERVER[Server.fs<br/>Lifecycle + handlers] - SERVER --> WS[Workspace.fs<br/>Docs + env state] + PROGRAM["Napper.Cli Program.fs<br/>napper lsp subcommand"] --> SERVER[Napper.Lsp.Server<br/>Lifecycle + handlers] + SERVER --> WS[Napper.Lsp.Workspace<br/>Docs + env state] WS --> CORE_P[Napper.Core.Parser] WS --> CORE_E[Napper.Core.Environment] @@ -102,7 +109,7 @@ graph TD ## ⚠️ Code Sharing with Napper.Core — MANDATORY -**`Napper.Lsp` contains ONLY LSP protocol glue.** All domain logic lives in `Napper.Core` and is shared with `Napper.Cli`. If the LSP needs a capability that doesn't exist in `Napper.Core` yet, ADD IT TO `Napper.Core` — do NOT put it in `Napper.Lsp`. This is non-negotiable. +**`Napper.Lsp` contains ONLY LSP protocol glue.** All domain logic lives in `Napper.Core` and is shared with every CLI subcommand. If the LSP needs a capability that doesn't exist in `Napper.Core` yet, ADD IT TO `Napper.Core` — do NOT put it in `Napper.Lsp`. This is non-negotiable. The rule is simple: **if it's not LSP protocol code, it goes in `Napper.Core`.** @@ -114,6 +121,7 @@ Examples of what belongs where: - Generating a curl command → `Napper.Core` (add new module) - Listing environment names → `Napper.Core.Environment` (add new function) - Formatting an LSP CompletionItem → `Napper.Lsp` (protocol glue) +- Dispatching `napper lsp` subcommand → `Napper.Cli/Program.fs` (CLI glue) | Napper.Core Module | LSP Usage | |-------------------|-----------| @@ -131,16 +139,15 @@ Examples of what belongs where: ## Implementation Phases -### Phase 1 — Project Scaffold + Document Sync +### Phase 1 — Library Scaffold + Document Sync -Set up the F# project, wire up JSON-RPC over stdio, and implement document synchronization. **No existing code is modified.** +Set up the F# library project, wire up JSON-RPC over stdio, and implement document synchronization. **No existing code is modified except adding the `Napper.Lsp` project to `Napper.slnx`.** -- Create `Napper.Lsp.fsproj` referencing `Napper.Core` and `Ionide.LanguageServerProtocol` +- Create `Napper.Lsp.fsproj` as a **library** (`<OutputType>` removed) referencing `Napper.Core` and `Ionide.LanguageServerProtocol` - Add project to `Napper.slnx` -- Implement `Program.fs` — stdio transport, server lifecycle -- Implement `Server.fs` — `initialize`/`initialized`/`shutdown` handlers, capability advertisement +- Implement `Server.fs` — `initialize`/`initialized`/`shutdown` handlers, capability advertisement, exposed as `Server.start : Stream -> Stream -> int` - Implement `Workspace.fs` — in-memory document store (`didOpen`, `didChange`, `didClose`) -- Verify the server starts, handshakes, and tracks open documents +- Verify the library builds; integration tests in `Napper.Lsp.Tests` drive `Server.start` directly with in-process pipes ### Phase 2 — Shared Features + Tests @@ -155,11 +162,11 @@ Build the LSP features that REPLACE duplicated TypeScript/Rust logic. These are - `napper/environments` — scan workspace for `.napenv.*` files, return list of environment names - `napper/curlCommand` — given a `.nap` file URI, return the curl command string -**Napper.Core additions** (shared with CLI): +**Napper.Core additions** (shared with every CLI subcommand): - `Environment.detectEnvironmentNames` — scan a directory for `.napenv.*` files and return env names - `CurlGenerator.toCurl` — generate curl string from a `NapRequest` -**Tests** — every test launches the real `napper-lsp` binary and talks JSON-RPC over stdio: +**Tests** — every test runs `Napper.Lsp.Server.start` against in-process pipes (or shells out to `napper lsp` once Phase 2.5 lands) and talks JSON-RPC: - All Phase 1 lifecycle tests (already done) - Test: `textDocument/documentSymbol` returns sections for valid `.nap` file - Test: `textDocument/documentSymbol` returns sections for valid `.naplist` file @@ -169,13 +176,25 @@ Build the LSP features that REPLACE duplicated TypeScript/Rust logic. These are - **ALL existing F# tests still pass** - **ALL existing VSIX e2e tests still pass** +### Phase 2.5 — `napper lsp` Subcommand + +Wire the LSP layer into the CLI entry point so `napper lsp` is a real command users (and IDE extensions) can launch. + +- `Napper.Cli.fsproj` adds a project reference to `Napper.Lsp` +- `Napper.Cli/Program.fs` matches `lsp` as a subcommand, calls `Napper.Lsp.Server.start (Console.OpenStandardInput()) (Console.OpenStandardOutput())` +- `Napper.Cli` MUST NOT print anything to stdout when `lsp` is the active subcommand — every log line goes to stderr or to a file. The CLI's banner / `--verbose` output is suppressed for `lsp`. +- `napper help` lists `lsp` as a valid subcommand: `napper lsp Run the language server (LSP 3.17 over stdio)` +- A `Napper.Cli.Tests` integration test spawns `napper lsp` as a subprocess, sends an `initialize` request over stdin, and asserts the `initialize` response on stdout +- **Delete `Napper.Lsp/Program.fs`** if it still exists from earlier scaffolding +- **Delete the `napper-lsp` `AssemblyName` and `OutputType=Exe`** from `Napper.Lsp.fsproj` + ### Phase 3 — Cutover (VSIX + Zed Wire Up) -**Only after Phase 2 is complete and all tests pass.** +**Only after Phase 2.5 is complete and all tests pass.** - Add `vscode-languageclient` dependency to VSIX -- Wire up VSIX to launch `napper-lsp` over stdio on activation -- Zed extension: implement `language_server_command` in `lib.rs` to launch `napper-lsp` +- Wire up VSIX to launch `<resolved-napper-path> lsp` over stdio on activation. The resolved path comes from [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition); no separate LSP discovery +- Zed extension: implement `language_server_command` in `lib.rs` to launch `napper lsp` - **DELETE** duplicated TypeScript parsing code (`extractHttpMethod`, `parseMethodAndUrl`, `parsePlaylistStepPaths`, `detectEnvironments`) — replace with LSP calls - Verify: existing VSIX features work exactly as before (now powered by LSP) - **Run ALL existing VSIX e2e tests — every single one must pass** @@ -191,7 +210,7 @@ These are genuinely NEW capabilities that don't exist in any IDE today. - Configuration — `workspace/didChangeConfiguration` for environment name and mask settings - File watching — `.napenv` changes trigger revalidation -Each feature gets its own LSP integration tests (same approach: real binary, real JSON-RPC, real assertions). +Each feature gets its own LSP integration tests (same approach: real `napper lsp` subprocess, real JSON-RPC, real assertions). --- @@ -200,7 +219,7 @@ Each feature gets its own LSP integration tests (same approach: real binary, rea **No unit tests. No mocks. LSP integration tests ONLY.** Every test: -1. Launches the `napper-lsp` binary as a subprocess +1. Spawns `napper lsp` as a subprocess (or, in early Phase 1/2, drives `Napper.Lsp.Server.start` directly with in-process pipes) 2. Sends LSP JSON-RPC messages over stdin (the exact same protocol VSCode/Zed use) 3. Reads LSP JSON-RPC responses from stdout 4. Asserts on the responses @@ -226,11 +245,11 @@ No other dependencies. The LSP is lightweight by design. ## TODO -### Phase 1 — Project Scaffold + Document Sync +### Phase 1 — Library Scaffold + Document Sync - [x] Create `Napper.Lsp.fsproj` with `Napper.Core` project reference - [x] Add `Ionide.LanguageServerProtocol` package reference - [x] Add `Napper.Lsp` to `Napper.slnx` -- [x] Implement `Program.fs` — stdio transport and server lifecycle +- [x] Implement `Program.fs` — stdio transport and server lifecycle (will move to `Napper.Cli/Program.fs` in Phase 2.5) - [x] Implement `Server.fs` — initialize/shutdown, capability registration - [x] Implement `Workspace.fs` — document store (didOpen/didChange/didClose) @@ -260,18 +279,43 @@ No other dependencies. The LSP is lightweight by design. - [x] Test: `napper.requestInfo` returns parsed method + URL - [x] Test: `napper.copyCurl` returns curl string - [x] Test: `napper.listEnvironments` returns env names -- [ ] Verify ALL existing F# tests pass -- [ ] Verify ALL existing VSIX e2e tests pass +- [x] Verify ALL existing F# tests pass +- [x] Verify ALL existing VSIX e2e tests pass + +### Phase 2.5 — `napper lsp` Subcommand +- [x] Convert `Napper.Lsp.fsproj` from executable to library: remove `<OutputType>Exe</OutputType>` and `napper-lsp` `<AssemblyName>` +- [x] Delete `src/Napper.Lsp/Program.fs` — logic moved to `Napper.Lsp.LspRunner.run` in `Server.fs` +- [x] Expose `Napper.Lsp.LspRunner.run : Stream -> Stream -> int` as public entry point +- [x] Add `Napper.Lsp` project reference to `src/Napper.Cli/Napper.Cli.fsproj` +- [x] Add `lsp` subcommand dispatch in `src/Napper.Cli/Program.fs` calling `LspRunner.run` +- [x] Suppress all stdout output when `lsp` subcommand active (early exit before Logger.init) +- [x] Update `napper help` to list `napper lsp` +- [x] Update `Napper.Lsp.Tests` to spawn `napper lsp` via `Napper.Cli` project ref (14/14 tests pass) +- [ ] Add a `Napper.Cli.Tests` integration test that spawns `napper lsp`, sends `initialize`, asserts response +- [ ] `napper --version` returns the same version regardless of subcommand ### Phase 3 — Cutover -- [ ] Add `vscode-languageclient` to VSIX -- [ ] Wire VSIX to launch `napper-lsp` on activation -- [ ] Wire Zed `language_server_command` to launch `napper-lsp` -- [ ] Delete duplicated TS parsing code, replace with LSP calls +- [x] Add `vscode-languageclient` to VSIX +- [x] Wire VSIX to launch `<resolvedNapperPath> lsp` on activation via `lspClient.ts:startLspClient` +- [x] Wire Zed `language_server_command` to launch `napper lsp` (finds napper on PATH) +- [x] Delete `parseMethodAndUrl` from `curlCopy.ts` — replaced by `lspClient.copyCurl` +- [x] Delete `detectEnvironments` from `environmentAdapter.ts` — replaced by `lspClient.listEnvironments` +- [x] Delete the fully-dead pre-LSP `environmentSwitcher.ts` (no imports, no tests; superseded by `environmentAdapter.ts` → `lspClient.listEnvironments`) +- [ ] Route VSCode `codeLensProvider` section detection through `textDocument/documentSymbol` (per LSP-SPEC cutover table). The active editor doc is already synced to the LSP, so this is unblocked; needs the VSIX e2e suite to land safely. +- [ ] Route VSCode `explorerProvider.extractHttpMethod` / `parsePlaylistStepPaths` through the LSP. Blocked: the explorer queries files that are not open in the editor, so this first needs the LSP `requestInfo`/document-symbol handlers (or a new `naplistSteps` command) to read unopened files from disk. - [ ] Verify existing VSIX features unchanged - [ ] Run ALL existing VSIX e2e tests — must pass - [ ] Run ALL existing F# tests — must pass +### Phase 3.5 — NativeAOT Transport (Implements [cli-aot-migration]) +- [x] Replace `Ionide.LanguageServerProtocol` + `StreamJsonRpc` + `Newtonsoft.Json` with a hand-rolled, reflection-free JSON-RPC transport in `Server.fs` (System.Text.Json DOM only). Newtonsoft's F#-union reflection crashes under NativeAOT (`FSharpUtils.GetMethodWithNonPublicFallback` NRE), so the reflection-based stack cannot ship in the AOT binary. +- [x] Delete `Client.fs` (Ionide `LspClient`); split wire constants into `Protocol.fs`; mark `Napper.Lsp` `IsAotCompatible`. +- [x] Harden the transport so a malformed frame never kills the session: JSON-object-only dispatch, null/type-safe DOM accessors, a 3-state frame reader (EOF vs skip vs body) with a body-size cap and truncation guard. +- [x] CLI publishes via `-p:PublishAot=true`; `napper lsp` runs inside the single native binary with **zero .NET runtime dependency** (verified: `otool -L` shows only system libs; runs under `env -i` with no `dotnet` on PATH). +- [x] All 14 LSP e2e tests pass against the **native AOT binary** (not just the JIT build). +- [x] **ALL distribution is AOT** — `release.yml` builds every RID with `-p:PublishAot=true` on platform-native runners (NativeAOT cannot cross-compile); `make _build_cli` is AOT; `ci.yml` runs an `aot-smoke` job that publishes the native binary and exercises `napper --version` + an `napper lsp` initialize on every PR. +- [x] Suppress only the unfixable third-party rollups `IL2104`/`IL3053` (FSharp.Core, FParsec) in `Napper.Cli.fsproj`; all first-party code stays warnings-as-errors. + ### Phase 4 — Post-Cutover: New LSP Features - [ ] Diagnostics (parse errors, unknown variables, missing blocks) - [ ] Completions (methods, headers, variables, status codes, operators) diff --git a/docs/plans/SCRIPTING-LANGUAGES-PLAN.md b/docs/plans/SCRIPTING-LANGUAGES-PLAN.md new file mode 100644 index 0000000..4c46106 --- /dev/null +++ b/docs/plans/SCRIPTING-LANGUAGES-PLAN.md @@ -0,0 +1,158 @@ +# Scripting Languages — JavaScript & Python Implementation Plan + +> Implements [`script-js`](../specs/SCRIPTING-SPEC.md#script-js), [`script-py`](../specs/SCRIPTING-SPEC.md#script-py), [`script-protocol`](../specs/SCRIPTING-SPEC.md#script-protocol), [`script-sdk`](../specs/SCRIPTING-SPEC.md#script-sdk), [`script-runtime`](../specs/SCRIPTING-SPEC.md#script-runtime), [`script-dispatch`](../specs/SCRIPTING-SPEC.md#script-dispatch). + +This plan adds **JavaScript** (`.js`/`.mjs`/`.cjs`, via Node.js) and **Python** (`.py`, via Python 3) as first-class scripting languages, alongside the existing F# (`.fsx`) and C# (`.csx`) runners. The goal: a JavaScript or Python shop can write pre/post hooks and full orchestration scripts **without ever installing .NET**, and every language sees the identical `ctx` / `nap` surface. + +--- + +## Why + +Napper is an API testing tool for *anyone* testing APIs — not only .NET developers. Today scripting is locked to `.fsx`/`.csx`, which forces a .NET SDK on every team that wants a hook. JavaScript and Python are the two most common languages among API testers, so they unlock the largest audience. `.fsx` and `.csx` remain excellent first-class options — this plan widens the door, it does not narrow it. + +--- + +## Design: one protocol, many languages + +The existing `.fsx`/`.csx` runners inject `NapContext` as **native .NET objects**. That cannot work for Node or Python. Rather than bolt on two more bespoke injection paths, we introduce a **single language-agnostic protocol** ([`script-protocol`](../specs/SCRIPTING-SPEC.md#script-protocol)) and a **thin per-language SDK** ([`script-sdk`](../specs/SCRIPTING-SPEC.md#script-sdk)) that wraps it into idiomatic `ctx`/`nap`. + +``` + ┌──────────────────────────────────────────┐ + │ Napper.Core (F#) │ + .nap / .naplist ───►│ Runner → ScriptDispatch (extension map) │ + │ │ │ + │ ├─ .fsx/.csx → native inject │ (existing) + │ └─ .js/.py → ScriptProtocol │ (new, shared) + └────────────┬─────────────────────────────┘ + │ context JSON (NAPPER_CONTEXT) + │ result JSON (NAPPER_RESULT) + │ binary path (NAPPER_BIN) + ┌────────────▼──────────┐ ┌────────────────────────┐ + │ node <script>.js │ │ python3 <script>.py │ + │ @nimblesite/napper │ │ napper (PyPI) │ + │ → ctx / nap │ │ → ctx / nap │ + └───────────────────────┘ └────────────────────────┘ +``` + +**Non-negotiable per [CLAUDE.md]:** the protocol (serialization of context in, directives out, orchestration ABI) lives **once** in `Napper.Core`. The runtime-resolution and dispatch tables live **once** in `Napper.Core`. The SDKs are the *only* per-language code, and each is a thin wrapper — no domain logic, no HTTP, no assertions, no variable scoping reimplemented per language. + +### Context in / directives out + +- Napper serializes the context (`phase`, `env`, `vars`, `request`, `response`) to a temp file; path in `NAPPER_CONTEXT` ([`script-protocol-in`](../specs/SCRIPTING-SPEC.md#script-protocol-in)). +- The SDK buffers `set`/`fail`/`log` calls and writes them to the file at `NAPPER_RESULT` on exit ([`script-protocol-out`](../specs/SCRIPTING-SPEC.md#script-protocol-out)). +- Napper merges `vars`, prints `logs`, and fails on `failed: true` / non-zero exit / uncaught exception — the **same** exit-code contract `runScript` already enforces today. + +### Orchestration ABI + +`nap.run` / `nap.runList` shell out to the Napper binary (`NAPPER_BIN`) with `--output json` and parse the result ([`script-protocol-orchestration`](../specs/SCRIPTING-SPEC.md#script-protocol-orchestration)). No IPC server, no long-lived socket — the CLI's existing machine-readable output *is* the orchestration ABI. This requires `output-json` to be stable and complete (status, headers, body, parsed json, durationMs, passed). + +--- + +## Touch points in the current code + +- [`src/Napper.Core/Runner.fs`](../../src/Napper.Core/Runner.fs) — `scriptArgs` (currently a two-branch `if csx … else fsi`) and `runScript`. These get refactored to drive a shared dispatch table and the protocol. **No regex** on paths — match on a normalized extension via a lookup, per [CLAUDE.md]. +- [`src/Napper.Core/Types.fs`](../../src/Napper.Core/Types.fs) — add the protocol DTOs (`ScriptContextDto`, `ScriptResultDto`) and a `ScriptLanguage` union. +- New `src/Napper.Core/ScriptProtocol.fs` — serialize context, deserialize result, merge into `NapResult`/vars. AOT-safe `System.Text.Json` (source-generated, no reflection — `cli-aot-migration` already forbids reflection). +- New `src/Napper.Core/ScriptRuntime.fs` — the resolver table ([`script-runtime`](../specs/SCRIPTING-SPEC.md#script-runtime)): extension → (executable, arg template, resolution chain, min version, install hint). +- New SDK packages: `sdk/js/` (`@nimblesite/napper`, npm) and `sdk/python/` (`napper`, PyPI), each bundled into the CLI publish output and exposed via `NODE_PATH` / `PYTHONPATH`. +- [`Makefile`](../../Makefile) — build/test/bundle the SDKs; wire into `make test` and the publish artifacts. + +Keep files under the F# 500 LOC / function 20 LOC limits — `ScriptProtocol.fs` and `ScriptRuntime.fs` exist precisely so `Runner.fs` does not grow. + +--- + +## Implementation phases + +### Phase 1 — Shared protocol & dispatch (Napper.Core) +- `ScriptLanguage` union + extension→language dispatch table (one source of truth). Refactor `scriptArgs`/`runScript` to consume it; existing `.fsx`/`.csx` behavior unchanged (covered by current e2e tests). +- Protocol DTOs + `ScriptProtocol.fs` (context-out, result-in, merge). Source-generated JSON for AOT. +- `ScriptRuntime.fs` resolver with actionable "runtime not found" errors (never silently skip a hook). +- Heavy logging at every step (start, resolved runtime, exit code, merged vars) per [CLAUDE.md]. + +### Phase 2 — JavaScript runner + SDK +- `node` resolution (`nap.nodePath` → `NAPPER_NODE` → `PATH`), min Node 18. +- `@nimblesite/napper`: read `NAPPER_CONTEXT`, expose `ctx` (`vars`, `request`, `response.json`, `set`, `fail`, `log`) and `nap` (`run`, `runList`, `vars`, `log`, `fail` → shell `NAPPER_BIN`). Flush to `NAPPER_RESULT` on `process.on('exit')`. Ship `.d.ts`. +- Bundle into CLI publish; set `NODE_PATH` so `import "napper"` resolves with **zero npm install**. + +### Phase 3 — Python runner + SDK +- `python3`/`python` resolution (`nap.pythonPath` → `NAPPER_PYTHON` → `PATH`), min Python 3.9. +- `napper` (PyPI): same surface as the JS SDK, Pythonic spelling (`ctx.vars["x"]`, `ctx.set(...)`). Flush to `NAPPER_RESULT` via `atexit`. Ship `.pyi` stubs. +- Bundle into CLI publish; set `PYTHONPATH` so `import napper` resolves with **zero pip install**. + +### Phase 4 — Tooling, docs, distribution +- IDE: editors pick up the bundled `.d.ts`/`.pyi` automatically for `ctx`/`nap` completion (no change to the Nap LSP — script files are owned by each language's own LSP). Document the path so users can point their editor at it. +- Publish `@nimblesite/napper` to npm and `napper` to PyPI (conveniences; the bundled copy stays authoritative and version-matched). +- Website + README: language-agnostic scripting story (see [website repositioning](#website--readme-repositioning)). +- Examples: add `.js` and `.py` hooks/orchestration to `examples/`. + +--- + +## Testing strategy + +Per [CLAUDE.md] — **e2e, black-box, through the real CLI** (separate files from unit tests; add assertions to existing suites where they already exercise scripting): + +- **Pre-hook in JS/Python** sets a var → assert a downstream `.nap` step sees it (observe via CLI output, not internal state). +- **Post-hook in JS/Python** reads `ctx.response.json`, calls `ctx.fail` → assert the run reports failure with the message and a non-zero exit code. +- **Orchestration** `.js`/`.py` as a `.naplist` step drives multiple `.nap` files in a loop → assert each request's result in CLI output. +- **Mixed-language playlist** — one `.naplist` with `.nap`, `.fsx`, `.js`, and `.py` steps all passing → assert combined JUnit/JSON output. +- **Missing runtime** — run a `.py` hook with Python absent → assert the actionable error names Python and the extension (never a silent skip). +- **Zero-install** — run JS/Python hooks in a clean environment with no `npm install`/`pip install` → assert the bundled SDK resolves. +- Add assertions to the existing `script-*` e2e suite rather than duplicating it; never remove assertions. + +--- + +## Risks & decisions + +- **Orchestration via subprocess `--output json`** (not an IPC server): simplest, language-agnostic, reuses existing CLI output. Cost: one process spawn per `nap.run`. Acceptable; revisit only if a hot loop proves it. +- **Zero-install SDK via `NODE_PATH`/`PYTHONPATH`**: a user `node_modules`/site-packages `napper` could shadow the bundled copy. Decision: bundled copy is authoritative and version-stamped; SDK exposes `napper.version` and warns on mismatch. +- **AOT**: protocol serialization must be source-generated `System.Text.Json` — no reflection, per `cli-aot-migration`. Audit before merge. +- **`.ts`** deliberately deferred (Deno / `tsx`) — one future dispatch row, out of scope here. +- **F#/C# stay native**: we do *not* force `.fsx`/`.csx` onto the protocol in this plan (no behavior change, no regression risk). The protocol is the shared north star; migrating the .NET runners onto it is a later, optional consolidation. + +--- + +## Website / README repositioning + +Tracked alongside this plan (Phase 4) because the language story is user-facing: + +- Drop "for F#/.NET developers" framing everywhere → "for anyone testing APIs." Scripting is **opt-in** and in the **language you already use**; `.fsx`/`.csx` are highlighted as genuinely nice, not required. +- Distribution: Napper ships **native binaries** (per-RID, AOT, zero runtime deps) — *not* .NET DLLs. The `dotnet tool` channel remains available as one option among Homebrew / Scoop / direct download / VSIX-bundled. +- Position Napper as a **Nimblesite IDE extension + portable LSP**, not a .NET tool. +- Add JavaScript & Python to every scripting surface: comparison tables, FAQ, feature cards, schema `featureList`, keywords, and new `/docs/javascript-scripting/` + `/docs/python-scripting/` pages. + +--- + +## TODO + +### Phase 1 — Shared protocol & dispatch (Napper.Core) +- [ ] Add `ScriptLanguage` union + extension→language dispatch table in `Napper.Core` (single source of truth) — `script-dispatch` +- [ ] Refactor `scriptArgs` / `runScript` in `Runner.fs` onto the dispatch table (no behavior change for `.fsx`/`.csx`) +- [ ] Add protocol DTOs (`ScriptContextDto`, `ScriptResultDto`) to `Types.fs` — `script-protocol` +- [ ] New `ScriptProtocol.fs` — serialize context → `NAPPER_CONTEXT`, parse `NAPPER_RESULT`, merge vars/logs/fail into `NapResult` (AOT-safe source-gen JSON) +- [ ] New `ScriptRuntime.fs` — runtime resolver table with actionable missing-runtime errors — `script-runtime` +- [ ] Heavy logging at start / resolved-runtime / exit-code / merged-vars +- [ ] e2e: existing `.fsx`/`.csx` suites still green after refactor + +### Phase 2 — JavaScript runner + SDK +- [ ] `node` resolution (`nap.nodePath` → `NAPPER_NODE` → `PATH`), min Node 18 — `script-runtime` +- [ ] `sdk/js` `@nimblesite/napper`: `ctx` + `nap`, flush to `NAPPER_RESULT` on exit, orchestration via `NAPPER_BIN --output json` +- [ ] Ship `.d.ts` type declarations +- [ ] Bundle SDK into CLI publish; set `NODE_PATH` for zero-install `import "napper"` +- [ ] e2e: JS pre-hook sets var, post-hook fails, JS orchestration loop — `script-js` + +### Phase 3 — Python runner + SDK +- [ ] `python3`/`python` resolution (`nap.pythonPath` → `NAPPER_PYTHON` → `PATH`), min Python 3.9 — `script-runtime` +- [ ] `sdk/python` `napper`: `ctx` + `nap`, flush via `atexit`, orchestration via `NAPPER_BIN --output json` +- [ ] Ship `.pyi` stubs +- [ ] Bundle SDK into CLI publish; set `PYTHONPATH` for zero-install `import napper` +- [ ] e2e: Python pre-hook sets var, post-hook fails, Python orchestration loop — `script-py` + +### Phase 4 — Tooling, docs, distribution +- [ ] e2e: mixed-language `.naplist` (`.nap` + `.fsx` + `.js` + `.py`) → combined JUnit/JSON +- [ ] e2e: missing-runtime actionable error (never silent skip) +- [ ] e2e: zero-install resolution in a clean environment +- [ ] Publish `@nimblesite/napper` (npm) and `napper` (PyPI); CLI bundled copy stays authoritative + version-stamped +- [ ] `Makefile`: build/test/bundle both SDKs; wire into `make test` and publish artifacts +- [ ] `examples/`: add `.js` and `.py` hook + orchestration samples +- [ ] Website + README: language-agnostic repositioning + `/docs/javascript-scripting/` + `/docs/python-scripting/` +- [ ] Document the bundled SDK path for editor completion (`.d.ts` / `.pyi`) diff --git a/specs/ZED-EXTENSION-PLAN.md b/docs/plans/ZED-EXTENSION-PLAN.md similarity index 70% rename from specs/ZED-EXTENSION-PLAN.md rename to docs/plans/ZED-EXTENSION-PLAN.md index 80fe21a..2021b1d 100644 --- a/specs/ZED-EXTENSION-PLAN.md +++ b/docs/plans/ZED-EXTENSION-PLAN.md @@ -54,12 +54,12 @@ Build the Tree-sitter grammar for `.nap` and `.naplist` files. Write all query f ### Phase 3 — LSP Integration -The Zed extension launches `nap-lsp` (the shared F# LSP binary) via `language_server_command`. The LSP itself is a separate project — see **[LSP Spec](./LSP-SPEC.md)** and **[LSP Plan](./LSP-PLAN.md)** for details. +The Zed extension launches the language server by spawning **`napper lsp`** — the LSP is a subcommand of the `napper` CLI ([`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary)), not a separate binary. Same `napper` install gives you the LSP for free. See **[LSP Spec](../specs/LSP-SPEC.md)** and **[LSP Plan](./LSP-PLAN.md)**. -- Implement `language_server_command` in `lib.rs` to launch `nap-lsp` binary +- Implement `language_server_command` in `lib.rs` to resolve `napper` from the worktree PATH and return `{ command: <resolved napper path>, args: ["lsp"] }` - Register the language server in `extension.toml` for `.nap` and `.naplist` languages - The LSP provides completions, diagnostics, hover, symbols — no Zed-specific code needed -- Handle LSP binary discovery (check PATH, fallback to download) +- Discovery: check `PATH` for `napper`. If missing, surface a notification linking to the install guide. Zed extensions cannot install dotnet tools themselves; the user runs `dotnet tool install -g napper` (or `brew install napper`) once. ### Phase 4 — Slash Commands + Redactions @@ -81,32 +81,33 @@ The Zed extension launches `nap-lsp` (the shared F# LSP binary) via `language_se ## TODO ### Phase 1 — Tree-sitter Grammar + Syntax Highlighting -- [ ] Write `grammar.js` for `.nap` file format -- [ ] Write `grammar.js` for `.naplist` file format (or combined grammar) -- [ ] Write `highlights.scm` -- [ ] Write `brackets.scm` -- [ ] Write `outline.scm` -- [ ] Write `indents.scm` -- [ ] Write `config.toml` with language metadata -- [ ] Register grammar in `extension.toml` +- [x] Write `grammar.js` for `.nap` file format +- [x] Write `grammar.js` for `.naplist` file format +- [x] Write `grammar.js` for `.napenv` file format +- [x] Write `highlights.scm` +- [x] Write `brackets.scm` +- [x] Write `outline.scm` +- [x] Write `indents.scm` +- [x] Write `config.toml` with language metadata +- [x] Register grammar in `extension.toml` - [ ] Test highlighting matches VSCode TextMate grammar visually ### Phase 2 — Runnables -- [ ] Write `runnables.scm` to detect `[request]` blocks +- [x] Write `runnables.scm` to detect `[request]` blocks - [ ] Verify `nap run <file>` executes in Zed terminal - [ ] Add runnable label showing HTTP method + URL ### Phase 3 — LSP Integration -- [ ] Implement `language_server_command` in `lib.rs` -- [ ] Register language server in `extension.toml` +- [x] Implement `language_server_command` in `lib.rs` — uses `worktree.which("napper")` and returns `{ command: napper_path, args: ["lsp"] }` +- [x] Register language server in `extension.toml` +- [x] PATH lookup for `napper` via Zed `Worktree::which`; surfaces error with install instructions if missing - [ ] Test completions, diagnostics, hover via LSP -- [ ] Handle LSP binary discovery (PATH lookup) ### Phase 4 — Slash Commands + Redactions -- [ ] Implement `/nap-run` slash command -- [ ] Implement `/nap-import-openapi` slash command -- [ ] Implement argument completion for slash commands -- [ ] Write `redactions.scm` for `{{variable}}` masking +- [x] Implement `/nap-run` slash command +- [x] Implement `/nap-import-openapi` slash command +- [x] Implement argument completion for slash commands +- [x] Write `redactions.scm` for `{{variable}}` masking ### Phase 5 — Polish & Publishing - [ ] Test on macOS and Linux @@ -119,6 +120,6 @@ The Zed extension launches `nap-lsp` (the shared F# LSP binary) via `language_se ## Related Specs -- [LSP Specification](./LSP-SPEC.md) — Language server capabilities, architecture, and protocol details +- [LSP Specification](../specs/LSP-SPEC.md) — Language server capabilities, architecture, and protocol details - [LSP Plan](./LSP-PLAN.md) — LSP implementation phases and TODO -- [IDE Extension Spec](./IDE-EXTENSION-SPEC.md) — Feature matrix and shared/IDE-specific behaviour +- [IDE Extension Spec](../specs/IDE-EXTENSION-SPEC.md) — Feature matrix and shared/IDE-specific behaviour diff --git a/specs/CLI-OPENAPI-GENERATION.md b/docs/specs/CLI-OPENAPI-GENERATION.md similarity index 100% rename from specs/CLI-OPENAPI-GENERATION.md rename to docs/specs/CLI-OPENAPI-GENERATION.md diff --git a/docs/specs/CLI-SPEC.md b/docs/specs/CLI-SPEC.md new file mode 100644 index 0000000..4a91db2 --- /dev/null +++ b/docs/specs/CLI-SPEC.md @@ -0,0 +1,189 @@ +# Nap CLI Specification + +> **Nap** (Network API Protocol) — a CLI-first, test-oriented alternative to Postman, Bruno, `.http` files, and curl. + +--- + +## Vision + +Nap is a developer-first HTTP testing tool. It is as simple as curl for one-off requests, but scales to full test suites with reusable components, scripted assertions, and CI integration. It is not a GUI-first tool with a CLI bolted on — the CLI is the product. + +--- + +## Core Principles + +1. **Files are the source of truth.** All requests, tests, and playlists are plain files. Git-friendly by default. +2. **Simple things are simple.** A single HTTP call should look almost as terse as curl. +3. **Tests are reusable components.** A `.nap` file (`nap-file`) is a reusable unit. It can be composed into playlists (`naplist-file`) without modification. +4. **Scripting is opt-in, external, and language-agnostic.** Scripts live in standalone files referenced by name — F# (`.fsx`), C# (`.csx`), JavaScript (`.js`), or Python (`.py`) (`script-fsx`, `script-csx`, `script-js`, `script-py`). Every language sees the same `ctx`/`nap` surface (`script-protocol`). Simple assertions need no scripting at all. +5. **No lock-in.** The format is plain text. Scripts are standard files in standard languages run by their standard runtimes — no proprietary sandbox. Results emit standard formats. + +--- + +## Installation + +**The primary channels are native-binary — end users never need .NET installed.** `napper` is a +self-contained NativeAOT binary. The VS Code extension bundles the matching per-platform binary +inside the VSIX ([`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)), so +installing the extension needs no separate CLI install. CLI users pick a channel below. + +A `dotnet tool` NuGet package remains available as a **secondary, optional** channel for .NET +developers who prefer it — it is the only channel that needs the .NET SDK, and its release job is +best-effort/non-blocking. The VS Code extension never resolves the CLI via `dotnet-tool` +([SWR-IDE-RESOLUTION]); it only uses the bundled native binary. + +### `cli-install-script` — install script (macOS / Linux / Windows) + +```sh +curl -fsSL https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.sh | bash +# Windows (PowerShell): +irm https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.ps1 | iex +``` + +Downloads the native binary for the host platform from the GitHub Release and verifies its +SHA-256 against `checksums-sha256.txt`. + +### `cli-install-homebrew` — Homebrew tap (macOS / Linux) + +```sh +brew tap Nimblesite/tap && brew install napper +``` + +Tracks latest only. Published by [`update-homebrew`](../../.github/workflows/release.yml) on every release. + +### `cli-install-scoop` — Scoop bucket (Windows) + +```sh +scoop bucket add Nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install napper +``` + +Tracks latest only. Published by [`update-scoop`](../../.github/workflows/release.yml) on every release. + +### `cli-install-dotnet-tool` — dotnet tool (secondary, optional) + +```sh +dotnet tool install -g napper # latest +dotnet tool install -g napper --version 0.12.0 # exact version +dotnet tool update -g napper # update +``` + +For .NET developers who prefer it. This is the **only** channel that needs the **.NET 10 SDK**; all +other channels need no .NET. Published best-effort by the non-blocking +[`publish-nuget`](../../.github/workflows/release.yml) job — a NuGet failure never blocks a release, +and the VS Code extension never resolves the CLI this way ([SWR-IDE-RESOLUTION]). + +### `cli-runtime-dependency` — Runtime dependency + +**None.** `napper` is published with **NativeAOT** (`-p:PublishAot=true`, see +[`cli-aot-migration`](#cli-aot-migration)) as a single statically-linked native binary per RID. +End users need neither the .NET runtime nor the SDK to install or run it. .NET is a build-time +dependency only. (Script *hooks* — `.fsx`/`.csx`/`.js`/`.py` — still need their own language +runtime, but that is the script author's choice and never a dependency of `napper` itself; +see `script-runtime`.) + +### `cli-aot-migration` — NativeAOT (landed) + +`napper` ships as a NativeAOT binary (`PublishAot=true`): a single statically-linked native +binary per RID with zero runtime dependencies, ~5–10 MB, ~10 ms cold start. Primary distribution is +the native binary — Brew / Scoop / install script / VSIX-bundled. A secondary, optional `dotnet tool` +NuGet package ([`cli-install-dotnet-tool`](#cli-install-dotnet-tool)) is published best-effort for +.NET users. The VSIX install flow needs no .NET SDK prerequisite. + +**AOT constraints** (enforced): no reflection-based serialization — `printf`, quotations, and +reflection fail at publish time; all third-party deps must be AOT-compatible. Verified by the +black-box e2e suite running the real native binary, and by the release `Verify binary version +contract` step. + +Tracked in [CLI-PLAN.md](../plans/CLI-PLAN.md). + +--- + +## Usage + +### `cli-run` — Run Command + +```sh +# Run a single request (simplest case — as easy as curl) +napper run ./users/get-user.nap + +# Run a single request with inline variable override +napper run ./users/get-user.nap --var userId=99 + +# Run a collection (folder) +napper run ./users/ + +# Run a playlist +napper run ./smoke.naplist + +# Specify environment +napper run ./smoke.naplist --env staging +``` + +### `cli-check` — Validate Syntax + +```sh +# Validate syntax without running +napper check ./smoke.naplist +``` + +### `cli-generate` — Generate from OpenAPI + +```sh +# Generate .nap files from an OpenAPI spec +napper generate openapi ./petstore.json --output-dir ./petstore/ +``` + +See [CLI OpenAPI Generation](./CLI-OPENAPI-GENERATION.md) for full details. + +### `cli-lsp` — Language Server + +```sh +# Start the Nap language server (LSP 3.17 over stdio) +napper lsp +``` + +`napper lsp` runs the language server in the same process as the CLI. **The LSP and CLI are one binary** ([`lsp-one-binary`](./LSP-SPEC.md#lsp-one-binary)) — there is no separate `napper-lsp`. IDE extensions spawn `napper lsp` as a child process and communicate via JSON-RPC over stdin/stdout. While `lsp` is the active subcommand, the process MUST NOT write anything to stdout outside LSP framing — all logs go to stderr or to a file. See [LSP Specification](./LSP-SPEC.md) for capabilities and protocol details. + +--- + +## CLI Flags + +| Flag | Spec ID | Description | +|------|---------|-------------| +| `--env <name>` | `cli-env` | Load environment variables from `.napenv.<name>` (`env-named`) | +| `--var <key=value>` | `cli-var` | Override a variable (repeatable). Highest priority in `env-resolution` | +| `--output <format>` | `cli-output` | Output format: `output-pretty` (default), `output-junit`, `output-json`, `output-ndjson` | +| `--output-dir <dir>` | `cli-output-dir` | Destination directory for `cli-generate` | +| `--verbose` | `cli-verbose` | Enable debug-level logging | + +--- + +## `cli-output` — Output Formats + +| Format | Spec ID | Description | +|--------|---------|-------------| +| `pretty` | `output-pretty` | Human-readable console output with ANSI colors (default) | +| `junit` | `output-junit` | JUnit XML for CI/CD integration | +| `json` | `output-json` | Single JSON object per result | +| `ndjson` | `output-ndjson` | Newline-delimited JSON for streaming | + +--- + +## `cli-exit-codes` — Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | All assertions passed | +| 1 | One or more assertions failed | +| 2 | Runtime error (network, script error, parse error) | + +--- + +## Related Specs + +- [File Formats](./FILE-FORMATS-SPEC.md) — `.nap`, `.napenv`, `.naplist` format specifications +- [Scripting](./SCRIPTING-SPEC.md) — language-agnostic scripting model (F#, C#, JavaScript, Python), NapContext, NapRunner, the context protocol +- [CLI Plan](../plans/CLI-PLAN.md) — Parser, project layout, implementation phases +- [LSP Specification](./LSP-SPEC.md) — `napper lsp` subcommand: protocol, capabilities, transport +- [LSP Plan](../plans/LSP-PLAN.md) — LSP implementation phases (same `napper` binary) +- [OpenAPI Generation (CLI)](./CLI-OPENAPI-GENERATION.md) — Test suite generation from OpenAPI specs diff --git a/specs/FILE-FORMATS-SPEC.md b/docs/specs/FILE-FORMATS-SPEC.md similarity index 93% rename from specs/FILE-FORMATS-SPEC.md rename to docs/specs/FILE-FORMATS-SPEC.md index d7dd7a2..d6a0b1e 100644 --- a/specs/FILE-FORMATS-SPEC.md +++ b/docs/specs/FILE-FORMATS-SPEC.md @@ -67,7 +67,7 @@ post = ./scripts/validate-user.fsx # runs after the response - `assert-contains` — `headers.Content-Type contains "json"` — substring check - `assert-lt` — `duration < 500ms` — less-than comparison - `assert-gt` — `body.count > 0` — greater-than comparison -- **`[script]` block** — references external `.fsx`/`.csx` files for pre/post hooks (see `script-fsx`, `script-csx`). +- **`[script]` block** — references external script files for pre/post hooks in any supported language: F# (`.fsx`), C# (`.csx`), JavaScript (`.js`), or Python (`.py`). Dispatch is by extension (see `script-dispatch`). - `nap-comments` — Comments with `#`. #### `http-methods` — Supported HTTP Methods @@ -135,7 +135,7 @@ A `.naplist` file is an explicit ordered list of steps. Steps can reference: - `naplist-nap-step` — Individual `.nap` files (by relative path) - `naplist-folder-step` — Folders (run all `.nap` files in that folder, sorted) - `naplist-nested` — Other `.naplist` files (nested playlists — fully recursive) -- `naplist-script-step` — `.fsx` or `.csx` scripts +- `naplist-script-step` — script files in any supported language (`.fsx`, `.csx`, `.js`, `.py`) (`script-dispatch`) ### Example `smoke.naplist` diff --git a/specs/HTTP-FILES-SPEC.md b/docs/specs/HTTP-FILES-SPEC.md similarity index 100% rename from specs/HTTP-FILES-SPEC.md rename to docs/specs/HTTP-FILES-SPEC.md diff --git a/specs/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md b/docs/specs/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md similarity index 100% rename from specs/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md rename to docs/specs/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md diff --git a/specs/IDE-EXTENSION-SPEC.md b/docs/specs/IDE-EXTENSION-SPEC.md similarity index 84% rename from specs/IDE-EXTENSION-SPEC.md rename to docs/specs/IDE-EXTENSION-SPEC.md index a3d238d..692e34c 100644 --- a/specs/IDE-EXTENSION-SPEC.md +++ b/docs/specs/IDE-EXTENSION-SPEC.md @@ -26,9 +26,8 @@ graph TB NV[Neovim Plugin<br/>Lua] end - subgraph "Nap Toolchain" - LSP[nap-lsp<br/>F# binary] - CLI[nap CLI<br/>F# binary] + subgraph "Nap Toolchain (single napper binary)" + NAPPER["napper<br/>F# binary<br/>(run / check / generate / convert / lsp)"] end subgraph "Napper.Core (shared F# library)" @@ -39,35 +38,31 @@ graph TB OPENAPI[OpenApiGenerator.fs] end - VS -->|stdio / LSP| LSP - ZD -->|stdio / LSP| LSP - NV -->|stdio / LSP| LSP + VS -->|spawns 'napper lsp', stdio| NAPPER + ZD -->|spawns 'napper lsp', stdio| NAPPER + NV -->|spawns 'napper lsp', stdio| NAPPER - VS -->|shell out| CLI - ZD -->|shell out| CLI - NV -->|shell out| CLI + VS -->|spawns 'napper run', exec| NAPPER + ZD -->|spawns 'napper run', exec| NAPPER + NV -->|spawns 'napper run', exec| NAPPER - LSP --> PARSER - LSP --> TYPES - LSP --> ENV - - CLI --> PARSER - CLI --> TYPES - CLI --> ENV - CLI --> RUNNER - CLI --> OPENAPI + NAPPER --> PARSER + NAPPER --> TYPES + NAPPER --> ENV + NAPPER --> RUNNER + NAPPER --> OPENAPI ``` ```mermaid graph LR - subgraph "IDE ↔ LSP (language intelligence)" + subgraph "IDE ↔ napper lsp (language intelligence)" direction LR - IDE1[IDE] -->|completions, diagnostics,<br/>hover, symbols| LSP1[nap-lsp] + IDE1[IDE] -->|completions, diagnostics,<br/>hover, symbols| LSP1["napper lsp<br/>(subcommand)"] end - subgraph "IDE ↔ CLI (execution)" + subgraph "IDE ↔ napper run (execution)" direction LR - IDE2[IDE] -->|nap run, nap generate| CLI1[nap CLI] + IDE2[IDE] -->|napper run, napper generate| CLI1["napper<br/>(other subcommands)"] end ``` @@ -85,13 +80,13 @@ graph LR ## `ide-lsp` — Portable Core: Nap Language Server (LSP) -The foundation for cross-IDE feature parity is a **Nap Language Server** (`napper-lsp`) — an F# binary that speaks LSP 3.17 over stdio. It reuses `Napper.Core` directly (parser, types, environment) with zero duplicated logic. +The foundation for cross-IDE feature parity is the **Nap Language Server**, which runs as the **`napper lsp` subcommand** of the `napper` CLI. **One binary, one install** — see [`lsp-one-binary`](./LSP-SPEC.md#lsp-one-binary). IDE extensions spawn `napper lsp` and speak LSP 3.17 over stdio. The LSP layer reuses `Napper.Core` directly (parser, types, environment) with zero duplicated logic. **The LSP replaces duplicated logic in IDE extensions.** The VSIX currently re-parses `.nap` files in TypeScript to extract HTTP methods, URLs, playlist steps, and environment names. This logic already exists in `Napper.Core` F#. After the LSP cutover, all IDEs ask the LSP for this data instead of reimplementing parsing in their own language. **Less TypeScript, less Rust, MORE F#.** IDE extensions become **thin UI shells** — they render data from the LSP and handle IDE-specific UI (CodeLens, tree views, status bars). They do NOT parse `.nap` files themselves. -See **[LSP Specification](./LSP-SPEC.md)** for the full capability spec and **[LSP Plan](./LSP-PLAN.md)** for implementation phases. +See **[LSP Specification](./LSP-SPEC.md)** for the full capability spec and **[LSP Plan](../plans/LSP-PLAN.md)** for implementation phases. --- @@ -149,7 +144,7 @@ Every IDE must support running a `.nap` file or `.naplist` file from within the ### Language Intelligence (via LSP) -All IDEs connect to the Nap Language Server (`nap-lsp`) for completions, diagnostics, hover, and document symbols. See **[LSP Specification](./LSP-SPEC.md)** for the full details. +All IDEs connect to the Nap Language Server by spawning `napper lsp` and speaking JSON-RPC 2.0 over stdio. The LSP provides completions, diagnostics, hover, and document symbols. See **[LSP Specification](./LSP-SPEC.md)** for the full details. --- @@ -354,11 +349,25 @@ These settings apply across all IDEs where the extension supports configuration. - Built in **TypeScript** using the VSCode Extension API. - The response panel webview uses a minimal framework (Lit or vanilla TS + CSS) — no heavy UI library. - The extension shells out to the **Nap CLI** (`nap run --output json`) for all HTTP execution. -- **CLI acquisition:** The VSIX installs the CLI via `dotnet tool install -g napper --version X.X.X` on activation, where `X.X.X` is the extension's own `package.json` version. This avoids raw binary downloads (which trigger Windows SmartScreen warnings on unsigned binaries) and leverages NuGet as a trusted distribution channel. If the CLI is already on PATH at the correct version, installation is skipped. +- **CLI acquisition:** see [`vscode-cli-acquisition`](#vscode-cli-acquisition) below. - File watching via `vscode.workspace.createFileSystemWatcher` keeps the panel tree up to date without polling. - The `.nap` language grammar (TextMate `.tmLanguage.json`) is generated from the ANTLR grammar to avoid drift. - Published to the **VS Code Marketplace** and the **Open VSX Registry** (for VSCodium / Cursor / Windsurf users). +#### `vscode-cli-acquisition` — CLI install resolution + +CLI resolution uses `@nimblesite/shipwright-vscode` (`activateDeploymentToolkit`), which reads `shipwright.json` from the extension root. + +**Canonical reference:** [Shipwright product repo adoption guide — §4 Wire Host Resolver Checks](https://github.com/MelbourneDeveloper/deployment_toolkit/blob/main/docs/agents/product-repo-adoption-guide.md) + +Resolution order (first match wins): user setting → env var → bundled binary → PATH → dotnet tool. + +The **bundled binary** (`bin/${platform}/napper[.exe]` inside the installed extension) is the primary path for all Marketplace installs. Each per-platform VSIX bundles exactly one binary; the Marketplace delivers the correct VSIX automatically. No runtime download, no .NET SDK required on the host. + +Version MUST exactly match `product.version` in `shipwright.json` (which MUST equal the VSIX `package.json` version). Mismatch → hard error (`onMismatch: "error"`). + +**VSIX packaging:** see [SWR-VSIX-PACKAGE] and [SWR-VSIX-PUBLISH] in the [Shipwright VSIX platform bundling spec](https://github.com/MelbourneDeveloper/deployment_toolkit/blob/main/docs/specs/vsix-platform-bundling.md). + ### Zed - Built in **Rust**, compiled to **WebAssembly** via `zed_extension_api` crate. @@ -370,7 +379,7 @@ These settings apply across all IDEs where the extension supports configuration. ### Shared - All extensions shell out to `nap run` for execution. No IDE re-implements HTTP logic. -- All extensions connect to `nap-lsp` for language intelligence. See **[LSP Specification](./LSP-SPEC.md)**. +- All extensions launch the LSP by spawning `napper lsp` over stdio. See **[LSP Specification](./LSP-SPEC.md)**. - Grammar definitions (TextMate and Tree-sitter) are both derived from the same ANTLR `.g4` grammar to prevent drift. --- @@ -378,7 +387,7 @@ These settings apply across all IDEs where the extension supports configuration. ## Related Specs - [LSP Specification](./LSP-SPEC.md) — Language server capabilities, architecture, and protocol details -- [LSP Plan](./LSP-PLAN.md) — LSP implementation phases and TODO -- [IDE Extension Plan (VSCode)](./IDE-EXTENSION-PLAN.md) — VSCode implementation phases and TODO -- [IDE Extension Plan (Zed)](./ZED-EXTENSION-PLAN.md) — Zed implementation phases and TODO +- [LSP Plan](../plans/LSP-PLAN.md) — LSP implementation phases and TODO +- [IDE Extension Plan (VSCode)](../plans/IDE-EXTENSION-PLAN.md) — VSCode implementation phases and TODO +- [IDE Extension Plan (Zed)](../plans/ZED-EXTENSION-PLAN.md) — Zed implementation phases and TODO - [OpenAPI Generation (Extension)](./IDE-EXTENION-OPENAPI-GENERATION-SPEC.md) — Import command and AI enrichment diff --git a/specs/LSP-SPEC.md b/docs/specs/LSP-SPEC.md similarity index 62% rename from specs/LSP-SPEC.md rename to docs/specs/LSP-SPEC.md index ec47eca..d2e19f8 100644 --- a/specs/LSP-SPEC.md +++ b/docs/specs/LSP-SPEC.md @@ -1,6 +1,14 @@ # Nap Language Server — Specification -> A standalone LSP binary that provides language intelligence for `.nap`, `.naplist`, and `.napenv` files across all IDEs. Built in F#, reusing **Napper.Core** modules directly. +> The Napper language server is **not a separate binary**. It is a subcommand of the `napper` CLI: `napper lsp` runs the LSP over stdio. **One binary. One install. One version.** The LSP and CLI are the same artifact. + +--- + +## `lsp-one-binary` — One Binary + +The CLI and the LSP ship as a single `napper` executable. Running `napper run …` executes a `.nap` file. Running `napper lsp` starts the language server, reads JSON-RPC from stdin, and writes JSON-RPC to stdout. There is no `napper-lsp`, no `nap-lsp`, no separate NuGet package, no separate brew formula, no separate version-resolution path. The version reported by `napper --version` is the version of every capability in the binary, including the LSP. + +This is non-negotiable. Any change that splits the LSP back out into its own binary is a regression. When [`cli-aot-migration`](./CLI-SPEC.md#cli-aot-migration) lands, the AOT-compiled `napper` binary still contains the LSP — exactly the same way. --- @@ -14,57 +22,40 @@ graph TB NV[Neovim Plugin<br/>Lua] end - subgraph "nap-lsp (F# binary)" - JSONRPC[JSON-RPC over stdio] - HANDLERS[LSP Handlers] + subgraph "napper (single F# binary)" + ENTRY["Program.fs<br/>napper run / check / lsp / ..."] + CLI_HANDLERS[CLI subcommands] + LSP_HANDLERS["LSP handlers<br/>(napper lsp subcommand)"] subgraph "Napper.Core (shared library)" - PARSER[Parser.fs<br/>FParsec] - ENV[Environment.fs<br/>Variable Resolution] - TYPES[Types.fs<br/>Domain Model] + PARSER[Parser.fs] + ENV[Environment.fs] + TYPES[Types.fs] + LOGGER[Logger.fs] end end - VS -->|stdio| JSONRPC - ZD -->|stdio| JSONRPC - NV -->|stdio| JSONRPC - JSONRPC --> HANDLERS - HANDLERS --> PARSER - HANDLERS --> ENV - HANDLERS --> TYPES -``` - -```mermaid -graph LR - subgraph "Napper.Core (shared)" - T[Types.fs] - P[Parser.fs] - E[Environment.fs] - L[Logger.fs] - end - - subgraph "Consumers" - CLI[Napper.Cli] - LSP[Napper.Lsp] - end - - CLI --> T - CLI --> P - CLI --> E - CLI --> L - LSP --> T - LSP --> P - LSP --> E - LSP --> L + VS -->|spawn 'napper lsp', stdio| ENTRY + ZD -->|spawn 'napper lsp', stdio| ENTRY + NV -->|spawn 'napper lsp', stdio| ENTRY + VS -->|spawn 'napper run', exec| ENTRY + ZD -->|spawn 'napper run', exec| ENTRY + ENTRY --> CLI_HANDLERS + ENTRY --> LSP_HANDLERS + CLI_HANDLERS --> PARSER + CLI_HANDLERS --> ENV + LSP_HANDLERS --> PARSER + LSP_HANDLERS --> ENV + LSP_HANDLERS --> TYPES ``` --- ## Design Principles -- **⚠️ ZERO duplicated logic — this is the #1 rule.** `Napper.Lsp` MUST NOT contain any parsing, type definitions, environment resolution, or domain logic. ALL of that lives in `Napper.Core`. The LSP is a thin protocol adapter that calls `Napper.Core` functions and translates results to LSP responses. If you find yourself writing domain logic in `Napper.Lsp`, STOP — it belongs in `Napper.Core` where the CLI can use it too. -- **Napper.Core is the single source of truth.** `Napper.Cli` and `Napper.Lsp` are both thin consumers of `Napper.Core`. They share the exact same parser, types, environment resolution, and logger. Any new capability needed by the LSP that could be useful to the CLI MUST be added to `Napper.Core`, not to `Napper.Lsp`. -- **Standalone binary.** Published as a self-contained `nap-lsp` executable via `dotnet publish`. No .NET runtime required on the user's machine. -- **Protocol-only coupling.** IDE extensions communicate exclusively via LSP over stdio. No IDE-specific code in the LSP binary. +- **One binary.** [`lsp-one-binary`](#lsp-one-binary). The LSP is a subcommand of `napper`, not a separate executable. +- **⚠️ ZERO duplicated logic.** LSP handler code MUST NOT contain parsing, types, environment resolution, or any domain logic. Those live in `Napper.Core` and are shared with the CLI subcommands. The LSP layer is a thin protocol adapter that calls `Napper.Core` functions and translates results to LSP responses. +- **Napper.Core is the single source of truth.** Every CLI subcommand and every LSP handler calls into `Napper.Core`. Any new capability the LSP needs that could be useful to the CLI MUST be added to `Napper.Core`. +- **Protocol-only coupling.** IDE extensions communicate with the LSP exclusively via JSON-RPC over stdio. No IDE-specific code in the F# binary. - **Incremental.** Each LSP capability ships independently. The server advertises only what it supports. --- @@ -73,12 +64,12 @@ graph LR | Property | Value | |----------|-------| +| Launch | `napper lsp` (subcommand) | | Transport | stdio (stdin/stdout) | | Protocol | JSON-RPC 2.0 (LSP 3.17) | | Encoding | UTF-8 | -| Binary name | `nap-lsp` | -IDE extensions launch `nap-lsp` as a child process and communicate over stdin/stdout. No TCP, no WebSocket, no HTTP. +IDE extensions spawn `napper lsp` as a child process and communicate over stdin/stdout. No TCP, no WebSocket, no HTTP. The `napper lsp` subcommand takes over stdio for the lifetime of the process — it MUST NOT print anything to stdout outside of LSP framing, and MUST log to stderr or to a file (never stdout). --- @@ -215,26 +206,27 @@ The LSP accepts configuration via `workspace/didChangeConfiguration` and `initia ## Distribution -| Platform | Binary | Notes | -|----------|--------|-------| -| macOS (arm64) | `nap-lsp` | Self-contained, single file | -| macOS (x64) | `nap-lsp` | Self-contained, single file | -| Linux (x64) | `nap-lsp` | Self-contained, single file | -| Windows (x64) | `nap-lsp.exe` | Self-contained, single file | +The LSP has no separate distribution. It ships inside the native `napper` binary — you launch +it via `napper lsp`. The primary channels need no .NET ([`cli-aot-migration`](./CLI-SPEC.md#cli-aot-migration)): + +- **Install script** — `install.sh` / `install.ps1` ([`cli-install-script`](./CLI-SPEC.md#cli-install-script)). +- **Homebrew tap** — `brew install napper` ([`cli-install-homebrew`](./CLI-SPEC.md#cli-install-homebrew)). +- **Scoop bucket** — `scoop install napper` ([`cli-install-scoop`](./CLI-SPEC.md#cli-install-scoop)). +- **dotnet tool** (secondary, optional, needs .NET SDK) — `dotnet tool install -g napper` ([`cli-install-dotnet-tool`](./CLI-SPEC.md#cli-install-dotnet-tool)). + +The VSIX install resolver ([`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)) installs `napper` once. That single install gives you the LSP for free — no second download, no second version pin, no second discovery step. -Built with `dotnet publish -c Release -r <rid> --self-contained -p:PublishSingleFile=true`. +## Discovery -IDE extensions discover the binary by: -1. Checking `nap.cliPath` setting (if configured) -2. Looking for `nap-lsp` on `PATH` -3. Downloading from GitHub releases (future) +IDE extensions launch the language server by spawning `<resolved-napper-path> lsp`. The resolved path is whatever the install resolver settled on (`napper` from `nap.cliPath`, the user's `PATH`, or the dotnet tools directory). There is no separate `nap-lsp` lookup — the LSP is reachable iff the CLI is reachable, by definition. --- ## Related Specs +- [CLI Spec](./CLI-SPEC.md) — `napper` CLI subcommands including `napper lsp` - [IDE Extension Spec](./IDE-EXTENSION-SPEC.md) — Feature matrix and IDE-specific behaviour -- [IDE Extension Plan (VSCode)](./IDE-EXTENSION-PLAN.md) — VSCode implementation phases -- [Zed Extension Plan](./ZED-EXTENSION-PLAN.md) — Zed implementation phases +- [IDE Extension Plan (VSCode)](../plans/IDE-EXTENSION-PLAN.md) — VSCode implementation phases +- [Zed Extension Plan](../plans/ZED-EXTENSION-PLAN.md) — Zed implementation phases - [File Formats Spec](./FILE-FORMATS-SPEC.md) — `.nap`, `.naplist`, `.napenv` format definitions -- [LSP Implementation Plan](./LSP-PLAN.md) — Implementation phases and TODO +- [LSP Implementation Plan](../plans/LSP-PLAN.md) — Implementation phases and TODO diff --git a/docs/specs/SCRIPTING-SPEC.md b/docs/specs/SCRIPTING-SPEC.md new file mode 100644 index 0000000..484c0c8 --- /dev/null +++ b/docs/specs/SCRIPTING-SPEC.md @@ -0,0 +1,304 @@ +# Nap Scripting Model + +Scripts are external files referenced by relative path from the `[script]` section of a `.nap` file (or as a step in a `.naplist`). This keeps `.nap` files clean and makes scripts independently testable and reusable across many `.nap` files. + +**Use whatever language you like.** Napper dispatches on file extension — pick the runtime your team already runs: + +- `script-fsx` — F# scripts (`.fsx`) executed via `dotnet fsi` +- `script-csx` — C# scripts (`.csx`) executed via `dotnet script` +- `script-js` — JavaScript scripts (`.js` / `.mjs`) executed via Node.js +- `script-py` — Python scripts (`.py`) executed via Python 3 + +Every language sees the **same** `ctx` (request/response context) and `nap` (orchestration runner) surface, defined once by the language-agnostic context protocol (`script-protocol`) and exposed through a thin per-language client library (`script-sdk`). There is no "preferred" language — `.fsx` and `.csx` are genuinely nice, but a JavaScript or Python shop never has to touch .NET to script Napper. + +--- + +## `script-context` — Script Context Object + +The runtime injects a `NapContext` (`ctx`) object into every pre/post script. The members are identical across languages; only the spelling follows each language's conventions. + +| Member | Available | Description | +|--------|-----------|-------------| +| `vars` | pre + post | Map of all resolved variables (mutable — see `set`) | +| `request` | pre + post | The request about to be sent (method, url, headers, body) | +| `response` | post only | The response: `status`, `headers`, `body` (raw), `json` (parsed when `Content-Type` is JSON), `durationMs` | +| `env` | pre + post | Current environment name | +| `set(key, value)` | pre + post | Set a variable for downstream steps | +| `fail(message)` | pre + post | Fail the test with a message (non-zero exit) | +| `log(message)` | pre + post | Write a line to test output | + +The canonical shape, expressed as an F# record (the .NET runners inject this natively; other languages receive the same data via `script-protocol`): + +```fsharp +type NapResponse = { + StatusCode : int + Headers : Map<string, string> + Body : string // raw body + Json : JsonElement // parsed if Content-Type is JSON + Duration : TimeSpan +} + +type NapContext = { + Vars : Map<string, string> // mutable — scripts can set vars for downstream steps + Request : HttpRequestMessage // pre-script only + Response : NapResponse // post-script only (None in pre-script) + Env : string // current environment name + Fail : string -> unit // call to fail the test with a message + Set : string -> string -> unit // set a variable for downstream steps + Log : string -> unit // write to test output +} +``` + +--- + +## `script-post` — Example Post-Scripts + +The same post-script — assert the returned user matches the requested id, then hand a token forward — in all four languages. + +### F# (`validate-user.fsx`) + +```fsharp +// ctx : NapContext is injected automatically +let user = ctx.Response.Json + +if user.GetProperty("id").GetString() <> ctx.Vars["userId"] then + ctx.Fail "User ID mismatch" + +// Extract a token from the response and pass it to the next step +let token = user.GetProperty("sessionToken").GetString() +ctx.Set "token" token +``` + +### C# (`validate-user.csx`) + +```csharp +// ctx is injected automatically +var user = ctx.Response.Json; + +if (user.GetProperty("id").GetString() != ctx.Vars["userId"]) + ctx.Fail("User ID mismatch"); + +ctx.Set("token", user.GetProperty("sessionToken").GetString()); +``` + +### JavaScript (`validate-user.js`) + +```js +import { ctx } from "napper"; // resolved automatically — no npm install required + +const user = ctx.response.json; + +if (user.id !== ctx.vars.userId) ctx.fail("User ID mismatch"); + +ctx.set("token", user.sessionToken); +``` + +### Python (`validate_user.py`) + +```python +from napper import ctx # resolved automatically — no pip install required + +user = ctx.response.json + +if user["id"] != ctx.vars["userId"]: + ctx.fail("User ID mismatch") + +ctx.set("token", user["sessionToken"]) +``` + +--- + +## `script-orchestration` — Script-Driven Execution (Inverse Model) + +The relationship between `.nap` files and scripts works **both ways**: + +**`.nap` file drives scripts** — a request file references one or more pre/post scripts. + +**Script drives `.nap` files** — a script file can itself act as the entry point, orchestrating as many requests as needed. The runner (`nap`) is injected the same way `ctx` is. + +### F# (`orchestrate.fsx`) + +```fsharp +// ctx : NapContext injected; nap : NapRunner also injected +let loginResult = nap.Run "./auth/01_login.nap" +ctx.Set "token" (loginResult.Response.Json.GetProperty("token").GetString()) + +for userId in [1; 2; 3] do + ctx.Set "userId" (string userId) + let result = nap.Run "./users/get-user.nap" + if result.Response.StatusCode <> 200 then + ctx.Fail $"User {userId} not found" +``` + +### JavaScript (`orchestrate.js`) + +```js +import { nap } from "napper"; + +const login = await nap.run("./auth/01_login.nap"); +nap.vars.token = login.response.json.token; + +for (const userId of [1, 2, 3]) { + nap.vars.userId = String(userId); + const result = await nap.run("./users/get-user.nap"); + if (result.response.status !== 200) nap.fail(`User ${userId} not found`); +} +``` + +### Python (`orchestrate.py`) + +```python +from napper import nap + +login = nap.run("./auth/01_login.nap") +nap.vars["token"] = login.response.json["token"] + +for user_id in (1, 2, 3): + nap.vars["userId"] = str(user_id) + result = nap.run("./users/get-user.nap") + if result.response.status != 200: + nap.fail(f"User {user_id} not found") +``` + +This enables arbitrarily complex test flows — loops, branching, data-driven runs — without any special playlist syntax, in any supported language. + +### `script-runner` — NapRunner + +The `NapRunner` (`nap`) object injected into orchestration scripts: + +| Member | Description | +|--------|-------------| +| `run(path)` | Run a `.nap` file, returns a result (`status`, `json`, `body`, `headers`, `durationMs`, `passed`) | +| `runList(path)` | Run a `.naplist` file, returns a list of results | +| `vars` | Shared, mutable variable bag (carried into every `run`/`runList`) | +| `log(message)` | Write a line to test output | +| `fail(message)` | Fail the orchestration with a message | + +Canonical F# shape: + +```fsharp +type NapRunner = { + Run : string -> NapResult // run a .nap file, returns result + RunList : string -> NapResult list // run a .naplist file + Vars : Map<string, string> // shared variable bag +} +``` + +A `.naplist` can reference an orchestration script as a step in **any** language, the same as any `.nap` file: + +```naplist +[steps] +./auth/01_login.nap +./scripts/parametrized-user-tests.py # Python script drives multiple .nap files +./scripts/seed-data.js # JavaScript step in the same playlist +./teardown/cleanup.nap +``` + +--- + +## `script-protocol` — Language-Agnostic Context Protocol + +The .NET runners (`.fsx`/`.csx`) can inject `NapContext` as native objects. JavaScript and Python cannot receive .NET objects, so all non-.NET languages — and, for uniformity, the SDKs in every language — exchange context with Napper over a **single JSON protocol**. The protocol is defined once in `Napper.Core` (no per-language reimplementation) and is the contract every `script-sdk` client speaks. + +### `script-protocol-in` — Context handed to the script + +Before launching the runtime, Napper writes the context as a JSON document to a temp file and exposes its path in the `NAPPER_CONTEXT` environment variable: + +```json +{ + "phase": "post", + "env": "staging", + "vars": { "userId": "42", "token": "" }, + "request": { + "method": "GET", + "url": "https://api.example.com/users/42", + "headers": { "Accept": "application/json" }, + "body": null + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "body": "{\"id\":\"42\",\"sessionToken\":\"abc\"}", + "json": { "id": "42", "sessionToken": "abc" }, + "durationMs": 123 + } +} +``` + +`request` is present in both phases; `response` is present only when `phase` is `post`. + +### `script-protocol-out` — Directives returned by the script + +The SDK accumulates every `set`/`fail`/`log` call and writes them as a single JSON document to the path in the `NAPPER_RESULT` environment variable when the script exits: + +```json +{ + "vars": { "token": "abc" }, + "failed": false, + "failMessage": null, + "logs": ["Created user 42"] +} +``` + +Napper merges `vars` into the downstream variable scope, prints `logs` to test output, and treats `failed: true` (or a non-zero exit code, or an uncaught exception) as a test failure — identical to the F#/C# exit-code contract that already exists. + +### `script-protocol-orchestration` — Orchestration callbacks + +`nap.run` / `nap.runList` do **not** use an IPC channel. The SDK invokes the Napper binary itself — its absolute path is injected as `NAPPER_BIN` — and parses standard `--output json`: + +``` +$NAPPER_BIN run ./auth/01_login.nap --output json --env staging --var userId=42 +``` + +This reuses the CLI's own machine-readable output (`output-json`) as the orchestration ABI, so orchestration behaves identically whether driven by a script or invoked directly, in any language, with zero bespoke transport. + +--- + +## `script-sdk` — Per-Language Client Libraries + +Each non-.NET language gets a tiny client library that wraps `script-protocol` into the idiomatic `ctx` / `nap` surface from `script-context` and `script-runner`. The SDKs are the **only** per-language code; all behavior lives behind the shared protocol. + +| Language | Package | Import | +|----------|---------|--------| +| JavaScript / TypeScript | `@nimblesite/napper` (npm) | `import { ctx, nap } from "napper"` | +| Python | `napper` (PyPI) | `from napper import ctx, nap` | + +**Zero-install resolution.** Users should not need a package manager just to write a hook. The Napper binary **bundles** a copy of each SDK and prepends it to the runtime's module search path before launching: + +- JavaScript: `NODE_PATH` is set so `require("napper")` / `import "napper"` resolves the bundled copy. +- Python: `PYTHONPATH` is set so `import napper` resolves the bundled copy. + +Publishing to npm / PyPI is purely a convenience for editor tooling (type stubs, autocomplete) and for users who prefer to vendor the SDK explicitly — the bundled copy is authoritative and always version-matched to the CLI. + +TypeScript `.d.ts` declarations and Python `.pyi` stubs ship with the SDK so editors give full completion on `ctx` and `nap`. + +--- + +## `script-runtime` — Runtime Resolution + +The Napper binary itself is a self-contained native binary with **zero runtime dependencies** for plain `.nap` / `.naplist` execution. Script hooks require the relevant language runtime, resolved per language (first match wins): + +| Extensions | Runtime | Minimum | Resolution order | +|------------|---------|---------|------------------| +| `.fsx` | `dotnet fsi` | .NET 10 SDK | `nap.dotnetPath` → `DOTNET_ROOT` → `PATH` | +| `.csx` | `dotnet script` | .NET 10 SDK | `nap.dotnetPath` → `DOTNET_ROOT` → `PATH` | +| `.js` `.mjs` `.cjs` | `node` | Node.js 18 | `nap.nodePath` → `NAPPER_NODE` → `PATH` | +| `.py` | `python3` (fallback `python`) | Python 3.9 | `nap.pythonPath` → `NAPPER_PYTHON` → `PATH` | + +If the required runtime is missing, Napper fails the step with an actionable message (which runtime, which extension, how to install) — it never silently skips a hook. The runtime is only needed by users who actually write script hooks in that language; a JavaScript shop never installs .NET, and a .NET shop never installs Node. + +--- + +## `script-dispatch` — Language Extensibility + +The `[script]` section and `.naplist` steps specify a file path. The runtime dispatches on file extension through a single mapping table (one source of truth, no scattered extension literals): + +| Extension | Runner | Spec | +|-----------|--------|------| +| `.fsx` | F# Interactive (`dotnet fsi`) | `script-fsx` | +| `.csx` | C# scripting (`dotnet script`) | `script-csx` | +| `.js` `.mjs` `.cjs` | Node.js (`node`) | `script-js` | +| `.py` | Python 3 (`python3`) | `script-py` | +| `.ts` | Future — Deno / `tsx` | — | + +Adding a language is: (1) one row in the dispatch table, (2) a `script-sdk` client speaking `script-protocol`, (3) a `script-runtime` resolver entry. The HTTP engine, variable scoping, assertions, and result handling are entirely language-agnostic and shared. diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..e9aa400 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CLAUDE.md"] +} diff --git a/schemas/shipwright.schema.json b/schemas/shipwright.schema.json new file mode 100644 index 0000000..c82c75b --- /dev/null +++ b/schemas/shipwright.schema.json @@ -0,0 +1,224 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nimblesite.dev/schemas/shipwright/v1.json", + "title": "Nimblesite Shipwright product manifest", + "description": "Authoritative per-product manifest declaring components, bundling, version contracts, and host policies. Lives at repo root of every product as `shipwright.json`.", + "type": "object", + "required": ["manifestVersion", "product", "components"], + "additionalProperties": false, + "properties": { + "manifestVersion": { + "type": "integer", + "const": 1, + "description": "Schema version. Increment on breaking changes." + }, + "product": { + "type": "object", + "required": ["id", "version"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{1,63}$", + "description": "Stable product identifier. kebab-case." + }, + "displayName": { "type": "string" }, + "version": { + "type": "string", + "description": "The expected product version this manifest targets. Stamped from tag at release time.", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "repository": { "type": "string", "format": "uri" }, + "homepage": { "type": "string", "format": "uri" } + } + }, + "components": { + "type": "array", + "minItems": 1, + "description": "Every deployable unit of the product: CLIs, LSPs, MCPs, sidecars, IDE extensions, assets.", + "items": { "$ref": "#/$defs/component" } + }, + "hosts": { + "type": "object", + "description": "Per-host policy bundle. A host that is absent is unsupported by the product.", + "additionalProperties": false, + "properties": { + "vscode": { "$ref": "#/$defs/hostPolicy" }, + "jetbrains": { "$ref": "#/$defs/hostPolicy" }, + "zed": { "$ref": "#/$defs/hostPolicy" }, + "cli": { "$ref": "#/$defs/hostPolicy" }, + "pkgmgr": { "$ref": "#/$defs/hostPolicy" } + } + } + }, + "$defs": { + "component": { + "type": "object", + "required": ["id", "kind"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{1,63}$", + "description": "Component id, unique within the product." + }, + "kind": { + "type": "string", + "enum": ["cli", "lsp", "mcp", "sidecar", "dap", "tool", "extension-vscode", "extension-jetbrains", "extension-zed", "asset"] + }, + "language": { + "type": "string", + "enum": ["rust", "dotnet", "dart", "typescript", "kotlin", "javascript"] + }, + "binaryName": { + "type": "string", + "description": "argv[0] of the binary or npm bin / dotnet tool command." + }, + "expectedVersion": { + "type": "string", + "description": "semver string or ${PRODUCT_VERSION}. When set, host must verify this value against binary --version output." + }, + "platforms": { + "type": "array", + "items": { + "type": "string", + "enum": ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64", "win32-x64", "win32-arm64", "all"] + }, + "description": "Platforms this component ships for. `all` means platform-agnostic (Node, dotnet tool, jar)." + }, + "bundled": { + "type": "object", + "description": "If set, this component is bundled inside an IDE extension artifact.", + "required": ["bundlePath"], + "additionalProperties": false, + "properties": { + "bundlePath": { + "type": "string", + "description": "Path template relative to extension root, e.g. `bin/${platform}/${binaryName}${exe}`." + }, + "perPlatformArtifact": { + "type": "boolean", + "default": true, + "description": "True = one artifact per platform (VSIX --target). False = single fat artifact." + } + } + }, + "sources": { + "type": "array", + "description": "Ordered discovery chain the host must follow. Earlier = higher priority.", + "items": { + "type": "string", + "enum": ["user-setting", "env", "path", "bundled", "pkgmgr", "dotnet-tool", "npm-global", "cargo-bin", "github-release", "lsp-initialize"] + }, + "uniqueItems": true + }, + "userSetting": { + "type": "string", + "description": "IDE settings key (e.g. `deslop.binaryPath`)." + }, + "env": { + "type": "object", + "additionalProperties": false, + "properties": { + "pathVar": { "type": "string", "description": "e.g. DESLOP_BINARY_PATH" }, + "dirVar": { "type": "string", "description": "e.g. DESLOP_BINARY_DIR" } + } + }, + "pkgmgr": { + "type": "object", + "additionalProperties": false, + "properties": { + "brew": { "type": "string" }, + "scoop": { "type": "string" }, + "apt": { "type": "string" }, + "winget": { "type": "string" } + } + }, + "dotnetTool": { + "type": "object", + "required": ["package"], + "additionalProperties": false, + "properties": { + "package": { "type": "string" }, + "command": { "type": "string" } + } + }, + "npm": { + "type": "object", + "additionalProperties": false, + "properties": { + "package": { "type": "string" }, + "bin": { "type": "string" } + } + }, + "githubRelease": { + "type": "object", + "additionalProperties": false, + "properties": { + "repo": { "type": "string", "pattern": "^[^/]+/[^/]+$" }, + "assetPattern": { "type": "string", "description": "e.g. `basilisk-${version}-${platform}.tar.gz`" }, + "checksum": { "type": "boolean", "default": true }, + "cosign": { "type": "boolean", "default": false } + } + }, + "verifyStartup": { + "type": "boolean", + "default": true, + "description": "Host must call --version (or initialize) before allowing the component to serve." + }, + "versionCheckStrategy": { + "type": "string", + "enum": ["version-flag", "version-flag-json", "lsp-initialize"], + "default": "version-flag" + }, + "required": { + "type": "boolean", + "default": true, + "description": "If true, failure to resolve + verify blocks activation." + }, + "asset": { + "type": "object", + "description": "Only for kind=asset. Non-executable payload.", + "additionalProperties": false, + "properties": { + "source": { "type": "string" }, + "target": { "type": "string" }, + "bundle": { "type": "boolean" }, + "contentHash": { "type": "boolean" }, + "downloadOnFirstUse": { "type": "boolean" } + } + } + }, + "allOf": [ + { + "if": { "properties": { "kind": { "const": "asset" } }, "required": ["kind"] }, + "then": { "required": ["asset"] } + }, + { + "if": { "properties": { "kind": { "enum": ["cli", "lsp", "mcp", "sidecar", "dap", "tool"] } }, "required": ["kind"] }, + "then": { "required": ["binaryName", "expectedVersion", "sources"] } + } + ] + }, + "hostPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "artifact": { + "type": "string", + "enum": ["vsix-per-platform", "vsix-fat", "intellij-jar", "zed-wasm", "archive", "brew-formula", "scoop-manifest", "nuget", "pub"] + }, + "activationVerifies": { + "type": "array", + "description": "Component ids whose version the host must verify at activation.", + "items": { "type": "string" } + }, + "onMismatch": { + "type": "string", + "enum": ["error", "warn", "prompt-reinstall", "prompt-pkgmgr"], + "default": "error" + } + } + } + } +} diff --git a/scripts/build-cli.sh b/scripts/build-cli.sh index 324e66c..f494c03 100755 --- a/scripts/build-cli.sh +++ b/scripts/build-cli.sh @@ -10,30 +10,53 @@ EXT_BIN="${REPO_ROOT}/src/Napper.VsCode/bin" ARCH="$(uname -m)" OS="$(uname -s)" +# RID is the .NET runtime identifier (osx-*/linux-*); NODE_PLATFORM is the +# `${process.platform}-${process.arch}` string the extension's resolver uses to find +# the bundled binary (bin/<NODE_PLATFORM>/napper — see src/binaryUtils.ts). They differ +# on macOS (osx-arm64 vs darwin-arm64), so we MUST stage under the NODE_PLATFORM name. case "${OS}" in Darwin) case "${ARCH}" in - arm64) RID="osx-arm64" ;; - x86_64) RID="osx-x64" ;; + arm64) RID="osx-arm64"; NODE_PLATFORM="darwin-arm64" ;; + x86_64) RID="osx-x64"; NODE_PLATFORM="darwin-x64" ;; *) echo "Unsupported arch: ${ARCH}" >&2; exit 1 ;; esac ;; - Linux) RID="linux-x64" ;; + Linux) + case "${ARCH}" in + x86_64) RID="linux-x64"; NODE_PLATFORM="linux-x64" ;; + aarch64|arm64) RID="linux-arm64"; NODE_PLATFORM="linux-arm64" ;; + *) echo "Unsupported arch: ${ARCH}" >&2; exit 1 ;; + esac + ;; *) echo "Unsupported OS: ${OS}" >&2; exit 1 ;; esac OUT_DIR="${REPO_ROOT}/out/${RID}" -echo "==> Building CLI for ${RID}..." +# NativeAOT per [CLI-AOT-MIGRATION]: a single statically-linked native binary with +# zero .NET runtime dependency — the same artifact that ships in releases and the VSIX, +# so tests exercise the REAL deployed CLI. (Linux needs `clang` + `zlib1g-dev`.) +echo "==> Building CLI (NativeAOT) for ${RID}..." dotnet publish "${REPO_ROOT}/src/Napper.Cli/Napper.Cli.fsproj" \ -r "${RID}" \ - --self-contained \ - -p:PublishTrimmed=true \ - -p:PublishSingleFile=true \ + -p:PublishAot=true \ -o "${OUT_DIR}" \ --nologo echo "==> CLI built → ${OUT_DIR}/" -mkdir -p "${EXT_BIN}" + +# PRIMARY: stage under the platform sub-dir the extension's bundled-binary resolver +# (bundledBinaryPath) and Shipwright look for — the SAME layout the shipped per-platform +# VSIX uses. This is what makes the extension + e2e tests resolve the REAL bundled binary. +PLATFORM_BIN="${EXT_BIN}/${NODE_PLATFORM}" +mkdir -p "${PLATFORM_BIN}" +cp "${OUT_DIR}/napper" "${PLATFORM_BIN}/napper" +chmod +x "${PLATFORM_BIN}/napper" +echo "==> Staged CLI → ${PLATFORM_BIN}/napper" + +# SECONDARY: also keep a flat copy so tooling that resolves `napper` on PATH keeps working +# (the CI Shipwright version-contract gate adds bin/ to PATH and runs `napper --version`). cp "${OUT_DIR}/napper" "${EXT_BIN}/napper" -echo "==> Copied CLI → ${EXT_BIN}/" +chmod +x "${EXT_BIN}/napper" +echo "==> Staged CLI (flat, for PATH) → ${EXT_BIN}/napper" diff --git a/scripts/generate-types.sh b/scripts/generate-types.sh new file mode 100755 index 0000000..658535d --- /dev/null +++ b/scripts/generate-types.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regenerate the gitignored src/Napper.Core/Types.Generated.fs from the canonical +# Types.td (typeDiagram DSL). Cross-platform: runs under bash on Linux, macOS, AND +# Windows (git-bash) so every release runner can produce it before the F# build — +# unlike `make generate-types`, whose recipe uses Unix `printf` and fails under the +# Makefile's PowerShell shell on Windows. Requires `typediagram` on PATH. +# Canonical source of truth: src/Napper.Core/Types.td. See Nimblesite/typeDiagram#36. + +command -v typediagram >/dev/null 2>&1 || { + echo "ERROR: typediagram not on PATH (install with: npm install -g typediagram)" >&2 + exit 1 +} + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TD="${ROOT}/src/Napper.Core/Types.td" +GEN="${ROOT}/src/Napper.Core/Types.Generated.fs" + +{ + printf '%s\n' \ + '// <auto-generated> DO NOT EDIT. Rebuilt by: make generate-types' \ + '// Canonical source of truth: src/Napper.Core/Types.td (typeDiagram DSL).' \ + '// See https://github.com/Nimblesite/typeDiagram/issues/36' \ + '' \ + 'namespace Napper.Core' \ + '' \ + '// Host-type bridge: the typeDiagram opaque type Duration maps to BCL TimeSpan.' \ + 'type Duration = System.TimeSpan' \ + '' + typediagram --to fsharp "${TD}" +} >"${GEN}" + +echo "==> Generated ${GEN} from ${TD}" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 948f0ee..e54fa9a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,5 +1,5 @@ # Install Napper CLI on Windows -# Usage: irm https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.ps1 | iex +# Usage: irm https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.ps1 | iex # Or: .\scripts\install.ps1 [-Version 0.2.0] [-InstallDir C:\tools] param( @@ -9,7 +9,7 @@ param( $ErrorActionPreference = "Stop" -$repo = "MelbourneDeveloper/napper" +$repo = "Nimblesite/napper" $asset = "napper-win-x64.exe" $checksumFile = "checksums-sha256.txt" diff --git a/scripts/install.sh b/scripts/install.sh index 6e1cc3a..2773276 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # Install Napper CLI on macOS / Linux -# Usage: curl -fsSL https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.sh | bash +# Usage: curl -fsSL https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.sh | bash # Or: ./scripts/install.sh [version] # e.g. ./scripts/install.sh 0.2.0 set -euo pipefail -REPO="MelbourneDeveloper/napper" +REPO="Nimblesite/napper" VERSION="${1:-latest}" INSTALL_DIR="${NAPPER_INSTALL_DIR:-$HOME/.local/bin}" CHECKSUM_FILE="checksums-sha256.txt" diff --git a/scripts/stamp-version.fsx b/scripts/stamp-version.fsx new file mode 100644 index 0000000..2611663 --- /dev/null +++ b/scripts/stamp-version.fsx @@ -0,0 +1,157 @@ +// scripts/stamp-version.fsx +// +// First-class, testable version stamper. Implements [SWR-VERSION-BUILD-STAMPING]. +// +// Source-controlled version carriers MUST stay at the placeholder 0.0.0-dev. The +// real release version is an explicit build input derived from the git tag and is +// stamped into the runner working tree BEFORE build/verify/package — never committed. +// +// Why a repo-local stamper instead of `shipwright-version-stamp`: that tool only +// rewrites Cargo.toml / *.csproj / package.json / pubspec.yaml. Napper keeps its +// .NET <Version> in Directory.Build.props (not a .csproj) and has a shipwright.json +// manifest, neither of which that tool touches. This script stamps Napper's ACTUAL +// carriers using structured parsers (XDocument + System.Text.Json.Nodes) — never +// sed/regex over structured data (CLAUDE.md rule). +// +// Usage: +// dotnet fsi scripts/stamp-version.fsx --tag v1.2.3 # stamp from a tag +// dotnet fsi scripts/stamp-version.fsx --version 1.2.3 # stamp from a bare version +// dotnet fsi scripts/stamp-version.fsx --version 1.2.3 --dry-run # show, change nothing +// dotnet fsi scripts/stamp-version.fsx --version 1.2.3 --root /tmp/copy # stamp a copy (tests) + +open System +open System.IO +open System.Text.Json +open System.Text.Json.Nodes +open System.Text.RegularExpressions +open System.Xml.Linq + +// ---- Constants (no string literals scattered through the code) ------------------- +let [<Literal>] DevPlaceholder = "0.0.0-dev" +let [<Literal>] PropsRelPath = "Directory.Build.props" +let [<Literal>] PackageJsonRelPath = "src/Napper.VsCode/package.json" +let [<Literal>] ShipwrightRelPath = "src/Napper.VsCode/shipwright.json" +let [<Literal>] VersionElement = "Version" +let [<Literal>] ProductKey = "product" +let [<Literal>] VersionKey = "version" +let [<Literal>] ComponentsKey = "components" +let [<Literal>] ExpectedVersionKey = "expectedVersion" +// Same shape the Shipwright manifest + version-manifest schemas accept. +let [<Literal>] SemverPattern = + @"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$" + +let log (msg: string) = printfn "[stamp] %s" msg + +// ---- Argument parsing (structured fold, no regex on the arg list) ---------------- +type Options = + { Version: string option + Root: string option + DryRun: bool } + +let private emptyOptions = { Version = None; Root = None; DryRun = false } + +/// One leading `v` is stripped per [SWR-VERSION-MATCHING]. +let private stripTag (raw: string) = + if raw.StartsWith "v" then raw.Substring 1 else raw + +let rec private parse (opts: Options) args = + match args with + | [] -> Ok opts + | "--tag" :: value :: rest + | "--version" :: value :: rest -> parse { opts with Version = Some(stripTag value) } rest + | "--root" :: value :: rest -> parse { opts with Root = Some value } rest + | "--dry-run" :: rest -> parse { opts with DryRun = true } rest + | unknown :: _ -> Error $"Unknown or incomplete argument: {unknown}" + +// ---- Carrier stampers (structured parsers only) ---------------------------------- + +/// Rewrite <Version> in an MSBuild props file via the XML DOM. +let private stampProps (path: string) (version: string) (dryRun: bool) = + let doc = XDocument.Load(path, LoadOptions.PreserveWhitespace) + + match doc.Descendants(XName.Get VersionElement) |> Seq.tryHead with + | None -> Error $"{PropsRelPath}: no <{VersionElement}> element found" + | Some el -> + log $"{path}: <{VersionElement}> {el.Value} -> {version}" + + if not dryRun then + el.Value <- version + doc.Save(path, SaveOptions.DisableFormatting) + + Ok() + +/// Rewrite version fields in a JSON carrier via the JSON DOM. `mutate` applies the +/// version to the relevant nodes and returns a human-readable change list. +let private stampJson (path: string) (version: string) (dryRun: bool) (mutate: JsonNode -> string list) = + let root = JsonNode.Parse(File.ReadAllText path) + let changes = mutate root + changes |> List.iter (fun c -> log $"{path}: {c}") + + if not dryRun then + let opts = JsonSerializerOptions(WriteIndented = true, IndentSize = 2) + File.WriteAllText(path, root.ToJsonString(opts) + "\n") + + Ok() + +let private mutatePackageJson (version: string) (root: JsonNode) = + let before = root[VersionKey].GetValue<string>() + root[VersionKey] <- JsonValue.Create version + [ $"{VersionKey} {before} -> {version}" ] + +let private mutateShipwright (version: string) (root: JsonNode) = + let product = root[ProductKey] + let productBefore = product[VersionKey].GetValue<string>() + product[VersionKey] <- JsonValue.Create version + + let componentChanges = + root[ComponentsKey].AsArray() + |> Seq.mapi (fun i comp -> + let before = comp[ExpectedVersionKey].GetValue<string>() + comp[ExpectedVersionKey] <- JsonValue.Create version + $"components[{i}].{ExpectedVersionKey} {before} -> {version}") + |> List.ofSeq + + $"{ProductKey}.{VersionKey} {productBefore} -> {version}" :: componentChanges + +// ---- Driver ---------------------------------------------------------------------- + +let private repoRootDefault = + // scripts/ -> repo root + Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..")) + +let private stampAll (root: string) (version: string) (dryRun: bool) = + let propsPath = Path.Combine(root, PropsRelPath) + let packagePath = Path.Combine(root, PackageJsonRelPath) + let shipwrightPath = Path.Combine(root, ShipwrightRelPath) + + [ propsPath; packagePath; shipwrightPath ] + |> List.tryFind (File.Exists >> not) + |> function + | Some missing -> Error $"Carrier not found: {missing}" + | None -> + stampProps propsPath version dryRun + |> Result.bind (fun () -> stampJson packagePath version dryRun (mutatePackageJson version)) + |> Result.bind (fun () -> stampJson shipwrightPath version dryRun (mutateShipwright version)) + +let private run () = + match parse emptyOptions (Array.toList (fsi.CommandLineArgs |> Array.skip 1)) with + | Error e -> Error e + | Ok opts -> + match opts.Version with + | None -> Error "Missing required --version <semver> or --tag <vX.Y.Z>" + | Some version when not (Regex.IsMatch(version, SemverPattern)) -> + Error $"Not a valid semantic version: {version}" + | Some version when version = DevPlaceholder -> + Error $"Refusing to stamp the dev placeholder {DevPlaceholder}; pass a real release version" + | Some version -> + let root = opts.Root |> Option.defaultValue repoRootDefault + log $"Stamping version {version} into {root} (dry-run={opts.DryRun})" + stampAll root version opts.DryRun + +match run () with +| Ok() -> + log "Done. All carriers stamped." + exit 0 +| Error e -> + eprintfn "[stamp] ERROR: %s" e + exit 1 diff --git a/specs/CLI-SPEC.md b/specs/CLI-SPEC.md deleted file mode 100644 index cd53220..0000000 --- a/specs/CLI-SPEC.md +++ /dev/null @@ -1,124 +0,0 @@ -# Nap CLI Specification - -> **Nap** (Network API Protocol) — a CLI-first, test-oriented alternative to Postman, Bruno, `.http` files, and curl. - ---- - -## Vision - -Nap is a developer-first HTTP testing tool. It is as simple as curl for one-off requests, but scales to full test suites with reusable components, scripted assertions, and CI integration. It is not a GUI-first tool with a CLI bolted on — the CLI is the product. - ---- - -## Core Principles - -1. **Files are the source of truth.** All requests, tests, and playlists are plain files. Git-friendly by default. -2. **Simple things are simple.** A single HTTP call should look almost as terse as curl. -3. **Tests are reusable components.** A `.nap` file (`nap-file`) is a reusable unit. It can be composed into playlists (`naplist-file`) without modification. -4. **Scripting is opt-in and external.** F# and C# scripts live in `.fsx`/`.csx` files referenced by name (`script-fsx`, `script-csx`). Simple assertions need no scripting. -5. **No lock-in.** The format is plain text. The scripting is standard `.fsx`/`.csx`. Results emit standard formats. - ---- - -## Installation - -The Napper CLI is distributed as a **dotnet tool** via NuGet. This is the primary distribution channel — it avoids code-signing requirements (no Windows SmartScreen warnings), works cross-platform, and integrates with existing .NET toolchains. - -```sh -# Install globally -dotnet tool install -g napper - -# Install a specific version -dotnet tool install -g napper --version 0.6.0 - -# Update to latest -dotnet tool update -g napper -``` - -The VSIX extension installs the CLI automatically via `dotnet tool install` on activation, using the extension's own version to determine which CLI version to install. Users with the CLI already on PATH (or configured via `nap.cliPath`) skip the auto-install. - -**Future channels** (not yet implemented): -- Homebrew formula (`brew install napper`) -- Winget / Chocolatey / Scoop packages -- Standalone native binary (NativeAOT single-file publish) - ---- - -## Usage - -### `cli-run` — Run Command - -```sh -# Run a single request (simplest case — as easy as curl) -napper run ./users/get-user.nap - -# Run a single request with inline variable override -napper run ./users/get-user.nap --var userId=99 - -# Run a collection (folder) -napper run ./users/ - -# Run a playlist -napper run ./smoke.naplist - -# Specify environment -napper run ./smoke.naplist --env staging -``` - -### `cli-check` — Validate Syntax - -```sh -# Validate syntax without running -napper check ./smoke.naplist -``` - -### `cli-generate` — Generate from OpenAPI - -```sh -# Generate .nap files from an OpenAPI spec -napper generate openapi ./petstore.json --output-dir ./petstore/ -``` - -See [CLI OpenAPI Generation](./CLI-OPENAPI-GENERATION.md) for full details. - ---- - -## CLI Flags - -| Flag | Spec ID | Description | -|------|---------|-------------| -| `--env <name>` | `cli-env` | Load environment variables from `.napenv.<name>` (`env-named`) | -| `--var <key=value>` | `cli-var` | Override a variable (repeatable). Highest priority in `env-resolution` | -| `--output <format>` | `cli-output` | Output format: `output-pretty` (default), `output-junit`, `output-json`, `output-ndjson` | -| `--output-dir <dir>` | `cli-output-dir` | Destination directory for `cli-generate` | -| `--verbose` | `cli-verbose` | Enable debug-level logging | - ---- - -## `cli-output` — Output Formats - -| Format | Spec ID | Description | -|--------|---------|-------------| -| `pretty` | `output-pretty` | Human-readable console output with ANSI colors (default) | -| `junit` | `output-junit` | JUnit XML for CI/CD integration | -| `json` | `output-json` | Single JSON object per result | -| `ndjson` | `output-ndjson` | Newline-delimited JSON for streaming | - ---- - -## `cli-exit-codes` — Exit Codes - -| Code | Meaning | -|------|---------| -| 0 | All assertions passed | -| 1 | One or more assertions failed | -| 2 | Runtime error (network, script error, parse error) | - ---- - -## Related Specs - -- [File Formats](./FILE-FORMATS-SPEC.md) — `.nap`, `.napenv`, `.naplist` format specifications -- [Scripting](./SCRIPTING-SPEC.md) — F# and C# scripting model, NapContext, NapRunner -- [CLI Plan](./CLI-PLAN.md) — Parser, project layout, implementation phases -- [OpenAPI Generation (CLI)](./CLI-OPENAPI-GENERATION.md) — Test suite generation from OpenAPI specs diff --git a/specs/SCRIPTING-SPEC.md b/specs/SCRIPTING-SPEC.md deleted file mode 100644 index 0e9d2eb..0000000 --- a/specs/SCRIPTING-SPEC.md +++ /dev/null @@ -1,104 +0,0 @@ -# Nap Scripting Model - -Scripts are external files referenced by relative path from the `nap-script` section. This keeps `.nap` files clean and makes scripts independently testable and reusable across many `.nap` files. - -- `script-fsx` — F# scripts (`.fsx`) executed via `dotnet fsi` -- `script-csx` — C# scripts (`.csx`) executed via `dotnet script` - ---- - -## `script-context` — Script Context Object - -The runtime injects a `NapContext` object into every script. The interface (F# record): - -```fsharp -type NapResponse = { - StatusCode : int - Headers : Map<string, string> - Body : string // raw body - Json : JsonElement // parsed if Content-Type is JSON - Duration : TimeSpan -} - -type NapContext = { - Vars : Map<string, string> // mutable — scripts can set vars for downstream steps - Request : HttpRequestMessage // pre-script only - Response : NapResponse // post-script only (None in pre-script) - Env : string // current environment name - Fail : string -> unit // call to fail the test with a message - Set : string -> string -> unit // set a variable for downstream steps - Log : string -> unit // write to test output -} -``` - ---- - -## `script-post` — Example Post-Script (`validate-user.fsx`) - -```fsharp -// ctx : NapContext is injected automatically -let user = ctx.Response.Json - -if user.GetProperty("id").GetString() <> ctx.Vars["userId"] then - ctx.Fail "User ID mismatch" - -// Extract a token from response and pass it to the next step -let token = user.GetProperty("sessionToken").GetString() -ctx.Set "token" token -``` - ---- - -## `script-orchestration` — Script-Driven Execution (Inverse Model) - -The relationship between `.nap` files and scripts works **both ways**: - -**`.nap` file drives scripts** — a request file references one or more pre/post scripts. - -**Script drives `.nap` files** — an `.fsx` file can itself act as the entry point, orchestrating as many requests as needed: - -```fsharp -// orchestrate.fsx — F# script as the top-level runner -// ctx : NapContext injected; nap : NapRunner also injected - -let loginResult = nap.Run "./auth/01_login.nap" -ctx.Set "token" (loginResult.Response.Json.GetProperty("token").GetString()) - -for userId in [1; 2; 3] do - ctx.Set "userId" (string userId) - let result = nap.Run "./users/get-user.nap" - if result.Response.StatusCode <> 200 then - ctx.Fail $"User {userId} not found" -``` - -### `script-runner` — NapRunner - -The `NapRunner` object injected into orchestration scripts: - -```fsharp -type NapRunner = { - Run : string -> NapResult // run a .nap file, returns result - RunList : string -> NapResult list // run a .naplist file - Vars : Map<string, string> // shared variable bag -} -``` - -This enables arbitrarily complex test flows — loops, branching, data-driven runs — without any special playlist syntax. - -A `.naplist` can reference an `.fsx` orchestration script as a step, the same as any `.nap` file: - -```naplist -[steps] -./auth/01_login.nap -./scripts/parametrized-user-tests.fsx # script drives multiple .nap files -./teardown/cleanup.nap -``` - ---- - -## `script-dispatch` — Language Extensibility - -The `nap-script` section specifies a file path. The runtime dispatches based on file extension: -- `.fsx` → F# interactive via `dotnet fsi` (`script-fsx`) -- `.csx` → C# scripting via `dotnet script` (`script-csx`) -- Future: `.py`, `.js`, etc. — the architecture allows pluggable runners diff --git a/src/Napper.Cli/Napper.Cli.fsproj b/src/Napper.Cli/Napper.Cli.fsproj index ee27a55..da687b4 100644 --- a/src/Napper.Cli/Napper.Cli.fsproj +++ b/src/Napper.Cli/Napper.Cli.fsproj @@ -3,12 +3,42 @@ <PropertyGroup> <OutputType>Exe</OutputType> <AssemblyName>napper</AssemblyName> + <Description>CLI-first, test-oriented HTTP API testing tool</Description> + <NuGetAuditMode>direct</NuGetAuditMode> + + <!-- DEPLOYMENT: the PRIMARY artifact is a self-contained NativeAOT native binary + (GitHub Releases / Homebrew / Scoop / install script) that is also bundled + inside the per-platform VSIX — end users never need .NET. These props only + affect `dotnet pack`, which produces a SECONDARY, best-effort `dotnet tool` + NuGet package for .NET users who want it. `dotnet pack` and the AOT + `dotnet publish` are independent commands and do not interfere. The VSIX + resolver NEVER uses dotnet-tool as a startup source ([SWR-IDE-RESOLUTION]); + the NuGet publish job is non-blocking and never gates the release. --> <PackAsTool>true</PackAsTool> <ToolCommandName>napper</ToolCommandName> <PackageId>napper</PackageId> <PackageOutputPath>./nupkg</PackageOutputPath> - <Description>CLI-first, test-oriented HTTP API testing tool</Description> <PackageTags>http;api;testing;cli;rest;fsharp;dotnet-tool</PackageTags> + + <!-- Implements [CLI-AOT-MIGRATION]. NativeAOT is enabled on the publish + command line (-p:PublishAot=true), keeping plain build/test on the JIT. --> + + <!-- IL2104 (trim) and IL3053 (AOT) are whole-assembly *rollup* warnings emitted + only by FSharp.Core and FParsec — third-party assemblies that ship without + full trim/AOT annotations and which we cannot modify. The FSharp.Core rollup + is present in every F# NativeAOT application. Our own F# code stays + warning-clean via TreatWarningsAsErrors, and the LSP/CLI are verified working + under AOT by the black-box e2e suite (which runs the real native binary), so + these two non-actionable codes are the only ones suppressed. --> + <NoWarn>$(NoWarn);IL2104;IL3053</NoWarn> + + <!-- NativeAOT portability: without ICU the runtime aborts (exit 133) the instant + any culture is touched (e.g. DateTimeFormatInfo.CurrentInfo when printing the + version). A bare Linux image (ubuntu:24.04) ships no libicu, so the binary must + carry its own invariant globalization to run with TRULY zero system deps: that + is the whole point of the clean-room release gate. The CLI/LSP/HTTP surface is + culture-agnostic, so invariant is the correct, deterministic mode. --> + <InvariantGlobalization>true</InvariantGlobalization> </PropertyGroup> <ItemGroup> @@ -18,7 +48,7 @@ <ItemGroup> <ProjectReference Include="..\Napper.Core\Napper.Core.fsproj" /> <ProjectReference Include="..\DotHttp\DotHttp.fsproj" /> + <ProjectReference Include="..\Napper.Lsp\Napper.Lsp.fsproj" /> </ItemGroup> - </Project> diff --git a/src/Napper.Cli/Program.fs b/src/Napper.Cli/Program.fs index 17739d7..a81aea8 100644 --- a/src/Napper.Cli/Program.fs +++ b/src/Napper.Cli/Program.fs @@ -93,6 +93,7 @@ let printHelp () = printfn " nap check <file> Validate a .nap or .naplist file" printfn " nap generate openapi <spec> --output-dir <dir> Generate .nap files from OpenAPI spec" printfn " nap convert http <file|dir> --output-dir <dir> Convert .http files to .nap format" + printfn " nap lsp Run the language server (LSP 3.17 over stdio)" printfn " nap help Show this help" printfn "" printfn "Options:" @@ -276,9 +277,11 @@ let private writeGenerated (outDir: string) (result: OpenApiGenerator.Generation /// Display generation results let private displayGenerated (output: string) (generated: OpenApiGenerator.GenerationResult) (outDir: string) : unit = match output with - | "json" -> printfn "{\"files\":%d,\"playlist\":\"%s\"}" generated.NapFiles.Length generated.Playlist.FileName + | "json" -> + // %s only: F# printf's %d/%f path is reflection-based and aborts under NativeAOT. + printfn "{\"files\":%s,\"playlist\":\"%s\"}" (string generated.NapFiles.Length) generated.Playlist.FileName | _ -> - printfn "Generated %d .nap files from OpenAPI spec" generated.NapFiles.Length + printfn "Generated %s .nap files from OpenAPI spec" (string generated.NapFiles.Length) printfn " Playlist: %s" generated.Playlist.FileName printfn " Environment: %s" generated.Environment.FileName printfn " Output: %s" outDir @@ -400,8 +403,9 @@ let convertHttp (args: CliArgs) : int = match DotHttp.Parser.parse content with | Error msg -> eprintfn "Error parsing %s: %s" (Path.GetFileName httpPath) msg | Ok(httpFile: DotHttp.HttpFile) -> - Logger.info - $"Parsed {httpPath}: {httpFile.Requests.Length} requests, dialect={httpFile.Dialect}" + // No {httpFile.Dialect}: interpolating the DU triggers reflective + // structured-print (GetUnionFields) which aborts under NativeAOT. + Logger.info $"Parsed {httpPath}: {httpFile.Requests.Length} requests" // Convert env files if present match args.EnvFile with @@ -476,18 +480,38 @@ let convertHttp (args: CliArgs) : int = eprintfn "Warning: %s%s" prefix w.Message match args.Output with - | "json" -> printfn "{\"files\":%d,\"warnings\":%d}" totalFiles allWarnings.Length + | "json" -> printfn "{\"files\":%s,\"warnings\":%s}" (string totalFiles) (string allWarnings.Length) | _ -> - printfn "Converted %d requests to .nap files" totalFiles + printfn "Converted %s requests to .nap files" (string totalFiles) printfn " Output: %s" outDir if not (List.isEmpty allWarnings) then - printfn " Warnings: %d" allWarnings.Length + printfn " Warnings: %s" (string allWarnings.Length) 0 [<EntryPoint>] let main argv = + // LSP subcommand: take over stdio immediately, suppress all other stdout. + // Logging goes to a file (never stdout — that would corrupt the LSP stream), + // opt-in via --verbose / NAPPER_LSP_VERBOSE so we don't litter on every spawn. + if argv.Length > 0 && argv[0] = "lsp" then + let verbose = + (argv |> Array.contains "--verbose") + || Environment.GetEnvironmentVariable "NAPPER_LSP_VERBOSE" = "1" + + if verbose then + try + Logger.init true + with _ -> + () + + let input = Console.OpenStandardInput() + let output = Console.OpenStandardOutput() + let exitCode = Napper.Lsp.LspRunner.run input output + Logger.close () + Environment.Exit(exitCode) + let args = parseArgs argv Logger.init args.Verbose let joinedArgs = argv |> String.concat " " @@ -516,9 +540,32 @@ let main argv = eprintfn "Usage: nap convert http <file|dir> --output-dir <dir>" 2 | "version" - | "--version" -> - let v = Reflection.Assembly.GetExecutingAssembly().GetName().Version - printfn "%d.%d.%d" v.Major v.Minor v.Build + | "--version" + | "-V" -> + // Implements [DTK-NAPPER-VERSION-CONTRACT] + // Plain text: "napper <semver>" per Shipwright version contract + let asm = Reflection.Assembly.GetExecutingAssembly() + + let infoVersion = + asm.GetCustomAttributes(typeof<Reflection.AssemblyInformationalVersionAttribute>, false) + |> Array.tryHead + |> Option.map (fun a -> (a :?> Reflection.AssemblyInformationalVersionAttribute).InformationalVersion) + |> Option.defaultWith (fun () -> + let v = asm.GetName().Version + $"{v.Major}.{v.Minor}.{v.Build}") + // Strip any build metadata suffix (e.g. "+commit") + let semver = infoVersion.Split('+')[0] + // Check for --json flag in remaining args + let isJson = argv |> Array.exists (fun a -> a = "--json") + + if isJson then + // JSON version manifest per Shipwright version-manifest.schema.json + printfn + """{"manifestVersion":1,"name":"napper","version":"%s","kind":"cli","language":"dotnet","product":"napper","capabilities":["cli","lsp"]}""" + semver + else + printfn "napper %s" semver + 0 | "help" | "--help" diff --git a/src/Napper.Cli/TrimmerRoots.xml b/src/Napper.Cli/TrimmerRoots.xml new file mode 100644 index 0000000..cc852b1 --- /dev/null +++ b/src/Napper.Cli/TrimmerRoots.xml @@ -0,0 +1,7 @@ +<linker> + <!-- These assemblies instantiate types via Newtonsoft.Json reflection at runtime. + PublishTrimmed removes their constructors without this descriptor. --> + <assembly fullname="StreamJsonRpc" preserve="all" /> + <assembly fullname="Ionide.LanguageServerProtocol" preserve="all" /> + <assembly fullname="Newtonsoft.Json" preserve="all" /> +</linker> diff --git a/src/Napper.Core.Tests/Napper.Core.Tests.fsproj b/src/Napper.Core.Tests/Napper.Core.Tests.fsproj index beec430..d9d4014 100644 --- a/src/Napper.Core.Tests/Napper.Core.Tests.fsproj +++ b/src/Napper.Core.Tests/Napper.Core.Tests.fsproj @@ -23,6 +23,7 @@ <Compile Include="RunnerE2eTests.fs" /> <Compile Include="OpenApiE2eTests.fs" /> <Compile Include="HttpConvertE2eTests.fs" /> + <Compile Include="VersionContractTests.fs" /> </ItemGroup> <ItemGroup> diff --git a/src/Napper.Core.Tests/TestHelpers.fs b/src/Napper.Core.Tests/TestHelpers.fs index 014b25e..d081643 100644 --- a/src/Napper.Core.Tests/TestHelpers.fs +++ b/src/Napper.Core.Tests/TestHelpers.fs @@ -24,7 +24,7 @@ let log (msg: string) = Console.Error.WriteLine(msg) Console.Error.Flush()) -let private findRepoRoot () : string option = +let findRepoRoot () : string option = let mutable dir = DirectoryInfo(AppContext.BaseDirectory) while dir <> null @@ -57,12 +57,14 @@ let private findNapper () : string = elif File.Exists localBin then localBin else NapperBinaryName -let runCliWithTimeout (timeoutMs: int) (args: string) (cwd: string) : int * string * string = - let binary = findNapper () +/// Generic process runner. Black-box: launches an arbitrary executable, captures +/// stdout/stderr, enforces a timeout. Reused by both the napper CLI runner and the +/// version-stamper test (zero duplication). +let runProcessWithTimeout (timeoutMs: int) (fileName: string) (args: string) (cwd: string) : int * string * string = let sw = Stopwatch.StartNew() - log $"[test] napper %s{args}" + log $"[test] %s{fileName} %s{args}" let psi = ProcessStartInfo() - psi.FileName <- binary + psi.FileName <- fileName psi.Arguments <- args psi.WorkingDirectory <- cwd psi.RedirectStandardOutput <- true @@ -78,15 +80,18 @@ let runCliWithTimeout (timeoutMs: int) (args: string) (cwd: string) : int * stri if not (proc.WaitForExit(timeoutMs)) then proc.Kill(true) sw.Stop() - log $"[test] TIMEOUT after %d{timeoutMs}ms | napper %s{args}" - failwith $"napper process timed out after %d{timeoutMs}ms: napper %s{args}" + log $"[test] TIMEOUT after %d{timeoutMs}ms | %s{fileName} %s{args}" + failwith $"process timed out after %d{timeoutMs}ms: %s{fileName} %s{args}" let stdout = stdoutTask.Result let stderr = stderrTask.Result sw.Stop() - log $"[test] napper %s{args} | exit=%d{proc.ExitCode} elapsed=%d{sw.ElapsedMilliseconds}ms" + log $"[test] %s{fileName} %s{args} | exit=%d{proc.ExitCode} elapsed=%d{sw.ElapsedMilliseconds}ms" proc.ExitCode, stdout, stderr +let runCliWithTimeout (timeoutMs: int) (args: string) (cwd: string) : int * string * string = + runProcessWithTimeout timeoutMs (findNapper ()) args cwd + let runCli (args: string) (cwd: string) : int * string * string = runCliWithTimeout DefaultTimeoutMs args cwd diff --git a/src/Napper.Core.Tests/VersionContractTests.fs b/src/Napper.Core.Tests/VersionContractTests.fs new file mode 100644 index 0000000..bc685e6 --- /dev/null +++ b/src/Napper.Core.Tests/VersionContractTests.fs @@ -0,0 +1,132 @@ +module VersionContractTests +// e2e black-box tests for the Shipwright binary version contract and the release +// version stamper. Tests drive the REAL napper binary and the REAL stamper script +// through their CLI surface — never internal state. +// Implements [SWR-VERSION-CLI-OUTPUT], [SWR-VERSION-JSON-OUTPUT], +// [SWR-VERSION-BUILD-STAMPING], [SWR-VERSION-TEST-REQ]. + +open System.IO +open System.Text.Json +open Xunit +open TestHelpers + +[<Literal>] +let private DevPlaceholder = "0.0.0-dev" + +// Same semver shape the Shipwright manifest + version-manifest schemas accept. +[<Literal>] +let private SemverPattern = + @"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$" + +/// The version contract only trusts the first stdout line. +let private firstLine (s: string) = + s.Replace("\r\n", "\n").Split('\n').[0].Trim() + +let private repoRoot () = + match findRepoRoot () with + | Some r -> r + | None -> failwith "repo root not found from test base directory" + +// ─── napper --version (plain text) ───────────────── [SWR-VERSION-CLI-OUTPUT] + +[<Fact>] +let ``napper --version prints 'napper <semver>' and exits 0`` () = + let exitCode, stdout, _ = runCli "--version" (Directory.GetCurrentDirectory()) + Assert.Equal(0, exitCode) + let line = firstLine stdout + let parts = line.Split(' ') + Assert.Equal(2, parts.Length) + Assert.Equal("napper", parts.[0]) + Assert.Matches(SemverPattern, parts.[1]) + // The source tree always carries the placeholder. A hard-coded release version + // in source is a release-engineering defect, so assert it here. + Assert.Equal($"napper {DevPlaceholder}", line) + +// ─── napper --version --json ─────────────────────── [SWR-VERSION-JSON-OUTPUT] + +[<Fact>] +let ``napper --version --json conforms to the version manifest schema`` () = + let exitCode, stdout, _ = + runCli "--version --json" (Directory.GetCurrentDirectory()) + + Assert.Equal(0, exitCode) + use doc = JsonDocument.Parse(firstLine stdout) + let root = doc.RootElement + Assert.Equal(1, root.GetProperty("manifestVersion").GetInt32()) + Assert.Equal("napper", root.GetProperty("name").GetString()) + Assert.Equal("cli", root.GetProperty("kind").GetString()) + Assert.Equal("dotnet", root.GetProperty("language").GetString()) + let jsonVersion = root.GetProperty("version").GetString() + Assert.Matches(SemverPattern, jsonVersion) + // Plain and JSON forms MUST report the same version. + let _, plainOut, _ = runCli "--version" (Directory.GetCurrentDirectory()) + Assert.Equal((firstLine plainOut).Split(' ').[1], jsonVersion) + +// ─── version stamper ─────────────────────────────── [SWR-VERSION-BUILD-STAMPING] + +let private vscodeDir = Path.Combine("src", "Napper.VsCode") +let private propsName = "Directory.Build.props" +let private pkgName = "package.json" +let private manifestName = "shipwright.json" + +let private copyCarriers (root: string) (dest: string) = + Directory.CreateDirectory(Path.Combine(dest, vscodeDir)) |> ignore + File.Copy(Path.Combine(root, propsName), Path.Combine(dest, propsName)) + File.Copy(Path.Combine(root, vscodeDir, pkgName), Path.Combine(dest, vscodeDir, pkgName)) + File.Copy(Path.Combine(root, vscodeDir, manifestName), Path.Combine(dest, vscodeDir, manifestName)) + +let private runStamper (root: string) (extraArgs: string) = + let script = Path.Combine(root, "scripts", "stamp-version.fsx") + runProcessWithTimeout ScriptTimeoutMs "dotnet" $"fsi \"{script}\" {extraArgs}" root + +[<Fact>] +let ``stamper rewrites every version carrier from a tag`` () = + let root = repoRoot () + let temp = createTempDir "stamp" + + try + copyCarriers root temp + let version = "7.8.9" + let exitCode, _, stderr = runStamper root $"--tag v{version} --root \"{temp}\"" + Assert.True((exitCode = 0), $"stamper exited {exitCode}: {stderr}") + + let props = File.ReadAllText(Path.Combine(temp, propsName)) + Assert.Contains($"<Version>{version}</Version>", props) + + use pkg = + JsonDocument.Parse(File.ReadAllText(Path.Combine(temp, vscodeDir, pkgName))) + + Assert.Equal(version, pkg.RootElement.GetProperty("version").GetString()) + + use ship = + JsonDocument.Parse(File.ReadAllText(Path.Combine(temp, vscodeDir, manifestName))) + + Assert.Equal(version, ship.RootElement.GetProperty("product").GetProperty("version").GetString()) + let mutable componentCount = 0 + + for comp in ship.RootElement.GetProperty("components").EnumerateArray() do + componentCount <- componentCount + 1 + Assert.Equal(version, comp.GetProperty("expectedVersion").GetString()) + + Assert.True((componentCount >= 1), "manifest must declare at least one component") + finally + cleanupDir temp + +[<Fact>] +let ``stamper dry-run changes nothing and rejects bad input`` () = + let root = repoRoot () + let temp = createTempDir "stamp-dry" + + try + copyCarriers root temp + // dry-run: succeeds but leaves carriers at the placeholder. + let exitDry, _, _ = runStamper root $"--version 5.5.5 --dry-run --root \"{temp}\"" + Assert.Equal(0, exitDry) + Assert.Contains(DevPlaceholder, File.ReadAllText(Path.Combine(temp, propsName))) + + // invalid semver: non-zero exit, carriers untouched. + let exitBad, _, _ = runStamper root $"--version not-a-semver --root \"{temp}\"" + Assert.NotEqual(0, exitBad) + Assert.Contains(DevPlaceholder, File.ReadAllText(Path.Combine(temp, vscodeDir, pkgName))) + finally + cleanupDir temp diff --git a/src/Napper.Core/CurlGenerator.fs b/src/Napper.Core/CurlGenerator.fs index f83b577..0637677 100644 --- a/src/Napper.Core/CurlGenerator.fs +++ b/src/Napper.Core/CurlGenerator.fs @@ -4,16 +4,6 @@ module Napper.Core.CurlGenerator open Napper.Core -let private methodString (m: HttpMethod) : string = - match m with - | GET -> "GET" - | POST -> "POST" - | PUT -> "PUT" - | PATCH -> "PATCH" - | DELETE -> "DELETE" - | HEAD -> "HEAD" - | OPTIONS -> "OPTIONS" - let private escapeShellArg (s: string) : string = s.Replace("'", "'\\''") let private headerFlag (key: string) (value: string) : string = @@ -25,7 +15,7 @@ let private bodyFlag (body: RequestBody) : string = $" -d '{escapeShellArg body. let toCurl (request: NapRequest) : string = let sb = System.Text.StringBuilder() - sb.Append($"curl -X {methodString request.Method} '{escapeShellArg request.Url}'") + sb.Append($"curl -X {request.Method.Name} '{escapeShellArg request.Url}'") |> ignore request.Headers |> Map.iter (fun k v -> sb.Append(headerFlag k v) |> ignore) diff --git a/src/Napper.Core/HttpMethodExtensions.fs b/src/Napper.Core/HttpMethodExtensions.fs new file mode 100644 index 0000000..a64b97b --- /dev/null +++ b/src/Napper.Core/HttpMethodExtensions.fs @@ -0,0 +1,33 @@ +// Implements [http-methods]. +// Behavior augmenting the generated HttpMethod union (Types.Generated.fs, from Types.td). +// typeDiagram models DATA only; these members are behavior and live here by hand — +// never in the generated file (which `make generate-types` overwrites). +namespace Napper.Core + +[<AutoOpen>] +module HttpMethodExtensions = + + type HttpMethod with + + /// The BCL System.Net.Http.HttpMethod equivalent. + member this.ToNetMethod() = + match this with + | GET -> System.Net.Http.HttpMethod.Get + | POST -> System.Net.Http.HttpMethod.Post + | PUT -> System.Net.Http.HttpMethod.Put + | PATCH -> System.Net.Http.HttpMethod.Patch + | DELETE -> System.Net.Http.HttpMethod.Delete + | HEAD -> System.Net.Http.HttpMethod.Head + | OPTIONS -> System.Net.Http.HttpMethod.Options + + /// The HTTP verb as an uppercase string. Single source of truth for + /// method-name rendering across the CLI, curl generation, and the LSP. + member this.Name = + match this with + | GET -> "GET" + | POST -> "POST" + | PUT -> "PUT" + | PATCH -> "PATCH" + | DELETE -> "DELETE" + | HEAD -> "HEAD" + | OPTIONS -> "OPTIONS" diff --git a/src/Napper.Core/Napper.Core.fsproj b/src/Napper.Core/Napper.Core.fsproj index 8203d04..d087ec8 100644 --- a/src/Napper.Core/Napper.Core.fsproj +++ b/src/Napper.Core/Napper.Core.fsproj @@ -5,7 +5,10 @@ </PropertyGroup> <ItemGroup> - <Compile Include="Types.fs" /> + <!-- Types.Generated.fs is generated by `make generate-types` from Types.td + (typeDiagram DSL, canonical source of truth). It is gitignored. --> + <Compile Include="Types.Generated.fs" /> + <Compile Include="HttpMethodExtensions.fs" /> <Compile Include="OpenApiTypes.fs" /> <Compile Include="Logger.fs" /> <Compile Include="Parser.fs" /> diff --git a/src/Napper.Core/OpenApiGenerator.fs b/src/Napper.Core/OpenApiGenerator.fs index 2e897e9..34bfe25 100644 --- a/src/Napper.Core/OpenApiGenerator.fs +++ b/src/Napper.Core/OpenApiGenerator.fs @@ -378,7 +378,8 @@ let private buildBody (ep: EndpointInfo) : string list = | Some body -> [ SectionRequestBody; TripleQuote; body; TripleQuote; "" ] let private buildAssertions (op: OpenApiOperation) : string list = - let status = sprintf "%s%d" AssertStatusPrefix (findSuccessStatus op.Responses) + // String concat, not sprintf %d: F#'s %d path is reflection-based and aborts under NativeAOT. + let status = AssertStatusPrefix + string (findSuccessStatus op.Responses) let bodyAsserts = match extractResponseSchema op.Responses with diff --git a/src/Napper.Core/Output.fs b/src/Napper.Core/Output.fs index 2c87b77..5ce9294 100644 --- a/src/Napper.Core/Output.fs +++ b/src/Napper.Core/Output.fs @@ -29,7 +29,7 @@ let formatPretty (result: NapResult) : string = else "33" appendLine - $" \x1b[{statusColor}m{resp.StatusCode}\x1b[0m {result.Request.Method} {result.Request.Url} ({resp.Duration.TotalMilliseconds:F0}ms)" + $" \x1b[{statusColor}m{resp.StatusCode}\x1b[0m {result.Request.Method.Name} {result.Request.Url} ({resp.Duration.TotalMilliseconds:F0}ms)" // Assertions for a in result.Assertions do @@ -132,7 +132,9 @@ let formatJson (result: NapResult) : string = | None -> () // Request info - writer.WriteString("requestMethod", string result.Request.Method) + // .Name, not `string` on the DU: the `string` operator on a union reflects + // (structured-print) and aborts under NativeAOT. + writer.WriteString("requestMethod", result.Request.Method.Name) writer.WriteString("requestUrl", result.Request.Url) writer.WriteStartObject("requestHeaders") diff --git a/src/Napper.Core/Runner.fs b/src/Napper.Core/Runner.fs index f547182..f71ba47 100644 --- a/src/Napper.Core/Runner.fs +++ b/src/Napper.Core/Runner.fs @@ -17,7 +17,9 @@ let private httpClient = new HttpClient() /// Execute an HTTP request from a resolved NapRequest let executeRequest (request: NapRequest) : Async<NapResponse> = async { - Logger.info $"HTTP {request.Method} {request.Url}" + // .Name, not {request.Method}: interpolating the DU triggers reflective + // structured-print (GetUnionFields) which aborts under NativeAOT. + Logger.info $"HTTP {request.Method.Name} {request.Url}" Logger.debug $"Request headers: {request.Headers.Count} headers" let msg = new HttpRequestMessage(request.Method.ToNetMethod(), request.Url) @@ -94,7 +96,8 @@ let private resolveTarget (response: NapResponse) (target: string) : string opti if target = "status" then Some(string response.StatusCode) elif target = "duration" then - Some(sprintf "%.0fms" response.Duration.TotalMilliseconds) + // .ToString, not sprintf %f: F#'s %f path is reflection-based and aborts under NativeAOT. + Some(response.Duration.TotalMilliseconds.ToString("F0") + "ms") elif target.StartsWith "headers." then let headerName = target.Substring(8) diff --git a/src/Napper.Core/SectionScanner.fs b/src/Napper.Core/SectionScanner.fs index 6c8d00a..f6dff43 100644 --- a/src/Napper.Core/SectionScanner.fs +++ b/src/Napper.Core/SectionScanner.fs @@ -94,3 +94,21 @@ let scanNaplistSections (content: string) : SectionLocation list = closeSection (lines.Length - 1) sections + +/// Extract the step file paths declared in a .naplist's [steps] section, in order. +/// Skips blank lines and comments. Shared by the CLI runner and the LSP so no +/// consumer (IDE extension) re-parses .naplist content itself. +let scanNaplistStepPaths (content: string) : string list = + let mutable inSteps = false + let mutable steps: string list = [] + + for rawLine in content.Split([| '\n' |]) do + let trimmed = rawLine.Trim() + + match isSectionHeader rawLine with + | Some name -> inSteps <- name = "steps" + | None -> + if inSteps && trimmed.Length > 0 && not (trimmed.StartsWith "#") then + steps <- steps @ [ trimmed ] + + steps diff --git a/src/Napper.Core/Types.fs b/src/Napper.Core/Types.fs deleted file mode 100644 index 670e613..0000000 --- a/src/Napper.Core/Types.fs +++ /dev/null @@ -1,110 +0,0 @@ -// Specs: nap-file, nap-meta, nap-vars, nap-request, nap-headers, nap-body, nap-assert, nap-script, -// http-methods, env-interpolation, assert-status, assert-equals, assert-exists, assert-contains, -// assert-matches, assert-lt, assert-gt, naplist-file, naplist-steps, naplist-nap-step, -// naplist-folder-step, naplist-nested, naplist-script-step -namespace Napper.Core - -open System -open System.Net.Http - -/// Assertion operators used in [assert] blocks -type AssertOp = - | Equals of string - | Exists - | Contains of string - | Matches of string - | LessThan of string - | GreaterThan of string - -/// A single assertion line, e.g. status = 200, body.id exists -type Assertion = - { Target: string // e.g. "status", "body.id", "headers.Content-Type", "duration" - Op: AssertOp } - -/// HTTP method -type HttpMethod = - | GET - | POST - | PUT - | PATCH - | DELETE - | HEAD - | OPTIONS - - member this.ToNetMethod() = - match this with - | GET -> System.Net.Http.HttpMethod.Get - | POST -> System.Net.Http.HttpMethod.Post - | PUT -> System.Net.Http.HttpMethod.Put - | PATCH -> System.Net.Http.HttpMethod.Patch - | DELETE -> System.Net.Http.HttpMethod.Delete - | HEAD -> System.Net.Http.HttpMethod.Head - | OPTIONS -> System.Net.Http.HttpMethod.Options - -/// Script references (pre/post hooks) -type ScriptRef = - { Pre: string option - Post: string option } - -/// Metadata block [meta] -type NapMeta = - { Name: string option - Description: string option - Tags: string list } - -/// Request body -type RequestBody = - { ContentType: string; Content: string } - -/// The request definition from a .nap file -type NapRequest = - { Method: HttpMethod - Url: string - Headers: Map<string, string> - Body: RequestBody option } - -/// A fully parsed .nap file -type NapFile = - { Meta: NapMeta - Vars: Map<string, string> - Request: NapRequest - Assertions: Assertion list - Script: ScriptRef } - -/// Result of evaluating a single assertion -type AssertionResult = - { Assertion: Assertion - Passed: bool - Expected: string - Actual: string } - -/// The HTTP response captured after running a request -type NapResponse = - { StatusCode: int - Headers: Map<string, string> - Body: string - Duration: TimeSpan } - -/// Overall result of running a single .nap file -type NapResult = - { File: string - Request: NapRequest - Response: NapResponse option - Assertions: AssertionResult list - Passed: bool - Error: string option - Log: string list } - -/// A step in a .naplist playlist -type PlaylistStep = - | NapFileStep of string // path to a .nap file - | PlaylistRef of string // path to another .naplist - | FolderRef of string // path to a folder - | ScriptStep of string // path to an .fsx or .csx orchestration script - -/// A parsed .naplist file -type NapPlaylist = - { Meta: NapMeta - Env: string option - Vars: Map<string, string> - Steps: PlaylistStep list } diff --git a/src/Napper.Core/Types.td b/src/Napper.Core/Types.td new file mode 100644 index 0000000..a08a634 --- /dev/null +++ b/src/Napper.Core/Types.td @@ -0,0 +1,124 @@ +# Napper.Core domain models — CANONICAL declarations (typeDiagram DSL). +# +# This .td file is the single source of truth for the DTOs in Types.fs. +# Per CLAUDE.md "Type Models": all models are declared in typeDiagram markup and +# the F# ADTs are produced by the typeDiagram code generator. +# +# NOTE: typeDiagram 0.8.0 does not yet emit F# (--to supports +# typescript|python|rust|go|csharp only). Until F# emit lands, Types.fs is kept +# hand-synced to this file. Tracking: https://github.com/Nimblesite/typeDiagram/issues/36 +# +# `Duration` is an opaque host type (maps to System.TimeSpan in F#); typeDiagram +# renders undeclared types as inline text, same as UUID in the language reference. +# +# Implements: nap-file, nap-meta, nap-vars, nap-request, nap-headers, nap-body, +# nap-assert, nap-script, http-methods, env-interpolation, assert-status, +# assert-equals, assert-exists, assert-contains, assert-matches, assert-lt, +# assert-gt, naplist-file, naplist-steps, naplist-nap-step, naplist-folder-step, +# naplist-nested, naplist-script-step + +# Assertion operators used in [assert] blocks +union AssertOp { + Equals(String) + Exists + Contains(String) + Matches(String) + LessThan(String) + GreaterThan(String) +} + +# A single assertion line, e.g. status = 200, body.id exists +type Assertion { + Target: String + Op: AssertOp +} + +# HTTP method +union HttpMethod { + GET + POST + PUT + PATCH + DELETE + HEAD + OPTIONS +} + +# Script references (pre/post hooks) +type ScriptRef { + Pre: Option<String> + Post: Option<String> +} + +# Metadata block [meta] +type NapMeta { + Name: Option<String> + Description: Option<String> + Tags: List<String> +} + +# Request body +type RequestBody { + ContentType: String + Content: String +} + +# The request definition from a .nap file +type NapRequest { + Method: HttpMethod + Url: String + Headers: Map<String, String> + Body: Option<RequestBody> +} + +# A fully parsed .nap file +type NapFile { + Meta: NapMeta + Vars: Map<String, String> + Request: NapRequest + Assertions: List<Assertion> + Script: ScriptRef +} + +# Result of evaluating a single assertion +type AssertionResult { + Assertion: Assertion + Passed: Bool + Expected: String + Actual: String +} + +# The HTTP response captured after running a request +type NapResponse { + StatusCode: Int + Headers: Map<String, String> + Body: String + Duration: Duration +} + +# Overall result of running a single .nap file +type NapResult { + File: String + Request: NapRequest + Response: Option<NapResponse> + Assertions: List<AssertionResult> + Passed: Bool + Error: Option<String> + Log: List<String> +} + +# A step in a .naplist playlist +union PlaylistStep { + NapFileStep(String) + PlaylistRef(String) + FolderRef(String) + ScriptStep(String) +} + +# A parsed .naplist file +type NapPlaylist { + Meta: NapMeta + Env: Option<String> + Vars: Map<String, String> + Steps: List<PlaylistStep> +} diff --git a/src/Napper.Lsp.Tests/LspClient.fs b/src/Napper.Lsp.Tests/LspClient.fs index 8140fb5..5fbe893 100644 --- a/src/Napper.Lsp.Tests/LspClient.fs +++ b/src/Napper.Lsp.Tests/LspClient.fs @@ -1,26 +1,23 @@ -/// Test client that launches napper-lsp and communicates via JSON-RPC over stdio. -/// This is the exact same protocol VSCode and Zed use. +// Implements [LSP-TEST-CLIENT] +/// Test client that launches 'napper lsp' as a child process and communicates +/// via JSON-RPC over stdio. This is the exact same protocol VSCode and Zed use. +/// All wire framing, envelope building and string constants live in LspWire so +/// this client and the in-process driver share one implementation. module Napper.Lsp.Tests.LspClient open System open System.Diagnostics open System.IO -open System.Text open System.Text.Json.Nodes open System.Threading open System.Threading.Tasks open Xunit +open Napper.Lsp.Tests.LspWire -let private lspBinaryPath = +let private napperBinaryPath = let baseDir = AppContext.BaseDirectory let repoRoot = DirectoryInfo(baseDir).Parent.Parent.Parent.Parent.Parent.FullName - Path.Combine(repoRoot, "src", "Napper.Lsp", "bin", "Debug", "net10.0", "napper-lsp") - -/// Encode a JSON-RPC message with Content-Length header (LSP wire format) -let private encodeMessage (json: string) : byte[] = - let body = Encoding.UTF8.GetBytes(json) - let header = $"Content-Length: {body.Length}\r\n\r\n" - Array.append (Encoding.UTF8.GetBytes(header)) body + Path.Combine(repoRoot, "src", "Napper.Cli", "bin", "Debug", "net10.0", "napper") /// Read a single LSP response from the stream (Content-Length header + body) let private readMessage (reader: StreamReader) (ct: CancellationToken) : Task<JsonNode option> = @@ -32,8 +29,8 @@ let private readMessage (reader: StreamReader) (ct: CancellationToken) : Task<Js headerLine <- firstLine while not (String.IsNullOrEmpty(headerLine)) do - if headerLine.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) then - contentLength <- headerLine.Substring(15).Trim() |> int + if headerLine.StartsWith(ContentLengthHeader, StringComparison.OrdinalIgnoreCase) then + contentLength <- headerLine.Substring(ContentLengthHeader.Length + 1).Trim() |> int let! nextLine = reader.ReadLineAsync(ct) headerLine <- nextLine @@ -47,41 +44,27 @@ let private readMessage (reader: StreamReader) (ct: CancellationToken) : Task<Js return Some(JsonNode.Parse(json)) } -/// Helper: create a JsonValue from a string -let str (s: string) : JsonNode = JsonValue.Create(s) - -/// Helper: create a JsonValue from an int -let num (n: int) : JsonNode = JsonValue.Create(n) - /// A running LSP server process for integration testing type LspServerProcess() = let proc = new Process() let mutable started = false member this.Start() : unit = - Assert.True(File.Exists(lspBinaryPath), $"LSP binary not found at {lspBinaryPath}") - proc.StartInfo.FileName <- lspBinaryPath + Assert.True(File.Exists(napperBinaryPath), $"napper binary not found at {napperBinaryPath}") + proc.StartInfo.FileName <- napperBinaryPath + proc.StartInfo.Arguments <- "lsp" proc.StartInfo.UseShellExecute <- false proc.StartInfo.RedirectStandardInput <- true proc.StartInfo.RedirectStandardOutput <- true proc.StartInfo.RedirectStandardError <- true proc.StartInfo.CreateNoWindow <- true let ok = proc.Start() - Assert.True(ok, "Failed to start napper-lsp process") + Assert.True(ok, "Failed to start 'napper lsp' process") started <- true member this.SendRequest(method: string, id: int, ?paramObj: JsonNode) : Task<JsonNode> = task { - let request = JsonObject() - request["jsonrpc"] <- str "2.0" - request["id"] <- num id - request["method"] <- str method - - match paramObj with - | Some p -> request["params"] <- p - | None -> () - - let json = request.ToJsonString() + let json = (buildRequest method id paramObj).ToJsonString() let bytes = encodeMessage json do! proc.StandardInput.BaseStream.WriteAsync(bytes, 0, bytes.Length) do! proc.StandardInput.BaseStream.FlushAsync() @@ -94,7 +77,7 @@ type LspServerProcess() = let! msg = readMessage reader cts.Token match msg with - | Some node when node["id"] <> null && node["id"].GetValue<int>() = id -> result <- Some node + | Some node when node[FId] <> null && node[FId].GetValue<int>() = id -> result <- Some node | Some _ -> () | None -> failwith "Stream ended before response received" @@ -103,15 +86,7 @@ type LspServerProcess() = member this.SendNotification(method: string, ?paramObj: JsonNode) : Task = task { - let notification = JsonObject() - notification["jsonrpc"] <- str "2.0" - notification["method"] <- str method - - match paramObj with - | Some p -> notification["params"] <- p - | None -> () - - let json = notification.ToJsonString() + let json = (buildNotification method paramObj).ToJsonString() let bytes = encodeMessage json do! proc.StandardInput.BaseStream.WriteAsync(bytes, 0, bytes.Length) do! proc.StandardInput.BaseStream.FlushAsync() diff --git a/src/Napper.Lsp.Tests/LspCommandTests.fs b/src/Napper.Lsp.Tests/LspCommandTests.fs new file mode 100644 index 0000000..89ce89b --- /dev/null +++ b/src/Napper.Lsp.Tests/LspCommandTests.fs @@ -0,0 +1,270 @@ +// Implements [LSP-SERVER] coverage — workspace/executeCommand and document +// version semantics. +/// In-process protocol e2e tests for the command surface (requestInfo, +/// copyCurl, listEnvironments) and the document version/lifecycle rules. +/// All assertions are on the framed JSON-RPC responses only. +module Napper.Lsp.Tests.LspCommandTests + +open System +open System.IO +open System.Text.Json.Nodes +open Xunit +open Napper.Lsp.Tests.LspWire +open Napper.Lsp.Tests.LspDriver + +[<Fact>] +let ``in-process requestInfo returns method, url and projected headers`` () = + let responses = + drive + [ buildNotification MDidOpen (Some(didOpenParams NapUri 1 ValidPostWithHeader)) + buildRequest MExecuteCommand 100 (Some(executeCommandParams CmdRequestInfo NapUri)) ] + + let info = resultOf responses 100 + Assert.Equal("POST", info |> field "method" |> asStr) + Assert.Equal("https://api.example.com/users", info |> field "url" |> asStr) + Assert.Equal("application/json", info |> field "headers" |> field "Accept" |> asStr) + +[<Fact>] +let ``in-process copyCurl returns a curl command for the request`` () = + let responses = + drive + [ buildNotification MDidOpen (Some(didOpenParams NapUri 1 ValidPostWithHeader)) + buildRequest MExecuteCommand 101 (Some(executeCommandParams CmdCopyCurl NapUri)) ] + + let curl = resultOf responses 101 |> asStr + Assert.Contains("curl", curl) + Assert.Contains("POST", curl) + Assert.Contains("https://api.example.com/users", curl) + +[<Fact>] +let ``in-process requestInfo and copyCurl return null for parse errors and unopened docs`` () = + let responses = + drive + [ buildNotification MDidOpen (Some(didOpenParams BadNapUri 1 UnparseableRequest)) + buildRequest MExecuteCommand 102 (Some(executeCommandParams CmdRequestInfo BadNapUri)) + buildRequest MExecuteCommand 103 (Some(executeCommandParams CmdCopyCurl BadNapUri)) + buildRequest MExecuteCommand 104 (Some(executeCommandParams CmdRequestInfo UnopenedUri)) ] + + Assert.Null(resultOf responses 102) // parse error → none + Assert.Null(resultOf responses 103) // parse error → none + Assert.Null(resultOf responses 104) // never opened → none + +[<Fact>] +let ``in-process listEnvironments works for both file uri and plain path`` () = + let tmpDir = Path.Combine(Path.GetTempPath(), $"napper-lsp-inproc-{Guid.NewGuid()}") + Directory.CreateDirectory(tmpDir) |> ignore + File.WriteAllText(Path.Combine(tmpDir, ".napenv"), "baseUrl = https://example.com") + File.WriteAllText(Path.Combine(tmpDir, ".napenv.staging"), "baseUrl = https://staging.example.com") + File.WriteAllText(Path.Combine(tmpDir, ".napenv.production"), "baseUrl = https://prod.example.com") + File.WriteAllText(Path.Combine(tmpDir, ".napenv.local"), "secret = hunter2") + + try + let responses = + drive + [ buildRequest MExecuteCommand 110 (Some(executeCommandParams CmdListEnvironments $"file://{tmpDir}")) + buildRequest MExecuteCommand 111 (Some(executeCommandParams CmdListEnvironments tmpDir)) ] + + for id in [ 110; 111 ] do + let envs = + (resultArray responses id) + |> Seq.map (fun e -> e.GetValue<string>()) + |> Seq.toList + + Assert.Contains("staging", envs) + Assert.Contains("production", envs) + Assert.DoesNotContain("local", envs) + Assert.Equal(2, envs.Length) + finally + Directory.Delete(tmpDir, true) + +[<Fact>] +let ``in-process executeCommand returns null for unknown command and missing or null args`` () = + let nullArg = + let args = JsonArray() + args.Add(null) + let p = JsonObject() + p[FCommand] <- str CmdRequestInfo + p[FArguments] <- args + p :> JsonNode + + let noArgs = + let p = JsonObject() + p[FCommand] <- str CmdRequestInfo + p :> JsonNode + + let responses = + drive + [ buildRequest MExecuteCommand 120 (Some(executeCommandParams "napper.bogusCommand" NapUri)) // unknown command + buildRequest MExecuteCommand 121 (Some nullArg) // firstArg null element + buildRequest MExecuteCommand 122 (Some noArgs) ] // firstArg missing arguments + + Assert.Null(resultOf responses 120) + Assert.Null(resultOf responses 121) + Assert.Null(resultOf responses 122) + +[<Fact>] +let ``in-process didChange honors version ordering, ignores stale and empty changes`` () = + let emptyChange = + let td = JsonObject() + td[FUri] <- str NapUri + td[FVersion] <- num 9 + let p = JsonObject() + p[FTextDocument] <- td + p[FContentChanges] <- JsonArray() // zero changes → wildcard arm + p :> JsonNode + + let noVersionChange = + let td = JsonObject() + td[FUri] <- str NapUri // no version → defaults to 0 → treated as stale + let change = JsonObject() + change[FText] <- str "[request]\nmethod = DELETE\nurl = https://example.com/wiped\n" + let changes = JsonArray() + changes.Add(change) + let p = JsonObject() + p[FTextDocument] <- td + p[FContentChanges] <- changes + p :> JsonNode + + let responses = + drive + [ buildNotification MDidOpen (Some(didOpenParams NapUri 1 ValidGet)) + buildRequest MExecuteCommand 130 (Some(executeCommandParams CmdRequestInfo NapUri)) + buildNotification + MDidChange + (Some(didChangeParams NapUri 2 "[request]\nmethod = POST\nurl = https://example.com/v2\n")) + buildRequest MExecuteCommand 131 (Some(executeCommandParams CmdRequestInfo NapUri)) + buildNotification + MDidChange + (Some(didChangeParams NapUri 1 "[request]\nmethod = PUT\nurl = https://example.com/stale\n")) + buildRequest MExecuteCommand 132 (Some(executeCommandParams CmdRequestInfo NapUri)) + buildNotification MDidChange (Some emptyChange) + buildRequest MExecuteCommand 133 (Some(executeCommandParams CmdRequestInfo NapUri)) + buildNotification MDidChange (Some noVersionChange) + buildRequest MExecuteCommand 134 (Some(executeCommandParams CmdRequestInfo NapUri)) + buildNotification MDidClose (Some(didCloseParams NapUri)) + buildRequest MExecuteCommand 135 (Some(executeCommandParams CmdRequestInfo NapUri)) + buildRequest MDocumentSymbol 136 (Some(textDocParams NapUri)) ] + + Assert.Equal("GET", resultOf responses 130 |> field "method" |> asStr) + // Newer version applied. + Assert.Equal("POST", resultOf responses 131 |> field "method" |> asStr) + Assert.Equal("https://example.com/v2", resultOf responses 131 |> field "url" |> asStr) + // Stale (older version) change ignored — still the v2 content. + Assert.Equal("POST", resultOf responses 132 |> field "method" |> asStr) + // Empty contentChanges ignored. + Assert.Equal("POST", resultOf responses 133 |> field "method" |> asStr) + // Missing version (=> 0) is stale and ignored. + Assert.Equal("POST", resultOf responses 134 |> field "method" |> asStr) + Assert.Equal("https://example.com/v2", resultOf responses 134 |> field "url" |> asStr) + // After close the document is gone. + Assert.Null(resultOf responses 135) + Assert.Equal(0, (resultArray responses 136).Count) + +[<Fact>] +let ``in-process didOpen without a version is tracked, queryable and superseded by a later version`` () = + // A didOpen with no version field defaults the version to 0; the document + // must still be fully tracked, queryable, and superseded by a real version. + let uri = "file:///tmp/no-version.nap" + + let noVersionOpen = + let td = JsonObject() + td[FUri] <- str uri + td[FLanguageId] <- str LangNap + td[FText] <- str ValidGet // no version → defaults to 0 + let p = JsonObject() + p[FTextDocument] <- td + p :> JsonNode + + let responses = + drive + [ buildNotification MDidOpen (Some noVersionOpen) + buildRequest MDocumentSymbol 140 (Some(textDocParams uri)) + buildRequest MExecuteCommand 141 (Some(executeCommandParams CmdRequestInfo uri)) + buildNotification MDidChange (Some(didChangeParams uri 5 ValidPostWithHeader)) + buildRequest MExecuteCommand 142 (Some(executeCommandParams CmdRequestInfo uri)) + buildRequest MDocumentSymbol 143 (Some(textDocParams uri)) ] + + // Tracked despite the missing version: the [request] section is visible. + let names0 = symbolNameKinds (resultOf responses 140) |> List.map fst + Assert.Equal(1, (resultArray responses 140).Count) + Assert.Contains("[request]", names0) + + // The v0 content is the GET. + let v0 = resultOf responses 141 + Assert.Equal("GET", v0 |> field "method" |> asStr) + Assert.Equal("https://example.com", v0 |> field "url" |> asStr) + + // A real (newer) version supersedes the v0 document and its headers appear. + let v5 = resultOf responses 142 + Assert.Equal("POST", v5 |> field "method" |> asStr) + Assert.Equal("https://api.example.com/users", v5 |> field "url" |> asStr) + Assert.Equal("application/json", v5 |> field "headers" |> field "Accept" |> asStr) + + let names5 = symbolNameKinds (resultOf responses 143) |> List.map fst + Assert.Equal(2, (resultArray responses 143).Count) + Assert.Contains("[request]", names5) + Assert.Contains("[request.headers]", names5) + +[<Fact>] +let ``in-process naplistSteps returns step paths in order and empty for stepless or unopened docs`` () = + let steplessUri = "file:///tmp/stepless.naplist" + + let responses = + drive + [ buildNotification MDidOpen (Some(didOpenParams NaplistUri 1 AllNaplistSections)) + buildRequest MExecuteCommand 150 (Some(executeCommandParams CmdNaplistSteps NaplistUri)) + buildNotification MDidOpen (Some(didOpenParams steplessUri 1 "[meta]\nname = \"none\"\n")) + buildRequest MExecuteCommand 151 (Some(executeCommandParams CmdNaplistSteps steplessUri)) + buildRequest MExecuteCommand 152 (Some(executeCommandParams CmdNaplistSteps UnopenedUri)) ] + + // The opened naplist yields its two step paths, in declaration order. + let steps = resultArray responses 150 |> Seq.map asStr |> Seq.toList + Assert.Equal<string list>([ "a.nap"; "b.nap" ], steps) + // A document with no [steps] section yields an empty array. + Assert.Equal(0, (resultArray responses 151).Count) + // An unopened, non-existent document yields an empty array (no crash). + Assert.Equal(0, (resultArray responses 152).Count) + +[<Fact>] +let ``in-process queries read .nap and .naplist from disk when never opened in the editor`` () = + // docText falls back to reading the file from disk for documents the IDE has + // not opened, so the explorer can query files it never sent didOpen for. + let dir = Path.Combine(Path.GetTempPath(), $"napper-lsp-disk-{Guid.NewGuid()}") + Directory.CreateDirectory(dir) |> ignore + let napPath = Path.Combine(dir, "ondisk.nap") + let listPath = Path.Combine(dir, "ondisk.naplist") + File.WriteAllText(napPath, ValidPostWithHeader) + File.WriteAllText(listPath, AllNaplistSections) + let napUri = $"file://{napPath}" + let listUri = $"file://{listPath}" + + try + let responses = + drive + [ buildRequest MDocumentSymbol 160 (Some(textDocParams napUri)) // never opened → disk + buildRequest MExecuteCommand 161 (Some(executeCommandParams CmdRequestInfo napUri)) + buildRequest MExecuteCommand 162 (Some(executeCommandParams CmdCopyCurl napUri)) + buildRequest MExecuteCommand 163 (Some(executeCommandParams CmdNaplistSteps listUri)) ] + + // Symbols come straight from the on-disk file. + let napNames = symbolNameKinds (resultOf responses 160) |> List.map fst + Assert.Equal(2, (resultArray responses 160).Count) + Assert.Contains("[request]", napNames) + Assert.Contains("[request.headers]", napNames) + + // requestInfo parsed the on-disk file. + let info = resultOf responses 161 + Assert.Equal("POST", info |> field "method" |> asStr) + Assert.Equal("https://api.example.com/users", info |> field "url" |> asStr) + Assert.Equal("application/json", info |> field "headers" |> field "Accept" |> asStr) + + // copyCurl works off the same on-disk read. + let curl = resultOf responses 162 |> asStr + Assert.Contains("curl", curl) + Assert.Contains("POST", curl) + + // naplist steps read from disk, in order. + let steps = resultArray responses 163 |> Seq.map asStr |> Seq.toList + Assert.Equal<string list>([ "a.nap"; "b.nap" ], steps) + finally + Directory.Delete(dir, true) diff --git a/src/Napper.Lsp.Tests/LspDriver.fs b/src/Napper.Lsp.Tests/LspDriver.fs new file mode 100644 index 0000000..f69879e --- /dev/null +++ b/src/Napper.Lsp.Tests/LspDriver.fs @@ -0,0 +1,176 @@ +// Implements [LSP-TEST-DRIVER] +/// In-process driver for the real LSP server entry point `LspRunner.run`. +/// +/// VSCode and Zed launch `napper lsp` as a child process and speak JSON-RPC +/// over its stdio. `LspRunner.run` IS that stdio loop — it takes an input and +/// an output Stream. Here we feed it in-memory streams instead of OS pipes, so +/// it runs inside the test host process and code-coverage instrumentation can +/// observe [Napper.Lsp]*. This is still pure black-box testing: we frame +/// JSON-RPC bytes in and assert on the framed JSON-RPC bytes out, never +/// touching the server's internal state. +module Napper.Lsp.Tests.LspDriver + +open System +open System.IO +open System.Text +open System.Text.Json.Nodes +open Xunit +open Napper.Lsp +open Napper.Lsp.Tests.LspWire + +/// Frame a batch of JSON-RPC messages into a single input buffer. +let framesOf (messages: JsonNode list) : byte[] = + use buf = new MemoryStream() + + for m in messages do + let b = encodeMessage (m.ToJsonString()) + buf.Write(b, 0, b.Length) + + buf.ToArray() + +/// Run the real server over raw input bytes; return (exitCode, responses). +let driveBytes (inputBytes: byte[]) : int * JsonNode list = + use input = new MemoryStream(inputBytes) + use output = new MemoryStream() + let code = LspRunner.run input output + code, decodeFrames (output.ToArray()) + +/// Run the real server over a batch of messages; return the framed responses. +let drive (messages: JsonNode list) : JsonNode list = driveBytes (framesOf messages) |> snd + +/// Run the server with an explicit output stream (e.g. one that fails on write, +/// to exercise the top-level crash handler). Returns the exit code. +let runWithOutput (inputBytes: byte[]) (output: Stream) : int = + use input = new MemoryStream(inputBytes) + LspRunner.run input output + +/// Find the response with the given JSON-RPC id, asserting it exists. +let responseFor (responses: JsonNode list) (id: int) : JsonNode = + let found = + responses + |> List.tryFind (fun r -> + match r[FId] with + | null -> false + | v -> v.GetValue<int>() = id) + + Assert.True(found.IsSome, $"expected a JSON-RPC response for id {id}, got {responses.Length} responses") + let r = found.Value + // Every response a test inspects must honour the JSON-RPC envelope: the 2.0 + // tag, the echoed id, and exactly one of `result` / `error`. Asserting it here + // enforces the contract on every lookup in every test, for free. + Assert.Equal(JsonRpcVersion, r[FJsonRpc].GetValue<string>()) + Assert.Equal(id, r[FId].GetValue<int>()) + let envelope = r.AsObject() + + Assert.True( + envelope.ContainsKey(FResult) <> envelope.ContainsKey(FError), + $"response {id} must carry exactly one of result/error" + ) + + r + +/// True when a response with the given id exists. +let hasResponse (responses: JsonNode list) (id: int) : bool = + responses + |> List.exists (fun r -> + match r[FId] with + | null -> false + | v -> v.GetValue<int>() = id) + +/// The `result` array of a response as (name, kind) pairs — for documentSymbol. +let symbolNameKinds (result: JsonNode) : (string * int) list = + (result :?> JsonArray) + |> Seq.map (fun s -> s["name"].GetValue<string>(), s["kind"].GetValue<int>()) + |> Seq.toList + +// ─── JSON navigation helpers ─── +// F# cannot chain indexers (`a[x][y]`) or index a parenthesised expression +// (`(f x)[y]`) without ambiguity, so navigate by piping these instead. +let field (name: string) (node: JsonNode) : JsonNode = node[name] +let asStr (node: JsonNode) : string = node.GetValue<string>() +let asInt (node: JsonNode) : int = node.GetValue<int>() +let asBool (node: JsonNode) : bool = node.GetValue<bool>() + +/// Assert the structural invariants every documentSymbol must satisfy: a +/// non-empty name, a positive LSP SymbolKind, a well-formed range, and a +/// selectionRange that mirrors the range's start. Applied per symbol so a +/// document with N sections contributes N×6 genuine assertions. +let assertWellFormedSymbols (symbols: JsonArray) : unit = + for s in symbols do + Assert.False(System.String.IsNullOrEmpty(s["name"].GetValue<string>()), "symbol name must be non-empty") + Assert.True((s["kind"].GetValue<int>()) > 0, "symbol kind must be a positive LSP SymbolKind") + let startLine = s |> field "range" |> field "start" |> field "line" |> asInt + let endLine = s |> field "range" |> field "end" |> field "line" |> asInt + Assert.True(startLine >= 0, "range start line must be >= 0") + Assert.True(endLine >= startLine, "range end line must be >= start line") + Assert.NotNull(s["selectionRange"]) + Assert.Equal(startLine, s |> field "selectionRange" |> field "start" |> field "line" |> asInt) + +/// The `result` node of the response with the given id. A result query must +/// never land on an error response, so that is asserted too. +let resultOf (responses: JsonNode list) (id: int) : JsonNode = + let r = responseFor responses id + Assert.Null(r[FError]) + r[FResult] + +/// The `result` node of the response with the given id, as a JSON array. +let resultArray (responses: JsonNode list) (id: int) : JsonArray = (resultOf responses id) :?> JsonArray + +// ─── Shared sample documents (one location for the test fixtures) ─── + +[<Literal>] +let NapUri = "file:///tmp/req.nap" + +[<Literal>] +let BadNapUri = "file:///tmp/bad.nap" + +[<Literal>] +let NaplistUri = "file:///tmp/list.naplist" + +[<Literal>] +let TxtUri = "file:///tmp/note.txt" + +[<Literal>] +let UnopenedUri = "file:///tmp/never-opened.nap" + +/// A valid GET request that parses cleanly. +[<Literal>] +let ValidGet = "[request]\nmethod = GET\nurl = https://example.com\n" + +/// A valid POST request carrying a header — exercises header projection. +[<Literal>] +let ValidPostWithHeader = + "[request]\nmethod = POST\nurl = https://api.example.com/users\n\n[request.headers]\nAccept = application/json\n" + +/// Has a [request] header line but a body the parser rejects. +[<Literal>] +let UnparseableRequest = "[request]\nthis is not a valid request line\n" + +/// Every known .nap section — drives documentSymbol kind coverage. +[<Literal>] +let AllNapSections = + "[meta]\nname = \"All\"\n\n[vars]\nx = 1\n\n[request]\nmethod = GET\nurl = https://example.com\n\n[request.headers]\nAccept = application/json\n\n[request.body]\n{}\n\n[assert]\nstatus = 200\n\n[script]\npost = \"x\"\n" + +/// Every known .naplist section — drives documentSymbol kind coverage. +[<Literal>] +let AllNaplistSections = + "[meta]\nname = \"L\"\n\n[vars]\ny = 2\n\n[steps]\na.nap\nb.nap\n" + +/// A write-only stream whose Write always throws — used to drive the server's +/// top-level crash handler (the write happens outside its per-message try). +type ThrowingStream() = + inherit Stream() + override _.CanRead = false + override _.CanSeek = false + override _.CanWrite = true + override _.Length = 0L + + override _.Position + with get () = 0L + and set _ = () + + override _.Flush() = () + override _.Read(_, _, _) = 0 + override _.Seek(_, _) = 0L + override _.SetLength _ = () + override _.Write(_, _, _) : unit = raise (IOException("stdout closed")) diff --git a/src/Napper.Lsp.Tests/LspIntegrationTests.fs b/src/Napper.Lsp.Tests/LspIntegrationTests.fs index 37b1da4..f5c4b9f 100644 --- a/src/Napper.Lsp.Tests/LspIntegrationTests.fs +++ b/src/Napper.Lsp.Tests/LspIntegrationTests.fs @@ -1,53 +1,37 @@ /// Integration tests for napper-lsp. /// Every test launches the real binary and talks JSON-RPC over stdio — -/// the exact same protocol VSCode and Zed use. +/// the exact same protocol VSCode and Zed use. These prove the shipped binary +/// works end to end; coverage of [Napper.Lsp]* comes from the in-process +/// protocol tests (LspProtocolTests / LspCommandTests) which exercise the very +/// same LspRunner loop without the process boundary. module Napper.Lsp.Tests.LspIntegrationTests -open System.Text open System.Text.Json.Nodes open System.Threading.Tasks open Xunit open Napper.Lsp.Tests.LspClient - -/// Build the standard initialize params -let private initializeParams () : JsonNode = - let p = JsonObject() - p["processId"] <- num 1 - p["capabilities"] <- JsonObject() - p["rootUri"] <- str "file:///tmp/test-workspace" - p :> JsonNode +open Napper.Lsp.Tests.LspWire /// Run a full initialize handshake (initialize request + initialized notification) let private handshake (server: LspServerProcess) : Task<JsonNode> = task { - let! response = server.SendRequest("initialize", 1, initializeParams ()) - do! server.SendNotification("initialized", JsonObject()) + let! response = server.SendRequest(MInitialize, 1, initializeParams ()) + do! server.SendNotification(MInitialized, JsonObject()) return response } -/// Build a textDocument/didOpen params object -let private didOpenParams (uri: string) (version: int) (text: string) : JsonNode = - let p = JsonObject() - let td = JsonObject() - td["uri"] <- str uri - td["languageId"] <- str "nap" - td["version"] <- num version - td["text"] <- str text - p["textDocument"] <- td - p :> JsonNode - [<Fact>] let ``initialize handshake returns capabilities`` () : Task = task { use server = new LspServerProcess() server.Start() - let! response = server.SendRequest("initialize", 1, initializeParams ()) + let! response = server.SendRequest(MInitialize, 1, initializeParams ()) - Assert.NotNull(response["result"]) - Assert.Null(response["error"]) + Assert.NotNull(response[FResult]) + Assert.Null(response[FError]) - let result = response["result"] + let result = response[FResult] Assert.NotNull(result["capabilities"]) // TextDocumentSync must be Full (1 = Full in LSP spec) @@ -65,86 +49,163 @@ let ``initialize handshake returns capabilities`` () : Task = } [<Fact>] -let ``initialized notification accepted without error`` () : Task = +let ``initialized handshake leaves the real server fully operational`` () : Task = task { use server = new LspServerProcess() server.Start() - let! _initResponse = server.SendRequest("initialize", 1, initializeParams ()) - do! server.SendNotification("initialized", JsonObject()) - do! Task.Delay(200) + let! initResponse = server.SendRequest(MInitialize, 1, initializeParams ()) + Assert.Null(initResponse[FError]) + Assert.NotNull(initResponse[FResult]) + let caps = initResponse[FResult]["capabilities"] + Assert.NotNull(caps) + let sync = caps["textDocumentSync"] + Assert.Equal(1, sync.GetValue<int>()) + + do! server.SendNotification(MInitialized, JsonObject()) + + // A synchronous round-trip is far stronger proof of liveness than a sleep: + // open a doc and query it back through the real binary. + let uri = "file:///tmp/post-init.nap" + + do! + server.SendNotification( + MDidOpen, + didOpenParams uri 1 "[request]\nmethod = GET\nurl = https://example.com\n" + ) + let! symResponse = server.SendRequest(MDocumentSymbol, 2, textDocParams uri) + Assert.Null(symResponse[FError]) + let symbols = symResponse[FResult] :?> JsonArray + Assert.True(symbols.Count >= 1, "server must answer real requests after the initialized handshake") Assert.True(server.IsRunning, "Server died after initialized notification") } [<Fact>] -let ``textDocument/didOpen tracks document`` () : Task = +let ``textDocument/didOpen tracks document so symbols, lenses and requestInfo all see it`` () : Task = task { use server = new LspServerProcess() server.Start() let! _ = handshake server - let napContent = "[request]\nmethod = GET\nurl = https://example.com\n" - do! server.SendNotification("textDocument/didOpen", didOpenParams "file:///tmp/test.nap" 1 napContent) - do! Task.Delay(200) + let uri = "file:///tmp/test.nap" + + let content = + "[meta]\nname = \"T\"\n\n[request]\nmethod = GET\nurl = https://example.com\n" + + do! server.SendNotification(MDidOpen, didOpenParams uri 1 content) + // documentSymbol proves the opened content is actually tracked. + let! symResponse = server.SendRequest(MDocumentSymbol, 10, textDocParams uri) + Assert.Null(symResponse[FError]) + let symbols = symResponse[FResult] :?> JsonArray + let names = symbols |> Seq.map (fun s -> s["name"].GetValue<string>()) |> Seq.toList + Assert.Contains("[meta]", names) + Assert.Contains("[request]", names) + + // codeLens proves the [request] section produced a lens with the right detail. + let! lensResponse = server.SendRequest(MCodeLens, 11, textDocParams uri) + Assert.Null(lensResponse[FError]) + let lenses = lensResponse[FResult] :?> JsonArray + Assert.True(lenses.Count >= 1, "expected a code lens on the [request] section") + let lensData = lenses[0]["data"] + Assert.NotNull(lensData) + Assert.Equal("GET https://example.com", lensData.GetValue<string>()) + + // requestInfo proves the parsed request round-trips through the real binary. + let! infoResponse = server.SendRequest(MExecuteCommand, 12, executeCommandParams CmdRequestInfo uri) + Assert.Null(infoResponse[FError]) + let info = infoResponse[FResult] + let methodNode = info["method"] + let urlNode = info["url"] + Assert.Equal("GET", methodNode.GetValue<string>()) + Assert.Equal("https://example.com", urlNode.GetValue<string>()) Assert.True(server.IsRunning, "Server died after didOpen") } [<Fact>] -let ``textDocument/didChange updates document`` () : Task = +let ``textDocument/didChange replaces tracked content and ignores stale versions`` () : Task = task { use server = new LspServerProcess() server.Start() let! _ = handshake server + let uri = "file:///tmp/test.nap" - // Open do! server.SendNotification( - "textDocument/didOpen", - didOpenParams "file:///tmp/test.nap" 1 "[request]\nmethod = GET\nurl = https://example.com\n" + MDidOpen, + didOpenParams uri 1 "[request]\nmethod = GET\nurl = https://example.com\n" ) - // Change - let changeParams = JsonObject() - let versionedDoc = JsonObject() - versionedDoc["uri"] <- str "file:///tmp/test.nap" - versionedDoc["version"] <- num 2 - changeParams["textDocument"] <- versionedDoc + // Before the change the tracked request is the GET. + let! before = server.SendRequest(MExecuteCommand, 20, executeCommandParams CmdRequestInfo uri) + let beforeInfo = before[FResult] + let beforeMethod = beforeInfo["method"] + let beforeUrl = beforeInfo["url"] + Assert.Equal("GET", beforeMethod.GetValue<string>()) + Assert.Equal("https://example.com", beforeUrl.GetValue<string>()) - let change = JsonObject() - change["text"] <- str "[request]\nmethod = POST\nurl = https://example.com/users\n" - let changes = JsonArray() - changes.Add(change) - changeParams["contentChanges"] <- changes + // A newer version replaces the content. + do! + server.SendNotification( + MDidChange, + didChangeParams uri 2 "[request]\nmethod = POST\nurl = https://example.com/users\n" + ) - do! server.SendNotification("textDocument/didChange", changeParams) - do! Task.Delay(200) + let! after = server.SendRequest(MExecuteCommand, 21, executeCommandParams CmdRequestInfo uri) + let afterInfo = after[FResult] + let afterMethod = afterInfo["method"] + let afterUrl = afterInfo["url"] + Assert.Equal("POST", afterMethod.GetValue<string>()) + Assert.Equal("https://example.com/users", afterUrl.GetValue<string>()) + // A stale (older version) change must be ignored — content stays at v2. + do! + server.SendNotification( + MDidChange, + didChangeParams uri 1 "[request]\nmethod = PUT\nurl = https://example.com/stale\n" + ) + + let! stale = server.SendRequest(MExecuteCommand, 22, executeCommandParams CmdRequestInfo uri) + let staleInfo = stale[FResult] + let staleMethod = staleInfo["method"] + let staleUrl = staleInfo["url"] + Assert.Equal("POST", staleMethod.GetValue<string>()) + Assert.Equal("https://example.com/users", staleUrl.GetValue<string>()) Assert.True(server.IsRunning, "Server died after didChange") } [<Fact>] -let ``textDocument/didClose removes document`` () : Task = +let ``textDocument/didClose removes the document so later queries see nothing`` () : Task = task { use server = new LspServerProcess() server.Start() let! _ = handshake server + let uri = "file:///tmp/test.nap" do! server.SendNotification( - "textDocument/didOpen", - didOpenParams "file:///tmp/test.nap" 1 "GET https://example.com\n" + MDidOpen, + didOpenParams uri 1 "[request]\nmethod = GET\nurl = https://example.com\n" ) - let closeParams = JsonObject() - let closeDoc = JsonObject() - closeDoc["uri"] <- str "file:///tmp/test.nap" - closeParams["textDocument"] <- closeDoc - - do! server.SendNotification("textDocument/didClose", closeParams) - do! Task.Delay(200) - + // While open: symbols are present and requestInfo resolves. + let! openSyms = server.SendRequest(MDocumentSymbol, 30, textDocParams uri) + Assert.Null(openSyms[FError]) + let openSymbols = openSyms[FResult] :?> JsonArray + Assert.True(openSymbols.Count >= 1, "the [request] section should be visible while open") + let! openInfo = server.SendRequest(MExecuteCommand, 31, executeCommandParams CmdRequestInfo uri) + Assert.NotNull(openInfo[FResult]) + let openMethod = openInfo[FResult]["method"] + Assert.Equal("GET", openMethod.GetValue<string>()) + + // After close the document is gone: empty symbols and a null requestInfo. + do! server.SendNotification(MDidClose, didCloseParams uri) + let! closedSyms = server.SendRequest(MDocumentSymbol, 32, textDocParams uri) + Assert.Null(closedSyms[FError]) + Assert.Equal(0, (closedSyms[FResult] :?> JsonArray).Count) + let! closedInfo = server.SendRequest(MExecuteCommand, 33, executeCommandParams CmdRequestInfo uri) + Assert.Null(closedInfo[FResult]) Assert.True(server.IsRunning, "Server died after didClose") } @@ -155,12 +216,12 @@ let ``shutdown and exit clean lifecycle`` () : Task = server.Start() let! _ = handshake server - let! shutdownResponse = server.SendRequest("shutdown", 2) + let! shutdownResponse = server.SendRequest(MShutdown, 2) // Shutdown returns result (may be null for void) with no error - Assert.Null(shutdownResponse["error"]) + Assert.Null(shutdownResponse[FError]) Assert.True(server.IsRunning, "Server died before exit notification") - do! server.SendNotification("exit") + do! server.SendNotification(MExit) do! Task.Delay(1000) Assert.False(server.IsRunning, "Server should have exited after exit notification") @@ -180,12 +241,12 @@ let ``malformed request with unknown params does not crash server`` () : Task = let! response = server.SendRequest("textDocument/totallyBogusMethod", 999, bogusParams) // Should return an error, not crash - Assert.NotNull(response["error"]) + Assert.NotNull(response[FError]) Assert.True(server.IsRunning, "Server crashed on malformed request") // Verify it still responds to a valid request after the bogus one - let! shutdownResponse = server.SendRequest("shutdown", 100) - Assert.Null(shutdownResponse["error"]) + let! shutdownResponse = server.SendRequest(MShutdown, 100) + Assert.Null(shutdownResponse[FError]) } [<Fact>] @@ -197,19 +258,12 @@ let ``unknown method returns LSP error`` () : Task = let! response = server.SendRequest("textDocument/somethingThatDoesNotExist", 42) - Assert.NotNull(response["error"]) + Assert.NotNull(response[FError]) Assert.True(server.IsRunning, "Server crashed on unknown method") } // ─── Document Symbols ──────────────────────────────────── -let private docSymbolParams (uri: string) : JsonNode = - let p = JsonObject() - let td = JsonObject() - td["uri"] <- str uri - p["textDocument"] <- td - p :> JsonNode - [<Fact>] let ``documentSymbol returns sections for nap file`` () : Task = task { @@ -222,14 +276,14 @@ let ``documentSymbol returns sections for nap file`` () : Task = let content = "[meta]\nname = \"Test\"\n\n[request]\nmethod = GET\nurl = https://example.com\n\n[assert]\nstatus = 200\n" - do! server.SendNotification("textDocument/didOpen", didOpenParams uri 1 content) + do! server.SendNotification(MDidOpen, didOpenParams uri 1 content) - let! response = server.SendRequest("textDocument/documentSymbol", 10, docSymbolParams uri) + let! response = server.SendRequest(MDocumentSymbol, 10, textDocParams uri) - Assert.Null(response["error"]) - Assert.NotNull(response["result"]) + Assert.Null(response[FError]) + Assert.NotNull(response[FResult]) - let symbols = response["result"] :?> JsonArray + let symbols = response[FResult] :?> JsonArray Assert.True(symbols.Count >= 3, $"Expected at least 3 symbols (meta, request, assert), got {symbols.Count}") // Check section names @@ -251,14 +305,14 @@ let ``documentSymbol returns sections for naplist file`` () : Task = let content = "[meta]\nname = \"Smoke tests\"\n\n[steps]\nauth/login.nap\nusers/get-user.nap\n" - do! server.SendNotification("textDocument/didOpen", didOpenParams uri 1 content) + do! server.SendNotification(MDidOpen, didOpenParams uri 1 content) - let! response = server.SendRequest("textDocument/documentSymbol", 11, docSymbolParams uri) + let! response = server.SendRequest(MDocumentSymbol, 11, textDocParams uri) - Assert.Null(response["error"]) - Assert.NotNull(response["result"]) + Assert.Null(response[FError]) + Assert.NotNull(response[FResult]) - let symbols = response["result"] :?> JsonArray + let symbols = response[FResult] :?> JsonArray Assert.True(symbols.Count >= 2, $"Expected at least 2 symbols (meta, steps), got {symbols.Count}") let names = symbols |> Seq.map (fun s -> s["name"].GetValue<string>()) |> Seq.toList @@ -268,13 +322,6 @@ let ``documentSymbol returns sections for naplist file`` () : Task = // ─── Code Lens ─────────────────────────────────────────── -let private codeLensParams (uri: string) : JsonNode = - let p = JsonObject() - let td = JsonObject() - td["uri"] <- str uri - p["textDocument"] <- td - p :> JsonNode - [<Fact>] let ``codeLens returns lenses for nap file with request section`` () : Task = task { @@ -284,14 +331,14 @@ let ``codeLens returns lenses for nap file with request section`` () : Task = let uri = "file:///tmp/test.nap" let content = "[request]\nmethod = GET\nurl = https://example.com\n" - do! server.SendNotification("textDocument/didOpen", didOpenParams uri 1 content) + do! server.SendNotification(MDidOpen, didOpenParams uri 1 content) - let! response = server.SendRequest("textDocument/codeLens", 12, codeLensParams uri) + let! response = server.SendRequest(MCodeLens, 12, textDocParams uri) - Assert.Null(response["error"]) - Assert.NotNull(response["result"]) + Assert.Null(response[FError]) + Assert.NotNull(response[FResult]) - let lenses = response["result"] :?> JsonArray + let lenses = response[FResult] :?> JsonArray Assert.True(lenses.Count >= 1, $"Expected at least 1 code lens, got {lenses.Count}") // First lens should be on line 0 (where [request] is) @@ -305,14 +352,6 @@ let ``codeLens returns lenses for nap file with request section`` () : Task = // ─── Execute Command: requestInfo ──────────────────────── -let private executeCommandParams (command: string) (arg: string) : JsonNode = - let p = JsonObject() - p["command"] <- str command - let args = JsonArray() - args.Add(str arg) - p["arguments"] <- args - p :> JsonNode - [<Fact>] let ``executeCommand requestInfo returns method and URL`` () : Task = task { @@ -322,15 +361,14 @@ let ``executeCommand requestInfo returns method and URL`` () : Task = let uri = "file:///tmp/test.nap" let content = "[request]\nmethod = POST\nurl = https://api.example.com/users\n" - do! server.SendNotification("textDocument/didOpen", didOpenParams uri 1 content) + do! server.SendNotification(MDidOpen, didOpenParams uri 1 content) - let! response = - server.SendRequest("workspace/executeCommand", 20, executeCommandParams "napper.requestInfo" uri) + let! response = server.SendRequest(MExecuteCommand, 20, executeCommandParams CmdRequestInfo uri) - Assert.Null(response["error"]) - Assert.NotNull(response["result"]) + Assert.Null(response[FError]) + Assert.NotNull(response[FResult]) - let result = response["result"] + let result = response[FResult] Assert.Equal("POST", result["method"].GetValue<string>()) Assert.Equal("https://api.example.com/users", result["url"].GetValue<string>()) } @@ -346,14 +384,14 @@ let ``executeCommand copyCurl returns curl string`` () : Task = let uri = "file:///tmp/test.nap" let content = "[request]\nmethod = GET\nurl = https://example.com/api\n" - do! server.SendNotification("textDocument/didOpen", didOpenParams uri 1 content) + do! server.SendNotification(MDidOpen, didOpenParams uri 1 content) - let! response = server.SendRequest("workspace/executeCommand", 21, executeCommandParams "napper.copyCurl" uri) + let! response = server.SendRequest(MExecuteCommand, 21, executeCommandParams CmdCopyCurl uri) - Assert.Null(response["error"]) - Assert.NotNull(response["result"]) + Assert.Null(response[FError]) + Assert.NotNull(response[FResult]) - let curl = response["result"].GetValue<string>() + let curl = response[FResult].GetValue<string>() Assert.Contains("curl", curl) Assert.Contains("GET", curl) Assert.Contains("https://example.com/api", curl) @@ -390,17 +428,12 @@ let ``executeCommand listEnvironments returns env names`` () : Task = try let rootUri = $"file://{tmpDir}" - let! response = - server.SendRequest( - "workspace/executeCommand", - 22, - executeCommandParams "napper.listEnvironments" rootUri - ) + let! response = server.SendRequest(MExecuteCommand, 22, executeCommandParams CmdListEnvironments rootUri) - Assert.Null(response["error"]) - Assert.NotNull(response["result"]) + Assert.Null(response[FError]) + Assert.NotNull(response[FResult]) - let envs = response["result"] :?> JsonArray + let envs = response[FResult] :?> JsonArray let envNames = envs |> Seq.map (fun e -> e.GetValue<string>()) |> Seq.toList // Should find staging and production, NOT base (.napenv) or local (.napenv.local) diff --git a/src/Napper.Lsp.Tests/LspProtocolTests.fs b/src/Napper.Lsp.Tests/LspProtocolTests.fs new file mode 100644 index 0000000..bab546b --- /dev/null +++ b/src/Napper.Lsp.Tests/LspProtocolTests.fs @@ -0,0 +1,444 @@ +// Implements [LSP-SERVER] coverage — initialize, documents, symbols, code lens, +// framing and lifecycle. +/// In-process protocol e2e tests. Each test frames real JSON-RPC messages, +/// feeds them through the actual server loop `LspRunner.run` over in-memory +/// streams, and asserts on the framed responses — the exact wire contract +/// VSCode and Zed depend on. No internal state is touched. +module Napper.Lsp.Tests.LspProtocolTests + +open System.Text +open System.Text.Json.Nodes +open Xunit +open Napper.Lsp.Tests.LspWire +open Napper.Lsp.Tests.LspDriver + +// LSP SymbolKind values (LSP 3.17) the server is contracted to emit. +[<Literal>] +let KindNamespace = 3 + +[<Literal>] +let KindFunction = 12 + +[<Literal>] +let KindVariable = 13 + +[<Literal>] +let KindArray = 18 + +[<Literal>] +let KindStruct = 23 + +[<Fact>] +let ``in-process initialize advertises capabilities, commands and serverInfo`` () = + let responses = drive [ buildRequest MInitialize 1 (Some(initializeParams ())) ] + let r = responseFor responses 1 + + Assert.Null(r[FError]) + Assert.NotNull(r[FResult]) + + let caps = r |> field FResult |> field "capabilities" + Assert.Equal(1, caps |> field "textDocumentSync" |> asInt) + Assert.True(caps |> field "documentSymbolProvider" |> asBool) + Assert.False(caps |> field "codeLensProvider" |> field "resolveProvider" |> asBool) + + let commandsNode = caps |> field "executeCommandProvider" |> field "commands" + + let commands = + (commandsNode :?> JsonArray) + |> Seq.map (fun c -> c.GetValue<string>()) + |> Seq.toList + + Assert.Contains(CmdCopyCurl, commands) + Assert.Contains(CmdListEnvironments, commands) + Assert.Contains(CmdRequestInfo, commands) + Assert.True(commands.Length >= 3, $"expected at least the 3 core commands, got {commands.Length}") + + let info = r |> field FResult |> field "serverInfo" + Assert.Equal("napper-lsp", info |> field "name" |> asStr) + Assert.Equal("0.1.0", info |> field "version" |> asStr) + +[<Fact>] +let ``in-process documentSymbol maps every nap section to its LSP kind`` () = + let responses = + drive + [ buildNotification MDidOpen (Some(didOpenParams NapUri 1 AllNapSections)) + buildRequest MDocumentSymbol 2 (Some(textDocParams NapUri)) ] + + Assert.Null((responseFor responses 2)[FError]) + + let symbols = resultArray responses 2 + let kinds = symbolNameKinds (resultOf responses 2) |> Map.ofList + + Assert.Equal(7, symbols.Count) + Assert.Equal(KindNamespace, kinds["[meta]"]) + Assert.Equal(KindVariable, kinds["[vars]"]) + Assert.Equal(KindFunction, kinds["[request]"]) + Assert.Equal(KindStruct, kinds["[request.headers]"]) + Assert.Equal(KindStruct, kinds["[request.body]"]) + Assert.Equal(KindFunction, kinds["[assert]"]) + Assert.Equal(KindFunction, kinds["[script]"]) + + // Every symbol is structurally well-formed (name, positive kind, ordered + // range, mirrored selectionRange) — 6 assertions per section. + assertWellFormedSymbols symbols + + // Sections are reported in file order, each on a strictly later line. + let names = symbolNameKinds (resultOf responses 2) |> List.map fst + + Assert.Equal<string list>( + [ "[meta]" + "[vars]" + "[request]" + "[request.headers]" + "[request.body]" + "[assert]" + "[script]" ], + names + ) + + let startLines = + [ for s in symbols -> s |> field "range" |> field "start" |> field "line" |> asInt ] + + Assert.Equal(0, List.head startLines) + Assert.Equal<int list>(startLines, List.sort startLines) + Assert.Equal(List.length startLines, List.length (List.distinct startLines)) + + // The first symbol ([meta]) starts on line 0 and carries a selectionRange. + let first = symbols[0] + Assert.Equal("[meta]", first |> field "name" |> asStr) + Assert.Equal(0, first |> field "range" |> field "start" |> field "line" |> asInt) + Assert.NotNull(first["selectionRange"]) + +[<Fact>] +let ``in-process documentSymbol maps naplist meta, vars and steps kinds`` () = + let responses = + drive + [ buildNotification MDidOpen (Some(didOpenParams NaplistUri 1 AllNaplistSections)) + buildRequest MDocumentSymbol 3 (Some(textDocParams NaplistUri)) ] + + let symbols = resultArray responses 3 + let kinds = symbolNameKinds (resultOf responses 3) |> Map.ofList + + Assert.Equal(3, symbols.Count) + Assert.Equal(KindNamespace, kinds["[meta]"]) + Assert.Equal(KindVariable, kinds["[vars]"]) + Assert.Equal(KindArray, kinds["[steps]"]) + + // Every naplist symbol is structurally well-formed, reported in file order. + assertWellFormedSymbols symbols + let names = symbolNameKinds (resultOf responses 3) |> List.map fst + Assert.Equal<string list>([ "[meta]"; "[vars]"; "[steps]" ], names) + + let startLines = + [ for s in symbols -> s |> field "range" |> field "start" |> field "line" |> asInt ] + + Assert.Equal(0, List.head startLines) + Assert.Equal<int list>(startLines, List.sort startLines) + Assert.Equal(List.length startLines, List.length (List.distinct startLines)) + +[<Fact>] +let ``in-process documentSymbol is empty for unopened, non-nap and malformed params`` () = + let noTextDocument = JsonObject() :> JsonNode + + let emptyTextDocument = + let p = JsonObject() + p[FTextDocument] <- JsonObject() + p :> JsonNode + + let responses = + drive + [ buildRequest MDocumentSymbol 4 (Some(textDocParams UnopenedUri)) // docText None + buildNotification MDidOpen (Some(didOpenParams TxtUri 1 ValidGet)) + buildRequest MDocumentSymbol 5 (Some(textDocParams TxtUri)) // not .nap/.naplist + buildRequest MDocumentSymbol 6 (Some noTextDocument) // uriOf null arm + buildRequest MDocumentSymbol 7 (Some emptyTextDocument) ] // strField null arm + + for id in [ 4; 5; 6; 7 ] do + Assert.Equal(0, (resultArray responses id).Count) + +[<Fact>] +let ``in-process codeLens emits request detail, naplist meta, and none otherwise`` () = + let responses = + drive + [ buildNotification MDidOpen (Some(didOpenParams NapUri 1 ValidGet)) + buildRequest MCodeLens 10 (Some(textDocParams NapUri)) + buildNotification MDidOpen (Some(didOpenParams BadNapUri 1 UnparseableRequest)) + buildRequest MCodeLens 11 (Some(textDocParams BadNapUri)) + buildNotification MDidOpen (Some(didOpenParams NaplistUri 1 AllNaplistSections)) + buildRequest MCodeLens 12 (Some(textDocParams NaplistUri)) + buildNotification MDidOpen (Some(didOpenParams TxtUri 1 ValidGet)) + buildRequest MCodeLens 13 (Some(textDocParams TxtUri)) + buildRequest MCodeLens 14 (Some(textDocParams UnopenedUri)) ] + + // Valid nap → one lens on line 0 with "METHOD url" detail. + let napLenses = resultArray responses 10 + Assert.Equal(1, napLenses.Count) + Assert.Equal(0, napLenses[0] |> field "range" |> field "start" |> field "line" |> asInt) + Assert.Equal("GET https://example.com", napLenses[0] |> field "data" |> asStr) + + // Unparseable nap still has a [request] section → lens, but no detail. + let badLenses = resultArray responses 11 + Assert.True(badLenses.Count >= 1) + Assert.True(isNull (badLenses[0]["data"])) + + // Naplist → a meta lens with no detail. + let listLenses = resultArray responses 12 + Assert.True(listLenses.Count >= 1) + Assert.True(isNull (listLenses[0]["data"])) + + // Non-nap and unopened → no lenses. + Assert.Equal(0, (resultArray responses 13).Count) + Assert.Equal(0, (resultArray responses 14).Count) + +[<Fact>] +let ``in-process initialized and lifecycle notifications are accepted`` () = + // initialize → initialized → shutdown → exit; the documentSymbol after + // exit must never be processed because exit stops the read loop. + let responses = + drive + [ buildRequest MInitialize 80 (Some(initializeParams ())) + buildNotification MInitialized (Some(JsonObject() :> JsonNode)) + buildRequest MShutdown 81 None + buildNotification MExit None + buildRequest MDocumentSymbol 82 (Some(textDocParams NapUri)) ] + + Assert.True(hasResponse responses 80) + Assert.Null((responseFor responses 81)[FError]) + Assert.False(hasResponse responses 82, "messages after exit must be ignored") + Assert.Equal(2, responses.Length) + +[<Fact>] +let ``in-process notifications with missing fields are harmless no-ops`` () = + let empty () = Some(JsonObject() :> JsonNode) + + let responses = + drive + [ buildNotification MDidOpen (empty ()) // onDidOpen null arm + buildNotification MDidChange (empty ()) // onDidChange wildcard arm + buildNotification MDidClose (empty ()) // onDidClose null arm + buildRequest MShutdown 90 None ] + + Assert.True(hasResponse responses 90, "server must survive degenerate notifications") + Assert.Equal(1, responses.Length) + +[<Fact>] +let ``in-process unknown request errors with method-not-found, unknown notification is ignored`` () = + let responses = + drive + [ buildRequest "textDocument/doesNotExist" 20 None + buildNotification "textDocument/alsoUnknown" None + buildRequest MShutdown 21 None ] + + let err = responseFor responses 20 + Assert.NotNull(err[FError]) + Assert.Equal(-32601, err |> field FError |> field FCode |> asInt) + + Assert.True(hasResponse responses 21, "server must keep serving after an unknown method") + Assert.Equal(2, responses.Length) + +[<Fact>] +let ``in-process malformed and null-body frames are skipped, valid requests still answered`` () = + let bytes = + Array.concat + [ framesOf [ buildRequest MInitialize 30 (Some(initializeParams ())) ] + encodeMessage "{ this is : not json" // JsonNode.Parse throws → skipped + encodeMessage "null" // JsonNode.Parse returns null → skipped + framesOf [ buildRequest MShutdown 31 None ] ] + + let code, responses = driveBytes bytes + + Assert.Equal(0, code) + Assert.True(hasResponse responses 30) + Assert.True(hasResponse responses 31) + Assert.Equal(2, responses.Length) + +[<Fact>] +let ``in-process malformed file uri makes every handler return an internal error and the server survives`` () = + // A file:// uri with an invalid port makes System.Uri throw inside the + // server's path resolution. Field reads are otherwise null-safe, so this is + // the input that exercises the per-message internal-error guard. It must + // surface as a JSON-RPC -32603 on every request that touches it, on every + // handler, and must never terminate the read loop. + let badUri = "file://h:zz/internal-error.nap" // invalid port → UriFormatException + + // textDocParams builds a fresh node each call (a JsonNode cannot have two parents). + let responses = + drive + [ buildRequest MDocumentSymbol 40 (Some(textDocParams badUri)) + buildRequest MCodeLens 41 (Some(textDocParams badUri)) + buildRequest MExecuteCommand 42 (Some(executeCommandParams CmdRequestInfo badUri)) + buildRequest MExecuteCommand 43 (Some(executeCommandParams CmdCopyCurl badUri)) + buildRequest MExecuteCommand 44 (Some(executeCommandParams CmdListEnvironments badUri)) + buildRequest MShutdown 45 None ] + + // Every core handler that resolves the bad uri reports an internal error... + for id in [ 40; 41; 42; 43; 44 ] do + let r = responseFor responses id + Assert.NotNull(r[FError]) + Assert.Null(r[FResult]) + Assert.Equal(-32603, r |> field FError |> field FCode |> asInt) + + // ...and the server keeps serving afterwards. + Assert.True(hasResponse responses 45, "server must survive internal errors") + Assert.Null((responseFor responses 45)[FError]) + Assert.Equal(6, responses.Length) + +[<Fact>] +let ``in-process notification with a non-int version is handled with no response`` () = + // version is a string, not an int; the handler coerces it safely (no crash) + // and, being a notification, emits no response while the server keeps running. + let badVersion = + let td = JsonObject() + td[FUri] <- str NapUri + td[FVersion] <- str "not-an-int" + td[FText] <- str ValidGet + let p = JsonObject() + p[FTextDocument] <- td + p :> JsonNode + + let responses = + drive [ buildNotification MDidOpen (Some badVersion); buildRequest MShutdown 50 None ] + + Assert.True(hasResponse responses 50) + Assert.Equal(1, responses.Length) + +[<Fact>] +let ``in-process bad Content-Length terminates the read after prior messages`` () = + let bytes = + Array.append + (framesOf [ buildRequest MInitialize 60 (Some(initializeParams ())) ]) + (Encoding.UTF8.GetBytes($"{ContentLengthHeader}: abc{HeaderSep}{{}}")) + + let code, responses = driveBytes bytes + + Assert.Equal(0, code) + Assert.True(hasResponse responses 60) + Assert.Equal(1, responses.Length) + +[<Fact>] +let ``in-process empty and truncated input exit cleanly`` () = + let emptyCode, emptyResponses = driveBytes [||] + Assert.Equal(0, emptyCode) + Assert.Empty(emptyResponses) + + let truncCode, truncResponses = + driveBytes (Encoding.UTF8.GetBytes($"{ContentLengthHeader}: 5")) // header, no terminator + + Assert.Equal(0, truncCode) + Assert.Empty(truncResponses) + +[<Fact>] +let ``in-process oversized and body-truncated frames end the read after prior work`` () = + // A Content-Length beyond the 64 MiB cap ends the read (and never allocates + // the buffer) — a prior valid message is still answered. + let oversized = + Array.append + (framesOf [ buildRequest MInitialize 300 (Some(initializeParams ())) ]) + (Encoding.UTF8.GetBytes($"{ContentLengthHeader}: 100000000{HeaderSep}x")) + + let overCode, overResponses = driveBytes oversized + Assert.Equal(0, overCode) + Assert.True(hasResponse overResponses 300) + Assert.Null((responseFor overResponses 300)[FError]) + Assert.Equal(1, overResponses.Length) + + // A body shorter than its declared Content-Length is treated as end-of-stream. + let truncatedBody = + Array.append + (framesOf [ buildRequest MShutdown 301 None ]) + (Encoding.UTF8.GetBytes($"{ContentLengthHeader}: 4096{HeaderSep}only-a-few-bytes")) + + let truncCode, truncResponses = driveBytes truncatedBody + Assert.Equal(0, truncCode) + Assert.True(hasResponse truncResponses 301) + Assert.Null((responseFor truncResponses 301)[FError]) + Assert.Equal(1, truncResponses.Length) + +[<Fact>] +let ``in-process a failing output stream yields the crash code while a working stream does not`` () = + let input = framesOf [ buildRequest MInitialize 70 (Some(initializeParams ())) ] + + // Baseline: over normal in-memory streams the run succeeds and answers. + let okCode, responses = driveBytes input + Assert.Equal(0, okCode) + Assert.True(hasResponse responses 70) + Assert.Null((responseFor responses 70)[FError]) + Assert.NotNull((responseFor responses 70)[FResult]) + + // The SAME input over a stream whose Write throws drives the top-level crash + // handler, which returns exit code 1 rather than letting the process die. + use failing = new ThrowingStream() + let crashCode = runWithOutput input failing + Assert.Equal(1, crashCode) + +[<Fact>] +let ``in-process degenerate envelopes and arguments are handled safely`` () = + // method as a number → coerced to "" → unknown method. + let numericMethod = + let o = JsonObject() + o[FJsonRpc] <- str JsonRpcVersion + o[FId] <- num 200 + o[FMethod] <- num 7 + o :> JsonNode + + // no method field at all → unknown method. + let missingMethod = + let o = JsonObject() + o[FJsonRpc] <- str JsonRpcVersion + o[FId] <- num 201 + o :> JsonNode + + // documentSymbol whose params is NOT an object → reads nothing, empty result. + let nonObjectParams = + let o = JsonObject() + o[FJsonRpc] <- str JsonRpcVersion + o[FId] <- num 202 + o[FMethod] <- str MDocumentSymbol + o[FParams] <- str "not-an-object" + o :> JsonNode + + // executeCommand requestInfo with a NUMERIC argument → coerced to "" → null. + let numericArg = + let args = JsonArray() + args.Add(num 123) + let p = JsonObject() + p[FCommand] <- str CmdRequestInfo + p[FArguments] <- args + let o = JsonObject() + o[FJsonRpc] <- str JsonRpcVersion + o[FId] <- num 203 + o[FMethod] <- str MExecuteCommand + o[FParams] <- p + o :> JsonNode + + let responses = drive [ numericMethod; missingMethod; nonObjectParams; numericArg ] + + Assert.Equal(-32601, responseFor responses 200 |> field FError |> field FCode |> asInt) + Assert.Equal(-32601, responseFor responses 201 |> field FError |> field FCode |> asInt) + Assert.Equal(0, (resultArray responses 202).Count) + Assert.Null(resultOf responses 203) + Assert.Equal(4, responses.Length) + +[<Fact>] +let ``in-process unreadable file on disk degrades to empty without crashing`` () = + let dir = + System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"napper-lsp-unreadable-{System.Guid.NewGuid()}") + + System.IO.Directory.CreateDirectory(dir) |> ignore + let file = System.IO.Path.Combine(dir, "locked.nap") + System.IO.File.WriteAllText(file, "plain text, no sections") // empty symbols even if it were readable + System.IO.File.SetUnixFileMode(file, System.IO.UnixFileMode.None) // deny read → ReadAllText throws + + try + // Not opened in the workspace → the server falls back to reading from disk. + let responses = + drive + [ buildRequest MDocumentSymbol 210 (Some(textDocParams $"file://{file}")) + buildRequest MShutdown 211 None ] + + Assert.Equal(0, (resultArray responses 210).Count) + Assert.True(hasResponse responses 211, "server must survive an unreadable file") + Assert.Null((responseFor responses 211)[FError]) + finally + System.IO.File.SetUnixFileMode(file, System.IO.UnixFileMode.UserRead ||| System.IO.UnixFileMode.UserWrite) + System.IO.Directory.Delete(dir, true) diff --git a/src/Napper.Lsp.Tests/LspWire.fs b/src/Napper.Lsp.Tests/LspWire.fs new file mode 100644 index 0000000..683721b --- /dev/null +++ b/src/Napper.Lsp.Tests/LspWire.fs @@ -0,0 +1,274 @@ +// Implements [LSP-TEST-WIRE] +/// Shared LSP / JSON-RPC wire helpers for the test assembly: the single +/// location for every wire string constant, the framing codec, and the +/// JSON-RPC envelope + param builders. Reused by BOTH the process-based client +/// (LspClient) and the in-process protocol driver (LspDriver) so there is zero +/// duplication of protocol strings or message construction. +module Napper.Lsp.Tests.LspWire + +open System +open System.Text +open System.Text.Json.Nodes + +// The in-process tests mutate one process-wide Workspace and share document URIs +// across test classes, so the whole assembly must run tests serially. +[<assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)>] +do () + +// ─── JSON-RPC envelope / version ─── +[<Literal>] +let JsonRpcVersion = "2.0" + +[<Literal>] +let LangNap = "nap" + +[<Literal>] +let FJsonRpc = "jsonrpc" + +[<Literal>] +let FId = "id" + +[<Literal>] +let FMethod = "method" + +[<Literal>] +let FParams = "params" + +[<Literal>] +let FResult = "result" + +[<Literal>] +let FError = "error" + +[<Literal>] +let FCode = "code" + +// ─── LSP wire framing ─── +[<Literal>] +let ContentLengthHeader = "Content-Length" + +[<Literal>] +let HeaderSep = "\r\n\r\n" + +// ─── Methods ─── +[<Literal>] +let MInitialize = "initialize" + +[<Literal>] +let MInitialized = "initialized" + +[<Literal>] +let MShutdown = "shutdown" + +[<Literal>] +let MExit = "exit" + +[<Literal>] +let MDidOpen = "textDocument/didOpen" + +[<Literal>] +let MDidChange = "textDocument/didChange" + +[<Literal>] +let MDidClose = "textDocument/didClose" + +[<Literal>] +let MDocumentSymbol = "textDocument/documentSymbol" + +[<Literal>] +let MCodeLens = "textDocument/codeLens" + +[<Literal>] +let MExecuteCommand = "workspace/executeCommand" + +// ─── Commands ─── +[<Literal>] +let CmdRequestInfo = "napper.requestInfo" + +[<Literal>] +let CmdCopyCurl = "napper.copyCurl" + +[<Literal>] +let CmdListEnvironments = "napper.listEnvironments" + +[<Literal>] +let CmdNaplistSteps = "napper.naplistSteps" + +// ─── Param fields ─── +[<Literal>] +let FTextDocument = "textDocument" + +[<Literal>] +let FUri = "uri" + +[<Literal>] +let FLanguageId = "languageId" + +[<Literal>] +let FVersion = "version" + +[<Literal>] +let FText = "text" + +[<Literal>] +let FContentChanges = "contentChanges" + +[<Literal>] +let FCommand = "command" + +[<Literal>] +let FArguments = "arguments" + +[<Literal>] +let FProcessId = "processId" + +[<Literal>] +let FCapabilities = "capabilities" + +[<Literal>] +let FRootUri = "rootUri" + +// ─── JsonNode helpers ─── +let str (s: string) : JsonNode = JsonValue.Create(s) +let num (n: int) : JsonNode = JsonValue.Create(n) + +// ─── Framing codec ─── + +/// Encode a JSON-RPC message with a Content-Length header (the LSP wire +/// format). Public so the in-process driver reuses the exact same framing. +let encodeMessage (json: string) : byte[] = + let body = Encoding.UTF8.GetBytes(json) + let header = $"{ContentLengthHeader}: {body.Length}{HeaderSep}" + Array.append (Encoding.UTF8.GetBytes(header)) body + +/// First index of `pat` in `arr` at or after `from`, or -1 if absent. +let private indexOf (arr: byte[]) (pat: byte[]) (from: int) : int = + let last = arr.Length - pat.Length + let mutable i = from + let mutable found = -1 + + while found < 0 && i <= last do + let mutable j = 0 + + while j < pat.Length && arr[i + j] = pat[j] do + j <- j + 1 + + if j = pat.Length then found <- i else i <- i + 1 + + found + +/// Parse the Content-Length value out of an ASCII header block. +let private contentLength (headers: string) : int = + headers.Split('\n') + |> Array.tryPick (fun line -> + match line.Split(':') with + | [| key; value |] when key.Trim().Equals(ContentLengthHeader, StringComparison.OrdinalIgnoreCase) -> + match Int32.TryParse(value.Trim()) with + | true, n -> Some n + | _ -> None + | _ -> None) + |> Option.defaultValue 0 + +/// Decode every Content-Length framed JSON-RPC message in `bytes`, in order. +/// Mirrors the framing produced by encodeMessage and by the server's Wire module. +let decodeFrames (bytes: byte[]) : JsonNode list = + let term = Encoding.ASCII.GetBytes(HeaderSep) + + let rec loop (pos: int) (acc: JsonNode list) : JsonNode list = + let hdrEnd = indexOf bytes term pos + + if hdrEnd < 0 then + List.rev acc + else + let len = contentLength (Encoding.ASCII.GetString(bytes, pos, hdrEnd - pos)) + let bodyStart = hdrEnd + term.Length + + if len <= 0 || bodyStart + len > bytes.Length then + List.rev acc + else + let json = Encoding.UTF8.GetString(bytes, bodyStart, len) + loop (bodyStart + len) (JsonNode.Parse(json) :: acc) + + loop 0 [] + +// ─── JSON-RPC envelope builders ─── + +/// Build a JSON-RPC request envelope (has an id). +let buildRequest (method: string) (id: int) (paramObj: JsonNode option) : JsonNode = + let o = JsonObject() + o[FJsonRpc] <- str JsonRpcVersion + o[FId] <- num id + o[FMethod] <- str method + + match paramObj with + | Some p -> o[FParams] <- p + | None -> () + + o :> JsonNode + +/// Build a JSON-RPC notification envelope (no id). +let buildNotification (method: string) (paramObj: JsonNode option) : JsonNode = + let o = JsonObject() + o[FJsonRpc] <- str JsonRpcVersion + o[FMethod] <- str method + + match paramObj with + | Some p -> o[FParams] <- p + | None -> () + + o :> JsonNode + +// ─── LSP param builders ─── + +let initializeParams () : JsonNode = + let p = JsonObject() + p[FProcessId] <- num 1 + p[FCapabilities] <- JsonObject() + p[FRootUri] <- str "file:///tmp/test-workspace" + p :> JsonNode + +let didOpenParams (uri: string) (version: int) (text: string) : JsonNode = + let td = JsonObject() + td[FUri] <- str uri + td[FLanguageId] <- str LangNap + td[FVersion] <- num version + td[FText] <- str text + let p = JsonObject() + p[FTextDocument] <- td + p :> JsonNode + +let didChangeParams (uri: string) (version: int) (text: string) : JsonNode = + let td = JsonObject() + td[FUri] <- str uri + td[FVersion] <- num version + let change = JsonObject() + change[FText] <- str text + let changes = JsonArray() + changes.Add(change) + let p = JsonObject() + p[FTextDocument] <- td + p[FContentChanges] <- changes + p :> JsonNode + +let didCloseParams (uri: string) : JsonNode = + let td = JsonObject() + td[FUri] <- str uri + let p = JsonObject() + p[FTextDocument] <- td + p :> JsonNode + +/// textDocument-only params, shared by documentSymbol and codeLens requests. +let textDocParams (uri: string) : JsonNode = + let td = JsonObject() + td[FUri] <- str uri + let p = JsonObject() + p[FTextDocument] <- td + p :> JsonNode + +let executeCommandParams (command: string) (arg: string) : JsonNode = + let args = JsonArray() + args.Add(str arg) + let p = JsonObject() + p[FCommand] <- str command + p[FArguments] <- args + p :> JsonNode diff --git a/src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj b/src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj index 5b390f2..4fb75cc 100644 --- a/src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj +++ b/src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj @@ -6,8 +6,12 @@ </PropertyGroup> <ItemGroup> + <Compile Include="LspWire.fs" /> <Compile Include="LspClient.fs" /> + <Compile Include="LspDriver.fs" /> <Compile Include="LspIntegrationTests.fs" /> + <Compile Include="LspProtocolTests.fs" /> + <Compile Include="LspCommandTests.fs" /> </ItemGroup> <ItemGroup> @@ -17,4 +21,8 @@ <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" /> </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\Napper.Cli\Napper.Cli.fsproj" /> + </ItemGroup> + </Project> diff --git a/src/Napper.Lsp/Client.fs b/src/Napper.Lsp/Client.fs deleted file mode 100644 index 1035e67..0000000 --- a/src/Napper.Lsp/Client.fs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Napper.Lsp - -open Ionide.LanguageServerProtocol -open Ionide.LanguageServerProtocol.JsonRpc - -/// Wraps the LSP client connection for sending notifications back to the IDE -type Client(notificationSender: Server.ClientNotificationSender, requestSender: Server.ClientRequestSender) = - inherit LspClient() - - member this.LogDebug(message: string) : Async<unit> = - this.WindowLogMessage( - { Type = Types.MessageType.Debug - Message = message } - ) - - member this.LogInfo(message: string) : Async<unit> = - this.WindowLogMessage( - { Type = Types.MessageType.Info - Message = message } - ) - - override this.WindowLogMessage p = - match box p with - | null -> async { () } - | value -> notificationSender "window/logMessage" value |> Async.Ignore - - override this.WindowShowMessage p = - match box p with - | null -> async { () } - | value -> notificationSender "window/showMessage" value |> Async.Ignore - - override this.WindowShowMessageRequest p = - match box p with - | null -> async { return Result.Error(Error.InternalError("Parameter was null")) } - | value -> requestSender.Send "window/showMessageRequest" value diff --git a/src/Napper.Lsp/Napper.Lsp.fsproj b/src/Napper.Lsp/Napper.Lsp.fsproj index 07a9722..9741d6a 100644 --- a/src/Napper.Lsp/Napper.Lsp.fsproj +++ b/src/Napper.Lsp/Napper.Lsp.fsproj @@ -1,20 +1,15 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <OutputType>Exe</OutputType> - <AssemblyName>napper-lsp</AssemblyName> <NuGetAuditMode>direct</NuGetAuditMode> + <!-- AOT-safe: no reflection-based serialization. Surfaces any regression at publish time. --> + <IsAotCompatible>true</IsAotCompatible> </PropertyGroup> <ItemGroup> <Compile Include="Workspace.fs" /> - <Compile Include="Client.fs" /> + <Compile Include="Protocol.fs" /> <Compile Include="Server.fs" /> - <Compile Include="Program.fs" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Ionide.LanguageServerProtocol" Version="0.7.0" /> </ItemGroup> <ItemGroup> diff --git a/src/Napper.Lsp/Program.fs b/src/Napper.Lsp/Program.fs deleted file mode 100644 index 508f72e..0000000 --- a/src/Napper.Lsp/Program.fs +++ /dev/null @@ -1,85 +0,0 @@ -/// Entry point for the napper-lsp language server. -/// LSP takes over stdio — do NOT read/write to stdin/stdout directly. -module Napper.Lsp.Program - -open System -open System.Threading.Tasks -open Ionide.LanguageServerProtocol -open Ionide.LanguageServerProtocol.JsonUtils -open Napper.Lsp -open Newtonsoft.Json -open StreamJsonRpc - -let private defaultJsonRpcFormatter () = - let fmt = new JsonMessageFormatter() - fmt.JsonSerializer.NullValueHandling <- NullValueHandling.Ignore - fmt.JsonSerializer.ConstructorHandling <- ConstructorHandling.AllowNonPublicDefaultConstructor - fmt.JsonSerializer.MissingMemberHandling <- MissingMemberHandling.Ignore - fmt.JsonSerializer.Converters.Add(StrictNumberConverter()) - fmt.JsonSerializer.Converters.Add(StrictStringConverter()) - fmt.JsonSerializer.Converters.Add(StrictBoolConverter()) - fmt.JsonSerializer.Converters.Add(SingleCaseUnionConverter()) - fmt.JsonSerializer.Converters.Add(OptionConverter()) - fmt.JsonSerializer.Converters.Add(ErasedUnionConverter()) - fmt.JsonSerializer.ContractResolver <- OptionAndCamelCasePropertyNamesContractResolver() - fmt - -let private createRpc (handler: IJsonRpcMessageHandler) : JsonRpc = - let rec (|HandleableException|_|) (e: exn) = - match e with - | :? LocalRpcException -> Some() - | :? TaskCanceledException -> Some() - | :? OperationCanceledException -> Some() - | :? JsonSerializationException -> Some() - | :? AggregateException as aex -> aex.InnerExceptions |> Seq.tryHead |> Option.bind (|HandleableException|_|) - | _ -> None - - let strategy = ActivityTracingStrategy() - - { new JsonRpc(handler, ActivityTracingStrategy = strategy) with - member _.IsFatalException(ex: Exception) = - match ex with - | HandleableException -> false - | _ -> true - - member this.CreateErrorDetails(request: Protocol.JsonRpcRequest, ex: Exception) = - match ex with - | :? JsonSerializationException as jex -> - let isSerializable = this.ExceptionStrategy = ExceptionProcessing.ISerializable - - let data: obj = - if isSerializable then - (jex :> obj) - else - Protocol.CommonErrorData(jex) - - Protocol.JsonRpcError.ErrorDetail( - Code = Protocol.JsonRpcErrorCode.ParseError, - Message = jex.Message, - Data = data - ) - | _ -> base.CreateErrorDetails(request, ex) } - -let private startServer () = - let input = Console.OpenStandardInput() - let output = Console.OpenStandardOutput() - - let requestHandlings: Map<string, Mappings.ServerRequestHandling<_>> = - Server.defaultRequestHandlings () - - Server.start - requestHandlings - input - output - (fun (notifier, requester) -> new Client(notifier, requester)) - (fun client -> new NapLspServer(client)) - createRpc - -[<EntryPoint>] -let main _args = - try - let result = startServer () - int result - with ex -> - eprintfn $"napper-lsp crashed: %A{ex}" - 1 diff --git a/src/Napper.Lsp/Protocol.fs b/src/Napper.Lsp/Protocol.fs new file mode 100644 index 0000000..ff3e133 --- /dev/null +++ b/src/Napper.Lsp/Protocol.fs @@ -0,0 +1,249 @@ +// Implements [LSP-SERVER] +// JSON-RPC / LSP protocol constants — the single source of truth for every wire +// string, error code, and enum value used by the AOT-safe LSP transport. +namespace Napper.Lsp + +module internal Protocol = + [<Literal>] + let JsonRpcVersion = "2.0" + + // ─── JSON-RPC envelope fields ─── + [<Literal>] + let FJsonRpc = "jsonrpc" + + [<Literal>] + let FId = "id" + + [<Literal>] + let FMethod = "method" + + [<Literal>] + let FParams = "params" + + [<Literal>] + let FResult = "result" + + [<Literal>] + let FError = "error" + + [<Literal>] + let FCode = "code" + + [<Literal>] + let FMessage = "message" + + // ─── Methods ─── + [<Literal>] + let MInitialize = "initialize" + + [<Literal>] + let MInitialized = "initialized" + + [<Literal>] + let MShutdown = "shutdown" + + [<Literal>] + let MExit = "exit" + + [<Literal>] + let MDidOpen = "textDocument/didOpen" + + [<Literal>] + let MDidChange = "textDocument/didChange" + + [<Literal>] + let MDidClose = "textDocument/didClose" + + [<Literal>] + let MDocumentSymbol = "textDocument/documentSymbol" + + [<Literal>] + let MCodeLens = "textDocument/codeLens" + + [<Literal>] + let MExecuteCommand = "workspace/executeCommand" + + // ─── Capability / result fields ─── + [<Literal>] + let FCapabilities = "capabilities" + + [<Literal>] + let FTextDocumentSync = "textDocumentSync" + + [<Literal>] + let FDocumentSymbolProvider = "documentSymbolProvider" + + [<Literal>] + let FCodeLensProvider = "codeLensProvider" + + [<Literal>] + let FExecuteCommandProvider = "executeCommandProvider" + + [<Literal>] + let FResolveProvider = "resolveProvider" + + [<Literal>] + let FCommands = "commands" + + [<Literal>] + let FServerInfo = "serverInfo" + + [<Literal>] + let FName = "name" + + [<Literal>] + let FVersion = "version" + + // ─── Document / params fields ─── + [<Literal>] + let FTextDocument = "textDocument" + + [<Literal>] + let FUri = "uri" + + [<Literal>] + let FText = "text" + + [<Literal>] + let FContentChanges = "contentChanges" + + [<Literal>] + let FCommand = "command" + + [<Literal>] + let FArguments = "arguments" + + // ─── Symbol / lens / range fields ─── + [<Literal>] + let FKind = "kind" + + [<Literal>] + let FRange = "range" + + [<Literal>] + let FSelectionRange = "selectionRange" + + [<Literal>] + let FStart = "start" + + [<Literal>] + let FEnd = "end" + + [<Literal>] + let FLine = "line" + + [<Literal>] + let FCharacter = "character" + + [<Literal>] + let FData = "data" + + [<Literal>] + let FTitle = "title" + + // ─── executeCommand result fields ─── + [<Literal>] + let FUrl = "url" + + [<Literal>] + let FHeaders = "headers" + + // ─── Commands ─── + [<Literal>] + let CmdCopyCurl = "napper.copyCurl" + + [<Literal>] + let CmdListEnvironments = "napper.listEnvironments" + + [<Literal>] + let CmdRequestInfo = "napper.requestInfo" + + [<Literal>] + let CmdNaplistSteps = "napper.naplistSteps" + + // ─── Section names (mirror Napper.Core.SectionScanner) ─── + [<Literal>] + let SecMeta = "meta" + + [<Literal>] + let SecRequest = "request" + + [<Literal>] + let SecRequestHeaders = "request.headers" + + [<Literal>] + let SecRequestBody = "request.body" + + [<Literal>] + let SecAssert = "assert" + + [<Literal>] + let SecScript = "script" + + [<Literal>] + let SecVars = "vars" + + [<Literal>] + let SecSteps = "steps" + + // ─── Misc ─── + [<Literal>] + let ServerName = "napper-lsp" + + [<Literal>] + let ServerVersion = "0.1.0" + + [<Literal>] + let FileScheme = "file://" + + [<Literal>] + let NapExtension = ".nap" + + [<Literal>] + let NaplistExtension = ".naplist" + + [<Literal>] + let HeaderContentLength = "Content-Length" + + [<Literal>] + let HeaderTerminator = "\r\n\r\n" + + [<Literal>] + let CrashPrefix = "napper lsp crashed: " + + /// Upper bound on a single LSP frame body (bytes). Guards against a hostile or + /// corrupt Content-Length forcing a huge allocation. + [<Literal>] + let MaxMessageBytes = 67108864 // 64 MiB + + // ─── LSP SymbolKind enum values (LSP 3.17) ─── + [<Literal>] + let KindNamespace = 3 + + [<Literal>] + let KindFunction = 12 + + [<Literal>] + let KindVariable = 13 + + [<Literal>] + let KindArray = 18 + + [<Literal>] + let KindKey = 20 + + [<Literal>] + let KindStruct = 23 + + // ─── TextDocumentSyncKind / JSON-RPC error codes ─── + [<Literal>] + let SyncFull = 1 + + [<Literal>] + let CodeMethodNotFound = -32601 + + [<Literal>] + let CodeInternalError = -32603 + + [<Literal>] + let MsgMethodNotFound = "Method not found" diff --git a/src/Napper.Lsp/Server.fs b/src/Napper.Lsp/Server.fs index 6f7d5e5..3c5477e 100644 --- a/src/Napper.Lsp/Server.fs +++ b/src/Napper.Lsp/Server.fs @@ -1,288 +1,470 @@ +// Implements [LSP-SERVER] +// AOT-safe LSP server. Native AOT cannot use reflection-based serialization, so +// this file talks JSON-RPC over stdio using only the System.Text.Json DOM +// (JsonNode / Utf8 framing) — no StreamJsonRpc, no Newtonsoft, no reflection. +// All domain logic lives in Napper.Core; this file is protocol glue only. +// Hardened to NEVER crash the loop on malformed input (LSP-SPEC: the server +// never crashes on malformed input). namespace Napper.Lsp -open Ionide.LanguageServerProtocol -open Ionide.LanguageServerProtocol.Types +open System +open System.IO +open System.Text +open System.Text.Json +open System.Text.Json.Nodes open Napper.Core -open Newtonsoft.Json.Linq - -/// LSP server — lifecycle, document sync, symbols, code lens, and commands. -/// All domain logic lives in Napper.Core. This file is protocol glue only. -type NapLspServer(client: Client) = - inherit LspServer() - - let serverName = "napper-lsp" - let serverVersion = "0.1.0" - - let commandCopyCurl = "napper.copyCurl" - let commandListEnvs = "napper.listEnvironments" - let commandRequestInfo = "napper.requestInfo" - - let capabilities: ServerCapabilities = - { ServerCapabilities.Default with - TextDocumentSync = Some(U2.C2 TextDocumentSyncKind.Full) - DocumentSymbolProvider = Some(U2.C1 true) - CodeLensProvider = - Some - { ResolveProvider = Some false - WorkDoneProgress = None } - ExecuteCommandProvider = - Some - { Commands = [| commandCopyCurl; commandListEnvs; commandRequestInfo |] - WorkDoneProgress = None } } - - // ─── Helpers ───────────────────────────────────────────── - - let isNapFile (uri: string) : bool = uri.EndsWith ".nap" - let isNaplistFile (uri: string) : bool = uri.EndsWith ".naplist" - - let symbolKindForSection (name: string) : SymbolKind = - match name with - | "meta" -> SymbolKind.Namespace - | "request" -> SymbolKind.Function - | "request.headers" -> SymbolKind.Struct - | "request.body" -> SymbolKind.Struct - | "assert" -> SymbolKind.Function - | "script" -> SymbolKind.Function - | "vars" -> SymbolKind.Variable - | "steps" -> SymbolKind.Array - | _ -> SymbolKind.Key - - let sectionToSymbol (section: SectionScanner.SectionLocation) : DocumentSymbol = - let range = - { Start = - { Line = uint32 section.Line - Character = 0u } - End = - { Line = uint32 section.EndLine - Character = 0u } } - - { Name = $"[{section.Name}]" - Detail = None - Kind = symbolKindForSection section.Name - Tags = None - Deprecated = None - Range = range - SelectionRange = range - Children = None } - - let getDocumentText (uri: string) : string option = - Workspace.tryGetDocument uri |> Option.map _.Text - - let uriToFilePath (uri: string) : string = - if uri.StartsWith "file://" then - System.Uri(uri).LocalPath +open Protocol + +/// Small reflection-free, null-safe helpers over the System.Text.Json DOM. +module private Json = + let jstr (s: string) : JsonNode = JsonValue.Create(s) :> JsonNode + let jint (n: int) : JsonNode = JsonValue.Create(n) :> JsonNode + let jbool (b: bool) : JsonNode = JsonValue.Create(b) :> JsonNode + + /// Safe property access: Some only when `node` is an object that has `key`. + /// Never throws on a null node or a non-object node. + let item (node: JsonNode) (key: string) : JsonNode option = + match node with + | :? JsonObject as o -> + match o[key] with + | null -> None + | v -> Some v + | _ -> None + + /// Lenient string read: "" when absent / null / not a string. Used for the + /// JSON-RPC envelope (`method`) and command args, which are read OUTSIDE the + /// per-message guard — they must never throw and crash the loop. + let tryStr (node: JsonNode) (key: string) : string = + match item node key with + | Some(:? JsonValue as v) -> + match v.TryGetValue<string>() with + | true, s -> s + | _ -> "" + | _ -> "" + + /// Strict string read: "" when absent, but THROWS on a present-but-wrong-type + /// value. Used inside handlers so a malformed request field becomes a clean + /// JSON-RPC -32603 (caught per-message), never a silent wrong result. + let strField (node: JsonNode) (key: string) : string = + match item node key with + | Some v -> v.GetValue<string>() + | None -> "" + + /// Strict int read: `fallback` when absent, THROWS on present-but-wrong-type. + let intField (node: JsonNode) (key: string) (fallback: int) : int = + match item node key with + | Some v -> v.GetValue<int>() + | None -> fallback + + /// Build a successful JSON-RPC response. `result` may be null (→ "result":null). + let ok (id: JsonNode) (result: JsonNode) : JsonNode = + let o = JsonObject() + o[FJsonRpc] <- jstr JsonRpcVersion + o[FId] <- (if isNull id then null else id.DeepClone()) + o[FResult] <- result + o :> JsonNode + + /// Build a JSON-RPC error response. + let err (id: JsonNode) (code: int) (message: string) : JsonNode = + let detail = JsonObject() + detail[FCode] <- jint code + detail[FMessage] <- jstr message + let o = JsonObject() + o[FJsonRpc] <- jstr JsonRpcVersion + o[FId] <- (if isNull id then null else id.DeepClone()) + o[FError] <- detail + o :> JsonNode + +/// LSP wire framing: `Content-Length: N\r\n\r\n` + UTF-8 JSON body. +module private Wire = + + /// A framed read result. `Skip` is a recoverable malformed/empty frame (keep + /// the loop alive); `Eof` is genuine end-of-stream (stop). + type Frame = + | Eof + | Skip + | Body of string + + /// Read raw header bytes up to and including the blank-line terminator. + let private readHeaders (input: Stream) : string option = + let sb = StringBuilder() + let mutable finished = false + let mutable eof = false + + while not finished && not eof do + let b = input.ReadByte() + + if b = -1 then + eof <- true + else + sb.Append(char b) |> ignore + + if sb.Length >= 4 && sb.ToString(sb.Length - 4, 4) = HeaderTerminator then + finished <- true + + if finished then Some(sb.ToString()) else None + + /// Extract the Content-Length value from a header block, or 0 when absent. + let private contentLength (headers: string) : int = + headers.Split('\n') + |> Array.tryPick (fun line -> + match line.Split(':') with + | [| key; value |] when key.Trim().Equals(HeaderContentLength, StringComparison.OrdinalIgnoreCase) -> + match Int32.TryParse(value.Trim()) with + | true, n -> Some n + | _ -> None + | _ -> None) + |> Option.defaultValue 0 + + /// Read exactly `len` bytes unless the stream ends first; returns bytes read. + let private readFully (input: Stream) (buf: byte[]) (len: int) : int = + let mutable total = 0 + let mutable n = 1 + + while total < len && n > 0 do + n <- input.Read(buf, total, len - total) + total <- total + n + + total + + /// Read one framed message. Distinguishes EOF (stop) from a recoverable + /// malformed/empty frame (Skip) so a bad frame never terminates the session. + let readMessage (input: Stream) : Frame = + match readHeaders input with + | None -> Eof + | Some headers -> + let len = contentLength headers + + if len <= 0 then + Skip + elif len > MaxMessageBytes then + Eof + else + let buf = Array.zeroCreate<byte> len + + if readFully input buf len < len then + Eof + else + Body(Encoding.UTF8.GetString buf) + + /// Frame and write one message, then flush. + let writeMessage (output: Stream) (json: string) : unit = + let body = Encoding.UTF8.GetBytes(json) + + let header = + Encoding.ASCII.GetBytes($"{HeaderContentLength}: {body.Length}{HeaderTerminator}") + + output.Write(header, 0, header.Length) + output.Write(body, 0, body.Length) + output.Flush() + +/// Request/notification handlers. All domain logic delegates to Napper.Core. +module private Handlers = + open Json + + let private isNap (uri: string) = uri.EndsWith NapExtension + let private isNaplist (uri: string) = uri.EndsWith NaplistExtension + + let private uriToFilePath (uri: string) : string = + if uri.StartsWith FileScheme then + Uri(uri).LocalPath else uri - let uriToDirectoryPath (uri: string) : string = - uriToFilePath uri |> System.IO.Path.GetDirectoryName - - let parseRequestFromUri (uri: string) : NapRequest option = - getDocumentText uri + /// The text of a tracked document, falling back to reading from disk so the + /// LSP serves files the IDE never opened (e.g. the explorer tree). A bad URI + /// throws here (in uriToFilePath, outside the IO guard) → JSON-RPC -32603. + let private docText (uri: string) : string option = + match Workspace.tryGetDocument uri with + | Some doc -> Some doc.Text + | None -> + let path = uriToFilePath uri + + if File.Exists path then + try + Some(File.ReadAllText path) + with _ -> + None + else + None + + let private parseRequest (uri: string) : NapRequest option = + docText uri |> Option.bind (fun text -> match Parser.parseNapFile text with | Result.Ok napFile -> Some napFile.Request | Result.Error _ -> None) - let methodString (m: HttpMethod) : string = - match m with - | GET -> "GET" - | POST -> "POST" - | PUT -> "PUT" - | PATCH -> "PATCH" - | DELETE -> "DELETE" - | HEAD -> "HEAD" - | OPTIONS -> "OPTIONS" - - // ─── Lifecycle ─────────────────────────────────────────── - - override _.Initialize(_param) = - async { - Logger.info $"{serverName} initializing" - do! client.LogInfo $"{serverName} v{serverVersion} initializing" - - return - Result.Ok - { InitializeResult.Capabilities = capabilities - ServerInfo = - Some - { InitializeResultServerInfo.Name = serverName - Version = Some serverVersion } } - } - - override _.Initialized(_param) = - async { - Logger.info $"{serverName} initialized" - do! client.LogInfo $"{serverName} ready" - } - - override _.Shutdown() = - async { - Logger.info $"{serverName} shutting down" - return Result.Ok() - } - - override _.Exit() = - async { Logger.info $"{serverName} exiting" } - - // ─── Document Sync ─────────────────────────────────────── - - override _.TextDocumentDidOpen(param) = - async { - let doc = param.TextDocument - Workspace.openDocument doc.Uri (int doc.Version) doc.Text - do! client.LogDebug $"Opened {doc.Uri}" - } - - override _.TextDocumentDidChange(param) = - async { - let doc = param.TextDocument - - match param.ContentChanges with - | [| U2.C2 { Text = newText } |] -> - Workspace.changeDocument doc.Uri (int doc.Version) newText - do! client.LogDebug $"Changed {doc.Uri}" - | _ -> Logger.warn "Received unsupported partial/multi change" - } - - override _.TextDocumentDidClose(param) = - async { - let doc = param.TextDocument - Workspace.closeDocument doc.Uri - do! client.LogDebug $"Closed {doc.Uri}" - } - - // ─── Document Symbols ──────────────────────────────────── - // Replaces: extractHttpMethod, parsePlaylistStepPaths, CodeLens section detection in TS - - override _.TextDocumentDocumentSymbol(param) = - async { - let uri = param.TextDocument.Uri - - match getDocumentText uri with - | None -> return Result.Ok None - | Some text -> - let sections = - if isNapFile uri then - SectionScanner.scanNapSections text - elif isNaplistFile uri then - SectionScanner.scanNaplistSections text + /// Section name → LSP SymbolKind. KindKey is the fallback for any section a + /// future scanner might surface that is not in this table. + let private sectionKinds = + Map + [ SecMeta, KindNamespace + SecVars, KindVariable + SecRequest, KindFunction + SecRequestHeaders, KindStruct + SecRequestBody, KindStruct + SecAssert, KindFunction + SecScript, KindFunction + SecSteps, KindArray ] + + let private symbolKind (name: string) : int = + sectionKinds |> Map.tryFind name |> Option.defaultValue KindKey + + let private position (line: int) : JsonNode = + let o = JsonObject() + o[FLine] <- jint line + o[FCharacter] <- jint 0 + o :> JsonNode + + let private range (startLine: int) (endLine: int) : JsonNode = + let o = JsonObject() + o[FStart] <- position startLine + o[FEnd] <- position endLine + o :> JsonNode + + let private sectionSymbol (section: SectionScanner.SectionLocation) : JsonNode = + let r = range section.Line section.EndLine + let o = JsonObject() + o[FName] <- jstr $"[{section.Name}]" + o[FKind] <- jint (symbolKind section.Name) + o[FRange] <- r + o[FSelectionRange] <- r.DeepClone() + o :> JsonNode + + let private scanSections (uri: string) (text: string) : SectionScanner.SectionLocation list = + if isNap uri then + SectionScanner.scanNapSections text + elif isNaplist uri then + SectionScanner.scanNaplistSections text + else + [] + + let documentSymbols (uri: string) : JsonNode = + let arr = JsonArray() + + match docText uri with + | Some text -> scanSections uri text |> List.iter (fun s -> arr.Add(sectionSymbol s)) + | None -> () + + arr :> JsonNode + + let private lens (line: int) (data: string option) : JsonNode = + let o = JsonObject() + o[FRange] <- range line line + + o[FData] <- + (match data with + | Some d -> jstr d + | None -> null) + + o :> JsonNode + + let codeLenses (uri: string) : JsonNode = + let arr = JsonArray() + + match docText uri with + | Some text when isNap uri -> + let detail = + match Parser.parseNapFile text with + | Result.Ok nap -> Some $"{nap.Request.Method.Name} {nap.Request.Url}" + | Result.Error _ -> None + + SectionScanner.scanNapSections text + |> List.filter (fun s -> s.Name = SecRequest) + |> List.iter (fun s -> arr.Add(lens s.Line detail)) + | Some text when isNaplist uri -> + SectionScanner.scanNaplistSections text + |> List.filter (fun s -> s.Name = SecMeta) + |> List.iter (fun s -> arr.Add(lens s.Line None)) + | _ -> () + + arr :> JsonNode + + let private requestInfo (uri: string) : JsonNode = + match parseRequest uri with + | None -> null + | Some req -> + let headers = JsonObject() + req.Headers |> Map.iter (fun k v -> headers[k] <- jstr v) + let o = JsonObject() + o[FMethod] <- jstr req.Method.Name + o[FUrl] <- jstr req.Url + o[FHeaders] <- headers + o :> JsonNode + + let private copyCurl (uri: string) : JsonNode = + match parseRequest uri with + | None -> null + | Some req -> jstr (CurlGenerator.toCurl req) + + /// Step file paths declared in a .naplist's [steps] section (read from the + /// tracked doc or disk) — lets the IDE drop its own .naplist parsing. + let private naplistSteps (uri: string) : JsonNode = + let arr = JsonArray() + + match docText uri with + | Some text -> SectionScanner.scanNaplistStepPaths text |> List.iter (fun p -> arr.Add(jstr p)) + | None -> () + + arr :> JsonNode + + let private listEnvironments (rootUri: string) : JsonNode = + let arr = JsonArray() + + Environment.detectEnvironmentNames (uriToFilePath rootUri) + |> List.iter (fun n -> arr.Add(jstr n)) + + arr :> JsonNode + + /// First string argument of a workspace/executeCommand request. + let private firstArg (p: JsonNode) : string = + match item p FArguments with + | Some(:? JsonArray as a) when a.Count > 0 -> + match a[0] with + | :? JsonValue as v -> + match v.TryGetValue<string>() with + | true, s -> s + | _ -> "" + | _ -> "" + | _ -> "" + + let private executeCommand (p: JsonNode) : JsonNode = + let arg = firstArg p + + match strField p FCommand with + | CmdRequestInfo -> requestInfo arg + | CmdCopyCurl -> copyCurl arg + | CmdListEnvironments -> listEnvironments arg + | CmdNaplistSteps -> naplistSteps arg + | _ -> null + + let private uriOf (p: JsonNode) : string = + match item p FTextDocument with + | Some td -> strField td FUri + | None -> "" + + let private capabilities () : JsonNode = + let codeLens = JsonObject() + codeLens[FResolveProvider] <- jbool false + + let commands = JsonArray() + commands.Add(jstr CmdCopyCurl) + commands.Add(jstr CmdListEnvironments) + commands.Add(jstr CmdRequestInfo) + commands.Add(jstr CmdNaplistSteps) + let exec = JsonObject() + exec[FCommands] <- commands + + let caps = JsonObject() + caps[FTextDocumentSync] <- jint SyncFull + caps[FDocumentSymbolProvider] <- jbool true + caps[FCodeLensProvider] <- codeLens + caps[FExecuteCommandProvider] <- exec + caps :> JsonNode + + let private initializeResult () : JsonNode = + let info = JsonObject() + info[FName] <- jstr ServerName + info[FVersion] <- jstr ServerVersion + let o = JsonObject() + o[FCapabilities] <- capabilities () + o[FServerInfo] <- info + o :> JsonNode + + let private onDidOpen (p: JsonNode) : unit = + match item p FTextDocument with + | Some td -> Workspace.openDocument (strField td FUri) (intField td FVersion 0) (strField td FText) + | None -> () + + let private onDidChange (p: JsonNode) : unit = + match item p FTextDocument, item p FContentChanges with + | Some td, Some(:? JsonArray as changes) when changes.Count > 0 -> + Workspace.changeDocument (strField td FUri) (intField td FVersion 0) (strField changes[0] FText) + | _ -> () + + let private onDidClose (p: JsonNode) : unit = + match item p FTextDocument with + | Some td -> Workspace.closeDocument (strField td FUri) + | None -> () + + /// Dispatch one message. Returns Some response for requests, None for + /// notifications. Notifications run their side effect here. + let handle (methodName: string) (p: JsonNode) (id: JsonNode) : JsonNode option = + let isRequest = not (isNull id) + + match methodName with + | MInitialize -> Some(ok id (initializeResult ())) + | MInitialized -> None + | MShutdown -> Some(ok id null) + | MDidOpen -> + onDidOpen p + None + | MDidChange -> + onDidChange p + None + | MDidClose -> + onDidClose p + None + | MDocumentSymbol -> Some(ok id (documentSymbols (uriOf p))) + | MCodeLens -> Some(ok id (codeLenses (uriOf p))) + | MExecuteCommand -> Some(ok id (executeCommand p)) + | _ -> + if isRequest then + Some(err id CodeMethodNotFound MsgMethodNotFound) + else + None + +/// Public entry point used by Napper.Cli and the integration tests. +module LspRunner = + + /// Parse a frame body; accept ONLY a JSON object root. Array/primitive roots + /// (valid JSON a non-conformant client may send) are dropped, not crashed on. + let private tryParse (body: string) : JsonObject option = + try + match JsonNode.Parse(body) with + | :? JsonObject as o -> Some o + | _ -> None + with _ -> + None + + /// Process one message; returns false when the server should stop (exit). + let private processMessage (output: Stream) (msg: JsonObject) : bool = + let methodName = Json.tryStr msg FMethod + + if methodName = MExit then + false + else + let id = msg[FId] + + let response = + try + Handlers.handle methodName msg[FParams] id + with ex -> + if isNull id then + None else - [] - - let symbols = sections |> List.map sectionToSymbol |> Array.ofList - - Logger.debug $"documentSymbol: {uri} -> {symbols.Length} symbols" - return Result.Ok(Some(U2.C2 symbols)) - } - - // ─── Code Lens ─────────────────────────────────────────── - // Replaces: codeLensProvider.ts section scanning + method extraction in TS - - override _.TextDocumentCodeLens(param) = - async { - let uri = param.TextDocument.Uri - - match getDocumentText uri with - | None -> return Result.Ok None - | Some text when isNapFile uri -> - let sections = SectionScanner.scanNapSections text - - let lenses = - sections - |> List.choose (fun s -> - if s.Name = "request" then - let range = - { Start = { Line = uint32 s.Line; Character = 0u } - End = { Line = uint32 s.Line; Character = 0u } } - - // Extract method + URL for display - let detail = - match Parser.parseNapFile text with - | Result.Ok nap -> Some $"{methodString nap.Request.Method} {nap.Request.Url}" - | Result.Error _ -> None - - Some - { Range = range - Command = None - Data = detail |> Option.map (fun d -> JValue(d) :> JToken) } - else - None) - |> Array.ofList - - Logger.debug $"codeLens: {uri} -> {lenses.Length} lenses" - return Result.Ok(Some lenses) - | Some text when isNaplistFile uri -> - let sections = SectionScanner.scanNaplistSections text - - let lenses = - sections - |> List.choose (fun s -> - if s.Name = "meta" then - let range = - { Start = { Line = uint32 s.Line; Character = 0u } - End = { Line = uint32 s.Line; Character = 0u } } - - Some - { Range = range - Command = None - Data = None } - else - None) - |> Array.ofList - - return Result.Ok(Some lenses) - | _ -> return Result.Ok None - } - - // ─── Execute Command ───────────────────────────────────── - // Replaces: parseMethodAndUrl, detectEnvironments, curl generation in TS - - override _.WorkspaceExecuteCommand(param) = - let extractedArg = - param.Arguments - |> Option.bind Array.tryHead - |> Option.map (fun (t: JToken) -> t.ToObject<string>()) - |> Option.defaultValue "" - - async { - match param.Command with - | cmd when cmd = commandRequestInfo -> - let uri = extractedArg - - match parseRequestFromUri uri with - | Some request -> - let result = JObject() - result["method"] <- JValue(methodString request.Method) - result["url"] <- JValue(request.Url) - let headers = JObject() - request.Headers |> Map.iter (fun k v -> headers[k] <- JValue(v)) - result["headers"] <- headers - Logger.debug $"requestInfo: {uri} -> {methodString request.Method} {request.Url}" - return Result.Ok(Some(result :> JToken)) - | None -> return Result.Ok None - - | cmd when cmd = commandCopyCurl -> - let uri = extractedArg - - match parseRequestFromUri uri with - | Some request -> - let curl = CurlGenerator.toCurl request - Logger.debug $"copyCurl: {uri} -> {curl}" - return Result.Ok(Some(JValue(curl) :> JToken)) - | None -> return Result.Ok None - - | cmd when cmd = commandListEnvs -> - let rootUri = extractedArg - let dir = uriToFilePath rootUri - let envNames = Environment.detectEnvironmentNames dir - Logger.debug $"listEnvironments: {dir} -> {envNames.Length} envs" - let arr = JArray(envNames |> List.map (fun n -> JValue(n) :> JToken)) - return Result.Ok(Some(arr :> JToken)) - - | _ -> - Logger.warn $"Unknown command: {param.Command}" - return Result.Ok None - } - - override _.Dispose() = () + Some(Json.err id CodeInternalError ex.Message) + + response |> Option.iter (fun r -> Wire.writeMessage output (r.ToJsonString())) + true + + /// Start the LSP server over the given streams. Returns the exit code. + /// Called by Napper.Cli for 'napper lsp' and by tests via the real binary. + let run (input: Stream) (output: Stream) : int = + try + let mutable running = true + + while running do + match Wire.readMessage input with + | Wire.Eof -> running <- false + | Wire.Skip -> () + | Wire.Body body -> + match tryParse body with + | Some msg -> running <- processMessage output msg + | None -> () + + 0 + with ex -> + Console.Error.WriteLine(CrashPrefix + string ex) + 1 diff --git a/src/Napper.Lsp/Workspace.fs b/src/Napper.Lsp/Workspace.fs index 3b5ff6c..78b8c03 100644 --- a/src/Napper.Lsp/Workspace.fs +++ b/src/Napper.Lsp/Workspace.fs @@ -43,9 +43,3 @@ let tryGetDocument (uri: string) : TrackedDocument option = match documents.TryGetValue(uri) with | true, doc -> Some doc | false, _ -> None - -/// Get all currently tracked document URIs -let trackedUris () : string list = documents.Keys |> Seq.toList - -/// Number of currently tracked documents -let documentCount () : int = documents.Count diff --git a/src/Napper.VsCode/.prettierrc b/src/Napper.VsCode/.prettierrc.json similarity index 100% rename from src/Napper.VsCode/.prettierrc rename to src/Napper.VsCode/.prettierrc.json diff --git a/src/Napper.VsCode/.vscodeignore b/src/Napper.VsCode/.vscodeignore index b23c57a..9ca70f3 100644 --- a/src/Napper.VsCode/.vscodeignore +++ b/src/Napper.VsCode/.vscodeignore @@ -1,10 +1,20 @@ src/** node_modules/** tsconfig.json +tsconfig.build.json +tsconfig.test.json webpack.config.js .gitignore **/*.ts **/*.map **/*.pdb +.c8rc.json +.vscode-test.mjs +.nyc_output/** +eslint.config.mjs +eslint-rules.cjs +coverage/** +out/** !dist/** -bin/** +!bin/** +!shipwright.json diff --git a/src/Napper.VsCode/README.md b/src/Napper.VsCode/README.md index f44a341..31afe3a 100644 --- a/src/Napper.VsCode/README.md +++ b/src/Napper.VsCode/README.md @@ -1,5 +1,5 @@ <p align="center"> - <img src="https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/logo.png" alt="Napper" width="128"> + <img src="https://raw.githubusercontent.com/Nimblesite/napper/main/logo.png" alt="Napper" width="128"> </p> <h1 align="center">Napper</h1> @@ -8,11 +8,11 @@ Napper is a free, open-source API testing tool that runs from the command line and integrates natively with VS Code. Define HTTP requests as plain text `.nap` files, add declarative assertions, chain them into test suites, and run everything in CI/CD with JUnit output. As simple as curl for quick requests. As powerful as F# and C# for full test suites. -[Documentation](https://napperapi.dev) | [GitHub Repository](https://github.com/MelbourneDeveloper/napper) | [Releases](https://github.com/MelbourneDeveloper/napper/releases) +[Documentation](https://napperapi.dev) | [GitHub Repository](https://github.com/Nimblesite/napper) | [Releases](https://github.com/Nimblesite/napper/releases) --- -![Napper VS Code extension showing playlist test results with response headers and body inspection](https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/screenshot.png) +![Napper VS Code extension showing playlist test results with response headers and body inspection](https://raw.githubusercontent.com/Nimblesite/napper/main/screenshot.png) --- @@ -37,7 +37,7 @@ code --install-extension nimblesite.napper ### Or grab the CLI binary -Download from the [latest release](https://github.com/MelbourneDeveloper/napper/releases). +Download from the [latest release](https://github.com/Nimblesite/napper/releases). ## How do you use Napper? diff --git a/src/Napper.VsCode/eslint.config.mjs b/src/Napper.VsCode/eslint.config.mjs index 75c8092..368d486 100644 --- a/src/Napper.VsCode/eslint.config.mjs +++ b/src/Napper.VsCode/eslint.config.mjs @@ -268,6 +268,9 @@ export default tseslint.config( // Sequential awaits in test helpers are intentional — // tests need deterministic ordering, not parallelism. "no-await-in-loop": "off", + // Unix file-mode checks require bitwise AND (e.g. mode & 0o111). + // There is no alternative — this is the POSIX API surface. + "no-bitwise": "off", // Test object literals (fixtures, expected values) don't // need to follow property naming conventions. "@typescript-eslint/naming-convention": "off", diff --git a/src/Napper.VsCode/package-lock.json b/src/Napper.VsCode/package-lock.json index a457a4c..e3bdbc9 100644 --- a/src/Napper.VsCode/package-lock.json +++ b/src/Napper.VsCode/package-lock.json @@ -8,6 +8,10 @@ "name": "napper", "version": "0.11.0", "license": "MIT", + "dependencies": { + "@nimblesite/shipwright-vscode": "^0.1.0", + "vscode-languageclient": "^9.0.1" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/mocha": "^10.0.10", @@ -563,6 +567,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nimblesite/shipwright-core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@nimblesite/shipwright-core/-/shipwright-core-0.1.0.tgz", + "integrity": "sha512-eLAiggcySuNVK/7TEoRZTMU733z0DDY4esoomQL2cDaUWOTPfPdq5z+F0davFlr0xrl9T0fBUpqVoe/vulAlPg==", + "license": "MIT OR Apache-2.0" + }, + "node_modules/@nimblesite/shipwright-vscode": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@nimblesite/shipwright-vscode/-/shipwright-vscode-0.1.0.tgz", + "integrity": "sha512-LDE+DJLjC4MzLOsmCY0Iy9AbH8nv7S46NlcXfa0bQcSes1qYP+xwgKRCTsyOcDQb6fUZMF4/yK4Lt4aq0FLrqg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@nimblesite/shipwright-core": "0.1.0" + }, + "peerDependencies": { + "vscode": "*" + }, + "peerDependenciesMeta": { + "vscode": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6252,7 +6279,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7329,6 +7355,72 @@ "url": "https://bevry.me/fund" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", + "license": "MIT", + "dependencies": { + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" + }, + "engines": { + "vscode": "^1.82.0" + } + }, + "node_modules/vscode-languageclient/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/vscode-languageclient/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/vscode-languageclient/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", diff --git a/src/Napper.VsCode/package.json b/src/Napper.VsCode/package.json index 49cc018..a56685d 100644 --- a/src/Napper.VsCode/package.json +++ b/src/Napper.VsCode/package.json @@ -2,16 +2,16 @@ "name": "napper", "displayName": "Napper", "description": "CLI-first, test-oriented HTTP API testing tool. Send requests, run assertions, manage environments.", - "version": "0.11.0", + "version": "0.0.0-dev", "publisher": "nimblesite", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/MelbourneDeveloper/napper" + "url": "https://github.com/Nimblesite/napper" }, - "homepage": "https://github.com/MelbourneDeveloper/napper", + "homepage": "https://github.com/Nimblesite/napper", "bugs": { - "url": "https://github.com/MelbourneDeveloper/napper/issues" + "url": "https://github.com/Nimblesite/napper/issues" }, "keywords": [ "api", @@ -25,7 +25,7 @@ "icon": "media/logo.png", "preview": true, "engines": { - "vscode": "^1.95.0" + "vscode": "^1.99.0" }, "categories": [ "Testing", @@ -318,8 +318,8 @@ }, "napper.cliPath": { "type": "string", - "default": "napper", - "description": "Path or command name for the Napper CLI binary" + "default": "", + "description": "Override path to the Napper CLI binary. Leave empty to use the bundled binary." } } } @@ -329,8 +329,9 @@ "build:cli": "bash ../../scripts/build-cli.sh", "compile": "webpack --mode production", "compile:tests": "tsc -p tsconfig.test.json", + "compile:e2e": "tsc -p tsconfig.e2e.json", "watch": "webpack --mode development --watch", - "pretest": "npm run build:cli && npm run compile && npm run compile:tests", + "pretest": "npm run build:cli && npm run compile && npm run compile:tests && npm run compile:e2e", "test": "vscode-test", "test:unit": "npm run compile:tests && c8 mocha out/test/unit/**/*.test.js --ui tdd --timeout 5000", "lint": "eslint src", @@ -356,5 +357,9 @@ "typescript-eslint": "^8.56.1", "webpack": "^5.105.3", "webpack-cli": "^6.0.1" + }, + "dependencies": { + "@nimblesite/shipwright-vscode": "^0.1.0", + "vscode-languageclient": "^9.0.1" } } diff --git a/src/Napper.VsCode/shipwright.json b/src/Napper.VsCode/shipwright.json new file mode 100644 index 0000000..779504d --- /dev/null +++ b/src/Napper.VsCode/shipwright.json @@ -0,0 +1,38 @@ +{ + "manifestVersion": 1, + "product": { + "id": "napper", + "displayName": "Napper", + "version": "0.0.0-dev" + }, + "components": [ + { + "id": "napper", + "kind": "cli", + "language": "dotnet", + "binaryName": "napper", + "expectedVersion": "0.0.0-dev", + "platforms": ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64", "win32-x64", "win32-arm64"], + "bundled": { + "bundlePath": "bin/${platform}/${binaryName}${exe}", + "perPlatformArtifact": true + }, + "sources": ["user-setting", "env", "bundled"], + "userSetting": "napper.cliPath", + "env": { + "pathVar": "NAPPER_PATH", + "dirVar": "NAPPER_BINARY_DIR" + }, + "verifyStartup": true, + "versionCheckStrategy": "version-flag", + "required": true + } + ], + "hosts": { + "vscode": { + "artifact": "vsix-per-platform", + "activationVerifies": ["napper"], + "onMismatch": "error" + } + } +} diff --git a/src/Napper.VsCode/src/binaryUtils.ts b/src/Napper.VsCode/src/binaryUtils.ts new file mode 100644 index 0000000..3b363c5 --- /dev/null +++ b/src/Napper.VsCode/src/binaryUtils.ts @@ -0,0 +1,18 @@ +// VSIX/ZIP extraction strips Unix execute bits — restore them before Shipwright version-checks the binary. +import * as fs from 'fs'; +import * as path from 'path'; + +export const bundledBinaryPath = (extensionPath: string): string => { + const platform = `${process.platform}-${process.arch}`; + const binaryName = process.platform === 'win32' ? 'napper.exe' : 'napper'; + return path.join(extensionPath, 'bin', platform, binaryName); +}; + +export const ensureExecutable = (binaryPath: string): void => { + if (process.platform === 'win32') { + return; + } + if (fs.existsSync(binaryPath)) { + fs.chmodSync(binaryPath, 0o755); + } +}; diff --git a/src/Napper.VsCode/src/cliInstaller.ts b/src/Napper.VsCode/src/cliInstaller.ts deleted file mode 100644 index 1e57522..0000000 --- a/src/Napper.VsCode/src/cliInstaller.ts +++ /dev/null @@ -1,347 +0,0 @@ -// Specs: vscode-impl -// CLI Installer — downloads matching binary with checksum verification, -// falls back to dotnet tool if binary cannot run. -// Decoupled from vscode SDK — takes config values as parameters - -import * as crypto from 'crypto'; -import * as fs from 'fs'; -import * as https from 'https'; -import * as os from 'os'; -import * as path from 'path'; -import { execFile } from 'child_process'; -import { type Result, err, ok } from './types'; -import { - CLI_ARCH_ARM64, - CLI_ARCH_X64, - CLI_ASSET_PREFIX, - CLI_BINARY_NAME, - CLI_BIN_DIR, - CLI_CHECKSUM_MISMATCH_MSG, - CLI_CHECKSUM_NOT_FOUND_MSG, - CLI_CHECKSUMS_FILE, - CLI_DOTNET_CMD, - CLI_DOTNET_FALLBACK_MSG, - CLI_DOTNET_INSTALL_ERROR_PREFIX, - CLI_DOTNET_TOOL_INSTALL_TIMEOUT, - CLI_DOWNLOAD_BASE_URL, - CLI_DOWNLOAD_ERROR_PREFIX, - CLI_FILE_MODE_EXECUTABLE, - CLI_MAX_REDIRECTS, - CLI_PLATFORM_DARWIN, - CLI_PLATFORM_LINUX, - CLI_PLATFORM_WIN32, - CLI_REDIRECT_ERROR, - CLI_RID_LINUX_X64, - CLI_RID_OSX_ARM64, - CLI_RID_OSX_X64, - CLI_RID_WIN_X64, - CLI_TOO_MANY_REDIRECTS, - CLI_TOOL_ARG, - CLI_TOOL_GLOBAL_FLAG, - CLI_TOOL_INSTALL_ARG, - CLI_TOOL_LIST_ARG, - CLI_TOOL_UPDATE_ARG, - CLI_TOOL_VERSION_FLAG, - CLI_UNSUPPORTED_PLATFORM_MSG, - CLI_VERSION_CHECK_ERROR, - CLI_VERSION_CHECK_TIMEOUT, - CLI_VERSION_FLAG, - CLI_WIN_EXE_SUFFIX, -} from './constants'; - -// ── Platform detection ────────────────────────────────────────────── - -const PLATFORM_RID_MAP: ReadonlyMap<string, string> = new Map([ - [`${CLI_PLATFORM_DARWIN}-${CLI_ARCH_ARM64}`, CLI_RID_OSX_ARM64], - [`${CLI_PLATFORM_DARWIN}-${CLI_ARCH_X64}`, CLI_RID_OSX_X64], - [`${CLI_PLATFORM_LINUX}-${CLI_ARCH_X64}`, CLI_RID_LINUX_X64], - [`${CLI_PLATFORM_WIN32}-${CLI_ARCH_X64}`, CLI_RID_WIN_X64], -]); - -const platformToRid = (): Result<string, string> => { - const key = `${os.platform()}-${os.arch()}`, - rid = PLATFORM_RID_MAP.get(key); - return rid !== undefined ? ok(rid) : err(`${CLI_UNSUPPORTED_PLATFORM_MSG}${key}`); -}; - -const assetName = (rid: string): string => { - const base = `${CLI_ASSET_PREFIX}${rid}`; - return rid === CLI_RID_WIN_X64 ? `${base}${CLI_WIN_EXE_SUFFIX}` : base; -}; - -const localBinaryName = (): string => - os.platform() === CLI_PLATFORM_WIN32 - ? `${CLI_BINARY_NAME}${CLI_WIN_EXE_SUFFIX}` - : CLI_BINARY_NAME; - -// ── Version check ─────────────────────────────────────────────────── - -export const getCliVersion = async (cliPath: string): Promise<Result<string, string>> => - new Promise((resolve) => { - execFile( - cliPath, - [CLI_VERSION_FLAG], - { timeout: CLI_VERSION_CHECK_TIMEOUT }, - (error: Error | null, stdout: string) => { - if (error !== null) { - resolve(err(`${CLI_VERSION_CHECK_ERROR}${error.message}`)); - return; - } - resolve(ok(stdout.trim())); - }, - ); - }); - -// ── HTTPS download with redirect following ────────────────────────── - -import type * as http from 'http'; - -type ResultResolver = (value: Result<Buffer, string>) => void; - -const collectBody = (response: http.IncomingMessage, resolve: ResultResolver): void => { - const chunks: Buffer[] = []; - response.on('data', (chunk: Buffer) => { - chunks.push(chunk); - }); - response.on('end', () => { - resolve(ok(Buffer.concat(chunks))); - }); - response.on('error', (e) => { - resolve(err(e.message)); - }); -}; - -interface HttpGetResult { - readonly response: http.IncomingMessage; - readonly status: number; -} - -const httpsGetOnce = async (url: string): Promise<Result<HttpGetResult, string>> => - new Promise((resolve) => { - https - .get(url, { headers: { 'User-Agent': CLI_BINARY_NAME } }, (response) => { - resolve(ok({ response, status: response.statusCode ?? 0 })); - }) - .on('error', (e) => { - resolve(err(e.message)); - }); - }); - -const resolveRedirect = (response: http.IncomingMessage): Result<string, string> => { - response.resume(); - const { location } = response.headers; - return location !== undefined && location !== '' ? ok(location) : err(CLI_REDIRECT_ERROR); -}; - -const handleNon200 = ( - response: http.IncomingMessage, - status: number, -): Result<http.IncomingMessage, string> => { - response.resume(); - return err(`${CLI_DOWNLOAD_ERROR_PREFIX}HTTP ${String(status)}`); -}; - -const followRedirects = async ( - url: string, - depth: number, -): Promise<Result<http.IncomingMessage, string>> => { - if (depth > CLI_MAX_REDIRECTS) { - return err(CLI_TOO_MANY_REDIRECTS); - } - const result = await httpsGetOnce(url); - if (!result.ok) { - return err(result.error); - } - const { response, status } = result.value; - if (status >= 300 && status < 400) { - const loc = resolveRedirect(response); - return loc.ok ? followRedirects(loc.value, depth + 1) : err(loc.error); - } - return status === 200 ? ok(response) : handleNon200(response, status); -}; - -const downloadFile = async (url: string): Promise<Result<Buffer, string>> => { - const result = await followRedirects(url, 0); - if (!result.ok) { - return err(result.error); - } - return new Promise((resolve) => { - collectBody(result.value, resolve); - }); -}; - -// ── Checksum verification ─────────────────────────────────────────── - -const verifyChecksum = ( - data: Buffer, - checksumFileContent: string, - asset: string, -): Result<void, string> => { - const line = checksumFileContent.split('\n').find((l) => l.includes(asset)); - - if (line === undefined) { - return err(CLI_CHECKSUM_NOT_FOUND_MSG); - } - - const expectedHash = line.split(/\s+/)[0]?.toLowerCase() ?? '', - actualHash = crypto.createHash('sha256').update(data).digest('hex'); - - return actualHash === expectedHash - ? ok(undefined) - : err(`${CLI_CHECKSUM_MISMATCH_MSG} — expected ${expectedHash}, got ${actualHash}`); -}; - -// ── Binary download + verify ──────────────────────────────────────── - -const buildDownloadUrls = ( - version: string, - rid: string, -): { readonly binaryUrl: string; readonly checksumUrl: string; readonly asset: string } => { - const asset = assetName(rid), - tag = `v${version}`; - return { - binaryUrl: `${CLI_DOWNLOAD_BASE_URL}/${tag}/${asset}`, - checksumUrl: `${CLI_DOWNLOAD_BASE_URL}/${tag}/${CLI_CHECKSUMS_FILE}`, - asset, - }; -}; - -const writeBinaryToDisk = (destPath: string, data: Buffer): void => { - const dir = path.dirname(destPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(destPath, data); - if (os.platform() !== CLI_PLATFORM_WIN32) { - fs.chmodSync(destPath, CLI_FILE_MODE_EXECUTABLE); - } -}; - -const downloadPair = async ( - version: string, - rid: string, -): Promise< - Result<{ readonly binary: Buffer; readonly checksum: Buffer; readonly asset: string }, string> -> => { - const { binaryUrl, checksumUrl, asset } = buildDownloadUrls(version, rid), - [binaryResult, checksumResult] = await Promise.all([ - downloadFile(binaryUrl), - downloadFile(checksumUrl), - ]); - if (!binaryResult.ok) { - return err(`${CLI_DOWNLOAD_ERROR_PREFIX}${binaryResult.error}`); - } - if (!checksumResult.ok) { - return err(`${CLI_DOWNLOAD_ERROR_PREFIX}checksums: ${checksumResult.error}`); - } - return ok({ binary: binaryResult.value, checksum: checksumResult.value, asset }); -}; - -const fetchAndVerify = async ( - version: string, - rid: string, -): Promise<Result<{ readonly data: Buffer; readonly asset: string }, string>> => { - const dlResult = await downloadPair(version, rid); - if (!dlResult.ok) { - return err(dlResult.error); - } - const { binary, checksum, asset } = dlResult.value, - verifyResult = verifyChecksum(binary, checksum.toString('utf-8'), asset); - return verifyResult.ok ? ok({ data: binary, asset }) : err(verifyResult.error); -}; - -const downloadAndVerifyBinary = async ( - version: string, - destPath: string, -): Promise<Result<void, string>> => { - const ridResult = platformToRid(); - if (!ridResult.ok) { - return err(ridResult.error); - } - const fetchResult = await fetchAndVerify(version, ridResult.value); - if (!fetchResult.ok) { - return err(fetchResult.error); - } - writeBinaryToDisk(destPath, fetchResult.value.data); - return ok(undefined); -}; - -// ── Dotnet tool fallback ──────────────────────────────────────────── - -const parseToolVersion = (stdout: string): Result<string, string> => { - const line = stdout.split('\n').find((l) => l.toLowerCase().startsWith(CLI_BINARY_NAME)); - if (line === undefined) { - return err('not installed'); - } - const parts = line.split(/\s+/); - return ok(parts[1] ?? ''); -}; - -const isToolInstalled = async (): Promise<Result<string, string>> => - new Promise((resolve) => { - execFile( - CLI_DOTNET_CMD, - [CLI_TOOL_ARG, CLI_TOOL_LIST_ARG, CLI_TOOL_GLOBAL_FLAG], - { timeout: CLI_VERSION_CHECK_TIMEOUT }, - (error: Error | null, stdout: string) => { - if (error !== null) { - resolve(err(error.message)); - return; - } - resolve(parseToolVersion(stdout)); - }, - ); - }); - -const runDotnetTool = async (action: string, version: string): Promise<Result<void, string>> => - new Promise((resolve) => { - execFile( - CLI_DOTNET_CMD, - [CLI_TOOL_ARG, action, CLI_TOOL_GLOBAL_FLAG, CLI_BINARY_NAME, CLI_TOOL_VERSION_FLAG, version], - { timeout: CLI_DOTNET_TOOL_INSTALL_TIMEOUT }, - (error: Error | null, _stdout: string, stderr: string) => { - if (error !== null) { - resolve(err(`${CLI_DOTNET_INSTALL_ERROR_PREFIX}${stderr || error.message}`)); - return; - } - resolve(ok(undefined)); - }, - ); - }); - -const installViaDotnetTool = async (version: string): Promise<Result<void, string>> => { - const existing = await isToolInstalled(), - action = existing.ok ? CLI_TOOL_UPDATE_ARG : CLI_TOOL_INSTALL_ARG; - return runDotnetTool(action, version); -}; - -// ── Public API ────────────────────────────────────────────────────── - -export interface DownloadBinaryParams { - readonly version: string; - readonly storageDir: string; - readonly log: (msg: string) => void; -} - -export const installedBinaryPath = (dir: string): string => - path.join(dir, CLI_BIN_DIR, localBinaryName()); - -export const downloadBinary = async ( - params: DownloadBinaryParams, -): Promise<Result<string, string>> => { - const destPath = installedBinaryPath(params.storageDir); - params.log(`Downloading binary v${params.version}...`); - const downloadResult = await downloadAndVerifyBinary(params.version, destPath); - if (!downloadResult.ok) { - return err(downloadResult.error); - } - params.log(`Binary written to ${destPath}`); - return ok(destPath); -}; - -export const installDotnetTool = async ( - params: DownloadBinaryParams, -): Promise<Result<void, string>> => { - params.log(CLI_DOTNET_FALLBACK_MSG); - return installViaDotnetTool(params.version); -}; diff --git a/src/Napper.VsCode/src/cliRunner.ts b/src/Napper.VsCode/src/cliRunner.ts index 2997283..0ae790b 100644 --- a/src/Napper.VsCode/src/cliRunner.ts +++ b/src/Napper.VsCode/src/cliRunner.ts @@ -4,6 +4,7 @@ import { execFile, spawn } from 'child_process'; import { + CLI_BINARY_NAME, CLI_CMD_CHECK, CLI_CMD_RUN, CLI_FLAG_ENV, @@ -12,7 +13,6 @@ import { CLI_OUTPUT_NDJSON, CLI_PARSE_FAILED_PREFIX, CLI_SPAWN_FAILED_PREFIX, - DEFAULT_CLI_PATH, } from './constants'; import { type Result, type RunResult, err, ok } from './types'; @@ -72,7 +72,7 @@ const appendEnvArgs = (args: string[], env: string | undefined): void => { }, ); }), - resolveCliPath = (cliPath: string): string => (cliPath.length > 0 ? cliPath : DEFAULT_CLI_PATH); + resolveCliPath = (cliPath: string): string => (cliPath.length > 0 ? cliPath : CLI_BINARY_NAME); export const runCli = async ( options: RunOptions, diff --git a/src/Napper.VsCode/src/constants.ts b/src/Napper.VsCode/src/constants.ts index d928298..e74d184 100644 --- a/src/Napper.VsCode/src/constants.ts +++ b/src/Napper.VsCode/src/constants.ts @@ -4,14 +4,13 @@ export const NAP_EXTENSION = '.nap'; export const NAPLIST_EXTENSION = '.naplist'; export const NAPENV_EXTENSION = '.napenv'; -export const NAPENV_LOCAL_SUFFIX = '.local'; +export const NAPENV_LOCAL_SUFFIX = '.napenv.local'; export const FSX_EXTENSION = '.fsx'; export const CSX_EXTENSION = '.csx'; // Glob patterns export const NAP_GLOB = '**/*.nap'; export const NAPLIST_GLOB = '**/*.naplist'; -export const NAPENV_GLOB = '**/.napenv*'; export const DIRECTORY_GLOB = '**/'; // View IDs @@ -35,8 +34,11 @@ export const CONFIG_SPLIT_LAYOUT = 'splitEditorLayout'; export const CONFIG_MASK_SECRETS = 'maskSecretsInPreview'; export const CONFIG_CLI_PATH = 'cliPath'; -// CLI defaults -export const DEFAULT_CLI_PATH = 'napper'; +// CLI default — MUST equal the `napper.cliPath` default in package.json (''). When the +// user has not configured an override, getCliPath() treats '' as "unset" and falls through +// to the Shipwright-resolved bundled binary path ([SWR-IDE-RESOLUTION]). A non-empty default +// here makes getCliPath() return '' (an empty/broken path) instead of the resolved one. +export const DEFAULT_CLI_PATH = ''; export const CLI_OUTPUT_JSON = 'json'; export const CLI_OUTPUT_NDJSON = 'ndjson'; export const CLI_CMD_RUN = 'run'; @@ -45,7 +47,6 @@ export const CLI_CMD_GENERATE = 'generate'; export const CLI_SUBCMD_OPENAPI = 'openapi'; export const CLI_FLAG_OUTPUT = '--output'; export const CLI_FLAG_ENV = '--env'; -export const CLI_FLAG_VAR = '--var'; export const CLI_FLAG_OUTPUT_DIR = '--output-dir'; // Context values for tree items @@ -66,7 +67,6 @@ export const ICON_RUNNING = 'loading~spin'; export const ICON_PASSED = 'pass'; export const ICON_FAILED = 'error'; export const ICON_ERROR = 'warning'; -export const ICON_IMPORT_OPENAPI = 'cloud-download'; // Badge decorations (single-char for file decorations) export const BADGE_PASSED = '\u2713'; @@ -126,9 +126,6 @@ export const CLI_ERROR_PREFIX = 'Napper CLI error: '; export const STATUS_RUNNING_ICON = '$(loading~spin) Running '; export const STATUS_RUNNING_SUFFIX = '...'; -// Curl -export const CURL_CMD_PREFIX = 'curl -X '; - // File creation export const REQUEST_NAME_SUFFIX = '-request'; @@ -139,53 +136,11 @@ export const NAP_NAME_KEY_SUFFIX = '"'; // Property keys export const PROP_FILE_PATH = 'filePath'; -// CLI installer (binary download) +// CLI binary name export const CLI_BINARY_NAME = 'napper'; -export const CLI_BIN_DIR = 'bin'; -export const CLI_DOWNLOAD_REPO = 'MelbourneDeveloper/napper'; -export const CLI_DOWNLOAD_BASE_URL = - 'https://github.com/MelbourneDeveloper/napper/releases/download'; -export const CLI_CHECKSUMS_FILE = 'checksums-sha256.txt'; -export const CLI_ASSET_PREFIX = 'napper-'; -export const CLI_WIN_EXE_SUFFIX = '.exe'; -export const CLI_PLATFORM_DARWIN = 'darwin'; -export const CLI_PLATFORM_LINUX = 'linux'; -export const CLI_PLATFORM_WIN32 = 'win32'; -export const CLI_ARCH_ARM64 = 'arm64'; -export const CLI_ARCH_X64 = 'x64'; -export const CLI_RID_OSX_ARM64 = 'osx-arm64'; -export const CLI_RID_OSX_X64 = 'osx-x64'; -export const CLI_RID_LINUX_X64 = 'linux-x64'; -export const CLI_RID_WIN_X64 = 'win-x64'; -export const CLI_UNSUPPORTED_PLATFORM_MSG = 'Unsupported platform: '; -export const CLI_DOWNLOAD_ERROR_PREFIX = 'Binary download failed: '; -export const CLI_CHECKSUM_MISMATCH_MSG = 'SHA256 checksum mismatch'; -export const CLI_CHECKSUM_NOT_FOUND_MSG = 'Asset not found in checksums file'; -export const CLI_FILE_MODE_EXECUTABLE = 0o755; -export const CLI_MAX_REDIRECTS = 5; -export const CLI_TOO_MANY_REDIRECTS = 'Too many redirects'; -export const CLI_REDIRECT_ERROR = 'Redirect with no location header'; - -// CLI installer (dotnet tool fallback) -export const CLI_DOTNET_CMD = 'dotnet'; -export const CLI_TOOL_ARG = 'tool'; -export const CLI_TOOL_INSTALL_ARG = 'install'; -export const CLI_TOOL_UPDATE_ARG = 'update'; -export const CLI_TOOL_LIST_ARG = 'list'; -export const CLI_TOOL_GLOBAL_FLAG = '-g'; -export const CLI_TOOL_VERSION_FLAG = '--version'; -export const CLI_DOTNET_TOOL_INSTALL_TIMEOUT = 60000; -export const CLI_DOTNET_FALLBACK_MSG = 'Binary install failed, falling back to dotnet tool'; -export const CLI_DOTNET_INSTALL_ERROR_PREFIX = 'dotnet tool install failed: '; - -// CLI installer (shared) -export const CLI_INSTALL_MSG = 'Installing Napper CLI...'; -export const CLI_INSTALL_COMPLETE_MSG = 'Napper CLI installed successfully'; -export const CLI_INSTALL_FAILED_MSG = 'Failed to install Napper CLI: '; -export const CLI_VERSION_FLAG = '--version'; -export const CLI_VERSION_CHECK_TIMEOUT = 5000; -export const CLI_VERSION_CHECK_ERROR = 'Failed to check CLI version: '; -export const CLI_VERSION_MISMATCH_MSG = 'CLI version mismatch — re-installing'; + +// CLI installer complete message +export const CLI_INSTALL_COMPLETE_MSG = 'Napper CLI ready'; // VSCode built-in commands export const CMD_VSCODE_OPEN = 'vscode.open'; @@ -216,11 +171,9 @@ export const PROMPT_SELECT_ENV = 'Select Napper environment'; // Default values export const PLACEHOLDER_URL = 'https://api.example.com/resource'; export const DEFAULT_PLAYLIST_NAME = 'new-playlist'; -export const DEFAULT_METHOD = 'GET'; // .nap file keys export const NAP_KEY_METHOD = 'method'; -export const NAP_KEY_URL = 'url'; // HTTP methods export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const; @@ -232,26 +185,12 @@ export const REPORT_FOOTER_GENERATED_BY = 'Generated by'; export const REPORT_FOOTER_MADE_BY = 'Made by'; // .nap file sections (additional) -export const SECTION_REQUEST_HEADERS = '[request.headers]'; export const SECTION_REQUEST_BODY = '[request.body]'; export const SECTION_ASSERT = '[assert]'; -export const SECTION_VARS = '[vars]'; // .nap file content export const NAP_TRIPLE_QUOTE = '"""'; -export const HEADER_CONTENT_TYPE = 'Content-Type'; -export const HEADER_ACCEPT = 'Accept'; -export const CONTENT_TYPE_JSON = 'application/json'; -export const ASSERT_STATUS_PREFIX = 'status = '; -export const ASSERT_BODY_EXISTS_SUFFIX = ' exists'; -export const ASSERT_BODY_PREFIX = 'body.'; -export const NAP_KEY_NAME = 'name'; -export const NAP_KEY_DESCRIPTION = 'description'; -export const NAP_KEY_GENERATED = 'generated'; -export const NAP_VALUE_TRUE = 'true'; -export const BASE_URL_VAR = '{{baseUrl}}'; export const BASE_URL_KEY = 'baseUrl'; -export const VARS_PLACEHOLDER = 'REPLACE_ME'; // OpenAPI generator — commands export const CMD_IMPORT_OPENAPI_URL = 'napper.importOpenApiUrl'; @@ -267,48 +206,6 @@ export const OPENAPI_URL_PROMPT = 'Enter OpenAPI specification URL'; export const OPENAPI_URL_PLACEHOLDER = 'https://petstore3.swagger.io/api/v3/openapi.json'; export const OPENAPI_DOWNLOAD_FAILED_PREFIX = 'Failed to download spec: '; export const OPENAPI_DOWNLOADING = 'Downloading OpenAPI spec...'; -export const ICON_IMPORT_OPENAPI_FILE = 'file-symlink-file'; - -// OpenAPI generator — validation -export const OPENAPI_INVALID_SPEC = 'Invalid OpenAPI specification: missing paths'; -export const OPENAPI_NO_ENDPOINTS = 'No endpoints found in specification'; -export const OPENAPI_PARSE_ERROR = 'Failed to parse JSON'; - -// OpenAPI generator — spec fields -export const HTTPS_SCHEME = 'https'; -export const DEFAULT_BASE_URL = 'https://api.example.com'; -export const OPENAPI_DEFAULT_TITLE = 'API Tests'; -export const PARAM_IN_BODY = 'body'; -export const PARAM_IN_QUERY = 'query'; -export const PARAM_IN_PATH = 'path'; -export const AUTH_BEARER_PREFIX = 'Authorization = Bearer '; -export const AUTH_BASIC_PREFIX = 'Authorization = Basic '; -export const SECURITY_TYPE_HTTP = 'http'; -export const SECURITY_SCHEME_BEARER = 'bearer'; -export const SECURITY_SCHEME_BASIC = 'basic'; -export const SECURITY_TYPE_API_KEY = 'apiKey'; -export const SECURITY_LOCATION_HEADER = 'header'; -export const SECURITY_LOCATION_QUERY = 'query'; - -// OpenAPI generator — HTTP methods (lowercase for spec parsing) -export const OPENAPI_HTTP_METHODS = [ - 'get', - 'post', - 'put', - 'patch', - 'delete', - 'head', - 'options', -] as const; - -// JSON Schema types -export const SCHEMA_TYPE_STRING = 'string'; -export const SCHEMA_TYPE_NUMBER = 'number'; -export const SCHEMA_TYPE_INTEGER = 'integer'; -export const SCHEMA_TYPE_BOOLEAN = 'boolean'; -export const SCHEMA_TYPE_ARRAY = 'array'; -export const SCHEMA_TYPE_OBJECT = 'object'; -export const SCHEMA_EXAMPLE_STRING = 'example'; // Logging export const LOG_CHANNEL_NAME = 'Napper'; @@ -334,7 +231,6 @@ export const LOG_MSG_OPENAPI_AI_CHOICE = 'OpenAPI AI choice:'; export const LOG_MSG_OPENAPI_AI_NO_MODEL = 'No Copilot model available for AI enhancement'; export const LOG_MSG_OPENAPI_AI_MODEL_SELECTED = 'Copilot model selected for AI enhancement:'; export const LOG_MSG_OPENAPI_GENERATE_CLI = 'OpenAPI generate CLI call:'; -export const LOG_MSG_OPENAPI_GENERATE_RESULT = 'OpenAPI generate result:'; // AI enrichment export const OPENAPI_AI_CHOICE_TITLE = 'How should tests be generated?'; @@ -386,7 +282,6 @@ export const DUPLICATE_SUFFIX = '-copy'; // .http file conversion export const HTTP_FILE_EXTENSION = '.http'; export const REST_FILE_EXTENSION = '.rest'; -export const HTTP_FILE_GLOB = '**/*.http'; export const CLI_CMD_CONVERT = 'convert'; export const CLI_SUBCMD_HTTP = 'http'; export const CMD_CONVERT_HTTP_FILE = 'napper.convertHttpFile'; @@ -405,10 +300,6 @@ export const CONVERT_HTTP_CODELENS_TITLE = '$(file-add) Convert to .nap'; // Numeric thresholds export const PERCENTAGE_MULTIPLIER = 100; -export const HTTP_STATUS_OK = 200; export const HTTP_STATUS_REDIRECT_MIN = 300; export const HTTP_STATUS_CLIENT_ERROR_MIN = 400; export const JSON_INDENT_SIZE = 2; -export const PAD_DIGITS_DEFAULT = 2; -export const PAD_DIGITS_LARGE = 3; -export const PAD_LARGE_THRESHOLD = 100; diff --git a/src/Napper.VsCode/src/curlCopy.ts b/src/Napper.VsCode/src/curlCopy.ts index 874a56e..d721e09 100644 --- a/src/Napper.VsCode/src/curlCopy.ts +++ b/src/Napper.VsCode/src/curlCopy.ts @@ -1,69 +1,20 @@ +// Implements [LSP-VSCODE-CURL] // Specs: vscode-commands -// Curl copy command — copyAsCurl and parsing helpers -// Extracted from extension.ts to keep files under 450 LOC +// Curl copy command — delegates to LSP napper.copyCurl command. import * as vscode from 'vscode'; -import { - CURL_CMD_PREFIX, - DEFAULT_METHOD, - HTTP_METHODS, - MSG_COPIED, - NAP_KEY_METHOD, - NAP_KEY_URL, -} from './constants'; - -const EQUALS_CHAR = '=', - SPACE_CHAR = ' ', - valueAfterFirstEquals = (line: string): string => { - const eqIndex = line.indexOf(EQUALS_CHAR); - return eqIndex === -1 ? '' : line.slice(eqIndex + 1).trim(); - }, - matchesHttpMethodLine = (trimmed: string, method: string): boolean => - trimmed.startsWith(`${method}${SPACE_CHAR}`), - extractMethodFromLine = ( - trimmed: string, - ): { readonly method: string; readonly url: string } | undefined => { - for (const m of HTTP_METHODS) { - if (matchesHttpMethodLine(trimmed, m)) { - return { method: m, url: trimmed.slice(m.length + 1).trim() }; - } - } - return undefined; - }, - parseLine = (trimmed: string, current: { method: string; url: string }): void => { - const httpMatch = extractMethodFromLine(trimmed); - if (httpMatch !== undefined) { - current.method = httpMatch.method; - current.url = httpMatch.url; - } - if (trimmed.startsWith(NAP_KEY_METHOD) && trimmed.includes(EQUALS_CHAR)) { - current.method = valueAfterFirstEquals(trimmed); - } - if (trimmed.startsWith(NAP_KEY_URL) && trimmed.includes(EQUALS_CHAR)) { - current.url = valueAfterFirstEquals(trimmed); - } - }; - -export const parseMethodAndUrl = ( - text: string, -): { readonly method: string; readonly url: string } => { - const result = { method: DEFAULT_METHOD, url: '' }, - lines = text.split('\n'); - for (const line of lines) { - parseLine(line.trim(), result); - } - return result; -}; +import { MSG_COPIED } from './constants'; +import { copyCurl } from './lspClient'; export const copyAsCurl = async (uri?: vscode.Uri): Promise<void> => { const fileUri = uri ?? vscode.window.activeTextEditor?.document.uri; if (fileUri === undefined) { return; } - - const doc = await vscode.workspace.openTextDocument(fileUri), - { method, url } = parseMethodAndUrl(doc.getText()), - curl = `${CURL_CMD_PREFIX}${method} '${url}'`; + const curl = await copyCurl(fileUri); + if (curl === undefined) { + return; + } await vscode.env.clipboard.writeText(curl); void vscode.window.showInformationMessage(MSG_COPIED); }; diff --git a/src/Napper.VsCode/src/environmentAdapter.ts b/src/Napper.VsCode/src/environmentAdapter.ts index aeeb02d..be441a5 100644 --- a/src/Napper.VsCode/src/environmentAdapter.ts +++ b/src/Napper.VsCode/src/environmentAdapter.ts @@ -1,14 +1,14 @@ +// Implements [LSP-VSCODE-ENV] // Specs: vscode-env-switcher, vscode-impl // VSCode adapter for the environment switcher // Status bar item and quick pick integration import * as vscode from 'vscode'; -import { detectEnvironments } from './environmentSwitcher'; +import { listEnvironments } from './lspClient'; import { CMD_SWITCH_ENV, CONFIG_DEFAULT_ENV, CONFIG_SECTION, - NAPENV_GLOB, PROMPT_SELECT_ENV, STATUS_BAR_NO_ENV, STATUS_BAR_PREFIX, @@ -49,8 +49,8 @@ export class EnvironmentStatusBar implements vscode.Disposable { } async showPicker(): Promise<void> { - const files = await vscode.workspace.findFiles(NAPENV_GLOB, '**/node_modules/**'), - envNames = detectEnvironments(files.map((f) => f.fsPath)), + const rootUri = vscode.workspace.workspaceFolders?.[0]?.uri; + const envNames = rootUri !== undefined ? ((await listEnvironments(rootUri)) ?? []) : [], items = envNames.map((name) => ({ label: name, picked: name === this._currentEnv, diff --git a/src/Napper.VsCode/src/environmentSwitcher.ts b/src/Napper.VsCode/src/environmentSwitcher.ts deleted file mode 100644 index 16488cc..0000000 --- a/src/Napper.VsCode/src/environmentSwitcher.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Specs: vscode-env-switcher -// Environment switcher — status bar item + quick pick -// Decoupled: detection logic is pure, only the adapter touches vscode - -import * as path from 'path'; -import { NAPENV_EXTENSION, NAPENV_LOCAL_SUFFIX } from './constants'; - -export const extractEnvName = (fileName: string): string | undefined => { - const base = path.basename(fileName); - - if (base === NAPENV_EXTENSION.slice(1)) { - return undefined; - } - if (base.endsWith(NAPENV_LOCAL_SUFFIX)) { - return undefined; - } - - const prefix = `${NAPENV_EXTENSION.slice(1)}.`; - if (base.startsWith(prefix)) { - return base.slice(prefix.length); - } - - return undefined; -}; - -export const detectEnvironments = (filePaths: readonly string[]): readonly string[] => { - const envs: string[] = []; - - for (const fp of filePaths) { - const name = extractEnvName(fp); - if (name !== undefined && !envs.includes(name)) { - envs.push(name); - } - } - - return envs.sort(); -}; diff --git a/src/Napper.VsCode/src/extension.ts b/src/Napper.VsCode/src/extension.ts index ad9eea7..38a918c 100644 --- a/src/Napper.VsCode/src/extension.ts +++ b/src/Napper.VsCode/src/extension.ts @@ -5,6 +5,9 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; +import type { activateDeploymentToolkit } from '@nimblesite/shipwright-vscode' with { + 'resolution-mode': 'import', +}; import { ExplorerAdapter } from './explorerAdapter'; import { CodeLensProvider } from './codeLensProvider'; import { EnvironmentStatusBar } from './environmentAdapter'; @@ -15,13 +18,6 @@ import type { RunResult } from './types'; import { parsePlaylistStepPaths } from './explorerProvider'; import { generatePlaylistReport } from './reportGenerator'; import { type Logger, createLogger } from './logger'; -import { - type DownloadBinaryParams, - downloadBinary, - getCliVersion, - installDotnetTool, - installedBinaryPath, -} from './cliInstaller'; import { registerEditCommands, registerHttpConvertCommands, @@ -29,14 +25,12 @@ import { } from './editAndImportCommands'; import { registerContextMenuCommands } from './contextMenuCommands'; import { registerAutoRun, registerWatchers } from './watchers'; +import { startLspClient, stopLspClient } from './lspClient'; +import { bundledBinaryPath, ensureExecutable } from './binaryUtils'; import { - CLI_BIN_DIR, CLI_BINARY_NAME, CLI_ERROR_PREFIX, CLI_INSTALL_COMPLETE_MSG, - CLI_INSTALL_FAILED_MSG, - CLI_INSTALL_MSG, - CLI_VERSION_MISMATCH_MSG, CMD_OPEN_RESPONSE, CMD_RUN_ALL, CMD_RUN_FILE, @@ -74,109 +68,60 @@ import { } from './constants'; let envStatusBar: EnvironmentStatusBar, - extensionDir: string, + extensionContext: vscode.ExtensionContext, extensionVersion: string, explorerProvider: ExplorerAdapter, - installedCliOverride: string | undefined, + resolvedCliPath: string | undefined, lastPlaylistReport: (() => void) | undefined, lastResult: RunResult | undefined, logger: Logger, + outputChannel: vscode.OutputChannel, playlistPanel: PlaylistPanel, - responsePanel: ResponsePanel, - storageDir: string; + responsePanel: ResponsePanel; -const bundledCliPath = (): string => path.join(extensionDir, CLI_BIN_DIR, CLI_BINARY_NAME), - getCliPath = (): string => { +const getCliPath = (): string => { const configured = vscode.workspace .getConfiguration(CONFIG_SECTION) .get<string>(CONFIG_CLI_PATH, DEFAULT_CLI_PATH); if (configured !== DEFAULT_CLI_PATH) { return configured; } - if (installedCliOverride !== undefined) { - return installedCliOverride; - } - const bundled = bundledCliPath(); - return fs.existsSync(bundled) ? bundled : CLI_BINARY_NAME; + return resolvedCliPath ?? CLI_BINARY_NAME; }, - checkVersionAt = async (cliPath: string): Promise<boolean> => { - logger.debug(`Version check: ${cliPath}`); - const result = await getCliVersion(cliPath); - if (!result.ok) { - logger.debug(`Version check failed at ${cliPath}: ${result.error}`); - return false; - } - logger.debug(`${cliPath}: v${result.value} (need ${extensionVersion})`); - if (result.value !== extensionVersion) { - return false; - } - installedCliOverride = cliPath; + startCliAndLsp = (cliPath: string): void => { + resolvedCliPath = cliPath; logger.info(`${CLI_INSTALL_COMPLETE_MSG} (${cliPath})`); - return true; + startLspClient(cliPath, outputChannel, extensionContext); }, - checkVersionMatch = async (): Promise<boolean> => { - if (await checkVersionAt(installedBinaryPath(storageDir))) { - return true; - } - if (await checkVersionAt(bundledCliPath())) { - return true; - } - if (await checkVersionAt(CLI_BINARY_NAME)) { - return true; - } - logger.info(CLI_VERSION_MISMATCH_MSG); - return false; - }, - installParams = (): DownloadBinaryParams => ({ - version: extensionVersion, - storageDir, - log: (msg) => { - logger.info(msg); + makeVscodeAdapter = () => ({ + workspace: vscode.workspace, + window: { + showErrorMessage: async (msg: string, opts: { modal: boolean }, ...items: string[]) => + vscode.window.showErrorMessage(msg, opts, ...items) as Promise<string | undefined>, + showWarningMessage: async (msg: string, opts: { modal: boolean }, ...items: string[]) => + vscode.window.showWarningMessage(msg, opts, ...items) as Promise<string | undefined>, }, }), - tryBinaryInstall = async (params: DownloadBinaryParams): Promise<boolean> => { - const dlResult = await downloadBinary(params); - if (!dlResult.ok) { - logger.error(dlResult.error); - return false; - } - if (await checkVersionAt(dlResult.value)) { - return true; + logShipwrightResult = (result: Awaited<ReturnType<typeof activateDeploymentToolkit>>): void => { + outputChannel.appendLine(`Shipwright result: ok=${String(result.ok)}`); + for (const d of result.diagnostics) { + outputChannel.appendLine(` [${d.componentId}] ${d.resolution.status}: ${d.message}`); } - logger.error(`Binary downloaded but version check failed at ${dlResult.value}`); - return false; }, - tryDotnetFallback = async (params: DownloadBinaryParams): Promise<void> => { - const dotnetResult = await installDotnetTool(params); - if (!dotnetResult.ok) { - logger.error(`${CLI_INSTALL_FAILED_MSG}${dotnetResult.error}`); - void vscode.window.showErrorMessage(`${CLI_INSTALL_FAILED_MSG}${dotnetResult.error}`); - return; - } - installedCliOverride = CLI_BINARY_NAME; - logger.info(`${CLI_INSTALL_COMPLETE_MSG} (dotnet tool)`); - }, - performInstall = async (): Promise<void> => { - const params = installParams(); - if (await tryBinaryInstall(params)) { - return; - } - await tryDotnetFallback(params); - }, - ensureCliInstalled = async (): Promise<void> => { - logger.info('Checking CLI installation...'); - if (await checkVersionMatch()) { - return; + runShipwright = async (): Promise<void> => { + logger.info('Resolving CLI via Shipwright...'); + ensureExecutable(bundledBinaryPath(extensionContext.extensionPath)); + const { activateDeploymentToolkit: deployToolkit } = + await import('@nimblesite/shipwright-vscode'); + const result = await deployToolkit(extensionContext, { + vscode: makeVscodeAdapter(), + manifestPath: path.join(extensionContext.extensionPath, 'shipwright.json'), + }); + logShipwrightResult(result); + if (result.ok) { + const napperDiag = result.diagnostics.find((d) => d.componentId === 'napper'); + startCliAndLsp(napperDiag?.resolution.path ?? CLI_BINARY_NAME); } - logger.info('No matching CLI found, starting install...'); - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: CLI_INSTALL_MSG, - cancellable: false, - }, - performInstall, - ); }, getWorkspacePath = (): string | undefined => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, getResponseColumn = (): vscode.ViewColumn => { @@ -292,12 +237,12 @@ const collectResult = (state: StreamState, result: RunResult): void => { } }, runSingleFile = async (fileUri: vscode.Uri, cwd: string): Promise<void> => { - const resolvedCliPath = getCliPath(); + const resolvedPath = getCliPath(); logger.info(`${LOG_MSG_RUN_FILE} ${fileUri.fsPath}`); - logger.info(`CLI path: ${resolvedCliPath}, cwd: ${cwd}`); + logger.info(`CLI path: ${resolvedPath}, cwd: ${cwd}`); const statusMsg = makeRunningStatus(fileUri.fsPath), result = await runCli({ - cliPath: resolvedCliPath, + cliPath: resolvedPath, filePath: fileUri.fsPath, env: currentEnvOrUndefined(), cwd, @@ -370,17 +315,16 @@ const collectResult = (state: StreamState, result: RunResult): void => { ); }, initLogger = (context: vscode.ExtensionContext): void => { - const outputChannel = vscode.window.createOutputChannel(LOG_CHANNEL_NAME); + extensionContext = context; + outputChannel = vscode.window.createOutputChannel(LOG_CHANNEL_NAME); context.subscriptions.push(outputChannel); logger = createLogger((msg) => { outputChannel.appendLine(msg); }); logger.info(LOG_MSG_ACTIVATED); extensionVersion = (context.extension.packageJSON as { version: string }).version; - extensionDir = context.extensionUri.fsPath; - storageDir = context.globalStorageUri.fsPath; logger.info(`Extension version: ${extensionVersion}`); - ensureCliInstalled().catch(() => undefined); + runShipwright().catch(() => undefined); }; export interface ExtensionApi { @@ -407,6 +351,7 @@ export function activate(context: vscode.ExtensionContext): ExtensionApi { return { explorerProvider }; } -export function deactivate(): void { +export async function deactivate(): Promise<void> { logger.info(LOG_MSG_DEACTIVATED); + await stopLspClient(); } diff --git a/src/Napper.VsCode/src/lspClient.ts b/src/Napper.VsCode/src/lspClient.ts new file mode 100644 index 0000000..8ab4b75 --- /dev/null +++ b/src/Napper.VsCode/src/lspClient.ts @@ -0,0 +1,121 @@ +// Implements [LSP-VSCODE-CLIENT] +// Napper LSP client — spawns 'napper lsp' and connects via vscode-languageclient. +// Decoupled from the CLI resolver: receives the resolved cliPath. + +import * as vscode from 'vscode'; +import { + LanguageClient, + type LanguageClientOptions, + type ServerOptions, + TransportKind, +} from 'vscode-languageclient/node'; +import { NAP_EXTENSION, NAPENV_EXTENSION, NAPLIST_EXTENSION } from './constants'; + +const LSP_CLIENT_ID = 'napper-lsp'; +const LSP_CLIENT_NAME = 'Napper Language Server'; +const LSP_SUBCOMMAND = 'lsp'; + +const documentSelector = [ + { scheme: 'file', language: 'nap' }, + { scheme: 'file', language: 'naplist' }, + { scheme: 'file', language: 'napenv' }, +]; + +const filePattern = `**/*{${NAP_EXTENSION},${NAPLIST_EXTENSION},${NAPENV_EXTENSION}}`; + +let client: LanguageClient | undefined; + +const buildServerOptions = (cliPath: string): ServerOptions => ({ + command: cliPath, + args: [LSP_SUBCOMMAND], + transport: TransportKind.stdio, +}); + +const buildClientOptions = (outputChannel: vscode.OutputChannel): LanguageClientOptions => ({ + documentSelector, + synchronize: { fileEvents: vscode.workspace.createFileSystemWatcher(filePattern) }, + outputChannel, +}); + +/** Start the Napper language server using the resolved CLI path. */ +export const startLspClient = ( + cliPath: string, + outputChannel: vscode.OutputChannel, + context: vscode.ExtensionContext, +): void => { + if (client !== undefined) { + return; + } + const serverOptions = buildServerOptions(cliPath); + const clientOptions = buildClientOptions(outputChannel); + const newClient = new LanguageClient( + LSP_CLIENT_ID, + LSP_CLIENT_NAME, + serverOptions, + clientOptions, + ); + client = newClient; + void newClient.start(); + context.subscriptions.push(newClient); +}; + +/** Stop the Napper language server (called on deactivate). */ +export const stopLspClient = async (): Promise<void> => { + const current = client; + if (current === undefined) { + return; + } + client = undefined; + await current.stop(); +}; + +/** + * Send napper.requestInfo custom command to the LSP. + * Returns { method, url, headers } or undefined if LSP not available. + */ +export const requestInfo = async ( + uri: vscode.Uri, +): Promise<{ method: string; url: string; headers: Record<string, string> } | undefined> => { + if (client === undefined) { + return undefined; + } + const result = await client.sendRequest<{ + method: string; + url: string; + headers: Record<string, string>; + } | null>('workspace/executeCommand', { + command: 'napper.requestInfo', + arguments: [uri.toString()], + }); + return result ?? undefined; +}; + +/** + * Send napper.copyCurl custom command to the LSP. + * Returns the curl string or undefined if LSP not available. + */ +export const copyCurl = async (uri: vscode.Uri): Promise<string | undefined> => { + if (client === undefined) { + return undefined; + } + const result = await client.sendRequest<string | null>('workspace/executeCommand', { + command: 'napper.copyCurl', + arguments: [uri.toString()], + }); + return result ?? undefined; +}; + +/** + * Send napper.listEnvironments custom command to the LSP. + * Returns the list of env names or undefined if LSP not available. + */ +export const listEnvironments = async (rootUri: vscode.Uri): Promise<string[] | undefined> => { + if (client === undefined) { + return undefined; + } + const result = await client.sendRequest<string[] | null>('workspace/executeCommand', { + command: 'napper.listEnvironments', + arguments: [rootUri.toString()], + }); + return result ?? undefined; +}; diff --git a/src/Napper.VsCode/src/test/e2e/openApiImport.e2e.test.ts b/src/Napper.VsCode/src/test/e2e/openApiImport.e2e.test.ts index af9f7d9..eec019f 100644 --- a/src/Napper.VsCode/src/test/e2e/openApiImport.e2e.test.ts +++ b/src/Napper.VsCode/src/test/e2e/openApiImport.e2e.test.ts @@ -5,8 +5,14 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { execFile } from 'child_process'; -import { activateExtension, getRegisteredCommands, readFixtureFile } from '../helpers/helpers'; +import { + activateExtension, + getExtensionPath, + getRegisteredCommands, + readFixtureFile, +} from '../helpers/helpers'; import { downloadSpec, saveTempSpec } from '../../openApiImport'; +import { bundledBinaryPath } from '../../binaryUtils'; import { BASE_URL_KEY, CLI_CMD_GENERATE, @@ -19,7 +25,6 @@ import { CMD_IMPORT_OPENAPI_URL, CONFIG_CLI_PATH, CONFIG_SECTION, - DEFAULT_CLI_PATH, ENCODING_UTF8, NAPENV_EXTENSION, NAP_EXTENSION, @@ -133,7 +138,10 @@ const ECOMMERCE_SPEC_FIXTURE = 'ecommerce-spec.json', const configured = vscode.workspace .getConfiguration(CONFIG_SECTION) .get<string>(CONFIG_CLI_PATH, ''); - return configured.length > 0 ? configured : DEFAULT_CLI_PATH; + // An explicit `napper.cliPath` override wins; otherwise resolve the REAL bundled + // binary exactly as the shipped extension does (bin/<platform>/napper) — never PATH — + // so this e2e exercises the deployed resolution path. ([SWR-IDE-RESOLUTION]) + return configured.length > 0 ? configured : bundledBinaryPath(getExtensionPath('')); }, runCliGenerate = async (specPath: string, outDir: string): Promise<string> => new Promise<string>((resolve, reject) => { diff --git a/src/Napper.VsCode/src/test/unit/binaryUtils.test.ts b/src/Napper.VsCode/src/test/unit/binaryUtils.test.ts new file mode 100644 index 0000000..b9c43de --- /dev/null +++ b/src/Napper.VsCode/src/test/unit/binaryUtils.test.ts @@ -0,0 +1,38 @@ +// Verifies that ensureExecutable restores the +x bit stripped by ZIP/VSIX extraction. +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { bundledBinaryPath, ensureExecutable } from '../../binaryUtils'; + +suite('binaryUtils', () => { + test('ensureExecutable sets +x on a file that lacks it', () => { + if (process.platform === 'win32') { + return; + } + const tmp = path.join(os.tmpdir(), `napper-test-${Date.now()}`); + fs.writeFileSync(tmp, '#!/bin/sh\n', { mode: 0o644 }); + try { + const before = fs.statSync(tmp).mode & 0o111; + assert.strictEqual(before, 0, 'file should start without execute bit'); + ensureExecutable(tmp); + const after = fs.statSync(tmp).mode & 0o111; + assert.notStrictEqual(after, 0, 'file should have execute bit after ensureExecutable'); + } finally { + fs.unlinkSync(tmp); + } + }); + + test('ensureExecutable does nothing when file does not exist', () => { + assert.doesNotThrow(() => { + ensureExecutable('/nonexistent/path/napper'); + }); + }); + + test('bundledBinaryPath returns path inside extensionPath/bin/<platform>/napper', () => { + const result = bundledBinaryPath('/fake/ext'); + assert.ok(result.startsWith('/fake/ext/bin/'), `expected path under bin/, got: ${result}`); + assert.ok(result.endsWith('/napper'), `expected path ending in /napper, got: ${result}`); + assert.ok(result.includes(process.platform), `expected platform in path, got: ${result}`); + }); +}); diff --git a/src/Napper.VsCode/src/test/unit/cliConfig.test.ts b/src/Napper.VsCode/src/test/unit/cliConfig.test.ts new file mode 100644 index 0000000..24d07ca --- /dev/null +++ b/src/Napper.VsCode/src/test/unit/cliConfig.test.ts @@ -0,0 +1,22 @@ +// Verifies DEFAULT_CLI_PATH constant matches napper.cliPath default in package.json. +// Implements [VSCODE-CLI-ACQUIRE]: empty default forces Shipwright-resolved path to be used. +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { DEFAULT_CLI_PATH } from '../../constants'; + +const _PKG_PATH = path.join(__dirname, '../../../package.json'); + +suite('CLI config', () => { + test('DEFAULT_CLI_PATH matches napper.cliPath default in package.json', () => { + const pkg = JSON.parse(fs.readFileSync(_PKG_PATH, 'utf8')) as { + contributes: { configuration: { properties: { 'napper.cliPath': { default: string } } } }; + }; + const pkgDefault = pkg.contributes.configuration.properties['napper.cliPath'].default; + assert.strictEqual( + DEFAULT_CLI_PATH, + pkgDefault, + `DEFAULT_CLI_PATH ('${DEFAULT_CLI_PATH}') must match napper.cliPath default ('${pkgDefault}') — mismatch causes getCliPath() to return empty string instead of the Shipwright-resolved path`, + ); + }); +}); diff --git a/src/Napper.VsCode/src/test/unit/shipwrightManifest.test.ts b/src/Napper.VsCode/src/test/unit/shipwrightManifest.test.ts new file mode 100644 index 0000000..4e9524f --- /dev/null +++ b/src/Napper.VsCode/src/test/unit/shipwrightManifest.test.ts @@ -0,0 +1,54 @@ +// Verifies shipwright.json has resolved versions — not unresolved templates. +// Implements [DTK-NAPPER-MANIFEST], [DTK-NAPPER-VERSION-CONTRACT] +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +const _MANIFEST_PATH = path.join(__dirname, '../../../shipwright.json'); +const _PKG_PATH = path.join(__dirname, '../../../package.json'); + +interface Manifest { + product: { version: string }; + components: { expectedVersion: string }[]; +} + +const _TEMPLATE_RE = /\$\{[^}]+\}/; + +suite('shipwright.json', () => { + test('product.version is a resolved semver, not an unresolved template placeholder', () => { + const manifest = JSON.parse(fs.readFileSync(_MANIFEST_PATH, 'utf8')) as Manifest; + assert.doesNotMatch( + manifest.product.version, + _TEMPLATE_RE, + `product.version must not contain template placeholders, got: ${manifest.product.version}`, + ); + }); + + test('product.version matches package.json version', () => { + const manifest = JSON.parse(fs.readFileSync(_MANIFEST_PATH, 'utf8')) as Manifest; + const pkg = JSON.parse(fs.readFileSync(_PKG_PATH, 'utf8')) as { version: string }; + assert.strictEqual(manifest.product.version, pkg.version); + }); + + test('expectedVersion is a resolved semver, not an unresolved template placeholder', () => { + const manifest = JSON.parse(fs.readFileSync(_MANIFEST_PATH, 'utf8')) as Manifest; + for (const component of manifest.components) { + assert.doesNotMatch( + component.expectedVersion, + _TEMPLATE_RE, + `component expectedVersion must not contain template placeholders, got: ${component.expectedVersion}`, + ); + } + }); + + test('expectedVersion matches product.version', () => { + const manifest = JSON.parse(fs.readFileSync(_MANIFEST_PATH, 'utf8')) as Manifest; + for (const component of manifest.components) { + assert.strictEqual( + component.expectedVersion, + manifest.product.version, + `component expectedVersion (${component.expectedVersion}) must match product.version (${manifest.product.version})`, + ); + } + }); +}); diff --git a/src/Napper.VsCode/src/test/unit/trimmerRoots.test.ts b/src/Napper.VsCode/src/test/unit/trimmerRoots.test.ts new file mode 100644 index 0000000..bc735de --- /dev/null +++ b/src/Napper.VsCode/src/test/unit/trimmerRoots.test.ts @@ -0,0 +1,31 @@ +// Verifies TrimmerRoots.xml protects all LSP/serialization assemblies from PublishTrimmed stripping. +// Each assembly here instantiates types via Newtonsoft.Json reflection at runtime. +// A missing entry → trimmer removes constructors → crash on LSP initialize. +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +const _TRIMMER_ROOTS_PATH = path.join(__dirname, '../../../../Napper.Cli/TrimmerRoots.xml'); + +const _REQUIRED_ASSEMBLIES = ['StreamJsonRpc', 'Ionide.LanguageServerProtocol', 'Newtonsoft.Json']; + +suite('TrimmerRoots.xml', () => { + test('file exists', () => { + assert.ok( + fs.existsSync(_TRIMMER_ROOTS_PATH), + `TrimmerRoots.xml not found at ${_TRIMMER_ROOTS_PATH}`, + ); + }); + + test('all LSP serialization assemblies are preserved with preserve="all"', () => { + const xml = fs.readFileSync(_TRIMMER_ROOTS_PATH, 'utf8'); + const missing = _REQUIRED_ASSEMBLIES.filter( + (asm) => !xml.includes(`fullname="${asm}"`) || !xml.includes('preserve="all"'), + ); + assert.deepStrictEqual( + missing, + [], + `TrimmerRoots.xml is missing preserve="all" entries for: ${missing.join(', ')}`, + ); + }); +}); diff --git a/src/Napper.VsCode/tsconfig.e2e.json b/src/Napper.VsCode/tsconfig.e2e.json new file mode 100644 index 0000000..92729e6 --- /dev/null +++ b/src/Napper.VsCode/tsconfig.e2e.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "out", + "module": "node16", + "moduleResolution": "node16", + "declaration": false, + "declarationMap": false + }, + "include": ["src/test/e2e/**/*", "src/test/helpers/**/*", "src/**/*.ts"], + "exclude": ["node_modules", "dist", "out", "src/test/unit"] +} diff --git a/src/Napper.VsCode/tsconfig.json b/src/Napper.VsCode/tsconfig.json index 005f7c0..06f5fb0 100644 --- a/src/Napper.VsCode/tsconfig.json +++ b/src/Napper.VsCode/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "commonjs", + "module": "preserve", + "moduleResolution": "bundler", "lib": ["ES2022"], "outDir": "dist", "rootDir": "src", diff --git a/src/Napper.VsCode/tsconfig.test.json b/src/Napper.VsCode/tsconfig.test.json index d022558..3c7db87 100644 --- a/src/Napper.VsCode/tsconfig.test.json +++ b/src/Napper.VsCode/tsconfig.test.json @@ -2,9 +2,11 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "out", + "module": "commonjs", + "moduleResolution": "node", "declaration": false, "declarationMap": false }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "out"] + "exclude": ["node_modules", "dist", "out", "src/extension.ts", "src/test/e2e"] } diff --git a/src/Napper.Zed/rustfmt.toml b/src/Napper.Zed/rustfmt.toml new file mode 100644 index 0000000..8529f73 --- /dev/null +++ b/src/Napper.Zed/rustfmt.toml @@ -0,0 +1,10 @@ +edition = "2021" +max_width = 100 +use_small_heuristics = "Default" +imports_granularity = "Crate" +group_imports = "StdExternalCrate" +format_code_in_doc_comments = true +wrap_comments = true +comment_width = 100 +normalize_comments = true +normalize_doc_attributes = true diff --git a/src/Napper.Zed/src/lib.rs b/src/Napper.Zed/src/lib.rs index 6e70803..75ed7bc 100644 --- a/src/Napper.Zed/src/lib.rs +++ b/src/Napper.Zed/src/lib.rs @@ -27,6 +27,9 @@ const NAP_LSP_ID: &str = "nap-lsp"; /// CLI binary name. const NAP_CLI: &str = "nap"; +/// CLI binary name for the language server. +const NAPPER_LSP_CLI: &str = "napper"; + /// Usage message for the nap-run command. const NAP_RUN_USAGE: &str = "Usage: /nap-run <file.nap>"; @@ -39,8 +42,12 @@ const CLI_LAUNCH_ERROR: &str = "Is `nap` installed and on PATH?"; /// Stderr separator in error output. const STDERR_SEPARATOR: &str = "\n--- stderr ---\n"; -/// LSP not-yet-available message. -const LSP_NOT_AVAILABLE: &str = "Nap Language Server not yet available — install when released"; +/// LSP subcommand argument. +const LSP_SUBCOMMAND: &str = "lsp"; + +/// Error message when napper binary is not found on PATH. +const NAPPER_NOT_FOUND: &str = + "napper not found on PATH — install via: dotnet tool install -g napper"; /// Nap Zed extension entry point — implements all Zed extension traits. pub struct NapExtension; @@ -56,8 +63,7 @@ impl zed::Extension for NapExtension { language_server_id: &LanguageServerId, worktree: &Worktree, ) -> Result<Command, String> { - let _ = worktree; - resolve_language_server(language_server_id.as_ref()) + resolve_language_server(language_server_id.as_ref(), worktree.which(NAPPER_LSP_CLI)) } fn language_server_initialization_options( @@ -103,12 +109,23 @@ impl zed::Extension for NapExtension { } /// Resolve language server command by ID. -fn resolve_language_server(id: &str) -> Result<Command, String> { +/// Implements [LSP-ZED-CLIENT]: launches 'napper lsp' over stdio. +fn resolve_language_server(id: &str, napper_path: Option<String>) -> Result<Command, String> { if id != NAP_LSP_ID { return Err(format!("Unknown language server: {id}")); } - // TODO: LOUD — implement LSP binary discovery and launch - Err(LSP_NOT_AVAILABLE.to_string()) + napper_path + .map(build_language_server_command) + .ok_or_else(|| NAPPER_NOT_FOUND.to_string()) +} + +/// Build the command used to launch 'napper lsp'. +fn build_language_server_command(napper: String) -> Command { + Command { + command: napper, + args: vec![LSP_SUBCOMMAND.to_string()], + env: Vec::default(), + } } /// Route slash command argument completions by command name. diff --git a/src/Napper.Zed/src/tests/tests_pure.rs b/src/Napper.Zed/src/tests/tests_pure.rs index 71a784e..41d31a2 100644 --- a/src/Napper.Zed/src/tests/tests_pure.rs +++ b/src/Napper.Zed/src/tests/tests_pure.rs @@ -155,6 +155,16 @@ fn cli_constant_is_nap() { assert_eq!(NAP_CLI, "nap"); } +#[test] +fn lsp_cli_constant_is_napper() { + assert_eq!(NAPPER_LSP_CLI, "napper"); +} + +#[test] +fn lsp_subcommand_constant_is_lsp() { + assert_eq!(LSP_SUBCOMMAND, "lsp"); +} + #[test] fn command_constants_match_extension_toml() { assert_eq!(NAP_RUN_COMMAND, "nap-run"); @@ -170,15 +180,24 @@ fn file_extension_constants() { // ─── resolve_language_server ──────────────────────────────── #[test] -fn resolve_known_lsp_returns_not_available() { - let result = resolve_language_server(NAP_LSP_ID); +fn resolve_known_lsp_returns_napper_lsp_command() { + let napper_path = "/usr/local/bin/napper".to_string(); + let result = resolve_language_server(NAP_LSP_ID, Some(napper_path.clone())).unwrap(); + assert_eq!(result.command, napper_path); + assert_eq!(result.args, vec![LSP_SUBCOMMAND.to_string()]); + assert!(result.env.is_empty()); +} + +#[test] +fn resolve_known_lsp_without_path_returns_install_error() { + let result = resolve_language_server(NAP_LSP_ID, None); let err = result.unwrap_err(); - assert_eq!(err, LSP_NOT_AVAILABLE); + assert_eq!(err, NAPPER_NOT_FOUND); } #[test] fn resolve_unknown_lsp_returns_error_with_id() { - let result = resolve_language_server("some-other-lsp"); + let result = resolve_language_server("some-other-lsp", Some(NAPPER_LSP_CLI.to_string())); let err = result.unwrap_err(); assert!(err.contains("Unknown language server")); assert!(err.contains("some-other-lsp")); diff --git a/website/eleventy.config.js b/website/eleventy.config.js index 476e2ac..dbe3fa8 100644 --- a/website/eleventy.config.js +++ b/website/eleventy.config.js @@ -6,7 +6,7 @@ export default function (eleventyConfig) { name: "Napper", url: "https://napperapi.dev", description: - "CLI-first, test-oriented HTTP API testing tool for VS Code with F# and C# scripting.", + "CLI-first, test-oriented HTTP API testing tool for VS Code, Zed, and any editor — script in JavaScript, Python, F#, or C#.", author: "Christian Findlay", themeColor: "#1B4965", stylesheet: "/assets/css/styles.css", @@ -16,7 +16,7 @@ export default function (eleventyConfig) { url: "https://napperapi.dev", logo: "/assets/images/logo.png", sameAs: [ - "https://github.com/MelbourneDeveloper/napper", + "https://github.com/Nimblesite/napper", "https://marketplace.visualstudio.com/items?itemName=nimblesite.napper", ], }, @@ -73,7 +73,7 @@ export default function (eleventyConfig) { eleventyConfig.addTransform("og-site-name", function (content) { if (this.page.outputPath?.endsWith(".html")) { return content.replace( - '<meta property="og:site_name" content="Napper — CLI-First API Testing for VS Code">', + '<meta property="og:site_name" content="Napper — CLI-First API Testing for VS Code, Zed & Any Editor">', '<meta property="og:site_name" content="Napper">' ); } diff --git a/website/src/_data/navigation.json b/website/src/_data/navigation.json index cac1e53..9525558 100644 --- a/website/src/_data/navigation.json +++ b/website/src/_data/navigation.json @@ -2,7 +2,7 @@ "main": [ { "text": "Docs", "url": "/docs/" }, { "text": "Blog", "url": "/blog/" }, - { "text": "GitHub", "url": "https://github.com/MelbourneDeveloper/napper", "external": true } + { "text": "GitHub", "url": "https://github.com/Nimblesite/napper", "external": true } ], "docs": [ { @@ -22,10 +22,18 @@ ] }, { - "title": "Advanced", + "title": "Scripting", "items": [ + { "text": "Scripting Overview", "url": "/docs/scripting/" }, + { "text": "JavaScript Scripting", "url": "/docs/javascript-scripting/" }, + { "text": "Python Scripting", "url": "/docs/python-scripting/" }, { "text": "F# Scripting", "url": "/docs/fsharp-scripting/" }, - { "text": "C# Scripting", "url": "/docs/csharp-scripting/" }, + { "text": "C# Scripting", "url": "/docs/csharp-scripting/" } + ] + }, + { + "title": "Advanced", + "items": [ { "text": "Assertions", "url": "/docs/assertions/" }, { "text": "CLI Reference", "url": "/docs/cli-reference/" }, { "text": "CI Integration", "url": "/docs/ci-integration/" } @@ -47,15 +55,15 @@ { "text": "Getting Started", "url": "/docs/" }, { "text": "File Formats", "url": "/docs/nap-files/" }, { "text": "CLI Reference", "url": "/docs/cli-reference/" }, - { "text": "F# Scripting", "url": "/docs/fsharp-scripting/" }, - { "text": "C# Scripting", "url": "/docs/csharp-scripting/" } + { "text": "JavaScript Scripting", "url": "/docs/javascript-scripting/" }, + { "text": "Python Scripting", "url": "/docs/python-scripting/" } ] }, { "title": "Community", "items": [ - { "text": "GitHub", "url": "https://github.com/MelbourneDeveloper/napper" }, - { "text": "Issues", "url": "https://github.com/MelbourneDeveloper/napper/issues" }, + { "text": "GitHub", "url": "https://github.com/Nimblesite/napper" }, + { "text": "Issues", "url": "https://github.com/Nimblesite/napper/issues" }, { "text": "VS Code Marketplace", "url": "https://marketplace.visualstudio.com/items?itemName=nimblesite.napper" } ] }, diff --git a/website/src/_data/site.json b/website/src/_data/site.json index 26876e4..5c3debc 100644 --- a/website/src/_data/site.json +++ b/website/src/_data/site.json @@ -1,17 +1,17 @@ { "name": "Napper", - "title": "Napper — CLI-First API Testing for VS Code", - "description": "The developer-first HTTP testing tool. As simple as curl for one-off requests, scales to full test suites with F# and C# scripting, assertions, and CI integration.", + "title": "Napper — CLI-First API Testing for VS Code, Zed & Any Editor", + "description": "The developer-first HTTP testing tool from Nimblesite. Native CLI binaries, a VS Code extension, and a portable language server. As simple as curl for one-off requests, scales to full test suites with scripting in your language — JavaScript, Python, F#, or C#.", "url": "https://napperapi.dev", "author": "Christian Findlay", "language": "en", "themeColor": "#1B4965", "stylesheet": "/assets/css/styles.css", - "github": "https://github.com/MelbourneDeveloper/napper", + "github": "https://github.com/Nimblesite/napper", "ogImage": "/assets/images/logo.png", "ogImageWidth": "800", "ogImageHeight": "800", - "keywords": "API testing, HTTP client, VS Code extension, F# scripting, C# scripting, Postman alternative, Bruno alternative, CLI testing, REST API, test automation, http file converter, dothttp migration", + "keywords": "API testing, HTTP client, VS Code extension, Zed extension, language server, LSP, JavaScript scripting, Python scripting, F# scripting, C# scripting, Postman alternative, Bruno alternative, CLI testing, REST API, test automation, http file converter, dothttp migration", "company": { "name": "Nimblesite", "url": "https://nimblesite.co" @@ -21,7 +21,7 @@ "url": "https://napperapi.dev", "logo": "/assets/images/logo.png", "sameAs": [ - "https://github.com/MelbourneDeveloper/napper", + "https://github.com/Nimblesite/napper", "https://marketplace.visualstudio.com/items?itemName=nimblesite.napper" ] } diff --git a/website/src/blog/introducing-napper.md b/website/src/blog/introducing-napper.md index 038bdac..92489b6 100644 --- a/website/src/blog/introducing-napper.md +++ b/website/src/blog/introducing-napper.md @@ -1,20 +1,20 @@ --- layout: layouts/blog.njk -title: "Introducing Napper: CLI-First API Testing for VS Code with C# and F# Scripting" +title: "Introducing Napper: CLI-First API Testing, Scripted in Your Language" date: 2026-02-27 author: Christian Findlay tags: posts category: announcements -excerpt: "Meet Napper — a free, open-source API testing tool that puts the CLI first, stores everything as plain text, and gives you the full power of C# and F# scripting with the entire .NET ecosystem." -description: "Introducing Napper, a free, open-source, CLI-first API testing tool for VS Code. A modern alternative to Postman, Bruno, and .http files with C# and F# scripting, declarative assertions, composable test suites, built-in .http file conversion, and CI/CD integration via JUnit XML." -keywords: "API testing, VS Code extension, C# scripting, F# scripting, CLI API testing, Postman alternative, Bruno alternative, HTTP testing, REST API testing, .NET API testing, CI/CD testing, JUnit XML, open source API testing tool, http file converter, convert http to nap" +excerpt: "Meet Napper — a free, open-source API testing tool for anyone testing APIs. The CLI is the product, everything is plain text, and you script in the language you already use: JavaScript, Python, F#, or C#." +description: "Introducing Napper, a free, open-source, CLI-first API testing tool for VS Code, Zed, and any editor. A modern alternative to Postman, Bruno, and .http files with scripting in JavaScript, Python, F#, or C#, declarative assertions, composable test suites, built-in .http file conversion, and CI/CD integration via JUnit XML." +keywords: "API testing, VS Code extension, Zed extension, language server, JavaScript scripting, Python scripting, F# scripting, C# scripting, CLI API testing, Postman alternative, Bruno alternative, HTTP testing, REST API testing, CI/CD testing, JUnit XML, open source API testing tool, http file converter, convert http to nap" --- -# Introducing Napper: CLI-First API Testing for VS Code with C# and F# Scripting +# Introducing Napper: CLI-First API Testing, Scripted in Your Language API testing tools have a problem. They're either too simple ([.http files](/docs/vs-http-files/) with no assertions and no CLI) or too heavy ([Postman](/docs/vs-postman/) with its mandatory accounts, cloud sync, and paid tiers). [Bruno](/docs/vs-bruno/) moved the needle with git-friendly collections, but it's still a GUI-first tool with sandboxed JavaScript. -**[Napper](https://github.com/MelbourneDeveloper/napper)** takes a different approach. It's a free, open-source API testing tool where the CLI is the primary interface, everything is stored as plain text, and you get full C# and F# scripting with access to the entire [.NET](https://dotnet.microsoft.com/) ecosystem. +**[Napper](https://github.com/Nimblesite/napper)** takes a different approach. It's a free, open-source API testing tool for *anyone* testing APIs: the CLI is the primary interface, everything is stored as plain text, and you script in the language you already use — **JavaScript, Python, F#, or C#** — on a real runtime, with no sandbox. Napper ships as a self-contained native binary (not a .NET DLL) and edits natively in [VS Code](https://code.visualstudio.com/), [Zed](https://zed.dev/), and any editor via a portable language server. ## The CLI is the product @@ -31,7 +31,7 @@ napper run ./smoke.naplist napper run ./tests/ --env staging --output junit > results.xml ``` -The CLI binary is self-contained with no runtime dependencies. It runs on Windows, macOS, and Linux. Download it from [GitHub Releases](https://github.com/MelbourneDeveloper/napper/releases) and you're ready to go. +The CLI binary is self-contained with no runtime dependencies. It runs on Windows, macOS, and Linux. Download it from [GitHub Releases](https://github.com/Nimblesite/napper/releases) and you're ready to go. ## Plain text everything — git-friendly by design @@ -71,9 +71,32 @@ duration < 2s That's a complete HTTP request with headers, a JSON body, and [declarative assertions](/docs/assertions/) — all in one readable file. No scripting needed for the common cases. -## C# scripting — the full power of .NET, no sandbox +## Scripting in your language — real runtimes, no sandbox -This is where Napper breaks away from every other API testing tool. [Postman](/docs/vs-postman/) and [Bruno](/docs/vs-bruno/) give you a sandboxed JavaScript environment with limited APIs. Napper gives you **full [C# scripting](/docs/csharp-scripting/)** with `.csx` files and access to the entire .NET ecosystem. +This is where Napper breaks away from every other API testing tool. [Postman](/docs/vs-postman/) and [Bruno](/docs/vs-bruno/) give you a sandboxed JavaScript environment with limited APIs. Napper lets you script in **JavaScript, Python, F#, or C#** — whichever your team already runs — on the real runtime, with full access to npm, PyPI, and NuGet. Every language sees the same `ctx` (request/response context) and `nap` (orchestration runner) surface, so the examples below translate one-to-one. + +Here's the same post-request hook — extract a user id, chain it forward, validate — in [JavaScript](/docs/javascript-scripting/) and [Python](/docs/python-scripting/): + +```js +// validate-response.js +import { ctx } from "napper"; // bundled — no npm install +const body = ctx.response.json; +ctx.set("userId", String(body.id)); +if (body.id <= 0) ctx.fail("User ID must be positive"); +ctx.log(`Created user ${body.id}`); +``` + +```python +# validate_response.py +from napper import ctx # bundled — no pip install +body = ctx.response.json +ctx.set("userId", str(body["id"])) +if body["id"] <= 0: + ctx.fail("User ID must be positive") +ctx.log(f"Created user {body['id']}") +``` + +Prefer .NET? The same hook in [C#](/docs/csharp-scripting/) (`.csx`) and [F#](/docs/fsharp-scripting/) (`.fsx`) is just as clean — and genuinely lovely. ### Pre-request and post-request hooks in C# @@ -176,7 +199,7 @@ if userId <= 0 then ctx.Log $"Created user {userId}" ``` -You can mix C# and F# scripts in the same project. A single `.naplist` can reference both `.csx` and `.fsx` files as steps. Choose whichever .NET language your team prefers — or use both. +You can mix languages in the same project. A single `.naplist` can reference `.js`, `.py`, `.csx`, and `.fsx` files as steps. Choose whichever language your team already tests with — or use several. See the [Scripting Overview](/docs/scripting/) for the full picture. ## Declarative assertions — no scripting needed for the common cases @@ -192,11 +215,11 @@ headers.Content-Type contains "application/json" duration < 500ms ``` -All assertions are evaluated and reported individually. When the declarative syntax isn't enough, drop into [C#](/docs/csharp-scripting/) or [F#](/docs/fsharp-scripting/) for complex validation logic. +All assertions are evaluated and reported individually. When the declarative syntax isn't enough, drop into [JavaScript](/docs/javascript-scripting/), [Python](/docs/python-scripting/), [C#](/docs/csharp-scripting/), or [F#](/docs/fsharp-scripting/) for complex validation logic. ## Composable test suites with .naplist files -Chain requests into ordered test suites with [.naplist files](/docs/naplist-files/). Nest playlists inside other playlists, reference entire folders, and mix `.nap` requests with `.csx` and `.fsx` scripts: +Chain requests into ordered test suites with [.naplist files](/docs/naplist-files/). Nest playlists inside other playlists, reference entire folders, and mix `.nap` requests with `.js`, `.py`, `.csx`, and `.fsx` scripts: ``` [meta] @@ -228,7 +251,7 @@ jobs: - name: Download Napper CLI run: | - curl -L -o napper https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64 + curl -L -o napper https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64 chmod +x napper sudo mv napper /usr/local/bin/ @@ -259,13 +282,13 @@ napper convert http ./api-tests/ --output-dir ./nap-tests/ The converter supports both **Microsoft** (VS Code REST Client) and **JetBrains** (IntelliJ, Rider, WebStorm) `.http` dialects. It maps variables to `.napenv` files, preserves request names, converts JetBrains `http-client.env.json` environments, and warns about unsupported features like WebSocket or gRPC requests. -Migration is non-destructive — your original `.http` files are untouched. Use `--dry-run` to preview what will be generated before writing any files. Once converted, you get all the benefits of Napper: declarative assertions, composable test suites, F# and C# scripting, and CI/CD integration. +Migration is non-destructive — your original `.http` files are untouched. Use `--dry-run` to preview what will be generated before writing any files. Once converted, you get all the benefits of Napper: declarative assertions, composable test suites, scripting in JavaScript, Python, F#, or C#, and CI/CD integration. See [Napper vs .http files](/docs/vs-http-files/) for a full comparison. -## VS Code extension — native editor integration +## Editor-native, LSP-powered -The [Napper VS Code extension](https://marketplace.visualstudio.com/items?itemName=nimblesite.napper) brings the full experience into your editor: +Napper meets you in your editor. There are first-class extensions for [VS Code](https://marketplace.visualstudio.com/items?itemName=nimblesite.napper) and [Zed](https://zed.dev/), plus a portable **language server** that brings completions, diagnostics, and hover to any editor that speaks LSP. The [Napper VS Code extension](https://marketplace.visualstudio.com/items?itemName=nimblesite.napper) brings the full experience into your editor: - **Syntax highlighting** for `.nap`, `.naplist`, and `.napenv` files - **Request explorer** in the sidebar with a tree view of all requests and playlists @@ -286,10 +309,10 @@ code --install-extension nimblesite.napper | Feature | Napper | [Postman](/docs/vs-postman/) | [Bruno](/docs/vs-bruno/) | [.http files](/docs/vs-http-files/) | |---------|--------|---------|-------|-------------| | CLI-first design | Yes | No | GUI-first | No CLI | -| VS Code integration | Native | Separate app | Separate app | REST Client | +| Editor integration | VS Code, Zed & LSP | Separate app | Separate app | REST Client | | Git-friendly files | Plain text | JSON blobs | Yes | Yes | | Assertions | Declarative + scripts | JS scripts | JS scripts | None | -| Scripting language | **C# + F# (.NET)** | Sandboxed JS | Sandboxed JS | None | +| Scripting language | **JS, Python, F#, C#** | Sandboxed JS | Sandboxed JS | None | | CI/CD output | JUnit, JSON, NDJSON | Via Newman | Via CLI | None | | Test Explorer | Native | No | No | No | | OpenAPI import | URL + file + AI | Import only | Import only | No | @@ -304,7 +327,7 @@ code --install-extension nimblesite.napper 3. [Migrate existing .http files](/docs/vs-http-files/) with `napper convert http` 4. Add [assertions](/docs/assertions/) to validate responses 5. Set up [environments](/docs/environments/) for different targets -6. Write [C# scripts](/docs/csharp-scripting/) or [F# scripts](/docs/fsharp-scripting/) for advanced flows +6. Write scripts in [JavaScript](/docs/javascript-scripting/), [Python](/docs/python-scripting/), [C#](/docs/csharp-scripting/), or [F#](/docs/fsharp-scripting/) for advanced flows 7. Run everything in [CI/CD](/docs/ci-integration/) with JUnit XML output -Napper is free, open source, and [MIT licensed](https://github.com/MelbourneDeveloper/napper/blob/main/LICENSE). Browse the source code and examples on [GitHub](https://github.com/MelbourneDeveloper/napper). +Napper is free, open source, and [MIT licensed](https://github.com/Nimblesite/napper/blob/main/LICENSE). Browse the source code and examples on [GitHub](https://github.com/Nimblesite/napper). diff --git a/website/src/docs/ci-integration.md b/website/src/docs/ci-integration.md index aa53717..85b0b6c 100644 --- a/website/src/docs/ci-integration.md +++ b/website/src/docs/ci-integration.md @@ -26,7 +26,7 @@ jobs: - name: Download Napper CLI run: | - curl -L -o napper https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64 + curl -L -o napper https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64 chmod +x napper sudo mv napper /usr/local/bin/ @@ -48,7 +48,7 @@ api-tests: stage: test image: mcr.microsoft.com/dotnet/runtime:10.0 before_script: - - curl -L -o /usr/local/bin/napper https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64 + - curl -L -o /usr/local/bin/napper https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64 - chmod +x /usr/local/bin/napper script: - napper run ./tests/ --env ci --output junit > results.xml diff --git a/website/src/docs/csharp-scripting.md b/website/src/docs/csharp-scripting.md index 259ffef..928e4a4 100644 --- a/website/src/docs/csharp-scripting.md +++ b/website/src/docs/csharp-scripting.md @@ -122,8 +122,10 @@ Both F# and C# scripts have full access to the .NET ecosystem. Choose based on y | Immutability | Default | Opt-in | | Ecosystem familiarity | Smaller community | Most .NET developers | -You can mix F# and C# scripts in the same project. A `.naplist` can reference both `.fsx` and `.csx` files as steps. +You can mix languages in the same project. A `.naplist` can reference `.fsx`, `.csx`, `.js`, and `.py` files as steps — every language sees the same `ctx` and `nap` surface. ## Requirements -C# scripts require the **.NET 10 SDK** installed on the machine. The Napper CLI binary itself is self-contained, but `.csx` scripts are executed via the .NET scripting runtime. +C# scripts require the **.NET 10 SDK** installed on the machine. The Napper CLI binary itself is self-contained, but `.csx` scripts are executed via the .NET scripting runtime. You only need the .NET SDK if you actually write `.fsx`/`.csx` hooks. + +Prefer a different language? Napper scripts in [JavaScript](/docs/javascript-scripting/), [Python](/docs/python-scripting/), and [F#](/docs/fsharp-scripting/) too. See the [Scripting Overview](/docs/scripting/). diff --git a/website/src/docs/fsharp-scripting.md b/website/src/docs/fsharp-scripting.md index 28e2807..8640c9a 100644 --- a/website/src/docs/fsharp-scripting.md +++ b/website/src/docs/fsharp-scripting.md @@ -111,6 +111,6 @@ Orchestration scripts receive a `runner` object: ## Requirements -F# scripts require the **.NET 10 SDK** installed on the machine. The Napper CLI binary itself is self-contained, but `.fsx` scripts are executed via F# Interactive. +F# scripts require the **.NET 10 SDK** installed on the machine. The Napper CLI binary itself is self-contained, but `.fsx` scripts are executed via F# Interactive. F# is one of four scripting languages — you only need the .NET SDK if you actually write `.fsx`/`.csx` hooks. -Prefer C#? See [C# Scripting](/docs/csharp-scripting/) for the same capabilities using `.csx` files. +Prefer a different language? Napper scripts in [JavaScript](/docs/javascript-scripting/), [Python](/docs/python-scripting/), and [C#](/docs/csharp-scripting/) too — the same `ctx` and `nap` surface in every one. See the [Scripting Overview](/docs/scripting/). diff --git a/website/src/docs/index.md b/website/src/docs/index.md index 007aa43..f9f6ffe 100644 --- a/website/src/docs/index.md +++ b/website/src/docs/index.md @@ -12,14 +12,15 @@ eleventyNavigation: ![Screenshot: Napper VS Code extension showing the request explorer panel, syntax-highlighted .nap file, and response viewer with JSON body and assertion results](introduction-overview.png) -**Napper** is a free, open-source, CLI-first API testing tool that integrates natively with VS Code. It is a modern alternative to Postman, Bruno, `.http` files, and curl. +**Napper** is a free, open-source, CLI-first API testing tool for anyone testing APIs. It integrates natively with VS Code and Zed, and works in any editor through a portable language server. It is a modern alternative to Postman, Bruno, `.http` files, and curl. -Napper is built for developers who want: +Napper is built for anyone who wants: - **Simple things to be simple** — a one-off request is nearly as terse as curl (spec: nap-minimal) -- **Complex things to be possible** — full F# and C# scripting for advanced flows (spec: script-fsx, script-csx) +- **Complex things to be possible** — script advanced flows in JavaScript, Python, F#, or C# (spec: script-js, script-py, script-fsx, script-csx) - **Everything in version control** — plain text files, no binary blobs (spec: nap-file, naplist-file, env-file) -- **First-class VS Code support** — syntax highlighting, Test Explorer, environment switching +- **First-class editor support** — VS Code & Zed extensions plus a portable LSP: syntax highlighting, Test Explorer, environment switching +- **No runtime to install** — Napper ships as a self-contained native binary, not a .NET DLL - **Easy migration** — convert existing `.http` files with a single CLI command (spec: cli-convert) ## How does Napper work? @@ -67,7 +68,7 @@ body.id exists duration < 500ms ``` -Chain requests into test suites with `.naplist` files (spec: naplist-file). Add F# or C# scripts for advanced orchestration (spec: script-fsx, script-csx). Output JUnit XML for your CI pipeline (spec: output-junit). +Chain requests into test suites with `.naplist` files (spec: naplist-file). Add JavaScript, Python, F#, or C# scripts for advanced orchestration — your language, your runtime, no sandbox (spec: script-js, script-py, script-fsx, script-csx). Output JUnit XML for your CI pipeline (spec: output-junit). ## Already using .http files? (spec: cli-convert) diff --git a/website/src/docs/installation.md b/website/src/docs/installation.md index 7b8ff94..ba46733 100644 --- a/website/src/docs/installation.md +++ b/website/src/docs/installation.md @@ -12,7 +12,7 @@ eleventyNavigation: ![Screenshot: Napper VS Code extension installed and active in the VS Code Activity Bar, showing the Napper panel icon](installation-vscode-activity-bar.png) -Napper has two components: the **CLI binary** and the **VS Code extension**. The CLI is standalone with no runtime dependencies. The extension shells out to the CLI, so you need both for full VS Code integration. +Napper has two parts: the **CLI binary** and an **editor integration**. The CLI is a self-contained native binary (not a .NET DLL) with no runtime dependencies — it ships the [language server](/docs/) inside it too. The editor integration shells out to the CLI, so you need both for the full experience. There are native extensions for **VS Code** and **Zed**, and any LSP-capable editor can connect to the bundled language server. --- @@ -43,10 +43,10 @@ ext install nimblesite.napper ### Install a VSIX manually -If you need a specific version or are working in an air-gapped environment, download the `.vsix` file from [GitHub Releases](https://github.com/MelbourneDeveloper/napper/releases) and install it manually. +If you need a specific version or are working in an air-gapped environment, download the `.vsix` file from [GitHub Releases](https://github.com/Nimblesite/napper/releases) and install it manually. **Via the VS Code UI:** -1. Download `napper-<version>.vsix` from the [Releases page](https://github.com/MelbourneDeveloper/napper/releases) +1. Download `napper-<version>.vsix` from the [Releases page](https://github.com/Nimblesite/napper/releases) 2. Open the Extensions panel (`Ctrl+Shift+X` / `Cmd+Shift+X`) 3. Click the `...` menu (top-right of the panel) 4. Select **Install from VSIX...** @@ -79,14 +79,14 @@ The CLI is a self-contained binary with **no runtime dependencies** — no .NET, ### Download from GitHub Releases -Download the binary for your platform from [GitHub Releases](https://github.com/MelbourneDeveloper/napper/releases). The current release is **v0.10.0**. +Download the binary for your platform from [GitHub Releases](https://github.com/Nimblesite/napper/releases). The current release is **v0.10.0**. | Platform | Binary | |----------|--------| -| macOS (Apple Silicon) | [`napper-osx-arm64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-osx-arm64) | -| macOS (Intel) | [`napper-osx-x64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-osx-x64) | -| Linux (x64) | [`napper-linux-x64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64) | -| Windows (x64) | [`napper-win-x64.exe`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-win-x64.exe) | +| macOS (Apple Silicon) | [`napper-osx-arm64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-osx-arm64) | +| macOS (Intel) | [`napper-osx-x64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-osx-x64) | +| Linux (x64) | [`napper-linux-x64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64) | +| Windows (x64) | [`napper-win-x64.exe`](https://github.com/Nimblesite/napper/releases/latest/download/napper-win-x64.exe) | **macOS / Linux — make it executable and move to PATH:** ```bash @@ -104,18 +104,18 @@ Move `napper-win-x64.exe` to a folder on your `PATH`, or rename it to `napper.ex The install script auto-detects your platform and verifies the SHA256 checksum: ```bash -curl -fsSL https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.sh | bash ``` Install a specific version: ```bash -curl -fsSL https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.sh | bash -s 0.10.0 +curl -fsSL https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.sh | bash -s 0.10.0 ``` ### Install script (Windows) ```powershell -irm https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.ps1 | iex +irm https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.ps1 | iex ``` Install a specific version: @@ -128,7 +128,7 @@ Install a specific version: If you have the .NET SDK and `make` installed, you can build from source: ```bash -git clone https://github.com/MelbourneDeveloper/napper.git +git clone https://github.com/Nimblesite/napper.git cd napper make install-binaries ``` @@ -150,13 +150,16 @@ You should see the version number and the list of available commands. | Scenario | Requirement | |----------|-------------| -| Running `.nap` / `.naplist` files | None — the CLI binary is self-contained | +| Running `.nap` / `.naplist` files | None — the CLI is a self-contained native binary, not a .NET DLL | | VS Code extension | VS Code 1.95.0 or later | +| Zed extension | Zed (latest) | +| JavaScript script hooks (`.js`) | [Node.js 18+](https://nodejs.org/) | +| Python script hooks (`.py`) | [Python 3.9+](https://www.python.org/downloads/) | | F# script hooks (`.fsx`) | [.NET 10 SDK](https://dotnet.microsoft.com/download) | | C# script hooks (`.csx`) | [.NET 10 SDK](https://dotnet.microsoft.com/download) | | Building from source | .NET 10 SDK + `make` | -No account is required. Napper is entirely open source and free. +You only need a script runtime for the language you actually script in — a JavaScript shop never installs .NET, and a .NET shop never installs Node. No account is required. Napper is entirely open source and free. --- @@ -234,9 +237,9 @@ On macOS, you may see a warning that the binary is from an unidentified develope xattr -dr com.apple.quarantine /usr/local/bin/napper ``` -**Script hooks fail with "dotnet not found"** +**Script hooks fail with "runtime not found"** -F# (`.fsx`) and C# (`.csx`) script hooks require the .NET 10 SDK. Download it from [dotnet.microsoft.com](https://dotnet.microsoft.com/download). Plain `.nap` and `.naplist` files do not need the SDK. +Script hooks need the runtime for the language they are written in — and only that one. JavaScript (`.js`) needs [Node.js 18+](https://nodejs.org/), Python (`.py`) needs [Python 3.9+](https://www.python.org/downloads/), and F# (`.fsx`) / C# (`.csx`) need the [.NET 10 SDK](https://dotnet.microsoft.com/download). Napper resolves each runtime from its setting (`nap.nodePath`, `nap.pythonPath`, `nap.dotnetPath`), the matching environment variable, or your `PATH`. Plain `.nap` and `.naplist` files need no runtime at all. The JavaScript and Python SDKs are bundled with Napper, so `import "napper"` works with no `npm install` or `pip install`. --- diff --git a/website/src/docs/javascript-scripting.md b/website/src/docs/javascript-scripting.md new file mode 100644 index 0000000..64f149f --- /dev/null +++ b/website/src/docs/javascript-scripting.md @@ -0,0 +1,133 @@ +--- +layout: layouts/docs.njk +title: JavaScript Scripting +description: "Use JavaScript scripts for pre/post request hooks and test orchestration in Napper. Real Node.js runtime, full npm access, no sandbox." +keywords: "JavaScript scripting, Node.js API testing, js, pre-request script, post-request script, test orchestration, napper sdk" +eleventyNavigation: + key: "JavaScript Scripting" + order: 6.1 +--- + +# JavaScript Scripting (spec: script-js) + +Napper runs JavaScript scripts (`.js` / `.mjs` files) via **Node.js** for pre/post request hooks and test orchestration. Scripts run on the real Node runtime with full access to npm — no sandbox, no limits. + +## Pre/post request hooks (spec: script-pre, script-post) + +Reference scripts in your `.nap` file: + +``` +[script] +pre = ./scripts/setup-auth.js +post = ./scripts/validate-response.js +``` + +Import the injected `ctx` from the bundled `napper` module — no `npm install` required: + +### Pre-request scripts (spec: script-pre) + +Run before the HTTP request is sent. Use them to set up authentication, generate dynamic data, or modify variables. + +```js +// setup-auth.js +import { ctx } from "napper"; + +const token = generateToken(); +ctx.set("token", token); +ctx.log(`Token generated: ${token.slice(0, 8)}...`); +``` + +### Post-request scripts (spec: script-post) + +Run after the response is received. Use them for complex validation, data extraction, or chaining. + +```js +// validate-response.js +import { ctx } from "napper"; + +const body = ctx.response.json; + +// Extract and pass to the next step +ctx.set("userId", String(body.id)); + +// Complex validation +if (body.id <= 0) ctx.fail("User ID must be positive"); + +ctx.log(`Created user ${body.id}`); +``` + +## NapContext (spec: script-context) + +Scripts receive a `ctx` object with these members: + +| Member | Available | Description | +|--------|-----------|-------------| +| `ctx.vars` | Pre + Post | Object of all resolved variables | +| `ctx.request` | Pre + Post | The request about to be sent (`method`, `url`, `headers`, `body`) | +| `ctx.response` | Post only | Response with `status`, `headers`, `body`, `json`, `durationMs` | +| `ctx.env` | Pre + Post | Current environment name | +| `ctx.set(key, value)` | Pre + Post | Set a variable for downstream steps | +| `ctx.fail(message)` | Pre + Post | Fail the test with a message | +| `ctx.log(message)` | Pre + Post | Write to test output | + +## Orchestration scripts (spec: script-orchestration) + +For complex flows, a `.js` file can be the entry point and drive requests directly with the injected `nap` runner: + +```js +// orchestration.js +import { nap } from "napper"; + +// Run a request and get the result +const login = await nap.run("./auth/login.nap"); + +// Extract token from the response +nap.vars.token = login.response.json.token; + +// Run a suite of tests with the token +const results = await nap.runList("./crud-tests.naplist"); + +// Data-driven testing +for (const userId of [1, 2, 3, 42, 99]) { + nap.vars.userId = String(userId); + const result = await nap.run("./users/get-user.nap"); + if (result.response.status !== 200) nap.fail(`User ${userId} failed`); +} +``` + +Reference orchestration scripts in a `.naplist`: + +``` +[steps] +./scripts/orchestration.js +``` + +## NapRunner (spec: script-runner) + +Orchestration scripts receive a `nap` object: + +| Member | Description | +|--------|-------------| +| `nap.run(path)` | Run a `.nap` file, returns a result (`status`, `json`, `body`, `headers`, `durationMs`, `passed`) | +| `nap.runList(path)` | Run a `.naplist` file, returns a list of results | +| `nap.vars` | Shared, mutable variable object | +| `nap.log(message)` | Write to test output | +| `nap.fail(message)` | Fail the orchestration with a message | + +## How it works (spec: script-protocol) + +Napper hands the context to your script as JSON and reads back any `set`/`fail`/`log` calls — the bundled `napper` SDK wraps this protocol into the idiomatic `ctx` / `nap` objects above. Orchestration calls (`nap.run`) invoke the Napper binary itself with `--output json`, so script-driven and direct runs behave identically. + +## Editor autocomplete + +The bundled SDK ships TypeScript `.d.ts` declarations, so editors give full completion on `ctx` and `nap`. For explicit vendoring or CI caching, install the published package: + +```bash +npm install --save-dev @nimblesite/napper +``` + +## Requirements (spec: script-runtime) + +JavaScript scripts require **Node.js 18+** on the machine. The Napper CLI binary itself is self-contained; `.js` scripts are executed via `node`, resolved from the `nap.nodePath` setting, the `NAPPER_NODE` environment variable, or your `PATH`. Plain `.nap` and `.naplist` files need no runtime at all. + +Prefer Python? See [Python Scripting](/docs/python-scripting/). Already in .NET? [F#](/docs/fsharp-scripting/) and [C#](/docs/csharp-scripting/) give the same surface. diff --git a/website/src/docs/naplist-files.md b/website/src/docs/naplist-files.md index 831b75e..eacac1e 100644 --- a/website/src/docs/naplist-files.md +++ b/website/src/docs/naplist-files.md @@ -81,20 +81,22 @@ Run another `.naplist` file: Nesting is recursive — playlists can reference other playlists. -### F# and C# scripts (spec: naplist-script-step) +### Scripts (spec: naplist-script-step) -Run an orchestration script: +Run an orchestration script in any supported language — a single playlist can mix them: ``` +./scripts/seed-data.js +./scripts/setup.py ./scripts/setup.fsx ./scripts/setup.csx ``` -Scripts can use the injected `NapRunner` to run requests and playlists programmatically. See [F# Scripting](/docs/fsharp-scripting/) or [C# Scripting](/docs/csharp-scripting/). +Scripts can use the injected `nap` runner (`NapRunner`) to run requests and playlists programmatically. See the [Scripting Overview](/docs/scripting/), or the [JavaScript](/docs/javascript-scripting/), [Python](/docs/python-scripting/), [F#](/docs/fsharp-scripting/), and [C#](/docs/csharp-scripting/) guides. ## Variables (spec: naplist-var-scope) -Variables defined in `[vars]` are available to all steps. Steps can also set variables for downstream steps using F# or C# scripts. +Variables defined in `[vars]` are available to all steps. Steps can also set variables for downstream steps using scripts in any supported language (`ctx.set` / `nap.vars`). ## Running playlists diff --git a/website/src/docs/openapi-import.md b/website/src/docs/openapi-import.md index 82e24a7..ecef820 100644 --- a/website/src/docs/openapi-import.md +++ b/website/src/docs/openapi-import.md @@ -292,7 +292,7 @@ napper run ./tests/petstore.naplist --output junit > results.xml - Verify the spec is valid JSON. YAML is not supported yet — convert it first. - Check that the spec is valid OpenAPI 3.x or Swagger 2.0 using the [Swagger Editor](https://editor.swagger.io/). -- Some specs with complex `$ref` chains may not resolve correctly. Open an issue on [GitHub](https://github.com/MelbourneDeveloper/napper/issues) with the spec attached. +- Some specs with complex `$ref` chains may not resolve correctly. Open an issue on [GitHub](https://github.com/Nimblesite/napper/issues) with the spec attached. **URL import fails with a network error** diff --git a/website/src/docs/python-scripting.md b/website/src/docs/python-scripting.md new file mode 100644 index 0000000..8668e56 --- /dev/null +++ b/website/src/docs/python-scripting.md @@ -0,0 +1,134 @@ +--- +layout: layouts/docs.njk +title: Python Scripting +description: "Use Python scripts for pre/post request hooks and test orchestration in Napper. Real Python 3 runtime, full PyPI access, no sandbox." +keywords: "Python scripting, Python API testing, py, pre-request script, post-request script, test orchestration, napper sdk" +eleventyNavigation: + key: "Python Scripting" + order: 6.2 +--- + +# Python Scripting (spec: script-py) + +Napper runs Python scripts (`.py` files) via **Python 3** for pre/post request hooks and test orchestration. Scripts run on the real Python runtime with full access to PyPI — no sandbox, no limits. + +## Pre/post request hooks (spec: script-pre, script-post) + +Reference scripts in your `.nap` file: + +``` +[script] +pre = ./scripts/setup_auth.py +post = ./scripts/validate_response.py +``` + +Import the injected `ctx` from the bundled `napper` module — no `pip install` required: + +### Pre-request scripts (spec: script-pre) + +Run before the HTTP request is sent. Use them to set up authentication, generate dynamic data, or modify variables. + +```python +# setup_auth.py +from napper import ctx + +token = generate_token() +ctx.set("token", token) +ctx.log(f"Token generated: {token[:8]}...") +``` + +### Post-request scripts (spec: script-post) + +Run after the response is received. Use them for complex validation, data extraction, or chaining. + +```python +# validate_response.py +from napper import ctx + +body = ctx.response.json + +# Extract and pass to the next step +ctx.set("userId", str(body["id"])) + +# Complex validation +if body["id"] <= 0: + ctx.fail("User ID must be positive") + +ctx.log(f"Created user {body['id']}") +``` + +## NapContext (spec: script-context) + +Scripts receive a `ctx` object with these members: + +| Member | Available | Description | +|--------|-----------|-------------| +| `ctx.vars` | Pre + Post | Dict of all resolved variables | +| `ctx.request` | Pre + Post | The request about to be sent (`method`, `url`, `headers`, `body`) | +| `ctx.response` | Post only | Response with `status`, `headers`, `body`, `json`, `duration_ms` | +| `ctx.env` | Pre + Post | Current environment name | +| `ctx.set(key, value)` | Pre + Post | Set a variable for downstream steps | +| `ctx.fail(message)` | Pre + Post | Fail the test with a message | +| `ctx.log(message)` | Pre + Post | Write to test output | + +## Orchestration scripts (spec: script-orchestration) + +For complex flows, a `.py` file can be the entry point and drive requests directly with the injected `nap` runner: + +```python +# orchestration.py +from napper import nap + +# Run a request and get the result +login = nap.run("./auth/login.nap") + +# Extract token from the response +nap.vars["token"] = login.response.json["token"] + +# Run a suite of tests with the token +results = nap.run_list("./crud-tests.naplist") + +# Data-driven testing +for user_id in (1, 2, 3, 42, 99): + nap.vars["userId"] = str(user_id) + result = nap.run("./users/get-user.nap") + if result.response.status != 200: + nap.fail(f"User {user_id} failed") +``` + +Reference orchestration scripts in a `.naplist`: + +``` +[steps] +./scripts/orchestration.py +``` + +## NapRunner (spec: script-runner) + +Orchestration scripts receive a `nap` object: + +| Member | Description | +|--------|-------------| +| `nap.run(path)` | Run a `.nap` file, returns a result (`status`, `json`, `body`, `headers`, `duration_ms`, `passed`) | +| `nap.run_list(path)` | Run a `.naplist` file, returns a list of results | +| `nap.vars` | Shared, mutable variable dict | +| `nap.log(message)` | Write to test output | +| `nap.fail(message)` | Fail the orchestration with a message | + +## How it works (spec: script-protocol) + +Napper hands the context to your script as JSON and reads back any `set`/`fail`/`log` calls — the bundled `napper` SDK wraps this protocol into the idiomatic `ctx` / `nap` objects above. Orchestration calls (`nap.run`) invoke the Napper binary itself with `--output json`, so script-driven and direct runs behave identically. + +## Editor autocomplete + +The bundled SDK ships `.pyi` type stubs, so editors give full completion on `ctx` and `nap`. For explicit vendoring or CI caching, install the published package: + +```bash +pip install napper +``` + +## Requirements (spec: script-runtime) + +Python scripts require **Python 3.9+** on the machine. The Napper CLI binary itself is self-contained; `.py` scripts are executed via `python3` (falling back to `python`), resolved from the `nap.pythonPath` setting, the `NAPPER_PYTHON` environment variable, or your `PATH`. Plain `.nap` and `.naplist` files need no runtime at all. + +Prefer JavaScript? See [JavaScript Scripting](/docs/javascript-scripting/). Already in .NET? [F#](/docs/fsharp-scripting/) and [C#](/docs/csharp-scripting/) give the same surface. diff --git a/website/src/docs/scripting.md b/website/src/docs/scripting.md new file mode 100644 index 0000000..9c5d42c --- /dev/null +++ b/website/src/docs/scripting.md @@ -0,0 +1,86 @@ +--- +layout: layouts/docs.njk +title: Scripting Overview +description: "Napper scripts in JavaScript, Python, F#, or C#. Same ctx and nap surface in every language, run by real runtimes — no sandbox. Pick the language you already test with." +keywords: "API testing scripting, JavaScript scripting, Python scripting, F# scripting, C# scripting, pre-request script, post-request script, test orchestration" +eleventyNavigation: + key: "Scripting Overview" + order: 6 +--- + +# Scripting Overview + +Most checks need no code at all — the declarative [`[assert]` block](/docs/assertions/) covers status codes, JSON paths, headers, and timing. When you need real logic, Napper lets you **script in the language you already use**. + +| Language | Extension | Runtime | Guide | +|----------|-----------|---------|-------| +| JavaScript | `.js` / `.mjs` | Node.js 18+ | [JavaScript Scripting](/docs/javascript-scripting/) | +| Python | `.py` | Python 3.9+ | [Python Scripting](/docs/python-scripting/) | +| F# | `.fsx` | .NET 10 SDK | [F# Scripting](/docs/fsharp-scripting/) | +| C# | `.csx` | .NET 10 SDK | [C# Scripting](/docs/csharp-scripting/) | + +There is no "preferred" language. Use whatever your team already tests with. `.fsx` and `.csx` happen to be genuinely lovely — concise, strongly typed, immutable by default — but they are never required. + +## One surface, every language (spec: script-context, script-runner) + +Every language sees the **same two objects**: + +- **`ctx`** — the request/response context for pre/post hooks: `ctx.vars`, `ctx.request`, `ctx.response`, `ctx.set(...)`, `ctx.fail(...)`, `ctx.log(...)`. +- **`nap`** — the orchestration runner for script-driven flows: `nap.run(...)`, `nap.runList(...)`, `nap.vars`, `nap.fail(...)`. + +The same post-script in four languages: + +```js +// validate-user.js +import { ctx } from "napper"; +const user = ctx.response.json; +if (user.id !== ctx.vars.userId) ctx.fail("User ID mismatch"); +ctx.set("token", user.sessionToken); +``` + +```python +# validate_user.py +from napper import ctx +user = ctx.response.json +if user["id"] != ctx.vars["userId"]: + ctx.fail("User ID mismatch") +ctx.set("token", user["sessionToken"]) +``` + +```fsharp +// validate-user.fsx +let user = ctx.Response.Json +if user.GetProperty("id").GetString() <> ctx.Vars["userId"] then ctx.Fail "User ID mismatch" +ctx.Set "token" (user.GetProperty("sessionToken").GetString()) +``` + +```csharp +// validate-user.csx +var user = ctx.Response.Json; +if (user.GetProperty("id").GetString() != ctx.Vars["userId"]) ctx.Fail("User ID mismatch"); +ctx.Set("token", user.GetProperty("sessionToken").GetString()); +``` + +## Real runtimes, no sandbox + +Unlike the sandboxed JavaScript in Postman and Bruno, Napper scripts run on **real runtimes** — Node.js, Python 3, or .NET — with full access to npm, PyPI, and NuGet. Parse XML, call a database, generate a JWT, validate a schema, or pull in any package. + +## How to reference a script (spec: nap-file, script-dispatch) + +Reference scripts from a `.nap` file's `[script]` block, or use a script file as a `.naplist` step. Dispatch is by extension, so a single playlist can mix languages: + +``` +[steps] +./auth/01_login.nap +./scripts/seed-data.js # JavaScript step +./scripts/parametrized-tests.py # Python step +./teardown/cleanup.nap +``` + +## Zero install (spec: script-sdk) + +The Napper binary **bundles** the JavaScript and Python SDKs and puts them on the runtime's module path, so `import { ctx } from "napper"` (JS) and `from napper import ctx` (Python) just work — no `npm install`, no `pip install`. The published `@nimblesite/napper` (npm) and `napper` (PyPI) packages are conveniences for editor autocomplete and explicit vendoring. + +## Requirements (spec: script-runtime) + +The Napper binary itself is self-contained with no runtime dependencies. Script hooks need the relevant runtime only for the language you actually script in — a JavaScript shop never installs .NET, and a .NET shop never installs Node. See [Installation](/docs/installation/#prerequisites). diff --git a/website/src/docs/vs-bruno.md b/website/src/docs/vs-bruno.md index 3e5ee51..f5eecfc 100644 --- a/website/src/docs/vs-bruno.md +++ b/website/src/docs/vs-bruno.md @@ -1,7 +1,7 @@ --- layout: layouts/docs.njk title: "Napper vs Bruno" -description: "Comparing Napper and Bruno for API testing. Both are open-source alternatives to Postman, but Napper is CLI-first with F# and C# scripting while Bruno is GUI-first with sandboxed JavaScript." +description: "Comparing Napper and Bruno for API testing. Both are open-source alternatives to Postman, but Napper is CLI-first with real scripting in JavaScript, Python, F#, or C# while Bruno is GUI-first with sandboxed JavaScript." keywords: "Napper vs Bruno, Bruno alternative, API testing comparison, open source API testing" eleventyNavigation: key: vs Bruno @@ -18,11 +18,11 @@ Bruno is a GUI-first tool with a standalone desktop application. It focuses on p ## How do the editors compare? -Bruno has its own standalone desktop application built with Electron. Napper integrates directly into VS Code as a native extension with syntax highlighting, a request explorer, environment switching, and Test Explorer integration. If you already work in VS Code, Napper fits into your existing workflow without switching applications. +Bruno has its own standalone desktop application built with Electron. Napper integrates directly into VS Code and Zed as native extensions — and into any editor through a portable language server — with syntax highlighting, a request explorer, environment switching, and Test Explorer integration. If you already work in an editor, Napper fits into your existing workflow without switching applications. -## How does scripting compare? (spec: script-fsx, script-csx) +## How does scripting compare? (spec: script-js, script-py, script-fsx, script-csx) -Bruno provides sandboxed JavaScript for pre-request and post-request scripts, similar to Postman. Napper supports both F# (`.fsx`) and C# (`.csx`) scripts with full access to the .NET ecosystem. Scripts in Napper are not sandboxed, so you can import NuGet packages, call databases, parse XML, generate tokens, and perform any operation the .NET runtime supports. +Bruno provides sandboxed JavaScript for pre-request and post-request scripts, similar to Postman. Napper lets you script in JavaScript (`.js`), Python (`.py`), F# (`.fsx`), or C# (`.csx`), running on the real runtime — Node.js, Python 3, or .NET — with no sandbox. Import npm, PyPI, or NuGet packages, call databases, parse XML, generate tokens, and perform any operation the runtime supports. ## How do file formats compare? (spec: nap-file) @@ -39,9 +39,9 @@ Bruno provides a CLI for running collections from the terminal. Napper is design | Primary interface | CLI + VS Code | Standalone desktop app | | CLI design | CLI-first | CLI secondary | | File format | `.nap` (TOML-inspired) | `.bru` (custom markup) | -| Assertions | Declarative + F#/C# scripts | JavaScript scripts | -| Scripting | Full F# and C# with .NET access | Sandboxed JavaScript | -| Editor integration | Native VS Code extension | Standalone Electron app | +| Assertions | Declarative + scripts | JavaScript scripts | +| Scripting | JavaScript, Python, F#, C# on real runtimes | Sandboxed JavaScript | +| Editor integration | VS Code & Zed extensions + LSP | Standalone Electron app | | Test Explorer | Native VS Code support | No | | CI/CD output | JUnit, JSON, NDJSON | JSON via CLI | | OpenAPI import | URL + file + AI | Import only | @@ -50,7 +50,7 @@ Bruno provides a CLI for running collections from the terminal. Napper is design ## When should you choose Napper over Bruno? -Choose Napper if you prefer working from the terminal, want to stay inside VS Code, need the full power of F# or C# and the .NET ecosystem for scripting, or want native JUnit output for CI/CD pipelines. Choose Bruno if you prefer a standalone GUI application with its own visual interface. +Choose Napper if you prefer working from the terminal, want to stay inside your editor (VS Code, Zed, or any editor via its language server), want to script in JavaScript, Python, F#, or C# on a real runtime, or want native JUnit output for CI/CD pipelines. Choose Bruno if you prefer a standalone GUI application with its own visual interface. ## Get started diff --git a/website/src/docs/vs-http-files.md b/website/src/docs/vs-http-files.md index 27740bd..bb324ae 100644 --- a/website/src/docs/vs-http-files.md +++ b/website/src/docs/vs-http-files.md @@ -1,7 +1,7 @@ --- layout: layouts/docs.njk title: "Napper vs .http Files" -description: "Comparing Napper and .http files for API testing. Napper adds assertions, test suites, environments, F# and C# scripting, CLI execution, and a built-in converter to migrate your existing .http files." +description: "Comparing Napper and .http files for API testing. Napper adds assertions, test suites, environments, scripting in JavaScript, Python, F#, or C#, CLI execution, and a built-in converter to migrate your existing .http files." keywords: "Napper vs http files, http file alternative, REST Client alternative, VS Code API testing, http file converter, convert http to nap, JetBrains http migration" eleventyNavigation: key: vs .http Files @@ -16,7 +16,7 @@ eleventyNavigation: `.http` files (also called `.rest` files) are plain text files supported by the REST Client extension in VS Code and by JetBrains IDEs (IntelliJ, Rider, WebStorm). They let you define HTTP requests and send them directly from your editor. They are simple and lightweight, but limited in functionality. -## What does Napper add beyond .http files? (spec: nap-assert, nap-vars, script-fsx, script-csx, cli-output) +## What does Napper add beyond .http files? (spec: nap-assert, nap-vars, script-js, script-py, script-fsx, script-csx, cli-output) Napper adds six major capabilities that `.http` files lack: @@ -24,7 +24,7 @@ Napper adds six major capabilities that `.http` files lack: - **Declarative assertions** (spec: nap-assert) — Verify status codes, JSON body paths, headers, and response times with a clean, readable syntax directly in the request file. - **Composable test suites** — Chain multiple requests into ordered playlists with `.naplist` files. Nest playlists and reference entire folders. - **Environment management** (spec: nap-vars, cli-env) — Define variables in `.napenv` files, create named environments for staging and production, and override secrets locally with `.napenv.local`. -- **F# and C# scripting** (spec: script-fsx, script-csx) — Run pre-request and post-request scripts with full access to the .NET ecosystem for token generation, data setup, and complex validation. +- **Scripting in your language** (spec: script-js, script-py, script-fsx, script-csx) — Run pre-request and post-request scripts in JavaScript, Python, F#, or C# on real runtimes for token generation, data setup, and complex validation. No sandbox. - **CLI execution** (spec: cli-run, cli-output) — Run any request or test suite from the terminal. Output JUnit XML, JSON, or NDJSON for CI/CD pipelines. ## How do I convert .http files to Napper? (spec: cli-convert) @@ -73,12 +73,12 @@ The converter auto-detects the dialect, or you can specify it explicitly with `- | Feature | Napper | .http files | |---------|--------|-------------| | Plain text requests | Yes (`.nap` files) | Yes (`.http` files) | -| VS Code support | Native extension | REST Client extension | +| Editor support | VS Code, Zed & LSP | REST Client extension | | CLI execution | Yes (primary interface) | No | -| Assertions | Declarative + F#/C# scripts | None | +| Assertions | Declarative + scripts | None | | Test suites | `.naplist` playlists | None | | Environment variables | `.napenv` files with layering | Limited (REST Client) | -| Scripting | Full F# and C# scripting | None | +| Scripting | JavaScript, Python, F#, C# on real runtimes | None | | CI/CD output | JUnit, JSON, NDJSON | None | | Test Explorer | Native VS Code support | No | | .http migration | Built-in converter | N/A | diff --git a/website/src/docs/vs-postman.md b/website/src/docs/vs-postman.md index 17f59e1..048ed23 100644 --- a/website/src/docs/vs-postman.md +++ b/website/src/docs/vs-postman.md @@ -1,7 +1,7 @@ --- layout: layouts/docs.njk title: "Napper vs Postman" -description: "Comparing Napper and Postman for API testing. Napper is a free, open-source, CLI-first alternative to Postman with F# and C# scripting, plain text files, and VS Code integration." +description: "Comparing Napper and Postman for API testing. Napper is a free, open-source, CLI-first alternative to Postman with scripting in JavaScript, Python, F#, or C#, plain text files, and native VS Code, Zed, and language-server integration." keywords: "Napper vs Postman, Postman alternative, API testing comparison, free Postman replacement" eleventyNavigation: key: vs Postman @@ -14,7 +14,7 @@ Napper is a free, open-source, CLI-first alternative to Postman for API testing. ## What is the main difference between Napper and Postman? -Postman is a GUI-first application with a standalone desktop client. The command line interface (Newman) is a secondary tool. Napper takes the opposite approach: the CLI is the primary product, and the VS Code extension provides a visual interface within your existing editor. +Postman is a GUI-first application with a standalone desktop client. The command line interface (Newman) is a secondary tool. Napper takes the opposite approach: the CLI is the primary product, and the IDE extension provides a visual interface within your existing editor. Currently, the main IDE extension is vscode, but the LSP decoupling means that we will soon be able to deliver for Zed, neovim, Intellij etc. ## Does Napper require an account? @@ -24,9 +24,9 @@ No. Napper requires no account, no sign-up, and no cloud sync. Postman requires Postman stores collections as JSON blobs that are difficult to read in diffs and code reviews. Napper stores every request as a plain text `.nap` file, every test suite as a `.naplist` file, and every environment as a `.napenv` file. All formats are human-readable and produce clean git diffs. -## How does scripting compare? (spec: script-fsx, script-csx) +## How does scripting compare? (spec: script-js, script-py, script-fsx, script-csx) -Postman provides a sandboxed JavaScript environment with a limited set of built-in libraries. Napper supports both F# (`.fsx`) and C# (`.csx`) scripts with full access to the .NET ecosystem. You can parse XML, call databases, generate cryptographic tokens, validate JSON schemas, and reference any NuGet package. +Postman provides a sandboxed JavaScript environment with a limited set of built-in libraries. Napper lets you script in JavaScript (`.js`), Python (`.py`), F# (`.fsx`), or C# (`.csx`) — whichever you already use — running on real runtimes with full access to npm, PyPI, and NuGet. You can parse XML, call databases, generate cryptographic tokens, validate JSON schemas, and reference any package, with no sandbox. ## How does CI/CD integration compare? (spec: cli-run, cli-output) @@ -37,10 +37,10 @@ Postman requires Newman (a separate npm package) for running collections from th | Feature | Napper | Postman | |---------|--------|---------| | CLI-first design | Yes | No (Newman is secondary) | -| VS Code integration | Native extension | Separate app | +| Editor integration | VS Code, Zed & LSP | Separate app | | Git-friendly files | Plain text `.nap` files | JSON blobs | -| Assertions | Declarative + F#/C# scripts | JavaScript scripts | -| Scripting | Full F# and C# with .NET access | Sandboxed JavaScript | +| Assertions | Declarative + scripts | JavaScript scripts | +| Scripting | JavaScript, Python, F#, C# on real runtimes | Sandboxed JavaScript | | CI/CD output | JUnit, JSON, NDJSON | Via Newman | | Test Explorer | Native VS Code support | No | | Account required | No | Yes | @@ -50,7 +50,7 @@ Postman requires Newman (a separate npm package) for running collections from th ## When should you choose Napper over Postman? -Choose Napper if you want a tool that lives in your terminal and editor, stores everything as plain text in your repository, runs natively in CI/CD without additional dependencies, and gives you the full power of F# and C# for advanced scripting. Choose Postman if you need a standalone GUI application with built-in collaboration features and cloud-based team workspaces. +Choose Napper if you want a tool that lives in your terminal and editor, stores everything as plain text in your repository, runs natively in CI/CD without additional dependencies, and lets you script in JavaScript, Python, F#, or C# — your language, your runtime. Choose Postman if you need a standalone GUI application with built-in collaboration features and cloud-based team workspaces. ## Get started diff --git a/website/src/index.njk b/website/src/index.njk index 4470120..42fb420 100644 --- a/website/src/index.njk +++ b/website/src/index.njk @@ -1,7 +1,7 @@ --- layout: layouts/base.njk -title: "Napper — CLI-First API Testing for VS Code" -description: "Napper is a free, open-source API testing tool that runs from the command line and integrates natively with VS Code. A modern alternative to Postman and Bruno with F# and C# scripting, declarative assertions, and CI/CD integration." +title: "Napper — CLI-First API Testing for VS Code, Zed & any editor" +description: "Napper is a free, open-source API testing tool for anyone testing APIs. Run from the command line, script in JavaScript, Python, F#, or C#, and edit anywhere with a native VS Code & Zed extension and a portable language server. A modern alternative to Postman and Bruno with declarative assertions and CI/CD integration." permalink: / --- @@ -11,13 +11,13 @@ permalink: / <img src="/assets/images/logo.png" alt="Napper logo — open-source CLI-first API testing tool for VS Code" class="hero-logo" width="80" height="80"> <h1>API Testing,<br>Supercharged.</h1> <p class="hero-subtitle"> - Napper is a free, open-source API testing tool that runs from the command line and integrates natively with VS Code. + Napper is a free, open-source API testing tool for anyone testing APIs. It runs from the command line and edits natively in VS Code, Zed, and any editor via a portable language server. Define HTTP requests as plain text <code>.nap</code> files, add declarative assertions, chain them into test suites, and run everything in CI/CD with JUnit output. - Migrate from <code>.http</code> files with a single command. As simple as curl for quick requests. As powerful as F# and C# for full test suites. + Migrate from <code>.http</code> files with a single command. As simple as curl for quick requests. As powerful as your own code — script in JavaScript, Python, F#, or C#. </p> <div class="hero-actions"> <a href="/docs/installation/" class="btn btn-primary">Get Started</a> - <a href="https://github.com/MelbourneDeveloper/napper" class="btn btn-secondary">View on GitHub</a> + <a href="https://github.com/Nimblesite/napper" class="btn btn-secondary">View on GitHub</a> </div> {# ---- Code Demo ---- #} @@ -78,16 +78,16 @@ permalink: / <div class="feature-icon" style="background: rgba(232,115,74,0.12); color: #E8734A;"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg> </div> - <h3>VS Code Native</h3> - <p>Full extension with syntax highlighting, request explorer, environment switching, and Test Explorer integration. Never leave your editor.</p> + <h3>Editor-Native, LSP-Powered</h3> + <p>First-class extensions for VS Code and Zed, plus a portable language server that brings completions, diagnostics, and hover to any editor. Syntax highlighting, request explorer, environment switching, and Test Explorer integration. Never leave your editor.</p> </div> <div class="feature-card"> <div class="feature-icon" style="background: rgba(27,73,101,0.12); color: #1B4965;"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg> </div> - <h3>F# and C# Scripting</h3> - <p>Full power of F# and C# for pre/post request hooks. Extract tokens, build dynamic payloads, orchestrate complex flows with the entire .NET ecosystem.</p> + <h3>Script in Any Language</h3> + <p>Write pre/post hooks and orchestration in JavaScript, Python, F#, or C# — whatever your team already runs. Extract tokens, build dynamic payloads, and orchestrate complex flows with real runtimes and full ecosystem access. No sandbox. <code>.fsx</code> and <code>.csx</code> are genuinely lovely, but never required.</p> </div> <div class="feature-card"> @@ -160,11 +160,11 @@ permalink: / <td><span class="cross">No CLI</span></td> </tr> <tr> - <td>VS Code integration</td> - <td><span class="check">Native</span></td> + <td>Editor integration</td> + <td><span class="check">VS Code, Zed & LSP</span></td> <td><span class="cross">Separate app</span></td> <td><span class="cross">Separate app</span></td> - <td><span class="check">Built-in</span></td> + <td><span class="check">VS Code only</span></td> </tr> <tr> <td>Git-friendly files</td> @@ -182,7 +182,7 @@ permalink: / </tr> <tr> <td>Full scripting language</td> - <td><span class="check">F# + C# (.fsx/.csx)</span></td> + <td><span class="check">JS, Python, F#, C#</span></td> <td><span class="cross">Sandboxed JS</span></td> <td><span class="cross">Sandboxed JS</span></td> <td><span class="cross">None</span></td> @@ -323,7 +323,7 @@ permalink: / <div class="faq-list"> <div class="faq-item"> <h3>What is Napper?</h3> - <p>Napper is a free, open-source, CLI-first API testing tool that integrates natively with VS Code. It lets you define HTTP requests as plain text <code>.nap</code> files, add declarative assertions to validate responses, compose requests into test suites with <code>.naplist</code> files, and run everything from the terminal or your editor. It uses F# and C# for advanced scripting and outputs JUnit XML for CI/CD pipelines.</p> + <p>Napper is a free, open-source, CLI-first API testing tool for anyone testing APIs. It integrates natively with VS Code and Zed, and works in any editor through a portable language server. It lets you define HTTP requests as plain text <code>.nap</code> files, add declarative assertions to validate responses, compose requests into test suites with <code>.naplist</code> files, and run everything from the terminal or your editor. Script advanced flows in JavaScript, Python, F#, or C#, and output JUnit XML for CI/CD pipelines.</p> </div> <div class="faq-item"> @@ -333,12 +333,12 @@ permalink: / <div class="faq-item"> <h3>How is Napper different from Postman?</h3> - <p>Postman is a GUI-first tool that requires an account, stores collections as JSON, and locks advanced features behind a paywall. Napper is CLI-first, stores everything as plain text files in your repository, requires no account, and is completely free. Napper also provides F# and C# scripting instead of sandboxed JavaScript, and integrates directly into VS Code with native Test Explorer support.</p> + <p>Postman is a GUI-first tool that requires an account, stores collections as JSON, and locks advanced features behind a paywall. Napper is CLI-first, stores everything as plain text files in your repository, requires no account, and is completely free. Napper also gives you real scripting in JavaScript, Python, F#, or C# — run by real runtimes, not a sandbox — and integrates directly into VS Code, Zed, and any editor via a portable language server with native Test Explorer support.</p> </div> <div class="faq-item"> <h3>How is Napper different from Bruno?</h3> - <p>Bruno is an excellent open-source alternative to Postman, but it is GUI-first with a standalone desktop application. Napper puts the CLI first and lives inside VS Code. For scripting, Bruno offers sandboxed JavaScript while Napper gives you full F# and C# scripting with access to the entire .NET ecosystem. Both store requests as plain text files.</p> + <p>Bruno is an excellent open-source alternative to Postman, but it is GUI-first with a standalone desktop application. Napper puts the CLI first and lives inside your editor — VS Code, Zed, or any editor via its language server. For scripting, Bruno offers sandboxed JavaScript while Napper lets you script in JavaScript, Python, F#, or C# with your real runtime and full ecosystem access — no sandbox. Both store requests as plain text files.</p> </div> <div class="faq-item"> @@ -348,7 +348,7 @@ permalink: / <div class="faq-item"> <h3>What scripting languages does Napper support?</h3> - <p>Napper supports both F# (<code>.fsx</code>) and C# (<code>.csx</code>) scripts for pre-request and post-request hooks. Unlike the sandboxed JavaScript in Postman and Bruno, scripts in Napper have full access to the .NET ecosystem. You can parse XML, call databases, generate cryptographic tokens, validate complex schemas, and use any NuGet package. Choose whichever language your team prefers.</p> + <p>Napper scripts in JavaScript (<code>.js</code>), Python (<code>.py</code>), F# (<code>.fsx</code>), and C# (<code>.csx</code>) for pre-request and post-request hooks and full orchestration. Every language sees the same <code>ctx</code> and <code>nap</code> objects. Unlike the sandboxed JavaScript in Postman and Bruno, Napper scripts run on real runtimes — Node.js, Python 3, or .NET — with full ecosystem access (npm, PyPI, NuGet). Parse XML, call databases, generate cryptographic tokens, validate complex schemas, use any package. Use whatever language you already test with — though <code>.fsx</code> and <code>.csx</code> are genuinely nice.</p> </div> <div class="faq-item"> @@ -393,7 +393,7 @@ permalink: / "name": "What is Napper?", "acceptedAnswer": { "@type": "Answer", - "text": "Napper is a free, open-source, CLI-first API testing tool that integrates natively with VS Code. It lets you define HTTP requests as plain text .nap files, add declarative assertions to validate responses, compose requests into test suites with .naplist files, and run everything from the terminal or your editor. It uses F# and C# for advanced scripting and outputs JUnit XML for CI/CD pipelines." + "text": "Napper is a free, open-source, CLI-first API testing tool for anyone testing APIs. It integrates natively with VS Code and Zed, and works in any editor through a portable language server. It lets you define HTTP requests as plain text .nap files, add declarative assertions to validate responses, compose requests into test suites with .naplist files, and run everything from the terminal or your editor. Script advanced flows in JavaScript, Python, F#, or C#, and output JUnit XML for CI/CD pipelines." } }, { @@ -409,7 +409,7 @@ permalink: / "name": "How is Napper different from Postman?", "acceptedAnswer": { "@type": "Answer", - "text": "Postman is a GUI-first tool that requires an account, stores collections as JSON, and locks advanced features behind a paywall. Napper is CLI-first, stores everything as plain text files in your repository, requires no account, and is completely free. Napper also provides F# and C# scripting instead of sandboxed JavaScript, and integrates directly into VS Code with native Test Explorer support." + "text": "Postman is a GUI-first tool that requires an account, stores collections as JSON, and locks advanced features behind a paywall. Napper is CLI-first, stores everything as plain text files in your repository, requires no account, and is completely free. Napper also gives you real scripting in JavaScript, Python, F#, or C# — run by real runtimes, not a sandbox — and integrates directly into VS Code, Zed, and any editor via a portable language server with native Test Explorer support." } }, { @@ -417,7 +417,7 @@ permalink: / "name": "How is Napper different from Bruno?", "acceptedAnswer": { "@type": "Answer", - "text": "Bruno is an excellent open-source alternative to Postman, but it is GUI-first with a standalone desktop application. Napper puts the CLI first and lives inside VS Code. For scripting, Bruno offers sandboxed JavaScript while Napper gives you full F# and C# scripting with access to the entire .NET ecosystem." + "text": "Bruno is an excellent open-source alternative to Postman, but it is GUI-first with a standalone desktop application. Napper puts the CLI first and lives inside your editor — VS Code, Zed, or any editor via its language server. For scripting, Bruno offers sandboxed JavaScript while Napper lets you script in JavaScript, Python, F#, or C# with your real runtime and full ecosystem access — no sandbox." } }, { @@ -433,7 +433,7 @@ permalink: / "name": "What scripting languages does Napper support?", "acceptedAnswer": { "@type": "Answer", - "text": "Napper supports both F# (.fsx) and C# (.csx) scripts for pre-request and post-request hooks. Unlike the sandboxed JavaScript in Postman and Bruno, scripts in Napper have full access to the .NET ecosystem." + "text": "Napper scripts in JavaScript (.js), Python (.py), F# (.fsx), and C# (.csx) for pre-request and post-request hooks and full orchestration. Every language sees the same ctx and nap objects. Unlike the sandboxed JavaScript in Postman and Bruno, Napper scripts run on real runtimes — Node.js, Python 3, or .NET — with full ecosystem access to npm, PyPI, and NuGet." } }, { @@ -468,7 +468,7 @@ permalink: / "@context": "https://schema.org", "@type": "SoftwareApplication", "name": "Napper", - "description": "CLI-first API testing tool for VS Code with F# and C# scripting, declarative assertions, and CI/CD integration.", + "description": "CLI-first API testing tool for anyone testing APIs. Native VS Code & Zed extensions and a portable language server, scripting in JavaScript, Python, F#, or C#, declarative assertions, and CI/CD integration.", "url": "https://napperapi.dev", "applicationCategory": "DeveloperApplication", "operatingSystem": "Windows, macOS, Linux", @@ -478,22 +478,23 @@ permalink: / "priceCurrency": "USD" }, "license": "https://opensource.org/licenses/MIT", - "downloadUrl": "https://github.com/MelbourneDeveloper/napper/releases", + "downloadUrl": "https://github.com/Nimblesite/napper/releases", "installUrl": "https://marketplace.visualstudio.com/items?itemName=nimblesite.napper", "author": { "@type": "Person", "name": "Christian Findlay" }, - "programmingLanguage": ["F#", "C#"], + "programmingLanguage": ["JavaScript", "Python", "F#", "C#"], "featureList": [ "CLI-first HTTP API testing", - "VS Code extension with syntax highlighting", + "Native VS Code and Zed extensions plus a portable language server for any editor", "Declarative assertions on status, body, headers, timing", - "F# and C# scripting for advanced request flows", + "Scripting in JavaScript, Python, F#, or C# for advanced request flows", "Composable test suites with .naplist playlists", "Environment variable management with .napenv files", "JUnit XML, JSON, NDJSON output for CI/CD", "Native VS Code Test Explorer integration", + "Self-contained native binaries — no .NET runtime required", "OpenAPI import from URL or file with optional AI enhancement", "Built-in .http file converter for Microsoft and JetBrains formats" ]