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
7 changes: 7 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,13 @@ priorities.
unchanged. It introduces no new compatibility correction; the
shell-oracle-proved corrections remain the ones documented in the
preceding provenance item.
- [x] Audit duplicated Bash and PowerShell path-normalization helpers. Share
only the identical string-level join and separator-normalization rules;
keep root detection, drive-relative handling, full segment
normalization, provider/PSDrive behavior, and resolver failure policy in
their shell-specific implementations. Direct boundary tests pin the
extracted helpers, while the complete resolver and corpus suites prove
the refactor leaves both compatibility projections unchanged.
- [ ] Add the structural and command-occurrence projections for the existing
grammar before enabling any control-flow construct.
- [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two
Expand Down
4 changes: 2 additions & 2 deletions openspec/changes/v0-3-structured-shell-analysis/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
- [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.
- [x] 2.3 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters that preserve the new provenance.
- [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.
- [ ] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
- [ ] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation.
- [x] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
- [x] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation.

## 3. Structural and Projection Skeleton

Expand Down
50 changes: 4 additions & 46 deletions src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
// Drop the leading `~` and join the rest (which starts with
// '/' or '\') to home.
var rest = working.Substring(2); // skip "~/" or "~\\"
working = JoinPath(home, rest);
working = ShellPathNormalization.Join(home, rest);
}

hadTilde = true;
Expand Down Expand Up @@ -446,7 +446,7 @@ private static string GetHomeDirectory(BashParserOptions options)

// Lazy fallback. SPEC §2 / §8: defaults to UserProfile. May be the
// empty string in pathological environments — callers tolerate that
// because JoinPath / Path.GetFullPath fall back accordingly.
// because downstream path joining and normalization handle it.
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
}

Expand Down Expand Up @@ -600,32 +600,6 @@ private static bool IsIdentifierContinuation(char c) =>
private static bool IsAsciiLetter(char c) =>
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');

/// <summary>
/// Combine a base directory with a relative or rooted sub-path using
/// bash semantics — forward slashes everywhere, regardless of host OS.
/// Strips a single leading separator from the sub-path so the combine
/// doesn't treat the sub-path as rooted.
/// </summary>
private static string JoinPath(string baseDir, string sub)
{
if (string.IsNullOrEmpty(sub))
{
return baseDir;
}

// Sub-paths may be rooted (e.g. `~/rest` produces `/rest` after
// tilde expansion) — strip exactly one leading separator before
// combining so we don't lose baseDir.
var s = sub;
if (s.Length > 0 && (s[0] == '/' || s[0] == '\\'))
{
s = s.Substring(1);
}

// Always forward-slash, always bash semantics.
return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/');
}

/// <summary>
/// Resolve <paramref name="token"/> to an absolute path against the
/// supplied options. Returns null on resolution failure (SPEC §8 step 6).
Expand All @@ -647,7 +621,7 @@ private static string JoinPath(string baseDir, string sub)
string combined;
if (IsRootedPath(token))
{
combined = NormalizeToForwardSlashes(token);
combined = ShellPathNormalization.NormalizeSeparators(token);
}
else if (workingDirectoryUnknown)
{
Expand All @@ -667,7 +641,7 @@ private static string JoinPath(string baseDir, string sub)
// rather than guess.
return null;
}
combined = JoinPath(wd, token);
combined = ShellPathNormalization.Join(wd, token);
}

return NormalizePath(combined);
Expand All @@ -686,22 +660,6 @@ private static string JoinPath(string baseDir, string sub)
}
}

/// <summary>
/// Normalize backslashes to forward slashes; preserve bash semantics
/// for `\\server\share` UNC paths by collapsing the leading `\\` to a
/// single `//`. (UNC paths are rare in bash but the heuristic preserves
/// them in a recognizable form for consumers.)
/// </summary>
private static string NormalizeToForwardSlashes(string token)
{
if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\')
{
// UNC: \\server\share -> //server/share
return "//" + token.Substring(2).Replace('\\', '/');
}
return token.Replace('\\', '/');
}

/// <summary>
/// Bash-style path normalization: collapse `.`/`..` segments, deduplicate
/// adjacent slashes, preserve a leading `/` (or `//` for UNC), use
Expand Down
34 changes: 5 additions & 29 deletions src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
}

