From 16c96bc323c1227ba3776c8652e221c88639d11c Mon Sep 17 00:00:00 2001 From: Bernie White Date: Tue, 28 May 2019 09:11:48 +1000 Subject: [PATCH] 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.