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'
+}
```