diff --git a/Directory.Packages.props b/Directory.Packages.props index 1fd50ba66..618cfb578 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,7 @@ + diff --git a/Src/CSharpier.Cli/ConsoleLogger.cs b/Src/CSharpier.Cli/ConsoleLogger.cs index 476b2f902..997fa65d9 100644 --- a/Src/CSharpier.Cli/ConsoleLogger.cs +++ b/Src/CSharpier.Cli/ConsoleLogger.cs @@ -89,14 +89,16 @@ void WriteLine(string? value = null) } } - private static ConsoleColor GetColorLevel(LogLevel logLevel) => - logLevel switch + private static ConsoleColor GetColorLevel(LogLevel logLevel) + { + return logLevel switch { LogLevel.Critical => ConsoleColor.DarkRed, LogLevel.Error => ConsoleColor.DarkRed, LogLevel.Warning => ConsoleColor.DarkYellow, _ => ConsoleColor.White, }; + } public bool IsEnabled(LogLevel logLevel) { diff --git a/Src/CSharpier.Cli/EditorConfig/GlobMatcher.cs b/Src/CSharpier.Cli/EditorConfig/GlobMatcher.cs index 8d0c7b3b3..48e88c84d 100644 --- a/Src/CSharpier.Cli/EditorConfig/GlobMatcher.cs +++ b/Src/CSharpier.Cli/EditorConfig/GlobMatcher.cs @@ -144,6 +144,7 @@ private struct MatchContext this.myOptions.IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private readonly char[] PathSeparatorChars => this.myOptions.AllowWindowsPaths ? ourWinPathSeparators : ourUnixPathSeparators; @@ -634,11 +635,12 @@ private readonly bool CheckDot(int dotPos) } } - private static bool IsPathSeparator(GlobMatcherOptions options, char c) => + private static bool IsPathSeparator(GlobMatcherOptions options, char c) + { // windows: need to use /, not \ // On other platforms, \ is a valid (albeit bad) filename char. - c == '/' - || options.AllowWindowsPaths && c == '\\'; + return c == '/' || options.AllowWindowsPaths && c == '\\'; + } private class PatternCase : List { diff --git a/Src/CSharpier.Core/CSharp/CSharpFormatter.cs b/Src/CSharpier.Core/CSharp/CSharpFormatter.cs index d32085d2f..8be1125c0 100644 --- a/Src/CSharpier.Core/CSharp/CSharpFormatter.cs +++ b/Src/CSharpier.Core/CSharp/CSharpFormatter.cs @@ -51,13 +51,19 @@ public static Task FormatAsync( internal static Task FormatAsync( string code, PrinterOptions printerOptions - ) => FormatAsync(code, printerOptions, CancellationToken.None); + ) + { + return FormatAsync(code, printerOptions, CancellationToken.None); + } internal static Task FormatAsync( string code, PrinterOptions printerOptions, CancellationToken cancellationToken - ) => FormatAsync(code, printerOptions, SourceCodeKind.Regular, cancellationToken); + ) + { + return FormatAsync(code, printerOptions, SourceCodeKind.Regular, cancellationToken); + } internal static Task FormatAsync( string code, diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/CSharpierIgnore.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/CSharpierIgnore.cs index c1b09111f..bb7cc075b 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/CSharpierIgnore.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/CSharpierIgnore.cs @@ -47,11 +47,15 @@ internal static partial class CSharpierIgnore ); #endif - public static bool HasIgnoreComment(SyntaxNode syntaxNode) => - Token.HasLeadingCommentMatching(syntaxNode, IgnoreRegex); + public static bool HasIgnoreComment(SyntaxNode syntaxNode) + { + return Token.HasLeadingCommentMatching(syntaxNode, IgnoreRegex); + } - public static bool HasIgnoreComment(SyntaxToken syntaxToken) => - Token.HasLeadingCommentMatching(syntaxToken, IgnoreRegex); + public static bool HasIgnoreComment(SyntaxToken syntaxToken) + { + return Token.HasLeadingCommentMatching(syntaxToken, IgnoreRegex); + } public static bool IsNodeIgnored(SyntaxNode syntaxNode) { diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/TupleExpression.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/TupleExpression.cs index 2ad30968c..2156c0fdd 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/TupleExpression.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/SyntaxNodePrinters/TupleExpression.cs @@ -5,8 +5,9 @@ namespace CSharpier.Core.CSharp.SyntaxPrinter.SyntaxNodePrinters; internal static class TupleExpression { - public static Doc Print(TupleExpressionSyntax node, CSharpPrintingContext context) => - Doc.Group( + public static Doc Print(TupleExpressionSyntax node, CSharpPrintingContext context) + { + return Doc.Group( ArgumentListLike.Print( node.OpenParenToken, node.Arguments, @@ -14,4 +15,5 @@ public static Doc Print(TupleExpressionSyntax node, CSharpPrintingContext contex context ) ); + } } diff --git a/Src/CSharpier.Core/CSharp/SyntaxPrinter/Token.cs b/Src/CSharpier.Core/CSharp/SyntaxPrinter/Token.cs index 5dfd8be6b..32020499f 100644 --- a/Src/CSharpier.Core/CSharp/SyntaxPrinter/Token.cs +++ b/Src/CSharpier.Core/CSharp/SyntaxPrinter/Token.cs @@ -399,16 +399,24 @@ void AddLeadingComment(CommentType commentType) return docs.Count > 0 ? Doc.Concat(docs) : Doc.Null; } - private static bool IsSingleLineComment(SyntaxKind kind) => - kind + private static bool IsSingleLineComment(SyntaxKind kind) + { + return kind is SyntaxKind.SingleLineDocumentationCommentTrivia or SyntaxKind.SingleLineCommentTrivia; + } - private static bool IsMultiLineComment(SyntaxKind kind) => - kind is SyntaxKind.MultiLineCommentTrivia or SyntaxKind.MultiLineDocumentationCommentTrivia; + private static bool IsMultiLineComment(SyntaxKind kind) + { + return kind + is SyntaxKind.MultiLineCommentTrivia + or SyntaxKind.MultiLineDocumentationCommentTrivia; + } - private static bool IsRegion(SyntaxKind kind) => - kind is SyntaxKind.RegionDirectiveTrivia or SyntaxKind.EndRegionDirectiveTrivia; + private static bool IsRegion(SyntaxKind kind) + { + return kind is SyntaxKind.RegionDirectiveTrivia or SyntaxKind.EndRegionDirectiveTrivia; + } public static Doc PrintTrailingTrivia(SyntaxToken node) { diff --git a/Src/CSharpier.Core/CSharpier.Core.csproj b/Src/CSharpier.Core/CSharpier.Core.csproj index d4d735e49..730567830 100644 --- a/Src/CSharpier.Core/CSharpier.Core.csproj +++ b/Src/CSharpier.Core/CSharpier.Core.csproj @@ -24,6 +24,12 @@ + + + + + + diff --git a/Src/CSharpier.Core/CodeFormatter.cs b/Src/CSharpier.Core/CodeFormatter.cs index b8724970d..8c56cbf5e 100644 --- a/Src/CSharpier.Core/CodeFormatter.cs +++ b/Src/CSharpier.Core/CodeFormatter.cs @@ -27,6 +27,12 @@ CancellationToken cancellationToken cancellationToken ), Formatter.XML => await XmlFormatter.FormatAsync(fileContents, options), +#if !NETSTANDARD2_0 + Formatter.PowerShell => await PowerShell.PowerShellFormatter.FormatAsync( + fileContents, + options + ), +#endif _ => new CodeFormatterResult { FailureMessage = "Is an unsupported file type." }, }; } diff --git a/Src/CSharpier.Core/DocTypes/StringDoc.cs b/Src/CSharpier.Core/DocTypes/StringDoc.cs index c2c5e2fd7..f0cf31fca 100644 --- a/Src/CSharpier.Core/DocTypes/StringDoc.cs +++ b/Src/CSharpier.Core/DocTypes/StringDoc.cs @@ -13,8 +13,10 @@ internal sealed class StringDoc(string value, bool isDirective = false) : Doc public string Value { get; } = value; public bool IsDirective { get; } = isDirective; - public static StringDoc Create(string value) => - value == " " ? SpaceStringDoc : new StringDoc(value); + public static StringDoc Create(string value) + { + return value == " " ? SpaceStringDoc : new StringDoc(value); + } public static StringDoc Create(SyntaxToken token) { diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/ExitStatement.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/ExitStatement.cs new file mode 100644 index 000000000..a0110e8b1 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/ExitStatement.cs @@ -0,0 +1,12 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class ExitStatement +{ + public static Doc Print(ExitStatementAst node, PrintContext context) + { + return "TODO"; + } +} \ No newline at end of file diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/ForEachStatement.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/ForEachStatement.cs new file mode 100644 index 000000000..7a1bdf833 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/ForEachStatement.cs @@ -0,0 +1,19 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class ForEachStatement +{ + internal static Doc Print(ForEachStatementAst node, PrintContext context) + { + return Doc.Concat( + "foreach (", + Verbatim.Print(node.Variable.Extent), + " in ", + Verbatim.Print(node.Condition.Extent), + ") ", + StatementBlock.Print(node.Body, context) + ); + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/FunctionDefinition.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/FunctionDefinition.cs new file mode 100644 index 000000000..6b1ee3efc --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/FunctionDefinition.cs @@ -0,0 +1,62 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class FunctionDefinition +{ + internal static Doc Print(FunctionDefinitionAst node, PrintContext context) + { + var keyword = node.IsFilter ? "filter " : "function "; + var body = node.Body; + + if ( + body.BeginBlock is not null + || body.ProcessBlock is not null + || body.DynamicParamBlock is not null + || body.EndBlock is null + || !body.EndBlock.Unnamed + ) + { + return Doc.Concat(keyword, node.Name, " ", Verbatim.Print(body.Extent)); + } + + var inner = new List(); + if (body.ParamBlock is not null) + { + inner.Add(Verbatim.Print(body.ParamBlock.Extent)); + inner.Add(Doc.HardLine); + } + + if (body.EndBlock.Statements.Count > 0) + { + if (inner.Count > 0) + { + inner.Add(Doc.HardLine); + } + + inner.Add( + Statements.Print( + body.EndBlock.Statements, + context, + body.Extent.StartOffset, + body.Extent.EndOffset + ) + ); + } + + if (inner.Count == 0) + { + return Doc.Concat(keyword, node.Name, " { }"); + } + + return Doc.Concat( + keyword, + node.Name, + " {", + Doc.Indent(Doc.HardLine, Doc.Concat(inner)), + Doc.HardLine, + "}" + ); + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/IfStatement.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/IfStatement.cs new file mode 100644 index 000000000..5427fffd8 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/IfStatement.cs @@ -0,0 +1,29 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class IfStatement +{ + internal static Doc Print(IfStatementAst node, PrintContext context) + { + var docs = new List(); + + for (var i = 0; i < node.Clauses.Count; i++) + { + var (condition, body) = (node.Clauses[i].Item1, node.Clauses[i].Item2); + docs.Add(i == 0 ? "if (" : " elseif ("); + docs.Add(Verbatim.Print(condition.Extent)); + docs.Add(") "); + docs.Add(StatementBlock.Print(body, context)); + } + + if (node.ElseClause is not null) + { + docs.Add(" else "); + docs.Add(StatementBlock.Print(node.ElseClause, context)); + } + + return Doc.Concat(docs); + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/Node.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/Node.cs new file mode 100644 index 000000000..9ff105acd --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/Node.cs @@ -0,0 +1,22 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class Node +{ + internal static Doc Print(Ast node, PrintContext context) + { + return node switch + { + ExitStatementAst exitStatement => ExitStatement.Print(exitStatement, context), + ForEachStatementAst forEach => ForEachStatement.Print(forEach, context), + FunctionDefinitionAst function => FunctionDefinition.Print(function, context), + IfStatementAst ifStatement => IfStatement.Print(ifStatement, context), + ScriptBlockAst scriptBlock => ScriptBlock.Print(scriptBlock, context), + TryStatementAst tryStatement => TryStatement.Print(tryStatement, context), + WhileStatementAst whileStatement => WhileStatement.Print(whileStatement, context), + _ => node.GetType().ToString(), + }; + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/PrintContext.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/PrintContext.cs new file mode 100644 index 000000000..8dc15d5fd --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/PrintContext.cs @@ -0,0 +1,30 @@ +using System.Management.Automation.Language; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal sealed class PrintContext(IReadOnlyList comments) +{ + internal bool HasCommentIn(IScriptExtent extent) + { + foreach (var comment in comments) + { + if (comment.StartOffset >= extent.StartOffset && comment.StartOffset < extent.EndOffset) + { + return true; + } + } + + return false; + } + + internal IEnumerable CommentsBetween(int startOffset, int endOffset) + { + foreach (var comment in comments) + { + if (comment.StartOffset >= startOffset && comment.StartOffset < endOffset) + { + yield return comment; + } + } + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/ScriptBlock.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/ScriptBlock.cs new file mode 100644 index 000000000..cfd60cc47 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/ScriptBlock.cs @@ -0,0 +1,58 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class ScriptBlock +{ + internal static Doc Print(ScriptBlockAst node, PrintContext context) + { + if ( + node.BeginBlock is not null + || node.ProcessBlock is not null + || node.DynamicParamBlock is not null + || node.EndBlock is null + || !node.EndBlock.Unnamed + ) + { + return Verbatim.Print(node.Extent); + } + + var parts = new List(); + + if (node.Extent.Text.StartsWith("<#", StringComparison.InvariantCulture)) + { + var endIndex = node.Extent.Text.IndexOf("#>", StringComparison.InvariantCulture) + 2; + // TODO 1894 probably needs proper line endings and spaces vs tabs + parts.Add(node.Extent.Text[..endIndex]); + parts.Add(Doc.HardLine); + parts.Add(Doc.HardLine); + } + + var startOffset = node.Extent.StartOffset; + if (node.ParamBlock is not null) + { + parts.Add(Verbatim.Print(node.ParamBlock.Extent)); + startOffset = node.ParamBlock.Extent.EndOffset; + } + + if (node.EndBlock.Statements.Count > 0) + { + if (parts.Count > 0) + { + parts.Add(Doc.HardLine); + } + + parts.Add( + Statements.Print( + node.EndBlock.Statements, + context, + startOffset, + node.Extent.EndOffset + ) + ); + } + + return parts.Count == 0 ? Doc.Null : Doc.Concat(parts); + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/StatementBlock.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/StatementBlock.cs new file mode 100644 index 000000000..9aab6c6c1 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/StatementBlock.cs @@ -0,0 +1,30 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class StatementBlock +{ + internal static Doc Print(StatementBlockAst node, PrintContext context) + { + if (node.Statements.Count == 0) + { + return "{ }"; + } + + return Doc.Concat( + "{", + Doc.Indent( + Doc.HardLine, + Statements.Print( + node.Statements, + context, + node.Extent.StartOffset, + node.Extent.EndOffset + ) + ), + Doc.HardLine, + "}" + ); + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/Statements.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/Statements.cs new file mode 100644 index 000000000..2aaea38f4 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/Statements.cs @@ -0,0 +1,90 @@ +using System.Collections.ObjectModel; +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class Statements +{ + internal static Doc Print( + ReadOnlyCollection statements, + PrintContext context, + int startOffset, + int endOffset + ) + { + var items = new List<(int Offset, int StartLine, int EndLine, Doc Doc)>(); + + foreach (var statement in statements) + { + items.Add( + ( + statement.Extent.StartOffset, + statement.Extent.StartLineNumber, + statement.Extent.EndLineNumber, + Node.Print(statement, context) + ) + ); + } + + // Comments are trivia, not attached to any Ast node. Emit the ones that live directly in + // this block - between or around its statements - alongside the statements. A comment inside + // a statement travels with that statement, which is emitted verbatim, so skip those here. + foreach (var comment in context.CommentsBetween(startOffset, endOffset)) + { + if (IsInsideAny(comment, statements)) + { + continue; + } + + items.Add( + ( + comment.StartOffset, + comment.StartLineNumber, + comment.EndLineNumber, + Verbatim.Print(comment) + ) + ); + } + + items.Sort((first, second) => first.Offset.CompareTo(second.Offset)); + + var docs = new List(); + int? previousEndLine = null; + foreach (var item in items) + { + if (previousEndLine is not null) + { + docs.Add(Doc.HardLine); + if (item.StartLine - previousEndLine > 1) + { + docs.Add(Doc.HardLine); + } + } + + docs.Add(item.Doc); + previousEndLine = item.EndLine; + } + + return Doc.Concat(docs); + } + + private static bool IsInsideAny( + IScriptExtent comment, + ReadOnlyCollection statements + ) + { + foreach (var statement in statements) + { + if ( + comment.StartOffset >= statement.Extent.StartOffset + && comment.StartOffset < statement.Extent.EndOffset + ) + { + return true; + } + } + + return false; + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/TryStatement.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/TryStatement.cs new file mode 100644 index 000000000..cc0f19059 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/TryStatement.cs @@ -0,0 +1,28 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class TryStatement +{ + internal static Doc Print(TryStatementAst node, PrintContext context) + { + return Doc.Concat( + "try ", + StatementBlock.Print(node.Body, context), + Doc.Join( + Doc.Null, + node.CatchClauses.Select(o => Doc.Concat(" catch ", PrintCatchClause(o, context))) + ), + node.Finally != null + ? Doc.Concat(" finally ", StatementBlock.Print(node.Finally, context)) + : Doc.Null + ); + } + + private static Doc PrintCatchClause(CatchClauseAst node, PrintContext context) + { + // TODO 1894 what about catching specific things? + return StatementBlock.Print(node.Body, context); + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/Verbatim.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/Verbatim.cs new file mode 100644 index 000000000..a3dc754a8 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/Verbatim.cs @@ -0,0 +1,30 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +// TODO 1894 this should probably go away completely +internal static class Verbatim +{ + // Emits the exact source text of an extent. Single-line text becomes a plain string; multi-line + // text keeps its interior lines verbatim (via LiteralLine, which does not add indentation) so + // here-strings and multi-line pipelines round-trip unchanged. + internal static Doc Print(IScriptExtent extent) + { + var text = extent.Text; + if (!text.Contains('\n')) + { + return text; + } + + var lines = text.Replace("\r\n", "\n").Split('\n'); + var docs = new List { lines[0] }; + for (var i = 1; i < lines.Length; i++) + { + docs.Add(Doc.LiteralLine); + docs.Add(lines[i]); + } + + return Doc.Concat(docs); + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstPrinters/WhileStatement.cs b/Src/CSharpier.Core/PowerShell/AstPrinters/WhileStatement.cs new file mode 100644 index 000000000..c9d88ce06 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstPrinters/WhileStatement.cs @@ -0,0 +1,17 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; + +namespace CSharpier.Core.PowerShell.AstPrinters; + +internal static class WhileStatement +{ + internal static Doc Print(WhileStatementAst node, PrintContext context) + { + return Doc.Concat( + "while (", + Verbatim.Print(node.Condition.Extent), + ") ", + StatementBlock.Print(node.Body, context) + ); + } +} diff --git a/Src/CSharpier.Core/PowerShell/AstSyntaxWriter.cs b/Src/CSharpier.Core/PowerShell/AstSyntaxWriter.cs new file mode 100644 index 000000000..98b6e72e9 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/AstSyntaxWriter.cs @@ -0,0 +1,92 @@ +using System.Collections; +using System.Management.Automation.Language; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CSharpier.Core.PowerShell; + +// The PowerShell AST does not serialize cleanly - nodes point back at their Parent, which would +// cycle - so walk it by reflection, emitting each node's type and its child Asts/values as JSON. +internal static class AstSyntaxWriter +{ + private const int MaxDepth = 200; + + private static readonly JsonSerializerOptions IndentedJson = new() { WriteIndented = true }; + + internal static string Write(Ast ast) + { + return JsonSerializer.Serialize(ToNode(ast, 0), IndentedJson); + } + + private static JsonObject ToNode(Ast ast, int depth) + { + var node = new JsonObject { ["NodeType"] = ast.GetType().Name }; + + if (depth >= MaxDepth) + { + return node; + } + + foreach ( + var property in ast.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) + ) + { + // Parent walks back up the tree; Extent repeats the source text on every node. + if (property.Name is nameof(Ast.Parent) or nameof(Ast.Extent)) + { + continue; + } + + object? value; + try + { + value = property.GetValue(ast); + } + catch + { + continue; + } + + var converted = ToValue(value, depth + 1); + if (converted is not null) + { + node[property.Name] = converted; + } + } + + return node; + } + + private static JsonNode? ToValue(object? value, int depth) + { + switch (value) + { + case null: + return null; + case Ast ast: + return ToNode(ast, depth); + case string text: + return text; + case bool boolean: + return boolean; + case Enum enumValue: + return enumValue.ToString(); + // A single string is IEnumerable, so it is handled above before we get here. + case IEnumerable enumerable: + var array = new JsonArray(); + foreach (var item in enumerable) + { + var converted = ToValue(item, depth); + if (converted is not null) + { + array.Add(converted); + } + } + + return array.Count > 0 ? array : null; + default: + return value.ToString(); + } + } +} diff --git a/Src/CSharpier.Core/PowerShell/PowerShellFormatter.cs b/Src/CSharpier.Core/PowerShell/PowerShellFormatter.cs new file mode 100644 index 000000000..e3a2d97f3 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/PowerShellFormatter.cs @@ -0,0 +1,82 @@ +using System.Management.Automation.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace CSharpier.Core.PowerShell; + +internal static class PowerShellFormatter +{ + internal static Task FormatAsync( + string code, + PrinterOptions printerOptions + ) + { + var ast = Parser.ParseInput(code, out var tokens, out var errors); + + if (errors.Length != 0) + { + var sourceText = SourceText.From(code); + return Task.FromResult( + new CodeFormatterResult + { + Code = code, + ErrorDiagnostics = errors + .Select(error => CreateDiagnosticFromParseError(sourceText, error)) + .ToList(), + AST = printerOptions.IncludeAST ? AstSyntaxWriter.Write(ast) : string.Empty, + } + ); + } + + var lineEnding = PrinterOptions.GetLineEnding(code, printerOptions); + + var comments = new List(); + foreach (var token in tokens) + { + if (token.Kind == TokenKind.Comment) + { + comments.Add(token.Extent); + } + } + + var doc = PowerShellPrinter.Print(ast, comments); + var formatted = DocPrinter.DocPrinter.Print(doc, printerOptions, lineEnding); + + return Task.FromResult( + new CodeFormatterResult + { + Code = formatted, + AST = printerOptions.IncludeAST ? AstSyntaxWriter.Write(ast) : string.Empty, + } + ); + } + + private static Diagnostic CreateDiagnosticFromParseError( + SourceText sourceText, + ParseError error + ) + { + var extent = error.Extent; + + var start = Math.Clamp(extent.StartOffset, 0, sourceText.Length); + var end = Math.Clamp(extent.EndOffset, start, sourceText.Length); + var span = new TextSpan(start, end - start); + + var location = Location.Create( + filePath: string.Empty, + textSpan: span, + lineSpan: sourceText.Lines.GetLinePositionSpan(span) + ); + + var descriptor = new DiagnosticDescriptor( + id: "PS001", + title: "PowerShell parsing error", + messageFormat: "{0}", + category: "PowerShell", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + return Diagnostic.Create(descriptor, location, error.Message); + } +} diff --git a/Src/CSharpier.Core/PowerShell/PowerShellPrinter.cs b/Src/CSharpier.Core/PowerShell/PowerShellPrinter.cs new file mode 100644 index 000000000..55f2e8a44 --- /dev/null +++ b/Src/CSharpier.Core/PowerShell/PowerShellPrinter.cs @@ -0,0 +1,17 @@ +using System.Management.Automation.Language; +using CSharpier.Core.DocTypes; +using CSharpier.Core.PowerShell.AstPrinters; + +namespace CSharpier.Core.PowerShell; + +internal static class PowerShellPrinter +{ + internal static Doc Print( + ScriptBlockAst scriptBlock, + IReadOnlyList comments + ) + { + var context = new PrintContext(comments); + return Node.Print(scriptBlock, context); + } +} diff --git a/Src/CSharpier.Core/PrinterOptions.cs b/Src/CSharpier.Core/PrinterOptions.cs index 6f31acb68..ad2034005 100644 --- a/Src/CSharpier.Core/PrinterOptions.cs +++ b/Src/CSharpier.Core/PrinterOptions.cs @@ -71,6 +71,7 @@ public static Formatter GetFormatter(string filePath) "csx" => Formatter.CSharpScript, "config" or "csproj" or "props" or "slnx" or "targets" or "axaml" or "xaml" or "xml" => Formatter.XML, + "ps1" or "psm1" or "psd1" => Formatter.PowerShell, _ => Formatter.Unknown, }; } @@ -99,4 +100,5 @@ internal enum Formatter CSharp, CSharpScript, XML, + PowerShell, } diff --git a/Src/CSharpier.Core/Utilities/DocListBuilder.cs b/Src/CSharpier.Core/Utilities/DocListBuilder.cs index 011abbfe2..b5ae41c55 100644 --- a/Src/CSharpier.Core/Utilities/DocListBuilder.cs +++ b/Src/CSharpier.Core/Utilities/DocListBuilder.cs @@ -39,7 +39,10 @@ public ref Doc this[int index] } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Clear() => this.Length = 0; + public void Clear() + { + this.Length = 0; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Add(Doc item) diff --git a/Src/CSharpier.Playground/ClientApp/src/CodeEditor.tsx b/Src/CSharpier.Playground/ClientApp/src/CodeEditor.tsx index 3c4d1a6d3..a8da70e40 100644 --- a/Src/CSharpier.Playground/ClientApp/src/CodeEditor.tsx +++ b/Src/CSharpier.Playground/ClientApp/src/CodeEditor.tsx @@ -2,6 +2,8 @@ import React, { useEffect } from "react"; import { Controlled as CodeMirror } from "react-codemirror2"; import "codemirror/lib/codemirror.css"; import "codemirror/mode/clike/clike"; +import "codemirror/mode/xml/xml"; +import "codemirror/mode/powershell/powershell"; import { useOptions } from "./Hooks"; import { useAppContext } from "./AppContext"; import { observer } from "mobx-react-lite"; diff --git a/Src/CSharpier.Playground/ClientApp/src/Controls.tsx b/Src/CSharpier.Playground/ClientApp/src/Controls.tsx index df906f932..3b418ad6b 100644 --- a/Src/CSharpier.Playground/ClientApp/src/Controls.tsx +++ b/Src/CSharpier.Playground/ClientApp/src/Controls.tsx @@ -60,6 +60,14 @@ export const Controls = observer(() => { setFormatter("XML")} /> XML + {formatter === "XML" && ( <> diff --git a/Src/CSharpier.Playground/ClientApp/src/FormatCode.ts b/Src/CSharpier.Playground/ClientApp/src/FormatCode.ts index c74dfa8be..844f22d59 100644 --- a/Src/CSharpier.Playground/ClientApp/src/FormatCode.ts +++ b/Src/CSharpier.Playground/ClientApp/src/FormatCode.ts @@ -2,6 +2,17 @@ let gutters: any[] = []; let marks: any[] = []; let editor: any = undefined; +const parseSyntaxTree = (json: string) => { + if (!json) { + return undefined; + } + try { + return JSON.parse(json); + } catch { + return undefined; + } +}; + export const formatCode = async ( code: string, printWidth: number, @@ -27,7 +38,7 @@ export const formatCode = async ( }, 100); return { - syntaxTree: !data.json ? undefined : JSON.parse(data.json), + syntaxTree: parseSyntaxTree(data.json), formattedCode: data.code, doc: data.doc, hasErrors: !!data.errors.length, diff --git a/Src/CSharpier.Playground/ClientApp/src/Hooks.ts b/Src/CSharpier.Playground/ClientApp/src/Hooks.ts index 9742a7134..cee736528 100644 --- a/Src/CSharpier.Playground/ClientApp/src/Hooks.ts +++ b/Src/CSharpier.Playground/ClientApp/src/Hooks.ts @@ -1,11 +1,22 @@ import { useAppContext } from "./AppContext"; +const modeForFormatter = (formatter: string) => { + switch (formatter) { + case "XML": + return "xml"; + case "PowerShell": + return "powershell"; + default: + return "text/x-java"; + } +}; + export const useOptions = () => { - const { formatCode, setEmptyMethod, setEmptyClass, copyLeft } = useAppContext(); + const { formatCode, setEmptyMethod, setEmptyClass, copyLeft, formatter } = useAppContext(); return { matchBrackets: true, - mode: "text/x-java", + mode: modeForFormatter(formatter), indentWithTabs: false, smartIndent: false, tabSize: 4, diff --git a/Src/CSharpier.Playground/ClientApp/src/Layout.tsx b/Src/CSharpier.Playground/ClientApp/src/Layout.tsx index 21e0ebf93..fc11eed2f 100644 --- a/Src/CSharpier.Playground/ClientApp/src/Layout.tsx +++ b/Src/CSharpier.Playground/ClientApp/src/Layout.tsx @@ -1,6 +1,8 @@ import React from "react"; import "codemirror/lib/codemirror.css"; import "codemirror/mode/clike/clike"; +import "codemirror/mode/xml/xml"; +import "codemirror/mode/powershell/powershell"; import { SyntaxTree } from "./SyntaxTree"; import { DocTree } from "./DocTree"; import { Header } from "./Header"; diff --git a/Src/CSharpier.Playground/Controllers/FormatController.cs b/Src/CSharpier.Playground/Controllers/FormatController.cs index 7b1836275..1b627ced1 100644 --- a/Src/CSharpier.Playground/Controllers/FormatController.cs +++ b/Src/CSharpier.Playground/Controllers/FormatController.cs @@ -66,15 +66,20 @@ CancellationToken cancellationToken cancellationToken ); - var comparer = new SyntaxNodeComparer( - model.Code, - result.Code, - result.ReorderedModifiers, - result.ReorderedUsingsWithDisabledText, - result.MovedTrailingTrivia, - parsedFormatter is Formatter.CSharp ? SourceCodeKind.Regular : SourceCodeKind.Script, - cancellationToken - ); + var syntaxValidation = string.Empty; + if (parsedFormatter is not Formatter.PowerShell) + { + var comparer = new SyntaxNodeComparer( + model.Code, + result.Code, + result.ReorderedModifiers, + result.ReorderedUsingsWithDisabledText, + result.MovedTrailingTrivia, + parsedFormatter is Formatter.CSharp ? SourceCodeKind.Regular : SourceCodeKind.Script, + cancellationToken + ); + syntaxValidation = await comparer.CompareSourceAsync(CancellationToken.None); + } return new FormatResult { @@ -82,7 +87,7 @@ CancellationToken cancellationToken Json = result.AST, Doc = result.DocTree, Errors = result.ErrorDiagnostics.Select(this.ConvertError).ToList(), - SyntaxValidation = await comparer.CompareSourceAsync(CancellationToken.None), + SyntaxValidation = syntaxValidation, }; } diff --git a/Src/CSharpier.Playground/Program.cs b/Src/CSharpier.Playground/Program.cs index 696b785b6..0e811f961 100644 --- a/Src/CSharpier.Playground/Program.cs +++ b/Src/CSharpier.Playground/Program.cs @@ -10,10 +10,12 @@ public static void Main(string[] args) CreateHostBuilder(args).Build().Run(); } - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) + public static IHostBuilder CreateHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); + } } diff --git a/Src/CSharpier.Tests/CommandLineFormatterTests.cs b/Src/CSharpier.Tests/CommandLineFormatterTests.cs index 6820edacf..13c2128d3 100644 --- a/Src/CSharpier.Tests/CommandLineFormatterTests.cs +++ b/Src/CSharpier.Tests/CommandLineFormatterTests.cs @@ -1263,6 +1263,7 @@ public void WriteError(string value) } public Encoding InputEncoding => Encoding.UTF8; + public ConsoleColor ForegroundColor { get; set; } public void ResetColor() { } diff --git a/Src/CSharpier.Tests/FormattingTests/BaseTest.cs b/Src/CSharpier.Tests/FormattingTests/BaseTest.cs index b1ea3196d..01503f2b8 100644 --- a/Src/CSharpier.Tests/FormattingTests/BaseTest.cs +++ b/Src/CSharpier.Tests/FormattingTests/BaseTest.cs @@ -49,6 +49,7 @@ public void BuildTests(DynamicTestBuilderContext context, string folder) "cs" => Formatter.CSharp, "csx" => Formatter.CSharpScript, "xml" => Formatter.XML, + "powershell" => Formatter.PowerShell, _ => Formatter.Unknown, }; @@ -123,7 +124,6 @@ PrinterOptions printerOptions normalizedCode = normalizedCode.Replace("\r\n", "\n"); } - // TODO #1359 xml comparer here var comparer = new SyntaxNodeComparer( expectedCode, normalizedCode, diff --git a/Src/CSharpier.Tests/FormattingTests/PowerShellFormatting.cs b/Src/CSharpier.Tests/FormattingTests/PowerShellFormatting.cs new file mode 100644 index 000000000..419608b6a --- /dev/null +++ b/Src/CSharpier.Tests/FormattingTests/PowerShellFormatting.cs @@ -0,0 +1,10 @@ +namespace CSharpier.Tests.FormattingTests; + +public class PowerShellFormatting : BaseTest +{ + [DynamicTestBuilder] + public void BuildTests(DynamicTestBuilderContext context) + { + this.BuildTests(context, "powershell"); + } +} diff --git a/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Comments.test b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Comments.test new file mode 100644 index 000000000..01aead12a --- /dev/null +++ b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Comments.test @@ -0,0 +1,7 @@ +# basic comment +Write-Host "SomeValue" + +if ($true) { + # basic comment + Write-Host "SomeValue" +} diff --git a/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Foreach.test b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Foreach.test new file mode 100644 index 000000000..c4befd122 --- /dev/null +++ b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Foreach.test @@ -0,0 +1,9 @@ +foreach ($x in $items) { + if ($x -gt 5) { + Write-Output "big" + } elseif ($x -gt 0) { + Write-Output "small" + } else { + Write-Output "neg" + } +} diff --git a/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Function.test b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Function.test new file mode 100644 index 000000000..5c825e641 --- /dev/null +++ b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Function.test @@ -0,0 +1,9 @@ +function Get-Foo { + param($a,$b) + + if ($a) { + Write-Host $a + } else { + Write-Host $b + } +} diff --git a/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/HereString.test b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/HereString.test new file mode 100644 index 000000000..73e035466 --- /dev/null +++ b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/HereString.test @@ -0,0 +1,5 @@ +$x = @" +line1 + indented +line2 +"@ diff --git a/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Random.test b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Random.test new file mode 100644 index 000000000..7234561de --- /dev/null +++ b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Random.test @@ -0,0 +1,42 @@ +<# +.SYNOPSIS + Plays audio files +.DESCRIPTION + This PowerShell script plays the given audio files (supporting .MP3 and .WAV format). +.PARAMETER filePattern + Specifies the file pattern ('*' by default) +.EXAMPLE + PS> ./play-files.ps1 *.mp3 + ▶️ Playing '01 Sandy beaches - strong waves.mp3' (02:54) ... + ... +.LINK + https://github.com/fleschutz/PowerShell +.NOTES + Author: Markus Fleschutz | License: CC0 +#> + +param([string]$filePattern = "*") + +try { + $stopWatch = [system.diagnostics.stopwatch]::startNew() + + $files = (Get-ChildItem -path "$filePattern" -attributes !Directory) + [int]$count = 0 + foreach ($file in $files) { + if ("$file" -like "*.mp3") { + & "$PSScriptRoot/play-mp3.ps1" "$file" + $count++ + } elseif ("$File" -like "*.wav") { + & "$PSScriptRoot/play-mp3.ps1" "$file" + $count++ + } else { + "Skipping $file (no audio file)..." + } + } + [int]$elapsed = $stopWatch.Elapsed.TotalSeconds + "✅ Played $count audio files for $($elapsed)s." + exit 0 # success +} catch { + "⚠️ ERROR: $($Error[0]) (script line $($_.InvocationInfo.ScriptLineNumber))" + exit 1 +} \ No newline at end of file diff --git a/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Try.test b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Try.test new file mode 100644 index 000000000..95ab7a3e7 --- /dev/null +++ b/Src/CSharpier.Tests/FormattingTests/TestFiles/powershell/Try.test @@ -0,0 +1,12 @@ +try { + exit 0 # success! +} catch { + Write-Host "Catch" + exit 1 +} finally { + Write-Host "Finally" +} + +try { + exit 0 # success! +} \ No newline at end of file diff --git a/Src/CSharpier.Tests/PowerShell/PowerShellFormatterTests.cs b/Src/CSharpier.Tests/PowerShell/PowerShellFormatterTests.cs new file mode 100644 index 000000000..5fe7316ad --- /dev/null +++ b/Src/CSharpier.Tests/PowerShell/PowerShellFormatterTests.cs @@ -0,0 +1,75 @@ +using AwesomeAssertions; +using CSharpier.Core; +using CSharpier.Core.PowerShell; + +namespace CSharpier.Tests.PowerShell; + +public class PowerShellFormatterTests +{ + [Test] + [Arguments("script.ps1")] + [Arguments("module.psm1")] + [Arguments("manifest.psd1")] + [Arguments("SCRIPT.PS1")] + public void GetFormatter_Recognizes_PowerShell_Extensions(string fileName) + { + PrinterOptions.GetFormatter(fileName).Should().Be(Formatter.PowerShell); + } + + [Test] + public void Should_Report_Errors() + { + var code = "function { broken ("; + + var options = new PrinterOptions(Formatter.PowerShell, XmlWhitespaceSensitivity.Strict); + var result = PowerShellFormatter.FormatAsync(code, options).Result; + + result.Code.Should().Be(code); + result + .ErrorDiagnostics.First() + .ToString() + .Should() + .Be("(1,9): error PS001: Missing name after function keyword."); + } + + [Test] + public void Should_Include_Ast_When_Requested() + { + var code = "if ($true) { Get-Item }\n"; + + var options = new PrinterOptions(Formatter.PowerShell, XmlWhitespaceSensitivity.Strict) + { + IncludeAST = true, + }; + var result = PowerShellFormatter.FormatAsync(code, options).Result; + + result.AST.Should().Contain("ScriptBlockAst"); + result.AST.Should().Contain("IfStatementAst"); + } + + [Test] + public void Should_Include_Ast_For_Unparsable_Code_When_Requested() + { + var code = "function { broken ("; + + var options = new PrinterOptions(Formatter.PowerShell, XmlWhitespaceSensitivity.Strict) + { + IncludeAST = true, + }; + var result = PowerShellFormatter.FormatAsync(code, options).Result; + + result.ErrorDiagnostics.Should().NotBeEmpty(); + result.AST.Should().Contain("ScriptBlockAst"); + } + + [Test] + public void Should_Not_Include_Ast_By_Default() + { + var code = "Get-Item\n"; + + var options = new PrinterOptions(Formatter.PowerShell, XmlWhitespaceSensitivity.Strict); + var result = PowerShellFormatter.FormatAsync(code, options).Result; + + result.AST.Should().BeEmpty(); + } +} diff --git a/openspec/changes/add-powershell-formatting/.openspec.yaml b/openspec/changes/add-powershell-formatting/.openspec.yaml new file mode 100644 index 000000000..d65893647 --- /dev/null +++ b/openspec/changes/add-powershell-formatting/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/add-powershell-formatting/design.md b/openspec/changes/add-powershell-formatting/design.md new file mode 100644 index 000000000..e66ff7f59 --- /dev/null +++ b/openspec/changes/add-powershell-formatting/design.md @@ -0,0 +1,78 @@ +## Context + +CSharpier is built around a language-agnostic printing engine ported from Prettier: a source language is parsed, walked to build a **Doc** tree out of the primitives in `Src/CSharpier.Core/DocTypes/` (`Group`, `IndentDoc`, `LineDoc`, `IfBreak`, `HardLine`, `StringDoc`, …), and that tree is rendered by the shared `DocPrinter` (`Src/CSharpier.Core/DocPrinter/`) which owns width, indentation, and line-break decisions. Two front-ends already sit on this engine: + +- **C#** (`CSharp/CSharpFormatter.cs`) — parses with Roslyn, walks `SyntaxNode`s, reattaches comments/trivia, and validates output with `SyntaxNodeComparer`. +- **XML** (`Xml/XmlFormatter.cs`) — deliberately avoids the `System.Xml` DOM for printing and instead uses a hand-written `RawNodeReader` to preserve fidelity, using `System.Xml`'s `XmlReader` only to *validate* that the input is well-formed. + +Adding a language is a known, bounded shape. The wiring touch points, all confirmed in the current codebase, are: + +1. `Formatter` enum + extension map in `Core/PrinterOptions.cs` (`GetFormatter`). +2. Dispatch arm in `Core/CodeFormatter.cs` (`FormatAsync`). +3. A public `*.Format` / `FormatAsync` entry point (parallel to `CSharpFormatter` / `XmlFormatter`), plus an entry in `Core/PublicAPI.Unshipped.txt`. +4. A validator branch in `Cli/FormattingEngine.cs` (`ValidateFormatting`). +5. Tests under `Src/CSharpier.Tests/` using the existing `FormattingTests` sample-pair harness. + +The novel, hard part for PowerShell is **not** the wiring — it is obtaining a faithful parse and re-printing a large, context-sensitive grammar well. + +## Goals / Non-Goals + +**Goals:** +- Format `.ps1`, `.psm1`, `.psd1` through the existing Doc engine with the same options (width, indent style/size, end-of-line) as other languages. +- Follow the established plug-in pattern so PowerShell is additive and the C#/XML paths are untouched. +- Be safe by default: parse → build Doc → print; leave unparseable input unchanged with a warning; validate that formatting preserves program meaning before writing. +- Cover the constructs found in typical scripts and module manifests, preserving comments, comment-based help, and here-strings verbatim. + +**Non-Goals:** +- 100% coverage of every PowerShell construct (DSC, dynamic keywords, exotic edge cases) in the first release. +- New user-facing style options — CSharpier follows Prettier's option philosophy; PowerShell inherits the existing options only. +- Semantic rewriting (alias expansion, casing normalization of cmdlet names, quote-style changes beyond what safe re-printing requires) — these are linting concerns, not formatting. +- Reformatting the *inside* of here-strings or embedded here-doc content. + +## Decisions + +### Decision 1: Parse with `System.Management.Automation`'s `Parser` — do not hand-roll a parser + +PowerShell ships an authoritative, MIT-licensed parser: `System.Management.Automation.Language.Parser.ParseInput(text, out Token[] tokens, out ParseError[] errors)`, which returns a `ScriptBlockAst` **and** the full token stream. This is the same engine PowerShell itself and PSScriptAnalyzer use. + +- **Why over a hand-rolled reader (the XML approach):** XML's grammar is small enough that `RawNodeReader` is tractable. PowerShell's grammar (pipelines, script blocks, splatting, subexpressions, format/redirection operators, backtick continuation, here-strings, expandable-string subexpressions, statement-terminating newlines) is not — re-implementing it would be a parser project of its own and a permanent correctness liability. Reuse the real one. +- **Comment/trivia handling:** PowerShell's AST does not contain comments; they arrive as `Token`s of kind `Comment` with source `Extent`s. This mirrors Roslyn trivia reattachment that the C# front-end already does — walk the AST for structure, and splice comments back in by offset from the token stream. +- **Verbatim spans:** string literals, here-strings, and command-argument text are emitted from their source `Extent` (offset range into the original text), not reconstructed, so their contents round-trip exactly. + +**Alternatives considered:** (a) hand-rolled tokenizer/reader — rejected, see above; (b) a third-party managed PowerShell grammar — none is authoritative or maintained enough to trust for round-tripping; (c) shelling out to `pwsh` — introduces a runtime dependency and process cost, unacceptable for a bundled formatter. + +### Decision 2: Isolate the dependency and target frameworks carefully + +`CSharpier.Core` currently multi-targets `net8.0;net9.0;net10.0;netstandard2.0`. The `System.Management.Automation` reference package supports the modern .NET targets but **not** `netstandard2.0`. This is the single biggest structural constraint. + +Approach: +- Add the PowerShell parser reference only for the modern TFMs, and either (a) compile the PowerShell front-end out of the `netstandard2.0` build (returning "unsupported on this target" for `.ps1` there), or (b) move the PowerShell front-end into a target-conditional compilation unit. The netstandard2.0 target exists for embedding scenarios; PowerShell support degrading there is acceptable and must be explicit. +- Keep all `System.Management.Automation` types behind the `PowerShell/` namespace so no other part of Core (or consumers who only format C#/XML) references them. +- **Binary size:** Core already carries Roslyn, so it is not a lightweight assembly, but the PowerShell SDK surface is large. Measure the packaged-size delta early; if it is unacceptable for the global tool, fall back to shipping PowerShell support as a separately-referenced piece. This measurement gates the approach and is tracked as an open question. + +### Decision 3: Mirror the XML wiring exactly + +Add `Formatter.PowerShell`; map `ps1`/`psm1`/`psd1` in `GetFormatter`; add a `Formatter.PowerShell` arm in `CodeFormatter.FormatAsync`; add `PowerShellFormatter.Format`/`FormatAsync` with the same signature shape as `XmlFormatter`; register the public entry in `PublicAPI.Unshipped.txt`. Default indent size is 4 (PowerShell convention), unlike XML's 2 — set via the same `PrinterOptions` mechanism that already special-cases XML. + +### Decision 4: Validate by re-parse and structural comparison + +Add `PowerShellFormattingValidator` (implementing the existing `IFormattingValidator`) and a `Formatter.PowerShell` branch in `FormattingEngine.ValidateFormatting`. Start with the lighter `XmlFormattingValidator` shape: re-parse the formatted output, assert it has no new `ParseError`s, and compare a trivia-independent normalization of the two `ScriptBlockAst`s for structural equivalence. This catches the catastrophic failure mode (formatting changed what the script *does*) without requiring a full C#-style node comparer up front. + +### Decision 5: Scope the first release to a parseable subset, format-what-you-can + +Everything that parses gets formatted; anything with parse errors is returned unchanged with a warning, exactly like `XmlFormatter`'s invalid-input path. Within parseable input, cover the common statement/expression/pipeline constructs first and expand node-printer coverage incrementally, with any unhandled node falling back to verbatim source-extent emission so output is never corrupted — only sub-optimally formatted. + +## Risks / Trade-offs + +- **netstandard2.0 cannot carry the parser** → Compile PowerShell support out of that target and document that `.ps1` formatting requires a modern-.NET host; the CLI/tool ships on modern .NET so end users are unaffected. +- **PowerShell SDK binary-size / cold-start cost** → Measure the packaged delta before committing; if unacceptable, ship the PowerShell front-end behind a separate reference rather than folding it into the default Core package. +- **Context-sensitive newlines and continuation** (statement-terminating newlines, backtick continuation, `|` at line start/end, splatting) are where most formatting bugs will live → Lean on source `Extent`s for anything ambiguous, add a large idempotency corpus, and treat "output re-parses to an equivalent AST" as a hard gate in tests. +- **Comment/here-string fidelity** → Reattach comments by token extent and emit here-strings/string literals verbatim from spans; cover with targeted sample pairs. +- **Formatting-quality maturity at first release** → Consider gating behind an experimental/opt-in signal initially (as CSharpier has done for maturing features) so early adopters opt in while the node-printer coverage broadens. + +## Open Questions + +- **Exact dependency and its size:** full `System.Management.Automation` (or `Microsoft.PowerShell.SDK`) vs a trimmed parser-only reference — and the measured packaged-size impact on the global tool. This gates Decision 2. +- **Coverage boundary for v1:** which constructs are explicitly in-scope vs deferred (e.g., DSC configurations, dynamic keywords, class syntax, workflow). +- **Experimental gate:** ship on by extension immediately, or behind an opt-in flag until node-printer coverage is broad enough to avoid churn in users' scripts? +- **Playground/editor/docs rollout:** whether these secondary surfaces land in the same change or a follow-up. diff --git a/openspec/changes/add-powershell-formatting/proposal.md b/openspec/changes/add-powershell-formatting/proposal.md new file mode 100644 index 000000000..55fd33337 --- /dev/null +++ b/openspec/changes/add-powershell-formatting/proposal.md @@ -0,0 +1,30 @@ +## Why + +CSharpier already formats two languages — C#/C# script and XML — through a shared, language-agnostic Doc/DocPrinter engine ported from Prettier. PowerShell (`.ps1`, `.psm1`, `.psd1`) is a common companion language in .NET repositories (build scripts, tooling, CI helpers, module manifests) and today has no widely-adopted, opinionated, zero-config formatter in the .NET ecosystem. Adding PowerShell support lets teams that already run CSharpier keep their scripts consistently formatted with the same tool, cache, editor integrations, and pre-commit hook they already use. + +## What Changes + +- Add a new `Formatter.PowerShell` variant and route `.ps1`, `.psm1`, and `.psd1` files to it via extension detection. +- Add a `PowerShellFormatter` that parses PowerShell source, builds a Prettier-style Doc tree with the existing Doc primitives, and prints it through the shared `DocPrinter`. +- Add a public `PowerShellFormatter.Format` / `FormatAsync` entry point mirroring `CSharpFormatter` and `XmlFormatter`, and surface it through `CodeFormatter.FormatAsync` dispatch. +- Add a `PowerShellFormattingValidator` so the CLI's post-format safety check can confirm the formatted output parses to an equivalent tree (parallel to `CSharpFormattingValidator` / `XmlFormattingValidator`). +- Add idempotency/formatting-sample tests and CLI integration coverage for the new file types. +- Scope note: the initial change targets a well-defined, common subset of PowerShell (the constructs found in typical scripts and module manifests) and formats anything it can parse; genuinely unparseable input is left untouched with a warning, matching the XML formatter's "invalid input is not formatted" behavior. + +## Capabilities + +### New Capabilities +- `powershell-formatting`: Detecting PowerShell files by extension, parsing them, re-printing them through the shared Doc engine with CSharpier's indentation/line-width rules, leaving unparseable input unchanged with a warning, and validating that formatting preserves program meaning. + +### Modified Capabilities + + +## Impact + +- **New code**: `Src/CSharpier.Core/PowerShell/` (formatter, node printers, validator, and — depending on the design decision — a raw/token reader). New tests under `Src/CSharpier.Tests/PowerShell/` plus CLI integration cases. +- **Modified code**: `PrinterOptions.GetFormatter` (extension map) and the `Formatter` enum; `CodeFormatter.FormatAsync` (dispatch); `FormattingEngine.ValidateFormatting` (validator branch); `Src/CSharpier.Core/PublicAPI.Unshipped.txt` (new public entry point). +- **Dependencies**: The central open question is how to obtain a PowerShell parser/AST. Options — take a dependency on the PowerShell parsing surface (`System.Management.Automation`), or write a lightweight tokenizer/reader in the spirit of the XML `RawNodeReader`. This trade-off (binary size, licensing, cross-target-framework support, comment/trivia fidelity) is resolved in design.md. +- **Surfaces to update (secondary)**: Playground language handling, documentation (supported file types), and editor extension file-type lists. +- **No breaking changes**: existing C#/XML behavior is unaffected; PowerShell is purely additive. diff --git a/openspec/changes/add-powershell-formatting/specs/powershell-formatting/spec.md b/openspec/changes/add-powershell-formatting/specs/powershell-formatting/spec.md new file mode 100644 index 000000000..598a621f8 --- /dev/null +++ b/openspec/changes/add-powershell-formatting/specs/powershell-formatting/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: PowerShell file detection + +The system SHALL recognize PowerShell source files by extension and route them to the PowerShell formatter. + +#### Scenario: Recognized PowerShell extensions +- **WHEN** a file path ends in `.ps1`, `.psm1`, or `.psd1` (case-insensitively) +- **THEN** the system SHALL resolve its formatter to PowerShell + +#### Scenario: Non-PowerShell extensions are unaffected +- **WHEN** a file path ends in an extension already mapped to another formatter (for example `.cs`, `.csx`, `.xml`) or in an unrecognized extension +- **THEN** the system SHALL resolve its formatter to that existing formatter or to Unknown, and SHALL NOT route it to the PowerShell formatter + +### Requirement: PowerShell formatting entry point + +The system SHALL expose a public PowerShell formatting entry point that accepts source text and formatting options and returns a formatter result, mirroring the existing C# and XML entry points. + +#### Scenario: Format valid PowerShell source +- **WHEN** valid PowerShell source is passed to the PowerShell formatter +- **THEN** the system SHALL return a result whose formatted code is the re-printed PowerShell and whose error diagnostics are empty + +#### Scenario: Dispatch through the shared formatter +- **WHEN** the shared code formatter is invoked with options whose formatter is PowerShell +- **THEN** the system SHALL delegate to the PowerShell formatter and return its result + +### Requirement: Opinionated re-printing through the shared engine + +The system SHALL re-print PowerShell by building a Doc tree with the shared Doc primitives and printing it through the shared DocPrinter, so that indentation, line-width, indent style, and end-of-line handling follow the same configured options as other languages. + +#### Scenario: Indentation and line width applied +- **WHEN** PowerShell source with inconsistent indentation is formatted with the configured print width and indent size +- **THEN** the system SHALL emit output indented according to the configured indent style and size, wrapping constructs that exceed the configured print width where the formatter supports breaking + +#### Scenario: End-of-line normalization +- **WHEN** PowerShell source is formatted +- **THEN** the system SHALL produce line endings according to the configured end-of-line option, defaulting to the source's detected line ending when the option is Auto + +#### Scenario: Idempotent formatting +- **WHEN** already-formatted PowerShell output is formatted a second time with the same options +- **THEN** the system SHALL produce byte-identical output + +### Requirement: Unparseable input is left unchanged + +The system SHALL NOT alter PowerShell source that cannot be parsed, and SHALL report the condition rather than emit corrupted output. + +#### Scenario: Syntactically invalid PowerShell +- **WHEN** PowerShell source contains parse errors +- **THEN** the system SHALL return the original source unchanged together with a warning (or error diagnostics) indicating the input could not be formatted, and SHALL NOT write mangled output + +### Requirement: Formatting preserves program meaning + +The system SHALL validate that formatting a PowerShell file does not change the program it represents, consistent with the validation performed for other languages, so that formatting is safe to apply automatically. + +#### Scenario: Post-format validation on change +- **WHEN** the CLI formats a PowerShell file whose formatted output differs from its input and validation is not skipped +- **THEN** the system SHALL re-parse the output and compare it against the input, and SHALL report a validation failure (rather than write the file) if the two are not equivalent + +#### Scenario: Comments and here-strings are preserved +- **WHEN** PowerShell source containing comments, comment-based help, or here-strings is formatted +- **THEN** the formatted output SHALL retain those comments and preserve here-string contents verbatim diff --git a/openspec/changes/add-powershell-formatting/tasks.md b/openspec/changes/add-powershell-formatting/tasks.md new file mode 100644 index 000000000..6f9e146d1 --- /dev/null +++ b/openspec/changes/add-powershell-formatting/tasks.md @@ -0,0 +1,47 @@ +## 1. Parser dependency spike (gating) + +- [ ] 1.1 Add the `System.Management.Automation` reference to a throwaway/test target and confirm `Parser.ParseInput` returns a `ScriptBlockAst`, token stream, and `ParseError[]` for representative `.ps1`/`.psm1`/`.psd1` samples +- [ ] 1.2 Measure the packaged-size and cold-start delta the dependency adds to the CLI/global tool; record the number in design.md's Open Questions and decide full SDK vs parser-only vs separate reference +- [ ] 1.3 Confirm the dependency's supported target frameworks and settle the `netstandard2.0` strategy (compile PowerShell support out of that TFM vs target-conditional unit); write the decision into `CSharpier.Core.csproj` conditions + +## 2. Formatter wiring (plug into the shared engine) + +- [ ] 2.1 Add `PowerShell` to the `Formatter` enum in `Src/CSharpier.Core/PrinterOptions.cs` +- [ ] 2.2 Map `ps1`, `psm1`, `psd1` (case-insensitive) to `Formatter.PowerShell` in `PrinterOptions.GetFormatter` +- [ ] 2.3 Default the PowerShell indent size to 4 in `PrinterOptions` (parallel to the XML special-case) +- [ ] 2.4 Add a `Formatter.PowerShell` arm to `CodeFormatter.FormatAsync` in `Src/CSharpier.Core/CodeFormatter.cs` + +## 3. PowerShell front-end + +- [ ] 3.1 Create `Src/CSharpier.Core/PowerShell/PowerShellFormatter.cs` with public `Format` and `FormatAsync` entry points mirroring `XmlFormatter` (parse → build Doc → `DocPrinter.Print`), returning original source + warning on parse errors +- [ ] 3.2 Add the new public entry point(s) to `Src/CSharpier.Core/PublicAPI.Unshipped.txt` +- [ ] 3.3 Implement AST-walking node printers under `Src/CSharpier.Core/PowerShell/` for the core constructs (script blocks, pipelines, commands + parameters/arguments, assignments, if/switch/loops, function definitions, hashtables/arrays, param blocks) +- [ ] 3.4 Emit string literals, here-strings, and unhandled/unknown nodes verbatim from their source `Extent` so contents round-trip exactly +- [ ] 3.5 Reattach comments and comment-based help from the token stream by source offset (parallel to the C# trivia handling) +- [ ] 3.6 Apply end-of-line normalization and indent style/size via the existing `PrinterOptions`/`DocPrinter` path + +## 4. Validation + +- [ ] 4.1 Create `PowerShellFormattingValidator` implementing `IFormattingValidator`: re-parse the formatted output, assert no new `ParseError`s, and compare a trivia-independent normalization of input vs output `ScriptBlockAst` for structural equivalence +- [ ] 4.2 Add a `Formatter.PowerShell` branch to `FormattingEngine.ValidateFormatting` in `Src/CSharpier.Cli/FormattingEngine.cs` + +## 5. Tests + +- [ ] 5.1 Add a PowerShell subclass of the `FormattingTests` sample-pair harness under `Src/CSharpier.Tests/` and seed input/expected sample files for the core constructs +- [ ] 5.2 Add an idempotency corpus asserting that formatting already-formatted output is byte-identical +- [ ] 5.3 Add a test asserting unparseable PowerShell is returned unchanged with a warning and no mangled output +- [ ] 5.4 Add `PowerShellFormattingValidator` unit tests (equivalent output passes; a meaning-changing edit fails) +- [ ] 5.5 Add CLI integration coverage: `.ps1`/`.psm1`/`.psd1` files are discovered, formatted, `--check`-reported, and warned-on when unsupported (netstandard2.0 host) +- [ ] 5.6 Add comment / comment-based-help / here-string fidelity tests + +## 6. Secondary surfaces (may split into a follow-up change) + +- [ ] 6.1 Surface PowerShell in the Playground language handling +- [ ] 6.2 Update documentation to list the new supported file types +- [ ] 6.3 Update editor-extension file-type lists as needed +- [ ] 6.4 Decide and, if chosen, implement an experimental/opt-in gate for the first release + +## 7. Verification + +- [ ] 7.1 Run the full test suite and confirm C#/XML behavior is unchanged +- [ ] 7.2 Run `openspec validate add-powershell-formatting` and confirm the change is consistent