21

So what I really want is somewhat usable tab completion in a PS module. ValidateSet seems to be the way to go here.

Unfortunately my data is dynamic, so I cannot annotate the parameter with all valid values upfront. DynamicParameters/IDynamicParameters seems to be the solution for that problem.

Putting these things together (and reducing my failure to a simple test case) we end up with:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Management.Automation;
using System.Text;
using System.Threading.Tasks;

namespace PSDummy
{
    [Cmdlet(VerbsCommon.Get, "BookDetails")]
    public class GetBookDetails : Cmdlet, IDynamicParameters
    {
        IDictionary<string, string[]> m_dummyData = new Dictionary<string, string[]> {
            {"Terry Pratchett", new [] {"Small Gods", "Mort", "Eric"}},
            {"Douglas Adams", new [] {"Hitchhiker's Guide", "The Meaning of Liff"}}
        };

        private RuntimeDefinedParameter m_authorParameter;
        private RuntimeDefinedParameter m_bookParameter;

        protected override void ProcessRecord()
        {
              // Do stuff here..
        }

        public object GetDynamicParameters()
        {
            var parameters = new RuntimeDefinedParameterDictionary();

            m_authorParameter = CreateAuthorParameter();
            m_bookParameter = CreateBookParameter();

            parameters.Add(m_authorParameter.Name, m_authorParameter);
            parameters.Add(m_bookParameter.Name, m_bookParameter);
            return parameters;
        }

        private RuntimeDefinedParameter CreateAuthorParameter()
        {
            var p = new RuntimeDefinedParameter(
                "Author",
                typeof(string),
                new Collection<Attribute>
                {
                    new ParameterAttribute {
                        ParameterSetName = "BookStuff",
                        Position = 0,
                        Mandatory = true
                    },
                    new ValidateSetAttribute(m_dummyData.Keys.ToArray()),
                    new ValidateNotNullOrEmptyAttribute()
                });

            // Actually this is always mandatory, but sometimes I can fall back to a default
            // value. How? p.Value = mydefault?

            return p;
        }

        private RuntimeDefinedParameter CreateBookParameter()
        {
            // How to define a ValidateSet based on the parameter value for
            // author?
            var p = new RuntimeDefinedParameter(
                "Book",
                typeof(string),
                new Collection<Attribute>
                {
                    new ParameterAttribute {
                        ParameterSetName = "BookStuff",
                        Position = 1,
                        Mandatory = true
                    },
                    new ValidateSetAttribute(new string[1] { string.Empty }/* cannot fill this, because I cannot access the author */),
                    new ValidateNotNullOrEmptyAttribute()
                });

            return p;
        }
    }
}

Unfortunately this tiny snippet causes a lot of issues already. Ordered descending:

  • I fail to see how I can create a connection between the parameters. If you pick an author, you should only be able to pick a book that matches the author. So far GetDynamicParameters() always seems stateless though: I see no way to access the value of a different/earlier dynamic parameter. Tried keeping it in a field, tried searching MyInvocation - no luck. Is that even possible?

  • How do you define a default value for mandatory parameter? Doesn't fit the silly example, but let's say you can store your favorite author. From now on I want to default to that author, but having a pointer to an author is still mandatory. Either you gave me a default (and can still specify something else) or you need to be explicit.

  • Tab completion for strings with spaces seems weird/broken/limited - because it doesn't enclose the value with quotes (like cmd.exe would do, for example, if you type dir C:\Program <tab>). So tab completion actually breaks the invocation (if the issues above would be resolved, Get-BookDetails Ter<tab> would/will expand to Get-BookDetails Terry Pratchett which puts the last name in parameter position 1 aka 'book'.

Shouldn't be so hard, surely someone did something similar already?

Update: After another good day of tinkering and fooling around I don't see a way to make this work. The commandlet is stateless and will be instantiated over and over again. At the point in time when I can define dynamic parameters (GetDynamicParameters) I cannot access their (current) values/see what they'd be bound to - e.g. MyInvocation.BoundParameters is zero. I'll leave the question open, but it seems as if this just isn't supported. All the examples I see add a dynamic parameter based on the value of a static one - and that's not relevant here. Bugger.

Benjamin Podszun
  • 9,679
  • 3
  • 34
  • 45

1 Answers1

10

