Reputation: 97
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.
Upvotes: 4
Views: 3525
Reputation: 1
Here's a potential hazard of the Invoke-Expression
approach. Was attempting to use to parse a Windows Command Line string. It was looking good, until I encountered strange behavior in one scenario.
This $line
value works fine [note path is QUOTED]:
PS C:\> $line = 'Some-Command "C:\some\(powershell)\path\file.txt"'
PS C:\> $command, $arguments = Invoke-Expression ('Write-Output -- ' + $line)
PS C:\> $command
Some-Command
PS C:\> $arguments
C:\some\(powershell)\path\file.txt
But with this $line
[note path NOT quoted], PS hangs:
PS C:\> $line = 'Some-Command C:\some\(powershell)\path\file.txt'
PS C:\> $command, $arguments = Invoke-Expression ('Write-Output -- ' + $line)
WTH?
Seems the PS "grouping operator" causes Invoke-Expression
to invoke expressions inside grouping operators in unquoted strings BEFORE it invokes Write-Output
! So in this case, PS was entering a second powershell.exe
"session" which was waiting for input. Entering the exit
command terminated the second powershell.exe
session, and back in the first PS session $arguments
contained the following:
PS C:\> $arguments
C:\some\
Windows PowerShell
Copyright (C) Microsoft Corporation. All rights reserved.
Try the new cross-platform PowerShell https://aka.ms/pscore6
PS C:\>
\path\file.txt
These $line
values of course cause similar behavior:
PS C:\> $line = 'Some-Command C:\some\(read-host)\path\file.txt'
PS C:\> $line = 'Some-Command C:\some\(cmd)\path\file.txt'
PS C:\> $line = 'Some-Command C:\some\(notepad)\path\file.txt'
[Oh, hello Notepad! ;-)]
And just imagine what something like this would do!
PS C:\> $line = 'Some-Command C:\some\(del foo)\path\file.txt'
Or:
PS C:\> $line = 'Some-Command C:\some\(shutdown)\path\file.txt'
Luckily, I was able to use [System.Environment]::GetCommandLineArgs()
as I wanted the parsed PS Host/Script invocation command line.
Well, @mklement0 did give us the obligatory security warning at the top of his answer!
Upvotes: 0
Reputation: 437813
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:
# 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.
-Foo Bar
rather than just Bar
); [switch]
parameters (flags) are supported too.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)
Upvotes: 5
Reputation: 23663
As you know what all of the parameter names are, why don't you just add (and waste) the "some-command" as the first parameter ($Command
) to your parameterMangle
function, and Invoke-Expression
that function together with the whole line:
Function Other-Command($parmA, $parmB) {
Write-Host $parma
Write-Host $parmB["ParamS"]
Write-Host $parmB["ParamT"]
}
function parameterMangle($Command, $parmA, $parmB) {
$parmA = $($parmA) + 'ExtraStuff'
$parmB["ParamT"] = 'Other Value'
Other-Command $parmA $parmB
}
$lines =
'Some-Command -parmA "Some Parameter" -parmB @{"ParamS"="value"; "ParamT"="value2"}',
'Some-Command -parmA "2nd Parameter" -parmB @{"ParamS"="SSS"; "ParamT"="value2"}'
ForEach ($Line in $Lines) {
Invoke-Expression ('parameterMangle ' + $Line)
}
Result:
Some ParameterExtraStuff
value
Other Value
2nd ParameterExtraStuff
SSS
Other Value
Upvotes: 2