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}>} The formatter result. + * @throws {TypeError} If source is not a string. + */ +export async function format(source, options = {}) { + if (typeof source !== "string") { + throw new TypeError("source must be a string"); + } + + const formatter = await getFormatter(); + return JSON.parse(formatter.Format(source, JSON.stringify(options))); +} diff --git a/Formatter/Wasm/package.json b/Formatter/Wasm/package.json new file mode 100644 index 000000000..8a130a055 --- /dev/null +++ b/Formatter/Wasm/package.json @@ -0,0 +1,33 @@ +{ + "name": "@psscriptanalyzer/formatter-wasm", + "version": "0.1.0", + "description": "Parser-backed PowerShell formatter for browsers and Node.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/kjanat/PSScriptAnalyzer.git", + "directory": "Formatter/Wasm" + }, + "type": "module", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.mjs" + }, + "./package.json": "./package.json" + }, + "imports": { + "#pkg": "./package.json" + }, + "files": [ + "index.d.ts", + "index.mjs", + "_framework", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=20" + } +} diff --git a/Formatter/Wasm/release-notes.md b/Formatter/Wasm/release-notes.md new file mode 100644 index 000000000..ff3dc1ab1 --- /dev/null +++ b/Formatter/Wasm/release-notes.md @@ -0,0 +1,12 @@ +## Install + +Download the `.tgz` asset, then install it directly: + +```sh +npm install ./psscriptanalyzer-formatter-wasm-.tgz +``` + +The package contains the parser-backed PowerShell formatter for browsers and Node.js, including its +.NET WebAssembly runtime, TypeScript declarations, and MIT license. The license identifies Microsoft +Corporation and Kaj Kowalski as copyright holders. The package does not start `pwsh`, `dotnet`, or +another formatter process at runtime. diff --git a/Formatter/Wasm/scripts/Test-Package.ps1 b/Formatter/Wasm/scripts/Test-Package.ps1 new file mode 100644 index 000000000..8a8c44e05 --- /dev/null +++ b/Formatter/Wasm/scripts/Test-Package.ps1 @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [string] $PackageDirectory +) + +function Stop-Workflow +{ + param ( + [Parameter(Mandatory)] + [string] $Message + ) + + "::error::$Message" + throw $Message +} + +function Assert-PackageField +{ + param ( + [Parameter(Mandatory)] + [string] $Name, + + [AllowNull()] + [object] $Actual, + + [Parameter(Mandatory)] + [string] $Expected + ) + + if ([string] $Actual -cne $Expected) + { + $actualValue = if ($null -eq $Actual) + { + "" + } + else + { + "'$Actual'" + } + Stop-Workflow "$Name must be '$Expected'; found $actualValue." + } +} + +if (-not (Test-Path -LiteralPath $PackageDirectory -PathType Container)) +{ + Stop-Workflow "Package directory '$PackageDirectory' does not exist." +} + +$packagePath = Join-Path $PackageDirectory "package.json" +try +{ + $package = Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json +} +catch +{ + Stop-Workflow "Reading package metadata from '$packagePath' failed: $($_.Exception.Message)" +} + +Assert-PackageField "types" $package.types "./index.d.ts" +Assert-PackageField "exports[.].types" $package.exports.".".types "./index.d.ts" +Assert-PackageField "exports[.].default" $package.exports.".".default "./index.mjs" +Assert-PackageField "exports[./package.json]" $package.exports."./package.json" "./package.json" +Assert-PackageField "imports[#pkg]" $package.imports."#pkg" "./package.json" + +Push-Location $PackageDirectory +try +{ + @' +import { format } from "./index.mjs"; +const first = await format("IF($x-EQ 1){'yes'}"); +const second = await format(first.text); +if (first.errors.length || second.text !== first.text) process.exit(1); +'@ | node --input-type=module + + if ($LASTEXITCODE -ne 0) + { + Stop-Workflow "The Node.js formatter validation failed with exit code $LASTEXITCODE." + } +} +finally +{ + Pop-Location +} diff --git a/PSScriptAnalyzer.sln b/PSScriptAnalyzer.sln index 7c71056a6..d50151d83 100644 --- a/PSScriptAnalyzer.sln +++ b/PSScriptAnalyzer.sln @@ -9,6 +9,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rules", "Rules\Rules.csproj EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PSCompatibilityCollector", "PSCompatibilityCollector\Microsoft.PowerShell.CrossCompatibility\Microsoft.PowerShell.CrossCompatibility.csproj", "{0A219FDB-79ED-402F-9B98-24389A1CCF9E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Formatter", "Formatter", "{6961A9C0-F130-4CBE-AF41-DEFCE3CE35F4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Formatter.Core", "Formatter\Core\Formatter.Core.csproj", "{6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Formatter.Core.Tests", "Formatter\Core.Tests\Formatter.Core.Tests.csproj", "{9FC1AA39-758A-45FC-975A-C93BF57A0F96}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Formatter.Wasm", "Formatter\Wasm\Formatter.Wasm.csproj", "{F3434B19-1673-482A-8441-B44037C6DF30}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Formatter.Dprint", "Formatter\Dprint\Formatter.Dprint.csproj", "{2E10316E-884C-4024-BD62-4AF94776B160}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +65,54 @@ Global {0A219FDB-79ED-402F-9B98-24389A1CCF9E}.Release|x64.Build.0 = Release|Any CPU {0A219FDB-79ED-402F-9B98-24389A1CCF9E}.Release|x86.ActiveCfg = Release|Any CPU {0A219FDB-79ED-402F-9B98-24389A1CCF9E}.Release|x86.Build.0 = Release|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Debug|x64.Build.0 = Debug|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Debug|x86.Build.0 = Debug|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Release|Any CPU.Build.0 = Release|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Release|x64.ActiveCfg = Release|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Release|x64.Build.0 = Release|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Release|x86.ActiveCfg = Release|Any CPU + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D}.Release|x86.Build.0 = Release|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Debug|x64.ActiveCfg = Debug|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Debug|x64.Build.0 = Debug|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Debug|x86.ActiveCfg = Debug|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Debug|x86.Build.0 = Debug|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Release|Any CPU.Build.0 = Release|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Release|x64.ActiveCfg = Release|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Release|x64.Build.0 = Release|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Release|x86.ActiveCfg = Release|Any CPU + {9FC1AA39-758A-45FC-975A-C93BF57A0F96}.Release|x86.Build.0 = Release|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Debug|x64.ActiveCfg = Debug|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Debug|x64.Build.0 = Debug|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Debug|x86.Build.0 = Debug|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Release|Any CPU.Build.0 = Release|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Release|x64.ActiveCfg = Release|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Release|x64.Build.0 = Release|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Release|x86.ActiveCfg = Release|Any CPU + {F3434B19-1673-482A-8441-B44037C6DF30}.Release|x86.Build.0 = Release|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Debug|x64.Build.0 = Debug|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Debug|x86.Build.0 = Debug|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Release|Any CPU.Build.0 = Release|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Release|x64.ActiveCfg = Release|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Release|x64.Build.0 = Release|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Release|x86.ActiveCfg = Release|Any CPU + {2E10316E-884C-4024-BD62-4AF94776B160}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -62,4 +120,10 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8354D5F1-95D7-48B3-B4BF-DD7AACDAA5BA} EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6C4FDAF7-7AAC-46F1-8036-AB4D20BF190D} = {6961A9C0-F130-4CBE-AF41-DEFCE3CE35F4} + {9FC1AA39-758A-45FC-975A-C93BF57A0F96} = {6961A9C0-F130-4CBE-AF41-DEFCE3CE35F4} + {F3434B19-1673-482A-8441-B44037C6DF30} = {6961A9C0-F130-4CBE-AF41-DEFCE3CE35F4} + {2E10316E-884C-4024-BD62-4AF94776B160} = {6961A9C0-F130-4CBE-AF41-DEFCE3CE35F4} + EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 24f2704ff..957369394 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Introduction](#introduction) - [Documentation Notice](#documentation-notice) - [Installation](#installation) +- [WebAssembly formatter development](#webassembly-formatter-development) - [Contributions are welcome](#contributions-are-welcome) - [Creating a Release](#creating-a-release) - [Code of Conduct](#code-of-conduct) @@ -182,6 +183,16 @@ Get-TestFailures The documentation in this section can be found in [Using PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/using-scriptanalyzer). +## WebAssembly formatter development + +The experimental formatter under `Formatter/` provides +parser-backed PowerShell formatting for browsers, Node.js, and a directly loadable dprint +`plugin.wasm` without creating a PowerShell runspace. See +[WebAssembly formatter development](docs/FormatterWasm.md) for its architecture, API, build and test +workflow, and current compatibility with `Invoke-Formatter`. + +[Back to ToC](#table-of-contents) + ## Contributions are welcome There are many ways to contribute: diff --git a/build.ps1 b/build.ps1 index 5dade48fe..af7d02672 100644 --- a/build.ps1 +++ b/build.ps1 @@ -12,9 +12,13 @@ param( [Parameter(ParameterSetName="BuildOne")] [Parameter(ParameterSetName="BuildAll")] + [Parameter(ParameterSetName="BuildFormatter")] [ValidateSet("Debug", "Release")] [string]$Configuration = "Debug", + [Parameter(Mandatory=$true, ParameterSetName="BuildFormatter")] + [switch]$Formatter, + # For building documentation only # or re-building it since docs gets built automatically only the first time [Parameter(ParameterSetName="BuildDocumentation")] @@ -82,6 +86,9 @@ END { } Start-ScriptAnalyzerBuild @buildArgs } + "BuildFormatter" { + Start-FormatterBuild -Configuration $Configuration -Verbose:$verboseWanted + } "Package" { Start-CreatePackage } diff --git a/build.psm1 b/build.psm1 index 041b207a9..36007f02f 100644 --- a/build.psm1 +++ b/build.psm1 @@ -81,6 +81,61 @@ function Copy-CompatibilityProfiles Copy-Item -Force $profileDir/* $targetProfileDir } +# Build the portable formatter, browser/Node.js package, and direct dprint plugin. +function Start-FormatterBuild +{ + [CmdletBinding()] + param ( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Debug" + ) + + if (-not $script:DotnetExe) + { + throw "The dotnet CLI is required to build the formatter projects." + } + + $targets = @( + @{ Verb = "build"; Project = "Formatter/Core.Tests/Formatter.Core.Tests.csproj" }, + @{ Verb = "publish"; Project = "Formatter/Wasm/Formatter.Wasm.csproj" } + ) + if ($IsLinux) + { + $targets += @{ Verb = "publish"; Project = "Formatter/Dprint/Formatter.Dprint.csproj" } + } + else + { + Write-Verbose -Message "Skipping the Linux-built direct dprint plugin." + } + + Push-Location -Path $projectRoot + try + { + foreach ($target in $targets) + { + $arguments = @( + $target.Verb, + $target.Project, + "--configuration", + $Configuration, + "--source", + "https://api.nuget.org/v3/index.json" + ) + Write-Verbose -Message "$($target.Verb) $($target.Project)" + $buildOutput = & $script:DotnetExe $arguments 2>&1 + if ($LASTEXITCODE -ne 0) + { + throw ($buildOutput -join [Environment]::NewLine) + } + Write-Verbose -Message "$buildOutput" + } + } + finally + { + Pop-Location + } +} + # build script analyzer (and optionally build everything with -All) function Start-ScriptAnalyzerBuild { @@ -128,6 +183,7 @@ function Start-ScriptAnalyzerBuild Write-Verbose -Verbose -Message "Configuration: $Configuration PSVersion: $psVersion" Start-ScriptAnalyzerBuild -Configuration $Configuration -PSVersion $psVersion -Verbose:$verboseWanted } + Start-FormatterBuild -Configuration $Configuration -Verbose:$verboseWanted if ( $Catalog ) { New-Catalog -Location $script:destinationDir } diff --git a/docs/FormatterWasm.md b/docs/FormatterWasm.md new file mode 100644 index 000000000..f9d823b55 --- /dev/null +++ b/docs/FormatterWasm.md @@ -0,0 +1,229 @@ +# WebAssembly formatter development + +The WebAssembly formatter provides PowerShell-aware formatting in browsers, Node.js, and dprint +without starting a PowerShell runspace. It uses PowerShell's parser for token and syntax +information, but keeps formatting policy in a small host-independent assembly. + +## Repository layout + +- `Formatter/Core` contains the formatter, options, result types, and text-edit implementation. It + depends on `System.Management.Automation` for the parser and has no dependency on the existing + PSScriptAnalyzer Engine or Rules projects. +- `Formatter/Wasm` contains the browser-WASM host, JSON serialization boundary, JavaScript module, + and npm package metadata. +- `Formatter/Dprint` contains the single-file .NET WASI module, dprint schema-version-4 ABI bridge, + configuration schema, and end-to-end dprint checks. +- `Formatter/Core.Tests` is a dependency-free native test executable covering representative + formatting and error cases. + +The call path is: + +```text +JavaScript format(source, options) + -> JSExport string boundary + -> PowerShellFormatter.Format + -> System.Management.Automation.Language.Parser +``` + +The dprint call path reuses the same formatter: + +```text +dprint -> plugin.wasm -> native Mono bridge -> PowerShellFormatter.Format +``` + +The WebAssembly boundary only passes strings. Options enter as JSON and results leave as JSON, +which avoids exposing managed objects or PowerShell runtime types to JavaScript. + +## Build + +Build the complete formatter family through the repository build entrypoint: + +```sh +mise exec -- pwsh -File ./build.ps1 -Formatter -Configuration Release +``` + +The existing `./build.ps1 -All` path also builds these targets once after its PowerShell 5 and 7 +module builds. + +Use the .NET SDK selected by `global.json` and install the WebAssembly workload once: + +```sh +dotnet workload install wasm-tools +dotnet publish Formatter/Wasm/Formatter.Wasm.csproj -c Release +``` + +The publishable npm package is written to: + +```text +Formatter/Wasm/bin/Release/net8.0/browser-wasm/AppBundle +``` + +The dprint plugin is built separately as one directly loadable module. The tool versions are pinned +in `mise.toml`: + +```sh +mise install +mise exec -- dotnet workload install wasi-experimental \ + --skip-manifest-update \ + --source https://api.nuget.org/v3/index.json +mise exec -- dotnet publish Formatter/Dprint/Formatter.Dprint.csproj \ + -c Release \ + --source https://api.nuget.org/v3/index.json +``` + +Its release artifact is +`Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm`. Unlike the browser AppBundle, +it embeds the managed assemblies into the module and implements dprint's exported memory/protocol +ABI. Runtime WASI calls are resolved inside the module; its only host import is `env.fd_write`, +which dprint provides. + +`System.Management.Automation` 7.4 does not provide a `browser-wasm` runtime asset. The WASM project +therefore references its Unix .NET 8 implementation explicitly. The implementation is compatible +with browser WASM for the parser-only surface used here. Publishing trims unused managed code and +uses invariant globalization to reduce the bundle. + +PowerShell initializes its built-in CIM type accelerators through reflection while parsing typed +scripts. The direct dprint bundle therefore preserves the required +`Microsoft.Management.Infrastructure` types and explicitly embeds the Unix runtime assembly after +WASI dependency resolution; otherwise real scripts with attributes or type constraints fail before +formatting. + +The .NET trimmer reports warnings from code elsewhere in `System.Management.Automation` and its +dependencies. These warnings are expected for the parser-only build; the formatter paths are +covered by native and WASM execution tests. + +## JavaScript API + +Import the module from the published package and await `format`: + +```js +import { format } from "@psscriptanalyzer/formatter-wasm"; + +const result = await format("IF($value-EQ 1){'yes'}", { + braceStyle: "sameLine", + indentSize: 4, +}); + +if (result.errors.length === 0) { + console.log(result.text); +} +``` + +Runtime initialization is lazy and cached. The first call loads .NET and the formatter assemblies; +later calls reuse that runtime. + +### Options + +| JavaScript property | Type | Default | Effect | +| ---------------------- | ---------------------------- | ------------ | --------------------------------------------- | +| `braceStyle` | `"sameLine"` or `"nextLine"` | `"sameLine"` | Places script-block opening braces. | +| `indentSize` | integer from 0 through 32 | `4` | Sets spaces per indentation level. | +| `useTabs` | boolean | `false` | Uses one tab per indentation level. | +| `correctKeywordCasing` | boolean | `true` | Lowercases PowerShell keywords and operators. | +| `spaceAroundOperators` | boolean | `true` | Spaces binary and assignment operators. | +| `spaceAroundPipe` | boolean | `true` | Spaces pipeline and pipeline-chain operators. | +| `spaceAfterSeparator` | boolean | `true` | Spaces after commas and semicolons. | + +When `useTabs` is true, `indentSize` does not affect indentation. + +### Result + +`format` resolves to an object with these properties: + +- `text`: formatted PowerShell source, or unchanged input when parsing fails. +- `errors`: PowerShell parser diagnostics. An empty array means parsing succeeded. + +Each parser error contains `message`, `errorId`, `startOffset`, `endOffset`, `startLine`, and +`startColumn`. Offsets are zero-based; lines and columns are one-based. + +Passing a non-string source throws `TypeError`. An `indentSize` outside 0 through 32 rejects the +format operation with a managed argument error. + +## .NET API + +Projects that can host .NET directly may reference `Formatter/Core` without using WebAssembly: + +```csharp +using Microsoft.PowerShell.ScriptAnalyzer.Formatter; + +FormatterResult result = PowerShellFormatter.Format( + "function Test { 'ok' }", + new FormatterOptions { BraceStyle = BraceStyle.NextLine, IndentSize = 2 } +); +``` + +`PowerShellFormatter.Format` never executes the source. If the initial parser pass reports an +error, it returns the input unchanged with those diagnostics. It also reparses the formatted output +and returns any resulting errors. + +## Formatting scope and PSScriptAnalyzer parity + +The formatter deliberately does not load the current `Formatter`, `ScriptAnalyzer`, or Rules +assemblies. Those components assume a cmdlet host, a live session state, reflection-based rule +discovery, and filesystem-backed settings. Keeping those dependencies outside the portable core is +the main isolation boundary. + +| Existing default rule | Portable support | +| ---------------------------- | ----------------------------------------------------------- | +| `PSPlaceOpenBrace` | Script-block brace placement; one-line blocks are expanded. | +| `PSPlaceCloseBrace` | Closing-brace placement and cuddled branch keywords. | +| `PSUseConsistentWhitespace` | Operators, pipelines, commas, and semicolons. | +| `PSUseConsistentIndentation` | Brace-depth indentation with tabs or spaces. | +| `PSAlignAssignmentStatement` | Not implemented. | +| `PSUseCorrectCasing` | Keywords and operators only. | + +Command and parameter casing is not available because the existing rule obtains canonical names +from a live PowerShell session. Portable support should use an injected command catalog rather than +reintroducing a runspace. Range formatting and PSScriptAnalyzer settings files are also not yet +supported. + +Multiline token contents, including here-strings, are protected from indentation rewriting. +Hashtable braces remain inline while whitespace inside hashtables can still be normalized. + +## Security boundary + +The formatter parses text and applies offset-based text edits. It does not invoke commands, evaluate +expressions, import modules, inspect the filesystem, or query command metadata. Consumers should +still treat formatted text as untrusted source code: formatting does not validate that a script is +safe to execute. + +## Validate changes + +Run the native checks: + +```sh +dotnet run --project Formatter/Core.Tests/Formatter.Core.Tests.csproj +``` + +Publish the package, then test the actual WebAssembly entry point from the `AppBundle` directory: + +```sh +node --input-type=module -e ' +import("./index.mjs").then(async ({ format }) => { + const first = await format("IF($x-EQ 1){\u0027yes\u0027}"); + const second = await format(first.text); + if (first.errors.length || second.text !== first.text) process.exitCode = 1; +});' +``` + +The idempotence check catches edit ordering and reparsing regressions that a compile-only WASM test +would miss. + +Run the direct dprint-module checks separately: + +```sh +mise exec -- Formatter/Dprint/scripts/e2e.sh +``` + +That suite validates the actual `plugin.wasm` import/export surface and metadata, generated schema, +real dprint formatting, idempotence, configuration diagnostics, and invalid UTF-8 handling. + +## Release namespaces + +Dprint releases use `dprint-` tags such as `dprint-0.1.1`. Browser and Node.js package +releases use the separate `npm-` namespace. + +The dprint proxy selects the newest non-draft, non-prerelease GitHub release when producing +`latest.json`. The npm workflow therefore publishes its GitHub release as a prerelease. This keeps +the npm tarball fully downloadable while preventing it from being mistaken for a dprint plugin +release. The dprint workflow explicitly marks its prefixed release as GitHub's latest release. diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..e71f9c700 --- /dev/null +++ b/mise.toml @@ -0,0 +1,10 @@ +[tools] +dotnet = "8.0.419" +node = "26.7.0" +dprint = "0.55.2" +tombi = "1.2.7" +clang-format = "22.1.8" +"github:WebAssembly/wasi-sdk" = "wasi-sdk-20" + +[env] +WASI_SDK_PATH = "{{xdg_data_home}}/mise/installs/github-web-assembly-wasi-sdk/wasi-sdk-20"