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