2

I have a Powershell script that (as one of its options) reads a user-defined pre-execution command from a file and needs to execute it. The user-defined pre-execution command is expected to be an ordinary DOS-style command. I can split the command by spaces and then feed it to the PowerShell "&" to get it executed:

$preExecutionCommand = "dir D:\Test"
$preExecutionArgs = $preExecutionCommand -split '\s+'
$preExecutionCmd = $preExecutionArgs[0]
$preExecutionNumArgs = $preExecutionArgs.Length - 1
if ($preExecutionNumArgs -gt 0) {
  $preExecutionArgs = $preExecutionArgs[1..$preExecutionNumArgs]
  & $preExecutionCmd $preExecutionArgs
} else {
  & $preExecutionCmd
}

But if the user-defined command string has spaces that need to go in the arguments, or the path to the command has spaces, then I need to be much smarter at parsing the user-defined string.

To the naked eye it is obvious that the following string has a command at the front followed by 2 parameters:

"C:\Program Files\Tool\program1" 25 "the quick brown fox"

Has anyone already got a function that will parse strings like this and give back an array or list of the DOS-style command and each of the parameters?

Phil Davis
  • 303
  • 1
  • 3
  • 9
  • 1
    `cmd /c """$preExecutionCommand"""` – user4003407 Aug 12 '15 at 07:37
  • See http://stackoverflow.com/q/298830/291641 for a C# example. – patthoyts Aug 12 '15 at 08:03
  • I am trying to avoid `cmd /c` - I had some scripts that worked in Windows7 and after upgrade to Windows 10 I found I had to change the way some double-quoting of stuff was done to make it work. The ampersand way to invoke general commands from PowerShell seems to be the "proper"/"modern" way to do it, so I am trying to get that working. – Phil Davis Aug 12 '15 at 08:32
  • It is the proper way, under the condition that the command and its arguments are also defined properly. In your scenario my advice would be to either fix your input or do what @PetSerAl suggested. Tokenizing arbitrary commandlines isn't a matter of simply splitting a string at whitespace. – Ansgar Wiechers Aug 12 '15 at 10:42
  • That C# example got me thinking and searching. The similar kind of thing for Powershell is at https://github.com/beatcracker/Powershell-Misc/blob/master/Split-CommandLine.ps1 which takes from http://edgylogic.com/blog/powershell-and-external-commands-done-right/ Making use of CommandLineToArgvW() is doing the job without having to write a command line parser myself in Powershell. – Phil Davis Aug 13 '15 at 06:13
  • For the benefit of others, we use this in a backup script in https://github.com/International-Nepal-Fellowship/Windows-Tools - ntfs-hardlink-backup – Phil Davis Aug 13 '15 at 06:15

4 Answers4

5

There is a very simple solution for that. You can misuse the Powershell paramater parsing mechanism for it:

> $paramString = '1 blah "bluh" "ding dong" """foo"""'
> $paramArray = iex "echo $paramString"
> $paramArray 
1
blah
bluh
ding dong
"foo"
Mehrdad Mirreza
  • 984
  • 12
  • 20
  • 1
    I commented earlier to say, this doesn't seem to work with `--file="sp ace.txt"`, however I'm now retracting that comment. It actually does work. You get `--file=sp ace.txt`, which doesn't seem like the right value (surely I need quotes?), but due to how command line arguments are handled, this is the right result, at least for my application. – Boinst May 12 '17 at 05:57
  • @Boinst: well, there is a simple rule for command line arguments parsing: any count of space is an argument delimiter, unless it is inside quotes, so "--file=sp ace.txt" as one string is exactly, what should be parsed. The second rule is that you can force quotes inside strings using either the DOS way (triple quotes) or the Powershell escape character (back tick). The solution above accounts both rules. – Mehrdad Mirreza Jun 23 '17 at 07:55
  • This does not work if the input can be considered a parameter to Write-Object. For example, `'1 -verbose'` yields only `1`. Also, this usage of Invoke-Expression will result in unexpected behavior or security vulnerabilities if certain special characters such as semicolon are present in the input. – mdonoughe Oct 29 '18 at 01:43
  • Doesn't `$paramArray = echo $paramString` also work? – xjcl Jun 21 '23 at 13:05
1

I have put together the following that seems to do what you require.

$parsercode = @"
using System;
using System.Linq;
using System.Collections.Generic;
public static class CommandLineParser
{
    public static List<String> Parse(string commandLine)
    {
       var result = commandLine.Split('"')
           .Select((element, index) => index % 2 == 0  // If even index
                  ? element.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)  // Split the item
                    : new string[] { String.Format("\"{0}\"", element) })  // Keep the entire item   
                         .SelectMany(element => element).ToList();

        return result;
    }
}
"@


Add-Type -TypeDefinition $parsercode -Language CSharp

$commands = Get-Content .\commands.txt

$commands | % {
    $tokens = [CommandLineParser]::Parse($_)
    $command = $tokens[0]
    $arguments = $tokens[1..($tokens.Count-1)]
    echo ("Command:{0}, ArgCount:{1}, Arguments:{2}" -f $command, $arguments.Count, ([string]::Join(" ", $arguments)))
    Start-Process -FilePath ($command) -ArgumentList $arguments
}

