diff --git a/Engine/Settings/core-6.1.0-linux-arm.json b/Engine/CommandDataFiles/core-6.1.0-linux-arm.json similarity index 100% rename from Engine/Settings/core-6.1.0-linux-arm.json rename to Engine/CommandDataFiles/core-6.1.0-linux-arm.json diff --git a/Engine/Settings/core-6.1.0-linux.json b/Engine/CommandDataFiles/core-6.1.0-linux.json similarity index 100% rename from Engine/Settings/core-6.1.0-linux.json rename to Engine/CommandDataFiles/core-6.1.0-linux.json diff --git a/Engine/Settings/core-6.1.0-macos.json b/Engine/CommandDataFiles/core-6.1.0-macos.json similarity index 100% rename from Engine/Settings/core-6.1.0-macos.json rename to Engine/CommandDataFiles/core-6.1.0-macos.json diff --git a/Engine/Settings/core-6.1.0-windows.json b/Engine/CommandDataFiles/core-6.1.0-windows.json similarity index 100% rename from Engine/Settings/core-6.1.0-windows.json rename to Engine/CommandDataFiles/core-6.1.0-windows.json diff --git a/Engine/Settings/desktop-2.0-windows.json b/Engine/CommandDataFiles/desktop-2.0-windows.json similarity index 100% rename from Engine/Settings/desktop-2.0-windows.json rename to Engine/CommandDataFiles/desktop-2.0-windows.json diff --git a/Engine/Settings/desktop-3.0-windows.json b/Engine/CommandDataFiles/desktop-3.0-windows.json similarity index 100% rename from Engine/Settings/desktop-3.0-windows.json rename to Engine/CommandDataFiles/desktop-3.0-windows.json diff --git a/Engine/Settings/desktop-4.0-windows.json b/Engine/CommandDataFiles/desktop-4.0-windows.json similarity index 100% rename from Engine/Settings/desktop-4.0-windows.json rename to Engine/CommandDataFiles/desktop-4.0-windows.json diff --git a/Engine/Settings/desktop-5.1.14393.206-windows.json b/Engine/CommandDataFiles/desktop-5.1.14393.206-windows.json similarity index 100% rename from Engine/Settings/desktop-5.1.14393.206-windows.json rename to Engine/CommandDataFiles/desktop-5.1.14393.206-windows.json diff --git a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs index 3219affa7..de9b2fa7e 100644 --- a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs +++ b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Linq; using System.Management.Automation; +using System.Reflection; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands { @@ -114,8 +115,35 @@ protected override void ProcessRecord() foreach (IRule rule in rules) { + IEnumerable optionInfos = null; + + if (rule is ConfigurableRule configurable) + { + var props = rule.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public); + var optList = new List(); + + foreach (var p in props) + { + if (p.GetCustomAttribute(inherit: true) == null) { + continue; + } + + optList.Add(new RuleOptionInfo + { + Name = p.Name, + OptionType = p.PropertyType, + DefaultValue = p.GetValue(rule) + }); + } + + if (optList.Count > 0) + { + optionInfos = optList; + } + } + WriteObject(new RuleInfo(rule.GetName(), rule.GetCommonName(), rule.GetDescription(), - rule.GetSourceType(), rule.GetSourceName(), rule.GetSeverity(), rule.GetType())); + rule.GetSourceType(), rule.GetSourceName(), rule.GetSeverity(), rule.GetType(), optionInfos)); } } } diff --git a/Engine/Commands/InvokeFormatterCommand.cs b/Engine/Commands/InvokeFormatterCommand.cs index 25a2d364e..bd23a5dcb 100644 --- a/Engine/Commands/InvokeFormatterCommand.cs +++ b/Engine/Commands/InvokeFormatterCommand.cs @@ -17,7 +17,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands public class InvokeFormatterCommand : PSCmdlet, IOutputWriter { private const string defaultSettingsPreset = "CodeFormatting"; - private Settings inputSettings; + private SettingsData inputSettings; private Range range; /// diff --git a/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs new file mode 100644 index 000000000..3dfab7d49 --- /dev/null +++ b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs @@ -0,0 +1,289 @@ +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Reflection; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands +{ + /// + /// Creates a new PSScriptAnalyzer settings file in the specified directory + /// optionally based on a preset, a blank template, or all rules with default arguments. + /// + [Cmdlet(VerbsCommon.New, "ScriptAnalyzerSettingsFile", SupportsShouldProcess = true)] + [OutputType(typeof(string))] + public sealed class NewScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter + { + private const string BaseOption_All = "All"; + private const string BaseOption_Blank = "Blank"; + + /// + /// Target directory (or file path) where the settings file will be created. Defaults to + /// current location. + /// + [Parameter(Position = 0)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// Settings file format/extension (e.g. json, psd1). Defaults to first supported format. + /// + [Parameter] + [ArgumentCompleter(typeof(FileFormatCompleter))] + [ValidateNotNullOrEmpty] + public string FileFormat { get; set; } + + /// + /// Base content: 'Blank', 'All', or a preset name returned by Get-SettingPresets. + /// 'Blank' -> minimal empty settings. + /// 'All' -> include all rules and their configurable arguments with current defaults. + /// preset -> copy preset contents. + /// + [Parameter] + [ArgumentCompleter(typeof(SettingsBaseCompleter))] + [ValidateNotNullOrEmpty] + public string Base { get; set; } = BaseOption_Blank; + + /// + /// Overwrite existing file if present. + /// + [Parameter] + public SwitchParameter Force { get; set; } + + protected override void BeginProcessing() + { + Helper.Instance = new Helper(SessionState.InvokeCommand); + Helper.Instance.Initialize(); + + string[] rulePaths = Helper.ProcessCustomRulePaths(null, SessionState, false); + ScriptAnalyzer.Instance.Initialize(this, rulePaths, null, null, null, null == rulePaths); + } + + protected override void ProcessRecord() + { + // Default Path + if (string.IsNullOrWhiteSpace(Path)) + { + Path = SessionState.Path.CurrentFileSystemLocation.ProviderPath; + } + + // If user passed an existing file path, switch to its directory. + if (File.Exists(Path)) + { + Path = System.IO.Path.GetDirectoryName(Path); + } + + // Require the directory to already exist (do not create it). + if (!Directory.Exists(Path)) + { + ThrowTerminatingError(new ErrorRecord( + new DirectoryNotFoundException($"Directory '{Path}' does not exist."), + "DIRECTORY_NOT_FOUND", + ErrorCategory.ObjectNotFound, + Path)); + return; + } + + // Ensure FileSystem provider for target Path. + ProviderInfo providerInfo; + try + { + SessionState.Path.GetResolvedProviderPathFromPSPath(Path, out providerInfo); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException($"Cannot resolve path '{Path}': {ex.Message}", ex), + "PATH_RESOLVE_FAILED", + ErrorCategory.InvalidArgument, + Path)); + return; + } + + if (!string.Equals(providerInfo.Name, "FileSystem", StringComparison.OrdinalIgnoreCase)) + { + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException("Target path must be in the FileSystem provider."), + "INVALID_PROVIDER", + ErrorCategory.InvalidArgument, + Path)); + } + + // Default format to first supported. + if (string.IsNullOrWhiteSpace(FileFormat)) + { + FileFormat = Settings.GetSettingsFormats().First(); + } + + // Validate requested format. + if (!Settings.GetSettingsFormats().Any(f => string.Equals(f, FileFormat, StringComparison.OrdinalIgnoreCase))) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"Unsupported settings format '{FileFormat}'."), + "UNSUPPORTED_FORMAT", + ErrorCategory.InvalidArgument, + FileFormat)); + } + + var targetFile = System.IO.Path.Combine(Path, $"{Settings.DefaultSettingsFileName}.{FileFormat}"); + + if (File.Exists(targetFile) && !Force) + { + WriteWarning($"Settings file already exists: {targetFile}. Use -Force to overwrite."); + return; + } + + SettingsData data; + try + { + data = BuildSettingsData(); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + ex, + "BUILD_SETTINGS_FAILED", + ErrorCategory.InvalidData, + Base)); + return; + } + + string content; + try + { + content = Settings.Serialize(data, FileFormat); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + ex, + "SERIALIZE_FAILED", + ErrorCategory.InvalidData, + FileFormat)); + return; + } + + if (ShouldProcess(targetFile, "Create settings file")) + { + try + { + File.WriteAllText(targetFile, content); + WriteVerbose($"Created settings file: {targetFile}"); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + ex, + "CREATE_FILE_FAILED", + ErrorCategory.InvalidData, + targetFile)); + return; + } + WriteObject(targetFile); + } + } + + private SettingsData BuildSettingsData() + { + if (string.Equals(Base, BaseOption_Blank, StringComparison.OrdinalIgnoreCase)) + { + return new SettingsData(); // empty snapshot + } + + if (string.Equals(Base, BaseOption_All, StringComparison.OrdinalIgnoreCase)) + { + return BuildAllSettingsData(); + } + + // Preset + var presetPath = Settings.TryResolvePreset(Base); + if (presetPath == null) + { + throw new FileNotFoundException($"Preset '{Base}' not found."); + } + return Settings.Create(presetPath); + } + + private SettingsData BuildAllSettingsData() + { + var ruleNames = new List(); + var ruleArgs = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var modNames = ScriptAnalyzer.Instance.GetValidModulePaths(); + var rules = ScriptAnalyzer.Instance.GetRule(modNames, null) ?? Enumerable.Empty(); + + foreach (var rule in rules) + { + var name = rule.GetName(); + ruleNames.Add(name); + + if (rule is ConfigurableRule configurable) + { + var props = rule.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public); + var argDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in props) + { + if (p.GetCustomAttribute(inherit: true) == null) + { + continue; + } + argDict[p.Name] = p.GetValue(rule); + } + if (argDict.Count > 0) + { + ruleArgs[name] = argDict; + } + } + } + + return new SettingsData + { + IncludeRules = ruleNames, + RuleArguments = ruleArgs, + }; + } + + #region Completers + + private sealed class FileFormatCompleter : IArgumentCompleter + { + public IEnumerable CompleteArgument(string commandName, + string parameterName, string wordToComplete, CommandAst commandAst, + IDictionary fakeBoundParameters) + { + foreach (var fmt in Settings.GetSettingsFormats()) + { + if (fmt.StartsWith(wordToComplete ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + yield return new CompletionResult(fmt, fmt, CompletionResultType.ParameterValue, $"Settings format '{fmt}'"); + } + } + } + } + + private sealed class SettingsBaseCompleter : IArgumentCompleter + { + public IEnumerable CompleteArgument(string commandName, + string parameterName, string wordToComplete, CommandAst commandAst, + IDictionary fakeBoundParameters) + { + var bases = new List { BaseOption_Blank, BaseOption_All }; + bases.AddRange(Settings.GetSettingPresets()); + + foreach (var b in bases) + { + if (b.StartsWith(wordToComplete ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + yield return new CompletionResult(b, b, CompletionResultType.ParameterValue, $"Base template '{b}'"); + } + } + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Engine/Engine.csproj b/Engine/Engine.csproj index 63b9a1b9c..b448710a2 100644 --- a/Engine/Engine.csproj +++ b/Engine/Engine.csproj @@ -71,12 +71,15 @@ + + $(DefineConstants);PSV7;CORECLR + diff --git a/Engine/Formatter.cs b/Engine/Formatter.cs index a6a25f0fb..ea00dfbb4 100644 --- a/Engine/Formatter.cs +++ b/Engine/Formatter.cs @@ -23,7 +23,7 @@ public class Formatter /// public static string Format( string scriptDefinition, - Settings settings, + SettingsData settings, Range range, TCmdlet cmdlet) where TCmdlet : PSCmdlet, IOutputWriter { @@ -81,9 +81,9 @@ private static void ValidateNotNull(T obj, string name) } } - private static Settings GetCurrentSettings(Settings settings, string rule) + private static SettingsData GetCurrentSettings(SettingsData settings, string rule) { - return new Settings(new Hashtable() + return Settings.Create(new Hashtable() { {"IncludeRules", new string[] {rule}}, {"Rules", new Hashtable() { { rule, new Hashtable(settings.RuleArguments[rule]) } } } diff --git a/Engine/Generic/RuleInfo.cs b/Engine/Generic/RuleInfo.cs index 755d16d15..befe8ba26 100644 --- a/Engine/Generic/RuleInfo.cs +++ b/Engine/Generic/RuleInfo.cs @@ -3,6 +3,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic { @@ -18,6 +20,7 @@ public class RuleInfo private string sourceName; private RuleSeverity ruleSeverity; private Type implementingType; + private IEnumerable options; /// /// Name: The name of the rule. @@ -90,6 +93,15 @@ public Type ImplementingType private set { implementingType = value; } } + /// + /// Options : The configurable options of the rule if it is a ConfigurableRule. + /// + public IEnumerable Options + { + get { return options; } + private set { options = value; } + } + /// /// Constructor for a RuleInfo. /// @@ -106,6 +118,7 @@ public RuleInfo(string name, string commonName, string description, SourceType s SourceType = sourceType; SourceName = sourceName; Severity = severity; + Options = Enumerable.Empty(); } /// @@ -119,13 +132,36 @@ public RuleInfo(string name, string commonName, string description, SourceType s /// The dotnet type of the rule. public RuleInfo(string name, string commonName, string description, SourceType sourceType, string sourceName, RuleSeverity severity, Type implementingType) { - RuleName = name; - CommonName = commonName; + RuleName = name; + CommonName = commonName; Description = description; - SourceType = sourceType; - SourceName = sourceName; + SourceType = sourceType; + SourceName = sourceName; + Severity = severity; + ImplementingType = implementingType; + Options = Enumerable.Empty(); + } + + /// + /// Constructor for a RuleInfo. + /// + /// Name of the rule. + /// Common Name of the rule. + /// Description of the rule. + /// Source type of the rule. + /// Source name of the rule. + /// The dotnet type of the rule. + /// The configurable options of the rule. + public RuleInfo(string name, string commonName, string description, SourceType sourceType, string sourceName, RuleSeverity severity, Type implementingType, IEnumerable options) + { + RuleName = name; + CommonName = commonName; + Description = description; + SourceType = sourceType; + SourceName = sourceName; Severity = severity; ImplementingType = implementingType; + Options = options ?? Enumerable.Empty(); } public override string ToString() diff --git a/Engine/Generic/RuleOptionInfo.cs b/Engine/Generic/RuleOptionInfo.cs new file mode 100644 index 000000000..da030fcdf --- /dev/null +++ b/Engine/Generic/RuleOptionInfo.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic +{ + + /// + /// Holds metadata for a single configurable rule property. + /// + public class RuleOptionInfo + { + public string Name { get; internal set; } + public Type OptionType { get; internal set; } + public object DefaultValue { get; internal set; } + } + +} diff --git a/Engine/Helper.cs b/Engine/Helper.cs index 098d8a276..125a3a726 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -1501,6 +1501,11 @@ public static string[] ProcessCustomRulePaths(string[] rulePaths, SessionState s return null; } + if (rulePaths.Length == 0) + { + return null; + } + Collection pathInfo = new Collection(); foreach (string rulePath in rulePaths) { diff --git a/Engine/PSScriptAnalyzer.psd1 b/Engine/PSScriptAnalyzer.psd1 index 49fb93227..b50494bad 100644 --- a/Engine/PSScriptAnalyzer.psd1 +++ b/Engine/PSScriptAnalyzer.psd1 @@ -65,7 +65,7 @@ FormatsToProcess = @('ScriptAnalyzer.format.ps1xml') FunctionsToExport = @() # Cmdlets to export from this module -CmdletsToExport = @('Get-ScriptAnalyzerRule', 'Invoke-ScriptAnalyzer', 'Invoke-Formatter') +CmdletsToExport = @('Get-ScriptAnalyzerRule', 'Invoke-ScriptAnalyzer', 'Invoke-Formatter', 'New-ScriptAnalyzerSettingsFile') # Variables to export from this module VariablesToExport = @() diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs index f250336b5..5bada52d2 100644 --- a/Engine/ScriptAnalyzer.cs +++ b/Engine/ScriptAnalyzer.cs @@ -207,7 +207,7 @@ public void CleanUp() /// Update includerules, excluderules, severity and rule arguments. /// /// An object of type Settings - public void UpdateSettings(Settings settings) + public void UpdateSettings(SettingsData settings) { if (settings == null) { diff --git a/Engine/Settings.cs b/Engine/Settings.cs deleted file mode 100644 index b0c424c64..000000000 --- a/Engine/Settings.cs +++ /dev/null @@ -1,569 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Management.Automation; -using System.Management.Automation.Language; -using System.Reflection; - -namespace Microsoft.Windows.PowerShell.ScriptAnalyzer -{ - internal enum SettingsMode { None = 0, Auto, File, Hashtable, Preset }; - - /// - /// A class to represent the settings provided to ScriptAnalyzer class. - /// - public class Settings - { - private bool recurseCustomRulePath = false; - private bool includeDefaultRules = false; - private string filePath; - private List includeRules; - private List excludeRules; - private List severities; - private List customRulePath; - private Dictionary> ruleArguments; - - public bool RecurseCustomRulePath => recurseCustomRulePath; - public bool IncludeDefaultRules => includeDefaultRules; - public string FilePath => filePath; - public IEnumerable IncludeRules => includeRules; - public IEnumerable ExcludeRules => excludeRules; - public IEnumerable Severities => severities; - public IEnumerable CustomRulePath => customRulePath; - public Dictionary> RuleArguments => ruleArguments; - - /// - /// Create a settings object from the input object. - /// - /// An input object of type Hashtable or string. - /// A function that takes in a preset and resolves it to a path. - 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); - } - } - } - - /// - /// Create a Settings object from the input object. - /// - /// An input object of type Hashtable or string. - 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; - } - - /// - /// Create a settings object from an input object. - /// - /// An input object of type Hashtable or string. - /// The path in which to search for a settings file. - /// An output writer. - /// The GetResolvedProviderPathFromPSPath method from PSCmdlet to resolve relative path including wildcard support. - /// An object of Settings type. - internal static Settings Create(object settingsObj, string cwd, IOutputWriter outputWriter, - PathResolver.GetResolvedProviderPathFromPSPath> getResolvedProviderPathFromPSPathDelegate) - { - object settingsFound; - var settingsMode = FindSettingsMode(settingsObj, cwd, out settingsFound); - - switch (settingsMode) - { - case SettingsMode.Auto: - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsNotProvided, - "")); - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsAutoDiscovered, - (string)settingsFound)); - break; - - case SettingsMode.Preset: - case SettingsMode.File: - var userProvidedSettingsString = settingsFound.ToString(); - try - { - var resolvedPath = getResolvedProviderPathFromPSPathDelegate(userProvidedSettingsString, out ProviderInfo providerInfo).Single(); - settingsFound = resolvedPath; - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsUsingFile, - resolvedPath)); - } - catch - { - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsCannotFindFile, - userProvidedSettingsString)); - } - break; - - case SettingsMode.Hashtable: - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsUsingHashtable)); - break; - - default: - outputWriter?.WriteVerbose( - String.Format( - CultureInfo.CurrentCulture, - Strings.SettingsObjectCouldNotBResolved)); - return null; - } - - return new Settings(settingsFound); - } - - /// - /// 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 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.ToLowerInvariant(); // ToLowerInvariant is important to also work with turkish culture, see https://github.com/PowerShell/PSScriptAnalyzer/issues/1095 - 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 "customrulepath": - customRulePath = GetData(val, key); - break; - - case "includedefaultrules": - case "recursecustomrulepath": - if (!(val is bool)) - { - throw new InvalidDataException(string.Format( - CultureInfo.CurrentCulture, - Strings.SettingsValueTypeMustBeBool, - settingKey)); - } - - var booleanVal = (bool)val; - var field = this.GetType().GetField( - key, - BindingFlags.Instance | BindingFlags.IgnoreCase | BindingFlags.NonPublic); - field.SetValue(this, booleanVal); - 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 = Helper.GetSafeValueFromHashtableAst(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 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; - - // if the provided settings argument is wrapped in an expressions then PowerShell resolves it but it will be of type PSObject and we have to operate then on the BaseObject - if (settings is PSObject settingsFoundPSObject) - { - settings = settingsFoundPSObject.BaseObject; - } - - 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 - { - if (!TryResolveSettingForStringType(settingsFound, ref settingsMode, ref settingsFound)) - { - if (settingsFound is Hashtable) - { - settingsMode = SettingsMode.Hashtable; - } - } - } - - return settingsMode; - } - - // If the settings object is a string determine wheter it is one of the settings preset or a file path and resolve the setting in the former case. - private static bool TryResolveSettingForStringType(object settingsObject, ref SettingsMode settingsMode, ref object resolvedSettingValue) - { - if (settingsObject is string settingsString) - { - if (IsBuiltinSettingPreset(settingsString)) - { - settingsMode = SettingsMode.Preset; - resolvedSettingValue = GetSettingPresetFilePath(settingsString); - } - else - { - settingsMode = SettingsMode.File; - resolvedSettingValue = settingsString; - } - return true; - } - - return false; - } - } -} diff --git a/Engine/Settings/HashtableSettingsConverter.cs b/Engine/Settings/HashtableSettingsConverter.cs new file mode 100644 index 000000000..55c95d515 --- /dev/null +++ b/Engine/Settings/HashtableSettingsConverter.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Converts an inline PowerShell hashtable into a strongly typed . + /// Overview of parsing logic: + /// 1. Recursively flattens nested Hashtables into case-insensitive Dictionary + /// while preserving inner dictionaries for rule arguments. + /// 2. Iterates top-level keys, normalising to lowercase to match known setting names. + /// 3. For list-valued keys (Severity, IncludeRules, etc.) coerces single string or enumerable + /// of strings into a List. + /// 4. For boolean flags (IncludeDefaultRules, RecurseCustomRulePath) enforces strict bool + /// types. + /// 5. For Rules, validates a two-level case-insensitive dictionary-of-dictionaries (rule -> + /// argument name/value). + /// 6. Throws on unknown keys or invalid value shapes to fail + /// fast and surface user errors clearly. + /// + internal static class HashtableSettingsConverter + { + + /// + /// Entry point: converts a user-supplied settings hashtable into a + /// instance. + /// + /// Inline settings hashtable. + /// Populated . + /// + /// Thrown when a key is unknown or a value does not meet required type/shape constraints. + /// + public static SettingsData Convert(Hashtable table) + { + var includeRules = new List(); + var excludeRules = new List(); + var severities = new List(); + var customRulePath = new List(); + bool includeDefaultRules = false; + bool recurseCustomRulePath = false; + var ruleArgsOuter = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var dict = ToDictionary(table); + + foreach (var kvp in dict) + { + var keyLower = kvp.Key.ToLowerInvariant(); + var val = kvp.Value; + switch (keyLower) + { + case "severity": + severities = CoerceStringList(val, kvp.Key); + break; + case "includerules": + includeRules = CoerceStringList(val, kvp.Key); + break; + case "excluderules": + excludeRules = CoerceStringList(val, kvp.Key); + break; + case "customrulepath": + customRulePath = CoerceStringList(val, kvp.Key); + break; + case "includedefaultrules": + includeDefaultRules = CoerceBool(val, kvp.Key); + break; + case "recursecustomrulepath": + recurseCustomRulePath = CoerceBool(val, kvp.Key); + break; + case "rules": + ruleArgsOuter = ConvertRuleArguments(val, kvp.Key); + break; + default: + throw new InvalidDataException($"Unknown settings key '{kvp.Key}'."); + } + } + + return new SettingsData + { + IncludeRules = includeRules, + ExcludeRules = excludeRules, + Severities = severities, + CustomRulePath = customRulePath, + IncludeDefaultRules = includeDefaultRules, + RecurseCustomRulePath = recurseCustomRulePath, + RuleArguments = ruleArgsOuter + }; + } + + /// + /// Recursively converts a Hashtable (and any nested Hashtables) to a case-insensitive + /// Dictionary. + /// Nested Hashtables become nested Dictionary instances. + /// + /// Source hashtable. + /// Case-insensitive dictionary representation. + /// If any key is not a string. + private static Dictionary ToDictionary(Hashtable table) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var keyObj in table.Keys) + { + if (keyObj is not string key) + throw new InvalidDataException("Settings keys must be strings."); + var value = table[keyObj]; + if (value is Hashtable ht) + { + dict[key] = ToDictionary(ht); + } + else + { + dict[key] = value; + } + } + return dict; + } + + /// + /// Coerces a value into a list of strings. Accepts a single string or an enumerable of + /// strings. + /// + /// Value to coerce. + /// Original key name for error context. + /// List of strings. + /// + /// If value is neither string nor enumerable of strings. + /// + private static List CoerceStringList(object val, string key) + { + if (val is string s) return new List { s }; + if (val is IEnumerable enumerable) + { + var list = new List(); + foreach (var item in enumerable) + { + if (item is string si) list.Add(si); + else throw new InvalidDataException($"Non-string element in array for key '{key}'."); + } + return list; + } + throw new InvalidDataException($"Value for key '{key}' must be string or string array."); + } + + /// + /// Validates and returns a boolean settings value. + /// + /// Value to validate. + /// Key name for error messages. + /// Boolean value. + /// If value is not a boolean. + private static bool CoerceBool(object val, string key) + { + if (val is bool b) return b; + throw new InvalidDataException($"Value for key '{key}' must be boolean."); + } + + /// + /// Converts the value of the Rules key into a two-level case-insensitive dictionary + /// structure. + /// Expects outer and each inner dictionary to be case-insensitive + /// Dictionary<string, object>. + /// + /// Rules value object. + /// Original key name ("Rules") for error context. + /// Dictionary of rule name to its argument dictionary. + /// + /// Thrown if the outer/inner dictionaries are missing, not case-insensitive, or wrongly + /// typed. + /// + private static Dictionary> ConvertRuleArguments(object val, string key) + { + if (val is not Dictionary outer || outer.Comparer != StringComparer.OrdinalIgnoreCase) + throw new InvalidDataException($"Rules value must be a case-insensitive dictionary for key '{key}'."); + + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var ruleName in outer.Keys) + { + if (outer[ruleName] is not Dictionary inner || inner.Comparer != StringComparer.OrdinalIgnoreCase) + throw new InvalidDataException($"Rule arguments for '{ruleName}' must be a case-insensitive dictionary."); + result[ruleName] = new Dictionary(inner, StringComparer.OrdinalIgnoreCase); + } + return result; + } + } +} \ No newline at end of file diff --git a/Engine/Settings/ISettingsFormat.cs b/Engine/Settings/ISettingsFormat.cs new file mode 100644 index 000000000..8660fcbf2 --- /dev/null +++ b/Engine/Settings/ISettingsFormat.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Interface for settings file formats + /// + public interface ISettingsFormat + { + /// + /// Format identifier (extension without dot, e.g. 'json', 'psd1'). + /// + string FormatName { get; } + + /// + /// Whether this format can handle the specified path + /// + /// Full path or filename. + /// True if this format can handle the path. + bool Supports(string path); + + /// + /// Deserialises the content stream into . + /// + /// The content to parse. + /// The parsed SettingsData. + SettingsData Deserialize(string content, string sourcePath); + + /// + /// Serializes the into a string representation. + /// + /// + /// + string Serialize(SettingsData settingsData); + + } +} \ No newline at end of file diff --git a/Engine/Settings/JsonSettingsFormat.cs b/Engine/Settings/JsonSettingsFormat.cs new file mode 100644 index 000000000..89b99b524 --- /dev/null +++ b/Engine/Settings/JsonSettingsFormat.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + + /// + /// Handles JSON settings files (extension .json). + /// Expected top-level properties: + /// Severity : string or string array + /// IncludeRules : string or string array + /// ExcludeRules : string or string array + /// CustomRulePath : string or string array + /// IncludeDefaultRules : bool + /// RecurseCustomRulePath : bool + /// Rules : object with ruleName -> { argumentName : value } mapping + /// + internal sealed class JsonSettingsFormat : ISettingsFormat + { + + /// + /// DTO for deserializing JSON settings. + /// + private sealed class JsonSettingsDto + { + public List Severity { get; set; } + public List IncludeRules { get; set; } + public List ExcludeRules { get; set; } + public List CustomRulePath { get; set; } + public bool? IncludeDefaultRules { get; set; } + public bool? RecurseCustomRulePath { get; set; } + public Dictionary> Rules { get; set; } + } + + public string FormatName => "json"; + + /// + /// Determines if this format can handle the supplied path by checking for .json extension. + /// + /// File path + /// True if extension is .json. + public bool Supports(string path) => + string.Equals(Path.GetExtension(path), ".json", StringComparison.OrdinalIgnoreCase); + + /// + /// Parses a JSON settings file stream into . + /// + /// Readable stream positioned at start of JSON content. + /// Original file path (for error context). + /// Populated . + /// + /// Thrown on JSON deserialization error or invalid/empty root object. + /// + public SettingsData Deserialize(string content, string sourcePath) + { + JsonSettingsDto dto; + try + { + dto = JsonConvert.DeserializeObject(content); + } + catch (JsonException je) + { + throw new InvalidDataException($"Failed to parse settings JSON '{sourcePath}': {je.Message}", je); + } + if (dto == null) + throw new InvalidDataException($"Settings JSON '{sourcePath}' is empty or invalid."); + + // Normalize rule arguments into case-insensitive dictionaries + var ruleArgs = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (dto.Rules != null) + { + foreach (var kv in dto.Rules) + { + ruleArgs[kv.Key] = kv.Value != null + ? new Dictionary(kv.Value, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + return new SettingsData + { + IncludeRules = dto.IncludeRules ?? new List(), + ExcludeRules = dto.ExcludeRules ?? new List(), + Severities = dto.Severity ?? new List(), + CustomRulePath = dto.CustomRulePath ?? new List(), + IncludeDefaultRules = dto.IncludeDefaultRules.GetValueOrDefault(), + RecurseCustomRulePath = dto.RecurseCustomRulePath.GetValueOrDefault(), + RuleArguments = ruleArgs + }; + } + + /// + /// Serializes a instance into a PSScriptAnalyzerSettings.json + /// formatted string. Omits empty collections and false boolean flags for brevity. + /// + /// Settings snapshot to serialize. + /// True for indented JSON, false for minified. + /// JSON string suitable for saving as PSScriptAnalyzerSettings.json. + /// If is null. + public string Serialize(SettingsData data) + { + if (data == null) throw new ArgumentNullException(nameof(data)); + + var root = new JObject(); + var serializer = JsonSerializer.CreateDefault(); + + void AddArray(string name, IList list) + { + if (list != null && list.Count > 0) + { + root[name] = new JArray(list); + } + } + + AddArray("IncludeRules", data.IncludeRules); + AddArray("ExcludeRules", data.ExcludeRules); + AddArray("Severity", data.Severities); + AddArray("CustomRulePath", data.CustomRulePath); + + if (data.IncludeDefaultRules) + { + root["IncludeDefaultRules"] = true; + } + if (data.RecurseCustomRulePath) + { + root["RecurseCustomRulePath"] = true; + } + + if (data.RuleArguments != null && data.RuleArguments.Count > 0) + { + var rulesObj = new JObject(); + foreach (var rule in data.RuleArguments) + { + var argsObj = new JObject(); + if (rule.Value != null) + { + foreach (var arg in rule.Value) + { + // Serialize scalar or complex value + argsObj[arg.Key] = arg.Value != null + ? JToken.FromObject(arg.Value, serializer) + : JValue.CreateNull(); + } + } + rulesObj[rule.Key] = argsObj; + } + root["Rules"] = rulesObj; + } + return root.ToString(Formatting.Indented); + } + } + +} \ No newline at end of file diff --git a/Engine/Settings/Psd1SettingsFormat.cs b/Engine/Settings/Psd1SettingsFormat.cs new file mode 100644 index 000000000..7705e540d --- /dev/null +++ b/Engine/Settings/Psd1SettingsFormat.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation.Language; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Parses PowerShell data files (.psd1) containing a top-level hashtable into + /// . + /// Parsing steps: + /// 1. Verify the source file exists (the PowerShell parser requires a path). + /// 2. Parse the file into an AST using . + /// 3. Locate the first (expected to represent the settings). + /// 4. Safely convert the hashtable AST into a via + /// . + /// 5. Delegate normalization and validation to + /// . + /// Throws for structural issues (missing hashtable, invalid + /// values). + /// + internal sealed class Psd1SettingsFormat : ISettingsFormat + { + public string FormatName => "psd1"; + + /// + /// Determines whether the supplied path (or extension) is a .psd1 settings file. + /// + /// Full path or just an extension string. + /// True if the extension is .psd1 (case-insensitive). + public bool Supports(string pathOrExtension) => + string.Equals(Path.GetExtension(pathOrExtension), ".psd1", StringComparison.OrdinalIgnoreCase); + + /// + /// Deserializes a .psd1 settings file into . + /// + /// + /// The content of the .psd1 file as a string. + /// + /// Absolute or relative path to the .psd1 file. + /// Normalized instance. + /// + /// If no top-level hashtable is found or conversion yields invalid data. + /// + public SettingsData Deserialize(string content, string sourcePath) + { + Ast ast = Parser.ParseInput(content, out Token[] tokens, out ParseError[] errors); + + if (ast.FindAll(a => a is HashtableAst, false).FirstOrDefault() is not HashtableAst hashTableAst) + { + throw new InvalidDataException($"Settings file '{sourcePath}' does not contain a hashtable."); + } + + Hashtable raw; + try + { + raw = Helper.GetSafeValueFromHashtableAst(hashTableAst); + } + catch (InvalidOperationException e) + { + throw new InvalidDataException($"Invalid settings file '{sourcePath}'.", e); + } + if (raw == null) + { + throw new InvalidDataException($"Invalid settings file '{sourcePath}'."); + } + + return HashtableSettingsConverter.Convert(raw); + } + + /// + /// Serializes a instance into a formatted .psd1 settings file + /// (PowerShell hashtable) similar to shipped presets. + /// Omits empty collections and flags (if false) to keep output concise. + /// + /// Settings to serialize. + /// Formatted .psd1 content as a string. + public string Serialize(SettingsData settingsData) + { + if (settingsData == null) throw new ArgumentNullException(nameof(settingsData)); + + var sb = new System.Text.StringBuilder(); + var indent = " "; + + string Quote(string s) => "'" + s.Replace("'", "''") + "'"; + + void AppendStringList(string key, List list) + { + if (list == null || list.Count == 0) return; + sb.Append(indent).Append(key).Append(" = @(").AppendLine(); + for (int i = 0; i < list.Count; i++) + { + sb.Append(indent).Append(indent).Append(Quote(list[i])); + sb.AppendLine(i == list.Count - 1 ? string.Empty : ","); + } + sb.AppendLine(indent + ")").AppendLine(); + } + + string FormatScalar(object value) + { + if (value == null) return "$null"; + return value switch + { + string s => Quote(s), + bool b => b ? "$true" : "$false", + Enum e => Quote(e.ToString()), + int or long or short or byte or sbyte or uint or ulong or ushort => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture), + float f => f.ToString(System.Globalization.CultureInfo.InvariantCulture), + double d => d.ToString(System.Globalization.CultureInfo.InvariantCulture), + decimal m => m.ToString(System.Globalization.CultureInfo.InvariantCulture), + _ => Quote(value.ToString()) + }; + } + + string FormatValue(object value) + { + // Non-string enumerable -> PowerShell array literal + if (value is System.Collections.IEnumerable en && value is not string) + { + var items = new List(); + foreach (var item in en) + { + // Treat nested enumerable of strings similarly; else fall back to scalar + items.Add(FormatScalar(item)); + } + return items.Count == 0 + ? "@()" + : "@(" + string.Join(", ", items) + ")"; + } + return FormatScalar(value); + } + + sb.AppendLine("@{"); + + AppendStringList("IncludeRules", settingsData.IncludeRules); + AppendStringList("ExcludeRules", settingsData.ExcludeRules); + AppendStringList("Severity", settingsData.Severities); + AppendStringList("CustomRulePath", settingsData.CustomRulePath); + + if (settingsData.IncludeDefaultRules) + { + sb.Append(indent).Append("IncludeDefaultRules = $true").AppendLine().AppendLine(); + } + if (settingsData.RecurseCustomRulePath) + { + sb.Append(indent).Append("RecurseCustomRulePath = $true").AppendLine().AppendLine(); + } + + if (settingsData.RuleArguments != null && settingsData.RuleArguments.Count > 0) + { + sb.Append(indent).AppendLine("Rules = @{"); + foreach (var ruleKvp in settingsData.RuleArguments) + { + sb.Append(indent).Append(indent).Append(ruleKvp.Key).Append(" = @{").AppendLine(); + if (ruleKvp.Value != null && ruleKvp.Value.Count > 0) + { + foreach (var argKvp in ruleKvp.Value) + { + sb.Append(indent).Append(indent).Append(indent) + .Append(argKvp.Key).Append(" = ") + .AppendLine(FormatValue(argKvp.Value)); + } + } + sb.Append(indent).Append(indent).AppendLine("}").AppendLine(); + } + sb.Append(indent).AppendLine("}"); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/Engine/Settings/Settings.cs b/Engine/Settings/Settings.cs new file mode 100644 index 000000000..53a408eb1 --- /dev/null +++ b/Engine/Settings/Settings.cs @@ -0,0 +1,445 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Management.Automation; +using System.Reflection; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + + /// + /// Central entry point for obtaining analyzer settings. Resolves the -Settings parameter + /// (null, preset name, file path, or inline hashtable) into a SettingsData instance by: + /// 1. Auto-discovering a settings file (PSScriptAnalyzerSettings.*) in the working directory. + /// 2. Mapping preset names to shipped settings files (supporting multiple formats). + /// 3. Loading and parsing settings files via registered formats (e.g. psd1, json). + /// 4. Converting inline hashtables directly to SettingsData. + /// Also exposes helpers to enumerate shipped presets and locate module resource folders. + /// + public static class Settings + { + + public readonly static string DefaultSettingsFileName = "PSScriptAnalyzerSettings"; + + /// + /// Registered settings formats in precedence order. + /// The first matching format "wins" for auto discovery and presets when multiple + /// files of the same base name, but different supported extensions, exist. + /// + private static readonly ISettingsFormat[] s_formats = + { + new JsonSettingsFormat(), + new Psd1SettingsFormat() + }; + + /// + /// Creates a from a user-supplied Settings + /// argument. Primarily used for testing. + /// + public static SettingsData Create(object settingsObj) + { + return Create(settingsObj, null, null); + } + + /// + /// Creates a from a user-supplied -Settings argument. + /// Accepted inputs: + /// null -> attempt auto discovery in + /// string preset name -> resolve shipped preset + /// string file path -> load that file (psd1/json) + /// hashtable -> inline settings + /// Uses the PowerShell provider resolver if supplied to expand relative/wildcard paths. + /// Returns null when no settings can be found (mode None). + /// + /// Hashtable, preset name, file path, or null. + /// Working directory for auto discovery (script or folder path). + /// An output writer. + /// Delegate from PSCmdlet to resolve provider paths (optional). + /// Populated or null if none. + internal static SettingsData Create( + object settingsObj, + string cwd, + IOutputWriter outputWriter, + PathResolver.GetResolvedProviderPathFromPSPath> getResolvedProviderPathFromPSPathDelegate = null + ) + { + // Determine how we're being passed settings + var result = ResolveSettingsSource(settingsObj, cwd, getResolvedProviderPathFromPSPathDelegate); + + switch (result.Kind) + { + case SettingsSourceKind.InlineHashtable: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsUsingHashtable + ) + ); + return HashtableSettingsConverter.Convert(result.InlineHashtable); + case SettingsSourceKind.AutoFile: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsNotProvided, + cwd ?? "" + ) + ); + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsAutoDiscovered, + result.FilePath + ) + ); + return Deserialize(result.FilePath); + case SettingsSourceKind.ExplicitFile: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsUsingFile, + result.FilePath + ) + ); + return Deserialize(result.FilePath); + case SettingsSourceKind.PresetFile: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsUsingPresetFile, + settingsObj, + result.FilePath + ) + ); + return Deserialize(result.FilePath); + case SettingsSourceKind.None: + default: + outputWriter?.WriteVerbose( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsObjectCouldNotBResolved + ) + ); + return null; + } + } + + /// + /// Intermediate resolution data describing where settings came from. + /// For Kind InlineHashtable only InlineHashtable is populated; for file-based kinds + /// FilePath is set. + /// + private struct ResolutionResult + { + public SettingsSourceKind Kind; + public string FilePath; + public Hashtable InlineHashtable; + } + + /// + /// Enumerates the distinct ways settings can be supplied or discovered. + /// + private enum SettingsSourceKind + { + /// No settings were provided and auto-discovery found nothing. + None, + /// Settings were discovered automatically from a file. + AutoFile, + /// Settings were explicitly provided via a file path. + ExplicitFile, + /// Settings were loaded from a preset file. + PresetFile, + /// Settings were provided inline as a hashtable. + InlineHashtable + } + + /// + /// Resolves the source kind for the supplied settings object. + /// Unwraps PSObject values to inspect the underlying CLR type. + /// + /// User -Settings argument value. + /// Working directory for auto discovery. + /// ResolutionResult indicating mode and relevant data. + /// Thrown when a string path does not exist. + /// Thrown for unsupported input types. + private static ResolutionResult ResolveSettingsSource( + object settingsObj, + string cwd, + PathResolver.GetResolvedProviderPathFromPSPath> getResolvedProviderPathFromPSPathDelegate = null + ) + { + // If we have no settings object, attempt auto-discovery of settings + // file in the current working directory + // If auto-discovery finds nothing, we return 'None' + if (settingsObj == null) + { + var auto = TryAutoDiscover(cwd); + return new ResolutionResult + { + Kind = auto != null ? SettingsSourceKind.AutoFile : SettingsSourceKind.None, + FilePath = auto + }; + } + + // Unwrap PSObject if necessary. Ensures we see the real underlying + // type (string, Hashtable). Without this, everything passed as an + // expression would remain a PSObject and fail the subsequent type + // checks. + if (settingsObj is PSObject pso) + { + settingsObj = pso.BaseObject; + } + + if (settingsObj is Hashtable ht) + { + return new ResolutionResult + { + Kind = SettingsSourceKind.InlineHashtable, + InlineHashtable = ht + }; + } + + if (settingsObj is string s) + { + // Does the string correspond to a preset name? + var presetPath = TryResolvePreset(s); + if (presetPath != null) + { + return new ResolutionResult + { + Kind = SettingsSourceKind.PresetFile, + FilePath = presetPath + }; + } + + // If it doesn't match a preset, is it a valid file path? + // Attempt provider path resolution if possible + s = ResolveProviderPathIfPossible(s, getResolvedProviderPathFromPSPathDelegate); + if (File.Exists(s)) + { + return new ResolutionResult + { + Kind = SettingsSourceKind.ExplicitFile, + FilePath = s + }; + } + + throw new FileNotFoundException(string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsCannotFindFile, + s + ) + ); + } + + throw new ArgumentException(string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsInvalidType + ) + ); + } + + /// + /// Attempts to locate a settings file in the supplied path's directory + /// using the supported formats in precedence order. + /// + /// File or directory path. + /// Full path to discovered settings file or null. + public static string TryAutoDiscover(string path) + { + // If no path provided, cannot auto-discover + if (string.IsNullOrWhiteSpace(path)) return null; + + // If path is a file, get its directory + string dir = path; + if (File.Exists(dir)) + { + dir = Path.GetDirectoryName(dir); + } + + // If directory doesn't exist, cannot auto-discover + if (!Directory.Exists(dir)) return null; + + // Test for the presence of a settings file for each of the formats + // supported. The format list order determines precedence. + foreach (var format in s_formats) + { + var filePath = Path.Combine(dir, $"{DefaultSettingsFileName}.{format.FormatName}"); + if (File.Exists(filePath)) return filePath; + } + + return null; + } + + /// + /// Resolves a shipped preset name to its settings file path. + /// Searches supported formats in precedence order, returning the first match. + /// + /// Preset base name without extension. + /// Full file path or null if not found. + public static string TryResolvePreset(string name) + { + // Get the path to the folder of preset settings files shipped with the module + var settingsDir = GetShippedModuleSubDirectory("Settings"); + + // If we can't locate it, return null + if (settingsDir == null) return null; + + // Loop through supported formats and check for existence + // return the first match + foreach (var format in s_formats) + { + var filePath = Path.Combine(settingsDir, name + "." + format.FormatName); + if (File.Exists(filePath)) return filePath; + } + + return null; + } + + /// + /// Attempts provider path resolution (wildcards, PSDrive) using PSCmdlet delegate. + /// Falls back to original path if resolution fails. + /// + /// Original path. + /// PSCmdlet resolution delegate. + /// Resolved single provider path or original path. + private static string ResolveProviderPathIfPossible( + string path, + PathResolver.GetResolvedProviderPathFromPSPath> getResolvedProviderPathFromPSPathDelegate) + { + if (getResolvedProviderPathFromPSPathDelegate == null) return path; + try + { + var resolved = getResolvedProviderPathFromPSPathDelegate(path, out ProviderInfo _); + if (resolved != null && resolved.Count == 1 && !string.IsNullOrEmpty(resolved[0])) + { + return resolved[0]; + } + } + catch + { + // Ignore resolution errors; use original path. + } + return path; + } + + /// + /// Opens and parses the specified settings file using an appropriate supported format. + /// + /// Existing settings file path. + /// Parsed . + /// If no format can handle the file. + public static SettingsData Deserialize(string path) + { + var format = Array.Find(s_formats, f => f.Supports(path)) ?? + throw new NotSupportedException($"No format registered for settings file '{path}'."); + using var fs = File.OpenRead(path); + var content = new StreamReader(fs).ReadToEnd(); + var data = format.Deserialize(content, path); + return data; + } + + /// + /// Serializes the supplied into the specified format. + /// + /// Settings data to serialize. + /// Format identifier (e.g. 'psd1', 'json'). + /// Serialized settings content. + /// If no format is registered for the specified name. + public static string Serialize(SettingsData data, string format) + { + // Find the appropriate format handler and use it to serialize the data + foreach (var f in s_formats) + { + if (string.Equals(f.FormatName, format, StringComparison.OrdinalIgnoreCase)) + { + return f.Serialize(data); + } + } + throw new NotSupportedException($"No supported format for '{format}'."); + } + + /// + /// Retrieves a subdirectory from the Module directory structure + /// + /// The subdirectory path + public static string GetShippedModuleSubDirectory(string subDirectoryName) + { + // Find the compatibility files in Settings folder + var path = typeof(Helper).GetTypeInfo().Assembly.Location; + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + // Find the compatibility files in Settings folder adjacent to the assembly. + // Some builds place binaries in subfolders (coreclr/, PSv3/); in those cases, + // the Settings folder lives in the parent (module root), so we also probe one level up. + var subDirectoryPath = Path.Combine(Path.GetDirectoryName(path), subDirectoryName); + if (!Directory.Exists(subDirectoryPath)) + { + // Probe parent directory (module root) for Settings folder. + var parentDir = Path.GetDirectoryName(Path.GetDirectoryName(path)); + subDirectoryPath = Path.Combine(parentDir ?? string.Empty, subDirectoryName); + if (!Directory.Exists(subDirectoryPath)) + { + return null; + } + } + + return subDirectoryPath; + } + + /// + /// Returns the builtin setting presets + /// + /// Looks for supported formats in the PSScriptAnalyzer module settings directory + /// and returns the names of the files without extension + /// + public static IEnumerable GetSettingPresets() + { + var settingsPath = GetShippedModuleSubDirectory("Settings"); + + if (settingsPath == null) + { + yield break; + } + + // Collect unique preset base names across all supported formats. + // e.g. if both Psd1 and Json versions of the same preset exist, + // only yield the name once. + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var format in s_formats) + { + var pattern = "*." + format.FormatName; + foreach (var filepath in Directory.EnumerateFiles(settingsPath, pattern)) + { + var name = Path.GetFileNameWithoutExtension(filepath); + if (yielded.Add(name)) + { + yield return name; + } + } + } + } + + /// + /// Returns supported settings file format identifiers + /// + public static IEnumerable GetSettingsFormats() + { + foreach (var f in s_formats) + { + yield return f.FormatName; + } + } + } + +} \ No newline at end of file diff --git a/Engine/Settings/SettingsData.cs b/Engine/Settings/SettingsData.cs new file mode 100644 index 000000000..902b99dc0 --- /dev/null +++ b/Engine/Settings/SettingsData.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Data container representing fully parsed and normalized PSScriptAnalyzer settings. + /// + public sealed class SettingsData + { + /// + /// Explicit rule names to include. + /// + public List IncludeRules { get; set; } = new List(); + + /// + /// Rule names to exclude from analysis even if they are part of defaults or includes. + /// + public List ExcludeRules { get; set; } = new List(); + + /// + /// Ordered severity list used for filtering or overriding rule output (e.g. Error, Warning, + /// Information). + /// + public List Severities { get; set; } = new List(); + + /// + /// Paths (files or directories) where custom rule assemblies/modules are located. + /// + public List CustomRulePath { get; set; } = new List(); + + /// + /// Indicates whether built-in default rules should be included when resolving effective + /// rule set. + /// + public bool IncludeDefaultRules { get; set; } + + /// + /// If true, recursively searches each CustomRulePath directory for rules. + /// + public bool RecurseCustomRulePath { get; set; } + + /// + /// Per-rule argument maps: rule name -> (argument name -> value). Case-insensitive outer + /// and inner dictionaries. + /// + public Dictionary> RuleArguments { get; set; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/Engine/Settings/CmdletDesign.psd1 b/Engine/SettingsPresets/CmdletDesign.psd1 similarity index 100% rename from Engine/Settings/CmdletDesign.psd1 rename to Engine/SettingsPresets/CmdletDesign.psd1 diff --git a/Engine/Settings/CodeFormatting.psd1 b/Engine/SettingsPresets/CodeFormatting.psd1 similarity index 100% rename from Engine/Settings/CodeFormatting.psd1 rename to Engine/SettingsPresets/CodeFormatting.psd1 diff --git a/Engine/Settings/CodeFormattingAllman.psd1 b/Engine/SettingsPresets/CodeFormattingAllman.psd1 similarity index 100% rename from Engine/Settings/CodeFormattingAllman.psd1 rename to Engine/SettingsPresets/CodeFormattingAllman.psd1 diff --git a/Engine/Settings/CodeFormattingOTBS.psd1 b/Engine/SettingsPresets/CodeFormattingOTBS.psd1 similarity index 100% rename from Engine/Settings/CodeFormattingOTBS.psd1 rename to Engine/SettingsPresets/CodeFormattingOTBS.psd1 diff --git a/Engine/Settings/CodeFormattingStroustrup.psd1 b/Engine/SettingsPresets/CodeFormattingStroustrup.psd1 similarity index 100% rename from Engine/Settings/CodeFormattingStroustrup.psd1 rename to Engine/SettingsPresets/CodeFormattingStroustrup.psd1 diff --git a/Engine/Settings/DSC.psd1 b/Engine/SettingsPresets/DSC.psd1 similarity index 100% rename from Engine/Settings/DSC.psd1 rename to Engine/SettingsPresets/DSC.psd1 diff --git a/Engine/Settings/PSGallery.psd1 b/Engine/SettingsPresets/PSGallery.psd1 similarity index 100% rename from Engine/Settings/PSGallery.psd1 rename to Engine/SettingsPresets/PSGallery.psd1 diff --git a/Engine/Settings/ScriptFunctions.psd1 b/Engine/SettingsPresets/ScriptFunctions.psd1 similarity index 100% rename from Engine/Settings/ScriptFunctions.psd1 rename to Engine/SettingsPresets/ScriptFunctions.psd1 diff --git a/Engine/Settings/ScriptSecurity.psd1 b/Engine/SettingsPresets/ScriptSecurity.psd1 similarity index 100% rename from Engine/Settings/ScriptSecurity.psd1 rename to Engine/SettingsPresets/ScriptSecurity.psd1 diff --git a/Engine/Settings/ScriptingStyle.psd1 b/Engine/SettingsPresets/ScriptingStyle.psd1 similarity index 100% rename from Engine/Settings/ScriptingStyle.psd1 rename to Engine/SettingsPresets/ScriptingStyle.psd1 diff --git a/Engine/Strings.resx b/Engine/Strings.resx index 346a25aa6..258ece76c 100644 --- a/Engine/Strings.resx +++ b/Engine/Strings.resx @@ -261,6 +261,9 @@ Using settings file at {0}. + + Using settings preset {0}. File found at {1}. + Using settings hashtable. diff --git a/Rules/AvoidOverwritingBuiltInCmdlets.cs b/Rules/AvoidOverwritingBuiltInCmdlets.cs index a390178d5..814f96c0b 100644 --- a/Rules/AvoidOverwritingBuiltInCmdlets.cs +++ b/Rules/AvoidOverwritingBuiltInCmdlets.cs @@ -90,17 +90,17 @@ public override IEnumerable AnalyzeScript(Ast ast, string file } var psVerList = PowerShellVersion; - string settingsPath = Settings.GetShippedSettingsDirectory(); + string commandDataFilesPath = Settings.GetShippedModuleSubDirectory("CommandDataFiles"); foreach (string reference in psVerList) { - if (settingsPath == null || !ContainsReferenceFile(settingsPath, reference)) + if (commandDataFilesPath == null || !ContainsReferenceFile(commandDataFilesPath, reference)) { throw new ArgumentException(nameof(PowerShellVersion)); } } - ProcessDirectory(settingsPath, psVerList); + ProcessDirectory(commandDataFilesPath, psVerList); if (_cmdletMap.Keys.Count != psVerList.Count()) { diff --git a/Rules/UseCompatibleCmdlets.cs b/Rules/UseCompatibleCmdlets.cs index c74665e95..b63e3cd91 100644 --- a/Rules/UseCompatibleCmdlets.cs +++ b/Rules/UseCompatibleCmdlets.cs @@ -306,7 +306,7 @@ private void SetupCmdletsDictionary() return; } - string settingsPath = Settings.GetShippedSettingsDirectory(); + string commandDataFilesPath = Settings.GetShippedModuleSubDirectory("CommandDataFiles"); #if DEBUG object modeObject; if (ruleArgs.TryGetValue("mode", out modeObject)) @@ -317,7 +317,7 @@ private void SetupCmdletsDictionary() switch (mode) { case "offline": - settingsPath = GetStringArgFromListStringArg(ruleArgs["uri"]); + commandDataFilesPath = GetStringArgFromListStringArg(ruleArgs["uri"]); break; case "online": // not implemented yet. @@ -328,8 +328,8 @@ private void SetupCmdletsDictionary() } #endif - if (settingsPath == null - || !ContainsReferenceFile(settingsPath)) + if (commandDataFilesPath == null + || !ContainsReferenceFile(commandDataFilesPath)) { return; } @@ -348,7 +348,7 @@ private void SetupCmdletsDictionary() } ProcessDirectory( - settingsPath, + commandDataFilesPath, extentedCompatibilityList); if (psCmdletMap.Keys.Count != extentedCompatibilityList.Count()) { diff --git a/Tests/Engine/Settings.tests.ps1 b/Tests/Engine/Settings.tests.ps1 index 917b4ed8e..89a69c7cb 100644 --- a/Tests/Engine/Settings.tests.ps1 +++ b/Tests/Engine/Settings.tests.ps1 @@ -5,7 +5,6 @@ BeforeAll { $settingsTestDirectory = [System.IO.Path]::Combine($PSScriptRoot, "SettingsTest") $project1Root = [System.IO.Path]::Combine($settingsTestDirectory, "Project1") $project2Root = [System.IO.Path]::Combine($settingsTestDirectory, "Project2") - $settingsTypeName = 'Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings' } Describe "Settings Precedence" { @@ -53,7 +52,7 @@ Describe "Settings Class" { ) { Param($Name) - $settings = New-Object -TypeName $settingsTypeName -ArgumentList @{} + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create(@{}) ${settings}.${Name}.Count | Should -Be 0 } @@ -67,7 +66,7 @@ Describe "Settings Class" { Context "When a string is provided for IncludeRules in a hashtable" { BeforeAll { $ruleName = "PSAvoidCmdletAliases" - $settings = New-Object -TypeName $settingsTypeName -ArgumentList @{ IncludeRules = $ruleName } + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create(@{ IncludeRules = $ruleName }) } It "Should return an IncludeRules array with 1 element" { @@ -88,7 +87,7 @@ Describe "Settings Class" { } } } - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) } It 'Should return the rule arguments' { @@ -113,7 +112,7 @@ Describe "Settings Class" { } } } - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) } It "Should return the rule arguments" { @@ -131,8 +130,9 @@ Describe "Settings Class" { Context "When a settings file path is provided" { BeforeAll { - $settings = New-Object -TypeName $settingsTypeName ` - -ArgumentList ([System.IO.Path]::Combine($project1Root, "ExplicitSettings.psd1")) + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create( + ([System.IO.Path]::Combine($project1Root, "ExplicitSettings.psd1")) + ) $expectedNumberOfIncludeRules = 3 } @@ -168,7 +168,7 @@ Describe "Settings Class" { CustomRulePath = $rulePath } - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) $settings.CustomRulePath.Count | Should -Be 1 $settings.CustomRulePath[0] | Should -Be $rulePath } @@ -179,15 +179,16 @@ Describe "Settings Class" { CustomRulePath = $rulePaths } - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) $settings.CustomRulePath.Count | Should -Be $rulePaths.Count 0..($rulePaths.Count - 1) | ForEach-Object { $settings.CustomRulePath[$_] | Should -Be $rulePaths[$_] } } It "Should detect the parameter in a settings file" { - $settings = New-Object -TypeName $settingsTypeName ` - -ArgumentList ([System.IO.Path]::Combine($project1Root, "CustomRulePathSettings.psd1")) + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create( + ([System.IO.Path]::Combine($project1Root, "CustomRulePathSettings.psd1")) + ) $settings.CustomRulePath.Count | Should -Be 2 } } @@ -197,7 +198,7 @@ Describe "Settings Class" { $settingsHashtable = @{} $settingsHashtable.Add($ParamName, $true) - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) $settings."$ParamName" | Should -BeTrue } @@ -205,7 +206,7 @@ Describe "Settings Class" { $settingsHashtable = @{} $settingsHashtable.Add($ParamName, $false) - $settings = New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) $settings."$ParamName" | Should -BeFalse } @@ -213,12 +214,13 @@ Describe "Settings Class" { $settingsHashtable = @{} $settingsHashtable.Add($ParamName, "some random string") - { New-Object -TypeName $settingsTypeName -ArgumentList $settingsHashtable } | Should -Throw + { [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create($settingsHashtable) } | Should -Throw } It ": Should detect the parameter in a settings file" -TestCases $customRuleParameterTestCases { - $settings = New-Object -TypeName $settingsTypeName ` - -ArgumentList ([System.IO.Path]::Combine($project1Root, "CustomRulePathSettings.psd1")) + $settings = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::Create( + ([System.IO.Path]::Combine($project1Root, "CustomRulePathSettings.psd1")) + ) $settings."$ParamName" | Should -BeTrue } } @@ -378,33 +380,33 @@ Describe "Settings Class" { ) } - Context "FindSettingsMode" { - BeforeAll { - $findSettingsMode = ($settingsTypeName -as [type]).GetMethod( - 'FindSettingsMode', - [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Static) - - $outputObject = [System.Object]::new() - } - - It "Should detect hashtable" { - $settings = @{} - $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable" - } - - It "Should detect hashtable wrapped by a PSObject" { - $settings = [PSObject]@{} # Force the settings hashtable to be wrapped - $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable" - } - - It "Should detect string" { - $settings = "" - $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File" - } - - It "Should detect string wrapped by a PSObject" { - $settings = [PSObject]"" # Force the settings string to be wrapped - $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File" - } - } + # Context "FindSettingsMode" { + # BeforeAll { + # $findSettingsMode = ('Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings' -as [type]).GetMethod( + # 'FindSettingsMode', + # [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Static) + + # $outputObject = [System.Object]::new() + # } + + # It "Should detect hashtable" { + # $settings = @{} + # $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable" + # } + + # It "Should detect hashtable wrapped by a PSObject" { + # $settings = [PSObject]@{} # Force the settings hashtable to be wrapped + # $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable" + # } + + # It "Should detect string" { + # $settings = "" + # $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File" + # } + + # It "Should detect string wrapped by a PSObject" { + # $settings = [PSObject]"" # Force the settings string to be wrapped + # $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File" + # } + # } } diff --git a/build.psm1 b/build.psm1 index 5daba36ba..e9abea6df 100644 --- a/build.psm1 +++ b/build.psm1 @@ -251,9 +251,12 @@ function Start-ScriptAnalyzerBuild } Publish-File $itemsToCopyBinaries $destinationDirBinaries - $settingsFiles = Get-Childitem "$projectRoot\Engine\Settings" | ForEach-Object -MemberName FullName + $settingsFiles = Get-Childitem "$projectRoot\Engine\SettingsPresets" | ForEach-Object -MemberName FullName Publish-File $settingsFiles (Join-Path -Path $script:destinationDir -ChildPath Settings) + $settingsFiles = Get-Childitem "$projectRoot\Engine\CommandDataFiles" | ForEach-Object -MemberName FullName + Publish-File $settingsFiles (Join-Path -Path $script:destinationDir -ChildPath CommandDataFiles) + $rulesProjectOutputDir = if ($env:TF_BUILD) { "$projectRoot\bin\${buildConfiguration}\${framework}" } else {