Skip to content

Commit c1a5458

Browse files
committed
Allow using .. (range operator) as a means to refer to the parent directory
1 parent 7d3fe74 commit c1a5458

File tree

5 files changed

+158
-23
lines changed

5 files changed

+158
-23
lines changed

Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ namespace Pathy
3737
public static string op_Implicit(Pathy.ChainablePath chainablePath) { }
3838
public static Pathy.ChainablePath op_Implicit(string path) { }
3939
public static Pathy.ChainablePath operator +(Pathy.ChainablePath leftPath, string additionalPath) { }
40+
public static Pathy.ChainablePath operator /(Pathy.ChainablePath leftPath, System.Range range) { }
4041
public static Pathy.ChainablePath operator /(Pathy.ChainablePath leftPath, string subPath) { }
4142
public static Pathy.ChainablePath operator /(Pathy.ChainablePath? leftPath, string subPath) { }
4243
}

Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ namespace Pathy
3737
public static string op_Implicit(Pathy.ChainablePath chainablePath) { }
3838
public static Pathy.ChainablePath op_Implicit(string path) { }
3939
public static Pathy.ChainablePath operator +(Pathy.ChainablePath leftPath, string additionalPath) { }
40+
public static Pathy.ChainablePath operator /(Pathy.ChainablePath leftPath, System.Range range) { }
4041
public static Pathy.ChainablePath operator /(Pathy.ChainablePath leftPath, string subPath) { }
4142
public static Pathy.ChainablePath operator /(Pathy.ChainablePath? leftPath, string subPath) { }
4243
}

Pathy.Specs/ChainablePathSpecs.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,4 +1085,95 @@ public void Returns_min_value_for_non_existing_path()
10851085
// Assert
10861086
actualTime.Should().Be(DateTime.MinValue);
10871087
}
1088+
1089+
#if NET6_0_OR_GREATER
1090+
[Fact]
1091+
public void Can_navigate_to_parent_using_range_operator()
1092+
{
1093+
// Arrange
1094+
var path = testFolder / "dir1" / "dir2" / "dir3";
1095+
path.CreateDirectoryRecursively();
1096+
1097+
// Act
1098+
var result = path / .. / "file.txt";
1099+
1100+
// Assert
1101+
result.DirectoryName.Should().Be(testFolder + Slash + "dir1" + Slash + "dir2");
1102+
result.Name.Should().Be("file.txt");
1103+
}
1104+
1105+
[Fact]
1106+
public void Can_chain_multiple_range_operators_for_parent_navigation()
1107+
{
1108+
// Arrange
1109+
var path = testFolder / "level1" / "level2" / "level3" / "level4";
1110+
path.CreateDirectoryRecursively();
1111+
1112+
// Act
1113+
var result = path / .. / .. / .. / "file.txt";
1114+
1115+
// Assert
1116+
result.DirectoryName.Should().Be(testFolder + Slash + "level1");
1117+
result.Name.Should().Be("file.txt");
1118+
}
1119+
1120+
[Fact]
1121+
public void Range_operator_is_equivalent_to_using_the_parent_property()
1122+
{
1123+
// Arrange
1124+
var path = testFolder / "dir1" / "dir2" / "dir3";
1125+
path.CreateDirectoryRecursively();
1126+
1127+
// Act
1128+
var usingRangeOperator = path / .. / .. / "file.txt";
1129+
var usingParentProperty = path.Parent.Parent / "file.txt";
1130+
1131+
// Assert
1132+
usingRangeOperator.Should().Be(usingParentProperty);
1133+
}
1134+
1135+
[Fact]
1136+
public void Can_mix_range_operator_with_regular_path_operations()
1137+
{
1138+
// Arrange
1139+
var baseDir = testFolder / "project" / "src" / "core";
1140+
baseDir.CreateDirectoryRecursively();
1141+
1142+
var testDir = testFolder / "project" / "tests";
1143+
testDir.CreateDirectoryRecursively();
1144+
1145+
// Act - Navigate from core to tests using range operator
1146+
var result = baseDir / .. / .. / "tests" / "CoreTests.cs";
1147+
1148+
// Assert
1149+
result.ToString().Should().Be(testFolder + Slash + "project" + Slash + "tests" + Slash + "CoreTests.cs");
1150+
}
1151+
1152+
[Fact]
1153+
public void Range_operator_works_with_current_path()
1154+
{
1155+
// Act
1156+
var result = ChainablePath.Current / .. / .. / "file.txt";
1157+
1158+
// Assert
1159+
result.DirectoryName.Should().Be(Environment.CurrentDirectory.ToPath().Parent.Parent.ToString());
1160+
result.Name.Should().Be("file.txt");
1161+
}
1162+
1163+
[Fact]
1164+
public void Only_two_dots_are_allowed()
1165+
{
1166+
// Arrange
1167+
var path = testFolder / "dir1" / "dir2";
1168+
path.CreateDirectoryRecursively();
1169+
1170+
// Act
1171+
Action act = () => { var _ = path / 1..3; };
1172+
1173+
// Assert
1174+
act.Should().Throw<ArgumentException>()
1175+
.WithMessage("*Only the '..' range operator is supported*")
1176+
.WithParameterName("range");
1177+
}
1178+
#endif
10881179
}

Pathy/ChainablePath.cs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,47 @@ private static string NormalizeSlashes(string path)
214214
return From(Path.Combine(leftPath.GetValueOrDefault(New), subPath));
215215
}
216216