I think this works. Unfortunately, it uses reflection to get at some of the cmdlet's private members for your first bullet. I got the idea from Garrett Serack. I'm not sure if I completely understood how to do the default author, so I made it so that the last valid author is stored in a static field so you don't need -Author the next time.

Here's the code:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Management.Automation;
using System.Text;
using System.Threading.Tasks;

namespace PSDummy
{
    internal class DynParamQuotedString {
        /*
            This works around the PowerShell bug where ValidateSet values aren't quoted when necessary, and
            adding the quotes breaks it. Example:

            ValidateSet valid values = 'Test string'  (The quotes are part of the string)

            PowerShell parameter binding would interperet that as [Test string] (no single quotes), which wouldn't match
            the valid value (which has the quotes). If you make the parameter a DynParamQuotedString, though,
            the parameter binder will coerce [Test string] into an instance of DynParamQuotedString, and the binder will
            call ToString() on the object, which will add the quotes back in.
        */

        internal static string DefaultQuoteCharacter = "'";

        public DynParamQuotedString(string quotedString) : this(quotedString, DefaultQuoteCharacter) {}
        public DynParamQuotedString(string quotedString, string quoteCharacter) {
            OriginalString = quotedString;
            _quoteCharacter = quoteCharacter;
        }

        public string OriginalString { get; set; }
        string _quoteCharacter;

        public override string ToString() {
            // I'm sure this is missing some other characters that need to be escaped. Feel free to add more:
            if (System.Text.RegularExpressions.Regex.IsMatch(OriginalString, @"\s|\(|\)|""|'")) {
                return string.Format("{1}{0}{1}", OriginalString.Replace(_quoteCharacter, string.Format("{0}{0}", _quoteCharacter)), _quoteCharacter);
            }
            else {
                return OriginalString;
            }
        }

        public static string[] GetQuotedStrings(IEnumerable<string> values) {
            var returnList = new List<string>();
            foreach (string currentValue in values) {
                returnList.Add((new DynParamQuotedString(currentValue)).ToString());
            }
            return returnList.ToArray();
        }
    }


    [Cmdlet(VerbsCommon.Get, "BookDetails")]
    public class GetBookDetails : PSCmdlet, IDynamicParameters
    {
        IDictionary<string, string[]> m_dummyData = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase) {
            {"Terry Pratchett", new [] {"Small Gods", "Mort", "Eric"}},
            {"Douglas Adams", new [] {"Hitchhiker's Guide", "The Meaning of Liff"}},
            {"An 'Author' (notice the ')", new [] {"A \"book\"", "Another 'book'","NoSpace(ButCharacterThatShouldBeEscaped)", "NoSpace'Quoted'", "NoSpace\"Quoted\""}}   // Test value I added
        };

        protected override void ProcessRecord()
        {
            WriteObject(string.Format("Author = {0}", _author));
            WriteObject(string.Format("Book = {0}", ((DynParamQuotedString) MyInvocation.BoundParameters["Book"]).OriginalString));
        }

        // Making this static means it should keep track of the last author used
        static string _author;
        public object GetDynamicParameters()
        {
            // Get 'Author' if found, otherwise get first unnamed value
            string author = GetUnboundValue("Author", 0) as string;
            if (!string.IsNullOrEmpty(author)) { 
                _author = author.Trim('\'').Replace(
                    string.Format("{0}{0}", DynParamQuotedString.DefaultQuoteCharacter), 
                    DynParamQuotedString.DefaultQuoteCharacter
                ); 
            }

            var parameters = new RuntimeDefinedParameterDictionary();

            bool isAuthorParamMandatory = true;
            if (!string.IsNullOrEmpty(_author) && m_dummyData.ContainsKey(_author)) {
                isAuthorParamMandatory = false;
                var m_bookParameter = new RuntimeDefinedParameter(
                    "Book",
                    typeof(DynParamQuotedString),
                    new Collection<Attribute>
                    {
                        new ParameterAttribute {
                            ParameterSetName = "BookStuff",
                            Position = 1,
                            Mandatory = true
                        },
                        new ValidateSetAttribute(DynParamQuotedString.GetQuotedStrings(m_dummyData[_author])),
                        new ValidateNotNullOrEmptyAttribute()
                    }
                );

                parameters.Add(m_bookParameter.Name, m_bookParameter);
            }

