weir
On this page

The weir REPL

weir with no arguments starts the REPL. Here you can use short unqualified names like map and where (scripts need the qualified names), every value is echoed back, and tab completion and history work as you’d expect. Ctrl+C abandons the current line; Ctrl+D or #quit exits. For a tour, see the guide; this page covers the details.

A line starting with # is an instruction to the tooling, not part of the language. Directives in files (#sig, #schema) are read at check time; session directives (#help, #quit) run immediately. The reference table lists where each directive is used.

Tab completion

Tab completes the word under the cursor: module members, record fields, keywords, bindings, and, where a command can go, the programs you can run. A command can go at the start of a statement or right after = in a top-level let, so let svc = kubect<TAB> completes just like a bare kubect<TAB>. The words after a command complete as arguments: directory entries, or session bindings after a $ (see below).

Coloring follows the same rule. The command word is colored as you type, so in let svc = kubectl … the color of kubectl shows whether it resolves. Session aliases are recognized in both places, for coloring and for Tab. A command starting with ^ completes from PATH only, skipping bindings and aliases.

Some behaviours worth knowing:

Completion never runs anything; at most it reads a directory or looks up a cached PATH entry.

What the echo shows

At a terminal, how a value is echoed depends on its type:

The type is always shown on a line below, with a note when a seq has not been fully evaluated. NO_COLOR turns off the styling. Type variables in that line are renamed to 'a, 'b, …; error messages use the checker’s internal names for them instead.

An expression that evaluates to a named function echoes the same thing #help would show:

A let binding is echoed the same way: the value’s lines (or table) first, then a name : type line with the same note about unevaluated seqs. That line always comes after the value, so a cut-off value never looks as if data silently went missing: you see where it was cut, then the note saying so and how to see the rest (Seq.freeze).

A let bound to a command is not run by the echo: the binding is lazy, as in a script, so the echo shows only its type and how to run it. Typing the name runs the command and prints its output, every time:

weir> let pods = kubectl get po -A
pods : seq<string> (command-backed — runs on each use; type pods to run it)
weir> pods
…the pods…
weir> let snap = kubectl get po -A |> Seq.freeze
…the pods…
snap : seq<string> (frozen)

So let cleanup = rm -rf build deletes nothing until you type cleanup. The same holds for a let that reads such a binding (let running = pods |> Seq.where …). An eager right-hand side has already run when the let is evaluated (|> Seq.freeze, | line, | text and the other reifiers), so its value is echoed as usual; a frozen binding shows (frozen). In a script, the checker warns when a command-backed binding is used twice. A lazy seq that doesn’t run commands gets no note. At a terminal, the (frozen) note and the truncation note share one pair of parentheses; piped output never includes them.

Seeing all of the output

The echo is only a preview. There are three ways to see a result:

#echo changes the echo’s limit for the session: #echo 25, or #echo all for no limit (an infinite seq will then hang). #echo on its own shows the current limit, and echoElems in the config sets the starting value. A frozen seq is always echoed in full; the limit only applies to lazy ones. Piped output is not affected.

Help

#help lists the directives and the modules, one module per line with a short description. #help Seq lists one module’s members the same way: one per line, each with the first line of its doc, cut to fit the terminal. #help Seq.collect shows one member’s full doc, and a short name such as #help map shows the same doc, saying which member it stands for (map is Seq.map). It is the same doc that editor hover shows, and the one-line summaries are its first line.

At a terminal, `code` in the docs is shown colored, without the backticks. The signature line and the example are colored too: in the signature the name is bold, types are yellow and punctuation is dim, and the example is colored the same way as the input you type at the prompt. When output is piped, or with NO_COLOR/TERM=dumb, there is no coloring: the backticks stay, so you can still see where code starts and ends.

#find [query] does a fuzzy search over all of it. When fzf is installed and you are at a terminal, every module (Seq — summary) and every member (Seq.map — summary) is listed in fzf with a live preview of the selected name’s full doc. Enter prints the #help output for the selection; Esc returns to the prompt. Without fzf, or when piped, #find query filters the same lines by case-insensitive substring, and never asks you to install fzf. The preview comes from running weir --repl-doc <name> in the background, which prints exactly what #help <name> would. It covers builtin docs only and evaluates nothing.

