PowerShell compatibility, without the wait.
PWR is an experimental, performance-first implementation of the PowerShell Core command-line interface and language runtime.
- Preserve
pwshcompatibility where it matters. - Make startup and common command execution dramatically faster.
- Measure compatibility and performance with reproducible tests.
- Keep the implementation small enough to understand and improve.
PWR targets .NET 10 and uses a clean language pipeline rather than the existing PowerShell execution engine:
source -> lexer -> parser -> semantic analysis -> binder -> bytecode lowering -> VM
The solution contains:
src/Pwr.Language— syntax, semantics, binding, and execution;src/Pwr.Cli— thepwrcommand and REPL;src/Pwr.LanguageServer— the crash-contained stdio language server;editors/vscode— the VS Code language client and PWR editor support;tests/Pwr.Language.Tests— executable language specifications.
PWR is designed to grow into a gradually typed PowerShell superset. .ps1
files use compatibility mode. .pwr files opt into static checking and syntax
such as:
let $answer: int = 40 + 2
$answerSee the language design for the compatibility rules and proposed type-system direction.
The runtime currently executes integer, Boolean, null, and string literals;
variables, assignment, and mutation; typed declarations; arithmetic and
comparisons; arrays and hashtables; branches; for, foreach, while, and
do loops; switch; labelled loop control; try/catch/finally, throw,
and exit; functions and returns; casts, indexing, and CLR instance/static
member invocation; comments; and parenthesized expressions. The compiler
exposes the complete recoverable, syntax-independent representation through
Compilation.GetBoundProgram(). Unresolved commands remain explicit bound
calls with a local resolution diagnostic. Evaluation sessions resolve compiled
functions, registered intrinsic commands, and native executables found on
PATH. Registered command stages consume success output lazily, allowing a
downstream stage to stop without forcing the complete upstream sequence. The
final host boundary materializes output for EvaluationResult. Native standard
output is read line-by-line while pipeline input is written concurrently, which
avoids full-output buffering and stdin/stdout pipe deadlocks. Native standard
error remains separate and $LASTEXITCODE is updated after full enumeration;
stopping early terminates the remaining native process tree. Asynchronous
and native stages share a bounded channel plan (default capacity 32) with named
stages, linked cancellation, ordered record envelopes, concurrent stdin/stdout/
stderr draining, and deterministic completion. Purely synchronous managed
pipelines retain the direct pull path and do not allocate channels or tasks.
The bytecode runtime lowers literals, variables, arithmetic,
comparisons, arrays, hashtables, branches, for, foreach, while, and do
loops, switch, try/catch/finally, explicit throws, function calls,
returns, casts, indexing, and CLR member calls to compact instructions. Function
parameters and locals use per-call numeric slots, while
script globals and compiled functions persist in the evaluation session. This
allows functions declared in one REPL submission to be called by later
submissions. A same-program function call used as the complete value of
return lowers to a tail-call instruction: its evaluated positional or named
arguments replace the current local frame and execution jumps to the target
function without CLR stack growth or a per-call argument allocation. This
supports both direct and mutual tail recursion, including functions with
different parameter and local layouts. A tail-position call resolved from an
earlier REPL submission uses a session-tail instruction: it resolves the target
again at execution so redefinitions win—including rebinding the caller's
original parameter spelling—then switches bytecode program without
growing the CLR stack. The trampoline caches per-program global layouts and
grows its local and operand buffers only when a larger target is first seen, so
mutual recursion can alternate across submissions with allocation bounded by
the programs rather than recursion depth. Globals are synchronized at program
boundaries. Calls nested inside another expression remain ordinary independent
frames.
Typed user-function arguments pass through the same runtime converter used by
registered commands before a function body or tail-call target executes. This
applies to ordinary bytecode frames, interpreter fallback, tail-frame rebinding,
and functions invoked from later REPL submissions. A failed conversion writes a
catchable, statement-terminating binding error without executing the function
body; outside a protected region, execution resumes at the next statement.
[Alias(...)] names join
canonical parameter names in case-insensitive exact and unique-prefix lookup;
binding always records the canonical name. [Parameter(Mandatory = $true)]
requires an explicitly supplied positional or named value. Missing mandatory
arguments, alias ambiguity, duplicate bindings through an alias, and conflicting
alias declarations are diagnosed before the body runs, and the metadata remains
available when the function is called from a later REPL submission. Repeated
[Parameter(ParameterSetName = ...)] declarations give a parameter set-specific
membership and mandatory rule; parameters without a set belong to every set.
Binding eliminates incompatible sets, honors
[CmdletBinding(DefaultParameterSetName = ...)] when compatible, and otherwise
uses mandatory satisfaction to select one set or reports ambiguity/no-match.
The same selection runs for bytecode entry, allocation-free tail rebinding,
interpreter fallback, and persisted REPL functions. ValueFromPipeline and
ValueFromPipelineByPropertyName opt a parameter into lazy, per-record pipeline
binding. PWR follows PowerShell's four binding phases: whole-value exact type,
property exact type, then the corresponding conversion phases. Canonical names
and aliases both match record properties case-insensitively; explicit arguments
win, and each record reruns parameter-set selection and mandatory checks. A bad
record writes a binding error and is skipped without stopping later records.
The metadata persists with functions called from later REPL submissions and
works in both bytecode and interpreter fallback.
Omitted optional function parameters evaluate their default expressions in
declaration order after parameter-set selection, conversion, validation of all
supplied values, and mandatory checks. Defaults can reference earlier
parameters, are converted to the declared type, and—as in PowerShell—are not
run for mandatory omissions or validated as caller input. The bytecode default
prologue, tree-interpreter path, persisted functions, and tail-frame rebinding
share this ordering. PWR executes ValidateNotNull,
ValidateNotNullOrEmpty, ValidateRange, ValidateSet, ValidatePattern,
ValidateLength, and ValidateCount; validation failures write one binding
error and never enter the function body. Regular-expression validation uses a
bounded match timeout.
Exact, wildcard, and regular-expression switch statements lower to bytecode,
including case sensitivity, collection input, per-item defaults, all matching
clauses, scoped $_/$PSItem, labels, and switch-local break/continue.
Predicate scriptblock clauses lower to bytecode, including scoped $_ and
$PSItem, captured output, control flow, and variable restoration.
try/catch/finally and throw lower to bytecode exception regions. Typed
catches select the underlying exception type for explicit throws, conversions,
arithmetic, parameter binding, host-command failures, and other eligible runtime
errors. Catch blocks receive a structured RuntimeErrorRecord through $_ and
$PSItem; $Error retains the newest 256 records across REPL submissions.
Uncaught explicit throws terminate the script, while uncaught runtime and binding
failures write error output and resume at the next statement. Write-Error
remains non-terminating unless -ErrorAction Stop or
$ErrorActionPreference = 'Stop' escalates it into a catchable terminating
error. finally runs on normal completion, rethrow, cancellation, return, and
labelled or unlabelled break/continue. Nested regions unwind from inner to
outer; tail-position calls protected by a region remain ordinary calls so their
cleanup cannot be skipped. Session globals are committed after cleanup even
when cancellation escapes; cancellation itself bypasses script catches. The
tree-interpreter fallback uses the same error and unwind model. The REPL buffers parser-incomplete input and
displays a continuation prompt for multi-line submissions.
PWR is not yet a complete PowerShell replacement. Incomplete built-in command
coverage and the remaining explicitly unsupported bound constructs use the
tree interpreter. PowerShell typed catch filters and catch automatic
variables are implemented for script and eligible runtime failures; PowerShell
does not use C#-style catch when clauses. The built-in command catalog is broader
than the native command implementations. Script and manifest modules are
supported; providers, remoting, jobs, workflows, classes, and arbitrary
PowerShell binary-module assemblies remain outside this runtime sequence.
Binary modules require an explicit PWR adapter contract rather than compatibility
with PowerShell's internal CLR APIs.
EvaluationSession discovers modules from its ordered ModulePaths (initialized
from PSModulePath) and exposes ImportModule(), GetModules(),
RemoveModule(), CurrentScope, GlobalScope, GetVariableValue(), and
SetVariableValue() for hosts, jobs, and future remoting consumers. The runtime
also provides Import-Module, Export-ModuleMember, Get-Module,
Remove-Module, and Set-Alias bootstrap commands.
.psm1 files execute in persistent isolated module scopes. .psd1 manifests
are parsed by a constrained-data reader: they cannot run commands or
subexpressions, and root, nested, and required components cannot traverse or
follow links outside the module root. Manifests carry semantic version, GUID,
root/nested/required modules, and wildcard export filters. Required-module
version constraints, highest-version discovery, canonical-path deduplication,
cycle detection, exactly-once concurrent initialization, failed-import retry,
force reload, and clean removal are deterministic.
Only exported functions, aliases, and variables enter caller resolution.
Module-qualified commands, prefixes, -NoClobber, -Scope Local/Global, and
-PassThru are supported. The last ordinary import wins an unqualified name;
qualified lookup remains stable. $PSModuleAutoLoadingPreference supports
All, ModuleQualified, and None; its shared discovery catalog and negative
cache are invalidated by path, import, reload, and removal changes. Module state
survives REPL submissions, while local function and script imports expire when
their frame returns. Removal stops future resolution without disrupting an
already-running module frame.
EvaluationSession.RegisterCommand() lets a host install commands without
moving command behavior into the parser or binder. PWR includes initial
Write-Output, Write-Error, and Out-String intrinsics. The CLI enumerates
success output, keeps non-terminating command errors on the error stream, and
invokes native programs without an intermediate shell. Native arguments such
as --version remain single command values. A host command can declare
CommandParameter metadata: non-negative positions opt parameters into
positional binding, while all declared parameters support case-insensitive
names, aliases, and unambiguous prefixes. Parameters declared mandatory must be
supplied before the handler can run. Switches bind to true; unknown, ambiguous,
duplicate, missing-value, and excess-positional arguments are diagnosed before
execution. Handlers receive both source-ordered arguments and a
case-insensitive CommandContext.BoundParameters map. A host can attach
CommandParameterSet entries and a default set during registration; incompatible
or unresolved sets prevent handler execution, and the selected name is exposed
as CommandContext.ParameterSetName. Values bound to typed
host parameters are converted before the handler runs. CommandParameter and
CommandParameterSet expose the same two pipeline-binding flags. Commands that
opt in are invoked once per upstream record, receive that record through
PipelineInput, and expose its converted bindings in BoundParameters; empty
input invokes no handler. Binding and handler errors remain on the separate
error output, cancellation is checked between records, and downstream early
termination stops upstream enumeration. Commands without pipeline-binding
metadata retain the existing single-handler lazy-stream contract. This slice covers
invariant scalar conversion, PowerShell-style Boolean truthiness,
case-insensitive enums, scalar-to-array wrapping and per-element array
conversion, nullable nulls and null-to-value-type defaults, and public
single-string constructors.
Hosts can attach a CommandParameterDefault and the same
ParameterValidationMetadata rules to CommandParameter. Static defaults are
converted and included in the handler's argument/map view only after supplied
values pass validation; defaults themselves do not select parameter sets or run
validation. Malformed host validation metadata is rejected during registration.
An invalid conversion raises a catchable statement-terminating binding error and
does not invoke the handler; outside try, its error is written and the next
statement runs. Per-record pipeline binding failures remain non-terminating;
native executable arguments are never converted. Evaluation sessions
carry a cancellation token; bytecode and interpreter loops observe it, native
waits terminate the child process tree, and Ctrl+C cancels the active REPL
submission without ending the session.
EvaluationSession.RegisterAsyncCommand() installs a bounded asynchronous
stage whose AsyncCommandContext.PipelineInput is an IAsyncEnumerable<object?>.
PipelineCapacity configures every bounded port; LastPipelineMetrics reports
stage identities, terminal states, peak occupancy, and live-stage count.
CancelActivePipelines() and WaitForPipelinesAsync() give REPL, job, and
remoting hosts deterministic shutdown primitives. Success, error, warning,
verbose, debug, information, and progress outputs remain distinct on
EvaluationResult; none are wrapped as success records.
Hosts that repeatedly execute a script can call Compilation.CompileBytecode()
once and reuse the returned CompiledScript. Its Evaluate() method performs
execution only; parsing, semantic analysis, binding, and lowering stay outside
the measured or repeated path. Numeric bytecode programs are translated once
to CLR IL by the first tier, while the portable VM remains the execution path
for dynamic values and instructions that have not yet been specialized.
Every first-class expression and statement produced by the parser has a
corresponding bound representation. Structurally recovered generic syntax is
the sole exception: it becomes an explicit bound error node with a diagnostic,
allowing later valid statements to remain available to compiler consumers.
Parameter default values, using directives, filter/workflow declarations,
function named blocks (dynamicparam, begin, process, end, and clean),
typed trap statements, and enum declarations are first-class syntax. Enum
members and parameter defaults retain their expressions through semantic and
bound analysis. Valid expression statements enter the expression parser
directly, so literal/parenthesized member calls and postfix mutation no longer
fall back to structural recovery.
dotnet build Pwr.slnx
dotnet test --solution Pwr.slnx
dotnet run --project src/Pwr.Cli -- --typed -c "let `$x: int = 6 * 7; `$x"Parse a script without binding or executing it:
dotnet run --project src/Pwr.Cli -- --parse-only <script.ps1>Lint a script without executing it (exit code 1 means a compiler error or
lint warning was found):
dotnet run --project src/Pwr.Cli -- --lint <script.ps1>The compiler-owned linter also runs in the language server. Its stable warning
codes are PWR2001 (assigned variable is never read), PWR2002 (unused
parameter), PWR2003 (unreachable statement), PWR2004 (invalid or duplicate
label declaration), PWR2005 (invalid labelled break/continue target), and
PWR2006 (empty catch block). Compilation.GetLintDiagnostics() exposes the
same rules to other hosts. Lint warnings never block normal compilation or
execution unless the CLI is explicitly invoked with --lint.
PWR requires the .NET 10 runtime. Install the published tool globally from NuGet with:
dotnet tool install --global Pwr.Cli
pwr --helpUpdate or remove it with dotnet tool update --global Pwr.Cli and
dotnet tool uninstall --global Pwr.Cli respectively.
To build and test the tool package locally:
dotnet pack src/Pwr.Cli -c Release -o artifacts/packages
dotnet tool install --global Pwr.Cli --add-source artifacts/packagesFor a repository-local installation, create a tool manifest and omit
--global from the install command. Run that installation with dotnet pwr.
Publish the ReadyToRun CLI, then compare process startup plus parser latency
against pwsh on the same script. Both sides parse and never execute it:
dotnet publish src/Pwr.Cli -c Release -r win-x64 --self-contained false -o artifacts/publish/win-x64
dotnet run --project benchmarks/Pwr.Benchmarks -c Release -- --pwr artifacts/publish/win-x64/pwr.exe --script <script.ps1> --warmup 3 --iterations 20 --require-fasterFor sustained parser profiling (excluding process startup and file I/O):
dotnet run --project benchmarks/Pwr.Benchmarks -c Release -- --profile-parser --script <script.ps1> --warmup 100 --iterations 10000Add --compare-pwsh to measure PowerShell's in-process Parser.ParseInput with
the same preloaded fixture, warmup count, iteration count, and allocation metric.
For a statistically rigorous in-process comparison between PWR and PowerShell's
System.Management.Automation parser with BenchmarkDotNet (the fixture is loaded
before timed operations):
dotnet run --project benchmarks/Pwr.Benchmarks -c Release -- --benchmark-parser --script <script.ps1>Profile complete document analysis, including PowerShell semantic enrichment but excluding startup and file I/O:
dotnet run --project benchmarks/Pwr.Benchmarks -c Release -- --profile-analysis --script <script.ps1> --warmup 20 --iterations 1000--max-allocated-bytes is optional and makes the probe fail when average managed
allocations per complete analysis exceed the supplied byte ceiling.
Measure compile-once bytecode execution against an equivalent pure-Python numeric loop (both measurements exclude process startup and compilation):
dotnet run --project benchmarks/Pwr.Benchmarks -c Release -- --profile-runtime --work 10000 --warmup 100 --iterations 1000 --require-fasterThis probe reports the portable numeric bytecode VM and the optional CLR-JIT
tier separately. --require-faster applies to the no-code-generation VM result,
not the CLR tier. It is a narrow integer-loop microbenchmark, not a claim about
general command, pipeline, string, or object-heavy workloads. Hosts can pass
BytecodeExecutionMode.VirtualMachine to CompiledScript.Evaluate() to force
portable execution; automatic mode uses CLR code generation only when the
program and its observable result types are supported exactly.
Measure synchronous fast-path and bounded asynchronous pipeline throughput at 1, 4, and N stages for cheap, conversion-heavy, and native streaming records:
dotnet run --project benchmarks/Pwr.Benchmarks -c Release -- --profile-pipeline --records 1000 --capacity 32 --stages 8 --warmup 10 --iterations 30The probe reports median/P95 completion latency, records per second, maximum
buffer occupancy, and managed bytes allocated per execution for each workload.
Native results include process startup and use pwsh as a cross-platform stdin/
stdout streaming fixture; they are not general command-performance claims.
The extension provides live parser diagnostics for typed .pwr files and
PowerShell .ps1, .psm1, and .psd1 files. Diagnostics preserve the parser's
UTF-8 byte spans while converting locations to the zero-based UTF-16
coordinates required by LSP.
PowerShell semantic enrichment uses a purpose-built lexer and parser shared by
the hashtable and function analyzers. Strings and comments are consumed as
atomic tokens, and malformed function bodies produce a contained PWR1002
structural-analysis diagnostic without disabling other language features.
Variable reads before a declaration or assignment produce PWR1003; later
assignments do not leak inferred types or completion items backward.
Calls to user-defined functions produce PWR1004 when a named argument's
statically known type is incompatible with the parameter's declared type.
Untyped param(...) declarations inside anonymous script blocks establish a
local parameter scope for variable diagnostics, including event handlers.
Variable hover and inlay hints use the compiler-owned, recoverable semantic
model across the parsed top-level executable slice. A reassignment can therefore
change a variable's
type for that occurrence and subsequent reads. User-defined PowerShell
functions also show an inferred return-type inlay hint after the function name.
Variables assigned an any result accumulate duck-typed properties as they are
read. Observations carry across sibling if/elseif conditions, so hovering a
JSON-derived variable shows every property observed up to and including the
current condition.
Functions with no success-stream output are shown as returning void; output
whose type cannot be established remains any and does not add a noisy hint.
Hovering a named argument at a user-defined function call shows the declared
parameter type and its .PARAMETER comment-based help description, when one is
available. Named arguments on nested built-in commands also use the generated
command catalog for parameter type hover, including switches.
After a regular-expression match operator, indexed $Matches captures are
shown as strings across the complete expression, such as $Matches[1].
String-returning .Trim() chains preserve that type for method hover and
function return inference. Assigning such a chain to a function-local variable
also preserves string for the assignment and subsequent reads.
Foreach loop variables inherit known collection element types and show an
inline hint after the declaration, such as $raw : string for [string[]]$Lines.
Go to Definition navigates user-defined function calls, named arguments,
parameters, foreach variables, and function-local reads within the document.
Find References and Rename use the same compiler-owned symbol occurrences, so
declarations, reads, writes, and named arguments are updated together without
renaming unrelated text. Document Symbols lists declared functions, parameters,
and variables; Workspace Symbols searches those declarations across documents
currently open in the language server.
Auto-complete suggests in-scope variables, PowerShell automatic variables,
user-defined functions, built-in commands and aliases, and language keywords.
Variable completion is triggered explicitly after $; command-name completion
also triggers after - and includes command syntax and synopsis details.
After a user-defined function name, completion suggests its named parameters
with their declared types, including partial flag filtering such as -V.
Get-ChildItem -File infers FileInfo output, while @(...) promotes it to
FileInfo[]; function-local foreach variables then inherit FileInfo.
Empty hashtables grow structural types through indexed assignments. Guarded
entries remain optional, so [string] assignments appear as Key?: string.
Hashtable parameter shapes are also inferred conservatively from user-defined
function calls. An indexed shape such as $vars['Name'] = [string]$value
therefore enriches a later -Vars $vars call without changing the earlier
$vars = @{} occurrence from its flow-sensitive hashtable type. Known shapes
flow through forwarded parameters, indexed array
arguments, foreach variables, and ForEach-Object's $_; member hover marks
properties absent from some known callers as optional possible properties.
For List[object] plans, structural element types are recovered from
.Add([pscustomobject]@{ ... }) mutations and member arguments such as
-Step $item.Step retain the nested hashtable shape. Document-scope foreach
variables use this same element resolver for variable and member hover, Go to
Definition, and inlay hints.
Script and function bodies share one scope-analysis pass. Assignments inside
if, while, try, and similar control-flow blocks therefore use the same
constructor, collection, variable, and member inference without separate
top-level implementations.
Script-level [CmdletBinding()] param(...) declarations seed that same scope;
typed parameters, including [switch], provide hover and Go to Definition.
Function hover renders signatures with name-first type annotations, for example
function Invoke-SqlFile($Server: string, $Path: string, $Audit: hashtable): any.
Parentheses inside parameter comments do not truncate parameter discovery.
Within functions, @{} infers hashtable, while [ordered]@{} infers the
runtime OrderedDictionary type and retains its imported .NET members.
Runtime .NET metadata supplies types and members for static constructors,
including generic element types such as List[string] and its Add(string)
method from [System.Collections.Generic.List[string]]::new().
Static constructors at document scope use the same inference, so a top-level
[System.Collections.Generic.List[object]]::new() is shown as List[object].
New-Object positional and -TypeName forms use the same importer, allowing
subsequent method calls to propagate their reflected return types.
Static .NET calls receive the same treatment, so assignments such as
[System.IO.Path]::GetDirectoryName($Step.Path) infer string and provide
method hover from runtime metadata.
Nested member chains resolve each receiver from left to right. For example,
$sw.Elapsed.TotalSeconds imports Stopwatch.Elapsed as TimeSpan, then
imports TimeSpan.TotalSeconds as double for hover and expression inference.
ForEach-Object method projections also propagate imported return types and
promote them to arrays; for example, projecting DbDataReader.GetName() over a
range infers string[].
Imported member hover loads matching XML documentation lazily from adjacent
files, the NuGet package cache, or installed .NET reference packs. Documentation
is cached per assembly, keeping XML parsing out of document analysis and making
subsequent hover lookups memory-only.
Signature Help reports user-defined and built-in command syntax and tracks the
active named parameter. Semantic Tokens classify keywords, variables,
functions, properties, strings, comments, and numbers. Folding Ranges follow
multi-line script blocks. Document Formatting conservatively normalizes line
endings, removes trailing whitespace, and adds a final newline; the same edit
is available as a source.fixAll.pwr Code Action. Call Hierarchy prepares
user-defined functions and reports calls across currently open documents.
Built-in command hover uses a generated baseline catalog containing cmdlets, functions, and aliases from PowerShell's shipped modules. The catalog records command kind, module, output type, synopsis, and alias target. It is also the machine-readable compatibility ledger for replacing each command with a native PWR implementation over time. Refresh it against an installed PowerShell with:
pwsh -NoLogo -NoProfile -File scripts/Generate-BuiltInCommandCatalog.ps1Build and package the extension:
Set-Location editors/vscode
npm install
npm run build
npm run packageInstall the resulting VSIX with Extensions: Install from VSIX. The bundled
server requires the .NET 10 runtime. For a self-contained build, publish
src/Pwr.LanguageServer for the target platform and set
pwr.languageServer.path to the resulting executable.
For extension development, build once and launch VS Code with:
code editors/vscodeThen press F5 and select Run PWR LSP Extension. Its pre-launch task rebuilds
both the client and server and opens the included .pwr test fixture.