├── .config ├── dotnet-tools.json └── dotnet-tools.json.license ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── main.yml │ ├── main.yml.license │ ├── release.yml │ └── release.yml.license ├── .gitignore ├── .idea └── .idea.TruePath │ └── .idea │ └── dictionaries │ ├── default_user.xml │ └── fried.xml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Directory.Build.props ├── LICENSE.txt ├── LICENSES └── MIT.txt ├── MAINTAINING.md ├── README.md ├── REUSE.toml ├── TruePath.Benchmarks ├── DriveLetter.cs ├── DriveLetterBenchmark.cs ├── PathStrings.cs ├── Program.cs └── TruePath.Benchmarks.csproj ├── TruePath.SystemIo ├── PathIo.cs └── TruePath.SystemIo.csproj ├── TruePath.Tests ├── AbsolutePathTests.cs ├── DiskUtilsTests.cs ├── GenericInterfaceTests.cs ├── GlobalUsings.cs ├── LocalPathTests.cs ├── NormalizatonTests.cs ├── PathExtensionsTests.cs ├── PathStringsTests.cs ├── TemporaryTests.cs ├── TruePath.Tests.csproj └── Utils.cs ├── TruePath.sln ├── TruePath.sln.DotSettings ├── TruePath.sln.license ├── TruePath ├── AbsolutePath.cs ├── Comparers │ ├── IPathComparer.cs │ ├── PlatformDefaultPathComparer.cs │ └── StrictStringPathComparer.cs ├── DiskUtils.cs ├── IPath.cs ├── Kernel32.cs ├── Libc.cs ├── LocalPath.cs ├── LocalPathPattern.cs ├── PathExtensions.cs ├── PathStrings.cs ├── Temporary.cs └── TruePath.csproj ├── docs ├── docfx.json ├── docfx.json.license ├── index.md └── toc.yml └── scripts ├── Get-Version.ps1 ├── Test-Encoding.ps1 └── github-actions.fsx /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "docfx": { 6 | "version": "2.78.3", 7 | "commands": [ 8 | "docfx" 9 | ], 10 | "rollForward": false 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /.config/dotnet-tools.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.yml] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "nuget" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: Docs 6 | on: 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | permissions: 13 | actions: read 14 | pages: write 15 | id-token: write 16 | 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | publish-docs: 23 | environment: 24 | name: github-pages 25 | url: ${{ steps.deployment.outputs.page_url }} 26 | runs-on: ubuntu-22.04 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Dotnet Setup 31 | uses: actions/setup-dotnet@v4 32 | with: 33 | dotnet-version: 8.x 34 | 35 | - run: dotnet tool restore 36 | - run: dotnet docfx docs/docfx.json 37 | 38 | - name: Upload artifact 39 | uses: actions/upload-pages-artifact@v3 40 | with: 41 | path: 'docs/_site' 42 | - name: Deploy to GitHub Pages 43 | id: deployment 44 | uses: actions/deploy-pages@v4 45 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This file is auto-generated. 2 | name: Main 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: 0 0 * * 6 12 | workflow_dispatch: 13 | jobs: 14 | check: 15 | strategy: 16 | matrix: 17 | image: 18 | - macos-14 19 | - ubuntu-24.04 20 | - windows-2022 21 | fail-fast: false 22 | runs-on: ${{ matrix.image }} 23 | env: 24 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 25 | DOTNET_NOLOGO: 1 26 | NUGET_PACKAGES: ${{ github.workspace }}/.github/nuget-packages 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Set up .NET SDK 30 | uses: actions/setup-dotnet@v4 31 | with: 32 | dotnet-version: 8.0.x 33 | - name: NuGet cache 34 | uses: actions/cache@v4 35 | with: 36 | key: ${{ runner.os }}.nuget.${{ hashFiles('**/*.csproj') }} 37 | path: ${{ env.NUGET_PACKAGES }} 38 | - name: Build 39 | run: dotnet build 40 | - name: Test 41 | run: dotnet test 42 | timeout-minutes: 10 43 | licenses: 44 | runs-on: ubuntu-24.04 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: REUSE license check 48 | uses: fsfe/reuse-action@v5 49 | encoding: 50 | runs-on: ubuntu-24.04 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Verify encoding 54 | shell: pwsh 55 | run: scripts/Test-Encoding.ps1 56 | nowarn-empty: 57 | runs-on: ubuntu-24.04 58 | steps: 59 | - uses: actions/checkout@v4 60 | - name: Verify with NoWarn as empty 61 | run: dotnet build /p:NoWarn='' --no-incremental 62 | -------------------------------------------------------------------------------- /.github/workflows/main.yml.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 TruePath contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file is auto-generated. 2 | name: Release 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | pull_request: 10 | branches: 11 | - main 12 | schedule: 13 | - cron: 0 0 * * 6 14 | workflow_dispatch: 15 | jobs: 16 | nuget: 17 | permissions: 18 | contents: write 19 | runs-on: ubuntu-24.04 20 | steps: 21 | - uses: actions/checkout@v4 22 | - id: version 23 | name: Get version 24 | shell: pwsh 25 | run: echo "version=$(scripts/Get-Version.ps1 -RefName $env:GITHUB_REF)" >> $env:GITHUB_OUTPUT 26 | - run: dotnet pack --configuration Release -p:Version=${{ steps.version.outputs.version }} 27 | - name: Read changelog 28 | uses: ForNeVeR/ChangelogAutomation.action@v2 29 | with: 30 | output: ./release-notes.md 31 | - name: Upload artifacts 32 | uses: actions/upload-artifact@v4 33 | with: 34 | path: |- 35 | ./release-notes.md 36 | ./TruePath/bin/Release/TruePath.${{ steps.version.outputs.version }}.nupkg 37 | ./TruePath/bin/Release/TruePath.${{ steps.version.outputs.version }}.snupkg 38 | ./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.nupkg 39 | ./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.snupkg 40 | - if: startsWith(github.ref, 'refs/tags/v') 41 | name: Create a release 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | body_path: ./release-notes.md 45 | files: |- 46 | ./TruePath/bin/Release/TruePath.${{ steps.version.outputs.version }}.nupkg 47 | ./TruePath/bin/Release/TruePath.${{ steps.version.outputs.version }}.snupkg 48 | ./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.nupkg 49 | ./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.snupkg 50 | name: TruePath v${{ steps.version.outputs.version }} 51 | - if: startsWith(github.ref, 'refs/tags/v') 52 | name: Push artifact to NuGet 53 | run: dotnet nuget push ./TruePath/bin/Release/TruePath.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_TOKEN_TRUE_PATH }} 54 | - if: startsWith(github.ref, 'refs/tags/v') 55 | name: Push artifact to NuGet 56 | run: dotnet nuget push ./TruePath.SystemIo/bin/Release/TruePath.SystemIo.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_TOKEN_TRUE_PATH_SYSTEM_IO }} 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | /.idea/ 6 | /docs/_site/ 7 | /docs/api/ 8 | 9 | bin/ 10 | obj/ 11 | 12 | *.user 13 | .vs/ 14 | -------------------------------------------------------------------------------- /.idea/.idea.TruePath/.idea/dictionaries/default_user.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fsfe 5 | generaptor 6 | kickstarting 7 | nupkg 8 | pwsh 9 | ryuner 10 | snupkg 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/.idea.TruePath/.idea/dictionaries/fried.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | andivionian 5 | fornever 6 | komroncube 7 | ronimizy 8 | ventis 9 | 10 | 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | Changelog 8 | ========= 9 | All notable changes to this project will be documented in this file. 10 | 11 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 12 | 13 | ## [1.9.0] - 2025-06-01 14 | ### Changed 15 | - `PathIo.CreateDirectory`, `DeleteEmptyDirectory`, `DeleteDirectoryRecursively` are now extension methods. Thanks to @seclerp! 16 | 17 | ### Added 18 | - Extension methods: 19 | - `PathIo.ReadAllLinesAsync`, 20 | - `PathIo.ReadAllText`, 21 | - `PathIo.ReadAllTextAsync`, 22 | - `PathIo.WriteAllTextAsync`, 23 | - `PathIo.GetFiles`, 24 | - `PathIo.GetDirectories` (the latter two for file system traversal). Thanks to @seclerp! 25 | 26 | ## [1.8.0] - 2025-05-31 27 | ### Added 28 | - [#87](https://github.com/ForNeVeR/TruePath/issues/87): all the path types are now `IComparable`. Thanks to @alvkn! 29 | - `IPath` generic interface now supports a `Create` method to create a path from its string representation. Thanks to @alvkn! 30 | - [#120](https://github.com/ForNeVeR/TruePath/issues/120): a new method on paths, `ChangeExtension`. Thanks to @alvkn! 31 | 32 | ### Fixed 33 | - [#134](https://github.com/ForNeVeR/TruePath/issues/134): path normalization fails when input has both dot folders and file extension. Thanks to @maxkatz6 and @kant2002 for a detailed report and a fix! 34 | 35 | ## [1.7.0] - 2025-04-18 36 | ### Added 37 | - A new package, **TruePath.SystemIo**, with adapter for `System.IO` types. 38 | 39 | `TruePath.SystemIo.PathIo` contains most of the static methods from .NET's `File` and `Directory` types as extension methods over the `AbstractPath`. 40 | 41 | Thanks to @kant2002! 42 | - A setter for `AbsolutePath.CurrentWorkingDirectory`. 43 | 44 | ## [1.6.0] - 2024-10-06 45 | ### Changed 46 | - Paths are now compared using a platform-specific comparer by default: 47 | - case-sensitive on Linux, 48 | - case-insensitive on Windows and macOS. 49 | 50 | Thanks to @babaruh and @Kataane for working on this improvement. 51 | 52 | ### Added 53 | - `Equals` method on `AbsolutePath` and `LocalPath` that accepts an alternate comparer (see `PlatformDefaultComparer` and `StrictStringComparer` static comparers on both types). Thanks to @babaruh and @Kataane for working on this improvement. 54 | 55 | ## [1.5.0] - 2024-09-22 56 | ### Fixed 57 | - Incorrect path normalization: last ellipsis (`...`) in a path was treated as a `..` entry. 58 | 59 | ### Changed 60 | - [#18](https://github.com/ForNeVeR/TruePath/issues/18): update to behavior of `.Parent` on relative paths. 61 | 62 | Now, it works for relative paths by either removing the last part or adding `..` as necessary to lead to a parent directory. 63 | 64 | Thanks to @Kataane for help on this one. 65 | 66 | ## [1.4.0] - 2024-08-12 67 | ### Changed 68 | - [#16: Support Windows disk drives in the normalization algorithm](https://github.com/ForNeVeR/TruePath/issues/16). 69 | 70 | Thanks to @Kataane. 71 | - More optimizations for `AbsolutePath`'s `/` operator: it will avoid the unnecessary check for absolute path. 72 | - [#85: Minor performance improvements for absolute path checking on Windows](https://github.com/ForNeVeR/TruePath/pull/85). 73 | 74 | Thanks to @Kataane. 75 | 76 | ### Added 77 | - `AbsolutePath::Canonicalize` to convert the path to absolute, convert to correct case on case-insensitive file systems, resolve symlinks. 78 | 79 | Thanks to @Kataane. 80 | - `LocalPath::ResolveToCurrentDirectory`: effectively calculates `currentDirectory / this`. No-op for paths that are already absolute (aside from converting to the `AbsolutePath` type). 81 | 82 | Thanks to @Illusion4. 83 | - `AbsolutePath::ReadKind` to check the file system object kind (file, directory, or something else) and whether it exists at all. 84 | 85 | Thanks to @Kataane. 86 | - [#76](https://github.com/ForNeVeR/TruePath/issues/76): a new `Temporary` class for creating a temp file or folder. 87 | 88 | Thanks to @Illusion4. 89 | 90 | ## [1.3.0] - 2024-06-21 91 | ### Added 92 | - [#39: Add `AbsolutePath::RelativeTo`](https://github.com/ForNeVeR/TruePath/issues/39). 93 | 94 | Thanks to @ronimizy. 95 | - `IPath::IsPrefixOf` to check path prefixes. 96 | 97 | Thanks to @babaruh. 98 | - `IPath::StartsWith` to check if the current path starts with a specified path. 99 | 100 | Thanks to @babaruh. 101 | - [#38: Introduce `AbsolutePath::CurrentWorkingDirectory`](https://github.com/ForNeVeR/TruePath/issues/38). 102 | 103 | Thanks to @babaruh. 104 | 105 | ### Changed 106 | - [#17: `AbsolutePath::Parent` should not re-check the path's absoluteness](https://github.com/ForNeVeR/TruePath/issues/17) (a performance optimization). 107 | 108 | Thanks to @ronimizy. 109 | 110 | ## [1.2.1] - 2024-05-25 111 | ### Fixed 112 | - [#60: `PathStrings.Normalize("../../foo")` is broken](https://github.com/ForNeVeR/TruePath/issues/60). 113 | 114 | Thanks to @ronimizy for report. 115 | 116 | ## [1.2.0] - 2024-05-05 117 | ### Added 118 | - New `IPath` and `IPath` interfaces that allow to process any paths (`LocalPath` and `AbsolutePath`) in a polymorphic way. 119 | - [#41](https://github.com/ForNeVeR/TruePath/issues/41): `GetFileNameWithoutExtension` extension method for `IPath`. 120 | 121 | Thanks to @Komroncube. 122 | - [#42](https://github.com/ForNeVeR/TruePath/issues/42): `GetExtensionWithDot` and `GetExtensionWithoutDot` extension methods for `IPath`. 123 | 124 | Thanks to @Komroncube and @y0ung3r. 125 | 126 | ### Changed 127 | - [#19](https://github.com/ForNeVeR/TruePath/issues/19): Optimize `PathStrings.Normalize` method. 128 | 129 | Thanks to @BadRyuner. 130 | - Improve the project documentation. 131 | 132 | ## [1.1.0] - 2024-04-27 133 | ### Added 134 | - [#26: Publish PDB files to NuGet](https://github.com/ForNeVeR/TruePath/issues/26) (in form of `.snupkg` for now). 135 | - Update and publish XML documentation with the package. 136 | - Enable the Source Link. 137 | - [#29](https://github.com/ForNeVeR/TruePath/issues/29): add converting constructors for `LocalPath` and `AbsolutePath`. 138 | - `AbsolutePath` and `LocalPath` now support `IEquatable` interface. 139 | 140 | ## [1.0.0] - 2024-04-21 141 | ### Added 142 | - New types: 143 | - `LocalPath` for paths that may be either absolute or relative, and stored in a normalized way; 144 | - `AbsolutePath` for paths that are guaranteed (checked) to be absolute; 145 | - `LocalPathPattern` (for paths including wildcards; note this is a marker type that doesn't offer any advanced functionality over the contained string). 146 | - New static classes: 147 | - `PathStrings` for path normalization (see the type's documentation on what exactly we consider as **normalization**). 148 | - Currently supported features: 149 | - path normalization (in-memory only, no disk IO performed), 150 | - path concatenation via `/` operator, 151 | - check for absolute path (work in progress; doesn't completely work for Windows paths yet), 152 | - get path parent, 153 | - get the last path component's name, 154 | - check for path prefix, 155 | - get a relative part between two paths, 156 | - check paths for equality (case-insensitive only, yet). 157 | 158 | ## [0.0.0] - 2024-04-20 159 | This is the first published version of the package. It doesn't contain any features, and serves the purpose of kickstarting the publication system, and to be an anchor for further additions to the package. 160 | 161 | [docs.readme]: README.md 162 | 163 | [0.0.0]: https://github.com/ForNeVeR/TruePath/releases/tag/v0.0.0 164 | [1.0.0]: https://github.com/ForNeVeR/TruePath/compare/v0.0.0...v1.0.0 165 | [1.1.0]: https://github.com/ForNeVeR/TruePath/compare/v1.0.0...v1.1.0 166 | [1.2.0]: https://github.com/ForNeVeR/TruePath/compare/v1.1.0...v1.2.0 167 | [1.2.1]: https://github.com/ForNeVeR/TruePath/compare/v1.2.0...v1.2.1 168 | [1.3.0]: https://github.com/ForNeVeR/TruePath/compare/v1.2.1...v1.3.0 169 | [1.4.0]: https://github.com/ForNeVeR/TruePath/compare/v1.3.0...v1.4.0 170 | [1.5.0]: https://github.com/ForNeVeR/TruePath/compare/v1.4.0...v1.5.0 171 | [1.6.0]: https://github.com/ForNeVeR/TruePath/compare/v1.5.0...v1.6.0 172 | [1.7.0]: https://github.com/ForNeVeR/TruePath/compare/v1.6.0...v1.7.0 173 | [1.8.0]: https://github.com/ForNeVeR/TruePath/compare/v1.7.0...v1.8.0 174 | [1.9.0]: https://github.com/ForNeVeR/TruePath/compare/v1.8.0...v1.9.0 175 | [Unreleased]: https://github.com/ForNeVeR/TruePath/compare/v1.9.0...HEAD 176 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | Contributor Guide 8 | ================= 9 | 10 | Prerequisites 11 | ------------- 12 | To work with the project, you'll need [.NET SDK 8][dotnet-sdk] or later. 13 | 14 | Build 15 | ----- 16 | Use the following shell command: 17 | 18 | ```console 19 | $ dotnet build 20 | ``` 21 | 22 | Test 23 | ---- 24 | Use the following shell command: 25 | 26 | ```console 27 | $ dotnet test 28 | ``` 29 | 30 | Test Documentation 31 | ------------------ 32 | To open the project documentation site locally, use the following shell commands: 33 | ```console 34 | $ dotnet tool restore 35 | $ dotnet docfx docs/docfx.json --serve 36 | ``` 37 | 38 | Then, open http://localhost:8080/ and browse the documentation. 39 | 40 | License Automation 41 | ------------------ 42 | If the CI asks you to update the file licenses, follow one of these: 43 | 1. Update the headers manually (look at the existing files), something like this: 44 | ```fsharp 45 | // SPDX-FileCopyrightText: %year% %your name% <%your contact info, e.g. email%> 46 | // 47 | // SPDX-License-Identifier: MIT 48 | ``` 49 | (accommodate to the file's comment style if required). 50 | 2. Alternately, use [REUSE][reuse] tool: 51 | ```console 52 | $ reuse annotate --license MIT --copyright '%your name% <%your contact info, e.g. email%>' %file names to annotate% 53 | ``` 54 | 55 | (Feel free to attribute the changes to "TruePath contributors " instead of your name in a multi-author file, or if you don't want your name to be mentioned in the project's source: this doesn't mean you'll lose the copyright.) 56 | 57 | File Encoding Changes 58 | --------------------- 59 | If the automation asks you to update the file encoding (line endings or UTF-8 BOM) in certain files, run the following PowerShell script ([PowerShell Core][powershell] is recommended to run this script): 60 | ```console 61 | $ pwsh -File scripts/Test-Encoding.ps1 -AutoFix 62 | ``` 63 | 64 | The `-AutoFix` switch will automatically fix the encoding issues, and you'll only need to commit and push the changes. 65 | 66 | GitHub Actions 67 | -------------- 68 | If you want to update the GitHub Actions used in the project, edit the file that generated them: `scripts/github-actions.fsx`. 69 | 70 | Then run the following shell command: 71 | ```console 72 | $ dotnet fsi scripts/github-actions.fsx 73 | ``` 74 | 75 | [dotnet-sdk]: https://dotnet.microsoft.com/en-us/download 76 | [powershell]: https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell 77 | [reuse]: https://reuse.software/ 78 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 1.9.0 10 | MIT 11 | 2024-2025 TruePath contributors <https://github.com/ForNeVeR/TruePath> 12 | README.md 13 | true 14 | true 15 | snupkg 16 | 17 | 18 | 19 | net8.0 20 | enable 21 | enable 22 | true 23 | CS0419;CS1570;CS1571;CS1572;CS1573;CS1574;CS1580;CS1581;CS1584;CS1587;CS1589;CS1590;CS1591;CS1592;CS1598;CS1710;CS1711;CS1712;$(NoWarn) 24 | true 25 | true 26 | 27 | 28 | 29 | true 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 TruePath contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /MAINTAINING.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | Maintainer Guide 8 | ================ 9 | 10 | Publish a New Version 11 | --------------------- 12 | 1. Update the project's status in the `README.md` file, if required. 13 | 2. Update the copyright statement in the `LICENSE.txt` file, if required. 14 | 3. Update the copyright statement in the `Directory.Build.props` file, if required. 15 | 4. Prepare a corresponding entry in the `CHANGELOG.md` file (usually by renaming the "Unreleased" section). 16 | 5. Set `` in the `Directory.Build.props` file. 17 | 6. Merge the aforementioned changes via a pull request. 18 | 7. Check if the NuGet keys are still valid (see the **Rotate NuGet Publishing Keys** section if they aren't). 19 | 8. Push a tag in form of `v`, e.g. `v0.0.0`. GitHub Actions will do the rest (push a NuGet packages). 20 | 21 | Rotate NuGet Publishing Keys 22 | ---------------------------- 23 | CI relies on NuGet API keys being added to the secrets. 24 | From time to time, these keys require maintenance: they will become obsolete and will have to be updated. 25 | 26 | To update the key: 27 | 28 | 1. Sign in onto nuget.org. 29 | 2. Go to the [API keys][nuget.api-keys] section. 30 | 3. Update the existing or create a new key named `truepath.github` with a permission to **Push only new package versions** and only allowed to publish the package **TruePath**. 31 | 32 | (If this is the first publication of a new package, 33 | upload a temporary short-living key with permission to add new packages and rotate it afterward.) 34 | 4. Update the existing or create a new key named `truepath.systemio.github` with a permission to **Push only new package versions** and only allowed to publish the package **TruePath.SystemIo**. 35 | 36 | (If this is the first publication of a new package, 37 | upload a temporary short-living key with permission to add new packages and rotate it afterward.) 38 | 5. Paste the generated keys, correspondingly, to the `NUGET_TOKEN_TRUE_PATH` and `NUGET_TOKEN_TRUE_PATH_SYSTEM_IO` variables on the [action secrets][github.secrets] section of GitHub settings. 39 | 40 | [github.secrets]: https://github.com/ForNeVeR/TruePath/settings/secrets/actions 41 | [nuget.api-keys]: https://www.nuget.org/account/apikeys 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | TruePath [![Status Ventis][status-ventis]][andivionian-status-classifier] 8 | ======== 9 | This is a library containing a set of types to work with file system paths in .NET. 10 | 11 | Motivation 12 | ---------- 13 | Historically, .NET has been lacking a good set of types to work with file system paths. The `System.IO.Path` class has a variety of methods that operate on path strings, but it doesn't provide any types to represent paths themselves. It's impossible to tell whether a method accepts an absolute path, a relative path, a file name, or something file-related at all, only looking at its signature: all these types are represented by plain strings. Also, comparing different paths is not straightforward. 14 | 15 | This library aims to fill this gap by providing a set of types that represent paths in a strongly-typed way. Now, you can require a path in a method's parameters, and it is guaranteed that the passed path will be well-formed and will have certain properties. 16 | 17 | Also, the methods in the library provide some qualities that are missing from the `System.IO.Path`: say, we aim to provide several ways of path normalization and comparison, the ones that will and will not perform disk IO to resolve paths on case-insensitive file systems. 18 | 19 | The library is inspired by the path libraries used in other ecosystems: in particular, Java's [java.nio.file.Path][java.path] and [Kotlin's extensions][kotlin.path]. 20 | 21 | Read more on [the documentation site][docs]. 22 | 23 | If you miss some feature or have questions, do not hesitate to [open an issue][issues] or [go to the discussions section][discussions]. 24 | 25 | Packages 26 | -------- 27 | | Name | NuGet Package | Documentation | 28 | |-----------------------|-------------------------------------------------------------------------------------------------|-------------------------------------------| 29 | | **TruePath** | [![TruePath on nuget.org][nuget.badge.true-path]][nuget.true-path] | [API Reference][docs.true-path] | 30 | | **TruePath.SystemIo** | [![TruePath.SystemIo on nuget.org][nuget.badge.true-path.system-io]][nuget.true-path.system-io] | [API Reference][docs.true-path.system-io] | 31 | 32 | TruePath provides two NuGet packages: 33 | - [**TruePath**][nuget.true-path] for the main path abstractions, 34 | - [**TruePath.SystemIo**][nuget.true-path.system-io] for the `System.IO` integration. 35 | 36 | Documentation 37 | ------------- 38 | - [Project Documentation Site][docs] 39 | - [Changelog][docs.changelog] 40 | - [Contributor Guide][docs.contributing] 41 | - [Maintainer Guide][docs.maintaining] 42 | 43 | License 44 | ------- 45 | The project is distributed under the terms of [the MIT license][docs.license]. 46 | 47 | The license indication in the project's sources is compliant with the [REUSE specification v3.3][reuse.spec]. 48 | 49 | [andivionian-status-classifier]: https://andivionian.fornever.me/v1/#status-ventis- 50 | [discussions]: https://github.com/ForNeVeR/TruePath/discussions 51 | [docs.changelog]: CHANGELOG.md 52 | [docs.contributing]: CONTRIBUTING.md 53 | [docs.license]: LICENSE.txt 54 | [docs.maintaining]: MAINTAINING.md 55 | [docs.true-path.system-io]: https://fornever.github.io/TruePath/api/TruePath.SystemIo.html 56 | [docs.true-path]: https://fornever.github.io/TruePath/api/TruePath.html 57 | [docs]: https://fornever.github.io/TruePath 58 | [issues]: https://github.com/ForNeVeR/TruePath/issues 59 | [java.path]: https://docs.oracle.com/en%2Fjava%2Fjavase%2F21%2Fdocs%2Fapi%2F%2F/java.base/java/nio/file/Path.html 60 | [kotlin.path]: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.io.path/java.nio.file.-path/ 61 | [nuget.badge.true-path.system-io]: https://img.shields.io/nuget/v/TruePath.SystemIo 62 | [nuget.badge.true-path]: https://img.shields.io/nuget/v/TruePath 63 | [nuget.true-path.system-io]: https://www.nuget.org/packages/TruePath.SystemIo 64 | [nuget.true-path]: https://www.nuget.org/packages/TruePath 65 | [reuse.spec]: https://reuse.software/spec-3.3/ 66 | [status-ventis]: https://img.shields.io/badge/status-ventis-yellow.svg 67 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | version = 1 6 | SPDX-PackageName = "TruePath" 7 | SPDX-PackageSupplier = "Friedrich von Never " 8 | SPDX-PackageDownloadLocation = "https://github.com/ForNeVeR/TruePath" 9 | 10 | [[annotations]] 11 | path = ".idea/**/**" 12 | precedence = "aggregate" 13 | SPDX-FileCopyrightText = "2024 Friedrich von Never " 14 | SPDX-License-Identifier = "MIT" 15 | 16 | [[annotations]] 17 | path = "**.DotSettings" 18 | precedence = "aggregate" 19 | SPDX-FileCopyrightText = "2024 Friedrich von Never " 20 | SPDX-License-Identifier = "MIT" 21 | -------------------------------------------------------------------------------- /TruePath.Benchmarks/DriveLetter.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Buffers; 6 | 7 | namespace TruePath.Benchmarks; 8 | 9 | public static class DriveLetter 10 | { 11 | private static readonly SearchValues DriveLetters = SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"); 12 | 13 | internal const char VolumeSeparatorChar = ':'; 14 | 15 | internal static bool UseLatinLetterRange(ReadOnlySpan source) 16 | { 17 | if (source.Length < 2) 18 | { 19 | return false; 20 | } 21 | 22 | return source[1] == VolumeSeparatorChar && (uint)((source[0] | 0x20) - 'a') <= 'z' - 'a'; 23 | } 24 | 25 | internal static bool UseSearchValues(ReadOnlySpan source) 26 | { 27 | if (source.Length < 2) return false; 28 | 29 | var letter = source[0]; 30 | var colon = source[1]; 31 | 32 | return DriveLetters.Contains(letter) && colon == ':'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /TruePath.Benchmarks/DriveLetterBenchmark.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using BenchmarkDotNet.Attributes; 6 | 7 | namespace TruePath.Benchmarks; 8 | 9 | public class DriveLetterBenchmark 10 | { 11 | public static IEnumerable ValuesForInput => 12 | [ 13 | "A://", "Z://", "a://", "z://", 14 | "K://", "k://", "R://", "r://", 15 | "//", "foobar", 16 | ]; 17 | 18 | [ParamsSource(nameof(ValuesForInput))] public string Input { get; set; } = null!; 19 | 20 | [Benchmark] 21 | public bool UseLatinLetterRange() 22 | { 23 | return DriveLetter.UseLatinLetterRange(Input); 24 | } 25 | 26 | [Benchmark] 27 | public bool UseSearchValues() 28 | { 29 | return DriveLetter.UseSearchValues(Input); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /TruePath.Benchmarks/PathStrings.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Buffers; 6 | using System.Runtime.CompilerServices; 7 | using System.Text; 8 | 9 | namespace TruePath.Benchmarks; 10 | 11 | /// Helper methods to manipulate paths as strings. 12 | public static class PathStrings 13 | { 14 | public static string Normalize1(string path) 15 | { 16 | // TODO[#19]: Optimize this. It is possible to do with less allocations. 17 | var segments = new List<(int Start, int End)>(); 18 | 19 | int? currentSegmentStart = 0; 20 | for (var i = 0; i < path.Length; i++) 21 | { 22 | if (path[i] == Path.DirectorySeparatorChar || path[i] == Path.AltDirectorySeparatorChar) 23 | { 24 | if (currentSegmentStart is { } s) 25 | { 26 | segments.Add((s, i)); 27 | currentSegmentStart = null; 28 | } 29 | } 30 | else 31 | { 32 | currentSegmentStart ??= i; 33 | } 34 | } 35 | 36 | if (currentSegmentStart is { } start) 37 | segments.Add((start, path.Length)); 38 | 39 | var resultSegments = new LinkedList<(int Start, int End)>(); 40 | foreach (var segment in segments) 41 | { 42 | var text = path.AsSpan()[segment.Start..segment.End]; 43 | switch (text) 44 | { 45 | case ".": 46 | continue; 47 | case ".." when resultSegments.Count > 0 48 | // check for the root segment (empty) 49 | && resultSegments.Last!.Value.Start != resultSegments.Last.Value.End: 50 | resultSegments.RemoveLast(); 51 | break; 52 | default: 53 | resultSegments.AddLast(segment); 54 | break; 55 | } 56 | } 57 | 58 | var buffer = new StringBuilder(); 59 | var index = 0; 60 | foreach (var segment in resultSegments) 61 | { 62 | buffer.Append(path, segment.Start, segment.End - segment.Start); 63 | if (++index < resultSegments.Count 64 | // check for the root segment: in such case, we still want to add the separator 65 | || segment.Start == segment.End) 66 | buffer.Append(Path.DirectorySeparatorChar); 67 | } 68 | 69 | return buffer.ToString(); 70 | } 71 | 72 | /// 73 | /// 74 | /// Will convert a path string to a normalized path, using path separator specific for the current system. 75 | /// 76 | /// 77 | /// The normalization includes: 78 | /// 79 | /// 80 | /// converting all the to 81 | /// (e.g. / to \ on Windows), 82 | /// 83 | /// 84 | /// collapsing any repeated separators in the input to only one separator (e.g. // to just 85 | /// / on Unix), 86 | /// 87 | /// 88 | /// resolving any sequence of current and parent directory marks (subsequently, . and ..) 89 | /// if possible (meaning they will not be replaced if they are in the root position: paths such as 90 | /// . or ../.. will not be affected by the normalization, while e.g. foo/../. will 91 | /// be resolved to just foo). 92 | /// 93 | /// 94 | /// 95 | /// 96 | /// Note that this operation will never perform any file IO, and is purely string manipulation. 97 | /// 98 | /// 99 | [SkipLocalsInit] // is necessary to prevent the CLR from filling stackalloc with zeros. 100 | public static string Normalize2(string path) 101 | { 102 | int written = 0; 103 | 104 | char[]? array = path.Length < (IntPtr.Size == 4 ? 512 : 4096) ? null : ArrayPool.Shared.Rent(path.Length); 105 | 106 | Span normalized = array != null ? array.AsSpan() : stackalloc char[path.Length]; 107 | ReadOnlySpan source = path.AsSpan(); 108 | 109 | var buffer = normalized; 110 | 111 | while (true) 112 | { 113 | bool last = false; 114 | var separator = source.IndexOf(Path.DirectorySeparatorChar); 115 | var altSeparator = source.IndexOf(Path.AltDirectorySeparatorChar); 116 | 117 | if (altSeparator == -1 && separator == -1) { last = true; separator = source.Length - 1; } 118 | else if (separator == -1) separator = altSeparator; 119 | else if (altSeparator == -1) { } 120 | else separator = Math.Min(separator, altSeparator); 121 | 122 | separator++; 123 | var block = source.Slice(0, separator); 124 | 125 | bool skip; 126 | // skip if '.' 127 | if (block.Length == 1 && block[0] == '.') 128 | skip = true; 129 | // skip if './' 130 | else if (block.Length == 2 && block[0] == '.' && (block[1] == Path.DirectorySeparatorChar || block[1] == Path.AltDirectorySeparatorChar)) 131 | skip = true; 132 | // cut if '..' or '../' 133 | else if (written != 0 && block.Length is 2 or 3 && block.StartsWith("..")) 134 | { 135 | var jump = normalized.Slice(0, written - 1).LastIndexOf(Path.DirectorySeparatorChar); 136 | 137 | if (jump == -1 && written > 1) 138 | { 139 | written = 0; 140 | buffer = normalized; 141 | skip = true; 142 | } 143 | else if (jump != -1) 144 | { 145 | written = jump; 146 | buffer = normalized.Slice(written + 1); 147 | skip = true; 148 | } 149 | else 150 | skip = false; 151 | } 152 | else 153 | skip = false; 154 | 155 | // append sliced path 156 | if (!skip) 157 | { 158 | block.CopyTo(buffer); 159 | written += separator; 160 | // replace \ with / if ends with \ 161 | if (separator > 0 && buffer[separator - 1] == Path.AltDirectorySeparatorChar) 162 | buffer[separator - 1] = Path.DirectorySeparatorChar; 163 | buffer = buffer.Slice(separator); 164 | } 165 | 166 | // skip the following / or \ 167 | while (separator < source.Length && (source[separator] == Path.DirectorySeparatorChar || source[separator] == Path.AltDirectorySeparatorChar)) 168 | separator++; 169 | 170 | // next iter 171 | source = source.Slice(separator); 172 | // append everything else if there`s no more '\' or '/' 173 | if (last) 174 | { 175 | source.CopyTo(buffer); 176 | written += source.Length; 177 | break; 178 | } 179 | } 180 | 181 | if (array != null) 182 | ArrayPool.Shared.Return(array); 183 | 184 | // why create an empty string when you can reuse it 185 | if (written == 0) 186 | return string.Empty; 187 | 188 | // remove / at the end of path 189 | if (written > 2 && normalized[written - 1] == Path.DirectorySeparatorChar) 190 | written--; 191 | 192 | // alloc new path 193 | return new string(normalized.Slice(0, written)); 194 | } 195 | 196 | public static string Normalize3(string path) 197 | { 198 | int written = 0; 199 | 200 | var array = ArrayPool.Shared.Rent(path.Length); 201 | Span normalized = array; 202 | ReadOnlySpan source = path.AsSpan(); 203 | 204 | var buffer = normalized; 205 | 206 | while (true) 207 | { 208 | bool last = false; 209 | var separator = source.IndexOf(Path.DirectorySeparatorChar); 210 | var altSeparator = source.IndexOf(Path.AltDirectorySeparatorChar); 211 | 212 | if (altSeparator == -1 && separator == -1) { last = true; separator = source.Length - 1; } 213 | else if (separator == -1) separator = altSeparator; 214 | else if (altSeparator == -1) { } 215 | else separator = Math.Min(separator, altSeparator); 216 | 217 | separator++; 218 | var block = source.Slice(0, separator); 219 | 220 | bool skip; 221 | // skip if '.' 222 | if (block.Length == 1 && block[0] == '.') 223 | skip = true; 224 | // skip if './' 225 | else if (block.Length == 2 && block[0] == '.' && (block[1] == Path.DirectorySeparatorChar || block[1] == Path.AltDirectorySeparatorChar)) 226 | skip = true; 227 | // cut if '..' or '../' 228 | else if (written != 0 && block.Length is 2 or 3 && block.StartsWith("..")) 229 | { 230 | var jump = normalized.Slice(0, written - 1).LastIndexOf(Path.DirectorySeparatorChar); 231 | 232 | if (jump == -1 && written > 1) 233 | { 234 | written = 0; 235 | buffer = normalized; 236 | skip = true; 237 | } 238 | else if (jump != -1) 239 | { 240 | written = jump; 241 | buffer = normalized.Slice(written + 1); 242 | skip = true; 243 | } 244 | else 245 | skip = false; 246 | } 247 | else 248 | skip = false; 249 | 250 | // append sliced path 251 | if (!skip) 252 | { 253 | block.CopyTo(buffer); 254 | written += separator; 255 | // replace \ with / if ends with \ 256 | if (separator > 0 && buffer[separator - 1] == Path.AltDirectorySeparatorChar) 257 | buffer[separator - 1] = Path.DirectorySeparatorChar; 258 | buffer = buffer.Slice(separator); 259 | } 260 | 261 | // skip the following / or \ 262 | while (separator < source.Length && (source[separator] == Path.DirectorySeparatorChar || source[separator] == Path.AltDirectorySeparatorChar)) 263 | separator++; 264 | 265 | // next iter 266 | source = source.Slice(separator); 267 | // append everything else if there`s no more '\' or '/' 268 | if (last) 269 | { 270 | source.CopyTo(buffer); 271 | written += source.Length; 272 | break; 273 | } 274 | } 275 | 276 | 277 | // why create an empty string when you can reuse it 278 | if (written == 0) 279 | return string.Empty; 280 | 281 | // remove / at the end of path 282 | if (written > 2 && normalized[written - 1] == Path.DirectorySeparatorChar) 283 | written--; 284 | 285 | // alloc new path 286 | var result = new string(normalized.Slice(0, written)); 287 | ArrayPool.Shared.Return(array); 288 | return result; 289 | } 290 | 291 | public static string Normalize4(string path) 292 | { 293 | int written = 0; 294 | 295 | var array = ArrayPool.Shared.Rent(path.Length); 296 | Span normalized = array; 297 | ReadOnlySpan source = path.AsSpan(); 298 | 299 | var buffer = normalized; 300 | 301 | while (true) 302 | { 303 | bool last = false; 304 | var sv = SearchValues.Create([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]); 305 | var separator = source.IndexOfAny(sv); 306 | 307 | if (separator == -1) { last = true; separator = source.Length - 1; } 308 | 309 | separator++; 310 | var block = source.Slice(0, separator); 311 | 312 | bool skip; 313 | // skip if '.' 314 | if (block.Length == 1 && block[0] == '.') 315 | skip = true; 316 | // skip if './' 317 | else if (block.Length == 2 && block[0] == '.' && sv.Contains(block[1])) 318 | skip = true; 319 | // cut if '..' or '../' 320 | else if (written != 0 && block.Length is 2 or 3 && block.StartsWith("..")) 321 | { 322 | var jump = normalized.Slice(0, written - 1).LastIndexOfAny(sv); 323 | 324 | if (jump == -1 && written > 1) 325 | { 326 | written = 0; 327 | buffer = normalized; 328 | skip = true; 329 | } 330 | else if (jump != -1) 331 | { 332 | written = jump; 333 | buffer = normalized.Slice(written + 1); 334 | skip = true; 335 | } 336 | else 337 | skip = false; 338 | } 339 | else 340 | skip = false; 341 | 342 | // append sliced path 343 | if (!skip) 344 | { 345 | block.CopyTo(buffer); 346 | written += separator; 347 | // replace \ with / if ends with \ 348 | if (separator > 0 && buffer[separator - 1] == Path.AltDirectorySeparatorChar) 349 | buffer[separator - 1] = Path.DirectorySeparatorChar; 350 | buffer = buffer.Slice(separator); 351 | } 352 | 353 | // skip the following / or \ 354 | while (separator < source.Length && sv.Contains(source[separator])) 355 | separator++; 356 | 357 | // next iter 358 | source = source.Slice(separator); 359 | // append everything else if there`s no more '\' or '/' 360 | if (last) 361 | { 362 | source.CopyTo(buffer); 363 | written += source.Length; 364 | break; 365 | } 366 | } 367 | 368 | // why create an empty string when you can reuse it 369 | if (written == 0) 370 | return string.Empty; 371 | 372 | // remove / at the end of path 373 | if (written > 2 && normalized[written - 1] == Path.DirectorySeparatorChar) 374 | written--; 375 | 376 | // alloc new path 377 | var result = new string(normalized.Slice(0, written)); 378 | ArrayPool.Shared.Return(array); 379 | return result; 380 | } 381 | 382 | public static string NormalizeWithWindowsDiskDrive(string path) 383 | { 384 | bool containsDriveLetter = SourceContainsDriveLetter(path.AsSpan()); 385 | 386 | int written = 0; 387 | 388 | char[]? array = path.Length <= 512 ? null : ArrayPool.Shared.Rent(path.Length); 389 | 390 | Span normalized = array != null ? array.AsSpan() : stackalloc char[path.Length]; 391 | ReadOnlySpan source = containsDriveLetter ? path.AsSpan()[2..] : path.AsSpan(); 392 | 393 | var buffer = normalized; 394 | 395 | while (true) 396 | { 397 | bool last = false; 398 | var separator = source.IndexOf(Path.DirectorySeparatorChar); 399 | var altSeparator = source.IndexOf(Path.AltDirectorySeparatorChar); 400 | 401 | if (altSeparator == -1 && separator == -1) { last = true; separator = source.Length - 1; } 402 | else if (separator == -1) separator = altSeparator; 403 | else if (altSeparator == -1) { } 404 | else separator = Math.Min(separator, altSeparator); 405 | 406 | separator++; 407 | var block = source.Slice(0, separator); 408 | 409 | bool skip; 410 | // skip if '.' 411 | if (block.Length == 1 && block[0] == '.') 412 | skip = true; 413 | // skip if './' 414 | else if (block.Length == 2 && block[0] == '.' && (block[1] == Path.DirectorySeparatorChar || block[1] == Path.AltDirectorySeparatorChar)) 415 | skip = true; 416 | // cut if '..' or '../' 417 | else if (written != 0 && block.Length is 2 or 3 && block.StartsWith("..")) 418 | { 419 | var alreadyWrittenPart = normalized[..(written - 1)]; 420 | var jump = alreadyWrittenPart.LastIndexOf(Path.DirectorySeparatorChar); 421 | 422 | // Check if the last entry in the normalized path is "..": in this case, no need to skip (we keep a 423 | // train of ../../.. in the normalized path's root because they are impossible to get rid of during 424 | // normalization). 425 | var lastEntryStartIndex = jump + 1; 426 | var lastEntry = alreadyWrittenPart[lastEntryStartIndex..]; 427 | if (lastEntry is "..") 428 | { 429 | skip = false; 430 | } 431 | else if (jump == -1 && written > 1) 432 | { 433 | written = 0; 434 | buffer = normalized; 435 | skip = true; 436 | } 437 | else if (jump != -1) 438 | { 439 | written = jump; 440 | buffer = normalized.Slice(written + 1); 441 | skip = true; 442 | } 443 | else 444 | skip = false; 445 | } 446 | else 447 | skip = false; 448 | 449 | // append sliced path 450 | if (!skip) 451 | { 452 | block.CopyTo(buffer); 453 | written += separator; 454 | // replace \ with / if ends with \ 455 | if (separator > 0 && buffer[separator - 1] == Path.AltDirectorySeparatorChar) 456 | buffer[separator - 1] = Path.DirectorySeparatorChar; 457 | buffer = buffer.Slice(separator); 458 | } 459 | 460 | // skip the following / or \ 461 | while (separator < source.Length && (source[separator] == Path.DirectorySeparatorChar || source[separator] == Path.AltDirectorySeparatorChar)) 462 | separator++; 463 | 464 | // next iter 465 | source = source.Slice(separator); 466 | // append everything else if there`s no more '\' or '/' 467 | if (last) 468 | { 469 | source.CopyTo(buffer); 470 | written += source.Length; 471 | break; 472 | } 473 | } 474 | 475 | // why create an empty string when you can reuse it 476 | if (written == 0 && containsDriveLetter) 477 | { 478 | return new string(path.AsSpan(0, 2)); 479 | } 480 | 481 | if (written == 0) 482 | { 483 | return string.Empty; 484 | } 485 | 486 | // remove / at the end of path 487 | if (written > 2 && normalized[written - 1] == Path.DirectorySeparatorChar) 488 | written--; 489 | 490 | // alloc new path 491 | string? result; 492 | 493 | if (containsDriveLetter) 494 | { 495 | var normalizedRef = new ReadOnlySpan(normalized.ToArray(), 0, written); 496 | result = string.Concat(path.AsSpan(0, 2), normalizedRef.Slice(0, written)); 497 | } 498 | else 499 | { 500 | result = new string(normalized.Slice(0, written)); 501 | } 502 | 503 | normalized.Slice(0, written); 504 | if (array != null) 505 | ArrayPool.Shared.Return(array); 506 | return result; 507 | } 508 | 509 | private static readonly SearchValues DriveLetters = SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZ"); 510 | private static bool SourceContainsDriveLetter(ReadOnlySpan source) 511 | { 512 | if (source.Length < 2) return false; 513 | 514 | var letter = source[0]; 515 | var colon = source[1]; 516 | 517 | return DriveLetters.Contains(letter) && colon == ':'; 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /TruePath.Benchmarks/Program.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using BenchmarkDotNet.Attributes; 6 | using BenchmarkDotNet.Running; 7 | 8 | namespace TruePath.Benchmarks; 9 | 10 | public class NormalizePathBenchmark 11 | { 12 | private const int N = 10000; 13 | 14 | public IEnumerable ValuesForInput => new[] 15 | { 16 | ".", "./foo", "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z", "a/../../.", 17 | "foo/./sdfaadfsf/safd/../fafdasf/asdfads/adsadsa/das/./../.." 18 | }; 19 | 20 | [ParamsSource(nameof(ValuesForInput))] public string Input { get; set; } = null!; 21 | 22 | [Benchmark] 23 | public string[] Normalize1() 24 | { 25 | var pp = new string[N]; 26 | for (var i = 0; i < N; ++i) 27 | { 28 | pp[i] = PathStrings.Normalize1(Input); 29 | } 30 | return pp; 31 | } 32 | 33 | [Benchmark] 34 | public string[] Normalize2() 35 | { 36 | var pp = new string[N]; 37 | for (int i = 0; i < N; ++i) 38 | { 39 | pp[i] = PathStrings.Normalize2(Input); 40 | } 41 | return pp; 42 | } 43 | 44 | [Benchmark] 45 | public string[] Normalize3() 46 | { 47 | var pp = new string[N]; 48 | for (int i = 0; i < N; ++i) 49 | { 50 | pp[i] = PathStrings.Normalize3(Input); 51 | } 52 | return pp; 53 | } 54 | 55 | [Benchmark] 56 | public string[] Normalize4() 57 | { 58 | var pp = new string[N]; 59 | for (int i = 0; i < N; ++i) 60 | { 61 | pp[i] = PathStrings.Normalize4(Input); 62 | } 63 | return pp; 64 | } 65 | 66 | [Benchmark] 67 | public string[] NormalizeWithWindowsDiskDrive() 68 | { 69 | var pp = new string[N]; 70 | for (int i = 0; i < N; ++i) 71 | { 72 | pp[i] = PathStrings.NormalizeWithWindowsDiskDrive(Input); 73 | } 74 | return pp; 75 | } 76 | } 77 | 78 | public class Program 79 | { 80 | public static void Main(string[] args) 81 | { 82 | _ = BenchmarkRunner.Run(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /TruePath.Benchmarks/TruePath.Benchmarks.csproj: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | Exe 11 | false 12 | false 13 | true 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /TruePath.SystemIo/TruePath.SystemIo.csproj: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | true 11 | Adapters to use System.IO.File and System.IO.Directory APIs together with TruePath. 12 | TruePath.SystemIO 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /TruePath.Tests/AbsolutePathTests.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Diagnostics; 6 | 7 | namespace TruePath.Tests; 8 | 9 | public class AbsolutePathTests 10 | { 11 | [Fact] 12 | public void ConstructionTest() 13 | { 14 | var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); 15 | var path = new AbsolutePath($"{root}/..."); 16 | Assert.Equal($"{root}...", path.Value); 17 | } 18 | 19 | [Fact] 20 | public void ReadKind_NonExistent() 21 | { 22 | // Arrange 23 | var currentDirectory = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); 24 | var localPath = new AbsolutePath(currentDirectory); 25 | 26 | // Act 27 | var kind = localPath.ReadKind(); 28 | 29 | // Assert 30 | Assert.Null(kind); 31 | } 32 | 33 | [Fact] 34 | public void ReadKind_IsDirectory() 35 | { 36 | // Arrange 37 | var currentDirectory = Environment.CurrentDirectory; 38 | var localPath = new AbsolutePath(currentDirectory); 39 | 40 | // Act 41 | var kind = localPath.ReadKind(); 42 | 43 | // Assert 44 | Assert.Equal(FileEntryKind.Directory, kind); 45 | } 46 | 47 | [Fact] 48 | public void ReadKind_IsFile() 49 | { 50 | // Arrange 51 | string tempFilePath = Path.GetTempFileName(); 52 | var localPath = new AbsolutePath(tempFilePath); 53 | 54 | // Act 55 | var kind = localPath.ReadKind(); 56 | 57 | // Assert 58 | Assert.Equal(FileEntryKind.File, kind); 59 | } 60 | 61 | [Fact] 62 | public void OnWindows_ReadKind_IsJunction() 63 | { 64 | // Arrange 65 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; 66 | 67 | var currentDirectory = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); 68 | var localPath = new AbsolutePath(currentDirectory); 69 | 70 | var tempDirectoryInfo = Path.GetTempPath(); 71 | 72 | var created = CreateJunction(currentDirectory, tempDirectoryInfo); 73 | 74 | Assert.True(created); 75 | 76 | // Act 77 | var kind = localPath.ReadKind(); 78 | 79 | // Assert 80 | Assert.Equal(FileEntryKind.Junction, kind); 81 | 82 | Directory.Delete(currentDirectory, true); 83 | } 84 | 85 | [Fact] 86 | public void ReadKind_IsSymlink() 87 | { 88 | // Arrange 89 | var currentDirectory = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); 90 | var localPath = new AbsolutePath(currentDirectory); 91 | 92 | var tempDirectoryInfo = Path.GetTempPath(); 93 | 94 | Directory.CreateSymbolicLink(currentDirectory, tempDirectoryInfo); 95 | 96 | // Act 97 | var kind = localPath.ReadKind(); 98 | 99 | // Assert 100 | Assert.Equal(FileEntryKind.Symlink, kind); 101 | 102 | Directory.Delete(currentDirectory, true); 103 | } 104 | 105 | [Theory] 106 | [InlineData("foo", ".")] 107 | [InlineData("foo/bar", "foo")] 108 | [InlineData("/", null)] 109 | public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRelativePath) 110 | { 111 | var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); 112 | var parent = root / relativePath; 113 | AbsolutePath? expectedPath = expectedRelativePath == null ? null : new(root / expectedRelativePath); 114 | Assert.Equal(expectedPath, parent.Parent); 115 | } 116 | 117 | [Theory] 118 | [InlineData("/home/user", "/home/user/documents")] 119 | [InlineData("/home/usEr", "/home/User/documents")] 120 | [InlineData("/home/user/documents", "/home/user/documents")] 121 | [InlineData("/home/user/documents", "/home/user")] 122 | public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB) 123 | { 124 | if (OperatingSystem.IsWindows()) return; 125 | 126 | // Arrange 127 | var a = new AbsolutePath(pathA); 128 | var b = new AbsolutePath(pathB); 129 | 130 | // Act 131 | var isPrefix = a.IsPrefixOf(b); 132 | var startsWith = b.Value.StartsWith(a.Value); 133 | 134 | // Assert 135 | Assert.Equal(isPrefix, startsWith); 136 | } 137 | 138 | [Fact] 139 | public void CurrentWorkingDirectoryShouldReturnCorrectAbsolutePath() 140 | { 141 | // Arrange 142 | var expectedPath = Directory.GetCurrentDirectory(); 143 | 144 | // Act 145 | var actualPath = AbsolutePath.CurrentWorkingDirectory; 146 | 147 | // Assert 148 | Assert.Equal(expectedPath, actualPath.Value); 149 | } 150 | 151 | [Fact] 152 | public void CurrentWorkingDirectoryShouldBeAbsolute() 153 | { 154 | // Act 155 | var path = AbsolutePath.CurrentWorkingDirectory; 156 | 157 | // Assert 158 | Assert.True(Path.IsPathRooted(path.Value)); 159 | } 160 | 161 | [Fact] 162 | public void CurrentWorkingDirectoryGetsChanged() 163 | { 164 | var prevPath = AbsolutePath.CurrentWorkingDirectory; 165 | var path = new AbsolutePath(Environment.ProcessPath!).Parent!.Value; 166 | try 167 | { 168 | AbsolutePath.CurrentWorkingDirectory = path; 169 | Assert.Equal(path, new AbsolutePath(Environment.CurrentDirectory)); 170 | } 171 | finally 172 | { 173 | AbsolutePath.CurrentWorkingDirectory = prevPath; 174 | } 175 | } 176 | 177 | [Fact] 178 | public void PathIsNormalizedOnCreation() 179 | { 180 | if (!OperatingSystem.IsWindows()) return; 181 | 182 | const string path = @"C:/Users/John Doe\Documents"; 183 | var absolutePath = new AbsolutePath(path); 184 | Assert.Equal(@"C:\Users\John Doe\Documents", absolutePath.Value); 185 | } 186 | 187 | [Fact] 188 | public void ConstructorThrowsOnNonRootedPath() 189 | { 190 | const string path = "uprooted"; 191 | const string expectedMessage = $"Path \"{path}\" is not absolute."; 192 | var ex = Assert.Throws(() => new AbsolutePath(path)); 193 | Assert.Equal(expectedMessage, ex.Message); 194 | 195 | var localPath = new LocalPath("uprooted"); 196 | ex = Assert.Throws(() => new AbsolutePath(localPath)); 197 | Assert.Equal(expectedMessage, ex.Message); 198 | } 199 | 200 | [Theory] 201 | [InlineData("/etc/bin", "/usr/bin", "../../usr/bin")] 202 | [InlineData("/usr/bin/log", "/usr/bin", "..")] 203 | [InlineData("/usr/bin", "/usr/bin/log", "log")] 204 | public void RelativeToReturnsCorrectRelativePath(string from, string to, string expected) 205 | { 206 | if (OperatingSystem.IsWindows()) return; 207 | 208 | var fromPath = new AbsolutePath(from); 209 | var toPath = new AbsolutePath(to); 210 | 211 | LocalPath relativePath = toPath.RelativeTo(fromPath); 212 | 213 | Assert.Equal(expected, relativePath.Value); 214 | } 215 | 216 | [Theory] 217 | [InlineData(@"C:\bin", @"D:\bin", @"D:\bin")] 218 | [InlineData(@"C:\bin\debug", @"C:\bin", "..")] 219 | [InlineData(@"C:\bin", @"C:\bin\log", "log")] 220 | public void RelativeToReturnsCorrectRelativePathForWindows(string from, string to, string expected) 221 | { 222 | if (OperatingSystem.IsWindows() is false) return; 223 | 224 | var fromPath = new AbsolutePath(from); 225 | var toPath = new AbsolutePath(to); 226 | 227 | LocalPath relativePath = toPath.RelativeTo(fromPath); 228 | 229 | Assert.Equal(expected, relativePath.Value); 230 | } 231 | 232 | [Fact] 233 | public void CanonicalizationCaseOnMacOs() 234 | { 235 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return; 236 | 237 | var newDirectory = new AbsolutePath(Path.GetTempFileName()).Canonicalize(); 238 | File.Delete(newDirectory.ToString()); 239 | newDirectory /= "foobar"; 240 | Directory.CreateDirectory(newDirectory.Value); 241 | 242 | var incorrectCaseDirectory = newDirectory.Parent!.Value / "FOOBAR"; 243 | var result = incorrectCaseDirectory.Canonicalize(); 244 | Assert.Equal(newDirectory.Value, result.Value); 245 | } 246 | 247 | [Fact] 248 | public void PlatformDefaultPathComparerTest() 249 | { 250 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; 251 | 252 | var path1 = new AbsolutePath(@"C:\Windows"); 253 | var path2 = new AbsolutePath(@"C:\WINDOWS"); 254 | 255 | Assert.True(path1.Equals(path2, AbsolutePath.PlatformDefaultComparer)); 256 | } 257 | 258 | [Fact] 259 | public void EqualsUseStrictStringPathComparer_SamePaths_True() 260 | { 261 | // Arrange 262 | var currentDirectory = Environment.CurrentDirectory; 263 | var nonCanonicalPath = currentDirectory; 264 | 265 | var path1 = new AbsolutePath(currentDirectory); 266 | var path2 = new AbsolutePath(nonCanonicalPath); 267 | 268 | // Act 269 | var equals = path1.Equals(path2, AbsolutePath.StrictStringComparer); 270 | 271 | // Assert 272 | Assert.True(equals); 273 | } 274 | 275 | [Fact] 276 | public void EqualsUseStrictStringPathComparer_NotSamePaths_False() 277 | { 278 | // Arrange 279 | var currentDirectory = Environment.CurrentDirectory; 280 | var nonCanonicalPath = new string(currentDirectory.ToNonCanonicalCase().ToArray()); 281 | 282 | var path1 = new AbsolutePath(currentDirectory); 283 | var path2 = new AbsolutePath(nonCanonicalPath); 284 | 285 | // Act 286 | var equals = path1.Equals(path2, AbsolutePath.StrictStringComparer); 287 | 288 | // Assert 289 | Assert.False(equals); 290 | } 291 | 292 | [Fact] 293 | public void OnLinux_EqualsDefault_CaseSensitive_False() 294 | { 295 | // Arrange 296 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 297 | { 298 | return; 299 | } 300 | 301 | var currentDirectory = Environment.CurrentDirectory; 302 | var nonCanonicalPath = new string(currentDirectory.ToNonCanonicalCase().ToArray()); 303 | 304 | var path1 = new AbsolutePath(currentDirectory); 305 | var path2 = new AbsolutePath(nonCanonicalPath); 306 | 307 | // Act 308 | var equals = path1.Equals(path2); 309 | 310 | // Assert 311 | Assert.False(equals); 312 | } 313 | 314 | [Fact] 315 | public void OnWindowsOrOsx_EqualsDefault_CaseInsensitive_True() 316 | { 317 | // Arrange 318 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 319 | { 320 | return; 321 | } 322 | 323 | var currentDirectory = Environment.CurrentDirectory; 324 | var nonCanonicalPath = new string(currentDirectory.ToNonCanonicalCase().ToArray()); 325 | 326 | var path1 = new AbsolutePath(currentDirectory); 327 | var path2 = new AbsolutePath(nonCanonicalPath); 328 | 329 | // Act 330 | var equals = path1.Equals(path2); 331 | 332 | // Assert 333 | Assert.True(equals); 334 | } 335 | 336 | [Theory] 337 | [InlineData("/path/to/file.txt", "/path/to/file.txt", 0)] 338 | [InlineData("/path/to/file.txt", "/PATH/TO/FILE.TXT", 1)] 339 | [InlineData("/PATH/TO/FILE.TXT", "/path/to/file.txt", -1)] 340 | [InlineData("/path/to/apple", "/path/to/banana", -1)] 341 | [InlineData("/path/to/banana", "/path/to/apple", 1)] 342 | [InlineData("/path/to/folder", "/path/to/folder/subfolder", -1)] 343 | [InlineData("/path/to/folder/subfolder", "/path/to/folder", 1)] 344 | public void PlatformDefaultAbsolutePathOrderingTest_Linux( 345 | string firstPathString, 346 | string secondPathString, 347 | int expected) 348 | { 349 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 350 | { 351 | return; 352 | } 353 | 354 | PlatformDefaultAbsolutePathOrderingTestBase(firstPathString, secondPathString, expected); 355 | } 356 | 357 | [Theory] 358 | [InlineData("/path/to/file.txt", "/path/to/file.txt", 0)] 359 | [InlineData("/path/to/file.txt", "/PATH/TO/FILE.TXT", 0)] 360 | [InlineData("/PATH/TO/FILE.TXT", "/path/to/file.txt", 0)] 361 | [InlineData("/path/to/apple", "/path/to/banana", -1)] 362 | [InlineData("/path/to/banana", "/path/to/apple", 1)] 363 | [InlineData("/path/to/folder", "/path/to/folder/subfolder", -1)] 364 | [InlineData("/path/to/folder/subfolder", "/path/to/folder", 1)] 365 | public void PlatformDefaultAbsolutePathOrderingTest_MacOs( 366 | string firstPathString, 367 | string secondPathString, 368 | int expected) 369 | { 370 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 371 | { 372 | return; 373 | } 374 | 375 | PlatformDefaultAbsolutePathOrderingTestBase(firstPathString, secondPathString, expected); 376 | } 377 | 378 | [Theory] 379 | [InlineData(@"C:\path\to\folder", @"C:\path\to\folder\subfolder", -1)] 380 | [InlineData(@"C:\path\to\folder\subfolder", @"C:\path\to\folder", 1)] 381 | [InlineData(@"C:\path", @"D:\path", -1)] 382 | [InlineData(@"D:\path", @"C:\path", 1)] 383 | [InlineData(@"C:\path\to\apple", @"C:\path\to\banana", -1)] 384 | [InlineData(@"C:\path\to\file.txt", @"C:\PATH\TO\FILE.TXT", 0)] 385 | [InlineData(@"C:\PATH\TO\FILE.TXT", @"C:\path\to\file.txt", 0)] 386 | [InlineData(@"C:\path\to\file.txt", @"C:\path\to\file.txt", 0)] 387 | public void PlatformDefaultAbsolutePathOrderingTest_Windows( 388 | string firstPathString, 389 | string secondPathString, 390 | int expected) 391 | { 392 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 393 | { 394 | return; 395 | } 396 | 397 | PlatformDefaultAbsolutePathOrderingTestBase(firstPathString, secondPathString, expected); 398 | } 399 | 400 | private static void PlatformDefaultAbsolutePathOrderingTestBase( 401 | string firstPathString, 402 | string secondPathString, 403 | int expected) 404 | { 405 | // Arrange 406 | var firstPath = new AbsolutePath(firstPathString); 407 | var secondPath = new AbsolutePath(secondPathString); 408 | var comparer = AbsolutePath.PlatformDefaultComparer; 409 | 410 | // Act 411 | var comparisonResult = comparer.Compare(firstPath, secondPath); 412 | 413 | // Assert 414 | Assert.Equal(expected, Math.Sign(comparisonResult)); 415 | } 416 | 417 | private static bool CreateJunction(string path, string target) 418 | { 419 | return Mklink(path, target, "J"); 420 | } 421 | 422 | private static bool Mklink(string path, string target, string type) 423 | { 424 | string cmdline = $"cmd /c mklink /{type} {path} {target}"; 425 | 426 | ProcessStartInfo si = new ProcessStartInfo("cmd.exe", cmdline) 427 | { 428 | UseShellExecute = false 429 | }; 430 | 431 | Process? p = Process.Start(si); 432 | if (p == null) 433 | { 434 | return false; 435 | } 436 | 437 | p.WaitForExit(); 438 | 439 | return p.ExitCode == 0; 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /TruePath.Tests/DiskUtilsTests.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Text; 6 | using RuntimeInformation = System.Runtime.InteropServices.RuntimeInformation; 7 | 8 | namespace TruePath.Tests; 9 | 10 | public class DiskUtilsTests 11 | { 12 | [Fact] 13 | public void DiskUtils_PassBackPath_ReturnCanonicalPath() 14 | { 15 | var tempPath = new AbsolutePath(Path.GetTempPath()).Canonicalize(); 16 | var expected = tempPath.Value; 17 | var nonCanonicalPath = (tempPath / "foobar" / "..").Value; 18 | 19 | // Act 20 | var actual = DiskUtils.GetRealPath(nonCanonicalPath); 21 | 22 | // Assert 23 | Assert.Equal(expected, actual); 24 | } 25 | 26 | [Fact] 27 | public void DiskUtils_OnCaseInsensitiveFs_PassNonCanonicalPath_ReturnCanonicalPath() 28 | { 29 | // Arrange 30 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return; 31 | 32 | var expected = new AbsolutePath(Path.GetTempPath()).Canonicalize(); 33 | Assert.True( 34 | expected.Value.Split(Path.DirectorySeparatorChar)[1].Length > 0, 35 | $"""There should be at least one directory in the temporary path "{expected}" for this test."""); 36 | var nonCanonicalPath = InvertCase(expected.Value); 37 | Assert.NotEqual(expected.Value, nonCanonicalPath); 38 | 39 | // Act 40 | var actual = DiskUtils.GetRealPath(nonCanonicalPath); 41 | 42 | // Assert 43 | Assert.Equal(expected.Value, actual); 44 | } 45 | 46 | private static string InvertCase(string path) 47 | { 48 | var builder = new StringBuilder(); 49 | foreach (var c in path) 50 | { 51 | builder.Append(char.IsUpper(c) ? char.ToLower(c) : char.ToUpper(c)); 52 | } 53 | 54 | return builder.ToString(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /TruePath.Tests/GenericInterfaceTests.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath.Tests; 6 | 7 | public class GenericInterfaceTests 8 | { 9 | [Fact] 10 | public void ParentTests() 11 | { 12 | IPath l = new LocalPath("foo/bar"); 13 | IPath a = new AbsolutePath("/foo/bar"); 14 | 15 | Assert.Equal("foo", l.Parent?.FileName); 16 | Assert.Equal("foo", a.Parent?.FileName); 17 | } 18 | 19 | [Fact] 20 | public void FileNameTests() 21 | { 22 | IPath l = new LocalPath("foo/bar"); 23 | IPath a = new AbsolutePath("/foo/bar"); 24 | 25 | Assert.Equal("bar", l.FileName); 26 | Assert.Equal("bar", a.FileName); 27 | } 28 | 29 | [Fact] 30 | public void OperatorTests() 31 | { 32 | var l = new LocalPath("foo/bar"); 33 | var a = new AbsolutePath("/foo/bar"); 34 | var fragment = new LocalPath("frog1"); 35 | 36 | Assert.Equal("frog1", AppendGeneric(l, fragment).FileName); 37 | Assert.Equal("frog1", AppendGeneric(a, fragment).FileName); 38 | 39 | Assert.Equal("frog2", AppendGeneric(l, "frog2").FileName); 40 | Assert.Equal("frog2", AppendGeneric(a, "frog2").FileName); 41 | } 42 | 43 | private static TPath AppendGeneric(TPath basePath, LocalPath appended) where TPath : IPath => 44 | basePath / appended; 45 | 46 | private static TPath AppendGeneric(TPath basePath, string appended) where TPath : IPath => 47 | basePath / appended; 48 | } 49 | -------------------------------------------------------------------------------- /TruePath.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | global using System.Runtime.InteropServices; 6 | global using Xunit; 7 | -------------------------------------------------------------------------------- /TruePath.Tests/LocalPathTests.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using Xunit.Abstractions; 6 | 7 | namespace TruePath.Tests; 8 | 9 | public class LocalPathTests(ITestOutputHelper output) 10 | { 11 | [Theory] 12 | [InlineData("foo", ".")] 13 | [InlineData("foo/bar", "foo")] 14 | [InlineData("/", null)] 15 | public void AbsolutePathParent(string relativePath, string? expectedRelativePath) 16 | { 17 | var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); 18 | var parent = root / relativePath; 19 | AbsolutePath? expectedPath = expectedRelativePath == null ? null : new(root / expectedRelativePath); 20 | Assert.Equal(expectedPath, parent.Parent); 21 | } 22 | 23 | [Theory] 24 | [InlineData(".", "")] 25 | [InlineData("..", "..")] 26 | [InlineData("../..", "../..")] 27 | [InlineData(".../...", ".../...")] 28 | [InlineData(".../..", "")] 29 | public void ConstructionTest(string pathString, string expectedValue) 30 | { 31 | var path = new LocalPath(pathString); 32 | Assert.Equal(expectedValue.Replace('/', Path.DirectorySeparatorChar), path.Value); 33 | } 34 | 35 | [Theory] 36 | [InlineData(".", "..")] 37 | [InlineData("..", "../..")] 38 | [InlineData("../..", "../../..")] 39 | [InlineData("../../", "../../..")] 40 | [InlineData("../...", "..")] 41 | [InlineData(".../..", "..")] 42 | [InlineData("./.", "..")] 43 | [InlineData("../../.", "../../..")] 44 | [InlineData("b", ".")] 45 | [InlineData("../b", "..")] 46 | [InlineData("b/..", "b/../..")] 47 | [InlineData("...", ".../..")] 48 | [InlineData(".../...", "...")] 49 | public void RelativePathParent(string path, string? expected) 50 | { 51 | // Arrange 52 | var localPath = new LocalPath(path); 53 | LocalPath? expectedPath = expected == null ? null : new(expected); 54 | 55 | // Act 56 | var parent = localPath.Parent; 57 | 58 | // Assert 59 | Assert.Equal(expectedPath, parent); 60 | } 61 | 62 | [Theory] 63 | [InlineData("user", "user/documents")] 64 | [InlineData("usEr", "User/documents")] 65 | [InlineData("user/documents", "user/documents")] 66 | [InlineData("user/documents", "user")] 67 | public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB) 68 | { 69 | // Arrange 70 | var a = new LocalPath(pathA); 71 | var b = new LocalPath(pathB); 72 | 73 | // Act 74 | var isPrefix = a.IsPrefixOf(b); 75 | var startsWith = b.Value.StartsWith(a.Value); 76 | 77 | // Assert 78 | Assert.Equal(isPrefix, startsWith); 79 | } 80 | 81 | [Fact] 82 | public void AbsolutePathIsNormalizedOnCreation() 83 | { 84 | if (!OperatingSystem.IsWindows()) return; 85 | 86 | const string path = @"C:/Users/John Doe\Documents"; 87 | var absolutePath = new LocalPath(path); 88 | Assert.Equal(@"C:\Users\John Doe\Documents", absolutePath.Value); 89 | } 90 | 91 | [Theory] 92 | [InlineData("/foo/bar", "/foo", false)] 93 | [InlineData("/foo/", "/foo/bar/", true)] 94 | [InlineData("/foo", "/foo1/bar/", false)] 95 | [InlineData("/foo", "/foo1", false)] 96 | [InlineData("/foo", "/foo", true)] 97 | public void IsPrefixOf(string prefix, string other, bool result) 98 | { 99 | Assert.Equal(result, new LocalPath(prefix).IsPrefixOf(new LocalPath(other))); 100 | } 101 | 102 | [Fact] 103 | public void RelativePathIsNormalizedOnCreation() 104 | { 105 | if (!OperatingSystem.IsWindows()) return; 106 | 107 | const string path = @"Users/John Doe\Documents"; 108 | var relativePath = new LocalPath(path); 109 | Assert.Equal(@"Users\John Doe\Documents", relativePath.Value); 110 | } 111 | 112 | [Fact] 113 | public void LocalPathConvertedFromAbsolute() 114 | { 115 | var absolutePath = new AbsolutePath("/foo/bar"); 116 | LocalPath localPath1 = absolutePath; 117 | var localPath2 = new LocalPath(absolutePath); 118 | 119 | Assert.Equal(localPath1, localPath2); 120 | } 121 | 122 | [Fact] 123 | public void ResolveToCurrentDirectoryTests() 124 | { 125 | var localPath = new LocalPath("foo/bar"); 126 | var currentDirectory = AbsolutePath.CurrentWorkingDirectory; 127 | var expected = currentDirectory / localPath; 128 | Assert.Equal(expected, localPath.ResolveToCurrentDirectory()); 129 | 130 | try 131 | { 132 | var newCurrentDirectory = new AbsolutePath(Path.GetTempPath()).Canonicalize(); 133 | output.WriteLine("New current directory: " + newCurrentDirectory); 134 | Environment.CurrentDirectory = newCurrentDirectory.Value; 135 | expected = newCurrentDirectory / localPath; 136 | Assert.Equal(expected, localPath.ResolveToCurrentDirectory()); 137 | } 138 | finally 139 | { 140 | Environment.CurrentDirectory = currentDirectory.Value; 141 | output.WriteLine("Current directory reset back to: " + currentDirectory); 142 | } 143 | } 144 | 145 | [Fact] 146 | public void PlatformDefaultPathComparerTest() 147 | { 148 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; 149 | 150 | var path1 = new LocalPath(@"C:\Windows"); 151 | var path2 = new LocalPath(@"C:\WINDOWS"); 152 | 153 | Assert.True(path1.Equals(path2, LocalPath.PlatformDefaultComparer)); 154 | } 155 | 156 | [Fact] 157 | public void EqualsUseStrictStringPathComparer_SamePaths_True() 158 | { 159 | // Arrange 160 | var currentDirectory = Environment.CurrentDirectory; 161 | var nonCanonicalPath = currentDirectory; 162 | 163 | var path1 = new LocalPath(currentDirectory); 164 | var path2 = new LocalPath(nonCanonicalPath); 165 | 166 | // Act 167 | var equals = path1.Equals(path2, LocalPath.StrictStringComparer); 168 | 169 | // Assert 170 | Assert.True(equals); 171 | } 172 | 173 | [Fact] 174 | public void EqualsUseStrictStringPathComparer_NotSamePaths_False() 175 | { 176 | // Arrange 177 | var currentDirectory = Environment.CurrentDirectory; 178 | var nonCanonicalPath = new string(currentDirectory.ToNonCanonicalCase().ToArray()); 179 | 180 | var path1 = new LocalPath(currentDirectory); 181 | var path2 = new LocalPath(nonCanonicalPath); 182 | 183 | // Act 184 | var equals = path1.Equals(path2, LocalPath.StrictStringComparer); 185 | 186 | // Assert 187 | Assert.False(equals); 188 | } 189 | 190 | [Fact] 191 | public void OnLinux_EqualsDefault_CaseSensitive_False() 192 | { 193 | // Arrange 194 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 195 | { 196 | return; 197 | } 198 | 199 | var currentDirectory = Environment.CurrentDirectory; 200 | var nonCanonicalPath = new string(currentDirectory.ToNonCanonicalCase().ToArray()); 201 | 202 | var path1 = new LocalPath(currentDirectory); 203 | var path2 = new LocalPath(nonCanonicalPath); 204 | 205 | // Act 206 | var equals = path1.Equals(path2); 207 | 208 | // Assert 209 | Assert.False(equals); 210 | } 211 | 212 | [Fact] 213 | public void OnWindowsOrOsx_EqualsDefault_CaseInsensitive_True() 214 | { 215 | // Arrange 216 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 217 | { 218 | return; 219 | } 220 | 221 | var currentDirectory = Environment.CurrentDirectory; 222 | var nonCanonicalPath = new string(currentDirectory.ToNonCanonicalCase().ToArray()); 223 | 224 | var path1 = new LocalPath(currentDirectory); 225 | var path2 = new LocalPath(nonCanonicalPath); 226 | 227 | // Act 228 | var equals = path1.Equals(path2); 229 | 230 | // Assert 231 | Assert.True(equals); 232 | } 233 | 234 | [Theory] 235 | [InlineData("/path/to/file.txt", "/path/to/file.txt", 0)] 236 | [InlineData("/path/to/file.txt", "/PATH/TO/FILE.TXT", 1)] 237 | [InlineData("/PATH/TO/FILE.TXT", "/path/to/file.txt", -1)] 238 | [InlineData("path/to/apple", "path/to/banana", -1)] 239 | [InlineData("path/to/banana", "path/to/apple", 1)] 240 | [InlineData("path/to/folder", "path/to/folder/subfolder", -1)] 241 | [InlineData("path/to/folder/subfolder", "path/to/folder", 1)] 242 | public void PlatformDefaultLocalPathOrderingTest_Linux( 243 | string firstPathString, 244 | string secondPathString, 245 | int expected) 246 | { 247 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 248 | { 249 | return; 250 | } 251 | 252 | PlatformDefaultLocalPathOrderingTestBase(firstPathString, secondPathString, expected); 253 | } 254 | 255 | [Theory] 256 | [InlineData("/path/to/file.txt", "/path/to/file.txt", 0)] 257 | [InlineData("/path/to/file.txt", "/PATH/TO/FILE.TXT", 0)] 258 | [InlineData("/PATH/TO/FILE.TXT", "/path/to/file.txt", 0)] 259 | [InlineData("path/to/apple", "path/to/banana", -1)] 260 | [InlineData("path/to/banana", "path/to/apple", 1)] 261 | [InlineData("path/to/folder", "path/to/folder/subfolder", -1)] 262 | [InlineData("path/to/folder/subfolder", "path/to/folder", 1)] 263 | public void PlatformDefaultLocalPathOrderingTest_MacOs( 264 | string firstPathString, 265 | string secondPathString, 266 | int expected) 267 | { 268 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 269 | { 270 | return; 271 | } 272 | 273 | PlatformDefaultLocalPathOrderingTestBase(firstPathString, secondPathString, expected); 274 | } 275 | 276 | [Theory] 277 | [InlineData(@"C:\path\to\folder", @"C:\path\to\folder\subfolder", -1)] 278 | [InlineData(@"C:\path\to\folder\subfolder", @"C:\path\to\folder", 1)] 279 | [InlineData(@"C:\path", @"D:\path", -1)] 280 | [InlineData(@"D:\path", @"C:\path", 1)] 281 | [InlineData(@"C:\path\to\apple", @"C:\path\to\banana", -1)] 282 | [InlineData(@"C:\path\to\banana", @"C:\path\to\apple", 1)] 283 | [InlineData(@"C:\path\to\file.txt", @"C:\PATH\TO\FILE.TXT", 0)] 284 | [InlineData(@"C:\PATH\TO\FILE.TXT", @"C:\path\to\file.txt", 0)] 285 | [InlineData(@"\path\to\file.txt", @"\path\to\file.txt", 0)] 286 | [InlineData(@"\path\to\file.txt", @"\PATH\TO\FILE.TXT", 0)] 287 | public void PlatformDefaultLocalPathOrderingTest_Windows( 288 | string firstPathString, 289 | string secondPathString, 290 | int expected) 291 | { 292 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 293 | { 294 | return; 295 | } 296 | 297 | PlatformDefaultLocalPathOrderingTestBase(firstPathString, secondPathString, expected); 298 | } 299 | 300 | private static void PlatformDefaultLocalPathOrderingTestBase( 301 | string firstPathString, 302 | string secondPathString, 303 | int expected) 304 | { 305 | // Arrange 306 | var firstPath = new LocalPath(firstPathString); 307 | var secondPath = new LocalPath(secondPathString); 308 | var comparer = LocalPath.PlatformDefaultComparer; 309 | 310 | // Act 311 | var comparisonResult = comparer.Compare(firstPath, secondPath); 312 | 313 | // Assert 314 | Assert.Equal(expected, Math.Sign(comparisonResult)); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /TruePath.Tests/NormalizatonTests.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath.Tests; 6 | 7 | using FsCheck; 8 | using FsCheck.Fluent; 9 | using FsCheck.Xunit; 10 | using System.Linq; 11 | using Xunit.Abstractions; 12 | 13 | public class NormalizatonTests 14 | { 15 | private readonly ITestOutputHelper _TestOutputHelper; 16 | public NormalizatonTests(ITestOutputHelper testOutputHelper) 17 | { 18 | _TestOutputHelper = testOutputHelper; 19 | } 20 | 21 | [Property(Arbitrary = new[] { typeof(AnyOsPath) })] 22 | public void NormalizedPathDoesNotEndWithDirSeparator(List pathParts) 23 | { 24 | var sourcePath = string.Join("", pathParts); 25 | 26 | // Act 27 | var normalizedPath = PathStrings.Normalize(sourcePath); 28 | 29 | _TestOutputHelper.WriteLine($"{sourcePath} => {normalizedPath}"); 30 | Assert.True(normalizedPath == "" 31 | || normalizedPath == Path.DirectorySeparatorChar.ToString() 32 | || (normalizedPath.Length == 3 && normalizedPath[1] == ':') 33 | || normalizedPath[^1] != Path.DirectorySeparatorChar); 34 | } 35 | 36 | [Property(Arbitrary = new[] { typeof(AnyOsPath) })] 37 | public void DepthPreserverd(List pathParts) 38 | { 39 | var sourcePath = string.Join("", pathParts); 40 | 41 | // Act 42 | var normalizedPath = PathStrings.Normalize(sourcePath); 43 | 44 | _TestOutputHelper.WriteLine($"{sourcePath} => {normalizedPath}"); 45 | var collapsedBlock = CollapseSameBlocks(pathParts); 46 | var expectedDepth = CountDepth(collapsedBlock); 47 | var actualDepth = CountDepth([.. normalizedPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)]); 48 | if (actualDepth != expectedDepth) 49 | { 50 | _TestOutputHelper.WriteLine($"{string.Join("|", collapsedBlock)}"); 51 | } 52 | Assert.Equal(expectedDepth, actualDepth); 53 | 54 | static int CountDepth(List pathParts) 55 | { 56 | int depth = 0; 57 | foreach (var part in pathParts) 58 | { 59 | if (part == Path.DirectorySeparatorChar.ToString() || part == Path.AltDirectorySeparatorChar.ToString()) 60 | { 61 | continue; 62 | } 63 | 64 | // Skip home drive which does not affect depth. 65 | if (part.Contains(':')) 66 | { 67 | continue; 68 | } 69 | 70 | if (part == "..") 71 | { 72 | if (depth > 0) 73 | { 74 | depth--; 75 | } 76 | } 77 | else if (part != ".") 78 | { 79 | depth++; 80 | } 81 | } 82 | return depth; 83 | } 84 | } 85 | 86 | private static List CollapseSameBlocks(IEnumerable pathParts) 87 | { 88 | var result = new List(); 89 | var lastSeparator = false; 90 | var hasHomeDrive = pathParts.Any(part => part.Length > 1 && part[1] == ':'); 91 | string? homeDrive = null; 92 | foreach (var item in pathParts) 93 | { 94 | if (homeDrive is null) 95 | { 96 | homeDrive = item; 97 | } 98 | 99 | if (item.Length > 1 && item[1] == ':') continue; 100 | 101 | var currentSeparator = item == Path.DirectorySeparatorChar.ToString() || item == Path.AltDirectorySeparatorChar.ToString(); 102 | if (lastSeparator && currentSeparator) 103 | { 104 | // Skip consecutive separators 105 | continue; 106 | } 107 | 108 | if (lastSeparator || currentSeparator) 109 | { 110 | result.Add(item); 111 | } 112 | else 113 | { 114 | if (result.Count > 0) 115 | { 116 | result[^1] += item; 117 | } 118 | else 119 | { 120 | result.Add(item); 121 | } 122 | } 123 | 124 | lastSeparator = currentSeparator; 125 | 126 | } 127 | 128 | if (hasHomeDrive && homeDrive is { }) 129 | { 130 | result.Insert(0, homeDrive); 131 | } 132 | 133 | return result; 134 | } 135 | } 136 | 137 | internal static class PathGenerators 138 | { 139 | public static Gen> LinuxPathItemsGenerator() 140 | { 141 | var baseDir = Gen.Constant(".."); 142 | var currentDir = Gen.Constant("."); 143 | var threeDots = Gen.Constant("..."); 144 | var dots = Gen.OneOf([baseDir, currentDir, threeDots]); 145 | var textPath = Gen.NonEmptyListOf(Gen.Choose('a', 'z').Or(Gen.Choose('A', 'Z')).Or(Gen.Choose('0', '9'))).Select(l => string.Join("", l.Select(c => (char)c))); 146 | 147 | var directorySeparatorChar = Gen.Constant(Path.DirectorySeparatorChar).Select(c => c.ToString()); 148 | var altDirectorySeparatorChar = Gen.Constant(Path.AltDirectorySeparatorChar).Select(c => c.ToString()); 149 | return Gen.NonEmptyListOf(Gen.OneOf([dots, directorySeparatorChar, altDirectorySeparatorChar, textPath])) 150 | .Where(l => 151 | { 152 | for (int i = 0; i < l.Count - 1; i++) 153 | { 154 | // Avoid consecutive dots like "..../.." 155 | if (l[i][0] == '.' && l[i + 1][0] == '.') return false; 156 | } 157 | 158 | return true; 159 | }); 160 | } 161 | 162 | public static Gen> WindowsPathItemsGenerator() 163 | { 164 | var driveLetter = Gen.Choose('a', 'z').Or(Gen.Choose('A', 'Z')).Select(c => (char)c + ":"); 165 | var directorySeparatorChar = Gen.Constant(Path.DirectorySeparatorChar).Select(c => c.ToString()); 166 | var altDirectorySeparatorChar = Gen.Constant(Path.AltDirectorySeparatorChar).Select(c => c.ToString()); 167 | var separator = Gen.OneOf([directorySeparatorChar, altDirectorySeparatorChar]); 168 | var drivePrefix = Gen.Zip(driveLetter, separator).Select(static t => 169 | { 170 | var (driveLetter, separator) = t; 171 | return driveLetter + separator; 172 | }); 173 | return Gen.Zip(drivePrefix, LinuxPathItemsGenerator()).Select(static t => 174 | { 175 | var (prefix, items) = t; 176 | items.Insert(0, prefix); 177 | return items; 178 | }); 179 | } 180 | } 181 | 182 | public class AnyOsPath 183 | { 184 | public static Arbitrary> Paths() 185 | { 186 | return Arb.From(Gen.Or(PathGenerators.LinuxPathItemsGenerator(), PathGenerators.WindowsPathItemsGenerator())); 187 | } 188 | } 189 | 190 | public class LinuxPath 191 | { 192 | public static Arbitrary> Paths() 193 | { 194 | return Arb.From(PathGenerators.LinuxPathItemsGenerator()); 195 | } 196 | } 197 | 198 | public class WindowsPath 199 | { 200 | public static Arbitrary> Paths() 201 | { 202 | return Arb.From(PathGenerators.WindowsPathItemsGenerator()); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /TruePath.Tests/PathExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath.Tests; 6 | 7 | public class PathExtensionsTests 8 | { 9 | [Theory] 10 | [InlineData("..", ".")] 11 | [InlineData("foo/bar.txt", ".txt")] 12 | [InlineData("/foo/bar.txt", ".txt")] 13 | [InlineData("foo/bar.", ".")] 14 | [InlineData("foo/bar", "")] 15 | [InlineData(".gitignore", ".gitignore")] 16 | public void GetExtensionWithDotTests(string path, string expected) 17 | { 18 | IPath local = new LocalPath(path); 19 | Assert.Equal(expected, local.GetExtensionWithDot()); 20 | 21 | if (!path.StartsWith('/')) return; 22 | 23 | IPath a = new AbsolutePath(path); 24 | Assert.Equal(expected, a.GetExtensionWithDot()); 25 | } 26 | 27 | [Theory] 28 | [InlineData(".", "")] 29 | [InlineData("..", "")] 30 | [InlineData("foo/bar.txt", "txt")] 31 | [InlineData("/foo/bar.txt", "txt")] 32 | [InlineData("foo/bar.", "")] 33 | [InlineData("foo/bar", "")] 34 | [InlineData(".gitignore", "gitignore")] 35 | public void GetExtensionWithoutDotTests(string path, string expected) 36 | { 37 | IPath l = new LocalPath(path); 38 | Assert.Equal(expected, l.GetExtensionWithoutDot()); 39 | 40 | if (!path.StartsWith('/')) return; 41 | 42 | IPath a = new AbsolutePath(path); 43 | Assert.Equal(expected, a.GetExtensionWithoutDot()); 44 | } 45 | 46 | [Theory] 47 | [InlineData("..", ".")] 48 | [InlineData("foo/bar.txt", "bar")] 49 | [InlineData("/foo/bar.txt", "bar")] 50 | [InlineData("foo/bar.", "bar")] 51 | [InlineData("foo/bar", "bar")] 52 | [InlineData(".gitignore", "")] 53 | public void GetFilenameWithoutExtensionTests(string path, string expected) 54 | { 55 | IPath l = new LocalPath(path); 56 | Assert.Equal(expected, l.GetFilenameWithoutExtension()); 57 | 58 | if (!path.StartsWith('/')) return; 59 | 60 | IPath a = new AbsolutePath(path); 61 | Assert.Equal(expected, a.GetFilenameWithoutExtension()); 62 | } 63 | 64 | [Theory] 65 | [InlineData("..")] 66 | [InlineData("file.txt")] 67 | [InlineData("file..txt")] 68 | [InlineData(".gitignore")] 69 | [InlineData("gitignore.")] 70 | public void FileNameInvariantTests(string inputPath) 71 | { 72 | var path = new LocalPath(inputPath); 73 | var fileName = path.FileName; 74 | Assert.Equal(fileName, path.GetFilenameWithoutExtension() + path.GetExtensionWithDot()); 75 | } 76 | 77 | [Theory] 78 | [InlineData(@"C:\", "bar", @"C:\.bar")] 79 | [InlineData(@"C:\filename.foo", "bar", @"C:\filename.bar")] 80 | [InlineData(@"\", "bar", @"\.bar")] 81 | [InlineData(@"\file", "bar", @"\file.bar")] 82 | [InlineData(@"\file", ".bar", @"\file.bar")] 83 | [InlineData(@"\file.", ".bar", @"\file.bar")] 84 | [InlineData("file.foo", "bar", "file.bar")] 85 | [InlineData(".gitignore", "hgignore", ".hgignore")] 86 | public void WithExtensionTests_Windows(string inputPath, string newExtension, string expected) 87 | { 88 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 89 | { 90 | return; 91 | } 92 | 93 | // Arrange 94 | var path = new LocalPath(inputPath); 95 | 96 | // Act 97 | var newPath = path.WithExtension(newExtension); 98 | 99 | // Assert 100 | Assert.Equal(expected, newPath.Value); 101 | } 102 | 103 | [Theory] 104 | [InlineData("/", "bar", "/.bar")] 105 | [InlineData("/file", "bar", "/file.bar")] 106 | [InlineData("/file", ".bar", "/file.bar")] 107 | [InlineData("/file.", ".bar", "/file.bar")] 108 | [InlineData("file.foo", "bar", "file.bar")] 109 | [InlineData(".gitignore", "hgignore", ".hgignore")] 110 | public void WithExtensionTests_Unix(string inputPath, string newExtension, string expected) 111 | { 112 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 113 | { 114 | return; 115 | } 116 | 117 | // Arrange 118 | var path = new LocalPath(inputPath); 119 | 120 | // Act 121 | var newPath = path.WithExtension(newExtension); 122 | 123 | // Assert 124 | Assert.Equal(expected, newPath.Value); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /TruePath.Tests/PathStringsTests.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath.Tests; 6 | 7 | public class PathStringsTests 8 | { 9 | [Fact] 10 | public void SlashesShouldBeNormalized() 11 | { 12 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; 13 | const string path = @"a/b\c/d"; 14 | Assert.Equal(@"a\b\c\d", PathStrings.Normalize(path)); 15 | } 16 | 17 | [Theory] 18 | [InlineData("/a/b/c/d", "/a/b/c/d")] 19 | [InlineData("/a/b/c/d/", "/a/b/c/d")] 20 | [InlineData("a/b/c/d//", "a/b/c/d")] 21 | [InlineData("a/b/c/d////", "a/b/c/d")] 22 | public void TrailingSlashShouldBeRemoved(string input, string expected) 23 | { 24 | Assert.Equal(NormalizeSeparators(expected), PathStrings.Normalize(input)); 25 | } 26 | 27 | [Theory] 28 | [InlineData("/a/b/c/d", "/a/b/c/d")] 29 | [InlineData("//a/b/c/d", "/a/b/c/d")] 30 | [InlineData("//a///b//c/d//", "/a/b/c/d")] 31 | [InlineData("a///b////c/d//", "a/b/c/d")] 32 | public void SeparatorsAreDeduplicated(string input, string expected) 33 | { 34 | Assert.Equal(NormalizeSeparators(expected), PathStrings.Normalize(input)); 35 | } 36 | 37 | [Theory] 38 | [InlineData(".", "")] 39 | [InlineData("./foo", "foo")] 40 | [InlineData("..", "..")] 41 | [InlineData("./..", "..")] 42 | [InlineData("a/..", "")] 43 | [InlineData("a/../..", "..")] 44 | [InlineData("a/../../.", "..")] 45 | [InlineData("a/../../..", "../..")] 46 | [InlineData("foo/./bar/../var/./dar/..", "foo/var")] 47 | [InlineData("foo/.bar", "foo/.bar")] 48 | [InlineData("/.", "/")] 49 | [InlineData("/..", "/..")] 50 | [InlineData("/../..", "/../..")] 51 | [InlineData("/../../foo/..", "/../..")] 52 | [InlineData("x/foo/bar/../..", "x")] 53 | [InlineData("x/foo/bar/.../.", "x/foo/bar/...")] 54 | [InlineData("x/foo/..bar/", "x/foo/..bar")] 55 | [InlineData("../../foo", "../../foo")] 56 | [InlineData("../../../foo", "../../../foo")] 57 | [InlineData("../foo/..", "..")] 58 | [InlineData("...", "...")] 59 | [InlineData(".../..", "")] 60 | [InlineData(".../...", ".../...")] 61 | [InlineData(".../../...", "...")] 62 | [InlineData("foo/bar/../file.ext", "foo/file.ext")] 63 | [InlineData("N/", "N")] 64 | public void DotFoldersAreTraversedCorrectly(string input, string expected) 65 | { 66 | Assert.Equal(NormalizeSeparators(expected), PathStrings.Normalize(input)); 67 | } 68 | 69 | [Theory] 70 | [InlineData(".", "")] 71 | [InlineData("./foo", "foo")] 72 | [InlineData("..", "..")] 73 | [InlineData("./..", "..")] 74 | [InlineData("a/..", "")] 75 | [InlineData("a/../..", "..")] 76 | [InlineData("a/../../.", "..")] 77 | [InlineData("a/../../..", "../..")] 78 | [InlineData("foo/./bar/../var/./dar/..", "foo/var")] 79 | [InlineData("foo/.bar", "foo/.bar")] 80 | [InlineData("/..", "/..")] 81 | [InlineData("/../..", "/../..")] 82 | [InlineData("/../../foo/..", "/../..")] 83 | [InlineData("x/foo/bar/../..", "x")] 84 | [InlineData("x/foo/bar/.../.", "x/foo/bar/...")] 85 | [InlineData("x/foo/..bar/", "x/foo/..bar")] 86 | [InlineData("../../foo", "../../foo")] 87 | [InlineData("../../../foo", "../../../foo")] 88 | [InlineData("../foo/..", "..")] 89 | public void WindowsSpecificDotFoldersAreTraversed(string input, string expected) 90 | { 91 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; 92 | 93 | var driveLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 94 | 95 | foreach (var driveLetter in driveLetters) 96 | { 97 | var inputPath = $"{driveLetter}:{input}"; 98 | var expectedPath = $"{driveLetter}:{expected}"; 99 | 100 | //Act 101 | var actual = PathStrings.Normalize(inputPath); 102 | 103 | // Assert 104 | Assert.Equal(NormalizeSeparators(expectedPath), actual); 105 | } 106 | 107 | driveLetters += driveLetters.ToLowerInvariant(); 108 | foreach (var driveLetter in driveLetters) 109 | { 110 | var inputPath = $"{driveLetter}:{input}"; 111 | var expectedPath = $"{driveLetter}:{expected}"; 112 | 113 | //Act 114 | var actual = PathStrings.Normalize(inputPath); 115 | 116 | // Assert 117 | Assert.Equal(NormalizeSeparators(expectedPath), actual); 118 | } 119 | } 120 | 121 | private static string NormalizeSeparators(string path) => 122 | path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); 123 | } 124 | -------------------------------------------------------------------------------- /TruePath.Tests/TemporaryTests.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath.Tests; 6 | 7 | public class TemporaryTests 8 | { 9 | [Fact] 10 | public void SystemTempDirectory_ReturnsValidDirectory() 11 | { 12 | // Act 13 | var tempDir = Temporary.SystemTempDirectory(); 14 | 15 | // Assert 16 | Assert.True(Directory.Exists(tempDir.Value)); 17 | } 18 | 19 | [Fact] 20 | public void CreateTempFile_CreatesNewFile() 21 | { 22 | // Act 23 | var tempFile = Temporary.CreateTempFile(); 24 | 25 | // Assert 26 | Assert.True(File.Exists(tempFile.Value)); 27 | 28 | // Cleanup 29 | File.Delete(tempFile.Value); 30 | } 31 | 32 | [Theory] 33 | [InlineData(null)] 34 | [InlineData("test_")] 35 | public void CreateTempFolder_CreatesNewFolder(string? prefix) 36 | { 37 | // Act 38 | var tempFolder = Temporary.CreateTempFolder(prefix); 39 | 40 | // Assert 41 | Assert.True(Directory.Exists(tempFolder.Value)); 42 | if (prefix != null) 43 | { 44 | Assert.StartsWith(prefix, tempFolder.FileName); 45 | } 46 | 47 | // Cleanup 48 | Directory.Delete(tempFolder.Value); 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /TruePath.Tests/TruePath.Tests.csproj: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | false 11 | true 12 | false 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /TruePath.Tests/Utils.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath.Tests; 6 | 7 | public static class Utils 8 | { 9 | internal static string ToNonCanonicalCase(this string path) 10 | { 11 | var result = new char[path.Length]; 12 | for (var i = 0; i < path.Length; i++) 13 | { 14 | result[i] = i % 2 == 0 ? char.ToUpper(path[i]) : char.ToLower(path[i]); 15 | } 16 | 17 | var nonCanonicalPath = new string(result); 18 | if (path.Equals(nonCanonicalPath, StringComparison.Ordinal)) 19 | { 20 | throw new InvalidOperationException("The non-canonical path is equal to the original path."); 21 | } 22 | 23 | return nonCanonicalPath; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /TruePath.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4393F95C-25AD-46F1-AEEF-85259893CF58}" 7 | ProjectSection(SolutionItems) = preProject 8 | README.md = README.md 9 | .gitignore = .gitignore 10 | CONTRIBUTING.md = CONTRIBUTING.md 11 | TruePath.sln.license = TruePath.sln.license 12 | .editorconfig = .editorconfig 13 | Directory.Build.props = Directory.Build.props 14 | CHANGELOG.md = CHANGELOG.md 15 | MAINTAINING.md = MAINTAINING.md 16 | LICENSE.txt = LICENSE.txt 17 | TruePath.sln.DotSettings = TruePath.sln.DotSettings 18 | REUSE.toml = REUSE.toml 19 | EndProjectSection 20 | EndProject 21 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{8C064598-EE83-463D-9822-BAFA70B2C278}" 22 | ProjectSection(SolutionItems) = preProject 23 | scripts\github-actions.fsx = scripts\github-actions.fsx 24 | scripts\Test-Encoding.ps1 = scripts\Test-Encoding.ps1 25 | scripts\Get-Version.ps1 = scripts\Get-Version.ps1 26 | EndProjectSection 27 | EndProject 28 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{10D9133A-772F-4364-929E-EEAE2E5DF04D}" 29 | ProjectSection(SolutionItems) = preProject 30 | .github\dependabot.yml = .github\dependabot.yml 31 | EndProjectSection 32 | EndProject 33 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{55C1E2A5-1630-4C22-A403-65DA2B1B969D}" 34 | ProjectSection(SolutionItems) = preProject 35 | .github\workflows\main.yml = .github\workflows\main.yml 36 | .github\workflows\main.yml.license = .github\workflows\main.yml.license 37 | .github\workflows\release.yml = .github\workflows\release.yml 38 | .github\workflows\release.yml.license = .github\workflows\release.yml.license 39 | .github\workflows\docs.yml = .github\workflows\docs.yml 40 | EndProjectSection 41 | EndProject 42 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LICENSES", "LICENSES", "{0E6F7E51-BAE3-459C-A0E3-8082FA094F62}" 43 | ProjectSection(SolutionItems) = preProject 44 | LICENSES\MIT.txt = LICENSES\MIT.txt 45 | LICENSES\CC0-1.0.txt = LICENSES\CC0-1.0.txt 46 | EndProjectSection 47 | EndProject 48 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruePath", "TruePath\TruePath.csproj", "{DB806921-D806-452A-99FB-94FC725291DC}" 49 | EndProject 50 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruePath.Tests", "TruePath.Tests\TruePath.Tests.csproj", "{7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}" 51 | EndProject 52 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruePath.Benchmarks", "TruePath.Benchmarks\TruePath.Benchmarks.csproj", "{4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}" 53 | EndProject 54 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{33FCBEF9-2172-4755-8792-AEAF646DE77D}" 55 | ProjectSection(SolutionItems) = preProject 56 | .config\dotnet-tools.json = .config\dotnet-tools.json 57 | .config\dotnet-tools.json.license = .config\dotnet-tools.json.license 58 | EndProjectSection 59 | EndProject 60 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{98C65796-8ACB-4CAF-A06B-6A179F338E9C}" 61 | ProjectSection(SolutionItems) = preProject 62 | docs\toc.yml = docs\toc.yml 63 | docs\docfx.json = docs\docfx.json 64 | docs\index.md = docs\index.md 65 | docs\docfx.json.license = docs\docfx.json.license 66 | EndProjectSection 67 | EndProject 68 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruePath.SystemIo", "TruePath.SystemIo\TruePath.SystemIo.csproj", "{46252949-EA57-49F5-BE71-2F387DA173E8}" 69 | EndProject 70 | Global 71 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 72 | Debug|Any CPU = Debug|Any CPU 73 | Release|Any CPU = Release|Any CPU 74 | EndGlobalSection 75 | GlobalSection(SolutionProperties) = preSolution 76 | HideSolutionNode = FALSE 77 | EndGlobalSection 78 | GlobalSection(NestedProjects) = preSolution 79 | {55C1E2A5-1630-4C22-A403-65DA2B1B969D} = {10D9133A-772F-4364-929E-EEAE2E5DF04D} 80 | EndGlobalSection 81 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 82 | {DB806921-D806-452A-99FB-94FC725291DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 83 | {DB806921-D806-452A-99FB-94FC725291DC}.Debug|Any CPU.Build.0 = Debug|Any CPU 84 | {DB806921-D806-452A-99FB-94FC725291DC}.Release|Any CPU.ActiveCfg = Release|Any CPU 85 | {DB806921-D806-452A-99FB-94FC725291DC}.Release|Any CPU.Build.0 = Release|Any CPU 86 | {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 87 | {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Debug|Any CPU.Build.0 = Debug|Any CPU 88 | {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Release|Any CPU.ActiveCfg = Release|Any CPU 89 | {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Release|Any CPU.Build.0 = Release|Any CPU 90 | {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 91 | {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Debug|Any CPU.Build.0 = Debug|Any CPU 92 | {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Release|Any CPU.ActiveCfg = Release|Any CPU 93 | {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Release|Any CPU.Build.0 = Release|Any CPU 94 | {46252949-EA57-49F5-BE71-2F387DA173E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 95 | {46252949-EA57-49F5-BE71-2F387DA173E8}.Debug|Any CPU.Build.0 = Debug|Any CPU 96 | {46252949-EA57-49F5-BE71-2F387DA173E8}.Release|Any CPU.ActiveCfg = Release|Any CPU 97 | {46252949-EA57-49F5-BE71-2F387DA173E8}.Release|Any CPU.Build.0 = Release|Any CPU 98 | EndGlobalSection 99 | EndGlobal 100 | -------------------------------------------------------------------------------- /TruePath.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | ShowAndRun -------------------------------------------------------------------------------- /TruePath.sln.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /TruePath/AbsolutePath.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Runtime.InteropServices; 6 | using TruePath.Comparers; 7 | 8 | namespace TruePath; 9 | 10 | /// 11 | /// This is a path on the local system that's guaranteed to be absolute: that is, path that is rooted and has a 12 | /// disk letter (on Windows). 13 | /// 14 | /// For a path that's not guaranteed to be absolute, use the type. 15 | public readonly struct AbsolutePath : IEquatable, IComparable, IPath, IPath 16 | { 17 | /// 18 | /// Provides a default comparer for comparing file paths, aware of the current platform. 19 | /// 20 | /// On Windows and macOS, this will perform case-insensitive string comparison, since the 21 | /// file systems are case-insensitive on these operating systems by default. 22 | /// 23 | /// On Linux, the comparison will be case-sensitive. 24 | /// 25 | /// 26 | /// Note that this comparison does not guarantee correctness: in practice, on any platform to control 27 | /// case-sensitiveness of either the whole file system or a part of it. This class does not take this into account, 28 | /// having a benefit of no accessing the file system for any of the comparisons. 29 | /// 30 | public static readonly IPathComparer PlatformDefaultComparer = 31 | new PlatformDefaultPathComparer(); 32 | 33 | /// 34 | /// A strict comparer for comparing file paths using ordinal, case-sensitive comparison of the underlying path 35 | /// strings. 36 | /// 37 | public static readonly IPathComparer StrictStringComparer = 38 | new StrictStringPathComparer(); 39 | 40 | internal readonly LocalPath Underlying; 41 | 42 | /// 43 | /// Creates an instance by normalizing the path from the passed string according to the 44 | /// rules stated in . 45 | /// 46 | /// Path string to normalize. 47 | /// Flag indicating whether absoluteness of path should be checked 48 | /// Thrown if the passed string does not represent an absolute path.> 49 | private AbsolutePath(string value, bool checkAbsoluteness) 50 | { 51 | Underlying = new LocalPath(value); 52 | 53 | if (checkAbsoluteness && Underlying.IsAbsolute is false) 54 | throw new ArgumentException($"Path \"{value}\" is not absolute."); 55 | } 56 | 57 | /// 58 | /// Creates an instance by normalizing the path from the passed string according to the 59 | /// rules stated in . 60 | /// 61 | /// Path string to normalize. 62 | /// Thrown if the passed string does not represent an absolute path. 63 | public AbsolutePath(string value) : this(value, checkAbsoluteness: true) { } 64 | 65 | /// 66 | /// Creates an instance by converting a object. 67 | /// 68 | /// Thrown if the passed path is not absolute. 69 | public AbsolutePath(LocalPath localPath) : this(localPath.Value, checkAbsoluteness: true) { } 70 | 71 | /// 72 | public string Value => Underlying.Value; 73 | 74 | /// 75 | public AbsolutePath? Parent => Underlying.Parent is { } path ? new(path.Value, checkAbsoluteness: false) : null; 76 | 77 | /// 78 | IPath? IPath.Parent => Parent; 79 | 80 | /// 81 | public string FileName => Underlying.FileName; 82 | 83 | /// 84 | public bool StartsWith(AbsolutePath other) => Value.StartsWith(other.Value); 85 | 86 | /// 87 | public static AbsolutePath Create(string value) => new(value); 88 | 89 | /// 90 | public bool IsPrefixOf(AbsolutePath other) 91 | { 92 | return Value.Length <= other.Value.Length && other.Value.StartsWith(Value); 93 | } 94 | 95 | /// Gets or sets the current working directory as an AbsolutePath instance. 96 | /// The current working directory. 97 | public static AbsolutePath CurrentWorkingDirectory 98 | { 99 | get => new(Environment.CurrentDirectory); 100 | set => Directory.SetCurrentDirectory(value.Value); 101 | } 102 | 103 | /// 104 | /// Calculates the relative path from a base path to this path. 105 | /// 106 | /// The base path from which to calculate the relative path. 107 | /// The relative path from the base path to this path. 108 | public LocalPath RelativeTo(AbsolutePath basePath) => new(Path.GetRelativePath(basePath.Value, Value)); 109 | 110 | /// Corrects the file name case on case-insensitive file systems, resolves symlinks. 111 | public AbsolutePath Canonicalize() => new(DiskUtils.GetRealPath(Value)); 112 | 113 | /// 114 | /// Note that in case path is absolute, it will completely take over and the 115 | /// will be ignored. 116 | /// 117 | public static AbsolutePath operator /(AbsolutePath basePath, LocalPath b) => 118 | new(Path.Combine(basePath.Value, b.Value), false); 119 | 120 | /// 121 | /// Note that in case path is absolute, it will completely take over and the 122 | /// will be ignored. 123 | /// 124 | public static AbsolutePath operator /(AbsolutePath basePath, string b) => basePath / new LocalPath(b); 125 | 126 | /// The normalized path string contained in this object. 127 | public override string ToString() => Value; 128 | 129 | /// Compares the path with another. 130 | /// Note that currently this comparison is case-sensitive. 131 | public bool Equals(AbsolutePath other) => Equals(other, PlatformDefaultComparer); 132 | 133 | /// 134 | /// Determines whether the specified is equal to the current 135 | /// using the specified comparer. 136 | /// 137 | /// The to compare with the current . 138 | /// 139 | /// The comparer to use for comparing the paths. For example, pass or 140 | /// . 141 | /// 142 | /// 143 | /// if the specified is equal to the current 144 | /// using the specified comparer; otherwise, . 145 | /// 146 | public bool Equals(AbsolutePath other, IEqualityComparer comparer) => 147 | comparer.Equals(this, other); 148 | 149 | /// 150 | public override bool Equals(object? obj) 151 | { 152 | return obj is AbsolutePath other && Equals(other); 153 | } 154 | 155 | /// 156 | public override int GetHashCode() => PlatformDefaultComparer.GetHashCode(this); 157 | 158 | /// 159 | public static bool operator ==(AbsolutePath left, AbsolutePath right) 160 | { 161 | return left.Equals(right); 162 | } 163 | 164 | /// 165 | public static bool operator !=(AbsolutePath left, AbsolutePath right) 166 | { 167 | return !left.Equals(right); 168 | } 169 | 170 | /// 171 | /// Determines the type of the file system entry (file, directory, symlink, or junction) for the given path. 172 | /// 173 | /// 174 | /// A enumeration value representing the type of the file system entry, or if the entry does not exist. 175 | /// 176 | /// 177 | /// This method checks if the specified path represents a file, directory, symbolic link, or junction. On Windows, it uses to identify junctions and checks for the flag to identify symbolic links. 178 | /// 179 | public FileEntryKind? ReadKind() 180 | { 181 | if (!File.Exists(Value) && !Directory.Exists(Value)) 182 | { 183 | return null; 184 | } 185 | 186 | var attributes = File.GetAttributes(Value); 187 | 188 | if (!attributes.HasFlag(FileAttributes.Directory)) 189 | { 190 | return FileEntryKind.File; 191 | } 192 | 193 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 194 | { 195 | if (DiskUtils.IsJunction(Value)) 196 | { 197 | return FileEntryKind.Junction; 198 | } 199 | 200 | if (attributes.HasFlag(FileAttributes.ReparsePoint)) 201 | { 202 | return FileEntryKind.Symlink; 203 | } 204 | 205 | return FileEntryKind.Directory; 206 | } 207 | 208 | if (attributes.HasFlag(FileAttributes.ReparsePoint)) 209 | { 210 | return FileEntryKind.Symlink; 211 | } 212 | 213 | return FileEntryKind.Directory; 214 | } 215 | 216 | /// 217 | /// Compares the current instance with another instance, 218 | /// using the default platform-aware comparison rules provided by . 219 | /// 220 | /// The instance to compare with the current instance. 221 | /// 222 | /// A signed integer that indicates the relative order of the compared objects. 223 | /// 224 | /// 225 | /// Value 226 | /// Meaning 227 | /// 228 | /// 229 | /// Less than zero 230 | /// The current instance precedes in the sort order. 231 | /// 232 | /// 233 | /// Zero 234 | /// The current instance occurs in the same position in the sort order as . 235 | /// 236 | /// 237 | /// Greater than zero 238 | /// The current instance follows in the sort order. 239 | /// 240 | /// 241 | /// 242 | public int CompareTo(AbsolutePath other) => PlatformDefaultComparer.Compare(this, other); 243 | } 244 | 245 | /// 246 | /// Specifies the type of file system entry. 247 | /// 248 | public enum FileEntryKind 249 | { 250 | /// 251 | /// The type of the file system entry is unknown. 252 | /// 253 | Unknown, 254 | 255 | /// 256 | /// The file system entry is a regular file. 257 | /// 258 | File, 259 | 260 | /// 261 | /// The file system entry is a directory. 262 | /// 263 | Directory, 264 | 265 | /// 266 | /// The file system entry is a symbolic link. 267 | /// 268 | Symlink, 269 | 270 | /// 271 | /// The file system entry is a junction. 272 | /// 273 | Junction 274 | } 275 | 276 | -------------------------------------------------------------------------------- /TruePath/Comparers/IPathComparer.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath.Comparers; 6 | 7 | /// 8 | /// Provides an interface for defining custom comparison logic for path types. 9 | /// This interface combines both equality () 10 | /// and ordering () comparisons. 11 | /// 12 | /// The type of paths being compared, which must implement . 13 | public interface IPathComparer : IEqualityComparer, IComparer where TPath : IPath; 14 | -------------------------------------------------------------------------------- /TruePath/Comparers/PlatformDefaultPathComparer.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Runtime.InteropServices; 6 | 7 | namespace TruePath.Comparers; 8 | 9 | /// 10 | /// Provides a default comparer for comparing file paths, aware of the current platform. 11 | /// 12 | /// On Windows and macOS, this will perform case-insensitive string comparison, since the file 13 | /// systems are case-insensitive on these operating systems by default. 14 | /// 15 | /// On Linux, the comparison will be case-sensitive. 16 | /// 17 | /// 18 | /// Note that this comparison does not guarantee correctness: in practice, on any platform to control 19 | /// case-sensitiveness of either the whole file system or a part of it. This class does not take this into account, 20 | /// having a benefit of no accessing the file system for any of the comparisons. 21 | /// 22 | internal class PlatformDefaultPathComparer : IPathComparer where TPath : IPath 23 | { 24 | private readonly StringComparer _stringComparer; 25 | 26 | public PlatformDefaultPathComparer() 27 | { 28 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 29 | { 30 | _stringComparer = StringComparer.OrdinalIgnoreCase; 31 | } 32 | else 33 | { 34 | _stringComparer = StringComparer.Ordinal; 35 | } 36 | } 37 | 38 | public bool Equals(TPath? x, TPath? y) 39 | { 40 | return _stringComparer.Equals(x?.Value, y?.Value); 41 | } 42 | 43 | public int GetHashCode(TPath obj) 44 | { 45 | return _stringComparer.GetHashCode(obj.Value); 46 | } 47 | 48 | public int Compare(TPath? x, TPath? y) 49 | { 50 | return _stringComparer.Compare(x?.Value, y?.Value); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /TruePath/Comparers/StrictStringPathComparer.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath.Comparers; 6 | 7 | /// 8 | /// A strict comparer for comparing file paths using ordinal, case-sensitive comparison of the underlying path strings. 9 | /// 10 | internal class StrictStringPathComparer : IPathComparer where TPath : IPath 11 | { 12 | public bool Equals(TPath? x, TPath? y) => StringComparer.Ordinal.Equals(x?.Value, y?.Value); 13 | 14 | public int GetHashCode(TPath obj) => StringComparer.Ordinal.GetHashCode(obj.Value); 15 | 16 | public int Compare(TPath? x, TPath? y) => StringComparer.Ordinal.Compare(x?.Value, y?.Value); 17 | } 18 | -------------------------------------------------------------------------------- /TruePath/DiskUtils.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Buffers; 6 | using System.Runtime.InteropServices; 7 | using Microsoft.Win32.SafeHandles; 8 | using static TruePath.Kernel32; 9 | 10 | namespace TruePath; 11 | 12 | /// 13 | /// Utility class for handling disk operations and obtaining real paths. 14 | /// 15 | internal static class DiskUtils 16 | { 17 | // from https://github.com/dotnet/corefx/blob/9c06da6a34fcefa6fb37776ac57b80730e37387c/src/Common/src/System/IO/PathInternal.Windows.cs#L52 18 | /// 19 | /// The maximum path length for Windows. 20 | /// 21 | private const short WindowsMaxPath = short.MaxValue; 22 | 23 | /// 24 | /// Determines whether the specified path is a junction (mount point). 25 | /// 26 | /// The path to check. 27 | /// if the specified path is a junction; otherwise, . 28 | public static bool IsJunction(string path) 29 | { 30 | var reparseDataBuffer = TryGetReparseDataBuffer(path); 31 | 32 | if (reparseDataBuffer == null) 33 | { 34 | return false; 35 | } 36 | 37 | return reparseDataBuffer.Value.ReparseTag == IOReparseOptions.IO_REPARSE_TAG_MOUNT_POINT; 38 | } 39 | 40 | /// 41 | /// Tries to get the reparse data buffer for the specified path. 42 | /// 43 | /// The path to check. 44 | /// 45 | /// A containing the reparse data if successful; otherwise, . 46 | /// 47 | internal static unsafe SymbolicLinkReparseBuffer? TryGetReparseDataBuffer(string path) 48 | { 49 | using SafeFileHandle output = CreateFile( 50 | path, 51 | Kernel32.FileAccess.ReadAttributes, 52 | Kernel32.FileShare.Read, 53 | IntPtr.Zero, 54 | FileMode.Open, 55 | Kernel32.FileAttributes.BackupSemantics | Kernel32.FileAttributes.OpenReparsePoint, 56 | IntPtr.Zero); 57 | 58 | if (output.IsInvalid) 59 | { 60 | return null; 61 | } 62 | 63 | byte[] buffer = ArrayPool.Shared.Rent(MAXIMUM_REPARSE_DATA_BUFFER_SIZE); 64 | 65 | bool success; 66 | fixed (byte* pBuffer = buffer) 67 | { 68 | success = DeviceIoControl( 69 | output, 70 | dwIoControlCode: FSCTL_GET_REPARSE_POINT, 71 | lpInBuffer: null, 72 | nInBufferSize: 0, 73 | lpOutBuffer: pBuffer, 74 | nOutBufferSize: MAXIMUM_REPARSE_DATA_BUFFER_SIZE, 75 | out _, 76 | IntPtr.Zero); 77 | 78 | if (!success) 79 | { 80 | return null; 81 | } 82 | } 83 | 84 | Span bufferSpan = new(buffer); 85 | success = MemoryMarshal.TryRead(bufferSpan, out SymbolicLinkReparseBuffer reparseDataBuffer); 86 | 87 | ArrayPool.Shared.Return(buffer); 88 | 89 | if (success) 90 | { 91 | return reparseDataBuffer; 92 | } 93 | 94 | return null; 95 | } 96 | 97 | /// 98 | /// Gets the real (absolute) path of the specified depending on the operating system. 99 | /// 100 | /// The file path to resolve. 101 | /// The resolved absolute path. 102 | internal static string GetRealPath(string path) 103 | { 104 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 105 | { 106 | return GetWindowsRealPath(path); 107 | } 108 | 109 | return GetPosixRealPath(path); 110 | } 111 | 112 | /// 113 | /// Gets the real (absolute) path of the specified for POSIX systems. 114 | /// 115 | /// The file path to resolve. 116 | /// The resolved absolute path, or the original path if resolution fails. 117 | private static string GetPosixRealPath(string path) 118 | { 119 | using var ptr = Libc.RealPath(path, IntPtr.Zero); 120 | 121 | if (ptr.DangerousGetHandle() == IntPtr.Zero) 122 | { 123 | return path; 124 | } 125 | 126 | var result = Marshal.PtrToStringAnsi(ptr.DangerousGetHandle()); 127 | 128 | return result ?? path; 129 | } 130 | 131 | /// 132 | /// Gets the real (absolute) path of the specified for Windows systems. 133 | /// 134 | /// The file path to resolve. 135 | /// The resolved absolute path, or the original path if resolution fails. 136 | private static string GetWindowsRealPath(string path) 137 | { 138 | using var handle = CreateFile(path, 139 | Kernel32.FileAccess.ReadEa, 140 | Kernel32.FileShare.Read, 141 | IntPtr.Zero, 142 | FileMode.Open, 143 | Kernel32.FileAttributes.BackupSemantics, 144 | IntPtr.Zero); 145 | 146 | if (handle.IsInvalid) 147 | { 148 | return path; 149 | } 150 | 151 | return GetWindowsRealPathByHandle(handle.DangerousGetHandle()) ?? path; 152 | } 153 | 154 | /// 155 | /// Gets the real (absolute) path by file handle for Windows systems. 156 | /// 157 | /// The file handle. 158 | /// The resolved absolute path, or null if resolution fails. 159 | private static unsafe string? GetWindowsRealPathByHandle(IntPtr handle) 160 | { 161 | // this is called for each storage environment for the Data, Journals and Temp paths 162 | // WindowsMaxPath is 32K and although this is called only once we can have a lot of storage environments 163 | if (GetPath(256, out var realPath) == false) 164 | { 165 | if (GetPath((uint)WindowsMaxPath, out realPath) == false) 166 | return null; 167 | } 168 | 169 | if (string.IsNullOrWhiteSpace(realPath)) 170 | { 171 | return null; 172 | } 173 | 174 | //The string that is returned by this function uses the \?\ syntax 175 | if (realPath.Length >= 8 && realPath.AsSpan().StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) 176 | { 177 | // network path, replace `\\?\UNC\` with `\\` 178 | realPath = string.Concat("\\", realPath.AsSpan(7)); 179 | } 180 | 181 | if (realPath.Length >= 4 && realPath.AsSpan().StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) 182 | { 183 | // local path, remove `\\?\` 184 | realPath = realPath.AsSpan(4).ToString(); 185 | } 186 | 187 | return realPath; 188 | 189 | bool GetPath(uint bufferSize, out string? outputPath) 190 | { 191 | var charArray = ArrayPool.Shared.Rent((int)bufferSize); 192 | 193 | fixed (char* buffer = charArray) 194 | { 195 | var result = GetFinalPathNameByHandle(handle, buffer, bufferSize); 196 | if (result == 0) 197 | { 198 | outputPath = null; 199 | ArrayPool.Shared.Return(charArray); 200 | return false; 201 | } 202 | 203 | // return value is the size of the path 204 | if (result > bufferSize) 205 | { 206 | outputPath = null; 207 | ArrayPool.Shared.Return(charArray); 208 | return false; 209 | } 210 | 211 | outputPath = new string(charArray, 0, (int)result); 212 | ArrayPool.Shared.Return(charArray); 213 | return true; 214 | } 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /TruePath/IPath.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath; 6 | 7 | /// Represents a path in a file system. 8 | public interface IPath 9 | { 10 | /// The normalized path string. 11 | string Value { get; } 12 | 13 | /// The name of this path's last component. 14 | string FileName { get; } 15 | 16 | /// 17 | /// The parent of this path. Will be null for a rooted absolute path. For a relative path, will always 18 | /// resolve to its parent directory — by either removing directories from the end of the path, or appending 19 | /// .. to the end. 20 | /// 21 | IPath? Parent { get; } 22 | } 23 | 24 | /// Represents a path in a file system. Allows generic operators to be applied. 25 | /// The type of this path. 26 | public interface IPath where TPath : IPath 27 | { 28 | /// Appends another path to this one. 29 | /// 30 | /// Note that in case path is absolute, it will completely take over and the 31 | /// will be ignored. 32 | /// 33 | static abstract TPath operator /(TPath basePath, LocalPath appended); 34 | 35 | /// 36 | static abstract TPath operator /(TPath basePath, string appended); 37 | 38 | /// 39 | /// Checks for a non-strict prefix: if the paths are equal, then they are still considered prefixes of each other. 40 | /// 41 | /// Note that currently this comparison is case-sensitive. 42 | bool IsPrefixOf(TPath other); 43 | 44 | /// 45 | /// Determines whether the current path starts with the specified path. 46 | /// 47 | /// The path to compare to the current path. 48 | /// Note that currently this comparison is case-sensitive. 49 | bool StartsWith(TPath other); 50 | 51 | /// 52 | /// Creates a new path instance of type from the specified string value. 53 | /// 54 | /// The string representation of the path to create. 55 | /// A new instance of representing the specified path. 56 | static abstract TPath Create(string value); 57 | } 58 | -------------------------------------------------------------------------------- /TruePath/Kernel32.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Diagnostics.CodeAnalysis; 6 | using System.Runtime.InteropServices; 7 | using Microsoft.Win32.SafeHandles; 8 | 9 | namespace TruePath; 10 | 11 | [SuppressMessage("ReSharper", "InconsistentNaming")] 12 | internal static partial class Kernel32 13 | { 14 | /// 15 | /// Retrieves the final path for the specified file handle. 16 | /// 17 | /// The file handle. 18 | /// A buffer that receives the final path. 19 | /// The size of the buffer, in characters. 20 | /// The flags to specify the path format. 21 | /// The length of the string copied to the buffer. 22 | [LibraryImport("kernel32.dll", 23 | EntryPoint = "GetFinalPathNameByHandleW", 24 | SetLastError = true, 25 | StringMarshalling = StringMarshalling.Utf16)] 26 | internal static unsafe partial uint GetFinalPathNameByHandle( 27 | IntPtr hFile, 28 | char* buffer, 29 | uint bufferLength, 30 | uint dwFlags = 0); 31 | 32 | [Flags] 33 | internal enum FileAttributes : uint 34 | { 35 | /// FILE_ATTRIBUTE_ARCHIVE 36 | Archive = 0x20, 37 | 38 | /// FILE_ATTRIBUTE_COMPRESSED 39 | Compressed = 0x800, 40 | 41 | /// FILE_ATTRIBUTE_DEVICE 42 | Device = 0x40, 43 | 44 | /// FILE_ATTRIBUTE_DIRECTORY 45 | Directory = 0x10, 46 | 47 | /// FILE_ATTRIBUTE_ENCRYPTED 48 | Encrypted = 0x4000, 49 | 50 | /// FILE_ATTRIBUTE_HIDDEN 51 | Hidden = 0x02, 52 | 53 | /// FILE_ATTRIBUTE_INTEGRITY_STREAM 54 | IntegrityStream = 0x8000, 55 | 56 | /// FILE_ATTRIBUTE_NORMAL 57 | Normal = 0x80, 58 | 59 | /// FILE_ATTRIBUTE_NOT_CONTENT_INDEXED 60 | NotContentIndexed = 0x2000, 61 | 62 | /// FILE_ATTRIBUTE_NO_SCRUB_DATA 63 | NoScrubData = 0x20000, 64 | 65 | /// FILE_ATTRIBUTE_OFFLINE 66 | Offline = 0x1000, 67 | 68 | /// FILE_ATTRIBUTE_READONLY 69 | Readonly = 0x01, 70 | 71 | /// FILE_ATTRIBUTE_REPARSE_POINT 72 | ReparsePoint = 0x400, 73 | 74 | /// FILE_ATTRIBUTE_SPARSE_FILE 75 | SparseFile = 0x200, 76 | 77 | /// FILE_ATTRIBUTE_SYSTEM 78 | System = 0x04, 79 | 80 | /// FILE_ATTRIBUTE_TEMPORARY 81 | Temporary = 0x100, 82 | 83 | /// FILE_ATTRIBUTE_VIRTUAL 84 | Virtual = 0x10000, 85 | 86 | /// FILE_FLAG_BACKUP_SEMANTICS 87 | BackupSemantics = 0x02000000, 88 | 89 | /// FILE_FLAG_DELETE_ON_CLOSE 90 | DeleteOnClose = 0x04000000, 91 | 92 | /// FILE_FLAG_NO_BUFFERING 93 | NoBuffering = 0x20000000, 94 | 95 | /// FILE_FLAG_OPEN_NO_RECALL 96 | OpenNoRecall = 0x00100000, 97 | 98 | /// FILE_FLAG_OPEN_REPARSE_POINT 99 | OpenReparsePoint = 0x00200000, 100 | 101 | /// FILE_FLAG_OVERLAPPED 102 | Overlapped = 0x40000000, 103 | 104 | /// FILE_FLAG_POSIX_SEMANTICS 105 | PosixSemantics = 0x0100000, 106 | 107 | /// FILE_FLAG_RANDOM_ACCESS 108 | RandomAccess = 0x10000000, 109 | 110 | /// FILE_FLAG_SESSION_AWARE 111 | SessionAware = 0x00800000, 112 | 113 | /// FILE_FLAG_SEQUENTIAL_SCAN 114 | SequentialScan = 0x08000000, 115 | 116 | /// FILE_FLAG_WRITE_THROUGH 117 | WriteThrough = 0x80000000 118 | } 119 | 120 | [Flags] 121 | internal enum FileAccess : uint 122 | { 123 | /// FILE_READ_DATA 124 | ReadData = 0x0001, 125 | 126 | /// FILE_LIST_DIRECTORY 127 | ListDirectory = ReadData, 128 | 129 | /// FILE_WRITE_DATA 130 | WriteData = 0x0002, 131 | 132 | /// FILE_ADD_FILE 133 | AddFile = WriteData, 134 | 135 | /// FILE_APPEND_DATA 136 | AppendData = 0x0004, 137 | 138 | /// FILE_ADD_SUBDIRECTORY 139 | AddSubdirectory = AppendData, 140 | 141 | /// FILE_CREATE_PIPE_INSTANCE 142 | CreatePipeInstance = AppendData, 143 | 144 | /// FILE_READ_EA 145 | ReadEa = 0x0008, 146 | 147 | /// FILE_WRITE_EA 148 | WriteEa = 0x0010, 149 | 150 | /// FILE_EXECUTE 151 | Execute = 0x0020, 152 | 153 | /// FILE_TRAVERSE 154 | Traverse = Execute, 155 | 156 | /// FILE_DELETE_CHILD 157 | DeleteChild = 0x0040, 158 | 159 | /// FILE_READ_ATTRIBUTES 160 | ReadAttributes = 0x0080, 161 | 162 | /// FILE_WRITE_ATTRIBUTES 163 | WriteAttributes = 0x0100, 164 | 165 | /// GENERIC_READ 166 | GenericRead = 0x80000000, 167 | 168 | /// GENERIC_WRITE 169 | GenericWrite = 0x40000000, 170 | 171 | /// GENERIC_EXECUTE 172 | GenericExecute = 0x20000000, 173 | 174 | /// GENERIC_ALL 175 | GenericAll = 0x10000000 176 | } 177 | 178 | [Flags] 179 | internal enum FileShare : uint 180 | { 181 | /// FILE_SHARE_NONE 182 | None = 0x00, 183 | 184 | /// FILE_SHARE_READ 185 | Read = 0x01, 186 | 187 | /// FILE_SHARE_WRITE 188 | Write = 0x02, 189 | 190 | /// FILE_SHARE_DELETE 191 | Delete = 0x03 192 | } 193 | 194 | /// 195 | /// Creates or opens a file or I/O device. 196 | /// 197 | /// The name of the file or device to be created or opened. 198 | /// The requested access to the file or device. 199 | /// The requested sharing mode of the file or device. 200 | /// A pointer to a SECURITY_ATTRIBUTES structure or IntPtr.Zero. 201 | /// An action to take on a file or device that exists or does not exist. 202 | /// The file or device attributes and flags. 203 | /// A valid handle to a template file, or IntPtr.Zero. 204 | /// A for the opened file or device. 205 | [LibraryImport("kernel32.dll", 206 | EntryPoint = "CreateFileW", 207 | SetLastError = true, 208 | StringMarshalling = StringMarshalling.Utf16)] 209 | internal static partial SafeFileHandle CreateFile( 210 | [MarshalAs(UnmanagedType.LPTStr)] string filename, 211 | FileAccess access, 212 | FileShare share, 213 | nint securityAttributes, // optional SECURITY_ATTRIBUTES struct or IntPtr.Zero 214 | FileMode creationDisposition, 215 | FileAttributes flagsAndAttributes, 216 | nint templateFile); 217 | 218 | /// 219 | /// Control code for retrieving reparse point data. 220 | /// 221 | /// 222 | /// For more information, see . 223 | /// 224 | internal const int FSCTL_GET_REPARSE_POINT = 0x000900a8; 225 | 226 | /// 227 | /// Control code for reading the storage capacity of a device. 228 | /// 229 | /// 230 | /// For more information, see . 231 | /// 232 | internal const int IOCTL_STORAGE_READ_CAPACITY = 0x002D5140; 233 | 234 | /// 235 | /// Sends a control code directly to a specified device driver, causing the corresponding device to perform the corresponding operation. 236 | /// 237 | /// A handle to the device on which the operation is to be performed. 238 | /// The control code for the operation. 239 | /// A pointer to the input buffer that contains the data required to perform the operation. 240 | /// The size of the input buffer, in bytes. 241 | /// A pointer to the output buffer that receives the data returned by the operation. 242 | /// The size of the output buffer, in bytes. 243 | /// A variable that receives the size of the data stored in the output buffer, in bytes. 244 | /// A pointer to an OVERLAPPED structure for asynchronous operations. For synchronous operations, this parameter is set to . 245 | /// 246 | /// if the operation succeeds; otherwise, . If the operation fails, call to get extended error information. 247 | /// 248 | /// 249 | /// For more information, see . 250 | /// 251 | [LibraryImport("kernel32.dll", EntryPoint = "DeviceIoControl", SetLastError = true)] 252 | [return: MarshalAs(UnmanagedType.Bool)] 253 | internal static unsafe partial bool DeviceIoControl( 254 | SafeHandle hDevice, 255 | uint dwIoControlCode, 256 | void* lpInBuffer, 257 | uint nInBufferSize, 258 | void* lpOutBuffer, 259 | uint nOutBufferSize, 260 | out uint lpBytesReturned, 261 | IntPtr lpOverlapped); 262 | 263 | /// 264 | /// The maximum size of the reparse data buffer. 265 | /// 266 | /// 267 | /// For more information, see . 268 | /// 269 | internal const int MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 16 * 1024; 270 | 271 | /// 272 | /// Contains constants for reparse tags used in IO operations. 273 | /// 274 | internal static class IOReparseOptions 275 | { 276 | /// 277 | /// Reparse tag for a file placeholder. 278 | /// 279 | internal const uint IO_REPARSE_TAG_FILE_PLACEHOLDER = 0x80000015; 280 | 281 | /// 282 | /// Reparse tag for a mount point. 283 | /// 284 | internal const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003; 285 | 286 | /// 287 | /// Reparse tag for a symbolic link. 288 | /// 289 | internal const uint IO_REPARSE_TAG_SYMLINK = 0xA000000C; 290 | } 291 | 292 | /// 293 | /// Represents a symbolic link reparse buffer. 294 | /// 295 | /// 296 | /// For more information, see . 297 | /// 298 | [StructLayout(LayoutKind.Sequential)] 299 | internal struct SymbolicLinkReparseBuffer 300 | { 301 | /// 302 | /// The reparse tag. 303 | /// 304 | internal uint ReparseTag; 305 | 306 | /// 307 | /// The length of the reparse data. 308 | /// 309 | internal ushort ReparseDataLength; 310 | 311 | /// 312 | /// Reserved; do not use. 313 | /// 314 | internal ushort Reserved; 315 | 316 | /// 317 | /// The offset, in bytes, to the substitute name string in the PathBuffer array. 318 | /// 319 | internal ushort SubstituteNameOffset; 320 | 321 | /// 322 | /// The length, in bytes, of the substitute name string. If this string is null-terminated, SubstituteNameLength does not include space for the null character. 323 | /// 324 | internal ushort SubstituteNameLength; 325 | 326 | /// 327 | /// The offset, in bytes, to the print name string in the PathBuffer array. 328 | /// 329 | internal ushort PrintNameOffset; 330 | 331 | /// 332 | /// The length, in bytes, of the print name string. If this string is null-terminated, PrintNameLength does not include space for the null character. 333 | /// 334 | internal ushort PrintNameLength; 335 | 336 | /// 337 | /// Flags that control the behavior of the symbolic link. 338 | /// 339 | internal uint Flags; 340 | } 341 | 342 | } 343 | -------------------------------------------------------------------------------- /TruePath/Libc.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Runtime.InteropServices; 6 | using System.Runtime.InteropServices.Marshalling; 7 | using Microsoft.Win32.SafeHandles; 8 | 9 | namespace TruePath; 10 | 11 | /// 12 | /// Provides interop methods for the libc library. 13 | /// 14 | internal static partial class Libc 15 | { 16 | /// 17 | /// Resolves the absolute path of the specified and stores it in the provided . 18 | /// 19 | /// The file path to resolve. 20 | /// A pointer to a buffer where the resolved absolute path will be stored. 21 | /// A representing the resolved path, or an invalid handle if the function fails. 22 | /// 23 | /// This method is an interop call to the 'realpath' function in the libc library. The should be allocated with enough space to hold the resolved path. 24 | /// 25 | [LibraryImport("libc", 26 | EntryPoint = "realpath", 27 | SetLastError = true, 28 | StringMarshalling = StringMarshalling.Custom, 29 | StringMarshallingCustomType = typeof(AnsiStringMarshaller))] 30 | internal static partial SafeFileHandle RealPath(string path, IntPtr buffer); 31 | } 32 | -------------------------------------------------------------------------------- /TruePath/LocalPath.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using TruePath.Comparers; 6 | 7 | namespace TruePath; 8 | 9 | /// 10 | /// A path pointing to a place in the local file system. 11 | /// It may be either absolute or relative. 12 | /// 13 | /// Always stored in a normalized form. Read the documentation on to 14 | /// know what form of normalization the path uses. 15 | /// 16 | /// 17 | public readonly struct LocalPath(string value) : IEquatable, IComparable, IPath, IPath 18 | { 19 | /// 20 | /// Provides a default comparer for comparing file paths, aware of the current platform. 21 | /// 22 | /// On Windows and macOS, this will perform case-insensitive string comparison, since the 23 | /// file systems are case-insensitive on these operating systems by default. 24 | /// 25 | /// On Linux, the comparison will be case-sensitive. 26 | /// 27 | /// 28 | /// Note that this comparison does not guarantee correctness: in practice, on any platform to control 29 | /// case-sensitiveness of either the whole file system or a part of it. This class does not take this into account, 30 | /// having a benefit of no accessing the file system for any of the comparisons. 31 | /// 32 | public static readonly IPathComparer PlatformDefaultComparer = 33 | new PlatformDefaultPathComparer(); 34 | 35 | /// 36 | /// A strict comparer for comparing file paths using ordinal, case-sensitive comparison of the underlying path 37 | /// strings. 38 | /// 39 | public static readonly IPathComparer StrictStringComparer = 40 | new StrictStringPathComparer(); 41 | 42 | private static char Separator => Path.DirectorySeparatorChar; 43 | 44 | /// 45 | public string Value { get; } = PathStrings.Normalize(value); 46 | 47 | /// 48 | /// Checks whether the path is absolute. 49 | /// 50 | /// Currently, any rooted paths are considered absolute, but this is subject to change: on Windows, there 51 | /// will be an additional requirement for a path to be either a DOS device path or start from a disk letter. 52 | /// 53 | /// 54 | public bool IsAbsolute => Path.IsPathRooted(Value); 55 | 56 | /// 57 | public LocalPath? Parent 58 | { 59 | get 60 | { 61 | if (Value == "" || Value == ".." || Value.EndsWith($"{Separator}..")) return this / ".."; 62 | return Path.GetDirectoryName(Value) is { } parent ? new(parent) : null; 63 | } 64 | } 65 | 66 | /// 67 | IPath? IPath.Parent => Parent; 68 | 69 | /// 70 | public string FileName => Path.GetFileName(Value); 71 | 72 | /// The normalized path string contained in this object. 73 | public override string ToString() => Value; 74 | 75 | /// Compares the path with another. 76 | /// Note that currently this comparison is case-sensitive. 77 | public bool Equals(LocalPath other) => Equals(other, PlatformDefaultComparer); 78 | 79 | /// 80 | /// Determines whether the specified is equal to the current using the specified string comparer. 81 | /// 82 | /// The to compare with the current . 83 | /// The comparer to use for comparing the paths. 84 | /// 85 | /// if the specified is equal to the current using the specified string comparer; otherwise, . 86 | /// 87 | public bool Equals(LocalPath other, IEqualityComparer comparer) => comparer.Equals(this, other); 88 | 89 | /// 90 | public override bool Equals(object? obj) 91 | { 92 | return obj is LocalPath other && Equals(other); 93 | } 94 | 95 | /// 96 | public override int GetHashCode() => PlatformDefaultComparer.GetHashCode(this); 97 | 98 | /// 99 | public static bool operator ==(LocalPath left, LocalPath right) 100 | { 101 | return left.Equals(right); 102 | } 103 | 104 | /// 105 | public static bool operator !=(LocalPath left, LocalPath right) 106 | { 107 | return !left.Equals(right); 108 | } 109 | 110 | /// 111 | public bool StartsWith(LocalPath other) => Value.StartsWith(other.Value); 112 | 113 | /// 114 | /// Creates a new instance from the specified string value. 115 | /// 116 | /// The string representation of the path to create. 117 | /// A new normalized instance representing the specified path. 118 | public static LocalPath Create(string value) => new(value); 119 | 120 | /// 121 | public bool IsPrefixOf(LocalPath other) 122 | { 123 | if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false; 124 | return other.Value.Length == Value.Length || other.Value[Value.Length] == Separator; 125 | } 126 | 127 | /// 128 | /// Calculates the relative path from a base path to this path. 129 | /// 130 | /// The base path from which to calculate the relative path. 131 | /// The relative path from the base path to this path. 132 | public LocalPath RelativeTo(LocalPath basePath) => new(Path.GetRelativePath(basePath.Value, Value)); 133 | 134 | /// Appends another path to this one. 135 | /// 136 | /// Note that in case path is absolute, it will completely take over and the 137 | /// will be ignored. 138 | /// 139 | public static LocalPath operator /(LocalPath basePath, LocalPath b) => 140 | new(Path.Combine(basePath.Value, b.Value)); 141 | 142 | /// Appends another path to this one. 143 | /// 144 | /// Note that in case path is absolute, it will completely take over and the 145 | /// will be ignored. 146 | /// 147 | public static LocalPath operator /(LocalPath basePath, string b) => basePath / new LocalPath(b); 148 | 149 | /// 150 | /// Implicitly converts an to a . 151 | /// 152 | /// Note that this conversion doesn't lose any information. 153 | public static implicit operator LocalPath(AbsolutePath path) => path.Underlying; 154 | 155 | /// 156 | /// Resolves this path to an absolute path based on the current working directory. 157 | /// 158 | /// An that represents this path resolved against the current working directory. 159 | /// 160 | /// Note that if this path is already absolute, it will just transform to . The current 161 | /// directory won't matter for such a case. 162 | /// 163 | public AbsolutePath ResolveToCurrentDirectory() => AbsolutePath.CurrentWorkingDirectory / this; 164 | 165 | /// Converts an to a . 166 | public LocalPath(AbsolutePath path) : this(path.Value) 167 | { 168 | } 169 | 170 | /// 171 | /// Compares the current instance with another instance, 172 | /// using the default platform-aware comparison rules provided by . 173 | /// 174 | /// The instance to compare with the current instance. 175 | /// 176 | /// A signed integer that indicates the relative order of the compared objects. 177 | /// 178 | /// 179 | /// Value 180 | /// Meaning 181 | /// 182 | /// 183 | /// Less than zero 184 | /// The current instance precedes in the sort order. 185 | /// 186 | /// 187 | /// Zero 188 | /// The current instance occurs in the same position in the sort order as . 189 | /// 190 | /// 191 | /// Greater than zero 192 | /// The current instance follows in the sort order. 193 | /// 194 | /// 195 | /// 196 | public int CompareTo(LocalPath other) => PlatformDefaultComparer.Compare(this, other); 197 | } 198 | -------------------------------------------------------------------------------- /TruePath/LocalPathPattern.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath; 6 | 7 | /// 8 | /// An opaque pattern that may be checked if it corresponds to a local path. 9 | /// 10 | /// This is a token type, created with an idea that it should be interpreted in usage-specific way by some external 11 | /// means. 12 | /// 13 | /// 14 | /// 15 | /// The pattern text. May be of various formats: say, a glob. This library doesn't dictate any particular format and 16 | /// gives no guarantees. 17 | /// 18 | public record struct LocalPathPattern(string Value) 19 | { 20 | /// The pattern text contained in this object. 21 | public override string ToString() => Value; 22 | } 23 | -------------------------------------------------------------------------------- /TruePath/PathExtensions.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath; 6 | 7 | /// 8 | /// Extension methods for and . 9 | /// 10 | public static class PathExtensions 11 | { 12 | /// 13 | /// Gets the extension of the file name of the with the dot character. 14 | /// For example, for the path file.txt, this method will return a string .txt. 15 | /// File name entirely consisting of extension, such as .gitignore, is returned as-is. 16 | /// 17 | /// The extension of the file name of the path with the dot character (if present). 18 | /// 19 | /// This method will return an empty string for paths without extensions, and will return a dot for paths whose 20 | /// names end with a dot (even though it is an unusual path). This behavior allows to distinguish such paths, 21 | /// and potentially reconstruct the file name from its part without the extension and the "extension with dot". 22 | /// 23 | public static string GetExtensionWithDot(this IPath path) => 24 | path.FileName.EndsWith('.') ? "." : Path.GetExtension(path.FileName); 25 | 26 | /// 27 | /// Gets the extension of the file name of the without the dot character. 28 | /// For example, for the path file.txt, this method will return a string txt. 29 | /// 30 | /// File name entirely consisting of extension, such as .gitignore, is returned with its leading dot 31 | /// trimmed. 32 | /// 33 | /// 34 | /// The extension of the file name of the path without the dot. 35 | /// 36 | /// This method will return an empty string for paths without extensions and with empty extensions (ending with 37 | /// dot, which may be unusual). This behavior doesn't allow to distinguish such paths using this method, to 38 | /// reconstruct the original name from its name without extension and its extension without dot. 39 | /// 40 | public static string GetExtensionWithoutDot(this IPath path) => GetExtensionWithDot(path).TrimStart('.'); 41 | 42 | /// 43 | /// Gets the file name of the without the extension. 44 | /// For example, for the path file.txt, this method will return a string file. 45 | /// 46 | /// 47 | /// The file name of the path without the extension. If the path has no extension, the file name is returned as-is 48 | /// (one trailing dot will be stripped, though). 49 | /// 50 | public static string GetFilenameWithoutExtension(this IPath path) => 51 | Path.GetFileNameWithoutExtension(path.FileName); 52 | 53 | /// 54 | /// Returns a new path of the same type with the extension of its file name component changed, 55 | /// or with a new extension-like component if the original file name was empty. 56 | /// 57 | /// The type of the path, which must implement . 58 | /// The original path. 59 | /// The new extension to apply. 60 | /// 61 | /// A new path of type with the modified file name component. 62 | /// The original object is not modified. 63 | /// 64 | public static TPath WithExtension(this TPath path, string? extension) where TPath : IPath => 65 | TPath.Create(Path.ChangeExtension(((IPath)path).Value, extension)); 66 | } 67 | -------------------------------------------------------------------------------- /TruePath/PathStrings.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | using System.Buffers; 6 | using System.Runtime.CompilerServices; 7 | 8 | namespace TruePath; 9 | 10 | /// Helper methods to manipulate paths as strings. 11 | public static class PathStrings 12 | { 13 | private const char VolumeSeparatorChar = ':'; 14 | 15 | /// 16 | /// 17 | /// Will convert a path string to a normalized path, using path separator specific for the current system. 18 | /// 19 | /// 20 | /// The normalization includes: 21 | /// 22 | /// 23 | /// converting all the to 24 | /// (e.g. / to \ on Windows), 25 | /// 26 | /// 27 | /// collapsing any repeated separators in the input to only one separator (e.g. // to just 28 | /// / on Unix), 29 | /// 30 | /// 31 | /// resolving any sequence of current and parent directory marks (subsequently, . and ..) 32 | /// if possible (meaning they will not be replaced if they are in the root position: paths such as 33 | /// . or ../.. will not be affected by the normalization, while e.g. foo/../. will 34 | /// be resolved to just foo). 35 | /// 36 | /// 37 | /// 38 | /// 39 | /// Note that this operation will never perform any file IO, and is purely string manipulation. 40 | /// 41 | /// 42 | [SkipLocalsInit] // is necessary to prevent the CLR from filling stackalloc with zeros. 43 | public static string Normalize(string path) 44 | { 45 | bool containsDriveLetter = SourceContainsDriveLetter(path.AsSpan()); 46 | 47 | int written = 0; 48 | 49 | char[]? array = path.Length <= 512 ? null : ArrayPool.Shared.Rent(path.Length); 50 | 51 | Span normalized = array != null ? array.AsSpan() : stackalloc char[path.Length]; 52 | ReadOnlySpan source = containsDriveLetter ? path.AsSpan()[2..] : path.AsSpan(); 53 | 54 | var buffer = normalized; 55 | 56 | while (true) 57 | { 58 | bool last = false; 59 | var separator = source.IndexOf(Path.DirectorySeparatorChar); 60 | var altSeparator = source.IndexOf(Path.AltDirectorySeparatorChar); 61 | 62 | if (altSeparator == -1 && separator == -1) { last = true; separator = source.Length - 1; } 63 | else if (separator == -1) separator = altSeparator; 64 | else if (altSeparator == -1) { } 65 | else separator = Math.Min(separator, altSeparator); 66 | 67 | separator++; 68 | var block = source.Slice(0, separator); 69 | 70 | bool skip; 71 | // skip if '.' 72 | if (block.Length == 1 && block[0] == '.') 73 | skip = true; 74 | // skip if './' 75 | else if (block.Length == 2 && block[0] == '.' && (block[1] == Path.DirectorySeparatorChar || block[1] == Path.AltDirectorySeparatorChar)) 76 | skip = true; 77 | // cut if '..' or '../' 78 | else if (written != 0 79 | && ( 80 | block is ".." 81 | || block.SequenceEqual($"..{Path.DirectorySeparatorChar}") 82 | || block.SequenceEqual($"..{Path.AltDirectorySeparatorChar}") 83 | )) 84 | { 85 | var alreadyWrittenPart = normalized[..(written - 1)]; 86 | var jump = alreadyWrittenPart.LastIndexOf(Path.DirectorySeparatorChar); 87 | 88 | // Check if the last entry in the normalized path is "..": in this case, no need to skip (we keep a 89 | // train of ../../.. in the normalized path's root because they are impossible to get rid of during 90 | // normalization). 91 | var lastEntryStartIndex = jump + 1; 92 | var lastEntry = alreadyWrittenPart[lastEntryStartIndex..]; 93 | if (lastEntry is "..") 94 | { 95 | skip = false; 96 | } 97 | else if (jump == -1 && written > 1) 98 | { 99 | written = 0; 100 | buffer = normalized; 101 | skip = true; 102 | } 103 | else if (jump != -1) 104 | { 105 | written = last ? jump : jump + 1; 106 | buffer = normalized.Slice(written); 107 | skip = true; 108 | } 109 | else 110 | skip = false; 111 | } 112 | else 113 | skip = false; 114 | 115 | // append sliced path 116 | if (!skip) 117 | { 118 | block.CopyTo(buffer); 119 | written += separator; 120 | // replace \ with / if ends with \ 121 | if (separator > 0 && buffer[separator - 1] == Path.AltDirectorySeparatorChar) 122 | buffer[separator - 1] = Path.DirectorySeparatorChar; 123 | buffer = buffer.Slice(separator); 124 | } 125 | 126 | // skip the following / or \ 127 | while (separator < source.Length && (source[separator] == Path.DirectorySeparatorChar || source[separator] == Path.AltDirectorySeparatorChar)) 128 | separator++; 129 | 130 | // next iter 131 | source = source.Slice(separator); 132 | // append everything else if there`s no more '\' or '/' 133 | if (last) 134 | { 135 | source.CopyTo(buffer); 136 | written += source.Length; 137 | break; 138 | } 139 | } 140 | 141 | if (written == 0 && containsDriveLetter) 142 | { 143 | return new string(path.AsSpan(0, 2)); 144 | } 145 | 146 | if (written == 0) 147 | { 148 | return string.Empty; 149 | } 150 | 151 | // remove / at the end of path 152 | if (written > 1 && normalized[written - 1] == Path.DirectorySeparatorChar) 153 | written--; 154 | 155 | // alloc new path 156 | string? result; 157 | 158 | if (containsDriveLetter) 159 | { 160 | var normalizedRef = new ReadOnlySpan(normalized.ToArray(), 0, written); 161 | result = string.Concat(path.AsSpan(0, 2), normalizedRef.Slice(0, written)); 162 | } 163 | else 164 | { 165 | result = new string(normalized.Slice(0, written)); 166 | } 167 | 168 | normalized.Slice(0, written); 169 | if (array != null) 170 | ArrayPool.Shared.Return(array); 171 | return result; 172 | } 173 | 174 | /// 175 | /// Determines whether the specified source contains a drive letter. 176 | /// 177 | /// A read-only span of characters to be checked. 178 | /// 179 | /// true if the source contains a drive letter (e.g., 'C:'); otherwise, false. 180 | /// 181 | private static bool SourceContainsDriveLetter(ReadOnlySpan source) 182 | { 183 | if (source.Length < 2) 184 | { 185 | return false; 186 | } 187 | 188 | return source[1] == VolumeSeparatorChar && (uint)((source[0] | 0x20) - 'a') <= 'z' - 'a'; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /TruePath/Temporary.cs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace TruePath; 6 | 7 | /// 8 | /// Provides methods for working with temporary files, directories and folders. 9 | /// 10 | public static class Temporary 11 | { 12 | 13 | /// 14 | /// Gets the system's temporary directory. 15 | /// 16 | /// An AbsolutePath representing the system's temporary directory. 17 | public static AbsolutePath SystemTempDirectory() 18 | { 19 | var tempPath = Path.GetTempPath(); 20 | return AbsolutePath.CurrentWorkingDirectory / tempPath; 21 | } 22 | 23 | /// 24 | /// Creates a temporary file. 25 | /// 26 | /// An AbsolutePath representing the newly created temporary file. 27 | public static AbsolutePath CreateTempFile() 28 | { 29 | var tempPath = Path.GetTempFileName(); 30 | return AbsolutePath.CurrentWorkingDirectory / tempPath; 31 | } 32 | 33 | /// 34 | /// Creates a temporary folder with the specified prefix. 35 | /// 36 | /// An optional string to add to the beginning of the subdirectory name. 37 | /// An AbsolutePath representing newly created temporary folder 38 | public static AbsolutePath CreateTempFolder(string? prefix = null) 39 | { 40 | var tempDirectoryInfo = Directory.CreateTempSubdirectory(prefix); 41 | return AbsolutePath.CurrentWorkingDirectory / tempDirectoryInfo.FullName; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /TruePath/TruePath.csproj: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | true 11 | File path abstraction library for .NET. 12 | true 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/docfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": [ 3 | { 4 | "src": [ 5 | { 6 | "src": "..", 7 | "files": [ 8 | "TruePath/TruePath.csproj", 9 | "TruePath.SystemIo/TruePath.SystemIo.csproj" 10 | ] 11 | } 12 | ], 13 | "dest": "api" 14 | } 15 | ], 16 | "build": { 17 | "content": [ 18 | { 19 | "files": [ 20 | "**/*.{md,yml}" 21 | ], 22 | "exclude": [ 23 | "_site/**" 24 | ] 25 | } 26 | ], 27 | "resource": [ 28 | { 29 | "files": [ 30 | "images/**" 31 | ] 32 | } 33 | ], 34 | "output": "_site", 35 | "template": [ 36 | "default", 37 | "modern" 38 | ], 39 | "globalMetadata": { 40 | "_appName": "TruePath", 41 | "_appTitle": "TruePath", 42 | "_enableSearch": true, 43 | "pdf": false 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docs/docfx.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | _disableBreadcrumb: true 3 | --- 4 | 5 | 10 | 11 | TruePath: the Path Manipulation Library for .NET 12 | ================================================ 13 | 14 | Motivation 15 | ---------- 16 | Historically, .NET has been lacking a good set of types to work with file system paths. The `System.IO.Path` class has a variety of methods that operate on path strings, but it doesn't provide any types to represent paths themselves. It's impossible to tell whether a method accepts an absolute path, a relative path, a file name, or something file-related at all, only looking at its signature: all these types are represented by plain strings. Also, comparing different paths is not straightforward. 17 | 18 | This library aims to fill this gap by providing a set of types that represent paths in a strongly-typed way. Now, you can require a path in a method's parameters, and it is guaranteed that the passed path will be well-formed and will have certain properties. 19 | 20 | Also, the methods in the library provide some qualities that are missing from the `System.IO.Path`: say, we aim to provide several ways of path normalization and comparison, the ones that will and will not perform disk IO to resolve paths on case-insensitive file systems. 21 | 22 | The library is inspired by the path libraries used in other ecosystems: in particular, Java's [java.nio.file.Path][java.path] and [Kotlin's extensions][kotlin.path]. 23 | 24 | Project Summary 25 | --------------- 26 | TruePath allows the user to employ two main approaches to work with paths. 27 | 28 | For cases when you want the path kinds to be checked at compile time, you can use the `AbsolutePath` and (WIP) `RelativePath` types. This guarantees that you will work with proper absolute or relative paths, and it's impossible to mix them in an unsupported way that sometimes leads to surprising results (such as `Path.Combine("/usr", "/bin")` being equals to `"/bin"`). 29 | 30 | If you just need a more convenient API to work with paths, and it's not important to use strict path kinds, just rely on the functionality provided by `LocalPath`: it is opaque in a sense it wraps both absolute and relative paths, and you can use it in a more flexible way. It _may_ still cause surprising behavior, though: `new LocalPath("/usr") / "/bin"` is still an equivalent of `"/bin"` (which is not the case for `AbsolutePath` and `RelativePath`). 31 | 32 | The strict approach will cost a bit of performance, as the library will have to validate the path kind at runtime. 33 | 34 | Packages 35 | -------- 36 | TruePath provides two packages: 37 | - [TruePath][nuget.true-path] for path abstractions, 38 | - [TruePath.SystemIo][nuget.true-path.system-io] for the `System.IO` integration. 39 | 40 | Usage 41 | ----- 42 | The library offers several struct (i.e. low to zero memory overhead) types wrapping path strings. The types are designed to not involve any disk IO operations by default, and thus provide excellent performance during common operations. This comes with a drawback, though: **path comparison is only performed as string comparison so far** — though it tries to take platform-specific case sensitivity defaults into account. See the section **Path Comparison** for details. 43 | 44 | The paths are stored in the **normalized form**. 45 | 46 | - All the `Path.AltDirectorySeparatorChar` are converted to `Path.DirectorySeparatorChar` (e.g. `/` to `\` on Windows). 47 | - Any repeated separators in the input are collapsed to only one separator (e.g. `//` to just `/` on Unix). 48 | - Any sequence of current and parent directory marks (subsequently, `.` and `..`) is resolved if possible (meaning they 49 | will not be replaced if they are in the root position: paths such as `.` or `../..` will not be affected by the 50 | normalization, while e.g. `foo/bar/../.` will be resolved to just `foo`). 51 | 52 | Note that the normalization operation will not perform any file IO, and is purely string manipulation. 53 | 54 | In this section, we'll overview some of the main library features. For details, see [the API reference][api.index]. 55 | 56 | ### [`LocalPath`][api.local-path] 57 | This is the type that may either be a relative or an absolute. Small showcase: 58 | ```csharp 59 | var myRoot = new LocalPath("foo/bar"); 60 | var fooDirectory = myRoot.Parent; 61 | 62 | var bazSubdirectory = myRoot / "baz"; 63 | var alsoBazSubdirectory = myRoot / new LocalPath("baz"); 64 | ``` 65 | 66 | ### [`AbsolutePath`][api.absolute-path] 67 | This functions basically the same as the `LocalPath`, but it is _always_ an absolute path, which is checked in the constructor. 68 | 69 | To convert from `LocalPath` to `AbsolutePath` and vice versa, you can use the constructors of `AbsolutePath` and `LocalPath` respectively. Any `AbsolutePath` constructor (from either a string or a `LocalPath`) has same check for absolute path, and any `LocalPath` constructor (from either a string or an `AbsolutePath`) doesn't have any checks. 70 | 71 | ### [`IPath`][api.i-path] 72 | This is an interface that is implemented by both `LocalPath` and `AbsolutePath`. It allows to process any paths in a polymorphic way. 73 | 74 | ### [`LocalPathPattern`][api.local-path-pattern] 75 | This is a marker type that doesn't offer any advanced functionality over the contained string. It is used to mark paths that include wildcards, for further integration with external libraries, such as [Microsoft.Extensions.FileSystemGlobbing][file-system-globbing.nuget]. 76 | 77 | ### [`PathIO`][api.path-io] 78 | `PathIo` class provides extension methods to interoperate with `System.File.IO` with TruePath. Operations to read and write files, create directories, query file attributes, etc. are available here. 79 | 80 | ### Path Comparison 81 | Path comparison is a complex topic, because whether several strings point to one object or not might depend on various factors: first and the most obvious one is **case sensitivity**: in general, operating systems allow to set up case-sensitive or case-insensitive mode for any path on the file system. Practically though, in most cases the users aren't changing the defaults for this mode, and have paths as **case-sensitive** on **Linux**, while having them **case-insensitive** on **Windows and macOS**. 82 | 83 | Another related issue is canonical path status (for the lack of a better term). Various systems allows for several different strings to mark the same file on disk (either via links, junctions, or obscure features such as 8.3 mode on Windows). 84 | 85 | TruePath allows the user to control certain aspects of how their paths are presented and compared, and offers a set of defaults _that prefer max performance over correctness_ — they should work for the most practical cases, but may break in certain situations. 86 | 87 | When comparing the path objects via either `==` operator or the standard `Equals(object)` method, the library uses the `AbsolutePath.PlatformDefaultComparer` or the `LocalPath.PlatformDefaultComparer`, meaning that 88 | - paths are compared as strings (no canonicalization performed), 89 | - paths are compared in either case-sensitive (Linux) or case-insensitive/ordinal mode (Windows, macOS). 90 | 91 | For cases when you want to always perform strict case-sensitive comparison (more performant yet not platform-aware), pass the `AbsolutePath.StrictStringComparer` or the `LocalPath.StrictStringComparer` to the overload of the `Equals` method: 92 | ```csharp 93 | var path1 = new LocalPath("a/b/c"); 94 | var path2 = new LocalPath("A/B/C"); 95 | var result1 = path1.Equals(path2, LocalPath.StrictStringComparer); // guaranteed to be false on all platforms 96 | var result2 = path1.Equals(path2); // might be true or false, depends on the current platform 97 | ``` 98 | 99 | The advantage of the current implementations is that they will never do any IO: they don't need to ask the OS about path features to compare them. This comes at cost of incorrect comparisons for paths that use unusual semantics (say, a folder that's marked as case-sensitive on a platform with case-insensitive default). We are planning to offer an option for a comparer that will take particular path case sensitivity into account; follow the [issue #20][issue.20] for details. 100 | 101 | To convert the path to the canonical form, use `AbsolutePath::Canonicalize`. 102 | 103 | ### [`Temporary`][api.temporary] 104 | 105 | `TruePath.Temporary` class contains a set of utility methods to work with the system temp directory (most widely known as `TEMP` or `TMP` environment variable): 106 | - `Temporary::SystemTempDirectory()` will return it as an absolute path; 107 | - `Temporary::CreateTempFile()` will create a randomly-named file in the system temp directory and return an absolute path to it; 108 | - `Temporary::CreateTempFolder()` will create a randomly-named folder in the system temp directory and return an absolute path to it. 109 | 110 | [api.absolute-path]: api/TruePath.AbsolutePath.yml 111 | [api.i-path]: api/TruePath.IPath.yml 112 | [api.local-path-pattern]: api/TruePath.LocalPathPattern.yml 113 | [api.local-path]: api/TruePath.LocalPath.yml 114 | [api.path-io]: api/TruePath.SystemIo.PathIo.yml 115 | [api.reference]: api/TruePath.yml 116 | [api.temporary]: api/TruePath.Temporary.yml 117 | [file-system-globbing.nuget]: https://www.nuget.org/packages/Microsoft.Extensions.FileSystemGlobbing 118 | [issue.20]: https://github.com/ForNeVeR/TruePath/issues/20 119 | [java.path]: https://docs.oracle.com/en%2Fjava%2Fjavase%2F21%2Fdocs%2Fapi%2F%2F/java.base/java/nio/file/Path.html 120 | [kotlin.path]: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.io.path/java.nio.file.-path/ 121 | [nuget.true-path]: https://www.nuget.org/packages/TruePath 122 | [nuget.true-path.system-io]: https://www.nuget.org/packages/TruePath.SystemIo 123 | -------------------------------------------------------------------------------- /docs/toc.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | - name: API 6 | href: api/TruePath.yml 7 | -------------------------------------------------------------------------------- /scripts/Get-Version.ps1: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | param( 6 | [string] $RefName, 7 | [string] $RepositoryRoot = "$PSScriptRoot/.." 8 | ) 9 | 10 | $ErrorActionPreference = 'Stop' 11 | Set-StrictMode -Version Latest 12 | 13 | Write-Host "Determining version from ref `"$RefName`"…" 14 | if ($RefName -match '^refs/tags/v') { 15 | $version = $RefName -replace '^refs/tags/v', '' 16 | Write-Host "Pushed ref is a version tag, version: $version" 17 | } else { 18 | $propsFilePath = "$RepositoryRoot/Directory.Build.props" 19 | [xml] $props = Get-Content $propsFilePath 20 | foreach ($group in $props.Project.PropertyGroup) { 21 | if ($group.Label -eq 'Packaging') { 22 | $version = $group.Version 23 | break 24 | } 25 | } 26 | Write-Host "Pushed ref is a not version tag, got version from $($propsFilePath): $version" 27 | } 28 | 29 | Write-Output $version 30 | -------------------------------------------------------------------------------- /scripts/Test-Encoding.ps1: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | <# 6 | .SYNOPSIS 7 | This script will verify that there's no UTF-8 BOM or CRLF line endings in the files inside of the project. 8 | #> 9 | param ( 10 | # Path to the repository root. All text files under the root will be checked for UTF-8 BOM and CRLF. 11 | $SourceRoot = "$PSScriptRoot/..", 12 | 13 | # Makes the script to perform file modifications to bring them to the standard. 14 | [switch] $Autofix 15 | ) 16 | 17 | Set-StrictMode -Version Latest 18 | $ErrorActionPreference = 'Stop' 19 | 20 | # For PowerShell to properly process the UTF-8 output from git ls-tree we need to set up the output encoding: 21 | [Console]::OutputEncoding = [Text.Encoding]::UTF8 22 | 23 | $allFiles = git -c core.quotepath=off ls-tree -r HEAD --name-only 24 | Write-Output "Total files in the repository: $($allFiles.Length)" 25 | 26 | # https://stackoverflow.com/questions/6119956/how-to-determine-if-git-handles-a-file-as-binary-or-as-text#comment15281840_6134127 27 | $nullHash = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' 28 | $textFiles = git -c core.quotepath=off diff --numstat $nullHash HEAD -- @allFiles | 29 | Where-Object { -not $_.StartsWith('-') } | 30 | ForEach-Object { [Regex]::Unescape($_.Split("`t", 3)[2]) } 31 | Write-Output "Text files in the repository: $($textFiles.Length)" 32 | 33 | $bom = @(0xEF, 0xBB, 0xBF) 34 | $bomErrors = @() 35 | $lineEndingErrors = @() 36 | [array] $excludeExtensions = @('.dotsettings') 37 | 38 | try { 39 | Push-Location $SourceRoot 40 | foreach ($file in $textFiles) { 41 | if ($excludeExtensions -contains [IO.Path]::GetExtension($file).ToLowerInvariant()) { 42 | continue 43 | } 44 | 45 | $fullPath = Resolve-Path -LiteralPath $file 46 | $bytes = [IO.File]::ReadAllBytes($fullPath) | Select-Object -First $bom.Length 47 | $bytesEqualsBom = @(Compare-Object $bytes $bom -SyncWindow 0).Length -eq 0 48 | if ($bytesEqualsBom -and $Autofix) { 49 | $fullContent = [IO.File]::ReadAllBytes($fullPath) 50 | $newContent = $fullContent | Select-Object -Skip $bom.Length 51 | [IO.File]::WriteAllBytes($fullPath, $newContent) 52 | Write-Output "Removed UTF-8 BOM from file $file" 53 | } elseif ($bytesEqualsBom) { 54 | $bomErrors += @($file) 55 | } 56 | 57 | $text = [IO.File]::ReadAllText($fullPath) 58 | $hasWrongLineEndings = $text.Contains("`r`n") 59 | if ($hasWrongLineEndings -and $Autofix) { 60 | $newText = $text -replace "`r`n", "`n" 61 | [IO.File]::WriteAllText($fullPath, $newText) 62 | Write-Output "Fixed the line endings for file $file" 63 | } elseif ($hasWrongLineEndings) { 64 | $lineEndingErrors += @($file) 65 | } 66 | } 67 | 68 | if ($bomErrors.Length) { 69 | throw "The following $($bomErrors.Length) files have UTF-8 BOM:`n" + ($bomErrors -join "`n") 70 | } 71 | if ($lineEndingErrors.Length) { 72 | throw "The following $($lineEndingErrors.Length) files have CRLF instead of LF:`n" + ($lineEndingErrors -join "`n") 73 | } 74 | } finally { 75 | Pop-Location 76 | } 77 | -------------------------------------------------------------------------------- /scripts/github-actions.fsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 TruePath contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | #r "nuget: Generaptor.Library, 1.2.0" 6 | 7 | open System 8 | open Generaptor 9 | open Generaptor.GitHubActions 10 | open type Generaptor.GitHubActions.Commands 11 | open type Generaptor.Library.Actions 12 | open type Generaptor.Library.Patterns 13 | 14 | let mainBranch = "main" 15 | let ubuntu = "ubuntu-24.04" 16 | let images = [ 17 | "macos-14" 18 | ubuntu 19 | "windows-2022" 20 | ] 21 | 22 | let workflows = [ 23 | let mainTriggers = [ 24 | onPushTo mainBranch 25 | onPullRequestTo mainBranch 26 | onSchedule(day = DayOfWeek.Saturday) 27 | onWorkflowDispatch 28 | ] 29 | 30 | workflow "main" [ 31 | name "Main" 32 | yield! mainTriggers 33 | 34 | job "check" [ 35 | checkout 36 | yield! dotNetBuildAndTest(projectFileExtensions = [".csproj"]) 37 | ] |> addMatrix images 38 | 39 | job "licenses" [ 40 | runsOn ubuntu 41 | checkout 42 | 43 | step(name = "REUSE license check", uses = "fsfe/reuse-action@v5") 44 | ] 45 | 46 | job "encoding" [ 47 | runsOn ubuntu 48 | checkout 49 | 50 | step(name = "Verify encoding", shell = "pwsh", run = "scripts/Test-Encoding.ps1") 51 | ] 52 | 53 | job "nowarn-empty" [ 54 | runsOn ubuntu 55 | checkout 56 | 57 | step(name = "Verify with NoWarn as empty", run = "dotnet build /p:NoWarn='' --no-incremental") 58 | ] 59 | ] 60 | 61 | workflow "release" [ 62 | name "Release" 63 | yield! mainTriggers 64 | onPushTags "v*" 65 | job "nuget" [ 66 | runsOn ubuntu 67 | checkout 68 | writeContentPermissions 69 | 70 | let configuration = "Release" 71 | 72 | let versionStepId = "version" 73 | let versionField = "${{ steps." + versionStepId + ".outputs.version }}" 74 | getVersionWithScript(stepId = versionStepId, scriptPath = "scripts/Get-Version.ps1") 75 | dotNetPack(version = versionField) 76 | 77 | let releaseNotes = "./release-notes.md" 78 | step( 79 | name = "Read changelog", 80 | uses = "ForNeVeR/ChangelogAutomation.action@v2", 81 | options = Map.ofList [ 82 | "output", releaseNotes 83 | ] 84 | ) 85 | 86 | let artifacts projectName includeSNuPkg = [ 87 | let packageId = projectName 88 | $"./{projectName}/bin/{configuration}/{packageId}.{versionField}.nupkg" 89 | if includeSNuPkg then $"./{projectName}/bin/{configuration}/{packageId}.{versionField}.snupkg" 90 | ] 91 | let allArtifacts = [ 92 | yield! artifacts "TruePath" true 93 | yield! artifacts "TruePath.SystemIo" true 94 | ] 95 | uploadArtifacts [ 96 | releaseNotes 97 | yield! allArtifacts 98 | ] 99 | yield! ifCalledOnTagPush [ 100 | createRelease( 101 | name = $"TruePath v{versionField}", 102 | releaseNotesPath = releaseNotes, 103 | files = allArtifacts 104 | ) 105 | yield! pushToNuGetOrg "NUGET_TOKEN_TRUE_PATH" ( 106 | artifacts "TruePath" false 107 | ) 108 | yield! pushToNuGetOrg "NUGET_TOKEN_TRUE_PATH_SYSTEM_IO" ( 109 | artifacts "TruePath.SystemIo" false 110 | ) 111 | ] 112 | ] 113 | ] 114 | ] 115 | 116 | EntryPoint.Process fsi.CommandLineArgs workflows 117 | --------------------------------------------------------------------------------