Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,16 @@ priorities.
The PowerShell manifest owns all 422 entries and round-trips them exactly,
including case-specific isolated-state inputs. Explicit false/null
assertions remain opt-in and generator-preserved.
- [x] Promote the remaining 21 stable Bash design cases into the executable
corpus with complete compatibility, syntax, occurrence, value, ancestry,
redirect, and completeness assertions. Nine compatibility-only entries
now carry the v0.3 projections and entries 281-292 cover the inputs that
had no exact executable-corpus case. The three Bash future-scope design
cases remain non-gating. Promotion also reconciled the unquoted wildcard
redirect story with the fail-closed completeness contract, publishes
sparse exact/unknown effective-value overlays, and pins quoted, escaped,
and continued tilde-prefix behavior against Bash. The PowerShell promotion
half keeps OpenSpec task 1.10 open.
- [x] Deliver the first Bash `$()` substitution slice for supported
simple-command arguments and redirect targets. Direct tests and corpus
entries pin multiple and nested ordering, exact ancestry/spans, isolated
Expand Down
9 changes: 8 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,11 @@ occurrence. Bash `HereString` data uses `Target`, includes the shell's trailing
newline in an exact value, and is not path-relevant. PowerShell here-strings
remain ordinary value tokens rather than redirect operations.

A Bash file redirect whose expansion cannot prove exactly one target has an
`Unknown` target and `IsComplete=false`; its containing command occurrence is
also incomplete. In particular, an unquoted wildcard target is not completed
by enumerating the parser process's filesystem.

The public records define an in-memory typed API, not a stable polymorphic JSON
wire format. Their generated equality, hashing, and `ToString()` behavior is
part of the normal record shape. Consumers that persist parser results own a
Expand Down Expand Up @@ -1859,7 +1864,9 @@ a normalized absolute path. Resolution order:
stays literal — `$HOME` is not expanded inside single quotes.

1. **Tilde expansion.** `~` → `BashParserOptions.HomeDirectory`.
`~/foo` → `<home>/foo`. `~user` not supported → `DynamicSkip`.
`~/foo` → `<home>/foo`. The complete tilde prefix must be unquoted;
quoted or escaped slash spellings remain literal, while backslash-newline
is removed before this test. `~user` not supported → `DynamicSkip`.

2. **Env-var substitution.** `$VAR` and `${VAR}` are **not expanded**
even if the value is in `Environment`. We treat any env var reference
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ retain their existing meanings.
- **THEN** the compatibility argument is a literal path resolved as `<cwd>/$HOME`
- **THEN** it is not `DynamicSkip` and is not resolved as the configured home directory

#### Scenario: Bash tilde expansion retains prefix provenance
- **WHEN** Bash parses `~/x` or a backslash-newline continuation between `~` and `/x`
- **THEN** the unquoted tilde prefix expands from the configured home directory
- **WHEN** the slash or an empty intervening fragment is quoted or escaped
- **THEN** the decoded `~/x` remains a literal path resolved under the configured cwd

