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."),
};
}