Skip to content

Commit 1eb5671

Browse files
authored
Make UseSingularNouns rule work on PowerShell Core (#1627)
1 parent ec089c9 commit 1eb5671

9 files changed

+158
-67
lines changed

RuleDocumentation/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
|[UseProcessBlockForPipelineCommand](./UseProcessBlockForPipelineCommand.md) | Warning | |
5858
|[UsePSCredentialType](./UsePSCredentialType.md) | Warning | |
5959
|[UseShouldProcessForStateChangingFunctions](./UseShouldProcessForStateChangingFunctions.md) | Warning | |
60-
|[UseSingularNouns<sup>*</sup>](./UseSingularNouns.md) | Warning | |
60+
|[UseSingularNouns](./UseSingularNouns.md) | Warning | |
6161
|[UseSupportsShouldProcess](./UseSupportsShouldProcess.md) | Warning | |
6262
|[UseToExportFieldsInManifest](./UseToExportFieldsInManifest.md) | Warning | |
6363
|[UseCompatibleCmdlets](./UseCompatibleCmdlets.md) | Warning | Yes |

Rules/Rules.csproj

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<AssemblyVersion>1.20.0</AssemblyVersion>
88
<PackageId>Rules</PackageId>
99
<RootNamespace>Microsoft.Windows.PowerShell.ScriptAnalyzer</RootNamespace> <!-- Namespace needs to match Assembly name for ressource binding -->
10+
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> <!-- Needed in order for Pluralize.NET DLL to appear in bin folder - https://github.com/NuGet/Home/issues/4488 -->
1011
</PropertyGroup>
1112

1213
<ItemGroup>
@@ -18,7 +19,7 @@
1819
<ItemGroup Condition=" '$(TargetFramework)' == 'netcoreapp3.1' ">
1920
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" />
2021
<PackageReference Include="Microsoft.Management.Infrastructure" Version="2.0.0" />
21-
<Compile Remove="UseSingularNouns.cs" />
22+
<PackageReference Include="Pluralize.NET" Version="1.0.2" />
2223
</ItemGroup>
2324

2425
<ItemGroup Condition=" '$(TargetFramework)' == 'net452' ">

Rules/UseSingularNouns.cs

+105-35
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,25 @@
1515
using System.Linq;
1616
using System.Management.Automation.Language;
1717
using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
18+
#if CORECLR
19+
using Pluralize.NET;
20+
#else
1821
using System.ComponentModel.Composition;
22+
using System.Data.Entity.Design.PluralizationServices;
23+
#endif
1924
using System.Globalization;
20-
using System.Text.RegularExpressions;
2125

2226
namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
2327
{
2428
/// <summary>
2529
/// CmdletSingularNoun: Analyzes scripts to check that all defined cmdlets use singular nouns.
2630
///
2731
/// </summary>
28-
[Export(typeof(IScriptRule))]
29-
public class CmdletSingularNoun : IScriptRule {
32+
#if !CORECLR
33+
[Export(typeof(IScriptRule))]
34+
#endif
35+
public class CmdletSingularNoun : IScriptRule
36+
{
3037

3138
private readonly string[] nounAllowList =
3239
{
@@ -39,46 +46,49 @@ public class CmdletSingularNoun : IScriptRule {
3946
/// <param name="ast"></param>
4047
/// <param name="fileName"></param>
4148
/// <returns></returns>
42-
public IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName) {
49+
public IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName)
50+
{
4351
if (ast == null) throw new ArgumentNullException(Strings.NullCommandInfoError);
4452

4553
IEnumerable<Ast> funcAsts = ast.FindAll(item => item is FunctionDefinitionAst, true);
4654

47-
char[] funcSeperator = { '-' };
48-
string[] funcNamePieces = new string[2];
55+
var pluralizer = new PluralizerProxy();
4956

5057
foreach (FunctionDefinitionAst funcAst in funcAsts)
5158
{
52-
if (funcAst.Name != null && funcAst.Name.Contains('-'))
59+
if (funcAst.Name == null || !funcAst.Name.Contains('-'))
60+
{
61+
continue;
62+
}
63+
64+
string noun = GetLastWordInCmdlet(funcAst.Name);
65+
66+
if (noun is null)
5367
{
54-
funcNamePieces = funcAst.Name.Split(funcSeperator);
55-
String nounPart = funcNamePieces[1];
56-
57-
// Convert the noun part of the function into a series of space delimited words
58-
// This helps the PluralizationService to provide an accurate determination about the plurality of the string
59-
nounPart = SplitCamelCaseString(nounPart);
60-
var words = nounPart.Split(new char [] { ' ' });
61-
var noun = words.LastOrDefault();
62-
if (noun == null)
68+
continue;
69+
}
70+
71+
if (pluralizer.CanOnlyBePlural(noun))
72+
{
73+
if (nounAllowList.Contains(noun, StringComparer.OrdinalIgnoreCase))
6374
{
6475
continue;
6576
}
66-
var ps = System.Data.Entity.Design.PluralizationServices.PluralizationService.CreateService(CultureInfo.GetCultureInfo("en-us"));
6777

68-
if (!ps.IsSingular(noun) && ps.IsPlural(noun))
78+
IScriptExtent extent = Helper.Instance.GetScriptExtentForFunctionName(funcAst);
79+
80+
if (extent is null)
6981
{
70-
IScriptExtent extent = Helper.Instance.GetScriptExtentForFunctionName(funcAst);
71-
if (nounAllowList.Contains(noun, StringComparer.OrdinalIgnoreCase))
72-
{
73-
continue;
74-
}
75-
if (null == extent)
76-
{
77-
extent = funcAst.Extent;
78-
}
79-
yield return new DiagnosticRecord(string.Format(CultureInfo.CurrentCulture, Strings.UseSingularNounsError, funcAst.Name),
80-
extent, GetName(), DiagnosticSeverity.Warning, fileName);
82+
extent = funcAst.Extent;
8183
}
84+
85+
yield return new DiagnosticRecord(
86+
string.Format(CultureInfo.CurrentCulture, Strings.UseSingularNounsError, funcAst.Name),
87+
extent,
88+
GetName(),
89+
DiagnosticSeverity.Warning,
90+
fileName,
91+
suggestedCorrections: new CorrectionExtent[] { GetCorrection(pluralizer, extent, funcAst.Name, noun) });
8292
}
8393
}
8494

@@ -106,7 +116,8 @@ public string GetCommonName()
106116
/// GetDescription: Retrieves the description of this rule.
107117
/// </summary>
108118
/// <returns>The description of this rule</returns>
109-
public string GetDescription() {
119+
public string GetDescription()
120+
{
110121
return string.Format(CultureInfo.CurrentCulture, Strings.UseSingularNounsDescription);
111122
}
112123

@@ -135,18 +146,77 @@ public string GetSourceName()
135146
return string.Format(CultureInfo.CurrentCulture, Strings.SourceName);
136147
}
137148

149+
private CorrectionExtent GetCorrection(PluralizerProxy pluralizer, IScriptExtent extent, string commandName, string noun)
150+
{
151+
string singularNoun = pluralizer.Singularize(noun);
152+
string newCommandName = commandName.Substring(0, commandName.Length - noun.Length) + singularNoun;
153+
return new CorrectionExtent(extent, newCommandName, extent.File, $"Singularized correction of '{extent.Text}'");
154+
}
155+
138156
/// <summary>
139-
/// SplitCamelCaseString: Splits a Camel Case'd string into individual words with space delimited
157+
/// Gets the last word in a standard syntax, CamelCase cmdlet.
158+
/// If the cmdlet name is non-standard, returns null.
140159
/// </summary>
141-
private string SplitCamelCaseString(string input)
160+
private string GetLastWordInCmdlet(string cmdletName)
142161
{
143-
if (String.IsNullOrEmpty(input))
162+
if (string.IsNullOrEmpty(cmdletName))
144163
{
145-
return String.Empty;
164+
return null;
146165
}
147166

148-
return Regex.Replace(input, "([A-Z])", " $1", RegexOptions.Compiled).Trim();
167+
// Cmdlet doesn't use CamelCase, so assume it's something like an initialism that shouldn't be singularized
168+
if (!char.IsLower(cmdletName[cmdletName.Length - 1]))
169+
{
170+
return null;
171+
}
172+
173+
for (int i = cmdletName.Length - 1; i >= 0; i--)
174+
{
175+
if (cmdletName[i] == '-')
176+
{
177+
// We got to the dash without seeing a CamelCase word, so nothing to singularize
178+
return null;
179+
}
180+
181+
// We just changed from lower case to upper, so we have the end word
182+
if (char.IsUpper(cmdletName[i]))
183+
{
184+
return cmdletName.Substring(i);
185+
}
186+
}
187+
188+
// We shouldn't ever get here since we should always eventually hit a '-'
189+
// But if we do, assume this isn't supported cmdlet name
190+
return null;
191+
}
192+
193+
#if CORECLR
194+
private class PluralizerProxy
195+
{
196+
private readonly Pluralizer _pluralizer;
197+
198+
public PluralizerProxy()
199+
{
200+
_pluralizer = new Pluralizer();
201+
}
202+
203+
public bool CanOnlyBePlural(string noun) =>
204+
!_pluralizer.IsSingular(noun) && _pluralizer.IsPlural(noun);
205+
206+
public string Singularize(string noun) => _pluralizer.Singularize(noun);
207+
}
208+
#else
209+
private class PluralizerProxy
210+
{
211+
private static readonly PluralizationService s_pluralizationService = PluralizationService.CreateService(
212+
CultureInfo.GetCultureInfo("en-us"));
213+
214+
public bool CanOnlyBePlural(string noun) =>
215+
!s_pluralizationService.IsSingular(noun) && s_pluralizationService.IsPlural(noun);
216+
217+
public string Singularize(string noun) => s_pluralizationService.Singularize(noun);
149218
}
219+
#endif
150220
}
151221

152222
}

Tests/Documentation/RuleDocumentation.tests.ps1

+2-8
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,8 @@ Describe "Validate rule documentation files" {
1515
}} |
1616
Sort-Object
1717

18-
# Remove rules from the diff list that aren't supported on PSCore
19-
if (($PSVersionTable.PSVersion.Major -ge 6) -and ($PSVersionTable.PSEdition -eq "Core"))
20-
{
21-
$RulesNotSupportedInNetstandard2 = @("PSUseSingularNouns")
22-
$docs = $docs | Where-Object {$RulesNotSupportedInNetstandard2 -notcontains $_}
23-
$readmeRules = $readmeRules | Where-Object { $RulesNotSupportedInNetstandard2 -notcontains $_ }
24-
}
25-
elseif ($PSVersionTable.PSVersion.Major -eq 4) {
18+
# Remove rules from the diff list that aren't supported on old PS version
19+
if ($PSVersionTable.PSVersion.Major -eq 4) {
2620
$docs = $docs | Where-Object {$_ -notmatch '^PSAvoidGlobalAliases$'}
2721
$readmeRules = $readmeRules | Where-Object { $_ -notmatch '^PSAvoidGlobalAliases$' }
2822
}

Tests/Engine/GetScriptAnalyzerRule.tests.ps1

+1-4
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,11 @@ Describe "Test Name parameters" {
6464
It "get Rules with no parameters supplied" {
6565
$defaultRules = Get-ScriptAnalyzerRule
6666
$expectedNumRules = 65
67-
if ($IsCoreCLR -or ($PSVersionTable.PSVersion.Major -eq 3) -or ($PSVersionTable.PSVersion.Major -eq 4))
67+
if ($PSVersionTable.PSVersion.Major -le 4)
6868
{
6969
# for PSv3 PSAvoidGlobalAliases is not shipped because
7070
# it uses StaticParameterBinder.BindCommand which is
7171
# available only on PSv4 and above
72-
# for PowerShell Core, PSUseSingularNouns is not
73-
# shipped because it uses APIs that are not present
74-
# in dotnet core.
7572

7673
$expectedNumRules--
7774
}

Tests/Engine/InvokeScriptAnalyzer.tests.ps1

-10
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,7 @@ Describe "Test IncludeRule" {
270270
}
271271

272272
It "includes the given rules" {
273-
# CoreCLR version of PSScriptAnalyzer does not contain PSUseSingularNouns rule
274273
$expectedNumViolations = 2
275-
if ($IsCoreCLR)
276-
{
277-
$expectedNumViolations = 1
278-
}
279274
$violations = Invoke-ScriptAnalyzer $PSScriptRoot\..\Rules\BadCmdlet.ps1 -IncludeRule $rules
280275
$violations.Count | Should -Be $expectedNumViolations
281276
}
@@ -295,12 +290,7 @@ Describe "Test IncludeRule" {
295290
}
296291

297292
It "includes 2 wildcardrules" {
298-
# CoreCLR version of PSScriptAnalyzer does not contain PSUseSingularNouns rule
299293
$expectedNumViolations = 4
300-
if ($IsCoreCLR)
301-
{
302-
$expectedNumViolations = 3
303-
}
304294
$includeWildcard = Invoke-ScriptAnalyzer $PSScriptRoot\..\Rules\BadCmdlet.ps1 -IncludeRule $avoidRules
305295
$includeWildcard += Invoke-ScriptAnalyzer $PSScriptRoot\..\Rules\BadCmdlet.ps1 -IncludeRule $useRules
306296
$includeWildcard.Count | Should -Be $expectedNumViolations

Tests/Rules/UseSingularNounsReservedVerbs.tests.ps1

+37-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) Microsoft Corporation. All rights reserved.
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
22
# Licensed under the MIT License.
33

44
BeforeAll {
@@ -15,7 +15,7 @@ BeforeAll {
1515
}
1616

1717
# UseSingularNouns rule doesn't exist in the non desktop version of PSScriptAnalyzer due to missing .Net Pluralization API
18-
Describe "UseSingularNouns" -Skip:$IsCoreCLR {
18+
Describe "UseSingularNouns" {
1919
Context "When there are violations" {
2020
It "has a cmdlet singular noun violation" {
2121
$nounViolations.Count | Should -Be 1
@@ -51,6 +51,41 @@ Write-Output "Adding some data"
5151
$nounNoViolations.Count | Should -Be 0
5252
}
5353
}
54+
55+
Context "Inline tests" {
56+
It 'Correctly diagnoses and corrects <Script>' -TestCases @(
57+
@{ Script = 'function Get-Bananas { "bananas" }'; Extent = @{ StartCol = 10; EndCol = 21 }; Correction = 'Get-Banana' }
58+
@{ Script = 'function ConvertTo-StartingCriteria { "criteria" }'; Extent = @{ StartCol = 10; EndCol = 36 }; Correction = 'ConvertTo-StartingCriterion' }
59+
@{ Script = 'function Invoke-Data { "data" }' }
60+
@{ Script = 'function Get-Banana { "bananas" }' }
61+
@{ Script = 'function get-banana { "bananas" }' }
62+
@{ Script = 'function banana { "bananas" }' }
63+
) {
64+
param([string]$Script, $Extent, $Correction)
65+
66+
$diagnostics = Invoke-ScriptAnalyzer -ScriptDefinition $Script
67+
68+
if (-not $Extent)
69+
{
70+
$diagnostics | Should -BeNullOrEmpty
71+
return
72+
}
73+
74+
$expectedStartLine = if ($Extent.StartLine) { $Extent.StartLine } else { 1 }
75+
$expectedEndLine = if ($Extent.EndLine) { $Extent.EndLine } else { 1 }
76+
77+
$diagnostics.Extent.StartLineNumber | Should -BeExactly $expectedStartLine
78+
$diagnostics.Extent.EndLineNumber | Should -BeExactly $expectedEndLine
79+
$diagnostics.Extent.StartColumnNumber | Should -BeExactly $Extent.StartCol
80+
$diagnostics.Extent.EndColumnNumber | Should -BeExactly $Extent.EndCol
81+
82+
$diagnostics.SuggestedCorrections.StartLineNumber | Should -BeExactly $expectedStartLine
83+
$diagnostics.SuggestedCorrections.EndLineNumber | Should -BeExactly $expectedEndLine
84+
$diagnostics.SuggestedCorrections.StartColumnNumber | Should -BeExactly $Extent.StartCol
85+
$diagnostics.SuggestedCorrections.EndColumnNumber | Should -BeExactly $Extent.EndCol
86+
$diagnostics.SuggestedCorrections.Text | Should -BeExactly $Correction
87+
}
88+
}
5489
}
5590

5691
Describe "UseApprovedVerbs" {

build.psm1

+9-6
Original file line numberDiff line numberDiff line change
@@ -311,15 +311,18 @@ function Start-ScriptAnalyzerBuild
311311
$settingsFiles = Get-Childitem "$projectRoot\Engine\Settings" | ForEach-Object -MemberName FullName
312312
Publish-File $settingsFiles (Join-Path -Path $script:destinationDir -ChildPath Settings)
313313

314+
$rulesProjectOutputDir = if ($env:TF_BUILD) {
315+
"$projectRoot\bin\${buildConfiguration}\${framework}" }
316+
else {
317+
"$projectRoot\Rules\bin\${buildConfiguration}\${framework}"
318+
}
314319
if ($framework -eq 'net452') {
315-
if ( $env:TF_BUILD ) {
316-
$nsoft = "$projectRoot\bin\${buildConfiguration}\${framework}\Newtonsoft.Json.dll"
317-
}
318-
else {
319-
$nsoft = "$projectRoot\Rules\bin\${buildConfiguration}\${framework}\Newtonsoft.Json.dll"
320-
}
320+
$nsoft = Join-Path $rulesProjectOutputDir 'Newtonsoft.Json.dll'
321321
Copy-Item -path $nsoft -Destination $destinationDirBinaries
322322
}
323+
else {
324+
Copy-Item -Path (Join-Path $rulesProjectOutputDir 'Pluralize.NET.dll') -Destination $destinationDirBinaries
325+
}
323326

324327
Pop-Location
325328
}

tools/releaseBuild/signing.xml

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<file src="__INPATHROOT__\out\PSScriptAnalyzer\1.19.1\PSv7\Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules.dll" signType="Authenticode" dest="__OUTPATHROOT__\PSScriptAnalyzer\1.19.1\PSv7\Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules.dll" />
2525
<file src="__INPATHROOT__\out\PSScriptAnalyzer\1.19.1\PSv7\Microsoft.Windows.PowerShell.ScriptAnalyzer.dll" signType="Authenticode" dest="__OUTPATHROOT__\PSScriptAnalyzer\1.19.1\PSv7\Microsoft.Windows.PowerShell.ScriptAnalyzer.dll" />
2626
<file src="__INPATHROOT__\out\PSScriptAnalyzer\1.19.1\PSv7\Microsoft.PowerShell.CrossCompatibility.dll" signType="Authenticode" dest="__OUTPATHROOT__\PSScriptAnalyzer\1.19.1\PSv7\Microsoft.PowerShell.CrossCompatibility.dll" />
27+
<file src="__INPATHROOT__\out\PSScriptAnalyzer\1.19.1\PSv7\Pluralize.NET.dll" signType="Authenticode" dest="__OUTPATHROOT__\PSScriptAnalyzer\1.19.1\PSv7\Pluralize.NET.dll" />
2728
</job>
2829
<job platform="" configuration="" dest="__OUTPATHROOT__\signed" jobname="PowerShell Script Analyzer PSv3" approvers="vigarg;gstolt">
2930
<file src="__INPATHROOT__\out\PSScriptAnalyzer\1.19.1\PSv3\Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules.dll" signType="Authenticode" dest="__OUTPATHROOT__\PSScriptAnalyzer\1.19.1\PSv3\Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules.dll" />

0 commit comments

Comments
 (0)