217+
#if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
218+
/// <summary>
219+
/// Navigates to the parent directory of the specified <see cref="ChainablePath"/> when using the range operator <c>..</c>.
220+
/// </summary>
221+
/// <example>
222+
/// <code>
223+
/// var path = ChainablePath.Current / .. / .. / "file.txt";
224+
/// // Equivalent to: ChainablePath.Current.Parent.Parent / "file.txt"
225+
/// </code>
226+
/// </example>
227+
/// <param name="leftPath">
228+
/// The base <see cref="ChainablePath"/> from which to navigate to the parent.
229+
/// </param>
230+
/// <param name="range">
231+
/// The range operator. Only the <c>..</c> range operator (representing all elements) is supported.
232+
/// </param>
233+
/// <returns>
234+
/// A new <see cref="ChainablePath"/> instance representing the parent directory of <paramref name="leftPath"/>.
235+
/// </returns>
236+
/// <exception cref="ArgumentException">
237+
/// Thrown if <paramref name="range"/> is not the <c>..</c> range operator.
238+
/// </exception>
239+
/// <remarks>
240+
/// This operator is designed to work specifically with the <c>..</c> range operator to provide
241+
/// a more intuitive syntax for navigating to parent directories.
242+
/// </remarks>
243+
public static ChainablePath operator /(ChainablePath leftPath, Range range)
244+
{
245+
// Validate that the range is the '..' operator (which is Range.All)
246+
if (!Range.All.Equals(range))
247+
{
248+
throw new ArgumentException(
249+
"Only the '..' range operator is supported for parent directory navigation. " +
250+
"The range operator should be '..' (not a specific range like '1..3').",
251+
nameof(range));
252+
}
253+
254+
return leftPath.Parent;
255+
}
256+
#endif
257+
217258
/// <summary>
218259
/// Adds a raw string to the end of a <see cref="ChainablePath"/> instance.
219260
/// </summary>
@@ -359,7 +400,7 @@ public DateTime LastWriteTimeUtc
359400
{
360401
return System.IO.Directory.GetLastWriteTimeUtc(path);
361402
}
362-
403+
363404
return DateTime.MinValue;
364405
}
365406
}

README.md

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Know that `ChainablePath` overrides `Equals` and `GetHashCode`, so you can alway
114114
Given an instance of `ChainablePath`, you can get a lot of useful information:
115115
* `Name` returns the full name, but without the directory, whereas `Extension` gives you the extension _including_ the dot.
116116
* `Directory`, `Parent` or `DirectoryName` give you the (parent) directory of a file or directory.
117+
* The range operator `..` in newer versions of .NET serves a similar purpose, e.g. `path / .. / "file.txt"`
117118
* To see if a path is absolute, use `IsRooted`
118119
* Not sure if a path points to an actual file system entry? Use `IsFile`, `IsDirectory` or `Exists`
119120
* Want to know the delta between two paths? Use `AsRelativeTo`.
@@ -127,6 +128,28 @@ Other features
127128
* Build an absolute path from a relative path using `ToAbsolute` to use the current directory as the base or `ToAbsolute(parentPath)` to use something else as the base.
128129
* Finding the closest parent directory containing a file matching one or more wildcards. For example, given you have a `ChainablePath` pointing to a `.csproj` file, you can then use `FindParentWithFileMatching("*.sln", "*.slnx")` to find the directory containing the `.sln` or `.slnx` file.
129130

131+
### Resolving files
132+
133+
If you have a `ChainablePath` that could represent either a file or a directory, and you want to resolve a specific file name, you can use the `ResolveFile` extension method:
134+
135+
```csharp
136+
// When the path is a directory containing the file
137+
var directory = ChainablePath.From("c:/projects/myapp");
138+
var configFile = directory.ResolveFile("appsettings.json");
139+
// Returns: c:/projects/myapp/appsettings.json (if it exists)
140+
141+
// When the path is already the file itself
142+
var filePath = ChainablePath.From("c:/projects/myapp/appsettings.json");
143+
var resolved = filePath.ResolveFile("appsettings.json");
144+
// Returns: c:/projects/myapp/appsettings.json (if it exists)
145+
146+
// When the file doesn't exist
147+
var missing = directory.ResolveFile("missing.txt");
148+
// Returns: ChainablePath.Empty
149+
```
150+
151+
The method performs case-insensitive file name matching, so `ResolveFile("CONFIG.JSON")` will match `config.json`.
152+
130153
### Globbing
131154

132155
If you add the `Pathy.Globbing` NuGet source-only package as well, you'll get access to the `GlobFiles` method. With that, you can fetch a collection of files like this:
@@ -164,28 +187,6 @@ var filesToMove = (ChainablePath.Current / "source").GlobFiles("*.txt");
164187
filesToMove.MoveFileOrDirectory(ChainablePath.Current / "destination");
165188
```
166189

167-
### Resolving files
168-
169-
If you have a `ChainablePath` that could represent either a file or a directory, and you want to resolve a specific file name, you can use the `ResolveFile` extension method:
170-
171-
```csharp
172-
// When the path is a directory containing the file
173-
var directory = ChainablePath.From("c:/projects/myapp");
174-
var configFile = directory.ResolveFile("appsettings.json");
175-
// Returns: c:/projects/myapp/appsettings.json (if it exists)
176-
177-
// When the path is already the file itself
178-
var filePath = ChainablePath.From("c:/projects/myapp/appsettings.json");
179-
var resolved = filePath.ResolveFile("appsettings.json");
180-
// Returns: c:/projects/myapp/appsettings.json (if it exists)
181-
182-
// When the file doesn't exist
183-
var missing = directory.ResolveFile("missing.txt");
184-
// Returns: ChainablePath.Empty
185-
```
186-
187-
The method performs case-insensitive file name matching, so `ResolveFile("CONFIG.JSON")` will match `config.json`.
188-
189190
## Building
190191

191192
To build this repository locally so you can contribute to it, you need the following:

0 commit comments

Comments
 (0)