var home = GetHomeDirectory(options);
working = working.Length == 1 ? home : JoinPath(home, working.Substring(2));
working = working.Length == 1
? home
: ShellPathNormalization.Join(home, working.Substring(2));
hadHomeish = true;
}

Expand Down Expand Up @@ -548,22 +550,6 @@ private static bool IsIdentifierContinuation(char c) =>
private static bool IsAsciiLetter(char c) =>
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');

private static string JoinPath(string baseDir, string sub)
{
if (string.IsNullOrEmpty(sub))
{
return baseDir;
}

var s = sub;
if (s.Length > 0 && (s[0] == '/' || s[0] == '\\'))
{
s = s.Substring(1);
}

return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/');
}

/// <summary>
/// Resolve <paramref name="token"/> to a normalized absolute path.
/// Returns null on failure (SPEC.POWERSHELL.md §8). Drive-qualified
Expand All @@ -583,7 +569,7 @@ private static string JoinPath(string baseDir, string sub)
string combined;
if (IsRootedPath(token))
{
combined = NormalizeToForwardSlashes(token);
combined = ShellPathNormalization.NormalizeSeparators(token);
}
else if (workingDirectoryUnknown)
{
Expand All @@ -597,7 +583,7 @@ private static string JoinPath(string baseDir, string sub)
return null;
}

combined = JoinPath(wd, token);
combined = ShellPathNormalization.Join(wd, token);
}

return NormalizePath(combined);
Expand All @@ -616,16 +602,6 @@ private static string JoinPath(string baseDir, string sub)
}
}

private static string NormalizeToForwardSlashes(string token)
{
if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\')
{
return "//" + token.Substring(2).Replace('\\', '/');
}

return token.Replace('\\', '/');
}

private static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path))
Expand Down
40 changes: 40 additions & 0 deletions src/ShellSyntaxTree/Internal/Resolving/ShellPathNormalization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// -----------------------------------------------------------------------
// <copyright file="ShellPathNormalization.cs" company="Aaron Stannard">
// Copyright (C) 2026 - 2026 Aaron Stannard <https://github.com/Aaronontheweb>
// </copyright>
// -----------------------------------------------------------------------
namespace ShellSyntaxTree.Internal.Resolving;

/// <summary>
/// String-only path operations whose behavior is identical for the Bash and
/// PowerShell resolvers. Root detection, drive handling, and segment
/// normalization remain shell-specific.
/// </summary>
internal static class ShellPathNormalization
{
internal static string Join(string baseDirectory, string subpath)
{
if (string.IsNullOrEmpty(subpath))
{
return baseDirectory;
}

var relative = subpath;
if (relative.Length > 0 && (relative[0] == '/' || relative[0] == '\\'))
{
relative = relative.Substring(1);
}

return baseDirectory.TrimEnd('/', '\\') + "/" + relative.Replace('\\', '/');
}

internal static string NormalizeSeparators(string path)
{
if (path.Length >= 2 && path[0] == '\\' && path[1] == '\\')
{
return "//" + path.Substring(2).Replace('\\', '/');
}

return path.Replace('\\', '/');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// -----------------------------------------------------------------------
// <copyright file="ShellPathNormalizationTests.cs" company="Aaron Stannard">
// Copyright (C) 2026 - 2026 Aaron Stannard <https://github.com/Aaronontheweb>
// </copyright>
// -----------------------------------------------------------------------
using ShellSyntaxTree.Internal.Resolving;
using Xunit;

namespace ShellSyntaxTree.Tests.Resolving;

public class ShellPathNormalizationTests
{
[Theory]
[InlineData("/base", "", "/base")]
[InlineData("/base/", "/child\\file", "/base/child/file")]
[InlineData("C:\\base\\", "child\\file", "C:\\base/child/file")]
[InlineData("/base", "//child", "/base//child")]
public void Join_preserves_the_existing_shared_string_semantics(
string baseDirectory,
string subpath,
string expected)
{
Assert.Equal(expected, ShellPathNormalization.Join(baseDirectory, subpath));
}

[Theory]
[InlineData("\\\\server\\share\\file", "//server/share/file")]
[InlineData("\\root\\file", "/root/file")]
[InlineData("/root\\file", "/root/file")]
public void NormalizeSeparators_preserves_UNC_and_normalizes_backslashes(
string path,
string expected)
{
Assert.Equal(expected, ShellPathNormalization.NormalizeSeparators(path));
}
}