From 82848f3ab39c1ba185e507bf68617e8ef6195f5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:59:29 +0000 Subject: [PATCH 1/8] Initial plan From ab19dd56b4ff855b94553e9030667a8f1b72281e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:06:25 +0000 Subject: [PATCH 2/8] Add Path support to Invoke-Formatter Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Engine/Commands/InvokeFormatterCommand.cs | 107 +++++++++++++++++++++- Tests/Engine/InvokeFormatter.tests.ps1 | 38 ++++++++ docs/Cmdlets/Invoke-Formatter.md | 35 ++++++- 3 files changed, 171 insertions(+), 9 deletions(-) diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs index 25a2d364e..b1db54604 100644 --- a/Engine/Commands/InvokeFormatterCommand.cs +++ b/Engine/Commands/InvokeFormatterCommand.cs @@ -2,7 +2,9 @@ // Licensed under the MIT License. using System; +using System.IO; using System.Globalization; +using System.Text; using System.Management.Automation; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands @@ -12,20 +14,32 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands /// /// A cmdlet to format a PowerShell script text. /// - [Cmdlet(VerbsLifecycle.Invoke, "Formatter")] + [Cmdlet(VerbsLifecycle.Invoke, "Formatter", DefaultParameterSetName = ParameterSet_ScriptDefinition, SupportsShouldProcess = true)] [OutputType(typeof(string))] public class InvokeFormatterCommand : PSCmdlet, IOutputWriter { + private const string ParameterSet_ScriptDefinition = nameof(ScriptDefinition); + private const string ParameterSet_Path = nameof(Path); + private const string IndentationRule = "PSUseConsistentIndentation"; private const string defaultSettingsPreset = "CodeFormatting"; private Settings inputSettings; private Range range; + private bool settingsWereSpecified; + + /// + /// The path to the file to format in place. + /// + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_Path, ValueFromPipelineByPropertyName = true, Position = 1)] + [ValidateNotNull] + [Alias("PSPath")] + public string Path { get; set; } /// /// The script text to be formated. /// /// *NOTE*: Unlike ScriptBlock parameter, the ScriptDefinition parameter require a string value. /// - [ParameterAttribute(Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, Position = 1)] + [ParameterAttribute(Mandatory = true, ParameterSetName = ParameterSet_ScriptDefinition, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, Position = 1)] [ValidateNotNull] public string ScriptDefinition { get; set; } @@ -80,6 +94,7 @@ protected override void BeginProcessing() #endif this.range = Range == null ? null : new Range(Range[0], Range[1], Range[2], Range[3]); + settingsWereSpecified = MyInvocation.BoundParameters.ContainsKey(nameof(Settings)); try { inputSettings = PSSASettings.Create(Settings, this.MyInvocation.PSScriptRoot, this, GetResolvedProviderPathFromPSPath); @@ -108,9 +123,91 @@ protected override void BeginProcessing() protected override void ProcessRecord() { // todo add tests to check range formatting - string formattedScriptDefinition; - formattedScriptDefinition = Formatter.Format(ScriptDefinition, inputSettings, range, this); - this.WriteObject(formattedScriptDefinition); + if (ParameterSetName == ParameterSet_Path) + { + ProviderInfo provider; + var resolvedPaths = GetResolvedProviderPathFromPSPath(Path, out provider); + if (!String.Equals(provider.Name, "FileSystem", StringComparison.OrdinalIgnoreCase)) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException("Path must resolve to a file system path.", nameof(Path)), + "INVALID_PATH_PROVIDER", + ErrorCategory.InvalidArgument, + Path)); + } + + foreach (var path in resolvedPaths) + { + FormatFile(path); + } + } + else + { + string formattedScriptDefinition = Formatter.Format(ScriptDefinition, inputSettings, range, this); + WriteObject(formattedScriptDefinition); + } + } + + private void FormatFile(string path) + { + Encoding encoding; + string scriptDefinition; + using (var reader = new StreamReader(path, Encoding.UTF8, true)) + { + scriptDefinition = reader.ReadToEnd(); + encoding = reader.CurrentEncoding; + } + + if (inputSettings.RuleArguments.TryGetValue(IndentationRule, out var indentationSettings) + && (!settingsWereSpecified || !indentationSettings.ContainsKey("Kind"))) + { + indentationSettings["Kind"] = GetIndentationKind(scriptDefinition); + } + + string formattedScriptDefinition = Formatter.Format(scriptDefinition, inputSettings, range, this); + if (!String.Equals(scriptDefinition, formattedScriptDefinition, StringComparison.Ordinal) + && ShouldProcess(path, "Format file")) + { + File.WriteAllText(path, formattedScriptDefinition, encoding); + } + + WriteObject(formattedScriptDefinition); + } + + private static string GetIndentationKind(string scriptDefinition) + { + int tabIndentedLines = 0; + int spaceIndentedLines = 0; + string firstIndentationKind = "space"; + bool foundIndentation = false; + + foreach (var line in scriptDefinition.Split('\n')) + { + var trimmedLine = line.TrimEnd('\r'); + if (trimmedLine.Length > 0 && (trimmedLine[0] == '\t' || trimmedLine[0] == ' ')) + { + if (!foundIndentation) + { + firstIndentationKind = trimmedLine[0] == '\t' ? "tab" : "space"; + foundIndentation = true; + } + + if (trimmedLine[0] == '\t') + { + tabIndentedLines++; + } + else + { + spaceIndentedLines++; + } + } + } + + return tabIndentedLines > spaceIndentedLines + ? "tab" + : tabIndentedLines < spaceIndentedLines + ? "space" + : firstIndentationKind; } protected override void EndProcessing() diff --git a/Tests/Engine/InvokeFormatter.tests.ps1 b/Tests/Engine/InvokeFormatter.tests.ps1 index dcc0a6174..9d03cdc73 100644 --- a/Tests/Engine/InvokeFormatter.tests.ps1 +++ b/Tests/Engine/InvokeFormatter.tests.ps1 @@ -27,6 +27,44 @@ Describe "Invoke-Formatter Cmdlet" { } } + Context 'Formatting files' { + It 'Formats a file in place while preserving its encoding, line endings, and tab indentation' { + $path = Join-Path $TestDrive 'script.ps1' + $scriptDefinition = "function foo {`r`n`t`$x=1`r`n}" + $expected = "function foo {`r`n`t`$x = 1`r`n}" + $encoding = [System.Text.UTF8Encoding]::new($true) + [System.IO.File]::WriteAllText($path, $scriptDefinition, $encoding) + + Invoke-Formatter -Path $path | Should -Be $expected + + [System.IO.File]::ReadAllText($path) | Should -Be $expected + [System.IO.File]::ReadAllBytes($path)[0..2] | Should -Be @(239, 187, 191) + } + + It 'Preserves a UTF-16 encoding and space indentation' { + $path = Join-Path $TestDrive 'unicode.ps1' + $scriptDefinition = "function foo {`n `$x=1`n}" + $expected = "function foo {`n `$x = 1`n}" + $encoding = [System.Text.UnicodeEncoding]::new($false, $true) + [System.IO.File]::WriteAllText($path, $scriptDefinition, $encoding) + + Invoke-Formatter -Path $path | Should -Be $expected + + [System.IO.File]::ReadAllText($path) | Should -Be $expected + [System.IO.File]::ReadAllBytes($path)[0..1] | Should -Be @(255, 254) + } + + It 'Does not modify a file when WhatIf is specified' { + $path = Join-Path $TestDrive 'whatif.ps1' + $scriptDefinition = '$x=1' + [System.IO.File]::WriteAllText($path, $scriptDefinition) + + Invoke-Formatter -Path $path -WhatIf 6>$null + + [System.IO.File]::ReadAllText($path) | Should -Be $scriptDefinition + } + } + Context "When positional parameters are given" { It "Should use the positional parameters" { $def = @" diff --git a/docs/Cmdlets/Invoke-Formatter.md b/docs/Cmdlets/Invoke-Formatter.md index 80711b0bd..c3f724803 100644 --- a/docs/Cmdlets/Invoke-Formatter.md +++ b/docs/Cmdlets/Invoke-Formatter.md @@ -10,20 +10,25 @@ schema: 2.0.0 ## SYNOPSIS -Formats a script text based on the input settings or default settings. +Formats script text or a file based on the input settings or default settings. ## SYNTAX ``` Invoke-Formatter [-ScriptDefinition] [[-Settings] ] [[-Range] ] [] +Invoke-Formatter -Path [[-Settings] ] [[-Range] ] + [] ``` ## DESCRIPTION -The `Invoke-Formatter` cmdlet takes a string input and formats it according to defined settings. If -no **Settings** parameter is provided, the cmdlet assumes the default code formatting settings as -defined in `Settings/CodeFormatting.psd1`. +The `Invoke-Formatter` cmdlet formats a string input or a file according to defined settings. If no +**Settings** parameter is provided, the cmdlet assumes the default code formatting settings as +defined in `Settings/CodeFormatting.psd1`. When **Path** is used, the file is formatted in place. +The file's detected encoding and line-ending style are retained, and the default settings use the +file's detected tab or space indentation style. Use `-WhatIf` to preview whether a file would be +changed without writing it. ## EXAMPLES @@ -83,6 +88,12 @@ function foo Invoke-Formatter -ScriptDefinition $scriptDefinition -Settings /path/to/settings.psd1 ``` +### EXAMPLE 4 - Format a file in place + +```powershell +Invoke-Formatter -Path /path/to/script.ps1 +``` + ## PARAMETERS ### -Range @@ -108,6 +119,22 @@ Accept pipeline input: True Accept wildcard characters: False ``` +### -Path + +The path to the file to format. The file is updated in place. + +```yaml +Type: String +Parameter Sets: Path +Aliases: PSPath + +Required: True +Position: 1 +Default value: None +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: True +``` + ### -ScriptDefinition The text of the script to be formatted represented as a string. This is not a **ScriptBlock** From a69865a142a6636a487d8ee07674f78d9ee74f07 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:57:46 +0100 Subject: [PATCH 3/8] Document Invoke-Formatter confirmation parameters (#2232) * Document formatter confirmation parameters Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> * Handle pipeline binding help values in tests Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> * Clarify -Confirm and -WhatIf parameter descriptions Updated descriptions for -Confirm and -WhatIf parameters to clarify their effects when using the Path and ScriptDefinition options. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> Co-authored-by: Christoph Bergmeister Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Tests/Engine/ModuleHelp.Tests.ps1 | 14 ++++------- docs/Cmdlets/Invoke-Formatter.md | 39 +++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/Tests/Engine/ModuleHelp.Tests.ps1 b/Tests/Engine/ModuleHelp.Tests.ps1 index 21188e019..b164784ca 100644 --- a/Tests/Engine/ModuleHelp.Tests.ps1 +++ b/Tests/Engine/ModuleHelp.Tests.ps1 @@ -229,20 +229,14 @@ Describe 'Cmdlet parameter help' { foreach($parameterAttribute in $parameter.Attributes) { if ($parameterAttribute.ValueFromPipeline -eq $null) { continue } - $parameterHelpPipelineInput = if ($parameterHelp.pipelineInput -eq 'True (ByPropertyName, ByValue)') { - $true - } - else { - [System.Boolean]::Parse($parameterHelp.pipelineInput) - } + $parameterHelpPipelineInput = $parameterHelp.pipelineInput -eq 'True' -or $parameterHelp.pipelineInput -match 'ByPropertyName' $parameterHelpPipelineInput | Should -Be $parameterAttribute.ValueFromPipelineByPropertyName ` -Because "Parameter $parameterName of command $CommandName and parameter set $($parameterAttribute.ParameterSetName) has correct ValueFromPipelineByPropertyName attribute" - if ($parameterHelp.pipelineInput -eq 'True (ByPropertyName, ByValue)') { - $parameterAttribute.ValueFromPipeline | Should -BeTrue ` - -Because "Parameter $parameterName of command $CommandName and parameter set $($parameterAttribute.ParameterSetName) has correct ValueFromPipeline attribute" - } + $parameterHelpPipelineInput = $parameterHelp.pipelineInput -match 'ByValue' + $parameterHelpPipelineInput | Should -Be $parameterAttribute.ValueFromPipeline ` + -Because "Parameter $parameterName of command $CommandName and parameter set $($parameterAttribute.ParameterSetName) has correct ValueFromPipeline attribute" } } diff --git a/docs/Cmdlets/Invoke-Formatter.md b/docs/Cmdlets/Invoke-Formatter.md index c3f724803..ca7548f74 100644 --- a/docs/Cmdlets/Invoke-Formatter.md +++ b/docs/Cmdlets/Invoke-Formatter.md @@ -16,9 +16,9 @@ Formats script text or a file based on the input settings or default settings. ``` Invoke-Formatter [-ScriptDefinition] [[-Settings] ] [[-Range] ] - [] + [-WhatIf] [-Confirm] [] Invoke-Formatter -Path [[-Settings] ] [[-Range] ] - [] + [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -168,6 +168,41 @@ Accept pipeline input: True Accept wildcard characters: False ``` +### -Confirm + +Prompts you for confirmation before writing changes to a file specified by **Path**. This parameter +has no effect when **ScriptDefinition** is used. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf + +Shows which files specified by **Path** would be changed without writing them. Formatting still +runs and the formatted text is returned. This parameter has no effect when **ScriptDefinition** is +used. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, From deb58bcd7408bafbf87c28e1e264e2b1dea7cb63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:10:51 +0000 Subject: [PATCH 4/8] Fix formatter indentation inference Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Engine/Commands/InvokeFormatterCommand.cs | 66 ++++++++++++++++++----- Tests/Engine/InvokeFormatter.tests.ps1 | 23 ++++++++ 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs index b1db54604..e03fe5e8e 100644 --- a/Engine/Commands/InvokeFormatterCommand.cs +++ b/Engine/Commands/InvokeFormatterCommand.cs @@ -2,10 +2,12 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.IO; using System.Globalization; using System.Text; using System.Management.Automation; +using System.Management.Automation.Language; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands { @@ -180,27 +182,63 @@ private static string GetIndentationKind(string scriptDefinition) int spaceIndentedLines = 0; string firstIndentationKind = "space"; bool foundIndentation = false; + string[] lines = scriptDefinition.Split('\n'); + Parser.ParseInput(scriptDefinition, out Token[] tokens, out ParseError[] errors); + var countedLines = new HashSet(); - foreach (var line in scriptDefinition.Split('\n')) + foreach (var token in tokens) { - var trimmedLine = line.TrimEnd('\r'); - if (trimmedLine.Length > 0 && (trimmedLine[0] == '\t' || trimmedLine[0] == ' ')) + if (token.Kind == TokenKind.Comment + || token.Kind == TokenKind.NewLine + || token.Kind == TokenKind.LineContinuation + || token.Kind == TokenKind.EndOfInput) { - if (!foundIndentation) - { - firstIndentationKind = trimmedLine[0] == '\t' ? "tab" : "space"; - foundIndentation = true; - } + continue; + } - if (trimmedLine[0] == '\t') - { - tabIndentedLines++; - } - else + int lineNumber = token.Extent.StartLineNumber; + if (!countedLines.Add(lineNumber)) + { + continue; + } + + string line = lines[lineNumber - 1].TrimEnd('\r'); + int indentationLength = token.Extent.StartColumnNumber - 1; + if (indentationLength == 0 || indentationLength > line.Length) + { + continue; + } + + char indentationCharacter = line[0]; + bool isIndentedCode = true; + for (int i = 0; i < indentationLength; i++) + { + if (line[i] != ' ' && line[i] != '\t') { - spaceIndentedLines++; + isIndentedCode = false; + break; } } + + if (!isIndentedCode) + { + continue; + } + + if (!foundIndentation) + { + firstIndentationKind = indentationCharacter == '\t' ? "tab" : "space"; + foundIndentation = true; + } + + if (indentationCharacter == '\t') + { + tabIndentedLines++; + } + else + { + spaceIndentedLines++; + } } return tabIndentedLines > spaceIndentedLines diff --git a/Tests/Engine/InvokeFormatter.tests.ps1 b/Tests/Engine/InvokeFormatter.tests.ps1 index 9d03cdc73..f98f42411 100644 --- a/Tests/Engine/InvokeFormatter.tests.ps1 +++ b/Tests/Engine/InvokeFormatter.tests.ps1 @@ -54,6 +54,29 @@ Describe "Invoke-Formatter Cmdlet" { [System.IO.File]::ReadAllBytes($path)[0..1] | Should -Be @(255, 254) } + It 'Infers indentation from code instead of whitespace-only lines' { + $path = Join-Path $TestDrive 'blank-lines.ps1' + $scriptDefinition = "function foo {`n`t`$x=1`n `n `n `n `n`t`$y=2`n}" + [System.IO.File]::WriteAllText($path, $scriptDefinition) + + $formatted = Invoke-Formatter -Path $path + + $formatted | Should -Match '(?m)^\t\$x = 1$' + $formatted | Should -Match '(?m)^\t\$y = 2$' + } + + It 'Infers indentation from code instead of multiline payload lines' { + $path = Join-Path $TestDrive 'here-string.ps1' + $scriptDefinition = "function foo {`n`t`$value = @'`n first payload`n second payload`n third payload`n fourth payload`n fifth payload`n'@`n<#`n comment payload`n second comment payload`n third comment payload`n fourth comment payload`n fifth comment payload`n#>`n`t`$x=1`n}" + [System.IO.File]::WriteAllText($path, $scriptDefinition) + + $formatted = Invoke-Formatter -Path $path + + $formatted | Should -Match '(?m)^\t\$x = 1$' + $formatted | Should -Match '(?m)^ first payload$' + $formatted | Should -Match '(?m)^ comment payload$' + } + It 'Does not modify a file when WhatIf is specified' { $path = Join-Path $TestDrive 'whatif.ps1' $scriptDefinition = '$x=1' From 6dc79dabb87db704ac01b8a1c061309a863f8f78 Mon Sep 17 00:00:00 2001 From: Christoph Bergmeister Date: Fri, 23 Oct 2026 08:39:01 +0100 Subject: [PATCH 5/8] Improve encoding reading and indentation inference Refactor encoding handling and indentation logic. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Engine/Commands/InvokeFormatterCommand.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs index e03fe5e8e..20cb020e3 100644 --- a/Engine/Commands/InvokeFormatterCommand.cs +++ b/Engine/Commands/InvokeFormatterCommand.cs @@ -154,19 +154,31 @@ private void FormatFile(string path) { Encoding encoding; string scriptDefinition; - using (var reader = new StreamReader(path, Encoding.UTF8, true)) + using (var reader = new StreamReader(path, new UTF8Encoding(false, true), true)) { scriptDefinition = reader.ReadToEnd(); encoding = reader.CurrentEncoding; } - if (inputSettings.RuleArguments.TryGetValue(IndentationRule, out var indentationSettings) - && (!settingsWereSpecified || !indentationSettings.ContainsKey("Kind"))) + bool inferIndentationKind = inputSettings.RuleArguments.TryGetValue(IndentationRule, out var indentationSettings) + && (!settingsWereSpecified || !indentationSettings.ContainsKey("Kind")); + if (inferIndentationKind) { indentationSettings["Kind"] = GetIndentationKind(scriptDefinition); } - string formattedScriptDefinition = Formatter.Format(scriptDefinition, inputSettings, range, this); + string formattedScriptDefinition; + try + { + formattedScriptDefinition = Formatter.Format(scriptDefinition, inputSettings, range, this); + } + finally + { + if (inferIndentationKind && settingsWereSpecified) + { + indentationSettings.Remove("Kind"); + } + } if (!String.Equals(scriptDefinition, formattedScriptDefinition, StringComparison.Ordinal) && ShouldProcess(path, "Format file")) { From e113020c75434c462a96a09c1a3976160b16e996 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:51:43 +0000 Subject: [PATCH 6/8] Support explicit encodings in file formatting Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Engine/Commands/InvokeFormatterCommand.cs | 13 +++++++++++- Tests/Engine/InvokeFormatter.tests.ps1 | 12 ++++++++++++ docs/Cmdlets/Invoke-Formatter.md | 24 ++++++++++++++++++++--- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs index 20cb020e3..2925cd659 100644 --- a/Engine/Commands/InvokeFormatterCommand.cs +++ b/Engine/Commands/InvokeFormatterCommand.cs @@ -65,6 +65,13 @@ public class InvokeFormatterCommand : PSCmdlet, IOutputWriter [ValidateCount(4, 4)] public int[] Range { get; set; } + /// + /// The encoding to use when reading files without a byte-order mark. + /// + [Parameter(Mandatory = false)] + [ValidateNotNullOrEmpty] + public string Encoding { get; set; } = "utf-8"; + #if DEBUG /// /// Attaches to an instance of a .Net debugger @@ -154,7 +161,11 @@ private void FormatFile(string path) { Encoding encoding; string scriptDefinition; - using (var reader = new StreamReader(path, new UTF8Encoding(false, true), true)) + var requestedEncoding = System.Text.Encoding.GetEncoding( + Encoding, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + using (var reader = new StreamReader(path, requestedEncoding, true)) { scriptDefinition = reader.ReadToEnd(); encoding = reader.CurrentEncoding; diff --git a/Tests/Engine/InvokeFormatter.tests.ps1 b/Tests/Engine/InvokeFormatter.tests.ps1 index f98f42411..c2d9f11d5 100644 --- a/Tests/Engine/InvokeFormatter.tests.ps1 +++ b/Tests/Engine/InvokeFormatter.tests.ps1 @@ -54,6 +54,18 @@ Describe "Invoke-Formatter Cmdlet" { [System.IO.File]::ReadAllBytes($path)[0..1] | Should -Be @(255, 254) } + It 'Formats BOM-less files using the specified encoding' { + $path = Join-Path $TestDrive 'legacy-encoding.ps1' + $scriptDefinition = '$name=''café''' + $expected = '$name = ''café''' + $encoding = [System.Text.Encoding]::GetEncoding(1252) + [System.IO.File]::WriteAllBytes($path, $encoding.GetBytes($scriptDefinition)) + + Invoke-Formatter -Path $path -Encoding 'windows-1252' | Should -Be $expected + + [System.IO.File]::ReadAllBytes($path) | Should -Be $encoding.GetBytes($expected) + } + It 'Infers indentation from code instead of whitespace-only lines' { $path = Join-Path $TestDrive 'blank-lines.ps1' $scriptDefinition = "function foo {`n`t`$x=1`n `n `n `n `n`t`$y=2`n}" diff --git a/docs/Cmdlets/Invoke-Formatter.md b/docs/Cmdlets/Invoke-Formatter.md index ca7548f74..d572bca7f 100644 --- a/docs/Cmdlets/Invoke-Formatter.md +++ b/docs/Cmdlets/Invoke-Formatter.md @@ -16,9 +16,9 @@ Formats script text or a file based on the input settings or default settings. ``` Invoke-Formatter [-ScriptDefinition] [[-Settings] ] [[-Range] ] - [-WhatIf] [-Confirm] [] + [-Encoding ] [-WhatIf] [-Confirm] [] Invoke-Formatter -Path [[-Settings] ] [[-Range] ] - [-WhatIf] [-Confirm] [] + [-Encoding ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -28,7 +28,8 @@ The `Invoke-Formatter` cmdlet formats a string input or a file according to defi defined in `Settings/CodeFormatting.psd1`. When **Path** is used, the file is formatted in place. The file's detected encoding and line-ending style are retained, and the default settings use the file's detected tab or space indentation style. Use `-WhatIf` to preview whether a file would be -changed without writing it. +changed without writing it. Use **Encoding** to specify the encoding for files without a byte-order +mark (BOM); a detected BOM takes precedence. ## EXAMPLES @@ -96,6 +97,23 @@ Invoke-Formatter -Path /path/to/script.ps1 ## PARAMETERS +### -Encoding + +The encoding to use when reading and writing files without a byte-order mark (BOM). Files with a BOM +continue to use the encoding detected from the BOM. The default is UTF-8. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: utf-8 +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Range The range within which formatting should take place. The value of this parameter must be an array of From d0ba92dbd5debe16a0c51538014f2237d65893e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:58:57 +0000 Subject: [PATCH 7/8] Remove formatter encoding parameter Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Engine/Commands/InvokeFormatterCommand.cs | 62 ++++++++++++++++++----- Tests/Engine/InvokeFormatter.tests.ps1 | 4 +- docs/Cmdlets/Invoke-Formatter.md | 25 ++------- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs index 2925cd659..d9df017fa 100644 --- a/Engine/Commands/InvokeFormatterCommand.cs +++ b/Engine/Commands/InvokeFormatterCommand.cs @@ -65,13 +65,6 @@ public class InvokeFormatterCommand : PSCmdlet, IOutputWriter [ValidateCount(4, 4)] public int[] Range { get; set; } - /// - /// The encoding to use when reading files without a byte-order mark. - /// - [Parameter(Mandatory = false)] - [ValidateNotNullOrEmpty] - public string Encoding { get; set; } = "utf-8"; - #if DEBUG /// /// Attaches to an instance of a .Net debugger @@ -161,14 +154,22 @@ private void FormatFile(string path) { Encoding encoding; string scriptDefinition; - var requestedEncoding = System.Text.Encoding.GetEncoding( - Encoding, - EncoderFallback.ExceptionFallback, - DecoderFallback.ExceptionFallback); - using (var reader = new StreamReader(path, requestedEncoding, true)) + try + { + using (var reader = new StreamReader(path, new UTF8Encoding(false, true), true)) + { + scriptDefinition = reader.ReadToEnd(); + encoding = reader.CurrentEncoding; + } + } + catch (DecoderFallbackException) when (!HasByteOrderMark(path)) { - scriptDefinition = reader.ReadToEnd(); - encoding = reader.CurrentEncoding; + encoding = GetLegacyFallbackEncoding(); + using (var reader = new StreamReader(path, encoding, true)) + { + scriptDefinition = reader.ReadToEnd(); + encoding = reader.CurrentEncoding; + } } bool inferIndentationKind = inputSettings.RuleArguments.TryGetValue(IndentationRule, out var indentationSettings) @@ -199,6 +200,39 @@ private void FormatFile(string path) WriteObject(formattedScriptDefinition); } + private static bool HasByteOrderMark(string path) + { + using (var stream = File.OpenRead(path)) + { + int firstByte = stream.ReadByte(); + int secondByte = stream.ReadByte(); + int thirdByte = stream.ReadByte(); + int fourthByte = stream.ReadByte(); + + return firstByte == 0xEF && secondByte == 0xBB && thirdByte == 0xBF + || firstByte == 0xFF && secondByte == 0xFE + || firstByte == 0xFE && secondByte == 0xFF + || firstByte == 0x00 && secondByte == 0x00 && thirdByte == 0xFE && fourthByte == 0xFF + || firstByte == 0xFF && secondByte == 0xFE && thirdByte == 0x00 && fourthByte == 0x00; + } + } + + private static Encoding GetLegacyFallbackEncoding() + { + if (Encoding.Default.CodePage != 65001) + { + return Encoding.GetEncoding( + Encoding.Default.CodePage, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + +#if CORECLR + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); +#endif + return Encoding.GetEncoding(1252, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); + } + private static string GetIndentationKind(string scriptDefinition) { int tabIndentedLines = 0; diff --git a/Tests/Engine/InvokeFormatter.tests.ps1 b/Tests/Engine/InvokeFormatter.tests.ps1 index c2d9f11d5..3d5bcf9b6 100644 --- a/Tests/Engine/InvokeFormatter.tests.ps1 +++ b/Tests/Engine/InvokeFormatter.tests.ps1 @@ -54,14 +54,14 @@ Describe "Invoke-Formatter Cmdlet" { [System.IO.File]::ReadAllBytes($path)[0..1] | Should -Be @(255, 254) } - It 'Formats BOM-less files using the specified encoding' { + It 'Best-effort formats BOM-less files using a legacy encoding' { $path = Join-Path $TestDrive 'legacy-encoding.ps1' $scriptDefinition = '$name=''café''' $expected = '$name = ''café''' $encoding = [System.Text.Encoding]::GetEncoding(1252) [System.IO.File]::WriteAllBytes($path, $encoding.GetBytes($scriptDefinition)) - Invoke-Formatter -Path $path -Encoding 'windows-1252' | Should -Be $expected + Invoke-Formatter -Path $path | Should -Be $expected [System.IO.File]::ReadAllBytes($path) | Should -Be $encoding.GetBytes($expected) } diff --git a/docs/Cmdlets/Invoke-Formatter.md b/docs/Cmdlets/Invoke-Formatter.md index d572bca7f..f0927ea8f 100644 --- a/docs/Cmdlets/Invoke-Formatter.md +++ b/docs/Cmdlets/Invoke-Formatter.md @@ -16,9 +16,9 @@ Formats script text or a file based on the input settings or default settings. ``` Invoke-Formatter [-ScriptDefinition] [[-Settings] ] [[-Range] ] - [-Encoding ] [-WhatIf] [-Confirm] [] + [-WhatIf] [-Confirm] [] Invoke-Formatter -Path [[-Settings] ] [[-Range] ] - [-Encoding ] [-WhatIf] [-Confirm] [] + [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -28,8 +28,8 @@ The `Invoke-Formatter` cmdlet formats a string input or a file according to defi defined in `Settings/CodeFormatting.psd1`. When **Path** is used, the file is formatted in place. The file's detected encoding and line-ending style are retained, and the default settings use the file's detected tab or space indentation style. Use `-WhatIf` to preview whether a file would be -changed without writing it. Use **Encoding** to specify the encoding for files without a byte-order -mark (BOM); a detected BOM takes precedence. +changed without writing it. BOM-less files are interpreted as UTF-8 when valid; otherwise the system +ANSI encoding or Windows-1252 is used as a best-effort fallback. ## EXAMPLES @@ -97,23 +97,6 @@ Invoke-Formatter -Path /path/to/script.ps1 ## PARAMETERS -### -Encoding - -The encoding to use when reading and writing files without a byte-order mark (BOM). Files with a BOM -continue to use the encoding detected from the BOM. The default is UTF-8. - -```yaml -Type: String -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: utf-8 -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Range The range within which formatting should take place. The value of this parameter must be an array of From ae4cac8441e7cafbc7aaf3e6dd69248be42b3232 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:45:42 +0100 Subject: [PATCH 8/8] Preserve CR-only line endings when formatting files (#2234) * Initial plan * Handle CR-only formatter line endings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> * Make legacy encoding fixture source-independent Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Engine/Commands/InvokeFormatterCommand.cs | 4 +++- Engine/Extensions.cs | 2 +- Tests/Engine/InvokeFormatter.tests.ps1 | 16 ++++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs index d9df017fa..3d1179e20 100644 --- a/Engine/Commands/InvokeFormatterCommand.cs +++ b/Engine/Commands/InvokeFormatterCommand.cs @@ -5,9 +5,11 @@ using System.Collections.Generic; using System.IO; using System.Globalization; +using System.Linq; using System.Text; using System.Management.Automation; using System.Management.Automation.Language; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Extensions; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands { @@ -239,7 +241,7 @@ private static string GetIndentationKind(string scriptDefinition) int spaceIndentedLines = 0; string firstIndentationKind = "space"; bool foundIndentation = false; - string[] lines = scriptDefinition.Split('\n'); + string[] lines = scriptDefinition.GetLines().ToArray(); Parser.ParseInput(scriptDefinition, out Token[] tokens, out ParseError[] errors); var countedLines = new HashSet(); diff --git a/Engine/Extensions.cs b/Engine/Extensions.cs index 46b67bf65..3402021e2 100644 --- a/Engine/Extensions.cs +++ b/Engine/Extensions.cs @@ -18,7 +18,7 @@ public static class Extensions /// public static IEnumerable GetLines(this string text) { - return text.Split('\n').Select(line => line.TrimEnd('\r')); + return text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); } /// diff --git a/Tests/Engine/InvokeFormatter.tests.ps1 b/Tests/Engine/InvokeFormatter.tests.ps1 index 3d5bcf9b6..d9b990285 100644 --- a/Tests/Engine/InvokeFormatter.tests.ps1 +++ b/Tests/Engine/InvokeFormatter.tests.ps1 @@ -54,10 +54,22 @@ Describe "Invoke-Formatter Cmdlet" { [System.IO.File]::ReadAllBytes($path)[0..1] | Should -Be @(255, 254) } + It 'Formats a file with CR-only line endings' { + $path = Join-Path $TestDrive 'cr-only.ps1' + $scriptDefinition = "function foo {`r`t`$x=1`r}" + $expected = "function foo {`r`t`$x = 1`r}" + [System.IO.File]::WriteAllText($path, $scriptDefinition) + + Invoke-Formatter -Path $path | Should -Be $expected + + [System.IO.File]::ReadAllText($path) | Should -Be $expected + } + It 'Best-effort formats BOM-less files using a legacy encoding' { $path = Join-Path $TestDrive 'legacy-encoding.ps1' - $scriptDefinition = '$name=''café''' - $expected = '$name = ''café''' + $cafe = 'caf' + [char]0xE9 + $scriptDefinition = "`$name='$cafe'" + $expected = "`$name = '$cafe'" $encoding = [System.Text.Encoding]::GetEncoding(1252) [System.IO.File]::WriteAllBytes($path, $encoding.GetBytes($scriptDefinition))