diff --git a/Icod.Path.csproj b/Icod.Path.csproj index 3f82be2..5fbb412 100644 --- a/Icod.Path.csproj +++ b/Icod.Path.csproj @@ -12,7 +12,7 @@ Icod.Path Icod.Path Debug;Release;Staging - 1.0.1 + 1.1.0 AnyCPU @@ -47,8 +47,8 @@ CS1591 - 1.0.1 - Canonical-path foundation release + 1.1.0 + Add platform-explicit lexical pathname decomposition for command-neutral consumers. Timothy J. Bruce README.md LGPL-3.0-or-later @@ -58,7 +58,7 @@ snupkg true true - path;filesystem;canonicalization;path-resolution;realpath;normalization;symlink;symbolic-link;reparse-point;cross-platform;posix;hard-link;junction-point;canonical-path + path;filesystem;canonicalization;path-resolution;realpath;normalization;symlink;symbolic-link;reparse-point;cross-platform;posix;hard-link;junction-point;canonical-path;pathname;path-parsing https://github.com/uniblab/Icod.Path git icon.png diff --git a/README.md b/README.md index 5bd127d..a27e4db 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,37 @@ # Icod.Path -`Icod.Path` is a standalone .NET library for deterministic pathname normalization, physical canonicalization, and no-follow pathname-indirection inspection across POSIX and Windows path models. +`Icod.Path` is a standalone .NET library for deterministic pathname decomposition, normalization, physical canonicalization, and no-follow pathname-indirection inspection across POSIX and Windows path models. The library is command-neutral. It can be consumed by utility suites, applications, services, build tools, or other libraries that need canonical-path behavior without depending on a command-line implementation. ## What the library provides - `PathPlatformSemantics` models POSIX and Windows separators, roots, volume identity, and pathname comparison rules independently of the host operating system. +- `PathSyntaxParser` decomposes pathname text into root and ordered components without normalizing components, interpreting wildcard characters, or observing the filesystem. - `PathLexicalNormalizer` converts input into absolute lexical form without observing the filesystem. - `CanonicalPathResolver` performs ordered physical resolution, missing-component handling, pathname-indirection traversal, relative-path calculation, and component-aware containment checks. - `ICanonicalPathFileSystemProvider` separates canonical-path policy from filesystem observation and permits deterministic or synthetic providers in tests and specialized hosts. - `IPathIndirectionInspector` and `SystemPathIndirectionInspector` characterize a terminal pathname object without silently dereferencing it. - `CanonicalPathResult`, `RelativePathResult`, `PathContainmentResult`, and related models return structured success or failure information instead of writing diagnostics or inventing a successful path after an error. +## Lexical pathname decomposition + +`PathSyntaxParser.Parse` separates pathname structure from later interpretation. It identifies POSIX and Windows roots, volume identity, drive-relative and current-volume-rooted Windows forms, and the ordered nonempty component sequence. + +The parser does not collapse `.` or `..`, does not interpret `*` or `?`, and does not enumerate the filesystem. Component text is preserved for higher-level consumers such as command frameworks that may apply their own pathname-pattern semantics. Windows root syntax is validated, while component contents are intentionally left uninterpreted by this decomposition layer. + +```csharp +var syntax = PathSyntaxParser.Parse( + @"C:\src\**\foo?.cs", + PathPlatformSemantics.Windows +); + +Console.WriteLine( syntax.RootPath ); +foreach ( var component in syntax.Components ) { + Console.WriteLine( component ); +} +``` + ## Canonicalization model Lexical normalization and physical resolution are deliberately separate operations. Lexical normalization applies the selected pathname grammar without touching the filesystem. Physical resolution processes pathname components in filesystem order so supported pathname indirection is expanded before a following `..`, matching actual traversal semantics rather than merely simplifying text. @@ -56,7 +75,7 @@ System-backed physical observation uses the current host filesystem. On Windows, ## Build and test -`Icod.Path` targets .NET 10.0 and uses C# 13. +`Icod.Path` targets .NET 7.0, 8.0, 9.0, and 10.0 and uses C# 13. ```text dotnet build Icod.Path.sln diff --git a/src/PathLexicalNormalizer.cs b/src/PathLexicalNormalizer.cs index fb24bbd..5e58c74 100644 --- a/src/PathLexicalNormalizer.cs +++ b/src/PathLexicalNormalizer.cs @@ -479,7 +479,7 @@ private static bool IsValidWindowsComponent( string component ) { return true; } - private static WindowsRoot ParseWindowsRoot( string path ) { + internal static WindowsRoot ParseWindowsRoot( string path ) { if ( 4 <= path.Length && IsSeparator( path[0] ) @@ -688,7 +688,7 @@ private static LexicalNormalizationOutcome InvalidBase( string path ) => ) ; - private readonly record struct WindowsRoot( + internal readonly record struct WindowsRoot( string RootPath, string VolumeName, int ContentStart, diff --git a/src/PathSyntax.cs b/src/PathSyntax.cs new file mode 100644 index 0000000..2056380 --- /dev/null +++ b/src/PathSyntax.cs @@ -0,0 +1,259 @@ +namespace Icod.Path; + +/// +/// Decomposes pathname text according to an explicit platform grammar without +/// normalizing components or observing the filesystem. +/// +public static class PathSyntaxParser { + /// + /// Parses pathname root structure and ordered components. + /// + /// The pathname text to decompose. + /// The pathname grammar to apply. + /// The decomposed pathname syntax. + /// + /// is empty, contains a NUL character, or has a + /// malformed Windows root. + /// + /// + /// or is + /// . + /// + public static PathSyntaxParts Parse( + string path, + PathPlatformSemantics semantics + ) { + ArgumentNullException.ThrowIfNull( + path + ); + ArgumentNullException.ThrowIfNull( + semantics + ); + if ( 0 == path.Length ) { + throw new ArgumentException( + "pathname is empty", + nameof( path ) + ); + } + if ( 0 <= path.IndexOf( '\0' ) ) { + throw new ArgumentException( + "pathname contains a NUL character", + nameof( path ) + ); + } + + return PathPlatformKind.Windows == semantics.Kind + ? ParseWindows( + path, + semantics + ) + : ParsePosix( + path, + semantics + ) + ; + } + + private static PathSyntaxParts ParsePosix( + string path, + PathPlatformSemantics semantics + ) { + var isAbsolute = semantics.IsDirectorySeparator( + path[ 0 ] + ); + var components = SplitComponents( + path.AsSpan( + isAbsolute + ? 1 + : 0 + ), + semantics + ); + return new PathSyntaxParts( + path, + PathPlatformKind.Posix, + isAbsolute + ? "/" + : string.Empty, + isAbsolute + ? "/" + : string.Empty, + components, + isAbsolute, + isDriveRelative: false, + isCurrentVolumeRooted: false + ); + } + + private static PathSyntaxParts ParseWindows( + string path, + PathPlatformSemantics semantics + ) { + var canonical = null == semantics.AlternateDirectorySeparator + ? path + : path.Replace( + semantics.AlternateDirectorySeparator.Value, + semantics.DirectorySeparator + ) + ; + var root = PathLexicalNormalizer.ParseWindowsRoot( + canonical + ); + if ( root.IsInvalid ) { + throw new ArgumentException( + "pathname contains a malformed Windows root", + nameof( path ) + ); + } + + var components = SplitComponents( + canonical.AsSpan( + root.ContentStart + ), + semantics + ); + var rootPath = root.IsCurrentVolumeRooted + ? semantics.DirectorySeparator.ToString() + : ( + root.IsDriveRelative + ? root.VolumeName + : root.RootPath + ) + ; + return new PathSyntaxParts( + path, + PathPlatformKind.Windows, + rootPath, + root.VolumeName, + components, + root.IsAbsolute, + root.IsDriveRelative, + root.IsCurrentVolumeRooted + ); + } + + private static IReadOnlyList SplitComponents( + ReadOnlySpan content, + PathPlatformSemantics semantics + ) { + var components = new List(); + var start = 0; + for ( + var index = 0; + index <= content.Length; + index++ + ) { + if ( + index < content.Length + && !semantics.IsDirectorySeparator( + content[ index ] + ) + ) { + continue; + } + + var component = content[ + start..index + ].ToString(); + start = index + 1; + if ( 0 == component.Length ) { + continue; + } + components.Add( + component + ); + } + return Array.AsReadOnly( + components.ToArray() + ); + } +} + +/// +/// Represents the structural decomposition of pathname text without component +/// normalization or filesystem observation. +/// +public sealed class PathSyntaxParts { + internal PathSyntaxParts( + string originalPath, + PathPlatformKind platformKind, + string rootPath, + string volumeName, + IReadOnlyList components, + bool isAbsolute, + bool isDriveRelative, + bool isCurrentVolumeRooted + ) { + this.OriginalPath = originalPath; + this.PlatformKind = platformKind; + this.RootPath = rootPath; + this.VolumeName = volumeName; + this.Components = components; + this.IsAbsolute = isAbsolute; + this.IsDriveRelative = isDriveRelative; + this.IsCurrentVolumeRooted = isCurrentVolumeRooted; + } + + /// Gets the pathname text supplied by the caller. + public string OriginalPath { + get; + } + + /// Gets the pathname grammar used for decomposition. + public PathPlatformKind PlatformKind { + get; + } + + /// + /// Gets the canonical root spelling, an empty string for an unrooted path, + /// the drive designator for a drive-relative Windows path, or one separator + /// for a current-volume-rooted Windows path. + /// + public string RootPath { + get; + } + + /// + /// Gets the comparison identity of the root volume when one is identified, + /// or an empty string otherwise. + /// + public string VolumeName { + get; + } + + /// + /// Gets the ordered nonempty pathname components exactly as parsed after + /// separator normalization. + /// + public IReadOnlyList Components { + get; + } + + /// Gets whether the pathname names an absolute root. + public bool IsAbsolute { + get; + } + + /// Gets whether a Windows pathname uses drive-relative syntax. + public bool IsDriveRelative { + get; + } + + /// + /// Gets whether a Windows pathname is rooted on the current volume without + /// identifying that volume. + /// + public bool IsCurrentVolumeRooted { + get; + } + + /// Gets whether the pathname contains any form of explicit root syntax. + public bool HasRoot { + get { + return this.IsAbsolute + || this.IsDriveRelative + || this.IsCurrentVolumeRooted + ; + } + } +} diff --git a/src/README.md b/src/README.md index d21fbce..b16de95 100644 --- a/src/README.md +++ b/src/README.md @@ -1,8 +1,9 @@ # Canonical-path and pathname-indirection model -The standalone `Icod.Path` library separates pathname grammar from physical filesystem observation. This document describes the source-level contract behind its public canonicalization and pathname-indirection APIs. +The standalone `Icod.Path` library separates pathname grammar from physical filesystem observation. This document describes the source-level contract behind its public pathname-syntax, canonicalization, and pathname-indirection APIs. - `PathPlatformSemantics` describes POSIX and Windows separators, root syntax, volume identity, and comparison rules independently of the host operating system. +- `PathSyntaxParser` decomposes pathname text into root metadata and ordered components without normalization, wildcard interpretation, or filesystem observation. - `PathLexicalNormalizer` creates absolute lexical paths without observing the filesystem and rejects invalid or unresolved drive-relative forms. - `IPathIndirectionInspector` characterizes one terminal physical object without dereferencing it or opening file content. - `SystemPathIndirectionInspector` reads POSIX link targets and, on Windows, combines no-follow handle information, `FSCTL_GET_REPARSE_POINT`, and volume-mount APIs. @@ -11,6 +12,14 @@ The standalone `Icod.Path` library separates pathname grammar from physical file - `CanonicalPathResolver` performs ordered physical resolution, loop and expansion-limit checks, missing-component policy, terminal-object inspection, relative-path computation, and containment evaluation. - `CanonicalPathResult`, `RelativePathResult`, and `PathContainmentResult` carry structured failures; no failure path is returned as a successful canonical result. +## Pathname syntax decomposition + +`PathSyntaxParser` is the command-neutral structural layer. It validates only pathname-level invariants needed to identify the root and component boundaries: nonempty input, no NUL character, and well-formed Windows root syntax. It returns `PathSyntaxParts` containing the original input, canonical root spelling, volume identity, ordered nonempty components, and flags for absolute, drive-relative, and current-volume-rooted forms. + +Component text is not normalized or interpreted. In particular, `.` and `..` remain components, and characters such as `*` and `?` remain ordinary component text at this layer. The parser does not decide whether those characters are valid filesystem names or wildcard operators. Higher-level consumers may impose those policies after decomposition. + +This distinction keeps pathname grammar in `Icod.Path` while leaving pathname-pattern matching, directory enumeration, recursive `**` semantics, unmatched-pattern handling, and traversal policy to higher-level libraries. + ## Missing components `RequireExisting` requires every component. `AllowFinalComponent` permits only the final unresolved component. `AllowMissingSuffix` permits the first missing component and the remaining lexical suffix. The result records the number of unresolved suffix components. diff --git a/tests/Path.Tests/src/PathPlatformSemanticsTests.cs b/tests/Path.Tests/src/PathPlatformSemanticsTests.cs new file mode 100644 index 0000000..2bbd06a --- /dev/null +++ b/tests/Path.Tests/src/PathPlatformSemanticsTests.cs @@ -0,0 +1,48 @@ +using Icod.Path; + +using Xunit; + +namespace Icod.Path.Tests; + +/// Tests deterministic POSIX and Windows pathname platform semantics. +public sealed class PathPlatformSemanticsTests { + /// Verifies the POSIX separator and comparison contract. + [Fact] + public void PosixSemanticsAreCaseSensitiveAndSlashDelimited() { + var semantics = PathPlatformSemantics.Posix; + + Assert.Equal( PathPlatformKind.Posix, semantics.Kind ); + Assert.Equal( '/', semantics.DirectorySeparator ); + Assert.Null( semantics.AlternateDirectorySeparator ); + Assert.Equal( StringComparison.Ordinal, semantics.PathComparison ); + Assert.True( semantics.IsDirectorySeparator( '/' ) ); + Assert.False( semantics.IsDirectorySeparator( '\\' ) ); + Assert.True( semantics.PathComparer.Equals( "alpha", "alpha" ) ); + Assert.False( semantics.PathComparer.Equals( "alpha", "ALPHA" ) ); + } + + /// Verifies the Windows separator and comparison contract. + [Fact] + public void WindowsSemanticsAreCaseInsensitiveAndAcceptBothSeparators() { + var semantics = PathPlatformSemantics.Windows; + + Assert.Equal( PathPlatformKind.Windows, semantics.Kind ); + Assert.Equal( '\\', semantics.DirectorySeparator ); + Assert.Equal( '/', semantics.AlternateDirectorySeparator ); + Assert.Equal( StringComparison.OrdinalIgnoreCase, semantics.PathComparison ); + Assert.True( semantics.IsDirectorySeparator( '\\' ) ); + Assert.True( semantics.IsDirectorySeparator( '/' ) ); + Assert.True( semantics.PathComparer.Equals( "alpha", "ALPHA" ) ); + } + + /// Verifies that host semantics select the current operating-system grammar. + [Fact] + public void HostSemanticsMatchCurrentOperatingSystem() { + var expected = OperatingSystem.IsWindows() + ? PathPlatformSemantics.Windows + : PathPlatformSemantics.Posix + ; + + Assert.Same( expected, PathPlatformSemantics.Host ); + } +} diff --git a/tests/Path.Tests/src/PathSyntaxParserTests.cs b/tests/Path.Tests/src/PathSyntaxParserTests.cs new file mode 100644 index 0000000..7179c1c --- /dev/null +++ b/tests/Path.Tests/src/PathSyntaxParserTests.cs @@ -0,0 +1,362 @@ +using Icod.Path; + +using Xunit; + +namespace Icod.Path.Tests; + +/// Tests platform-explicit pathname syntax decomposition. +public sealed class PathSyntaxParserTests { + /// Verifies that POSIX pattern-like component text is preserved. + [Fact] + public void ParsesPosixComponentsWithoutNormalization() { + var result = PathSyntaxParser.Parse( + "/src/**/../foo?.cs", + PathPlatformSemantics.Posix + ); + + Assert.True( result.IsAbsolute ); + Assert.True( result.HasRoot ); + Assert.Equal( "/", result.RootPath ); + Assert.Equal( "/", result.VolumeName ); + Assert.Equal( + new string[] { + "src", + "**", + "..", + "foo?.cs" + }, + result.Components + ); + } + + /// Verifies that a relative POSIX dot component remains structural input. + [Fact] + public void PreservesRelativePosixDotComponent() { + var result = PathSyntaxParser.Parse( + "./src/*.txt", + PathPlatformSemantics.Posix + ); + + Assert.False( result.IsAbsolute ); + Assert.False( result.HasRoot ); + Assert.Equal( string.Empty, result.RootPath ); + Assert.Equal( + new string[] { + ".", + "src", + "*.txt" + }, + result.Components + ); + } + + /// Verifies canonical Windows drive-root spelling and wildcard preservation. + [Fact] + public void ParsesWindowsDriveRootWithoutInterpretingPatterns() { + var result = PathSyntaxParser.Parse( + @"c:/src/**/foo?.cs", + PathPlatformSemantics.Windows + ); + + Assert.True( result.IsAbsolute ); + Assert.False( result.IsDriveRelative ); + Assert.False( result.IsCurrentVolumeRooted ); + Assert.Equal( @"C:\", result.RootPath ); + Assert.Equal( "C:", result.VolumeName ); + Assert.Equal( + new string[] { + "src", + "**", + "foo?.cs" + }, + result.Components + ); + } + + /// Verifies Windows drive-relative structure without resolving a base path. + [Fact] + public void ParsesWindowsDriveRelativePath() { + var result = PathSyntaxParser.Parse( + @"d:src\*.cs", + PathPlatformSemantics.Windows + ); + + Assert.False( result.IsAbsolute ); + Assert.True( result.IsDriveRelative ); + Assert.True( result.HasRoot ); + Assert.Equal( "D:", result.RootPath ); + Assert.Equal( "D:", result.VolumeName ); + Assert.Equal( + new string[] { + "src", + "*.cs" + }, + result.Components + ); + } + + /// Verifies Windows current-volume-rooted structure without selecting a volume. + [Fact] + public void ParsesWindowsCurrentVolumeRootedPath() { + var result = PathSyntaxParser.Parse( + @"\src\?.txt", + PathPlatformSemantics.Windows + ); + + Assert.False( result.IsAbsolute ); + Assert.False( result.IsDriveRelative ); + Assert.True( result.IsCurrentVolumeRooted ); + Assert.True( result.HasRoot ); + Assert.Equal( @"\", result.RootPath ); + Assert.Equal( string.Empty, result.VolumeName ); + Assert.Equal( + new string[] { + "src", + "?.txt" + }, + result.Components + ); + } + + /// Verifies UNC root identity while preserving pattern text below the share. + [Fact] + public void ParsesWindowsUncPath() { + var result = PathSyntaxParser.Parse( + @"\\Server\Share\src\**\*.cs", + PathPlatformSemantics.Windows + ); + + Assert.True( result.IsAbsolute ); + Assert.Equal( @"\\Server\Share\", result.RootPath ); + Assert.Equal( @"\\Server\Share", result.VolumeName ); + Assert.Equal( + new string[] { + "src", + "**", + "*.cs" + }, + result.Components + ); + } + + /// Verifies deterministic rejection of malformed Windows roots. + [Fact] + public void RejectsMalformedWindowsUncRoot() { + var exception = Assert.Throws( + () => PathSyntaxParser.Parse( + @"\\server", + PathPlatformSemantics.Windows + ) + ); + + Assert.Equal( "path", exception.ParamName ); + } + + /// Verifies that NUL remains invalid pathname syntax. + [Fact] + public void RejectsNulCharacter() { + var exception = Assert.Throws( + () => PathSyntaxParser.Parse( + "alpha\0beta", + PathPlatformSemantics.Posix + ) + ); + + Assert.Equal( "path", exception.ParamName ); + } + + /// Verifies the structural representation of the POSIX root alone. + [Fact] + public void ParsesPosixRootOnly() { + var result = PathSyntaxParser.Parse( + "/", + PathPlatformSemantics.Posix + ); + + Assert.True( result.IsAbsolute ); + Assert.True( result.HasRoot ); + Assert.Equal( PathPlatformKind.Posix, result.PlatformKind ); + Assert.Equal( "/", result.OriginalPath ); + Assert.Equal( "/", result.RootPath ); + Assert.Equal( "/", result.VolumeName ); + Assert.Empty( result.Components ); + } + + /// Verifies the structural representation of a Windows drive root alone. + [Fact] + public void ParsesWindowsDriveRootOnly() { + var result = PathSyntaxParser.Parse( + @"c:\", + PathPlatformSemantics.Windows + ); + + Assert.True( result.IsAbsolute ); + Assert.True( result.HasRoot ); + Assert.Equal( PathPlatformKind.Windows, result.PlatformKind ); + Assert.Equal( @"c:\", result.OriginalPath ); + Assert.Equal( @"C:\", result.RootPath ); + Assert.Equal( "C:", result.VolumeName ); + Assert.Empty( result.Components ); + } + + /// Verifies the structural representation of a Windows UNC root alone. + [Fact] + public void ParsesWindowsUncRootOnly() { + var result = PathSyntaxParser.Parse( + @"\\Server\Share\", + PathPlatformSemantics.Windows + ); + + Assert.True( result.IsAbsolute ); + Assert.True( result.HasRoot ); + Assert.Equal( @"\\Server\Share\", result.RootPath ); + Assert.Equal( @"\\Server\Share", result.VolumeName ); + Assert.Empty( result.Components ); + } + + /// Verifies an unrooted Windows pathname without selecting a base directory. + [Fact] + public void ParsesRelativeWindowsPath() { + var result = PathSyntaxParser.Parse( + @"src\**\*.cs", + PathPlatformSemantics.Windows + ); + + Assert.False( result.IsAbsolute ); + Assert.False( result.IsDriveRelative ); + Assert.False( result.IsCurrentVolumeRooted ); + Assert.False( result.HasRoot ); + Assert.Equal( string.Empty, result.RootPath ); + Assert.Equal( string.Empty, result.VolumeName ); + Assert.Equal( + new string[] { + "src", + "**", + "*.cs" + }, + result.Components + ); + } + + /// Verifies extended drive roots while preserving pattern component text. + [Fact] + public void ParsesWindowsExtendedDrivePath() { + var result = PathSyntaxParser.Parse( + @"\\?\c:\src\**\foo?.cs", + PathPlatformSemantics.Windows + ); + + Assert.True( result.IsAbsolute ); + Assert.Equal( @"\\?\C:\", result.RootPath ); + Assert.Equal( "C:", result.VolumeName ); + Assert.Equal( + new string[] { + "src", + "**", + "foo?.cs" + }, + result.Components + ); + } + + /// Verifies extended UNC roots while preserving pattern component text. + [Fact] + public void ParsesWindowsExtendedUncPath() { + var result = PathSyntaxParser.Parse( + @"\\?\UNC\Server\Share\src\**\*.cs", + PathPlatformSemantics.Windows + ); + + Assert.True( result.IsAbsolute ); + Assert.Equal( @"\\?\UNC\Server\Share\", result.RootPath ); + Assert.Equal( @"\\Server\Share", result.VolumeName ); + Assert.Equal( + new string[] { + "src", + "**", + "*.cs" + }, + result.Components + ); + } + + /// Verifies alternate and repeated Windows separators are structural delimiters. + [Fact] + public void ParsesMixedAndRepeatedWindowsSeparators() { + var result = PathSyntaxParser.Parse( + @"C:/one\\two//three\**/*.txt", + PathPlatformSemantics.Windows + ); + + Assert.Equal( @"C:\", result.RootPath ); + Assert.Equal( + new string[] { + "one", + "two", + "three", + "**", + "*.txt" + }, + result.Components + ); + } + + /// Verifies Windows dot and parent components remain uninterpreted. + [Fact] + public void PreservesWindowsDotComponents() { + var result = PathSyntaxParser.Parse( + @"C:\src\.\one\..\*.cs", + PathPlatformSemantics.Windows + ); + + Assert.Equal( + new string[] { + "src", + ".", + "one", + "..", + "*.cs" + }, + result.Components + ); + } + + /// Verifies deterministic rejection of an empty pathname. + [Fact] + public void RejectsEmptyPath() { + var exception = Assert.Throws( + () => PathSyntaxParser.Parse( + string.Empty, + PathPlatformSemantics.Posix + ) + ); + + Assert.Equal( "path", exception.ParamName ); + } + + /// Verifies deterministic rejection of a null pathname. + [Fact] + public void RejectsNullPath() { + var exception = Assert.Throws( + () => PathSyntaxParser.Parse( + null!, + PathPlatformSemantics.Posix + ) + ); + + Assert.Equal( "path", exception.ParamName ); + } + + /// Verifies deterministic rejection of null platform semantics. + [Fact] + public void RejectsNullSemantics() { + var exception = Assert.Throws( + () => PathSyntaxParser.Parse( + "src", + null! + ) + ); + + Assert.Equal( "semantics", exception.ParamName ); + } +}