Skip to content

Commit 18a569e

Browse files
committed
fix(pwsh): preserve hyphenated native options
1 parent 363cdef commit 18a569e

14 files changed

Lines changed: 361 additions & 27 deletions

IMPLEMENTATION_PLAN.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ priorities.
4848
`RELEASE_NOTES.md` v0.2.0 section; CLI + Web samples gain a shell
4949
selector; `README.md` updated.
5050

51+
### Completed maintenance
52+
53+
- [x] **Issue #52 — hyphenated PowerShell parameters/native options.**
54+
Preserve internal hyphens, apply bash-compatible native
55+
`--flag=value` splitting and path classification, keep colon binding
56+
cmdlet-only, and pin the behavior in unit tests plus the PowerShell
57+
corpus.
58+
5159
### 15. Release 0.2.0 (alpha → beta → stable) — SPEC.PWSH §15 / §17
5260

5361
- [x] Tag `0.2.0-alpha`; `publish_nuget.yml` produced

SPEC.POWERSHELL.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,13 @@ The `PwshLexer` produces tokens consumed by `PwshCommandParser`. Token kinds
280280
`${name}`, `$env:PATH`, drive-qualified `C:\x`. Backtick escapes are
281281
processed; simple `$x` / `${x}` is absorbed into the Word.
282282
- **Parameter** — a `-Name` parameter token. A `-Name:value` colon form
283-
keeps the value; the parser splits on the first `:`.
283+
keeps the value; the parser splits on the first `:` for cmdlet-style
284+
commands. Parameter names may contain internal hyphens, so `-Name-Part`
285+
and native `--work-tree` each remain one token. An unquoted native
286+
`--flag=value` likewise remains one source token; the native-command
287+
parser splits it into flag and value args using the bash rules. `=` is
288+
not cmdlet parameter binding — `-Name=value` stays one parameter token
289+
for a cmdlet.
284290
- **QuotedString** — single-quoted, double-quoted, or here-string.
285291
Delimiters stripped from the value. Carries `IsSingleQuoted` and
286292
`IsHereString` flags.
@@ -638,7 +644,11 @@ positionals are paths," exactly as `SPEC.md` §7.
638644

639645
Native commands reuse the bash per-verb rules table verbatim — `git`,
640646
`curl`, `tar`, etc. behave identically to `SPEC.md` §7 (`curl` / `wget`:
641-
the first positional is a URL; the `-o` / `-O` value is a path).
647+
the first positional is a URL; the `-o` / `-O` value is a path). This
648+
includes hyphenated option names and the bash `--flag=value` split: the
649+
flag and value surface as separate args, and a curated flag's value receives
650+
the same path classification in both parsers. Native `--flag:value` has no
651+
cmdlet-binding semantics and remains verbatim.
642652

643653
---
644654

src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -669,15 +669,19 @@ private static int ReadParameter(
669669
i++;
670670
}
671671

672-
// Parameter name: letters, digits, underscores.
673-
while (i < src.Length && IsIdentifierContinuation(src[i]))
672+
// Parameter / native-option name. Hyphens after the first name
673+
// character are significant (`-Name-Part`, `--work-tree`) and must
674+
// stay in the same token; the identifier predicate is deliberately
675+
// not widened because it also governs splat names.
676+
while (i < src.Length && IsParameterNameContinuation(src[i]))
674677
{
675678
i++;
676679
}
677680

678-
// Colon form -Name:value — consume the value word-style. The parser
679-
// splits the token on the first ':'.
680-
if (i < src.Length && src[i] == ':')
681+
// Keep an unquoted inline value attached to its source token. The
682+
// parser interprets ':' only for cmdlet-style parameters and '='
683+
// only for native options.
684+
if (i < src.Length && (src[i] == ':' || src[i] == '='))
681685
{
682686
i++;
683687
i = ScanWordRun(src, i);
@@ -860,4 +864,7 @@ private static bool IsIdentifierStart(char c) =>
860864

861865
private static bool IsIdentifierContinuation(char c) =>
862866
IsAsciiLetter(c) || (c >= '0' && c <= '9') || c == '_';
867+
868+
private static bool IsParameterNameContinuation(char c) =>
869+
IsIdentifierContinuation(c) || c == '-';
863870
}