#### Scenario: PowerShell escaped variable is a literal path component
- **WHEN** PowerShell parses ``Get-Content `$HOME`` with an exact working directory
- **THEN** the shell value is the literal `$HOME`
Expand Down Expand Up @@ -123,6 +129,7 @@ retain their existing meanings.
#### Scenario: Bash redirect wildcard cardinality is quote-sensitive
- **WHEN** Bash parses unquoted `> *.txt`
- **THEN** the target is unknown without filesystem enumeration because expansion may produce zero, one, or multiple paths
- **THEN** the redirect and containing command occurrence remain incomplete
- **WHEN** Bash parses quoted `> "*.txt"`
- **THEN** the target is the exact literal filename `*.txt`

Expand Down
11 changes: 10 additions & 1 deletion src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -573,11 +573,20 @@ private static int ReadSingleQuoted(
// src[i] = closing '
// Strip the delimiters from the value per SPEC §5.
var inner = src.Slice(start + 1, i - start - 1).ToString();
var boundary = new ShellValueBuilder();
boundary.AppendBoundary(start + 1);
if (inner.Length > 0)
{
boundary.AppendLiteral(inner, start + 1, i - start - 1);
}

var resolverValue = boundary.Build();

tokens.Add(new BashToken(
BashTokenKind.QuotedString, inner, null, start, (i - start) + 1, null)
{
IsSingleQuoted = true,
ResolverValue = ShellValue.Literal(inner, start + 1, i - start - 1),
ResolverValue = resolverValue,
});
return i + 1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
using ShellSyntaxTree.Internal.Resolving;

namespace ShellSyntaxTree.Internal.Bash.Parsing;
Expand Down Expand Up @@ -467,11 +468,27 @@ private void RecordEffectiveArguments(
var evaluator = input.Bindings;
foreach (var provenance in sourceFacts.ValueProvenance)
{
if (!evaluator.TryAnalyzeEffectiveValue(provenance.Value, out var domain))
var hasStateDependentValue = evaluator.TryAnalyzeEffectiveValue(
provenance.Value,
out var domain);
if (!hasStateDependentValue &&
!RequiresIndependentEffectiveValue(simple.Clause, provenance))
{
continue;
}

if (!hasStateDependentValue)
{
if (TryAnalyzeParserKnownValue(provenance.Value, out var knownValue))
{
domain = knownValue;
}
else
{
domain = evaluator.AnalyzeWordForTransfer(provenance.Value);
}
}

accumulated ??= GetEffectiveArguments(simple.Clause);
if (accumulated.TryGetValue(provenance.ClauseElementIndex, out var prior))
{
Expand All @@ -485,6 +502,156 @@ private void RecordEffectiveArguments(
}
}

private static bool RequiresIndependentEffectiveValue(
Clause clause,
ShellValueElementProvenance provenance)
{
if (provenance.ClauseElementIndex < 0 ||
provenance.ClauseElementIndex >= clause.Elements.Count)
{
return false;
}

var nonEmptyLiteralFragments = 0;
var hasLiteralLexicalTransform = false;
foreach (var fragment in provenance.Value.Fragments)
{
if (fragment.Kind != ShellValueFragmentKind.Literal)
{
return true;
}

if (fragment.Value.Length == 0)
{
continue;
}

nonEmptyLiteralFragments++;
if (fragment.SourceLength != fragment.Value.Length)
{
hasLiteralLexicalTransform = true;
}
}

// Clause.Elements already carries ordinary authored literals. Keep the
// overlay for values whose shell decoding or shell-specific path
// spelling gives a policy consumer additional information.
var element = clause.Elements[provenance.ClauseElementIndex];
if ((element.IsPath || element.IsFlag) &&
(hasLiteralLexicalTransform || nonEmptyLiteralFragments > 1))
{
return true;
}

return element.IsPath && HasProviderQualifier(provenance.Value.Decoded);
}

private bool TryAnalyzeParserKnownValue(
ShellValue value,
out ShellValueDomain domain)
{
var homeDirectory = BashResolver.GetHomeDirectory(_options);
var composed = new StringBuilder(value.Decoded.Length);
for (var fragmentIndex = 0;
fragmentIndex < value.Fragments.Count;
fragmentIndex++)
{
var fragment = value.Fragments[fragmentIndex];
if (fragment.Kind == ShellValueFragmentKind.Literal)
{
composed.Append(fragment.Value);
continue;
}

if (fragment.Kind != ShellValueFragmentKind.Expansion ||
fragment.Expansion is not ShellExpansionReference expansion)
{
domain = ShellValueDomain.Unknown;
return false;
}

if (expansion.Kind == ShellExpansionKind.Glob &&
(fragment.AllowedTransforms & ShellLexicalTransform.Glob) == 0)
{
composed.Append(fragment.Value);
continue;
}

if (expansion.Kind == ShellExpansionKind.Tilde)
{
var tildeKind = BashResolver.ClassifyTildeExpansion(value, fragmentIndex);
if (tildeKind == BashTildeExpansionKind.Literal)
{
composed.Append(fragment.Value);
continue;
}

if (tildeKind == BashTildeExpansionKind.Unknown ||
homeDirectory.Length == 0)
{
domain = ShellValueDomain.Unknown;
return false;
}

composed.Append(homeDirectory);
continue;
}

if (expansion.Kind != ShellExpansionKind.Variable ||
!string.Equals(expansion.Name, "HOME", StringComparison.Ordinal) ||
fragment.Cardinality != ShellValueCardinality.ExactlyOne ||
(fragment.AllowedTransforms & ShellLexicalTransform.Variable) == 0 ||
homeDirectory.Length == 0 ||
((fragment.AllowedTransforms & ShellLexicalTransform.FieldSplit) != 0 &&
ContainsFieldSplitOrGlobCharacter(homeDirectory)))
{
domain = ShellValueDomain.Unknown;
return false;
}

composed.Append(homeDirectory);
}

domain = new ShellValueDomain
{
Kind = ShellValueDomainKind.Exact,
Values = new[] { composed.ToString() },
};
return true;
}

private static bool ContainsFieldSplitOrGlobCharacter(string value)
{
foreach (var character in value)
{
if (char.IsWhiteSpace(character) || character is '*' or '?' or '[')
{
return true;
}
}

return false;
}

private static bool HasProviderQualifier(string value)
{
var separator = value.IndexOf("::", StringComparison.Ordinal);
if (separator <= 0)
{
return false;
}

for (var index = 0; index < separator; index++)
{
if (!char.IsLetterOrDigit(value[index]) && value[index] != '-')
{
return false;
}
}

return true;
}

private void RecordUnvisitedBindingArguments(
ShellBlockSyntax block,
string bindingName)
Expand Down
79 changes: 70 additions & 9 deletions src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@

namespace ShellSyntaxTree.Internal.Resolving;

internal enum BashTildeExpansionKind
{
Unknown,
Literal,
Home,
}

/// <summary>
/// Path-token resolver for the bash parser. Implements SPEC §8 — tilde
/// expansion, the lone <c>$HOME</c> expansion, <c>filesystem::</c> prefix
Expand Down Expand Up @@ -295,25 +302,21 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
break;

case ShellExpansionKind.Tilde:
// An empty quoted fragment before '~' is still an authored
// word prefix and suppresses Bash tilde expansion.
if (fragmentIndex != 0
|| (fragment.AllowedTransforms & ShellLexicalTransform.Tilde) == 0)
var tildeKind = ClassifyTildeExpansion(value, fragmentIndex);
if (tildeKind == BashTildeExpansionKind.Literal)
{
composed.Append(fragment.Value);
break;
}

if (value.Decoded.Length > 1
&& value.Decoded[1] != '/'
&& value.Decoded[1] != '\\')
if (tildeKind == BashTildeExpansionKind.Unknown)
{
return treatAsPath
? (ArgKind.DynamicSkip, null, false)
: (ArgKind.Tilde, null, false);
}

composed.Append(GetHomeDirectory(options).TrimEnd('/', '\\'));
composed.Append(GetHomeDirectory(options));
hadHomeExpansion = true;
break;

Expand Down Expand Up @@ -439,9 +442,67 @@ internal static bool LooksLikePath(string token)
return false;
}

internal static BashTildeExpansionKind ClassifyTildeExpansion(
ShellValue value,
int fragmentIndex)
{
var fragment = value.Fragments[fragmentIndex];
if (fragmentIndex != 0 ||
(fragment.AllowedTransforms & ShellLexicalTransform.Tilde) == 0)
{
return BashTildeExpansionKind.Literal;
}

if (value.Decoded.Length == 1)
{
return value.Fragments.Count == 1
? BashTildeExpansionKind.Home
: BashTildeExpansionKind.Literal;
}

if (value.Decoded[1] != '/')
{
if (value.Fragments.Count > 1)
{
var prefix = value.Fragments[1];
var hasQuoteBoundary = prefix.Kind == ShellValueFragmentKind.Literal &&
prefix.Value.Length == 0;
var hasEscapedPrefix = prefix.Kind == ShellValueFragmentKind.Literal &&
prefix.Value.Length > 0 &&
prefix.SourceLength is not null &&
prefix.SourceLength != prefix.Value.Length;
if (hasQuoteBoundary || hasEscapedPrefix)
{
return BashTildeExpansionKind.Literal;
}
}

return BashTildeExpansionKind.Unknown;
}

if (value.Fragments.Count <= 1 ||
fragment.SourceStart is null ||
fragment.SourceLength is null)
{
return BashTildeExpansionKind.Unknown;
}

var delimiter = value.Fragments[1];
// A source gap without a quote boundary is a removed line
// continuation. Bash removes it before testing the unquoted slash.
var isUnquotedSlash = delimiter.Kind == ShellValueFragmentKind.Literal &&
delimiter.Value.Length > 0 &&
delimiter.Value[0] == '/' &&
delimiter.SourceStart >= fragment.SourceStart + fragment.SourceLength &&
delimiter.SourceLength == delimiter.Value.Length;
return isUnquotedSlash
? BashTildeExpansionKind.Home
: BashTildeExpansionKind.Literal;
}

// ---------------------------------------------------------------- helpers

private static string GetHomeDirectory(BashParserOptions options)
internal static string GetHomeDirectory(BashParserOptions options)
{
if (!string.IsNullOrEmpty(options.HomeDirectory))
{
Expand Down
Loading