Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 198 additions & 4 deletions Engine/Commands/InvokeFormatterCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -12,20 +18,32 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands
/// <summary>
/// A cmdlet to format a PowerShell script text.
/// </summary>
[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;

/// <summary>
/// The path to the file to format in place.
/// </summary>
[Parameter(Mandatory = true, ParameterSetName = ParameterSet_Path, ValueFromPipelineByPropertyName = true, Position = 1)]
[ValidateNotNull]
[Alias("PSPath")]
public string Path { get; set; }

/// <summary>
/// The script text to be formated.
///
/// *NOTE*: Unlike ScriptBlock parameter, the ScriptDefinition parameter require a string value.
/// </summary>
[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; }

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<int>();

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()
Expand Down
2 changes: 1 addition & 1 deletion Engine/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static class Extensions
/// <returns></returns>
public static IEnumerable<string> GetLines(this string text)
{
return text.Split('\n').Select(line => line.TrimEnd('\r'));
return text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
}

/// <summary>
Expand Down
85 changes: 85 additions & 0 deletions Tests/Engine/InvokeFormatter.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @"
Expand Down
14 changes: 4 additions & 10 deletions Tests/Engine/ModuleHelp.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
Loading
Loading