History

#history shows the session’s history. It starts with the file path, history at <path> (N entries), so you know where the file is and can cat it directly. #history on its own prints every entry, numbered; #history 20 shows the last twenty. Each entry is shown on one line, as in history search: a multi-line entry is joined with ⏎, so you can still grep it.

The history file is at $XDG_STATE_HOME/weir/history (otherwise ~/.local/state/weir/history; %LOCALAPPDATA%\weir\ on Windows). It is created with mode 0600, since a REPL line can contain a secret. To find that base directory from a script, use Path.stateHome (). In a piped session (printf '…' | weir), #history shows the file plus that session’s lines, which are not saved. If there is no usable home (HOME= is empty, or the file cannot be written), the REPL still starts: history is off for that session, with one note saying why.

History keeps each entry once: when you enter a command again, it moves to the most recent position and the earlier copy is removed. So Up-arrow, #history and Ctrl+R each show a command once (duplicates already in the file are merged when it loads). Turn this off with historyDedup = false in the config.

The last result: it

Every expression and command sets it to its result, even when the result is unit (as in F# Interactive). So the result of the pipeline you just ran is one word away:

weir> kubectl get po -o json |> from json Pods |> _.items
weir> it |> Seq.map _.name

A let binds only its own name: let o = 10 leaves it alone, just as let o = 10;; does in F# Interactive. Directives never change it, and in a new session it is not yet defined. it exists only in the REPL; scripts and -e don’t have it, just as they don’t have the short unqualified names. The name comes from F# Interactive and ghci; _ wasn’t an option, because it already means other things (the _.field shorthand and the let _ = discard).

At a terminal, a bare command writes straight to the terminal, so its colors are kept and weir never sees the output. Its value is therefore (), and that is what it is set to.

When such a command exits nonzero, the REPL shows a dim ↳ exit N line and the next prompt turns red, instead of printing a red error. The output has already been shown and the session carries on, much like $? in a shell. The nonzero exit still raises an error, though. In a script it stops the script. Inside a value ($(cmd), a reifier, a binding) you see the full error:, because there the failure interrupted a computation. To get a nonzero exit as a value instead, use cmd | exitCode or cmd | complete.

Using that unit it where a value is needed is an ordinary type error, and the error shows how to capture the command instead, using the exact command you ran:

weir> kubectl get po -A -o yaml
…the pods, live…
: seq<string>
weir> #infer it from yaml as Pods
#infer: the source has type unit; the adapter needs seq<string> (a captured JSON/YAML sample)
to capture: let x = kubectl get po -A -o yaml

#infer: draft types from a sample

Exploring an unfamiliar JSON/YAML document (or a kubectl-style aligned table) normally means writing out its shape by hand. #infer drafts the types for you from a sample:

weir> let raw = kubectl get po -o json
weir> #infer raw from json as Pods
defined: Pods, Metadata, Status (3 types)
weir> raw |> from json Pods |> _.items |> Seq.map _.⟨TAB⟩

#infer <source> from <json|jsonl|yaml|table> as <Name> evaluates the source once, parses it in the given format, derives a set of named type declarations from it, and adds them to the session as if you had typed them. After that, from json Pods type-checks and field completion works. If you leave out the source, it defaults to it (#infer from json as Pods reads the last result, which is how you “pipe” into a directive). For a top-level array (or from jsonl), Name is the element type, and you read the value as seq<Name>. from table drafts the row record from the header and the cells of each column; a column with empty or <none> cells becomes an Option, with a note, and the value reads as seq<Name>.

The one-step form drafts the types and binds the parsed value:

weir> #infer let po = kubectl get po -o json |> from json as _
defined: Type1, Metadata, Status (3 types)
po : Type1 = { items = …; count = 3 }
weir> po.items |> Seq.map _.⟨TAB⟩

#infer let <x> = <source> |> from <fmt> as <Name|_> runs the source exactly once and uses that one sample both to infer the types and to bind the value. Nothing runs twice (no Seq.freeze needed), and you don’t need a third statement repeating the name. as _ names the top-level type automatically (Type1), the way nested types are always named; it works in the two-step form too. A top-level array is bound as seq<Name>. If the sample’s top level is a scalar or a map with data keys, #infer defines what types it can and binds nothing; the notes show how to read the value.

#save writes out the explicit form, the type declarations plus let x = source |> from <fmt> Name, never the directive line. The one-step form is a REPL convenience; the saved file is ordinary weir that weir check accepts. (In a script, from json as Name is a check error that tells you to declare the type, and how to draft it with #infer.)

The result is ordinary type declarations that you own and can edit. Like weir gen types, #infer generates types once, up front; nothing is inferred at check time (check never evaluates anything, and from json never guesses a shape).

Array elements are merged: the element type has every key seen in any element, and a key missing from some elements becomes Option<T>. So one sample of a Kubernetes List reveals the optional fields that differ between its items. Where the sample doesn’t settle a question, #infer prints a note rather than guessing:

When the format has a published JSON Schema, generate the types from that instead: weir gen types reads a locked schema and knows what no sample can, such as which fields are required, additionalProperties, and the definition names.

An object whose keys are data rather than field names is drafted as a map, seq<string * V>, instead of a record, with a note. #infer treats an object this way when all its values have the same shape, and either:

Map keys are data, so no [<Wire>] is drafted; read them as pairs (cm.data |> Seq.tryFind (fun (k, _) -> k == "Corefile")). Both from json and from yaml support this shape. An object with identifier keys that are the same in every element, like metadata, stays a record. An empty {} is a map with no entries; it is drafted as seq<string * string> with a note (with no values to go on, the value type defaults to string).

A drafted type name never reuses a name that already exists in the session, whether a builtin (Secret, Yaml, Duration, …) or a type you declared. For example, a Kubernetes volume’s secret: object would draft type Secret, and every secret: Secret field would then refer to the builtin Secret type instead (which from yaml refuses to read). So #infer prefixes the parent’s name instead (VolumeSecret) and prints a note about the rename. The name you give with as is never renamed: #infer … as Secret is refused, and asks you to pick another name.

A key that isn’t a valid weir field name never breaks the draft. (An object drafted as a map doesn’t need field names at all, since its keys are data.) For a record, a key that isn’t an identifier, or is a keyword, gets a derived field name with [<Wire "the-key">] on it (k8s-app→k8sApp, in→inField; no keyword can be a field name). An empty-string key can’t be a field name and [<Wire>] doesn’t accept it, so it is dropped with a note (reading ignores the extra key).

If a drafted type still fails to check (say the sample had the same key twice), #infer doesn’t just say “a drafted type did not check”. It shows the line:col within the drafted type and prints the offending line with a caret, just like a normal parse or type error:

weir> #infer sample from json as Pods
#infer: a drafted type did not check at line 1, col 1: duplicate field 'a'
  type Pods = {
  ^

You can see exactly which generated field is wrong and fix the sample (or hand-edit once you #save).

The same inference is available as a function outside the REPL: sample |> Json.inferShape |> print (and Yaml.inferShape, Table.inferShape) returns the declaration text.

#save: distill the session to a script

#save <path> writes the session’s reusable definitions to a file. The session is scratch space; the file is what you keep. You use #infer to explore and #save to keep what you found.

The file gets:

Expressions that were only echoed are left out. Short unqualified names are replaced with qualified ones (map → Seq.map, startsWith → Str.startsWith), and the result is formatted.

The file always passes weir check. Any statement that depends on something that only exists in the session (such as let x = it) is dropped, and #save prints a note saying how many were dropped. A binding nothing in the file reads is kept with a _ added to its name (let _total = …, or let (a, _b) = … inside a pattern), so the unused-binding check accepts it.

#save also expands command aliases, since scripts don’t have them. Each saved line’s command is rewritten to the real command: let pods = k get po is saved as let pods = kubectl get po, and kb overlays/prod as kustomize build overlays/prod. Only the command word itself is rewritten, so a k in a string or an argument is left alone. The saved script contains no aliases and passes weir check.

The prompt and the colors

The prompt turns red after an entry that fails, and returns to normal after the next one that succeeds. A nonzero exit that you asked for as a value (cmd | exitCode, | complete) is not an error, so the prompt stays normal. weir has no global $?; the color only shows errors.

Input is colored as you type: keywords, strings, comments, numbers and the command markers. The command word is colored by what it resolves to: bold for a known binding or builtin, blue for a program found on PATH, and red if it would fail. A red command word lets you catch a typo before pressing Enter. NO_COLOR is honored, and piped sessions are always plain text.

The init file

init.weir beside the config file ($XDG_CONFIG_HOME/weir/, else ~/.config/weir/; %APPDATA%\weir\ on Windows) is loaded before the first prompt. Like a module, it may contain only declarations (type and let) and imports, plus #alias lines and one #session block for settings that a declaration can’t express. An imported module’s members are available in the session as Alias.member.

The file must start with #init on its first line; the REPL refuses to load it otherwise. Any file starting with #init is checked as an init file wherever it is. So you can keep it in a dotfiles repo, with full editor support (#session and #alias are recognized and the declarations are checked), and symlink it into place:

ln -s ~/dotfiles/weir/init.weir ~/.config/weir/init.weir

Imports resolve from the file’s real location, so with that link, import "./prompt.weir" loads ~/dotfiles/weir/prompt.weir.

The REPL loads only <configHome>/weir/init.weir. Which init file loads never depends on the current directory, so a stray init.weir in a cloned repo is never loaded automatically. An init file can’t be run as a script: weir init.weir refuses it.

An example:

#init

#session {
    cwd = "/home/me/work"
    logLevel = "debug"
    echoCap = 50
    env = [
        "EDITOR", "hx"
    ]
}

/// push the current branch and set upstream
let pu () = git push --set-upstream origin HEAD

The #session keys:

A misspelled key gets a suggestion for the closest one. The values can’t run commands.

A let pu () = … is a function with no arguments, not a command alias. Functions can take parameters and span several lines, but you have to call them with (), and they don’t accept command-line arguments. When you want a short command name that passes its arguments straight through, like k get po, use #alias (below).

A let can hold a command: let gl = git log --oneline -n 10 binds a lazy value, so nothing runs while the file loads, and typing gl runs it, every time. A let that would run its command at load is refused with the reason: a reifier (| line, | text, | succeeds…), |> Seq.freeze, or passing the command to a function. Wrap those in a function, as with pu above. A command value runs in the directory you are in when you use it, so gl logs the repo you have cd-ed into; only a command written inside within cd/within env keeps that scope.

Loading is all-or-nothing. If the init file has an error, the REPL prints the error with its location plus init: not loaded, and the session starts without any of it. This is safe because nothing in the file runs while loading. A successful load prints nothing (and so does a missing init file); you’ll see your names, settings and prompt working. Only failures are reported. #help on a name from the init file shows its /// doc.

prompt: your own prompt

prompt takes a string, or the name of a unit -> string or PromptStatus -> string function declared in the same file.

PromptStatus describes how the last entry went:

So a custom prompt can color itself red or green where the default prompt would turn red, and can show the code the way ↳ exit N does.

Write the parameter without a type and read its fields (st.ok, st.exit). The init file has no signatures, so the function’s type is inferred from the fields it uses, and it is accepted as long as PromptStatus has every field it reads. One thing to watch for: match st.exit with doesn’t type-check here, because the type of st.exit isn’t known yet and matching on Some/None needs it. Use the Option functions instead (st.exit |> Option.map show |> Option.defaultValue "").

The function may run commands. It is called once each time the REPL reads an entry (never on every keystroke), after the previous entry has finished. Because prompt refers to your declarations, it is checked after they are defined.

An example that shows the current directory and git branch:

let sigil () =
    let dir = pwd |> Path.fileName
    let g = git branch --show-current | complete

    let branch =
        if g.exitCode == 0 then
            g.stdout |> Seq.tryHead |> Option.defaultValue ""
        else
            ""

    let tag = if branch == "" then "" else $" ({branch})"
    $"≋ {dir}{tag}> "

#session {
    prompt = sigil
}

That shows ≋ weir (main)> inside a repo and ≋ tmp> outside one; | complete keeps git from raising an error outside a repo.

You can use colors in the prompt. Color escape codes (SGR) don’t count toward the prompt’s width, and a reset is added at the end so the color doesn’t spill into what you type. Other escape sequences (window title, clipboard, hyperlinks) are removed. Newlines and other control characters become spaces, and the continuation prompt is padded to the same width. If the function raises an error, the REPL falls back to the default weir> and says so once on stderr; later failures in the same session are silent.

Two limits:

#alias: command-head aliases

A #alias maps a short name to a program plus some fixed leading arguments. It only applies where a command name goes. Declare aliases in init.weir (the usual place), one per line:

#alias k  = kubectl
#alias kb = kustomize build

Now k get po -o yaml runs kubectl get po -o yaml, and kb overlays/prod runs kustomize build overlays/prod: the fixed arguments go right after the program, followed by yours. An alias is not a text substitution. Your arguments are passed exactly as usual, so k get $x still passes $x as one argument, and a k in a string, a variable or an argument is left alone. Only the command word itself is replaced. A keyword (let, if, …) can’t be an alias name, since a line starting with it is never a command.

Because an alias is just a program plus fixed words, not a command line, weir syntax in it is not interpreted: $(…) captures, pipes, and shell &&/|| become literal arguments and are never evaluated. For a shortcut that needs those (say, cd to the repo root), write a function with no arguments in init.weir instead, and call it with ():

let root () = cd $(git rev-parse --show-toplevel | line)

The alias target may be a builtin like cd: #alias up = cd .. runs the cd builtin, not an external cd.

Aliases are looked up before PATH, and a ^ prefix skips them: with #alias ls = ls --color, ls x runs ls --color x and ^ls x runs the real ls x. An alias always points at a program, never at another alias; an alias of an alias is rejected when the init file loads. The program doesn’t need to exist when you define the alias (as in bash). A malformed #alias line is an init error, so nothing in the init file is loaded.

Aliases exist only in the REPL. Scripts and -e never load init.weir, so an alias name there is just an unknown command. #save expands aliases, so a saved script contains none.

You can also type #alias at the prompt (#alias on its own lists all aliases), but init.weir is the usual place for them.

Multi-line editing

Enter submits when the statement is complete, and starts a new line when it is not. weir uses its own parser to decide, so after match x with you get a new line, while 1 + 1 is submitted. Up and Down move between lines, and a recalled history entry comes back whole: a three-line match comes back as three lines that you can edit. Lines you type before the first prompt appears are kept, and their Enter submits them as usual.

The key bindings (they can’t be customized):

keyin the buffer
Entersubmit if complete, otherwise start a new line; on an empty last line, submit anyway (a way out of an unfinished statement: the error is shown and your input is kept)
Alt+Enter / Ctrl+Jinsert a newline (for formatting; the entry is still one statement). Shift+Enter can’t be used, because terminals don’t distinguish it from Enter. Windows Terminal uses left-Alt+Enter for fullscreen: use Ctrl+J or right-Alt there
Up / Downmove between lines; Up on the first line recalls history
Ctrl+Rhistory search (uses fzf when installed; each entry is shown on one line, joined with ⏎)
Ctrl+W / Ctrl+U / Ctrl+Kcut the previous word / to the start of the line / to the end of the line, saving it for Ctrl+Y
Ctrl+Ypaste the last cut text at the cursor. Cut text is kept for the whole session, so you can cut on one line and paste into a later one. (To copy from elsewhere, use your terminal’s mouse selection; weir does not read the OS clipboard.)
Esc / Ctrl+Cabandon the whole buffer
Ctrl+DEOF on an empty buffer; delete/join otherwise

A session with redirected input (printf '…' | weir) has no editor, but it still groups multi-line statements the way a script does. It keeps reading lines while the statement is unfinished (a heredoc body, a multi-line type, an indented if/match block, a pipeline continued with |> lines), so a pasted or scripted block runs as one statement. A blank line ends the statement even if it is unfinished, as an empty last line does in the editor: the error is shown and the lines after it still run. Input with one statement per line works as usual, and a directive is always a single line.

weir and fzf

fzf is always optional: everything that uses it has a built-in fallback, and weir never tells you to install it. When it is on PATH:

One catch is handled for you: characters common in weir (^, |, $, !) are operators in fzf’s extended search, so weir passes --no-extended first, for plain fuzzy matching over code and names. fzf uses the last flag given, so finderFlags = ["--extended"] turns extended search back on.