I have used some c# code posted by @Cédric Bignon in 2013 which shows a very nice C# Linq solution to your parser problem to create a parser method in [CommandLineParser]::Parse. That is then used to parse the command and Arguments to send to Start-Process.

Try it out and see if it does what you want.

Jower
  • 565
  • 2
  • 8
  • That just simply strips out the spaces and special characters and puts the resulting pieces into an array. e.g. `dir "abc def"` Results in the command being "dir" and 2 arguments "abc" and "def"> But I want to get just 1 argument for that case: "abc def" In this example the user wants a dir listing of a folder called "abc def" that has a space in the folder name. I can code a parser myself that steps along the string and remembers if it is in or out of a quoted area and builds the parameter array. – Phil Davis Aug 13 '15 at 04:34
  • You are right. I have updated my answer. Let me know if it works. – Jower Aug 13 '15 at 08:54
  • That passes through spaces in quoted parameters, which works for the example in the question I asked. Thanks for the help and clues. It is not a generic command-line parameter parser. e.g. it has no way to escape a double-quote to literally send it through as text in the parameter. I posted the solution I am using so far that calls CommandLineToArgvW() - that does the things your example does and also allows passing of literal double-quotes... – Phil Davis Aug 17 '15 at 06:52
  • @PhilDavis: I have modified the parser code, to keep the double quotes. – Jower Aug 17 '15 at 15:45
1

In the end I am using CommandLineToArgvW() to parse the command line. With this I can pass double quotes literally into parameters when needed, as well as have spaces in double-quoted parameters. e.g.:

dir "abc def" 23 """z"""

becomes a directory command with 3 parameters:

abc def
23
"z"

The code is:

function Split-CommandLine
{
    <#
    .Synopsis
        Parse command-line arguments using Win32 API CommandLineToArgvW function.

    .Link
        https://github.com/beatcracker/Powershell-Misc/blob/master/Split-CommandLine.ps1
        http://edgylogic.com/blog/powershell-and-external-commands-done-right/

    .Description
        This is the Cmdlet version of the code from the article http://edgylogic.com/blog/powershell-and-external-commands-done-right.
        It can parse command-line arguments using Win32 API function CommandLineToArgvW . 

    .Parameter CommandLine
        A string representing the command-line to parse. If not specified, the command-line of the current PowerShell host is used.
    #>
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true, Position=0)]
        [ValidateNotNullOrEmpty()]
        [string]$CommandLine
    )

    Begin
    {
        $Kernel32Definition = @'
            [DllImport("kernel32")]
            public static extern IntPtr LocalFree(IntPtr hMem);
'@
        $Kernel32 = Add-Type -MemberDefinition $Kernel32Definition -Name 'Kernel32' -Namespace 'Win32' -PassThru

        $Shell32Definition = @'
            [DllImport("shell32.dll", SetLastError = true)]
            public static extern IntPtr CommandLineToArgvW(
                [MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine,
                out int pNumArgs);
'@
        $Shell32 = Add-Type -MemberDefinition $Shell32Definition -Name 'Shell32' -Namespace 'Win32' -PassThru
    }

    Process
    {
        $ParsedArgCount = 0
        $ParsedArgsPtr = $Shell32::CommandLineToArgvW($CommandLine, [ref]$ParsedArgCount)

        Try
        {
            $ParsedArgs = @();

            0..$ParsedArgCount | ForEach-Object {
                $ParsedArgs += [System.Runtime.InteropServices.Marshal]::PtrToStringUni(
                [System.Runtime.InteropServices.Marshal]::ReadIntPtr($ParsedArgsPtr, $_ * [IntPtr]::Size)
                )
            }
        }
        Finally
        {
            $Kernel32::LocalFree($ParsedArgsPtr) | Out-Null
        }

        $ret = @()

        # -lt to skip the last item, which is a NULL ptr
        for ($i = 0; $i -lt $ParsedArgCount; $i += 1) {
            $ret += $ParsedArgs[$i]
        }

        return $ret
    }
}

$executionCommand = Get-Content .\commands.txt
$executionArgs = Split-CommandLine $executionCommand
$executionCmd = $executionArgs[0]
$executionNumArgs = $executionArgs.Length - 1
if ($executionNumArgs -gt 0) {
    $executionArgs = $executionArgs[1..$executionNumArgs]
    echo $executionCmd $executionArgs
    & $executionCmd $executionArgs
} else {
    echo $executionCmd
    & $executionCmd
}
Phil Davis
  • 303
  • 1
  • 3
  • 9
1
function ParseCommandLine($commandLine)
{
  return Invoke-Expression ".{`$args} $commandLine"
}
psmolkin
  • 415
  • 3
  • 11
  • 2
    If the input contains certain special characters such as semicolon this method of parsing the command line using Invoke-Expression could lead to unexpected behavior or even security vulnerabilities in your scripts. – mdonoughe Oct 29 '18 at 01:46