Skip to content

Commit 0a2ffec

Browse files
Merge pull request #81 from Aaronontheweb/feature/native-fragment-classifier
Refactor native argument fragment classification
2 parents 38e1fc7 + 14ecb7f commit 0a2ffec

9 files changed

Lines changed: 573 additions & 162 deletions

File tree

IMPLEMENTATION_PLAN.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,19 @@ priorities.
194194
Executable corpus cases preserve the corrected v0.2 compatibility
195195
projection; unknown facts remain `DynamicSkip` or unparseable, while
196196
completely proved mixed fragments resolve exactly.
197-
- [ ] Implement [issue #69](https://github.com/Aaronontheweb/ShellSyntaxTree/issues/69)
198-
against the corrected fragment contract. Preserve raw, decoded, and span
199-
facts plus unaffected classifications; explicitly document only
200-
shell-oracle-proved compatibility corrections to false exact, `Glob`,
201-
`Tilde`, provider, or path claims and avoidable `DynamicSkip` results.
197+
- [x] Implement [issue #69](https://github.com/Aaronontheweb/ShellSyntaxTree/issues/69)
198+
against the corrected fragment contract. One shell-neutral classifier
199+
now aggregates the complete raw span, decoded value, next-token index,
200+
and ordered `ShellValue` provenance supplied by explicit Bash and
201+
PowerShell adapters. It distinguishes literal-only, typed expansion,
202+
and opaque/computed runs without rescanning decoded text; missing lexer
203+
provenance fails closed as `Opaque` in both adapters. Direct adapter
204+
tests pin spans, maximal consumption, expansion identity, opaque cause,
205+
and fallback behavior. The full Bash and PowerShell corpora prove the
206+
extraction leaves raw, decoded, span, path, and `DynamicSkip` results
207+
unchanged. It introduces no new compatibility correction; the
208+
shell-oracle-proved corrections remain the ones documented in the
209+
preceding provenance item.
202210
- [ ] Add the structural and command-occurrence projections for the existing
203211
grammar before enabling any control-flow construct.
204212
- [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two

openspec/changes/v0-3-structured-shell-analysis/tasks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515

1616
- [x] 2.1 Implement shell-specific lexical fragment provenance that distinguishes literal, typed recognized-expansion, and opaque resolver input; retains transform eligibility, expansion identity, cardinality, and opaque cause; aggregates complete argument and redirect-target fragment runs; and passes explicit Bash-argument, Bash-redirect, PowerShell-native, cmdlet-Path, cmdlet-LiteralPath, and PowerShell-redirect resolver context without changing the public API.
1717
- [x] 2.2 Add paired Bash and PowerShell shell-oracle regressions for standalone escapes, adjacent escaped values, all-static mixed quoting, within-token escapes, genuine literal-plus-expandable values, adjacent and wildcard redirect targets, runtime special/positional/numeric/Unicode variables, incomplete and escaped-literal braced interpolation, Bash provider-looking literals, and PowerShell native-versus-cmdlet, Path-versus-LiteralPath, and redirect-context divergence; require exact compatibility path results when every fragment, binding fact, and required resolver fact is exact, otherwise fail closed.
18-
- [ ] 2.3 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters that preserve the new provenance.
19-
- [ ] 2.4 Prove raw spelling, decoded logical values, source spans, and unaffected classifications remain unchanged; document each oracle-proved false exact, `Glob`, `Tilde`, provider, path, or avoidable `DynamicSkip` compatibility correction.
18+
- [x] 2.3 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters that preserve the new provenance.
19+
- [x] 2.4 Prove raw spelling, decoded logical values, source spans, and unaffected classifications remain unchanged; document each oracle-proved false exact, `Glob`, `Tilde`, provider, path, or avoidable `DynamicSkip` compatibility correction.
2020
- [ ] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
2121
- [ ] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation.
2222

src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs

Lines changed: 49 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,8 @@ private static List<BashToken> FilterSignificant(IReadOnlyList<BashToken> tokens
514514
if (filtered.Count > 0
515515
&& IsNativeArgumentFragment(filtered[filtered.Count - 1])
516516
&& IsNativeArgumentFragment(t)
517-
&& IsAdjacent(filtered[filtered.Count - 1], t))
517+
&& IsAdjacent(filtered[filtered.Count - 1], t)
518+
&& !IsInlineNativeArgumentPrefix(filtered[filtered.Count - 1]))
518519
{
519520
var previous = filtered[filtered.Count - 1];
520521
var previousValue = previous.ResolverValue
@@ -1200,48 +1201,19 @@ private static void ExtractRedirectsAndArgs(
12001201
// `--data="@request file"` / `--data=$(generate)`.
12011202
if (NativeFlagSyntax.TrySplitEqualsPrefix(
12021203
t.Value, out var adjacentFlagPart, out var adjacentValuePrefix)
1203-
&& i + 1 < segmentTokens.Count
1204-
&& IsAdjacent(t, segmentTokens[i + 1])
1205-
&& segmentTokens[i + 1].Kind is BashTokenKind.QuotedString
1206-
or BashTokenKind.OpaqueSubstitution)
1204+
&& NativeArgumentFragmentClassifier.TryClassify(
1205+
source,
1206+
t.SourceStart,
1207+
t.SourceStart + t.SourceLength,
1208+
t.SourceStart + sourceRaw.IndexOf('=') + 1,
1209+
adjacentFlagPart + "=",
1210+
GetResolverValue(t, adjacentValuePrefix),
1211+
segmentTokens,
1212+
i + 1,
1213+
new BashNativeArgumentFragmentAdapter(),
1214+
out var fragmentClassification))
12071215
{
1208-
var valueStart = i + 1;
1209-
var valueEnd = valueStart;
1210-
var valueBuilder = new StringBuilder(adjacentValuePrefix);
1211-
var hasOpaqueFragment = false;
1212-
var allFragmentsSingleQuoted = adjacentValuePrefix.Length == 0;
1213-
var hasSingleQuotedFragment = false;
1214-
var hasNonSingleQuotedFragment = adjacentValuePrefix.Length > 0;
1215-
var hasSensitiveLiteralFragment = false;
1216-
var previousFragment = t;
1217-
while (valueEnd < segmentTokens.Count
1218-
&& IsAdjacent(previousFragment, segmentTokens[valueEnd])
1219-
&& IsNativeArgumentFragment(segmentTokens[valueEnd]))
1220-
{
1221-
var fragment = segmentTokens[valueEnd];
1222-
valueBuilder.Append(fragment.Value);
1223-
hasOpaqueFragment |= fragment.Kind == BashTokenKind.OpaqueSubstitution;
1224-
hasSingleQuotedFragment |= fragment.Kind == BashTokenKind.QuotedString
1225-
&& fragment.IsSingleQuoted;
1226-
hasNonSingleQuotedFragment |= fragment.Kind != BashTokenKind.QuotedString
1227-
|| !fragment.IsSingleQuoted;
1228-
hasSensitiveLiteralFragment |= fragment.Kind == BashTokenKind.QuotedString
1229-
&& fragment.IsSingleQuoted
1230-
&& NativeFlagSyntax.ContainsResolverSensitiveLiteralSyntax(fragment.Value);
1231-
allFragmentsSingleQuoted &= fragment.Kind == BashTokenKind.QuotedString
1232-
&& fragment.IsSingleQuoted;
1233-
previousFragment = fragment;
1234-
valueEnd++;
1235-
}
1236-
1237-
var lastValueToken = segmentTokens[valueEnd - 1];
1238-
var adjacentValue = valueBuilder.ToString();
1239-
var equalsOffset = SourceSlice(source, t).IndexOf('=');
1240-
var adjacentRawStart = t.SourceStart + equalsOffset + 1;
1241-
var adjacentRaw = source.Substring(
1242-
adjacentRawStart,
1243-
lastValueToken.SourceStart + lastValueToken.SourceLength
1244-
- adjacentRawStart);
1216+
var adjacentValue = fragmentClassification.DecodedValue;
12451217
argList.Add(new Arg
12461218
{
12471219
Raw = adjacentFlagPart,
@@ -1251,17 +1223,14 @@ private static void ExtractRedirectsAndArgs(
12511223
});
12521224

12531225
Arg valueArg;
1254-
if (hasOpaqueFragment
1255-
|| (hasSingleQuotedFragment
1256-
&& hasNonSingleQuotedFragment
1257-
&& hasSensitiveLiteralFragment)
1226+
if (fragmentClassification.HasOpaqueFragment
12581227
|| (verbKeyForFlagValuePaths is not null
12591228
&& BashPerVerbRules.ValueOfFlagIsOpaqueCommand(
12601229
verbKeyForFlagValuePaths, adjacentFlagPart)))
12611230
{
12621231
valueArg = new Arg
12631232
{
1264-
Raw = adjacentRaw,
1233+
Raw = fragmentClassification.ValueRaw,
12651234
Kind = ArgKind.DynamicSkip,
12661235
IsPath = false,
12671236
};
@@ -1276,7 +1245,7 @@ private static void ExtractRedirectsAndArgs(
12761245
adjacentValue,
12771246
out adjacentValueForResolution);
12781247
var adjacentResolverValue = GetResolverValue(
1279-
t,
1248+
fragmentClassification.ResolverValue,
12801249
adjacentValueForResolution);
12811250
var (adjacentKind, adjacentResolved, adjacentIsPath) = BashResolver.Resolve(
12821251
adjacentResolverValue,
@@ -1286,7 +1255,7 @@ private static void ExtractRedirectsAndArgs(
12861255
ShellResolutionConsumer.BashArgument);
12871256
valueArg = new Arg
12881257
{
1289-
Raw = adjacentRaw,
1258+
Raw = fragmentClassification.ValueRaw,
12901259
Resolved = adjacentResolved,
12911260
Kind = adjacentKind,
12921261
IsPath = adjacentIsPath,
@@ -1295,16 +1264,13 @@ private static void ExtractRedirectsAndArgs(
12951264

12961265
argList.Add(valueArg);
12971266
elementList.Add(CreateCombinedElement(
1298-
source,
1299-
t,
1300-
lastValueToken,
1301-
adjacentFlagPart + "=" + adjacentValue,
1267+
fragmentClassification,
13021268
precedingVerbTokenCount,
13031269
valueArg.Kind,
13041270
isFlag: true,
13051271
valueArg.IsPath,
13061272
valueArg.Resolved));
1307-
i = valueEnd;
1273+
i = fragmentClassification.NextTokenIndex;
13081274
continue;
13091275
}
13101276

@@ -1695,34 +1661,6 @@ private static ClauseElement CreateElement(
16951661
Resolved = resolved,
16961662
};
16971663

1698-
private static ClauseElement CreateCombinedElement(
1699-
string source,
1700-
BashToken first,
1701-
BashToken last,
1702-
string value,
1703-
int precedingVerbTokenCount,
1704-
ArgKind kind,
1705-
bool isFlag,
1706-
bool isPath,
1707-
string? resolved)
1708-
{
1709-
var sourceStart = first.SourceStart;
1710-
var sourceEnd = last.SourceStart + last.SourceLength;
1711-
return new ClauseElement
1712-
{
1713-
Raw = source.Substring(sourceStart, sourceEnd - sourceStart),
1714-
Value = value,
1715-
Role = ClauseElementRole.Argument,
1716-
SourceStart = sourceStart,
1717-
SourceLength = sourceEnd - sourceStart,
1718-
PrecedingVerbElementCount = precedingVerbTokenCount,
1719-
Kind = kind,
1720-
IsFlag = isFlag,
1721-
IsPath = isPath,
1722-
Resolved = resolved,
1723-
};
1724-
}
1725-
17261664
private static ClauseElement CreateRedirectElement(
17271665
string source,
17281666
BashToken redirectOperator,
@@ -1753,6 +1691,11 @@ private static ShellValue GetResolverValue(BashToken token, string logicalValue)
17531691
{
17541692
var value = token.ResolverValue
17551693
?? ShellValue.Literal(token.Value, token.SourceStart, token.SourceLength);
1694+
return GetResolverValue(value, logicalValue);
1695+
}
1696+
1697+
private static ShellValue GetResolverValue(ShellValue value, string logicalValue)
1698+
{
17561699
if (string.Equals(value.Decoded, logicalValue, StringComparison.Ordinal))
17571700
{
17581701
return value;
@@ -1768,6 +1711,26 @@ private static ShellValue GetResolverValue(BashToken token, string logicalValue)
17681711
return ShellValue.Opaque(logicalValue, ShellOpaqueCause.Unsupported);
17691712
}
17701713

1714+
private static ClauseElement CreateCombinedElement(
1715+
NativeArgumentFragmentClassification classification,
1716+
int precedingVerbTokenCount,
1717+
ArgKind kind,
1718+
bool isFlag,
1719+
bool isPath,
1720+
string? resolved) => new()
1721+
{
1722+
Raw = classification.Raw,
1723+
Value = classification.DecodedArgument,
1724+
Role = ClauseElementRole.Argument,
1725+
SourceStart = classification.SourceStart,
1726+
SourceLength = classification.SourceLength,
1727+
PrecedingVerbElementCount = precedingVerbTokenCount,
1728+
Kind = kind,
1729+
IsFlag = isFlag,
1730+
IsPath = isPath,
1731+
Resolved = resolved,
1732+
};
1733+
17711734
private static bool TrySplitInlineFlag(
17721735
BashToken token, out string flagPart, out string valuePart)
17731736
{
@@ -1799,6 +1762,10 @@ token.Kind is BashTokenKind.Word
17991762
or BashTokenKind.QuotedString
18001763
or BashTokenKind.OpaqueSubstitution;
18011764

1765+
private static bool IsInlineNativeArgumentPrefix(BashToken token) =>
1766+
token.Kind == BashTokenKind.Word
1767+
&& NativeFlagSyntax.TrySplitEqualsPrefix(token.Value, out _, out _);
1768+
18021769
private static bool IsFdDupTarget(string value)
18031770
{
18041771
// Recognized shapes (POSIX `[n]>&word` / `[n]<&word`):
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="BashNativeArgumentFragmentAdapter.cs" company="Aaron Stannard">
3+
// Copyright (C) 2026 - 2026 Aaron Stannard <https://github.com/Aaronontheweb>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
using ShellSyntaxTree.Internal.Bash.Lexing;
7+
using ShellSyntaxTree.Internal.Parsing;
8+
using ShellSyntaxTree.Internal.Resolving;
9+
10+
namespace ShellSyntaxTree.Internal.Bash.Parsing;
11+
12+
internal readonly struct BashNativeArgumentFragmentAdapter
13+
: INativeArgumentFragmentAdapter<BashToken>
14+
{
15+
public bool CanStart(BashToken token) => token.Kind is
16+
BashTokenKind.Word
17+
or BashTokenKind.QuotedString
18+
or BashTokenKind.OpaqueSubstitution;
19+
20+
public bool TryAdapt(BashToken token, out NativeArgumentFragment fragment)
21+
{
22+
if (token.Kind is not (BashTokenKind.Word
23+
or BashTokenKind.QuotedString
24+
or BashTokenKind.OpaqueSubstitution))
25+
{
26+
fragment = default;
27+
return false;
28+
}
29+
30+
var value = token.ResolverValue
31+
?? ShellValue.Opaque(
32+
token.Value,
33+
token.Kind == BashTokenKind.OpaqueSubstitution
34+
? ShellOpaqueCause.CommandSubstitution
35+
: ShellOpaqueCause.Unsupported,
36+
token.SourceStart,
37+
token.SourceLength);
38+
fragment = new NativeArgumentFragment(
39+
value,
40+
token.SourceStart,
41+
token.SourceLength);
42+
return true;
43+
}
44+
}

0 commit comments

Comments
 (0)