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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<VersionPrefix>0.1.1</VersionPrefix>
<VersionPrefix>0.1.2</VersionPrefix>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
Expand Down
33 changes: 33 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
#### 0.1.2-alpha May 11th 2026 ####

Two parser correctness fixes. Public API unchanged.

**Fixed**

- **Single-quoted strings are now literal per SPEC §5 (B2).** Previously
`echo '$HOME'` produced `Kind=Tilde` because the resolver substituted
`$HOME` uniformly regardless of quote style. Now the lexer marks
single-quoted `QuotedString` tokens with the internal `IsSingleQuoted`
flag, and the resolver bypasses tilde / `$HOME` / `$VAR` / glob /
`filesystem::` handling for them. `echo '$HOME'` stays `Kind=Literal`,
`Resolved=null`; `cat '/etc/passwd'` still resolves a path. Matches
bash semantics.
- **`LooksLikePath` no longer false-positives on a lone trailing
backslash (B3).** A double-quoted token like `"foo\\"` lexes to
Value `foo\`; the trailing `\` is an escape-collapse artifact, not a
meaningful path signal. The heuristic now requires a backslash at a
non-trailing position. Forward-slash behavior is unchanged — `dir/`
still classifies as a path (trailing `/` is a meaningful bash
directory hint).

**Behavior notes**

- Public API surface is unchanged (no `PublicApiSnapshotTests` delta).
- SPEC.md §8: new "Step 0: Single-quoted bypass" preamble; LooksLikePath
heuristic updated to call out the trailing-backslash carve-out.
- Corpus entries 104 (`echo 'literal $HOME'`) and 109 (`echo "trailing
backslash\\"`) updated to the corrected outputs. Three new entries
(119–121) pin the regression guards: single-quoted absolute paths
still resolve, `cd dir/` still classifies as a path, single-quoted
`$VAR` stays literal under `rm`.

#### 0.1.1-alpha May 11th 2026 ####

Bug fix release for v0.1.0-alpha consumers.
Expand Down
15 changes: 14 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,14 @@ path, then the verb chain continues with `log`.
For each Arg with potential path content, the resolver attempts to produce
a normalized absolute path. Resolution order:

0. **Single-quoted bypass.** If the source token came from a single-quoted
string (per §5: bytes are preserved literally — no escape processing,
no variable expansion), the resolver skips steps 1–5 entirely. Kind is
`Literal`; `IsPath` is `true` and `Resolved` is set only when the slot
is a path AND `TryResolveAbsolutePath` on the raw bytes succeeds. So
`cat '/etc/passwd'` still produces a resolved path, but `echo '$HOME'`
stays literal — `$HOME` is not expanded inside single quotes.

1. **Tilde expansion.** `~` → `BashParserOptions.HomeDirectory`.
`~/foo` → `<home>/foo`. `~user` not supported → `DynamicSkip`.

