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/CHANGELOG.md b/CHANGELOG.md index 592b0ac5f0..3a9ffbed49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ ## 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) + - 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) + ## v0.6.0-B190514 (pre-release) - Fix operation is not supported on this platform failure. [#152](https://github.com/BernieWhite/PSRule/issues/152) diff --git a/README.md b/README.md index 44cd176896..51bbc30332 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.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. 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/Get-PSRuleHelp.md b/docs/commands/PSRule/en-US/Get-PSRuleHelp.md new file mode 100644 index 0000000000..b1e709c2fc --- /dev/null +++ b/docs/commands/PSRule/en-US/Get-PSRuleHelp.md @@ -0,0 +1,139 @@ +--- +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 [-Name] [-Path ] [-Module ] [-Culture ] [-Online] + [] +``` + +## DESCRIPTION + +Get documentation for a rule. + +## EXAMPLES + +### Example 1 + +```powershell +Get-PSRuleHelp 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 + +### -Name + +The name of the rule to get documentation for. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: n + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: True +``` + +### -Path + +A path to check documentation for. If this is not specified, documentation is sourced for imported modules. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: p + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Module + +Limit returned information to rules in the specified module. + +```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). + +## OUTPUTS + +### PSRule.Rules.RuleHelpInfo + +## NOTES + +## RELATED LINKS 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/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/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/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 diff --git a/PSRule.build.ps1 b/pipeline.build.ps1 similarity index 88% rename from PSRule.build.ps1 rename to pipeline.build.ps1 index 449cfd28c2..2ecaf76b21 100644 --- a/PSRule.build.ps1 +++ b/pipeline.build.ps1 @@ -1,7 +1,7 @@ - +[CmdletBinding()] param ( [Parameter(Mandatory = $False)] - [String]$ModuleVersion, + [String]$ModuleVersion = '0.0.1', [Parameter(Mandatory = $False)] [AllowNull()] @@ -23,10 +23,26 @@ param ( [String]$ArtifactPath = (Join-Path -Path $PWD -ChildPath out/modules) ) -if ($Env:COVERAGE -eq 'true') { +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 { @@ -60,7 +76,8 @@ 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) + # Add build version -p:versionPrefix=$ModuleVersion + dotnet publish src/PSRule -c $Configuration -f netstandard2.0 -o $(Join-Path -Path $PWD -ChildPath out/modules/PSRule) } } @@ -106,7 +123,6 @@ task Clean { } task VersionModule { - if (![String]::IsNullOrEmpty($ReleaseVersion)) { Write-Verbose -Message "[VersionModule] -- ReleaseVersion: $ReleaseVersion"; $ModuleVersion = $ReleaseVersion; 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..ab937dde86 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: 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.Text : 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(); + return lexer.Process(stream: stream); + } + + return null; + } } } diff --git a/src/PSRule/Host/HostHelper.cs b/src/PSRule/Host/HostHelper.cs index 5ab49c9db0..75a182f789 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; @@ -19,6 +20,11 @@ public static IEnumerable GetRule(RuleSource[] source, RuleFilter filter) return ToRule(GetLanguageBlock(sources: source), filter); } + public static IEnumerable GetRuleHelp(RuleSource[] source, RuleFilter filter) + { + return ToRuleHelp(GetLanguageBlock(sources: source), filter); + } + public static DependencyGraph GetRuleBlockGraph(RuleSource[] source, RuleFilter filter) { var builder = new DependencyGraphBuilder(); @@ -96,17 +102,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 +121,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 +203,36 @@ 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 + }; + } + } + + 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() }; } } diff --git a/src/PSRule/PSRule.Format.ps1xml b/src/PSRule/PSRule.Format.ps1xml index d6ba527ddc..19e40ad07d 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,74 @@ + + PSRule.Rules.RuleHelpInfo + + PSRule.Rules.RuleHelpInfo + + + + + + + Help-Name + + + + + + Help-Synopsis + + + + + + Help-Recommendation + + + + + + + + + + + + PSRule.Rules.RuleHelpInfo+Collection + + PSRule.Rules.RuleHelpInfo+Collection + + + + + + + + + + + + + + + Name + + + ModuleName + + + Synopsis + + + + + + diff --git a/src/PSRule/PSRule.csproj b/src/PSRule/PSRule.csproj index 7cda369f35..05fc2ded57 100644 --- a/src/PSRule/PSRule.csproj +++ b/src/PSRule/PSRule.csproj @@ -4,8 +4,17 @@ 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. @@ -19,22 +28,36 @@ - - - - + + True + True + DocumentStrings.resx + True True PSRuleResources.resx + + True + True + ViewStrings.resx + + + ResXFileCodeGenerator + DocumentStrings.Designer.cs + ResXFileCodeGenerator PSRuleResources.Designer.cs + + ResXFileCodeGenerator + ViewStrings.Designer.cs + diff --git a/src/PSRule/PSRule.psd1 b/src/PSRule/PSRule.psd1 index ea1e43c01b..3616807c15 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 = @() @@ -74,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 45316d922a..fd23ebeb24 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 @@ -391,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; @@ -433,6 +436,130 @@ function Get-PSRule { } } +# .ExternalHelp PSRule-Help.xml +function Get-PSRuleHelp { + [CmdletBinding()] + [OutputType([PSRule.Rules.RuleHelpInfo])] + param ( + # The name of the rule to get documentation for. + [Parameter(Position = 0, Mandatory = $False)] + [Alias('n')] + [SupportsWildcards()] + [String]$Name, + + # A path to check documentation for. + [Parameter(Mandatory = $False)] + [Alias('p')] + [String]$Path, + + [Parameter(Mandatory = $False)] + [String]$Module, + + [Parameter(Mandatory = $False)] + [PSRule.Configuration.PSRuleOption]$Option, + + [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 ($PSBoundParameters.ContainsKey('Culture')) { + $sourceParams['Culture'] = $Culture; + } + [PSRule.Rules.RuleSource[]]$sourceFiles = GetRuleScriptPath @sourceParams -PreferModule -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) source file(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 $result.Length -gt 0) { + + 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; + } + } + } + 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()] @@ -947,18 +1074,28 @@ function GetRuleScriptPath { [String[]]$Module, [Parameter(Mandatory = $False)] - [Switch]$ListAvailable + [Switch]$ListAvailable, + + [Parameter(Mandatory = $False)] + [String]$Culture, + + [Parameter(Mandatory = $False)] + [Switch]$PreferModule = $False ) process { $builder = New-Object -TypeName 'PSRule.Rules.RuleSourceBuilder'; + if ([String]::IsNullOrEmpty($Culture)) { + $Culture = GetCulture; + } 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); } } @@ -972,18 +1109,20 @@ 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; - if ($Null -ne $fileObjects) { - $builder.Add($fileObjects.FullName, $m.Name); + foreach ($file in $fileObjects) { + $builder.Add($file.FullName, $m.Name, $helpPath); } } } @@ -1169,13 +1308,9 @@ function YamlContainsComments { } function IsDeviceGuardEnabled { - [CmdletBinding()] [OutputType([System.Boolean])] - param ( - - ) - + param () process { if ((Get-Variable -Name IsMacOS -ErrorAction Ignore) -or (Get-Variable -Name IsLinux -ErrorAction Ignore)) { @@ -1191,13 +1326,35 @@ function IsDeviceGuardEnabled { } } -function InitEditorServices { +function GetCulture { + [CmdletBinding()] + [OutputType([System.String])] + param () + process { + return [System.Threading.Thread]::CurrentThread.CurrentCulture.ToString(); + } +} +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 @@ -1244,6 +1401,7 @@ Export-ModuleMember -Function @( 'Invoke-PSRule' 'Test-PSRuleTarget' 'Get-PSRule' + 'Get-PSRuleHelp' 'New-PSRuleOption' 'Set-PSRuleOption' ) 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..c7b728766e --- /dev/null +++ b/src/PSRule/Parser/MarkdownReader.cs @@ -0,0 +1,492 @@ +namespace PSRule.Parser +{ + internal enum MarkdownReaderMode + { + None, + + List + } + + /// + /// 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 Hash = '#'; + 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 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', ':' }; + + 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.EOF && !_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 != Dash && _Stream.Current != 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 == 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 != Hash || !_Stream.IsStartOfLine) + { + return false; + } + + _Stream.MarkExtentStart(); + _Stream.Next(); + + // Get the header depth + var headerDepth = _Stream.Skip(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 != Backtick || !_Stream.IsSequence(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(TripleBacktick, onNewLine: true, ignoreEscaping: true); + + // Skip backticks + _Stream.Skip(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..3ae9c40a26 --- /dev/null +++ b/src/PSRule/Parser/MarkdownStream.cs @@ -0,0 +1,597 @@ +using System; +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 readonly string _Source; + + // Lazily cache extracted text + private string _Text; + + internal SourceExtent(string source, string path, int start, int end, int line, int column) + { + _Text = null; + _Source = source; + Path = path; + Start = start; + End = end; + Line = line; + Column = column; + } + + public readonly string Path; + + public readonly int Start; + + public readonly int End; + + public readonly int Line; + + public readonly int Column; + + public string Text + { + get + { + if (_Text == null) + { + _Text = _Source.Substring(Start, (End - Start)); + } + + return _Text; + } + } + } + + [DebuggerDisplay("Position = {Position}, Current = {Current}")] + internal sealed class MarkdownStream + { + private sealed class StreamCursor + { + public int Position = 0; + public int Line = 0; + public int Column = 0; + } + + private readonly string _Source; + private readonly int _Length; + + /// + /// The current character position in the markdown string. Call Next() to change the position. + /// + private int _Position; + private int _Line; + private int _Column; + private char _Current; + private char _Previous; + private int _EscapeLength; + + 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'; + 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 = '>'; + private const char Backslash = '\\'; + private readonly static char[] NewLineStopCharacters = new char[] { '\r', '\n' }; + private readonly static char[] UnorderListCharacters = new char[] { '-', '*' }; + + public MarkdownStream(string markdown) + { + _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 (_Source.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 _Source.Substring(_Position); } + } + +#endif + + public int Position + { + get { return _Position; } + } + + private int Remaining + { + get { return _Length - Position; } + } + + 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 (Remaining == 0) + { + break; + } + + if (Current == CarrageReturn && Peak() == NewLine) + { + Next(); + } + + Next(ignoreEscaping: 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 _Source[_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(_Source, 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 > 0 ? _EscapeLength + 1 : 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 = _Source[_Position + _EscapeLength]; + } + + private int GetEscapeCount(int position) + { + // Check for escape sequences + if (position < _Length && _Source[position] == Backslash) + { + 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) + { + 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 _Source.Substring(start, length); + } + + var position = start; + var i = 0; + + var buffer = new char[length]; + + while (i < length) + { + var offset = GetEscapeCount(position); + + buffer[i] = _Source[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(NewLineStopCharacters); + } + + 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..11b32af4ff --- /dev/null +++ b/src/PSRule/Parser/RuleLexer.cs @@ -0,0 +1,254 @@ +using PSRule.Resources; +using PSRule.Rules; +using System; +using System.Collections.Generic; +using System.Text; + +namespace PSRule.Parser +{ + /// + /// 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 const string Space = " "; + + public RuleLexer() + { + // Do nothing + } + + 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(stream.Current.Text) + { + 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, DocumentStrings.Synopsis)) + { + return false; + } + + doc.Synopsis = TextBlock(stream); + stream.SkipUntilHeader(); + + return true; + } + + /// + /// Read recommendation. + /// + private bool Recommendation(TokenStream stream, RuleDocument doc) + { + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, DocumentStrings.Recommendation)) + { + return false; + } + + doc.Recommendation = TextBlock(stream); + stream.SkipUntilHeader(); + + return true; + } + + /// + /// Read notes. + /// + private bool Notes(TokenStream stream, RuleDocument doc) + { + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, DocumentStrings.Notes)) + { + return false; + } + + doc.Notes = TextBlock(stream); + stream.SkipUntilHeader(); + + return true; + } + + private bool RelatedLinks(TokenStream stream, RuleDocument doc) + { + if (!IsHeading(stream.Current, RULE_ENTRIES_HEADING_LEVEL, DocumentStrings.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.SkipUntilHeader(); + + doc.Links = links.ToArray(); + + return true; + } + + private TextBlock TextBlock(TokenStream stream) + { + var useBreak = stream.Current.IsDoubleLineEnding(); + + stream.Next(); + + var text = ReadText(stream); + + return new TextBlock(text: text, formatOption: useBreak ? FormatOption.LineBreak : FormatOption.None); + } + + /// + /// Read tokens from the stream as text. + /// + private string ReadText(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)); + sb.Append(stream.Current.Text); + } + else if (stream.IsTokenType(MarkdownTokenType.Link)) + { + AppendEnding(sb, stream.Peak(-1)); + 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)); + + 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)); + } + + break; + } + + AppendEnding(sb, stream.Peak(-1), preserveEnding: true); + sb.Append(stream.Current.Text); + } + else if (stream.IsTokenType(MarkdownTokenType.LineBreak)) + { + AppendEnding(sb, stream.Peak(-1)); + } + + 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 ? string.Concat(Environment.NewLine, Environment.NewLine) : Environment.NewLine); + } + else if (token.IsSingleLineEnding()) + { + stringBuilder.Append(preserveEnding ? Environment.NewLine : Space); + } + } + } +} diff --git a/src/PSRule/Parser/RuleModels.cs b/src/PSRule/Parser/RuleModels.cs new file mode 100644 index 0000000000..5ba66e8822 --- /dev/null +++ b/src/PSRule/Parser/RuleModels.cs @@ -0,0 +1,76 @@ +using PSRule.Rules; +using System; + +namespace PSRule.Parser +{ + /// + /// Define options that determine how markdown will be rendered. + /// + [Flags()] + internal enum FormatOption : byte + { + None = 0, + + /// + /// Add a line break after headers. + /// + LineBreak = 1 + } + + /// + /// YAML text content. + /// + internal sealed class TextBlock + { + /// + /// The text of the section body. + /// + public readonly string Text; + + /// + /// Additional options that determine how the section will be formated when rendering markdown. + /// + public readonly FormatOption FormatOption; + + public TextBlock(string text, FormatOption formatOption = FormatOption.None) + { + Text = text; + FormatOption = formatOption; + } + + public override string ToString() + { + return Text; + } + } + + /// + /// YAML link. + /// + internal sealed class Link + { + public string Name; + + public string Uri; + } + + internal sealed class RuleDocument + { + public RuleDocument(string name) + { + Name = name; + } + + public readonly string Name; + + public TextBlock Synopsis; + + public TextBlock Notes; + + public TextBlock 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..815ffd2d42 --- /dev/null +++ b/src/PSRule/Parser/TokenStream.cs @@ -0,0 +1,407 @@ +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 SkipUntilHeader() + { + SkipUntil(MarkdownTokenType.Header); + } + + 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/GetRuleHelpPipeline.cs b/src/PSRule/Pipeline/GetRuleHelpPipeline.cs new file mode 100644 index 0000000000..a32115e268 --- /dev/null +++ b/src/PSRule/Pipeline/GetRuleHelpPipeline.cs @@ -0,0 +1,21 @@ +using PSRule.Configuration; +using PSRule.Host; +using PSRule.Rules; +using System.Collections.Generic; + +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 IEnumerable 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..7057dc7e15 --- /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, 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/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/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/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/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/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/Resources/ViewStrings.Designer.cs b/src/PSRule/Resources/ViewStrings.Designer.cs new file mode 100644 index 0000000000..cd3fb21392 --- /dev/null +++ b/src/PSRule/Resources/ViewStrings.Designer.cs @@ -0,0 +1,144 @@ +//------------------------------------------------------------------------------ +// +// 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 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 ViewStrings() { + } + + /// + /// 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.ViewStrings", typeof(ViewStrings).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 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 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. + /// + internal static string Name { + get { + return ResourceManager.GetString("Name", resourceCulture); + } + } + + /// + /// 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 RuleName. + /// + internal static string RuleName { + get { + return ResourceManager.GetString("RuleName", 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/ViewStrings.resx b/src/PSRule/Resources/ViewStrings.resx new file mode 100644 index 0000000000..2c83c4fc17 --- /dev/null +++ b/src/PSRule/Resources/ViewStrings.resx @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Description + + + Fail + + + Message + + + ModuleName + + + Name + + + Outcome + + + Pass + + + RuleName + + + Synopsis + + \ No newline at end of file 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/RuleFilter.cs b/src/PSRule/Rules/RuleFilter.cs index 6e258c7890..0b55ab1bb8 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,22 @@ 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 && ruleName != null && ruleName.Length > 0 && WildcardPattern.ContainsWildcardCharacters(ruleName[0])) + { + if (ruleName.Length > 1) + { + throw new NotSupportedException("Wildcard match requires exactly one ruleName"); + } + + _WildcardMatch = new WildcardPattern(ruleName[0]); + } } /// @@ -37,7 +50,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 +80,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/src/PSRule/Rules/RuleHelpInfo.cs b/src/PSRule/Rules/RuleHelpInfo.cs new file mode 100644 index 0000000000..c9ab83e010 --- /dev/null +++ b/src/PSRule/Rules/RuleHelpInfo.cs @@ -0,0 +1,57 @@ +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. + /// + 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; } + + /// + /// 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; + } + } +} 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..5527e38542 100644 --- a/tests/PSRule.Tests/PSRule.Common.Tests.ps1 +++ b/tests/PSRule.Tests/PSRule.Common.Tests.ps1 @@ -38,10 +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.culture | Should -Be 'en-ZZ'; + $result.Message | Should -Be 'This is a synopsis.'; + Assert-VerifiableMock; } It 'Returns failure' { @@ -215,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; @@ -224,11 +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.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' { @@ -805,10 +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 'Test rule 1'; + $result.Description | Should -Be 'This is a synopsis.'; + $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' { @@ -823,18 +844,57 @@ 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.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'; } It 'Returns module and path rules' { $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 4; + $result.RuleName | Should -BeIn 'M1.Rule1', 'M1.Rule2'; + } + + 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 2; - $result.RuleName | Should -BeIn 'Rule1'; + $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 2; + $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 2; + $result[0].RuleName | Should -Be 'M1.Rule1'; + $result[0].Description | Should -Be 'Synopsis en-AU.'; + $result[0].Annotations.culture | Should -Be 'en-AU'; } } @@ -861,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/PSRule.Tests.csproj b/tests/PSRule.Tests/PSRule.Tests.csproj index 8bafb9c144..38cc57a555 100644 --- a/tests/PSRule.Tests/PSRule.Tests.csproj +++ b/tests/PSRule.Tests/PSRule.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp2.1 @@ -9,8 +9,8 @@ - - + + @@ -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..cfa0782bd1 --- /dev/null +++ b/tests/PSRule.Tests/RuleDocumentTests.cs @@ -0,0 +1,89 @@ +using PSRule.Parser; +using PSRule.Rules; +using System; +using System.Collections; +using System.IO; +using System.Security.Cryptography.Xml; +using Xunit; + +namespace PSRule +{ + public sealed class RuleDocumentTests + { + [Fact] + public void ReadDocument_Windows() + { + var document = GetDocument(GetToken(nx: false)); + var expected = GetExpected(); + + Assert.Equal(expected.Name, document.Name); + Assert.Equal(expected.Synopsis.Text, document.Synopsis.Text); + Assert.Equal(expected.Recommendation.Text, document.Recommendation.Text); + 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(); + + Assert.Equal(expected.Name, document.Name); + Assert.Equal(expected.Synopsis.Text, document.Synopsis.Text); + 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() + { + var annotations = new Hashtable(); + annotations["severity"] = "Critical"; + annotations["category"] = "Pod security"; + + var result = new RuleDocument(name: "Kubernetes.Deployment.NotLatestImage") + { + Synopsis = new TextBlock(text: "Containers should use specific tags instead of latest."), + Annotations = TagSet.FromHashtable(annotations), + 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; + } + + private RuleDocument GetDocument(TokenStream stream) + { + var lexer = new RuleLexer(); + return lexer.Process(stream: stream); + } + + private TokenStream GetToken(bool nx) + { + var reader = new MarkdownReader(yamlHeaderOnly: false); + 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() + { + var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "RuleDocument.md"); + return File.ReadAllText(path); + } + } +} 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..58bca6c2c2 --- /dev/null +++ b/tests/PSRule.Tests/TestModule/en-AU/M1.Rule1.md @@ -0,0 +1,14 @@ +--- +culture: en-AU +online version: https://github.com/BernieWhite/PSRule +--- + +# 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..4daaa30725 --- /dev/null +++ b/tests/PSRule.Tests/TestModule/en-US/M1.Rule1.md @@ -0,0 +1,14 @@ +--- +culture: en-US +online version: https://github.com/BernieWhite/PSRule +--- + +# 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..44e0b9783f 100644 --- a/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 +++ b/tests/PSRule.Tests/TestModule/rules/Test.Rule.ps1 @@ -2,6 +2,12 @@ # A set of test rules in a module # -Rule 'Rule1' { +# Description: This is the default +Rule 'M1.Rule1' { + # This is a test rule +} + +# Description: This is the default +Rule 'M1.Rule2' { # This is a test rule } diff --git a/tests/PSRule.Tests/en-ZZ/FromFile1.md b/tests/PSRule.Tests/en-ZZ/FromFile1.md new file mode 100644 index 0000000000..3d037d7982 --- /dev/null +++ b/tests/PSRule.Tests/en-ZZ/FromFile1.md @@ -0,0 +1,13 @@ +--- +culture: en-ZZ +--- + +# FromFile + +## Synopsis + +This is a synopsis. + +## Recommendation + +This is a recommendation.