From 49a8ef4fb500ba8dcc9516d206e609c3a2779e16 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 8 Aug 2026 04:03:49 +0200 Subject: [PATCH 01/11] Add browser-compatible PowerShell formatter Provide a parser-backed formatter core without runspace or session-state requirements, plus a trimmed WebAssembly package for browsers and Node.js. --- .../Formatter.Core.Tests.csproj | 14 + Formatter.Core.Tests/Program.cs | 61 ++++ Formatter.Core/Formatter.Core.csproj | 15 + Formatter.Core/FormatterOptions.cs | 24 ++ Formatter.Core/FormatterResult.cs | 11 + Formatter.Core/PowerShellFormatter.cs | 286 ++++++++++++++++++ Formatter.Core/TextEdit.cs | 30 ++ Formatter.Wasm/Formatter.Wasm.csproj | 30 ++ Formatter.Wasm/FormatterJsonContext.cs | 14 + Formatter.Wasm/Program.cs | 25 ++ Formatter.Wasm/README.md | 28 ++ Formatter.Wasm/index.mjs | 21 ++ Formatter.Wasm/package.json | 14 + 13 files changed, 573 insertions(+) create mode 100644 Formatter.Core.Tests/Formatter.Core.Tests.csproj create mode 100644 Formatter.Core.Tests/Program.cs create mode 100644 Formatter.Core/Formatter.Core.csproj create mode 100644 Formatter.Core/FormatterOptions.cs create mode 100644 Formatter.Core/FormatterResult.cs create mode 100644 Formatter.Core/PowerShellFormatter.cs create mode 100644 Formatter.Core/TextEdit.cs create mode 100644 Formatter.Wasm/Formatter.Wasm.csproj create mode 100644 Formatter.Wasm/FormatterJsonContext.cs create mode 100644 Formatter.Wasm/Program.cs create mode 100644 Formatter.Wasm/README.md create mode 100644 Formatter.Wasm/index.mjs create mode 100644 Formatter.Wasm/package.json diff --git a/Formatter.Core.Tests/Formatter.Core.Tests.csproj b/Formatter.Core.Tests/Formatter.Core.Tests.csproj new file mode 100644 index 000000000..d938ae59e --- /dev/null +++ b/Formatter.Core.Tests/Formatter.Core.Tests.csproj @@ -0,0 +1,14 @@ + + + + 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..817d42c15 --- /dev/null +++ b/Formatter.Core.Tests/Program.cs @@ -0,0 +1,61 @@ +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", + "function Test { 'ok' }", + "function Test\n{\n 'ok'\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}"); + +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("7 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..bc667cc78 --- /dev/null +++ b/Formatter.Core/Formatter.Core.csproj @@ -0,0 +1,15 @@ + + + + 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..6cf96cabd --- /dev/null +++ b/Formatter.Core/FormatterOptions.cs @@ -0,0 +1,24 @@ +namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter; + +public sealed class FormatterOptions +{ + public BraceStyle BraceStyle { get; set; } = BraceStyle.SameLine; + + public int IndentSize { get; set; } = 4; + + public bool UseTabs { get; set; } + + public bool CorrectKeywordCasing { get; set; } = true; + + public bool SpaceAroundOperators { get; set; } = true; + + public bool SpaceAroundPipe { get; set; } = true; + + public bool SpaceAfterSeparator { get; set; } = true; +} + +public enum BraceStyle +{ + SameLine, + NextLine, +} diff --git a/Formatter.Core/FormatterResult.cs b/Formatter.Core/FormatterResult.cs new file mode 100644 index 000000000..e9cd9ed57 --- /dev/null +++ b/Formatter.Core/FormatterResult.cs @@ -0,0 +1,11 @@ +namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter; + +public sealed record FormatterResult(string Text, IReadOnlyList Errors); + +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..54656dd63 --- /dev/null +++ b/Formatter.Core/PowerShellFormatter.cs @@ -0,0 +1,286 @@ +using System.Management.Automation.Language; +using System.Text; + +namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter; + +public static class PowerShellFormatter +{ + private const TokenFlags OperatorFlags = + TokenFlags.AssignmentOperator | + TokenFlags.BinaryOperator; + + 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)) + { + ReplaceWhitespaceBetween(source, token, next, " ", 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 = first.Kind == TokenKind.RCurly ? 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 (token.Kind is TokenKind.LCurly or TokenKind.AtCurly) + { + depth++; + } + else if (token.Kind == TokenKind.RCurly) + { + 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 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..3facab88f --- /dev/null +++ b/Formatter.Core/TextEdit.cs @@ -0,0 +1,30 @@ +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.Wasm/Formatter.Wasm.csproj b/Formatter.Wasm/Formatter.Wasm.csproj new file mode 100644 index 000000000..d8d6df1dd --- /dev/null +++ b/Formatter.Wasm/Formatter.Wasm.csproj @@ -0,0 +1,30 @@ + + + + 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..4fca2d76a --- /dev/null +++ b/Formatter.Wasm/FormatterJsonContext.cs @@ -0,0 +1,14 @@ +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/Program.cs b/Formatter.Wasm/Program.cs new file mode 100644 index 000000000..1cdb98838 --- /dev/null +++ b/Formatter.Wasm/Program.cs @@ -0,0 +1,25 @@ +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..ad93039bb --- /dev/null +++ b/Formatter.Wasm/README.md @@ -0,0 +1,28 @@ +# 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`. diff --git a/Formatter.Wasm/index.mjs b/Formatter.Wasm/index.mjs new file mode 100644 index 000000000..bcb783099 --- /dev/null +++ b/Formatter.Wasm/index.mjs @@ -0,0 +1,21 @@ +import { dotnet } from "./_framework/dotnet.js"; + +let formatterPromise; + +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; +} + +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..a65eb5999 --- /dev/null +++ b/Formatter.Wasm/package.json @@ -0,0 +1,14 @@ +{ + "name": "@psscriptanalyzer/formatter-wasm", + "version": "0.1.0", + "type": "module", + "exports": "./index.mjs", + "files": [ + "index.mjs", + "_framework", + "README.md" + ], + "engines": { + "node": ">=20" + } +} From db0fc4dd7c3459fe56461ad780d56e88632d77c3 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 8 Aug 2026 04:09:46 +0200 Subject: [PATCH 02/11] Document WebAssembly formatter architecture Describe the API, isolation boundary, build workflow, security model, and current `Invoke-Formatter` parity so contributors can extend and validate the portable formatter without reverse-engineering the code. --- Formatter.Core/FormatterOptions.cs | 17 +++ Formatter.Core/FormatterResult.cs | 13 ++ Formatter.Core/PowerShellFormatter.cs | 11 ++ Formatter.Wasm/README.md | 5 +- Formatter.Wasm/index.mjs | 12 ++ README.md | 10 ++ docs/FormatterWasm.md | 171 ++++++++++++++++++++++++++ 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 docs/FormatterWasm.md diff --git a/Formatter.Core/FormatterOptions.cs b/Formatter.Core/FormatterOptions.cs index 6cf96cabd..5763d1d01 100644 --- a/Formatter.Core/FormatterOptions.cs +++ b/Formatter.Core/FormatterOptions.cs @@ -1,24 +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 index e9cd9ed57..6d0ea003c 100644 --- a/Formatter.Core/FormatterResult.cs +++ b/Formatter.Core/FormatterResult.cs @@ -1,7 +1,20 @@ 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, diff --git a/Formatter.Core/PowerShellFormatter.cs b/Formatter.Core/PowerShellFormatter.cs index 54656dd63..d7dd8ff6d 100644 --- a/Formatter.Core/PowerShellFormatter.cs +++ b/Formatter.Core/PowerShellFormatter.cs @@ -3,12 +3,23 @@ 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); diff --git a/Formatter.Wasm/README.md b/Formatter.Wasm/README.md index ad93039bb..1996446f9 100644 --- a/Formatter.Wasm/README.md +++ b/Formatter.Wasm/README.md @@ -3,7 +3,7 @@ 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"; +import { format } from '@psscriptanalyzer/formatter-wasm'; const result = await format("IF($x-EQ 1){'yes'}"); console.log(result.text); @@ -26,3 +26,6 @@ dotnet publish Formatter.Wasm/Formatter.Wasm.csproj -c Release ``` The publishable package is written to `Formatter.Wasm/bin/Release/net8.0/browser-wasm/AppBundle`. + +See [WebAssembly formatter development](../docs/FormatterWasm.md) for the architecture, complete +API reference, parity details, testing, and troubleshooting. diff --git a/Formatter.Wasm/index.mjs b/Formatter.Wasm/index.mjs index bcb783099..d0a3b1409 100644 --- a/Formatter.Wasm/index.mjs +++ b/Formatter.Wasm/index.mjs @@ -2,6 +2,7 @@ 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(); @@ -11,6 +12,17 @@ async function getFormatter() { 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"); diff --git a/README.md b/README.md index 24f2704ff..a7e8ef2d7 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,15 @@ 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 in `Formatter.Core` and `Formatter.Wasm` provides parser-backed +PowerShell formatting for browsers and Node.js 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/docs/FormatterWasm.md b/docs/FormatterWasm.md new file mode 100644 index 000000000..8e81936a5 --- /dev/null +++ b/docs/FormatterWasm.md @@ -0,0 +1,171 @@ +# WebAssembly formatter development + +The WebAssembly formatter provides PowerShell-aware formatting in browsers and Node.js 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.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 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 + +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 +``` + +`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. + +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. From 81cd4b45e30df05ff8135e52dc46c0086c332022 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 8 Aug 2026 04:23:43 +0200 Subject: [PATCH 03/11] dprint-plugin-powershell --- Formatter.Dprint/.cargo/config.toml | 7 + Formatter.Dprint/Cargo.toml | 49 +++ Formatter.Dprint/README.md | 30 ++ Formatter.Dprint/rust-toolchain.toml | 4 + Formatter.Dprint/scripts/e2e.sh | 16 + Formatter.Dprint/src/formatter.rs | 437 +++++++++++++++++++ Formatter.Dprint/src/lib.rs | 156 +++++++ Formatter.Dprint/src/schema.rs | 39 ++ Formatter.Dprint/tests/fixtures/expected.ps1 | 6 + Formatter.Dprint/tests/fixtures/input.ps1 | 6 + Formatter.Dprint/tests/plugin.rs | 88 ++++ 11 files changed, 838 insertions(+) create mode 100644 Formatter.Dprint/.cargo/config.toml create mode 100644 Formatter.Dprint/Cargo.toml create mode 100644 Formatter.Dprint/README.md create mode 100644 Formatter.Dprint/rust-toolchain.toml create mode 100644 Formatter.Dprint/scripts/e2e.sh create mode 100644 Formatter.Dprint/src/formatter.rs create mode 100644 Formatter.Dprint/src/lib.rs create mode 100644 Formatter.Dprint/src/schema.rs create mode 100644 Formatter.Dprint/tests/fixtures/expected.ps1 create mode 100644 Formatter.Dprint/tests/fixtures/input.ps1 create mode 100644 Formatter.Dprint/tests/plugin.rs diff --git a/Formatter.Dprint/.cargo/config.toml b/Formatter.Dprint/.cargo/config.toml new file mode 100644 index 000000000..a5f6da3cd --- /dev/null +++ b/Formatter.Dprint/.cargo/config.toml @@ -0,0 +1,7 @@ +[target.wasm32-unknown-unknown] +rustflags = ["-Clink-args=-z stack-size=10485760"] + +[alias] +wasm = "build --profile wasm-release --target wasm32-unknown-unknown" +check-wasm = "check --lib --target wasm32-unknown-unknown" +lint = "clippy --all-targets -- -D warnings" diff --git a/Formatter.Dprint/Cargo.toml b/Formatter.Dprint/Cargo.toml new file mode 100644 index 000000000..9e0decb9b --- /dev/null +++ b/Formatter.Dprint/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "dprint-plugin-powershell" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "dprint Wasm plugin for PowerShell formatting" +readme = "README.md" +repository = "https://github.com/kjanat/PSScriptAnalyzer" +license = "MIT" +keywords = ["dprint", "formatter", "powershell", "wasm"] +categories = ["development-tools", "text-editors"] +publish = false +autobins = false + +[lib] +crate-type = ["cdylib", "lib"] +name = "dprint_plugin_powershell" +path = "src/lib.rs" + +[[bin]] +name = "generate-schema" +path = "src/bin/generate-schema.rs" +required-features = ["schema"] + +[dependencies] +anyhow = "1" +dprint-core = { version = "0.67", features = ["wasm"] } +json-schema-sort = { + version = "0.1", + default-features = false, + optional = true +} +schemars = { version = "1", optional = true } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +tree-sitter = "0.26" +tree-sitter-pwsh = "0.38" + +[features] +default = [] +schema = ["dep:json-schema-sort", "dep:schemars"] + +[profile.wasm-release] +inherits = "release" +opt-level = "z" +strip = true +lto = "fat" +panic = "abort" +codegen-units = 1 diff --git a/Formatter.Dprint/README.md b/Formatter.Dprint/README.md new file mode 100644 index 000000000..26b0eeb18 --- /dev/null +++ b/Formatter.Dprint/README.md @@ -0,0 +1,30 @@ +# dprint PowerShell plugin + +This is the standalone dprint WebAssembly adapter for the portable PowerShell formatter. It is a +single sandboxed `plugin.wasm`; it does not use the .NET browser runtime or a process plugin. + +Build and test it with: + +```sh +cargo test --manifest-path Formatter.Dprint/Cargo.toml +cargo build --manifest-path Formatter.Dprint/Cargo.toml --profile wasm-release --target wasm32-unknown-unknown +Formatter.Dprint/scripts/e2e.sh +``` + +Use the local artifact in `dprint.json`: + +```jsonc +{ + "powerShell": { + "braceStyle": "sameLine", + "indentWidth": 4 + }, + "plugins": [ + "./Formatter.Dprint/target/wasm32-unknown-unknown/wasm-release/dprint_plugin_powershell.wasm" + ] +} +``` + +The plugin formats `.ps1`, `.psm1`, and `.psd1` files. It inherits `indentWidth` and `useTabs` from +dprint's global configuration and returns no change for invalid PowerShell syntax or range-format +requests. diff --git a/Formatter.Dprint/rust-toolchain.toml b/Formatter.Dprint/rust-toolchain.toml new file mode 100644 index 000000000..77f06cdb9 --- /dev/null +++ b/Formatter.Dprint/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] +targets = ["wasm32-unknown-unknown"] diff --git a/Formatter.Dprint/scripts/e2e.sh b/Formatter.Dprint/scripts/e2e.sh new file mode 100644 index 000000000..184f37fb0 --- /dev/null +++ b/Formatter.Dprint/scripts/e2e.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -eu + +crate_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +artifact="$crate_dir/target/wasm32-unknown-unknown/wasm-release/dprint_plugin_powershell.wasm" +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +cargo build --manifest-path "$crate_dir/Cargo.toml" --profile wasm-release --target wasm32-unknown-unknown +cp "$crate_dir/tests/fixtures/input.ps1" "$work_dir/input.ps1" + +config_file="$work_dir/dprint.json" +printf '{"powerShell":{},"plugins":["%s"]}\n' "$artifact" >"$config_file" +dprint fmt --config "$config_file" "$work_dir/input.ps1" +diff -u "$crate_dir/tests/fixtures/expected.ps1" "$work_dir/input.ps1" +dprint check --config "$config_file" "$work_dir/input.ps1" diff --git a/Formatter.Dprint/src/formatter.rs b/Formatter.Dprint/src/formatter.rs new file mode 100644 index 000000000..59fc00eac --- /dev/null +++ b/Formatter.Dprint/src/formatter.rs @@ -0,0 +1,437 @@ +use anyhow::{Context, Result}; +use tree_sitter::Parser; + +use crate::{BraceStyle, Configuration}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Kind { + Word, + Operator, + Pipe, + Separator, + OpenBrace, + HashOpen, + CloseBrace, + Comment, + Literal, + Other, +} + +#[derive(Clone, Debug)] +struct Token { + start: usize, + end: usize, + kind: Kind, +} + +#[derive(Clone, Debug)] +struct Edit { + start: usize, + end: usize, + text: String, +} + +pub fn format(source: &str, config: &Configuration) -> Result { + if !is_parseable(source)? { + return Ok(source.to_string()); + } + + let mut text = format_braces(source, config); + text = format_whitespace(&text, config); + text = format_indentation(&text, config); + if config.correct_keyword_casing { + text = format_casing(&text); + } + Ok(text) +} + +fn is_parseable(source: &str) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_pwsh::LANGUAGE.into()) + .context("failed loading the PowerShell parser")?; + let tree = parser + .parse(source, None) + .context("PowerShell parser returned no syntax tree")?; + Ok(!tree.root_node().has_error()) +} + +fn format_braces(source: &str, config: &Configuration) -> String { + let tokens = tokenize(source); + let newline = detect_newline(source); + let mut edits = Vec::new(); + let mut hash_depth = Vec::new(); + let mut hash_closes = std::collections::HashSet::new(); + + for (index, token) in tokens.iter().enumerate() { + match token.kind { + Kind::HashOpen => hash_depth.push(true), + Kind::OpenBrace => hash_depth.push(false), + Kind::CloseBrace => { + if hash_depth.pop() == Some(true) { + hash_closes.insert(index); + } + } + _ => {} + } + } + + for (index, token) in tokens.iter().enumerate() { + if token.kind == Kind::OpenBrace { + if let Some(previous) = previous_token(&tokens, index) { + replace_whitespace( + source, + previous, + token, + match config.brace_style { + BraceStyle::SameLine => " ", + BraceStyle::NextLine => newline, + }, + &mut edits, + ); + } + if let Some(next) = next_token(&tokens, index) + && next.kind != Kind::CloseBrace + { + replace_whitespace(source, token, next, newline, &mut edits); + } + } else if token.kind == Kind::CloseBrace && !hash_closes.contains(&index) { + if let Some(previous) = previous_token(&tokens, index) + && previous.kind != Kind::OpenBrace + { + replace_whitespace(source, previous, token, newline, &mut edits); + } + if let Some(next) = next_token(&tokens, index) + && next.kind == Kind::Word + && is_cuddled_keyword(&source[next.start..next.end]) + { + replace_whitespace(source, token, next, " ", &mut edits); + } + } + } + + apply_edits(source, edits) +} + +fn format_whitespace(source: &str, config: &Configuration) -> String { + let tokens = tokenize(source); + let mut edits = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + let around = (token.kind == Kind::Operator && config.space_around_operators) + || (token.kind == Kind::Pipe && config.space_around_pipe); + if around { + if let Some(previous) = previous_token(&tokens, index) { + replace_whitespace(source, previous, token, " ", &mut edits); + } + if let Some(next) = next_token(&tokens, index) { + replace_whitespace(source, token, next, " ", &mut edits); + } + } else if token.kind == Kind::Separator + && config.space_after_separator + && let Some(next) = next_token(&tokens, index) + { + replace_whitespace(source, token, next, " ", &mut edits); + } + } + apply_edits(source, edits) +} + +fn format_indentation(source: &str, config: &Configuration) -> String { + let newline = detect_newline(source); + let terminal_newline = source.ends_with('\n'); + let normalized = source.replace("\r\n", "\n"); + let tokens = tokenize(&normalized); + let mut lines: Vec = normalized.split('\n').map(str::to_string).collect(); + let mut depth = 0usize; + + for (line_index, line) in lines.iter_mut().enumerate() { + let start = normalized + .split_inclusive('\n') + .take(line_index) + .map(str::len) + .sum::(); + let end = start + line.len(); + let line_tokens: Vec<_> = tokens + .iter() + .filter(|token| token.start >= start && token.start < end) + .collect(); + if line_tokens.is_empty() { + continue; + } + let first = line_tokens[0]; + let line_depth = if first.kind == Kind::CloseBrace { + depth.saturating_sub(1) + } else { + depth + }; + let content = line.trim_start_matches([' ', '\t']); + if !content.is_empty() { + let indent = if config.use_tabs { + "\t".repeat(line_depth) + } else { + " ".repeat(line_depth * usize::from(config.indent_width)) + }; + *line = format!("{indent}{content}"); + } + for token in line_tokens { + match token.kind { + Kind::OpenBrace | Kind::HashOpen => depth += 1, + Kind::CloseBrace => depth = depth.saturating_sub(1), + _ => {} + } + } + } + + let mut result = lines.join(newline); + if terminal_newline && !result.ends_with(newline) { + result.push_str(newline); + } + result +} + +fn format_casing(source: &str) -> String { + let edits = tokenize(source) + .into_iter() + .filter(|token| token.kind == Kind::Word || token.kind == Kind::Operator) + .filter_map(|token| { + let text = &source[token.start..token.end]; + let lower = text.to_ascii_lowercase(); + (lower != text && (token.kind == Kind::Operator || is_keyword(text))).then_some(Edit { + start: token.start, + end: token.end, + text: lower, + }) + }) + .collect(); + apply_edits(source, edits) +} + +fn tokenize(source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut tokens = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index].is_ascii_whitespace() { + index += 1; + continue; + } + let start = index; + let (end, kind) = match bytes[index] { + b'#' => (scan_until(bytes, index + 1, b'\n'), Kind::Comment), + b'<' if bytes.get(index + 1) == Some(&b'#') => { + (scan_pair(bytes, index + 2, b'#', b'>'), Kind::Comment) + } + b'\'' | b'"' => (scan_quoted(bytes, index, bytes[index]), Kind::Literal), + b'@' if matches!(bytes.get(index + 1), Some(b'\'') | Some(b'"')) => ( + scan_here_string(bytes, index, bytes[index + 1]), + Kind::Literal, + ), + b'@' if bytes.get(index + 1) == Some(&b'{') => (index + 2, Kind::HashOpen), + b'{' => (index + 1, Kind::OpenBrace), + b'}' => (index + 1, Kind::CloseBrace), + b',' | b';' => (index + 1, Kind::Separator), + b'|' => ( + index + usize::from(bytes.get(index + 1) == Some(&b'|')) + 1, + Kind::Pipe, + ), + b'&' if bytes.get(index + 1) == Some(&b'&') => (index + 2, Kind::Pipe), + b'=' | b'+' | b'*' | b'/' | b'%' | b'!' | b'?' => { + (scan_operator(bytes, index), Kind::Operator) + } + b'-' if bytes.get(index + 1).is_some_and(u8::is_ascii_alphabetic) => { + (scan_word(bytes, index), Kind::Operator) + } + byte if byte.is_ascii_alphabetic() || byte == b'_' => { + (scan_word(bytes, index), Kind::Word) + } + _ => (scan_other(bytes, index), Kind::Other), + }; + tokens.push(Token { start, end, kind }); + index = end.max(index + 1); + } + tokens +} + +fn scan_until(bytes: &[u8], mut index: usize, end: u8) -> usize { + while index < bytes.len() && bytes[index] != end { + index += 1; + } + index +} + +fn scan_pair(bytes: &[u8], mut index: usize, first: u8, second: u8) -> usize { + while index + 1 < bytes.len() { + if bytes[index] == first && bytes[index + 1] == second { + return index + 2; + } + index += 1; + } + bytes.len() +} + +fn scan_quoted(bytes: &[u8], mut index: usize, quote: u8) -> usize { + index += 1; + while index < bytes.len() { + if bytes[index] == b'`' { + index += 2; + } else if bytes[index] == quote { + if bytes.get(index + 1) == Some("e) { + index += 2; + } else { + return index + 1; + } + } else { + index += 1; + } + } + bytes.len() +} + +fn scan_here_string(bytes: &[u8], index: usize, quote: u8) -> usize { + let closing = [quote, b'@']; + let mut cursor = index + 2; + while cursor + 1 < bytes.len() { + if bytes[cursor..].starts_with(&closing) + && (cursor == 0 || bytes[cursor - 1] == b'\n' || bytes[cursor - 1] == b'\r') + { + return cursor + 2; + } + cursor += 1; + } + bytes.len() +} + +fn scan_operator(bytes: &[u8], index: usize) -> usize { + let mut end = index + 1; + while end < bytes.len() && b"=+*/%!?.".contains(&bytes[end]) { + end += 1; + } + end +} + +fn scan_word(bytes: &[u8], mut index: usize) -> usize { + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'_' | b'-')) + { + index += 1; + } + index +} + +fn scan_other(bytes: &[u8], mut index: usize) -> usize { + index += 1; + while index < bytes.len() + && !bytes[index].is_ascii_whitespace() + && !b"{}@,;|&=+*/%!?\"'".contains(&bytes[index]) + { + index += 1; + } + index +} + +fn previous_token(tokens: &[Token], index: usize) -> Option<&Token> { + tokens[..index] + .iter() + .rev() + .find(|token| token.kind != Kind::Comment) +} + +fn next_token(tokens: &[Token], index: usize) -> Option<&Token> { + tokens[index + 1..] + .iter() + .find(|token| token.kind != Kind::Comment) +} + +fn replace_whitespace( + source: &str, + left: &Token, + right: &Token, + replacement: &str, + edits: &mut Vec, +) { + if right.start < left.end { + return; + } + let current = &source[left.end..right.start]; + if current.chars().all(char::is_whitespace) && current != replacement { + edits.push(Edit { + start: left.end, + end: right.start, + text: replacement.to_string(), + }); + } +} + +fn apply_edits(source: &str, mut edits: Vec) -> String { + edits.sort_by(|left, right| right.start.cmp(&left.start).then(right.end.cmp(&left.end))); + let mut result = source.to_string(); + let mut previous_start = source.len(); + for edit in edits { + if edit.end <= previous_start { + result.replace_range(edit.start..edit.end, &edit.text); + previous_start = edit.start; + } + } + result +} + +fn detect_newline(source: &str) -> &'static str { + if source.contains("\r\n") { + "\r\n" + } else { + "\n" + } +} + +fn is_cuddled_keyword(text: &str) -> bool { + matches_ignore_ascii_case(text, &["else", "elseif", "catch", "finally"]) +} + +fn is_keyword(text: &str) -> bool { + matches_ignore_ascii_case( + text, + &[ + "begin", + "break", + "catch", + "class", + "clean", + "continue", + "data", + "do", + "dynamicparam", + "else", + "elseif", + "end", + "enum", + "exit", + "filter", + "finally", + "for", + "foreach", + "from", + "function", + "hidden", + "if", + "in", + "param", + "process", + "return", + "static", + "switch", + "throw", + "trap", + "try", + "until", + "using", + "var", + "while", + "workflow", + ], + ) +} + +fn matches_ignore_ascii_case(text: &str, values: &[&str]) -> bool { + values.iter().any(|value| text.eq_ignore_ascii_case(value)) +} diff --git a/Formatter.Dprint/src/lib.rs b/Formatter.Dprint/src/lib.rs new file mode 100644 index 000000000..bf4da5623 --- /dev/null +++ b/Formatter.Dprint/src/lib.rs @@ -0,0 +1,156 @@ +mod formatter; + +#[cfg(feature = "schema")] +pub mod schema; + +use anyhow::anyhow; +use dprint_core::configuration::{ + ConfigKeyMap, ConfigurationDiagnostic, GlobalConfiguration, get_unknown_property_diagnostics, + get_value, +}; +use dprint_core::plugins::{ + CheckConfigUpdatesMessage, ConfigChange, FileMatchingInfo, FormatResult, PluginInfo, + PluginResolveConfigurationResult, SyncFormatRequest, SyncHostFormatRequest, SyncPluginHandler, +}; +use serde::{Deserialize, Serialize}; + +pub const SCHEMA_URL: &str = concat!( + "https://github.com/kjanat/PSScriptAnalyzer/releases/download/dprint-powershell-", + env!("CARGO_PKG_VERSION"), + "/schema.json" +); +pub const UPDATE_URL: &str = "https://github.com/kjanat/PSScriptAnalyzer/releases/latest/download/dprint-powershell-latest.json"; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum BraceStyle { + #[default] + SameLine, + NextLine, +} +dprint_core::generate_str_to_from![BraceStyle, [SameLine, "sameLine"], [NextLine, "nextLine"]]; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Configuration { + pub brace_style: BraceStyle, + pub indent_width: u8, + pub use_tabs: bool, + pub correct_keyword_casing: bool, + pub space_around_operators: bool, + pub space_around_pipe: bool, + pub space_after_separator: bool, +} + +pub struct PowerShellPluginHandler; + +impl SyncPluginHandler for PowerShellPluginHandler { + fn plugin_info(&mut self) -> PluginInfo { + PluginInfo { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + config_key: "powerShell".to_string(), + help_url: env!("CARGO_PKG_REPOSITORY").to_string(), + config_schema_url: SCHEMA_URL.to_string(), + update_url: Some(UPDATE_URL.to_string()), + } + } + + fn license_text(&mut self) -> String { + include_str!("../../LICENSE").to_string() + } + + fn resolve_config( + &mut self, + mut config: ConfigKeyMap, + global_config: &GlobalConfiguration, + ) -> PluginResolveConfigurationResult { + let mut diagnostics = Vec::::new(); + let brace_style = get_value( + &mut config, + "braceStyle", + BraceStyle::default(), + &mut diagnostics, + ); + let mut indent_width = get_value( + &mut config, + "indentWidth", + global_config.indent_width.unwrap_or(4), + &mut diagnostics, + ); + let use_tabs = get_value( + &mut config, + "useTabs", + global_config.use_tabs.unwrap_or(false), + &mut diagnostics, + ); + let correct_keyword_casing = + get_value(&mut config, "correctKeywordCasing", true, &mut diagnostics); + let space_around_operators = + get_value(&mut config, "spaceAroundOperators", true, &mut diagnostics); + let space_around_pipe = get_value(&mut config, "spaceAroundPipe", true, &mut diagnostics); + let space_after_separator = + get_value(&mut config, "spaceAfterSeparator", true, &mut diagnostics); + + if indent_width > 32 { + diagnostics.push(ConfigurationDiagnostic { + property_name: "indentWidth".to_string(), + message: "Expected a value from 0 through 32.".to_string(), + }); + indent_width = 4; + } + + diagnostics.extend(get_unknown_property_diagnostics(config)); + + PluginResolveConfigurationResult { + file_matching: FileMatchingInfo { + file_extensions: vec!["ps1".into(), "psm1".into(), "psd1".into()], + file_names: Vec::new(), + }, + diagnostics, + config: Configuration { + brace_style, + indent_width, + use_tabs, + correct_keyword_casing, + space_around_operators, + space_around_pipe, + space_after_separator, + }, + } + } + + fn check_config_updates( + &self, + _message: CheckConfigUpdatesMessage, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + fn format( + &mut self, + request: SyncFormatRequest, + _format_with_host: impl FnMut(SyncHostFormatRequest) -> FormatResult, + ) -> FormatResult { + if request.range.is_some() || request.token.is_cancelled() { + return Ok(None); + } + + let source = std::str::from_utf8(&request.file_bytes) + .map_err(|error| anyhow!("file is not valid UTF-8: {error}"))?; + let formatted = formatter::format(source, request.config)?; + if formatted == source || request.token.is_cancelled() { + Ok(None) + } else { + Ok(Some(formatted.into_bytes())) + } + } +} + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +dprint_core::generate_plugin_code!( + PowerShellPluginHandler, + PowerShellPluginHandler, + Configuration +); diff --git a/Formatter.Dprint/src/schema.rs b/Formatter.Dprint/src/schema.rs new file mode 100644 index 000000000..68626fa93 --- /dev/null +++ b/Formatter.Dprint/src/schema.rs @@ -0,0 +1,39 @@ +use schemars::{JsonSchema, Schema, generate::SchemaSettings}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::{BraceStyle, SCHEMA_URL}; + +#[derive(Clone, Debug, Default, Serialize, JsonSchema)] +#[schemars( + title = "dprint PowerShell plugin configuration", + description = "All fields are optional. Indentation options inherit from dprint global configuration." +)] +#[serde(rename_all = "camelCase")] +pub struct DprintPowerShellConfigSchema { + pub locked: Option, + pub brace_style: Option, + #[schemars(range(min = 0, max = 32))] + pub indent_width: Option, + pub use_tabs: Option, + pub correct_keyword_casing: Option, + pub space_around_operators: Option, + pub space_around_pipe: Option, + pub space_after_separator: Option, +} + +pub fn generate_schema_value() -> Result { + let schema: Schema = SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); + let mut value = serde_json::to_value(schema)?; + let object = value + .as_object_mut() + .expect("generated schema should be an object"); + object.insert( + "$schema".to_string(), + json!("http://json-schema.org/draft-07/schema#"), + ); + object.insert("$id".to_string(), json!(SCHEMA_URL)); + Ok(json_schema_sort::sorted_schema(value)) +} diff --git a/Formatter.Dprint/tests/fixtures/expected.ps1 b/Formatter.Dprint/tests/fixtures/expected.ps1 new file mode 100644 index 000000000..de39ec0c0 --- /dev/null +++ b/Formatter.Dprint/tests/fixtures/expected.ps1 @@ -0,0 +1,6 @@ +if ($value -eq 1) { + Write-Output 'yes' +} +else { + Write-Output 'no' +} diff --git a/Formatter.Dprint/tests/fixtures/input.ps1 b/Formatter.Dprint/tests/fixtures/input.ps1 new file mode 100644 index 000000000..de39ec0c0 --- /dev/null +++ b/Formatter.Dprint/tests/fixtures/input.ps1 @@ -0,0 +1,6 @@ +if ($value -eq 1) { + Write-Output 'yes' +} +else { + Write-Output 'no' +} diff --git a/Formatter.Dprint/tests/plugin.rs b/Formatter.Dprint/tests/plugin.rs new file mode 100644 index 000000000..068826f76 --- /dev/null +++ b/Formatter.Dprint/tests/plugin.rs @@ -0,0 +1,88 @@ +use std::path::Path; + +use dprint_core::configuration::{ConfigKeyMap, ConfigKeyValue, GlobalConfiguration}; +use dprint_core::plugins::{ + FormatConfigId, NullCancellationToken, SyncFormatRequest, SyncPluginHandler, +}; +use dprint_plugin_powershell::{Configuration, PowerShellPluginHandler}; + +fn resolve( + config: ConfigKeyMap, +) -> dprint_core::plugins::PluginResolveConfigurationResult { + let mut handler = PowerShellPluginHandler; + handler.resolve_config(config, &GlobalConfiguration::default()) +} + +fn format(config: &Configuration, input: &[u8]) -> anyhow::Result>> { + let mut handler = PowerShellPluginHandler; + let token = NullCancellationToken; + handler.format( + SyncFormatRequest { + file_path: Path::new("test.ps1"), + file_bytes: input.to_vec(), + config_id: FormatConfigId::from_raw(1), + config, + range: None, + token: &token, + }, + |_| Ok(None), + ) +} + +#[test] +fn formats_powershell_and_is_idempotent() { + let resolved = resolve(ConfigKeyMap::new()); + assert!(resolved.diagnostics.is_empty()); + let input = b"IF($x-EQ 1){'yes'}ELSE{'no'}"; + let expected = "if($x -eq 1) {\n 'yes'\n} else {\n 'no'\n}"; + let first = format(&resolved.config, input) + .unwrap() + .expect("first pass should change source"); + assert_eq!(String::from_utf8(first.clone()).unwrap(), expected); + assert!(format(&resolved.config, &first).unwrap().is_none()); +} + +#[test] +fn supports_next_line_braces_and_global_indentation() { + let mut config = ConfigKeyMap::new(); + config.insert( + "braceStyle".into(), + ConfigKeyValue::String("nextLine".into()), + ); + config.insert("indentWidth".into(), ConfigKeyValue::Number(2)); + let resolved = resolve(config); + let output = format(&resolved.config, b"function Test { 'ok' }") + .unwrap() + .expect("format should change source"); + assert_eq!( + String::from_utf8(output).unwrap(), + "function Test\n{\n 'ok'\n}" + ); +} + +#[test] +fn unknown_configuration_is_diagnostic_first() { + let mut config = ConfigKeyMap::new(); + config.insert("indentation".into(), ConfigKeyValue::Number(2)); + let resolved = resolve(config); + assert!( + resolved + .diagnostics + .iter() + .any(|diagnostic| diagnostic.property_name == "indentation") + ); +} + +#[test] +fn invalid_utf8_returns_an_error() { + let resolved = resolve(ConfigKeyMap::new()); + assert!(format(&resolved.config, &[0xff, 0xfe]).is_err()); +} + +#[test] +fn plugin_info_has_release_urls() { + let mut handler = PowerShellPluginHandler; + let info = handler.plugin_info(); + assert!(info.config_schema_url.ends_with("/schema.json")); + assert!(info.update_url.is_some()); +} From daa531e6d287f0196b9627bf41c325387a67627e Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 8 Aug 2026 17:15:23 +0200 Subject: [PATCH 04/11] Add direct dprint PowerShell WASM plugin Compile the C# formatter into one dprint ABI module with an embedded Mono runtime and internal WASI shims, so dprint can load it without a process host. Pin reproducible tools and validate the artifact, schema, diagnostics, UTF-8 handling, and idempotence while documenting both WASM outputs. --- .github/workflows/ci-test.yml | 10 +- .github/workflows/formatter-wasm.yml | 45 ++ Formatter.Dprint/.cargo/config.toml | 7 - Formatter.Dprint/Cargo.toml | 49 --- Formatter.Dprint/Formatter.Dprint.csproj | 62 +++ Formatter.Dprint/Program.cs | 262 +++++++++++ Formatter.Dprint/Properties/AssemblyInfo.cs | 4 + Formatter.Dprint/README.md | 112 ++++- Formatter.Dprint/native/dprint_exports.c | 397 +++++++++++++++++ Formatter.Dprint/native/wasi_stubs.c | 282 ++++++++++++ Formatter.Dprint/runtimeconfig.template.json | 10 + Formatter.Dprint/rust-toolchain.toml | 4 - Formatter.Dprint/schema.json | 46 ++ Formatter.Dprint/scripts/check-plugin.mjs | 59 +++ Formatter.Dprint/scripts/e2e.sh | 66 ++- Formatter.Dprint/scripts/generate-schema.mjs | 28 ++ Formatter.Dprint/src/formatter.rs | 437 ------------------- Formatter.Dprint/src/lib.rs | 156 ------- Formatter.Dprint/src/schema.rs | 39 -- Formatter.Dprint/tests/dprint.json | 6 + Formatter.Dprint/tests/fixtures/expected.ps1 | 7 +- Formatter.Dprint/tests/fixtures/input.ps1 | 7 +- Formatter.Dprint/tests/input.ps1 | 3 + Formatter.Dprint/tests/plugin.rs | 88 ---- Formatter.Dprint/tests/unknown-config.json | 8 + README.md | 5 +- docs/FormatterWasm.md | 42 +- mise.toml | 7 + 28 files changed, 1412 insertions(+), 836 deletions(-) create mode 100644 .github/workflows/formatter-wasm.yml delete mode 100644 Formatter.Dprint/.cargo/config.toml delete mode 100644 Formatter.Dprint/Cargo.toml create mode 100644 Formatter.Dprint/Formatter.Dprint.csproj create mode 100644 Formatter.Dprint/Program.cs create mode 100644 Formatter.Dprint/Properties/AssemblyInfo.cs create mode 100644 Formatter.Dprint/native/dprint_exports.c create mode 100644 Formatter.Dprint/native/wasi_stubs.c create mode 100644 Formatter.Dprint/runtimeconfig.template.json delete mode 100644 Formatter.Dprint/rust-toolchain.toml create mode 100644 Formatter.Dprint/schema.json create mode 100644 Formatter.Dprint/scripts/check-plugin.mjs mode change 100644 => 100755 Formatter.Dprint/scripts/e2e.sh create mode 100644 Formatter.Dprint/scripts/generate-schema.mjs delete mode 100644 Formatter.Dprint/src/formatter.rs delete mode 100644 Formatter.Dprint/src/lib.rs delete mode 100644 Formatter.Dprint/src/schema.rs create mode 100644 Formatter.Dprint/tests/dprint.json create mode 100644 Formatter.Dprint/tests/input.ps1 delete mode 100644 Formatter.Dprint/tests/plugin.rs create mode 100644 Formatter.Dprint/tests/unknown-config.json create mode 100644 mise.toml diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 0a48b30fb..a27debc93 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -19,10 +19,10 @@ jobs: DOTNET_GENERATE_ASPNET_CERTIFICATE: false steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: cache: true cache-dependency-path: '**/*.csproj' @@ -51,7 +51,7 @@ jobs: shell: powershell - name: Download PowerShell install script - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: repository: PowerShell/PowerShell path: pwsh @@ -70,14 +70,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-wasm.yml b/.github/workflows/formatter-wasm.yml new file mode 100644 index 000000000..db225ce96 --- /dev/null +++ b/.github/workflows/formatter-wasm.yml @@ -0,0 +1,45 @@ +name: Formatter WebAssembly + +on: + pull_request: + paths: + - "Formatter.Core/**" + - "Formatter.Dprint/**" + - "Formatter.Wasm/**" + - "mise.toml" + - ".github/workflows/formatter-wasm.yml" + push: + branches: + - main + paths: + - "Formatter.Core/**" + - "Formatter.Dprint/**" + - "Formatter.Wasm/**" + - "mise.toml" + - ".github/workflows/formatter-wasm.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + dprint-plugin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: jdx/mise-action@v4 + - name: Install .NET WASI workload + run: >- + mise exec -- dotnet workload install wasi-experimental + --skip-manifest-update + --source https://api.nuget.org/v3/index.json + - name: Build and validate plugin + run: Formatter.Dprint/scripts/e2e.sh + - name: Upload dprint plugin + uses: actions/upload-artifact@v7 + with: + name: dprint-plugin-powershell + path: | + Formatter.Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm + Formatter.Dprint/schema.json + if-no-files-found: error diff --git a/Formatter.Dprint/.cargo/config.toml b/Formatter.Dprint/.cargo/config.toml deleted file mode 100644 index a5f6da3cd..000000000 --- a/Formatter.Dprint/.cargo/config.toml +++ /dev/null @@ -1,7 +0,0 @@ -[target.wasm32-unknown-unknown] -rustflags = ["-Clink-args=-z stack-size=10485760"] - -[alias] -wasm = "build --profile wasm-release --target wasm32-unknown-unknown" -check-wasm = "check --lib --target wasm32-unknown-unknown" -lint = "clippy --all-targets -- -D warnings" diff --git a/Formatter.Dprint/Cargo.toml b/Formatter.Dprint/Cargo.toml deleted file mode 100644 index 9e0decb9b..000000000 --- a/Formatter.Dprint/Cargo.toml +++ /dev/null @@ -1,49 +0,0 @@ -[package] -name = "dprint-plugin-powershell" -version = "0.1.0" -edition = "2024" -rust-version = "1.85" -description = "dprint Wasm plugin for PowerShell formatting" -readme = "README.md" -repository = "https://github.com/kjanat/PSScriptAnalyzer" -license = "MIT" -keywords = ["dprint", "formatter", "powershell", "wasm"] -categories = ["development-tools", "text-editors"] -publish = false -autobins = false - -[lib] -crate-type = ["cdylib", "lib"] -name = "dprint_plugin_powershell" -path = "src/lib.rs" - -[[bin]] -name = "generate-schema" -path = "src/bin/generate-schema.rs" -required-features = ["schema"] - -[dependencies] -anyhow = "1" -dprint-core = { version = "0.67", features = ["wasm"] } -json-schema-sort = { - version = "0.1", - default-features = false, - optional = true -} -schemars = { version = "1", optional = true } -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } -tree-sitter = "0.26" -tree-sitter-pwsh = "0.38" - -[features] -default = [] -schema = ["dep:json-schema-sort", "dep:schemars"] - -[profile.wasm-release] -inherits = "release" -opt-level = "z" -strip = true -lto = "fat" -panic = "abort" -codegen-units = 1 diff --git a/Formatter.Dprint/Formatter.Dprint.csproj b/Formatter.Dprint/Formatter.Dprint.csproj new file mode 100644 index 000000000..975160166 --- /dev/null +++ b/Formatter.Dprint/Formatter.Dprint.csproj @@ -0,0 +1,62 @@ + + + net8.0 + plugin + wasi-wasm + Exe + true + true + true + enable + enable + + + + + + + + + + + <_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/Program.cs b/Formatter.Dprint/Program.cs new file mode 100644 index 000000000..814b6e102 --- /dev/null +++ b/Formatter.Dprint/Program.cs @@ -0,0 +1,262 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.PowerShell.ScriptAnalyzer.Formatter; + +namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter.Dprint; + +public static class Program +{ + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(Plugin))] + public static void Main() + { + } +} + +public static class Plugin +{ + 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 GetConfigSchema() => """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "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 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..1e407edc8 --- /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 index 26b0eeb18..1328de068 100644 --- a/Formatter.Dprint/README.md +++ b/Formatter.Dprint/README.md @@ -1,30 +1,104 @@ -# dprint PowerShell plugin +# dprint PowerShell formatter plugin -This is the standalone dprint WebAssembly adapter for the portable PowerShell formatter. It is a -single sandboxed `plugin.wasm`; it does not use the .NET browser runtime or a process 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 and test it with: +## Build + +Install the repository-pinned tools and the .NET 8 experimental WASI workload: ```sh -cargo test --manifest-path Formatter.Dprint/Cargo.toml -cargo build --manifest-path Formatter.Dprint/Cargo.toml --profile wasm-release --target wasm32-unknown-unknown -Formatter.Dprint/scripts/e2e.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 ``` -Use the local artifact in `dprint.json`: +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 -```jsonc +Reference the built module in `dprint.json`: + +```json { - "powerShell": { - "braceStyle": "sameLine", - "indentWidth": 4 - }, - "plugins": [ - "./Formatter.Dprint/target/wasm32-unknown-unknown/wasm-release/dprint_plugin_powershell.wasm" - ] + "plugins": [ + "./Formatter.Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm" + ], + "powershell": { + "indentSize": 4, + "braceStyle": "sameLine" + } } ``` -The plugin formats `.ps1`, `.psm1`, and `.psd1` files. It inherits `indentWidth` and `useTabs` from -dprint's global configuration and returns no change for invalid PowerShell syntax or range-format -requests. +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. + +## 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 +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 +``` diff --git a/Formatter.Dprint/native/dprint_exports.c b/Formatter.Dprint/native/dprint_exports.c new file mode 100644 index 000000000..3ec2face5 --- /dev/null +++ b/Formatter.Dprint/native/dprint_exports.c @@ -0,0 +1,397 @@ +#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 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); + if (format_method == NULL || diagnostics_method == NULL || resolved_config_method == NULL || schema_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) { + return write_shared( + "{\"name\":\"dprint-plugin-powershell\",\"version\":\"0.1.0\"," + "\"configKey\":\"powershell\"," + "\"helpUrl\":\"https://github.com/kjanat/PSScriptAnalyzer/tree/wasm-formatter/Formatter.Dprint\"," + "\"configSchemaUrl\":\"https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/0.1.0/schema.json\"," + "\"updateUrl\":\"https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/latest.json\"}" + ); +} + +DPRINT_EXPORT("get_license_text") +uint32_t get_license_text(void) { + return write_shared("MIT License"); +} + +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/runtimeconfig.template.json b/Formatter.Dprint/runtimeconfig.template.json new file mode 100644 index 000000000..1647a418b --- /dev/null +++ b/Formatter.Dprint/runtimeconfig.template.json @@ -0,0 +1,10 @@ +{ + "wasmHostProperties": { + "perHostConfig": [ + { + "name": "wasmtime", + "Host": "wasmtime" + } + ] + } +} diff --git a/Formatter.Dprint/rust-toolchain.toml b/Formatter.Dprint/rust-toolchain.toml deleted file mode 100644 index 77f06cdb9..000000000 --- a/Formatter.Dprint/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "stable" -components = ["clippy", "rustfmt"] -targets = ["wasm32-unknown-unknown"] diff --git a/Formatter.Dprint/schema.json b/Formatter.Dprint/schema.json new file mode 100644 index 000000000..f9c02d7a8 --- /dev/null +++ b/Formatter.Dprint/schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "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..f8f233125 --- /dev/null +++ b/Formatter.Dprint/scripts/check-plugin.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import process from 'node:process'; + +const [pluginPath] = process.argv.slice(2); +if (!pluginPath) { + 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_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.configKey, 'powershell'); +assert.match(info.helpUrl, /^https:\/\//); +assert.match(info.configSchemaUrl, /^https:\/\//); +assert.match(info.updateUrl, /^https:\/\//); + +console.log(`validated ${pluginPath} (${bytes.length} bytes)`); diff --git a/Formatter.Dprint/scripts/e2e.sh b/Formatter.Dprint/scripts/e2e.sh old mode 100644 new mode 100755 index 184f37fb0..fab92c0e8 --- a/Formatter.Dprint/scripts/e2e.sh +++ b/Formatter.Dprint/scripts/e2e.sh @@ -1,16 +1,50 @@ -#!/usr/bin/env sh -set -eu - -crate_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -artifact="$crate_dir/target/wasm32-unknown-unknown/wasm-release/dprint_plugin_powershell.wasm" -work_dir=$(mktemp -d) -trap 'rm -rf "$work_dir"' EXIT - -cargo build --manifest-path "$crate_dir/Cargo.toml" --profile wasm-release --target wasm32-unknown-unknown -cp "$crate_dir/tests/fixtures/input.ps1" "$work_dir/input.ps1" - -config_file="$work_dir/dprint.json" -printf '{"powerShell":{},"plugins":["%s"]}\n' "$artifact" >"$config_file" -dprint fmt --config "$config_file" "$work_dir/input.ps1" -diff -u "$crate_dir/tests/fixtures/expected.ps1" "$work_dir/input.ps1" -dprint check --config "$config_file" "$work_dir/input.ps1" +#!/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" +mise exec -- dotnet publish Formatter.Dprint/Formatter.Dprint.csproj \ + -c Release \ + --source https://api.nuget.org/v3/index.json + +node Formatter.Dprint/scripts/check-plugin.mjs "$plugin" +node Formatter.Dprint/scripts/generate-schema.mjs \ + "$plugin" \ + Formatter.Dprint/schema.json \ + --check + +cd Formatter.Dprint/tests +input=$(< fixtures/input.ps1) +expected=$(< fixtures/expected.ps1) +formatted=$(printf '%s' "$input" | dprint fmt --stdin input.ps1 --config dprint.json) +second=$(printf '%s' "$formatted" | dprint fmt --stdin input.ps1 --config dprint.json) + +if [[ "$formatted" != "$expected" ]]; then + echo "dprint output did not match the fixture" >&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..2528eb60b --- /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/src/formatter.rs b/Formatter.Dprint/src/formatter.rs deleted file mode 100644 index 59fc00eac..000000000 --- a/Formatter.Dprint/src/formatter.rs +++ /dev/null @@ -1,437 +0,0 @@ -use anyhow::{Context, Result}; -use tree_sitter::Parser; - -use crate::{BraceStyle, Configuration}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Kind { - Word, - Operator, - Pipe, - Separator, - OpenBrace, - HashOpen, - CloseBrace, - Comment, - Literal, - Other, -} - -#[derive(Clone, Debug)] -struct Token { - start: usize, - end: usize, - kind: Kind, -} - -#[derive(Clone, Debug)] -struct Edit { - start: usize, - end: usize, - text: String, -} - -pub fn format(source: &str, config: &Configuration) -> Result { - if !is_parseable(source)? { - return Ok(source.to_string()); - } - - let mut text = format_braces(source, config); - text = format_whitespace(&text, config); - text = format_indentation(&text, config); - if config.correct_keyword_casing { - text = format_casing(&text); - } - Ok(text) -} - -fn is_parseable(source: &str) -> Result { - let mut parser = Parser::new(); - parser - .set_language(&tree_sitter_pwsh::LANGUAGE.into()) - .context("failed loading the PowerShell parser")?; - let tree = parser - .parse(source, None) - .context("PowerShell parser returned no syntax tree")?; - Ok(!tree.root_node().has_error()) -} - -fn format_braces(source: &str, config: &Configuration) -> String { - let tokens = tokenize(source); - let newline = detect_newline(source); - let mut edits = Vec::new(); - let mut hash_depth = Vec::new(); - let mut hash_closes = std::collections::HashSet::new(); - - for (index, token) in tokens.iter().enumerate() { - match token.kind { - Kind::HashOpen => hash_depth.push(true), - Kind::OpenBrace => hash_depth.push(false), - Kind::CloseBrace => { - if hash_depth.pop() == Some(true) { - hash_closes.insert(index); - } - } - _ => {} - } - } - - for (index, token) in tokens.iter().enumerate() { - if token.kind == Kind::OpenBrace { - if let Some(previous) = previous_token(&tokens, index) { - replace_whitespace( - source, - previous, - token, - match config.brace_style { - BraceStyle::SameLine => " ", - BraceStyle::NextLine => newline, - }, - &mut edits, - ); - } - if let Some(next) = next_token(&tokens, index) - && next.kind != Kind::CloseBrace - { - replace_whitespace(source, token, next, newline, &mut edits); - } - } else if token.kind == Kind::CloseBrace && !hash_closes.contains(&index) { - if let Some(previous) = previous_token(&tokens, index) - && previous.kind != Kind::OpenBrace - { - replace_whitespace(source, previous, token, newline, &mut edits); - } - if let Some(next) = next_token(&tokens, index) - && next.kind == Kind::Word - && is_cuddled_keyword(&source[next.start..next.end]) - { - replace_whitespace(source, token, next, " ", &mut edits); - } - } - } - - apply_edits(source, edits) -} - -fn format_whitespace(source: &str, config: &Configuration) -> String { - let tokens = tokenize(source); - let mut edits = Vec::new(); - for (index, token) in tokens.iter().enumerate() { - let around = (token.kind == Kind::Operator && config.space_around_operators) - || (token.kind == Kind::Pipe && config.space_around_pipe); - if around { - if let Some(previous) = previous_token(&tokens, index) { - replace_whitespace(source, previous, token, " ", &mut edits); - } - if let Some(next) = next_token(&tokens, index) { - replace_whitespace(source, token, next, " ", &mut edits); - } - } else if token.kind == Kind::Separator - && config.space_after_separator - && let Some(next) = next_token(&tokens, index) - { - replace_whitespace(source, token, next, " ", &mut edits); - } - } - apply_edits(source, edits) -} - -fn format_indentation(source: &str, config: &Configuration) -> String { - let newline = detect_newline(source); - let terminal_newline = source.ends_with('\n'); - let normalized = source.replace("\r\n", "\n"); - let tokens = tokenize(&normalized); - let mut lines: Vec = normalized.split('\n').map(str::to_string).collect(); - let mut depth = 0usize; - - for (line_index, line) in lines.iter_mut().enumerate() { - let start = normalized - .split_inclusive('\n') - .take(line_index) - .map(str::len) - .sum::(); - let end = start + line.len(); - let line_tokens: Vec<_> = tokens - .iter() - .filter(|token| token.start >= start && token.start < end) - .collect(); - if line_tokens.is_empty() { - continue; - } - let first = line_tokens[0]; - let line_depth = if first.kind == Kind::CloseBrace { - depth.saturating_sub(1) - } else { - depth - }; - let content = line.trim_start_matches([' ', '\t']); - if !content.is_empty() { - let indent = if config.use_tabs { - "\t".repeat(line_depth) - } else { - " ".repeat(line_depth * usize::from(config.indent_width)) - }; - *line = format!("{indent}{content}"); - } - for token in line_tokens { - match token.kind { - Kind::OpenBrace | Kind::HashOpen => depth += 1, - Kind::CloseBrace => depth = depth.saturating_sub(1), - _ => {} - } - } - } - - let mut result = lines.join(newline); - if terminal_newline && !result.ends_with(newline) { - result.push_str(newline); - } - result -} - -fn format_casing(source: &str) -> String { - let edits = tokenize(source) - .into_iter() - .filter(|token| token.kind == Kind::Word || token.kind == Kind::Operator) - .filter_map(|token| { - let text = &source[token.start..token.end]; - let lower = text.to_ascii_lowercase(); - (lower != text && (token.kind == Kind::Operator || is_keyword(text))).then_some(Edit { - start: token.start, - end: token.end, - text: lower, - }) - }) - .collect(); - apply_edits(source, edits) -} - -fn tokenize(source: &str) -> Vec { - let bytes = source.as_bytes(); - let mut tokens = Vec::new(); - let mut index = 0; - while index < bytes.len() { - if bytes[index].is_ascii_whitespace() { - index += 1; - continue; - } - let start = index; - let (end, kind) = match bytes[index] { - b'#' => (scan_until(bytes, index + 1, b'\n'), Kind::Comment), - b'<' if bytes.get(index + 1) == Some(&b'#') => { - (scan_pair(bytes, index + 2, b'#', b'>'), Kind::Comment) - } - b'\'' | b'"' => (scan_quoted(bytes, index, bytes[index]), Kind::Literal), - b'@' if matches!(bytes.get(index + 1), Some(b'\'') | Some(b'"')) => ( - scan_here_string(bytes, index, bytes[index + 1]), - Kind::Literal, - ), - b'@' if bytes.get(index + 1) == Some(&b'{') => (index + 2, Kind::HashOpen), - b'{' => (index + 1, Kind::OpenBrace), - b'}' => (index + 1, Kind::CloseBrace), - b',' | b';' => (index + 1, Kind::Separator), - b'|' => ( - index + usize::from(bytes.get(index + 1) == Some(&b'|')) + 1, - Kind::Pipe, - ), - b'&' if bytes.get(index + 1) == Some(&b'&') => (index + 2, Kind::Pipe), - b'=' | b'+' | b'*' | b'/' | b'%' | b'!' | b'?' => { - (scan_operator(bytes, index), Kind::Operator) - } - b'-' if bytes.get(index + 1).is_some_and(u8::is_ascii_alphabetic) => { - (scan_word(bytes, index), Kind::Operator) - } - byte if byte.is_ascii_alphabetic() || byte == b'_' => { - (scan_word(bytes, index), Kind::Word) - } - _ => (scan_other(bytes, index), Kind::Other), - }; - tokens.push(Token { start, end, kind }); - index = end.max(index + 1); - } - tokens -} - -fn scan_until(bytes: &[u8], mut index: usize, end: u8) -> usize { - while index < bytes.len() && bytes[index] != end { - index += 1; - } - index -} - -fn scan_pair(bytes: &[u8], mut index: usize, first: u8, second: u8) -> usize { - while index + 1 < bytes.len() { - if bytes[index] == first && bytes[index + 1] == second { - return index + 2; - } - index += 1; - } - bytes.len() -} - -fn scan_quoted(bytes: &[u8], mut index: usize, quote: u8) -> usize { - index += 1; - while index < bytes.len() { - if bytes[index] == b'`' { - index += 2; - } else if bytes[index] == quote { - if bytes.get(index + 1) == Some("e) { - index += 2; - } else { - return index + 1; - } - } else { - index += 1; - } - } - bytes.len() -} - -fn scan_here_string(bytes: &[u8], index: usize, quote: u8) -> usize { - let closing = [quote, b'@']; - let mut cursor = index + 2; - while cursor + 1 < bytes.len() { - if bytes[cursor..].starts_with(&closing) - && (cursor == 0 || bytes[cursor - 1] == b'\n' || bytes[cursor - 1] == b'\r') - { - return cursor + 2; - } - cursor += 1; - } - bytes.len() -} - -fn scan_operator(bytes: &[u8], index: usize) -> usize { - let mut end = index + 1; - while end < bytes.len() && b"=+*/%!?.".contains(&bytes[end]) { - end += 1; - } - end -} - -fn scan_word(bytes: &[u8], mut index: usize) -> usize { - index += 1; - while index < bytes.len() - && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'_' | b'-')) - { - index += 1; - } - index -} - -fn scan_other(bytes: &[u8], mut index: usize) -> usize { - index += 1; - while index < bytes.len() - && !bytes[index].is_ascii_whitespace() - && !b"{}@,;|&=+*/%!?\"'".contains(&bytes[index]) - { - index += 1; - } - index -} - -fn previous_token(tokens: &[Token], index: usize) -> Option<&Token> { - tokens[..index] - .iter() - .rev() - .find(|token| token.kind != Kind::Comment) -} - -fn next_token(tokens: &[Token], index: usize) -> Option<&Token> { - tokens[index + 1..] - .iter() - .find(|token| token.kind != Kind::Comment) -} - -fn replace_whitespace( - source: &str, - left: &Token, - right: &Token, - replacement: &str, - edits: &mut Vec, -) { - if right.start < left.end { - return; - } - let current = &source[left.end..right.start]; - if current.chars().all(char::is_whitespace) && current != replacement { - edits.push(Edit { - start: left.end, - end: right.start, - text: replacement.to_string(), - }); - } -} - -fn apply_edits(source: &str, mut edits: Vec) -> String { - edits.sort_by(|left, right| right.start.cmp(&left.start).then(right.end.cmp(&left.end))); - let mut result = source.to_string(); - let mut previous_start = source.len(); - for edit in edits { - if edit.end <= previous_start { - result.replace_range(edit.start..edit.end, &edit.text); - previous_start = edit.start; - } - } - result -} - -fn detect_newline(source: &str) -> &'static str { - if source.contains("\r\n") { - "\r\n" - } else { - "\n" - } -} - -fn is_cuddled_keyword(text: &str) -> bool { - matches_ignore_ascii_case(text, &["else", "elseif", "catch", "finally"]) -} - -fn is_keyword(text: &str) -> bool { - matches_ignore_ascii_case( - text, - &[ - "begin", - "break", - "catch", - "class", - "clean", - "continue", - "data", - "do", - "dynamicparam", - "else", - "elseif", - "end", - "enum", - "exit", - "filter", - "finally", - "for", - "foreach", - "from", - "function", - "hidden", - "if", - "in", - "param", - "process", - "return", - "static", - "switch", - "throw", - "trap", - "try", - "until", - "using", - "var", - "while", - "workflow", - ], - ) -} - -fn matches_ignore_ascii_case(text: &str, values: &[&str]) -> bool { - values.iter().any(|value| text.eq_ignore_ascii_case(value)) -} diff --git a/Formatter.Dprint/src/lib.rs b/Formatter.Dprint/src/lib.rs deleted file mode 100644 index bf4da5623..000000000 --- a/Formatter.Dprint/src/lib.rs +++ /dev/null @@ -1,156 +0,0 @@ -mod formatter; - -#[cfg(feature = "schema")] -pub mod schema; - -use anyhow::anyhow; -use dprint_core::configuration::{ - ConfigKeyMap, ConfigurationDiagnostic, GlobalConfiguration, get_unknown_property_diagnostics, - get_value, -}; -use dprint_core::plugins::{ - CheckConfigUpdatesMessage, ConfigChange, FileMatchingInfo, FormatResult, PluginInfo, - PluginResolveConfigurationResult, SyncFormatRequest, SyncHostFormatRequest, SyncPluginHandler, -}; -use serde::{Deserialize, Serialize}; - -pub const SCHEMA_URL: &str = concat!( - "https://github.com/kjanat/PSScriptAnalyzer/releases/download/dprint-powershell-", - env!("CARGO_PKG_VERSION"), - "/schema.json" -); -pub const UPDATE_URL: &str = "https://github.com/kjanat/PSScriptAnalyzer/releases/latest/download/dprint-powershell-latest.json"; - -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum BraceStyle { - #[default] - SameLine, - NextLine, -} -dprint_core::generate_str_to_from![BraceStyle, [SameLine, "sameLine"], [NextLine, "nextLine"]]; - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Configuration { - pub brace_style: BraceStyle, - pub indent_width: u8, - pub use_tabs: bool, - pub correct_keyword_casing: bool, - pub space_around_operators: bool, - pub space_around_pipe: bool, - pub space_after_separator: bool, -} - -pub struct PowerShellPluginHandler; - -impl SyncPluginHandler for PowerShellPluginHandler { - fn plugin_info(&mut self) -> PluginInfo { - PluginInfo { - name: env!("CARGO_PKG_NAME").to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - config_key: "powerShell".to_string(), - help_url: env!("CARGO_PKG_REPOSITORY").to_string(), - config_schema_url: SCHEMA_URL.to_string(), - update_url: Some(UPDATE_URL.to_string()), - } - } - - fn license_text(&mut self) -> String { - include_str!("../../LICENSE").to_string() - } - - fn resolve_config( - &mut self, - mut config: ConfigKeyMap, - global_config: &GlobalConfiguration, - ) -> PluginResolveConfigurationResult { - let mut diagnostics = Vec::::new(); - let brace_style = get_value( - &mut config, - "braceStyle", - BraceStyle::default(), - &mut diagnostics, - ); - let mut indent_width = get_value( - &mut config, - "indentWidth", - global_config.indent_width.unwrap_or(4), - &mut diagnostics, - ); - let use_tabs = get_value( - &mut config, - "useTabs", - global_config.use_tabs.unwrap_or(false), - &mut diagnostics, - ); - let correct_keyword_casing = - get_value(&mut config, "correctKeywordCasing", true, &mut diagnostics); - let space_around_operators = - get_value(&mut config, "spaceAroundOperators", true, &mut diagnostics); - let space_around_pipe = get_value(&mut config, "spaceAroundPipe", true, &mut diagnostics); - let space_after_separator = - get_value(&mut config, "spaceAfterSeparator", true, &mut diagnostics); - - if indent_width > 32 { - diagnostics.push(ConfigurationDiagnostic { - property_name: "indentWidth".to_string(), - message: "Expected a value from 0 through 32.".to_string(), - }); - indent_width = 4; - } - - diagnostics.extend(get_unknown_property_diagnostics(config)); - - PluginResolveConfigurationResult { - file_matching: FileMatchingInfo { - file_extensions: vec!["ps1".into(), "psm1".into(), "psd1".into()], - file_names: Vec::new(), - }, - diagnostics, - config: Configuration { - brace_style, - indent_width, - use_tabs, - correct_keyword_casing, - space_around_operators, - space_around_pipe, - space_after_separator, - }, - } - } - - fn check_config_updates( - &self, - _message: CheckConfigUpdatesMessage, - ) -> anyhow::Result> { - Ok(Vec::new()) - } - - fn format( - &mut self, - request: SyncFormatRequest, - _format_with_host: impl FnMut(SyncHostFormatRequest) -> FormatResult, - ) -> FormatResult { - if request.range.is_some() || request.token.is_cancelled() { - return Ok(None); - } - - let source = std::str::from_utf8(&request.file_bytes) - .map_err(|error| anyhow!("file is not valid UTF-8: {error}"))?; - let formatted = formatter::format(source, request.config)?; - if formatted == source || request.token.is_cancelled() { - Ok(None) - } else { - Ok(Some(formatted.into_bytes())) - } - } -} - -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -dprint_core::generate_plugin_code!( - PowerShellPluginHandler, - PowerShellPluginHandler, - Configuration -); diff --git a/Formatter.Dprint/src/schema.rs b/Formatter.Dprint/src/schema.rs deleted file mode 100644 index 68626fa93..000000000 --- a/Formatter.Dprint/src/schema.rs +++ /dev/null @@ -1,39 +0,0 @@ -use schemars::{JsonSchema, Schema, generate::SchemaSettings}; -use serde::Serialize; -use serde_json::{Value, json}; - -use crate::{BraceStyle, SCHEMA_URL}; - -#[derive(Clone, Debug, Default, Serialize, JsonSchema)] -#[schemars( - title = "dprint PowerShell plugin configuration", - description = "All fields are optional. Indentation options inherit from dprint global configuration." -)] -#[serde(rename_all = "camelCase")] -pub struct DprintPowerShellConfigSchema { - pub locked: Option, - pub brace_style: Option, - #[schemars(range(min = 0, max = 32))] - pub indent_width: Option, - pub use_tabs: Option, - pub correct_keyword_casing: Option, - pub space_around_operators: Option, - pub space_around_pipe: Option, - pub space_after_separator: Option, -} - -pub fn generate_schema_value() -> Result { - let schema: Schema = SchemaSettings::draft07() - .into_generator() - .into_root_schema_for::(); - let mut value = serde_json::to_value(schema)?; - let object = value - .as_object_mut() - .expect("generated schema should be an object"); - object.insert( - "$schema".to_string(), - json!("http://json-schema.org/draft-07/schema#"), - ); - object.insert("$id".to_string(), json!(SCHEMA_URL)); - Ok(json_schema_sort::sorted_schema(value)) -} diff --git a/Formatter.Dprint/tests/dprint.json b/Formatter.Dprint/tests/dprint.json new file mode 100644 index 000000000..332de274b --- /dev/null +++ b/Formatter.Dprint/tests/dprint.json @@ -0,0 +1,6 @@ +{ + "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 index de39ec0c0..0b7b4d712 100644 --- a/Formatter.Dprint/tests/fixtures/expected.ps1 +++ b/Formatter.Dprint/tests/fixtures/expected.ps1 @@ -1,6 +1,3 @@ -if ($value -eq 1) { - Write-Output 'yes' -} -else { - Write-Output 'no' +function Get-Greeting($Name) { + Write-Output "Hello, $Name!" } diff --git a/Formatter.Dprint/tests/fixtures/input.ps1 b/Formatter.Dprint/tests/fixtures/input.ps1 index de39ec0c0..39a104815 100644 --- a/Formatter.Dprint/tests/fixtures/input.ps1 +++ b/Formatter.Dprint/tests/fixtures/input.ps1 @@ -1,6 +1 @@ -if ($value -eq 1) { - Write-Output 'yes' -} -else { - Write-Output 'no' -} +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/plugin.rs b/Formatter.Dprint/tests/plugin.rs deleted file mode 100644 index 068826f76..000000000 --- a/Formatter.Dprint/tests/plugin.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::path::Path; - -use dprint_core::configuration::{ConfigKeyMap, ConfigKeyValue, GlobalConfiguration}; -use dprint_core::plugins::{ - FormatConfigId, NullCancellationToken, SyncFormatRequest, SyncPluginHandler, -}; -use dprint_plugin_powershell::{Configuration, PowerShellPluginHandler}; - -fn resolve( - config: ConfigKeyMap, -) -> dprint_core::plugins::PluginResolveConfigurationResult { - let mut handler = PowerShellPluginHandler; - handler.resolve_config(config, &GlobalConfiguration::default()) -} - -fn format(config: &Configuration, input: &[u8]) -> anyhow::Result>> { - let mut handler = PowerShellPluginHandler; - let token = NullCancellationToken; - handler.format( - SyncFormatRequest { - file_path: Path::new("test.ps1"), - file_bytes: input.to_vec(), - config_id: FormatConfigId::from_raw(1), - config, - range: None, - token: &token, - }, - |_| Ok(None), - ) -} - -#[test] -fn formats_powershell_and_is_idempotent() { - let resolved = resolve(ConfigKeyMap::new()); - assert!(resolved.diagnostics.is_empty()); - let input = b"IF($x-EQ 1){'yes'}ELSE{'no'}"; - let expected = "if($x -eq 1) {\n 'yes'\n} else {\n 'no'\n}"; - let first = format(&resolved.config, input) - .unwrap() - .expect("first pass should change source"); - assert_eq!(String::from_utf8(first.clone()).unwrap(), expected); - assert!(format(&resolved.config, &first).unwrap().is_none()); -} - -#[test] -fn supports_next_line_braces_and_global_indentation() { - let mut config = ConfigKeyMap::new(); - config.insert( - "braceStyle".into(), - ConfigKeyValue::String("nextLine".into()), - ); - config.insert("indentWidth".into(), ConfigKeyValue::Number(2)); - let resolved = resolve(config); - let output = format(&resolved.config, b"function Test { 'ok' }") - .unwrap() - .expect("format should change source"); - assert_eq!( - String::from_utf8(output).unwrap(), - "function Test\n{\n 'ok'\n}" - ); -} - -#[test] -fn unknown_configuration_is_diagnostic_first() { - let mut config = ConfigKeyMap::new(); - config.insert("indentation".into(), ConfigKeyValue::Number(2)); - let resolved = resolve(config); - assert!( - resolved - .diagnostics - .iter() - .any(|diagnostic| diagnostic.property_name == "indentation") - ); -} - -#[test] -fn invalid_utf8_returns_an_error() { - let resolved = resolve(ConfigKeyMap::new()); - assert!(format(&resolved.config, &[0xff, 0xfe]).is_err()); -} - -#[test] -fn plugin_info_has_release_urls() { - let mut handler = PowerShellPluginHandler; - let info = handler.plugin_info(); - assert!(info.config_schema_url.ends_with("/schema.json")); - assert!(info.update_url.is_some()); -} 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/README.md b/README.md index a7e8ef2d7..1b4c4bc5e 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,9 @@ The documentation in this section can be found in ## WebAssembly formatter development -The experimental formatter in `Formatter.Core` and `Formatter.Wasm` provides parser-backed -PowerShell formatting for browsers and Node.js without creating a PowerShell runspace. See +The experimental formatter in `Formatter.Core`, `Formatter.Wasm`, and `Formatter.Dprint` 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`. diff --git a/docs/FormatterWasm.md b/docs/FormatterWasm.md index 8e81936a5..5682129cc 100644 --- a/docs/FormatterWasm.md +++ b/docs/FormatterWasm.md @@ -1,8 +1,8 @@ # WebAssembly formatter development -The WebAssembly formatter provides PowerShell-aware formatting in browsers and Node.js 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. +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 @@ -11,6 +11,8 @@ keeps formatting policy in a small host-independent assembly. 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. @@ -23,6 +25,12 @@ JavaScript format(source, options) -> 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. @@ -41,6 +49,25 @@ The publishable npm package is written to: 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 @@ -169,3 +196,12 @@ import("./index.mjs").then(async ({ format }) => { 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 +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. diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..cee19ba82 --- /dev/null +++ b/mise.toml @@ -0,0 +1,7 @@ +[tools] +dotnet = "8.0.419" +dprint = "0.55.2" +"github:WebAssembly/wasi-sdk" = "wasi-sdk-20" + +[env] +WASI_SDK_PATH = "{{xdg_data_home}}/mise/installs/github-web-assembly-wasi-sdk/wasi-sdk-20" From 1886a7a6a097e35c479b5a24ffe049fce1081943 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 8 Aug 2026 17:41:34 +0200 Subject: [PATCH 05/11] Prepare dprint formatter release Derive published metadata from the assembly version, embed the full MIT license, and validate the schema identity so registry updates stay in lockstep with the release artifact. Document installation and immutable releases, and publish the verified WASM, schema, and checksums only when a bare-semver tag matches the plugin project version. --- .github/workflows/formatter-wasm-release.yml | 58 ++++++++++++++++++++ Formatter.Dprint/Formatter.Dprint.csproj | 3 + Formatter.Dprint/Program.cs | 28 +++++++++- Formatter.Dprint/README.md | 42 ++++++++++++-- Formatter.Dprint/native/dprint_exports.c | 35 +++++++++--- Formatter.Dprint/release-notes.md | 11 ++++ Formatter.Dprint/schema.json | 1 + Formatter.Dprint/scripts/check-plugin.mjs | 28 ++++++++-- Formatter.Dprint/scripts/e2e.sh | 5 +- 9 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/formatter-wasm-release.yml create mode 100644 Formatter.Dprint/release-notes.md diff --git a/.github/workflows/formatter-wasm-release.yml b/.github/workflows/formatter-wasm-release.yml new file mode 100644 index 000000000..01417109e --- /dev/null +++ b/.github/workflows/formatter-wasm-release.yml @@ -0,0 +1,58 @@ +name: Release dprint PowerShell formatter + +on: + push: + tags: ["[0-9]+.[0-9]+.[0-9]+"] + +permissions: + contents: write + +jobs: + release: + if: github.repository == 'kjanat/PSScriptAnalyzer' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up mise and install tools + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + install: true + - name: Verify release version + run: | + version=$(mise exec -- dotnet msbuild Formatter.Dprint/Formatter.Dprint.csproj \ + -nologo \ + -getProperty:Version) + if [[ "$GITHUB_REF_NAME" != "$version" ]]; then + echo "Tag $GITHUB_REF_NAME does not match dprint plugin version $version." >&2 + exit 1 + fi + - name: Install .NET WASI workload + run: >- + mise exec -- dotnet workload install wasi-experimental + --skip-manifest-update + --source https://api.nuget.org/v3/index.json + - name: Build and validate plugin + run: Formatter.Dprint/scripts/e2e.sh + - name: Assemble release assets + run: | + release_dir="$RUNNER_TEMP/dprint-release" + mkdir -p "$release_dir" + cp Formatter.Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm "$release_dir/plugin.wasm" + cp Formatter.Dprint/schema.json "$release_dir/schema.json" + cd "$release_dir" + sha256sum plugin.wasm schema.json > checksums.txt + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + notes=$(< Formatter.Dprint/release-notes.md) + gh release create "$GITHUB_REF_NAME" \ + "$RUNNER_TEMP/dprint-release/plugin.wasm" \ + "$RUNNER_TEMP/dprint-release/schema.json" \ + "$RUNNER_TEMP/dprint-release/checksums.txt" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "dprint PowerShell formatter $GITHUB_REF_NAME" \ + --notes "$notes" \ + --generate-notes diff --git a/Formatter.Dprint/Formatter.Dprint.csproj b/Formatter.Dprint/Formatter.Dprint.csproj index 975160166..d4ec708f9 100644 --- a/Formatter.Dprint/Formatter.Dprint.csproj +++ b/Formatter.Dprint/Formatter.Dprint.csproj @@ -2,6 +2,8 @@ net8.0 plugin + 0.1.0 + https://github.com/kjanat/PSScriptAnalyzer wasi-wasm Exe true @@ -15,6 +17,7 @@ + diff --git a/Formatter.Dprint/Program.cs b/Formatter.Dprint/Program.cs index 814b6e102..dcf8cb756 100644 --- a/Formatter.Dprint/Program.cs +++ b/Formatter.Dprint/Program.cs @@ -16,6 +16,12 @@ 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", @@ -75,9 +81,22 @@ public static string GetResolvedConfig(string configJson) """; } - public static string GetConfigSchema() => """ + 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, @@ -124,6 +143,13 @@ public static string GetConfigSchema() => """ } """; + 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(); diff --git a/Formatter.Dprint/README.md b/Formatter.Dprint/README.md index 1328de068..4c522dfba 100644 --- a/Formatter.Dprint/README.md +++ b/Formatter.Dprint/README.md @@ -34,13 +34,16 @@ dprint protocol bridge. Its only host import is dprint's supported `env.fd_write ## Use with dprint -Reference the built module in `dprint.json`: +Install the latest released plugin from the dprint registry: + +```sh +dprint add kjanat/PSScriptAnalyzer +``` + +Then configure it in `dprint.json`: ```json { - "plugins": [ - "./Formatter.Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm" - ], "powershell": { "indentSize": 4, "braceStyle": "sameLine" @@ -59,6 +62,16 @@ The plugin matches `.ps1`, `.psm1`, and `.psd1` files. Configuration is describe [`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 @@ -102,3 +115,24 @@ 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.csproj`. A release uses the same +bare semantic version for the assembly, schema URL, Git tag, and GitHub release. Do not prefix the +tag with `v` or `dprint-`: the dprint registry resolves the version directly to that GitHub 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 `Formatter.Dprint/scripts/e2e.sh` and commit the version, schema, and related changes. +3. Create and push a signed bare-semver 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`, 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 index 3ec2face5..f18c44417 100644 --- a/Formatter.Dprint/native/dprint_exports.c +++ b/Formatter.Dprint/native/dprint_exports.c @@ -25,6 +25,8 @@ 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; @@ -153,7 +155,10 @@ static int ensure_runtime(void) { 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); - if (format_method == NULL || diagnostics_method == NULL || resolved_config_method == NULL || schema_method == NULL) { + 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; } @@ -292,18 +297,30 @@ uint32_t get_config_schema(void) { DPRINT_EXPORT("get_plugin_info") uint32_t get_plugin_info(void) { - return write_shared( - "{\"name\":\"dprint-plugin-powershell\",\"version\":\"0.1.0\"," - "\"configKey\":\"powershell\"," - "\"helpUrl\":\"https://github.com/kjanat/PSScriptAnalyzer/tree/wasm-formatter/Formatter.Dprint\"," - "\"configSchemaUrl\":\"https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/0.1.0/schema.json\"," - "\"updateUrl\":\"https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/latest.json\"}" - ); + 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) { - return write_shared("MIT License"); + 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") diff --git a/Formatter.Dprint/release-notes.md b/Formatter.Dprint/release-notes.md new file mode 100644 index 000000000..8f45aee09 --- /dev/null +++ b/Formatter.Dprint/release-notes.md @@ -0,0 +1,11 @@ +## Install + +```sh +dprint add kjanat/PSScriptAnalyzer +``` + +The release contains the directly loadable dprint `plugin.wasm`, its configuration `schema.json`, +and SHA-256 checksums for both 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 repository's MIT license. diff --git a/Formatter.Dprint/schema.json b/Formatter.Dprint/schema.json index f9c02d7a8..0063fc6b4 100644 --- a/Formatter.Dprint/schema.json +++ b/Formatter.Dprint/schema.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/0.1.0/schema.json", "title": "dprint PowerShell formatter configuration", "type": "object", "additionalProperties": false, diff --git a/Formatter.Dprint/scripts/check-plugin.mjs b/Formatter.Dprint/scripts/check-plugin.mjs index f8f233125..caecdf848 100644 --- a/Formatter.Dprint/scripts/check-plugin.mjs +++ b/Formatter.Dprint/scripts/check-plugin.mjs @@ -2,9 +2,9 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import process from 'node:process'; -const [pluginPath] = process.argv.slice(2); -if (!pluginPath) { - console.error('usage: node check-plugin.mjs '); +const [pluginPath, expectedVersion, licensePath] = process.argv.slice(2); +if (!pluginPath || !expectedVersion || !licensePath) { + console.error('usage: node check-plugin.mjs '); process.exit(2); } @@ -24,6 +24,7 @@ const requiredExports = [ 'get_config_diagnostics', 'get_resolved_config', 'get_config_file_matching', + 'get_config_schema', 'get_plugin_info', 'get_license_text', 'set_file_path', @@ -51,9 +52,24 @@ const readSharedText = (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.match(info.helpUrl, /^https:\/\//); -assert.match(info.configSchemaUrl, /^https:\/\//); -assert.match(info.updateUrl, /^https:\/\//); +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 index fab92c0e8..f95b071a9 100755 --- a/Formatter.Dprint/scripts/e2e.sh +++ b/Formatter.Dprint/scripts/e2e.sh @@ -10,7 +10,10 @@ mise exec -- dotnet publish Formatter.Dprint/Formatter.Dprint.csproj \ -c Release \ --source https://api.nuget.org/v3/index.json -node Formatter.Dprint/scripts/check-plugin.mjs "$plugin" +version=$(mise exec -- dotnet msbuild Formatter.Dprint/Formatter.Dprint.csproj \ + -nologo \ + -getProperty:Version) +node Formatter.Dprint/scripts/check-plugin.mjs "$plugin" "$version" LICENSE node Formatter.Dprint/scripts/generate-schema.mjs \ "$plugin" \ Formatter.Dprint/schema.json \ From a1aaeebc9f5988731f92bc19f98b929db4cb2da6 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 9 Aug 2026 19:48:36 +0200 Subject: [PATCH 06/11] Package portable PowerShell formatter releases Group the formatter implementations under `Formatter`, expose the browser WASM package through typed ESM exports, and validate it with shared PowerShell release tooling. Add repository-local dprint orchestration and prefixed npm release workflow support so both WASM distributions use the pinned toolchain. --- .clang-format | 3 + .config/dotnet-tools.json | 11 ++ .dprint.jsonc | 94 +++++++++ .github/workflows/formatter-npm-release.yml | 114 +++++++++++ .github/workflows/formatter-wasm-release.yml | 43 ++-- .github/workflows/formatter-wasm.yml | 133 ++++++++++--- Directory.Packages.props | 1 + Formatter.Dprint/release-notes.md | 11 -- Formatter.Dprint/runtimeconfig.template.json | 10 - Formatter.Wasm/package.json | 14 -- .../Core.Tests}/Formatter.Core.Tests.csproj | 4 +- .../Core.Tests}/Program.cs | 34 ++-- .../Core}/Formatter.Core.csproj | 2 - .../Core}/FormatterOptions.cs | 0 .../Core}/FormatterResult.cs | 3 +- .../Core}/PowerShellFormatter.cs | 140 +++++++++---- .../Core}/TextEdit.cs | 6 +- .../Dprint}/Formatter.Dprint.csproj | 33 +++- Formatter/Dprint/LICENSE | 22 +++ .../Dprint}/Program.cs | 186 +++++++++++------- .../Dprint}/Properties/AssemblyInfo.cs | 2 +- .../Dprint}/README.md | 28 +-- .../Dprint}/native/dprint_exports.c | 0 .../Dprint}/native/wasi_stubs.c | 0 Formatter/Dprint/release-notes.md | 13 ++ Formatter/Dprint/runtimeconfig.template.json | 10 + .../Dprint}/schema.json | 2 +- .../Dprint}/scripts/check-plugin.mjs | 54 ++--- .../Dprint}/scripts/e2e.sh | 21 +- .../Dprint}/scripts/generate-schema.mjs | 12 +- .../Dprint}/tests/dprint.json | 1 + .../Dprint}/tests/fixtures/expected.ps1 | 0 .../Dprint}/tests/fixtures/input.ps1 | 0 .../Dprint}/tests/input.ps1 | 0 .../Dprint}/tests/unknown-config.json | 0 Formatter/README.md | 35 ++++ .../Wasm}/Formatter.Wasm.csproj | 22 ++- .../Wasm}/FormatterJsonContext.cs | 7 +- Formatter/Wasm/LICENSE | 22 +++ {Formatter.Wasm => Formatter/Wasm}/Program.cs | 4 +- {Formatter.Wasm => Formatter/Wasm}/README.md | 15 +- Formatter/Wasm/index.d.ts | 36 ++++ {Formatter.Wasm => Formatter/Wasm}/index.mjs | 22 +-- Formatter/Wasm/package.json | 33 ++++ Formatter/Wasm/release-notes.md | 12 ++ Formatter/Wasm/scripts/Test-Package.ps1 | 87 ++++++++ PSScriptAnalyzer.sln | 64 ++++++ README.md | 2 +- build.ps1 | 7 + build.psm1 | 49 +++++ docs/FormatterWasm.md | 63 ++++-- mise.toml | 3 + 52 files changed, 1160 insertions(+), 330 deletions(-) create mode 100644 .clang-format create mode 100644 .config/dotnet-tools.json create mode 100644 .dprint.jsonc create mode 100644 .github/workflows/formatter-npm-release.yml delete mode 100644 Formatter.Dprint/release-notes.md delete mode 100644 Formatter.Dprint/runtimeconfig.template.json delete mode 100644 Formatter.Wasm/package.json rename {Formatter.Core.Tests => Formatter/Core.Tests}/Formatter.Core.Tests.csproj (77%) rename {Formatter.Core.Tests => Formatter/Core.Tests}/Program.cs (63%) rename {Formatter.Core => Formatter/Core}/Formatter.Core.csproj (99%) rename {Formatter.Core => Formatter/Core}/FormatterOptions.cs (100%) rename {Formatter.Core => Formatter/Core}/FormatterResult.cs (98%) rename {Formatter.Core => Formatter/Core}/PowerShellFormatter.cs (70%) rename {Formatter.Core => Formatter/Core}/TextEdit.cs (82%) rename {Formatter.Dprint => Formatter/Dprint}/Formatter.Dprint.csproj (73%) create mode 100644 Formatter/Dprint/LICENSE rename {Formatter.Dprint => Formatter/Dprint}/Program.cs (60%) rename {Formatter.Dprint => Formatter/Dprint}/Properties/AssemblyInfo.cs (67%) rename {Formatter.Dprint => Formatter/Dprint}/README.md (80%) rename {Formatter.Dprint => Formatter/Dprint}/native/dprint_exports.c (100%) rename {Formatter.Dprint => Formatter/Dprint}/native/wasi_stubs.c (100%) create mode 100644 Formatter/Dprint/release-notes.md create mode 100644 Formatter/Dprint/runtimeconfig.template.json rename {Formatter.Dprint => Formatter/Dprint}/schema.json (98%) rename {Formatter.Dprint => Formatter/Dprint}/scripts/check-plugin.mjs (62%) rename {Formatter.Dprint => Formatter/Dprint}/scripts/e2e.sh (73%) rename {Formatter.Dprint => Formatter/Dprint}/scripts/generate-schema.mjs (70%) rename {Formatter.Dprint => Formatter/Dprint}/tests/dprint.json (73%) rename {Formatter.Dprint => Formatter/Dprint}/tests/fixtures/expected.ps1 (100%) rename {Formatter.Dprint => Formatter/Dprint}/tests/fixtures/input.ps1 (100%) rename {Formatter.Dprint => Formatter/Dprint}/tests/input.ps1 (100%) rename {Formatter.Dprint => Formatter/Dprint}/tests/unknown-config.json (100%) create mode 100644 Formatter/README.md rename {Formatter.Wasm => Formatter/Wasm}/Formatter.Wasm.csproj (52%) rename {Formatter.Wasm => Formatter/Wasm}/FormatterJsonContext.cs (96%) create mode 100644 Formatter/Wasm/LICENSE rename {Formatter.Wasm => Formatter/Wasm}/Program.cs (94%) rename {Formatter.Wasm => Formatter/Wasm}/README.md (68%) create mode 100644 Formatter/Wasm/index.d.ts rename {Formatter.Wasm => Formatter/Wasm}/index.mjs (55%) create mode 100644 Formatter/Wasm/package.json create mode 100644 Formatter/Wasm/release-notes.md create mode 100644 Formatter/Wasm/scripts/Test-Package.ps1 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/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 index 01417109e..880f76cea 100644 --- a/.github/workflows/formatter-wasm-release.yml +++ b/.github/workflows/formatter-wasm-release.yml @@ -1,58 +1,55 @@ name: Release dprint PowerShell formatter - -on: - push: - tags: ["[0-9]+.[0-9]+.[0-9]+"] - -permissions: - contents: write - +on: { push: { tags: ["[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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@v7 - name: Set up mise and install tools - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + uses: jdx/mise-action@v4 with: install: true - name: Verify release version run: | - version=$(mise exec -- dotnet msbuild Formatter.Dprint/Formatter.Dprint.csproj \ + version=$(dotnet msbuild Formatter/Dprint/Formatter.Dprint.csproj \ -nologo \ -getProperty:Version) if [[ "$GITHUB_REF_NAME" != "$version" ]]; then echo "Tag $GITHUB_REF_NAME does not match dprint plugin version $version." >&2 exit 1 fi + echo "DPRINT_PLUGIN_VERSION=$version" >> "$GITHUB_ENV" - name: Install .NET WASI workload run: >- - mise exec -- dotnet workload install wasi-experimental + dotnet workload install wasi-experimental --skip-manifest-update --source https://api.nuget.org/v3/index.json - name: Build and validate plugin - run: Formatter.Dprint/scripts/e2e.sh + run: Formatter/Dprint/scripts/e2e.sh - name: Assemble release assets run: | release_dir="$RUNNER_TEMP/dprint-release" mkdir -p "$release_dir" - cp Formatter.Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm "$release_dir/plugin.wasm" - cp Formatter.Dprint/schema.json "$release_dir/schema.json" + cp Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm "$release_dir/plugin.wasm" + cp Formatter/Dprint/schema.json "$release_dir/schema.json" + cp Formatter/Dprint/LICENSE "$release_dir/LICENSE" cd "$release_dir" - sha256sum plugin.wasm schema.json > checksums.txt + sha256sum plugin.wasm schema.json LICENSE > checksums.txt - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} run: | - notes=$(< Formatter.Dprint/release-notes.md) gh release create "$GITHUB_REF_NAME" \ - "$RUNNER_TEMP/dprint-release/plugin.wasm" \ - "$RUNNER_TEMP/dprint-release/schema.json" \ - "$RUNNER_TEMP/dprint-release/checksums.txt" \ + "$RUNNER_TEMP/dprint-release/plugin.wasm#dprint PowerShell formatter $DPRINT_PLUGIN_VERSION, WebAssembly" \ + "$RUNNER_TEMP/dprint-release/schema.json#dprint PowerShell formatter $DPRINT_PLUGIN_VERSION, configuration schema" \ + "$RUNNER_TEMP/dprint-release/LICENSE#dprint PowerShell formatter $DPRINT_PLUGIN_VERSION, MIT license" \ + "$RUNNER_TEMP/dprint-release/checksums.txt#dprint PowerShell formatter $DPRINT_PLUGIN_VERSION, SHA-256 checksums" \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ - --title "dprint PowerShell formatter $GITHUB_REF_NAME" \ - --notes "$notes" \ - --generate-notes + --latest \ + --title "dprint PowerShell formatter $DPRINT_PLUGIN_VERSION" \ + --notes-file Formatter/Dprint/release-notes.md diff --git a/.github/workflows/formatter-wasm.yml b/.github/workflows/formatter-wasm.yml index db225ce96..d7fc2b629 100644 --- a/.github/workflows/formatter-wasm.yml +++ b/.github/workflows/formatter-wasm.yml @@ -3,25 +3,19 @@ name: Formatter WebAssembly on: pull_request: paths: - - "Formatter.Core/**" - - "Formatter.Dprint/**" - - "Formatter.Wasm/**" - - "mise.toml" - - ".github/workflows/formatter-wasm.yml" + - Formatter/** + - mise.toml + - .github/workflows/formatter-wasm.yml push: - branches: - - main + branches: [main] paths: - - "Formatter.Core/**" - - "Formatter.Dprint/**" - - "Formatter.Wasm/**" - - "mise.toml" - - ".github/workflows/formatter-wasm.yml" - workflow_dispatch: - -permissions: - contents: read + - 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 @@ -29,17 +23,108 @@ jobs: - uses: actions/checkout@v7 - uses: jdx/mise-action@v4 - name: Install .NET WASI workload - run: >- - mise exec -- dotnet workload install wasi-experimental - --skip-manifest-update - --source https://api.nuget.org/v3/index.json + 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 - run: Formatter.Dprint/scripts/e2e.sh + 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-plugin-powershell + 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/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.Dprint/release-notes.md b/Formatter.Dprint/release-notes.md deleted file mode 100644 index 8f45aee09..000000000 --- a/Formatter.Dprint/release-notes.md +++ /dev/null @@ -1,11 +0,0 @@ -## Install - -```sh -dprint add kjanat/PSScriptAnalyzer -``` - -The release contains the directly loadable dprint `plugin.wasm`, its configuration `schema.json`, -and SHA-256 checksums for both 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 repository's MIT license. diff --git a/Formatter.Dprint/runtimeconfig.template.json b/Formatter.Dprint/runtimeconfig.template.json deleted file mode 100644 index 1647a418b..000000000 --- a/Formatter.Dprint/runtimeconfig.template.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "wasmHostProperties": { - "perHostConfig": [ - { - "name": "wasmtime", - "Host": "wasmtime" - } - ] - } -} diff --git a/Formatter.Wasm/package.json b/Formatter.Wasm/package.json deleted file mode 100644 index a65eb5999..000000000 --- a/Formatter.Wasm/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@psscriptanalyzer/formatter-wasm", - "version": "0.1.0", - "type": "module", - "exports": "./index.mjs", - "files": [ - "index.mjs", - "_framework", - "README.md" - ], - "engines": { - "node": ">=20" - } -} diff --git a/Formatter.Core.Tests/Formatter.Core.Tests.csproj b/Formatter/Core.Tests/Formatter.Core.Tests.csproj similarity index 77% rename from Formatter.Core.Tests/Formatter.Core.Tests.csproj rename to Formatter/Core.Tests/Formatter.Core.Tests.csproj index d938ae59e..375084d5e 100644 --- a/Formatter.Core.Tests/Formatter.Core.Tests.csproj +++ b/Formatter/Core.Tests/Formatter.Core.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -8,7 +7,6 @@ - + - diff --git a/Formatter.Core.Tests/Program.cs b/Formatter/Core.Tests/Program.cs similarity index 63% rename from Formatter.Core.Tests/Program.cs rename to Formatter/Core.Tests/Program.cs index 817d42c15..e09f6eeae 100644 --- a/Formatter.Core.Tests/Program.cs +++ b/Formatter/Core.Tests/Program.cs @@ -5,33 +5,37 @@ Check( "default formatting", "IF($x-EQ 1){'yes'}ELSE{'no'}", - "if($x -eq 1) {\n 'yes'\n} else {\n 'no'\n}"); + "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}"); + "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("hashtable remains inline", "$x=@{one=1;two=2}", "$x = @{one = 1; two = 2}"); Check( "next-line braces", - "function Test { 'ok' }", - "function Test\n{\n 'ok'\n}", - new FormatterOptions { BraceStyle = BraceStyle.NextLine }); + "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("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}"); + "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); @@ -46,7 +50,7 @@ return 1; } -Console.WriteLine("7 formatter checks passed"); +Console.WriteLine("8 formatter checks passed"); return 0; void Check(string name, string input, string expected, FormatterOptions? options = null) diff --git a/Formatter.Core/Formatter.Core.csproj b/Formatter/Core/Formatter.Core.csproj similarity index 99% rename from Formatter.Core/Formatter.Core.csproj rename to Formatter/Core/Formatter.Core.csproj index bc667cc78..d687266b9 100644 --- a/Formatter.Core/Formatter.Core.csproj +++ b/Formatter/Core/Formatter.Core.csproj @@ -1,5 +1,4 @@ - net8.0 Microsoft.PowerShell.ScriptAnalyzer.Formatter.Core @@ -11,5 +10,4 @@ - diff --git a/Formatter.Core/FormatterOptions.cs b/Formatter/Core/FormatterOptions.cs similarity index 100% rename from Formatter.Core/FormatterOptions.cs rename to Formatter/Core/FormatterOptions.cs diff --git a/Formatter.Core/FormatterResult.cs b/Formatter/Core/FormatterResult.cs similarity index 98% rename from Formatter.Core/FormatterResult.cs rename to Formatter/Core/FormatterResult.cs index 6d0ea003c..5bfa7bd0c 100644 --- a/Formatter.Core/FormatterResult.cs +++ b/Formatter/Core/FormatterResult.cs @@ -21,4 +21,5 @@ public sealed record FormatterParseError( int StartOffset, int EndOffset, int StartLine, - int StartColumn); + int StartColumn +); diff --git a/Formatter.Core/PowerShellFormatter.cs b/Formatter/Core/PowerShellFormatter.cs similarity index 70% rename from Formatter.Core/PowerShellFormatter.cs rename to Formatter/Core/PowerShellFormatter.cs index d7dd8ff6d..8e7af15c1 100644 --- a/Formatter.Core/PowerShellFormatter.cs +++ b/Formatter/Core/PowerShellFormatter.cs @@ -7,8 +7,7 @@ namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter; public static class PowerShellFormatter { private const TokenFlags OperatorFlags = - TokenFlags.AssignmentOperator | - TokenFlags.BinaryOperator; + TokenFlags.AssignmentOperator | TokenFlags.BinaryOperator; /// Formats a complete PowerShell source string. /// The PowerShell source text to format. @@ -26,7 +25,10 @@ public static FormatterResult Format(string source, FormatterOptions? options = options ??= new FormatterOptions(); if (options.IndentSize < 0 || options.IndentSize > 32) { - throw new ArgumentOutOfRangeException(nameof(options), "IndentSize must be between 0 and 32."); + throw new ArgumentOutOfRangeException( + nameof(options), + "IndentSize must be between 0 and 32." + ); } var (_, _, initialErrors) = Parse(source); @@ -52,12 +54,14 @@ 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)) + (token.TokenFlags & (TokenFlags.Keyword | OperatorFlags)) != 0 + && token.Text.Any(char.IsUpper) + ) .Select(token => new TextEdit( token.Extent.StartOffset, token.Extent.EndOffset, - token.Text.ToLowerInvariant())); + token.Text.ToLowerInvariant() + )); return TextEdits.Apply(source, edits); } @@ -65,8 +69,10 @@ 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) + var hashtableBraces = ast.FindAll( + node => node is HashtableAst, + searchNestedScriptBlocks: true + ) .Cast() .SelectMany(table => new[] { table.Extent.StartOffset, table.Extent.EndOffset - 1 }) .ToHashSet(); @@ -75,7 +81,10 @@ private static string FormatBraces(string source, FormatterOptions options) for (var index = 0; index < tokens.Length; index++) { var token = tokens[index]; - if (token.Kind == TokenKind.LCurly && !hashtableBraces.Contains(token.Extent.StartOffset)) + if ( + token.Kind == TokenKind.LCurly + && !hashtableBraces.Contains(token.Extent.StartOffset) + ) { var previous = PreviousSignificant(tokens, index); var next = NextSignificant(tokens, index); @@ -86,26 +95,42 @@ private static string FormatBraces(string source, FormatterOptions options) previous, token, options.BraceStyle == BraceStyle.NextLine ? newLine : " ", - edits); + edits + ); } - if (next is not null && next.Kind != TokenKind.RCurly && next.Kind != TokenKind.NewLine) + 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)) + 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)) + 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)) + if ( + next is not null + && next.Kind != TokenKind.NewLine + && IsCuddledKeyword(next.Kind) + ) { - ReplaceWhitespaceBetween(source, token, next, " ", edits); + var separator = options.BraceStyle == BraceStyle.NextLine ? newLine : " "; + ReplaceWhitespaceBetween(source, token, next, separator, edits); } } } @@ -133,7 +158,10 @@ private static string FormatWhitespace(string source, FormatterOptions options) ReplaceWhitespaceBetween(source, token, next, " ", edits); } } - else if (options.SpaceAroundPipe && token.Kind is TokenKind.Pipe or TokenKind.AndAnd or TokenKind.OrOr) + 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); @@ -163,11 +191,18 @@ 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)) + 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++) + for ( + var line = token.Extent.StartLineNumber + 1; + line <= token.Extent.EndLineNumber; + line++ + ) { protectedLines.Add(line); } @@ -179,19 +214,25 @@ private static string FormatIndentation(string source, FormatterOptions options) 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()); + .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)) + if ( + !tokensByLine.TryGetValue(lineNumber, out var lineTokens) + || protectedLines.Contains(lineNumber) + ) { continue; } var first = lineTokens[0]; - var lineDepth = first.Kind == TokenKind.RCurly ? Math.Max(0, depth - 1) : depth; + var lineDepth = IsClosingDelimiter(first.Kind) ? Math.Max(0, depth - 1) : depth; var content = lines[lineIndex].TrimStart(' ', '\t'); if (content.Length > 0) { @@ -200,11 +241,11 @@ private static string FormatIndentation(string source, FormatterOptions options) foreach (var token in lineTokens) { - if (token.Kind is TokenKind.LCurly or TokenKind.AtCurly) + if (IsOpeningDelimiter(token.Kind)) { depth++; } - else if (token.Kind == TokenKind.RCurly) + else if (IsClosingDelimiter(token.Kind)) { depth = Math.Max(0, depth - 1); } @@ -220,12 +261,24 @@ private static string FormatIndentation(string source, FormatterOptions options) } private static bool IsBinaryOrAssignmentOperator(Token token) => - (token.TokenFlags & OperatorFlags) != 0 && - token.Kind is not (TokenKind.DotDot or TokenKind.PlusPlus or TokenKind.MinusMinus); + (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--) @@ -242,7 +295,10 @@ private static bool IsCuddledKeyword(TokenKind kind) => { for (var cursor = index + 1; cursor < tokens.Length; cursor++) { - if (tokens[cursor].Kind != TokenKind.Comment && tokens[cursor].Kind != TokenKind.EndOfInput) + if ( + tokens[cursor].Kind != TokenKind.Comment + && tokens[cursor].Kind != TokenKind.EndOfInput + ) { return tokens[cursor]; } @@ -255,7 +311,8 @@ private static void ReplaceWhitespaceBetween( Token left, Token right, string replacement, - ICollection edits) + ICollection edits + ) { var start = left.Extent.EndOffset; var end = right.Extent.StartOffset; @@ -272,9 +329,8 @@ private static void ReplaceWhitespaceBetween( 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 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"; @@ -285,13 +341,15 @@ private static (ScriptBlockAst Ast, Token[] Tokens, ParseError[] Errors) Parse(s 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(); + 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 similarity index 82% rename from Formatter.Core/TextEdit.cs rename to Formatter/Core/TextEdit.cs index 3facab88f..5e64342d9 100644 --- a/Formatter.Core/TextEdit.cs +++ b/Formatter/Core/TextEdit.cs @@ -21,7 +21,11 @@ public static string Apply(string source, IEnumerable edits) continue; } - source = string.Concat(source.AsSpan(0, edit.Start), edit.Text, source.AsSpan(edit.End)); + source = string.Concat( + source.AsSpan(0, edit.Start), + edit.Text, + source.AsSpan(edit.End) + ); previousStart = edit.Start; } diff --git a/Formatter.Dprint/Formatter.Dprint.csproj b/Formatter/Dprint/Formatter.Dprint.csproj similarity index 73% rename from Formatter.Dprint/Formatter.Dprint.csproj rename to Formatter/Dprint/Formatter.Dprint.csproj index d4ec708f9..94eee95c5 100644 --- a/Formatter.Dprint/Formatter.Dprint.csproj +++ b/Formatter/Dprint/Formatter.Dprint.csproj @@ -2,7 +2,7 @@ net8.0 plugin - 0.1.0 + 0.1.1 https://github.com/kjanat/PSScriptAnalyzer wasi-wasm Exe @@ -14,12 +14,35 @@ - - - - + + + + + + + + + + <_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" /> 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 similarity index 60% rename from Formatter.Dprint/Program.cs rename to Formatter/Dprint/Program.cs index dcf8cb756..c6c21e594 100644 --- a/Formatter.Dprint/Program.cs +++ b/Formatter/Dprint/Program.cs @@ -2,16 +2,20 @@ 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 void Main() { } } public static class Plugin @@ -35,7 +39,10 @@ public static class Plugin public static string Format(string source, string configJson, string overrideConfigJson) { - var result = PowerShellFormatter.Format(source, ParseOptions(configJson, overrideConfigJson)); + var result = PowerShellFormatter.Format( + source, + ParseOptions(configJson, overrideConfigJson) + ); return result.Text; } @@ -45,7 +52,10 @@ public static string GetConfigDiagnostics(string configJson) try { using var document = JsonDocument.Parse(configJson); - if (document.RootElement.TryGetProperty("plugin", out var plugin) && plugin.ValueKind == JsonValueKind.Object) + if ( + document.RootElement.TryGetProperty("plugin", out var plugin) + && plugin.ValueKind == JsonValueKind.Object + ) { foreach (var property in plugin.EnumerateObject()) { @@ -77,76 +87,92 @@ 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)}}} + {"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 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 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 + 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."); + 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}"; } @@ -186,7 +212,10 @@ private static void ApplyPluginOptions(JsonElement plugin, FormatterOptions opti 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) + if ( + plugin.TryGetProperty("braceStyle", out var braceStyle) + && braceStyle.ValueKind == JsonValueKind.String + ) { options.BraceStyle = braceStyle.GetString() switch { @@ -194,16 +223,18 @@ private static void ApplyPluginOptions(JsonElement plugin, FormatterOptions opti _ => BraceStyle.SameLine, }; } - } private static void ValidateBoolean( JsonElement config, string name, - ICollection<(string PropertyName, string Message)> diagnostics) + ICollection<(string PropertyName, string Message)> diagnostics + ) { - if (config.TryGetProperty(name, out var value) && - value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + if ( + config.TryGetProperty(name, out var value) + && value.ValueKind is not (JsonValueKind.True or JsonValueKind.False) + ) { diagnostics.Add((name, "Expected a boolean value.")); } @@ -214,7 +245,8 @@ private static void ValidateInteger( string name, int minimum, int maximum, - ICollection<(string PropertyName, string Message)> diagnostics) + ICollection<(string PropertyName, string Message)> diagnostics + ) { if (!config.TryGetProperty(name, out var value)) { @@ -234,7 +266,8 @@ private static void ValidateStringChoice( JsonElement config, string name, IReadOnlyCollection choices, - ICollection<(string PropertyName, string Message)> diagnostics) + ICollection<(string PropertyName, string Message)> diagnostics + ) { if (!config.TryGetProperty(name, out var value)) { @@ -246,7 +279,9 @@ private static void ValidateStringChoice( } } - private static string SerializeDiagnostics(IEnumerable<(string PropertyName, string Message)> diagnostics) + private static string SerializeDiagnostics( + IEnumerable<(string PropertyName, string Message)> diagnostics + ) { var json = new StringBuilder("["); var first = true; @@ -270,7 +305,10 @@ private static string SerializeDiagnostics(IEnumerable<(string PropertyName, str 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) + if ( + config.TryGetProperty(name, out var value) + && value.ValueKind is JsonValueKind.True or JsonValueKind.False + ) { apply(value.GetBoolean()); } @@ -278,9 +316,11 @@ private static void ApplyBoolean(JsonElement config, string name, Action a 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)) + 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 similarity index 67% rename from Formatter.Dprint/Properties/AssemblyInfo.cs rename to Formatter/Dprint/Properties/AssemblyInfo.cs index 1e407edc8..9493fcc6d 100644 --- a/Formatter.Dprint/Properties/AssemblyInfo.cs +++ b/Formatter/Dprint/Properties/AssemblyInfo.cs @@ -1,4 +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")] +[assembly: System.Runtime.Versioning.SupportedOSPlatform("wasi")] diff --git a/Formatter.Dprint/README.md b/Formatter/Dprint/README.md similarity index 80% rename from Formatter.Dprint/README.md rename to Formatter/Dprint/README.md index 4c522dfba..e02a5c5b8 100644 --- a/Formatter.Dprint/README.md +++ b/Formatter/Dprint/README.md @@ -18,7 +18,7 @@ mise exec -- dotnet workload install wasi-experimental \ Publish the plugin: ```sh -mise exec -- dotnet publish Formatter.Dprint/Formatter.Dprint.csproj \ +mise exec -- dotnet publish Formatter/Dprint/Formatter.Dprint.csproj \ -c Release \ --source https://api.nuget.org/v3/index.json ``` @@ -26,7 +26,7 @@ mise exec -- dotnet publish Formatter.Dprint/Formatter.Dprint.csproj \ The dprint artifact is: ```text -Formatter.Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm +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 @@ -67,7 +67,7 @@ For local development, replace the registry-installed plugin URL with the built ```json { "plugins": [ - "./Formatter.Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm" + "./Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm" ] } ``` @@ -101,7 +101,7 @@ by the browser/Node AppBundle. Run the complete plugin suite: ```sh -Formatter.Dprint/scripts/e2e.sh +mise exec -- Formatter/Dprint/scripts/e2e.sh ``` It checks the module's imports and required exports, metadata URLs, generated schema drift, a real @@ -111,16 +111,16 @@ dprint fixture, idempotence, unknown-key diagnostics, and invalid UTF-8 handling 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 +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.csproj`. A release uses the same -bare semantic version for the assembly, schema URL, Git tag, and GitHub release. Do not prefix the -tag with `v` or `dprint-`: the dprint registry resolves the version directly to that GitHub tag. +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. @@ -128,10 +128,12 @@ refuses to publish unless the pushed tag exactly matches the dprint project's de To publish a new immutable release: 1. Update `Version`, build the module, and regenerate `schema.json`. -2. Run `Formatter.Dprint/scripts/e2e.sh` and commit the version, schema, and related changes. -3. Create and push a signed bare-semver tag for that exact signed commit. +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`, and `checksums.txt`, then +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 diff --git a/Formatter.Dprint/native/dprint_exports.c b/Formatter/Dprint/native/dprint_exports.c similarity index 100% rename from Formatter.Dprint/native/dprint_exports.c rename to Formatter/Dprint/native/dprint_exports.c diff --git a/Formatter.Dprint/native/wasi_stubs.c b/Formatter/Dprint/native/wasi_stubs.c similarity index 100% rename from Formatter.Dprint/native/wasi_stubs.c rename to Formatter/Dprint/native/wasi_stubs.c 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 similarity index 98% rename from Formatter.Dprint/schema.json rename to Formatter/Dprint/schema.json index 0063fc6b4..4daf81c00 100644 --- a/Formatter.Dprint/schema.json +++ b/Formatter/Dprint/schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/0.1.0/schema.json", + "$id": "https://plugins.dprint.dev/kjanat/PSScriptAnalyzer/0.1.1/schema.json", "title": "dprint PowerShell formatter configuration", "type": "object", "additionalProperties": false, diff --git a/Formatter.Dprint/scripts/check-plugin.mjs b/Formatter/Dprint/scripts/check-plugin.mjs similarity index 62% rename from Formatter.Dprint/scripts/check-plugin.mjs rename to Formatter/Dprint/scripts/check-plugin.mjs index caecdf848..ca430323c 100644 --- a/Formatter.Dprint/scripts/check-plugin.mjs +++ b/Formatter/Dprint/scripts/check-plugin.mjs @@ -1,37 +1,37 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import process from 'node:process'; +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 '); + 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' }, + { 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', + "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) { @@ -51,23 +51,23 @@ const readSharedText = (length) => { }; const info = JSON.parse(readSharedText(instance.exports.get_plugin_info())); -assert.equal(info.name, 'dprint-plugin-powershell'); +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.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', + "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 expectedLicense = await readFile(licensePath, "utf8"); const license = readSharedText(instance.exports.get_license_text()); assert.equal(license, expectedLicense); assert.match(license, /Copyright \(c\) Microsoft Corporation\./); diff --git a/Formatter.Dprint/scripts/e2e.sh b/Formatter/Dprint/scripts/e2e.sh similarity index 73% rename from Formatter.Dprint/scripts/e2e.sh rename to Formatter/Dprint/scripts/e2e.sh index f95b071a9..faecaa611 100755 --- a/Formatter.Dprint/scripts/e2e.sh +++ b/Formatter/Dprint/scripts/e2e.sh @@ -2,26 +2,29 @@ set -euo pipefail project_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -repo_dir=$(cd "$project_dir/.." && pwd) +repo_dir=$(cd "$project_dir/../.." && pwd) plugin="$project_dir/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm" cd "$repo_dir" -mise exec -- dotnet publish Formatter.Dprint/Formatter.Dprint.csproj \ +dotnet publish Formatter/Dprint/Formatter.Dprint.csproj \ -c Release \ --source https://api.nuget.org/v3/index.json -version=$(mise exec -- dotnet msbuild Formatter.Dprint/Formatter.Dprint.csproj \ +version=$(dotnet msbuild Formatter/Dprint/Formatter.Dprint.csproj \ -nologo \ -getProperty:Version) -node Formatter.Dprint/scripts/check-plugin.mjs "$plugin" "$version" LICENSE -node Formatter.Dprint/scripts/generate-schema.mjs \ +node Formatter/Dprint/scripts/check-plugin.mjs \ "$plugin" \ - Formatter.Dprint/schema.json \ + "$version" \ + Formatter/Dprint/LICENSE +node Formatter/Dprint/scripts/generate-schema.mjs \ + "$plugin" \ + Formatter/Dprint/schema.json \ --check -cd Formatter.Dprint/tests -input=$(< fixtures/input.ps1) -expected=$(< fixtures/expected.ps1) +cd Formatter/Dprint/tests +input=$( [--check]'); + console.error("usage: node generate-schema.mjs [--check]"); process.exit(2); } @@ -15,10 +15,10 @@ 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'; +) + "\n"; -if (mode === '--check') { - const existing = await readFile(schemaPath, 'utf8'); +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); diff --git a/Formatter.Dprint/tests/dprint.json b/Formatter/Dprint/tests/dprint.json similarity index 73% rename from Formatter.Dprint/tests/dprint.json rename to Formatter/Dprint/tests/dprint.json index 332de274b..185fadef9 100644 --- a/Formatter.Dprint/tests/dprint.json +++ b/Formatter/Dprint/tests/dprint.json @@ -1,4 +1,5 @@ { + "excludes": ["fixtures/input.ps1"], "plugins": [ "../bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm" ], diff --git a/Formatter.Dprint/tests/fixtures/expected.ps1 b/Formatter/Dprint/tests/fixtures/expected.ps1 similarity index 100% rename from Formatter.Dprint/tests/fixtures/expected.ps1 rename to Formatter/Dprint/tests/fixtures/expected.ps1 diff --git a/Formatter.Dprint/tests/fixtures/input.ps1 b/Formatter/Dprint/tests/fixtures/input.ps1 similarity index 100% rename from Formatter.Dprint/tests/fixtures/input.ps1 rename to Formatter/Dprint/tests/fixtures/input.ps1 diff --git a/Formatter.Dprint/tests/input.ps1 b/Formatter/Dprint/tests/input.ps1 similarity index 100% rename from Formatter.Dprint/tests/input.ps1 rename to Formatter/Dprint/tests/input.ps1 diff --git a/Formatter.Dprint/tests/unknown-config.json b/Formatter/Dprint/tests/unknown-config.json similarity index 100% rename from Formatter.Dprint/tests/unknown-config.json rename to Formatter/Dprint/tests/unknown-config.json 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 similarity index 52% rename from Formatter.Wasm/Formatter.Wasm.csproj rename to Formatter/Wasm/Formatter.Wasm.csproj index d8d6df1dd..57e2c3487 100644 --- a/Formatter.Wasm/Formatter.Wasm.csproj +++ b/Formatter/Wasm/Formatter.Wasm.csproj @@ -1,5 +1,4 @@ - net8.0 browser-wasm @@ -16,15 +15,24 @@ - - + + - + - - + + + + - diff --git a/Formatter.Wasm/FormatterJsonContext.cs b/Formatter/Wasm/FormatterJsonContext.cs similarity index 96% rename from Formatter.Wasm/FormatterJsonContext.cs rename to Formatter/Wasm/FormatterJsonContext.cs index 4fca2d76a..da4f985c4 100644 --- a/Formatter.Wasm/FormatterJsonContext.cs +++ b/Formatter/Wasm/FormatterJsonContext.cs @@ -6,9 +6,8 @@ namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter.Wasm; [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, - Converters = new[] { typeof(JsonStringEnumConverter) })] + Converters = new[] { typeof(JsonStringEnumConverter) } +)] [JsonSerializable(typeof(FormatterOptions))] [JsonSerializable(typeof(FormatterResult))] -internal partial class FormatterJsonContext : JsonSerializerContext -{ -} +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 similarity index 94% rename from Formatter.Wasm/Program.cs rename to Formatter/Wasm/Program.cs index 1cdb98838..e005b9463 100644 --- a/Formatter.Wasm/Program.cs +++ b/Formatter/Wasm/Program.cs @@ -8,9 +8,7 @@ namespace Microsoft.PowerShell.ScriptAnalyzer.Formatter.Wasm; [SupportedOSPlatform("browser")] public partial class Program { - public static void Main() - { - } + public static void Main() { } [JSExport] internal static string Format(string source, string optionsJson) diff --git a/Formatter.Wasm/README.md b/Formatter/Wasm/README.md similarity index 68% rename from Formatter.Wasm/README.md rename to Formatter/Wasm/README.md index 1996446f9..3f29c412f 100644 --- a/Formatter.Wasm/README.md +++ b/Formatter/Wasm/README.md @@ -3,7 +3,7 @@ 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'; +import { format } from "@psscriptanalyzer/formatter-wasm"; const result = await format("IF($x-EQ 1){'yes'}"); console.log(result.text); @@ -22,10 +22,17 @@ This is a small browser-safe formatter core, not yet a byte-for-byte port of eve Build the package with: ```sh -dotnet publish Formatter.Wasm/Formatter.Wasm.csproj -c Release +dotnet publish Formatter/Wasm/Formatter.Wasm.csproj -c Release ``` -The publishable package is written to `Formatter.Wasm/bin/Release/net8.0/browser-wasm/AppBundle`. +The publishable package is written to `Formatter/Wasm/bin/Release/net8.0/browser-wasm/AppBundle`. -See [WebAssembly formatter development](../docs/FormatterWasm.md) for the architecture, complete +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 similarity index 55% rename from Formatter.Wasm/index.mjs rename to Formatter/Wasm/index.mjs index d0a3b1409..f64ac0534 100644 --- a/Formatter.Wasm/index.mjs +++ b/Formatter/Wasm/index.mjs @@ -4,12 +4,12 @@ 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; + 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; } /** @@ -24,10 +24,10 @@ async function getFormatter() { * @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"); - } + 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))); + 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 1b4c4bc5e..957369394 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ The documentation in this section can be found in ## WebAssembly formatter development -The experimental formatter in `Formatter.Core`, `Formatter.Wasm`, and `Formatter.Dprint` provides +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 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..f88d188db 100644 --- a/build.psm1 +++ b/build.psm1 @@ -81,6 +81,54 @@ 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" }, + @{ Verb = "publish"; Project = "Formatter/Dprint/Formatter.Dprint.csproj" } + ) + + 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 +176,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 index 5682129cc..8495ff443 100644 --- a/docs/FormatterWasm.md +++ b/docs/FormatterWasm.md @@ -6,14 +6,14 @@ 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 +- `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, +- `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, +- `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 +- `Formatter/Core.Tests` is a dependency-free native test executable covering representative formatting and error cases. The call path is: @@ -36,17 +36,26 @@ 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 +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 +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 @@ -57,13 +66,13 @@ 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 \ +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, +`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. @@ -73,6 +82,12 @@ therefore references its Unix .NET 8 implementation explicitly. The implementati 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. @@ -82,15 +97,15 @@ covered by native and WASM execution tests. Import the module from the published package and await `format`: ```js -import { format } from '@psscriptanalyzer/formatter-wasm'; +import { format } from "@psscriptanalyzer/formatter-wasm"; const result = await format("IF($value-EQ 1){'yes'}", { - braceStyle: 'sameLine', - indentSize: 4, + braceStyle: "sameLine", + indentSize: 4, }); if (result.errors.length === 0) { - console.log(result.text); + console.log(result.text); } ``` @@ -126,18 +141,15 @@ format operation with a managed argument error. ## .NET API -Projects that can host .NET directly may reference `Formatter.Core` without using WebAssembly: +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, - }); + new FormatterOptions { BraceStyle = BraceStyle.NextLine, IndentSize = 2 } +); ``` `PowerShellFormatter.Format` never executes the source. If the initial parser pass reports an @@ -180,7 +192,7 @@ safe to execute. Run the native checks: ```sh -dotnet run --project Formatter.Core.Tests/Formatter.Core.Tests.csproj +dotnet run --project Formatter/Core.Tests/Formatter.Core.Tests.csproj ``` Publish the package, then test the actual WebAssembly entry point from the `AppBundle` directory: @@ -200,8 +212,19 @@ would miss. Run the direct dprint-module checks separately: ```sh -Formatter.Dprint/scripts/e2e.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 bare semantic-version tags such as `0.1.1`. This is required by +`plugins.dprint.dev`, whose plugin tag grammar excludes dashes. 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 bare-semver release as GitHub's latest release. diff --git a/mise.toml b/mise.toml index cee19ba82..e71f9c700 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,9 @@ [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] From 90288a4b8f6014c6d0318572b602ab48985b3b11 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 9 Aug 2026 20:40:45 +0200 Subject: [PATCH 07/11] Namespace dprint release tags Use `dprint-` tags so formatter releases cannot collide with PSScriptAnalyzer versions or the separate npm release namespace. Run every release step as valid PowerShell and surface native-command failures through GitHub workflow error annotations. --- .github/workflows/formatter-wasm-release.yml | 102 +++++++++++++------ docs/FormatterWasm.md | 7 +- 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/.github/workflows/formatter-wasm-release.yml b/.github/workflows/formatter-wasm-release.yml index 880f76cea..9f72ff281 100644 --- a/.github/workflows/formatter-wasm-release.yml +++ b/.github/workflows/formatter-wasm-release.yml @@ -1,5 +1,5 @@ name: Release dprint PowerShell formatter -on: { push: { tags: ["[0-9]+.[0-9]+.[0-9]+"] } } +on: { push: { tags: ["dprint-[0-9]+.[0-9]+.[0-9]+"] } } permissions: { contents: write } defaults: { run: { shell: pwsh } } jobs: @@ -15,41 +15,81 @@ jobs: install: true - name: Verify release version run: | - version=$(dotnet msbuild Formatter/Dprint/Formatter.Dprint.csproj \ - -nologo \ - -getProperty:Version) - if [[ "$GITHUB_REF_NAME" != "$version" ]]; then - echo "Tag $GITHUB_REF_NAME does not match dprint plugin version $version." >&2 - exit 1 - fi - echo "DPRINT_PLUGIN_VERSION=$version" >> "$GITHUB_ENV" + $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: >- - dotnet workload install wasi-experimental - --skip-manifest-update - --source https://api.nuget.org/v3/index.json + 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 + 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: | - release_dir="$RUNNER_TEMP/dprint-release" - mkdir -p "$release_dir" - cp Formatter/Dprint/bin/Release/net8.0/wasi-wasm/AppBundle/plugin.wasm "$release_dir/plugin.wasm" - cp Formatter/Dprint/schema.json "$release_dir/schema.json" - cp Formatter/Dprint/LICENSE "$release_dir/LICENSE" - cd "$release_dir" - sha256sum plugin.wasm schema.json LICENSE > checksums.txt + $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: | - gh release create "$GITHUB_REF_NAME" \ - "$RUNNER_TEMP/dprint-release/plugin.wasm#dprint PowerShell formatter $DPRINT_PLUGIN_VERSION, WebAssembly" \ - "$RUNNER_TEMP/dprint-release/schema.json#dprint PowerShell formatter $DPRINT_PLUGIN_VERSION, configuration schema" \ - "$RUNNER_TEMP/dprint-release/LICENSE#dprint PowerShell formatter $DPRINT_PLUGIN_VERSION, MIT license" \ - "$RUNNER_TEMP/dprint-release/checksums.txt#dprint PowerShell formatter $DPRINT_PLUGIN_VERSION, SHA-256 checksums" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --latest \ - --title "dprint PowerShell formatter $DPRINT_PLUGIN_VERSION" \ - --notes-file Formatter/Dprint/release-notes.md + $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/docs/FormatterWasm.md b/docs/FormatterWasm.md index 8495ff443..f9d823b55 100644 --- a/docs/FormatterWasm.md +++ b/docs/FormatterWasm.md @@ -220,11 +220,10 @@ real dprint formatting, idempotence, configuration diagnostics, and invalid UTF- ## Release namespaces -Dprint releases use bare semantic-version tags such as `0.1.1`. This is required by -`plugins.dprint.dev`, whose plugin tag grammar excludes dashes. Browser and Node.js package releases -use the separate `npm-` namespace. +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 bare-semver release as GitHub's latest release. +release. The dprint workflow explicitly marks its prefixed release as GitHub's latest release. From 5dd3b6d0e754dd4966c6ef45336aa34d6b98cc64 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 9 Aug 2026 20:48:22 +0200 Subject: [PATCH 08/11] Install formatter workloads in CI Provision the browser and WASI workloads before `build.ps1 -All` so the formatter projects compile on every Pester matrix runner. --- .github/workflows/ci-test.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index a27debc93..8ed91b3ee 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -31,6 +31,21 @@ jobs: run: ./tools/installPSResources.ps1 shell: pwsh + - name: Install formatter workloads + run: | + $arguments = @( + "workload", "install", "wasm-tools", "wasi-experimental", + "--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 From 9d6788436213a3806129385aa631fe1f4f9f9c08 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 9 Aug 2026 21:11:30 +0200 Subject: [PATCH 09/11] Provide pinned WASI SDK in CI Install the repository's `wasi-sdk` tool through mise and export `WASI_SDK_PATH` so the native dprint plugin links during `build.ps1 -All`. --- .github/workflows/ci-test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 8ed91b3ee..50f733df1 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -21,6 +21,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + - name: Set up WASI SDK + uses: jdx/mise-action@v4 + with: + install: true + install_args: github:WebAssembly/wasi-sdk + export_path: false + - name: Install dotnet uses: actions/setup-dotnet@v6 with: From b70f83a3c2b45ad59f74d61495aa3d3376ec6668 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 9 Aug 2026 21:21:39 +0200 Subject: [PATCH 10/11] Build direct dprint plugin on Linux Keep portable core and browser WASM builds on every CI runner, while restricting the native dprint link and its WASI SDK to Linux where the pinned toolchain provides clang. --- .github/workflows/ci-test.yml | 8 ++++++-- build.psm1 | 11 +++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 50f733df1..10162caa1 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -22,6 +22,7 @@ jobs: uses: actions/checkout@v7 - name: Set up WASI SDK + if: matrix.os == 'ubuntu-latest' uses: jdx/mise-action@v4 with: install: true @@ -40,8 +41,11 @@ jobs: - name: Install formatter workloads run: | - $arguments = @( - "workload", "install", "wasm-tools", "wasi-experimental", + $workloads = @("wasm-tools") + if ($IsLinux) { + $workloads += "wasi-experimental" + } + $arguments = @("workload", "install") + $workloads + @( "--skip-manifest-update", "--source", "https://api.nuget.org/v3/index.json" ) diff --git a/build.psm1 b/build.psm1 index f88d188db..36007f02f 100644 --- a/build.psm1 +++ b/build.psm1 @@ -97,9 +97,16 @@ function Start-FormatterBuild $targets = @( @{ Verb = "build"; Project = "Formatter/Core.Tests/Formatter.Core.Tests.csproj" }, - @{ Verb = "publish"; Project = "Formatter/Wasm/Formatter.Wasm.csproj" }, - @{ Verb = "publish"; Project = "Formatter/Dprint/Formatter.Dprint.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 From e3b802717abd56a9cc7fb0c238922e0ed74d53ca Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 9 Aug 2026 21:32:16 +0200 Subject: [PATCH 11/11] Pin Windows PowerShell Pester version --- .github/workflows/ci-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 10162caa1..aeb671be9 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -72,7 +72,7 @@ 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