diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs
index 25a2d364e..3d1179e20 100644
--- a/Engine/Commands/InvokeFormatterCommand.cs
+++ b/Engine/Commands/InvokeFormatterCommand.cs
@@ -2,8 +2,14 @@
// Licensed under the MIT License.
using System;
+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
{
@@ -12,20 +18,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 +98,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 +127,184 @@ protected override void BeginProcessing()
protected override void ProcessRecord()
{
// todo add tests to check range formatting
+ 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;
+ try
+ {
+ using (var reader = new StreamReader(path, new UTF8Encoding(false, true), true))
+ {
+ scriptDefinition = reader.ReadToEnd();
+ encoding = reader.CurrentEncoding;
+ }
+ }
+ catch (DecoderFallbackException) when (!HasByteOrderMark(path))
+ {
+ 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)
+ && (!settingsWereSpecified || !indentationSettings.ContainsKey("Kind"));
+ if (inferIndentationKind)
+ {
+ indentationSettings["Kind"] = GetIndentationKind(scriptDefinition);
+ }
+
string formattedScriptDefinition;
- formattedScriptDefinition = Formatter.Format(ScriptDefinition, inputSettings, range, this);
- this.WriteObject(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"))
+ {
+ File.WriteAllText(path, formattedScriptDefinition, encoding);
+ }
+
+ 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;
+ int spaceIndentedLines = 0;
+ string firstIndentationKind = "space";
+ bool foundIndentation = false;
+ string[] lines = scriptDefinition.GetLines().ToArray();
+ Parser.ParseInput(scriptDefinition, out Token[] tokens, out ParseError[] errors);
+ var countedLines = new HashSet();
+
+ foreach (var token in tokens)
+ {
+ if (token.Kind == TokenKind.Comment
+ || token.Kind == TokenKind.NewLine
+ || token.Kind == TokenKind.LineContinuation
+ || token.Kind == TokenKind.EndOfInput)
+ {
+ continue;
+ }
+
+ 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')
+ {
+ isIndentedCode = false;
+ break;
+ }
+ }
+
+ if (!isIndentedCode)
+ {
+ continue;
+ }
+
+ if (!foundIndentation)
+ {
+ firstIndentationKind = indentationCharacter == '\t' ? "tab" : "space";
+ foundIndentation = true;
+ }
+
+ if (indentationCharacter == '\t')
+ {
+ tabIndentedLines++;
+ }
+ else
+ {
+ spaceIndentedLines++;
+ }
+ }
+
+ return tabIndentedLines > spaceIndentedLines
+ ? "tab"
+ : tabIndentedLines < spaceIndentedLines
+ ? "space"
+ : firstIndentationKind;
}
protected override void EndProcessing()
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 dcc0a6174..d9b990285 100644
--- a/Tests/Engine/InvokeFormatter.tests.ps1
+++ b/Tests/Engine/InvokeFormatter.tests.ps1
@@ -27,6 +27,91 @@ 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 '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'
+ $cafe = 'caf' + [char]0xE9
+ $scriptDefinition = "`$name='$cafe'"
+ $expected = "`$name = '$cafe'"
+ $encoding = [System.Text.Encoding]::GetEncoding(1252)
+ [System.IO.File]::WriteAllBytes($path, $encoding.GetBytes($scriptDefinition))
+
+ Invoke-Formatter -Path $path | 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}"
+ [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'
+ [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/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 80711b0bd..f0927ea8f 100644
--- a/docs/Cmdlets/Invoke-Formatter.md
+++ b/docs/Cmdlets/Invoke-Formatter.md
@@ -10,20 +10,26 @@ 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]