diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 282c75985..445391f3d 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,4 +1,19 @@ -## [1.10.0](https://github.com/PowerShell/PSScriptAnalyzer/tree/1.10.0) - 2017-01-19 +## unreleased + +### Added +- Built-in settings presets to specify settings from command line. Currently, PSSA ships with `PSGallery`, `CodeFormatting`, `DSC`, and other setting presets. All of them can be found in the `Settings/` directory in the module. To use them just pass them as an argument to the `Settings` parameters. For example, if you want to run rules that *powershellgallery* runs, then use the following command. +```powershell +PS> Invoke-ScriptAnalyzer -Path /path/to/your/module -Settings PSGallery +``` +- Argument completion for built-in settings presets. +- Argument completion for `IncludeRule` and `ExcludeRule` parameters. + +### Fixed + +### Changed + - Settings implementation to decouple it from engine. + +## [1.10.0](https://github.com/PowerShell/PSScriptAnalyzer/tree/1.10.0) - 2017-01-19 ### Added - Three rules to enable code formatting feature in vscode (#690) - [PSPlaceOpenBrace](https://github.com/PowerShell/PSScriptAnalyzer/blob/03a6e2b4ee24894bf574a8a8ce911d03680da607/RuleDocumentation/PlaceOpenBrace.md) diff --git a/Engine/Commands/InvokeScriptAnalyzerCommand.cs b/Engine/Commands/InvokeScriptAnalyzerCommand.cs index 90fc2e9e1..db42db740 100644 --- a/Engine/Commands/InvokeScriptAnalyzerCommand.cs +++ b/Engine/Commands/InvokeScriptAnalyzerCommand.cs @@ -206,9 +206,9 @@ public SwitchParameter SaveDscDependency } private bool saveDscDependency; #endif // !PSV3 -#endregion Parameters + #endregion Parameters -#region Overrides + #region Overrides /// /// Imports all known rules and loggers. @@ -227,67 +227,74 @@ protected override void BeginProcessing() Helper.Instance.SetPSVersionTable(psVersionTable); } - string[] rulePaths = Helper.ProcessCustomRulePaths(customRulePath, - this.SessionState, recurseCustomRulePath); + string[] rulePaths = Helper.ProcessCustomRulePaths( + customRulePath, + this.SessionState, + recurseCustomRulePath); + if (IsFileParameterSet()) { ProcessPath(); } - var settingFileHasErrors = false; - if (settings == null - && processedPaths != null - && processedPaths.Count == 1) + object settingsFound; + var settingsMode = PowerShell.ScriptAnalyzer.Settings.FindSettingsMode( + this.settings, + processedPaths == null || processedPaths.Count == 0 ? null : processedPaths[0], + out settingsFound); + + switch (settingsMode) { - // add a directory separator character because if there is no trailing separator character, it will return the parent - var directory = processedPaths[0].TrimEnd(System.IO.Path.DirectorySeparatorChar); - if (File.Exists(directory)) - { - // if given path is a file, get its directory - directory = System.IO.Path.GetDirectoryName(directory); - } + case SettingsMode.Auto: + this.WriteVerbose( + String.Format( + CultureInfo.CurrentCulture, + Strings.SettingsNotProvided, + path)); + this.WriteVerbose( + String.Format( + CultureInfo.CurrentCulture, + Strings.SettingsAutoDiscovered, + (string)settingsFound)); + break; - this.WriteVerbose( - String.Format( - "Settings not provided. Will look for settings file in the given path {0}.", - path)); - var settingsFileAutoDiscovered = false; - if (Directory.Exists(directory)) - { - // if settings are not provided explicitly, look for it in the given path - // check if pssasettings.psd1 exists - var settingsFilename = "PSScriptAnalyzerSettings.psd1"; - var settingsFilepath = System.IO.Path.Combine(directory, settingsFilename); - if (File.Exists(settingsFilepath)) - { - settingsFileAutoDiscovered = true; - this.WriteVerbose( - String.Format( - "Found {0} in {1}. Will use it to provide settings for this invocation.", - settingsFilename, - directory)); - settingFileHasErrors = !ScriptAnalyzer.Instance.ParseProfile(settingsFilepath, this.SessionState.Path, this); - } - } + case SettingsMode.Preset: + case SettingsMode.File: + this.WriteVerbose( + String.Format( + CultureInfo.CurrentCulture, + Strings.SettingsUsingFile, + (string)settingsFound)); + break; - if (!settingsFileAutoDiscovered) - { + case SettingsMode.Hashtable: this.WriteVerbose( String.Format( - "Cannot find a settings file in the given path {0}.", - path)); - } - } - else - { - settingFileHasErrors = !ScriptAnalyzer.Instance.ParseProfile(this.settings, this.SessionState.Path, this); + CultureInfo.CurrentCulture, + Strings.SettingsUsingHashtable)); + break; + + default: // case SettingsMode.None + this.WriteVerbose( + String.Format( + CultureInfo.CurrentCulture, + Strings.SettingsCannotFindFile)); + break; } - if (settingFileHasErrors) + if (settingsMode != SettingsMode.None) { - this.WriteWarning("Cannot parse settings. Will abort the invocation."); - stopProcessing = true; - return; + try + { + var settingsObj = new Settings(settingsFound); + ScriptAnalyzer.Instance.UpdateSettings(settingsObj); + } + catch + { + this.WriteWarning(String.Format(CultureInfo.CurrentCulture, Strings.SettingsNotParsable)); + stopProcessing = true; + return; + } } ScriptAnalyzer.Instance.Initialize( @@ -323,7 +330,8 @@ protected override void ProcessRecord() ScriptAnalyzer.Instance.ModuleHandler = moduleHandler; this.WriteVerbose( String.Format( - "Temporary module location: {0}", + CultureInfo.CurrentCulture, + Strings.ModuleDepHandlerTempLocation, moduleHandler.TempModulePath)); ProcessInput(); } @@ -346,9 +354,10 @@ protected override void StopProcessing() base.StopProcessing(); } -#endregion + #endregion + + #region Private Methods -#region Methods private void ProcessInput() { IEnumerable diagnosticsList = Enumerable.Empty(); @@ -392,6 +401,7 @@ private bool IsFileParameterSet() { return String.Equals(this.ParameterSetName, "File", StringComparison.OrdinalIgnoreCase); } -#endregion + + #endregion // Private Methods } -} \ No newline at end of file +} diff --git a/Engine/Generic/CorrectionExtent.cs b/Engine/Generic/CorrectionExtent.cs index f4857c597..1b9350769 100644 --- a/Engine/Generic/CorrectionExtent.cs +++ b/Engine/Generic/CorrectionExtent.cs @@ -1,4 +1,16 @@ -using System; +// +// Copyright (c) Microsoft Corporation. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; using System.Collections.Generic; using System.IO; using System.Linq; diff --git a/Engine/Helper.cs b/Engine/Helper.cs index 7680befbd..31120ed99 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -21,6 +21,7 @@ using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; using System.Management.Automation.Runspaces; using System.Collections; +using System.Reflection; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer { diff --git a/Engine/PSScriptAnalyzer.psm1 b/Engine/PSScriptAnalyzer.psm1 index 9c50155f2..03f4a44e6 100644 --- a/Engine/PSScriptAnalyzer.psm1 +++ b/Engine/PSScriptAnalyzer.psm1 @@ -18,7 +18,7 @@ else { if ($PSVersionTable.PSVersion -lt [Version]'5.0') { $binaryModuleRoot = Join-Path -Path $PSModuleRoot -ChildPath 'PSv3' - } + } } $binaryModulePath = Join-Path -Path $binaryModuleRoot -ChildPath 'Microsoft.Windows.PowerShell.ScriptAnalyzer.dll' @@ -27,4 +27,27 @@ $binaryModule = Import-Module -Name $binaryModulePath -PassThru # When the module is unloaded, remove the nested binary module that was loaded with it $PSModule.OnRemove = { Remove-Module -ModuleInfo $binaryModule +} + +if (Get-Command Register-ArgumentCompleter -ErrorAction Ignore) +{ + Register-ArgumentCompleter -CommandName 'Invoke-ScriptAnalyzer' -ParameterName 'Settings' -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParmeter) + + [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::GetSettingPresets() | ` + Where-Object {$_ -like "$wordToComplete*"} | ` + ForEach-Object { New-Object System.Management.Automation.CompletionResult $_ } + } + + Function RuleNameCompleter + { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParmeter) + + Get-ScriptAnalyzerRule *$wordToComplete* | ` + ForEach-Object { New-Object System.Management.Automation.CompletionResult $_.RuleName } + } + + Register-ArgumentCompleter -CommandName 'Invoke-ScriptAnalyzer' -ParameterName 'IncludeRule' -ScriptBlock $Function:RuleNameCompleter + Register-ArgumentCompleter -CommandName 'Invoke-ScriptAnalyzer' -ParameterName 'ExcludeRule' -ScriptBlock $Function:RuleNameCompleter + Register-ArgumentCompleter -CommandName 'Get-ScriptAnalyzerRule' -ParameterName 'Name' -ScriptBlock $Function:RuleNameCompleter } \ No newline at end of file diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs index d9ce16031..61fdd6ec7 100644 --- a/Engine/ScriptAnalyzer.cs +++ b/Engine/ScriptAnalyzer.cs @@ -198,6 +198,30 @@ public void CleanUp() suppressedOnly = false; } + /// + /// Update includerules, excluderules, severity and rule arguments. + /// + /// An object of type Settings + public void UpdateSettings(Settings settings) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + this.severity = (!settings.Severities.Any()) ? null : settings.Severities.ToArray(); + this.includeRule = (!settings.IncludeRules.Any()) ? null : settings.IncludeRules.ToArray(); + this.excludeRule = (!settings.ExcludeRules.Any()) ? null : settings.ExcludeRules.ToArray(); + if (settings.RuleArguments != null) + { + Helper.Instance.SetRuleArguments( + settings.RuleArguments.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value as object, + StringComparer.OrdinalIgnoreCase)); + } + } + internal bool ParseProfile(object profileObject, PathIntrinsics path, IOutputWriter writer) { // profile was not given diff --git a/Engine/ScriptAnalyzerEngine.csproj b/Engine/ScriptAnalyzerEngine.csproj index f6da2ffb6..bbf06710c 100644 --- a/Engine/ScriptAnalyzerEngine.csproj +++ b/Engine/ScriptAnalyzerEngine.csproj @@ -74,6 +74,7 @@ + diff --git a/Engine/Settings.cs b/Engine/Settings.cs new file mode 100644 index 000000000..1cd35f448 --- /dev/null +++ b/Engine/Settings.cs @@ -0,0 +1,615 @@ +// +// Copyright (c) Microsoft Corporation. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Management.Automation.Language; +using System.Reflection; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + internal enum SettingsMode { None = 0, Auto, File, Hashtable, Preset }; + + public class Settings + { + private string filePath; + private List includeRules; + private List excludeRules; + private List severities; + private Dictionary> ruleArguments; + + public string FilePath { get { return filePath; } } + public IEnumerable IncludeRules { get { return includeRules; } } + public IEnumerable ExcludeRules { get { return excludeRules; } } + public IEnumerable Severities { get { return severities; } } + public Dictionary> RuleArguments { get { return ruleArguments; } } + + public Settings(object settings, Func presetResolver) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + includeRules = new List(); + excludeRules = new List(); + severities = new List(); + ruleArguments = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var settingsFilePath = settings as string; + + //it can either be a preset or path to a file or a hashtable + if (settingsFilePath != null) + { + if (presetResolver != null) + { + var resolvedFilePath = presetResolver(settingsFilePath); + if (resolvedFilePath != null) + { + settingsFilePath = resolvedFilePath; + } + } + + if (File.Exists(settingsFilePath)) + { + filePath = settingsFilePath; + parseSettingsFile(settingsFilePath); + } + else + { + throw new ArgumentException( + String.Format( + CultureInfo.CurrentCulture, + Strings.InvalidPath, + settingsFilePath)); + } + } + else + { + var settingsHashtable = settings as Hashtable; + if (settingsHashtable != null) + { + parseSettingsHashtable(settingsHashtable); + } + else + { + throw new ArgumentException(Strings.SettingsInvalidType); + } + } + } + + public Settings(object settings) : this(settings, null) + { + } + + /// + /// Retrieves the Settings directory from the Module directory structure + /// + public static string GetShippedSettingsDirectory() + { + // Find the compatibility files in Settings folder + var path = typeof(Helper).GetTypeInfo().Assembly.Location; + if (String.IsNullOrWhiteSpace(path)) + { + return null; + } + + var settingsPath = Path.Combine(Path.GetDirectoryName(path), "Settings"); + if (!Directory.Exists(settingsPath)) + { + // try one level down as the PSScriptAnalyzer module structure is not consistent + // CORECLR binaries are in PSScriptAnalyzer/coreclr/, PowerShell v3 binaries are in PSScriptAnalyzer/PSv3/ + // and PowerShell v5 binaries are in PSScriptAnalyzer/ + settingsPath = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(path)), "Settings"); + if (!Directory.Exists(settingsPath)) + { + return null; + } + } + + return settingsPath; + } + + /// + /// Returns the builtin setting presets + /// + /// Looks for powershell data files (*.psd1) in the PSScriptAnalyzer module settings directory + /// and returns the names of the files without extension + /// + public static IEnumerable GetSettingPresets() + { + var settingsPath = GetShippedSettingsDirectory(); + if (settingsPath != null) + { + foreach (var filepath in System.IO.Directory.EnumerateFiles(settingsPath, "*.psd1")) + { + yield return System.IO.Path.GetFileNameWithoutExtension(filepath); + } + } + } + + /// + /// Gets the path to the settings file corresponding to the given preset. + /// + /// If the corresponding preset file is not found, the method returns null. + /// + public static string GetSettingPresetFilePath(string settingPreset) + { + var settingsPath = GetShippedSettingsDirectory(); + if (settingsPath != null) + { + if (GetSettingPresets().Contains(settingPreset, StringComparer.OrdinalIgnoreCase)) + { + return System.IO.Path.Combine(settingsPath, settingPreset + ".psd1"); + } + } + + return null; + } + + /// + /// Recursively convert hashtable to dictionary + /// + /// + /// Dictionary that maps string to object + private Dictionary GetDictionaryFromHashtable(Hashtable hashtable) + { + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var obj in hashtable.Keys) + { + string key = obj as string; + if (key == null) + { + throw new InvalidDataException( + string.Format( + CultureInfo.CurrentCulture, + Strings.KeyNotString, + key)); + } + + var valueHashtableObj = hashtable[obj]; + if (valueHashtableObj == null) + { + throw new InvalidDataException( + string.Format( + CultureInfo.CurrentCulture, + Strings.WrongValueHashTable, + "", + key)); + } + + var valueHashtable = valueHashtableObj as Hashtable; + if (valueHashtable == null) + { + dictionary.Add(key, valueHashtableObj); + } + else + { + dictionary.Add(key, GetDictionaryFromHashtable(valueHashtable)); + } + } + return dictionary; + } + + private bool IsStringOrStringArray(object val) + { + if (val is string) + { + return true; + } + + var valArr = val as object[]; + return val == null ? false : valArr.All(x => x is string); + } + + private List GetData(object val, string key) + { + // value must be either string or or an array of strings + if (val == null) + { + throw new InvalidDataException( + string.Format( + CultureInfo.CurrentCulture, + Strings.WrongValueHashTable, + "", + key)); + } + + List values = new List(); + var valueStr = val as string; + if (valueStr != null) + { + values.Add(valueStr); + } + else + { + var valueArr = val as object[]; + if (valueArr == null) + { + // check if it is an array of strings + valueArr = val as string[]; + } + + if (valueArr != null) + { + foreach (var item in valueArr) + { + var itemStr = item as string; + if (itemStr != null) + { + values.Add(itemStr); + } + else + { + throw new InvalidDataException( + string.Format( + CultureInfo.CurrentCulture, + Strings.WrongValueHashTable, + val, + key)); + } + } + } + else + { + throw new InvalidDataException( + string.Format( + CultureInfo.CurrentCulture, + Strings.WrongValueHashTable, + val, + key)); + } + } + + return values; + } + + /// + /// Sets the arguments for consumption by rules + /// + /// A hashtable with rule names as keys + private Dictionary> ConvertToRuleArgumentType(object ruleArguments) + { + var ruleArgs = ruleArguments as Dictionary; + if (ruleArgs == null) + { + throw new ArgumentException(Strings.SettingsInputShouldBeDictionary, nameof(ruleArguments)); + } + + if (ruleArgs.Comparer != StringComparer.OrdinalIgnoreCase) + { + throw new ArgumentException(Strings.SettingsDictionaryShouldBeCaseInsesitive, nameof(ruleArguments)); + } + + var ruleArgsDict = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var rule in ruleArgs.Keys) + { + var argsDict = ruleArgs[rule] as Dictionary; + if (argsDict == null) + { + throw new InvalidDataException(Strings.SettingsInputShouldBeDictionary); + } + ruleArgsDict[rule] = argsDict; + } + + return ruleArgsDict; + } + + private void parseSettingsHashtable(Hashtable settingsHashtable) + { + HashSet validKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var settings = GetDictionaryFromHashtable(settingsHashtable); + foreach (var settingKey in settings.Keys) + { + var key = settingKey.ToLower(); + object val = settings[key]; + switch (key) + { + case "severity": + severities = GetData(val, key); + break; + + case "includerules": + includeRules = GetData(val, key); + break; + + case "excluderules": + excludeRules = GetData(val, key); + break; + + case "rules": + try + { + ruleArguments = ConvertToRuleArgumentType(val); + } + catch (ArgumentException argumentException) + { + throw new InvalidDataException( + string.Format(CultureInfo.CurrentCulture, Strings.WrongValueHashTable, "", key), + argumentException); + } + + break; + + default: + throw new InvalidDataException( + string.Format( + CultureInfo.CurrentCulture, + Strings.WrongKeyHashTable, + key)); + } + } + } + + private void parseSettingsFile(string settingsFilePath) + { + Token[] parserTokens = null; + ParseError[] parserErrors = null; + Ast profileAst = Parser.ParseFile(settingsFilePath, out parserTokens, out parserErrors); + IEnumerable hashTableAsts = profileAst.FindAll(item => item is HashtableAst, false); + + // no hashtable, raise warning + if (hashTableAsts.Count() == 0) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Strings.InvalidProfile, settingsFilePath)); + } + + HashtableAst hashTableAst = hashTableAsts.First() as HashtableAst; + Hashtable hashtable; + try + { + // ideally we should use HashtableAst.SafeGetValue() but since + // it is not available on PSv3, we resort to our own narrow implementation. + hashtable = GetHashtableFromHashTableAst(hashTableAst); + } + catch (InvalidOperationException e) + { + throw new ArgumentException(Strings.InvalidProfile, e); + } + + if (hashtable == null) + { + throw new ArgumentException( + String.Format( + CultureInfo.CurrentCulture, + Strings.InvalidProfile, + settingsFilePath)); + } + + parseSettingsHashtable(hashtable); + } + + private Hashtable GetHashtableFromHashTableAst(HashtableAst hashTableAst) + { + var output = new Hashtable(); + foreach (var kvp in hashTableAst.KeyValuePairs) + { + var keyAst = kvp.Item1 as StringConstantExpressionAst; + if (keyAst == null) + { + // first item (the key) should be a string + ThrowInvalidDataException(kvp.Item1); + } + var key = keyAst.Value; + + // parse the item2 as array + PipelineAst pipeAst = kvp.Item2 as PipelineAst; + List rhsList = new List(); + if (pipeAst != null) + { + ExpressionAst pureExp = pipeAst.GetPureExpression(); + var constExprAst = pureExp as ConstantExpressionAst; + if (constExprAst != null) + { + var strConstExprAst = constExprAst as StringConstantExpressionAst; + if (strConstExprAst != null) + { + rhsList.Add(strConstExprAst.Value); + } + else + { + // it is either an integer or a float + output[key] = constExprAst.Value; + continue; + } + } + else if (pureExp is HashtableAst) + { + output[key] = GetHashtableFromHashTableAst((HashtableAst)pureExp); + continue; + } + else if (pureExp is VariableExpressionAst) + { + var varExprAst = (VariableExpressionAst)pureExp; + switch (varExprAst.VariablePath.UserPath.ToLower()) + { + case "true": + output[key] = true; + break; + + case "false": + output[key] = false; + break; + + default: + ThrowInvalidDataException(varExprAst.Extent); + break; + } + + continue; + } + else + { + rhsList = GetArrayFromAst(pureExp); + } + } + + if (rhsList.Count == 0) + { + ThrowInvalidDataException(kvp.Item2); + } + + output[key] = rhsList.ToArray(); + } + + return output; + } + + private List GetArrayFromAst(ExpressionAst exprAst) + { + ArrayLiteralAst arrayLitAst = exprAst as ArrayLiteralAst; + var result = new List(); + + if (arrayLitAst == null && exprAst is ArrayExpressionAst) + { + ArrayExpressionAst arrayExp = (ArrayExpressionAst)exprAst; + return arrayExp == null ? null : GetArrayFromArrayExpressionAst(arrayExp); + } + + if (arrayLitAst == null) + { + ThrowInvalidDataException(arrayLitAst); + } + + foreach (var element in arrayLitAst.Elements) + { + var elementValue = element as StringConstantExpressionAst; + if (elementValue == null) + { + ThrowInvalidDataException(element); + } + + result.Add(elementValue.Value); + } + + return result; + } + + private List GetArrayFromArrayExpressionAst(ArrayExpressionAst arrayExp) + { + var result = new List(); + if (arrayExp.SubExpression != null) + { + StatementAst stateAst = arrayExp.SubExpression.Statements.FirstOrDefault(); + if (stateAst != null && stateAst is PipelineAst) + { + CommandBaseAst cmdBaseAst = (stateAst as PipelineAst).PipelineElements.FirstOrDefault(); + if (cmdBaseAst != null && cmdBaseAst is CommandExpressionAst) + { + CommandExpressionAst cmdExpAst = cmdBaseAst as CommandExpressionAst; + if (cmdExpAst.Expression is StringConstantExpressionAst) + { + return new List() + { + (cmdExpAst.Expression as StringConstantExpressionAst).Value + }; + } + else + { + // It should be an ArrayLiteralAst at this point + return GetArrayFromAst(cmdExpAst.Expression); + } + } + } + } + + return null; + } + + private void ThrowInvalidDataException(Ast ast) + { + ThrowInvalidDataException(ast.Extent); + } + + private void ThrowInvalidDataException(IScriptExtent extent) + { + throw new InvalidDataException(string.Format( + CultureInfo.CurrentCulture, + Strings.WrongValueFormat, + extent.StartLineNumber, + extent.StartColumnNumber, + extent.File ?? "")); + } + + private static bool IsBuiltinSettingPreset(object settingPreset) + { + var preset = settingPreset as string; + if (preset != null) + { + return GetSettingPresets().Contains(preset, StringComparer.OrdinalIgnoreCase); + } + + return false; + } + + internal static SettingsMode FindSettingsMode(object settings, string path, out object settingsFound) + { + var settingsMode = SettingsMode.None; + settingsFound = settings; + if (settingsFound == null) + { + if (path != null) + { + // add a directory separator character because if there is no trailing separator character, it will return the parent + var directory = path.TrimEnd(System.IO.Path.DirectorySeparatorChar); + if (File.Exists(directory)) + { + // if given path is a file, get its directory + directory = Path.GetDirectoryName(directory); + } + + if (Directory.Exists(directory)) + { + // if settings are not provided explicitly, look for it in the given path + // check if pssasettings.psd1 exists + var settingsFilename = "PSScriptAnalyzerSettings.psd1"; + var settingsFilePath = Path.Combine(directory, settingsFilename); + settingsFound = settingsFilePath; + if (File.Exists(settingsFilePath)) + { + settingsMode = SettingsMode.Auto; + } + } + } + } + else + { + var settingsFilePath = settingsFound as String; + if (settingsFilePath != null) + { + if (IsBuiltinSettingPreset(settingsFilePath)) + { + settingsMode = SettingsMode.Preset; + settingsFound = GetSettingPresetFilePath(settingsFilePath); + } + else + { + settingsMode = SettingsMode.File; + settingsFound = settingsFilePath; + } + } + else + { + if (settingsFound is Hashtable) + { + settingsMode = SettingsMode.Hashtable; + } + } + } + + return settingsMode; + } + } +} diff --git a/Engine/Strings.resx b/Engine/Strings.resx index 0c91b1f1d..d5c677da3 100644 --- a/Engine/Strings.resx +++ b/Engine/Strings.resx @@ -244,7 +244,37 @@ {0} is not a valid key in the settings hashtable. Valid keys are ExcludeRules, IncludeRules and Severity. - Value {0} for key {1} has the wrong data type. Value in the settings hashtable should be a string or an array of strings. + Value {0} for key {1} has the wrong data type. + + + Input should be a dictionary type. + + + Dictionary should be indexable in a case-insensitive manner. + + + Settings should be either a file path, built-in preset or a hashtable. + + + Settings not provided. Will look for settings file in the given path {0}. + + + Found {0}. Will use it to provide settings for this invocation. + + + Using settings file at {0}. + + + Using settings hashtable. + + + Cannot find a settings file. + + + Cannot parse settings. Will abort the invocation. + + + Temporary module location: {0}. Vertex {0} already exists! Cannot add it to the digraph. diff --git a/Rules/UseCompatibleCmdlets.cs b/Rules/UseCompatibleCmdlets.cs index 4f5093482..bd922e074 100644 --- a/Rules/UseCompatibleCmdlets.cs +++ b/Rules/UseCompatibleCmdlets.cs @@ -306,8 +306,7 @@ private void SetupCmdletsDictionary() return; } - string settingsPath; - settingsPath = GetShippedSettingsDirectory(); + string settingsPath = Settings.GetShippedSettingsDirectory(); #if DEBUG object modeObject; if (ruleArgs.TryGetValue("mode", out modeObject)) @@ -381,34 +380,6 @@ private void ResetCurCmdletCompatibilityMap() } } - /// - /// Retrieves the Settings directory from the Module directory structure - /// - private string GetShippedSettingsDirectory() - { - // Find the compatibility files in Settings folder - var path = this.GetType().GetTypeInfo().Assembly.Location; - if (String.IsNullOrWhiteSpace(path)) - { - return null; - } - - var settingsPath = Path.Combine(Path.GetDirectoryName(path), "Settings"); - if (!Directory.Exists(settingsPath)) - { - // try one level down as the PSScriptAnalyzer module structure is not consistent - // CORECLR binaries are in PSScriptAnalyzer/coreclr/, PowerShell v3 binaries are in PSScriptAnalyzer/PSv3/ - // and PowerShell v5 binaries are in PSScriptAnalyzer/ - settingsPath = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(path)), "Settings"); - if (!Directory.Exists(settingsPath)) - { - return null; - } - } - - return settingsPath; - } - private bool IsValidPlatformString(string fileNameWithoutExt) { string psedition, psversion, os; diff --git a/Tests/Engine/Settings.tests.ps1 b/Tests/Engine/Settings.tests.ps1 index 56f6f7c55..638de0d3a 100644 --- a/Tests/Engine/Settings.tests.ps1 +++ b/Tests/Engine/Settings.tests.ps1 @@ -1,31 +1,113 @@ -if (!(Get-Module PSScriptAnalyzer)) -{ - Import-Module PSScriptAnalyzer +if (!(Get-Module PSScriptAnalyzer)) { + Import-Module PSScriptAnalyzer } $directory = Split-Path $MyInvocation.MyCommand.Path +$settingsTestDirectory = [System.IO.Path]::Combine($directory, "SettingsTest") +$project1Root = [System.IO.Path]::Combine($settingsTestDirectory, "Project1") +$project2Root = [System.IO.Path]::Combine($settingsTestDirectory, "Project2") +$settingsTypeName = 'Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings' + Describe "Settings Precedence" { - $settingsTestDirectory = [System.IO.Path]::Combine($directory, "SettingsTest") - $project1Root = [System.IO.Path]::Combine($settingsTestDirectory, "Project1") - $project2Root = [System.IO.Path]::Combine($settingsTestDirectory, "Project2") Context "settings object is explicit" { It "runs rules from the explicit setting file" { - $settingsFilepath = [System.IO.Path]::Combine($project1Root, "ExplicitSettings.psd1") - $violations = Invoke-ScriptAnalyzer -Path $project1Root -Settings $settingsFilepath -Recurse - $violations.Count | Should Be 1 - $violations[0].RuleName | Should Be "PSAvoidUsingWriteHost" + $settingsFilepath = [System.IO.Path]::Combine($project1Root, "ExplicitSettings.psd1") + $violations = Invoke-ScriptAnalyzer -Path $project1Root -Settings $settingsFilepath -Recurse + $violations.Count | Should Be 2 } - } - Context "settings file is implicit" { + } + + Context "settings file is implicit" { It "runs rules from the implicit setting file" { - $violations = Invoke-ScriptAnalyzer -Path $project1Root -Recurse - $violations.Count | Should Be 1 - $violations[0].RuleName | Should Be "PSAvoidUsingCmdletAliases" + $violations = Invoke-ScriptAnalyzer -Path $project1Root -Recurse + $violations.Count | Should Be 1 + $violations[0].RuleName | Should Be "PSAvoidUsingCmdletAliases" } It "cannot find file if not named PSScriptAnalyzerSettings.psd1" { - $violations = Invoke-ScriptAnalyzer -Path $project2Root -Recurse - $violations.Count | Should Be 2 + $violations = Invoke-ScriptAnalyzer -Path $project2Root -Recurse + $violations.Count | Should Be 2 + } + } +} + +Describe "Settings Class" { + Context "When an empty hashtable is provided" { + BeforeAll { + $settings = New-Object -TypeName $settingsTypeName -ArgumentList @{} + } + + 'IncludeRules', 'ExcludeRules', 'Severity', 'RuleArguments' | ForEach-Object { + It ("Should return empty {0} property" -f $_) { + $settings.${$_}.Count | Should Be 0 + } + } + } + + Context "When a string is provided for IncludeRules in a hashtable" { + BeforeAll { + $ruleName = "PSAvoidCmdletAliases" + $settings = New-Object -TypeName $settingsTypeName -ArgumentList @{ IncludeRules = $ruleName } + } + + It "Should return an IncludeRules array with 1 element" { + $settings.IncludeRules.Count | Should Be 1 + } + + It "Should return the rule in the IncludeRules array" { + $settings.IncludeRules[0] | Should Be $ruleName + } + } + + Context "When rule arguments are provided in a hashtable" { + BeforeAll { + $settingsHashtable = @{ + Rules = @{ + PSAvoidUsingCmdletAliases = @{ + WhiteList = @("cd", "cp") + } + } + } + $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + } + + It "Should return the rule arguments" { + $settings.RuleArguments["PSAvoidUsingCmdletAliases"]["WhiteList"].Count | Should Be 2 + $settings.RuleArguments["PSAvoidUsingCmdletAliases"]["WhiteList"][0] | Should Be "cd" + $settings.RuleArguments["PSAvoidUsingCmdletAliases"]["WhiteList"][1] | Should Be "cp" + } + + It "Should be case insensitive" { + $settings.RuleArguments["psAvoidUsingCmdletAliases"]["whiteList"].Count | Should Be 2 + $settings.RuleArguments["psAvoidUsingCmdletAliases"]["whiteList"][0] | Should Be "cd" + $settings.RuleArguments["psAvoidUsingCmdletAliases"]["whiteList"][1] | Should Be "cp" + } + } + + Context "When a settings file path is provided" { + BeforeAll { + $settings = New-Object -TypeName $settingsTypeName ` + -ArgumentList ([System.IO.Path]::Combine($project1Root, "ExplicitSettings.psd1")) + } + + It "Should return 2 IncludeRules" { + $settings.IncludeRules.Count | Should Be 3 + } + + It "Should return 2 ExcludeRules" { + $settings.ExcludeRules.Count | Should Be 3 + } + + It "Should return 1 rule argument" { + $settings.RuleArguments.Count | Should Be 2 + } + + It "Should parse boolean type argument" { + $settings.RuleArguments["PSUseConsistentIndentation"]["Enable"] | Should Be $true + } + + It "Should parse int type argument" { + $settings.RuleArguments["PSUseConsistentIndentation"]["IndentationSize"] | Should Be 4 } - } + } } \ No newline at end of file diff --git a/Tests/Engine/SettingsTest/Project1/ExplicitSettings.psd1 b/Tests/Engine/SettingsTest/Project1/ExplicitSettings.psd1 index 9ad44a11c..88d2a6b4f 100644 --- a/Tests/Engine/SettingsTest/Project1/ExplicitSettings.psd1 +++ b/Tests/Engine/SettingsTest/Project1/ExplicitSettings.psd1 @@ -1,3 +1,14 @@ @{ - "IncludeRules" = @("PSAvoidUsingWriteHost") + "IncludeRules" = @("PSAvoidUsingCmdletAliases", "PSAvoidUsingWriteHost", "PSUseConsistentIndentation") + "ExcludeRules" = @("PSShouldProcess", "PSAvoidUsingWMICmdlet", "PSUseCmdletCorrectly") + "rules" = @{ + PSAvoidUsingCmdletAliases = @{ + WhiteList = @("cd", "cp") + } + + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 4 + } + } } \ No newline at end of file