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] ] [[-Range] ] - [] + [-WhatIf] [-Confirm] [] +Invoke-Formatter -Path [[-Settings] ] [[-Range] ] + [-WhatIf] [-Confirm] [] ``` ## 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. 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 @@ -83,6 +89,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 +120,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** @@ -141,6 +169,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,