diff --git a/CHANGELOG.md b/CHANGELOG.md index abbaa5af..c00a2474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## v0.0.50 +## v0.0.51 + + ### Added @@ -14,6 +18,14 @@ (`$e(cmd | exec)`), but refuses a piped stdin — there is no parent left to feed the replacement. +- **`cmd | line` — the single-line capture reifier.** Reads a one-value + command (`az … --query X -o tsv`, `git rev-parse`, `id -un`) as its one + trimmed line of stdout, as a `string` — replacing the + `$"{$(cmd) |> Seq.exactlyOne}"` mouthful with `let x = cmd | line`. It + joins the reifier family (`complete`/`succeeds`/`exitCode`/`orFail`/`exec`), + raises on a nonzero exit or on 0-or-2+ lines, and composes with the env + sigil (`$e(cmd | line)`) and a value head (`xs | grep foo | line`). + - **`Path.home` and the XDG directory trio (`Path.configHome`, `Path.stateHome`, `Path.cacheHome`).** The typed stand-in for `~`/`$HOME`, which never expand in argv — build a path with an interpolation instead: @@ -30,6 +42,21 @@ `#history N` shows the last N. Entries render one per line, matching the history search's display. +- **REPL kill-ring: `Ctrl+W` / `Ctrl+Y`.** `Ctrl+W` kills the previous word, + `Ctrl+Y` yanks (pastes) the last killed text, and `Ctrl+U` / `Ctrl+K` + (kill to line start / end) now feed the same ring. The ring is + session-wide, so a kill on one line yanks into a later one. (Copying from + elsewhere remains your terminal's own mouse selection — there is no + shift-arrow selection, matching every terminal line editor.) + +- **`from table` reads `az … -o table` output.** Azure CLI (and other + tabulate-style tools) draw a `------ ----------` dashes rule under the + header; `from table` now skips that separator when it is the first data + row, so both `from table T` and `#infer … from table` work on az output. + kubectl/docker tables (no separator) are unchanged, and a real row that + merely contains a lone `-` cell is kept — only an all-dashes row is + dropped. + - **A checker warning for the `Path.newTempDir` cleanup footgun.** A `Path.newTempDir ()` binding that is later `Dir.delete`/`Dir.deleteAll`'d in the same scope now draws an advisory warning pointing at `within tmp`, @@ -38,6 +65,18 @@ directory that must outlive its scope, so a binding with no in-scope delete stays silent. Warning severity — `check` still exits 0. +### Changed + +- **A failed command in the REPL is now a quiet exit-code status, not a red + error.** When a bare command you type at the prompt exits nonzero, the + REPL shows a dim `↳ exit N` instead of `error: command failed with exit + code N: …` — the output already streamed and the session continues, so + it reads like a shell's `$?` (and since weir has no `$?`, showing the + code is the only way to see it). The raise itself is unchanged: scripts + still fail-fast, and a command failure inside a value (`$(…)`, a reifier, + a binding) still raises the full error, because there it aborted a + computation. + ### Fixed - **A top-level `if … then … else …` block now parses.** The block form at @@ -57,6 +96,18 @@ mixed value types stays a typed record; genuinely uniform data-keyed objects (a ConfigMap's `data`) are still drafted as an open mapping. +- **Tab-completing a `$name` splice in a command argument now offers session + bindings.** `az … --location $l` completes to `$location` (any + binding whose name starts with `l`); before, an argv position offered only + filesystem paths, so a splice never completed. A bare (non-`$`) argument + still completes paths only. + +- **`exec` is now refused inside a `plan` block.** A helper that invokes + `cmd | exec` inside a `plan` is refused at runtime, matching the guard on + every other spawn — `execve` does not funnel through the spawn path, so + the guard is applied in `Proc.exec` directly (it could otherwise replace + the process inside a dry run). + ## v0.0.49 ### Fixed diff --git a/ci/e2e.sh b/ci/e2e.sh index 1db666d6..51b71723 100755 --- a/ci/e2e.sh +++ b/ci/e2e.sh @@ -452,6 +452,27 @@ WEOF echo "$out" | grep -qF "cannot take a piped stdin" || fail "value-headed exec must refuse at check" fi +# ---- the single-line capture reifier [D:reify-line] ------------------ +# `cmd | line` reads a one-value CLI's single stdout line as a string — +# the $(cmd) |> Seq.exactlyOne idiom, first-class. Raises on nonzero and +# on 0-or-2+ lines. +lout=$($BIN -e 'let scope = printf "id-abc" | line +echo scope $scope') +expect "| line reifies one stdout line to a string" "scope id-abc" "$lout" +# a trailing newline (the -o tsv shape) is still one line +lout2=$($BIN -e 'let v = sh -c "printf \"val\n\"" | line +echo got $v') +expect "| line handles the trailing newline (one line)" "got val" "$lout2" +# nonzero exit raises +lerr=$($BIN -e 'let x = sh -c "exit 4" | line +print x' 2>&1) && fail "| line must raise on nonzero" || true +echo "$lerr" | grep -qF "exit code 4" || fail "| line lost the nonzero raise: $lerr" +# 2+ lines is the caller's mistake, named +lmulti=$($BIN -e 'let x = sh -c "printf \"a\nb\n\"" | line +print x' 2>&1) && fail "| line must raise on 2+ lines" || true +echo "$lmulti" | grep -qF "expected exactly one line" || fail "| line lost the one-line assert: $lmulti" +echo "e2e ok: | line — one stdout line to a string, nonzero raises, 2+ lines named [D:reify-line]" + # a 2-param generic union checks + evals through the binary (was the # prelude-Result pin; Result removed [D:no-result], the fixture is now a # locally-declared Either) @@ -1631,7 +1652,7 @@ PYADP echo "e2e ok: repl cooked-trap (one child run per echo, Enter survives a slow child)" python3 "$(dirname "$0")/../tests/repl/repl-it-streamed.py" "$BIN" || fail "it FSI-parity / function echo" - echo "e2e ok: it FSI-parity — streamed binds (), misuse teaches the capture, functions echo mini-help [D:repl-it] [D:repl-fn-echo]" + echo "e2e ok: it FSI-parity — streamed binds (), misuse teaches the capture, functions echo mini-help, a failed command quiets to an exit-code status [D:repl-it] [D:repl-fn-echo] [D:repl-cmd-fail]" python3 "$(dirname "$0")/../tests/repl/repl-directives.py" "$BIN" || fail "repl directives" echo "e2e ok: repl directives (#help x3, #quit, :q retired, comments no-op, #echo cap)" @@ -2448,7 +2469,9 @@ if [ "$IS_WINDOWS" != "1" ] && command -v python3 >/dev/null 2>&1; then echo "$gzout" | grep -qi "is a terminal\|to a terminal" || fail "gzip refuses its own tty now — the incident's cause removed: $gzout" rsout=$(printf 'SLEEP 700\nSEND sh -c "read x"\\r\nSLEEP 400\nSEND \\x03\nSLEEP 500\nSEND print (Str.toUpper "revived")\\r\nSLEEP 400\nSEND #quit\\r\n' \ | python3 "$ptyrun" 10 "$BIN" | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g; s/\x1b[=>]//g') - echo "$rsout" | grep -q "exit code 130" || fail "a REPL ^C must kill the foreground child naming 130: $rsout" + # the child's ^C death renders as the QUIET exit-code status now, not the + # loud error [D:repl-cmd-fail] — still names 130 and the SIGINT note + echo "$rsout" | grep -q "exit 130 (SIGINT" || fail "a REPL ^C must kill the foreground child naming 130: $rsout" echo "$rsout" | grep -q "REVIVED" || fail "the session must survive its child's ^C: $rsout" echo "$rsout" | grep -q "^EXIT 0" || fail "the session must end clean after a ^C'd child: $rsout" @@ -3934,7 +3957,13 @@ echo "$tto" | grep -qF "the table boundary is read-only" || fail "to table refus tid=$($BIN -e 'let table = 5 show (table + 1)') || fail "'table' must stay bindable" echo "$tid" | grep -qF '"6"' || fail "'table' as an identifier: $tid" -echo "e2e ok: from table — header-offset slicing, Wire + Option/, located errors, no to table, 'table' unreserved" +# az `-o table` dashes separator [D:from-table-az]: the rule line under the +# header is skipped, so the real rows read typed — bool/int survive +taz=$($BIN -e 'type Vm = { name: string; running: bool; port: int } +["Name Running Port"; "------ -------- ----"; "web-1 true 8080"; "db-0 false 443"] |> from table Vm |> Seq.where (fun v -> v.running) |> Seq.map (fun v -> v.name) |> Str.join ","') +echo "$taz" | grep -qF "web-1" || fail "az separator skip: real rows must read (got: $taz)" +echo "$taz" | grep -qF -- "------" && fail "az separator must not become a data row: $taz" || true +echo "e2e ok: from table — header-offset slicing, Wire + Option/, located errors, az separator skip, no to table, 'table' unreserved" rm -rf "$tdir" # --- #infer from table [D:from-table]: the REPL drafts the row record diff --git a/ci/release-burned.yaml b/ci/release-burned.yaml new file mode 100644 index 00000000..b8e70352 --- /dev/null +++ b/ci/release-burned.yaml @@ -0,0 +1,7 @@ +# Burned tags [D:burned-tag-ack]: pushed, never published, never +# re-pointed [D:tag-immutability] — superseded by the next release. +# Swept by ci/release-published.weir: a listed-but-published tag fails +# as stale; an aged-out entry is named for removal without failing. +burned: + - tag: v0.0.50 + reason: release run red (release-smoke redaction probe pinned the wrong error path; fixed on main); changes ship in v0.0.51 diff --git a/ci/release-published.weir b/ci/release-published.weir index 48b73e38..21eb7986 100755 --- a/ci/release-published.weir +++ b/ci/release-published.weir @@ -8,10 +8,29 @@ // spot that let a publish-wait poll report v0.0.31 shipped while // /latest still served v0.0.29). A burned tag ages out on its own: the // next release makes a newer tag the newest, and the burned one is -// never demanded again (the v0.0.30 precedent). +// never demanded again (the v0.0.30 precedent) — but the aging-out +// WINDOW is red and blocks the superseding release's own PR (the check +// eating its repair, the shape [D:release-assets] reversed with +// known-incomplete): ci/release-burned.yaml acknowledges the burn for +// that window [D:burned-tag-ack]. let repo = "weir-shell/weir" +// burned tags [D:burned-tag-ack]: a tag whose release never shipped is +// BURNED — immutable [D:tag-immutability], never re-pointed, superseded +// by the next tag. An entry carries its reason and is SWEPT below: a +// listed tag that IS published fails as stale; an aged-out entry (a +// newer stable tag exists) is named for removal WITHOUT failing — it +// suppresses nothing, and a hard stale-fail would re-open the exact +// red-window this file closes. +let burned = + (File.read "ci/release-burned.yaml" + |> from yaml {| burned: seq<{| tag: string; reason: string |}> |}) + .burned + |> Seq.freeze + +let burnedTags = burned |> Seq.map (fun e -> e.tag) |> Seq.freeze + // tags come from the checkout (the release tag is pushed before the // draft is published). Only STABLE tags count [D:tag-immutability]: a // prerelease suffix (v0.0.0-rc1) is EXCLUDED from releases/latest by @@ -21,10 +40,11 @@ let repo = "weir-shell/weir" let tags = git tag -l "v*" |> Seq.where (fun t -> not (Str.contains "-" t)) + |> Seq.where (fun t -> not (Seq.contains t burnedTags)) |> Seq.freeze if Seq.isEmpty tags then - print "no stable v* tag yet — nothing to check (pre-first-release; prerelease tags are exempt)" + print "no stable unburned v* tag yet — nothing to check (pre-first-release; prerelease and burned tags are exempt)" exit 0 // newest by VERSION, not by text — v0.0.9 sorts after v0.0.31 textually @@ -39,13 +59,24 @@ let newest = tags |> Seq.maxBy verKey // GitHub's API requires a User-Agent; the default one [D:http-ua] covers it. // GITHUB_TOKEN when present: CI runners share egress IPs, so the // unauthenticated rate limit 403s routinely — auth lifts it. -let req = Http.get $"https://api.github.com/repos/{repo}/releases/latest" - -let rel = +let sendGh req = match Env.get "GITHUB_TOKEN" with | Some t -> Http.send { req with auth = Bearer (Secret.of t) } | None -> Http.send req +// the burned sweep: a still-current entry must reference a tag that is +// genuinely unpublished — /releases/tags/{tag} answers 200 for +// PUBLISHED only (a draft has no tag association until the click) +for e in burned do + if verKey e.tag < verKey newest then + print $"note: burned entry {e.tag} ('{e.reason}') aged out — {newest} supersedes it; remove it at leisure" + else + let r = sendGh (Http.get $"https://api.github.com/repos/{repo}/releases/tags/{e.tag}") + if r.status == 200 then + fail $"ci/release-burned.yaml lists {e.tag} ('{e.reason}') as burned but that release IS published — stale entry, remove it" + +let rel = sendGh (Http.get $"https://api.github.com/repos/{repo}/releases/latest") + if rel.status == 200 then let latest = (rel.body |> from json {| tag_name: string |}).tag_name if latest == newest then @@ -81,7 +112,7 @@ if rel.status == 200 then weir ci/release-assets.weir --tag $latest | orFail $"releases/latest {latest} is published but INCOMPLETE (verdicts above) — a published release is immutable, so nothing repairs {latest} in place: fix forward, cut and publish release vNEXT (the supersede path) and acknowledge {latest} in ci/release-known-incomplete.yaml" exit 0 - fail $"releases/latest is {latest} but the newest stable tag is {newest} — that tag was pushed and never PUBLISHED, and the installers silently keep serving {latest}. If its draft exists: gh release edit {newest} --draft=false (or the Releases page); if the release run never built (a red gate), fix and re-release — or burn the tag by cutting the next one" + fail $"releases/latest is {latest} but the newest stable tag is {newest} — that tag was pushed and never PUBLISHED, and the installers silently keep serving {latest}. If its draft exists: gh release edit {newest} --draft=false (or the Releases page); if the release run never built (a red gate), fix and re-release — or burn the tag: acknowledge it in ci/release-burned.yaml (tag + reason) and cut the next release; the gate then demands the newest unburned tag" // a 404 is the missing-publish state with NO release published yet — // /latest excluding drafts answers 404, never 403. Any other status is diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2a12d25b..97945ff0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -451,6 +451,11 @@ the old key, never an edit-in-place. | value-key-complete | 2026-09-18 | THE REPL COMPLETES A BOUND MAP'S KEYS BY PEEKING, NEVER EVALUATING — with open maps first-class (`data: seq` from #infer) keys are data, so no static pool can offer them; the mitigation reads the SESSION's Values table. The slot is LEXICAL (the headSlotAt style): the open string literal keying a pipe-form Map lookup — `Map.get`/`tryGet`/`has`, the Map surface's lookup members — whose receiver is a BARE session binding (`it` included). THE LAW, pinned both ways: reading the Values table is a peek (the value already exists), so a VMap or a MATERIALIZED pair-seq (Eval.forcedItems, the echo rule's own probe, now public — echo and completion agree about "forced") offers its keys, prefix-filtered, sorted, inserted bare inside the quotes (the user owns the closer, [D:repl-path-quote]'s convention); but an UNFORCED seq is never pulled (pulling a command-backed seq RUNS its command — [D:repl-quality]'s never-executes law at its sharp edge) and a PIPELINE receiver (`cm |> from json … |> _.data |> Map.tryGet "`) offers nothing — its value would need evaluating; the user binds first (`let d = …`) and the keys complete. Once the lookup shape is detected the slot is CLAIMED: keys or nothing, never the general pool (a keyword inside a key literal is the wrong-suggestion class). Pipe-form only, stated: the applied spelling (`Map.get "k" m`) puts the receiver AFTER the key, so nothing exists to peek at while the literal is typed. Subset, stated: a naive quote count finds the open literal — an escaped quote in a key falls outside it. The values ride a loop-refreshed ref into Complete.suggestSession (the alias-threading pattern); scripts and the LSP pass none. | Complete.fs valueKeySlot/mapLookupHead/suggestSession; Eval.fs forcedItems public; Repl.fs currentVals; unit pins (VMap keys, materialized pair-seq, prefix filter, let-RHS form, it receiver, pipeline-receiver nothing, unforced nothing, applied-spelling falls through, path-in-quotes unaffected); tests/repl/repl-mapkeys.py + ci/e2e.sh cell; docs/repl.md Tab-completion bullet; CHANGELOG v0.0.44 | | repl-fn-echo | 2026-09-18 | THE FUNCTION-VALUE ECHO IS A MINI-HELP, user-ruled — a bare expression evaluating to a FUNCTION renders, instead of the opaque ` : ty` / ` : ty`: a BUILTIN named by an identifier (a bare alias resolves to its home — `find` → `Seq.find`) echoes the qualified tinted signature plus the doc's first line, EXACTLY the #help composition (formatSignatureWith + the glance source, one renderer, no second copy — the glance/help-tint sources hoisted above the eval path so the echo shares them); piped keeps the plain spelling (the byte discipline). A SESSION-DEFINED function named bare echoes `name : ` plus a dim second line with its recorded definition's FIRST physical line (the #save transcript machinery keeps per-name physical source; a redefinition shows the LAST accepted), clipped to width, `…` when the definition is multi-line. Anonymous/composed closures keep ``/`` `: ty` — nothing to name. PLUS the var-display normalization: the checker's fresh names (`'a1`, `'a2`) render `'a`, `'b`, … in first-appearance order in the ECHO rendering of schemes ONLY — formatEchoTy, shared by exactly two consumers: the REPL echo meta lines and the `-e` last-statement echo; type errors, hover, and #help signatures keep their own formatting untouched. | Repl functionEchoLines/functionEcho + lastDefLine; Types normalizeEchoVars/formatEchoTy; Program -e echo; Tests [D:repl-fn-echo] pins (composition string-equality against the #help pieces, alias home, session def line + normalization, decline); tests/repl/repl-it-streamed.py (f)/(f2)/(g) | | from-table | 2026-09-18 | `from table T` — A READ-ONLY TYPED BOUNDARY FOR ALIGNED COLUMN OUTPUT (kubectl/docker style: one header row + aligned data rows), `seq -> seq`. THE SLICING LAW: column boundaries come from HEADER OFFSETS — column i spans [start_i, start_{i+1}), the LAST column runs to end of line — NEVER whitespace runs, so spaced cell values (`Up 2 hours`, free-text last columns) survive; probed reality: both kubectl and docker render through Go's text/tabwriter with padding 3, so every column is separated by 3+ spaces while a two-word header (`CONTAINER ID`, `NOMINATED NODE`) keeps its single interior space — RULE PINNED: a header boundary is a run of 2+ SPACES, a single space stays inside one header. MATCHING: a field reads its header case-insensitively on the NORMALIZED form (alphanumerics lowercased — `podTemplateHash` reads `POD-TEMPLATE-HASH`, `name` reads `NAME`); `[]` matches the raw header VERBATIM (the wire-keys law extended to a fifth boundary). CELLS trim and type by the declared field — string/int/float/bool, FLAT rows only (no nested record/seq/Map — a cell is one aligned column's text); `Option` reads an EMPTY cell or exactly `` (the kubectl idiom) as None, and a REQUIRED field refuses an absent cell naming the Option repair (the sentinel never silently reads as data). ERRORS LOCATED: missing declared column names itself + lists the headers seen; a bad cell carries row line + column offset + header name; duplicate NORMALIZED headers refuse (unanswerable match); no header at all refuses; a header-only table is the EMPTY SEQ (docker ps with nothing running), not an error. Extra columns are ignored (the extra-keys precedent), blank lines skip. NO WRAP: rows are already plural — seq/stream/Map each refuse with a teaching (the jsonl precedent). NO `to table` v1 BY RULING: the REPL's record echo already renders tables for display; a write-adapter needs its own ruling (alignment policy, header casing) — parked. GRAMMAR RIDER: the parser accepts any adapter word (the to-jsonl probe re-confirmed — `from table` was a CHECKER refusal), so the arm is checker-only; the adapter SET moved (builtinDocs key -> manifest -> micro/tmLanguage in-repo; fuzz ×3 fresh seeds run anyway), and the EXTERNAL tree-sitter-weir repo owes the matching bump before the next release — grammar-currency goes red until it lands. `#infer from table as Pod` + `Table.inferShape` draft the row record: per-column token scan (all-int -> int, else float/bool, else string), ANY empty/`` cell -> Option + note, headers sanitize via the Wire-sanitizer conventions (all-caps segments lower-camel; `TYPE` lands `kind` + []), and a note says the value reads as `seq` (the bare-top-array precedent); a header-only sample drafts all-string + note. | Table.fs (columns/cells/matchKey/isAbsent/parse); Check tableableRecord + EFrom table arm + wrap guards + to-table refusal + format lists; Eval tableRows + fromAdapter table arm; Infer Format.Table + parseFormat + tableDecls; Builtins from-table entry + Table.inferShape; Repl #infer format strings; Tests fromTableTests (7) + InferProbe table drafts; e2e table cells + #infer repl cell; SKILL/GUIDE/adapters.md + manifest/micro/tmLanguage + lexical regen; CHANGELOG v0.0.44 | +| from-table-az | 2026-09-24 | `from table` SKIPS THE az `-o table` DASHES SEPARATOR — Azure CLI (and other tabulate-style tools) draw a rule line of dashes under the header (`-------- ---------- -----`), which [D:from-table]'s header-offset slicer would otherwise read as a garbage first data row (every cell `------`), and `#infer` would sample it and force every column to `string`. RULED a CONDITIONAL, non-guessing skip in `Table.parse` (so BOTH the `from table T` read via Eval AND the `#infer`/`Table.inferShape` draft via Infer inherit it from the ONE parse): drop the FIRST data row when — and only when — it `isSeparatorRow` (entirely `-` and spaces, with ≥1 dash). kubectl/docker emit no such row, so their real first row is untouched; a row that merely CONTAINS a lone `-` cell (a tool's own 'none' spelling) is not all-dashes and stays data. Only the first data row is eligible, so a later all-dashes line (implausible as real data anyway) cannot silently vanish. The header-offset law, the 2+-space boundary, matching, cells, and Option/absent rules are ALL unchanged — this is a one-line pre-filter, not a new table dialect (ps-aux's right-aligned numeric columns still need a separate whitespace-split mode, parked). Surfaced by a user asking for az/`ls -la`/`ps aux` support: az fits with this skip, `ls -la` is headerless (use the typed `ls` builtin), `ps aux` needs the parked mode. | src/Weir/Table.fs isSeparatorRow + parse first-row skip; tests/Weir.Tests/Tests.fs fromTableTests (az separator skipped; a lone-dash cell row kept); tests/Weir.Tests/InferProbe.fs (draft types from real rows, not the dashes); ci/e2e.sh az-table cell; skills/weir/SKILL.md + docs/adapters.md from-table note; CHANGELOG v0.0.51 | +| repl-cmd-fail | 2026-09-24 | A BARE COMMAND STATEMENT'S NONZERO EXIT IS A QUIET STATUS AT THE REPL, NOT THE LOUD `error:`. THE OBSERVATION: a script's `command failed with exit code N` raise is load-bearing (fail-fast aborts the run), but the REPL already CATCHES that raise per-statement (the session continues), so there it is PURELY presentation — the output already streamed, nothing in weir malfunctioned, and a nonzero exit (a failed build, a `grep` no-match) is exactly the shell's `$?`, which shells report quietly. AND weir has no `$?`, so going silent would LOSE the code — the user's own point. RULED: the raise is UNCHANGED (scripts still abort; `it`/`| complete`/`| exitCode` unmoved), but `Proc.raiseNonzero` now raises a TYPED `Proc.CommandFailure(code, signalNote, shown)` whose `.Message` is BYTE-IDENTICAL to the old `failwith` (every existing catcher — the script runner, the top-level guard — renders exactly as before). The REPL catches it SPECIFICALLY on the BARE-COMMAND-STATEMENT path ONLY ([D:colour-inherit]'s `inheritsStdout` branch: a direct `TECmd`, tty, line-producing) and renders a dim `↳ exit N{signalNote}` via `echoMeta` (the same dim channel the streamed `: unit` footer uses) — the exit code is the ONLY surface for it, so it SHOWS. `it := ()` binds as for any streamed command; `lastErrored` reddens the next prompt (the shell's red-prompt cue). A VALUE/reifier-position failure (`$(cmd)`, a pipeline computing a value, a `let`-bind) still raises to the LOUD `error:` — there it aborted a computation, not a shell `$?` — and the PIPED REPL (byte-pinned automation) is untouched. | src/Weir/Proc.fs CommandFailure exn + raiseNonzero raises it (message identical); src/Weir/Repl.fs inherit-branch `:? Proc.CommandFailure` arm → dim `↳ exit N`; tests/repl/repl-it-streamed.py (h) quiet status + session continues + (h2) value-position stays loud; docs/repl.md; CHANGELOG v0.0.51 | +| reify-line | 2026-09-24 | `cmd | line` — THE SINGLE-LINE CAPTURE REIFIER, joining the family ([D:exit-reifiers], beside complete/succeeds/exitCode/orFail/exec). THE PAIN: the one-value-CLI pattern (`az … --query X -o tsv`, `git rev-parse`, `id -un`, `date +%s`) needed `$"{$(cmd) |> Seq.exactlyOne}"` — a `$()` capture, a seq→string reduce, AND an interpolation to make it an argv word — the nested-command mouthful a user flagged. RULED a REIFIER, not a sigil: "single" is about the OUTPUT, and a sigil frames INPUT (`$(`/`$e(`/`!(`) — a `$1(` spelling collides with the env-capture `$name(` slot and forces an ugly `$1e(` to combine env-input with single-output. As a reifier, single-output composes with env-input for FREE (`$e(cmd | line)`, the way `$e(cmd | complete)` already does) — no new glyph. SEMANTICS: `seq/command -> string`. It CAPTURES stdout via `Proc.linesWith` (the `$(cmd)` path, so it RAISES on a nonzero exit like `$()`), then ASSERTS exactly one line (0 or 2+ is the caller's mistake, named with the command) and TRIMS it. A value reifier like complete (not streaming), so it is fine in value positions; the stdin twin `|linedIn` serves the value-headed `xs | grep foo | line`, and the env twin `|linedEnv` the `$e(…)` route. THE NAME shadows a `line` PROGRAM the way the family already shadows `complete`/`exitCode` (reifier markers win in the pipe-stage choice) — accepted, family-consistent. `Seq.only`/`Str.line` (the pipeline spellings) were the runners-up, parked — the reifier is the one that also deletes the `$(…)` wrapper. | src/Weir/Builtins.fs oneLine + linedWith/linedWithIn + |lined/|linedIn/|linedEnv registration + reifierSurface + `line` hover doc; src/Weir/Parser.fs LineMarker + lineMarker + pipedStages + foldChain tuple + chainReifier; src/Weir/Lsp.fs reifierHeads; tests/Weir.Tests/Tests.fs "line desugars…" (desugar + string round-trip base/env/value-headed); ci/e2e.sh line cell; skills/weir/SKILL.md + docs/reference/adapters.md (or commands doc); CHANGELOG v0.0.51 | +| argv-splice-complete | 2026-09-24 | A `$name` SPLICE IN ARGV COMPLETES SESSION BINDINGS — the bug: `az … --location $l` offered nothing. ROOT: `$` is not a word char ([D:complete-argv]'s `wordStartAt`), so the completion word starts AFTER it (`l`) and the before-text ends with `$`; the argv branch offered ONLY `filesystemComplete word`, which found no file named `l…`, so a declared `location` never surfaced. FIX: in the argv branch, when the before-text ends with `$` (a splice sigil immediately before the word), offer the session's `env.Values` (Types.isUserName, prefix-filtered, sorted) BARE — the `$` is already typed, so the editor yields `$location`. SCOPED to the splice case: a NON-`$` argv word stays the paths-only pool (a binding name must not leak in as an ordinary argv word — [D:complete-argv]'s law holds). The offer is unfiltered by type (any user binding is splice-eligible; the user picks), mirroring the path-param branch's binding offer. | src/Weir/Complete.fs argv branch `before.EndsWith "$"` arm; tests/Weir.Tests/Tests.fs "a $name splice in argv completes session bindings" (offers matching bindings bare; a non-splice argv word does not); CHANGELOG v0.0.51 | +| repl-killring | 2026-09-24 | THE REPL EDITOR GAINS A KILL-RING — `Ctrl+W` (kill the previous word), `Ctrl+Y` (yank), and `Ctrl+U`/`Ctrl+K` now FEED the ring (they used to just delete). THE ASK vs THE RULING: a user wanted shift+arrow text SELECTION; declined — selection in a terminal is the EMULATOR's job (mouse), no terminal line editor (readline/fish/zsh) implements shift-select, and a selection model + highlight rendering + OS-clipboard is a large subsystem for marginal gain over the emulator's own copy. The terminal-idiomatic "select-and-cut" is the readline kill-ring, which weir HALF-had (`Ctrl+U`/`Ctrl+K` deleted without saving, no `Ctrl+W`/`Ctrl+Y`); completing it is the cheap, conventional win. RULED session-scoped `killRing` (module-level, readline parity — a kill on one line yanks into a later one), LAST-KILL-WINS (no consecutive-kill accumulation — the simple, predictable v1). `Ctrl+W` reuses `Ctrl+Left`'s word range (skip separators, then the word); the kill verbs guard an empty range (no clobbering the ring with ""). Copy-FROM-elsewhere stays the terminal's mouse selection — weir reads no OS clipboard. | src/Weir/Repl.fs killRing + Ctrl+W/Ctrl+Y arms + Ctrl+U/Ctrl+K feed the ring; tests/repl/repl-wordnav.py kill-ring cases (Ctrl+W→ring→yank KRXKRX; Ctrl+U→ring→yank UKILLUKILL); docs/repl.md keybinding table; CHANGELOG v0.0.51 | | reenum-warning | 2026-09-18 | THE LAZY RE-RUN HAZARD SPEAKS ON EVERY SURFACE — a NEW advisory checker diagnostic (`re-enumeration`, warning severity, `weir check` still exits 0 — the cmd-not-found family's plumbing) plus a REPL echo annotation, one theme: a command-backed seq re-runs its command per pull and nothing said so until it bit. THE SCOPE RULING: COMMAND-BACKED ONLY (the RHS runs/streams a command per the module weak-purity walk runsCommandT — moved to Check so loader and checker share one spelling; eager positions, stops at lambdas) AND seq-typed AND not visibly materialized. The RECOGNIZED-FORCED SET, closed and pinned: a `|> Seq.force` tail, an applied `Seq.force …` head (the comprehension's own desugar), the eager list literal — judged at the RHS tail through let-in bodies; branch arms read as unforced, the stated over-approximation the word `possible` carries (ReSharper's possible-multiple-enumeration named as INSPIRATION, not authority — its IEnumerable heuristic, weir's command-effect stakes). THE ENUMERATING-USE TAXONOMY, conservative and stated: EVERY read of the name counts as a possible pull — piped into anything, an adapter/for source, an argv splat ($@x expands = pulls), print/an echoing bare use, an interpolation hole, passed as an argument (a callee may pull) — EXCEPT the classified alias: a whole-RHS bare name (`let y = x`) neither pulls nor carries tracking onto the alias (the stated gap: split uses across an alias stay under threshold). The warning lands on the SECOND AND LATER use sites, naming the rendered command (splices as $name) and the repair — the binding's own source line + `|> Seq.force` when it is one clean line (the streamed-it precedent), the generic spelling otherwise. WHOLE-FILE like the unused law: fed per statement, flushed post-fold in analyzeLines (check / --json / --can / LSP — the squiggle falls out free at severity 2), block-local lets judged in their own body scope; poisoned on any errored statement; modules skip (the module-rule error already owns a command RHS); the RUNNER deliberately does not print it (the runner's warnings stay per-statement). THE ECHO HALF [D:echo-teaching-consistency]'s sibling: the `let` meta states the bound seq's state, tty-only — `(command-backed — re-runs on each use)` unforced-with-command, `(frozen)` materialized (the forcedItems probe, echo/completion's own source), NOTHING for a pure lazy seq (silence is the default; only the hazard and its resolution speak); state and the truncation teaching share one parenthetical; the piped meta bytes stay pinned. The repo sweep found ZERO hits — every script already forces where it reuses; the positive fixture is the e2e cell. | Check runsCommandT (moved) + commandBackedUnforced/forcedTail/reenumEvents; Script ReenumTracker + analyzeLines wiring; Repl letSeqState + letEchoMeta state param; unit pins (13 checker + echo trio); e2e check cell + pty state cell; lsp-e2e squiggle assertion; docs/repl.md, GUIDE pods sentence, reference/types.md; SKILL REUSE clause; CHANGELOG v0.0.45 | | schema-types | 2026-09-18 | `weir gen types --schema [--as ] [--out \|-]` — GENERATE WEIR TYPES FROM A LOCKED SCHEMA. The tiering, stated: #infer = scaffold from a sample (sees only what the sample had), schema = CONTRACT (required vs optional, additionalProperties, names — the facts no sample carries), table = the no-schema tools. THE VERB: `gen`, not `add` — add's invariant is artifact-plus-lock-entry together [D:contracts-spine], and the generated module is deliberately UNLOCKED: user-owned after generation (the sigs/restore symmetry [D:command-signatures] — regeneration is an explicit re-run, never automatic; provenance is a header comment carrying schema name + lock hash + source URL, NO timestamp so re-runs are byte-identical). Output: a decl-only module at `.weir/types/.weir` (the sigs precedent), imported via the ONE `weir:` namespace — modules/ resolves FIRST, then types/. ONE EMITTER, TWO FRONTENDS: the naming registry, Wire sanitizer, taken-name guard and decl renderer are Infer's own (renderDecls factored; Registry/identStem/singularize/resolveFieldNames unsealed); the schema frontend walks shape directly, no sample walking. THE SUBSET GROWTH (add-validates keeps its law — everything the checker later reads is validated at add): in-document $ref (#/definitions, #/$defs, #/components/schemas; ROOT holders only, dangling refuses at parse, pointer-unescaped), allOf of ONE-schema-plus-annotations (the k8s $ref-with-description idiom; general composition still refuses), anyOf (all-scalar folds like oneOf's IntOrString; mixed KEEPS as SChoice — first-variant+note for the generator, UNVALIDATED for the district: a value legal under any variant must not error), nullable:true and type:[..,null] (scalar folds the kind set; object/array wraps SNullable); a PURE ref cycle derefs to SAny. MAPPING RULES, each pinned: required member → plain field; absent → Option (THE headline — the user's live pain: a container without env broke the #infer draft); null spellings → Option, wrapped once; additionalProperties-only object → seq (the v0.0.44 mapping shape, both adapters read it); props+additionalProperties → record + note; object with neither → seq + note (the empty-mapping posture); enum → string + a note listing values (string unions a stated follow-up); multi-kind scalar (IntOrString) → string + convert note; $ref name → last dot-segment type name, SECOND-to-last segment prefix on collision (io.k8s.api.core.v1.EnvVar → V1EnvVar when EnvVar is taken); CYCLES → opaque Yaml + self-referential note (JSONSchemaProps posture); SAny → string + note. Never silence: every undecidable lands as a `// note:` line IN the generated file. DETERMINISM: fields alphabetical by wire key (ordinal — the sig generator's ordering law), types in bottom-up claim order (children precede parents, so the module checks sequentially), byte-identical on re-run (e2e cmp-pinned; fmt-canonical too). ADD-VALIDATES: the emitted module runs through checkVendoredModule before writing; an unrepresentable top (scalar, open-mapping) refuses BEFORE emit with the direct read spelling. DELIVERABLE 2: from json/yaml missing-field errors gained the from-table Option repair line ("a type drafted from a sample only sees what the sample had"). | tests/fixtures/pod-v1-trimmed.json (the representative trimmed k8s fixture: $ref/definitions, allOf, anyOf IntOrString, enum, nullable, x-kubernetes-*); src/Weir/Contracts.fs (SRef/SChoice/SNullable, SchemaDoc, parse growth, deref, validateTpl defs threading); src/Weir/SchemaTypes.fs (the generator frontend); src/Weir/Infer.fs (renderDecls factored, registry unsealed); src/Weir/Program.fs (the gen arm); src/Weir/Script.fs (weir: types/ resolution); src/Weir/Eval.fs (the missing-field teach); tests/Weir.Tests/Tests.fs units; ci/e2e.sh gen-types battery (offline server, determinism cmp, THE acceptance: env-less container reads + labels mapping); docs/tooling.md; docs/repl.md; skills/weir/SKILL.md; CHANGELOG v0.0.45 | | freeze-rename | 2026-09-19 | `Seq.force`/bare `force` RENAMED `Seq.freeze`/`freeze` (user-ruled), NO compatibility spelling — "force" names the MOMENT (evaluate now) and misleadingly suggests repeatability; the durable property of the result is a FROZEN SNAPSHOT: pulls never re-run (the REPL echo already said `(frozen)` — the vocabulary converges). The hard break rides the [D:rq-retired] precedent: pre-adoption window, ~zero external users, so no bridge alias and no deprecation period. A stray old spelling TEACHES the new name via the retired-names table at both lookup sites ([D:seq-force]'s own mechanism, the defaultTo→defaultValue rename precedent) — necessary because the generic did-you-mean cannot carry it (editDistance force→freeze = 4, threshold 2). History rows in this ledger keep the old spelling — a row's recorded past stays; only forward-facing text swept | Builtins member+bare docs+internalAliases (\|seqFreeze); Check retired rows + forcedTail TEVar; Parser comprehension desugar; Eval unforcedHint; Script reenum repair text + Self.stdin teach; Tests sweep + rename-teach pins + bare-set pin; e2e/pty cells; SKILL inventory+prose; GUIDE/repl/reference/LEXICON/SECURITY; docs-json regen; CHANGELOG v0.0.45 Changed | @@ -493,3 +498,4 @@ the old key, never an edit-in-place. | toplevel-if-else | 2026-09-24 | A TOP-LEVEL BLOCK `if/else` NOW ASSEMBLES — the block form `if c then ` followed by a DEDENTED `else`/`elif ` at column 0 was a parse error (`'else' is a keyword`, backtrack). ROOT: the ASSEMBLER, not the expression parser. The `if` is its own logical statement, and the col-0 continuation gate ([D:retry-poll]'s precedent — a dedented `|`/`until`/`always` continues the statement above) admitted `|`, `until`, `always`, but not `else`/`elif`, so a col-0 `else` fell to the new-statement branch and the parser hit a stray keyword. The INDENTED form (inside a function/expression body — the else rides at the body's indent, `raw[0]=' '`) always worked, and the join machinery was already ready (`classifyPiece` yields `ElseHead`, and the `[D:pipe-alignment]` join extends the piece with "no sibling `;`, else keeps its standing rules"). FIX: add a col-0 `else`/`elif` to the same gate — a keyword-boundary check (`lw = "else" || lw.StartsWith "else " || lw.StartsWith "elif "`, so an identifier like `elsewhere` is untouched). Since `else`/`elif` are keywords they can ONLY continue an open `if`, never head a fresh statement, so the admission is unconditional (a stray one errors at parse exactly as a stray `until` does). Surfaced by the docker-postgres port [D:exec], which wanted a top-level `if firstRun then … else …`. | src/Weir/Script.fs assemble col-0 continuation gate (else/elif clause); tests/Weir.Tests/Tests.fs "top-level if/else assembles as ONE statement" (if/else, if/elif/else, multi-stmt then, checks-clean); CHANGELOG v0.0.50 | | debug-optimize | 2026-09-24 | `true` IN EVERY CONFIGURATION — the F# compiler's NON-optimized debug-info generator ICEs (`FS0193 internal error: … (Parameter 'index')`) on the large `Builtins.fs` module. IT REPRODUCES DETERMINISTICALLY under `dotnet build`/`dotnet test` in the DEFAULT Debug config while `-c Release` (optimized) has ALWAYS been clean — which is why local Release work + the AOT publish never saw it and CI's `dotnet test tests/Weir.Tests` did. BISECTED to the module SIZE, not a construct: adding ~5 trivial top-level bindings to v0.0.49's Builtins reproduces the ICE, and removing the exact new bindings does not clear it — v0.0.49 sits at ZERO headroom in the Debug debug-info index (a standing hazard: any future Builtins growth re-triggers it). Every `DebugType` (portable/embedded/full) ICEs; only turning OFF symbols or turning ON the optimizer avoids it. RULED: optimize in Debug too — it reshapes the module past the overflow AND keeps the portable PDB (stack-trace line numbers intact, unlike DebugType=none), so the only cost is step-debug fidelity, accepted (weir dev is test-driven). Not an SDK pin (the earlier hypothesis): the trigger is our module size on the shipped compiler, not a floated SDK regression. Release build/publish unchanged (already optimized). | src/Weir/Weir.fsproj `true` | | eq-depth | 2026-09-21 | VALUE EQUALITY IS ITERATIVE (STRIX-2 / vuln-0006): a checker-accepted, legally-built recursive-record value (an Option-linked record folded ~100k deep via `Seq.fold`) crashed the whole process with an uncatchable StackOverflow on `==`, because `Value.Equals` recursed one stack frame per nesting level. RULED: the equality walk carries an explicit heap work-list of pending `(Value * Value)` pairs instead — scalars compare in place; VRecord (order-insensitive [D:record-order]), VUnion payloads, VTuple, and VMap entry values QUEUE their children; VSeq compares LOCKSTEP via enumerators (never materialize two lists, short-circuit at the first mismatch — the Seq.equal discipline); a mismatch drains the list. Every prior equality semantic is preserved (closures/builtins/proc/server by reference; bytes structural; floats [D:floats]). The `show`/interpolation renderer (`formatValue`) shared the same recursive-crash class — it now carries a finite MaxDepth (100, past the ~11 corpus max) with a teaching ellipsis, matching the REPL echo's existing depth bound. THE STANDING RULE: any new Value-walking helper must walk iteratively or carry a depth bound. | Eval.fs Value.Equals (work-list) + showLimits.MaxDepth; tests/Weir.Tests typeClassTests (200k-deep VRecord compares true, deep-vs-shallow false, 200k VSeq lockstep true / tail-mismatch false); CHANGELOG v0.0.48 Fixed | +| burned-tag-ack | 2026-09-24 | A BURNED TAG IS ACKNOWLEDGED, NOT WAITED OUT — v0.0.50 was tagged, its release run went red (the release-smoke redaction probe pinned the wrong error path), and the tag stays where it is [D:tag-immutability]. The [D:release-gate] "ages out on its own" ruling hid a chicken-and-egg: aging out needs the NEXT release, and the gate's red blocked the superseding release's own PR CI — the check eating its repair, the exact shape [D:release-assets] already reversed with release-known-incomplete.yaml. RULED the same pattern for the tag-without-publish state: ci/release-burned.yaml (tag + reason); ci/release-published.weir excludes burned tags from the newest-stable computation and demands the newest UNBURNED tag be published. SWEPT one-sided by design: a listed-but-PUBLISHED tag fails as stale (the entry is a lie and would skew the newest computation), but an AGED-OUT entry (a newer stable tag exists) is only NAMED for removal — it suppresses nothing once superseded, and a hard stale-fail would re-open the red-window between the superseding publish and the cleanup commit, the exact window this file closes. First entry: v0.0.50, superseded by v0.0.51. | ci/release-burned.yaml; ci/release-published.weir burned load + sweep + newest-unburned; docs/DECISIONS.md | diff --git a/docs/reference/adapters.md b/docs/reference/adapters.md index fc317724..5ec8e0d3 100644 --- a/docs/reference/adapters.md +++ b/docs/reference/adapters.md @@ -163,6 +163,13 @@ the row line and column. Rows are already plural — no `seq`/`stream`/`Map` wrap — and the boundary is read-only: there is no `to table`. +`az … -o table` (and other `tabulate`-style tools) draw a dashes rule +line under the header (`------ ----------`). `from table` skips that +separator when it is the first data row, so az output reads without a +garbage row and `#infer` types from the real rows. Tables with no +separator (kubectl, docker) are unchanged; only a row that is entirely +dashes and spaces is dropped, and only in first position. + `#infer from table as Pod` (or `sample |> Table.inferShape`) drafts the row record from a live sample — per-column type scanning, `Option` where a column has empty/`` cells, and a note that diff --git a/docs/repl.md b/docs/repl.md index 194d82ed..64f5baf5 100644 --- a/docs/repl.md +++ b/docs/repl.md @@ -200,7 +200,14 @@ result — `it` (FSI's and ghci's convention) collides with nothing. A bare command at a tty streams straight to the terminal (the colour-inherit path — weir never holds the bytes), so its value is -`()` and that is what `it` binds. Using that unit `it` where a value +`()` and that is what `it` binds. When such a command exits nonzero, +the prompt shows a dim `↳ exit N` status (and the next prompt reddens) +rather than a red error — the output already streamed and the session +continues, so it reads like a shell's `$?`. The exit still *raises*: +in a script it fails-fast, and a failure inside a value (`$(cmd)`, a +reifier, a binding) keeps the full `error:` because there it aborted a +computation — `cmd | exitCode` / `cmd | complete` are the ways to make +a nonzero exit into data. Using that unit `it` where a value is needed is an ordinary type error, and the error appends the repair with the streamed command verbatim: @@ -432,6 +439,8 @@ The fixed bindings (this is not a keybinding-config feature): | Alt+Enter / Ctrl+J | force a newline (formatting; an entry stays one statement). Shift+Enter is not bindable — terminals do not distinguish it from Enter. Windows Terminal claims left-Alt+Enter for fullscreen: use Ctrl+J or right-Alt there | | Up / Down | move between lines; Up on the first line recalls history | | Ctrl+R | history search (fzf when installed; entries display one-line, ⏎-joined) | +| Ctrl+W / Ctrl+U / Ctrl+K | kill the previous word / to line start / to line end — each into the kill-ring | +| Ctrl+Y | yank (paste) the last killed text at the cursor. The ring is session-wide, so a kill on one line yanks into a later one. (Copy from *elsewhere* is your terminal's own mouse selection — weir does not read the OS clipboard.) | | Esc / Ctrl+C | abandon the whole buffer | | Ctrl+D | EOF on an empty buffer; delete/join otherwise | diff --git a/site/src/data/reference.json b/site/src/data/reference.json index 1f435f44..16d90630 100644 --- a/site/src/data/reference.json +++ b/site/src/data/reference.json @@ -2007,6 +2007,13 @@ "example": "let pure double n = n * 2", "pointer": "#help pure has the block form and the effect families" }, + { + "name": "line", + "signature": null, + "summary": "Reify a one-value command to its single line of stdout, trimmed \u2014 the \u0060az \u2026 -o tsv\u0060 / \u0060git rev-parse\u0060 idiom (replaces \u0060$(cmd) |\u003E Seq.exactlyOne\u0060). Raises on a nonzero exit, or on 0 or 2\u002B lines.", + "example": null, + "pointer": "the reifier law: the meaning is the value." + }, { "name": "not", "signature": "not (b: bool) : bool", diff --git a/skills/weir/SKILL.md b/skills/weir/SKILL.md index f8f4b3c2..68443815 100644 --- a/skills/weir/SKILL.md +++ b/skills/weir/SKILL.md @@ -1321,7 +1321,12 @@ within tmp d entrypoint the app gets signals directly with no forwarding layer); it NEVER returns, diverging like `fail`/`exit`, so it is a legal bare statement and cannot take a piped stdin (there is no parent left to - feed it). **`succeeds` is exitCode == 0, exactly** — + feed it). `cmd | line` reifies a one-value CLI to its single trimmed + line of stdout as a `string` [D:reify-line] — the + `$(cmd) |> Seq.exactlyOne` idiom for `az … -o tsv`/`git rev-parse`; + it raises on a nonzero exit or on 0-or-2+ lines, and composes with + the env sigil (`$e(cmd | line)`) and a value head. **`succeeds` is + exitCode == 0, exactly** — for tools whose nonzero codes AND output are both data (grep, fzf), use `| complete` and read the record. An `if`/`elif` CONDITION takes the chain inline: `if test -f $p | succeeds then` @@ -1650,6 +1655,9 @@ refs |> Seq.iter (fun r -> print r.Include) repair. Extra columns are ignored, blank lines skip, a header-only table is the empty seq, and every error is located (a missing column lists the headers seen; a bad cell carries line and column). + An `az … -o table` dashes separator (`------ ----------`) under the + header is skipped when it is the first data row, so az output reads + too; kubectl/docker (no separator) are unchanged. Rows are already plural — no `seq`/`stream`/`Map` wrap — and there is NO `to table` (the REPL's record echo already renders tables for display). `table` stays an ordinary identifier elsewhere. diff --git a/src/Weir/Builtins.fs b/src/Weir/Builtins.fs index f297d3b9..b2f1f07e 100644 --- a/src/Weir/Builtins.fs +++ b/src/Weir/Builtins.fs @@ -404,6 +404,39 @@ let private execedWith (overlay: (string * string) list) : Value = unreachable "exec returned — execve replaces the image or raises" | _ -> unreachable "the checker rejects 'exec' on these arguments")) +// the single-line capture [D:reify-line]: `cmd | line` reads a one-value +// CLI (`az … -o tsv`, `git rev-parse`, `id -un`) as a trimmed STRING — +// the `$(cmd) |> Seq.exactlyOne` idiom as a first-class reifier. It +// CAPTURES stdout and raises on a nonzero exit (the `$(…)` path via +// Proc.linesWith), then ASSERTS exactly one line: a value was expected, +// so 0 or 2+ lines is the caller's mistake, named. +let private oneLine (prog: string) (argv: string list) (lines: seq) : Value = + match List.ofSeq lines with + | [ one ] -> VStr(one.Trim()) + | ls -> + let shown = String.concat " " (prog :: argv) + failwith $"'{shown} | line' expected exactly one line of output, got {List.length ls}" + +let private linedWith (overlay: (string * string) list) : Value = + VBuiltin(fun progV -> + VBuiltin(fun argsV -> + match progV, argsV with + | VStr prog, VSeq args -> + let argv = argStrings args + oneLine prog argv (Proc.linesWith overlay (Proc.resolveProg prog) argv None) + | _ -> unreachable "the checker rejects 'line' on these arguments")) + +let private linedWithIn (overlay: (string * string) list) : Value = + VBuiltin(fun progV -> + VBuiltin(fun argsV -> + VBuiltin(fun stdinV -> + match progV, argsV, stdinV with + | VStr prog, VSeq args, VSeq stdin -> + let argv = argStrings args + let input = stdin |> Seq.map asString + oneLine prog argv (Proc.linesWith overlay (Proc.resolveProg prog) argv (Some input)) + | _ -> unreachable "the checker rejects 'lineIn' on these arguments"))) + // stdin-carrying reifier twins [D:value-headed-pipe]: `xs | grep foo | // complete` reifies the segment WITH the value as stdin. INTERNAL — // the public expression-position spellings (completed/succeeded/…) keep @@ -5730,6 +5763,11 @@ let builtinDocs: Map = "Replace the current process with the command (execve) — never returns; the app keeps weir's pid, so as a container entrypoint it gets signals directly. Diverging, like fail/exit; cannot take piped stdin." None (Some "the reifier law: the command becomes the process.") + "line", + bd + "Reify a one-value command to its single line of stdout, trimmed — the `az … -o tsv` / `git rev-parse` idiom (replaces `$(cmd) |> Seq.exactlyOne`). Raises on a nonzero exit, or on 0 or 2+ lines." + None + (Some "the reifier law: the meaning is the value.") // ---- types: a hover renders the structure; the value here is // WHEN you get one ---- @@ -5786,6 +5824,7 @@ let reifierSurface (name: string) : string option = elif name.StartsWith "|orFailed" then Some "orFail" elif name.StartsWith "|exitCoded" then Some "exitCode" elif name.StartsWith "|execed" then Some "exec" + elif name.StartsWith "|lined" then Some "line" else None /// the hover/completion text: summary, then example, then pointer — each @@ -5945,6 +5984,9 @@ let private entries: (string * Ty * Value) list = "|succeeded", TFun(TStr, TFun(TSeq TStr, TBool)), succeededWith [] "|orFailed", TFun(TStr, TFun(TStr, TFun(TSeq TStr, TUnit))), orFailedWith [] "|exitCoded", TFun(TStr, TFun(TSeq TStr, TInt)), exitCodedWith [] + // the single-line capture [D:reify-line] — `cmd | line` : the one + // trimmed line of stdout as a string (the one-value-CLI idiom) + "|lined", TFun(TStr, TFun(TSeq TStr, TStr)), linedWith [] // process replacement [D:exec] — diverging (tA), like fail/exit; the // stdin twin is refused at parse (no parent to feed a replacement) "|execed", TFun(TStr, TFun(TSeq TStr, tA)), execedWith [] @@ -5954,6 +5996,7 @@ let private entries: (string * Ty * Value) list = "|succeededIn", TFun(TStr, TFun(TSeq TStr, TFun(TSeq TStr, TBool))), succeededWithIn [] "|orFailedIn", TFun(TStr, TFun(TStr, TFun(TSeq TStr, TFun(TSeq TStr, TUnit)))), orFailedWithIn [] "|exitCodedIn", TFun(TStr, TFun(TSeq TStr, TFun(TSeq TStr, TInt))), exitCodedWithIn [] + "|linedIn", TFun(TStr, TFun(TSeq TStr, TFun(TSeq TStr, TStr))), linedWithIn [] // diverging [D:fail-bottom]: the result var generalizes, so an arm // or branch that fails/exits unifies with the value the others make "fail", TFun(TStr, tA), failImpl @@ -5990,7 +6033,10 @@ let private entries: (string * Ty * Value) list = VBuiltin(fun envV -> exitCodedWith (envVarPairs envV)) "|execedEnv", TFun(TSeq(TNamed("EnvVar", [])), TFun(TStr, TFun(TSeq TStr, tA))), - VBuiltin(fun envV -> execedWith (envVarPairs envV)) ] + VBuiltin(fun envV -> execedWith (envVarPairs envV)) + "|linedEnv", + TFun(TSeq(TNamed("EnvVar", [])), TFun(TStr, TFun(TSeq TStr, TStr))), + VBuiltin(fun envV -> linedWith (envVarPairs envV)) ] @ bareEntries let private showImpl: Value = VBuiltin(formatValue >> VStr) diff --git a/src/Weir/Complete.fs b/src/Weir/Complete.fs index 5248235e..b9e1af59 100644 --- a/src/Weir/Complete.fs +++ b/src/Weir/Complete.fs @@ -691,8 +691,22 @@ let suggestScopedWith Extern.names () |> Set.filter (fun n -> n.StartsWith word && n <> word) |> Set.toList elif commandArgvPosition env before then // argv position [D:complete-argv]: paths, nothing else — the - // pool, fields, and members are expression furniture - filesystemComplete word + // pool, fields, and members are expression furniture. EXCEPT a + // `$name` splice [D:argv-splice-complete]: `$` is not a word + // char, so the word starts after it and `before` ends with `$`; + // offer the session's bindings (bare — the `$` is already typed), + // so `--location $l` completes to `$location`. + if before.EndsWith "$" then + env.Values + |> Map.toList + |> List.choose (fun (n, _) -> + if Types.isUserName n && n.StartsWith word && n <> word then + Some n + else + None) + |> List.sort + else + filesystemComplete word elif pathParamAt before then // a path position wants paths AND string bindings — `cd // target` applies the binding, so hard-removing identifiers diff --git a/src/Weir/Lsp.fs b/src/Weir/Lsp.fs index 80976504..954c10c5 100644 --- a/src/Weir/Lsp.fs +++ b/src/Weir/Lsp.fs @@ -210,7 +210,10 @@ let semanticTokensFor (lines: string list) : (int * int * int * int) list = "|exitCodedIn" "|orFailedIn" "|execed" - "|execedEnv" ] + "|execedEnv" + "|lined" + "|linedEnv" + "|linedIn" ] let rec spineIsReifier (te: Check.TypedExpr) = match te.Kind with diff --git a/src/Weir/Parser.fs b/src/Weir/Parser.fs index 54552e37..ef036524 100644 --- a/src/Weir/Parser.fs +++ b/src/Weir/Parser.fs @@ -1147,6 +1147,7 @@ let rec private chainReifier (e: Expr) : string option = | EVar v when v.StartsWith "|exitCoded" -> Some "exitCode" | EVar v when v.StartsWith "|orFailed" -> Some "orFail" | EVar v when v.StartsWith "|execed" -> Some "exec" + | EVar v when v.StartsWith "|lined" -> Some "line" | EApp(f, _) -> chainReifier f | EPipe(l, r) -> chainReifier r |> Option.orElseWith (fun () -> chainReifier l) | _ -> None @@ -3827,6 +3828,9 @@ type private Seg = // returns (execve replaces the image), so it ends a command chain // like the rest of the family | ExecMarker of Span + // the single-line capture [D:reify-line] — `cmd | line` reifies to + // the one trimmed stdout line as a string + | LineMarker of Span let private reifierEnd = // the let-RHS chain also ends at bare `in` [D:block-let-cmd] — @@ -3903,6 +3907,14 @@ let private execMarker = ) |>> fun (_, span) -> ExecMarker span +let private lineMarker = + attempt ( + spanned (pstring "line" .>> notFollowedBy (satisfy cmdWordChar)) + .>> ws + .>> reifierEnd + ) + |>> fun (_, span) -> LineMarker span + // fold a parsed pipeline — an initial head expression plus piped stages // and reifier markers — into one Expr. Shared by the command-headed // chain and the value-headed chain [D:value-headed-pipe]: the ONLY @@ -3940,7 +3952,7 @@ let private foldChain (h: Expr) (rest: ((string * Span) * Seg) list) : Result + | (CompleteMarker _ | SucceedsMarker _ | ExitCodeMarker _ | OrFailMarker _ | ExecMarker _ | LineMarker _ as marker) -> let stageName, mspan, plainVar, envVar, stdinVar, extraArgs = match marker with | CompleteMarker sp -> "complete", sp, "|completed", "|completedEnv", "|completedIn", [] @@ -3948,6 +3960,7 @@ let private foldChain (h: Expr) (rest: ((string * Span) * Seg) list) : Result "exitCode", sp, "|exitCoded", "|exitCodedEnv", "|exitCodedIn", [] | OrFailMarker(msg, sp) -> "orFail", sp, "|orFailed", "|orFailedEnv", "|orFailedIn", [ msg ] | ExecMarker sp -> "exec", sp, "|execed", "|execedEnv", "|execedIn", [] + | LineMarker sp -> "line", sp, "|lined", "|linedEnv", "|linedIn", [] | Stage _ -> "", acc.Span, "", "", "", [] // a chain head is command-ish (an external segment or a @@ -4151,6 +4164,7 @@ let private pipedStages (builtinHeads: bool) (argP: Parser) (sigilEn <|> exitCodeMarker <|> orFailMarker <|> execMarker + <|> lineMarker <|> reifierStageGuard <|> (segment builtinHeads argP sigilEnv r |>> Stage)) ) diff --git a/src/Weir/Proc.fs b/src/Weir/Proc.fs index fab59c83..445bfc0d 100644 --- a/src/Weir/Proc.fs +++ b/src/Weir/Proc.fs @@ -169,6 +169,19 @@ let private reap (p: Process) = with _ -> () +/// a command's nonzero exit as a TYPED failure [D:repl-cmd-fail]: the REPL +/// catches THIS specifically to render a bare command statement's failure +/// as a quiet exit-code status (there is no `$?`, so the code must show) +/// while a script — or a value/reifier position — keeps the loud raise. +/// The Message is BYTE-IDENTICAL to the old failwith, so every existing +/// catcher (the script runner, the top-level guard, `orFail`'s siblings) +/// renders exactly as before. +type CommandFailure(code: int, signalNote: string, shown: string) = + inherit exn($"command failed with exit code {code}{signalNote}: {shown}") + member _.Code = code + member _.SignalNote = signalNote + member _.Shown = shown + let private raiseNonzero (s: Spec) (code: int) = let shown = String.concat " " (s.Prog :: s.Args) @@ -182,7 +195,7 @@ let private raiseNonzero (s: Spec) (code: int) = | c when c > 128 && c < 165 -> $" (signal {c - 128})" | _ -> "" - failwith $"command failed with exit code {code}{signalNote}: {shown}" + raise (CommandFailure(code, signalNote, shown)) // ---- the consumers (the output axis) ------------------------------- diff --git a/src/Weir/Repl.fs b/src/Weir/Repl.fs index 385647b7..eee572d1 100644 --- a/src/Weir/Repl.fs +++ b/src/Weir/Repl.fs @@ -16,6 +16,12 @@ let private prompt = "weir> " // everywhere counts prompt.Length — the tint is zero-width. let mutable private lastErrored = false +// the kill-ring [D:repl-killring]: the last text a kill verb removed +// (Ctrl+U/Ctrl+K/Ctrl+W), yanked back by Ctrl+Y. Session-scoped (readline +// parity) so a kill on one line yanks into a later one. Last kill wins — +// no consecutive-kill accumulation (the simple, predictable v1). +let mutable private killRing = "" + // the streamed-statement latch [D:repl-it]: Some while the // CURRENT `it` binding came from a streamed statement (the inherit path // [D:colour-inherit] — the child wrote the terminal itself, weir never @@ -1178,12 +1184,41 @@ let private readLineTty () : string option = col <- cur().Length redraw () | ConsoleKey.U when ctrl -> - cur().Remove(0, col) |> ignore - col <- 0 - redraw () + // kill to line start → the ring [D:repl-killring] + if col > 0 then + killRing <- cur().ToString().Substring(0, col) + cur().Remove(0, col) |> ignore + col <- 0 + redraw () | ConsoleKey.K when ctrl -> - cur().Remove(col, cur().Length - col) |> ignore - redraw () + // kill to line end → the ring [D:repl-killring] + if col < cur().Length then + killRing <- cur().ToString().Substring(col) + cur().Remove(col, cur().Length - col) |> ignore + redraw () + | ConsoleKey.W when ctrl -> + // kill the previous WORD → the ring [D:repl-killring] — the + // Ctrl+Left range (skip separators, then the word) + let t = cur().ToString() + let mutable p = col + + while p > 0 && not (isWordChar t[p - 1]) do + p <- p - 1 + + while p > 0 && isWordChar t[p - 1] do + p <- p - 1 + + if p < col then + killRing <- t.Substring(p, col - p) + cur().Remove(p, col - p) |> ignore + col <- p + redraw () + | ConsoleKey.Y when ctrl -> + // yank the ring at the cursor [D:repl-killring] + if killRing <> "" then + cur().Insert(col, killRing) |> ignore + col <- col + killRing.Length + redraw () | ConsoleKey.UpArrow -> // Up WITHIN the buffer; history only from the FIRST line // (the fish/ipython convention; Ctrl+R is the explicit path) @@ -1761,6 +1796,17 @@ let private evalCheckedBody (source: string) (state: State) (chk: Script.Checked st with | Eval.ExitRequest _ -> reraise () + // a BARE command statement's nonzero exit is the shell's `$?`, + // not a weir error [D:repl-cmd-fail]: the output already + // streamed and the session continues, so render a QUIET + // exit-code status (the ONLY surface for the code — there is no + // `$?`) instead of the loud `error:`. The raise itself is + // unchanged (scripts still abort; a value/reifier failure below + // still errors loudly). The red prompt cue rides `lastErrored`. + | :? Proc.CommandFailure as cf -> + lastErrored <- true + echoMeta $"↳ exit {cf.Code}{cf.SignalNote}" + bindIt TUnit Eval.VUnit state | ex -> lastErrored <- true diff --git a/src/Weir/Table.fs b/src/Weir/Table.fs index d791eaf5..0f0279f5 100644 --- a/src/Weir/Table.fs +++ b/src/Weir/Table.fs @@ -81,6 +81,15 @@ let matchKey (s: string) : string = /// `` — under Option both read as None let isAbsent (cell: string) : bool = cell = "" || cell = "" +/// az `-o table` (and other tabulate-style tools) draw a rule line of +/// dashes under the header — `-------- ---------- -----` [D:from-table-az]. +/// A row that is ONLY dashes and spaces (with at least one dash) is that +/// separator, never data: every cell would be dashes, which no real row is. +let isSeparatorRow (line: string) : bool = + line.Trim() <> "" + && line |> Seq.forall (fun c -> c = '-' || c = ' ') + && line |> Seq.exists ((=) '-') + /// parse numbered input lines into the header's columns and the sliced /// data rows (1-based line numbers ride each row; blank lines skip). /// Errors are bare text — the caller prefixes its own name @@ -91,6 +100,15 @@ let parse (lines: (int * string) list) : Result let cols = columns header + // az `-o table` puts a dashes separator row between the header and + // the data [D:from-table-az]; drop it when it is the FIRST data row. + // kubectl/docker have none, so a real first data row is untouched — + // the skip is conditional on that row actually being a separator. + let rows = + match rows with + | (_, sep) :: rest when isSeparatorRow sep -> rest + | _ -> rows + let dup = cols |> List.groupBy (fun c -> matchKey c.Header) diff --git a/tests/Weir.Tests/InferProbe.fs b/tests/Weir.Tests/InferProbe.fs index 4b132ec1..63c3dfcd 100644 --- a/tests/Weir.Tests/InferProbe.fs +++ b/tests/Weir.Tests/InferProbe.fs @@ -794,6 +794,22 @@ let tableInferRules = Expect.stringContains out "age: string" "" Expect.exists (notes) (fun n -> n.Contains "no data rows under the header") "verify-against-a-fuller-sample" } + test "az `-o table` dashes separator is skipped when drafting — types come from real rows [D:from-table-az]" { + let ws = [ 8; 10; 8 ] + + let out, _ = + inferTable + "Vm" + [ trow ws [ "NAME"; "RUNNING"; "PORT" ] + trow ws [ "------"; "--------"; "------" ] + trow ws [ "web-1"; "true"; "8080" ] + trow ws [ "db-0"; "false"; "443" ] ] + + // without the skip, the `------` cells would force every column + // to string; with it, the scan types from the real rows + Expect.stringContains out "running: bool" "the dashes row is not sampled — bool survives" + Expect.stringContains out "port: int" "int survives too" + } test "the drafted type checks and its from-table read checks (the injection round-trip)" { let decl, _ = inferTable "Pod" [ "NAME RESTARTS"; "web-1 0" ] diff --git a/tests/Weir.Tests/Tests.fs b/tests/Weir.Tests/Tests.fs index 5f072dd9..187da740 100644 --- a/tests/Weir.Tests/Tests.fs +++ b/tests/Weir.Tests/Tests.fs @@ -3282,6 +3282,31 @@ let completionTests = // from the full parser set [D:keyword-completion] Expect.equal (suggest "ls |> whe" 6) [ "when"; "where" ] "" } + test "a $name splice in argv completes session bindings [D:argv-splice-complete]" { + // `$` is not a word char, so the word starts after it and the + // before-text ends with `$`: `--location $l` must offer the + // binding `location` (bare — the $ is already typed), not the + // empty filesystem match it used to. + let envL = + { env with + Values = + env.Values + |> Map.add "location" (Weir.Types.mono Weir.Types.TStr) + |> Map.add "logLevel" (Weir.Types.mono Weir.Types.TStr) } + + let ask (text: string) = + Weir.Complete.suggest envL text (Weir.Complete.wordStartAt text text.Length) + + let cands = ask "az vm list-usage --location $l" + Expect.contains cands "location" "$l offers the location binding" + Expect.contains cands "logLevel" "prefix $l offers every l-binding" + Expect.isFalse (cands |> List.exists (fun c -> c.StartsWith "$")) "candidates are bare (the $ is already before the word)" + + // a BARE argv word (no $) stays the paths-only pool — a binding + // name must NOT leak in as an argv word + let bare = ask "az vm list-usage --location loc" + Expect.isFalse (List.contains "location" bare) "a non-splice argv word is not a binding pool" + } test "a line-head '#' completes the session directives, bare [D:repl-directives]" { // bare names: the editor's word starts AFTER the '#', so // replacement yields `#help` — never `##help` or `head` @@ -12030,6 +12055,22 @@ let agentFindingsTests = Expect.stringContains msg "cannot take a piped stdin" "" | Ok _ -> failtest "value-headed exec must refuse" } + test "line desugars to the lined application and types as string [D:reify-line]" { + match Weir.Parser.parseLine cmdResolver "echo hi | line" with + | Ok(SCmd e) + | Ok(SExpr e) -> Expect.stringContains (Weir.Ast.sexpr e) "|lined" "" + | other -> failtest $"expected the lined desugar, got {other}" + + // a value-position `| line` is a string — a binding checks as + // string end-to-end (base, env twin, value-headed) + let clean (lines: string list) (label: string) = + let diags, _, _, _ = Weir.Script.analyzeLines "line.weir" lines + Expect.isEmpty (diags |> List.filter (fun d -> d.Severity = "error")) $"{label}: {diags |> List.map _.Message}" + + clean [ "let scope = printf \"x\" | line"; "print (Str.length scope |> show)" ] "binds a string" + clean [ "let e = [Env.pair \"X\" \"1\"]"; "let v = $e(printenv X | line)"; "print v" ] "env twin binds a string" + clean [ "let m = [\"a\"; \"b\"] | grep a | line"; "print m" ] "value-headed | line binds a string" + } test "the fifth refusal cell: refused-context reifiers TEACH, never PATH-resolve [D:reifier-family-complete]" { // [D:statement-lets] moved the boundary: if-body and // within-body block lets now TAKE the reifier (statement @@ -21446,6 +21487,7 @@ let fromTableTests = |> declare "type TNode = { name: string; [] roles: Option; cpu: Option; ready: bool; podTemplateHash: string }" |> declare "type TReq = { name: string; restarts: int }" + |> declare "type TPod3 = { name: string; ready: string; status: string }" |> declare "type TBadSeq = { name: seq }" |> declare "type TBadOpt = { name: Option> }" |> declare "type TUni = A of int | B" @@ -21500,6 +21542,44 @@ let fromTableTests = ) ]) "columns slice at header offsets; int cells convert; blank interior lines skip" } + test "az `-o table` dashes separator under the header is skipped, never a garbage row [D:from-table-az]" { + // az (and tabulate-style tools) draw `---- ----` between the + // header and the data; it must not parse as a data row. The + // separator rides at the header offsets like any aligned row. + let azSample = + VSeq + [ VStr(row podWidths [ "NAME"; "READY"; "STATUS"; "RESTARTS"; "AGE" ]) + VStr(row podWidths [ "------"; "-----"; "-------"; "--------"; "---" ]) + VStr(row podWidths [ "web-1"; "1/1"; "Running"; "0"; "2d1h" ]) ] + + Expect.equal + (runT [ "src", azSample ] "src |> from table TPod") + (VSeq + [ VRecord( + "TPod", + [ "name", VStr "web-1" + "ready", VStr "1/1" + "status", VStr "Running" + "restarts", VInt 0L + "age", VStr "2d1h" ] ) ]) + "the dashes row is dropped; only the real row reads (no `------` cell became data)" + } + test "a real first row is NOT skipped — the separator drop is conditional [D:from-table-az]" { + // a row with a lone `-` cell (a tool's own 'none' spelling) is + // NOT all-dashes, so it stays data — the skip fires only on a + // row that is ENTIRELY dashes and spaces + let ws = [ 8; 8; 0 ] + + let sample = + VSeq + [ VStr(row ws [ "name"; "ready"; "status" ]) + VStr(row ws [ "web-1"; "-"; "Running" ]) ] + + Expect.equal + (runT [ "src", sample ] "src |> from table TPod3") + (VSeq [ VRecord("TPod3", [ "name", VStr "web-1"; "ready", VStr "-"; "status", VStr "Running" ]) ]) + "a first row that only LOOKS dash-ish (one `-` cell) is real data, kept" + } test "a two-word single-space header is ONE column; spaced values and a spaced last column survive" { // docker's reality: `CONTAINER ID` is one header (single // space), columns pad with 3+ spaces, STATUS holds diff --git a/tests/repl/repl-it-streamed.py b/tests/repl/repl-it-streamed.py index 41d62b7a..b9fd4b92 100644 --- a/tests/repl/repl-it-streamed.py +++ b/tests/repl/repl-it-streamed.py @@ -167,8 +167,30 @@ def drain(t): if out.count("let f x = x + 2") < 2: failures.append(f"(g) redefinition must show the LAST accepted definition: {out!r}") +# --- (h): a bare command that EXITS NONZERO renders a QUIET exit-code +# status (there is no $?, so the code must show), NOT the loud `error:` — +# the raise is caught on the inherit path and the session continues +# [D:repl-cmd-fail]. `it` after binds () like any streamed command. +out = session([(b'sh -c "echo OUT; exit 3"\r', 1.2), (b"it\r", 0.8), (b'print "after"\r', 0.8)]) +if "OUT" not in out: + failures.append(f"(h) the failing command's output must still stream: {out!r}") +if "exit 3" not in out: + failures.append(f"(h) a nonzero exit must show its code (the $?-less REPL's only surface): {out!r}") +if "error: command failed" in out: + failures.append(f"(h) a bare command failure must NOT be the loud error at a tty: {out!r}") +if "() : unit" not in out: + failures.append(f"(h) it after a failed streamed command still binds () : unit: {out!r}") +if "after" not in out: + failures.append(f"(h) the session must continue past a failed command: {out!r}") + +# --- (h2): a VALUE-position command failure stays the LOUD error — there +# it aborted a computation, not a shell `$?` [D:repl-cmd-fail] --- +out = session([(b'let x = $(sh -c "exit 4")\r', 1.0)]) +if "error: command failed with exit code 4" not in out: + failures.append(f"(h2) a $() capture failure must keep the loud error (it broke a value): {out!r}") + if failures: for f in failures: print("FAIL:", f) sys.exit(1) -print("ok: it FSI-parity — streamed binds (), misuse teaches the capture, functions echo mini-help") +print("ok: it FSI-parity — streamed binds (), misuse teaches the capture, functions echo mini-help, failed command quiets to an exit-code status") diff --git a/tests/repl/repl-wordnav.py b/tests/repl/repl-wordnav.py index 90c08fc6..8d9cf6dc 100644 --- a/tests/repl/repl-wordnav.py +++ b/tests/repl/repl-wordnav.py @@ -41,6 +41,22 @@ def send(s, delay=0.15): send("\x1b[D", 0.02) # Left over ' (fun s -> s) |> Seq.head' send("\t", 0.3) send("\r", 0.6) +# kill-ring [D:repl-killring]: Ctrl+W kills the previous word INTO the ring, +# Ctrl+Y yanks it — kill "KRX", yank twice -> echo KRXKRX (streams KRXKRX) +send("echo ") +send("KRX") +send("\x17") # Ctrl+W: kill "KRX" into the ring +send("\x19") # Ctrl+Y: yank +send("\x19") # yank again -> KRXKRX +send("\r", 0.5) +# Ctrl+U feeds the SAME ring: type UKILL, ^U kills it to the ring, then a +# fresh `echo `, yanked twice -> echo UKILLUKILL (streams UKILLUKILL) +send("UKILL") +send("\x15") # Ctrl+U: kill to line start into the ring +send("echo ") +send("\x19") +send("\x19") +send("\r", 0.5) send("\x04") # Ctrl+D time.sleep(0.4) @@ -66,6 +82,10 @@ def send(s, delay=0.15): failures.append("Ctrl+Left x2 / Ctrl+Right did not hop segment-wise (no ab9.cd echo)") if '"zz"' not in text: failures.append("mid-line Tab completion did not complete Seq.ma with a tail after the cursor (no zz echo)") +if "KRXKRX" not in text: + failures.append("Ctrl+W did not kill a word into the ring / Ctrl+Y did not yank it (no KRXKRX)") +if "UKILLUKILL" not in text: + failures.append("Ctrl+U did not feed the kill-ring / Ctrl+Y yank failed (no UKILLUKILL)") if failures: print(text) @@ -73,4 +93,4 @@ def send(s, delay=0.15): print("repl-wordnav FAIL:", f) sys.exit(1) -print("repl-wordnav: word navigation holds") +print("repl-wordnav: word navigation + kill-ring (Ctrl+W/Ctrl+U → ring, Ctrl+Y yanks) hold")