From 16c96bc323c1227ba3776c8652e221c88639d11c Mon Sep 17 00:00:00 2001 From: Bernie White Date: Tue, 28 May 2019 09:11:48 +1000 Subject: [PATCH 01/26] Add annotations and import markdown #148 #18 --- PSRule.build.ps1 | 2 +- docs/commands/PSRule/en-US/Get-PSRule.md | 22 +- docs/commands/PSRule/en-US/Invoke-PSRule.md | 26 +- .../PSRule/en-US/Test-PSRuleTarget.md | 25 +- src/PSRule/Annotations/BlockMetadata.cs | 2 +- .../Commands/NewRuleDefinitionCommand.cs | 42 +- src/PSRule/Host/HostHelper.cs | 27 +- src/PSRule/PSRule.csproj | 15 +- src/PSRule/PSRule.psd1 | 4 +- src/PSRule/PSRule.psm1 | 57 +- src/PSRule/Parser/MarkdownLexer.cs | 35 ++ src/PSRule/Parser/MarkdownReader.cs | 507 +++++++++++++++ src/PSRule/Parser/MarkdownStream.cs | 591 ++++++++++++++++++ src/PSRule/Parser/MarkdownToken.cs | 87 +++ src/PSRule/Parser/MarkdownTokenExtensions.cs | 22 + src/PSRule/Parser/MetadataLexer.cs | 16 + src/PSRule/Parser/RuleLexer.cs | 287 +++++++++ src/PSRule/Parser/RuleModels.cs | 80 +++ src/PSRule/Parser/TokenStream.cs | 402 ++++++++++++ src/PSRule/Pipeline/InvokeRulePipeline.cs | 10 +- src/PSRule/Pipeline/PipelineContext.cs | 3 +- .../Resources/FormatResources.Designer.cs | 108 ++++ src/PSRule/Resources/FormatResources.resx | 135 ++++ .../Resources/PSRuleResources.Designer.cs | 9 + src/PSRule/Resources/PSRuleResources.resx | 4 + src/PSRule/Rules/Rule.cs | 4 + src/PSRule/Rules/RuleBlock.cs | 15 +- src/PSRule/Rules/RuleHelpInfo.cs | 28 + src/PSRule/Rules/RuleRecord.cs | 13 +- src/PSRule/Rules/RuleSource.cs | 23 +- src/PSRule/Rules/RuleSummaryRecord.cs | 9 +- src/PSRule/Rules/TagSet.cs | 14 +- tests/PSRule.Tests/PSRule.Common.Tests.ps1 | 10 +- tests/PSRule.Tests/PSRule.Tests.csproj | 5 +- tests/PSRule.Tests/RuleDocument.md | 18 + tests/PSRule.Tests/RuleDocumentTests.cs | 45 ++ tests/PSRule.Tests/en-AU/FromFile1.md | 13 + 37 files changed, 2641 insertions(+), 74 deletions(-) create mode 100644 src/PSRule/Parser/MarkdownLexer.cs create mode 100644 src/PSRule/Parser/MarkdownReader.cs create mode 100644 src/PSRule/Parser/MarkdownStream.cs create mode 100644 src/PSRule/Parser/MarkdownToken.cs create mode 100644 src/PSRule/Parser/MarkdownTokenExtensions.cs create mode 100644 src/PSRule/Parser/MetadataLexer.cs create mode 100644 src/PSRule/Parser/RuleLexer.cs create mode 100644 src/PSRule/Parser/RuleModels.cs create mode 100644 src/PSRule/Parser/TokenStream.cs create mode 100644 src/PSRule/Resources/FormatResources.Designer.cs create mode 100644 src/PSRule/Resources/FormatResources.resx create mode 100644 src/PSRule/Rules/RuleHelpInfo.cs create mode 100644 tests/PSRule.Tests/RuleDocument.md create mode 100644 tests/PSRule.Tests/RuleDocumentTests.cs create mode 100644 tests/PSRule.Tests/en-AU/FromFile1.md diff --git a/PSRule.build.ps1 b/PSRule.build.ps1 index 449cfd28c2..675d7a2e15 100644 --- a/PSRule.build.ps1 +++ b/PSRule.build.ps1 @@ -60,7 +60,7 @@ function CopyModuleFiles { task BuildDotNet { exec { # Build library - dotnet publish src/PSRule -c $Configuration -f netstandard2.0 -o $(Join-Path -Path $PWD -ChildPath out/modules/PSRule/core) + dotnet publish src/PSRule -c $Configuration -f netstandard2.0 -o $(Join-Path -Path $PWD -ChildPath out/modules/PSRule) } } diff --git a/docs/commands/PSRule/en-US/Get-PSRule.md b/docs/commands/PSRule/en-US/Get-PSRule.md index b7645a097c..7b4c473557 100644 --- a/docs/commands/PSRule/en-US/Get-PSRule.md +++ b/docs/commands/PSRule/en-US/Get-PSRule.md @@ -15,7 +15,7 @@ Get a list of rule definitions. ```text Get-PSRule [[-Path] ] [-Name ] [-Tag ] [-Option ] - [-Module ] [-ListAvailable] [] + [-Module ] [-ListAvailable] [-Culture ] [] ``` ## DESCRIPTION @@ -148,6 +148,26 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Culture + +Specifies the culture to use for rule documentation and messages. By default, the culture of PowerShell is used. + +This option does not affect the culture used for the PSRule engine, which always uses the culture of PowerShell. + +The PowerShell cmdlet `Get-Culture` shows the current culture of PowerShell. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/docs/commands/PSRule/en-US/Invoke-PSRule.md b/docs/commands/PSRule/en-US/Invoke-PSRule.md index e6937f635e..e22e219ecc 100644 --- a/docs/commands/PSRule/en-US/Invoke-PSRule.md +++ b/docs/commands/PSRule/en-US/Invoke-PSRule.md @@ -18,7 +18,8 @@ Evaluate objects against matching rules. ```text Invoke-PSRule [[-Path] ] [-Name ] [-Tag ] -InputObject [-Outcome ] [-Option ] [-As ] [-Format ] - [-ObjectPath ] [-Module ] [-OutputFormat ] [] + [-ObjectPath ] [-Module ] [-OutputFormat ] [-Culture ] + [] ``` ### InputPath @@ -26,7 +27,8 @@ Invoke-PSRule [[-Path] ] [-Name ] [-Tag ] -InputO ```text Invoke-PSRule [[-Path] ] [-Name ] [-Tag ] [-Outcome ] [-Option ] [-As ] [-Format ] [-ObjectPath ] - [-Module ] [-OutputFormat ] -InputPath [] + [-Module ] [-OutputFormat ] -InputPath [-Culture ] + [] ``` ## DESCRIPTION @@ -330,6 +332,26 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Culture + +Specifies the culture to use for rule documentation and messages. By default, the culture of PowerShell is used. + +This option does not affect the culture used for the PSRule engine, which always uses the culture of PowerShell. + +The PowerShell cmdlet `Get-Culture` shows the current culture of PowerShell. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/docs/commands/PSRule/en-US/Test-PSRuleTarget.md b/docs/commands/PSRule/en-US/Test-PSRuleTarget.md index a0c8da48fd..2204fec43b 100644 --- a/docs/commands/PSRule/en-US/Test-PSRuleTarget.md +++ b/docs/commands/PSRule/en-US/Test-PSRuleTarget.md @@ -18,14 +18,15 @@ Pass or fail objects against matching rules. ```text Test-PSRuleTarget [[-Path] ] [-Name ] [-Tag ] -InputObject [-Option ] [-Format ] [-ObjectPath ] [-Module ] - [] + [-Culture ] [] ``` ### InputPath ```text Test-PSRuleTarget [[-Path] ] [-Name ] [-Tag ] [-Option ] - [-Format ] [-ObjectPath ] [-Module ] -InputPath [] + [-Format ] [-ObjectPath ] [-Module ] -InputPath [-Culture ] + [] ``` ## DESCRIPTION @@ -211,6 +212,26 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Culture + +Specifies the culture to use for rule documentation and messages. By default, the culture of PowerShell is used. + +This option does not affect the culture used for the PSRule engine, which always uses the culture of PowerShell. + +The PowerShell cmdlet `Get-Culture` shows the current culture of PowerShell. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/src/PSRule/Annotations/BlockMetadata.cs b/src/PSRule/Annotations/BlockMetadata.cs index 53ef6982bf..fdf53f2612 100644 --- a/src/PSRule/Annotations/BlockMetadata.cs +++ b/src/PSRule/Annotations/BlockMetadata.cs @@ -3,7 +3,7 @@ /// /// Metadata properties that can be exposed by comment help. /// - public sealed class BlockMetadata + internal sealed class BlockMetadata { public string Description; } diff --git a/src/PSRule/Commands/NewRuleDefinitionCommand.cs b/src/PSRule/Commands/NewRuleDefinitionCommand.cs index dd1fe8eafa..69ad3818c1 100644 --- a/src/PSRule/Commands/NewRuleDefinitionCommand.cs +++ b/src/PSRule/Commands/NewRuleDefinitionCommand.cs @@ -1,6 +1,8 @@ -using PSRule.Pipeline; +using PSRule.Parser; +using PSRule.Pipeline; using PSRule.Rules; using System.Collections; +using System.IO; using System.Management.Automation; namespace PSRule.Commands @@ -60,14 +62,15 @@ internal sealed class NewRuleDefinitionCommand : LanguageBlock protected override void ProcessRecord() { + var context = PipelineContext.CurrentThread; var metadata = GetMetadata(MyInvocation.ScriptName, MyInvocation.ScriptLineNumber, MyInvocation.OffsetInLine); var tag = GetTag(Tag); - var moduleName = PipelineContext.CurrentThread.ModuleName; + var moduleName = context.Source.ModuleName; - PipelineContext.CurrentThread.VerboseFoundRule(ruleName: Name, scriptName: MyInvocation.ScriptName); + context.VerboseFoundRule(ruleName: Name, scriptName: MyInvocation.ScriptName); var ps = PowerShell.Create(); - ps.Runspace = PipelineContext.CurrentThread.GetRunspace(); + ps.Runspace = context.GetRunspace(); ps.AddCommand(new CmdletInfo(InvokeBlockCmdletName, typeof(InvokeRuleBlockCommand))); ps.AddParameter(InvokeBlockCmdlet_TypeParameter, Type); ps.AddParameter(InvokeBlockCmdlet_IfParameter, If); @@ -75,18 +78,47 @@ protected override void ProcessRecord() PipelineContext.EnableLogging(ps); + var doc = GetDoc(context: context, Name); + var block = new RuleBlock( sourcePath: MyInvocation.ScriptName, moduleName: moduleName, ruleName: Name, - description: metadata.Description, + description: doc == null ? metadata.Description : doc.Synopsis.Text, + recommendation: doc != null ? doc.Recommendation[0].Introduction : null, condition: ps, tag: tag, + annotations: doc?.Annotations, dependsOn: RuleHelper.ExpandRuleName(DependsOn, MyInvocation.ScriptName, moduleName), configuration: Configure ); WriteObject(block); } + + private Parser.RuleDocument GetDoc(PipelineContext context, string name) + { + if (context.Source.HelpPath == null || context.Source.HelpPath.Length == 0) + { + return null; + } + + for (var i = 0; i < context.Source.HelpPath.Length; i++) + { + var path = Path.Combine(context.Source.HelpPath[i], $"{name}.md"); + + if (!File.Exists(path)) + { + continue; + } + + var reader = new MarkdownReader(yamlHeaderOnly: false); + var stream = reader.Read(markdown: File.ReadAllText(path: path), path: path); + var lexer = new RuleLexer(preserveFomatting: false); + return lexer.Process(stream: stream); + } + + return null; + } } } diff --git a/src/PSRule/Host/HostHelper.cs b/src/PSRule/Host/HostHelper.cs index 5ab49c9db0..4628bb292f 100644 --- a/src/PSRule/Host/HostHelper.cs +++ b/src/PSRule/Host/HostHelper.cs @@ -1,6 +1,7 @@ using PSRule.Annotations; using PSRule.Configuration; using PSRule.Pipeline; +using PSRule.Resources; using PSRule.Rules; using System; using System.Collections.Generic; @@ -96,17 +97,12 @@ private static IEnumerable GetLanguageBlock(RuleSource[] sources if (!File.Exists(source.Path)) { - throw new FileNotFoundException("The script was not found.", source.Path); + throw new FileNotFoundException(PSRuleResources.ScriptNotFound, source.Path); } - PipelineContext.CurrentThread.ModuleName = string.IsNullOrEmpty(source.ModuleName) ? null : source.ModuleName; - + PipelineContext.CurrentThread.Source = source; PipelineContext.CurrentThread.VerboseRuleDiscovery(path: source.Path); - - if (!File.Exists(source.Path)) - { - throw new FileNotFoundException("Can't find file", source.Path); - } + //PipelineContext.CurrentThread.UseSource(source: source); // Invoke script ps.AddScript(source.Path, true); @@ -120,18 +116,22 @@ private static IEnumerable GetLanguageBlock(RuleSource[] sources foreach (var ir in invokeResults) { - if (ir.BaseObject is ILanguageBlock) + if (ir.BaseObject is RuleBlock) { - var block = ir.BaseObject as ILanguageBlock; - + var block = ir.BaseObject as RuleBlock; results.Add(block); } + //else if (ir.BaseObject is ILanguageBlock) + //{ + // var block = ir.BaseObject as ILanguageBlock; + // results.Add(block); + //} } } } finally { - PipelineContext.CurrentThread.ModuleName = null; + PipelineContext.CurrentThread.Source = null; ps.Runspace = null; ps.Dispose(); } @@ -198,7 +198,8 @@ private static Rule[] ToRule(IEnumerable blocks, RuleFilter filt SourcePath = block.SourcePath, ModuleName = block.ModuleName, Description = block.Description, - Tag = block.Tag + Tag = block.Tag, + Annotations = block.Annotations }; } } diff --git a/src/PSRule/PSRule.csproj b/src/PSRule/PSRule.csproj index 7cda369f35..2e2640ac63 100644 --- a/src/PSRule/PSRule.csproj +++ b/src/PSRule/PSRule.csproj @@ -1,4 +1,4 @@ - + netstandard2.0;net472 @@ -19,10 +19,11 @@ - - - - + + True + True + FormatResources.resx + True True @@ -31,6 +32,10 @@ + + ResXFileCodeGenerator + FormatResources.Designer.cs + ResXFileCodeGenerator PSRuleResources.Designer.cs diff --git a/src/PSRule/PSRule.psd1 b/src/PSRule/PSRule.psd1 index ea1e43c01b..4c6d71e5ce 100644 --- a/src/PSRule/PSRule.psd1 +++ b/src/PSRule/PSRule.psd1 @@ -54,7 +54,9 @@ DotNetFrameworkVersion = '4.7.2' # RequiredModules = @() # Assemblies that must be loaded prior to importing this module -# RequiredAssemblies = @() +RequiredAssemblies = @( + 'PSRule.dll' +) # Script files (.ps1) that are run in the caller's environment prior to importing this module. # ScriptsToProcess = @() diff --git a/src/PSRule/PSRule.psm1 b/src/PSRule/PSRule.psm1 index 45316d922a..39bc17863a 100644 --- a/src/PSRule/PSRule.psm1 +++ b/src/PSRule/PSRule.psm1 @@ -6,20 +6,6 @@ Set-StrictMode -Version latest; -# Set up some helper variables to make it easier to work with the module -$PSModule = $ExecutionContext.SessionState.Module; -$PSModuleRoot = $PSModule.ModuleBase; - -# Import the appropriate nested binary module based on the current PowerShell version -$binModulePath = Join-Path -Path $PSModuleRoot -ChildPath '/core/PSRule.dll'; - -$binaryModule = Import-Module -Name $binModulePath -PassThru; - -# When the module is unloaded, remove the nested binary module that was loaded with it -$PSModule.OnRemove = { - Remove-Module -ModuleInfo $binaryModule; -} - [PSRule.Configuration.PSRuleOption]::UseExecutionContext($ExecutionContext); # @@ -86,7 +72,10 @@ function Invoke-PSRule { [PSRule.Configuration.OutputFormat]$OutputFormat, [Parameter(Mandatory = $True, ParameterSetName = 'InputPath')] - [String[]]$InputPath + [String[]]$InputPath, + + [Parameter(Mandatory = $False)] + [String]$Culture ) begin { @@ -114,6 +103,9 @@ function Invoke-PSRule { if ($sourceParams.Count -eq 0) { $sourceParams['Path'] = $Path; } + if ($PSBoundParameters.ContainsKey('Culture')) { + $sourceParams['Culture'] = $Culture; + } [PSRule.Rules.RuleSource[]]$sourceFiles = GetRuleScriptPath @sourceParams -Verbose:$VerbosePreference; # Check that some matching script files were found @@ -227,7 +219,10 @@ function Test-PSRuleTarget { [String[]]$Module, [Parameter(Mandatory = $True, ParameterSetName = 'InputPath')] - [String[]]$InputPath + [String[]]$InputPath, + + [Parameter(Mandatory = $False)] + [String]$Culture ) begin { @@ -255,6 +250,9 @@ function Test-PSRuleTarget { if ($sourceParams.Count -eq 0) { $sourceParams['Path'] = $Path; } + if ($PSBoundParameters.ContainsKey('Culture')) { + $sourceParams['Culture'] = $Culture; + } [PSRule.Rules.RuleSource[]]$sourceFiles = GetRuleScriptPath @sourceParams -Verbose:$VerbosePreference; # Check that some matching script files were found @@ -328,7 +326,6 @@ function Test-PSRuleTarget { # .ExternalHelp PSRule-Help.xml function Get-PSRule { - [CmdletBinding()] [OutputType([PSRule.Rules.Rule])] param ( @@ -352,7 +349,10 @@ function Get-PSRule { [String[]]$Module, [Parameter(Mandatory = $False)] - [Switch]$ListAvailable + [Switch]$ListAvailable, + + [Parameter(Mandatory = $False)] + [String]$Culture ) begin { @@ -383,6 +383,9 @@ function Get-PSRule { if ($sourceParams.Count -eq 0) { $sourceParams['Path'] = $Path; } + if ($PSBoundParameters.ContainsKey('Culture')) { + $sourceParams['Culture'] = $Culture; + } [PSRule.Rules.RuleSource[]]$sourceFiles = GetRuleScriptPath @sourceParams -Verbose:$VerbosePreference; # Check that some matching script files were found @@ -947,18 +950,25 @@ function GetRuleScriptPath { [String[]]$Module, [Parameter(Mandatory = $False)] - [Switch]$ListAvailable + [Switch]$ListAvailable, + + [Parameter(Mandatory = $False)] + [String]$Culture ) process { $builder = New-Object -TypeName 'PSRule.Rules.RuleSourceBuilder'; + if ([String]::IsNullOrEmpty($Culture)) { + $Culture = [System.Threading.Thread]::CurrentThread.CurrentCulture.ToString(); + } if ($PSBoundParameters.ContainsKey('Path')) { Write-Verbose -Message "[PSRule][D] -- Scanning for source files: $Path"; $fileObjects = (Get-ChildItem -Path $Path -Recurse -File -Include '*.rule.ps1' -ErrorAction Stop); - if ($Null -ne $fileObjects) { - $builder.Add($fileObjects.FullName, $Null); + foreach ($file in $fileObjects) { + $helpPath = Join-Path -Path $file.Directory.FullName -ChildPath $Culture; + $builder.Add($file.FullName, $helpPath); } } @@ -981,9 +991,10 @@ function GetRuleScriptPath { foreach ($m in $modules) { Write-Verbose -Message "[PSRule][D] -- Found module: $($m.Name)"; $fileObjects = (Get-ChildItem -Path $m.ModuleBase -Recurse -File -Include '*.rule.ps1' -ErrorAction Stop); + $helpPath = Join-Path $m.ModuleBase -ChildPath $Culture; - if ($Null -ne $fileObjects) { - $builder.Add($fileObjects.FullName, $m.Name); + foreach ($file in $fileObjects) { + $builder.Add($file.FullName, $m.Name, $helpPath); } } } diff --git a/src/PSRule/Parser/MarkdownLexer.cs b/src/PSRule/Parser/MarkdownLexer.cs new file mode 100644 index 0000000000..863ee2cdb3 --- /dev/null +++ b/src/PSRule/Parser/MarkdownLexer.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace PSRule.Parser +{ + internal abstract class MarkdownLexer + { + protected bool IsHeading(MarkdownToken token, int level) + { + return token.Type == MarkdownTokenType.Header && + token.Depth == level; + } + + protected bool IsHeading(MarkdownToken token, int level, string text) + { + return token.Type == MarkdownTokenType.Header && + token.Depth == level && + string.Equals(text, token.Text, StringComparison.OrdinalIgnoreCase); + } + + protected Dictionary YamlHeader(TokenStream stream) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + + while (!stream.EOF && stream.IsTokenType(MarkdownTokenType.YamlKeyValue)) + { + metadata[stream.Current.Meta] = stream.Current.Text; + + stream.Next(); + } + + return metadata.Count == 0 ? null : metadata; + } + } +} diff --git a/src/PSRule/Parser/MarkdownReader.cs b/src/PSRule/Parser/MarkdownReader.cs new file mode 100644 index 0000000000..f42f8c11b3 --- /dev/null +++ b/src/PSRule/Parser/MarkdownReader.cs @@ -0,0 +1,507 @@ +using System; + +namespace PSRule.Parser +{ + internal enum MarkdownReaderMode + { + None, + + List + } + + /// + /// Define options that determine how sections will be formated when rendering markdown. + /// + [Flags()] + public enum SectionFormatOption : byte + { + None = 0, + + /// + /// A line break should be added after the section header. + /// + LineBreakAfterHeader = 1 + } + + /// + /// Stateful markdown reader. + /// + internal sealed class MarkdownReader + { + private readonly TokenStream _Output; + private readonly bool _YamlHeaderOnly; + + /// + /// Preserve formatting skips processing inlines and treats them as raw text. + /// + private readonly bool _PreserveFormatting; + + private MarkdownReaderMode _Context; + + private MarkdownStream _Stream; + + /// + /// Line ending characters: \r, \n + /// + private readonly static char[] LineEndingCharacters = new char[] { '\r', '\n' }; + + private const char Asterix = '*'; + private const char Backtick = '`'; + private const char Underscore = '_'; + private const char Whitespace = ' '; + private const char Colon = ':'; + private const char Dash = '-'; + private const char BracketOpen = '['; + private const char BracketClose = ']'; + private const char ParenthesesOpen = '('; + private const char ParenthesesClose = ')'; + private static readonly char[] LinkNameStopCharacters = new char[] { '\r', '\n', ']' }; + private static readonly char[] LinkUrlStopCharacters = new char[] { '\r', '\n', ')' }; + private static readonly char[] YamlHeaderStopCharacters = new char[] { '\r', '\n', ':' }; + + private const string TripleDash = "---"; + + internal MarkdownReader(bool yamlHeaderOnly) + { + _Output = new TokenStream(); + _YamlHeaderOnly = yamlHeaderOnly; + _PreserveFormatting = false; + } + + public TokenStream Read(string markdown, string path) + { + _Context = MarkdownReaderMode.None; + + _Stream = new MarkdownStream(markdown); + + YamlHeader(); + + if (_YamlHeaderOnly) + { + return _Output; + } + + while (!_Stream.EOF) + { + var processed = UnderlineHeader() || + HashHeader() || + FencedBlock() || + Link() || + LineBreak(); + + if (!processed) + { + Text(); + } + } + + return _Output; + } + + private void YamlHeader() + { + if (_Stream.EOF || _Stream.Line > 1 || _Stream.Current != Dash) + { + return; + } + + // Check if the line is just dashes indicating start of yaml header + if (!_Stream.PeakLine(Dash, out int count) || count < 2) + { + return; + } + + _Stream.Skip(count + 1); + _Stream.SkipLineEnding(); + + while (!_Stream.IsSequence(TripleDash, onNewLine: true)) + { + var key = _Stream.CaptureUntil(YamlHeaderStopCharacters).Trim(); + + _Stream.SkipWhitespace(); + + if (!string.IsNullOrEmpty(key) && _Stream.Skip(Colon)) + { + _Stream.SkipWhitespace(); + + var value = _Stream.CaptureUntil(LineEndingCharacters).TrimEnd(); + _Stream.SkipLineEnding(); + + _Output.YamlKeyValue(key, value); + } + else + { + _Stream.Next(); + } + } + + _Stream.Skip(TripleDash); + _Stream.SkipLineEnding(); + } + + private bool UnderlineHeader() + { + if ((_Stream.Current != MarkdownStream.Dash && _Stream.Current != MarkdownStream.EqualSign) || !_Stream.IsStartOfLine) + { + return false; + } + + // Check the line is made up of the same characters + if (!_Stream.PeakLine(_Stream.Current, out int count)) + { + return false; + } + + char currentChar = _Stream.Current; + + // Remove the previous token and replace with a header + if (_Output.Current?.Type == MarkdownTokenType.Text) + { + var previousToken = _Output.Pop(); + + _Stream.Skip(count + 1); + + _Output.Header(currentChar == MarkdownStream.EqualSign ? 1 : 2, previousToken.Text, null, lineBreak: (_Stream.SkipLineEnding(max: 0) > 1)); + + return true; + } + + return false; + } + + /// + /// Process hash header. + /// + private bool HashHeader() + { + if (_Stream.Current != MarkdownStream.Hash || !_Stream.IsStartOfLine) + { + return false; + } + + _Stream.MarkExtentStart(); + _Stream.Next(); + + // Get the header depth + var headerDepth = _Stream.Skip(MarkdownStream.Hash, max: 0) + 1; + + // Capture to the end of the line + _Stream.SkipWhitespace(); + var text = _Stream.CaptureLine(); + + var extent = _Stream.GetExtent(); + + _Output.Header(headerDepth, text, extent, lineBreak: (_Stream.SkipLineEnding(max: 0) > 1)); + + return true; + } + + /// + /// Process a fenced block. + /// + private bool FencedBlock() + { + if (_Stream.Current != MarkdownStream.Backtick || !_Stream.IsSequence(MarkdownStream.TripleBacktick, onNewLine: true)) + { + return false; + } + + _Stream.MarkExtentStart(); + + // Skip backticks + _Stream.Skip(3); + + // Get info-string + var info = _Stream.CaptureLine(); + _Stream.SkipLineEnding(); + + // Capture text within code fence + var text = _Stream.CaptureUntil(MarkdownStream.TripleBacktick, onNewLine: true, ignoreEscaping: true); + + // Skip backticks + _Stream.Skip(MarkdownStream.TripleBacktick); + + // Write code fence beginning + _Output.FencedBlock(info, text, null, lineBreak: _Stream.SkipLineEnding(max: 0) > 1); + + return true; + } + + private bool LineBreak() + { + if (_Stream.Current != '\r' && _Stream.Current != '\n') + { + return false; + } + + if (_Stream.IsSequence("\r\n")) + { + var breakCount = _Stream.SkipLineEnding(max: 0, ignoreEscaping: _PreserveFormatting); + + if (_PreserveFormatting) + { + _Output.LineBreak(count: breakCount); + } + + return true; + } + + return false; + } + + private void Text() + { + _Stream.MarkExtentStart(); + + // Set the default style + var textStyle = MarkdownTokenFlag.None; + + var startOfLine = _Stream.IsStartOfLine; + + // Get the text + var text = (_PreserveFormatting) ? _Stream.CaptureUntil(LineEndingCharacters, ignoreEscaping: true) : UnwrapStyleMarkers(_Stream, out textStyle); + + // Set the line ending + var ending = GetEnding(_Stream.SkipLineEnding(max: 2)); + + if (string.IsNullOrWhiteSpace(text) && !_PreserveFormatting) + { + return; + } + + if (_Context != MarkdownReaderMode.List && startOfLine && IsList(text)) + { + _Context = MarkdownReaderMode.List; + + if (_Output.Current != null && _Output.Current.Flag.IsEnding() && !_Output.Current.Flag.ShouldPreserve()) + { + _Output.Current.Flag |= MarkdownTokenFlag.Preserve; + } + } + + // Override line ending if the line was a list item so that the line ending is preserved + if (_Context == MarkdownReaderMode.List) + { + if (ending.IsEnding()) + { + ending |= MarkdownTokenFlag.Preserve; + } + } + + // Add the text to the output stream + _Output.Text(text, flag: textStyle | ending); + + if (_Context == MarkdownReaderMode.List && ending.IsEnding()) + { + _Context = MarkdownReaderMode.None; + } + } + + private string UnwrapStyleMarkers(MarkdownStream stream, out MarkdownTokenFlag flag) + { + flag = MarkdownTokenFlag.None; + + // Check for style + var styleChar = stream.Current; + var stylePrevious = stream.Previous; + var styleCount = styleChar == Asterix || styleChar == Underscore ? stream.Skip(styleChar, max: 0) : 0; + var codeCount = styleChar == Backtick ? stream.Skip(Backtick, max: 0) : 0; + + var text = stream.CaptureUntil(IsTextStop, ignoreEscaping: false); + + // Check for italic and bold endings + if (styleCount > 0) + { + if (stream.Current == styleChar) + { + var styleEnding = stream.Skip(styleChar, max: styleCount); + + // Add back underscores within text + if (styleChar == Underscore && stylePrevious != Whitespace) + { + return Pad(text, styleChar, left: styleCount, right: styleCount); + } + + // Add back asterixes/underscores that are part of text + if (styleEnding < styleCount) + { + text = Pad(text, styleChar, left: styleCount - styleEnding); + } + + if (styleEnding == 1 || styleEnding == 3) + { + flag |= MarkdownTokenFlag.Italic; + } + + if (styleEnding >= 2) + { + flag |= MarkdownTokenFlag.Bold; + } + } + else + { + // Add back asterixes/underscores that are part of text + text = Pad(text, styleChar, left: styleCount); + } + } + + if (codeCount > 0) + { + if (stream.Current == styleChar) + { + var codeEnding = stream.Skip(styleChar, max: 1); + + // Add back backticks that are part of text + if (codeEnding < codeCount) + { + text = Pad(text, styleChar, left: codeCount - codeEnding); + } + + if (codeEnding == 1) + { + flag |= MarkdownTokenFlag.Code; + } + } + else + { + // Add back backticks that are part of text + text = Pad(text, styleChar, left: codeCount); + } + } + + return text; + } + + private string Pad(string text, char c, int left = 0, int right = 0) + { + return text.PadLeft(text.Length + left, c).PadRight(text.Length + left + right, c); + } + + private bool IsList(string text) + { + var clean = text.Trim(); + + if (string.IsNullOrEmpty(clean)) + { + return false; + } + + var firstChar = clean[0]; + + if (firstChar == Dash || firstChar == Asterix) + { + return true; + } + + return false; + } + + private MarkdownTokenFlag GetEnding(int lineEndings) + { + return lineEndings == 0 ? MarkdownTokenFlag.None : (lineEndings == 1) ? MarkdownTokenFlag.LineEnding : MarkdownTokenFlag.LineBreak; + } + + /// + /// Process link. + /// + private bool Link() + { + if (_Stream.Current != BracketOpen || _Stream.IsEscaped) + { + return false; + } + + _Stream.MarkExtentStart(); + _Stream.Checkpoint(); + + // Skip [ + _Stream.Next(); + + // Find end ] + var text = _Stream.CaptureUntil(LinkNameStopCharacters); + + // Check if closing bracket was found in line + if (_Stream.Current != BracketClose) + { + // Ignore and add as text + _Stream.Rollback(); + _Stream.Next(); + + var ending = GetEnding(_Stream.SkipLineEnding(max: 0)); + + _Output.Text("[", flag: ending); + + return true; + } + + // Skip ] + _Stream.Next(); + + if(string.IsNullOrEmpty(text)) + { + var ending = GetEnding(_Stream.SkipLineEnding(max: 0)); + + _Output.Text("[]", flag: ending); + + return true; + } + + // Check for link destination indicated by '('. i.e. [text](destination) + if (_Stream.Skip(ParenthesesOpen)) + { + var uri = _Stream.CaptureUntil(LinkUrlStopCharacters); + + // Check if closing bracket was found in line + if (_Stream.Current != ParenthesesClose) + { + // TODO: Looks like error, double check, will position be lost + return true; + } + + // Skip ) + _Stream.Next(); + + _Output.Link(text, uri); + } + // Check for link label indicated by '['. i.e. [text][label] + else if (_Stream.Skip(BracketOpen)) + { + var linkRef = _Stream.CaptureUntil(LinkNameStopCharacters); + + // Skip ] + _Stream.Next(); + + _Output.LinkReference(text, linkRef); + } + // Check for link reference definition indicated by ':'. i.e. [label]: destination + else if (_Stream.Skip(Colon)) + { + _Stream.SkipWhitespace(); + + var destination = _Stream.CaptureUntil(LineEndingCharacters); + + _Output.LinkReferenceDefinition(text, destination); + } + else + { + _Output.LinkReference(text, text); + } + + var extent = _Stream.GetExtent(); + + if (string.IsNullOrEmpty(text)) + { + _Stream.Rollback(); + + return false; + } + + return true; + } + + private static bool IsTextStop(char c) + { + return c == '\r' || c == '\n' || c == '[' || c == '*' || c == '`' || c == '_'; + } + } +} diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs new file mode 100644 index 0000000000..56c552b4fc --- /dev/null +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -0,0 +1,591 @@ +using System.Diagnostics; +using System.Linq; + +namespace PSRule.Parser +{ + internal delegate bool CharacterMatchDelegate(char c); + + [DebuggerDisplay("StartPos = (L: {Start}, C: {Column}), EndPos = (L: {End}, C: {Column.End}), Text = {Text}")] + public sealed class SourceExtent + { + private string _Text; + + internal SourceExtent(string markdown, string path, int start, int end, int line, int column) + { + _Text = null; + + Markdown = markdown; + Path = path; + Start = start; + End = end; + Line = line; + Column = column; + } + + public string Markdown { get; private set; } + + public string Path { get; private set; } + + public int Start { get; private set; } + + public int End { get; private set; } + + public int Line { get; private set; } + + public int Column { get; private set; } + + public string Text + { + get + { + if (_Text == null) + { + _Text = Markdown.Substring(Start, (End - Start)); + } + + return _Text; + } + } + } + + internal sealed class MarkdownStream + { + private sealed class StreamCursor + { + public int Position = 0; + public int Line = 0; + public int Column = 0; + } + + private readonly string _Markdown; + + /// + /// The current character position in the markdown string. Call Next() to change the position. + /// + private int _Position = 0; + + private int _Line = 0; + private int _Column = 0; + private int _Length; + private int? _ExtentMarker; + private StreamCursor _Checkpoint; + private const char NewLine = '\n'; + private const char CarrageReturn = '\r'; + public const char Dash = '-'; + public const char Whitespace = ' '; + public const char Hash = '#'; + public const char Backtick = '`'; + private const char BracketOpen = '['; + private const char BracketClose = ']'; + private const char ParenthesesOpen = '('; + private const char ParenthesesClose = ')'; + private const char AngleOpen = '<'; + private const char AngleClose = '>'; + public const char Backslash = '\\'; + public const string TripleBacktick = "```"; + public const string NewLineTripleBacktick = "\r\n```"; + public static char[] NewLineStopCharacters = new char[] { '\r', '\n' }; + public const char EqualSign = '='; + public static char[] UnorderListCharacters = new char[] { '-', '*' }; + private char _Current; + private char _Previous; + private int _EscapeLength; + + public MarkdownStream(string markdown) + { + _Markdown = markdown; + _Length = _Markdown.Length; + + UpdateCurrent(); + + if (_Markdown.Length > 0) + { + _Line = 1; + } + } + + #region Properties + + public bool EOF + { + get { return _Position >= _Length; } + } + + public bool IsStartOfLine + { + get { return _Column == 0; } + } + + /// + /// The character at the current position in the stream. + /// + public char Current + { + get { return _Current; } + } + + public char Previous + { + get { return _Previous;} + } + + public int Line + { + get { return _Line; } + } + + public int Column + { + get { return _Column; } + } + +#if DEBUG + + /// + /// Used for interactive debugging of current position and next characters in the stream. + /// + public string Preview + { + get { return _Markdown.Substring(_Position); } + } + +#endif + + public int Position + { + get { return _Position; } + } + + private int Remaining + { + get { return _Length - Position; } + } + + public string Body + { + get { return _Markdown; } + } + + public bool IsEscaped + { + get { return _EscapeLength > 0; } + } + + #endregion Properties + + /// + /// Skip if the current character is whitespace. + /// + public void SkipWhitespace() + { + Skip(Whitespace, max: 0); + } + + /// + /// If the current character and sequential characters are line ending control characters, skip ahead. + /// + /// The number of line endings to skip. When max is 0, sequential line endings will be skipped. + /// The number of line endings skipped. + public int SkipLineEnding(int max = 1, bool ignoreEscaping = false) + { + var skipped = 0; + + while ((Current == CarrageReturn || Current == NewLine) && (max == 0 || skipped < max)) + { + if (Current == CarrageReturn && (Remaining == 0 || Peak() != NewLine)) + { + break; + } + else + { + Next(); + } + + Next(ignoreEscaping); + + skipped++; + } + + return skipped; + } + + /// + /// Skip ahead if the next character is expected. + /// + /// The character to skip. + public int SkipNext(char c) + { + var skipped = 0; + + while (Peak() == c) + { + Next(); + + skipped++; + } + + return skipped; + } + + public bool Skip(char c) + { + if (_Current != c) + { + return false; + } + + Next(); + + return true; + } + + /// + /// Skip ahead if the current character is expected. Keep skipping when the character is repeated. + /// + /// The character to skip. + /// The number of characters that where skipped. + public int Skip(char c, int max) + { + var skipped = 0; + + while (Current == c && (max == 0 || skipped < max)) + { + Next(); + + skipped++; + } + + return skipped; + } + + public int Skip(string sequence, int max = 0, bool ignoreEscaping = false) + { + var skipped = 0; + + while (IsSequence(sequence) && (max == 0 || skipped < max)) + { + Skip(sequence.Length, ignoreEscaping); + + skipped++; + } + + return skipped; + } + + /// + /// Skip ahead a number of characters. Use Next() in preference of Skip if the number to skip is 1. + /// + /// The number of characters to skip + public void Skip(int toSkip, bool ignoreEscaping = false) + { + toSkip = HasRemaining(toSkip) ? toSkip : Remaining; + + for (var i = 0; i < toSkip; i++) + { + Next(ignoreEscaping); + } + } + + /// + /// Peak at the n'th character from the current position. Check remaining characters prior to calling. + /// + /// The offset from the current position. + /// The character at the offset. + public char Peak(int offset = 1) + { + return _Markdown[_Position + offset]; + } + + public bool PeakAnyOf(int offset = 1, params char[] c) + { + return c.Contains(Peak(offset)); + } + + public bool PeakLine(char c, out int count) + { + var offset = 1; + + while (Peak(offset) == c) + { + offset++; + } + + count = offset - 1; + + return NewLineStopCharacters.Contains(Peak(offset)); + } + + public int PeakCount(char c) + { + int count = 1; + + while (Peak(count) == c) + { + count++; + } + + return count; + } + + public void MarkExtentStart() + { + _ExtentMarker = _Position; + } + + /// + /// Get the extent and clear previous marker. + /// + /// + public SourceExtent GetExtent() + { + if (!_ExtentMarker.HasValue) + { + return null; + } + + var extent = new SourceExtent(_Markdown, null, _ExtentMarker.Value, _Position, _Line, _Column); + + _ExtentMarker = null; + + return extent; + } + + /// + /// Create a position checkpoint that can be rolled back. + /// + public void Checkpoint() + { + _Checkpoint = new StreamCursor { Position = _Position, Line = _Line, Column = _Column }; + } + + /// + /// Rollback a position checkpoint. + /// + public void Rollback() + { + _Position = _Checkpoint.Position; + _Line = _Checkpoint.Line; + _Column = _Checkpoint.Column; + + UpdateCurrent(); + } + + /// + /// Move to the next character in the stream. + /// + /// Is True when more characters exist in the stream. + public bool Next(bool ignoreEscaping = false) + { + _Position += _EscapeLength + 1; + + if (_Position >= _Length) + { + _Current = char.MinValue; + + return false; + } + + // Update line and column counters + if (_Current == NewLine) + { + _Line++; + _Column = 0; + } + else + { + _Column += _EscapeLength + 1; + } + + UpdateCurrent(ignoreEscaping); + + return true; + } + + private void UpdateCurrent(bool ignoreEscaping = false) + { + // Handle escape sequences + _EscapeLength = ignoreEscaping ? 0 : GetEscapeCount(_Position); + + _Previous = _Current; + _Current = _Markdown[_Position + _EscapeLength]; + } + + private int GetEscapeCount(int position) + { + // Check for escape sequences + if (_Markdown[position] == Backslash && position < _Length) + { + var next = _Markdown[position + 1]; + + // Check against list of escapable characters + if (next == Backslash || next == BracketOpen || next == ParenthesesOpen ||next == AngleOpen || next == AngleClose || next == Backtick || next == BracketClose || next == ParenthesesClose) + { + return 1; + } + } + + return 0; + } + + /// + /// Capture text until the sequence is found. + /// + /// A specific sequence that ends the capture. + /// + /// Interprets the string literally instead of processing escape sequences. + /// Returns the captured text up until the sequence. + public string CaptureUntil(string sequence, bool onNewLine = false, bool ignoreEscaping = false) + { + var start = Position; + var length = 0; + + while (!IsSequence(sequence, onNewLine) && !EOF) + { + length++; + + Next(ignoreEscaping); + } + + // Back track line endings so they are not included in the captured string + for (var i = 1; i < length; i++) + { + if (!IsLineEnding(Peak(-i))) + { + break; + } + + length--; + } + + return Substring(start, length, ignoreEscaping); + } + + public string CaptureWithinLineUntil(char c) + { + var start = Position; + var length = 0; + + while (Current != c && !EOF) + { + if (NewLineStopCharacters.Contains(Current)) + { + break; + } + + length++; + + Next(); + } + + return Substring(start, length); + } + + public string CaptureUntil(char[] c, bool ignoreEscaping = false) + { + var start = Position; + var length = 0; + + while (!EOF) + { + if (!IsEscaped && c.Contains(Current)) + { + break; + } + + length++; + + Next(ignoreEscaping); + } + + return Substring(start, length, ignoreEscaping); + } + + public string CaptureUntil(CharacterMatchDelegate match, bool ignoreEscaping = false) + { + var start = Position; + var length = 0; + + while (!EOF) + { + if (!IsEscaped && match(Current)) + { + break; + } + + length++; + + Next(ignoreEscaping); + } + + return Substring(start, length, ignoreEscaping); + } + + private string Substring(int start, int length, bool ignoreEscaping = false) + { + if (ignoreEscaping) + { + return _Markdown.Substring(start, length); + } + + var position = start; + var i = 0; + + var buffer = new char[length]; + + while (i < length) + { + var offset = GetEscapeCount(position); + + buffer[i] = _Markdown[position + offset]; + + position += offset + 1; + + i++; + } + + return new string(buffer); + } + + /// + /// Capture text until the end of the line. + /// + /// Returns the captured text up until the end of the line. + public string CaptureLine() + { + return CaptureUntil("\r\n"); + } + + public bool IsSequence(string sequence, bool onNewLine = false) + { + if (onNewLine && !IsStartOfLine) + { + return false; + } + + if (!HasRemaining(sequence.Length)) + { + return false; + } + + for (var i = 0; i < sequence.Length; i++) + { + if (Peak(i) != sequence[i]) + { + return false; + } + } + + return true; + } + + private bool HasRemaining(int length) + { + return Remaining >= length; + } + + private bool IsLineEnding(char c) + { + return c == CarrageReturn || c == NewLine; + } + } +} diff --git a/src/PSRule/Parser/MarkdownToken.cs b/src/PSRule/Parser/MarkdownToken.cs new file mode 100644 index 0000000000..17e050ce14 --- /dev/null +++ b/src/PSRule/Parser/MarkdownToken.cs @@ -0,0 +1,87 @@ +using System; +using System.Diagnostics; + +namespace PSRule.Parser +{ + public enum MarkdownTokenType : byte + { + None = 0, + + Text, + + Header, + + FencedBlock, + + LineBreak, + + ParagraphStart, + + ParagraphEnd, + + LinkReference, + + Link, + + LinkReferenceDefinition, + + YamlKeyValue + } + + [Flags()] + public enum MarkdownTokenFlag + { + None = 0, + + Italic = 1, + + Bold = 2, + + Code = 4, + + LineEnding = 8, + + LineBreak = 16, + + Preserve = 32, + + // Accelerators + PreserveLineEnding = 40 + } + + public static class MarkdownTokenFlagExtensions + { + public static bool IsEnding(this MarkdownTokenFlag flag) + { + return flag.HasFlag(MarkdownTokenFlag.LineEnding) || flag.HasFlag(MarkdownTokenFlag.LineBreak); + } + + public static bool IsLineBreak(this MarkdownTokenFlag flag) + { + return flag.HasFlag(MarkdownTokenFlag.LineBreak); + } + + public static bool ShouldPreserve(this MarkdownTokenFlag flag) + { + return flag.HasFlag(MarkdownTokenFlag.Preserve); + } + } + + [DebuggerDisplay("Type = {Type}, Text = {Text}")] + public sealed class MarkdownToken + { + public SourceExtent Extent { get; set; } + + public MarkdownTokenType Type { get; set; } + + public string Text { get; set; } + + public string Meta { get; set; } + + public int Depth { get; set; } + + //public MarkdownTokenEnding Ending { get; set; } + + public MarkdownTokenFlag Flag { get; set; } + } +} diff --git a/src/PSRule/Parser/MarkdownTokenExtensions.cs b/src/PSRule/Parser/MarkdownTokenExtensions.cs new file mode 100644 index 0000000000..151daa1d02 --- /dev/null +++ b/src/PSRule/Parser/MarkdownTokenExtensions.cs @@ -0,0 +1,22 @@ +namespace PSRule.Parser +{ + internal static class MarkdownTokenExtensions + { + public static bool IsSingleLineEnding(this MarkdownToken token) + { + return token.Flag.HasFlag(MarkdownTokenFlag.LineEnding) || + token.Type == MarkdownTokenType.LineBreak; + } + + public static bool IsPreservableLineEnding(this MarkdownToken token) + { + return (token.Flag.HasFlag(MarkdownTokenFlag.LineEnding) && token.Flag.HasFlag(MarkdownTokenFlag.Preserve)) || + token.Type == MarkdownTokenType.LineBreak; + } + + public static bool IsDoubleLineEnding(this MarkdownToken token) + { + return token.Flag.HasFlag(MarkdownTokenFlag.LineBreak) && token.Type != MarkdownTokenType.LineBreak; + } + } +} diff --git a/src/PSRule/Parser/MetadataLexer.cs b/src/PSRule/Parser/MetadataLexer.cs new file mode 100644 index 0000000000..b6d161b6d0 --- /dev/null +++ b/src/PSRule/Parser/MetadataLexer.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace PSRule.Parser +{ + internal sealed class MetadataLexer : MarkdownLexer + { + public Dictionary Process(TokenStream stream) + { + stream.MoveTo(0); + + // Look for yaml header + + return YamlHeader(stream); + } + } +} diff --git a/src/PSRule/Parser/RuleLexer.cs b/src/PSRule/Parser/RuleLexer.cs new file mode 100644 index 0000000000..84fcacd3c1 --- /dev/null +++ b/src/PSRule/Parser/RuleLexer.cs @@ -0,0 +1,287 @@ +using PSRule.Resources; +using PSRule.Rules; +using System; +using System.Collections.Generic; +using System.Text; + +namespace PSRule.Parser +{ + /// + /// A lexer that inteprets markdown as a rule. + /// + internal sealed class RuleLexer : MarkdownLexer + { + private const int RULE_NAME_HEADING_LEVEL = 1; + private const int RULE_ENTRIES_HEADING_LEVEL = 2; + + private readonly bool _PreserveFormatting; + + public RuleLexer(bool preserveFomatting) + { + _PreserveFormatting = preserveFomatting; + } + + public RuleDocument Process(TokenStream stream) + { + stream.MoveTo(0); + + // Look for yaml header + var metadata = YamlHeader(stream); + + RuleDocument doc = null; + + // Process sections + while (!stream.EOF) + { + if (IsHeading(stream.Current, RULE_NAME_HEADING_LEVEL)) + { + doc = new RuleDocument + { + Name = stream.Current.Text + }; + + doc.Annotations = TagSet.FromDictionary(metadata); + } + else if (doc != null) + { + if (IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL)) + { + var matching = Synopsis(stream, doc) || + Recommendation(stream, doc) || + Notes(stream, doc) || + RelatedLinks(stream, doc); + + if (matching) + { + continue; + } + } + } + + // Skip the current token + stream.Next(); + } + + return doc; + } + + /// + /// Read Synopsis. + /// + private bool Synopsis(TokenStream stream, RuleDocument doc) + { + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, FormatResources.Synopsis)) + { + return false; + } + + doc.Synopsis = SectionBody(stream); + stream.SkipUntil(MarkdownTokenType.Header); + + return true; + } + + /// + /// Process recommendations. + /// + private bool Recommendation(TokenStream stream, RuleDocument doc) + { + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, FormatResources.Recommendation)) + { + return false; + } + + stream.Next(); + + var recommendations = new List(); + + if (!stream.EOF) + { + var hasLineBreak = stream.Current.IsDoubleLineEnding(); + var recommendation = new RuleRecommendation + { + Title = "default", + FormatOption = hasLineBreak ? SectionFormatOption.LineBreakAfterHeader : SectionFormatOption.None, + Introduction = SimpleTextSection(stream), + Code = RecommendationBlock(stream), + Remarks = SimpleTextSection(stream) + }; + + stream.SkipUntil(MarkdownTokenType.Header); + + recommendations.Add(recommendation); + } + + doc.Recommendation = recommendations.ToArray(); + + return true; + } + + /// + /// Read Notes. + /// + private bool Notes(TokenStream stream, RuleDocument doc) + { + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, FormatResources.Notes)) + { + return false; + } + + doc.Notes = SectionBody(stream); + stream.SkipUntil(MarkdownTokenType.Header); + + return true; + } + + private bool RelatedLinks(TokenStream stream, RuleDocument doc) + { + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, FormatResources.Links)) + { + return false; + } + + List links = new List(); + + stream.Next(); + + while (stream.IsTokenType(MarkdownTokenType.Link, MarkdownTokenType.LinkReference, MarkdownTokenType.LineBreak)) + { + if (stream.IsTokenType(MarkdownTokenType.LineBreak)) + { + stream.Next(); + + continue; + } + + var link = new Link + { + Name = stream.Current.Meta, + Uri = stream.Current.Text + }; + + // Update link to point to resolved target + if (stream.IsTokenType(MarkdownTokenType.LinkReference)) + { + var target = stream.ResolveLinkTarget(link.Uri); + link.Uri = target.Text; + } + + links.Add(link); + + stream.Next(); + } + + stream.SkipUntil(MarkdownTokenType.Header); + + doc.Links = links.ToArray(); + + return true; + } + + private Body SectionBody(TokenStream stream) + { + var useBreak = stream.Current.IsDoubleLineEnding(); + + stream.Next(); + + var text = SimpleTextSection(stream); + + return new Body(text, useBreak ? SectionFormatOption.LineBreakAfterHeader : SectionFormatOption.None); + } + + private string SimpleTextSection(TokenStream stream, bool includeNonYamlFencedBlocks = false) + { + var sb = new StringBuilder(); + + while (stream.IsTokenType(MarkdownTokenType.Text, MarkdownTokenType.Link, MarkdownTokenType.FencedBlock, MarkdownTokenType.LineBreak)) + { + if (stream.IsTokenType(MarkdownTokenType.Text)) + { + AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + sb.Append(stream.Current.Text); + } + else if (stream.IsTokenType(MarkdownTokenType.Link)) + { + AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + sb.Append(stream.Current.Meta); + + if (!string.IsNullOrEmpty(stream.Current.Text)) + { + sb.AppendFormat(" ({0})", stream.Current.Text); + } + } + else if (stream.IsTokenType(MarkdownTokenType.LinkReference)) + { + AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + + sb.Append(stream.Current.Meta); + } + else if (stream.IsTokenType(MarkdownTokenType.FencedBlock)) + { + // Only process fenced blocks if specified, and never process yaml blocks + if (!includeNonYamlFencedBlocks || string.Equals(stream.Current.Meta, "yaml", StringComparison.OrdinalIgnoreCase)) + { + if (stream.PeakTokenType(-1) == MarkdownTokenType.LineBreak) + { + AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + } + + break; + } + + AppendEnding(sb, stream.Peak(-1), preserveEnding: true); + sb.Append(stream.Current.Text); + } + else if (stream.IsTokenType(MarkdownTokenType.LineBreak)) + { + AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + } + + stream.Next(); + } + + if (stream.EOF && stream.Peak(-1).Flag.HasFlag(MarkdownTokenFlag.Preserve) && stream.Peak(-1).Flag.HasFlag(MarkdownTokenFlag.LineEnding)) + { + AppendEnding(sb, stream.Peak(-1)); + } + + return sb.ToString(); + } + + private void AppendEnding(StringBuilder stringBuilder, MarkdownToken token, bool preserveEnding = false) + { + if (token == null || stringBuilder.Length == 0 || !token.Flag.IsEnding()) + { + return; + } + + if (!preserveEnding && token.Flag.ShouldPreserve()) + { + preserveEnding = true; + } + + if (token.IsDoubleLineEnding()) + { + stringBuilder.Append(preserveEnding ? "\r\n\r\n" : "\r\n"); + } + else if (token.IsSingleLineEnding()) + { + stringBuilder.Append(preserveEnding ? "\r\n" : " "); + } + } + + private static CodeBlock[] RecommendationBlock(TokenStream stream) + { + List blocks = new List(); + + foreach (var token in stream.CaptureWhile(MarkdownTokenType.FencedBlock)) + { + var block = new CodeBlock(token.Text, token.Meta); + + blocks.Add(block); + } + + return blocks.ToArray(); + } + } +} diff --git a/src/PSRule/Parser/RuleModels.cs b/src/PSRule/Parser/RuleModels.cs new file mode 100644 index 0000000000..c0ea8c9991 --- /dev/null +++ b/src/PSRule/Parser/RuleModels.cs @@ -0,0 +1,80 @@ +using PSRule.Rules; + +namespace PSRule.Parser +{ + /// + /// YAML text content. + /// + internal sealed class Body + { + public Body(string text, SectionFormatOption formatOption = SectionFormatOption.None) + { + Text = text; + FormatOption = formatOption; + } + + /// + /// The text of the section body. + /// + public string Text { get; set; } + + /// + /// Additional options that determine how the section will be formated when rendering markdown. + /// + public SectionFormatOption FormatOption { get; set; } + + public override string ToString() + { + return Text; + } + } + + /// + /// YAML link. + /// + internal sealed class Link + { + public string Name; + + public string Uri; + } + + /// + /// YAML code block. + /// + internal sealed class CodeBlock + { + public CodeBlock(string text, string meta) + { + + } + } + + internal sealed class RuleRecommendation + { + public string Title { get; set; } + + public object FormatOption { get; set; } + + public string Introduction { get; set; } + + public CodeBlock[] Code { get; set; } + + public string Remarks { get; set; } + } + + internal sealed class RuleDocument + { + public string Name; + + public Body Synopsis; + + public Body Notes; + + public RuleRecommendation[] Recommendation; + + public Link[] Links; + + public TagSet Annotations; + } +} diff --git a/src/PSRule/Parser/TokenStream.cs b/src/PSRule/Parser/TokenStream.cs new file mode 100644 index 0000000000..c2593caf5e --- /dev/null +++ b/src/PSRule/Parser/TokenStream.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace PSRule.Parser +{ + internal static class TokenStreamExtensions + { + /// + /// Add a header. + /// + public static void Header(this TokenStream stream, int depth, string text, SourceExtent extent, bool lineBreak) + { + stream.Add(new MarkdownToken() + { + Depth = depth, + Extent = extent, + Text = text, + Type = MarkdownTokenType.Header, + Flag = lineBreak ? MarkdownTokenFlag.LineBreak : MarkdownTokenFlag.LineEnding | MarkdownTokenFlag.Preserve + }); + } + + public static void YamlKeyValue(this TokenStream stream, string key, string value) + { + stream.Add(new MarkdownToken() + { + Meta = key, + Text = value, + Type = MarkdownTokenType.YamlKeyValue + }); + } + + /// + /// Add a code fence. + /// + public static void FencedBlock(this TokenStream stream, string meta, string text, SourceExtent extent, bool lineBreak) + { + stream.Add(new MarkdownToken() { + Extent = extent, + Meta = meta, + Text = text, + Type = MarkdownTokenType.FencedBlock, + Flag = (lineBreak ? MarkdownTokenFlag.LineBreak : MarkdownTokenFlag.LineEnding) | MarkdownTokenFlag.Preserve + }); + } + + /// + /// Add a line break. + /// + public static void LineBreak(this TokenStream stream, int count) + { + // Ignore line break at the very start of file + if (stream.Count == 0) + { + return; + } + + for (var i = 0; i < count; i++) + { + stream.Add(new MarkdownToken() { Type = MarkdownTokenType.LineBreak, Flag = MarkdownTokenFlag.LineBreak }); + } + } + + public static void Text(this TokenStream stream, string text, MarkdownTokenFlag flag = MarkdownTokenFlag.None) + { + if (MergeText(stream.Current, text, flag)) + { + return; + } + + stream.Add(new MarkdownToken() { Type = MarkdownTokenType.Text, Text = text, Flag = flag }); + } + + private static bool MergeText(MarkdownToken current, string text, MarkdownTokenFlag flag) + { + // Only allow merge if the previous token was text + if (current == null || current.Type != MarkdownTokenType.Text) + { + return false; + } + + if (current.Flag.ShouldPreserve()) + { + return false; + } + + // If the previous token was text, lessen the break but still don't allow merging + if (current.Flag.HasFlag(MarkdownTokenFlag.LineBreak) && !current.Flag.ShouldPreserve()) + { + //current.Flag |= MarkdownTokenFlag.LineEnding | MarkdownTokenFlag.Preserve; + + //current.Flag -= MarkdownTokenFlag.LineBreak; + + return false; + } + + // Text must have the same flags set + if (current.Flag.HasFlag(MarkdownTokenFlag.Italic) != flag.HasFlag(MarkdownTokenFlag.Italic)) + { + return false; + } + + if (current.Flag.HasFlag(MarkdownTokenFlag.Bold) != flag.HasFlag(MarkdownTokenFlag.Bold)) + { + return false; + } + + if (current.Flag.HasFlag(MarkdownTokenFlag.Code) != flag.HasFlag(MarkdownTokenFlag.Code)) + { + return false; + } + + if (!current.Flag.IsEnding()) + { + current.Text = string.Concat(current.Text, text); + } + else if (current.Flag == MarkdownTokenFlag.LineEnding) + { + //current.Text = string.Concat(current.Text, " ", text); + + return false; + } + + // Take on the ending of the merged token + current.Flag = flag; + + return true; + } + + public static void Link(this TokenStream stream, string text, string uri) + { + stream.Add(new MarkdownToken() { Type = MarkdownTokenType.Link, Meta = text, Text = uri }); + } + + public static void LinkReference(this TokenStream stream, string text, string linkRef) + { + stream.Add(new MarkdownToken() { Type = MarkdownTokenType.LinkReference, Meta = text, Text = linkRef }); + } + + public static void LinkReferenceDefinition(this TokenStream stream, string text, string linkTarget) + { + stream.Add(new MarkdownToken() { Type = MarkdownTokenType.LinkReferenceDefinition, Meta = text, Text = linkTarget }); + } + + /// + /// Add a marker for the start of a paragraph. + /// + public static void ParagraphStart(this TokenStream stream) + { + stream.Add(new MarkdownToken() { Type = MarkdownTokenType.ParagraphStart }); + } + + /// + /// Add a marker for the end of a paragraph. + /// + public static void ParagraphEnd(this TokenStream stream) + { + if (stream.Count > 0) + { + if (stream.Current.Type == MarkdownTokenType.ParagraphStart) + { + stream.Pop(); + + return; + } + + stream.Add(new MarkdownToken() { Type = MarkdownTokenType.ParagraphEnd }); + } + } + + public static IEnumerable GetSection(this TokenStream stream, string header) + { + if (stream.Count == 0) + { + return Enumerable.Empty(); + } + + return stream + // Skip until we reach the header + .SkipWhile(token => token.Type != MarkdownTokenType.Header || token.Text != header) + + // Get all tokens to the next header + .Skip(1) + .TakeWhile(token => token.Type != MarkdownTokenType.Header); + } + + public static IEnumerable GetSections(this TokenStream stream) + { + if (stream.Count == 0) + { + return Enumerable.Empty(); + } + + return stream + // Skip until we reach the header + .SkipWhile(token => token.Type != MarkdownTokenType.Header) + + // Get all tokens to the next header + .Skip(1) + .TakeWhile(token => token.Type != MarkdownTokenType.Header); + } + } + + [DebuggerDisplay("Current = {Current?.Text}")] + internal sealed class TokenStream : IEnumerable + { + private readonly List _Token; + private readonly Dictionary _LinkTargetIndex; + + private int _Position; + + public TokenStream() + { + _Token = new List(); + _LinkTargetIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public TokenStream(IEnumerable tokens) + : this() + { + foreach (var token in tokens) + { + Add(token); + } + } + + #region Properties + + public bool EOF + { + get { return _Position >= _Token.Count; } + } + + public MarkdownToken Current + { + get + { + return (_Token.Count <= _Position) ? null : _Token[_Position]; + } + } + + public int Position + { + get { return _Position; } + } + + public int Count + { + get { return _Token.Count; } + } + + private int Remaining + { + get { return _Token.Count - Position; } + } + + #endregion Properties + + public bool IsTokenType(params MarkdownTokenType[] tokenType) + { + if (Current == null || tokenType == null) + { + return false; + } + + if (tokenType.Length == 1) + { + return tokenType[0] == Current.Type; + } + + return tokenType.Contains(Current.Type); + } + + public MarkdownTokenType PeakTokenType(int offset = 1) + { + var p = _Position + offset; + + if (p < 0 || p >= _Token.Count) + { + return MarkdownTokenType.None; + } + + return _Token[p].Type; + } + + public MarkdownToken Peak(int offset = 1) + { + var p = _Position + offset; + + if (p < 0 || p >= _Token.Count) + { + return null; + } + + return _Token[p]; + } + + public void SkipUntil(MarkdownTokenType tokenType) + { + while (!EOF && Current.Type != tokenType) + { + Next(); + } + } + + public IEnumerable CaptureUntil(MarkdownTokenType tokenType) + { + var start = Position; + var count = 0; + + while (!EOF && Current.Type != tokenType) + { + count++; + + Next(); + } + + return _Token.GetRange(start, count); + } + + public IEnumerable CaptureWhile(params MarkdownTokenType[] tokenType) + { + var start = Position; + var count = 0; + + while (!EOF && IsTokenType(tokenType)) + { + count++; + + Next(); + } + + return _Token.GetRange(start, count); + } + + public void Add(MarkdownToken token) + { + _Token.Add(token); + _Position = _Token.Count - 1; + + if (token.Type == MarkdownTokenType.LinkReferenceDefinition) + { + // CommonMark specifies that link labels are case-insensitive, and + // first reference definition takes prescidence when multiple definitions use the same link label + if (!_LinkTargetIndex.ContainsKey(token.Meta)) + { + _LinkTargetIndex[token.Meta] = token; + } + } + } + + public bool Next() + { + _Position++; + + return !EOF; + } + + public MarkdownToken Pop() + { + if (Count == 0) + { + return null; + } + + var token = _Token[_Position]; + + _Token.RemoveAt(_Position); + _Position = _Token.Count - 1; + + return token; + } + + public void MoveTo(int position) + { + _Position = position; + } + + public MarkdownToken ResolveLinkTarget(string name) + { + if (!_LinkTargetIndex.ContainsKey(name)) + { + return null; + } + + return _LinkTargetIndex[name]; + } + + public IEnumerator GetEnumerator() + { + return ((IEnumerable)_Token).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)_Token).GetEnumerator(); + } + } +} diff --git a/src/PSRule/Pipeline/InvokeRulePipeline.cs b/src/PSRule/Pipeline/InvokeRulePipeline.cs index 57d74a66cc..f97e53b6be 100644 --- a/src/PSRule/Pipeline/InvokeRulePipeline.cs +++ b/src/PSRule/Pipeline/InvokeRulePipeline.cs @@ -161,10 +161,12 @@ private void AddToSummary(RuleBlock ruleBlock, RuleOutcome outcome) { if (!_Summary.TryGetValue(ruleBlock.RuleId, out RuleSummaryRecord s)) { - s = new RuleSummaryRecord(ruleBlock.RuleId, ruleBlock.RuleName) - { - Tag = ruleBlock.Tag?.ToHashtable() - }; + s = new RuleSummaryRecord( + ruleId: ruleBlock.RuleId, + ruleName: ruleBlock.RuleName, + tag: ruleBlock.Tag, + annotations: ruleBlock.Annotations + ); _Summary.Add(ruleBlock.RuleId, s); } diff --git a/src/PSRule/Pipeline/PipelineContext.cs b/src/PSRule/Pipeline/PipelineContext.cs index 9177866741..c0571a2980 100644 --- a/src/PSRule/Pipeline/PipelineContext.cs +++ b/src/PSRule/Pipeline/PipelineContext.cs @@ -51,7 +51,7 @@ internal sealed class PipelineContext : IDisposable, IBindingContext internal PSObject TargetObject; internal RuleBlock RuleBlock; internal PSRuleOption Option; - internal string ModuleName; + internal RuleSource Source; public HashAlgorithm ObjectHashAlgorithm { @@ -421,6 +421,7 @@ public RuleRecord EnterRuleBlock(RuleBlock ruleBlock) targetName: TargetName, targetType: TargetType, tag: ruleBlock.Tag, + annotations: ruleBlock.Annotations, message: ruleBlock.Description ); diff --git a/src/PSRule/Resources/FormatResources.Designer.cs b/src/PSRule/Resources/FormatResources.Designer.cs new file mode 100644 index 0000000000..36ef9aecb6 --- /dev/null +++ b/src/PSRule/Resources/FormatResources.Designer.cs @@ -0,0 +1,108 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PSRule.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class FormatResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal FormatResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PSRule.Resources.FormatResources", typeof(FormatResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to RELATED LINKS. + /// + internal static string Links { + get { + return ResourceManager.GetString("Links", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to NAME. + /// + internal static string Name { + get { + return ResourceManager.GetString("Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to NOTES. + /// + internal static string Notes { + get { + return ResourceManager.GetString("Notes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to RECOMMENDATION. + /// + internal static string Recommendation { + get { + return ResourceManager.GetString("Recommendation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to SYNOPSIS. + /// + internal static string Synopsis { + get { + return ResourceManager.GetString("Synopsis", resourceCulture); + } + } + } +} diff --git a/src/PSRule/Resources/FormatResources.resx b/src/PSRule/Resources/FormatResources.resx new file mode 100644 index 0000000000..378ce1a053 --- /dev/null +++ b/src/PSRule/Resources/FormatResources.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + RELATED LINKS + + + NAME + + + NOTES + + + RECOMMENDATION + + + SYNOPSIS + + \ No newline at end of file diff --git a/src/PSRule/Resources/PSRuleResources.Designer.cs b/src/PSRule/Resources/PSRuleResources.Designer.cs index d81bdff36b..3754cd8105 100644 --- a/src/PSRule/Resources/PSRuleResources.Designer.cs +++ b/src/PSRule/Resources/PSRuleResources.Designer.cs @@ -114,6 +114,15 @@ internal static string RuleNotFound { } } + /// + /// Looks up a localized string similar to The script was not found.. + /// + internal static string ScriptNotFound { + get { + return ResourceManager.GetString("ScriptNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Can not serialize a null PSObject.. /// diff --git a/src/PSRule/Resources/PSRuleResources.resx b/src/PSRule/Resources/PSRuleResources.resx index c7fdef1b59..3415211fb6 100644 --- a/src/PSRule/Resources/PSRuleResources.resx +++ b/src/PSRule/Resources/PSRuleResources.resx @@ -136,6 +136,10 @@ Could not find a matching rule. Please check that Path, Name and Tag parameters are correct. + + The script was not found. + Occurs when the script rule file specified does not exist. + Can not serialize a null PSObject. diff --git a/src/PSRule/Rules/Rule.cs b/src/PSRule/Rules/Rule.cs index f1c4ebb334..f939586876 100644 --- a/src/PSRule/Rules/Rule.cs +++ b/src/PSRule/Rules/Rule.cs @@ -46,5 +46,9 @@ public sealed class Rule [JsonProperty(PropertyName = "tag")] [DefaultValue(null)] public TagSet Tag { get; set; } + + [JsonProperty(PropertyName = "annotations")] + [DefaultValue(null)] + public TagSet Annotations { get; set; } } } diff --git a/src/PSRule/Rules/RuleBlock.cs b/src/PSRule/Rules/RuleBlock.cs index 9b42e00a79..043183f1df 100644 --- a/src/PSRule/Rules/RuleBlock.cs +++ b/src/PSRule/Rules/RuleBlock.cs @@ -17,7 +17,7 @@ namespace PSRule.Rules [DebuggerDisplay("{RuleId} @{SourcePath}")] public sealed class RuleBlock : ILanguageBlock, IDependencyTarget, IDisposable { - public RuleBlock(string sourcePath, string moduleName, string ruleName, string description, PowerShell condition, TagSet tag, string[] dependsOn, Hashtable configuration) + internal RuleBlock(string sourcePath, string moduleName, string ruleName, string description, string recommendation, PowerShell condition, TagSet tag, TagSet annotations, string[] dependsOn, Hashtable configuration) { SourcePath = sourcePath; ModuleName = moduleName; @@ -30,8 +30,10 @@ public RuleBlock(string sourcePath, string moduleName, string ruleName, string d string.Concat(scriptFileName, '/', RuleName) : string.Concat(ModuleName, '/', scriptFileName, '/', RuleName); Description = description; + Recommendation = recommendation; Condition = condition; Tag = tag; + Annotations = annotations; DependsOn = dependsOn; Configuration = configuration; } @@ -61,6 +63,11 @@ public RuleBlock(string sourcePath, string moduleName, string ruleName, string d /// public readonly string Description; + /// + /// A human readable block of text that identifies the recommendation to address the rule when failed. + /// + public readonly string Recommendation; + /// /// The body of the rule definition where conditions are provided that either pass or fail the rule. /// @@ -76,6 +83,8 @@ public RuleBlock(string sourcePath, string moduleName, string ruleName, string d /// public readonly TagSet Tag; + public readonly TagSet Annotations; + /// /// Configuration defaults for the rule definition. /// @@ -84,6 +93,10 @@ public RuleBlock(string sourcePath, string moduleName, string ruleName, string d /// public readonly Hashtable Configuration; + //string ILanguageBlock.Name => RuleName; + + //string ILanguageBlock.Synopsis => Description; + string ILanguageBlock.SourcePath => SourcePath; string ILanguageBlock.Module => ModuleName; diff --git a/src/PSRule/Rules/RuleHelpInfo.cs b/src/PSRule/Rules/RuleHelpInfo.cs new file mode 100644 index 0000000000..68fcd4d87c --- /dev/null +++ b/src/PSRule/Rules/RuleHelpInfo.cs @@ -0,0 +1,28 @@ +namespace PSRule.Rules +{ + /// + /// Output view helper class for rule help. + /// + public sealed class RuleHelpInfo + { + /// + /// The name of the rule. + /// + public string Name { get; set; } + + /// + /// The synopsis of the rule. + /// + public string Synopsis { get; set; } + + /// + /// The recommendation for the rule. + /// + public string Recommendation { get; set; } + + /// + /// Additional notes for the rule. + /// + public string Notes { get; set; } + } +} diff --git a/src/PSRule/Rules/RuleRecord.cs b/src/PSRule/Rules/RuleRecord.cs index b57fc33b99..e13475b4d3 100644 --- a/src/PSRule/Rules/RuleRecord.cs +++ b/src/PSRule/Rules/RuleRecord.cs @@ -14,7 +14,7 @@ namespace PSRule.Rules [JsonObject] public sealed class RuleRecord { - internal RuleRecord(string ruleId, string ruleName, PSObject targetObject, string targetName, string targetType, TagSet tag, RuleOutcome outcome = RuleOutcome.None, RuleOutcomeReason reason = RuleOutcomeReason.None, string message = null) + internal RuleRecord(string ruleId, string ruleName, PSObject targetObject, string targetName, string targetType, TagSet tag, TagSet annotations, RuleOutcome outcome = RuleOutcome.None, RuleOutcomeReason reason = RuleOutcomeReason.None, string message = null) { RuleId = ruleId; RuleName = ruleName; @@ -27,6 +27,11 @@ internal RuleRecord(string ruleId, string ruleName, PSObject targetObject, strin Tag = tag.ToHashtable(); } + if (annotations != null) + { + Annotations = annotations.ToHashtable(); + } + Outcome = outcome; OutcomeReason = reason; Message = message; @@ -75,7 +80,11 @@ internal RuleRecord(string ruleId, string ruleName, PSObject targetObject, strin [DefaultValue(null)] [JsonProperty(PropertyName = "tag")] - public Hashtable Tag { get; internal set; } + public Hashtable Tag { get; } + + [DefaultValue(null)] + [JsonProperty(PropertyName = "annotations")] + public Hashtable Annotations { get; } [DefaultValue(0f)] [JsonProperty(PropertyName = "time")] diff --git a/src/PSRule/Rules/RuleSource.cs b/src/PSRule/Rules/RuleSource.cs index 9b5dffaa1d..7336760ea7 100644 --- a/src/PSRule/Rules/RuleSource.cs +++ b/src/PSRule/Rules/RuleSource.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; +using System.Collections.Generic; namespace PSRule.Rules { @@ -11,12 +9,14 @@ public sealed class RuleSource { public readonly string Path; public readonly string ModuleName; + public readonly string[] HelpPath; - public RuleSource(string path, string moduleName) + public RuleSource(string path, string moduleName, string[] helpPath = null) { Path = path; ModuleName = moduleName; - } + HelpPath = helpPath; + } } /// @@ -31,17 +31,24 @@ public RuleSourceBuilder() _Source = new List(); } - public void Add(string[] path, string moduleName) + public void Add(string path, string moduleName, string helpPath) { if (path == null || path.Length == 0) { return; } - for (var i = 0; i < path.Length; i++) + _Source.Add(new RuleSource(path, moduleName, new string[] { helpPath })); + } + + public void Add(string path, string helpPath) + { + if (path == null) { - _Source.Add(new RuleSource(path[i], moduleName)); + return; } + + _Source.Add(new RuleSource(path, null, new string[] { helpPath })); } public RuleSource[] Build() diff --git a/src/PSRule/Rules/RuleSummaryRecord.cs b/src/PSRule/Rules/RuleSummaryRecord.cs index 31a791269b..5c015103b4 100644 --- a/src/PSRule/Rules/RuleSummaryRecord.cs +++ b/src/PSRule/Rules/RuleSummaryRecord.cs @@ -11,10 +11,12 @@ namespace PSRule.Rules [DebuggerDisplay("{RuleId}, Outcome = {Outcome}")] public sealed class RuleSummaryRecord { - internal RuleSummaryRecord(string ruleId, string ruleName) + internal RuleSummaryRecord(string ruleId, string ruleName, TagSet tag, TagSet annotations) { RuleId = ruleId; RuleName = ruleName; + Tag = tag?.ToHashtable(); + Annotations = annotations?.ToHashtable(); } /// @@ -68,8 +70,13 @@ public RuleOutcome Outcome } [DefaultValue(null)] + [JsonProperty(PropertyName = "tag")] public Hashtable Tag { get; internal set; } + [DefaultValue(null)] + [JsonProperty(PropertyName = "annotations")] + public Hashtable Annotations { get; internal set; } + public bool IsSuccess() { return Outcome == RuleOutcome.Pass || Outcome == RuleOutcome.None; diff --git a/src/PSRule/Rules/TagSet.cs b/src/PSRule/Rules/TagSet.cs index fdab686a48..c98f3f6074 100644 --- a/src/PSRule/Rules/TagSet.cs +++ b/src/PSRule/Rules/TagSet.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Dynamic; @@ -24,6 +24,8 @@ private TagSet(Dictionary tag) public int Count => _Tag.Count; + public string this[string key] => _Tag[key]; + public bool Contains(object key, object value) { var k = key.ToString(); @@ -58,6 +60,16 @@ public static TagSet FromHashtable(Hashtable hashtable) return new TagSet(dictionary); } + internal static TagSet FromDictionary(Dictionary dictionary) + { + if (dictionary == null) + { + return null; + } + + return new TagSet(dictionary); + } + public Hashtable ToHashtable() { return new Hashtable(_Tag, StringComparer.OrdinalIgnoreCase); diff --git a/tests/PSRule.Tests/PSRule.Common.Tests.ps1 b/tests/PSRule.Tests/PSRule.Common.Tests.ps1 index 33ad81a51b..8f38aca97a 100644 --- a/tests/PSRule.Tests/PSRule.Common.Tests.ps1 +++ b/tests/PSRule.Tests/PSRule.Common.Tests.ps1 @@ -42,6 +42,7 @@ Describe 'Invoke-PSRule' -Tag 'Invoke-PSRule','Common' { $result | Should -Not -BeNullOrEmpty; $result.IsSuccess() | Should -Be $True; $result.TargetName | Should -Be 'TestObject1'; + $result.Annotations.severity | Should -Be 'Critical'; } It 'Returns failure' { @@ -224,6 +225,7 @@ Describe 'Invoke-PSRule' -Tag 'Invoke-PSRule','Common' { $result.Tag.category | Should -BeIn 'group1'; ($result | Where-Object { $_.RuleName -eq 'FromFile1'}).Outcome | Should -Be 'Pass'; ($result | Where-Object { $_.RuleName -eq 'FromFile1'}).Pass | Should -Be 2; + ($result | Where-Object { $_.RuleName -eq 'FromFile1'}).Annotations.severity | Should -Be 'Critical'; ($result | Where-Object { $_.RuleName -eq 'FromFile2'}).Outcome | Should -Be 'Fail'; ($result | Where-Object { $_.RuleName -eq 'FromFile2'}).Fail | Should -Be 2; ($result | Where-Object { $_.RuleName -eq 'FromFile4'}).Outcome | Should -Be 'None'; @@ -808,7 +810,13 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { $result = Get-PSRule -Path $ruleFilePath -Name 'FromFile1'; $result | Should -Not -BeNullOrEmpty; $result.RuleName | Should -Be 'FromFile1'; - $result.Description | Should -Be 'Test rule 1'; + $result.Description | Should -Be 'This is a synopsis.'; + $result.Annotations.severity | Should -Be 'Critical'; + + $result = Get-PSRule -Path $ruleFilePath -Name 'FromFile2'; + $result | Should -Not -BeNullOrEmpty; + $result.RuleName | Should -Be 'FromFile2'; + $result.Description | Should -Be 'Test rule 2'; } It 'Handles empty path' { diff --git a/tests/PSRule.Tests/PSRule.Tests.csproj b/tests/PSRule.Tests/PSRule.Tests.csproj index 8bafb9c144..ad8e6a3479 100644 --- a/tests/PSRule.Tests/PSRule.Tests.csproj +++ b/tests/PSRule.Tests/PSRule.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp2.1 @@ -36,6 +36,9 @@ PreserveNewest + + PreserveNewest + diff --git a/tests/PSRule.Tests/RuleDocument.md b/tests/PSRule.Tests/RuleDocument.md new file mode 100644 index 0000000000..296fc18189 --- /dev/null +++ b/tests/PSRule.Tests/RuleDocument.md @@ -0,0 +1,18 @@ +--- +category: Pod security +severity: Critical +--- + +# Kubernetes.Deployment.NotLatestImage + +## SYNOPSIS + +Containers should use specific tags instead of latest. + +## RECOMMENDATION + +Deployments or pods should identify a specific tag to use for container images instead of `latest`. When `latest` is used it may be hard to determine which version of the image is running. + +When using variable tags such as v1.0 (which may refer to v1.0.0 or v1.0.1) consider using `imagePullPolicy: Always` to ensure that the an out-of-date cached image is not used. + +The `latest` tag automatically uses `imagePullPolicy: Always` instead of the default `imagePullPolicy: IfNotPresent`. diff --git a/tests/PSRule.Tests/RuleDocumentTests.cs b/tests/PSRule.Tests/RuleDocumentTests.cs new file mode 100644 index 0000000000..c4a92285f0 --- /dev/null +++ b/tests/PSRule.Tests/RuleDocumentTests.cs @@ -0,0 +1,45 @@ +using PSRule.Parser; +using System; +using System.IO; +using Xunit; + +namespace PSRule +{ + public sealed class RuleDocumentTests + { + [Fact] + public void ReadDocument() + { + var document = GetDocument(); + + Assert.Equal("Kubernetes.Deployment.NotLatestImage", document.Name); + Assert.Equal("Containers should use specific tags instead of latest.", document.Synopsis.Text); + Assert.Single(document.Recommendation); + Assert.Equal(@"Deployments or pods should identify a specific tag to use for container images instead of latest. When latest is used it may be hard to determine which version of the image is running. +When using variable tags such as v1.0 (which may refer to v1.0.0 or v1.0.1) consider using imagePullPolicy: Always to ensure that the an out-of-date cached image is not used. +The latest tag automatically uses imagePullPolicy: Always instead of the default imagePullPolicy: IfNotPresent." + , document.Recommendation[0].Introduction); + Assert.Equal("Critical", document.Annotations["severity"]); + Assert.Equal("Pod security", document.Annotations["category"]); + } + + private RuleDocument GetDocument() + { + var tokens = GetToken(); + var lexer = new RuleLexer(preserveFomatting: false); + return lexer.Process(stream: tokens); + } + + private TokenStream GetToken() + { + var reader = new MarkdownReader(yamlHeaderOnly: false); + return reader.Read(GetMarkdownContent(), Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "RuleDocument.md")); + } + + private string GetMarkdownContent() + { + var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "RuleDocument.md"); + return File.ReadAllText(path); + } + } +} diff --git a/tests/PSRule.Tests/en-AU/FromFile1.md b/tests/PSRule.Tests/en-AU/FromFile1.md new file mode 100644 index 0000000000..14148a04f6 --- /dev/null +++ b/tests/PSRule.Tests/en-AU/FromFile1.md @@ -0,0 +1,13 @@ +--- +severity: Critical +--- + +# FromFile + +## Synopsis + +This is a synopsis. + +## Recommendation + +This is a recommendation. From 1c471c1e898ab487bfb148bf72248f6c52acb286 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Tue, 28 May 2019 21:42:20 +1000 Subject: [PATCH 02/26] Add Get-PSRuleHelp and online link #19 #147 #157 --- src/PSRule/Host/HostHelper.cs | 33 +++++ src/PSRule/PSRule.Format.ps1xml | 121 +++++++++++++-- src/PSRule/PSRule.psd1 | 1 + src/PSRule/PSRule.psm1 | 138 +++++++++++++++++- src/PSRule/Pipeline/GetRuleHelpPipeline.cs | 20 +++ .../Pipeline/GetRuleHelpPipelineBuilder.cs | 70 +++++++++ src/PSRule/Pipeline/PipelineBuilder.cs | 5 + .../Resources/FormatResources.Designer.cs | 63 ++++++++ src/PSRule/Resources/FormatResources.resx | 21 +++ src/PSRule/Rules/RuleHelpInfo.cs | 31 +++- 10 files changed, 485 insertions(+), 18 deletions(-) create mode 100644 src/PSRule/Pipeline/GetRuleHelpPipeline.cs create mode 100644 src/PSRule/Pipeline/GetRuleHelpPipelineBuilder.cs diff --git a/src/PSRule/Host/HostHelper.cs b/src/PSRule/Host/HostHelper.cs index 4628bb292f..f6dc945b6f 100644 --- a/src/PSRule/Host/HostHelper.cs +++ b/src/PSRule/Host/HostHelper.cs @@ -20,6 +20,11 @@ public static IEnumerable GetRule(RuleSource[] source, RuleFilter filter) return ToRule(GetLanguageBlock(sources: source), filter); } + public static RuleHelpInfo GetRuleHelp(RuleSource[] source, RuleFilter filter) + { + return ToRuleHelp(GetLanguageBlock(sources: source), filter); + } + public static DependencyGraph GetRuleBlockGraph(RuleSource[] source, RuleFilter filter) { var builder = new DependencyGraphBuilder(); @@ -206,5 +211,33 @@ private static Rule[] ToRule(IEnumerable blocks, RuleFilter filt return results.Values.ToArray(); } + + private static RuleHelpInfo ToRuleHelp(IEnumerable blocks, RuleFilter filter) + { + // Index deployments by environment/name + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var block in blocks.OfType()) + { + // Ignore deployment blocks that don't match + if (filter != null && !filter.Match(block)) + { + continue; + } + + if (!results.ContainsKey(block.RuleId)) + { + results[block.RuleId] = new RuleHelpInfo + { + Name = block.RuleName, + Synopsis = block.Description, + Recommendation = block.Recommendation, + Annotations = block.Annotations.ToHashtable() + }; + } + } + + return results.Values.FirstOrDefault(); + } } } diff --git a/src/PSRule/PSRule.Format.ps1xml b/src/PSRule/PSRule.Format.ps1xml index d6ba527ddc..5089ce964f 100644 --- a/src/PSRule/PSRule.Format.ps1xml +++ b/src/PSRule/PSRule.Format.ps1xml @@ -1,5 +1,73 @@ + + + Help-Name + + + + + + + + 4 + + + NAME + + + + + + + + + + + Help-Synopsis + + + + + + + + 4 + + + Synopsis + + + + + + + + + + + Help-Recommendation + + + + + + + + 4 + + + Recommendation + + + + + + + + + + PSRule.Rules.Rule @@ -9,14 +77,14 @@ - + - + - + @@ -47,15 +115,15 @@ - + - + - + @@ -83,19 +151,19 @@ - + - + - + - + @@ -118,5 +186,38 @@ + + PSRule.Rules.RuleHelpInfo + + PSRule.Rules.RuleHelpInfo + + + + + + + Help-Name + + + + + + Help-Synopsis + + + + + + Help-Recommendation + + + + + + + + + + diff --git a/src/PSRule/PSRule.psd1 b/src/PSRule/PSRule.psd1 index 4c6d71e5ce..3616807c15 100644 --- a/src/PSRule/PSRule.psd1 +++ b/src/PSRule/PSRule.psd1 @@ -76,6 +76,7 @@ FunctionsToExport = @( 'Invoke-PSRule' 'Test-PSRuleTarget' 'Get-PSRule' + 'Get-PSRuleHelp' 'New-PSRuleOption' 'Set-PSRuleOption' 'AllOf' diff --git a/src/PSRule/PSRule.psm1 b/src/PSRule/PSRule.psm1 index 39bc17863a..d107a0c286 100644 --- a/src/PSRule/PSRule.psm1 +++ b/src/PSRule/PSRule.psm1 @@ -436,6 +436,119 @@ function Get-PSRule { } } +# .ExternalHelp PSRule-Help.xml +function Get-PSRuleHelp { + [CmdletBinding()] + [OutputType([PSRule.Rules.RuleHelpInfo])] + param ( + # A list of paths to check for rule definitions + [Parameter(Position = 0, Mandatory = $False)] + [Alias('p')] + [String]$Path = $PWD, + + # Filter to rules with the following names + [Parameter(Mandatory = $True)] + [Alias('n')] + [String]$Name, + + [Parameter(Mandatory = $False)] + [String]$Module, + + [Parameter(Mandatory = $False)] + [String]$Culture, + + [Parameter(Mandatory = $False)] + [Switch]$Online = $False + ) + + begin { + Write-Verbose -Message "[Get-PSRuleHelp]::BEGIN"; + + # Get parameter options, which will override options from other sources + $optionParams = @{ }; + + if ($PSBoundParameters.ContainsKey('Option')) { + $optionParams['Option'] = $Option; + } + + # Get an options object + $Option = New-PSRuleOption @optionParams; + + # Discover scripts in the specified paths + $sourceParams = @{ }; + + if ($PSBoundParameters.ContainsKey('Path')) { + $sourceParams['Path'] = $Path; + } + if ($PSBoundParameters.ContainsKey('Module')) { + $sourceParams['Module'] = $Module; + } + if ($sourceParams.Count -eq 0) { + $sourceParams['Path'] = $Path; + } + if ($PSBoundParameters.ContainsKey('Culture')) { + $sourceParams['Culture'] = $Culture; + } + [PSRule.Rules.RuleSource[]]$sourceFiles = GetRuleScriptPath @sourceParams -Verbose:$VerbosePreference; + + # Check that some matching script files were found + if ($Null -eq $sourceFiles) { + Write-Verbose -Message "[Get-PSRuleHelp] -- Could not find any .Rule.ps1 script files in the path"; + return; # continue causes issues with Pester + } + + Write-Verbose -Message "[Get-PSRuleHelp] -- Found $($sourceFiles.Length) script(s)"; + + $isDeviceGuard = IsDeviceGuardEnabled; + + # If DeviceGuard is enabled, force a contrained execution environment + if ($isDeviceGuard) { + $Option.Execution.LanguageMode = [PSRule.Configuration.LanguageMode]::ConstrainedLanguage; + } + + if ($PSBoundParameters.ContainsKey('Name')) { + $Option.Baseline.RuleName = $Name; + } + + $builder = [PSRule.Pipeline.PipelineBuilder]::GetHelp().Configure($Option); + $builder.Source($sourceFiles); + $builder.UseCommandRuntime($PSCmdlet.CommandRuntime); + $builder.UseLoggingPreferences($ErrorActionPreference, $WarningPreference, $VerbosePreference, $InformationPreference); + $pipeline = $builder.Build(); + } + + process { + if ($Null -ne (Get-Variable -Name pipeline -ErrorAction SilentlyContinue)) { + try { + # Get matching rule help + $result = $pipeline.Process(); + + if ($Null -ne $result -and $Online) { + $launchUri = $result.GetOnlineHelpUri(); + + if ($Null -ne $launchUri) { + LaunchOnlineHelp -Uri $launchUri -Verbose:$VerbosePreference; + } + } + else { + $result; + } + } + catch { + $pipeline.Dispose(); + throw; + } + } + } + + end { + if ($Null -ne (Get-Variable -Name pipeline -ErrorAction SilentlyContinue)) { + $pipeline.Dispose(); + } + Write-Verbose -Message "[Get-PSRuleHelp]::END"; + } +} + # .ExternalHelp PSRule-Help.xml function New-PSRuleOption { [CmdletBinding()] @@ -1180,12 +1293,9 @@ function YamlContainsComments { } function IsDeviceGuardEnabled { - [CmdletBinding()] [OutputType([System.Boolean])] - param ( - - ) + param () process { @@ -1202,13 +1312,26 @@ function IsDeviceGuardEnabled { } } -function InitEditorServices { - +function LaunchOnlineHelp { [CmdletBinding()] + [OutputType([void])] param ( - + [Parameter(Mandatory = $True)] + [System.Uri]$Uri ) + process { + $launchProcess = New-Object -TypeName System.Diagnostics.Process; + $launchProcess.StartInfo.FileName = $Uri.OriginalString; + $launchProcess.StartInfo.UseShellExecute = $True; + $Null = $launchProcess.Start(); + } +} + +function InitEditorServices { + [CmdletBinding()] + param () + process { if ($Null -ne (Get-Variable -Name psEditor -ErrorAction Ignore)) { # Export keywords @@ -1255,6 +1378,7 @@ Export-ModuleMember -Function @( 'Invoke-PSRule' 'Test-PSRuleTarget' 'Get-PSRule' + 'Get-PSRuleHelp' 'New-PSRuleOption' 'Set-PSRuleOption' ) diff --git a/src/PSRule/Pipeline/GetRuleHelpPipeline.cs b/src/PSRule/Pipeline/GetRuleHelpPipeline.cs new file mode 100644 index 0000000000..1ea08b3d83 --- /dev/null +++ b/src/PSRule/Pipeline/GetRuleHelpPipeline.cs @@ -0,0 +1,20 @@ +using PSRule.Configuration; +using PSRule.Host; +using PSRule.Rules; + +namespace PSRule.Pipeline +{ + public sealed class GetRuleHelpPipeline : RulePipeline + { + internal GetRuleHelpPipeline(PSRuleOption option, RuleSource[] source, RuleFilter filter, PipelineContext context) + : base(context, option, source, filter) + { + // Do nothing + } + + public RuleHelpInfo Process() + { + return HostHelper.GetRuleHelp(source: _Source, filter: _Filter); + } + } +} diff --git a/src/PSRule/Pipeline/GetRuleHelpPipelineBuilder.cs b/src/PSRule/Pipeline/GetRuleHelpPipelineBuilder.cs new file mode 100644 index 0000000000..92275411db --- /dev/null +++ b/src/PSRule/Pipeline/GetRuleHelpPipelineBuilder.cs @@ -0,0 +1,70 @@ +using PSRule.Configuration; +using PSRule.Rules; +using System.Management.Automation; + +namespace PSRule.Pipeline +{ + public sealed class GetRuleHelpPipelineBuilder + { + private readonly PSRuleOption _Option; + private readonly PipelineLogger _Logger; + + private RuleSource[] _Source; + private bool _LogError; + private bool _LogWarning; + private bool _LogVerbose; + private bool _LogInformation; + + internal GetRuleHelpPipelineBuilder() + { + _Logger = new PipelineLogger(); + _Option = new PSRuleOption(); + } + + public void Source(RuleSource[] source) + { + _Source = source; + } + + public GetRuleHelpPipelineBuilder Configure(PSRuleOption option) + { + if (option == null) + { + return this; + } + + _Option.Execution.LanguageMode = option.Execution.LanguageMode ?? ExecutionOption.Default.LanguageMode; + + if (option.Baseline != null) + { + _Option.Baseline.RuleName = option.Baseline.RuleName; + _Option.Baseline.Exclude = option.Baseline.Exclude; + } + + return this; + } + + public void UseCommandRuntime(ICommandRuntime2 commandRuntime) + { + _Logger.OnWriteVerbose = commandRuntime.WriteVerbose; + _Logger.OnWriteWarning = commandRuntime.WriteWarning; + _Logger.OnWriteError = commandRuntime.WriteError; + _Logger.OnWriteInformation = commandRuntime.WriteInformation; + } + + public void UseLoggingPreferences(ActionPreference error, ActionPreference warning, ActionPreference verbose, ActionPreference information) + { + _LogError = (error != ActionPreference.Ignore); + _LogWarning = (warning != ActionPreference.Ignore); + _LogVerbose = !(verbose == ActionPreference.Ignore || verbose == ActionPreference.SilentlyContinue); + _LogInformation = !(information == ActionPreference.Ignore || information == ActionPreference.SilentlyContinue); + } + + public GetRuleHelpPipeline Build() + { + var filter = new RuleFilter(ruleName: _Option.Baseline.RuleName, tag: null, exclude: _Option.Baseline.Exclude); + var context = PipelineContext.New(logger: _Logger, option: _Option, bindTargetName: null, bindTargetType: null, logError: _LogError, logWarning: _LogWarning, logVerbose: _LogVerbose, logInformation: _LogInformation); + return new GetRuleHelpPipeline(option: _Option, source: _Source, filter: filter, context: context); + } + } +} diff --git a/src/PSRule/Pipeline/PipelineBuilder.cs b/src/PSRule/Pipeline/PipelineBuilder.cs index 8906dffd3f..761995f3b5 100644 --- a/src/PSRule/Pipeline/PipelineBuilder.cs +++ b/src/PSRule/Pipeline/PipelineBuilder.cs @@ -11,5 +11,10 @@ public static GetRulePipelineBuilder Get() { return new GetRulePipelineBuilder(); } + + public static GetRuleHelpPipelineBuilder GetHelp() + { + return new GetRuleHelpPipelineBuilder(); + } } } diff --git a/src/PSRule/Resources/FormatResources.Designer.cs b/src/PSRule/Resources/FormatResources.Designer.cs index 36ef9aecb6..d08fda95dc 100644 --- a/src/PSRule/Resources/FormatResources.Designer.cs +++ b/src/PSRule/Resources/FormatResources.Designer.cs @@ -60,6 +60,24 @@ internal FormatResources() { } } + /// + /// Looks up a localized string similar to Description. + /// + internal static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fail. + /// + internal static string Fail { + get { + return ResourceManager.GetString("Fail", resourceCulture); + } + } + /// /// Looks up a localized string similar to RELATED LINKS. /// @@ -69,6 +87,24 @@ internal static string Links { } } + /// + /// Looks up a localized string similar to Message. + /// + internal static string Message { + get { + return ResourceManager.GetString("Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ModuleName. + /// + internal static string ModuleName { + get { + return ResourceManager.GetString("ModuleName", resourceCulture); + } + } + /// /// Looks up a localized string similar to NAME. /// @@ -87,6 +123,24 @@ internal static string Notes { } } + /// + /// Looks up a localized string similar to Outcome. + /// + internal static string Outcome { + get { + return ResourceManager.GetString("Outcome", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pass. + /// + internal static string Pass { + get { + return ResourceManager.GetString("Pass", resourceCulture); + } + } + /// /// Looks up a localized string similar to RECOMMENDATION. /// @@ -96,6 +150,15 @@ internal static string Recommendation { } } + /// + /// Looks up a localized string similar to RuleName. + /// + internal static string RuleName { + get { + return ResourceManager.GetString("RuleName", resourceCulture); + } + } + /// /// Looks up a localized string similar to SYNOPSIS. /// diff --git a/src/PSRule/Resources/FormatResources.resx b/src/PSRule/Resources/FormatResources.resx index 378ce1a053..8e451edec7 100644 --- a/src/PSRule/Resources/FormatResources.resx +++ b/src/PSRule/Resources/FormatResources.resx @@ -117,18 +117,39 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Description + + + Fail + RELATED LINKS + + Message + + + ModuleName + NAME NOTES + + Outcome + + + Pass + RECOMMENDATION + + RuleName + SYNOPSIS diff --git a/src/PSRule/Rules/RuleHelpInfo.cs b/src/PSRule/Rules/RuleHelpInfo.cs index 68fcd4d87c..c9ab83e010 100644 --- a/src/PSRule/Rules/RuleHelpInfo.cs +++ b/src/PSRule/Rules/RuleHelpInfo.cs @@ -1,10 +1,15 @@ -namespace PSRule.Rules +using System; +using System.Collections; + +namespace PSRule.Rules { /// /// Output view helper class for rule help. /// public sealed class RuleHelpInfo { + private const string ONLINE_HELP_LINK_ANNOTATION = "online version"; + /// /// The name of the rule. /// @@ -24,5 +29,29 @@ public sealed class RuleHelpInfo /// Additional notes for the rule. /// public string Notes { get; set; } + + /// + /// Metadata annotations for the rule. + /// + public Hashtable Annotations { get; set; } + + /// + /// Get the URI for the online version of the documentation. + /// + /// A URI when a valid link is set. Otherwise null is returned. + public Uri GetOnlineHelpUri() + { + if (Annotations == null || !Annotations.ContainsKey(ONLINE_HELP_LINK_ANNOTATION)) + { + return null; + } + + if (Uri.TryCreate(Annotations[ONLINE_HELP_LINK_ANNOTATION].ToString(), UriKind.Absolute, out Uri result)) + { + return result; + } + + return null; + } } } From 87f5e73c9b018348b509b82474ad07541f83645d Mon Sep 17 00:00:00 2001 From: Bernie White Date: Tue, 28 May 2019 21:57:07 +1000 Subject: [PATCH 03/26] Update change log --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 592b0ac5f0..560383c13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ ## Unreleased +- Added rule documentation, which allows additional rule information to be stored in markdown files. [#157](https://github.com/BernieWhite/PSRule/issues/157) + - Rule documentation also adds culture support. [#18](https://github.com/BernieWhite/PSRule/issues/18) +- Added annotations, which are non-indexed metadata stored in rule documentation. [#148](https://github.com/BernieWhite/PSRule/issues/148) + - Annotations can contain a link to online version of the documentation. [#147](https://github.com/BernieWhite/PSRule/issues/147) + ## v0.6.0-B190514 (pre-release) - Fix operation is not supported on this platform failure. [#152](https://github.com/BernieWhite/PSRule/issues/152) From f2b831a74c87689e3f747e34019c3e9c9129c8f0 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Wed, 29 May 2019 22:36:36 +1000 Subject: [PATCH 04/26] Improve usae of Get-PSRuleHelp --- README.md | 1 + docs/commands/PSRule/en-US/Get-PSRuleHelp.md | 133 +++++++++++++++++ src/PSRule/Host/HostHelper.cs | 8 +- src/PSRule/PSRule.Format.ps1xml | 62 ++++++-- src/PSRule/PSRule.csproj | 17 ++- src/PSRule/PSRule.psm1 | 74 ++++++---- src/PSRule/Parser/RuleLexer.cs | 8 +- src/PSRule/Pipeline/GetRuleHelpPipeline.cs | 3 +- .../Pipeline/GetRuleHelpPipelineBuilder.cs | 2 +- .../Resources/DocumentStrings.Designer.cs | 108 ++++++++++++++ src/PSRule/Resources/DocumentStrings.resx | 135 ++++++++++++++++++ ...es.Designer.cs => ViewStrings.Designer.cs} | 37 +---- ...{FormatResources.resx => ViewStrings.resx} | 13 +- src/PSRule/Rules/RuleFilter.cs | 32 ++++- tests/PSRule.Tests/PSRule.Common.Tests.ps1 | 64 ++++++++- .../PSRule.Tests/TestModule/en-AU/M1.Rule1.md | 13 ++ .../PSRule.Tests/TestModule/en-US/M1.Rule1.md | 13 ++ .../TestModule/rules/Test.Rule.ps1 | 3 +- .../{en-AU => en-ZZ}/FromFile1.md | 2 +- 19 files changed, 620 insertions(+), 108 deletions(-) create mode 100644 docs/commands/PSRule/en-US/Get-PSRuleHelp.md create mode 100644 src/PSRule/Resources/DocumentStrings.Designer.cs create mode 100644 src/PSRule/Resources/DocumentStrings.resx rename src/PSRule/Resources/{FormatResources.Designer.cs => ViewStrings.Designer.cs} (82%) rename src/PSRule/Resources/{FormatResources.resx => ViewStrings.resx} (95%) create mode 100644 tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md create mode 100644 tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md rename tests/PSRule.Tests/{en-AU => en-ZZ}/FromFile1.md (84%) diff --git a/README.md b/README.md index 44cd176896..d313f2d7e9 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ The following language keywords are used by the `PSRule` module: The following commands exist in the `PSRule` module: - [Get-PSRule](docs/commands/PSRule/en-US/Get-PSRule.md) - Get a list of rule definitions. +- [Get-PSRuleHelp](docs/commands/PSRule/en-US/Get-PSRuleHelp) - Get documentation for a rule. - [Invoke-PSRule](docs/commands/PSRule/en-US/Invoke-PSRule.md) - Evaluate objects against matching rules. - [New-PSRuleOption](docs/commands/PSRule/en-US/New-PSRuleOption.md) - Create options to configure PSRule execution. - [Set-PSRuleOption](docs/commands/PSRule/en-US/Set-PSRuleOption.md) - Sets options that configure PSRule execution. diff --git a/docs/commands/PSRule/en-US/Get-PSRuleHelp.md b/docs/commands/PSRule/en-US/Get-PSRuleHelp.md new file mode 100644 index 0000000000..f9572c4a09 --- /dev/null +++ b/docs/commands/PSRule/en-US/Get-PSRuleHelp.md @@ -0,0 +1,133 @@ +--- +external help file: PSRule-help.xml +Module Name: PSRule +online version: https://berniewhite.github.io/PSRule/commands/PSRule/en-US/Get-PSRuleHelp.html +schema: 2.0.0 +--- + +# Get-PSRuleHelp + +## SYNOPSIS + +Get documentation for a rule. + +## SYNTAX + +```text +Get-PSRuleHelp [[-Path] ] -Name [-Module ] [-Culture ] [-Online] + [] +``` + +## DESCRIPTION + +Get documentation for a rule. + +## EXAMPLES + +### Example 1 + +```powershell +PS C:\> Get-PSRuleHelp Azure.ACR.AdminUser +``` + +{{ Add example description here }} + +## PARAMETERS + +### -Name + +The name of the rule to get documentation for. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: n + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Path + +A list of paths to check for rule definitions + +```yaml +Type: String +Parameter Sets: (All) +Aliases: p + +Required: False +Position: 1 +Default value: $PWD +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Module + +{{ Fill Module Description }} + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Culture + +Specifies the culture to use for rule documentation and messages. By default, the culture of PowerShell is used. + +This option does not affect the culture used for the PSRule engine, which always uses the culture of PowerShell. + +The PowerShell cmdlet `Get-Culture` shows the current culture of PowerShell. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Online + +Instead of displaying documentation within PowerShell, browse to the online version using the default web browser. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +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, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +### PSRule.Rules.RuleHelpInfo + +## NOTES + +## RELATED LINKS diff --git a/src/PSRule/Host/HostHelper.cs b/src/PSRule/Host/HostHelper.cs index f6dc945b6f..75a182f789 100644 --- a/src/PSRule/Host/HostHelper.cs +++ b/src/PSRule/Host/HostHelper.cs @@ -20,7 +20,7 @@ public static IEnumerable GetRule(RuleSource[] source, RuleFilter filter) return ToRule(GetLanguageBlock(sources: source), filter); } - public static RuleHelpInfo GetRuleHelp(RuleSource[] source, RuleFilter filter) + public static IEnumerable GetRuleHelp(RuleSource[] source, RuleFilter filter) { return ToRuleHelp(GetLanguageBlock(sources: source), filter); } @@ -212,7 +212,7 @@ private static Rule[] ToRule(IEnumerable blocks, RuleFilter filt return results.Values.ToArray(); } - private static RuleHelpInfo ToRuleHelp(IEnumerable blocks, RuleFilter filter) + private static RuleHelpInfo[] ToRuleHelp(IEnumerable blocks, RuleFilter filter) { // Index deployments by environment/name var results = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -232,12 +232,12 @@ private static RuleHelpInfo ToRuleHelp(IEnumerable blocks, RuleF Name = block.RuleName, Synopsis = block.Description, Recommendation = block.Recommendation, - Annotations = block.Annotations.ToHashtable() + Annotations = block.Annotations?.ToHashtable() }; } } - return results.Values.FirstOrDefault(); + return results.Values.ToArray(); } } } diff --git a/src/PSRule/PSRule.Format.ps1xml b/src/PSRule/PSRule.Format.ps1xml index 5089ce964f..19e40ad07d 100644 --- a/src/PSRule/PSRule.Format.ps1xml +++ b/src/PSRule/PSRule.Format.ps1xml @@ -7,7 +7,7 @@ - + 4 @@ -29,7 +29,7 @@ - + 4 @@ -51,7 +51,7 @@ - + 4 @@ -77,14 +77,14 @@ - - - @@ -115,15 +115,15 @@ - - - @@ -151,19 +151,19 @@ - - - - @@ -219,5 +219,41 @@ + + PSRule.Rules.RuleHelpInfo+Collection + + PSRule.Rules.RuleHelpInfo+Collection + + + + + + + + + + + + + + + Name + + + ModuleName + + + Synopsis + + + + + + diff --git a/src/PSRule/PSRule.csproj b/src/PSRule/PSRule.csproj index 2e2640ac63..50f86db888 100644 --- a/src/PSRule/PSRule.csproj +++ b/src/PSRule/PSRule.csproj @@ -19,27 +19,36 @@ - + True True - FormatResources.resx + DocumentStrings.resx True True PSRuleResources.resx + + True + True + ViewStrings.resx + - + ResXFileCodeGenerator - FormatResources.Designer.cs + DocumentStrings.Designer.cs ResXFileCodeGenerator PSRuleResources.Designer.cs + + ResXFileCodeGenerator + ViewStrings.Designer.cs + diff --git a/src/PSRule/PSRule.psm1 b/src/PSRule/PSRule.psm1 index d107a0c286..cc832e8d1b 100644 --- a/src/PSRule/PSRule.psm1 +++ b/src/PSRule/PSRule.psm1 @@ -394,7 +394,7 @@ function Get-PSRule { return; # continue causes issues with Pester } - Write-Verbose -Message "[Get-PSRule] -- Found $($sourceFiles.Length) script(s)"; + Write-Verbose -Message "[Get-PSRule] -- Found $($sourceFiles.Length) source file(s)"; $isDeviceGuard = IsDeviceGuardEnabled; @@ -441,16 +441,17 @@ function Get-PSRuleHelp { [CmdletBinding()] [OutputType([PSRule.Rules.RuleHelpInfo])] param ( - # A list of paths to check for rule definitions - [Parameter(Position = 0, Mandatory = $False)] - [Alias('p')] - [String]$Path = $PWD, - # Filter to rules with the following names - [Parameter(Mandatory = $True)] + [Parameter(Position = 0, Mandatory = $True)] [Alias('n')] + [SupportsWildcards()] [String]$Name, + # A list of paths to check for rule definitions + [Parameter(Mandatory = $False)] + [Alias('p')] + [String]$Path = $PWD, + [Parameter(Mandatory = $False)] [String]$Module, @@ -483,13 +484,10 @@ function Get-PSRuleHelp { if ($PSBoundParameters.ContainsKey('Module')) { $sourceParams['Module'] = $Module; } - if ($sourceParams.Count -eq 0) { - $sourceParams['Path'] = $Path; - } if ($PSBoundParameters.ContainsKey('Culture')) { $sourceParams['Culture'] = $Culture; } - [PSRule.Rules.RuleSource[]]$sourceFiles = GetRuleScriptPath @sourceParams -Verbose:$VerbosePreference; + [PSRule.Rules.RuleSource[]]$sourceFiles = GetRuleScriptPath @sourceParams -PreferModule -Verbose:$VerbosePreference; # Check that some matching script files were found if ($Null -eq $sourceFiles) { @@ -497,7 +495,7 @@ function Get-PSRuleHelp { return; # continue causes issues with Pester } - Write-Verbose -Message "[Get-PSRuleHelp] -- Found $($sourceFiles.Length) script(s)"; + Write-Verbose -Message "[Get-PSRuleHelp] -- Found $($sourceFiles.Length) source file(s)"; $isDeviceGuard = IsDeviceGuardEnabled; @@ -521,17 +519,27 @@ function Get-PSRuleHelp { if ($Null -ne (Get-Variable -Name pipeline -ErrorAction SilentlyContinue)) { try { # Get matching rule help - $result = $pipeline.Process(); + $result = @($pipeline.Process()); - if ($Null -ne $result -and $Online) { - $launchUri = $result.GetOnlineHelpUri(); + if ($Null -ne $result -and $result.Length -gt 0) { - if ($Null -ne $launchUri) { - LaunchOnlineHelp -Uri $launchUri -Verbose:$VerbosePreference; + if ($Online -and $result.Length -eq 1) { + $launchUri = $result.GetOnlineHelpUri(); + + if ($Null -ne $launchUri) { + Write-Verbose -Message "[Get-PSRuleHelp] -- Launching online version: $($launchUri.OriginalString)"; + LaunchOnlineHelp -Uri $launchUri -Verbose:$VerbosePreference; + } + } + elseif ($result.Length -gt 1) { + $result | ForEach-Object -Process { + $Null = $_.PSObject.TypeNames.Insert(0, 'PSRule.Rules.RuleHelpInfo+Collection'); + $_; + } + } + else { + $result; } - } - else { - $result; } } catch { @@ -1066,13 +1074,16 @@ function GetRuleScriptPath { [Switch]$ListAvailable, [Parameter(Mandatory = $False)] - [String]$Culture + [String]$Culture, + + [Parameter(Mandatory = $False)] + [Switch]$PreferModule = $False ) process { $builder = New-Object -TypeName 'PSRule.Rules.RuleSourceBuilder'; if ([String]::IsNullOrEmpty($Culture)) { - $Culture = [System.Threading.Thread]::CurrentThread.CurrentCulture.ToString(); + $Culture = GetCulture; } if ($PSBoundParameters.ContainsKey('Path')) { @@ -1095,14 +1106,15 @@ function GetRuleScriptPath { $moduleParams['ListAvailable'] = $ListAvailable.ToBool(); } - if ($moduleParams.Count -gt 0) { - $modules = Microsoft.PowerShell.Core\Get-Module @moduleParams | Where-Object -FilterScript { + if ($moduleParams.Count -gt 0 -or $PreferModule) { + $modules = @(Microsoft.PowerShell.Core\Get-Module @moduleParams | Where-Object -FilterScript { 'PSRule' -in $_.Tags - } + }) + Write-Verbose -Message "[PSRule][D] -- Found $($modules.Length) PSRule module(s)"; if ($Null -ne $modules) { foreach ($m in $modules) { - Write-Verbose -Message "[PSRule][D] -- Found module: $($m.Name)"; + Write-Verbose -Message "[PSRule][D] -- Scanning for source files in module: $($m.Name)"; $fileObjects = (Get-ChildItem -Path $m.ModuleBase -Recurse -File -Include '*.rule.ps1' -ErrorAction Stop); $helpPath = Join-Path $m.ModuleBase -ChildPath $Culture; @@ -1296,7 +1308,6 @@ function IsDeviceGuardEnabled { [CmdletBinding()] [OutputType([System.Boolean])] param () - process { if ((Get-Variable -Name IsMacOS -ErrorAction Ignore) -or (Get-Variable -Name IsLinux -ErrorAction Ignore)) { @@ -1312,6 +1323,15 @@ function IsDeviceGuardEnabled { } } +function GetCulture { + [CmdletBinding()] + [OutputType([System.String])] + param () + process { + return [System.Threading.Thread]::CurrentThread.CurrentCulture.ToString(); + } +} + function LaunchOnlineHelp { [CmdletBinding()] [OutputType([void])] diff --git a/src/PSRule/Parser/RuleLexer.cs b/src/PSRule/Parser/RuleLexer.cs index 84fcacd3c1..62718f3197 100644 --- a/src/PSRule/Parser/RuleLexer.cs +++ b/src/PSRule/Parser/RuleLexer.cs @@ -70,7 +70,7 @@ public RuleDocument Process(TokenStream stream) /// private bool Synopsis(TokenStream stream, RuleDocument doc) { - if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, FormatResources.Synopsis)) + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, DocumentStrings.Synopsis)) { return false; } @@ -86,7 +86,7 @@ private bool Synopsis(TokenStream stream, RuleDocument doc) /// private bool Recommendation(TokenStream stream, RuleDocument doc) { - if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, FormatResources.Recommendation)) + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, DocumentStrings.Recommendation)) { return false; } @@ -122,7 +122,7 @@ private bool Recommendation(TokenStream stream, RuleDocument doc) /// private bool Notes(TokenStream stream, RuleDocument doc) { - if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, FormatResources.Notes)) + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, DocumentStrings.Notes)) { return false; } @@ -135,7 +135,7 @@ private bool Notes(TokenStream stream, RuleDocument doc) private bool RelatedLinks(TokenStream stream, RuleDocument doc) { - if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, FormatResources.Links)) + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, DocumentStrings.Links)) { return false; } diff --git a/src/PSRule/Pipeline/GetRuleHelpPipeline.cs b/src/PSRule/Pipeline/GetRuleHelpPipeline.cs index 1ea08b3d83..a32115e268 100644 --- a/src/PSRule/Pipeline/GetRuleHelpPipeline.cs +++ b/src/PSRule/Pipeline/GetRuleHelpPipeline.cs @@ -1,6 +1,7 @@ using PSRule.Configuration; using PSRule.Host; using PSRule.Rules; +using System.Collections.Generic; namespace PSRule.Pipeline { @@ -12,7 +13,7 @@ internal GetRuleHelpPipeline(PSRuleOption option, RuleSource[] source, RuleFilte // Do nothing } - public RuleHelpInfo Process() + public IEnumerable Process() { return HostHelper.GetRuleHelp(source: _Source, filter: _Filter); } diff --git a/src/PSRule/Pipeline/GetRuleHelpPipelineBuilder.cs b/src/PSRule/Pipeline/GetRuleHelpPipelineBuilder.cs index 92275411db..7057dc7e15 100644 --- a/src/PSRule/Pipeline/GetRuleHelpPipelineBuilder.cs +++ b/src/PSRule/Pipeline/GetRuleHelpPipelineBuilder.cs @@ -62,7 +62,7 @@ public void UseLoggingPreferences(ActionPreference error, ActionPreference warni public GetRuleHelpPipeline Build() { - var filter = new RuleFilter(ruleName: _Option.Baseline.RuleName, tag: null, exclude: _Option.Baseline.Exclude); + var filter = new RuleFilter(ruleName: _Option.Baseline.RuleName, tag: null, exclude: _Option.Baseline.Exclude, wildcardMatch: true); var context = PipelineContext.New(logger: _Logger, option: _Option, bindTargetName: null, bindTargetType: null, logError: _LogError, logWarning: _LogWarning, logVerbose: _LogVerbose, logInformation: _LogInformation); return new GetRuleHelpPipeline(option: _Option, source: _Source, filter: filter, context: context); } diff --git a/src/PSRule/Resources/DocumentStrings.Designer.cs b/src/PSRule/Resources/DocumentStrings.Designer.cs new file mode 100644 index 0000000000..e4250d7bf8 --- /dev/null +++ b/src/PSRule/Resources/DocumentStrings.Designer.cs @@ -0,0 +1,108 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PSRule.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class DocumentStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal DocumentStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PSRule.Resources.DocumentStrings", typeof(DocumentStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to RELATED LINKS. + /// + internal static string Links { + get { + return ResourceManager.GetString("Links", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to NAME. + /// + internal static string Name { + get { + return ResourceManager.GetString("Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to NOTES. + /// + internal static string Notes { + get { + return ResourceManager.GetString("Notes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to RECOMMENDATION. + /// + internal static string Recommendation { + get { + return ResourceManager.GetString("Recommendation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to SYNOPSIS. + /// + internal static string Synopsis { + get { + return ResourceManager.GetString("Synopsis", resourceCulture); + } + } + } +} diff --git a/src/PSRule/Resources/DocumentStrings.resx b/src/PSRule/Resources/DocumentStrings.resx new file mode 100644 index 0000000000..378ce1a053 --- /dev/null +++ b/src/PSRule/Resources/DocumentStrings.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + RELATED LINKS + + + NAME + + + NOTES + + + RECOMMENDATION + + + SYNOPSIS + + \ No newline at end of file diff --git a/src/PSRule/Resources/FormatResources.Designer.cs b/src/PSRule/Resources/ViewStrings.Designer.cs similarity index 82% rename from src/PSRule/Resources/FormatResources.Designer.cs rename to src/PSRule/Resources/ViewStrings.Designer.cs index d08fda95dc..cd3fb21392 100644 --- a/src/PSRule/Resources/FormatResources.Designer.cs +++ b/src/PSRule/Resources/ViewStrings.Designer.cs @@ -22,14 +22,14 @@ namespace PSRule.Resources { [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class FormatResources { + internal class ViewStrings { private static global::System.Resources.ResourceManager resourceMan; private static global::System.Globalization.CultureInfo resourceCulture; [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal FormatResources() { + internal ViewStrings() { } /// @@ -39,7 +39,7 @@ internal FormatResources() { internal static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PSRule.Resources.FormatResources", typeof(FormatResources).Assembly); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PSRule.Resources.ViewStrings", typeof(ViewStrings).Assembly); resourceMan = temp; } return resourceMan; @@ -78,15 +78,6 @@ internal static string Fail { } } - /// - /// Looks up a localized string similar to RELATED LINKS. - /// - internal static string Links { - get { - return ResourceManager.GetString("Links", resourceCulture); - } - } - /// /// Looks up a localized string similar to Message. /// @@ -106,7 +97,7 @@ internal static string ModuleName { } /// - /// Looks up a localized string similar to NAME. + /// Looks up a localized string similar to Name. /// internal static string Name { get { @@ -114,15 +105,6 @@ internal static string Name { } } - /// - /// Looks up a localized string similar to NOTES. - /// - internal static string Notes { - get { - return ResourceManager.GetString("Notes", resourceCulture); - } - } - /// /// Looks up a localized string similar to Outcome. /// @@ -141,15 +123,6 @@ internal static string Pass { } } - /// - /// Looks up a localized string similar to RECOMMENDATION. - /// - internal static string Recommendation { - get { - return ResourceManager.GetString("Recommendation", resourceCulture); - } - } - /// /// Looks up a localized string similar to RuleName. /// @@ -160,7 +133,7 @@ internal static string RuleName { } /// - /// Looks up a localized string similar to SYNOPSIS. + /// Looks up a localized string similar to Synopsis. /// internal static string Synopsis { get { diff --git a/src/PSRule/Resources/FormatResources.resx b/src/PSRule/Resources/ViewStrings.resx similarity index 95% rename from src/PSRule/Resources/FormatResources.resx rename to src/PSRule/Resources/ViewStrings.resx index 8e451edec7..2c83c4fc17 100644 --- a/src/PSRule/Resources/FormatResources.resx +++ b/src/PSRule/Resources/ViewStrings.resx @@ -123,9 +123,6 @@ Fail - - RELATED LINKS - Message @@ -133,10 +130,7 @@ ModuleName - NAME - - - NOTES + Name Outcome @@ -144,13 +138,10 @@ Pass - - RECOMMENDATION - RuleName - SYNOPSIS + Synopsis \ No newline at end of file diff --git a/src/PSRule/Rules/RuleFilter.cs b/src/PSRule/Rules/RuleFilter.cs index 6e258c7890..786c080003 100644 --- a/src/PSRule/Rules/RuleFilter.cs +++ b/src/PSRule/Rules/RuleFilter.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Management.Automation; namespace PSRule.Rules { @@ -12,6 +13,7 @@ public sealed class RuleFilter private readonly HashSet _RequiredRuleName; private readonly HashSet _ExcludedRuleName; private readonly Hashtable _RequiredTag; + private readonly WildcardPattern _WildcardMatch; /// /// Filter rules by id or tag. @@ -19,11 +21,25 @@ public sealed class RuleFilter /// Only accept these rules by name. /// Only accept rules that have these tags. /// Rule that are always excluded by name. - public RuleFilter(IEnumerable ruleName, Hashtable tag, IEnumerable exclude) + public RuleFilter(string[] ruleName, Hashtable tag, IEnumerable exclude, bool wildcardMatch = false) { - _RequiredRuleName = ruleName == null ? null : new HashSet(ruleName, StringComparer.OrdinalIgnoreCase); + _RequiredRuleName = ruleName == null || ruleName.Length == 0 ? null : new HashSet(ruleName, StringComparer.OrdinalIgnoreCase); _ExcludedRuleName = exclude == null ? null : new HashSet(exclude, StringComparer.OrdinalIgnoreCase); _RequiredTag = tag ?? null; + _WildcardMatch = null; + + if (wildcardMatch) + { + if (ruleName == null || ruleName.Length != 1) + { + throw new NotSupportedException("Wildcard match requires exactly one ruleName"); + } + + if (WildcardPattern.ContainsWildcardCharacters(ruleName[0])) + { + _WildcardMatch = new WildcardPattern(ruleName[0]); + } + } } /// @@ -37,7 +53,7 @@ public bool Match(string ruleName, TagSet tag) return false; } - if (_RequiredRuleName == null || _RequiredRuleName.Contains(ruleName)) + if (_RequiredRuleName == null || _RequiredRuleName.Contains(ruleName) || MatchWildcard(ruleName: ruleName)) { if (_RequiredTag == null) { @@ -67,5 +83,15 @@ public bool Match(RuleBlock block) { return Match(block.RuleName, block.Tag); } + + private bool MatchWildcard(string ruleName) + { + if (_WildcardMatch == null) + { + return false; + } + + return _WildcardMatch.IsMatch(ruleName); + } } } diff --git a/tests/PSRule.Tests/PSRule.Common.Tests.ps1 b/tests/PSRule.Tests/PSRule.Common.Tests.ps1 index 8f38aca97a..f6a3598b2d 100644 --- a/tests/PSRule.Tests/PSRule.Common.Tests.ps1 +++ b/tests/PSRule.Tests/PSRule.Common.Tests.ps1 @@ -38,11 +38,16 @@ Describe 'Invoke-PSRule' -Tag 'Invoke-PSRule','Common' { $testObject.PSObject.TypeNames.Insert(0, 'TestType'); It 'Returns passed' { + Mock -CommandName 'GetCulture' -ModuleName 'PSRule' -Verifiable -MockWith { + return 'en-ZZ'; + } $result = $testObject | Invoke-PSRule -Path $ruleFilePath -Name 'FromFile1'; $result | Should -Not -BeNullOrEmpty; $result.IsSuccess() | Should -Be $True; $result.TargetName | Should -Be 'TestObject1'; - $result.Annotations.severity | Should -Be 'Critical'; + $result.Annotations.culture | Should -Be 'en-ZZ'; + $result.Message | Should -Be 'This is a synopsis.'; + Assert-VerifiableMock; } It 'Returns failure' { @@ -216,6 +221,9 @@ Describe 'Invoke-PSRule' -Tag 'Invoke-PSRule','Common' { } It 'Returns summary' { + Mock -CommandName 'GetCulture' -ModuleName 'PSRule' -Verifiable -MockWith { + return 'en-ZZ'; + } $option = @{ 'Execution.InconclusiveWarning' = $False }; $result = $testObject | Invoke-PSRule -Path $ruleFilePath -Tag @{ category = 'group1' } -As Summary -Outcome All -Option $option; $result | Should -Not -BeNullOrEmpty; @@ -225,12 +233,13 @@ Describe 'Invoke-PSRule' -Tag 'Invoke-PSRule','Common' { $result.Tag.category | Should -BeIn 'group1'; ($result | Where-Object { $_.RuleName -eq 'FromFile1'}).Outcome | Should -Be 'Pass'; ($result | Where-Object { $_.RuleName -eq 'FromFile1'}).Pass | Should -Be 2; - ($result | Where-Object { $_.RuleName -eq 'FromFile1'}).Annotations.severity | Should -Be 'Critical'; + ($result | Where-Object { $_.RuleName -eq 'FromFile1'}).Annotations.culture | Should -Be 'en-ZZ'; ($result | Where-Object { $_.RuleName -eq 'FromFile2'}).Outcome | Should -Be 'Fail'; ($result | Where-Object { $_.RuleName -eq 'FromFile2'}).Fail | Should -Be 2; ($result | Where-Object { $_.RuleName -eq 'FromFile4'}).Outcome | Should -Be 'None'; ($result | Where-Object { $_.RuleName -eq 'FromFile4'}).Pass | Should -Be 0; ($result | Where-Object { $_.RuleName -eq 'FromFile4'}).Fail | Should -Be 0; + Assert-VerifiableMock; } It 'Returns filtered summary' { @@ -807,16 +816,20 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { } It 'Reads metadata' { + Mock -CommandName 'GetCulture' -ModuleName 'PSRule' -Verifiable -MockWith { + return 'en-ZZ'; + } $result = Get-PSRule -Path $ruleFilePath -Name 'FromFile1'; $result | Should -Not -BeNullOrEmpty; $result.RuleName | Should -Be 'FromFile1'; $result.Description | Should -Be 'This is a synopsis.'; - $result.Annotations.severity | Should -Be 'Critical'; + $result.Annotations.culture | Should -Be 'en-ZZ'; $result = Get-PSRule -Path $ruleFilePath -Name 'FromFile2'; $result | Should -Not -BeNullOrEmpty; $result.RuleName | Should -Be 'FromFile2'; $result.Description | Should -Be 'Test rule 2'; + Assert-VerifiableMock; } It 'Handles empty path' { @@ -831,10 +844,12 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { Context 'Using -Module' { It 'Returns module rules' { $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); - $result = @(Get-PSRule -Module 'TestModule'); + $result = @(Get-PSRule -Module 'TestModule' -Culture 'en-US'); $result | Should -Not -BeNullOrEmpty; $result.Length | Should -Be 1; - $result.RuleName | Should -Be 'Rule1'; + $result[0].RuleName | Should -Be 'M1.Rule1'; + $result[0].Description | Should -Be 'Synopsis en-US.'; + $result[0].Annotations.culture | Should -Be 'en-US'; } It 'Returns module and path rules' { @@ -842,7 +857,44 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { $result = @(Get-PSRule -Path (Join-Path $here -ChildPath 'TestModule') -Module 'TestModule'); $result | Should -Not -BeNullOrEmpty; $result.Length | Should -Be 2; - $result.RuleName | Should -BeIn 'Rule1'; + $result.RuleName | Should -BeIn 'M1.Rule1'; + } + + It 'Read from documentation' { + Mock -CommandName 'GetCulture' -ModuleName 'PSRule' -MockWith { + return 'en-US'; + } + + # en-US default + $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); + $result = @(Get-PSRule -Module 'TestModule'); + $result | Should -Not -BeNullOrEmpty; + $result.Length | Should -Be 1; + $result[0].RuleName | Should -Be 'M1.Rule1'; + $result[0].Description | Should -Be 'Synopsis en-US.'; + $result[0].Annotations.culture | Should -Be 'en-US'; + + # en-AU + $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); + $result = @(Get-PSRule -Module 'TestModule' -Culture 'en-AU'); + $result | Should -Not -BeNullOrEmpty; + $result.Length | Should -Be 1; + $result[0].RuleName | Should -Be 'M1.Rule1'; + $result[0].Description | Should -Be 'Synopsis en-AU.'; + $result[0].Annotations.culture | Should -Be 'en-AU'; + + Mock -CommandName 'GetCulture' -ModuleName 'PSRule' -MockWith { + return 'en-AU'; + } + + # en-AU default + $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); + $result = @(Get-PSRule -Module 'TestModule'); + $result | Should -Not -BeNullOrEmpty; + $result.Length | Should -Be 1; + $result[0].RuleName | Should -Be 'M1.Rule1'; + $result[0].Description | Should -Be 'Synopsis en-AU.'; + $result[0].Annotations.culture | Should -Be 'en-AU'; } } diff --git a/tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md b/tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md new file mode 100644 index 0000000000..e2f94959f0 --- /dev/null +++ b/tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md @@ -0,0 +1,13 @@ +--- +culture: en-AU +--- + +# M1.Rule1 + +## Synopsis + +Synopsis en-AU. + +## Recommendation + +Recommendation en-AU. diff --git a/tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md b/tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md new file mode 100644 index 0000000000..93ed0e7d34 --- /dev/null +++ b/tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md @@ -0,0 +1,13 @@ +--- +culture: en-US +--- + +# M1.Rule1 + +## Synopsis + +Synopsis en-US. + +## Recommendation + +Recommendation en-US. diff --git a/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 b/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 index a866c5dc41..3ed6cc52d3 100644 --- a/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 +++ b/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 @@ -2,6 +2,7 @@ # A set of test rules in a module # -Rule 'Rule1' { +# Description: This is the default +Rule 'M1.Rule1' { # This is a test rule } diff --git a/tests/PSRule.Tests/en-AU/FromFile1.md b/tests/PSRule.Tests/en-ZZ/FromFile1.md similarity index 84% rename from tests/PSRule.Tests/en-AU/FromFile1.md rename to tests/PSRule.Tests/en-ZZ/FromFile1.md index 14148a04f6..3d037d7982 100644 --- a/tests/PSRule.Tests/en-AU/FromFile1.md +++ b/tests/PSRule.Tests/en-ZZ/FromFile1.md @@ -1,5 +1,5 @@ --- -severity: Critical +culture: en-ZZ --- # FromFile From 9cf8b01214e443bf3612e858e15f4eb34bc88cf9 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Wed, 29 May 2019 22:50:28 +1000 Subject: [PATCH 05/26] Update documentation --- CHANGELOG.md | 1 + docs/commands/PSRule/en-US/Get-PSRuleHelp.md | 18 +++++++++--------- docs/commands/PSRule/en-US/PSRule.md | 4 ++++ src/PSRule/PSRule.psm1 | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 560383c13f..3a9ffbed49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Added rule documentation, which allows additional rule information to be stored in markdown files. [#157](https://github.com/BernieWhite/PSRule/issues/157) - Rule documentation also adds culture support. [#18](https://github.com/BernieWhite/PSRule/issues/18) + - Rule documentation can be access like help with the `Get-PSRuleHelp` cmdlet. - Added annotations, which are non-indexed metadata stored in rule documentation. [#148](https://github.com/BernieWhite/PSRule/issues/148) - Annotations can contain a link to online version of the documentation. [#147](https://github.com/BernieWhite/PSRule/issues/147) diff --git a/docs/commands/PSRule/en-US/Get-PSRuleHelp.md b/docs/commands/PSRule/en-US/Get-PSRuleHelp.md index f9572c4a09..a0d91b68dc 100644 --- a/docs/commands/PSRule/en-US/Get-PSRuleHelp.md +++ b/docs/commands/PSRule/en-US/Get-PSRuleHelp.md @@ -14,7 +14,7 @@ Get documentation for a rule. ## SYNTAX ```text -Get-PSRuleHelp [[-Path] ] -Name [-Module ] [-Culture ] [-Online] +Get-PSRuleHelp [-Name] [-Path ] [-Module ] [-Culture ] [-Online] [] ``` @@ -27,10 +27,10 @@ Get documentation for a rule. ### Example 1 ```powershell -PS C:\> Get-PSRuleHelp Azure.ACR.AdminUser +PS C:\> Get-PSRuleHelp Azure.ACR.AdminUser; ``` -{{ Add example description here }} +Get rule documentation for the imported rule `Azure.ACR.AdminUser`. ## PARAMETERS @@ -44,15 +44,15 @@ Parameter Sets: (All) Aliases: n Required: True -Position: Named +Position: 1 Default value: None Accept pipeline input: False -Accept wildcard characters: False +Accept wildcard characters: True ``` ### -Path -A list of paths to check for rule definitions +A path to check documentation for. If this is not specified, documentation is sourced for imported modules. ```yaml Type: String @@ -60,15 +60,15 @@ Parameter Sets: (All) Aliases: p Required: False -Position: 1 -Default value: $PWD +Position: Named +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` ### -Module -{{ Fill Module Description }} +Limit returned information to rules in the specified module. ```yaml Type: String diff --git a/docs/commands/PSRule/en-US/PSRule.md b/docs/commands/PSRule/en-US/PSRule.md index 830a94427f..2aa30fb859 100644 --- a/docs/commands/PSRule/en-US/PSRule.md +++ b/docs/commands/PSRule/en-US/PSRule.md @@ -18,6 +18,10 @@ A PowerShell rules engine. Get a list of matching rule definitions within the search path. +### [Get-PSRule](Get-PSRuleHelp.md) + +Get documentation for a rule. + ### [Invoke-PSRule](Invoke-PSRule.md) Evaluate pipeline objects against matching rules. diff --git a/src/PSRule/PSRule.psm1 b/src/PSRule/PSRule.psm1 index cc832e8d1b..019e57c55c 100644 --- a/src/PSRule/PSRule.psm1 +++ b/src/PSRule/PSRule.psm1 @@ -450,7 +450,7 @@ function Get-PSRuleHelp { # A list of paths to check for rule definitions [Parameter(Mandatory = $False)] [Alias('p')] - [String]$Path = $PWD, + [String]$Path, [Parameter(Mandatory = $False)] [String]$Module, From 7e27c2ddefdc95171946476fcf1721ca439be710 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Thu, 30 May 2019 18:38:42 +1000 Subject: [PATCH 06/26] Update documentation TOC --- docs/commands/PSRule/en-US/toc.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/commands/PSRule/en-US/toc.yml b/docs/commands/PSRule/en-US/toc.yml index 0eac2d3862..907cb30438 100644 --- a/docs/commands/PSRule/en-US/toc.yml +++ b/docs/commands/PSRule/en-US/toc.yml @@ -6,6 +6,9 @@ - name: Get-PSRule href: Get-PSRule.md topicHref: Get-PSRule.md + - name: Get-PSRuleHelp + href: Get-PSRuleHelp.md + topicHref: Get-PSRuleHelp.md - name: Invoke-PSRule href: Invoke-PSRule.md topicHref: Invoke-PSRule.md From e9d081d1828dfd43d9a97a185878a0cd6a74bd70 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Thu, 30 May 2019 21:15:21 +1000 Subject: [PATCH 07/26] Add help units tests --- PSRule.build.ps1 | 4 +- src/PSRule/PSRule.csproj | 13 +++- src/PSRule/PSRule.psm1 | 9 ++- src/PSRule/Rules/RuleFilter.cs | 9 +-- tests/PSRule.Tests/PSRule.Common.Tests.ps1 | 62 +++++++++++++++++-- .../PSRule.Tests/TestModule/en-AU/M1.Rule1.md | 1 + .../PSRule.Tests/TestModule/en-US/M1.Rule1.md | 1 + .../TestModule/rules/Test.Rule.ps1 | 5 ++ 8 files changed, 85 insertions(+), 19 deletions(-) diff --git a/PSRule.build.ps1 b/PSRule.build.ps1 index 675d7a2e15..79f8c98d6f 100644 --- a/PSRule.build.ps1 +++ b/PSRule.build.ps1 @@ -60,7 +60,7 @@ function CopyModuleFiles { task BuildDotNet { exec { # Build library - dotnet publish src/PSRule -c $Configuration -f netstandard2.0 -o $(Join-Path -Path $PWD -ChildPath out/modules/PSRule) + dotnet publish src/PSRule -p:versionPrefix=$ModuleVersion -c $Configuration -f netstandard2.0 -o $(Join-Path -Path $PWD -ChildPath out/modules/PSRule) } } @@ -112,7 +112,7 @@ task VersionModule { $ModuleVersion = $ReleaseVersion; } - if (![String]::IsNullOrEmpty($ModuleVersion)) { + if ($PSBoundParameters.ContainsKey('ModuleVersion') -and ![String]::IsNullOrEmpty($ModuleVersion)) { Write-Verbose -Message "[VersionModule] -- ModuleVersion: $ModuleVersion"; $version = $ModuleVersion; diff --git a/src/PSRule/PSRule.csproj b/src/PSRule/PSRule.csproj index 50f86db888..05fc2ded57 100644 --- a/src/PSRule/PSRule.csproj +++ b/src/PSRule/PSRule.csproj @@ -1,11 +1,20 @@ - + netstandard2.0;net472 Library portable - false + true true + Bernie White + PSRule + https://github.com/BernieWhite/PSRule + https://github.com/BernieWhite/PSRule/blob/master/LICENSE + + (c) Bernie White. All rights reserved. + Validate objects using PowerShell rules. + +This project is to be considered a proof-of-concept and not a supported product. diff --git a/src/PSRule/PSRule.psm1 b/src/PSRule/PSRule.psm1 index 019e57c55c..fd23ebeb24 100644 --- a/src/PSRule/PSRule.psm1 +++ b/src/PSRule/PSRule.psm1 @@ -441,13 +441,13 @@ function Get-PSRuleHelp { [CmdletBinding()] [OutputType([PSRule.Rules.RuleHelpInfo])] param ( - # Filter to rules with the following names - [Parameter(Position = 0, Mandatory = $True)] + # The name of the rule to get documentation for. + [Parameter(Position = 0, Mandatory = $False)] [Alias('n')] [SupportsWildcards()] [String]$Name, - # A list of paths to check for rule definitions + # A path to check documentation for. [Parameter(Mandatory = $False)] [Alias('p')] [String]$Path, @@ -455,6 +455,9 @@ function Get-PSRuleHelp { [Parameter(Mandatory = $False)] [String]$Module, + [Parameter(Mandatory = $False)] + [PSRule.Configuration.PSRuleOption]$Option, + [Parameter(Mandatory = $False)] [String]$Culture, diff --git a/src/PSRule/Rules/RuleFilter.cs b/src/PSRule/Rules/RuleFilter.cs index 786c080003..0b55ab1bb8 100644 --- a/src/PSRule/Rules/RuleFilter.cs +++ b/src/PSRule/Rules/RuleFilter.cs @@ -28,17 +28,14 @@ public RuleFilter(string[] ruleName, Hashtable tag, IEnumerable exclude, _RequiredTag = tag ?? null; _WildcardMatch = null; - if (wildcardMatch) + if (wildcardMatch && ruleName != null && ruleName.Length > 0 && WildcardPattern.ContainsWildcardCharacters(ruleName[0])) { - if (ruleName == null || ruleName.Length != 1) + if (ruleName.Length > 1) { throw new NotSupportedException("Wildcard match requires exactly one ruleName"); } - if (WildcardPattern.ContainsWildcardCharacters(ruleName[0])) - { - _WildcardMatch = new WildcardPattern(ruleName[0]); - } + _WildcardMatch = new WildcardPattern(ruleName[0]); } } diff --git a/tests/PSRule.Tests/PSRule.Common.Tests.ps1 b/tests/PSRule.Tests/PSRule.Common.Tests.ps1 index f6a3598b2d..5527e38542 100644 --- a/tests/PSRule.Tests/PSRule.Common.Tests.ps1 +++ b/tests/PSRule.Tests/PSRule.Common.Tests.ps1 @@ -846,7 +846,7 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); $result = @(Get-PSRule -Module 'TestModule' -Culture 'en-US'); $result | Should -Not -BeNullOrEmpty; - $result.Length | Should -Be 1; + $result.Length | Should -Be 2; $result[0].RuleName | Should -Be 'M1.Rule1'; $result[0].Description | Should -Be 'Synopsis en-US.'; $result[0].Annotations.culture | Should -Be 'en-US'; @@ -856,8 +856,8 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); $result = @(Get-PSRule -Path (Join-Path $here -ChildPath 'TestModule') -Module 'TestModule'); $result | Should -Not -BeNullOrEmpty; - $result.Length | Should -Be 2; - $result.RuleName | Should -BeIn 'M1.Rule1'; + $result.Length | Should -Be 4; + $result.RuleName | Should -BeIn 'M1.Rule1', 'M1.Rule2'; } It 'Read from documentation' { @@ -869,7 +869,7 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); $result = @(Get-PSRule -Module 'TestModule'); $result | Should -Not -BeNullOrEmpty; - $result.Length | Should -Be 1; + $result.Length | Should -Be 2; $result[0].RuleName | Should -Be 'M1.Rule1'; $result[0].Description | Should -Be 'Synopsis en-US.'; $result[0].Annotations.culture | Should -Be 'en-US'; @@ -878,7 +878,7 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); $result = @(Get-PSRule -Module 'TestModule' -Culture 'en-AU'); $result | Should -Not -BeNullOrEmpty; - $result.Length | Should -Be 1; + $result.Length | Should -Be 2; $result[0].RuleName | Should -Be 'M1.Rule1'; $result[0].Description | Should -Be 'Synopsis en-AU.'; $result[0].Annotations.culture | Should -Be 'en-AU'; @@ -891,7 +891,7 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); $result = @(Get-PSRule -Module 'TestModule'); $result | Should -Not -BeNullOrEmpty; - $result.Length | Should -Be 1; + $result.Length | Should -Be 2; $result[0].RuleName | Should -Be 'M1.Rule1'; $result[0].Description | Should -Be 'Synopsis en-AU.'; $result[0].Annotations.culture | Should -Be 'en-AU'; @@ -921,3 +921,53 @@ Describe 'Get-PSRule' -Tag 'Get-PSRule','Common' { } #endregion Get-PSRule + +#region Get-PSRuleHelp + +Describe 'Get-PSRuleHelp' -Tag 'Get-PSRuleHelp', 'Common' { + $ruleFilePath = (Join-Path -Path $here -ChildPath 'FromFile.Rule.ps1'); + $Null = Import-Module (Join-Path $here -ChildPath 'TestModule'); + + Context 'With defaults' { + $result = @(Get-PSRuleHelp -Module 'TestModule'); + $result.Length | Should -Be 2; + } + + Context 'With -Module' { + $result = @(Get-PSRuleHelp -Module 'TestModule'); + $result.Length | Should -Be 2; + } + + Context 'With -Online' { + It 'Launches browser with single result' { + Mock -CommandName LaunchOnlineHelp -ModuleName PSRule -Verifiable; + $Null = Get-PSRuleHelp -Module 'TestModule' -Name 'M1.Rule1' -Online; + Assert-VerifiableMock; + } + + It 'Returns collection' { + Mock -CommandName LaunchOnlineHelp -ModuleName PSRule; + $result = @(Get-PSRuleHelp -Module 'TestModule' -Online); + Assert-MockCalled -CommandName LaunchOnlineHelp -ModuleName PSRule -Times 0 -Exactly -Scope It; + $result.Length | Should -Be 2; + } + } + + Context 'With constrained language' { + It 'Checks if DeviceGuard is enabled' { + Mock -CommandName IsDeviceGuardEnabled -ModuleName PSRule -Verifiable -MockWith { + return $True; + } + + $Null = Get-PSRuleHelp -Path $ruleFilePath -Name 'ConstrainedTest1'; + Assert-MockCalled -CommandName IsDeviceGuardEnabled -ModuleName PSRule -Times 1; + } + + # Check that '[Console]::WriteLine('Should fail')' is not executed + It 'Should fail to execute blocked code' { + { $Null = Get-PSRuleHelp -Path (Join-Path -Path $here -ChildPath 'UnconstrainedFile.Rule.ps1') -Name 'UnconstrainedFile1' -Option @{ 'execution.mode' = 'ConstrainedLanguage' } -ErrorAction Stop } | Should -Throw 'Cannot invoke method. Method invocation is supported only on core types in this language mode.'; + } + } +} + +#endregion Get-PSRuleHelp diff --git a/tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md b/tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md index e2f94959f0..58bca6c2c2 100644 --- a/tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md +++ b/tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md @@ -1,5 +1,6 @@ --- culture: en-AU +online version: https://github.com/BernieWhite/PSRule --- # M1.Rule1 diff --git a/tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md b/tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md index 93ed0e7d34..4daaa30725 100644 --- a/tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md +++ b/tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md @@ -1,5 +1,6 @@ --- culture: en-US +online version: https://github.com/BernieWhite/PSRule --- # M1.Rule1 diff --git a/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 b/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 index 3ed6cc52d3..44e0b9783f 100644 --- a/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 +++ b/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 @@ -6,3 +6,8 @@ Rule 'M1.Rule1' { # This is a test rule } + +# Description: This is the default +Rule 'M1.Rule2' { + # This is a test rule +} From 2a59053308c8ed6ae82bfd3e2a781c7c4c7b4a82 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Thu, 30 May 2019 21:27:09 +1000 Subject: [PATCH 08/26] Fix dotnet build version --- PSRule.build.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PSRule.build.ps1 b/PSRule.build.ps1 index 79f8c98d6f..fafb924d3f 100644 --- a/PSRule.build.ps1 +++ b/PSRule.build.ps1 @@ -1,7 +1,7 @@ param ( [Parameter(Mandatory = $False)] - [String]$ModuleVersion, + [String]$ModuleVersion = '0.0.1', [Parameter(Mandatory = $False)] [AllowNull()] @@ -60,7 +60,8 @@ function CopyModuleFiles { task BuildDotNet { exec { # Build library - dotnet publish src/PSRule -p:versionPrefix=$ModuleVersion -c $Configuration -f netstandard2.0 -o $(Join-Path -Path $PWD -ChildPath out/modules/PSRule) + # Add build version -p:versionPrefix=$ModuleVersion + dotnet publish src/PSRule -c $Configuration -f netstandard2.0 -o $(Join-Path -Path $PWD -ChildPath out/modules/PSRule) } } From edd9b37f1c8f57432c2691501238d495ace4d1c0 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 31 May 2019 08:48:45 +1000 Subject: [PATCH 09/26] Fix compile error --- src/PSRule/Commands/NewRuleDefinitionCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PSRule/Commands/NewRuleDefinitionCommand.cs b/src/PSRule/Commands/NewRuleDefinitionCommand.cs index 69ad3818c1..9ba08601bb 100644 --- a/src/PSRule/Commands/NewRuleDefinitionCommand.cs +++ b/src/PSRule/Commands/NewRuleDefinitionCommand.cs @@ -78,7 +78,7 @@ protected override void ProcessRecord() PipelineContext.EnableLogging(ps); - var doc = GetDoc(context: context, Name); + var doc = GetDoc(context: context, name: Name); var block = new RuleBlock( sourcePath: MyInvocation.ScriptName, From 2a7d416fb8cacb71de694dd639215a5892693e4f Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 31 May 2019 09:12:02 +1000 Subject: [PATCH 10/26] Fix coverage variable --- PSRule.build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PSRule.build.ps1 b/PSRule.build.ps1 index fafb924d3f..8f8e9e98e7 100644 --- a/PSRule.build.ps1 +++ b/PSRule.build.ps1 @@ -23,7 +23,7 @@ param ( [String]$ArtifactPath = (Join-Path -Path $PWD -ChildPath out/modules) ) -if ($Env:COVERAGE -eq 'true') { +if ($Env:coverage -eq 'true') { $CodeCoverage = $True; } From 7a710663b999e0ad898065fa4b9dff72c69e0f11 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 31 May 2019 12:37:13 +1000 Subject: [PATCH 11/26] Update README.md Fix link to Get-PSRuleHelp --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d313f2d7e9..51bbc30332 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ The following language keywords are used by the `PSRule` module: The following commands exist in the `PSRule` module: - [Get-PSRule](docs/commands/PSRule/en-US/Get-PSRule.md) - Get a list of rule definitions. -- [Get-PSRuleHelp](docs/commands/PSRule/en-US/Get-PSRuleHelp) - Get documentation for a rule. +- [Get-PSRuleHelp](docs/commands/PSRule/en-US/Get-PSRuleHelp.md) - Get documentation for a rule. - [Invoke-PSRule](docs/commands/PSRule/en-US/Invoke-PSRule.md) - Evaluate objects against matching rules. - [New-PSRuleOption](docs/commands/PSRule/en-US/New-PSRuleOption.md) - Create options to configure PSRule execution. - [Set-PSRuleOption](docs/commands/PSRule/en-US/Set-PSRuleOption.md) - Sets options that configure PSRule execution. From 43df1fc6180418b1a27742be7160144f8ed30d5c Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 31 May 2019 13:25:40 +1000 Subject: [PATCH 12/26] Fix bug in markdown parser --- src/PSRule/Parser/MarkdownStream.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index 56c552b4fc..359d8b53c9 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -413,7 +413,7 @@ private void UpdateCurrent(bool ignoreEscaping = false) private int GetEscapeCount(int position) { // Check for escape sequences - if (_Markdown[position] == Backslash && position < _Length) + if (position < _Length && _Markdown[position] == Backslash) { var next = _Markdown[position + 1]; From 30aa014b20ab057224ce358a3fc5f16340e06da0 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 31 May 2019 20:01:20 +1000 Subject: [PATCH 13/26] Add 0 postion range check --- src/PSRule/Parser/MarkdownStream.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index 359d8b53c9..bf252e782b 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -413,7 +413,7 @@ private void UpdateCurrent(bool ignoreEscaping = false) private int GetEscapeCount(int position) { // Check for escape sequences - if (position < _Length && _Markdown[position] == Backslash) + if (position >= 0 && position < _Length && _Markdown[position] == Backslash) { var next = _Markdown[position + 1]; From 544e1ac4d530c802a513d3c91cf0e20ee429d218 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 31 May 2019 20:53:27 +1000 Subject: [PATCH 14/26] Update to fix int defaults --- src/PSRule/Parser/MarkdownReader.cs | 1 - src/PSRule/Parser/MarkdownStream.cs | 22 ++++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/PSRule/Parser/MarkdownReader.cs b/src/PSRule/Parser/MarkdownReader.cs index f42f8c11b3..91d11c2166 100644 --- a/src/PSRule/Parser/MarkdownReader.cs +++ b/src/PSRule/Parser/MarkdownReader.cs @@ -71,7 +71,6 @@ internal MarkdownReader(bool yamlHeaderOnly) public TokenStream Read(string markdown, string path) { _Context = MarkdownReaderMode.None; - _Stream = new MarkdownStream(markdown); YamlHeader(); diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index bf252e782b..c808b96f40 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -58,17 +58,21 @@ private sealed class StreamCursor } private readonly string _Markdown; + private readonly int _Length; /// /// The current character position in the markdown string. Call Next() to change the position. /// - private int _Position = 0; + private int _Position; + private int _Line; + private int _Column; + private char _Current; + private char _Previous; + private int _EscapeLength; - private int _Line = 0; - private int _Column = 0; - private int _Length; private int? _ExtentMarker; private StreamCursor _Checkpoint; + private const char NewLine = '\n'; private const char CarrageReturn = '\r'; public const char Dash = '-'; @@ -84,17 +88,15 @@ private sealed class StreamCursor public const char Backslash = '\\'; public const string TripleBacktick = "```"; public const string NewLineTripleBacktick = "\r\n```"; - public static char[] NewLineStopCharacters = new char[] { '\r', '\n' }; public const char EqualSign = '='; - public static char[] UnorderListCharacters = new char[] { '-', '*' }; - private char _Current; - private char _Previous; - private int _EscapeLength; + public readonly static char[] NewLineStopCharacters = new char[] { '\r', '\n' }; + public readonly static char[] UnorderListCharacters = new char[] { '-', '*' }; public MarkdownStream(string markdown) { _Markdown = markdown; _Length = _Markdown.Length; + _Position = _Line = _Column = _EscapeLength = 0; UpdateCurrent(); @@ -126,7 +128,7 @@ public char Current public char Previous { - get { return _Previous;} + get { return _Previous; } } public int Line From 12b30baf3e40c6d73e50d4a7ddedf99a82f42232 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 00:50:22 +1000 Subject: [PATCH 15/26] Prevent escaping negative position --- src/PSRule/Parser/MarkdownStream.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index c808b96f40..298e121df5 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -406,6 +406,7 @@ public bool Next(bool ignoreEscaping = false) private void UpdateCurrent(bool ignoreEscaping = false) { // Handle escape sequences + _Position = _Position < 0 ? 0 : _Position; _EscapeLength = ignoreEscaping ? 0 : GetEscapeCount(_Position); _Previous = _Current; From 68cfb95c6ab5fe0dc633db0756151ab2b5d4a8e9 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 10:01:33 +1000 Subject: [PATCH 16/26] Add diagnostic exception for range checking --- src/PSRule/Parser/MarkdownStream.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index 298e121df5..18c0b4f55c 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System; +using System.Diagnostics; using System.Linq; namespace PSRule.Parser @@ -398,6 +399,15 @@ public bool Next(bool ignoreEscaping = false) _Column += _EscapeLength + 1; } + if (_Position < 0) + { + throw new IndexOutOfRangeException("Position can not be < zero."); + } + else if (_Position >= _Length) + { + throw new IndexOutOfRangeException("Position can not be >= length."); + } + UpdateCurrent(ignoreEscaping); return true; @@ -406,7 +416,6 @@ public bool Next(bool ignoreEscaping = false) private void UpdateCurrent(bool ignoreEscaping = false) { // Handle escape sequences - _Position = _Position < 0 ? 0 : _Position; _EscapeLength = ignoreEscaping ? 0 : GetEscapeCount(_Position); _Previous = _Current; @@ -416,7 +425,7 @@ private void UpdateCurrent(bool ignoreEscaping = false) private int GetEscapeCount(int position) { // Check for escape sequences - if (position >= 0 && position < _Length && _Markdown[position] == Backslash) + if (position < _Length && _Markdown[position] == Backslash) { var next = _Markdown[position + 1]; From 1d3d5e12dab551c9d77e3c312fb9d37e08410da7 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 10:12:21 +1000 Subject: [PATCH 17/26] Add diagnosics --- src/PSRule/Parser/MarkdownStream.cs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index 18c0b4f55c..222a9597b5 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -399,15 +399,6 @@ public bool Next(bool ignoreEscaping = false) _Column += _EscapeLength + 1; } - if (_Position < 0) - { - throw new IndexOutOfRangeException("Position can not be < zero."); - } - else if (_Position >= _Length) - { - throw new IndexOutOfRangeException("Position can not be >= length."); - } - UpdateCurrent(ignoreEscaping); return true; @@ -417,7 +408,16 @@ private void UpdateCurrent(bool ignoreEscaping = false) { // Handle escape sequences _EscapeLength = ignoreEscaping ? 0 : GetEscapeCount(_Position); - + + if (_Position + _EscapeLength < 0) + { + throw new IndexOutOfRangeException($"Position can not be < zero. Position={_Position.ToString()}, EscapeLength={_EscapeLength.ToString()}""); + } + else if (_Position + _EscapeLength >= _Length) + { + throw new IndexOutOfRangeException($"Position can not be >= length. Position={_Position.ToString()}, EscapeLength={_EscapeLength.ToString()}"); + } + _Previous = _Current; _Current = _Markdown[_Position + _EscapeLength]; } @@ -425,7 +425,7 @@ private void UpdateCurrent(bool ignoreEscaping = false) private int GetEscapeCount(int position) { // Check for escape sequences - if (position < _Length && _Markdown[position] == Backslash) + if (position >= 0 && position < _Length && _Markdown[position] == Backslash) { var next = _Markdown[position + 1]; From d127f7371f11f81c84f8197998a49d2ebfcb5d16 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 10:14:08 +1000 Subject: [PATCH 18/26] Fix typo --- src/PSRule/Parser/MarkdownStream.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index 222a9597b5..272de4b564 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -411,7 +411,7 @@ private void UpdateCurrent(bool ignoreEscaping = false) if (_Position + _EscapeLength < 0) { - throw new IndexOutOfRangeException($"Position can not be < zero. Position={_Position.ToString()}, EscapeLength={_EscapeLength.ToString()}""); + throw new IndexOutOfRangeException($"Position can not be < zero. Position={_Position.ToString()}, EscapeLength={_EscapeLength.ToString()}"); } else if (_Position + _EscapeLength >= _Length) { From 43d25f45f788cd3a1cae5fe3fcd331012f31d21f Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 10:44:45 +1000 Subject: [PATCH 19/26] Check escape length --- src/PSRule/Parser/MarkdownStream.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index 272de4b564..f935b9f52e 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -97,7 +97,10 @@ public MarkdownStream(string markdown) { _Markdown = markdown; _Length = _Markdown.Length; - _Position = _Line = _Column = _EscapeLength = 0; + _Position = 0; + _Line = 0; + _Column = 0; + _EscapeLength = 0; UpdateCurrent(); @@ -379,7 +382,7 @@ public void Rollback() /// Is True when more characters exist in the stream. public bool Next(bool ignoreEscaping = false) { - _Position += _EscapeLength + 1; + _Position += _EscapeLength > 0 ? _EscapeLength + 1 : 1; if (_Position >= _Length) { From 8af5f02430a18ad529cc25d1dd2806a86253114b Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 12:00:52 +1000 Subject: [PATCH 20/26] Fix parser handling on line endings and testing --- docs/commands/PSRule/en-US/Get-PSRuleHelp.md | 14 +++++-- src/PSRule/Parser/MarkdownReader.cs | 2 +- src/PSRule/Parser/MarkdownStream.cs | 10 +++-- tests/PSRule.Tests/RuleDocumentTests.cs | 43 ++++++++++++++++---- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/commands/PSRule/en-US/Get-PSRuleHelp.md b/docs/commands/PSRule/en-US/Get-PSRuleHelp.md index a0d91b68dc..b1e709c2fc 100644 --- a/docs/commands/PSRule/en-US/Get-PSRuleHelp.md +++ b/docs/commands/PSRule/en-US/Get-PSRuleHelp.md @@ -27,10 +27,18 @@ Get documentation for a rule. ### Example 1 ```powershell -PS C:\> Get-PSRuleHelp Azure.ACR.AdminUser; +Get-PSRuleHelp Azure.ACR.AdminUser; ``` -Get rule documentation for the imported rule `Azure.ACR.AdminUser`. +Get rule documentation for the rule `Azure.ACR.AdminUser`. + +### Example 2 + +```powershell +Get-PSRuleHelp Azure.ACR.AdminUser -Online; +``` + +Browse to the online version of documentation for `Azure.ACR.AdminUser` using the default web browser. ## PARAMETERS @@ -122,8 +130,6 @@ Accept wildcard characters: False This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). -## INPUTS - ## OUTPUTS ### PSRule.Rules.RuleHelpInfo diff --git a/src/PSRule/Parser/MarkdownReader.cs b/src/PSRule/Parser/MarkdownReader.cs index 91d11c2166..d3e8d96cb5 100644 --- a/src/PSRule/Parser/MarkdownReader.cs +++ b/src/PSRule/Parser/MarkdownReader.cs @@ -113,7 +113,7 @@ private void YamlHeader() _Stream.Skip(count + 1); _Stream.SkipLineEnding(); - while (!_Stream.IsSequence(TripleDash, onNewLine: true)) + while (!_Stream.EOF && !_Stream.IsSequence(TripleDash, onNewLine: true)) { var key = _Stream.CaptureUntil(YamlHeaderStopCharacters).Trim(); diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index f935b9f52e..84248a96a4 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -49,6 +49,7 @@ public string Text } } + [DebuggerDisplay("Position = {Position}, Current = {Current}")] internal sealed class MarkdownStream { private sealed class StreamCursor @@ -198,16 +199,17 @@ public int SkipLineEnding(int max = 1, bool ignoreEscaping = false) while ((Current == CarrageReturn || Current == NewLine) && (max == 0 || skipped < max)) { - if (Current == CarrageReturn && (Remaining == 0 || Peak() != NewLine)) + if (Remaining == 0) { break; } - else + + if (Current == CarrageReturn && Peak() == NewLine) { Next(); } - Next(ignoreEscaping); + Next(ignoreEscaping: ignoreEscaping); skipped++; } @@ -567,7 +569,7 @@ private string Substring(int start, int length, bool ignoreEscaping = false) /// Returns the captured text up until the end of the line. public string CaptureLine() { - return CaptureUntil("\r\n"); + return CaptureUntil(NewLineStopCharacters); } public bool IsSequence(string sequence, bool onNewLine = false) diff --git a/tests/PSRule.Tests/RuleDocumentTests.cs b/tests/PSRule.Tests/RuleDocumentTests.cs index c4a92285f0..4812dcd5fe 100644 --- a/tests/PSRule.Tests/RuleDocumentTests.cs +++ b/tests/PSRule.Tests/RuleDocumentTests.cs @@ -8,9 +8,9 @@ namespace PSRule public sealed class RuleDocumentTests { [Fact] - public void ReadDocument() + public void ReadDocument_Windows() { - var document = GetDocument(); + var document = GetDocument(GetToken(nx: false)); Assert.Equal("Kubernetes.Deployment.NotLatestImage", document.Name); Assert.Equal("Containers should use specific tags instead of latest.", document.Synopsis.Text); @@ -23,17 +23,46 @@ public void ReadDocument() Assert.Equal("Pod security", document.Annotations["category"]); } - private RuleDocument GetDocument() + [Fact] + public void ReadDocument_Linux() + { + var document = GetDocument(GetToken(nx: true)); + + Assert.Equal("Kubernetes.Deployment.NotLatestImage", document.Name); + Assert.Equal("Containers should use specific tags instead of latest.", document.Synopsis.Text); + Assert.Single(document.Recommendation); + Assert.Equal(@"Deployments or pods should identify a specific tag to use for container images instead of latest. When latest is used it may be hard to determine which version of the image is running. +When using variable tags such as v1.0 (which may refer to v1.0.0 or v1.0.1) consider using imagePullPolicy: Always to ensure that the an out-of-date cached image is not used. +The latest tag automatically uses imagePullPolicy: Always instead of the default imagePullPolicy: IfNotPresent." + , document.Recommendation[0].Introduction); + Assert.Equal("Critical", document.Annotations["severity"]); + Assert.Equal("Pod security", document.Annotations["category"]); + } + + private RuleDocument GetDocument(TokenStream stream) { - var tokens = GetToken(); var lexer = new RuleLexer(preserveFomatting: false); - return lexer.Process(stream: tokens); + return lexer.Process(stream: stream); } - private TokenStream GetToken() + private TokenStream GetToken(bool nx) { var reader = new MarkdownReader(yamlHeaderOnly: false); - return reader.Read(GetMarkdownContent(), Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "RuleDocument.md")); + var content = GetMarkdownContent(); + + if (nx) + { + content = content.Replace("\r\n", "\n"); + } + else + { + if (!content.Contains("\r\n")) + { + content = content.Replace("\n", "\r\n"); + } + } + + return reader.Read(content, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "RuleDocument.md")); } private string GetMarkdownContent() From acdcb59a744c73c498a6a9c7fab33a011b9f1672 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 18:03:11 +1000 Subject: [PATCH 21/26] Impove line handling --- src/PSRule/Parser/MarkdownStream.cs | 3 ++ src/PSRule/Parser/RuleLexer.cs | 4 +- tests/PSRule.Tests/PSRule.Tests.csproj | 4 +- tests/PSRule.Tests/RuleDocumentTests.cs | 49 ++++++++++++++++++------- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index 84248a96a4..003b01b192 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -75,6 +75,9 @@ private sealed class StreamCursor private int? _ExtentMarker; private StreamCursor _Checkpoint; + // The maximum length of a markdown document. ~32 MB in UTF-8 + private const int MaxLength = 4194304; + private const char NewLine = '\n'; private const char CarrageReturn = '\r'; public const char Dash = '-'; diff --git a/src/PSRule/Parser/RuleLexer.cs b/src/PSRule/Parser/RuleLexer.cs index 62718f3197..92316016d8 100644 --- a/src/PSRule/Parser/RuleLexer.cs +++ b/src/PSRule/Parser/RuleLexer.cs @@ -262,11 +262,11 @@ private void AppendEnding(StringBuilder stringBuilder, MarkdownToken token, bool if (token.IsDoubleLineEnding()) { - stringBuilder.Append(preserveEnding ? "\r\n\r\n" : "\r\n"); + stringBuilder.Append(preserveEnding ? string.Concat(Environment.NewLine, Environment.NewLine) : Environment.NewLine); } else if (token.IsSingleLineEnding()) { - stringBuilder.Append(preserveEnding ? "\r\n" : " "); + stringBuilder.Append(preserveEnding ? Environment.NewLine : " "); } } diff --git a/tests/PSRule.Tests/PSRule.Tests.csproj b/tests/PSRule.Tests/PSRule.Tests.csproj index ad8e6a3479..67ffa160aa 100644 --- a/tests/PSRule.Tests/PSRule.Tests.csproj +++ b/tests/PSRule.Tests/PSRule.Tests.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/tests/PSRule.Tests/RuleDocumentTests.cs b/tests/PSRule.Tests/RuleDocumentTests.cs index 4812dcd5fe..22af211603 100644 --- a/tests/PSRule.Tests/RuleDocumentTests.cs +++ b/tests/PSRule.Tests/RuleDocumentTests.cs @@ -1,5 +1,7 @@ using PSRule.Parser; +using PSRule.Rules; using System; +using System.Collections; using System.IO; using Xunit; @@ -11,32 +13,51 @@ public sealed class RuleDocumentTests public void ReadDocument_Windows() { var document = GetDocument(GetToken(nx: false)); + var expected = GetExpected(nx: false); - Assert.Equal("Kubernetes.Deployment.NotLatestImage", document.Name); - Assert.Equal("Containers should use specific tags instead of latest.", document.Synopsis.Text); + Assert.Equal(expected.Name, document.Name); + Assert.Equal(expected.Synopsis.Text, document.Synopsis.Text); Assert.Single(document.Recommendation); - Assert.Equal(@"Deployments or pods should identify a specific tag to use for container images instead of latest. When latest is used it may be hard to determine which version of the image is running. -When using variable tags such as v1.0 (which may refer to v1.0.0 or v1.0.1) consider using imagePullPolicy: Always to ensure that the an out-of-date cached image is not used. -The latest tag automatically uses imagePullPolicy: Always instead of the default imagePullPolicy: IfNotPresent." - , document.Recommendation[0].Introduction); - Assert.Equal("Critical", document.Annotations["severity"]); - Assert.Equal("Pod security", document.Annotations["category"]); + Assert.Equal(expected.Recommendation[0].Introduction, document.Recommendation[0].Introduction); + Assert.Equal(expected.Annotations["severity"], document.Annotations["severity"]); + Assert.Equal(expected.Annotations["category"], document.Annotations["category"]); } [Fact] public void ReadDocument_Linux() { var document = GetDocument(GetToken(nx: true)); + var expected = GetExpected(nx: true); - Assert.Equal("Kubernetes.Deployment.NotLatestImage", document.Name); - Assert.Equal("Containers should use specific tags instead of latest.", document.Synopsis.Text); + Assert.Equal(expected.Name, document.Name); + Assert.Equal(expected.Synopsis.Text, document.Synopsis.Text); Assert.Single(document.Recommendation); - Assert.Equal(@"Deployments or pods should identify a specific tag to use for container images instead of latest. When latest is used it may be hard to determine which version of the image is running. + Assert.Equal(expected.Recommendation[0].Introduction, document.Recommendation[0].Introduction); + Assert.Equal(expected.Annotations["severity"], document.Annotations["severity"]); + Assert.Equal(expected.Annotations["category"], document.Annotations["category"]); + } + + private RuleDocument GetExpected(bool nx) + { + var annotations = new Hashtable(); + annotations["severity"] = "Critical"; + annotations["category"] = "Pod security"; + var recommendation = new RuleRecommendation + { + Introduction = @"Deployments or pods should identify a specific tag to use for container images instead of latest. When latest is used it may be hard to determine which version of the image is running. When using variable tags such as v1.0 (which may refer to v1.0.0 or v1.0.1) consider using imagePullPolicy: Always to ensure that the an out-of-date cached image is not used. The latest tag automatically uses imagePullPolicy: Always instead of the default imagePullPolicy: IfNotPresent." - , document.Recommendation[0].Introduction); - Assert.Equal("Critical", document.Annotations["severity"]); - Assert.Equal("Pod security", document.Annotations["category"]); + }; + + var result = new RuleDocument + { + Name = "Kubernetes.Deployment.NotLatestImage", + Synopsis = new Body("Containers should use specific tags instead of latest."), + Annotations = TagSet.FromHashtable(annotations), + Recommendation = new RuleRecommendation[] { recommendation } + }; + + return result; } private RuleDocument GetDocument(TokenStream stream) From ae18ea50cfc159d9c1f5af835fb920d5a08e4055 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 18:06:20 +1000 Subject: [PATCH 22/26] Fix typo --- tests/PSRule.Tests/PSRule.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PSRule.Tests/PSRule.Tests.csproj b/tests/PSRule.Tests/PSRule.Tests.csproj index 67ffa160aa..38cc57a555 100644 --- a/tests/PSRule.Tests/PSRule.Tests.csproj +++ b/tests/PSRule.Tests/PSRule.Tests.csproj @@ -10,7 +10,7 @@ - + From 0fad07644af05a5277920ffe7a19d24a7693a529 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 18:26:37 +1000 Subject: [PATCH 23/26] Update CI pipeline --- azure-pipelines.yml => .azure-pipelines/azure-pipelines.yaml | 4 ++-- {scripts => .azure-pipelines}/pipeline-build.ps1 | 0 PSRule.build.ps1 => pipeline.build.ps1 | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) rename azure-pipelines.yml => .azure-pipelines/azure-pipelines.yaml (89%) rename {scripts => .azure-pipelines}/pipeline-build.ps1 (100%) rename PSRule.build.ps1 => pipeline.build.ps1 (99%) diff --git a/azure-pipelines.yml b/.azure-pipelines/azure-pipelines.yaml similarity index 89% rename from azure-pipelines.yml rename to .azure-pipelines/azure-pipelines.yaml index a436ab39a9..46c7aa96f8 100644 --- a/azure-pipelines.yml +++ b/.azure-pipelines/azure-pipelines.yaml @@ -29,11 +29,11 @@ pool: steps: # Install pipeline dependencies and build module -- powershell: ./scripts/pipeline-build.ps1 -File ./PSRule.build.ps1 -Configuration $(buildConfiguration) -ModuleVersion $(Build.BuildNumber) -ReleaseVersion "$(Release.Version)" +- powershell: ./.azure-pipelines/pipeline-build.ps1 -File ./pipeline.build.ps1 -Configuration $(buildConfiguration) -ModuleVersion $(Build.BuildNumber) -ReleaseVersion "$(Release.Version)" displayName: 'Build module' # Run module benchmark -- powershell: ./scripts/pipeline-build.ps1 -Task Benchmark -File ./PSRule.build.ps1 -Configuration $(buildConfiguration) -ModuleVersion $(Build.BuildNumber) -ReleaseVersion "$(Release.Version)" +- powershell: ./.azure-pipelines/pipeline-build.ps1 -Task Benchmark -File ./pipeline.build.ps1 -Configuration $(buildConfiguration) -ModuleVersion $(Build.BuildNumber) -ReleaseVersion "$(Release.Version)" displayName: 'Benchmark' condition: eq(variables['benchmark'], 'true') diff --git a/scripts/pipeline-build.ps1 b/.azure-pipelines/pipeline-build.ps1 similarity index 100% rename from scripts/pipeline-build.ps1 rename to .azure-pipelines/pipeline-build.ps1 diff --git a/PSRule.build.ps1 b/pipeline.build.ps1 similarity index 99% rename from PSRule.build.ps1 rename to pipeline.build.ps1 index 8f8e9e98e7..3251b7c4ea 100644 --- a/PSRule.build.ps1 +++ b/pipeline.build.ps1 @@ -107,7 +107,6 @@ task Clean { } task VersionModule { - if (![String]::IsNullOrEmpty($ReleaseVersion)) { Write-Verbose -Message "[VersionModule] -- ReleaseVersion: $ReleaseVersion"; $ModuleVersion = $ReleaseVersion; From 6a551350f286e0245f246f79353f7aaed19411aa Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 18:46:15 +1000 Subject: [PATCH 24/26] Update build script version handling --- pipeline.build.ps1 | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pipeline.build.ps1 b/pipeline.build.ps1 index 3251b7c4ea..4f26cea895 100644 --- a/pipeline.build.ps1 +++ b/pipeline.build.ps1 @@ -1,4 +1,4 @@ - +[CmdletBinding()] param ( [Parameter(Mandatory = $False)] [String]$ModuleVersion = '0.0.1', @@ -23,10 +23,26 @@ param ( [String]$ArtifactPath = (Join-Path -Path $PWD -ChildPath out/modules) ) +Write-Host -Object "[Pipeline] -- PWD: $PWD" -ForegroundColor Green; +Write-Host -Object "[Pipeline] -- ArtifactPath: $ArtifactPath" -ForegroundColor Green; +Write-Host -Object "[Pipeline] -- BuildNumber: $($Env:BUILD_BUILDNUMBER)" -ForegroundColor Green; +Write-Host -Object "[Pipeline] -- SourceBranch: $($Env:BUILD_SOURCEBRANCH)" -ForegroundColor Green; +Write-Host -Object "[Pipeline] -- SourceBranchName: $($Env:BUILD_SOURCEBRANCHNAME)" -ForegroundColor Green; + +if ($Env:SYSTEM_DEBUG -eq 'true') { + $VerbosePreference = 'Continue'; +} + if ($Env:coverage -eq 'true') { $CodeCoverage = $True; } +if ($Env:BUILD_SOURCEBRANCH -contains '/tags/' -and $Env:BUILD_SOURCEBRANCHNAME -like "v0.") { + $ModuleVersion = $Env:BUILD_SOURCEBRANCHNAME.Substring(1); +} + +Write-Host -Object "[Pipeline] -- ModuleVersion: $ModuleVersion" -ForegroundColor Green; + # Copy the PowerShell modules files to the destination path function CopyModuleFiles { @@ -112,7 +128,7 @@ task VersionModule { $ModuleVersion = $ReleaseVersion; } - if ($PSBoundParameters.ContainsKey('ModuleVersion') -and ![String]::IsNullOrEmpty($ModuleVersion)) { + if ($PSBoundParameters.ContainsKey('ModuleVersion')) { Write-Verbose -Message "[VersionModule] -- ModuleVersion: $ModuleVersion"; $version = $ModuleVersion; From 7e98ee1fe4ea96ea7a6c14e815aa5292c85448f8 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sat, 1 Jun 2019 18:57:18 +1000 Subject: [PATCH 25/26] Update versioning --- pipeline.build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipeline.build.ps1 b/pipeline.build.ps1 index 4f26cea895..2ecaf76b21 100644 --- a/pipeline.build.ps1 +++ b/pipeline.build.ps1 @@ -128,7 +128,7 @@ task VersionModule { $ModuleVersion = $ReleaseVersion; } - if ($PSBoundParameters.ContainsKey('ModuleVersion')) { + if (![String]::IsNullOrEmpty($ModuleVersion)) { Write-Verbose -Message "[VersionModule] -- ModuleVersion: $ModuleVersion"; $version = $ModuleVersion; From 0736c1a77fa0529d31fe07ae884b7232552fef75 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Sun, 2 Jun 2019 21:24:35 +1000 Subject: [PATCH 26/26] Optimisation --- .../Commands/NewRuleDefinitionCommand.cs | 4 +- src/PSRule/Parser/MarkdownReader.cs | 36 +++----- src/PSRule/Parser/MarkdownStream.cs | 80 +++++++--------- src/PSRule/Parser/RuleLexer.cs | 91 ++++++------------- src/PSRule/Parser/RuleModels.cs | 66 +++++++------- src/PSRule/Parser/TokenStream.cs | 5 + tests/PSRule.Tests/RuleDocumentTests.cs | 30 +++--- 7 files changed, 123 insertions(+), 189 deletions(-) diff --git a/src/PSRule/Commands/NewRuleDefinitionCommand.cs b/src/PSRule/Commands/NewRuleDefinitionCommand.cs index 9ba08601bb..ab937dde86 100644 --- a/src/PSRule/Commands/NewRuleDefinitionCommand.cs +++ b/src/PSRule/Commands/NewRuleDefinitionCommand.cs @@ -85,7 +85,7 @@ protected override void ProcessRecord() moduleName: moduleName, ruleName: Name, description: doc == null ? metadata.Description : doc.Synopsis.Text, - recommendation: doc != null ? doc.Recommendation[0].Introduction : null, + recommendation: doc != null ? doc.Recommendation.Text : null, condition: ps, tag: tag, annotations: doc?.Annotations, @@ -114,7 +114,7 @@ private Parser.RuleDocument GetDoc(PipelineContext context, string name) var reader = new MarkdownReader(yamlHeaderOnly: false); var stream = reader.Read(markdown: File.ReadAllText(path: path), path: path); - var lexer = new RuleLexer(preserveFomatting: false); + var lexer = new RuleLexer(); return lexer.Process(stream: stream); } diff --git a/src/PSRule/Parser/MarkdownReader.cs b/src/PSRule/Parser/MarkdownReader.cs index d3e8d96cb5..c7b728766e 100644 --- a/src/PSRule/Parser/MarkdownReader.cs +++ b/src/PSRule/Parser/MarkdownReader.cs @@ -1,6 +1,4 @@ -using System; - -namespace PSRule.Parser +namespace PSRule.Parser { internal enum MarkdownReaderMode { @@ -9,20 +7,6 @@ internal enum MarkdownReaderMode List } - /// - /// Define options that determine how sections will be formated when rendering markdown. - /// - [Flags()] - public enum SectionFormatOption : byte - { - None = 0, - - /// - /// A line break should be added after the section header. - /// - LineBreakAfterHeader = 1 - } - /// /// Stateful markdown reader. /// @@ -37,7 +21,6 @@ internal sealed class MarkdownReader private readonly bool _PreserveFormatting; private MarkdownReaderMode _Context; - private MarkdownStream _Stream; /// @@ -45,6 +28,7 @@ internal sealed class MarkdownReader /// private readonly static char[] LineEndingCharacters = new char[] { '\r', '\n' }; + private const char Hash = '#'; private const char Asterix = '*'; private const char Backtick = '`'; private const char Underscore = '_'; @@ -55,6 +39,8 @@ internal sealed class MarkdownReader private const char BracketClose = ']'; private const char ParenthesesOpen = '('; private const char ParenthesesClose = ')'; + private const char EqualSign = '='; + private const string TripleBacktick = "```"; private static readonly char[] LinkNameStopCharacters = new char[] { '\r', '\n', ']' }; private static readonly char[] LinkUrlStopCharacters = new char[] { '\r', '\n', ')' }; private static readonly char[] YamlHeaderStopCharacters = new char[] { '\r', '\n', ':' }; @@ -140,7 +126,7 @@ private void YamlHeader() private bool UnderlineHeader() { - if ((_Stream.Current != MarkdownStream.Dash && _Stream.Current != MarkdownStream.EqualSign) || !_Stream.IsStartOfLine) + if ((_Stream.Current != Dash && _Stream.Current != EqualSign) || !_Stream.IsStartOfLine) { return false; } @@ -160,7 +146,7 @@ private bool UnderlineHeader() _Stream.Skip(count + 1); - _Output.Header(currentChar == MarkdownStream.EqualSign ? 1 : 2, previousToken.Text, null, lineBreak: (_Stream.SkipLineEnding(max: 0) > 1)); + _Output.Header(currentChar == EqualSign ? 1 : 2, previousToken.Text, null, lineBreak: (_Stream.SkipLineEnding(max: 0) > 1)); return true; } @@ -173,7 +159,7 @@ private bool UnderlineHeader() /// private bool HashHeader() { - if (_Stream.Current != MarkdownStream.Hash || !_Stream.IsStartOfLine) + if (_Stream.Current != Hash || !_Stream.IsStartOfLine) { return false; } @@ -182,7 +168,7 @@ private bool HashHeader() _Stream.Next(); // Get the header depth - var headerDepth = _Stream.Skip(MarkdownStream.Hash, max: 0) + 1; + var headerDepth = _Stream.Skip(Hash, max: 0) + 1; // Capture to the end of the line _Stream.SkipWhitespace(); @@ -200,7 +186,7 @@ private bool HashHeader() /// private bool FencedBlock() { - if (_Stream.Current != MarkdownStream.Backtick || !_Stream.IsSequence(MarkdownStream.TripleBacktick, onNewLine: true)) + if (_Stream.Current != Backtick || !_Stream.IsSequence(TripleBacktick, onNewLine: true)) { return false; } @@ -215,10 +201,10 @@ private bool FencedBlock() _Stream.SkipLineEnding(); // Capture text within code fence - var text = _Stream.CaptureUntil(MarkdownStream.TripleBacktick, onNewLine: true, ignoreEscaping: true); + var text = _Stream.CaptureUntil(TripleBacktick, onNewLine: true, ignoreEscaping: true); // Skip backticks - _Stream.Skip(MarkdownStream.TripleBacktick); + _Stream.Skip(TripleBacktick); // Write code fence beginning _Output.FencedBlock(info, text, null, lineBreak: _Stream.SkipLineEnding(max: 0) > 1); diff --git a/src/PSRule/Parser/MarkdownStream.cs b/src/PSRule/Parser/MarkdownStream.cs index 003b01b192..3ae9c40a26 100644 --- a/src/PSRule/Parser/MarkdownStream.cs +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -9,13 +9,15 @@ namespace PSRule.Parser [DebuggerDisplay("StartPos = (L: {Start}, C: {Column}), EndPos = (L: {End}, C: {Column.End}), Text = {Text}")] public sealed class SourceExtent { + private readonly string _Source; + + // Lazily cache extracted text private string _Text; - internal SourceExtent(string markdown, string path, int start, int end, int line, int column) + internal SourceExtent(string source, string path, int start, int end, int line, int column) { _Text = null; - - Markdown = markdown; + _Source = source; Path = path; Start = start; End = end; @@ -23,17 +25,15 @@ internal SourceExtent(string markdown, string path, int start, int end, int line Column = column; } - public string Markdown { get; private set; } + public readonly string Path; - public string Path { get; private set; } + public readonly int Start; - public int Start { get; private set; } + public readonly int End; - public int End { get; private set; } + public readonly int Line; - public int Line { get; private set; } - - public int Column { get; private set; } + public readonly int Column; public string Text { @@ -41,7 +41,7 @@ public string Text { if (_Text == null) { - _Text = Markdown.Substring(Start, (End - Start)); + _Text = _Source.Substring(Start, (End - Start)); } return _Text; @@ -59,7 +59,7 @@ private sealed class StreamCursor public int Column = 0; } - private readonly string _Markdown; + private readonly string _Source; private readonly int _Length; /// @@ -80,35 +80,35 @@ private sealed class StreamCursor private const char NewLine = '\n'; private const char CarrageReturn = '\r'; - public const char Dash = '-'; - public const char Whitespace = ' '; - public const char Hash = '#'; - public const char Backtick = '`'; + private const char Whitespace = ' '; + private const char Backtick = '`'; private const char BracketOpen = '['; private const char BracketClose = ']'; private const char ParenthesesOpen = '('; private const char ParenthesesClose = ')'; private const char AngleOpen = '<'; private const char AngleClose = '>'; - public const char Backslash = '\\'; - public const string TripleBacktick = "```"; - public const string NewLineTripleBacktick = "\r\n```"; - public const char EqualSign = '='; - public readonly static char[] NewLineStopCharacters = new char[] { '\r', '\n' }; - public readonly static char[] UnorderListCharacters = new char[] { '-', '*' }; + private const char Backslash = '\\'; + private readonly static char[] NewLineStopCharacters = new char[] { '\r', '\n' }; + private readonly static char[] UnorderListCharacters = new char[] { '-', '*' }; public MarkdownStream(string markdown) { - _Markdown = markdown; - _Length = _Markdown.Length; + _Source = markdown; + _Length = _Source.Length; _Position = 0; _Line = 0; _Column = 0; _EscapeLength = 0; + if (_Length < 0 || _Length > MaxLength) + { + throw new ArgumentOutOfRangeException(nameof(markdown)); + } + UpdateCurrent(); - if (_Markdown.Length > 0) + if (_Source.Length > 0) { _Line = 1; } @@ -156,7 +156,7 @@ public int Column /// public string Preview { - get { return _Markdown.Substring(_Position); } + get { return _Source.Substring(_Position); } } #endif @@ -171,11 +171,6 @@ private int Remaining get { return _Length - Position; } } - public string Body - { - get { return _Markdown; } - } - public bool IsEscaped { get { return _EscapeLength > 0; } @@ -304,7 +299,7 @@ public void Skip(int toSkip, bool ignoreEscaping = false) /// The character at the offset. public char Peak(int offset = 1) { - return _Markdown[_Position + offset]; + return _Source[_Position + offset]; } public bool PeakAnyOf(int offset = 1, params char[] c) @@ -354,7 +349,7 @@ public SourceExtent GetExtent() return null; } - var extent = new SourceExtent(_Markdown, null, _ExtentMarker.Value, _Position, _Line, _Column); + var extent = new SourceExtent(_Source, null, _ExtentMarker.Value, _Position, _Line, _Column); _ExtentMarker = null; @@ -417,25 +412,16 @@ private void UpdateCurrent(bool ignoreEscaping = false) // Handle escape sequences _EscapeLength = ignoreEscaping ? 0 : GetEscapeCount(_Position); - if (_Position + _EscapeLength < 0) - { - throw new IndexOutOfRangeException($"Position can not be < zero. Position={_Position.ToString()}, EscapeLength={_EscapeLength.ToString()}"); - } - else if (_Position + _EscapeLength >= _Length) - { - throw new IndexOutOfRangeException($"Position can not be >= length. Position={_Position.ToString()}, EscapeLength={_EscapeLength.ToString()}"); - } - _Previous = _Current; - _Current = _Markdown[_Position + _EscapeLength]; + _Current = _Source[_Position + _EscapeLength]; } private int GetEscapeCount(int position) { // Check for escape sequences - if (position >= 0 && position < _Length && _Markdown[position] == Backslash) + if (position < _Length && _Source[position] == Backslash) { - var next = _Markdown[position + 1]; + var next = _Source[position + 1]; // Check against list of escapable characters if (next == Backslash || next == BracketOpen || next == ParenthesesOpen ||next == AngleOpen || next == AngleClose || next == Backtick || next == BracketClose || next == ParenthesesClose) @@ -544,7 +530,7 @@ private string Substring(int start, int length, bool ignoreEscaping = false) { if (ignoreEscaping) { - return _Markdown.Substring(start, length); + return _Source.Substring(start, length); } var position = start; @@ -556,7 +542,7 @@ private string Substring(int start, int length, bool ignoreEscaping = false) { var offset = GetEscapeCount(position); - buffer[i] = _Markdown[position + offset]; + buffer[i] = _Source[position + offset]; position += offset + 1; diff --git a/src/PSRule/Parser/RuleLexer.cs b/src/PSRule/Parser/RuleLexer.cs index 92316016d8..11b32af4ff 100644 --- a/src/PSRule/Parser/RuleLexer.cs +++ b/src/PSRule/Parser/RuleLexer.cs @@ -7,18 +7,18 @@ namespace PSRule.Parser { /// - /// A lexer that inteprets markdown as a rule. + /// A lexer that interprets markdown as a rule. /// internal sealed class RuleLexer : MarkdownLexer { private const int RULE_NAME_HEADING_LEVEL = 1; private const int RULE_ENTRIES_HEADING_LEVEL = 2; - private readonly bool _PreserveFormatting; + private const string Space = " "; - public RuleLexer(bool preserveFomatting) + public RuleLexer() { - _PreserveFormatting = preserveFomatting; + // Do nothing } public RuleDocument Process(TokenStream stream) @@ -35,12 +35,10 @@ public RuleDocument Process(TokenStream stream) { if (IsHeading(stream.Current, RULE_NAME_HEADING_LEVEL)) { - doc = new RuleDocument + doc = new RuleDocument(stream.Current.Text) { - Name = stream.Current.Text + Annotations = TagSet.FromDictionary(metadata) }; - - doc.Annotations = TagSet.FromDictionary(metadata); } else if (doc != null) { @@ -66,7 +64,7 @@ public RuleDocument Process(TokenStream stream) } /// - /// Read Synopsis. + /// Read synopsis. /// private bool Synopsis(TokenStream stream, RuleDocument doc) { @@ -75,14 +73,14 @@ private bool Synopsis(TokenStream stream, RuleDocument doc) return false; } - doc.Synopsis = SectionBody(stream); - stream.SkipUntil(MarkdownTokenType.Header); + doc.Synopsis = TextBlock(stream); + stream.SkipUntilHeader(); return true; } /// - /// Process recommendations. + /// Read recommendation. /// private bool Recommendation(TokenStream stream, RuleDocument doc) { @@ -91,34 +89,14 @@ private bool Recommendation(TokenStream stream, RuleDocument doc) return false; } - stream.Next(); - - var recommendations = new List(); - - if (!stream.EOF) - { - var hasLineBreak = stream.Current.IsDoubleLineEnding(); - var recommendation = new RuleRecommendation - { - Title = "default", - FormatOption = hasLineBreak ? SectionFormatOption.LineBreakAfterHeader : SectionFormatOption.None, - Introduction = SimpleTextSection(stream), - Code = RecommendationBlock(stream), - Remarks = SimpleTextSection(stream) - }; - - stream.SkipUntil(MarkdownTokenType.Header); - - recommendations.Add(recommendation); - } - - doc.Recommendation = recommendations.ToArray(); + doc.Recommendation = TextBlock(stream); + stream.SkipUntilHeader(); return true; } /// - /// Read Notes. + /// Read notes. /// private bool Notes(TokenStream stream, RuleDocument doc) { @@ -127,8 +105,8 @@ private bool Notes(TokenStream stream, RuleDocument doc) return false; } - doc.Notes = SectionBody(stream); - stream.SkipUntil(MarkdownTokenType.Header); + doc.Notes = TextBlock(stream); + stream.SkipUntilHeader(); return true; } @@ -171,25 +149,28 @@ private bool RelatedLinks(TokenStream stream, RuleDocument doc) stream.Next(); } - stream.SkipUntil(MarkdownTokenType.Header); + stream.SkipUntilHeader(); doc.Links = links.ToArray(); return true; } - private Body SectionBody(TokenStream stream) + private TextBlock TextBlock(TokenStream stream) { var useBreak = stream.Current.IsDoubleLineEnding(); stream.Next(); - var text = SimpleTextSection(stream); + var text = ReadText(stream); - return new Body(text, useBreak ? SectionFormatOption.LineBreakAfterHeader : SectionFormatOption.None); + return new TextBlock(text: text, formatOption: useBreak ? FormatOption.LineBreak : FormatOption.None); } - private string SimpleTextSection(TokenStream stream, bool includeNonYamlFencedBlocks = false) + /// + /// Read tokens from the stream as text. + /// + private string ReadText(TokenStream stream, bool includeNonYamlFencedBlocks = false) { var sb = new StringBuilder(); @@ -197,12 +178,12 @@ private string SimpleTextSection(TokenStream stream, bool includeNonYamlFencedBl { if (stream.IsTokenType(MarkdownTokenType.Text)) { - AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + AppendEnding(sb, stream.Peak(-1)); sb.Append(stream.Current.Text); } else if (stream.IsTokenType(MarkdownTokenType.Link)) { - AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + AppendEnding(sb, stream.Peak(-1)); sb.Append(stream.Current.Meta); if (!string.IsNullOrEmpty(stream.Current.Text)) @@ -212,7 +193,7 @@ private string SimpleTextSection(TokenStream stream, bool includeNonYamlFencedBl } else if (stream.IsTokenType(MarkdownTokenType.LinkReference)) { - AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + AppendEnding(sb, stream.Peak(-1)); sb.Append(stream.Current.Meta); } @@ -223,7 +204,7 @@ private string SimpleTextSection(TokenStream stream, bool includeNonYamlFencedBl { if (stream.PeakTokenType(-1) == MarkdownTokenType.LineBreak) { - AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + AppendEnding(sb, stream.Peak(-1)); } break; @@ -234,7 +215,7 @@ private string SimpleTextSection(TokenStream stream, bool includeNonYamlFencedBl } else if (stream.IsTokenType(MarkdownTokenType.LineBreak)) { - AppendEnding(sb, stream.Peak(-1), _PreserveFormatting); + AppendEnding(sb, stream.Peak(-1)); } stream.Next(); @@ -266,22 +247,8 @@ private void AppendEnding(StringBuilder stringBuilder, MarkdownToken token, bool } else if (token.IsSingleLineEnding()) { - stringBuilder.Append(preserveEnding ? Environment.NewLine : " "); - } - } - - private static CodeBlock[] RecommendationBlock(TokenStream stream) - { - List blocks = new List(); - - foreach (var token in stream.CaptureWhile(MarkdownTokenType.FencedBlock)) - { - var block = new CodeBlock(token.Text, token.Meta); - - blocks.Add(block); + stringBuilder.Append(preserveEnding ? Environment.NewLine : Space); } - - return blocks.ToArray(); } } } diff --git a/src/PSRule/Parser/RuleModels.cs b/src/PSRule/Parser/RuleModels.cs index c0ea8c9991..5ba66e8822 100644 --- a/src/PSRule/Parser/RuleModels.cs +++ b/src/PSRule/Parser/RuleModels.cs @@ -1,27 +1,42 @@ using PSRule.Rules; +using System; namespace PSRule.Parser { /// - /// YAML text content. + /// Define options that determine how markdown will be rendered. /// - internal sealed class Body + [Flags()] + internal enum FormatOption : byte { - public Body(string text, SectionFormatOption formatOption = SectionFormatOption.None) - { - Text = text; - FormatOption = formatOption; - } + None = 0, + /// + /// Add a line break after headers. + /// + LineBreak = 1 + } + + /// + /// YAML text content. + /// + internal sealed class TextBlock + { /// /// The text of the section body. /// - public string Text { get; set; } + public readonly string Text; /// /// Additional options that determine how the section will be formated when rendering markdown. /// - public SectionFormatOption FormatOption { get; set; } + public readonly FormatOption FormatOption; + + public TextBlock(string text, FormatOption formatOption = FormatOption.None) + { + Text = text; + FormatOption = formatOption; + } public override string ToString() { @@ -39,39 +54,20 @@ internal sealed class Link public string Uri; } - /// - /// YAML code block. - /// - internal sealed class CodeBlock + internal sealed class RuleDocument { - public CodeBlock(string text, string meta) + public RuleDocument(string name) { - + Name = name; } - } - - internal sealed class RuleRecommendation - { - public string Title { get; set; } - public object FormatOption { get; set; } - - public string Introduction { get; set; } - - public CodeBlock[] Code { get; set; } - - public string Remarks { get; set; } - } - - internal sealed class RuleDocument - { - public string Name; + public readonly string Name; - public Body Synopsis; + public TextBlock Synopsis; - public Body Notes; + public TextBlock Notes; - public RuleRecommendation[] Recommendation; + public TextBlock Recommendation; public Link[] Links; diff --git a/src/PSRule/Parser/TokenStream.cs b/src/PSRule/Parser/TokenStream.cs index c2593caf5e..815ffd2d42 100644 --- a/src/PSRule/Parser/TokenStream.cs +++ b/src/PSRule/Parser/TokenStream.cs @@ -298,6 +298,11 @@ public MarkdownToken Peak(int offset = 1) return _Token[p]; } + public void SkipUntilHeader() + { + SkipUntil(MarkdownTokenType.Header); + } + public void SkipUntil(MarkdownTokenType tokenType) { while (!EOF && Current.Type != tokenType) diff --git a/tests/PSRule.Tests/RuleDocumentTests.cs b/tests/PSRule.Tests/RuleDocumentTests.cs index 22af211603..cfa0782bd1 100644 --- a/tests/PSRule.Tests/RuleDocumentTests.cs +++ b/tests/PSRule.Tests/RuleDocumentTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections; using System.IO; +using System.Security.Cryptography.Xml; using Xunit; namespace PSRule @@ -13,12 +14,11 @@ public sealed class RuleDocumentTests public void ReadDocument_Windows() { var document = GetDocument(GetToken(nx: false)); - var expected = GetExpected(nx: false); + var expected = GetExpected(); Assert.Equal(expected.Name, document.Name); Assert.Equal(expected.Synopsis.Text, document.Synopsis.Text); - Assert.Single(document.Recommendation); - Assert.Equal(expected.Recommendation[0].Introduction, document.Recommendation[0].Introduction); + Assert.Equal(expected.Recommendation.Text, document.Recommendation.Text); Assert.Equal(expected.Annotations["severity"], document.Annotations["severity"]); Assert.Equal(expected.Annotations["category"], document.Annotations["category"]); } @@ -27,34 +27,28 @@ public void ReadDocument_Windows() public void ReadDocument_Linux() { var document = GetDocument(GetToken(nx: true)); - var expected = GetExpected(nx: true); + var expected = GetExpected(); Assert.Equal(expected.Name, document.Name); Assert.Equal(expected.Synopsis.Text, document.Synopsis.Text); - Assert.Single(document.Recommendation); - Assert.Equal(expected.Recommendation[0].Introduction, document.Recommendation[0].Introduction); + Assert.Equal(expected.Recommendation.Text, document.Recommendation.Text); Assert.Equal(expected.Annotations["severity"], document.Annotations["severity"]); Assert.Equal(expected.Annotations["category"], document.Annotations["category"]); } - private RuleDocument GetExpected(bool nx) + private RuleDocument GetExpected() { var annotations = new Hashtable(); annotations["severity"] = "Critical"; annotations["category"] = "Pod security"; - var recommendation = new RuleRecommendation - { - Introduction = @"Deployments or pods should identify a specific tag to use for container images instead of latest. When latest is used it may be hard to determine which version of the image is running. -When using variable tags such as v1.0 (which may refer to v1.0.0 or v1.0.1) consider using imagePullPolicy: Always to ensure that the an out-of-date cached image is not used. -The latest tag automatically uses imagePullPolicy: Always instead of the default imagePullPolicy: IfNotPresent." - }; - var result = new RuleDocument + var result = new RuleDocument(name: "Kubernetes.Deployment.NotLatestImage") { - Name = "Kubernetes.Deployment.NotLatestImage", - Synopsis = new Body("Containers should use specific tags instead of latest."), + Synopsis = new TextBlock(text: "Containers should use specific tags instead of latest."), Annotations = TagSet.FromHashtable(annotations), - Recommendation = new RuleRecommendation[] { recommendation } + Recommendation = new TextBlock(text: @"Deployments or pods should identify a specific tag to use for container images instead of latest. When latest is used it may be hard to determine which version of the image is running. +When using variable tags such as v1.0 (which may refer to v1.0.0 or v1.0.1) consider using imagePullPolicy: Always to ensure that the an out-of-date cached image is not used. +The latest tag automatically uses imagePullPolicy: Always instead of the default imagePullPolicy: IfNotPresent.") }; return result; @@ -62,7 +56,7 @@ private RuleDocument GetExpected(bool nx) private RuleDocument GetDocument(TokenStream stream) { - var lexer = new RuleLexer(preserveFomatting: false); + var lexer = new RuleLexer(); return lexer.Process(stream: stream); }