diff --git a/.clang-format b/.clang-format
new file mode 100644
index 000000000..ed5e6cc42
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,3 @@
+BasedOnStyle: LLVM
+IndentWidth: 4
+ColumnLimit: 120
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 000000000..101687337
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "csharpier": {
+ "version": "1.3.0",
+ "commands": ["csharpier"],
+ "rollForward": false
+ }
+ }
+}
diff --git a/.dprint.jsonc b/.dprint.jsonc
new file mode 100644
index 000000000..167ac8326
--- /dev/null
+++ b/.dprint.jsonc
@@ -0,0 +1,94 @@
+{
+ "$schema": "https://dprint.dev/schemas/v0.json",
+ "lineWidth": 120,
+ "useTabs": false,
+ "newLineKind": "lf",
+ "includes": [
+ ".dprint.jsonc",
+ ".config/dotnet-tools.json",
+ "mise.toml",
+ ".github/workflows/formatter-*.yml",
+ "Formatter/**/*.cs",
+ "Formatter/**/*.csproj",
+ "Formatter/**/*.json",
+ "Formatter/**/*.md",
+ "Formatter/**/*.mjs",
+ "Formatter/**/*.ps1",
+ "Formatter/**/*.sh",
+ "Formatter/**/*.ts",
+ "docs/FormatterWasm.md"
+ ],
+ "powershell": {
+ "braceStyle": "nextLine",
+ "indentSize": 4,
+ "useTabs": false,
+ "correctKeywordCasing": true,
+ "spaceAroundOperators": true,
+ "spaceAroundPipe": true,
+ "spaceAfterSeparator": true
+ },
+ "json": {
+ "useTabs": false,
+ "indentWidth": 2
+ },
+ "markdown": {
+ "textWrap": "maintain",
+ "emphasisKind": "asterisks"
+ },
+ "typescript": {
+ "useTabs": false,
+ "indentWidth": 2,
+ "quoteStyle": "preferDouble"
+ },
+ "yaml": {
+ "indentWidth": 2,
+ "printWidth": 160
+ },
+ "shfmt": {
+ "useTabs": false,
+ "binaryNextLine": true,
+ "switchCaseIndent": true
+ },
+ "exec": {
+ "cwd": "${configDir}",
+ "lineWidth": 120,
+ "indentWidth": 4,
+ "useTabs": false,
+ "timeout": 120,
+ "commands": [
+ {
+ "command": "mise exec -- dotnet csharpier format --stdin-path {{cwd}}/{{file_path}} --log-level None",
+ "exts": ["cs", "csproj", "props", "targets", "resx", "ps1xml"],
+ "setupCommand": "mise exec -- dotnet tool restore --add-source https://api.nuget.org/v3/index.json --ignore-failed-sources",
+ "cacheKeyFiles": [".config/dotnet-tools.json"]
+ },
+ {
+ "command": "mise exec -- clang-format --assume-filename {{file_path}}",
+ "exts": ["c", "h"],
+ "cacheKeyFiles": [".clang-format", "mise.toml"]
+ },
+ {
+ "command": "mise exec -- tombi format - --stdin-filename {{file_path}}",
+ "exts": ["toml"],
+ "cacheKeyFiles": ["mise.toml"]
+ }
+ ]
+ },
+ "excludes": [
+ "**/{bin,obj,out}/**",
+ "**/node_modules/**",
+ "PSCompatibilityCollector/{optional_profiles,profiles}/**",
+ "Formatter/Dprint/native/**",
+ "Formatter/Dprint/schema.json",
+ "Formatter/Dprint/tests/**"
+ ],
+ "plugins": [
+ "Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm",
+ "https://plugins.dprint.dev/kjanat/shfmt-1.0.0.wasm",
+ "https://plugins.dprint.dev/json-0.23.0.wasm",
+ "https://plugins.dprint.dev/markdown-0.22.1.wasm",
+ "https://plugins.dprint.dev/typescript-0.96.1.wasm",
+ "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm",
+ "https://plugins.dprint.dev/exec-0.7.3.json@a7898d5f1897e77bff474cec3d948c3ec3a7f455e32de2cc60c8adb9a5dd24aa"
+ ]
+}
diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml
index 0a48b30fb..aeb671be9 100644
--- a/.github/workflows/ci-test.yml
+++ b/.github/workflows/ci-test.yml
@@ -19,10 +19,18 @@ jobs:
DOTNET_GENERATE_ASPNET_CERTIFICATE: false
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
+
+ - name: Set up WASI SDK
+ if: matrix.os == 'ubuntu-latest'
+ uses: jdx/mise-action@v4
+ with:
+ install: true
+ install_args: github:WebAssembly/wasi-sdk
+ export_path: false
- name: Install dotnet
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@v6
with:
cache: true
cache-dependency-path: '**/*.csproj'
@@ -31,6 +39,24 @@ jobs:
run: ./tools/installPSResources.ps1
shell: pwsh
+ - name: Install formatter workloads
+ run: |
+ $workloads = @("wasm-tools")
+ if ($IsLinux) {
+ $workloads += "wasi-experimental"
+ }
+ $arguments = @("workload", "install") + $workloads + @(
+ "--skip-manifest-update",
+ "--source", "https://api.nuget.org/v3/index.json"
+ )
+ & dotnet @arguments
+ if ($LASTEXITCODE -ne 0) {
+ $message = "Installing the formatter workloads failed with exit code $LASTEXITCODE."
+ "::error::$message"
+ throw $message
+ }
+ shell: pwsh
+
- name: Build
run: ./build.ps1 -Configuration Release -All -Verbose
shell: pwsh
@@ -46,12 +72,12 @@ jobs:
- name: Test Windows PowerShell
if: matrix.os == 'windows-latest'
run: |
- Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck
+ Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force -SkipPublisherCheck
./build.ps1 -Test -Verbose
shell: powershell
- name: Download PowerShell install script
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
repository: PowerShell/PowerShell
path: pwsh
@@ -70,14 +96,14 @@ jobs:
shell: pwsh
- name: Upload build artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
if: always()
with:
name: PSScriptAnalyzer-package-${{ matrix.os }}
path: out/**/*.nupkg
- name: Upload test results
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
if: always()
with:
name: PSScriptAnalyzer-tests-${{ matrix.os }}
diff --git a/.github/workflows/formatter-npm-release.yml b/.github/workflows/formatter-npm-release.yml
new file mode 100644
index 000000000..7788bc1ed
--- /dev/null
+++ b/.github/workflows/formatter-npm-release.yml
@@ -0,0 +1,114 @@
+name: Release PowerShell formatter npm package
+on: { push: { tags: ["npm-[0-9]+.[0-9]+.[0-9]+"] } }
+permissions: { contents: write }
+defaults: { run: { shell: pwsh } }
+jobs:
+ release:
+ if: github.repository == 'kjanat/PSScriptAnalyzer'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ - name: Set up mise and install tools
+ uses: jdx/mise-action@v4
+ with: { install: true }
+ - name: Verify release version
+ working-directory: Formatter/Wasm
+ run: |
+ $package = Get-Content -LiteralPath package.json -Raw | ConvertFrom-Json
+ $packageVersion = [string] $package.version
+ $expectedTag = "npm-$packageVersion"
+ if ($env:GITHUB_REF_NAME -cne $expectedTag) {
+ $message = "Tag {0} does not match npm package tag {1}." -f $env:GITHUB_REF_NAME, $expectedTag
+ "::error::$message"
+ throw $message
+ }
+ Add-Content -LiteralPath $env:GITHUB_ENV -Value "NPM_PACKAGE_VERSION=$packageVersion"
+ - name: Install .NET WebAssembly workload
+ run: |
+ $arguments = @(
+ "workload", "install", "wasm-tools",
+ "--skip-manifest-update",
+ "--source", "https://api.nuget.org/v3/index.json"
+ )
+ & dotnet @arguments
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Installing the .NET WebAssembly workload failed with exit code $LASTEXITCODE."
+ throw "Installing the .NET WebAssembly workload failed with exit code $LASTEXITCODE."
+ }
+ - name: Build browser and Node.js package
+ run: |
+ $arguments = @(
+ "publish", "Formatter/Wasm/Formatter.Wasm.csproj",
+ "--configuration", "Release",
+ "--source", "https://api.nuget.org/v3/index.json"
+ )
+ & dotnet @arguments
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Publishing the browser and Node.js package failed with exit code $LASTEXITCODE."
+ throw "Publishing the browser and Node.js package failed with exit code $LASTEXITCODE."
+ }
+ - name: Validate Node.js package
+ run: |
+ Formatter/Wasm/scripts/Test-Package.ps1 `
+ -PackageDirectory Formatter/Wasm/bin/Release/net8.0/browser-wasm/AppBundle
+ - name: Assemble npm release assets
+ run: |
+ $packageDirectory = Join-Path $PWD "Formatter/Wasm/bin/Release/net8.0/browser-wasm/AppBundle"
+ $releaseDirectory = Join-Path $env:RUNNER_TEMP "npm-release"
+ $null = New-Item -ItemType Directory -Path $releaseDirectory -Force
+
+ & npm pack $packageDirectory --pack-destination $releaseDirectory
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Packing the npm package failed with exit code $LASTEXITCODE."
+ throw "Packing the npm package failed with exit code $LASTEXITCODE."
+ }
+
+ $tarballs = @(Get-ChildItem -LiteralPath $releaseDirectory -Filter *.tgz)
+ if ($tarballs.Count -ne 1) {
+ "::error::Expected one npm tarball, got $($tarballs.Count)."
+ throw "Expected one npm tarball, got $($tarballs.Count)."
+ }
+
+ $entries = @(& tar -tzf $tarballs[0].FullName)
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Reading the npm tarball failed with exit code $LASTEXITCODE."
+ throw "Reading the npm tarball failed with exit code $LASTEXITCODE."
+ }
+ foreach ($requiredEntry in "package/LICENSE", "package/index.d.ts", "package/index.mjs") {
+ if ($requiredEntry -cnotin $entries) {
+ "::error::The npm tarball is missing $requiredEntry."
+ throw "The npm tarball is missing $requiredEntry."
+ }
+ }
+ if (-not ($entries -cmatch '^package/_framework/.*\.wasm$')) {
+ "::error::The npm tarball does not contain a WebAssembly runtime file."
+ throw "The npm tarball does not contain a WebAssembly runtime file."
+ }
+
+ $checksumsPath = Join-Path $releaseDirectory "checksums.txt"
+ $hash = (Get-FileHash -LiteralPath $tarballs[0].FullName -Algorithm SHA256).Hash.ToLowerInvariant()
+ Set-Content -LiteralPath $checksumsPath -Value "$hash $($tarballs[0].Name)"
+ Add-Content -LiteralPath $env:GITHUB_ENV -Value "NPM_TARBALL=$($tarballs[0].FullName)"
+ Add-Content -LiteralPath $env:GITHUB_ENV -Value "NPM_CHECKSUMS=$checksumsPath"
+ - name: Publish GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $assets = @(
+ "$($env:NPM_TARBALL)#PowerShell formatter for WebAssembly $($env:NPM_PACKAGE_VERSION), npm package",
+ "$($env:NPM_CHECKSUMS)#PowerShell formatter for WebAssembly $($env:NPM_PACKAGE_VERSION), SHA-256 checksums"
+ )
+ $arguments = @("release", "create", $env:GITHUB_REF_NAME) + $assets + @(
+ "--repo", $env:GITHUB_REPOSITORY,
+ "--verify-tag",
+ "--prerelease",
+ "--latest=false",
+ "--title", "PowerShell formatter for WebAssembly $($env:NPM_PACKAGE_VERSION)",
+ "--notes-file", "Formatter/Wasm/release-notes.md"
+ )
+ & gh @arguments
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Creating the GitHub release failed with exit code $LASTEXITCODE."
+ throw "Creating the GitHub release failed with exit code $LASTEXITCODE."
+ }
diff --git a/.github/workflows/formatter-wasm-release.yml b/.github/workflows/formatter-wasm-release.yml
new file mode 100644
index 000000000..9f72ff281
--- /dev/null
+++ b/.github/workflows/formatter-wasm-release.yml
@@ -0,0 +1,95 @@
+name: Release dprint PowerShell formatter
+on: { push: { tags: ["dprint-[0-9]+.[0-9]+.[0-9]+"] } }
+permissions: { contents: write }
+defaults: { run: { shell: pwsh } }
+jobs:
+ release:
+ if: github.repository == 'kjanat/PSScriptAnalyzer'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ - name: Set up mise and install tools
+ uses: jdx/mise-action@v4
+ with:
+ install: true
+ - name: Verify release version
+ run: |
+ $version = (& dotnet msbuild Formatter/Dprint/Formatter.Dprint.csproj `
+ -nologo `
+ -getProperty:Version).Trim()
+ if ($LASTEXITCODE -ne 0) {
+ $message = "Reading the dprint plugin version failed with exit code $LASTEXITCODE."
+ "::error::$message"
+ throw $message
+ }
+ $expectedTag = "dprint-$version"
+ if ($env:GITHUB_REF_NAME -cne $expectedTag) {
+ $message = "Tag {0} does not match dprint plugin tag {1}." -f $env:GITHUB_REF_NAME, $expectedTag
+ "::error::$message"
+ throw $message
+ }
+ Add-Content -LiteralPath $env:GITHUB_ENV -Value "DPRINT_PLUGIN_VERSION=$version"
+ - name: Install .NET WASI workload
+ run: |
+ $arguments = @(
+ "workload", "install", "wasi-experimental",
+ "--skip-manifest-update",
+ "--source", "https://api.nuget.org/v3/index.json"
+ )
+ & dotnet @arguments
+ if ($LASTEXITCODE -ne 0) {
+ $message = "Installing the .NET WASI workload failed with exit code $LASTEXITCODE."
+ "::error::$message"
+ throw $message
+ }
+ - name: Build and validate plugin
+ run: |
+ & ./Formatter/Dprint/scripts/e2e.sh
+ if ($LASTEXITCODE -ne 0) {
+ $message = "Building and validating the dprint plugin failed with exit code $LASTEXITCODE."
+ "::error::$message"
+ throw $message
+ }
+ - name: Assemble release assets
+ run: |
+ $releaseDirectory = Join-Path $env:RUNNER_TEMP "dprint-release"
+ $null = New-Item -ItemType Directory -Path $releaseDirectory -Force
+ $assets = @(
+ @{ Source = "Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm"; Name = "plugin.wasm" },
+ @{ Source = "Formatter/Dprint/schema.json"; Name = "schema.json" },
+ @{ Source = "Formatter/Dprint/LICENSE"; Name = "LICENSE" }
+ )
+ foreach ($asset in $assets) {
+ Copy-Item -LiteralPath $asset.Source -Destination (Join-Path $releaseDirectory $asset.Name)
+ }
+ $checksums = foreach ($asset in $assets) {
+ $path = Join-Path $releaseDirectory $asset.Name
+ $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
+ "$hash $($asset.Name)"
+ }
+ Set-Content -LiteralPath (Join-Path $releaseDirectory "checksums.txt") -Value $checksums
+ - name: Publish GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $releaseDirectory = Join-Path $env:RUNNER_TEMP "dprint-release"
+ $assets = @(
+ "$(Join-Path $releaseDirectory 'plugin.wasm')#dprint PowerShell formatter $($env:DPRINT_PLUGIN_VERSION), WebAssembly",
+ "$(Join-Path $releaseDirectory 'schema.json')#dprint PowerShell formatter $($env:DPRINT_PLUGIN_VERSION), configuration schema",
+ "$(Join-Path $releaseDirectory 'LICENSE')#dprint PowerShell formatter $($env:DPRINT_PLUGIN_VERSION), MIT license",
+ "$(Join-Path $releaseDirectory 'checksums.txt')#dprint PowerShell formatter $($env:DPRINT_PLUGIN_VERSION), SHA-256 checksums"
+ )
+ $arguments = @("release", "create", $env:GITHUB_REF_NAME) + $assets + @(
+ "--repo", $env:GITHUB_REPOSITORY,
+ "--verify-tag",
+ "--latest",
+ "--title", "dprint PowerShell formatter $($env:DPRINT_PLUGIN_VERSION)",
+ "--notes-file", "Formatter/Dprint/release-notes.md"
+ )
+ & gh @arguments
+ if ($LASTEXITCODE -ne 0) {
+ $message = "Creating the dprint GitHub release failed with exit code $LASTEXITCODE."
+ "::error::$message"
+ throw $message
+ }
diff --git a/.github/workflows/formatter-wasm.yml b/.github/workflows/formatter-wasm.yml
new file mode 100644
index 000000000..d7fc2b629
--- /dev/null
+++ b/.github/workflows/formatter-wasm.yml
@@ -0,0 +1,130 @@
+name: Formatter WebAssembly
+
+on:
+ pull_request:
+ paths:
+ - Formatter/**
+ - mise.toml
+ - .github/workflows/formatter-wasm.yml
+ push:
+ branches: [main]
+ paths:
+ - Formatter/**
+ - mise.toml
+ - .github/workflows/formatter-wasm.yml
+ workflow_dispatch: null
+
+permissions: { contents: read }
+defaults: { run: { shell: pwsh } }
+jobs:
+ dprint-plugin:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: jdx/mise-action@v4
+ - name: Install .NET WASI workload
+ run: |
+ $arguments = @(
+ "workload", "install", "wasi-experimental",
+ "--skip-manifest-update",
+ "--source", "https://api.nuget.org/v3/index.json"
+ )
+ & dotnet @arguments
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Installing the .NET WASI workload failed with exit code $LASTEXITCODE."
+ throw "Installing the .NET WASI workload failed with exit code $LASTEXITCODE."
+ }
+ - name: Build and validate plugin
+ shell: bash
+ run: Formatter/Dprint/scripts/e2e.sh
+ - name: Read plugin version
+ id: metadata
+ run: |
+ $version = [string] (& dotnet msbuild Formatter/Dprint/Formatter.Dprint.csproj -nologo -getProperty:Version)
+ $version = $version.Trim()
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($version)) {
+ "::error::Reading the dprint plugin version failed with exit code $LASTEXITCODE."
+ throw "Reading the dprint plugin version failed with exit code $LASTEXITCODE."
+ }
+ Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "version=$version"
+ - name: Upload dprint plugin
+ uses: actions/upload-artifact@v7
+ with:
+ name: dprint-powershell-formatter-${{ steps.metadata.outputs.version }}
+ path: |
+ Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm
+ Formatter/Dprint/schema.json
+ Formatter/Dprint/LICENSE
+ if-no-files-found: error
+
+ browser-package:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: jdx/mise-action@v4
+ - name: Install .NET WebAssembly workload
+ run: |
+ $arguments = @(
+ "workload", "install", "wasm-tools",
+ "--skip-manifest-update",
+ "--source", "https://api.nuget.org/v3/index.json"
+ )
+ & dotnet @arguments
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Installing the .NET WebAssembly workload failed with exit code $LASTEXITCODE."
+ throw "Installing the .NET WebAssembly workload failed with exit code $LASTEXITCODE."
+ }
+ - name: Build browser and Node.js package
+ run: |
+ $arguments = @(
+ "publish", "Formatter/Wasm/Formatter.Wasm.csproj",
+ "--configuration", "Release",
+ "--source", "https://api.nuget.org/v3/index.json"
+ )
+ & dotnet @arguments
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Publishing the browser and Node.js package failed with exit code $LASTEXITCODE."
+ throw "Publishing the browser and Node.js package failed with exit code $LASTEXITCODE."
+ }
+ - name: Validate package
+ run: |
+ Formatter/Wasm/scripts/Test-Package.ps1 `
+ -PackageDirectory Formatter/Wasm/bin/Release/net8.0/browser-wasm/AppBundle
+ - name: Pack npm artifact
+ id: package
+ run: |
+ $packageDirectory = Join-Path $PWD "Formatter/Wasm/bin/Release/net8.0/browser-wasm/AppBundle"
+ $artifactDirectory = Join-Path $env:RUNNER_TEMP "npm-artifact"
+ $package = Get-Content -LiteralPath (Join-Path $packageDirectory "package.json") -Raw | ConvertFrom-Json
+ $version = [string] $package.version
+ $null = New-Item -ItemType Directory -Path $artifactDirectory -Force
+
+ & npm pack $packageDirectory --pack-destination $artifactDirectory
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Packing the npm artifact failed with exit code $LASTEXITCODE."
+ throw "Packing the npm artifact failed with exit code $LASTEXITCODE."
+ }
+
+ $tarballs = @(Get-ChildItem -LiteralPath $artifactDirectory -Filter *.tgz)
+ if ($tarballs.Count -ne 1) {
+ "::error::Expected one npm tarball, got $($tarballs.Count)."
+ throw "Expected one npm tarball, got $($tarballs.Count)."
+ }
+ $entries = @(& tar -tzf $tarballs[0].FullName)
+ if ($LASTEXITCODE -ne 0) {
+ "::error::Reading the npm tarball failed with exit code $LASTEXITCODE."
+ throw "Reading the npm tarball failed with exit code $LASTEXITCODE."
+ }
+ if (-not ($entries -cmatch '^package/_framework/.*\.wasm$')) {
+ "::error::The npm tarball does not contain a WebAssembly runtime file."
+ throw "The npm tarball does not contain a WebAssembly runtime file."
+ }
+
+ Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "version=$version"
+ Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "path=$($tarballs[0].FullName)"
+ - name: Upload npm artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: powershell-formatter-wasm-npm-${{ steps.package.outputs.version }}
+ path: ${{ steps.package.outputs.path }}
+ if-no-files-found: error
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bd8565ee8..567896fcb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,6 +3,7 @@
+
diff --git a/Formatter/Core.Tests/Formatter.Core.Tests.csproj b/Formatter/Core.Tests/Formatter.Core.Tests.csproj
new file mode 100644
index 000000000..375084d5e
--- /dev/null
+++ b/Formatter/Core.Tests/Formatter.Core.Tests.csproj
@@ -0,0 +1,12 @@
+
+
+ net8.0
+ Exe
+ enable
+ enable
+
+
+
+
+
+
diff --git a/Formatter/Core.Tests/Program.cs b/Formatter/Core.Tests/Program.cs
new file mode 100644
index 000000000..e09f6eeae
--- /dev/null
+++ b/Formatter/Core.Tests/Program.cs
@@ -0,0 +1,65 @@
+using Microsoft.PowerShell.ScriptAnalyzer.Formatter;
+
+var failures = new List();
+
+Check(
+ "default formatting",
+ "IF($x-EQ 1){'yes'}ELSE{'no'}",
+ "if($x -eq 1) {\n 'yes'\n} else {\n 'no'\n}"
+);
+
+Check(
+ "nested indentation",
+ "function Test {\nif ($true) {\nWrite-Output 'yes'\n}\n}",
+ "function Test {\n if ($true) {\n Write-Output 'yes'\n }\n}"
+);
+
+Check("hashtable remains inline", "$x=@{one=1;two=2}", "$x = @{one = 1; two = 2}");
+
+Check(
+ "next-line braces",
+ "if ($true) { 'yes' } else { 'no' }",
+ "if ($true)\n{\n 'yes'\n}\nelse\n{\n 'no'\n}",
+ new FormatterOptions { BraceStyle = BraceStyle.NextLine }
+);
+
+Check("unary operators", "$x=-1\n$y=!$false", "$x = -1\n$y = !$false");
+
+Check(
+ "multiline strings",
+ "if($true){\n$x=@'\n untouched\n'@\n}",
+ "if($true) {\n $x = @'\n untouched\n'@\n}"
+);
+
+Check(
+ "multiline parameter indentation",
+ "[CmdletBinding()]\nparam (\n[Parameter(Mandatory)]\n[string] $Path\n)",
+ "[CmdletBinding()]\nparam (\n [Parameter(Mandatory)]\n [string] $Path\n)"
+);
+
+var invalid = "if (";
+var invalidResult = PowerShellFormatter.Format(invalid);
+if (invalidResult.Text != invalid || invalidResult.Errors.Count == 0)
+{
+ failures.Add("parse errors must preserve input and return diagnostics");
+}
+
+if (failures.Count > 0)
+{
+ Console.Error.WriteLine(string.Join(Environment.NewLine, failures));
+ return 1;
+}
+
+Console.WriteLine("8 formatter checks passed");
+return 0;
+
+void Check(string name, string input, string expected, FormatterOptions? options = null)
+{
+ var result = PowerShellFormatter.Format(input, options);
+ if (result.Errors.Count > 0 || result.Text != expected)
+ {
+ failures.Add($"{name}: expected [{Escape(expected)}], got [{Escape(result.Text)}]");
+ }
+}
+
+static string Escape(string value) => value.Replace("\r", "\\r").Replace("\n", "\\n");
diff --git a/Formatter/Core/Formatter.Core.csproj b/Formatter/Core/Formatter.Core.csproj
new file mode 100644
index 000000000..d687266b9
--- /dev/null
+++ b/Formatter/Core/Formatter.Core.csproj
@@ -0,0 +1,13 @@
+
+
+ net8.0
+ Microsoft.PowerShell.ScriptAnalyzer.Formatter.Core
+ Microsoft.PowerShell.ScriptAnalyzer.Formatter
+ enable
+ enable
+
+
+
+
+
+
diff --git a/Formatter/Core/FormatterOptions.cs b/Formatter/Core/FormatterOptions.cs
new file mode 100644
index 000000000..5763d1d01
--- /dev/null
+++ b/Formatter/Core/FormatterOptions.cs
@@ -0,0 +1,41 @@
+namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter;
+
+///
+/// Controls the formatting passes applied by .
+///
+public sealed class FormatterOptions
+{
+ /// Gets or sets where script-block opening braces are placed.
+ public BraceStyle BraceStyle { get; set; } = BraceStyle.SameLine;
+
+ ///
+ /// Gets or sets the number of spaces in one indentation level. Valid values are 0 through 32.
+ /// This value is ignored when is .
+ ///
+ public int IndentSize { get; set; } = 4;
+
+ /// Gets or sets whether indentation levels use tabs instead of spaces.
+ public bool UseTabs { get; set; }
+
+ /// Gets or sets whether PowerShell keywords and operators are lowercased.
+ public bool CorrectKeywordCasing { get; set; } = true;
+
+ /// Gets or sets whether binary and assignment operators have surrounding spaces.
+ public bool SpaceAroundOperators { get; set; } = true;
+
+ /// Gets or sets whether pipeline and pipeline-chain operators have surrounding spaces.
+ public bool SpaceAroundPipe { get; set; } = true;
+
+ /// Gets or sets whether commas and semicolons are followed by a space.
+ public bool SpaceAfterSeparator { get; set; } = true;
+}
+
+/// Specifies the placement of script-block opening braces.
+public enum BraceStyle
+{
+ /// Place the opening brace on the same line as the preceding token.
+ SameLine,
+
+ /// Place the opening brace on the following line.
+ NextLine,
+}
diff --git a/Formatter/Core/FormatterResult.cs b/Formatter/Core/FormatterResult.cs
new file mode 100644
index 000000000..5bfa7bd0c
--- /dev/null
+++ b/Formatter/Core/FormatterResult.cs
@@ -0,0 +1,25 @@
+namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter;
+
+///
+/// Contains the formatted script and any PowerShell parser errors. When is
+/// nonempty, is the unchanged input.
+///
+/// The formatted script, or the original script when parsing failed.
+/// PowerShell parser errors reported for the input or formatted output.
+public sealed record FormatterResult(string Text, IReadOnlyList Errors);
+
+/// Describes a PowerShell parser error using offsets and one-based source coordinates.
+/// The human-readable parser message.
+/// The stable PowerShell parser error identifier.
+/// The zero-based start offset in the source string.
+/// The exclusive zero-based end offset in the source string.
+/// The one-based source line.
+/// The one-based source column.
+public sealed record FormatterParseError(
+ string Message,
+ string ErrorId,
+ int StartOffset,
+ int EndOffset,
+ int StartLine,
+ int StartColumn
+);
diff --git a/Formatter/Core/PowerShellFormatter.cs b/Formatter/Core/PowerShellFormatter.cs
new file mode 100644
index 000000000..8e7af15c1
--- /dev/null
+++ b/Formatter/Core/PowerShellFormatter.cs
@@ -0,0 +1,355 @@
+using System.Management.Automation.Language;
+using System.Text;
+
+namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter;
+
+/// Formats PowerShell source text without creating or using a PowerShell runspace.
+public static class PowerShellFormatter
+{
+ private const TokenFlags OperatorFlags =
+ TokenFlags.AssignmentOperator | TokenFlags.BinaryOperator;
+
+ /// Formats a complete PowerShell source string.
+ /// The PowerShell source text to format.
+ /// Formatting options, or for defaults.
+ ///
+ /// The formatted text and parser errors. Input containing parser errors is returned unchanged.
+ ///
+ /// is null.
+ ///
+ /// is outside the range 0 through 32.
+ ///
+ public static FormatterResult Format(string source, FormatterOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ options ??= new FormatterOptions();
+ if (options.IndentSize < 0 || options.IndentSize > 32)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(options),
+ "IndentSize must be between 0 and 32."
+ );
+ }
+
+ var (_, _, initialErrors) = Parse(source);
+ if (initialErrors.Length > 0)
+ {
+ return new FormatterResult(source, ToErrors(initialErrors));
+ }
+
+ var text = FormatBraces(source, options);
+ text = FormatWhitespace(text, options);
+ text = FormatIndentation(text, options);
+ if (options.CorrectKeywordCasing)
+ {
+ text = FormatCasing(text);
+ }
+
+ var (_, _, finalErrors) = Parse(text);
+ return new FormatterResult(text, ToErrors(finalErrors));
+ }
+
+ private static string FormatCasing(string source)
+ {
+ var (_, tokens, _) = Parse(source);
+ var edits = tokens
+ .Where(token =>
+ (token.TokenFlags & (TokenFlags.Keyword | OperatorFlags)) != 0
+ && token.Text.Any(char.IsUpper)
+ )
+ .Select(token => new TextEdit(
+ token.Extent.StartOffset,
+ token.Extent.EndOffset,
+ token.Text.ToLowerInvariant()
+ ));
+ return TextEdits.Apply(source, edits);
+ }
+
+ private static string FormatBraces(string source, FormatterOptions options)
+ {
+ var (ast, tokens, _) = Parse(source);
+ var newLine = DetectNewLine(source);
+ var hashtableBraces = ast.FindAll(
+ node => node is HashtableAst,
+ searchNestedScriptBlocks: true
+ )
+ .Cast()
+ .SelectMany(table => new[] { table.Extent.StartOffset, table.Extent.EndOffset - 1 })
+ .ToHashSet();
+ var edits = new List();
+
+ for (var index = 0; index < tokens.Length; index++)
+ {
+ var token = tokens[index];
+ if (
+ token.Kind == TokenKind.LCurly
+ && !hashtableBraces.Contains(token.Extent.StartOffset)
+ )
+ {
+ var previous = PreviousSignificant(tokens, index);
+ var next = NextSignificant(tokens, index);
+ if (previous is not null && previous.Kind != TokenKind.NewLine)
+ {
+ ReplaceWhitespaceBetween(
+ source,
+ previous,
+ token,
+ options.BraceStyle == BraceStyle.NextLine ? newLine : " ",
+ edits
+ );
+ }
+
+ if (
+ next is not null
+ && next.Kind != TokenKind.RCurly
+ && next.Kind != TokenKind.NewLine
+ )
+ {
+ ReplaceWhitespaceBetween(source, token, next, newLine, edits);
+ }
+ }
+ else if (
+ token.Kind == TokenKind.RCurly
+ && !hashtableBraces.Contains(token.Extent.StartOffset)
+ )
+ {
+ var previous = PreviousSignificant(tokens, index);
+ var next = NextSignificant(tokens, index);
+ if (
+ previous is not null
+ && previous.Kind is not (TokenKind.LCurly or TokenKind.NewLine)
+ )
+ {
+ ReplaceWhitespaceBetween(source, previous, token, newLine, edits);
+ }
+
+ if (
+ next is not null
+ && next.Kind != TokenKind.NewLine
+ && IsCuddledKeyword(next.Kind)
+ )
+ {
+ var separator = options.BraceStyle == BraceStyle.NextLine ? newLine : " ";
+ ReplaceWhitespaceBetween(source, token, next, separator, edits);
+ }
+ }
+ }
+
+ return TextEdits.Apply(source, edits);
+ }
+
+ private static string FormatWhitespace(string source, FormatterOptions options)
+ {
+ var (_, tokens, _) = Parse(source);
+ var edits = new List();
+ for (var index = 0; index < tokens.Length; index++)
+ {
+ var token = tokens[index];
+ if (options.SpaceAroundOperators && IsBinaryOrAssignmentOperator(token))
+ {
+ var previous = PreviousSignificant(tokens, index);
+ var next = NextSignificant(tokens, index);
+ if (previous is not null && previous.Kind != TokenKind.NewLine)
+ {
+ ReplaceWhitespaceBetween(source, previous, token, " ", edits);
+ }
+ if (next is not null && next.Kind != TokenKind.NewLine)
+ {
+ ReplaceWhitespaceBetween(source, token, next, " ", edits);
+ }
+ }
+ else if (
+ options.SpaceAroundPipe
+ && token.Kind is TokenKind.Pipe or TokenKind.AndAnd or TokenKind.OrOr
+ )
+ {
+ var previous = PreviousSignificant(tokens, index);
+ var next = NextSignificant(tokens, index);
+ if (previous is not null && previous.Kind != TokenKind.NewLine)
+ {
+ ReplaceWhitespaceBetween(source, previous, token, " ", edits);
+ }
+ if (next is not null && next.Kind != TokenKind.NewLine)
+ {
+ ReplaceWhitespaceBetween(source, token, next, " ", edits);
+ }
+ }
+ else if (options.SpaceAfterSeparator && token.Kind is TokenKind.Comma or TokenKind.Semi)
+ {
+ var next = NextSignificant(tokens, index);
+ if (next is not null && next.Kind != TokenKind.NewLine)
+ {
+ ReplaceWhitespaceBetween(source, token, next, " ", edits);
+ }
+ }
+ }
+
+ return TextEdits.Apply(source, edits);
+ }
+
+ private static string FormatIndentation(string source, FormatterOptions options)
+ {
+ var (_, tokens, _) = Parse(source);
+ var protectedLines = new HashSet();
+ foreach (
+ var token in tokens.Where(token =>
+ token.Kind != TokenKind.NewLine
+ && token.Extent.EndLineNumber > token.Extent.StartLineNumber
+ )
+ )
+ {
+ for (
+ var line = token.Extent.StartLineNumber + 1;
+ line <= token.Extent.EndLineNumber;
+ line++
+ )
+ {
+ protectedLines.Add(line);
+ }
+ }
+
+ var newLine = DetectNewLine(source);
+ var hasTerminalNewLine = source.EndsWith("\n", StringComparison.Ordinal);
+ var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
+ var tokensByLine = tokens
+ .Where(token => token.Kind is not (TokenKind.NewLine or TokenKind.EndOfInput))
+ .GroupBy(token => token.Extent.StartLineNumber)
+ .ToDictionary(
+ group => group.Key,
+ group => group.OrderBy(token => token.Extent.StartOffset).ToArray()
+ );
+ var depth = 0;
+
+ for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++)
+ {
+ var lineNumber = lineIndex + 1;
+ if (
+ !tokensByLine.TryGetValue(lineNumber, out var lineTokens)
+ || protectedLines.Contains(lineNumber)
+ )
+ {
+ continue;
+ }
+
+ var first = lineTokens[0];
+ var lineDepth = IsClosingDelimiter(first.Kind) ? Math.Max(0, depth - 1) : depth;
+ var content = lines[lineIndex].TrimStart(' ', '\t');
+ if (content.Length > 0)
+ {
+ lines[lineIndex] = MakeIndent(lineDepth, options) + content;
+ }
+
+ foreach (var token in lineTokens)
+ {
+ if (IsOpeningDelimiter(token.Kind))
+ {
+ depth++;
+ }
+ else if (IsClosingDelimiter(token.Kind))
+ {
+ depth = Math.Max(0, depth - 1);
+ }
+ }
+ }
+
+ var result = string.Join(newLine, lines);
+ if (hasTerminalNewLine && !result.EndsWith(newLine, StringComparison.Ordinal))
+ {
+ result += newLine;
+ }
+ return result;
+ }
+
+ private static bool IsBinaryOrAssignmentOperator(Token token) =>
+ (token.TokenFlags & OperatorFlags) != 0
+ && token.Kind is not (TokenKind.DotDot or TokenKind.PlusPlus or TokenKind.MinusMinus);
+
+ private static bool IsCuddledKeyword(TokenKind kind) =>
+ kind is TokenKind.Else or TokenKind.ElseIf or TokenKind.Catch or TokenKind.Finally;
+
+ private static bool IsOpeningDelimiter(TokenKind kind) =>
+ kind
+ is TokenKind.LCurly
+ or TokenKind.AtCurly
+ or TokenKind.LParen
+ or TokenKind.AtParen
+ or TokenKind.DollarParen
+ or TokenKind.LBracket;
+
+ private static bool IsClosingDelimiter(TokenKind kind) =>
+ kind is TokenKind.RCurly or TokenKind.RParen or TokenKind.RBracket;
+
+ private static Token? PreviousSignificant(Token[] tokens, int index)
+ {
+ for (var cursor = index - 1; cursor >= 0; cursor--)
+ {
+ if (tokens[cursor].Kind != TokenKind.Comment)
+ {
+ return tokens[cursor];
+ }
+ }
+ return null;
+ }
+
+ private static Token? NextSignificant(Token[] tokens, int index)
+ {
+ for (var cursor = index + 1; cursor < tokens.Length; cursor++)
+ {
+ if (
+ tokens[cursor].Kind != TokenKind.Comment
+ && tokens[cursor].Kind != TokenKind.EndOfInput
+ )
+ {
+ return tokens[cursor];
+ }
+ }
+ return null;
+ }
+
+ private static void ReplaceWhitespaceBetween(
+ string source,
+ Token left,
+ Token right,
+ string replacement,
+ ICollection edits
+ )
+ {
+ var start = left.Extent.EndOffset;
+ var end = right.Extent.StartOffset;
+ if (end < start)
+ {
+ return;
+ }
+
+ var current = source[start..end];
+ if (current.Any(character => !char.IsWhiteSpace(character)) || current == replacement)
+ {
+ return;
+ }
+ edits.Add(new TextEdit(start, end, replacement));
+ }
+
+ private static string MakeIndent(int depth, FormatterOptions options) =>
+ options.UseTabs ? new string('\t', depth) : new string(' ', depth * options.IndentSize);
+
+ private static string DetectNewLine(string source) =>
+ source.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
+
+ private static (ScriptBlockAst Ast, Token[] Tokens, ParseError[] Errors) Parse(string source)
+ {
+ var ast = Parser.ParseInput(source, out var tokens, out var errors);
+ return (ast, tokens, errors);
+ }
+
+ private static IReadOnlyList ToErrors(ParseError[] errors) =>
+ errors
+ .Select(error => new FormatterParseError(
+ error.Message,
+ error.ErrorId,
+ error.Extent.StartOffset,
+ error.Extent.EndOffset,
+ error.Extent.StartLineNumber,
+ error.Extent.StartColumnNumber
+ ))
+ .ToArray();
+}
diff --git a/Formatter/Core/TextEdit.cs b/Formatter/Core/TextEdit.cs
new file mode 100644
index 000000000..5e64342d9
--- /dev/null
+++ b/Formatter/Core/TextEdit.cs
@@ -0,0 +1,34 @@
+namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter;
+
+internal readonly record struct TextEdit(int Start, int End, string Text);
+
+internal static class TextEdits
+{
+ public static string Apply(string source, IEnumerable edits)
+ {
+ var ordered = edits
+ .Where(edit => edit.Start >= 0 && edit.End >= edit.Start && edit.End <= source.Length)
+ .Distinct()
+ .OrderByDescending(edit => edit.Start)
+ .ThenByDescending(edit => edit.End)
+ .ToArray();
+
+ var previousStart = source.Length;
+ foreach (var edit in ordered)
+ {
+ if (edit.End > previousStart)
+ {
+ continue;
+ }
+
+ source = string.Concat(
+ source.AsSpan(0, edit.Start),
+ edit.Text,
+ source.AsSpan(edit.End)
+ );
+ previousStart = edit.Start;
+ }
+
+ return source;
+ }
+}
diff --git a/Formatter/Dprint/Formatter.Dprint.csproj b/Formatter/Dprint/Formatter.Dprint.csproj
new file mode 100644
index 000000000..94eee95c5
--- /dev/null
+++ b/Formatter/Dprint/Formatter.Dprint.csproj
@@ -0,0 +1,88 @@
+
+
+ net8.0
+ plugin
+ 0.1.1
+ https://github.com/kjanat/PSScriptAnalyzer
+ wasi-wasm
+ Exe
+ true
+ true
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_WasmAssembliesInternal
+ Include="$(PkgMicrosoft_Management_Infrastructure_Runtime_Unix)/runtimes/unix/lib/netstandard1.6/Microsoft.Management.Infrastructure.dll"
+ WasmRole="assembly"
+ />
+
+
+
+
+
+ <_WasiObjectFilesForBundle Include="$(MSBuildProjectDirectory)/native/dprint_exports.c" />
+ <_WasiObjectFilesForBundle Include="$(MSBuildProjectDirectory)/native/wasi_stubs.c" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_sock_accept" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_args_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_args_sizes_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_environ_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_environ_sizes_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_clock_res_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_clock_time_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_advise" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_close" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_fdstat_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_fdstat_set_flags" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_filestat_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_filestat_set_size" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_filestat_set_times" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_pread" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_pwrite" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_prestat_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_prestat_dir_name" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_read" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_readdir" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_seek" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_tell" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_sync" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_fd_write" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_filestat_get" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_create_directory" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_filestat_set_times" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_link" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_open" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_readlink" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_remove_directory" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_rename" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_path_unlink_file" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_poll_oneoff" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_proc_exit" />
+ <_WasiSdkLinkerFlags Include="--wrap=__wasi_random_get" />
+
+
+
diff --git a/Formatter/Dprint/LICENSE b/Formatter/Dprint/LICENSE
new file mode 100644
index 000000000..4718a6efb
--- /dev/null
+++ b/Formatter/Dprint/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) Microsoft Corporation.
+Copyright (c) Kaj Kowalski.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE
diff --git a/Formatter/Dprint/Program.cs b/Formatter/Dprint/Program.cs
new file mode 100644
index 000000000..c6c21e594
--- /dev/null
+++ b/Formatter/Dprint/Program.cs
@@ -0,0 +1,328 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Microsoft.Management.Infrastructure;
+using Microsoft.PowerShell.ScriptAnalyzer.Formatter;
+
+namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter.Dprint;
+
+public static class Program
+{
+ // PowerShell's parser registers these CIM type accelerators through reflection.
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CimInstance))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CimClass))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CimType))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CimConverter))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(Plugin))]
+ public static void Main() { }
+}
+
+public static class Plugin
+{
+ private const string RepositoryPath = "kjanat/PSScriptAnalyzer";
+ private const string RepositoryUrl = $"https://github.com/{RepositoryPath}";
+ private static readonly string Version = GetVersion();
+ private static readonly string ConfigSchemaUrl =
+ $"https://plugins.dprint.dev/{RepositoryPath}/{Version}/schema.json";
+
+ private static readonly HashSet KnownProperties =
+ [
+ "braceStyle",
+ "indentSize",
+ "useTabs",
+ "correctKeywordCasing",
+ "spaceAroundOperators",
+ "spaceAroundPipe",
+ "spaceAfterSeparator",
+ ];
+
+ public static string Format(string source, string configJson, string overrideConfigJson)
+ {
+ var result = PowerShellFormatter.Format(
+ source,
+ ParseOptions(configJson, overrideConfigJson)
+ );
+ return result.Text;
+ }
+
+ public static string GetConfigDiagnostics(string configJson)
+ {
+ var diagnostics = new List<(string PropertyName, string Message)>();
+ try
+ {
+ using var document = JsonDocument.Parse(configJson);
+ if (
+ document.RootElement.TryGetProperty("plugin", out var plugin)
+ && plugin.ValueKind == JsonValueKind.Object
+ )
+ {
+ foreach (var property in plugin.EnumerateObject())
+ {
+ if (!KnownProperties.Contains(property.Name))
+ {
+ diagnostics.Add((property.Name, "Unknown property."));
+ }
+ }
+
+ ValidateStringChoice(plugin, "braceStyle", ["sameLine", "nextLine"], diagnostics);
+ ValidateInteger(plugin, "indentSize", 0, 32, diagnostics);
+ ValidateBoolean(plugin, "useTabs", diagnostics);
+ ValidateBoolean(plugin, "correctKeywordCasing", diagnostics);
+ ValidateBoolean(plugin, "spaceAroundOperators", diagnostics);
+ ValidateBoolean(plugin, "spaceAroundPipe", diagnostics);
+ ValidateBoolean(plugin, "spaceAfterSeparator", diagnostics);
+ }
+ }
+ catch (JsonException exception)
+ {
+ diagnostics.Add(("", exception.Message));
+ }
+
+ return SerializeDiagnostics(diagnostics);
+ }
+
+ public static string GetResolvedConfig(string configJson)
+ {
+ var options = ParseOptions(configJson, "");
+ var braceStyle = options.BraceStyle == BraceStyle.NextLine ? "nextLine" : "sameLine";
+ return $$"""
+ {"braceStyle":"{{braceStyle}}","indentSize":{{options.IndentSize}},"useTabs":{{Boolean(
+ options.UseTabs
+ )}},"correctKeywordCasing":{{Boolean(
+ options.CorrectKeywordCasing
+ )}},"spaceAroundOperators":{{Boolean(
+ options.SpaceAroundOperators
+ )}},"spaceAroundPipe":{{Boolean(
+ options.SpaceAroundPipe
+ )}},"spaceAfterSeparator":{{Boolean(options.SpaceAfterSeparator)}}}
+ """;
+ }
+
+ public static string GetPluginInfo() =>
+ $$"""
+ {"name":"dprint-plugin-powershell","version":"{{Version}}","configKey":"powershell","helpUrl":"{{RepositoryUrl}}","configSchemaUrl":"{{ConfigSchemaUrl}}","updateUrl":"https://plugins.dprint.dev/{{RepositoryPath}}/latest.json"}
+ """;
+
+ public static string GetLicenseText()
+ {
+ using var stream =
+ typeof(Plugin).Assembly.GetManifestResourceStream("Formatter.Dprint.LICENSE")
+ ?? throw new InvalidOperationException(
+ "The embedded plugin license could not be found."
+ );
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ public static string GetConfigSchema() =>
+ $$"""
+ {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "{{ConfigSchemaUrl}}",
+ "title": "dprint PowerShell formatter configuration",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "braceStyle": {
+ "description": "Placement of script-block opening braces.",
+ "type": "string",
+ "enum": ["sameLine", "nextLine"],
+ "default": "sameLine"
+ },
+ "indentSize": {
+ "description": "Spaces in one indentation level when tabs are disabled.",
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 32,
+ "default": 4
+ },
+ "useTabs": {
+ "description": "Use one tab per indentation level.",
+ "type": "boolean",
+ "default": false
+ },
+ "correctKeywordCasing": {
+ "description": "Lowercase PowerShell keywords and operators.",
+ "type": "boolean",
+ "default": true
+ },
+ "spaceAroundOperators": {
+ "description": "Add spaces around binary and assignment operators.",
+ "type": "boolean",
+ "default": true
+ },
+ "spaceAroundPipe": {
+ "description": "Add spaces around pipeline and pipeline-chain operators.",
+ "type": "boolean",
+ "default": true
+ },
+ "spaceAfterSeparator": {
+ "description": "Add a space after commas and semicolons.",
+ "type": "boolean",
+ "default": true
+ }
+ }
+ }
+ """;
+
+ private static string GetVersion()
+ {
+ var version =
+ typeof(Plugin).Assembly.GetName().Version
+ ?? throw new InvalidOperationException(
+ "The plugin assembly version could not be read."
+ );
+ return $"{version.Major}.{version.Minor}.{version.Build}";
+ }
+
+ private static FormatterOptions ParseOptions(string configJson, string overrideConfigJson)
+ {
+ var options = new FormatterOptions();
+ if (!string.IsNullOrWhiteSpace(configJson))
+ {
+ using var document = JsonDocument.Parse(configJson);
+ var root = document.RootElement;
+ if (root.TryGetProperty("global", out var global))
+ {
+ ApplyBoolean(global, "useTabs", value => options.UseTabs = value);
+ ApplyInteger(global, "indentWidth", value => options.IndentSize = value);
+ }
+ if (root.TryGetProperty("plugin", out var plugin))
+ {
+ ApplyPluginOptions(plugin, options);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(overrideConfigJson))
+ {
+ using var overrideDocument = JsonDocument.Parse(overrideConfigJson);
+ ApplyPluginOptions(overrideDocument.RootElement, options);
+ }
+
+ return options;
+ }
+
+ private static void ApplyPluginOptions(JsonElement plugin, FormatterOptions options)
+ {
+ ApplyInteger(plugin, "indentSize", value => options.IndentSize = value);
+ ApplyBoolean(plugin, "useTabs", value => options.UseTabs = value);
+ ApplyBoolean(plugin, "correctKeywordCasing", value => options.CorrectKeywordCasing = value);
+ ApplyBoolean(plugin, "spaceAroundOperators", value => options.SpaceAroundOperators = value);
+ ApplyBoolean(plugin, "spaceAroundPipe", value => options.SpaceAroundPipe = value);
+ ApplyBoolean(plugin, "spaceAfterSeparator", value => options.SpaceAfterSeparator = value);
+
+ if (
+ plugin.TryGetProperty("braceStyle", out var braceStyle)
+ && braceStyle.ValueKind == JsonValueKind.String
+ )
+ {
+ options.BraceStyle = braceStyle.GetString() switch
+ {
+ "nextLine" => BraceStyle.NextLine,
+ _ => BraceStyle.SameLine,
+ };
+ }
+ }
+
+ private static void ValidateBoolean(
+ JsonElement config,
+ string name,
+ ICollection<(string PropertyName, string Message)> diagnostics
+ )
+ {
+ if (
+ config.TryGetProperty(name, out var value)
+ && value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)
+ )
+ {
+ diagnostics.Add((name, "Expected a boolean value."));
+ }
+ }
+
+ private static void ValidateInteger(
+ JsonElement config,
+ string name,
+ int minimum,
+ int maximum,
+ ICollection<(string PropertyName, string Message)> diagnostics
+ )
+ {
+ if (!config.TryGetProperty(name, out var value))
+ {
+ return;
+ }
+ if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var integer))
+ {
+ diagnostics.Add((name, "Expected an integer value."));
+ }
+ else if (integer < minimum || integer > maximum)
+ {
+ diagnostics.Add((name, $"Expected a value from {minimum} through {maximum}."));
+ }
+ }
+
+ private static void ValidateStringChoice(
+ JsonElement config,
+ string name,
+ IReadOnlyCollection choices,
+ ICollection<(string PropertyName, string Message)> diagnostics
+ )
+ {
+ if (!config.TryGetProperty(name, out var value))
+ {
+ return;
+ }
+ if (value.ValueKind != JsonValueKind.String || !choices.Contains(value.GetString()))
+ {
+ diagnostics.Add((name, $"Expected one of: {string.Join(", ", choices)}."));
+ }
+ }
+
+ private static string SerializeDiagnostics(
+ IEnumerable<(string PropertyName, string Message)> diagnostics
+ )
+ {
+ var json = new StringBuilder("[");
+ var first = true;
+ foreach (var diagnostic in diagnostics)
+ {
+ if (!first)
+ {
+ json.Append(',');
+ }
+ first = false;
+ json.Append("{\"propertyName\":\"")
+ .Append(JavaScriptEncoder.Default.Encode(diagnostic.PropertyName))
+ .Append("\",\"message\":\"")
+ .Append(JavaScriptEncoder.Default.Encode(diagnostic.Message))
+ .Append("\"}");
+ }
+ return json.Append(']').ToString();
+ }
+
+ private static string Boolean(bool value) => value ? "true" : "false";
+
+ private static void ApplyBoolean(JsonElement config, string name, Action apply)
+ {
+ if (
+ config.TryGetProperty(name, out var value)
+ && value.ValueKind is JsonValueKind.True or JsonValueKind.False
+ )
+ {
+ apply(value.GetBoolean());
+ }
+ }
+
+ private static void ApplyInteger(JsonElement config, string name, Action apply)
+ {
+ if (
+ config.TryGetProperty(name, out var value)
+ && value.ValueKind == JsonValueKind.Number
+ && value.TryGetInt32(out var integer)
+ )
+ {
+ apply(integer);
+ }
+ }
+}
diff --git a/Formatter/Dprint/Properties/AssemblyInfo.cs b/Formatter/Dprint/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000..9493fcc6d
--- /dev/null
+++ b/Formatter/Dprint/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+[assembly: System.Runtime.Versioning.SupportedOSPlatform("wasi")]
diff --git a/Formatter/Dprint/README.md b/Formatter/Dprint/README.md
new file mode 100644
index 000000000..e02a5c5b8
--- /dev/null
+++ b/Formatter/Dprint/README.md
@@ -0,0 +1,140 @@
+# dprint PowerShell formatter plugin
+
+This project compiles the parser-backed C# formatter into one `plugin.wasm` implementing dprint's
+schema-version-4 WebAssembly ABI. Dprint loads the module directly; this is not a process plugin and
+does not start `pwsh`, `dotnet`, Node.js, or another formatter process.
+
+## Build
+
+Install the repository-pinned tools and the .NET 8 experimental WASI workload:
+
+```sh
+mise install
+mise exec -- dotnet workload install wasi-experimental \
+ --skip-manifest-update \
+ --source https://api.nuget.org/v3/index.json
+```
+
+Publish the plugin:
+
+```sh
+mise exec -- dotnet publish Formatter/Dprint/Formatter.Dprint.csproj \
+ -c Release \
+ --source https://api.nuget.org/v3/index.json
+```
+
+The dprint artifact is:
+
+```text
+Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm
+```
+
+It contains the Mono runtime, `Formatter.Core`, the required PowerShell parser assemblies, and the
+dprint protocol bridge. Its only host import is dprint's supported `env.fd_write` function.
+
+## Use with dprint
+
+Install the latest released plugin from the dprint registry:
+
+```sh
+dprint add kjanat/PSScriptAnalyzer
+```
+
+Then configure it in `dprint.json`:
+
+```json
+{
+ "powershell": {
+ "indentSize": 4,
+ "braceStyle": "sameLine"
+ }
+}
+```
+
+Then run normal dprint commands:
+
+```sh
+dprint fmt script.ps1
+dprint check .
+```
+
+The plugin matches `.ps1`, `.psm1`, and `.psd1` files. Configuration is described by
+[`schema.json`](schema.json); dprint also reports unknown keys and invalid values as configuration
+diagnostics.
+
+For local development, replace the registry-installed plugin URL with the built module path:
+
+```json
+{
+ "plugins": [
+ "./Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm"
+ ]
+}
+```
+
+## How the C# module works
+
+`WasmSingleFileBundle` embeds the managed assemblies into the WASI module. A small native bridge
+exports dprint's memory and formatter protocol and invokes the managed `Plugin` methods through
+Mono's embedding API. The .NET WASI runtime normally imports a broad
+`wasi_snapshot_preview1` surface, but dprint intentionally provides only its own plugin imports.
+The bridge redirects those runtime calls to deterministic in-module implementations, so the final
+module has no WASI host dependency.
+
+The formatting path is:
+
+```text
+dprint
+ -> plugin.wasm protocol exports
+ -> native Mono bridge
+ -> Formatter.Dprint.Plugin.Format
+ -> Formatter.Core.PowerShellFormatter
+ -> System.Management.Automation.Language.Parser
+```
+
+The native layer handles only dprint byte transfer, UTF-8 validation, configuration lifetime, and
+managed-runtime invocation. Formatting policy remains in the same C# `Formatter.Core` assembly used
+by the browser/Node AppBundle.
+
+## Validate
+
+Run the complete plugin suite:
+
+```sh
+mise exec -- Formatter/Dprint/scripts/e2e.sh
+```
+
+It checks the module's imports and required exports, metadata URLs, generated schema drift, a real
+dprint fixture, idempotence, unknown-key diagnostics, and invalid UTF-8 handling.
+
+`schema.json` is generated from the configuration contract embedded in the managed plugin. After
+changing that contract, publish the module and regenerate the schema with:
+
+```sh
+node Formatter/Dprint/scripts/generate-schema.mjs \
+ Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm \
+ Formatter/Dprint/schema.json
+```
+
+## Release
+
+The plugin version is the `Version` property in `Formatter/Dprint/Formatter.Dprint.csproj`. Its Git
+tag, published dprint version, and schema URL use that same bare semantic version. The dprint proxy
+does not accept dashes in plugin tags, so `dprint-` is not a valid registry release tag.
+
+The repository also contains PSScriptAnalyzer's historical tags. The release workflow therefore
+refuses to publish unless the pushed tag exactly matches the dprint project's declared version.
+
+To publish a new immutable release:
+
+1. Update `Version`, build the module, and regenerate `schema.json`.
+2. Run `mise exec -- Formatter/Dprint/scripts/e2e.sh` and commit the version, schema, and related
+ changes.
+3. Create and push a signed `` tag for that exact signed commit.
+4. Follow the `Release dprint PowerShell formatter` workflow through completion.
+5. Verify that the GitHub release contains `plugin.wasm`, `schema.json`, `LICENSE`, and
+ `checksums.txt`, then
+ verify `dprint add kjanat/PSScriptAnalyzer` against the published release.
+
+Released assets are immutable through the dprint registry's cache. Never replace an asset on an
+existing release; fix it by incrementing `Version` and publishing a new release.
diff --git a/Formatter/Dprint/native/dprint_exports.c b/Formatter/Dprint/native/dprint_exports.c
new file mode 100644
index 000000000..f18c44417
--- /dev/null
+++ b/Formatter/Dprint/native/dprint_exports.c
@@ -0,0 +1,414 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define DPRINT_EXPORT(name) __attribute__((export_name(name)))
+
+typedef struct ConfigEntry {
+ uint32_t id;
+ char *json;
+ struct ConfigEntry *next;
+} ConfigEntry;
+
+static uint8_t *shared_bytes;
+static uint32_t shared_capacity;
+static uint32_t shared_length;
+static ConfigEntry *configs;
+static char *file_path;
+static char *override_config;
+static MonoMethod *format_method;
+static MonoMethod *diagnostics_method;
+static MonoMethod *resolved_config_method;
+static MonoMethod *schema_method;
+static MonoMethod *plugin_info_method;
+static MonoMethod *license_method;
+static const char *error_text;
+static char *owned_error_text;
+static int runtime_state;
+
+const char *dotnet_wasi_getentrypointassemblyname(void);
+
+static uint32_t write_shared_bytes(const uint8_t *bytes, uint32_t length) {
+ if (length > shared_capacity) {
+ uint8_t *resized = realloc(shared_bytes, length);
+ if (resized == NULL) {
+ error_text = "Could not allocate the dprint shared buffer.";
+ return 0;
+ }
+ shared_bytes = resized;
+ shared_capacity = length;
+ }
+ if (length > 0) {
+ memcpy(shared_bytes, bytes, length);
+ }
+ shared_length = length;
+ return length;
+}
+
+static uint32_t write_shared(const char *text) {
+ return write_shared_bytes((const uint8_t *)text, (uint32_t)strlen(text));
+}
+
+static void set_owned_error(const char *text) {
+ free(owned_error_text);
+ owned_error_text = strdup(text);
+ error_text = owned_error_text == NULL ? "Could not allocate the formatter error message." : owned_error_text;
+}
+
+static char *copy_shared_string(void) {
+ char *copy = malloc((size_t)shared_length + 1);
+ if (copy == NULL) {
+ return NULL;
+ }
+ if (shared_length > 0) {
+ memcpy(copy, shared_bytes, shared_length);
+ }
+ copy[shared_length] = '\0';
+ return copy;
+}
+
+static ConfigEntry *find_config(uint32_t id) {
+ for (ConfigEntry *entry = configs; entry != NULL; entry = entry->next) {
+ if (entry->id == id) {
+ return entry;
+ }
+ }
+ return NULL;
+}
+
+static int is_valid_utf8(const uint8_t *bytes, uint32_t length) {
+ uint32_t index = 0;
+ while (index < length) {
+ uint8_t byte = bytes[index++];
+ if (byte <= 0x7f) {
+ if (byte == 0) {
+ return 0;
+ }
+ continue;
+ }
+
+ uint32_t remaining;
+ uint32_t codepoint;
+ if ((byte & 0xe0) == 0xc0) {
+ remaining = 1;
+ codepoint = byte & 0x1f;
+ if (codepoint < 2) {
+ return 0;
+ }
+ } else if ((byte & 0xf0) == 0xe0) {
+ remaining = 2;
+ codepoint = byte & 0x0f;
+ } else if ((byte & 0xf8) == 0xf0) {
+ remaining = 3;
+ codepoint = byte & 0x07;
+ } else {
+ return 0;
+ }
+
+ if (index + remaining > length) {
+ return 0;
+ }
+ for (uint32_t offset = 0; offset < remaining; offset++) {
+ uint8_t continuation = bytes[index++];
+ if ((continuation & 0xc0) != 0x80) {
+ return 0;
+ }
+ codepoint = (codepoint << 6) | (continuation & 0x3f);
+ }
+ if ((remaining == 2 && codepoint < 0x800) ||
+ (remaining == 3 && codepoint < 0x10000) ||
+ (codepoint >= 0xd800 && codepoint <= 0xdfff) ||
+ codepoint > 0x10ffff) {
+ return 0;
+ }
+ }
+ return 1;
+}
+
+static int ensure_runtime(void) {
+ if (runtime_state != 0) {
+ return runtime_state > 0;
+ }
+ runtime_state = -1;
+ mono_wasm_load_runtime("", 0);
+
+ MonoAssembly *assembly = mono_assembly_open(dotnet_wasi_getentrypointassemblyname(), NULL);
+ if (assembly == NULL) {
+ error_text = "Could not load the embedded Formatter.Dprint assembly.";
+ return 0;
+ }
+ MonoClass *klass = mono_wasm_assembly_find_class(
+ assembly,
+ "Microsoft.PowerShell.ScriptAnalyzer.Formatter.Dprint",
+ "Plugin"
+ );
+ if (klass == NULL) {
+ error_text = "Could not find the managed dprint Plugin type.";
+ return 0;
+ }
+ format_method = mono_wasm_assembly_find_method(klass, "Format", 3);
+ diagnostics_method = mono_wasm_assembly_find_method(klass, "GetConfigDiagnostics", 1);
+ resolved_config_method = mono_wasm_assembly_find_method(klass, "GetResolvedConfig", 1);
+ schema_method = mono_wasm_assembly_find_method(klass, "GetConfigSchema", 0);
+ plugin_info_method = mono_wasm_assembly_find_method(klass, "GetPluginInfo", 0);
+ license_method = mono_wasm_assembly_find_method(klass, "GetLicenseText", 0);
+ if (format_method == NULL || diagnostics_method == NULL || resolved_config_method == NULL ||
+ schema_method == NULL || plugin_info_method == NULL || license_method == NULL) {
+ error_text = "Could not find the managed dprint formatter entry point.";
+ return 0;
+ }
+ runtime_state = 1;
+ return 1;
+}
+
+static char *invoke_managed_no_args(MonoMethod *method) {
+ MonoObject *exception = NULL;
+ MonoObject *result = mono_runtime_invoke(method, NULL, NULL, &exception);
+ if (exception != NULL || result == NULL) {
+ return NULL;
+ }
+ return mono_string_to_utf8((MonoString *)result);
+}
+
+static char *invoke_managed_string(MonoMethod *method, const char *input) {
+ MonoString *managed_input = mono_string_new(mono_domain_get(), input);
+ void *arguments[] = { managed_input };
+ MonoObject *exception = NULL;
+ MonoObject *result = mono_runtime_invoke(method, NULL, arguments, &exception);
+ if (exception != NULL || result == NULL) {
+ return NULL;
+ }
+ return mono_string_to_utf8((MonoString *)result);
+}
+
+DPRINT_EXPORT("dprint_plugin_version_4")
+uint32_t dprint_plugin_version_4(void) {
+ return 4;
+}
+
+DPRINT_EXPORT("clear_shared_bytes")
+uint32_t clear_shared_bytes(uint32_t capacity) {
+ if (capacity > shared_capacity) {
+ uint8_t *resized = realloc(shared_bytes, capacity);
+ if (resized == NULL) {
+ error_text = "Could not allocate the dprint shared buffer.";
+ return 0;
+ }
+ shared_bytes = resized;
+ shared_capacity = capacity;
+ }
+ shared_length = capacity;
+ return (uint32_t)(uintptr_t)shared_bytes;
+}
+
+DPRINT_EXPORT("get_shared_bytes_ptr")
+uint32_t get_shared_bytes_ptr(void) {
+ return (uint32_t)(uintptr_t)shared_bytes;
+}
+
+DPRINT_EXPORT("register_config")
+void register_config(uint32_t config_id) {
+ ConfigEntry *entry = find_config(config_id);
+ if (entry == NULL) {
+ entry = calloc(1, sizeof(ConfigEntry));
+ if (entry == NULL) {
+ error_text = "Could not allocate a dprint configuration.";
+ return;
+ }
+ entry->id = config_id;
+ entry->next = configs;
+ configs = entry;
+ }
+ free(entry->json);
+ entry->json = copy_shared_string();
+}
+
+DPRINT_EXPORT("release_config")
+void release_config(uint32_t config_id) {
+ ConfigEntry **cursor = &configs;
+ while (*cursor != NULL) {
+ if ((*cursor)->id == config_id) {
+ ConfigEntry *removed = *cursor;
+ *cursor = removed->next;
+ free(removed->json);
+ free(removed);
+ return;
+ }
+ cursor = &(*cursor)->next;
+ }
+}
+
+DPRINT_EXPORT("get_config_diagnostics")
+uint32_t get_config_diagnostics(uint32_t config_id) {
+ ConfigEntry *entry = find_config(config_id);
+ const char *config_json = entry != NULL && entry->json != NULL ? entry->json : "{}";
+ if (!ensure_runtime()) {
+ return write_shared("[{\"propertyName\":\"\",\"message\":\"Could not initialize the managed formatter.\"}]");
+ }
+ char *diagnostics = invoke_managed_string(diagnostics_method, config_json);
+ if (diagnostics == NULL) {
+ return write_shared("[{\"propertyName\":\"\",\"message\":\"Managed configuration validation failed.\"}]");
+ }
+ uint32_t length = write_shared(diagnostics);
+ mono_free(diagnostics);
+ return length;
+}
+
+DPRINT_EXPORT("get_resolved_config")
+uint32_t get_resolved_config(uint32_t config_id) {
+ ConfigEntry *entry = find_config(config_id);
+ const char *config_json = entry != NULL && entry->json != NULL ? entry->json : "{}";
+ if (!ensure_runtime()) {
+ return write_shared("{}");
+ }
+ char *resolved = invoke_managed_string(resolved_config_method, config_json);
+ if (resolved == NULL) {
+ return write_shared("{}");
+ }
+ uint32_t length = write_shared(resolved);
+ mono_free(resolved);
+ return length;
+}
+
+DPRINT_EXPORT("get_config_file_matching")
+uint32_t get_config_file_matching(uint32_t config_id) {
+ (void)config_id;
+ return write_shared("{\"fileExtensions\":[\"ps1\",\"psm1\",\"psd1\"],\"fileNames\":[]}");
+}
+
+DPRINT_EXPORT("get_config_schema")
+uint32_t get_config_schema(void) {
+ if (!ensure_runtime()) {
+ return write_shared("{}");
+ }
+ char *schema = invoke_managed_no_args(schema_method);
+ if (schema == NULL) {
+ return write_shared("{}");
+ }
+ uint32_t length = write_shared(schema);
+ mono_free(schema);
+ return length;
+}
+
+DPRINT_EXPORT("get_plugin_info")
+uint32_t get_plugin_info(void) {
+ if (!ensure_runtime()) {
+ return write_shared("{}");
+ }
+ char *info = invoke_managed_no_args(plugin_info_method);
+ if (info == NULL) {
+ return write_shared("{}");
+ }
+ uint32_t length = write_shared(info);
+ mono_free(info);
+ return length;
+}
+
+DPRINT_EXPORT("get_license_text")
+uint32_t get_license_text(void) {
+ if (!ensure_runtime()) {
+ return write_shared("");
+ }
+ char *license = invoke_managed_no_args(license_method);
+ if (license == NULL) {
+ return write_shared("");
+ }
+ uint32_t length = write_shared(license);
+ mono_free(license);
+ return length;
+}
+
+DPRINT_EXPORT("set_file_path")
+void set_file_path(void) {
+ free(file_path);
+ file_path = copy_shared_string();
+}
+
+DPRINT_EXPORT("set_override_config")
+void set_override_config(void) {
+ free(override_config);
+ override_config = copy_shared_string();
+}
+
+DPRINT_EXPORT("format")
+uint32_t format(uint32_t config_id) {
+ free(owned_error_text);
+ owned_error_text = NULL;
+ error_text = NULL;
+ if (!is_valid_utf8(shared_bytes, shared_length)) {
+ error_text = "PowerShell source must be valid UTF-8 without NUL bytes.";
+ return 2;
+ }
+ if (!ensure_runtime()) {
+ return 2;
+ }
+
+ ConfigEntry *entry = find_config(config_id);
+ const char *config_json = entry != NULL && entry->json != NULL ? entry->json : "{}";
+ char *source = copy_shared_string();
+ if (source == NULL) {
+ error_text = "Could not copy the PowerShell source.";
+ return 2;
+ }
+
+ MonoDomain *domain = mono_domain_get();
+ MonoString *managed_source = mono_string_new(domain, source);
+ MonoString *managed_config = mono_string_new(domain, config_json);
+ MonoString *managed_override = mono_string_new(domain, override_config == NULL ? "" : override_config);
+ void *arguments[] = { managed_source, managed_config, managed_override };
+ MonoObject *exception = NULL;
+ MonoObject *result = mono_runtime_invoke(format_method, NULL, arguments, &exception);
+ if (exception != NULL) {
+ MonoObject *string_exception = NULL;
+ MonoString *exception_string = mono_object_to_string(exception, &string_exception);
+ if (exception_string != NULL && string_exception == NULL) {
+ char *exception_utf8 = mono_string_to_utf8(exception_string);
+ if (exception_utf8 != NULL) {
+ set_owned_error(exception_utf8);
+ mono_free(exception_utf8);
+ }
+ }
+ free(source);
+ if (error_text == NULL) {
+ error_text = "The managed PowerShell formatter threw an exception.";
+ }
+ return 2;
+ }
+ if (result == NULL) {
+ free(source);
+ error_text = "The managed PowerShell formatter returned no result.";
+ return 2;
+ }
+
+ char *formatted = mono_string_to_utf8((MonoString *)result);
+ if (formatted == NULL) {
+ free(source);
+ error_text = "The managed PowerShell formatter returned no text.";
+ return 2;
+ }
+ uint32_t formatted_length = (uint32_t)strlen(formatted);
+ int changed = formatted_length != shared_length || memcmp(formatted, source, formatted_length) != 0;
+ if (changed) {
+ write_shared_bytes((const uint8_t *)formatted, formatted_length);
+ }
+ mono_free(formatted);
+ free(source);
+ free(override_config);
+ override_config = NULL;
+ return changed ? 1 : 0;
+}
+
+DPRINT_EXPORT("get_formatted_text")
+uint32_t get_formatted_text(void) {
+ return shared_length;
+}
+
+DPRINT_EXPORT("get_error_text")
+uint32_t get_error_text(void) {
+ return write_shared(error_text == NULL ? "Unknown formatter error." : error_text);
+}
diff --git a/Formatter/Dprint/native/wasi_stubs.c b/Formatter/Dprint/native/wasi_stubs.c
new file mode 100644
index 000000000..fa5f39354
--- /dev/null
+++ b/Formatter/Dprint/native/wasi_stubs.c
@@ -0,0 +1,282 @@
+#include
+#include
+#include
+
+#define pssa_sock_accept __wrap___wasi_sock_accept
+#define pssa_args_get __wrap___wasi_args_get
+#define pssa_args_sizes_get __wrap___wasi_args_sizes_get
+#define pssa_environ_get __wrap___wasi_environ_get
+#define pssa_environ_sizes_get __wrap___wasi_environ_sizes_get
+#define pssa_clock_res_get __wrap___wasi_clock_res_get
+#define pssa_clock_time_get __wrap___wasi_clock_time_get
+#define pssa_fd_advise __wrap___wasi_fd_advise
+#define pssa_fd_close __wrap___wasi_fd_close
+#define pssa_fd_fdstat_get __wrap___wasi_fd_fdstat_get
+#define pssa_fd_fdstat_set_flags __wrap___wasi_fd_fdstat_set_flags
+#define pssa_fd_filestat_get __wrap___wasi_fd_filestat_get
+#define pssa_fd_filestat_set_size __wrap___wasi_fd_filestat_set_size
+#define pssa_fd_filestat_set_times __wrap___wasi_fd_filestat_set_times
+#define pssa_fd_pread __wrap___wasi_fd_pread
+#define pssa_fd_pwrite __wrap___wasi_fd_pwrite
+#define pssa_fd_prestat_get __wrap___wasi_fd_prestat_get
+#define pssa_fd_prestat_dir_name __wrap___wasi_fd_prestat_dir_name
+#define pssa_fd_read __wrap___wasi_fd_read
+#define pssa_fd_readdir __wrap___wasi_fd_readdir
+#define pssa_fd_seek __wrap___wasi_fd_seek
+#define pssa_fd_tell __wrap___wasi_fd_tell
+#define pssa_fd_sync __wrap___wasi_fd_sync
+#define pssa_fd_write __wrap___wasi_fd_write
+#define pssa_path_create_directory __wrap___wasi_path_create_directory
+#define pssa_path_filestat_get __wrap___wasi_path_filestat_get
+#define pssa_path_filestat_set_times __wrap___wasi_path_filestat_set_times
+#define pssa_path_link __wrap___wasi_path_link
+#define pssa_path_open __wrap___wasi_path_open
+#define pssa_path_readlink __wrap___wasi_path_readlink
+#define pssa_path_remove_directory __wrap___wasi_path_remove_directory
+#define pssa_path_rename __wrap___wasi_path_rename
+#define pssa_path_unlink_file __wrap___wasi_path_unlink_file
+#define pssa_poll_oneoff __wrap___wasi_poll_oneoff
+#define pssa_proc_exit __wrap___wasi_proc_exit
+#define pssa_random_get __wrap___wasi_random_get
+
+extern __wasi_errno_t dprint_fd_write(
+ __wasi_fd_t fd,
+ const __wasi_ciovec_t *iovs,
+ size_t iovs_len,
+ __wasi_size_t *written
+) __attribute__((import_module("env"), import_name("fd_write")));
+
+__wasi_errno_t pssa_sock_accept(__wasi_fd_t fd, __wasi_fdflags_t flags, __wasi_fd_t *result) {
+ (void)fd; (void)flags; (void)result;
+ return __WASI_ERRNO_NOTSUP;
+}
+
+__wasi_errno_t __imported_wasi_snapshot_preview1_sock_accept(
+ __wasi_fd_t fd,
+ __wasi_fdflags_t flags,
+ __wasi_fd_t *result
+) {
+ return pssa_sock_accept(fd, flags, result);
+}
+
+__wasi_errno_t sock_accept(__wasi_fd_t fd, __wasi_fdflags_t flags, __wasi_fd_t *result) {
+ return pssa_sock_accept(fd, flags, result);
+}
+
+__wasi_errno_t pssa_args_get(uint8_t **argv, uint8_t *argv_buf) {
+ (void)argv; (void)argv_buf;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_args_sizes_get(__wasi_size_t *argc, __wasi_size_t *argv_size) {
+ *argc = 0;
+ *argv_size = 0;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_environ_get(uint8_t **environ, uint8_t *environ_buf) {
+ (void)environ; (void)environ_buf;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_environ_sizes_get(__wasi_size_t *count, __wasi_size_t *size) {
+ *count = 0;
+ *size = 0;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_clock_res_get(__wasi_clockid_t id, __wasi_timestamp_t *resolution) {
+ (void)id;
+ *resolution = 1000000;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_clock_time_get(__wasi_clockid_t id, __wasi_timestamp_t precision, __wasi_timestamp_t *time) {
+ static __wasi_timestamp_t deterministic_time;
+ (void)id; (void)precision;
+ deterministic_time += 1000000;
+ *time = deterministic_time;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_fd_advise(__wasi_fd_t fd, __wasi_filesize_t offset, __wasi_filesize_t len, __wasi_advice_t advice) {
+ (void)fd; (void)offset; (void)len; (void)advice;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_fd_close(__wasi_fd_t fd) {
+ (void)fd;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_fd_fdstat_get(__wasi_fd_t fd, __wasi_fdstat_t *stat) {
+ memset(stat, 0, sizeof(*stat));
+ if (fd <= 2) {
+ stat->fs_filetype = __WASI_FILETYPE_CHARACTER_DEVICE;
+ return __WASI_ERRNO_SUCCESS;
+ }
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_fdstat_set_flags(__wasi_fd_t fd, __wasi_fdflags_t flags) {
+ (void)fd; (void)flags;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_fd_filestat_get(__wasi_fd_t fd, __wasi_filestat_t *stat) {
+ (void)fd;
+ memset(stat, 0, sizeof(*stat));
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_filestat_set_size(__wasi_fd_t fd, __wasi_filesize_t size) {
+ (void)fd; (void)size;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_filestat_set_times(
+ __wasi_fd_t fd,
+ __wasi_timestamp_t accessed,
+ __wasi_timestamp_t modified,
+ __wasi_fstflags_t flags
+) {
+ (void)fd; (void)accessed; (void)modified; (void)flags;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_pread(__wasi_fd_t fd, const __wasi_iovec_t *iovs, size_t iovs_len, __wasi_filesize_t offset, __wasi_size_t *read) {
+ (void)fd; (void)iovs; (void)iovs_len; (void)offset;
+ *read = 0;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_pwrite(__wasi_fd_t fd, const __wasi_ciovec_t *iovs, size_t iovs_len, __wasi_filesize_t offset, __wasi_size_t *written) {
+ (void)fd; (void)iovs; (void)iovs_len; (void)offset;
+ *written = 0;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_prestat_get(__wasi_fd_t fd, __wasi_prestat_t *prestat) {
+ (void)fd; (void)prestat;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_prestat_dir_name(__wasi_fd_t fd, uint8_t *path, size_t path_len) {
+ (void)fd; (void)path; (void)path_len;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_read(__wasi_fd_t fd, const __wasi_iovec_t *iovs, size_t iovs_len, __wasi_size_t *read) {
+ (void)fd; (void)iovs; (void)iovs_len;
+ *read = 0;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_fd_readdir(__wasi_fd_t fd, uint8_t *buf, size_t buf_len, __wasi_dircookie_t cookie, __wasi_size_t *used) {
+ (void)fd; (void)buf; (void)buf_len; (void)cookie;
+ *used = 0;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_seek(__wasi_fd_t fd, __wasi_filedelta_t offset, __wasi_whence_t whence, __wasi_filesize_t *position) {
+ (void)fd; (void)offset; (void)whence;
+ *position = 0;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_tell(__wasi_fd_t fd, __wasi_filesize_t *position) {
+ (void)fd;
+ *position = 0;
+ return __WASI_ERRNO_BADF;
+}
+
+__wasi_errno_t pssa_fd_sync(__wasi_fd_t fd) {
+ (void)fd;
+ return __WASI_ERRNO_SUCCESS;
+}
+
+__wasi_errno_t pssa_fd_write(__wasi_fd_t fd, const __wasi_ciovec_t *iovs, size_t iovs_len, __wasi_size_t *written) {
+ return dprint_fd_write(fd, iovs, iovs_len, written);
+}
+
+__wasi_errno_t pssa_path_create_directory(__wasi_fd_t fd, const char *path) {
+ (void)fd; (void)path;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_path_filestat_get(__wasi_fd_t fd, __wasi_lookupflags_t flags, const char *path, __wasi_filestat_t *stat) {
+ (void)fd; (void)flags; (void)path; (void)stat;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_path_filestat_set_times(
+ __wasi_fd_t fd,
+ __wasi_lookupflags_t lookup_flags,
+ const char *path,
+ __wasi_timestamp_t accessed,
+ __wasi_timestamp_t modified,
+ __wasi_fstflags_t flags
+) {
+ (void)fd; (void)lookup_flags; (void)path; (void)accessed; (void)modified; (void)flags;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_path_link(
+ __wasi_fd_t old_fd,
+ __wasi_lookupflags_t old_flags,
+ const char *old_path,
+ __wasi_fd_t new_fd,
+ const char *new_path
+) {
+ (void)old_fd; (void)old_flags; (void)old_path; (void)new_fd; (void)new_path;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_path_open(__wasi_fd_t fd, __wasi_lookupflags_t dirflags, const char *path, __wasi_oflags_t oflags, __wasi_rights_t rights_base, __wasi_rights_t rights_inheriting, __wasi_fdflags_t fdflags, __wasi_fd_t *opened_fd) {
+ (void)fd; (void)dirflags; (void)path; (void)oflags;
+ (void)rights_base; (void)rights_inheriting; (void)fdflags; (void)opened_fd;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_path_readlink(__wasi_fd_t fd, const char *path, uint8_t *buf, __wasi_size_t buf_len, __wasi_size_t *used) {
+ (void)fd; (void)path; (void)buf; (void)buf_len;
+ *used = 0;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_path_remove_directory(__wasi_fd_t fd, const char *path) {
+ (void)fd; (void)path;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_path_rename(__wasi_fd_t fd, const char *old_path, __wasi_fd_t new_fd, const char *new_path) {
+ (void)fd; (void)old_path; (void)new_fd; (void)new_path;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_path_unlink_file(__wasi_fd_t fd, const char *path) {
+ (void)fd; (void)path;
+ return __WASI_ERRNO_NOENT;
+}
+
+__wasi_errno_t pssa_poll_oneoff(const __wasi_subscription_t *subscriptions, __wasi_event_t *events, size_t count, __wasi_size_t *event_count) {
+ (void)subscriptions; (void)events; (void)count;
+ *event_count = 0;
+ return __WASI_ERRNO_NOTSUP;
+}
+
+_Noreturn void pssa_proc_exit(__wasi_exitcode_t code) {
+ (void)code;
+ __builtin_trap();
+}
+
+__wasi_errno_t pssa_random_get(uint8_t *buf, __wasi_size_t len) {
+ static uint32_t state = 0x9e3779b9u;
+ for (__wasi_size_t i = 0; i < len; i++) {
+ state ^= state << 13;
+ state ^= state >> 17;
+ state ^= state << 5;
+ buf[i] = (uint8_t)state;
+ }
+ return __WASI_ERRNO_SUCCESS;
+}
diff --git a/Formatter/Dprint/release-notes.md b/Formatter/Dprint/release-notes.md
new file mode 100644
index 000000000..cd6763865
--- /dev/null
+++ b/Formatter/Dprint/release-notes.md
@@ -0,0 +1,13 @@
+## Install
+
+```sh
+dprint add kjanat/PSScriptAnalyzer
+```
+
+The release contains the directly loadable dprint `plugin.wasm`, its formatter configuration
+`schema.json`, its MIT `LICENSE`, and SHA-256 checksums for all three files. The module formats
+`.ps1`, `.psm1`, and `.psd1` files without starting `pwsh`, `dotnet`, Node.js, or another formatter
+process.
+
+The formatter and its embedded license are distributed under the MIT license, with copyright held
+by Microsoft Corporation and Kaj Kowalski.
diff --git a/Formatter/Dprint/runtimeconfig.template.json b/Formatter/Dprint/runtimeconfig.template.json
new file mode 100644
index 000000000..9ff798493
--- /dev/null
+++ b/Formatter/Dprint/runtimeconfig.template.json
@@ -0,0 +1,10 @@
+{
+ "wasmHostProperties": {
+ "perHostConfig": [
+ {
+ "name": "wasmtime",
+ "Host": "wasmtime"
+ }
+ ]
+ }
+}
diff --git a/Formatter/Dprint/schema.json b/Formatter/Dprint/schema.json
new file mode 100644
index 000000000..4daf81c00
--- /dev/null
+++ b/Formatter/Dprint/schema.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/0.1.1/schema.json",
+ "title": "dprint PowerShell formatter configuration",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "braceStyle": {
+ "description": "Placement of script-block opening braces.",
+ "type": "string",
+ "enum": ["sameLine", "nextLine"],
+ "default": "sameLine"
+ },
+ "indentSize": {
+ "description": "Spaces in one indentation level when tabs are disabled.",
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 32,
+ "default": 4
+ },
+ "useTabs": {
+ "description": "Use one tab per indentation level.",
+ "type": "boolean",
+ "default": false
+ },
+ "correctKeywordCasing": {
+ "description": "Lowercase PowerShell keywords and operators.",
+ "type": "boolean",
+ "default": true
+ },
+ "spaceAroundOperators": {
+ "description": "Add spaces around binary and assignment operators.",
+ "type": "boolean",
+ "default": true
+ },
+ "spaceAroundPipe": {
+ "description": "Add spaces around pipeline and pipeline-chain operators.",
+ "type": "boolean",
+ "default": true
+ },
+ "spaceAfterSeparator": {
+ "description": "Add a space after commas and semicolons.",
+ "type": "boolean",
+ "default": true
+ }
+ }
+}
diff --git a/Formatter/Dprint/scripts/check-plugin.mjs b/Formatter/Dprint/scripts/check-plugin.mjs
new file mode 100644
index 000000000..ca430323c
--- /dev/null
+++ b/Formatter/Dprint/scripts/check-plugin.mjs
@@ -0,0 +1,75 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import process from "node:process";
+
+const [pluginPath, expectedVersion, licensePath] = process.argv.slice(2);
+if (!pluginPath || !expectedVersion || !licensePath) {
+ console.error("usage: node check-plugin.mjs ");
+ process.exit(2);
+}
+
+const bytes = await readFile(pluginPath);
+const module = await WebAssembly.compile(bytes);
+assert.deepEqual(WebAssembly.Module.imports(module), [
+ { module: "env", name: "fd_write", kind: "function" },
+]);
+
+const requiredExports = [
+ "memory",
+ "dprint_plugin_version_4",
+ "clear_shared_bytes",
+ "get_shared_bytes_ptr",
+ "register_config",
+ "release_config",
+ "get_config_diagnostics",
+ "get_resolved_config",
+ "get_config_file_matching",
+ "get_config_schema",
+ "get_plugin_info",
+ "get_license_text",
+ "set_file_path",
+ "set_override_config",
+ "format",
+ "get_formatted_text",
+ "get_error_text",
+];
+const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
+for (const name of requiredExports) {
+ assert(exports.has(name), `missing dprint export: ${name}`);
+}
+
+const instance = await WebAssembly.instantiate(module, {
+ env: { fd_write: () => 0 },
+});
+assert.equal(instance.exports.dprint_plugin_version_4(), 4);
+
+const readSharedText = (length) => {
+ const pointer = instance.exports.get_shared_bytes_ptr();
+ return new TextDecoder().decode(
+ new Uint8Array(instance.exports.memory.buffer, pointer, length),
+ );
+};
+
+const info = JSON.parse(readSharedText(instance.exports.get_plugin_info()));
+assert.equal(info.name, "dprint-plugin-powershell");
+assert.equal(info.version, expectedVersion);
+assert.equal(info.configKey, "powershell");
+assert.equal(info.helpUrl, "https://github.com/kjanat/PSScriptAnalyzer");
+assert.equal(
+ info.configSchemaUrl,
+ `https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/${expectedVersion}/schema.json`,
+);
+assert.equal(
+ info.updateUrl,
+ "https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/latest.json",
+);
+
+const schema = JSON.parse(readSharedText(instance.exports.get_config_schema()));
+assert.equal(schema.$id, info.configSchemaUrl);
+
+const expectedLicense = await readFile(licensePath, "utf8");
+const license = readSharedText(instance.exports.get_license_text());
+assert.equal(license, expectedLicense);
+assert.match(license, /Copyright \(c\) Microsoft Corporation\./);
+
+console.log(`validated ${pluginPath} (${bytes.length} bytes)`);
diff --git a/Formatter/Dprint/scripts/e2e.sh b/Formatter/Dprint/scripts/e2e.sh
new file mode 100755
index 000000000..faecaa611
--- /dev/null
+++ b/Formatter/Dprint/scripts/e2e.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+repo_dir=$(cd "$project_dir/../.." && pwd)
+plugin="$project_dir/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm"
+
+cd "$repo_dir"
+dotnet publish Formatter/Dprint/Formatter.Dprint.csproj \
+ -c Release \
+ --source https://api.nuget.org/v3/index.json
+
+version=$(dotnet msbuild Formatter/Dprint/Formatter.Dprint.csproj \
+ -nologo \
+ -getProperty:Version)
+node Formatter/Dprint/scripts/check-plugin.mjs \
+ "$plugin" \
+ "$version" \
+ Formatter/Dprint/LICENSE
+node Formatter/Dprint/scripts/generate-schema.mjs \
+ "$plugin" \
+ Formatter/Dprint/schema.json \
+ --check
+
+cd Formatter/Dprint/tests
+input=$(&2
+ exit 1
+fi
+if [[ "$second" != "$formatted" ]]; then
+ echo "dprint formatting was not idempotent" >&2
+ exit 1
+fi
+
+set +e
+unknown_output=$(dprint check --config unknown-config.json fixtures/input.ps1 2>&1)
+unknown_status=$?
+invalid_output=$(printf '\377' | dprint fmt --stdin invalid.ps1 --config dprint.json 2>&1)
+invalid_status=$?
+set -e
+
+if [[ $unknown_status -eq 0 || "$unknown_output" != *"Unknown property. (unknownProperty)"* ]]; then
+ echo "unknown configuration key was not diagnosed" >&2
+ exit 1
+fi
+if [[ $invalid_status -eq 0 || "$invalid_output" != *"valid UTF-8"* ]]; then
+ echo "invalid UTF-8 was not rejected" >&2
+ exit 1
+fi
+
+echo "dprint plugin checks passed"
diff --git a/Formatter/Dprint/scripts/generate-schema.mjs b/Formatter/Dprint/scripts/generate-schema.mjs
new file mode 100644
index 000000000..bc3a1ce69
--- /dev/null
+++ b/Formatter/Dprint/scripts/generate-schema.mjs
@@ -0,0 +1,28 @@
+import { readFile, writeFile } from "node:fs/promises";
+import process from "node:process";
+
+const [pluginPath, schemaPath, mode] = process.argv.slice(2);
+if (!pluginPath || !schemaPath) {
+ console.error("usage: node generate-schema.mjs [--check]");
+ process.exit(2);
+}
+
+const bytes = await readFile(pluginPath);
+const { instance } = await WebAssembly.instantiate(bytes, {
+ env: { fd_write: () => 0 },
+});
+const length = instance.exports.get_config_schema();
+const pointer = instance.exports.get_shared_bytes_ptr();
+const schema = new TextDecoder().decode(
+ new Uint8Array(instance.exports.memory.buffer, pointer, length),
+) + "\n";
+
+if (mode === "--check") {
+ const existing = await readFile(schemaPath, "utf8");
+ if (existing !== schema) {
+ console.error(`${schemaPath} is out of date; regenerate it from plugin.wasm.`);
+ process.exit(1);
+ }
+} else {
+ await writeFile(schemaPath, schema);
+}
diff --git a/Formatter/Dprint/tests/dprint.json b/Formatter/Dprint/tests/dprint.json
new file mode 100644
index 000000000..185fadef9
--- /dev/null
+++ b/Formatter/Dprint/tests/dprint.json
@@ -0,0 +1,7 @@
+{
+ "excludes": ["fixtures/input.ps1"],
+ "plugins": [
+ "../bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm"
+ ],
+ "powershell": {}
+}
diff --git a/Formatter/Dprint/tests/fixtures/expected.ps1 b/Formatter/Dprint/tests/fixtures/expected.ps1
new file mode 100644
index 000000000..0b7b4d712
--- /dev/null
+++ b/Formatter/Dprint/tests/fixtures/expected.ps1
@@ -0,0 +1,3 @@
+function Get-Greeting($Name) {
+ Write-Output "Hello, $Name!"
+}
diff --git a/Formatter/Dprint/tests/fixtures/input.ps1 b/Formatter/Dprint/tests/fixtures/input.ps1
new file mode 100644
index 000000000..39a104815
--- /dev/null
+++ b/Formatter/Dprint/tests/fixtures/input.ps1
@@ -0,0 +1 @@
+function Get-Greeting($Name){Write-Output "Hello, $Name!"}
diff --git a/Formatter/Dprint/tests/input.ps1 b/Formatter/Dprint/tests/input.ps1
new file mode 100644
index 000000000..0b7b4d712
--- /dev/null
+++ b/Formatter/Dprint/tests/input.ps1
@@ -0,0 +1,3 @@
+function Get-Greeting($Name) {
+ Write-Output "Hello, $Name!"
+}
diff --git a/Formatter/Dprint/tests/unknown-config.json b/Formatter/Dprint/tests/unknown-config.json
new file mode 100644
index 000000000..dddf11935
--- /dev/null
+++ b/Formatter/Dprint/tests/unknown-config.json
@@ -0,0 +1,8 @@
+{
+ "plugins": [
+ "../bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm"
+ ],
+ "powershell": {
+ "unknownProperty": true
+ }
+}
diff --git a/Formatter/README.md b/Formatter/README.md
new file mode 100644
index 000000000..26847cfb7
--- /dev/null
+++ b/Formatter/README.md
@@ -0,0 +1,35 @@
+# Portable PowerShell formatter
+
+The formatter projects are grouped here so their portable runtime boundary is visible in the
+repository layout:
+
+- `Core` contains the parser-backed C# formatter and its public .NET API.
+- `Core.Tests` runs the dependency-free native formatter checks.
+- `Wasm` publishes the browser and Node.js AppBundle and npm package.
+- `Dprint` publishes the directly loadable dprint `plugin.wasm`.
+
+Both WebAssembly hosts call the same `Core` implementation. Neither creates a PowerShell runspace
+or executes the source being formatted.
+
+Build all formatter targets through the repository build module:
+
+```sh
+mise exec -- pwsh -File ./build.ps1 -Formatter -Configuration Release
+```
+
+`./build.ps1 -All` also includes the formatter targets after building the PowerShell 5 and 7 module
+variants.
+
+Format supported repository files from the root with the pinned toolchain:
+
+```sh
+mise exec -- dprint fmt
+```
+
+The root `.dprint.jsonc` loads the plugin produced by the formatter build above and applies the
+repository's Allman, four-space PowerShell style. Native dprint plugins handle JSON, Markdown,
+JavaScript/TypeScript, YAML, and shell files; `dprint-plugin-exec` delegates C# and MSBuild XML to
+CSharpier, C/C++ to clang-format, and TOML to tombi.
+
+See [WebAssembly formatter development](../docs/FormatterWasm.md) for architecture, build,
+validation, and release details.
diff --git a/Formatter/Wasm/Formatter.Wasm.csproj b/Formatter/Wasm/Formatter.Wasm.csproj
new file mode 100644
index 000000000..57e2c3487
--- /dev/null
+++ b/Formatter/Wasm/Formatter.Wasm.csproj
@@ -0,0 +1,38 @@
+
+
+ net8.0
+ browser-wasm
+ Exe
+ true
+ index.mjs
+ true
+ true
+ false
+ false
+ none
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Formatter/Wasm/FormatterJsonContext.cs b/Formatter/Wasm/FormatterJsonContext.cs
new file mode 100644
index 000000000..da4f985c4
--- /dev/null
+++ b/Formatter/Wasm/FormatterJsonContext.cs
@@ -0,0 +1,13 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.PowerShell.ScriptAnalyzer.Formatter;
+
+namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter.Wasm;
+
+[JsonSourceGenerationOptions(
+ JsonSerializerDefaults.Web,
+ Converters = new[] { typeof(JsonStringEnumConverter) }
+)]
+[JsonSerializable(typeof(FormatterOptions))]
+[JsonSerializable(typeof(FormatterResult))]
+internal partial class FormatterJsonContext : JsonSerializerContext { }
diff --git a/Formatter/Wasm/LICENSE b/Formatter/Wasm/LICENSE
new file mode 100644
index 000000000..4718a6efb
--- /dev/null
+++ b/Formatter/Wasm/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) Microsoft Corporation.
+Copyright (c) Kaj Kowalski.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE
diff --git a/Formatter/Wasm/Program.cs b/Formatter/Wasm/Program.cs
new file mode 100644
index 000000000..e005b9463
--- /dev/null
+++ b/Formatter/Wasm/Program.cs
@@ -0,0 +1,23 @@
+using System.Runtime.InteropServices.JavaScript;
+using System.Runtime.Versioning;
+using System.Text.Json;
+using Microsoft.PowerShell.ScriptAnalyzer.Formatter;
+
+namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter.Wasm;
+
+[SupportedOSPlatform("browser")]
+public partial class Program
+{
+ public static void Main() { }
+
+ [JSExport]
+ internal static string Format(string source, string optionsJson)
+ {
+ var options = string.IsNullOrWhiteSpace(optionsJson)
+ ? new FormatterOptions()
+ : JsonSerializer.Deserialize(optionsJson, FormatterJsonContext.Default.FormatterOptions)
+ ?? new FormatterOptions();
+ var result = PowerShellFormatter.Format(source, options);
+ return JsonSerializer.Serialize(result, FormatterJsonContext.Default.FormatterResult);
+ }
+}
diff --git a/Formatter/Wasm/README.md b/Formatter/Wasm/README.md
new file mode 100644
index 000000000..3f29c412f
--- /dev/null
+++ b/Formatter/Wasm/README.md
@@ -0,0 +1,38 @@
+# PowerShell formatter for WebAssembly
+
+This package runs a parser-backed PowerShell formatter in browsers and Node.js. It does not create a runspace or execute the input script.
+
+```js
+import { format } from "@psscriptanalyzer/formatter-wasm";
+
+const result = await format("IF($x-EQ 1){'yes'}");
+console.log(result.text);
+```
+
+Formatting is skipped when PowerShell reports a parse error; those errors are returned in `result.errors`.
+
+Options use camel-case names. For example, `{ braceStyle: "nextLine", indentSize: 2 }` selects Allman-style braces and two-space indentation.
+
+## Scope
+
+The portable core formats script-block braces, indentation, operator and separator whitespace, and keyword/operator casing. It intentionally has no dependency on PSScriptAnalyzer's cmdlet host, rule discovery, session state, filesystem, or command metadata.
+
+This is a small browser-safe formatter core, not yet a byte-for-byte port of every `Invoke-Formatter` rule. Assignment alignment and command/parameter casing are the main remaining parity gaps; command casing will need an injected command catalog rather than a live PowerShell session.
+
+Build the package with:
+
+```sh
+dotnet publish Formatter/Wasm/Formatter.Wasm.csproj -c Release
+```
+
+The publishable package is written to `Formatter/Wasm/bin/Release/net8.0/browser-wasm/AppBundle`.
+
+Release tags use the package-specific `npm-` namespace. Download and install the GitHub
+release tarball directly with:
+
+```sh
+npm install https://github.com/kjanat/PSScriptAnalyzer/releases/download/npm-0.1.0/psscriptanalyzer-formatter-wasm-0.1.0.tgz
+```
+
+See [WebAssembly formatter development](../../docs/FormatterWasm.md) for the architecture, complete
+API reference, parity details, testing, and troubleshooting.
diff --git a/Formatter/Wasm/index.d.ts b/Formatter/Wasm/index.d.ts
new file mode 100644
index 000000000..3f08c5027
--- /dev/null
+++ b/Formatter/Wasm/index.d.ts
@@ -0,0 +1,36 @@
+export type BraceStyle = "sameLine" | "nextLine";
+
+export interface FormatterOptions {
+ /** Placement of script-block opening braces. Defaults to `"sameLine"`. */
+ braceStyle?: BraceStyle;
+ /** Spaces per indentation level, from 0 through 32. Ignored when `useTabs` is true. */
+ indentSize?: number;
+ /** Indent with tabs instead of spaces. Defaults to false. */
+ useTabs?: boolean;
+ /** Lowercase PowerShell keywords and operators. Defaults to true. */
+ correctKeywordCasing?: boolean;
+ /** Add spaces around binary and assignment operators. Defaults to true. */
+ spaceAroundOperators?: boolean;
+ /** Add spaces around pipeline and pipeline-chain operators. Defaults to true. */
+ spaceAroundPipe?: boolean;
+ /** Add a space after commas and semicolons. Defaults to true. */
+ spaceAfterSeparator?: boolean;
+}
+
+export interface FormatterParseError {
+ message: string;
+ errorId: string;
+ startOffset: number;
+ endOffset: number;
+ startLine: number;
+ startColumn: number;
+}
+
+export interface FormatterResult {
+ /** Formatted source, or the unchanged input when parsing fails. */
+ text: string;
+ errors: FormatterParseError[];
+}
+
+/** Format a complete PowerShell source string. */
+export function format(source: string, options?: FormatterOptions): Promise;
diff --git a/Formatter/Wasm/index.mjs b/Formatter/Wasm/index.mjs
new file mode 100644
index 000000000..f64ac0534
--- /dev/null
+++ b/Formatter/Wasm/index.mjs
@@ -0,0 +1,33 @@
+import { dotnet } from "./_framework/dotnet.js";
+
+let formatterPromise;
+
+/** Load and cache the .NET WebAssembly runtime and exported formatter. */
+async function getFormatter() {
+ formatterPromise ??= dotnet.create().then(async runtime => {
+ const config = runtime.getConfig();
+ const exports = await runtime.getAssemblyExports(config.mainAssemblyName);
+ return exports.Microsoft.PowerShell.ScriptAnalyzer.Formatter.Wasm.Program;
+ });
+ return formatterPromise;
+}
+
+/**
+ * Format a complete PowerShell source string.
+ *
+ * Input containing PowerShell parser errors is returned unchanged and the
+ * errors are included in the result.
+ *
+ * @param {string} source PowerShell source text.
+ * @param {object} [options={}] Camel-case formatter options.
+ * @returns {Promise<{text: string, errors: Array