src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ namespace ShellSyntaxTree.Internal.Pwsh.Lexing;
1414
/// <see cref="PwshTokenKind.QuotedString"/>. For <see cref="PwshTokenKind.Word"/>
1515
/// this is the text after backtick-escape processing. For
1616
/// <see cref="PwshTokenKind.Parameter"/> this is the verbatim <c>-Name</c>
17-
/// or <c>-Name:value</c> text. For <see cref="PwshTokenKind.ScriptBlock"/>,
17+
/// or <c>-Name:value</c> text, or a native option such as
18+
/// <c>--work-tree=value</c>. For <see cref="PwshTokenKind.ScriptBlock"/>,
1819
/// <see cref="PwshTokenKind.Subexpression"/>, <see cref="PwshTokenKind.Splat"/>,
1920
/// and <see cref="PwshTokenKind.StopParsing"/> this is the full verbatim
2021
/// source slice. Empty for kinds that carry no content.

src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ internal enum PwshTokenKind
1717
/// <c>${x}</c> absorbed.</summary>
1818
Word,
1919

20-
/// <summary>A <c>-Name</c> parameter token. A <c>-Name:value</c> colon
21-
/// form keeps the value in <see cref="PwshToken.Value"/>; the parser
22-
/// splits on the first <c>:</c>.</summary>
20+
/// <summary>A parameter-shaped token, including hyphenated cmdlet
21+
/// parameters and native options. Inline <c>:</c> or <c>=</c> text stays
22+
/// in <see cref="PwshToken.Value"/>; the parser interprets it according
23+
/// to command kind.</summary>
2324
Parameter,
2425

2526
/// <summary>Single-quoted, double-quoted, or here-string. Delimiters

src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,14 @@ private static ClassifiedVerb ClassifyVerb(List<PwshToken> body, int start)
684684
var t = body[i];
685685
if (t.Kind == PwshTokenKind.Parameter)
686686
{
687-
if (flagsForVerb is null || !flagsForVerb.Contains(StripColon(t.Value)))
687+
if (TrySplitNativeEqualsFlag(t.Value, out _, out _))
688+
{
689+
// Match Bash: an inline --flag=value is surfaced by
690+
// arg extraction, and terminates the greedy walk.
691+
break;
692+
}
693+
694+
if (flagsForVerb is null || !flagsForVerb.Contains(t.Value))
688695
{
689696
break;
690697
}
@@ -721,12 +728,6 @@ private static ClassifiedVerb ClassifyVerb(List<PwshToken> body, int start)
721728
};
722729
}
723730

724-
private static string StripColon(string paramToken)
725-
{
726-
var colon = paramToken.IndexOf(':');
727-
return colon > 0 ? paramToken.Substring(0, colon) : paramToken;
728-
}
729-
730731
// ---------------------------------------------------------------- args
731732

