Skip to content

Repository files navigation

PWR

PowerShell compatibility, without the wait.

PWR is an experimental, performance-first implementation of the PowerShell Core command-line interface and language runtime.

Direction

  • Preserve pwsh compatibility 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.

Architecture

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 — the pwr command 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.

Typed PWR

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
$answer

See the language design for the compatibility rules and proposed type-system direction.

Current executable slice

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.

Current runtime limits

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.

Modules and session scopes

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.

Install as a .NET tool

PWR requires the .NET 10 runtime. Install the published tool globally from NuGet with:

dotnet tool install --global Pwr.Cli
pwr --help

Update 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/packages

For 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-faster

For 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 10000

Add --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-faster

This 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 30

The 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.

VS Code language server

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.ps1

Build and package the extension:

Set-Location editors/vscode
npm install
npm run build
npm run package

Install 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/vscode

Then 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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages