diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 8e908c0..cf660d3 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -48,6 +48,17 @@ priorities. `RELEASE_NOTES.md` v0.2.0 section; CLI + Web samples gain a shell selector; `README.md` updated. +### Completed maintenance + +- [x] **Issue #52 — hyphenated PowerShell parameters/native options.** + Preserve internal hyphens, apply bash-compatible native + `--flag=value` splitting and path classification, keep colon binding + cmdlet-only, and pin the behavior in unit tests plus the PowerShell + corpus. Review follow-ups shipped with it: the equals-form split moved + to a shared `NativeFlagSyntax` so the two parsers can't drift, a colon + value under an `=`-bearing parameter name safe-fails to `DynamicSkip`, + and `-?` lexes as one parameter token. + ### 15. Release 0.2.0 (alpha → beta → stable) — SPEC.PWSH §15 / §17 - [x] Tag `0.2.0-alpha`; `publish_nuget.yml` produced diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index c8d2fcb..c70547e 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -280,7 +280,14 @@ The `PwshLexer` produces tokens consumed by `PwshCommandParser`. Token kinds `${name}`, `$env:PATH`, drive-qualified `C:\x`. Backtick escapes are processed; simple `$x` / `${x}` is absorbed into the Word. - **Parameter** — a `-Name` parameter token. A `-Name:value` colon form - keeps the value; the parser splits on the first `:`. + keeps the value; the parser splits on the first `:` for cmdlet-style + commands. Parameter names may contain internal hyphens and `?`, so + `-Name-Part`, `-?`, and native `--work-tree` each remain one token. An + unquoted native + `--flag=value` likewise remains one source token; the native-command + parser splits it into flag and value args using the bash rules. `=` is + not cmdlet parameter binding — `-Name=value` stays one parameter token + for a cmdlet. - **QuotedString** — single-quoted, double-quoted, or here-string. Delimiters stripped from the value. Carries `IsSingleQuoted` and `IsHereString` flags. @@ -555,7 +562,11 @@ and treated as unknown. For each `-Name` token, in order: -1. Colon form `-Name:value` → value-binding; value is the colon tail. +1. Colon form `-Name:value` → value-binding; value is the colon tail. When + the name half carries an `=` the colon tail is **`DynamicSkip`** instead: + PowerShell reads `-Path=C:\Windows` as parameter `-Path=C:` plus argument + `\Windows`, a name no cmdlet can bind, so the value's role is unknowable + and the parser must not classify it. 2. `-Name` (prefix-)matches `PwshValueParameters` → value-binding; consume the next significant token as its value. If there is no next token, or the next token is itself a parameter or an operator, `-Name` bound @@ -638,7 +649,11 @@ positionals are paths," exactly as `SPEC.md` §7. Native commands reuse the bash per-verb rules table verbatim — `git`, `curl`, `tar`, etc. behave identically to `SPEC.md` §7 (`curl` / `wget`: -the first positional is a URL; the `-o` / `-O` value is a path). +the first positional is a URL; the `-o` / `-O` value is a path). This +includes hyphenated option names and the bash `--flag=value` split: the +flag and value surface as separate args, and a curated flag's value receives +the same path classification in both parsers. Native `--flag:value` has no +cmdlet-binding semantics and remains verbatim. --- diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index 3dec324..0603298 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using ShellSyntaxTree.Internal.Bash.Lexing; using ShellSyntaxTree.Internal.Bash.Verbs; +using ShellSyntaxTree.Internal.Parsing; using ShellSyntaxTree.Internal.Resolving; namespace ShellSyntaxTree.Internal.Bash.Parsing; @@ -1099,7 +1100,8 @@ private static void ExtractRedirectsAndArgs( // flag half is a Literal arg with IsFlag=true (Raw // starts with '-'); the value half is classified per // the flag-value path rule. - if (TrySplitEqualsFlag(t.Value, out var flagPart, out var valuePart)) + if (NativeFlagSyntax.TrySplitEqualsFlag( + t.Value, out var flagPart, out var valuePart)) { // Flag arg. argList.Add(new Arg @@ -1393,28 +1395,6 @@ private static bool TryMapRedirect(string? op, out RedirectDirection direction) } } - private static bool TrySplitEqualsFlag(string raw, out string flagPart, out string valuePart) - { - if (raw.Length < 2 || raw[0] != '-') - { - flagPart = ""; - valuePart = ""; - return false; - } - - var eq = raw.IndexOf('='); - if (eq <= 0 || eq == raw.Length - 1) - { - flagPart = ""; - valuePart = ""; - return false; - } - - flagPart = raw.Substring(0, eq); - valuePart = raw.Substring(eq + 1); - return true; - } - private static string SourceSlice(string source, BashToken token) { if (token.SourceStart < 0 || token.SourceStart >= source.Length) diff --git a/src/ShellSyntaxTree/Internal/Parsing/NativeFlagSyntax.cs b/src/ShellSyntaxTree/Internal/Parsing/NativeFlagSyntax.cs new file mode 100644 index 0000000..1513456 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Parsing/NativeFlagSyntax.cs @@ -0,0 +1,49 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +namespace ShellSyntaxTree.Internal.Parsing; + +/// +/// Option syntax shared by every native command, whichever shell invoked +/// it. SPEC.POWERSHELL.md §7.3 requires the PowerShell native-command +/// path to behave identically to SPEC.md §7, so the rules live here +/// once instead of once per parser — a second copy could drift, and this one +/// decides whether a path reaches the consumer's gate. +/// +internal static class NativeFlagSyntax +{ + /// + /// Split an equals-form option — --output=file.txt — into its + /// flag and value halves so the value can be path-classified through the + /// per-verb table. + /// + /// + /// false when is not a flag, carries no + /// =, or ends with one (--work-tree=). Those stay a single + /// opaque arg — there is no value half to classify. + /// + internal static bool TrySplitEqualsFlag( + string raw, out string flagPart, out string valuePart) + { + if (raw.Length < 2 || raw[0] != '-') + { + flagPart = ""; + valuePart = ""; + return false; + } + + var eq = raw.IndexOf('='); + if (eq <= 0 || eq == raw.Length - 1) + { + flagPart = ""; + valuePart = ""; + return false; + } + + flagPart = raw.Substring(0, eq); + valuePart = raw.Substring(eq + 1); + return true; + } +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs index 7862e84..273b472 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs @@ -669,15 +669,20 @@ private static int ReadParameter( i++; } - // Parameter name: letters, digits, underscores. - while (i < src.Length && IsIdentifierContinuation(src[i])) + // Parameter / native-option name. Hyphens after the first name + // character are significant (`-Name-Part`, `--work-tree`) and `?` is + // a name character in its own right (`-?`, `-Ba?r`) — both must stay + // in the same token; the identifier predicate is deliberately not + // widened because it also governs splat names. + while (i < src.Length && IsParameterNameContinuation(src[i])) { i++; } - // Colon form -Name:value — consume the value word-style. The parser - // splits the token on the first ':'. - if (i < src.Length && src[i] == ':') + // Keep an unquoted inline value attached to its source token. The + // parser interprets ':' only for cmdlet-style parameters and '=' + // only for native options. + if (i < src.Length && (src[i] == ':' || src[i] == '=')) { i++; i = ScanWordRun(src, i); @@ -860,4 +865,9 @@ private static bool IsIdentifierStart(char c) => private static bool IsIdentifierContinuation(char c) => IsAsciiLetter(c) || (c >= '0' && c <= '9') || c == '_'; + + // Mirrors IsParameterStart, which already admits '?' — without it + // `Get-Help -?` lexed as a bare `-` flag plus a `?` glob arg. + private static bool IsParameterNameContinuation(char c) => + IsIdentifierContinuation(c) || c == '-' || c == '?'; } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs index 3a734c5..2e78b63 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs @@ -14,7 +14,8 @@ namespace ShellSyntaxTree.Internal.Pwsh.Lexing; /// . For /// this is the text after backtick-escape processing. For /// this is the verbatim -Name -/// or -Name:value text. For , +/// or -Name:value text, or a native option such as +/// --work-tree=value. For , /// , , /// and this is the full verbatim /// source slice. Empty for kinds that carry no content. diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs index 7eb84f6..fcce90f 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs @@ -17,9 +17,10 @@ internal enum PwshTokenKind /// ${x} absorbed. Word, - /// A -Name parameter token. A -Name:value colon - /// form keeps the value in ; the parser - /// splits on the first :. + /// A parameter-shaped token, including hyphenated cmdlet + /// parameters and native options. Inline : or = text stays + /// in ; the parser interprets it according + /// to command kind. Parameter, /// Single-quoted, double-quoted, or here-string. Delimiters diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 8eb2589..ffcdd96 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Text; using ShellSyntaxTree.Internal.Bash.Verbs; +using ShellSyntaxTree.Internal.Parsing; using ShellSyntaxTree.Internal.Pwsh.Lexing; using ShellSyntaxTree.Internal.Pwsh.Verbs; using ShellSyntaxTree.Internal.Resolving; @@ -684,7 +685,14 @@ private static ClassifiedVerb ClassifyVerb(List body, int start) var t = body[i]; if (t.Kind == PwshTokenKind.Parameter) { - if (flagsForVerb is null || !flagsForVerb.Contains(StripColon(t.Value))) + if (NativeFlagSyntax.TrySplitEqualsFlag(t.Value, out _, out _)) + { + // Match Bash: an inline --flag=value is surfaced by + // arg extraction, and terminates the greedy walk. + break; + } + + if (flagsForVerb is null || !flagsForVerb.Contains(t.Value)) { break; } @@ -721,12 +729,6 @@ private static ClassifiedVerb ClassifyVerb(List body, int start) }; } - private static string StripColon(string paramToken) - { - var colon = paramToken.IndexOf(':'); - return colon > 0 ? paramToken.Substring(0, colon) : paramToken; - } - // ---------------------------------------------------------------- args private readonly struct ArgResult @@ -800,15 +802,30 @@ private static ArgResult ExtractArgsAndRedirects( pendingNativeFlag = null; var raw = t.Value; - var colon = raw.IndexOf(':'); - var paramName = colon > 0 ? raw.Substring(0, colon) : raw; - var colonValue = colon > 0 ? raw.Substring(colon + 1) : null; - - args.Add(new Arg { Raw = paramName, Kind = ArgKind.Literal, IsPath = false }); if (cmdletStyle) { - if (colonValue is not null) + var colon = raw.IndexOf(':'); + var paramName = colon > 0 ? raw.Substring(0, colon) : raw; + var colonValue = colon > 0 ? raw.Substring(colon + 1) : null; + + args.Add(new Arg { Raw = paramName, Kind = ArgKind.Literal, IsPath = false }); + + if (colonValue is not null && paramName.IndexOf('=') >= 0) + { + // `-Path=C:\Windows` splits here into name `-Path=C` + // and value `\Windows` — exactly how PowerShell + // tokenizes it, and a name that can never bind. The + // value's role is unknowable, so don't hand the gate + // a confident literal (§6.5.3 / SPEC.md §1). + args.Add(new Arg + { + Raw = colonValue, + Kind = ArgKind.DynamicSkip, + IsPath = false, + }); + } + else if (colonValue is not null) { // Colon form always binds (§6.5.3 rule 1). var valueIsPath = PwshPerVerbRules.ParameterValueIsPath(canonical, paramName); @@ -821,13 +838,31 @@ private static ArgResult ExtractArgsAndRedirects( } else { - // Native flag-with-value via the shared bash table (§7.3). var verbKey = verb.VerbTokens.Count > 0 ? verb.VerbTokens[0] : string.Empty; - if (colonValue is null + + // Native --flag=value follows Bash exactly: surface the + // flag and value separately, and classify a curated + // flag's value through the shared per-verb table. + if (NativeFlagSyntax.TrySplitEqualsFlag(raw, out var flagPart, out var valuePart)) + { + args.Add(new Arg { Raw = flagPart, Kind = ArgKind.Literal, IsPath = false }); + var valueIsPath = BashPerVerbRules.ValueOfFlagIsPath(verbKey, flagPart); + args.Add(ResolveValue( + valuePart, valueIsPath, options, workingDirectoryUnknown, false)); + } + else + { + // Every other option shape stays one arg. A colon in + // particular has no native binding meaning, so the + // tail is preserved rather than dropped. + args.Add(new Arg { Raw = raw, Kind = ArgKind.Literal, IsPath = false }); + } + + if (raw.IndexOf('=') < 0 && BashVerbs.FlagsWithValue.TryGetValue(verbKey, out var flags) - && flags.Contains(paramName)) + && flags.Contains(raw)) { - pendingNativeFlag = paramName; + pendingNativeFlag = raw; } } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/212_native_git_worktree_equals.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/212_native_git_worktree_equals.json new file mode 100644 index 0000000..8e6cf3c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/212_native_git_worktree_equals.json @@ -0,0 +1,40 @@ +{ + "name": "Native git worktree equals", + "input": "git --work-tree=../test add somefile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "git" + ], + "args": [ + { + "raw": "--work-tree", + "kind": "Literal", + "isPath": false + }, + { + "raw": "../test", + "kind": "Literal", + "isPath": true, + "resolved": "C:/test" + }, + { + "raw": "add", + "kind": "Literal", + "isPath": false + }, + { + "raw": "somefile", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + } + ] + }, + "notes": "Issue #52: native --flag=value stays atomic, then splits into flag and path value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/213_native_git_worktree_spaced.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/213_native_git_worktree_spaced.json new file mode 100644 index 0000000..4a24f5c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/213_native_git_worktree_spaced.json @@ -0,0 +1,31 @@ +{ + "name": "Native git worktree spaced", + "input": "git --work-tree repo status", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "git", + "status" + ], + "args": [ + { + "raw": "--work-tree", + "kind": "Literal", + "isPath": false + }, + { + "raw": "repo", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/repo" + } + ], + "redirects": [] + } + ] + }, + "notes": "Issue #52: a hyphenated curated flag consumes and path-classifies its spaced value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/214_native_colon_option.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/214_native_colon_option.json new file mode 100644 index 0000000..68a4914 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/214_native_colon_option.json @@ -0,0 +1,29 @@ +{ + "name": "Native colon option", + "input": "git --option:value status", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "git" + ], + "args": [ + { + "raw": "--option:value", + "kind": "Literal", + "isPath": false + }, + { + "raw": "status", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + } + ] + }, + "notes": "Native colon options are preserved verbatim; colon binding is cmdlet-only." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/215_bind_hyphenated_colon.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/215_bind_hyphenated_colon.json new file mode 100644 index 0000000..d82dc8d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/215_bind_hyphenated_colon.json @@ -0,0 +1,29 @@ +{ + "name": "Bind hyphenated colon", + "input": "Get-Thing -Name-Part:value", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Thing" + ], + "args": [ + { + "raw": "-Name-Part", + "kind": "Literal", + "isPath": false + }, + { + "raw": "value", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + } + ] + }, + "notes": "A hyphenated cmdlet parameter remains one token and retains colon binding." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/216_bind_equals_not_cmdlet_binding.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/216_bind_equals_not_cmdlet_binding.json new file mode 100644 index 0000000..80d9485 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/216_bind_equals_not_cmdlet_binding.json @@ -0,0 +1,30 @@ +{ + "name": "Bind equals not cmdlet binding", + "input": "Get-Item -Path=foo bar", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Item" + ], + "args": [ + { + "raw": "-Path=foo", + "kind": "Literal", + "isPath": false + }, + { + "raw": "bar", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/bar" + } + ], + "redirects": [] + } + ] + }, + "notes": "Equals does not bind a cmdlet parameter value; the following positional remains a path." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/217_bind_equals_colon_value_is_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/217_bind_equals_colon_value_is_dynamic.json new file mode 100644 index 0000000..972e2aa --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/217_bind_equals_colon_value_is_dynamic.json @@ -0,0 +1,29 @@ +{ + "name": "Bind equals colon value is dynamic", + "input": "Remove-Item -Path=C:\\Windows", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Remove-Item" + ], + "args": [ + { + "raw": "-Path=C", + "kind": "Literal", + "isPath": false + }, + { + "raw": "\\Windows", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ] + }, + "notes": "PowerShell reads this as parameter \u0027-Path=C:\u0027 plus argument \u0027\\Windows\u0027 \u2014 a name that can never bind, so the value safe-fails to DynamicSkip." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/218_bind_question_mark_help.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/218_bind_question_mark_help.json new file mode 100644 index 0000000..ab0cec6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/218_bind_question_mark_help.json @@ -0,0 +1,24 @@ +{ + "name": "Bind question mark help", + "input": "Get-Help -?", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Help" + ], + "args": [ + { + "raw": "-?", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + } + ] + }, + "notes": "\u0027-?\u0027 is a single parameter token, not a bare dash plus a \u0027?\u0027 glob." +} diff --git a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs index 149495c..256a766 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs @@ -97,6 +97,20 @@ public void Colon_form_parameter_keeps_its_value() Assert.Equal("-Path:C:\\logs", tokens[1].Value); } + [Theory] + [InlineData("git --work-tree repo", "--work-tree")] + [InlineData("Get-Thing -Name-Part:value", "-Name-Part:value")] + [InlineData("git --work-tree=../test", "--work-tree=../test")] + [InlineData("Get-Help -?", "-?")] + [InlineData("Get-Foo -Ba?r x", "-Ba?r")] + public void Hyphenated_parameter_or_native_option_stays_one_token( + string input, string expected) + { + var tokens = Significant(input); + Assert.Equal(PwshTokenKind.Parameter, tokens[1].Kind); + Assert.Equal(expected, tokens[1].Value); + } + [Fact] public void Negative_number_is_a_word_not_a_parameter() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs index 017c75b..d5200ef 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs @@ -102,6 +102,66 @@ public void Native_chain_stops_at_a_capitalized_token() Assert.Equal("InitialCreate", clause.Args[0].Raw); } + [Fact] + public void Native_hyphenated_flag_consumes_spaced_path_value() + { + var clause = Assert.Single(Parse("git --work-tree repo status").Clauses); + + Assert.Equal(new[] { "git", "status" }, clause.Verb.Tokens); + Assert.Equal("--work-tree", clause.Args[0].Raw); + Assert.Equal("repo", clause.Args[1].Raw); + Assert.True(clause.Args[1].IsPath); + Assert.Equal("C:/work/repo", clause.Args[1].Resolved); + } + + [Theory] + [InlineData("--work-tree=../test", "--work-tree", "../test", "C:/test")] + [InlineData("--git-dir=repo", "--git-dir", "repo", "C:/work/repo")] + public void Native_equals_flag_splits_and_classifies_its_path_value( + string option, string expectedFlag, string expectedValue, string expectedResolved) + { + var clause = Assert.Single(Parse($"git {option} add somefile").Clauses); + + Assert.Equal(new[] { "git" }, clause.Verb.Tokens); + Assert.Equal(4, clause.Args.Count); + Assert.Equal(expectedFlag, clause.Args[0].Raw); + Assert.Equal(expectedValue, clause.Args[1].Raw); + Assert.True(clause.Args[1].IsPath); + Assert.Equal(expectedResolved, clause.Args[1].Resolved); + Assert.Equal("add", clause.Args[2].Raw); + Assert.Equal("somefile", clause.Args[3].Raw); + } + + [Fact] + public void Native_colon_option_is_preserved_verbatim() + { + var clause = Assert.Single(Parse("git --option:value status").Clauses); + + Assert.Equal("--option:value", clause.Args[0].Raw); + Assert.True(clause.Args[0].IsFlag); + } + + [Fact] + public void Unknown_native_equals_flag_does_not_gain_path_semantics() + { + var clause = Assert.Single(Parse("git --destination=repo status").Clauses); + + Assert.Equal("--destination", clause.Args[0].Raw); + Assert.Equal("repo", clause.Args[1].Raw); + Assert.False(clause.Args[1].IsPath); + } + + [Fact] + public void Dynamic_native_equals_path_value_safe_fails() + { + var clause = Assert.Single(Parse("git --work-tree=$repo status").Clauses); + + Assert.Equal("--work-tree", clause.Args[0].Raw); + Assert.Equal("$repo", clause.Args[1].Raw); + Assert.Equal(ArgKind.DynamicSkip, clause.Args[1].Kind); + Assert.False(clause.Args[1].IsPath); + } + // ---------------------------------------------------------------- pipelines [Fact] @@ -179,6 +239,53 @@ public void Colon_form_parameter_binds_its_value() Assert.True(clause.Args[1].IsPath); } + [Fact] + public void Hyphenated_cmdlet_parameter_colon_form_stays_one_parameter() + { + var clause = Assert.Single(Parse("Get-Thing -Name-Part:value").Clauses); + + Assert.Equal(2, clause.Args.Count); + Assert.Equal("-Name-Part", clause.Args[0].Raw); + Assert.Equal("value", clause.Args[1].Raw); + } + + [Fact] + public void Equals_does_not_bind_a_cmdlet_parameter_value() + { + var clause = Assert.Single(Parse("Get-Item -Path=foo bar").Clauses); + + Assert.Equal(2, clause.Args.Count); + Assert.Equal("-Path=foo", clause.Args[0].Raw); + Assert.True(clause.Args[0].IsFlag); + Assert.Equal("bar", clause.Args[1].Raw); + Assert.True(clause.Args[1].IsPath); + } + + [Fact] + public void Equals_in_a_parameter_name_makes_its_colon_value_dynamic() + { + // PowerShell tokenizes this as parameter `-Path=C:` plus argument + // `\Windows` — a name that can never bind, so the value's role is + // unknowable and must not surface as a confident literal. + var clause = Assert.Single(Parse("Remove-Item -Path=C:\\Windows").Clauses); + + Assert.Equal(2, clause.Args.Count); + Assert.Equal("-Path=C", clause.Args[0].Raw); + Assert.Equal("\\Windows", clause.Args[1].Raw); + Assert.Equal(ArgKind.DynamicSkip, clause.Args[1].Kind); + Assert.False(clause.Args[1].IsPath); + } + + [Fact] + public void Question_mark_help_parameter_is_one_flag() + { + var clause = Assert.Single(Parse("Get-Help -?").Clauses); + + var arg = Assert.Single(clause.Args); + Assert.Equal("-?", arg.Raw); + Assert.True(arg.IsFlag); + } + [Fact] public void Unknown_parameter_defaults_to_switch() { diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index f6bec5f..4de03bc 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -461,5 +461,22 @@ private static string B64(string s) => "An unterminated $( ) subexpression."), E("unparseable_unterminated_herestring", "Write-Output @\"\nnever closed", "An unterminated here-string."), + + // ---- Issue #52: hyphenated parameters and native inline values ---- + E("native_git_worktree_equals", "git --work-tree=../test add somefile", + "Issue #52: native --flag=value stays atomic, then splits into flag and path value."), + E("native_git_worktree_spaced", "git --work-tree repo status", + "Issue #52: a hyphenated curated flag consumes and path-classifies its spaced value."), + E("native_colon_option", "git --option:value status", + "Native colon options are preserved verbatim; colon binding is cmdlet-only."), + E("bind_hyphenated_colon", "Get-Thing -Name-Part:value", + "A hyphenated cmdlet parameter remains one token and retains colon binding."), + E("bind_equals_not_cmdlet_binding", "Get-Item -Path=foo bar", + "Equals does not bind a cmdlet parameter value; the following positional remains a path."), + E("bind_equals_colon_value_is_dynamic", "Remove-Item -Path=C:\\Windows", + "PowerShell reads this as parameter '-Path=C:' plus argument '\\Windows' — a name that " + + "can never bind, so the value safe-fails to DynamicSkip."), + E("bind_question_mark_help", "Get-Help -?", + "'-?' is a single parameter token, not a bare dash plus a '?' glob."), }; }