732733
private readonly struct ArgResult
@@ -800,14 +801,15 @@ private static ArgResult ExtractArgsAndRedirects(
800801
pendingNativeFlag = null;
801802

802803
var raw = t.Value;
803-
var colon = raw.IndexOf(':');
804-
var paramName = colon > 0 ? raw.Substring(0, colon) : raw;
805-
var colonValue = colon > 0 ? raw.Substring(colon + 1) : null;
806-
807-
args.Add(new Arg { Raw = paramName, Kind = ArgKind.Literal, IsPath = false });
808804

809805
if (cmdletStyle)
810806
{
807+
var colon = raw.IndexOf(':');
808+
var paramName = colon > 0 ? raw.Substring(0, colon) : raw;
809+
var colonValue = colon > 0 ? raw.Substring(colon + 1) : null;
810+
811+
args.Add(new Arg { Raw = paramName, Kind = ArgKind.Literal, IsPath = false });
812+
811813
if (colonValue is not null)
812814
{
813815
// Colon form always binds (§6.5.3 rule 1).
@@ -821,13 +823,30 @@ private static ArgResult ExtractArgsAndRedirects(
821823
}
822824
else
823825
{
824-
// Native flag-with-value via the shared bash table (§7.3).
825826
var verbKey = verb.VerbTokens.Count > 0 ? verb.VerbTokens[0] : string.Empty;
826-
if (colonValue is null
827+
828+
// Native --flag=value follows Bash exactly: surface the
829+
// flag and value separately, and classify a curated
830+
// flag's value through the shared per-verb table.
831+
if (TrySplitNativeEqualsFlag(raw, out var flagPart, out var valuePart))
832+
{
833+
args.Add(new Arg { Raw = flagPart, Kind = ArgKind.Literal, IsPath = false });
834+
var valueIsPath = BashPerVerbRules.ValueOfFlagIsPath(verbKey, flagPart);
835+
args.Add(ResolveValue(
836+
valuePart, valueIsPath, options, workingDirectoryUnknown, false));
837+
}
838+
else
839+
{
840+
// A colon has no native binding meaning. Preserve the
841+
// complete option rather than dropping its tail.
842+
args.Add(new Arg { Raw = raw, Kind = ArgKind.Literal, IsPath = false });
843+
}
844+
845+
if (raw.IndexOf('=') < 0
827846
&& BashVerbs.FlagsWithValue.TryGetValue(verbKey, out var flags)
828-
&& flags.Contains(paramName))
847+
&& flags.Contains(raw))
829848
{
830-
pendingNativeFlag = paramName;
849+
pendingNativeFlag = raw;
831850
}
832851
}
833852

@@ -889,6 +908,29 @@ private static ArgResult ExtractArgsAndRedirects(
889908
return new ArgResult(args, redirects, null);
890909
}
891910

911+
private static bool TrySplitNativeEqualsFlag(
912+
string raw, out string flagPart, out string valuePart)
913+
{
914+
if (raw.Length < 2 || raw[0] != '-')
915+
{
916+
flagPart = "";
917+
valuePart = "";
918+
return false;
919+
}
920+
921+
var equals = raw.IndexOf('=');
922+
if (equals <= 0 || equals == raw.Length - 1)
923+
{
924+
flagPart = "";
925+
valuePart = "";
926+
return false;
927+
}
928+
929+
flagPart = raw.Substring(0, equals);
930+
valuePart = raw.Substring(equals + 1);
931+
return true;
932+
}
933+
892934
private static Arg ResolveValueToken(
893935
string raw, string logicalValue, bool treatAsPath,
894936
PwshParserOptions options, bool workingDirectoryUnknown, bool isLiteralBytes)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "Native git worktree equals",
3+
"input": "git --work-tree=../test add somefile",
4+
"expected": {
5+
"isUnparseable": false,
6+
"clauses": [
7+
{
8+
"operator": "None",
9+
"verb": [
10+
"git"
11+
],
12+
"args": [
13+
{
14+
"raw": "--work-tree",
15+
"kind": "Literal",
16+
"isPath": false
17+
},
18+
{
19+
"raw": "../test",
20+
"kind": "Literal",
21+
"isPath": true,
22+
"resolved": "C:/test"
23+
},
24+
{
25+
"raw": "add",
26+
"kind": "Literal",
27+
"isPath": false
28+
},
29+
{
30+
"raw": "somefile",
31+
"kind": "Literal",
32+
"isPath": false
33+
}
34+
],
35+
"redirects": []
36+
}
37+
]
38+
},
39+
"notes": "Issue #52: native --flag=value stays atomic, then splits into flag and path value."
40+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "Native git worktree spaced",
3+
"input": "git --work-tree repo status",
4+
"expected": {
5+
"isUnparseable": false,
6+
"clauses": [
7+
{
8+
"operator": "None",
9+
"verb": [
10+
"git",
11+
"status"
12+
],
13+
"args": [
14+
{
15+
"raw": "--work-tree",
16+
"kind": "Literal",
17+
"isPath": false
18+
},
19+
{
20+
"raw": "repo",
21+
"kind": "Literal",
22+
"isPath": true,
23+
"resolved": "C:/work/repo"
24+
}
25+
],
26+
"redirects": []
27+
}
28+
]
29+
},
30+
"notes": "Issue #52: a hyphenated curated flag consumes and path-classifies its spaced value."
31+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "Native colon option",
3+
"input": "git --option:value status",
4+
"expected": {
5+
"isUnparseable": false,
6+
"clauses": [
7+
{
8+
"operator": "None",
9+
"verb": [
10+
"git"
11+
],
12+
"args": [
13+
{
14+
"raw": "--option:value",
15+
"kind": "Literal",
16+
"isPath": false
17+
},
18+
{
19+
"raw": "status",
20+
"kind": "Literal",
21+
"isPath": false
22+
}
23+
],
24+
"redirects": []
25+
}
26+
]
27+
},
28+
"notes": "Native colon options are preserved verbatim; colon binding is cmdlet-only."
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "Bind hyphenated colon",
3+
"input": "Get-Thing -Name-Part:value",
4+
"expected": {
5+
"isUnparseable": false,
6+
"clauses": [
7+
{
8+
"operator": "None",
9+
"verb": [
10+
"Get-Thing"
11+
],
12+
"args": [
13+
{
14+
"raw": "-Name-Part",
15+
"kind": "Literal",
16+
"isPath": false
17+
},
18+
{
19+
"raw": "value",
20+
"kind": "Literal",
21+
"isPath": false
22+
}
23+
],
24+
"redirects": []
25+
}
26+
]
27+
},
28+
"notes": "A hyphenated cmdlet parameter remains one token and retains colon binding."
29+
}

0 commit comments

Comments
 (0)