            // Create author parameter. Parameter isn't mandatory if _author
            // has a valid author in it
            var m_authorParameter = new RuntimeDefinedParameter(
                "Author",
                typeof(DynParamQuotedString),
                new Collection<Attribute>
                {
                    new ParameterAttribute {
                        ParameterSetName = "BookStuff",
                        Position = 0,
                        Mandatory = isAuthorParamMandatory
                    },
                    new ValidateSetAttribute(DynParamQuotedString.GetQuotedStrings(m_dummyData.Keys.ToArray())),
                    new ValidateNotNullOrEmptyAttribute()
                }
            );
            parameters.Add(m_authorParameter.Name, m_authorParameter);

            return parameters;
        }

        /*
            TryGetProperty() and GetUnboundValue() are from here: https://gist.github.com/fearthecowboy/1936f841d3a81710ae87
            Source created a dictionary for all unbound values; I had issues getting ValidateSet on Author parameter to work
            if I used that directly for some reason, but changing it into a function to get a specific parameter seems to work
        */

        object TryGetProperty(object instance, string fieldName) {
            var bindingFlags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public;

            // any access of a null object returns null. 
            if (instance == null || string.IsNullOrEmpty(fieldName)) {
                return null;
            }

            var propertyInfo = instance.GetType().GetProperty(fieldName, bindingFlags);

            if (propertyInfo != null) {
                try {
                    return propertyInfo.GetValue(instance, null);
                } 
                catch {
                }
            }

            // maybe it's a field
            var fieldInfo = instance.GetType().GetField(fieldName, bindingFlags);

            if (fieldInfo!= null) {
                try {
                    return fieldInfo.GetValue(instance);
                } 
                catch {
                }
            }

            // no match, return null.
            return null;
        }

        object GetUnboundValue(string paramName) {
            return GetUnboundValue(paramName, -1);
        }

        object GetUnboundValue(string paramName, int unnamedPosition) {

            // If paramName isn't found, value at unnamedPosition will be returned instead
            var context = TryGetProperty(this, "Context");
            var processor = TryGetProperty(context, "CurrentCommandProcessor");
            var parameterBinder = TryGetProperty(processor, "CmdletParameterBinderController");
            var args = TryGetProperty(parameterBinder, "UnboundArguments") as System.Collections.IEnumerable;

            if (args != null) {
                var currentParameterName = string.Empty;
                object unnamedValue = null;
                int i = 0;
                foreach (var arg in args) {
                    var isParameterName = TryGetProperty(arg, "ParameterNameSpecified");
                    if (isParameterName != null && true.Equals(isParameterName)) {
                        string parameterName = TryGetProperty(arg, "ParameterName") as string;
                        currentParameterName = parameterName;

                        continue;
                    }

                    // Treat as a value:
                    var parameterValue = TryGetProperty(arg, "ArgumentValue");

                    if (currentParameterName != string.Empty) {
                        // Found currentParameterName's value. If it matches paramName, return
                        // it
                        if (currentParameterName.Equals(paramName, StringComparison.OrdinalIgnoreCase)) {
                            return parameterValue;
                        }
                    }
                    else if (i++ == unnamedPosition) {
                        unnamedValue = parameterValue;  // Save this for later in case paramName isn't found
                    }

                    // Found a value, so currentParameterName needs to be cleared
                    currentParameterName = string.Empty;
                }

                if (unnamedValue != null) {
                    return unnamedValue;
                }
            }

            return null;
        }
    }
}
Rohn Edwards
  • 2,499
  • 1
  • 14
  • 19
  • Wow, that's .. quite a hack. But quite clever :) Seems to be the answer/workaround. I'll wait a day or two and accept this and award the bounty. There are a couple glitches: The GetUnboundValue unfortunately relies on a parameter name, so positional parameters are probably out. Basically you can't Get-BookDetails - but I guess I should stop being greedy :) – Benjamin Podszun Apr 17 '15 at 08:30
  • Try it now. I updated it so it should work with positional parameters, too. You might also be able to play with dynamically changing the Position parameter attributes so that you can do Get-BookDetails after the author has already been specified, but it doesn't currently do that. – Rohn Edwards Apr 17 '15 at 16:33
  • Amazing. Will give it a try tomorrow (late here). That definitely looks great. Thanks already. – Benjamin Podszun Apr 17 '15 at 19:49
  • You're welcome. Hopefully it works for you...I had fun trying to figure it out :) – Rohn Edwards Apr 18 '15 at 03:01