Search code examples
powershell

How to manually parse a Powershell command line string


Short version: I need a way to emulate the Powershell command line parsing within my own function. Something like the .Net System.CommandLine method but with support for Splatting.

Details: I have a text file containing a set of Powershell commands. The file content might look like:

Some-Command -parA "Some Parameter" -parB @{"ParamS"="value"; "ParamT"="value2"}
Some-Command -parA "2nd Parameter"  -parB @{"ParamS"="SSS"; "ParamT"="value2"}

As I read each line of the file, I need to transform that line and call a different command with the parameters modified. Taking the first line from above I need to execute Other-Command as if it was called as

Other-Command -parA "Some Parameter" -parB @{"ParamS"="value"; "ParamT"="Other Value"}

(as an aside, these files are generated by me from a different program so I don't need to worry about sanitizing my inputs.)

If someone just entered the Some-Command line above into Powershell, then the parameters would be parsed out, I could access them by name and the splatted parameter would be converted into a hashtable of dictionaries. But, since this is coming from within a text file none of that happens automatically so I'm hoping that there is some commandlet that will do it so I don't have to roll my own.

In my current case I know what all of the parameter names are and what order they will be in the string, so I can just hard code up some string splits to get parameter,value pairs. That still leaves the issue of breaking up the splatted parameter though.

I've looked at ConvertFrom-StringData, it's similar, but doesn't quite do what I need:

    ConvertFrom-StringData -StringData '@{"ParamS"="value"; "ParamT"="value2"}'

    Name                           Value
    ----                           -----
    @{"ParamS"                     "value"; "ParamT"="value2"}

Again, all I'm after in this question is to break this string up as if it was parsed by the powershell command line parser.

Edit: Apparently I wasn't as clear as I could have been. Let's try this. If I call parameterMangle as

    parameterMangle -parmA "Some Parameter" -parmB @{"ParamS"="value"; "ParamT"="value2"}

Then there is a clear syntax to modify the parameters and pass them off to another function.

    function parameterMangle ($parmA, $parmB)
    {
       $parmA = $($parmA) + 'ExtraStuff'
       $parmB["ParamT"] = 'Other Value'
       Other-Command $parmA $parmB
    }

But if that same call was a line of text in a file, then modifying those parameters with string functions is very prone to error. To do it robustly you'd have to bring up a full lexical analyzer. I'd much rather find some builtin function that can break up the string in exactly the same way as the powershell command line processor does. That splatted parameter is particularly difficult with all of its double quotes and braces.


Solution

  • The obligatory security warning first:

    Invoke-Expression should generally be avoided - look for alternatives first (usually they exist and are preferable) and if there are non, use Invoke-Expression only with strings you fully control or implicitly trust.

    In your specific case, if you trust the input, Invoke-Expression offers a fairly straightforward solution, however:


    If the overall set of possible parameters isn't known in advance:

    # Example input line from your file.
    $line = 'Some-Command -parA "Some Parameter" -parB @{"ParamS"="value"; "ParamT"="value2"}'
    
    # Parse into command name and arguments array, via Invoke-Expression
    # and Write-Output.
    $command, $arguments = Invoke-Expression ('Write-Output -- ' + $line)
    
    # Convert the arguments *array* to a *hashtable* that can
    # later be used for splatting.
    # IMPORTANT: 
    #   This assumes that *all* arguments in the input command line are *named*,
    #   i.e. preceded by their target-parameter name.
    $htArguments = [ordered] @{}
    foreach ($a in $arguments) {
      if ($a -match '^-([^:]+):?(.+)?') {  # a parameter *name*, optionally with directly attached value
        $key = $Matches[1]
        # Create the entry with either the directly attached value, or
        # initialize to $true, which is correct if the parameter is a *switch*,
        # or will be replaced by the next argument, if it turns out to be a *value*.
        $htArguments[$key] = if ($Matches[2]) { $Matches[2] } else { $true }
      } else { # argument -> value; using the previous key.
        $htArguments[$key] = $a
      }
    }
    
    # Modify arguments as needed.
    $htArguments.parB.ParamT = 'newValue2'
    
    # Pass the hashtable with the modified arguments to the 
    # (different) target command via splatting.
    Other-Command @htArguments
    

    By effectively passing the argument list to Write-Output, via the string passed to Invoke-Expression, the arguments are evaluated as they normally would when invoking a command, and Write-Output outputs the evaluated arguments one by one, which allows capturing them in an array for later use.

    • Note that this relies on Write-Output's ability to pass arguments that look like parameter names through rather than interpreting them as its own parameter names; e.g., Write-Output -Foo bar outputs strings -Foo and -bar (instead of Write-Object complaining, because it itself implements no -Foo parameter).

    • To additionally avoid collisions with Write-Output's own parameters (-InputObject, -NoEnumerate, and the common parameters it supports), special token -- is used before the pass-through arguments to ensure that they're interpreted as such (as positional arguments, even if they look like parameter names).

    The resulting array is then converted into an (ordered) hash table that is later used for splatting.

    • As stated in the code comments, this only works correctly if all arguments are named in the input command line, i.e. if they're all preceded by their target-parameter name (e.g., -Foo Bar rather than just Bar); [switch] parameters (flags) are supported too.

    If the overall set of possible parameters is known in advance:

    Note: This is a generalized variation of iRon's helpful solution.

    Modify your parameterMangle function as follows:

    • Declare it with the set of all possible (named) parameters, across all input lines from the file, named the same as in the file (case doesn't matter); that is, with your sample lines this means naming your parameters $parA and $parB to match parameter names -parA and -parB.

    • Use the automatic $PSBoundParameters dictionary to pass all bound parameters through to the other command via splatting, and also non-declared parameters positionally, if present.

    # Example input line from your file.
    $line = 'Some-Command -parA "Some Parameter" -parB @{"ParamS"="value"; "ParamT"="value2"}'
    
    function parameterMangle {
    
      [CmdletBinding(PositionalBinding=$false)]
      param(
        # The first, positional argument specifying the original command ('Some-Command')
        [Parameter(Position=0)] $OriginalCommand,
        # Declare *all possible* parameters here.
        $parA,
        $parB,
        # Optional catch-all parameter for any extra arguments.
        # Note: If you don't declare this and extra arguments are passed,
        #       invocation of the function *fails*.
        [Parameter(ValueFromRemainingArguments=$true)] [object[]] $Rest
      )
    
      # Modify the values as needed.
      $parB.ParamT += '-NEW'
    
      # Remove the original command from the dictionary of bound parameters.
      $null = $PSBoundParameters.Remove('OriginalCommand')
      # Also remove the artifical -Rest parameter, as we'll pass its elements
      # separately, as positional arguments.
      $null = $PSBoundParameters.Remove('Rest')
    
      # Use splatting to pass all bound (known) parameters, as well as the
      # remaining arguments, if any, positionally (array splatting)
      Other-Command @PSBoundParameters @Rest
    
    }
    
    # Use Invoke-Expression to call parameterMangle with the command-line
    # string appended, which ensures that the arguments are parsed and bound as
    # they normally would be.
    Invoke-Expression ('parameterMangle ' + $line)