Expand Down Expand Up @@ -675,12 +683,17 @@ LooksLikePath(token) =
|| token starts with '\\' or '<letter>:' (Windows absolute)
|| token starts with './' or '../' (Unix relative)
|| token starts with '~' (Tilde)
|| token contains '/' or '\\' anywhere
|| token contains '/' anywhere
|| token contains '\\' at a NON-TRAILING position
|| token ends with a known file extension (.json, .md, .txt, .conf, ...)
|| token is in the args of a FileVerb at a position the per-verb rule
marks as a path
```

A lone trailing `\\` is excluded because it commonly appears as a
double-quote escape-collapse artifact (`"foo\\"` lexes to Value `foo\\`)
and is not a meaningful path signal on its own.

The per-verb rule wins when present; the heuristic is the fallback.

---
Expand Down
3 changes: 2 additions & 1 deletion src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ private static int ReadSingleQuoted(
// Strip the delimiters from the value per SPEC §5.
var inner = src.Slice(start + 1, i - start - 1).ToString();
tokens.Add(new BashToken(
BashTokenKind.QuotedString, inner, null, start, (i - start) + 1, null));
BashTokenKind.QuotedString, inner, null, start, (i - start) + 1, null)
{ IsSingleQuoted = true });
return i + 1;
}

Expand Down
14 changes: 13 additions & 1 deletion src/ShellSyntaxTree/Internal/Bash/Lexing/BashToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,16 @@ internal readonly record struct BashToken(
string? OperatorText,
int SourceStart,
int SourceLength,
string? UnparseableReason);
string? UnparseableReason)
{
/// <summary>
/// True when this is a single-quoted <see cref="BashTokenKind.QuotedString"/>.
/// Bash semantics: contents are literal bytes — no variable expansion,
/// no glob handling, no <c>filesystem::</c> stripping. The resolver
/// consults this flag to bypass meta-character processing on the
/// token's <see cref="Value"/>. Default <c>false</c> for every other
/// kind and for double-quoted strings (which allow <c>$HOME</c>
/// substitution per SPEC §8 step 3).
/// </summary>
public bool IsSingleQuoted { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -1190,7 +1190,11 @@ private static void ExtractRedirectsAndArgs(
positionalIndex++;
}

var (kind, resolved, isPath) = BashResolver.Resolve(t.Value, treatAsPath, options, workingDirectoryUnknown);
// Single-quoted tokens carry literal bytes per SPEC §5
// — bypass tilde / $HOME / $VAR / glob handling so
// `'$HOME'` doesn't expand.
var (kind, resolved, isPath) = BashResolver.Resolve(
t.Value, treatAsPath, options, workingDirectoryUnknown, t.IsSingleQuoted);
argList.Add(new Arg
{
Raw = sourceRaw,
Expand Down
52 changes: 47 additions & 5 deletions src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ internal static class BashResolver
/// </returns>
internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
string raw, bool treatAsPath, BashParserOptions options) =>
Resolve(raw, treatAsPath, options, workingDirectoryUnknown: false);
Resolve(raw, treatAsPath, options, workingDirectoryUnknown: false, isLiteralBytes: false);

internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
string raw,
bool treatAsPath,
BashParserOptions options,
bool workingDirectoryUnknown) =>
Resolve(raw, treatAsPath, options, workingDirectoryUnknown, isLiteralBytes: false);

/// <summary>
/// Internal extended-resolver entry point. PR 5 adds the
Expand All @@ -78,13 +85,18 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
/// preceding clause did <c>cd $VAR</c>, we statically don't know the
/// working directory of subsequent clauses, so relative-path args
/// resolve to <c>DynamicSkip</c> instead of falling back to the
/// daemon cwd.
/// daemon cwd. v0.1.2 adds <paramref name="isLiteralBytes"/> so the
/// parser can tell the resolver "this token came from a single-quoted
/// string — treat its bytes as opaque literals per SPEC §5"; that
/// suppresses tilde / <c>$HOME</c> / <c>$VAR</c> / glob /
/// <c>filesystem::</c> handling so <c>'$HOME'</c> no longer expands.
/// </summary>
internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
string raw,
bool treatAsPath,
BashParserOptions options,
bool workingDirectoryUnknown)
bool workingDirectoryUnknown,
bool isLiteralBytes)
{
if (raw is null)
{
Expand All @@ -93,6 +105,24 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
return (ArgKind.Literal, null, false);
}

if (isLiteralBytes)
{
// Single-quoted token per SPEC §5: contents are literal bytes.
// No tilde / $HOME / $VAR / glob / filesystem:: handling. If
// this slot is a path AND the literal value happens to look
// like one (e.g. `cat '/etc/passwd'`), still normalize it; the
// user typed an absolute path inside single quotes.
if (!treatAsPath)
{
return (ArgKind.Literal, null, false);
}

var resolvedLiteral = TryResolveAbsolutePath(raw, options, workingDirectoryUnknown);
return resolvedLiteral is null
? (ArgKind.DynamicSkip, null, false)
: (ArgKind.Literal, resolvedLiteral, true);
}

// Step 1: filesystem::/path prefix stripping. Some agent tools emit
// `filesystem::/path/to/file` (e.g. MCP filesystem servers). Strip
// the prefix and continue with the remainder. We *do not* set a
Expand Down Expand Up @@ -242,8 +272,20 @@ internal static bool LooksLikePath(string token)
return true;
}

// Any directory separator.
if (token.IndexOf('/') >= 0 || token.IndexOf('\\') >= 0)
// Forward slash anywhere counts (trailing `/` is a meaningful
// bash directory hint, e.g. `cd dir/`).
if (token.IndexOf('/') >= 0)
{
return true;
}

// Backslash counts when it appears at a non-trailing position. A
// lone trailing `\` is typically a double-quote escape-collapse
// artifact (e.g. lexed `"foo\\"` → Value `foo\`), not a real
// path signal — accepting it would falsely classify
// `echo "trailing\\"` as a path.
var backslash = token.IndexOf('\\');
if (backslash >= 0 && backslash < token.Length - 1)
{
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "Quote: echo 'literal $HOME' (single quotes — but resolver still substitutes $HOME)",
"name": "Quote: echo 'literal $HOME' (single quotes — literal, no expansion)",
"input": "echo 'literal $HOME'",
"expected": {
"isUnparseable": false,
Expand All @@ -8,13 +8,13 @@
"operator": "None",
"verb": ["echo"],
"args": [
{ "raw": "'literal $HOME'", "kind": "Tilde", "isPath": false, "resolved": "__NULL__" }
{ "raw": "'literal $HOME'", "kind": "Literal", "isPath": false, "resolved": "__NULL__" }
],
"redirects": [],
"isSubshell": false,
"isBashCWrapped": false
}
]
},
"notes": "Single-quote lexer preservation (SPEC §5) keeps '$HOME' as literal bytes in the Value, but BashResolver runs uniformly on the Value and substitutes $HOME → Kind=Tilde. This diverges from real bash (which wouldn't expand inside single quotes). Documented v0.1 behavior; consumer gets Kind=Tilde, no IsPath signal."
"notes": "v0.1.2 / B2: single-quoted tokens are literal bytes per SPEC §5 — the resolver bypasses tilde / $HOME / $VAR / glob / filesystem:: handling when BashToken.IsSingleQuoted is true. Kind stays Literal; $HOME stays inside the Raw verbatim and nothing is resolved."
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "Quote: echo \"trailing backslash\\\\\" (escaped backslash inside double-quote)",
"name": "Quote: echo \"trailing backslash\\\\\" (trailing-only backslash isn't a path)",
"input": "echo \"trailing backslash\\\\\"",
"expected": {
"isUnparseable": false,
Expand All @@ -8,13 +8,13 @@
"operator": "None",
"verb": ["echo"],
"args": [
{ "raw": "\"trailing backslash\\\\\"", "kind": "Literal", "isPath": true, "resolved": "/work/trailing backslash" }
{ "raw": "\"trailing backslash\\\\\"", "kind": "Literal", "isPath": false, "resolved": "__NULL__" }
],
"redirects": [],
"isSubshell": false,
"isBashCWrapped": false
}
]
},
"notes": "Inside double quotes \\\\ collapses to a single literal \\. The post-escape Value is `trailing backslash\\` — which contains a directory-separator character (backslash) so LooksLikePath returns true even though echo isn't a FileVerb. The resolver normalizes the trailing \\ away during path normalization → /work/trailing backslash. Documented v0.1 behavior; SPEC clarification candidate: should LooksLikePath ignore trailing-only backslashes?"
"notes": "v0.1.2 / B3: inside double quotes \\\\ collapses to a single literal \\. The post-escape Value is `trailing backslash\\` — but LooksLikePath now requires a backslash at a non-trailing position, so the lone trailing artifact no longer falsely classifies this as a path. Kind=Literal, IsPath=false, Resolved=null."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "Quote: cat '/etc/passwd' (single-quoted absolute path still resolves)",
"input": "cat '/etc/passwd'",
"expected": {
"isUnparseable": false,
"clauses": [
{
"operator": "None",
"verb": ["cat"],
"args": [
{ "raw": "'/etc/passwd'", "kind": "Literal", "isPath": true, "resolved": "/etc/passwd" }
],
"redirects": [],
"isSubshell": false,
"isBashCWrapped": false
}
]
},
"notes": "v0.1.2 / B2: single-quoting suppresses meta-character expansion (tilde / $VAR / glob) but a single-quoted absolute path still resolves through the path-arg slot. cat is a FileVerb so the positional is a path slot; the literal bytes happen to be a rooted absolute path."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "Path: cd dir/ (trailing forward slash still classifies as path)",
"input": "cd dir/",
"expected": {
"isUnparseable": false,
"clauses": [
{
"operator": "None",
"verb": ["cd"],
"args": [
{ "raw": "dir/", "kind": "Literal", "isPath": true, "resolved": "/work/dir" }
],
"redirects": [],
"isSubshell": false,
"isBashCWrapped": false
}
]
},
"notes": "v0.1.2 / B3 regression guard: the LooksLikePath heuristic now treats a lone trailing backslash as a non-signal, but a trailing forward slash is a meaningful bash directory hint (`cd dir/` is common). Confirm `dir/` still resolves as a path."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "Quote: rm '$VAR/file.txt' (single-quoted env var stays literal)",
"input": "rm '$VAR/file.txt'",
"expected": {
"isUnparseable": false,
"clauses": [
{
"operator": "None",
"verb": ["rm"],
"args": [
{ "raw": "'$VAR/file.txt'", "kind": "Literal", "isPath": true, "resolved": "/work/$VAR/file.txt" }
],
"redirects": [],
"isSubshell": false,
"isBashCWrapped": false
}
]
},
"notes": "v0.1.2 / B2: inside single quotes the `$VAR` token is literal — the resolver does NOT trigger the DynamicSkip path for unresolved env vars. The literal bytes `$VAR/file.txt` resolve under cwd as `/work/$VAR/file.txt` and Kind stays Literal. This matches bash semantics: `rm '$VAR/file.txt'` deletes the file literally named `$VAR/file.txt`."
}
Loading