diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index 74818f1..0000000 --- a/.appveyor.yml +++ /dev/null @@ -1,67 +0,0 @@ -# based on rutie and rust_blank .appveyor.yml files -platform: - - x86 - - x64 -environment: - matrix: - - RUST_VERSION: stable - BUILD_TARGET: gnu - RUBY_VERSION: 23 - - RUST_VERSION: beta - BUILD_TARGET: gnu - RUBY_VERSION: 23 - - RUST_VERSION: nightly - BUILD_TARGET: gnu - RUBY_VERSION: 23 -cache: - - target\debug\build - - target\debug\deps - - '%USERPROFILE%\.cargo' -install: - - ps: | - $env:PATH += ";C:\rust\bin"; - if ($env:platform -eq 'x86') { - $env:RUBY_DIR = "Ruby${env:RUBY_VERSION}" - $arch_expanded = "i686-pc-windows-${env:BUILD_TARGET}"; - $env:ARCH = "x86"; - $env:bits = "32"; - } else { - $env:RUBY_DIR = "Ruby${env:RUBY_VERSION}-x64" - $arch_expanded = "x86_64-pc-windows-${env:BUILD_TARGET}"; - $env:ARCH = "amd64"; - $env:bits ="64"; - } - $env:WIN_RUBY_BIN = "C:\${env:RUBY_DIR}\bin"; - $env:PATH = "${env:WIN_RUBY_BIN};${env:PATH}"; - if ($env:BUILD_TARGET -eq 'gnu') { - $env:PATH += ";C:\msys64\mingw${env:bits}\bin"; - gcc --version; - } - if ($env:RUST_VERSION -eq 'stable') { - echo "Downloading $channel channel manifest"; - Start-FileDownload "https://static.rust-lang.org/dist/channel-rust-stable"; - - $env:RUST_VERSION = Get-Content channel-rust-stable | Select -first 1 | %{$_.split('-')[1]} - } - $env:rust_installer = "rust-${env:RUST_VERSION}-${arch_expanded}.exe"; - $tag_suffix = echo "${env:APPVEYOR_REPO_TAG_NAME}" | Select-String -pattern "-rust$" - if ($tag_suffix) { - $env:RUST_TAG = "1"; - } - - curl --show-error --location --retry 5 --output rust-installer.exe https://static.rust-lang.org/dist/%rust_installer% - - .\rust-installer.exe /VERYSILENT /NORESTART /DIR="C:\rust" - - call "C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\vcvarsall.bat" %ARCH% - - rustc -vV - - cargo -vV - - ruby --version - - gem --version - - rake --version - - bundle --version - - bundle config --local path vendor/bundle - - bundle install -build: false -test_script: - - bundle exec rake --trace test -matrix: - allow_failures: - - RUST_VERSION: nightly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72e84fc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + RUST_BACKTRACE: '1' + +jobs: + test: + name: Ruby ${{ matrix.ruby }} on ${{ matrix.os }} (${{ matrix.suite.name }}) + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + # Rutie 0.10, which the extension is built with, supports Ruby 2.5 to 2.7 + os: [ubuntu-22.04, windows-latest] + ruby: ['2.5', '2.6', '2.7'] + suite: + - name: regression + env: WITH_REGRESSION=true + - name: monkeypatches + env: TEST_MONKEYPATCHES=true WITH_REGRESSION=true + - name: encoding + env: ENCODING=true TEST_MONKEYPATCHES=true WITH_REGRESSION=true + include: + - os: macos-latest + ruby: '2.7' + suite: + name: regression + env: WITH_REGRESSION=true + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: System details + run: bundle exec rake sysinfo + - name: Test (cargo, minitest, rubocop, pbench, mspec) + run: env ${{ matrix.suite.env }} bundle exec rake test + + # Builds the precompiled extension tarballs that `gem install` downloads, + # and attaches them to the GitHub release of a version tag. + release: + name: Release tarball for Ruby ${{ matrix.ruby }} on ${{ matrix.os }} + if: startsWith(github.ref, 'refs/tags/v') + needs: [test] + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + matrix: + os: [ubuntu-22.04] + ruby: ['2.5', '2.6', '2.7'] + include: + - os: macos-latest + ruby: '2.7' + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake thermite:tarball + - uses: softprops/action-gh-release@v2 + with: + files: faster_path-*.tar.gz diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4862cc0..0000000 --- a/.travis.yml +++ /dev/null @@ -1,99 +0,0 @@ -sudo: false -language: ruby -os: -- linux -- osx -cache: - bundler: true - directories: - - $HOME/.cargo - - $TRAVIS_BUILD_DIR/target -rvm: -- 2.4.6 -- 2.5.5 -- 2.6.3 -env: - global: - - NO_LINK_RUTIE=1 - - RUST_BACKTRACE=full - - PATH=$PATH:$HOME/.cargo/bin - - secure: UCarEfq9wDpD6FV0dIdfDMCWfeLoKBAM2PS+xZ82K1uoFBMtwet/UuEuz06OnX6B9k2TwV0aqQpkUuo6o+h9YoHKAGz1/O5iB5uJaHcoII8yL43LH4YUADqupsgbvFzUCdcLuKCvDVG5RD96tt5XX87UdyZMgHvBV41wKOoUF+0ZyIWq8sKFPlItM+oYN55gY+PZ5ZadsOOkWDbTAG/LuxDi1GJxb/ObiYkjk4b93xzKEx1t7H2LSs8/f8dg0w1gnM6bVXQHjdXGuAw8jqy6IoszFh2oKDyttix4yB5l5xxj/NwO/aJNmOLBRNf611WskH6RLKWteCxwyY0gIZnDlC7q+SMYJtDapuffvqqKdlC5ECcVNimw9D3DwuxizP/5IGoC5+X+RIJiWBx/RJgFV2dw22/XvC62rOMttQL1K1dUByMRJL1pjLoauofbTdJgZgdrb1eDxGNyxL+Rg8za2wqs5NtENnXY6RIX4NvSXUPE9vRLdYnmGtH5hLF36hRh5AwvgOZ544xYT2ss6FxUJNo/zphlE7zfZrD+waeUBRUabzi6S5xEBXainj01UjtfVXRDtcjV9vbh5pYYjZsX8lLZAdW8OZeKxhaOv7zQyUtAwMHE/sGfKj1yQSDh2fscY48mepV7xUPVE7jRgUDk1ayt2a+WeGE2DCR3KBv5gkk= - matrix: - - WITH_REGRESSION=true - - TEST_MONKEYPATCHES=true WITH_REGRESSION=true - - RUST_BACKTRACE=1 ENCODING=true TEST_MONKEYPATCHES=true WITH_REGRESSION=true -matrix: - allow_failures: - - env: RUST_BACKTRACE=1 ENCODING=true TEST_MONKEYPATCHES=true WITH_REGRESSION=true - - os: linux - rvm: 2.6.3 - env: LONG_RUN=20 - script: bundle exec rake pbench - - os: linux - rvm: 2.4.6 - if: tag =~ ^v0 - env: - - os: linux - rvm: 2.5.5 - if: tag =~ ^v0 - env: - - os: linux - rvm: 2.6.3 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.4.6 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.5.5 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.6.3 - if: tag =~ ^v0 - env: - include: - - os: linux - rvm: 2.6.3 - env: LONG_RUN=20 - script: bundle exec rake pbench - - os: linux - rvm: 2.4.6 - if: tag =~ ^v0 - env: - - os: linux - rvm: 2.5.5 - if: tag =~ ^v0 - env: - - os: linux - rvm: 2.6.3 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.4.6 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.5.5 - if: tag =~ ^v0 - env: - - os: osx - rvm: 2.6.3 - if: tag =~ ^v0 - env: -before_install: -- curl -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable -- gem install bundler -deploy: - provider: releases - api_key: - secure: NhCKpRLm/aOLOoFuoXHkc1AgNIoIRlVrZs08VxJ7Hbjw0/iUX4Uq+0DloX9NVRKupd3oVH/p6gNnPQEkOdpDmx+4x8KdlwX3uYLOQ8LZTjjarOb+3StDsl748Zm1dnO69SkVHZPQ6m6W2ITg065eH82pF83QUp6Th7MLuF8f9F4Dlgdt4thHtqU43VWWRGQAT0/haLZhdKczCQJDuWrDbPHNCZwgCdm59htSmQHL1FyWAehymGy+iI9fKvs+ZifRqV/PxvC3nJSEKFbFW/aq/1LvA0AL2GKjClODiklDGdMoDsFFH6t889ZYMgIzOITP7O3cpN2+Y5tMy03U8Yf4hKg8M0fPoA1ufJ2k+OGNEFOcLeWfbwZrdLEBsm+U8vZ/r2pvz4837IA/vrHbFUFnT8ynv+JZfyaNwKNh7durRgOJYHpwsxqe/PSjlQocL1Z0FBeMVKQYViIz4g/rgBzQHwyrzou6FSGhk3kYRMD2+gPuZ+pj/hnWXc/fF3UvtAcFOxuQrk07OA7T9sZhHhA+/lQCYhVNA1YQen01dkX1yYmA+jpZvZ3p6gyhmvHksEmrJIu912QPMWFehtjPlF4te7FppQYDdjBpyePIZs0GLz6Puf5YjVu6E2knfHQzTIM3nvSko17D9fDv7d/1oXuMgiOlM0YzRPeEtr6TtGwee98= - file: faster_path-*.tar.gz - file_glob: true - skip_cleanup: true - on: - condition: -z ${TEST_MONKEYPATCHES} - repo: danielpclark/faster_path - tags: true -after_success: bundle exec rake thermite:tarball diff --git a/Cargo.lock b/Cargo.lock index 935aca7..0c87d7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,40 +1,39 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 4 + [[package]] name = "faster_path" -version = "0.0.1" +version = "0.1.0" dependencies = [ - "lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", - "memchr 2.2.0 (registry+https://github.com/rust-lang/crates.io-index)", - "rutie 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", + "memchr", + "rutie", ] [[package]] name = "lazy_static" -version = "1.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.58" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "memchr" -version = "2.2.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "rutie" -version = "0.6.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97641d6bd3731d263d914141e960a8b515bf5ff4ca6c961b25ca4b2694c6490" dependencies = [ - "lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", - "libc 0.2.58 (registry+https://github.com/rust-lang/crates.io-index)", + "lazy_static", + "libc", ] - -[metadata] -"checksum lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bc5729f27f159ddd61f4df6228e827e86643d4d3e7c32183cb30a1c08f604a14" -"checksum libc 0.2.58 (registry+https://github.com/rust-lang/crates.io-index)" = "6281b86796ba5e4366000be6e9e18bf35580adf9e63fbe2294aadb587613a319" -"checksum memchr 2.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2efc7bc57c883d4a4d6e3246905283d8dae951bb3bd32f49d6ef297f546e1c39" -"checksum rutie 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "695eb534c22e60d5723d57be6b3b6a4e9367e8b203e607a19eb94d03cb32ea34" diff --git a/Cargo.toml b/Cargo.toml index 6c6cfa9..0982b33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,21 @@ [package] name = "faster_path" -version = "0.0.1" +version = "0.1.0" authors = ["Daniel P. Clark <6ftdan@gmail.com>"] description = "Alternative to Pathname" homepage = "https://github.com/danielpclark/faster_path" repository = "https://github.com/danielpclark/faster_path" license = "MIT OR Apache-2.0" readme = "README.md" +edition = "2021" [package.metadata.thermite] github_releases = true [lib] name = "faster_path" -crate-type = ["dylib"] +crate-type = ["cdylib"] [dependencies] -rutie = "0.6.0" -lazy_static = "1.3" -memchr = "2.2" +rutie = "0.10.2" +memchr = "2" diff --git a/Gemfile b/Gemfile index 6ce00d2..31d1a72 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,8 @@ -source 'https://rubygems.org' do - # Specify your gem's dependencies in faster_path.gemspec - gemspec +source 'https://rubygems.org' - group :test do - gem 'coveralls', require: false - end +# Specify your gem's dependencies in faster_path.gemspec +gemspec + +group :test do + gem 'coveralls', require: false end diff --git a/README.md b/README.md index 003c3be..43b7f97 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # FasterPath [![Gem Version](https://badge.fury.io/rb/faster_path.svg)](https://badge.fury.io/rb/faster_path) -[![TravisCI Build Status](https://travis-ci.org/danielpclark/faster_path.svg?branch=master)](https://travis-ci.org/danielpclark/faster_path) -[![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/10ul0gk3cwhlt2lj/branch/master?svg=true)](https://ci.appveyor.com/project/danielpclark/faster-path/branch/master) +[![CI](https://github.com/danielpclark/faster_path/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/danielpclark/faster_path/actions/workflows/ci.yml) [![Latest Tag](https://img.shields.io/github/tag/danielpclark/faster_path.svg)](https://github.com/danielpclark/faster_path/tags) [![Commits Since Last Release](https://img.shields.io/github/commits-since/danielpclark/faster_path/v0.3.10.svg)](https://github.com/danielpclark/faster_path/pulse) [![Binary Release](https://img.shields.io/github/release/danielpclark/faster_path.svg)](https://github.com/danielpclark/faster_path/releases) @@ -111,7 +110,13 @@ As mentioned earlier Sprockets, which handles assets, changed away from using `P * Methods are stable * Thoroughly tested * Testers and developers are most welcome -* Windows & encoding support is underway! +* Windows: paths follow Ruby's Windows rules (`\` and `/` separators, drive letters, UNC paths) +* Paths keep their encoding, as with Ruby's own methods + +## Requirements + +* Ruby 2.5, 2.6 or 2.7 (the Rust extension is built with [Rutie](https://github.com/danielpclark/rutie) 0.10, which supports these) +* Rust 1.71 or later ## Installation @@ -120,13 +125,13 @@ Ensure Rust is installed: [Rust Downloads](https://www.rust-lang.org/downloads.html) ``` -curl -sSf https://static.rust-lang.org/rustup.sh | sh +curl -sSf https://sh.rustup.rs | sh ``` Add this line to your application's Gemfile: ```ruby -gem 'faster_path', '~> 0.3.10' +gem 'faster_path', '~> 0.4.0' ``` And then execute: @@ -155,25 +160,27 @@ require "faster_path" Current methods implemented: -|FasterPath Rust Implementation|Ruby 2.5.0 Implementation|Time Shaved Off| -|---|---|:---:| -| `FasterPath.absolute?` | `Pathname#absolute?` | 95.3% | -| `FasterPath.add_trailing_separator` | `Pathname#add_trailing_separator` | 48.4% | -| `FasterPath.basename` | `File.basename` | 12.0% | -| `FasterPath.children` | `Pathname#children` | 34.4% | -| `FasterPath.chop_basename` | `Pathname#chop_basename` | 83.4% | -| `FasterPath.cleanpath_aggressive` | `Pathname#cleanpath_aggressive` | 94.1% | -| `FasterPath.cleanpath_conservative` | `Pathname#cleanpath_conservative` | 93.5% | -| `FasterPath.del_trailing_separator` | `Pathname#del_trailing_separator` | 85.4% | -| `FasterPath.directory?` | `Pathname#directory?` | 6.4% | -| `FasterPath.dirname` | `File.dirname` | 55.4% | -| `FasterPath.entries` | `Pathname#entries` | 41.0% | -| `FasterPath.extname` | `File.extname` | 63.1% | -| `FasterPath.has_trailing_separator?` | `Pathname#has_trailing_separator` | 88.9% | -| `FasterPath.plus` | `Pathname#join` | 79.1% | -| `FasterPath.plus` | `Pathname#plus` | 94.7% | -| `FasterPath.relative?` | `Pathname#relative?` | 92.6% | -| `FasterPath.relative_path_from` | `Pathname#relative_path_from` | 93.3% | +|FasterPath Rust Implementation|Ruby Implementation|Time Shaved Off (Ruby 2.7)|Time Shaved Off (Ruby 2.5)| +|---|---|:---:|:---:| +| `FasterPath.absolute?` | `Pathname#absolute?` | 95.7% | 96.7% | +| `FasterPath.add_trailing_separator` | `Pathname#add_trailing_separator` | 85.4% | 83.0% | +| `FasterPath.basename` | `File.basename` | 42.4% | 39.1% | +| `FasterPath.children` | `Pathname#children` | 54.7% | 47.8% | +| `FasterPath.chop_basename` | `Pathname#chop_basename` | 70.4% | 75.1% | +| `FasterPath.cleanpath_aggressive` | `Pathname#cleanpath_aggressive` | 90.3% | 90.6% | +| `FasterPath.cleanpath_conservative` | `Pathname#cleanpath_conservative` | 89.6% | 91.6% | +| `FasterPath.del_trailing_separator` | `Pathname#del_trailing_separator` | 85.3% | 87.6% | +| `FasterPath.directory?` | `Pathname#directory?` | 34.3% | 37.5% | +| `FasterPath.dirname` | `File.dirname` | 56.8% | 54.7% | +| `FasterPath.entries` | `Pathname#entries` | 38.0% | 33.9% | +| `FasterPath.extname` | `File.extname` | 74.0% | 73.1% | +| `FasterPath.has_trailing_separator?` | `Pathname#has_trailing_separator` | 88.5% | 89.1% | +| `FasterPath.join` | `Pathname#join` | 88.1% | 90.3% | +| `FasterPath.plus` | `Pathname#plus` | 91.7% | 93.4% | +| `FasterPath.relative?` | `Pathname#relative?` | 92.8% | 95.2% | +| `FasterPath.relative_path_from` | `Pathname#relative_path_from` | 92.5% | 93.6% | + +See [Benchmarks](#benchmarks) for how these are measured. You may choose to use the methods directly, or scope change to rewrite behavior on the standard library with the included refinements, or even call a method to monkeypatch @@ -199,14 +206,59 @@ FasterPath.sledgehammer_everything! These will **not** be included by default in monkey-patches. To try them with monkeypatching use the environment flag of `WITH_REGRESSION`. These methods are here to be improved upon. -|FasterPath Implementation|Ruby Implementation| -|---|---| -| `FasterPath.entries_compat` | `Pathname.entries` | -| `FasterPath.children_compat` | `Pathname.children` | +|FasterPath Implementation|Ruby Implementation|Time Shaved Off (Ruby 2.7)|Time Shaved Off (Ruby 2.5)| +|---|---|:---:|:---:| +| `FasterPath.entries_compat` | `Pathname.entries` | 16.8% | 9.2% | +| `FasterPath.children_compat` | `Pathname.children` | 32.1% | 26.6% | It's been my observation (and some others) that the Rust implementation of the C code for `File` has similar results but performance seems to vary based on CPU cache on possibly 64bit/32bit system environments. These are not included by default when the monkey patch method `FasterPath.sledgehammer_everything!` is executed. +## Benchmarks + +The "Time Shaved Off" figures come from the project's Pinch-bench (`rake pbench`, in +`test/pbench`): each method runs against Ruby's own implementation, and the result is how much +less time FasterPath took. They are the median of 3 runs of `LONG_RUN=10 rake pbench` (500,000 +calls of each method, 5 times) on Linux x86_64 with Ruby 2.7.8 and 2.5.7, built with Rust 1.97. +Expect a few points of difference from run to run. + +### Before and after the Rutie 0.10 upgrade + +Measured the same way, alternating runs of the code before the upgrade (built with Rutie 0.6) +and after it. Higher is better; the change is in percentage points. + +|Method|Ruby 2.7 before|Ruby 2.7 after|Change|Ruby 2.5 before|Ruby 2.5 after|Change| +|---|:---:|:---:|:---:|:---:|:---:|:---:| +| `absolute?` | 93.3% | 95.7% | +2.4 | 96.1% | 96.7% | +0.6 | +| `add_trailing_separator` | 66.6% | 85.4% | **+18.8** | 64.3% | 83.0% | **+18.7** | +| `basename` | 42.4% | 42.4% | 0.0 | 32.1% | 39.1% | **+7.0** | +| `children` | 52.8% | 54.7% | +1.9 | 46.5% | 47.8% | +1.3 | +| `children_compat` | −1.2% | 32.1% | **+33.3** | 1.1% | 26.6% | **+25.5** | +| `chop_basename` | 71.2% | 70.4% | −0.8 | 74.7% | 75.1% | +0.4 | +| `cleanpath_aggressive` | 90.3% | 90.3% | 0.0 | 92.7% | 90.6% | −2.1 | +| `cleanpath_conservative` | 89.8% | 89.6% | −0.2 | 91.7% | 91.6% | −0.1 | +| `del_trailing_separator` | 86.5% | 85.3% | −1.2 | 87.0% | 87.6% | +0.6 | +| `directory?` | 33.2% | 34.3% | +1.1 | 37.2% | 37.5% | +0.3 | +| `dirname` | 49.8% | 56.8% | **+7.0** | 51.2% | 54.7% | +3.5 | +| `entries` | 40.4% | 38.0% | −2.4 | 33.6% | 33.9% | +0.3 | +| `entries_compat` | −29.8% | 16.8% | **+46.6** | −23.3% | 9.2% | **+32.5** | +| `extname` | 70.3% | 74.0% | +3.7 | 74.0% | 73.1% | −0.9 | +| `has_trailing_separator?` | 87.5% | 88.5% | +1.0 | 87.1% | 89.1% | +2.0 | +| `join` | crashes | 88.1% | | crashes | 90.3% | | +| `plus` | 92.3% | 91.7% | −0.6 | 93.0% | 93.4% | +0.4 | +| `relative?` | 90.7% | 92.8% | +2.1 | 93.6% | 95.2% | +1.6 | +| `relative_path_from` | 92.7% | 92.5% | −0.2 | 94.0% | 93.6% | −0.4 | + +Before the upgrade, `FasterPath.join` segfaulted, so its benchmark can't be measured. +`add_trailing_separator`, `dirname` and the `_compat` methods gained the most: +`add_trailing_separator` no longer builds a temporary string, no method checks its arguments +for valid UTF-8 any more, and the `_compat` methods build `Pathname` objects without calling +`Pathname.new`. `basename` was re-measured after it was made to take a shorter path on Unix +and skip an empty extension; it was the one method that first measured slower on Ruby 2.5. +Its timings vary a lot from run to run (Ruby 2.5 before: 40.0%, 32.1%, 28.7%; after: 29.6%, +39.1%, 39.2%), so its instruction counts from `valgrind --tool=callgrind` are a steadier +measure. They went from 2439 to 2331 per call on Ruby 2.5 and from 2689 to 2561 on Ruby 2.7. + ## Getting Started with Development The primary methods to target are mostly listed in the **Why** section above. You may find the Ruby diff --git a/Rakefile b/Rakefile index b25c822..1220540 100644 --- a/Rakefile +++ b/Rakefile @@ -69,7 +69,9 @@ task :init_mspec do |_t| end task test: [:cargo, :minitest, :lint, :pbench, :init_mspec] do |_t| - exec 'spec/mspec/bin/mspec --format spec core/file/basename core/file/extname core/file/dirname library/pathname' + # Run through Ruby so this also works on Windows + exec Gem.ruby, 'spec/mspec/bin/mspec', '--format', 'spec', + 'core/file/basename', 'core/file/extname', 'core/file/dirname', 'library/pathname' end desc "Full mspec results w/o encoding" diff --git a/faster_path.gemspec b/faster_path.gemspec index 06b62ca..a66e525 100644 --- a/faster_path.gemspec +++ b/faster_path.gemspec @@ -24,12 +24,15 @@ Gem::Specification.new do |spec| spec.extensions = ['ext/Rakefile'] spec.require_paths = ['lib'] + # Rutie 0.10, which the Rust extension is built with, supports Ruby 2.5 to 2.7 + spec.required_ruby_version = ['>= 2.5', '< 3.0'] + spec.add_dependency 'bundler', '>= 1.12' spec.add_dependency 'rake', '~> 12.3' spec.add_dependency 'thermite', '0.13.0' spec.add_development_dependency 'read_source', '~> 0.2.6' spec.add_development_dependency 'minitest', '~> 5.11' - spec.add_development_dependency 'minitest-reporters', '1.1' + spec.add_development_dependency 'minitest-reporters', '~> 1.1' spec.add_development_dependency 'color_pound_spec_reporter', '~> 0.0.9' spec.add_development_dependency 'rubocop', '0.53' spec.add_development_dependency 'stop_watch', '~> 1.0' diff --git a/lib/faster_path.rb b/lib/faster_path.rb index 3c0f40c..b50ff00 100644 --- a/lib/faster_path.rb +++ b/lib/faster_path.rb @@ -22,7 +22,7 @@ module FasterPath end Fiddle::Function. - new(Fiddle.dlopen(FFI_LIBRARY)['Init_faster_pathname'], [], Fiddle::TYPE_VOIDP). + new(Fiddle.dlopen(FFI_LIBRARY)['Init_faster_pathname'], [], Fiddle::TYPE_VOID). call Public.class_eval do diff --git a/lib/faster_path/version.rb b/lib/faster_path/version.rb index 5ca5387..54bd02d 100644 --- a/lib/faster_path/version.rb +++ b/lib/faster_path/version.rb @@ -1,3 +1,3 @@ module FasterPath - VERSION = "0.3.10" + VERSION = "0.4.0" end diff --git a/spec/mspec b/spec/mspec index 297309c..0091e8a 160000 --- a/spec/mspec +++ b/spec/mspec @@ -1 +1 @@ -Subproject commit 297309c05b89e1a964595d6b8d67a01fd62966e0 +Subproject commit 0091e8a62e954717cd54641f935eaf1403692041 diff --git a/spec/ruby_spec b/spec/ruby_spec index 77e927d..c503335 160000 --- a/spec/ruby_spec +++ b/spec/ruby_spec @@ -1 +1 @@ -Subproject commit 77e927d97faeaaab699851d6068a5efc323b4b3d +Subproject commit c503335d3d9f6ec6ef24de60a0716c34af69b64f diff --git a/src/basename.rs b/src/basename.rs index a663e52..31e7cb6 100644 --- a/src/basename.rs +++ b/src/basename.rs @@ -1,146 +1,243 @@ -extern crate memchr; - -use self::memchr::memrchr; - -use path_parsing::{find_last_sep_pos, find_last_non_sep_pos}; - -pub fn basename<'a>(path: &'a str, ext: &str) -> &'a str { - let bytes: &[u8] = path.as_bytes(); - let mut left: usize = 0; - let mut right: usize = bytes.len(); - if let Some(last_slash_pos) = find_last_sep_pos(bytes) { - if last_slash_pos == right - 1 { - if let Some(pos) = find_last_non_sep_pos(&bytes[..last_slash_pos]) { - right = pos + 1; - } else { - return "/"; - } - if let Some(pos) = find_last_sep_pos(&bytes[..right]) { - left = pos + 1; - } - } else { - left = last_slash_pos + 1; - } +use memchr::memrchr; + +use crate::path_parsing::{Rules, SEP_BYTES}; + +// Ruby's `File.basename(path, ext)`. +pub fn basename<'a>(rules: Rules, path: &'a [u8], ext: &[u8]) -> &'a [u8] { + let (name, base_len) = last_component(rules, path); + match base_len { + Some(base_len) => &name[..ext_end(rules, name, base_len, ext)], + None => name, } - &path[left..left + ext_end(&bytes[left..right], ext)] } -fn ext_end(slice: &[u8], ext: &str) -> usize { - if ext.len() >= slice.len() || slice == b"." || slice == b".." { - return slice.len(); +// Ruby's `ruby_enc_find_basename`: the last component of the path, without +// trailing separators (and on Windows, trailing dots, spaces and `:stream`), +// and where that component ends without its extension. A path that is only +// separators and prefixes has no extension: its "component" is a separator, +// or "" for a drive letter. +pub fn last_component(rules: Rules, path: &[u8]) -> (&[u8], Option) { + if path.is_empty() { + return (path, None); } - let ext_bytes = ext.as_bytes(); - if ext_bytes.len() == 2 && *ext_bytes.get(1).unwrap() == b'*' { - match memrchr(*ext_bytes.get(0).unwrap(), slice) { - Some(end) if end != 0 => return end, - _ => {} + if !rules.is_dosish() { + // On Unix this is just the last name, or the last separator. + let end = match rules.last_non_sep_pos(path) { + Some(pos) => pos + 1, + None => return (&path[path.len() - 1..], None), }; - } else if slice.ends_with(ext_bytes) { - return slice.len() - ext_bytes.len(); + // Paths are short: a plain scan beats `memrchr`'s setup here. + let start = path[..end].iter().rposition(|&c| c == b'/').map_or(0, |pos| pos + 1); + let component = &path[start..end]; + return (component, Some(base_len(component))); + } + let root = rules.skip_prefix(path); + let mut name = root; + while name < path.len() && rules.is_sep(path[name]) { + name += 1; + } + if name == path.len() { + if name != root { + // The last separator + return (&path[name - 1..name], None); + } + if path[name - 1] == b':' { + // A drive letter + return (&path[name..], None); + } + // A UNC prefix + return (SEP_BYTES, None); } - slice.len() + let start = match rules.last_separator(&path[name..]) { + Some(pos) => { + let mut start = name + pos; + while rules.is_sep(path[start]) { + start += 1; + } + start + } + None => name, + }; + let rest = &path[start..]; + let component = &rest[..rules.ntfs_tail(rest)]; + (component, Some(base_len(component))) +} + +// Where the name ends without its extension: at its last dot, unless that +// is one of its leading dots. +fn base_len(component: &[u8]) -> usize { + let dots = component.iter().take_while(|&&c| c == b'.').count(); + component[dots..].iter().rposition(|&c| c == b'.').map_or(component.len(), |pos| dots + pos) +} + +// Ruby's `rmext`: where `component` ends without the extension `ext`, or +// its length if `ext` isn't there. `ext` is an extension to remove, ".*" +// for any extension, or a character followed by `*` to remove everything +// from the last occurrence of that character. +pub fn ext_end(rules: Rules, component: &[u8], base_len: usize, ext: &[u8]) -> usize { + let len = component.len(); + let end = match *ext { + [] => 0, + [b'.', b'*'] => base_len, + [c, b'*'] => memrchr(c, component).unwrap_or(len), + _ if len < ext.len() => len, + _ => { + let start = len - ext.len(); + if rules.same_path(&component[start..], ext) { start } else { 0 } + } + }; + if end == 0 { len } else { end } +} + +#[cfg(test)] +fn basename_str<'a>(path: &'a str, ext: &str) -> &'a str { + std::str::from_utf8(basename(Rules::UNIX, path.as_bytes(), ext.as_bytes())).unwrap() +} + +#[cfg(test)] +fn windows_basename<'a>(path: &'a str, ext: &str) -> &'a str { + std::str::from_utf8(basename(Rules::WINDOWS, path.as_bytes(), ext.as_bytes())).unwrap() } #[test] fn non_dot_asterisk_ext() { // This is undocumented Ruby functionality. We match it in case some code out there relies on it. - assert_eq!(basename("abc", "b*"), "a"); - assert_eq!(basename("abc", "abc"), "abc"); - assert_eq!(basename("abc", "a*"), "abc"); - assert_eq!(basename("playlist", "l*"), "play"); + assert_eq!(basename_str("abc", "b*"), "a"); + assert_eq!(basename_str("abc", "abc"), "abc"); + assert_eq!(basename_str("abc", "a*"), "abc"); + assert_eq!(basename_str("playlist", "l*"), "play"); // Treated as literal "*": - assert_eq!(basename("playlist", "yl*"), "playlist"); - assert_eq!(basename("playl*", "yl*"), "pla"); + assert_eq!(basename_str("playlist", "yl*"), "playlist"); + assert_eq!(basename_str("playl*", "yl*"), "pla"); } #[test] fn empty() { - assert_eq!(basename("", ""), ""); - assert_eq!(basename("", ".*"), ""); - assert_eq!(basename("", ".a"), ""); + assert_eq!(basename_str("", ""), ""); + assert_eq!(basename_str("", ".*"), ""); + assert_eq!(basename_str("", ".a"), ""); } #[test] fn sep() { - assert_eq!(basename("/", ""), "/"); - assert_eq!(basename("//", ""), "/"); + assert_eq!(basename_str("/", ""), "/"); + assert_eq!(basename_str("//", ""), "/"); } #[test] fn trailing_dot() { - assert_eq!(basename("file.test.", ""), "file.test."); - assert_eq!(basename("file.test.", "."), "file.test"); - assert_eq!(basename("file.test.", ".*"), "file.test"); + assert_eq!(basename_str("file.test.", ""), "file.test."); + assert_eq!(basename_str("file.test.", "."), "file.test"); + assert_eq!(basename_str("file.test.", ".*"), "file.test"); } #[test] fn trailing_dot_dot() { - assert_eq!(basename("a..", ".."), "a"); - assert_eq!(basename("a..", ".*"), "a."); + assert_eq!(basename_str("a..", ".."), "a"); + assert_eq!(basename_str("a..", ".*"), "a."); } #[test] fn dot() { - assert_eq!(basename(".", ""), "."); - assert_eq!(basename(".", "."), "."); - assert_eq!(basename(".", ".*"), "."); + assert_eq!(basename_str(".", ""), "."); + assert_eq!(basename_str(".", "."), "."); + assert_eq!(basename_str(".", ".*"), "."); } #[test] fn dot_dot() { - assert_eq!(basename("..", ""), ".."); - assert_eq!(basename("..", ".*"), ".."); - assert_eq!(basename("..", ".."), ".."); - assert_eq!(basename("..", "..."), ".."); + assert_eq!(basename_str("..", ""), ".."); + assert_eq!(basename_str("..", ".*"), ".."); + assert_eq!(basename_str("..", ".."), ".."); + assert_eq!(basename_str("..", "..."), ".."); } #[test] fn non_dot_ext() { - assert_eq!(basename("abc", "bc"), "a"); + assert_eq!(basename_str("abc", "bc"), "a"); } #[test] fn basename_eq_ext() { - assert_eq!(basename(".x", ".x"), ".x"); - assert_eq!(basename(".x", ".*"), ".x"); + assert_eq!(basename_str(".x", ".x"), ".x"); + assert_eq!(basename_str(".x", ".*"), ".x"); } #[test] fn absolute() { - assert_eq!(basename("/a/b///c", ""), "c"); + assert_eq!(basename_str("/a/b///c", ""), "c"); } #[test] fn trailing_slashes_absolute() { - assert_eq!(basename("/a/b///c//////", ""), "c"); + assert_eq!(basename_str("/a/b///c//////", ""), "c"); } #[test] fn relative() { - assert_eq!(basename("b///c", ""), "c"); + assert_eq!(basename_str("b///c", ""), "c"); } #[test] fn trailing_slashes_relative() { - assert_eq!(basename("b/c//", ""), "c"); + assert_eq!(basename_str("b/c//", ""), "c"); } #[test] fn root() { - assert_eq!(basename("//c", ""), "c"); + assert_eq!(basename_str("//c", ""), "c"); } #[test] fn trailing_slashes_root() { - assert_eq!(basename("//c//", ""), "c"); + assert_eq!(basename_str("//c//", ""), "c"); } #[test] fn trailing_slashes_relative_root() { - assert_eq!(basename("c//", ""), "c"); + assert_eq!(basename_str("c//", ""), "c"); } #[test] fn edge_case_all_seps() { - assert_eq!("/", basename("///", ".*")); + assert_eq!("/", basename_str("///", ".*")); +} + +#[test] +fn ext_compared_like_ruby() { + assert_eq!(basename_str("..", "."), "."); + assert_eq!(basename_str("a.rb", ".RB"), "a.rb"); + assert_eq!(windows_basename("a.rb", ".RB"), "a"); +} + +#[test] +fn windows_separators() { + assert_eq!(windows_basename("a\\b", ""), "b"); + assert_eq!(windows_basename("a\\b\\", ""), "b"); + assert_eq!(windows_basename("a/b\\c", ""), "c"); + assert_eq!(windows_basename("\\", ""), "\\"); + assert_eq!(basename_str("a\\b", ""), "a\\b"); +} + +#[test] +fn windows_prefixes() { + assert_eq!(windows_basename("C:", ""), ""); + assert_eq!(windows_basename("C:/", ""), "/"); + assert_eq!(windows_basename("C:/a", ""), "a"); + assert_eq!(windows_basename("C:a", ""), "a"); + assert_eq!(windows_basename("//a/b", ""), "/"); + assert_eq!(windows_basename("//a/b/", ""), "/"); + assert_eq!(windows_basename("//a/b/c", ""), "c"); + assert_eq!(basename_str("//a/b", ""), "b"); +} + +#[test] +fn windows_ntfs_names() { + assert_eq!(windows_basename("foo.test ", ""), "foo.test"); + assert_eq!(windows_basename("foo.test.", ""), "foo.test"); + assert_eq!(windows_basename("foo.test::$DATA", ""), "foo.test"); + assert_eq!(windows_basename("foo.test ", ".test"), "foo"); + assert_eq!(windows_basename("foo.test.", ".*"), "foo"); + assert_eq!(windows_basename("foo.test::$DATA", ".*"), "foo"); + assert_eq!(windows_basename("...", ""), "..."); } diff --git a/src/chop_basename.rs b/src/chop_basename.rs index fa379ec..7d5b6f1 100644 --- a/src/chop_basename.rs +++ b/src/chop_basename.rs @@ -1,30 +1,93 @@ -use path_parsing::{find_last_non_sep_pos, find_last_sep_pos}; -use std::str; - -pub fn chop_basename<'a>(input: &'a str) -> Option<(&'a str, &'a str)> { - let bytes = input.as_bytes(); - let len = find_last_non_sep_pos(&bytes)? + 1; - let base_start = find_last_sep_pos(&bytes[..len]).map_or(0, |pos| pos + 1); - if base_start == len { +use memchr::memmem; + +use crate::basename::basename; +use crate::path_parsing::{offset_in, Rules}; + +// Pathname's `chop_basename`: the path split before its basename, unless +// the basename is empty or a separator. +pub fn chop_basename(rules: Rules, path: &[u8]) -> Option<(&[u8], &[u8])> { + let base = basename(rules, path, b""); + if base.is_empty() || (base.len() == 1 && rules.is_sep(base[0])) { return None; } - Some((&input[0..base_start], &input[base_start..len])) + // `path.rindex(base)`. On Unix, nothing after the basename can contain it. + let start = if rules.is_dosish() { memmem::rfind(path, base)? } else { offset_in(path, base) }; + Some((&path[..start], &path[start..start + base.len()])) +} + +// Pathname's `relative?`: whether chopping every basename off leaves nothing. +pub fn is_relative(rules: Rules, path: &[u8]) -> bool { + if !rules.is_dosish() { + return path.first() != Some(&b'/'); + } + let mut prefix = path; + while let Some((rest, _)) = chop_basename(rules, prefix) { + prefix = rest; + } + prefix.is_empty() +} + +#[cfg(test)] +fn chop_basename_str(input: &str) -> Option<(&str, &str)> { + chop_basename(Rules::UNIX, input.as_bytes()).map(|(dirname, basename)| { + (std::str::from_utf8(dirname).unwrap(), std::str::from_utf8(basename).unwrap()) + }) } #[test] fn it_chops_the_basename_and_dirname() { - assert_eq!(chop_basename(""), None ); - assert_eq!(chop_basename("/"), None ); - assert_eq!(chop_basename("."), Some(("", ".")) ); - assert_eq!(chop_basename("asdf/asdf"), Some(("asdf/", "asdf")) ); - assert_eq!(chop_basename("asdf.txt"), Some(("", "asdf.txt")) ); - assert_eq!(chop_basename("asdf/"), Some(("", "asdf")) ); - assert_eq!(chop_basename("/asdf/"), Some(("/", "asdf")) ); - assert_eq!(chop_basename("a///b"), Some(("a///", "b")) ); - assert_eq!(chop_basename("a///b//"), Some(("a///", "b")) ); - assert_eq!(chop_basename("/a///b//"), Some(("/a///", "b")) ); - assert_eq!(chop_basename("/a///b//"), Some(("/a///", "b")) ); + assert_eq!(chop_basename_str(""), None ); + assert_eq!(chop_basename_str("/"), None ); + assert_eq!(chop_basename_str("."), Some(("", ".")) ); + assert_eq!(chop_basename_str("asdf/asdf"), Some(("asdf/", "asdf")) ); + assert_eq!(chop_basename_str("asdf.txt"), Some(("", "asdf.txt")) ); + assert_eq!(chop_basename_str("asdf/"), Some(("", "asdf")) ); + assert_eq!(chop_basename_str("/asdf/"), Some(("/", "asdf")) ); + assert_eq!(chop_basename_str("a///b"), Some(("a///", "b")) ); + assert_eq!(chop_basename_str("a///b//"), Some(("a///", "b")) ); + assert_eq!(chop_basename_str("/a///b//"), Some(("/a///", "b")) ); + assert_eq!(chop_basename_str("/a///b//"), Some(("/a///", "b")) ); + + assert_eq!(chop_basename_str("./../..///.../..//"), Some(("./../..///.../", ".."))); +} - assert_eq!(chop_basename("./../..///.../..//"), Some(("./../..///.../", ".."))); +#[test] +fn it_chops_non_utf8_paths() { + assert_eq!(chop_basename(Rules::UNIX, b"\xff/\xfe\xfd/"), Some((&b"\xff/"[..], &b"\xfe\xfd"[..]))); +} + +#[cfg(test)] +fn windows_chop(input: &str) -> Option<(&str, &str)> { + chop_basename(Rules::WINDOWS, input.as_bytes()).map(|(dirname, basename)| { + (std::str::from_utf8(dirname).unwrap(), std::str::from_utf8(basename).unwrap()) + }) +} + +#[test] +fn it_chops_windows_paths() { + assert_eq!(windows_chop("a\\b"), Some(("a\\", "b"))); + assert_eq!(windows_chop("a\\b\\"), Some(("a\\", "b"))); + assert_eq!(windows_chop("C:"), None); + assert_eq!(windows_chop("C:/"), None); + assert_eq!(windows_chop("C:/a"), Some(("C:/", "a"))); + assert_eq!(windows_chop("C:a"), Some(("C:", "a"))); + assert_eq!(windows_chop("//a/b"), None); + assert_eq!(windows_chop("//a/b/c"), Some(("//a/b/", "c"))); + assert_eq!(windows_chop("a."), Some(("", "a"))); } +#[test] +fn it_knows_relative_paths() { + for &rules in &[Rules::UNIX, Rules::WINDOWS] { + assert!(is_relative(rules, b"")); + assert!(is_relative(rules, b"a")); + assert!(is_relative(rules, b"a/b")); + assert!(!is_relative(rules, b"/")); + assert!(!is_relative(rules, b"/a")); + } + assert!(is_relative(Rules::UNIX, b"C:/a")); + assert!(is_relative(Rules::UNIX, b"\\a")); + for path in &["A:", "A:/", "A:/a", "//", "//a", "//a/", "//a/b", "//a/b/", "//a/b/c", "\\a"] { + assert!(!is_relative(Rules::WINDOWS, path.as_bytes()), "{}", path); + } +} diff --git a/src/cleanpath_aggressive.rs b/src/cleanpath_aggressive.rs index d976301..8ffb25b 100644 --- a/src/cleanpath_aggressive.rs +++ b/src/cleanpath_aggressive.rs @@ -1,19 +1,21 @@ use std::borrow::Cow; -use prepend_prefix::prepend_prefix; -use basename::basename; -use chop_basename::chop_basename; -use path_parsing::{SEP_STR, contains_sep}; -pub fn cleanpath_aggressive(path: &str) -> Cow { - let mut names: Vec<&str> = vec![]; +use crate::basename::basename; +use crate::chop_basename::chop_basename; +use crate::path_parsing::{Rules, SEP_BYTES}; +use crate::prepend_prefix::prepend_prefix; + +// Pathname's `cleanpath_aggressive` +pub fn cleanpath_aggressive(rules: Rules, path: &[u8]) -> Cow<'_, [u8]> { + let mut names: Vec<&[u8]> = vec![]; let mut prefix = path; - while let Some((ref p, ref base)) = chop_basename(&prefix) { + while let Some((p, base)) = chop_basename(rules, prefix) { prefix = p; - match base.as_ref() { - "." => {} - ".." => names.push(base), + match base { + b"." => {} + b".." => names.push(base), _ => { - if names.last() == Some(&"..") { + if names.last() == Some(&&b".."[..]) { names.pop(); } else { names.push(base); @@ -21,72 +23,86 @@ pub fn cleanpath_aggressive(path: &str) -> Cow { } } } - // // Windows Feature - // - // ```ruby - // pre.tr!(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR - // ``` - // - if contains_sep(basename(&prefix, "").as_bytes()) { - let len = names.iter().rposition(|&c| c != "..").map_or(0, |pos| pos + 1); + let prefix = with_separators(rules, prefix); + if rules.contains_sep(basename(rules, &prefix, b"")) { + let len = names.iter().rposition(|&c| c != b"..").map_or(0, |pos| pos + 1); names.truncate(len); } names.reverse(); - prepend_prefix(&prefix, &names.join(&SEP_STR)) + let path = prepend_prefix(rules, &prefix, &names.join(SEP_BYTES)); + Cow::Owned(path.into_owned()) +} + +// `pre.tr!(File::ALT_SEPARATOR, File::SEPARATOR)` +pub fn with_separators(rules: Rules, prefix: &[u8]) -> Cow<'_, [u8]> { + if rules.is_dosish() && prefix.contains(&b'\\') { + Cow::Owned(prefix.iter().map(|&c| if c == b'\\' { b'/' } else { c }).collect()) + } else { + Cow::Borrowed(prefix) + } +} + +#[cfg(test)] +fn cleanpath_aggressive_str(path: &str) -> String { + String::from_utf8(cleanpath_aggressive(Rules::UNIX, path.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn windows_cleanpath_aggressive(path: &str) -> String { + String::from_utf8(cleanpath_aggressive(Rules::WINDOWS, path.as_bytes()).into_owned()).unwrap() } #[test] fn it_aggressively_cleans_the_path() { - assert_eq!(cleanpath_aggressive("/") , "/"); - assert_eq!(cleanpath_aggressive("") , "."); - assert_eq!(cleanpath_aggressive(".") , "."); - assert_eq!(cleanpath_aggressive("..") , ".."); - assert_eq!(cleanpath_aggressive("a") , "a"); - assert_eq!(cleanpath_aggressive("/.") , "/"); - assert_eq!(cleanpath_aggressive("/..") , "/"); - assert_eq!(cleanpath_aggressive("/a") , "/a"); - assert_eq!(cleanpath_aggressive("./") , "."); - assert_eq!(cleanpath_aggressive("../") , ".."); - assert_eq!(cleanpath_aggressive("a/") , "a"); - assert_eq!(cleanpath_aggressive("a//b") , "a/b"); - assert_eq!(cleanpath_aggressive("a/.") , "a"); - assert_eq!(cleanpath_aggressive("a/./") , "a"); - assert_eq!(cleanpath_aggressive("a/..") , "."); - assert_eq!(cleanpath_aggressive("a/../") , "."); - assert_eq!(cleanpath_aggressive("/a/.") , "/a"); - assert_eq!(cleanpath_aggressive("./..") , ".."); - assert_eq!(cleanpath_aggressive("../.") , ".."); - assert_eq!(cleanpath_aggressive("./../") , ".."); - assert_eq!(cleanpath_aggressive(".././") , ".."); - assert_eq!(cleanpath_aggressive("/./..") , "/"); - assert_eq!(cleanpath_aggressive("/../.") , "/"); - assert_eq!(cleanpath_aggressive("/./../") , "/"); - assert_eq!(cleanpath_aggressive("/.././") , "/"); - assert_eq!(cleanpath_aggressive("a/b/c") , "a/b/c"); - assert_eq!(cleanpath_aggressive("./b/c") , "b/c"); - assert_eq!(cleanpath_aggressive("a/./c") , "a/c"); - assert_eq!(cleanpath_aggressive("a/b/.") , "a/b"); - assert_eq!(cleanpath_aggressive("a/../.") , "."); - assert_eq!(cleanpath_aggressive("/../.././../a") , "/a"); - assert_eq!(cleanpath_aggressive("a/b/../../../../c/../d"), "../../d"); + assert_eq!(cleanpath_aggressive_str("/") , "/"); + assert_eq!(cleanpath_aggressive_str("") , "."); + assert_eq!(cleanpath_aggressive_str(".") , "."); + assert_eq!(cleanpath_aggressive_str("..") , ".."); + assert_eq!(cleanpath_aggressive_str("a") , "a"); + assert_eq!(cleanpath_aggressive_str("/.") , "/"); + assert_eq!(cleanpath_aggressive_str("/..") , "/"); + assert_eq!(cleanpath_aggressive_str("/a") , "/a"); + assert_eq!(cleanpath_aggressive_str("./") , "."); + assert_eq!(cleanpath_aggressive_str("../") , ".."); + assert_eq!(cleanpath_aggressive_str("a/") , "a"); + assert_eq!(cleanpath_aggressive_str("a//b") , "a/b"); + assert_eq!(cleanpath_aggressive_str("a/.") , "a"); + assert_eq!(cleanpath_aggressive_str("a/./") , "a"); + assert_eq!(cleanpath_aggressive_str("a/..") , "."); + assert_eq!(cleanpath_aggressive_str("a/../") , "."); + assert_eq!(cleanpath_aggressive_str("/a/.") , "/a"); + assert_eq!(cleanpath_aggressive_str("./..") , ".."); + assert_eq!(cleanpath_aggressive_str("../.") , ".."); + assert_eq!(cleanpath_aggressive_str("./../") , ".."); + assert_eq!(cleanpath_aggressive_str(".././") , ".."); + assert_eq!(cleanpath_aggressive_str("/./..") , "/"); + assert_eq!(cleanpath_aggressive_str("/../.") , "/"); + assert_eq!(cleanpath_aggressive_str("/./../") , "/"); + assert_eq!(cleanpath_aggressive_str("/.././") , "/"); + assert_eq!(cleanpath_aggressive_str("a/b/c") , "a/b/c"); + assert_eq!(cleanpath_aggressive_str("./b/c") , "b/c"); + assert_eq!(cleanpath_aggressive_str("a/./c") , "a/c"); + assert_eq!(cleanpath_aggressive_str("a/b/.") , "a/b"); + assert_eq!(cleanpath_aggressive_str("a/../.") , "."); + assert_eq!(cleanpath_aggressive_str("/../.././../a") , "/a"); + assert_eq!(cleanpath_aggressive_str("a/b/../../../../c/../d"), "../../d"); +} + +#[test] +fn it_cleans_like_ruby_on_unix() { + assert_eq!(cleanpath_aggressive_str("///"), "/"); + assert_eq!(cleanpath_aggressive_str("///a"), "/a"); + assert_eq!(cleanpath_aggressive_str("///.."), "/"); + assert_eq!(cleanpath_aggressive_str("///."), "/"); + assert_eq!(cleanpath_aggressive_str("///a/../.."), "/"); } -// Future Windows Support -// -// DOSISH = File::ALT_SEPARATOR != nil -// DOSISH_DRIVE_LETTER = File.dirname("A:") == "A:." -// DOSISH_UNC = File.dirname("//") == "//" -// -// if DOSISH_UNC -// defassert(:cleanpath_aggressive, '//a/b/c', '//a/b/c/') -// else -// defassert(:cleanpath_aggressive, '/', '///') -// defassert(:cleanpath_aggressive, '/a', '///a') -// defassert(:cleanpath_aggressive, '/', '///..') -// defassert(:cleanpath_aggressive, '/', '///.') -// defassert(:cleanpath_aggressive, '/', '///a/../..') -// end -// -// if DOSISH -// defassert(:cleanpath_aggressive, 'c:/foo/bar', 'c:\\foo\\bar') -// end +#[test] +fn it_cleans_like_ruby_on_windows() { + assert_eq!(windows_cleanpath_aggressive("//a/b/c/"), "//a/b/c"); + assert_eq!(windows_cleanpath_aggressive("c:\\foo\\bar"), "c:/foo/bar"); + assert_eq!(windows_cleanpath_aggressive("c:\\foo\\..\\..\\bar"), "c:/bar"); + assert_eq!(windows_cleanpath_aggressive("a\\.\\b"), "a/b"); + assert_eq!(windows_cleanpath_aggressive("C:"), "C:."); + assert_eq!(windows_cleanpath_aggressive("C:a/../b"), "C:b"); +} diff --git a/src/cleanpath_conservative.rs b/src/cleanpath_conservative.rs index f4c7b87..cd6abe3 100644 --- a/src/cleanpath_conservative.rs +++ b/src/cleanpath_conservative.rs @@ -1,96 +1,192 @@ use std::borrow::Cow; -use std::path::MAIN_SEPARATOR; -use prepend_prefix::prepend_prefix; -use basename::basename; -use dirname::dirname; -use chop_basename::chop_basename; -use path_parsing::{SEP_STR, contains_sep}; - -pub fn cleanpath_conservative(path: &str) -> Cow { - let mut names: Vec<&str> = vec![]; + +use crate::basename::basename; +use crate::chop_basename::chop_basename; +use crate::cleanpath_aggressive::with_separators; +use crate::dirname::dirname; +use crate::path_parsing::{Rules, SEP_BYTES}; +use crate::prepend_prefix::prepend_prefix; + +// Pathname's `cleanpath_conservative` +pub fn cleanpath_conservative(rules: Rules, path: &[u8]) -> Cow<'_, [u8]> { + let mut names: Vec<&[u8]> = vec![]; let mut prefix = path; - while let Some((ref p, ref base)) = chop_basename(&prefix) { + while let Some((p, base)) = chop_basename(rules, prefix) { prefix = p; - if base != &"." { + if base != b"." { names.push(base); } } - // // Windows Feature - // - // ```ruby - // pre.tr!(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR - // ``` - // - if contains_sep(basename(&prefix, "").as_bytes()) { - let len = names.iter().rposition(|&c| c != "..").map_or(0, |pos| pos + 1); + let prefix = with_separators(rules, prefix); + if rules.contains_sep(basename(rules, &prefix, b"")) { + let len = names.iter().rposition(|&c| c != b"..").map_or(0, |pos| pos + 1); names.truncate(len); } - let last_name = match names.first() { - Some(&name) => name, - None => return dirname(&prefix).into(), - }; + if names.is_empty() { + return Cow::Owned(dirname(rules, &prefix).into_owned()); + } + names.reverse(); + if names.last() != Some(&&b".."[..]) && basename(rules, path, b"") == b"." { + names.push(b"."); + } + let result = prepend_prefix(rules, &prefix, &names.join(SEP_BYTES)).into_owned(); + match names.last() { + Some(&b".") | Some(&b"..") => Cow::Owned(result), + _ if has_trailing_separator(rules, path) => Cow::Owned(add_trailing_separator(rules, Cow::Owned(result)).into_owned()), + _ => Cow::Owned(result), + } +} - if last_name != ".." && basename(&path, "") == "." { - names.reverse(); - names.push("."); - } else if last_name != "." && last_name != ".." && - chop_basename(path).map(|(a, b)| a.len() + b.len()).unwrap() < path.len() { - return format!("{}{}", last_name, MAIN_SEPARATOR).into(); +// Pathname's `has_trailing_separator?` +pub fn has_trailing_separator(rules: Rules, path: &[u8]) -> bool { + match chop_basename(rules, path) { + Some((dirname, basename)) => dirname.len() + basename.len() < path.len(), + None => false, + } +} + +// Pathname's `add_trailing_separator`: +// `File.basename(path + 'a') == 'a' ? path : File.join(path, '')` +pub fn add_trailing_separator(rules: Rules, path: Cow<'_, [u8]>) -> Cow<'_, [u8]> { + let ends_a_path = if rules.is_dosish() { + basename(rules, &[&path[..], b"a"].concat(), b"") == b"a" + } else { + path.last().map_or(true, |&c| rules.is_sep(c)) + }; + if ends_a_path { + path } else { - names.reverse(); + Cow::Owned(rules.join(&path, b"")) + } +} + +// Pathname's `del_trailing_separator` +pub fn del_trailing_separator(rules: Rules, path: &[u8]) -> Cow<'_, [u8]> { + if let Some((dirname, basename)) = chop_basename(rules, path) { + // The basename follows the dirname + return Cow::Borrowed(&path[..dirname.len() + basename.len()]); + } + match rules.last_non_sep_pos(path) { + // No trailing separators + _ if path.last().map_or(true, |&c| !rules.is_sep(c)) => Cow::Borrowed(path), + // `$` + File.dirname(path)[/#{SEPARATOR_PAT}*\z/]` + last => { + let before = last.map_or(0, |pos| pos + 1); + let dir = dirname(rules, path); + let separators = dir.len() - dir.iter().rev().take_while(|&&c| rules.is_sep(c)).count(); + Cow::Owned([&path[..before], &dir[separators..]].concat()) + } } - prepend_prefix(&prefix, &names.join(&SEP_STR)) +} + +#[cfg(test)] +fn cleanpath_conservative_str(path: &str) -> String { + String::from_utf8(cleanpath_conservative(Rules::UNIX, path.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn windows_cleanpath_conservative(path: &str) -> String { + String::from_utf8(cleanpath_conservative(Rules::WINDOWS, path.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn del_str(rules: Rules, path: &str) -> String { + String::from_utf8(del_trailing_separator(rules, path.as_bytes()).into_owned()).unwrap() +} + +#[test] +fn it_keeps_the_whole_path_with_a_trailing_separator() { + assert_eq!(cleanpath_conservative_str("a/b/"), "a/b/"); + assert_eq!(cleanpath_conservative_str("/a/b//"), "/a/b/"); + assert_eq!(cleanpath_conservative_str("a/./b/"), "a/b/"); + assert_eq!(cleanpath_conservative_str("a/b/../"), "a/b/.."); +} + +#[test] +fn it_adds_trailing_separators() { + assert_eq!(add_trailing_separator(Rules::UNIX, Cow::Borrowed(&b""[..])), &b""[..]); + assert_eq!(add_trailing_separator(Rules::UNIX, Cow::Borrowed(&b"/"[..])), &b"/"[..]); + assert_eq!(add_trailing_separator(Rules::UNIX, Cow::Borrowed(&b"a"[..])), &b"a/"[..]); + assert_eq!(add_trailing_separator(Rules::UNIX, Cow::Borrowed(&b"a/"[..])), &b"a/"[..]); +} + +#[test] +fn it_detects_trailing_separators() { + assert!(!has_trailing_separator(Rules::UNIX, b"")); + assert!(!has_trailing_separator(Rules::UNIX, b"/")); + assert!(!has_trailing_separator(Rules::UNIX, b"a")); + assert!(has_trailing_separator(Rules::UNIX, b"a/")); + assert!(has_trailing_separator(Rules::UNIX, b"/a//")); } #[test] fn it_conservatively_cleans_the_path() { - assert_eq!(cleanpath_conservative("/"), "/"); - assert_eq!(cleanpath_conservative(""), "."); - assert_eq!(cleanpath_conservative("."), "."); - assert_eq!(cleanpath_conservative(".."), ".."); - assert_eq!(cleanpath_conservative("a"), "a"); - assert_eq!(cleanpath_conservative("/."), "/"); - assert_eq!(cleanpath_conservative("/.."), "/"); - assert_eq!(cleanpath_conservative("/a"), "/a"); - assert_eq!(cleanpath_conservative("./"), "."); - assert_eq!(cleanpath_conservative("../"), ".."); - assert_eq!(cleanpath_conservative("a/"), "a/"); - assert_eq!(cleanpath_conservative("a//b"), "a/b"); - assert_eq!(cleanpath_conservative("a/."), "a/."); - assert_eq!(cleanpath_conservative("a/./"), "a/."); - assert_eq!(cleanpath_conservative("a/../"), "a/.."); - assert_eq!(cleanpath_conservative("/a/."), "/a/."); - assert_eq!(cleanpath_conservative("./.."), ".."); - assert_eq!(cleanpath_conservative("../."), ".."); - assert_eq!(cleanpath_conservative("./../"), ".."); - assert_eq!(cleanpath_conservative(".././"), ".."); - assert_eq!(cleanpath_conservative("/./.."), "/"); - assert_eq!(cleanpath_conservative("/../."), "/"); - assert_eq!(cleanpath_conservative("/./../"), "/"); - assert_eq!(cleanpath_conservative("/.././"), "/"); - assert_eq!(cleanpath_conservative("a/b/c"), "a/b/c"); - assert_eq!(cleanpath_conservative("./b/c"), "b/c"); - assert_eq!(cleanpath_conservative("a/./c"), "a/c"); - assert_eq!(cleanpath_conservative("a/b/."), "a/b/."); - assert_eq!(cleanpath_conservative("a/../."), "a/.."); - assert_eq!(cleanpath_conservative("/../.././../a"), "/a"); - assert_eq!(cleanpath_conservative("a/b/../../../../c/../d"), "a/b/../../../../c/../d"); - -// Future Windows Support -// -// DOSISH = File::ALT_SEPARATOR != nil -// DOSISH_DRIVE_LETTER = File.dirname("A:") == "A:." -// DOSISH_UNC = File.dirname("//") == "//" -// -// -// if DOSISH -// assert_eq!(cleanpath_conservative, 'c:/foo/bar', 'c:\\foo\\bar') -// end -// -// if DOSISH_UNC -// assert_eq!(cleanpath_conservative, '//', '//') -// else -// assert_eq!(cleanpath_conservative, '/', '//') -// end + assert_eq!(cleanpath_conservative_str("/"), "/"); + assert_eq!(cleanpath_conservative_str(""), "."); + assert_eq!(cleanpath_conservative_str("."), "."); + assert_eq!(cleanpath_conservative_str(".."), ".."); + assert_eq!(cleanpath_conservative_str("a"), "a"); + assert_eq!(cleanpath_conservative_str("/."), "/"); + assert_eq!(cleanpath_conservative_str("/.."), "/"); + assert_eq!(cleanpath_conservative_str("/a"), "/a"); + assert_eq!(cleanpath_conservative_str("./"), "."); + assert_eq!(cleanpath_conservative_str("../"), ".."); + assert_eq!(cleanpath_conservative_str("a/"), "a/"); + assert_eq!(cleanpath_conservative_str("a//b"), "a/b"); + assert_eq!(cleanpath_conservative_str("a/."), "a/."); + assert_eq!(cleanpath_conservative_str("a/./"), "a/."); + assert_eq!(cleanpath_conservative_str("a/../"), "a/.."); + assert_eq!(cleanpath_conservative_str("/a/."), "/a/."); + assert_eq!(cleanpath_conservative_str("./.."), ".."); + assert_eq!(cleanpath_conservative_str("../."), ".."); + assert_eq!(cleanpath_conservative_str("./../"), ".."); + assert_eq!(cleanpath_conservative_str(".././"), ".."); + assert_eq!(cleanpath_conservative_str("/./.."), "/"); + assert_eq!(cleanpath_conservative_str("/../."), "/"); + assert_eq!(cleanpath_conservative_str("/./../"), "/"); + assert_eq!(cleanpath_conservative_str("/.././"), "/"); + assert_eq!(cleanpath_conservative_str("a/b/c"), "a/b/c"); + assert_eq!(cleanpath_conservative_str("./b/c"), "b/c"); + assert_eq!(cleanpath_conservative_str("a/./c"), "a/c"); + assert_eq!(cleanpath_conservative_str("a/b/."), "a/b/."); + assert_eq!(cleanpath_conservative_str("a/../."), "a/.."); + assert_eq!(cleanpath_conservative_str("/../.././../a"), "/a"); + assert_eq!(cleanpath_conservative_str("a/b/../../../../c/../d"), "a/b/../../../../c/../d"); + + assert_eq!(cleanpath_conservative_str("//"), "/"); +} + +#[test] +fn it_conservatively_cleans_windows_paths() { + assert_eq!(windows_cleanpath_conservative("c:\\foo\\bar"), "c:/foo/bar"); + assert_eq!(windows_cleanpath_conservative("//"), "//"); + assert_eq!(windows_cleanpath_conservative("a\\b\\"), "a/b/"); + assert_eq!(windows_cleanpath_conservative("C:/a/../b/"), "C:/a/../b/"); +} + +#[test] +fn it_adds_trailing_separators_on_windows() { + let add = |path: &'static str| add_trailing_separator(Rules::WINDOWS, Cow::Borrowed(path.as_bytes())).into_owned(); + assert_eq!(add("a"), b"a/"); + assert_eq!(add("a\\"), b"a\\"); + assert_eq!(add("C:"), b"C:"); + assert_eq!(add("C:/"), b"C:/"); + assert_eq!(add("//a/b"), b"//a/b/"); + assert!(has_trailing_separator(Rules::WINDOWS, b"a\\")); + assert!(!has_trailing_separator(Rules::UNIX, b"a\\")); +} + +#[test] +fn it_deletes_trailing_separators() { + for &(path, expected) in &[("", ""), ("/", "/"), ("/a", "/a"), ("/a/", "/a"), ("/a//", "/a"), + (".", "."), ("./", "."), (".//", "."), ("///", "/"), ("///a/", "///a")] { + assert_eq!(del_str(Rules::UNIX, path), expected, "{:?}", path); + } + for &(path, expected) in &[("A:", "A:"), ("A:/", "A:/"), ("A://", "A:/"), ("A:.", "A:."), ("A:./", "A:."), + ("A:.//", "A:."), ("//", "//"), ("//a", "//a"), ("//a/", "//a"), ("//a//", "//a"), + ("//a/b", "//a/b"), ("//a/b/", "//a/b"), ("//a/b//", "//a/b"), ("//a/b/c", "//a/b/c"), + ("//a/b/c/", "//a/b/c"), ("//a/b/c//", "//a/b/c"), ("a\\", "a")] { + assert_eq!(del_str(Rules::WINDOWS, path), expected, "{:?}", path); + } } diff --git a/src/debug.rs b/src/debug.rs deleted file mode 100644 index f802632..0000000 --- a/src/debug.rs +++ /dev/null @@ -1,29 +0,0 @@ -use rutie::{ - RString, - AnyObject, - Object, -}; - -#[derive(Debug)] -pub struct RubyDebugInfo { - object_id: String, - class: String, - inspect: String, -} - -impl From for RubyDebugInfo { - fn from(ao: AnyObject) -> Self { - let object_id = ao.send("object_id", &[]).send("to_s", &[]). - try_convert_to::().unwrap_or(RString::new_utf8("Failed to get object_id!")).to_string(); - let class = ao.send("class", &[]).send("to_s", &[]). - try_convert_to::().unwrap_or(RString::new_utf8("Failed to get class!")).to_string(); - let inspect = ao.send("inspect", &[]). - try_convert_to::().unwrap_or(RString::new_utf8("Failed to get inspect!")).to_string(); - - RubyDebugInfo { - object_id: object_id, - class: class, - inspect: inspect, - } - } -} diff --git a/src/dirname.rs b/src/dirname.rs index 490835f..6acae1f 100644 --- a/src/dirname.rs +++ b/src/dirname.rs @@ -1,66 +1,130 @@ -use std::str; -use path_parsing::{find_last_sep_pos, find_last_non_sep_pos}; - -pub fn dirname(path: &str) -> &str { - let bytes = path.as_bytes(); - let mut last_slash_pos = match find_last_sep_pos(bytes) { - Some(pos) => pos, - _ => return ".", - }; - // Skip trailing slashes. - if last_slash_pos == bytes.len() - 1 { - let last_non_slash_pos = match find_last_non_sep_pos(&bytes[..last_slash_pos]) { - Some(pos) => pos, - _ => return "/" - }; - last_slash_pos = match find_last_sep_pos(&bytes[..last_non_slash_pos]) { - Some(pos) => pos, - _ => return "." - }; - }; - if let Some(end) = find_last_non_sep_pos(&bytes[..last_slash_pos]) { - &path[..end + 1] - } else { - "/" +use std::borrow::Cow; + +use crate::path_parsing::Rules; + +// Ruby's `File.dirname` +pub fn dirname(rules: Rules, path: &[u8]) -> Cow<'_, [u8]> { + let mut name = 0; + let mut root = rules.skip_root(path); + if rules.is_dosish() { + // Keep a UNC prefix + if root > 1 && rules.is_sep(path[0]) { + name = root - 2; + root = name + rules.skip_prefix(&path[name..]); + } + } else if root > 1 { + // Leading separators become one + name = root - 1; + } + let end = rules.last_separator(&path[root..]).map_or(root, |pos| root + pos); + if end == name { + return Cow::Borrowed(b"."); } + let path = &path[name..]; + let end = end - name; + let root = root - name; + if rules.has_drive_letter(path) { + if path.len() > 2 && rules.is_sep(path[2]) { + // "C:" and one separator, then the rest + let top = 2 + rules.skip_root(&path[2..]); + return Cow::Owned([&path[..3], &path[top..end.max(top)]].concat()); + } + if root == 2 && end == 2 { + return Cow::Owned([&path[..2], b"."].concat()); + } + } + Cow::Borrowed(&path[..end]) +} + +#[cfg(test)] +fn dirname_str(path: &str) -> String { + String::from_utf8(dirname(Rules::UNIX, path.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn windows_dirname(path: &str) -> String { + String::from_utf8(dirname(Rules::WINDOWS, path.as_bytes()).into_owned()).unwrap() } #[test] fn absolute() { - assert_eq!(dirname("/a/b///c"), "/a/b"); + assert_eq!(dirname_str("/a/b///c"), "/a/b"); } #[test] fn trailing_slashes_absolute() { - assert_eq!(dirname("/a/b///c//////"), "/a/b"); + assert_eq!(dirname_str("/a/b///c//////"), "/a/b"); } #[test] fn relative() { - assert_eq!(dirname("b///c"), "b"); + assert_eq!(dirname_str("b///c"), "b"); } #[test] fn trailing_slashes_relative() { - assert_eq!(dirname("b/c//"), "b"); + assert_eq!(dirname_str("b/c//"), "b"); } #[test] fn root() { - assert_eq!(dirname("//c"), "/"); + assert_eq!(dirname_str("//c"), "/"); } #[test] fn trailing_slashes_root() { - assert_eq!(dirname("//c//"), "/"); + assert_eq!(dirname_str("//c//"), "/"); } #[test] fn trailing_slashes_relative_root() { - assert_eq!(dirname("c//"), "."); + assert_eq!(dirname_str("c//"), "."); } #[test] fn returns_dot_for_empty_string() { - assert_eq!(dirname(""), "."); + assert_eq!(dirname_str(""), "."); +} + +#[test] +fn only_separators() { + assert_eq!(dirname_str("/"), "/"); + assert_eq!(dirname_str("///"), "/"); +} + +#[test] +fn non_utf8() { + assert_eq!(dirname(Rules::UNIX, b"\xff\xfe/a").as_ref(), b"\xff\xfe"); +} + +#[test] +fn windows_separators() { + assert_eq!(windows_dirname("a\\b"), "a"); + assert_eq!(windows_dirname("a\\b\\"), "a"); + assert_eq!(windows_dirname("a/b\\c"), "a/b"); + assert_eq!(dirname_str("a\\b"), "."); +} + +#[test] +fn windows_drive_letters() { + assert_eq!(windows_dirname("C:"), "C:."); + assert_eq!(windows_dirname("C:a"), "C:."); + assert_eq!(windows_dirname("C:/"), "C:/"); + assert_eq!(windows_dirname("C:/a"), "C:/"); + assert_eq!(windows_dirname("C://a"), "C:/"); + assert_eq!(windows_dirname("C:/a/b"), "C:/a"); + assert_eq!(windows_dirname("C://a//b"), "C:/a"); + assert_eq!(windows_dirname("C:a/b"), "C:a"); + assert_eq!(dirname_str("C:"), "."); +} + +#[test] +fn windows_unc() { + assert_eq!(windows_dirname("//"), "//"); + assert_eq!(windows_dirname("//a"), "//a"); + assert_eq!(windows_dirname("//a/b"), "//a/b"); + assert_eq!(windows_dirname("//a/b/c"), "//a/b"); + assert_eq!(windows_dirname("//a/b/c/d"), "//a/b/c"); + assert_eq!(windows_dirname("///a"), "//a"); + assert_eq!(dirname_str("//a/b"), "/a"); } diff --git a/src/extname.rs b/src/extname.rs index 36eb732..5667d1b 100644 --- a/src/extname.rs +++ b/src/extname.rs @@ -1,25 +1,155 @@ -use std::str; -use path_parsing::{SEP, find_last_non_sep_pos}; +use std::sync::atomic::{AtomicBool, Ordering}; -pub fn extname(path: &str) -> &str { - let end = match find_last_non_sep_pos(path.as_bytes()) { - Some(pos) => pos + 1, - _ => return "", - }; - let bytes = &path.as_bytes()[..end]; - for (pos, c) in bytes.iter().enumerate().rev() { - match *c { - b'.' => { - let prev = bytes.get(pos - 1); - if pos == end - 1 || prev == None || prev == Some(&SEP) { - return ""; - } else { - return &path[pos..end] - }; +use crate::path_parsing::Rules; + +// Ruby 2.7 changed `File.extname` to return an extension of only a dot +// (`"foo."` on Unix, `"foo./"` on both Unix and Windows, where trailing dots +// aren't part of a file name) instead of `""`. `FasterPath` follows the Ruby +// it is loaded into; see `Init_faster_pathname`. +static TRAILING_DOT_IS_EXTNAME: AtomicBool = AtomicBool::new(false); + +pub fn set_trailing_dot_is_extname(value: bool) { + TRAILING_DOT_IS_EXTNAME.store(value, Ordering::Relaxed); +} + +// Ruby's `File.extname` +pub fn extname(rules: Rules, path: &[u8]) -> &[u8] { + extname_with(rules, path, TRAILING_DOT_IS_EXTNAME.load(Ordering::Relaxed)) +} + +// Ruby's `ruby_enc_find_extname` +fn extname_with(rules: Rules, path: &[u8], trailing_dot_is_extname: bool) -> &[u8] { + let end = path.len(); + // The last component + let mut p = match rules.last_separator(path) { + Some(pos) => { + let mut p = pos; + while p < end && rules.is_sep(path[p]) { + p += 1; } - SEP => return "", - _ => {} + p } + None => 0, }; - "" + let name = p; + + // Leading dots don't start an extension + while p < end && path[p] == b'.' { + p += 1; + } + let mut dot = None; + while p < end { + let c = path[p]; + if c == b'.' || (rules.is_dosish() && c == b' ') { + if !rules.is_dosish() { + dot = Some(p); + } else { + // NTFS ignores trailing dots and spaces + let last = p; + let mut last_dot = last; + p += 1; + while p < end && (path[p] == b'.' || path[p] == b' ') { + if path[p] == b'.' { + last_dot = p; + } + p += 1; + } + if p >= end || rules.is_ads(path[p]) { + p = last; + break; + } + if c == b'.' || last_dot > last { + dot = Some(last_dot); + } + continue; + } + } else if rules.is_ads(c) || rules.is_sep(c) { + break; + } + p += 1; + } + match dot { + Some(dot) if dot != name && dot + 1 == p => if trailing_dot_is_extname { &path[dot..p] } else { b"" }, + Some(dot) if dot != name => &path[dot..p], + _ => b"", + } +} + +#[cfg(test)] +fn extname_str(path: &str, trailing_dot_is_extname: bool) -> &str { + std::str::from_utf8(extname_with(Rules::UNIX, path.as_bytes(), trailing_dot_is_extname)).unwrap() +} + +#[cfg(test)] +fn windows_extname(path: &str) -> &str { + std::str::from_utf8(extname_with(Rules::WINDOWS, path.as_bytes(), true)).unwrap() +} + +#[test] +fn it_finds_the_extension() { + for &trailing_dot in &[false, true] { + assert_eq!(extname_str("", trailing_dot), ""); + assert_eq!(extname_str("/", trailing_dot), ""); + assert_eq!(extname_str("a.rb", trailing_dot), ".rb"); + assert_eq!(extname_str("a.rb/", trailing_dot), ".rb"); + assert_eq!(extname_str("a.rb//", trailing_dot), ".rb"); + assert_eq!(extname_str("a/b.c.d", trailing_dot), ".d"); + assert_eq!(extname_str("a..b", trailing_dot), ".b"); + assert_eq!(extname_str(".a.b", trailing_dot), ".b"); + assert_eq!(extname_str("a/.rb", trailing_dot), ""); + assert_eq!(extname_str("a.b/c", trailing_dot), ""); + assert_eq!(extname_str(".bashrc", trailing_dot), ""); + assert_eq!(extname_str("..foo", trailing_dot), ""); + assert_eq!(extname_str("a/..b", trailing_dot), ""); + assert_eq!(extname_str("...a", trailing_dot), ""); + assert_eq!(extname_str("a.rb ", trailing_dot), ".rb "); + assert_eq!(extname_str("a\\b.c", trailing_dot), ".c"); + } +} + +#[test] +fn only_dots() { + for &trailing_dot in &[false, true] { + assert_eq!(extname_str(".", trailing_dot), ""); + assert_eq!(extname_str("..", trailing_dot), ""); + assert_eq!(extname_str("....", trailing_dot), ""); + assert_eq!(extname_str("/.", trailing_dot), ""); + assert_eq!(extname_str(".a/", trailing_dot), ""); + } +} + +#[test] +fn trailing_dot() { + assert_eq!(extname_str("foo.", false), ""); + assert_eq!(extname_str(".foo.", false), ""); + assert_eq!(extname_str("foo..", false), ""); + assert_eq!(extname_str("a./", false), ""); + assert_eq!(extname_str("foo.", true), "."); + assert_eq!(extname_str(".foo.", true), "."); + assert_eq!(extname_str("foo..", true), "."); + assert_eq!(extname_str("a./", true), "."); +} + +#[test] +fn non_utf8() { + assert_eq!(extname_with(Rules::UNIX, b"\xff.\xfe", false), b".\xfe"); +} + +#[test] +fn windows() { + assert_eq!(windows_extname("a.rb"), ".rb"); + assert_eq!(windows_extname("a.b\\c"), ""); + assert_eq!(windows_extname("a\\b.c"), ".c"); + assert_eq!(windows_extname("a.rb\\"), ".rb"); + assert_eq!(windows_extname("foo."), ""); + assert_eq!(windows_extname("foo.rb."), ".rb"); + assert_eq!(windows_extname("foo.rb "), ".rb"); + assert_eq!(windows_extname("foo.rb. ."), ".rb"); + assert_eq!(windows_extname("foo.rb::$DATA"), ".rb"); + assert_eq!(windows_extname("foo.rb::$DATA.bar"), ".rb"); + assert_eq!(windows_extname("foo .ext"), ".ext"); + assert_eq!(windows_extname("foo. .ext"), ".ext"); + assert_eq!(windows_extname("foo.ext .ext"), ".ext"); + assert_eq!(windows_extname("foo .ext "), ".ext"); + assert_eq!(windows_extname(".bashrc"), ""); } diff --git a/src/helpers.rs b/src/helpers.rs deleted file mode 100644 index e360eb3..0000000 --- a/src/helpers.rs +++ /dev/null @@ -1,50 +0,0 @@ -use rutie::{RString, Object, Class, AnyObject}; -use debug::RubyDebugInfo; -use rutie; - -type MaybeString = Result; - -pub trait TryFrom: Sized { - type Error; - - fn try_from(value: T) -> Result; -} - -#[cfg(windows)] -#[inline] -pub fn is_same_path(a: &str, b: &str) -> bool { - a.to_uppercase() == b.to_uppercase() -} - -#[cfg(not(windows))] -#[inline] -pub fn is_same_path(a: &str, b: &str) -> bool { - a == b -} - -pub fn anyobject_to_string(item: AnyObject) -> Result { - let result = &item; - if Class::from_existing("String").case_equals(result) { - return Ok(RString::from(result.value()).to_string()) - } - - if Class::from_existing("Pathname").case_equals(result) { - return Ok(result.instance_variable_get("@path"). - try_convert_to::(). - unwrap_or(RString::new_utf8("")). - to_string()) - } - - if result.respond_to("to_path") { - return Ok(RString::from(result.send("to_path", &[]).value()).to_string()) - } - - Ok(RString::from(result.send("to_s", &[]).value()).to_string()) -} - -pub fn to_str(maybe_string: &MaybeString) -> &str { - match maybe_string { - &Ok(ref rutie_string) => rutie_string.to_str(), - &Err(_) => "", - } -} diff --git a/src/lib.rs b/src/lib.rs index 4f13c63..1f691b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,16 +4,10 @@ // http://apache.org/licenses/LICENSE-2.0> or the MIT license , at your option. This file may not be // copied, modified, or distributed except according to those terms. -#[macro_use] -extern crate rutie; +#![forbid(unsafe_op_in_unsafe_fn)] #[macro_use] -extern crate lazy_static; - -module!(FasterPath); - -mod debug; -mod helpers; +mod ruby; mod pathname; mod basename; mod chop_basename; @@ -21,148 +15,106 @@ mod cleanpath_aggressive; mod cleanpath_conservative; mod dirname; mod extname; -mod pathname_sys; mod plus; mod prepend_prefix; pub mod rust_arch_bits; -mod memrnchr; mod path_parsing; mod relative_path_from; -use pathname::Pathname; - -use rutie::{Module, Object, RString, Boolean, AnyObject, VM}; - -use pathname_sys::*; - -methods!( - FasterPath, - _itself, +use rutie::{Class, Module, Object, RString}; - fn pub_add_trailing_separator(pth: RString) -> RString { - pathname::pn_add_trailing_separator(pth) +ruby_methods! { + fn pub_add_trailing_separator(arguments) { + pathname::pn_add_trailing_separator(arguments) } - fn pub_is_absolute(pth: RString) -> Boolean { - pathname::pn_is_absolute(pth) + fn pub_is_absolute(arguments) { + pathname::pn_is_absolute(arguments) } - // fn r_ascend(){} - - fn pub_basename(pth: RString, ext: RString) -> RString { - pathname::pn_basename(pth, ext) + fn pub_basename(arguments) { + pathname::pn_basename(arguments) } - fn pub_children(pth: RString, with_dir: Boolean) -> AnyObject { - pathname::pn_children(pth, with_dir). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_children(arguments) { + pathname::pn_children(arguments) } - fn pub_children_compat(pth: RString, with_dir: Boolean) -> AnyObject { - pathname::pn_children_compat(pth, with_dir). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_children_compat(arguments) { + pathname::pn_children_compat(arguments) } - fn pub_chop_basename(pth: RString) -> AnyObject { - pathname::pn_chop_basename(pth) + fn pub_chop_basename(arguments) { + pathname::pn_chop_basename(arguments) } - // fn r_cleanpath(){ pub_cleanpath(r_to_path()) } - // fn pub_cleanpath(pth: RString){} - - fn pub_cleanpath_aggressive(pth: RString) -> RString { - pathname::pn_cleanpath_aggressive(pth) + fn pub_cleanpath_aggressive(arguments) { + pathname::pn_cleanpath_aggressive(arguments) } - fn pub_cleanpath_conservative(pth: RString) -> RString { - pathname::pn_cleanpath_conservative(pth) + fn pub_cleanpath_conservative(arguments) { + pathname::pn_cleanpath_conservative(arguments) } - fn pub_del_trailing_separator(pth: RString) -> RString { - pathname::pn_del_trailing_separator(pth) + fn pub_del_trailing_separator(arguments) { + pathname::pn_del_trailing_separator(arguments) } - // fn r_descend(){} - - fn pub_is_directory(pth: RString) -> Boolean { - pathname::pn_is_directory(pth) + fn pub_is_directory(arguments) { + pathname::pn_is_directory(arguments) } - fn pub_dirname(pth: RString) -> RString { - pathname::pn_dirname(pth) + fn pub_dirname(arguments) { + pathname::pn_dirname(arguments) } - // fn r_each_child(){} - // fn pub_each_child(){} - - // fn pub_each_filename(pth: RString) -> NilClass { - // pathname::pn_each_filename(pth) - // } - // pub_entries returns an array of String objects - fn pub_entries(pth: RString) -> AnyObject { - pathname::pn_entries(pth). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_entries(arguments) { + pathname::pn_entries(arguments) } // pub_entries_compat returns an array of Pathname objects - fn pub_entries_compat(pth: RString) -> AnyObject { - pathname::pn_entries_compat(pth). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_entries_compat(arguments) { + pathname::pn_entries_compat(arguments) } - fn pub_extname(pth: RString) -> RString { - pathname::pn_extname(pth) + fn pub_extname(arguments) { + pathname::pn_extname(arguments) } - // fn r_find(ignore_error: Boolean){} - // fn pub_find(pth: RString ,ignore_error: Boolean){} - - fn pub_has_trailing_separator(pth: RString) -> Boolean { - pathname::pn_has_trailing_separator(pth) + fn pub_has_trailing_separator(arguments) { + pathname::pn_has_trailing_separator(arguments) } - // fn pub_mkpath(pth: RString) -> NilClass { - // pathname::pn_mkpath(pth) - // } - - // fn r_is_mountpoint(){ pub_is_mountpount(r_to_path()) } - // fn pub_is_mountpoint(pth: RString){} - - // fn r_parent(){ pub_parent(r_to_path()) } - // fn pub_parent(pth: RString){} - - // also need impl + - fn pub_plus(pth1: RString, pth2: RString) -> RString { - pathname::pn_plus(pth1, pth2) + fn pub_join(arguments) { + pathname::pn_join(arguments) } - // fn r_prepend_prefix(prefix: RString, relpath: RString){} - - fn pub_is_relative(pth: RString) -> Boolean { - pathname::pn_is_relative(pth) + fn pub_plus(arguments) { + pathname::pn_plus(arguments) } - // fn r_root(){ pub_root(r_to_path()) } - // fn pub_root(pth: RString){} - - // fn r_split_names(pth: RString){} - - fn pub_relative_path_from(itself: RString, base_directory: AnyObject) -> Pathname { - let to_string = |i: AnyObject| { RString::from(i.send("to_s", &[]).value()) }; - - pathname::pn_relative_path_from(itself, base_directory.map(to_string)). - map_err(|e| VM::raise_ex(e) ).unwrap() + fn pub_is_relative(arguments) { + pathname::pn_is_relative(arguments) } - // fn pub_rmtree(pth: RString) -> NilClass { - // pathname::pn_rmtree(pth) - // } -); + fn pub_relative_path_from(arguments) { + pathname::pn_relative_path_from(arguments) + } +} #[allow(non_snake_case)] #[no_mangle] pub extern "C" fn Init_faster_pathname() { + // An extension of only a dot: "" before Ruby 2.7, "." since. ("a." would + // tell on Unix, but Windows doesn't count trailing dots.) + let trailing_dot_extname = Class::file(). + protect_send("extname", &[RString::new_utf8("a./").into()]). + ok(). + and_then(|extname| extname.try_convert_to::().ok()). + map_or(false, |extname| extname.to_bytes_unchecked() == b"."); + extname::set_trailing_dot_is_extname(trailing_dot_extname); + Module::from_existing("FasterPath").define(|itself| { itself.def_self("absolute?", pub_is_absolute); itself.def_self("add_trailing_separator", pub_add_trailing_separator); diff --git a/src/memrnchr.rs b/src/memrnchr.rs deleted file mode 100644 index d9a8f27..0000000 --- a/src/memrnchr.rs +++ /dev/null @@ -1,70 +0,0 @@ -// The code below is based on the fallback `memrchr` implementation from the respective crate. -// -// We use this mainly to skip repeated `/`. If there is only one slash, `memrnchr` performs the same -// as a naive version (e.g. `rposition`). However, it is much faster in pathological cases. - -use std::mem::size_of; - -// Returns the byte offset of the last byte that is NOT equal to the given one. -#[inline(always)] -pub fn memrnchr(x: u8, text: &[u8]) -> Option { - // Scan for a single byte value by reading two `usize` words at a time. - // - // Split `text` in three parts - // - unaligned tail, after the last word aligned address in text - // - body, scan by 2 words at a time - // - the first remaining bytes, < 2 word size - let len = text.len(); - let ptr = text.as_ptr(); - - // search to an aligned boundary - let end_align = (ptr as usize + len) & (size_of::() - 1); - let mut offset; - if end_align > 0 { - offset = if end_align >= len { 0 } else { len - end_align }; - if let Some(index) = memrnchr_naive(x, &text[offset..]) { - return Some(offset + index); - } - } else { - offset = len; - } - - // search the body of the text - let repeated_x = repeat_byte(x); - while offset >= 2 * size_of::() { - debug_assert_eq!((ptr as usize + offset) % size_of::(), 0); - unsafe { - let u = *(ptr.offset(offset as isize - 2 * size_of::() as isize) as *const usize); - let v = *(ptr.offset(offset as isize - size_of::() as isize) as *const usize); - if u & repeated_x != usize::max_value() || v & repeated_x != usize::max_value() { - break; - } - } - offset -= 2 * size_of::(); - } - - // find the byte before the point the body loop stopped - memrnchr_naive(x, &text[..offset]) -} - -#[inline(always)] -fn memrnchr_naive(x: u8, text: &[u8]) -> Option { - text.iter().rposition(|c| *c != x) -} - -#[cfg(target_pointer_width = "32")] -#[inline] -fn repeat_byte(b: u8) -> usize { - let mut rep = (b as usize) << 8 | b as usize; - rep = rep << 16 | rep; - rep -} - -#[cfg(target_pointer_width = "64")] -#[inline] -fn repeat_byte(b: u8) -> usize { - let mut rep = (b as usize) << 8 | b as usize; - rep = rep << 16 | rep; - rep = rep << 32 | rep; - rep -} diff --git a/src/path_parsing.rs b/src/path_parsing.rs index 2c90516..517055b 100644 --- a/src/path_parsing.rs +++ b/src/path_parsing.rs @@ -1,29 +1,290 @@ -extern crate memchr; +// The building blocks of Ruby's path handling (`file.c`), for both of the +// sets of rules Ruby is built with: +// +// * Unix: `/` is the only separator. +// * Windows ("DOSISH"): `/` and `\` are separators, a path can start with a +// drive letter (`C:`) or a UNC prefix (`//server/share`), NTFS ignores +// trailing dots and spaces and `:stream` names at the end of a file name, +// and file names compare case-insensitively. +// +// Both are always compiled so each can be tested on any platform; +// `Rules::NATIVE` is the one Ruby uses where this is built. +use memchr::{memchr, memchr2, memrchr, memrchr2}; -use self::memchr::{memchr, memrchr}; -use memrnchr::memrnchr; -use std::path::MAIN_SEPARATOR; -use std::str; - -pub const SEP: u8 = MAIN_SEPARATOR as u8; -lazy_static! { - pub static ref SEP_STR: &'static str = str::from_utf8(&[SEP]).unwrap(); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rules { + dosish: bool, } -// Returns the byte offset of the last byte that equals MAIN_SEPARATOR. -#[inline(always)] -pub fn find_last_sep_pos(bytes: &[u8]) -> Option { - memrchr(SEP, bytes) +// `File::SEPARATOR`, which joins paths on every platform. +pub const SEP_BYTES: &[u8] = b"/"; + +impl Rules { + #[cfg_attr(not(test), allow(dead_code))] + pub const UNIX: Rules = Rules { dosish: false }; + #[cfg_attr(not(test), allow(dead_code))] + pub const WINDOWS: Rules = Rules { dosish: true }; + pub const NATIVE: Rules = Rules { dosish: cfg!(windows) }; + + #[inline(always)] + pub fn is_dosish(self) -> bool { + self.dosish + } + + // Ruby's `isdirsep` + #[inline(always)] + pub fn is_sep(self, c: u8) -> bool { + c == b'/' || (self.dosish && c == b'\\') + } + + // Byte offset of the last separator. + #[inline] + pub fn last_sep_pos(self, bytes: &[u8]) -> Option { + if self.dosish { memrchr2(b'/', b'\\', bytes) } else { memrchr(b'/', bytes) } + } + + // Byte offset of the last byte that isn't a separator. + #[inline] + pub fn last_non_sep_pos(self, bytes: &[u8]) -> Option { + bytes.iter().rposition(|&c| !self.is_sep(c)) + } + + #[inline] + pub fn contains_sep(self, bytes: &[u8]) -> bool { + if self.dosish { memchr2(b'/', b'\\', bytes).is_some() } else { memchr(b'/', bytes).is_some() } + } + + // `has_drive_letter`: "C:" + #[inline] + pub fn has_drive_letter(self, path: &[u8]) -> bool { + self.dosish && path.len() >= 2 && path[0].is_ascii_alphabetic() && path[1] == b':' + } + + // `istrailinggarbage`: ignored at the end of NTFS file names. + #[inline(always)] + fn is_trailing_garbage(self, c: u8) -> bool { + self.dosish && (c == b'.' || c == b' ') + } + + // `isADS`: starts an NTFS alternate data stream name. + #[inline(always)] + pub fn is_ads(self, c: u8) -> bool { + self.dosish && c == b':' + } + + // `Pathname::SAME_PATHS`, and the comparison `File.basename` uses for + // extensions (`CASEFOLD_FILESYSTEM`). + #[inline] + pub fn same_path(self, a: &[u8], b: &[u8]) -> bool { + if self.dosish { a.eq_ignore_ascii_case(b) } else { a == b } + } + + // `skipprefix`: the end of a UNC ("//server/share") or drive letter prefix. + pub fn skip_prefix(self, path: &[u8]) -> usize { + if !self.dosish { + return 0; + } + let end = path.len(); + if end >= 2 && self.is_sep(path[0]) && self.is_sep(path[1]) { + let mut i = 2; + while i < end && self.is_sep(path[i]) { + i += 1; + } + i = self.next_sep(path, i); + if i + 1 < end && !self.is_sep(path[i + 1]) { + i = self.next_sep(path, i + 1); + } + return i; + } + if self.has_drive_letter(path) { + return 2; + } + 0 + } + + // `skiproot`: past a drive letter and the separators after it. + pub fn skip_root(self, path: &[u8]) -> usize { + let mut i = if self.has_drive_letter(path) { 2 } else { 0 }; + while i < path.len() && self.is_sep(path[i]) { + i += 1; + } + i + } + + // `nextdirsep`: the next separator at or after `from`, or the end. + fn next_sep(self, path: &[u8], from: usize) -> usize { + let rest = &path[from.min(path.len())..]; + let found = if self.dosish { memchr2(b'/', b'\\', rest) } else { memchr(b'/', rest) }; + found.map_or(path.len(), |pos| from + pos) + } + + // `strrdirsep`: where the last run of separators that isn't at the end + // of the path starts. + pub fn last_separator(self, path: &[u8]) -> Option { + let end = self.last_non_sep_pos(path)? + 1; + let pos = self.last_sep_pos(&path[..end])?; + Some(path[..pos].iter().rposition(|&c| !self.is_sep(c)).map_or(0, |p| p + 1)) + } + + // `chompdirsep`: where the separators at the end of the path start, or + // its end. + #[inline] + pub fn chomp_dir_sep(self, path: &[u8]) -> usize { + self.last_non_sep_pos(path).map_or(0, |pos| pos + 1) + } + + // `ntfs_tail`: the end of an NTFS file name without trailing dots, + // spaces, separators or `:stream`. + pub fn ntfs_tail(self, path: &[u8]) -> usize { + let end = path.len(); + let mut i = 0; + while i < end && path[i] == b'.' { + i += 1; + } + while i < end && !self.is_ads(path[i]) { + if self.is_trailing_garbage(path[i]) { + let last = i; + i += 1; + while i < end && self.is_trailing_garbage(path[i]) { + i += 1; + } + if i >= end || self.is_ads(path[i]) { + return last; + } + } else if self.is_sep(path[i]) { + let last = i; + i += 1; + while i < end && self.is_sep(path[i]) { + i += 1; + } + if i >= end { + return last; + } + if self.is_ads(path[i]) { + i += 1; + } + } else { + i += 1; + } + } + i + } + + // Ruby's Windows `stat` doesn't find a path whose first "..." is a + // whole name of dots, as Windows would take "dir\\..." for "dir" + // (`check_valid_dir` in `win32.c`). + pub fn has_dots_name(self, path: &[u8]) -> bool { + if !self.dosish { + return false; + } + let start = match memchr::memmem::find(path, b"...") { + Some(start) => start, + None => return false, + }; + let end = start + path[start..].iter().take_while(|&&c| c == b'.').count(); + let delimits = |c: u8| c == b':' || self.is_sep(c); + (start == 0 || delimits(path[start - 1])) && (end == path.len() || delimits(path[end])) + } + + // `File.join(a, b)` + pub fn join(self, a: &[u8], b: &[u8]) -> Vec { + let tail = self.chomp_dir_sep(a); + if b.first().map_or(false, |&c| self.is_sep(c)) { + [&a[..tail], b].concat() + } else if tail == a.len() { + [a, SEP_BYTES, b].concat() + } else { + [a, b].concat() + } + } } -// Returns the byte offset of the last byte that is not MAIN_SEPARATOR. -#[inline(always)] -pub fn find_last_non_sep_pos(bytes: &[u8]) -> Option { - memrnchr(SEP, bytes) +// Where `part`, a slice of `whole`, starts in it. +#[inline] +pub fn offset_in(whole: &[u8], part: &[u8]) -> usize { + part.as_ptr() as usize - whole.as_ptr() as usize } -// Whether the given byte sequence contains a MAIN_SEPARATOR. -#[inline(always)] -pub fn contains_sep(bytes: &[u8]) -> bool { - memchr(SEP, bytes) != None +#[cfg(test)] +mod tests { + use super::*; + + const U: Rules = Rules::UNIX; + const W: Rules = Rules::WINDOWS; + + #[test] + fn it_finds_separators() { + assert_eq!(U.last_sep_pos(b""), None); + assert_eq!(U.last_sep_pos(b"a/b/c"), Some(3)); + assert_eq!(U.last_sep_pos(b"a/b\\c"), Some(1)); + assert_eq!(W.last_sep_pos(b"a/b\\c"), Some(3)); + assert_eq!(U.last_non_sep_pos(b"///"), None); + assert_eq!(U.last_non_sep_pos(b"a///"), Some(0)); + assert_eq!(W.last_non_sep_pos(b"a/\\/"), Some(0)); + assert!(U.contains_sep(b"a/b")); + assert!(!U.contains_sep(b"a\\b")); + assert!(W.contains_sep(b"a\\b")); + } + + #[test] + fn it_skips_prefixes() { + assert_eq!(U.skip_prefix(b"//a/b/c"), 0); + assert_eq!(W.skip_prefix(b"//a/b/c"), 5); + assert_eq!(W.skip_prefix(b"\\\\a\\b\\c"), 5); + assert_eq!(W.skip_prefix(b"//a/"), 3); + assert_eq!(W.skip_prefix(b"//a"), 3); + assert_eq!(W.skip_prefix(b"//"), 2); + assert_eq!(W.skip_prefix(b"C:/a"), 2); + assert_eq!(W.skip_prefix(b"/a"), 0); + assert_eq!(W.skip_root(b"C://a"), 4); + assert_eq!(U.skip_root(b"C://a"), 0); + assert_eq!(U.skip_root(b"//a"), 2); + } + + #[test] + fn it_finds_the_last_separator() { + assert_eq!(U.last_separator(b"a//b//"), Some(1)); + assert_eq!(U.last_separator(b"//b"), Some(0)); + assert_eq!(U.last_separator(b"b//"), None); + assert_eq!(W.last_separator(b"a\\/b"), Some(1)); + assert_eq!(U.chomp_dir_sep(b"a//"), 1); + assert_eq!(U.chomp_dir_sep(b"///"), 0); + assert_eq!(U.chomp_dir_sep(b"a"), 1); + } + + #[test] + fn it_finds_ntfs_tails() { + assert_eq!(W.ntfs_tail(b"foo."), 3); + assert_eq!(W.ntfs_tail(b"foo. ."), 3); + assert_eq!(W.ntfs_tail(b"foo::$DATA"), 3); + assert_eq!(W.ntfs_tail(b"foo.bar"), 7); + assert_eq!(W.ntfs_tail(b"..."), 3); + assert_eq!(W.ntfs_tail(b"foo/"), 3); + } + + #[test] + fn it_finds_names_of_dots() { + assert!(W.has_dots_name(b"dir/...")); + assert!(W.has_dots_name(b"...")); + assert!(W.has_dots_name(b"C:..../a")); + assert!(W.has_dots_name(b"a\\...\\b")); + assert!(!W.has_dots_name(b"dir/a...")); + assert!(!W.has_dots_name(b"dir/..a")); + assert!(!W.has_dots_name(b"dir/.")); + assert!(!U.has_dots_name(b"dir/...")); + } + + #[test] + fn it_joins_like_file_join() { + assert_eq!(U.join(b"a", b"b"), b"a/b"); + assert_eq!(U.join(b"a/", b"b"), b"a/b"); + assert_eq!(U.join(b"a//", b"b"), b"a//b"); + assert_eq!(U.join(b"a/", b"/b"), b"a/b"); + assert_eq!(U.join(b"a", b""), b"a/"); + assert_eq!(U.join(b"/", b""), b"/"); + assert_eq!(U.join(b"", b"b"), b"/b"); + assert_eq!(W.join(b"a\\", b"b"), b"a\\b"); + assert_eq!(W.join(b"a", b"\\b"), b"a\\b"); + assert_eq!(U.join(b"a", b"\\b"), b"a/\\b"); + } } diff --git a/src/pathname.rs b/src/pathname.rs index b419500..5a88f51 100644 --- a/src/pathname.rs +++ b/src/pathname.rs @@ -1,325 +1,365 @@ -use helpers::*; -use basename; -use chop_basename; -use cleanpath_aggressive; -use cleanpath_conservative; -use dirname; -use extname; -use plus; -use relative_path_from; -use debug; -use helpers::{TryFrom, to_str}; -use path_parsing::{SEP, find_last_non_sep_pos}; - -use rutie; -use rutie::{ - RString, - Boolean, - Array, - AnyObject, - NilClass, - Object, - Class, - VerifiedObject, - Exception as Exc, - AnyException as Exception, -}; -use rutie::types::{Value, ValueType}; use std::borrow::Cow; -use std::path::{MAIN_SEPARATOR, Path}; +use std::ffi::OsStr; use std::fs; +use std::io; + +use rutie::{AnyException, AnyObject, Array, Boolean, Class, NilClass, Object, RString}; + +use crate::basename; +use crate::chop_basename::{self, is_relative}; +use crate::cleanpath_aggressive; +use crate::cleanpath_conservative::{self, add_trailing_separator, del_trailing_separator, has_trailing_separator}; +use crate::dirname; +use crate::extname; +use crate::path_parsing::Rules; +use crate::plus; +use crate::relative_path_from::{self, RelativePathError}; +use crate::ruby::{ + argument_error, check_max_arguments, error, new_pathname, path_like_to_string, pathname_class, + truthy_argument, EncodingId, EncodingOf, PathArgument, PathString, RubyResult, +}; -type MaybeArray = Result; -type MaybeString = Result; -type MaybeBoolean = Result; - -pub struct Pathname { - value: Value -} - -impl Pathname { - pub fn new(path: &str) -> Pathname { - let arguments = [RString::new_utf8(path).to_any_object()]; - let instance = Class::from_existing("Pathname").new_instance(&arguments); +// Ruby's path rules on this platform +const RULES: Rules = Rules::NATIVE; - Pathname { value: instance.value() } +pub fn pn_add_trailing_separator(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + if argument.is_missing() { + return Err(implicit_conversion_error(arguments.first())); } - - pub fn to_any_object(&self) -> AnyObject { - AnyObject::from(self.value()) + let path = argument.path()?; + match (add_trailing_separator(RULES, Cow::Borrowed(path.bytes())), argument.string()) { + (Cow::Borrowed(_), Some(string)) => Ok(string.to_any_object()), + (result, _) => Ok(path.to_ruby(&result).into()), } } -impl From for Pathname { - fn from(value: Value) -> Self { - Pathname { value } - } +pub fn pn_is_absolute(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(Boolean::new(!is_relative(RULES, path.bytes())).into()) } -impl TryFrom for Pathname { - type Error = debug::RubyDebugInfo; - fn try_from(obj: AnyObject) -> Result { - if Class::from_existing("String").case_equals(&obj) { - Ok(Pathname::new(&RString::from(obj.value()).to_str())) - } else if Class::from_existing("Pathname").case_equals(&obj) { - Ok(Pathname::from(obj.value())) - } else if obj.respond_to("to_path") { - Ok(Pathname::new(&RString::from(obj.send("to_path", &[]).value()).to_str())) - } else { - Err(Self::Error::from(obj)) +pub fn pn_basename(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let ext_argument = PathArgument::new(arguments, 1)?; + let ext = ext_argument.path()?; + let (component, base_len) = basename::last_component(RULES, path.bytes()); + let result = match base_len { + // Like Ruby, ignore an extension in an incompatible encoding, and only + // remove one that starts a character. + Some(base_len) if !ext.bytes().is_empty() && + (path.encoding == ext.encoding || EncodingOf::new(&path).merge(&ext).is_ok()) => { + let end = basename::ext_end(RULES, component, base_len, ext.bytes()); + if end == component.len() || path.is_char_boundary(component, end) { &component[..end] } else { component } } - } + _ => component, + }; + Ok(path.to_ruby(result).into()) } -impl Object for Pathname { - #[inline] - fn value(&self) -> Value { - self.value - } +pub fn pn_children(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path_or(b".")?; + let with_directory = truthy_argument(arguments, 1, true) && path.bytes() != b"."; + let error_path = error_path(&argument, "."); + children(&path, with_directory, &error_path, |entry| Ok(entry.into())) } -impl VerifiedObject for Pathname { - fn is_correct_type(object: &T) -> bool { - object.value().ty() == ValueType::Class && - Class::from_existing("Pathname").case_equals(object) - } +pub fn pn_children_compat(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let with_directory = truthy_argument(arguments, 1, true) && path.bytes() != b"."; + let error_path = error_path(&argument, ""); + let pathname = pathname_class()?; + children(&path, with_directory, &error_path, |entry| new_pathname(&pathname, entry)) +} - fn error_message() -> &'static str { - "Error converting to Pathname" +fn children(path: &PathString, with_directory: bool, error_path: &AnyObject, mut wrap: F) -> RubyResult + where F: FnMut(RString) -> RubyResult { + let entries = read_dir(path, error_path)?; + let filesystem = EncodingId::filesystem(); + let mut array = Array::with_capacity(entries.size_hint().0); + for entry in entries { + let entry = entry.map_err(|e| system_call_error(&e, error_path, "dir_read"))?; + let os_name = entry.file_name(); + let (name, name_encoding) = file_name(&os_name, filesystem); + let string = if with_directory { + // `File.join(path, name)`, encoded like that, or as the file name if they don't mix. + let encoding = EncodingOf::new(path).merge_with(EncodingOf::of_bytes(&name, name_encoding)). + map_or(name_encoding, |encoding| encoding.encoding); + encoding.new_string(&RULES.join(path.bytes(), &name)) + } else { + name_encoding.new_string(&name) + }; + array.push(wrap(string)?); } + Ok(array.into()) } -pub fn pn_add_trailing_separator(pth: MaybeString) -> RString { - let p = pth.unwrap(); - let x = format!("{}{}", p.to_str(), "a"); - match x.rsplit_terminator(MAIN_SEPARATOR).next() { - Some("a") => p, - _ => RString::new_utf8(format!("{}{}", p.to_str(), MAIN_SEPARATOR).as_str()) +pub fn pn_chop_basename(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + match chop_basename::chop_basename(RULES, path.bytes()) { + Some((dirname, basename)) => { + let mut array = Array::with_capacity(2); + array.push(path.to_ruby(dirname)); + array.push(path.to_ruby(basename)); + Ok(array.into()) + }, + None => Ok(NilClass::new().into()), } } -pub fn pn_is_absolute(pth: MaybeString) -> Boolean { - Boolean::new(to_str(&pth).as_bytes().get(0) == Some(&SEP)) +pub fn pn_cleanpath_aggressive(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(path.to_ruby(&cleanpath_aggressive::cleanpath_aggressive(RULES, path.bytes())).into()) } -// pub fn pn_ascend(){} - -pub fn pn_basename(pth: MaybeString, ext: MaybeString) -> RString { - RString::new_utf8(basename::basename(to_str(&pth), to_str(&ext))) +pub fn pn_cleanpath_conservative(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(path.to_ruby(&cleanpath_conservative::cleanpath_conservative(RULES, path.bytes())).into()) } -pub fn pn_children(pth: MaybeString, with_dir: MaybeBoolean) -> Result { - let path = pth.unwrap_or(RString::new_utf8(".")); - let path = path.to_str(); - - if let Ok(entries) = fs::read_dir(path) { - let mut with_directory = with_dir.unwrap_or(Boolean::new(true)).to_bool(); - if path == "." { - with_directory = false; - } - - let mut arr = Array::with_capacity(entries.size_hint().1.unwrap_or(0)); - for entry in entries { - if with_directory { - match entry { - Ok(v) => { arr.push(RString::new_utf8(v.path().to_str().unwrap())); }, - _ => {} - }; - } else { - match entry { - Ok(v) => { arr.push(RString::new_utf8(v.file_name().to_str().unwrap())); }, - _ => {} - }; - } - } - - Ok(arr.to_any_object()) - } else { - let msg = format!("No such file or directory @ dir_initialize - {}", path); - Err(Exception::new("Errno::NOENT", Some(&msg))) +pub fn pn_del_trailing_separator(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + match (del_trailing_separator(RULES, path.bytes()), argument.string()) { + (Cow::Borrowed(result), Some(string)) if result.len() == path.bytes().len() => Ok(string.to_any_object()), + (result, _) => Ok(path.to_ruby(&result).into()), } } -pub fn pn_children_compat(pth: MaybeString, with_dir: MaybeBoolean) -> Result { - let path = to_str(&pth); - - if let Ok(entries) = fs::read_dir(path) { - let mut with_directory = with_dir.unwrap_or(Boolean::new(true)).to_bool(); - if path == "." { - with_directory = false; - } - - let mut arr = Array::with_capacity(entries.size_hint().1.unwrap_or(0)); - for entry in entries { - if with_directory { - if let Ok(v) = entry { - arr.push(Pathname::new(v.path().to_str().unwrap())); - }; - } else { - if let Ok(v) = entry { - arr.push(Pathname::new(v.file_name().to_str().unwrap())); - }; - } - } - - Ok(arr.to_any_object()) - } else { - let msg = format!("No such file or directory @ dir_initialize - {}", path); - Err(Exception::new("Errno::NOENT", Some(&msg))) - } +pub fn pn_is_directory(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + // Windows takes "dir\\..." for "dir"; Ruby doesn't. + let is_directory = !RULES.has_dots_name(path.bytes()) && + path.os_path().map_or(false, |os_path| os_path.is_dir()); + Ok(Boolean::new(is_directory).into()) } -pub fn pn_chop_basename(pth: MaybeString) -> AnyObject { - match chop_basename::chop_basename(to_str(&pth)) { - Some((dirname, basename)) => { - let mut arr = Array::with_capacity(2); - arr.push(RString::new_utf8(&dirname)); - arr.push(RString::new_utf8(&basename)); - arr.to_any_object() - }, - None => NilClass::new().to_any_object() - } +pub fn pn_dirname(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(path.to_ruby(&dirname::dirname(RULES, path.bytes())).into()) } -// pub fn pn_cleanpath(pth: MaybeString){} - -pub fn pn_cleanpath_aggressive(pth: MaybeString) -> RString { - RString::new_utf8(&cleanpath_aggressive::cleanpath_aggressive(to_str(&pth))) +// Returns an array of `String`s +pub fn pn_entries(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let error_path = error_path(&argument, ""); + entries(&path, &error_path, |entry| Ok(entry.into())) } -pub fn pn_cleanpath_conservative(pth: MaybeString) -> RString { - RString::new_utf8(&cleanpath_conservative::cleanpath_conservative(to_str(&pth))) +// Returns an array of `Pathname`s +pub fn pn_entries_compat(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + let error_path = error_path(&argument, ""); + let pathname = pathname_class()?; + entries(&path, &error_path, |entry| new_pathname(&pathname, entry)) } -pub fn pn_del_trailing_separator(pth: MaybeString) -> RString { - { - let path = to_str(&pth); - if path.is_empty() { - return RString::new_utf8("/"); - } - let pos = match find_last_non_sep_pos(path.as_bytes()) { - Some(pos) => pos, - None => return RString::new_utf8("/"), - }; - if pos != path.len() - 1 { - return RString::new_utf8(&path[..pos + 1]); - } +fn entries(path: &PathString, error_path: &AnyObject, mut wrap: F) -> RubyResult + where F: FnMut(RString) -> RubyResult { + let files = read_dir(path, error_path)?; + let filesystem = EncodingId::filesystem(); + let mut array = Array::with_capacity(files.size_hint().0 + 2); + array.push(wrap(filesystem.new_string(b"."))?); + array.push(wrap(filesystem.new_string(b".."))?); + for file in files { + let file = file.map_err(|e| system_call_error(&e, error_path, "dir_read"))?; + let os_name = file.file_name(); + let (name, encoding) = file_name(&os_name, filesystem); + array.push(wrap(encoding.new_string(&name))?); } - pth.unwrap() + Ok(array.into()) } -// pub fn pn_descend(){} - -pub fn pn_is_directory(pth: MaybeString) -> Boolean { - Boolean::new(Path::new(to_str(&pth)).is_dir()) +pub fn pn_extname(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(path.to_ruby(extname::extname(RULES, path.bytes())).into()) } -pub fn pn_dirname(pth: MaybeString) -> RString { - RString::new_utf8(dirname::dirname(to_str(&pth))) +pub fn pn_has_trailing_separator(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + let path = argument.path()?; + Ok(Boolean::new(has_trailing_separator(RULES, path.bytes())).into()) } -// pub fn pn_each_child(){} - -// pub fn pn_each_filename(pth: MaybeString) -> NilClass { -// NilClass::new() -// } - -pub fn pn_entries(pth: MaybeString) -> Result { - let path = to_str(&pth); - if let Ok(files) = fs::read_dir(path) { - let mut arr = Array::with_capacity(files.size_hint().1.unwrap_or(0) + 2); - - arr.push(RString::new_utf8(".")); - arr.push(RString::new_utf8("..")); +pub fn pn_join(arguments: &[AnyObject]) -> RubyResult { + if arguments.is_empty() { + return Err(argument_error("wrong number of arguments (given 0, expected 1+)")); + } + let pathname = pathname_class()?; + + // Copy every path out of Ruby before calling more Ruby code: the strings + // returned by `to_path` or `to_s` aren't referenced from anywhere the + // garbage collector looks once we only hold their bytes. + let mut paths: Vec> = Vec::with_capacity(arguments.len()); + let mut encoding: Option = None; + for argument in arguments { + let string = path_like_to_string(argument, &pathname)?; + let path = PathString::new(&string)?; + encoding = Some(match encoding { + None => EncodingOf::new(&path), + Some(previous) => previous.merge(&path)?, + }); + paths.push(path.bytes().to_vec()); + } - for file in files { - arr.push(RString::new_utf8(file.unwrap().file_name().to_str().unwrap())); + let mut parts = paths.iter().rev(); + let mut result: Cow<[u8]> = match parts.next() { + Some(last) => Cow::Borrowed(last), + None => Cow::Borrowed(b""), + }; + for part in parts { + if !is_relative(RULES, &result) { + break; } - - Ok(arr.to_any_object()) - } else { - let msg = format!("No such file or directory @ dir_initialize - {}", path); - Err(Exception::new("Errno::NOENT", Some(&msg))) + result = Cow::Owned(plus::plus_paths(RULES, part, &result).into_owned()); } + let encoding = match encoding { + Some(encoding) => encoding.encoding, + None => EncodingId::us_ascii(), + }; + new_pathname(&pathname, encoding.new_string(&result)) } -pub fn pn_entries_compat(pth: MaybeString) -> Result { - let path = to_str(&pth); - if let Ok(files) = fs::read_dir(path) { - let mut arr = Array::with_capacity(files.size_hint().1.unwrap_or(0) + 2); +pub fn pn_plus(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let argument1 = PathArgument::new(arguments, 0)?; + let path1 = argument1.path()?; + let argument2 = PathArgument::new(arguments, 1)?; + let path2 = argument2.path()?; + let encoding = EncodingOf::new(&path1).merge(&path2)?.encoding; + Ok(encoding.new_string(&plus::plus_paths(RULES, path1.bytes(), path2.bytes())).into()) +} - arr.push(Pathname::new(".")); - arr.push(Pathname::new("..")); +pub fn pn_is_relative(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 1)?; + let argument = PathArgument::new(arguments, 0)?; + if argument.is_missing() { + return Ok(Boolean::new(false).into()); + } + let path = argument.path()?; + Ok(Boolean::new(is_relative(RULES, path.bytes())).into()) +} - for file in files { - arr.push(Pathname::new(file.unwrap().file_name().to_str().unwrap())); +pub fn pn_relative_path_from(arguments: &[AnyObject]) -> RubyResult { + check_max_arguments(arguments, 2)?; + let pathname = pathname_class()?; + let argument = PathArgument::new(arguments, 0)?; + let dest = argument.path()?; + let base = match arguments.get(1) { + Some(base_directory) => { + let base_string = path_like_to_string(base_directory, &pathname)?; + // Copied right away, see `pn_join`. + PathString::new(&base_string)?.into_owned() } - - Ok(arr.to_any_object()) - } else { - let msg = format!("No such file or directory @ dir_initialize - {}", path); - Err(Exception::new("Errno::NOENT", Some(&msg))) + None => PathString::empty(), + }; + let encoding = EncodingOf::new(&dest).merge(&base)?.encoding; + let inspect = |bytes: &[u8]| crate::ruby::inspect(&encoding.new_string(bytes)); + + match relative_path_from::relative_path_from(RULES, dest.bytes(), base.bytes()) { + Ok(path) => new_pathname(&pathname, encoding.new_string(&path)), + Err(RelativePathError::DifferentPrefix(dest_prefix, base_directory)) => { + let message = format!("different prefix: {} and {}", inspect(&dest_prefix)?, inspect(&base_directory)?); + Err(argument_error(&message)) + }, + Err(RelativePathError::BaseDirectoryHasDotDot(base_directory)) => { + Err(argument_error(&format!("base_directory has ..: {}", inspect(&base_directory)?))) + }, } } -pub fn pn_extname(pth: MaybeString) -> RString { - RString::new_utf8(extname::extname(to_str(&pth))) +// The path to name in a `SystemCallError` +fn error_path(argument: &PathArgument, default: &str) -> AnyObject { + match argument.string() { + Some(string) => string.to_any_object(), + None => RString::new_utf8(default).into(), + } } -// pub fn pn_find(pth: MaybeString, ignore_error: Boolean){} - -pub fn pn_has_trailing_separator(pth: MaybeString) -> Boolean { - let v = to_str(&pth); - match chop_basename::chop_basename(v) { - Some((a,b)) => Boolean::new(a.len() + b.len() < v.len()), - _ => Boolean::new(false) - } +fn read_dir(path: &PathString, error_path: &AnyObject) -> Result { + let os_path = path.os_path().map_err(|e| system_call_error(&e, error_path, "dir_initialize"))?; + fs::read_dir(&*os_path).map_err(|e| system_call_error(&e, error_path, "dir_initialize")) } -pub fn pn_join(args: MaybeArray) -> AnyObject { - let paths = args.unwrap().into_iter().map(|arg| anyobject_to_string(arg).unwrap()).collect::>(); - let mut paths_iter = paths.iter().rev(); - let mut result = Cow::Borrowed(paths_iter.next().unwrap().as_str()); - for part in paths_iter { - result = plus::plus_paths(&part, result.as_ref()); - if result.as_bytes().first() == Some(&SEP) { - break; +// `SystemCallError.new(path, errno, func)` gives the same `Errno::*` error +// and message as Ruby's `Dir` methods, e.g. +// "No such file or directory @ dir_initialize - path". +fn system_call_error(err: &io::Error, path: &AnyObject, func: &str) -> AnyException { + #[cfg(unix)] + { + if let Some(errno) = err.raw_os_error() { + let arguments = [path.clone(), rutie::Integer::new(errno.into()).into(), RString::new_utf8(func).into()]; + if let Ok(exception) = Class::system_call_error().protect_send("new", &arguments) { + if let Ok(exception) = exception.try_convert_to::() { + return exception; + } + } } } - Pathname::new(&result).to_any_object() + #[cfg(not(unix))] + let _ = func; + let path = crate::ruby::inspect(path).unwrap_or_default(); + AnyException::from_io_error(err, &path) } -// pub fn pn_mkpath(pth: MaybeString) -> NilClass { -// NilClass::new() -// } - -// pub fn pn_is_mountpoint(pth: MaybeString){} - -// pub fn pn_parent(pth: MaybeString){} - -pub fn pn_plus(pth1: MaybeString, pth2: MaybeString) -> RString { - RString::new_utf8(&plus::plus_paths(to_str(&pth1), to_str(&pth2))) +// A file name from the OS as Ruby's `Dir` methods return it, in the +// filesystem encoding: its bytes, and their encoding. +#[cfg(unix)] +fn file_name(name: &OsStr, filesystem: EncodingId) -> (Cow<'_, [u8]>, EncodingId) { + use std::os::unix::ffi::OsStrExt; + (Cow::Borrowed(name.as_bytes()), filesystem) } -// pub fn pn_prepend_prefix(prefix: MaybeString, relpath: MaybeString){} - -pub fn pn_is_relative(pth: MaybeString) -> Boolean { - let path = match &pth { - &Ok(ref rutie_string) => rutie_string.to_str(), - &Err(_) => return Boolean::new(false), +#[cfg(not(unix))] +fn file_name(name: &OsStr, filesystem: EncodingId) -> (Cow<'_, [u8]>, EncodingId) { + let utf8 = match name.to_string_lossy() { + Cow::Borrowed(name) => Cow::Borrowed(name.as_bytes()), + Cow::Owned(name) => Cow::Owned(name.into_bytes()), }; - Boolean::new(path.as_bytes().get(0) != Some(&SEP)) + if utf8.is_ascii() || filesystem == EncodingId::utf8() { + return (utf8, filesystem); + } + match EncodingId::utf8().transcode(&utf8, filesystem) { + Some(encoded) => (Cow::Owned(encoded), filesystem), + None => (utf8, EncodingId::utf8()), + } } -// pub fn pn_root(pth: MaybeString){} - -// pub fn pn_split_names(pth: MaybeString){} - -pub fn pn_relative_path_from(itself: MaybeString, base_directory: MaybeString) -> Result { - relative_path_from::relative_path_from(itself, base_directory) +fn implicit_conversion_error(argument: Option<&AnyObject>) -> AnyException { + let name = match argument { + None => "nil".to_string(), + Some(argument) if argument.is_nil() => "nil".to_string(), + Some(argument) if argument.value().is_true() => "true".to_string(), + Some(argument) if argument.value().is_false() => "false".to_string(), + Some(argument) => argument.protect_send("class", &[]).ok(). + and_then(|class| crate::ruby::inspect(&class).ok()).unwrap_or_default(), + }; + error(&Class::type_error(), &format!("no implicit conversion of {} into String", name)) } - -// pub fn pn_rmtree(pth: MaybeString) -> NilClass { -// NilClass::new() -// } -// diff --git a/src/pathname_sys.rs b/src/pathname_sys.rs deleted file mode 100644 index 376618f..0000000 --- a/src/pathname_sys.rs +++ /dev/null @@ -1,24 +0,0 @@ -use rutie::{AnyObject, Array}; -use rutie::types::{Argc, Value}; -use rutie::util::str_to_cstring; -use rutie::rubysys::class; -use ::pathname; -use std::mem; - -// rutie doesn't support splat arguments yet natively -pub extern fn pub_join(argc: Argc, argv: *const AnyObject, _: AnyObject) -> AnyObject { - let args = Value::from(0); - - unsafe { - let p_argv: *const Value = mem::transmute(argv); - - class::rb_scan_args( - argc, - p_argv, - str_to_cstring("*").as_ptr(), - &args - ) - }; - - pathname::pn_join(Ok(Array::from(args))) -} diff --git a/src/plus.rs b/src/plus.rs index 227a650..7007982 100644 --- a/src/plus.rs +++ b/src/plus.rs @@ -1,102 +1,118 @@ use std::borrow::Cow; -use std::str; -use std::path::MAIN_SEPARATOR; -use chop_basename::chop_basename; -use path_parsing::SEP; +use crate::basename::basename; +use crate::chop_basename::chop_basename; +use crate::dirname::dirname; +use crate::path_parsing::Rules; -pub fn plus_paths<'a>(path1: &'a str, path2: &str) -> Cow<'a, str> { +// Pathname's `plus`, which `Pathname#+` and `Pathname#join` use. +pub fn plus_paths<'a>(rules: Rules, path1: &'a [u8], path2: &[u8]) -> Cow<'a, [u8]> { + // The names in path2, and where each starts let mut prefix2 = path2; let mut index_list2: Vec = vec![]; - let mut basename_list2: Vec<&str> = vec![]; - loop { - match chop_basename(prefix2) { - None => { break; } - Some((pfx2, basename2)) => { - prefix2 = pfx2; - index_list2.push(pfx2.len()); - basename_list2.push(basename2); - } - } + let mut basename_list2: Vec<&[u8]> = vec![]; + while let Some((prefix, basename)) = chop_basename(rules, prefix2) { + prefix2 = prefix; + index_list2.push(prefix.len()); + basename_list2.push(basename); } if !prefix2.is_empty() { - return path2.to_string().into(); - }; + return Cow::Owned(path2.to_vec()); + } + index_list2.reverse(); + basename_list2.reverse(); + // The names before `first` have been shifted off + let mut first = 0; - let result_prefix: Cow; let mut prefix1 = path1; loop { - let mut new_len = basename_list2.len() - count_trailing(".", &basename_list2); - index_list2.truncate(new_len); - basename_list2.truncate(new_len); - match chop_basename(prefix1) { - None => { - result_prefix = prefix1.into(); - break; - } - Some((pfx1, basename1)) => { - prefix1 = pfx1; - if basename1 == "." { continue; }; - if basename1 == ".." || basename_list2.last() != Some(&"..") { - result_prefix = [prefix1, basename1].concat().into(); - break; - } - } + while basename_list2.get(first) == Some(&&b"."[..]) { + first += 1; + } + let (prefix, basename1) = match chop_basename(rules, prefix1) { + Some(chopped) => chopped, + None => break, + }; + prefix1 = prefix; + if basename1 == b"." { + continue; } - if new_len > 0 { - new_len -= 1; - index_list2.truncate(new_len); - basename_list2.truncate(new_len); + if basename1 == b".." || basename_list2.get(first) != Some(&&b".."[..]) { + // `prefix1 + basename1`; the basename follows its prefix + prefix1 = &path1[..prefix1.len() + basename1.len()]; + break; } + first += 1; } - if !result_prefix.is_empty() && result_prefix.as_bytes().iter().cloned().all(|b| b == SEP) { - let new_len = basename_list2.len() - count_trailing("..", &basename_list2); - index_list2.truncate(new_len); - basename_list2.truncate(new_len); - } - if let Some(last_index2) = index_list2.last() { - let suffix = &path2[*last_index2..]; - match (result_prefix.as_bytes().last(), suffix.as_bytes().first()) { - (Some(&SEP), Some(&SEP)) => [&result_prefix, &suffix[1..]].concat().into(), - (Some(&SEP), Some(_)) | (Some(_), Some(&SEP)) => [&result_prefix, suffix].concat().into(), - (None, Some(_)) => suffix.to_string().into(), - _ => format!("{}{}{}", result_prefix.as_ref(), MAIN_SEPARATOR, suffix).into(), + let mut prefix1_has_name = chop_basename(rules, prefix1).is_some(); + if !prefix1_has_name && rules.contains_sep(basename(rules, prefix1, b"")) { + // Nothing goes above the root + prefix1_has_name = true; + while basename_list2.get(first) == Some(&&b".."[..]) { + first += 1; } - } else { - if result_prefix.is_empty() { - ".".into() + } + if first < basename_list2.len() { + let suffix2 = &path2[index_list2[first]..]; + if prefix1_has_name { + Cow::Owned(rules.join(prefix1, suffix2)) } else { - result_prefix + Cow::Owned([prefix1, suffix2].concat()) } + } else if prefix1_has_name { + Cow::Borrowed(prefix1) + } else { + dirname(rules, prefix1) } } -#[inline(always)] -fn count_trailing(x: &str, xs: &Vec<&str>) -> usize { - xs.iter().rev().take_while(|&c| c == &x).count() +#[cfg(test)] +fn plus_str(path1: &str, path2: &str) -> String { + String::from_utf8(plus_paths(Rules::UNIX, path1.as_bytes(), path2.as_bytes()).into_owned()).unwrap() +} + +#[cfg(test)] +fn windows_plus(path1: &str, path2: &str) -> String { + String::from_utf8(plus_paths(Rules::WINDOWS, path1.as_bytes(), path2.as_bytes()).into_owned()).unwrap() } #[test] fn it_will_plus_same_as_ruby() { - assert_eq!("/" , plus_paths("/" , "/")); - assert_eq!("a/b" , plus_paths("a" , "b")); - assert_eq!("a" , plus_paths("a" , ".")); - assert_eq!("b" , plus_paths("." , "b")); - assert_eq!("." , plus_paths("." , ".")); - assert_eq!("/b" , plus_paths("a" , "/b")); + assert_eq!("/" , plus_str("/" , "/")); + assert_eq!("a/b" , plus_str("a" , "b")); + assert_eq!("a" , plus_str("a" , ".")); + assert_eq!("b" , plus_str("." , "b")); + assert_eq!("." , plus_str("." , ".")); + assert_eq!("/b" , plus_str("a" , "/b")); - assert_eq!("/" , plus_paths("/" , "..")); - assert_eq!("////" , plus_paths("////", "")); - assert_eq!("." , plus_paths("a" , "..")); - assert_eq!("a" , plus_paths("a/b", "..")); - assert_eq!("../.." , plus_paths(".." , "..")); - assert_eq!("/c" , plus_paths("/" , "../c")); - assert_eq!("c" , plus_paths("a" , "../c")); - assert_eq!("a/c" , plus_paths("a/b", "../c")); - assert_eq!("../../c", plus_paths(".." , "../c")); + assert_eq!("/" , plus_str("/" , "..")); + assert_eq!("////" , plus_str("////", "")); + assert_eq!("." , plus_str("a" , "..")); + assert_eq!("a" , plus_str("a/b", "..")); + assert_eq!("../.." , plus_str(".." , "..")); + assert_eq!("/c" , plus_str("/" , "../c")); + assert_eq!("c" , plus_str("a" , "../c")); + assert_eq!("a/c" , plus_str("a/b", "../c")); + assert_eq!("../../c", plus_str(".." , "../c")); - assert_eq!("a//b/d//e", plus_paths("a//b/c", "../d//e")); + assert_eq!("a//b/d//e", plus_str("a//b/c", "../d//e")); - assert_eq!("//foo/var/bar", plus_paths("//foo/var", "bar")); + assert_eq!("//foo/var/bar", plus_str("//foo/var", "bar")); +} + +#[test] +fn it_will_plus_same_as_ruby_on_windows() { + assert_eq!("a/b", windows_plus("a", "b")); + assert_eq!("a/b", windows_plus("a\\", "b")); + assert_eq!("/b", windows_plus("a", "/b")); + assert_eq!("\\b", windows_plus("a", "\\b")); + assert_eq!("C:/b", windows_plus("a", "C:/b")); + assert_eq!("C:/a/b", windows_plus("C:/a", "b")); + assert_eq!("C:/b", windows_plus("C:/a", "../b")); + assert_eq!("C:/b", windows_plus("C:/", "../b")); + assert_eq!("C:\\a/b", windows_plus("C:\\a", "b")); + assert_eq!("a", windows_plus("a\\b", "..")); + assert_eq!("//a/b/c", windows_plus("//a/b", "c")); + assert_eq!("//a/b/c", windows_plus("//a/b", "../c")); } diff --git a/src/prepend_prefix.rs b/src/prepend_prefix.rs index fadb54b..6636483 100644 --- a/src/prepend_prefix.rs +++ b/src/prepend_prefix.rs @@ -1,19 +1,17 @@ use std::borrow::Cow; -use dirname::dirname; -use path_parsing::{SEP, contains_sep}; -use std::path::MAIN_SEPARATOR; -pub fn prepend_prefix<'a>(prefix: &'a str, relpath: &str) -> Cow<'a, str> { +use crate::cleanpath_conservative::add_trailing_separator; +use crate::dirname::dirname; +use crate::path_parsing::Rules; + +// Pathname's `prepend_prefix` +pub fn prepend_prefix<'a>(rules: Rules, prefix: &'a [u8], relpath: &[u8]) -> Cow<'a, [u8]> { if relpath.is_empty() { - dirname(prefix).into() - } else if contains_sep(prefix.as_bytes()) { - let prefix_dirname = dirname(prefix); - match prefix_dirname.as_bytes().last() { - None => relpath.to_string().into(), - Some(&SEP) => format!("{}{}", prefix_dirname, relpath).into(), - _ => format!("{}{}{}", prefix_dirname, MAIN_SEPARATOR, relpath).into() - } + dirname(rules, prefix) + } else if rules.contains_sep(prefix) { + let prefix = add_trailing_separator(rules, dirname(rules, prefix)); + Cow::Owned([&prefix[..], relpath].concat()) } else { - format!("{}{}", prefix, relpath).into() + Cow::Owned([prefix, relpath].concat()) } } diff --git a/src/relative_path_from.rs b/src/relative_path_from.rs index 5b11ab9..2b40c96 100644 --- a/src/relative_path_from.rs +++ b/src/relative_path_from.rs @@ -1,70 +1,94 @@ -use std::iter; -use helpers::{is_same_path, to_str}; -use path_parsing::SEP_STR; -use cleanpath_aggressive::cleanpath_aggressive; -use chop_basename::chop_basename; -use pathname::Pathname; -use rutie; -use rutie::{Exception as Exc, AnyException as Exception}; +use crate::chop_basename::chop_basename; +use crate::cleanpath_aggressive::cleanpath_aggressive; +use crate::path_parsing::{Rules, SEP_BYTES}; -type MaybeString = Result; +#[derive(Debug, PartialEq)] +pub enum RelativePathError { + // Holds the destination prefix and the cleaned base directory. + DifferentPrefix(Vec, Vec), + // Holds the cleaned base directory. + BaseDirectoryHasDotDot(Vec), +} -pub fn relative_path_from(itself: MaybeString, base_directory: MaybeString) -> Result { - let dest_directory = cleanpath_aggressive(to_str(&itself)); - let base_directory = cleanpath_aggressive(to_str(&base_directory)); +// Pathname's `relative_path_from` +pub fn relative_path_from(rules: Rules, dest: &[u8], base: &[u8]) -> Result, RelativePathError> { + let dest_directory = cleanpath_aggressive(rules, dest); + let base_directory = cleanpath_aggressive(rules, base); - let (dest_prefix, mut dest_names) = to_names(dest_directory.as_ref()); - let (base_prefix, mut base_names) = to_names(base_directory.as_ref()); + let (dest_prefix, mut dest_names) = to_names(rules, &dest_directory); + let (base_prefix, mut base_names) = to_names(rules, &base_directory); - if !is_same_path(&dest_prefix, &base_prefix) { - return Err( - Exception::new( - "ArgumentError", - Some(&format!("different prefix: {} and {}", dest_prefix, base_prefix)), - ) - ); + if !rules.same_path(dest_prefix, base_prefix) { + return Err(RelativePathError::DifferentPrefix(dest_prefix.to_vec(), base_directory.to_vec())); } - // Remove shared tail + // Remove the shared leading names (stored last, as the names are collected in reverse) { let num_same = dest_names.iter().rev().zip(base_names.iter().rev()). - take_while(|&(dest, base)| dest == base).count(); - let num_dest_names = dest_names.len(); - dest_names.truncate(num_dest_names - num_same); - let num_base_names = base_names.len(); - base_names.truncate(num_base_names - num_same); + take_while(|&(dest, base)| rules.same_path(dest, base)).count(); + dest_names.truncate(dest_names.len() - num_same); + base_names.truncate(base_names.len() - num_same); }; - if base_names.contains(&"..") { - return Err( - Exception::new( - "ArgumentError", - Some(&format!("base_directory has ..: {}", base_directory)), - ) - ); + if base_names.contains(&&b".."[..]) { + return Err(RelativePathError::BaseDirectoryHasDotDot(base_directory.to_vec())); } if base_names.is_empty() && dest_names.is_empty() { - Ok(Pathname::new(".")) + Ok(b".".to_vec()) } else { - Ok(Pathname::new(&iter::repeat("..").take(base_names.len()).chain(dest_names.into_iter().rev()). - collect::>().join(&SEP_STR))) + let names: Vec<&[u8]> = std::iter::repeat(&b".."[..]).take(base_names.len()). + chain(dest_names.into_iter().rev()).collect(); + Ok(names.join(SEP_BYTES)) } } #[inline(always)] -fn to_names(path: &str) -> (&str, Vec<&str>) { - let mut result: Vec<&str> = vec![]; +fn to_names(rules: Rules, path: &[u8]) -> (&[u8], Vec<&[u8]>) { + let mut result: Vec<&[u8]> = vec![]; let mut prefix = path; - loop { - match chop_basename(&prefix) { - Some((ref dest, ref basename)) => { - prefix = dest; - if basename != &"." { - result.push(basename); - } - } - None => return (prefix, result), + while let Some((dest, basename)) = chop_basename(rules, prefix) { + prefix = dest; + if basename != b"." { + result.push(basename); } } + (prefix, result) +} + +#[cfg(test)] +fn relative_str(rules: Rules, dest: &str, base: &str) -> Option { + relative_path_from(rules, dest.as_bytes(), base.as_bytes()).ok().map(|path| String::from_utf8(path).unwrap()) +} + +#[test] +fn it_finds_relative_paths() { + let u = Rules::UNIX; + assert_eq!(relative_str(u, "a", "b").unwrap(), "../a"); + assert_eq!(relative_str(u, "/a/b/c/d", "/a/b").unwrap(), "c/d"); + assert_eq!(relative_str(u, "/a/b", "/a/b/c/d").unwrap(), "../.."); + assert_eq!(relative_str(u, ".", ".").unwrap(), "."); + assert_eq!(relative_str(u, "a/b/c", "a/d").unwrap(), "../b/c"); +} + +#[test] +fn it_rejects_incompatible_paths() { + assert_eq!( + relative_path_from(Rules::UNIX, b"/", b"."), + Err(RelativePathError::DifferentPrefix(b"/".to_vec(), b".".to_vec())) + ); + assert_eq!( + relative_path_from(Rules::UNIX, b"a", b".."), + Err(RelativePathError::BaseDirectoryHasDotDot(b"..".to_vec())) + ); +} + +#[test] +fn it_finds_relative_paths_on_windows() { + let w = Rules::WINDOWS; + assert_eq!(relative_str(w, "C:\\a\\b", "c:/a").unwrap(), "b"); + assert_eq!(relative_str(w, "C:/A/b", "c:/a/c").unwrap(), "../b"); + assert_eq!(relative_str(w, "//a/b/c/d", "//a/b/c").unwrap(), "d"); + assert_eq!(relative_str(w, "C:/a", "D:/a"), None); + assert_eq!(relative_str(Rules::UNIX, "a/B", "a/b").unwrap(), "../B"); } diff --git a/src/ruby.rs b/src/ruby.rs new file mode 100644 index 0000000..b6f467b --- /dev/null +++ b/src/ruby.rs @@ -0,0 +1,530 @@ +// Glue between Ruby and the path functions, written so that nothing Ruby +// passes in can crash the interpreter: +// +// * Panics never unwind into Ruby; they are caught and raised as `RuntimeError`. +// * Ruby exceptions are only raised once every Rust value owning memory has +// been dropped, since raising `longjmp`s past Rust frames without running +// their destructors. +// * Ruby code (`to_path`, `to_s`, ...) is only called through `protect_send`, +// so an exception it raises comes back as an `Err` instead of jumping over +// Rust frames. +// * Strings are read as bytes in their own encoding, never assumed to be UTF-8. +use std::any::Any; +use std::borrow::Cow; +use std::io; +use std::path::Path; +use std::sync::atomic::{AtomicI32, AtomicU8, Ordering}; +use std::thread; + +use rutie::rubysys; +use rutie::types::{c_char, c_long, Argc, EncodingIndex, Value, ValueType}; +use rutie::{AnyException, AnyObject, Class, Encoding, NilClass, Object, RString, Symbol, VM}; + +use crate::path_parsing::Rules; + +pub type RubyResult = Result; + +// Defines Ruby methods taking any number of arguments (arity -1). The body +// sees the arguments as `&[AnyObject]` and returns a `RubyResult`; an `Err` +// is raised in Ruby. +macro_rules! ruby_methods { + ($($(#[$attribute:meta])* fn $name:ident($arguments:ident) $body:block)*) => {$( + rutie::rutie_callback! { + $(#[$attribute])* + pub fn $name(argc: rutie::types::Argc, argv: *const rutie::AnyObject, _itself: rutie::AnyObject) -> rutie::AnyObject { + // Safety: Ruby passes `argc` arguments in `argv`, alive for the whole call. + let $arguments = unsafe { $crate::ruby::arguments(argc, argv) }; + $crate::ruby::finish(std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || -> $crate::ruby::RubyResult { $body } + ))) + } + } + )*}; +} + +/// Borrows the arguments of a method call. +/// +/// # Safety +/// +/// `argv` must point to `argc` Ruby values that stay alive for `'a`, as the +/// arguments of a method call do while the method runs. +pub unsafe fn arguments<'a>(argc: Argc, argv: *const AnyObject) -> &'a [AnyObject] { + if argc <= 0 || argv.is_null() { + &[] + } else { + // Safety: guaranteed by the caller. + unsafe { std::slice::from_raw_parts(argv, argc as usize) } + } +} + +/// Returns the method's result to Ruby, or raises its error. +/// +/// Raising does not return, so it happens here, after the method body and +/// everything it allocated have been dropped. +pub fn finish(result: thread::Result) -> AnyObject { + let exception = match result { + Ok(Ok(value)) => return value, + Ok(Err(exception)) => exception, + Err(payload) => panic_exception(payload), + }; + VM::raise_ex(exception); + NilClass::new().into() +} + +fn panic_exception(payload: Box) -> AnyException { + let message = if let Some(message) = payload.downcast_ref::<&str>() { + message.to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "unknown error".to_string() + }; + error(&Class::runtime_error(), &format!("faster_path internal error: {}", message)) +} + +pub fn error(class: &Class, message: &str) -> AnyException { + AnyException::from_class(class, message) +} + +pub fn argument_error(message: &str) -> AnyException { + error(&Class::argument_error(), message) +} + +/// Raises `ArgumentError` unless there are at most `max` arguments. +pub fn check_max_arguments(arguments: &[AnyObject], max: usize) -> Result<(), AnyException> { + if arguments.len() > max { + let expected = if max == 0 { "0".to_string() } else { format!("0..{}", max) }; + Err(argument_error(&format!( + "wrong number of arguments (given {}, expected {})", + arguments.len(), + expected + ))) + } else { + Ok(()) + } +} + +/// Truthiness of an optional argument. +pub fn truthy_argument(arguments: &[AnyObject], index: usize, default: bool) -> bool { + match arguments.get(index) { + Some(argument) => !(argument.is_nil() || argument.value().is_false()), + None => default, + } +} + +/// A path argument. +pub enum PathArgument { + /// Missing, `nil`, or neither a `String` nor something with `to_path`. + /// The methods have always treated these as an empty path. + Missing, + /// A `String` argument. + String(RString), + /// The `String` an argument's `to_path` returned. + Converted(RString), +} + +impl PathArgument { + /// Reads the argument at `index` like Ruby's `File` methods do: a `String`, + /// or an object with `to_path`. + pub fn new(arguments: &[AnyObject], index: usize) -> Result { + let argument = match arguments.get(index) { + Some(argument) => argument, + None => return Ok(PathArgument::Missing), + }; + if let Ok(string) = argument.try_convert_to::() { + return Ok(PathArgument::String(string)); + } + if argument.is_nil() { + return Ok(PathArgument::Missing); + } + let responds_to_path = argument.protect_send("respond_to?", &[Symbol::new("to_path").into()])?; + if !responds_to_path.value().is_true() { + return Ok(PathArgument::Missing); + } + let path = argument.protect_send("to_path", &[])?; + Ok(PathArgument::Converted(expect_string(path, argument, "to_path")?)) + } + + pub fn is_missing(&self) -> bool { + matches!(self, PathArgument::Missing) + } + + /// The path, or `default` if the argument is missing. + pub fn path_or(&self, default: &'static [u8]) -> Result, AnyException> { + match self { + PathArgument::Missing => Ok(PathString::literal(default)), + PathArgument::String(string) => PathString::new(string), + // Nothing but this value refers to the string `to_path` returned, and + // the garbage collector may not see it, so keep a copy. + PathArgument::Converted(string) => PathString::new(string).map(PathString::into_owned), + } + } + + pub fn path(&self) -> Result, AnyException> { + self.path_or(b"") + } + + /// The `String`, for error messages or to return unchanged. + pub fn string(&self) -> Option<&RString> { + match self { + PathArgument::Missing => None, + PathArgument::String(string) | PathArgument::Converted(string) => Some(string), + } + } +} + +/// A path read from a Ruby `String`: its bytes and its encoding. +pub struct PathString<'a> { + original: Cow<'a, [u8]>, + // On Windows, a copy in which each `\` that is part of a multibyte + // character is a NUL instead, so it isn't taken for a separator; see + // `EncodingId::mask_backslashes`. + masked: Option>, + pub encoding: EncodingId, +} + +impl<'a> PathString<'a> { + /// An empty path, used where a `String` argument is missing. + pub fn empty() -> Self { + PathString::literal(b"") + } + + pub fn literal(bytes: &'static [u8]) -> Self { + PathString { original: Cow::Borrowed(bytes), masked: None, encoding: EncodingId::us_ascii() } + } + + /// Reads `string`, which must be kept alive (and unmodified) while the + /// result is in use; a method argument is. + /// + /// Like Ruby's own path methods this rejects strings that are not + /// ASCII-compatible (such as UTF-16), since those can't be split on `/`, + /// and strings containing a null byte. + pub fn new(string: &'a RString) -> Result { + let encoding = EncodingId::of(string); + if !encoding.is_ascii_compatible() { + return Err(error( + &Class::encoding_compatibility_error(), + &format!("path name must be ASCII-compatible ({}): {}", encoding.name(), inspect(string)?), + )); + } + // This returns the bytes of a `String`; `RString` is always one. + let bytes = string.to_bytes_unchecked(); + if bytes.contains(&0) { + return Err(argument_error("path name contains null byte")); + } + let masked = if Rules::NATIVE.is_dosish() { encoding.mask_backslashes(bytes) } else { None }; + Ok(PathString { original: Cow::Borrowed(bytes), masked, encoding }) + } + + pub fn into_owned(self) -> PathString<'static> { + PathString { original: Cow::Owned(self.original.into_owned()), masked: self.masked, encoding: self.encoding } + } + + /// The bytes the path functions work on. + pub fn bytes(&self) -> &[u8] { + self.masked.as_deref().unwrap_or(&self.original) + } + + /// A new Ruby string with `bytes` in this path's encoding. + pub fn to_ruby(&self, bytes: &[u8]) -> RString { + self.encoding.new_string(bytes) + } + + /// Whether `pos` starts a character of `bytes`, which must start at a + /// character of this path's encoding. + pub fn is_char_boundary(&self, bytes: &[u8], pos: usize) -> bool { + if pos == 0 || pos >= bytes.len() { + return true; + } + if self.encoding == EncodingId::utf8() { + // Not a continuation byte + return (bytes[pos] & 0xC0) != 0x80; + } + if self.encoding.is_single_byte_or_utf8() { + return true; + } + // Other encodings: walk the characters from the start like Ruby does. + let bytes = unmask(bytes); + let mut index = 0; + while index < pos { + index += self.encoding.char_len(&bytes, index); + } + index == pos + } + + /// The path to give the OS. + #[cfg(unix)] + pub fn os_path(&self) -> io::Result> { + use std::os::unix::ffi::OsStrExt; + Ok(Cow::Borrowed(Path::new(std::ffi::OsStr::from_bytes(&self.original)))) + } + + /// The path to give the OS, which takes Unicode on Windows. + #[cfg(not(unix))] + pub fn os_path(&self) -> io::Result> { + let invalid = || io::Error::new(io::ErrorKind::InvalidInput, "path name can't be converted to UTF-8"); + if self.encoding == EncodingId::utf8() || self.original.is_ascii() { + return std::str::from_utf8(&self.original).map(|path| Cow::Borrowed(Path::new(path))).map_err(|_| invalid()); + } + let utf8 = self.encoding.transcode(&self.original, EncodingId::utf8()).ok_or_else(invalid)?; + String::from_utf8(utf8).map(|path| Cow::Owned(path.into())).map_err(|_| invalid()) + } +} + +// Puts back the `\`s `EncodingId::mask_backslashes` replaced. NUL is never +// in a path otherwise. +fn unmask(bytes: &[u8]) -> Cow<'_, [u8]> { + if Rules::NATIVE.is_dosish() && memchr::memchr(0, bytes).is_some() { + Cow::Owned(bytes.iter().map(|&c| if c == 0 { b'\\' } else { c }).collect()) + } else { + Cow::Borrowed(bytes) + } +} + +/// An encoding, by its index in Ruby's table of encodings. +/// +/// `Encoding` objects compare with `Encoding#==` and answer +/// `Encoding#ascii_compatible?` through Ruby method calls, which cost more +/// than most path operations; this compares indexes, and remembers whether +/// an encoding is ASCII-compatible (which never changes). +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct EncodingId(EncodingIndex); + +static UTF8_INDEX: AtomicI32 = AtomicI32::new(-1); +static US_ASCII_INDEX: AtomicI32 = AtomicI32::new(-1); +static ASCII_8BIT_INDEX: AtomicI32 = AtomicI32::new(-1); + +const UNKNOWN: u8 = 0; +const ASCII_COMPATIBLE: u8 = 1; +const NOT_ASCII_COMPATIBLE: u8 = 2; +#[allow(clippy::declare_interior_mutable_const)] +const UNKNOWN_SLOT: AtomicU8 = AtomicU8::new(UNKNOWN); +static ASCII_COMPATIBILITY: [AtomicU8; 128] = [UNKNOWN_SLOT; 128]; + +impl EncodingId { + /// The encoding of a `String`. + pub fn of(string: &RString) -> Self { + // Safety: `string` is a `String`. + EncodingId(unsafe { rubysys::encoding::rb_enc_get_index(string.value()) }) + } + + pub fn from_encoding(encoding: &Encoding) -> Self { + EncodingId(encoding.index()) + } + + pub fn filesystem() -> Self { + EncodingId::from_encoding(&Encoding::filesystem()) + } + + pub fn utf8() -> Self { + EncodingId::known(&UTF8_INDEX, Encoding::utf8) + } + + pub fn us_ascii() -> Self { + EncodingId::known(&US_ASCII_INDEX, Encoding::us_ascii) + } + + pub fn ascii_8bit() -> Self { + EncodingId::known(&ASCII_8BIT_INDEX, Encoding::ascii_8bit) + } + + fn known(cache: &AtomicI32, encoding: fn() -> Encoding) -> Self { + let index = cache.load(Ordering::Relaxed); + if index >= 0 { + return EncodingId(index); + } + let index = encoding().index(); + cache.store(index, Ordering::Relaxed); + EncodingId(index) + } + + /// The `Encoding` object. + pub fn to_encoding(self) -> Encoding { + // Safety: the index is valid (see `is_char_boundary`). + Encoding::from(unsafe { + rubysys::encoding::rb_enc_from_encoding(rubysys::encoding::rb_enc_from_index(self.0)) + }) + } + + pub fn name(self) -> String { + self.to_encoding().name() + } + + pub fn is_ascii_compatible(self) -> bool { + let slot = usize::try_from(self.0).ok().and_then(|index| ASCII_COMPATIBILITY.get(index)); + match slot.map(|slot| slot.load(Ordering::Relaxed)) { + Some(ASCII_COMPATIBLE) => true, + Some(NOT_ASCII_COMPATIBLE) => false, + _ => { + let compatible = self.to_encoding().is_ascii_compatible(); + if let Some(slot) = slot { + slot.store(if compatible { ASCII_COMPATIBLE } else { NOT_ASCII_COMPATIBLE }, Ordering::Relaxed); + } + compatible + } + } + } + + // UTF-8, or an encoding of single bytes where every character starts a + // character. + fn is_single_byte_or_utf8(self) -> bool { + self == EncodingId::utf8() || self == EncodingId::us_ascii() || self == EncodingId::ascii_8bit() + } + + // The length of the character at `start` (`rb_enc_mbclen`), at least 1. + fn char_len(self, bytes: &[u8], start: usize) -> usize { + // Safety: the index is valid (see `is_char_boundary`), and both + // pointers are within `bytes`, `end` one past its last byte. + let length = unsafe { + rubysys::encoding::rb_enc_mbclen( + bytes[start..].as_ptr() as *const c_char, + bytes.as_ptr_range().end as *const c_char, + rubysys::encoding::rb_enc_from_index(self.0), + ) + }; + (length.max(1) as usize).min(bytes.len() - start) + } + + /// In encodings such as Shift_JIS, `\` (0x5C) can be the second byte of a + /// character. Windows Ruby only takes a `\` that starts a character for a + /// separator; this returns a copy of `bytes` with the others replaced by + /// NUL (which a path never contains), or `None` if there are none. + pub fn mask_backslashes(self, bytes: &[u8]) -> Option> { + if self.is_single_byte_or_utf8() || !bytes.contains(&b'\\') { + return None; + } + let mut masked: Option> = None; + let mut index = 0; + while index < bytes.len() { + let length = self.char_len(bytes, index); + for trailing in index + 1..index + length { + if bytes[trailing] == b'\\' { + masked.get_or_insert_with(|| bytes.to_vec())[trailing] = 0; + } + } + index += length; + } + masked + } + + /// `bytes`, in this encoding, converted to the encoding `to` + /// (`String#encode`), or `None` if they can't be. + #[cfg_attr(unix, allow(dead_code))] + pub fn transcode(self, bytes: &[u8], to: EncodingId) -> Option> { + let string = self.new_string(bytes); + let encoded = string.protect_send("encode", &[to.to_encoding().into()]).ok()?; + // Copied before anything else can run the garbage collector + let encoded = encoded.try_convert_to::().ok()?; + Some(encoded.to_bytes_unchecked().to_vec()) + } + + /// A new `String` of `bytes` in this encoding. + pub fn new_string(self, bytes: &[u8]) -> RString { + let bytes = unmask(bytes); + // Safety: `bytes` is valid for its length, which Ruby copies, and the + // index is valid (see `is_char_boundary`). + RString::from(unsafe { + rubysys::string::rb_enc_str_new( + bytes.as_ptr() as *const c_char, + bytes.len() as c_long, + rubysys::encoding::rb_enc_from_index(self.0), + ) + }) + } +} + +/// The encoding of a string built from parts of other strings, and whether +/// all of those were ASCII only. +pub struct EncodingOf { + pub encoding: EncodingId, + ascii_only: bool, +} + +impl EncodingOf { + pub fn new(path: &PathString) -> Self { + EncodingOf::of_bytes(path.bytes(), path.encoding) + } + + pub fn of_bytes(bytes: &[u8], encoding: EncodingId) -> Self { + EncodingOf { encoding, ascii_only: bytes.is_ascii() } + } + + pub fn merge(self, path: &PathString) -> Result { + self.merge_with(EncodingOf::new(path)) + } + + /// Ruby's rules for the encoding of two concatenated strings. + pub fn merge_with(self, other: EncodingOf) -> Result { + if self.encoding == other.encoding || other.ascii_only { + Ok(EncodingOf { ascii_only: self.ascii_only && other.ascii_only, ..self }) + } else if self.ascii_only { + Ok(other) + } else { + Err(error( + &Class::encoding_compatibility_error(), + &format!("incompatible character encodings: {} and {}", self.encoding.name(), other.encoding.name()), + )) + } + } +} + +/// `object.inspect` +pub fn inspect(object: &T) -> Result { + let inspected = object.protect_send("inspect", &[])?; + Ok(match inspected.try_convert_to::() { + Ok(string) => String::from_utf8_lossy(string.to_bytes_unchecked()).into_owned(), + Err(_) => String::new(), + }) +} + +/// Converts a path-like object to a `String` the way `FasterPath.join` and +/// `FasterPath.relative_path_from` always have: `String`s as they are, +/// `Pathname`s by their path, then `to_path`, then `to_s`. +pub fn path_like_to_string(object: &AnyObject, pathname_class: &Class) -> Result { + if object.ty() == ValueType::RString { + return object.try_convert_to::(); + } + if object.is_kind_of(pathname_class) { + let path = object.protect_send("to_s", &[])?; + return expect_string(path, object, "to_s"); + } + let responds_to_path = object.protect_send("respond_to?", &[Symbol::new("to_path").into()])?; + let method = if responds_to_path.value().is_true() { "to_path" } else { "to_s" }; + let path = object.protect_send(method, &[])?; + expect_string(path, object, method) +} + +fn expect_string(result: AnyObject, object: &AnyObject, method: &str) -> Result { + result.try_convert_to::().map_err(|_| { + let class_name = object.protect_send("class", &[]).ok(). + and_then(|class| inspect(&class).ok()).unwrap_or_default(); + error( + &Class::type_error(), + &format!("can't convert {} to String ({}#{} gives {})", class_name, class_name, method, result_class(&result)), + ) + }) +} + +fn result_class(result: &AnyObject) -> String { + result.protect_send("class", &[]).ok().and_then(|class| inspect(&class).ok()).unwrap_or_default() +} + +pub fn pathname_class() -> Result { + Class::from_path("Pathname") +} + +extern "C" { + fn rb_obj_alloc(klass: Value) -> Value; +} + +/// A `Pathname` of `path`, a new `String` without null bytes. +/// +/// This sets `@path` like `Pathname#initialize` does instead of calling +/// `Pathname.new`, which is much faster and runs no Ruby code. +pub fn new_pathname(pathname_class: &Class, path: RString) -> RubyResult { + // Safety: `pathname_class` is a class (`Class::from_path` checks), and + // `Pathname` uses the default allocator. + let mut pathname = AnyObject::from(unsafe { rb_obj_alloc(pathname_class.value()) }); + pathname.instance_variable_set("@path", path); + Ok(pathname) +} diff --git a/test/add_trailing_separator_test.rb b/test/add_trailing_separator_test.rb index e95d67a..c89f517 100644 --- a/test/add_trailing_separator_test.rb +++ b/test/add_trailing_separator_test.rb @@ -23,12 +23,13 @@ def test_add_trailing_separator def test_add_trailing_separator_in_dosish_context if File.dirname('A:') == 'A:.' - assert_equal FasterPath.add_trailing_separator('A:'), 'A:/' - assert_equal FasterPath.add_trailing_separator('A:/'), 'A:/' - assert_equal FasterPath.add_trailing_separator('A://'), 'A://' - assert_equal FasterPath.add_trailing_separator('A:.'), 'A:./' - assert_equal FasterPath.add_trailing_separator('A:./'), 'A:./' - assert_equal FasterPath.add_trailing_separator('A:.//'), 'A:.//' + # As in Ruby's test_pathname.rb + assert_equal 'A:', FasterPath.add_trailing_separator('A:') + assert_equal 'A:/', FasterPath.add_trailing_separator('A:/') + assert_equal 'A://', FasterPath.add_trailing_separator('A://') + assert_equal 'A:./', FasterPath.add_trailing_separator('A:.') + assert_equal 'A:./', FasterPath.add_trailing_separator('A:./') + assert_equal 'A:.//', FasterPath.add_trailing_separator('A:.//') end end diff --git a/test/cleanpath_conservative_test.rb b/test/cleanpath_conservative_test.rb index 7b87cfe..90d0e35 100644 --- a/test/cleanpath_conservative_test.rb +++ b/test/cleanpath_conservative_test.rb @@ -38,9 +38,16 @@ def test_it_creates_cleanpath_conservative_correctly_part_2 assert_equal FasterPath.cleanpath_conservative("a/b/../../../../c/../d"), "a/b/../../../../c/../d" end + def test_it_keeps_every_name_with_a_trailing_separator + %w[a/b/ /a/b// a/./b/ a/b/../ ./a/b/ a//b/c/].each do |path| + assert_equal Pathname.new(path).cleanpath(true).to_s, FasterPath.cleanpath_conservative(path), path + end + end + def test_windows_compat if DOSISH - assert_equal FasterPath.cleanpath_conservative('c:/foo/bar'), 'c:\\foo\\bar' + # Ruby's test_pathname.rb: defassert(:cleanpath_conservative, 'c:/foo/bar', 'c:\\foo\\bar') + assert_equal 'c:/foo/bar', FasterPath.cleanpath_conservative('c:\\foo\\bar') end if DOSISH_UNC diff --git a/test/extname_test.rb b/test/extname_test.rb index 2fdcc2c..efbefdd 100644 --- a/test/extname_test.rb +++ b/test/extname_test.rb @@ -25,7 +25,8 @@ def test_extname_official appendixes.each do |appendix| infixes.each do |infix| path = "#{prefix}foo#{infix}#{appendix}" - assert_equal("", FasterPath.extname(path), "FasterPath.extname(#{path.inspect})") + expected = infix == "." && appendix.empty? ? TRAILING_DOT_EXTNAME : "" + assert_equal(expected, FasterPath.extname(path), "FasterPath.extname(#{path.inspect})") end infixes2.each do |infix| path = "#{prefix}foo#{infix}.ext#{appendix}" @@ -56,8 +57,8 @@ def test_extname_edge_cases assert_equal "", FasterPath.extname("..") assert_equal "", FasterPath.extname("...") assert_equal "", FasterPath.extname("....") - assert_equal "", FasterPath.extname(".foo.") - assert_equal "", FasterPath.extname("foo.") + assert_equal TRAILING_DOT_EXTNAME, FasterPath.extname(".foo.") + assert_equal TRAILING_DOT_EXTNAME, FasterPath.extname("foo.") assert_equal "", FasterPath.extname("..foo") end @@ -86,5 +87,10 @@ def test_substitutability_of_rust_and_ruby_impls assert_equal( *result_pair.("foo.") ) assert_equal( *result_pair.("foo.rb/") ) assert_equal( *result_pair.("foo.rb//") ) + assert_equal( *result_pair.("..foo") ) + assert_equal( *result_pair.("a/..b") ) + assert_equal( *result_pair.("...a") ) + assert_equal( *result_pair.("foo..") ) + assert_equal( *result_pair.("a./") ) end end diff --git a/test/refinements/absoulte_test.rb b/test/refinements/absoulte_test.rb index 0bf039c..d71b91e 100644 --- a/test/refinements/absoulte_test.rb +++ b/test/refinements/absoulte_test.rb @@ -9,7 +9,7 @@ def absolute?(v) end end -class AbsoluteRefinementTest < Minitest::Test +class RefinementsAbsoluteRefinementTest < Minitest::Test def test_refines_pathname_absolute? assert RefinedPathname.new.absolute?("/") end diff --git a/test/refinements/add_trailing_separator_test.rb b/test/refinements/add_trailing_separator_test.rb index b3776df..4db2518 100644 --- a/test/refinements/add_trailing_separator_test.rb +++ b/test/refinements/add_trailing_separator_test.rb @@ -8,7 +8,7 @@ def add_trailing_separator(pth) end end -class AddTrailingSeparatorTest < Minitest::Test +class RefinementsAddTrailingSeparatorTest < Minitest::Test def test_refines_pathname_add_trailing_separator assert RefinedPathname.allocate.send(:add_trailing_separator, 'hello') end diff --git a/test/refinements/basename_test.rb b/test/refinements/basename_test.rb index 9ce357a..f4207a0 100644 --- a/test/refinements/basename_test.rb +++ b/test/refinements/basename_test.rb @@ -7,7 +7,7 @@ def basename(*args) end end -class BasenameTest < Minitest::Test +class RefinementsBasenameTest < Minitest::Test # Tests copied from https://searchcode.com/codesearch/view/12785140/ def test_it_creates_basename_correctly # Tests for basename diff --git a/test/refinements/chop_basename_test.rb b/test/refinements/chop_basename_test.rb index 3563078..0e50ce4 100644 --- a/test/refinements/chop_basename_test.rb +++ b/test/refinements/chop_basename_test.rb @@ -9,7 +9,7 @@ def chop_basename(v) end end -class ChopBasenameRefinementTest < Minitest::Test +class RefinementsChopBasenameRefinementTest < Minitest::Test def test_refines_pathname_chop_basename assert RefinedPathname.new.chop_basename("/hello/world") end diff --git a/test/refinements/directory_test.rb b/test/refinements/directory_test.rb index a2ac7bc..6815156 100644 --- a/test/refinements/directory_test.rb +++ b/test/refinements/directory_test.rb @@ -8,7 +8,7 @@ def directory?(v) end end -class DirectoryRefinementTest < Minitest::Test +class RefinementsDirectoryRefinementTest < Minitest::Test def test_refines_pathname_directory? assert RefinedPathname.new.directory?("/") end diff --git a/test/refinements/dirname_test.rb b/test/refinements/dirname_test.rb index 0c3a57b..712592a 100644 --- a/test/refinements/dirname_test.rb +++ b/test/refinements/dirname_test.rb @@ -9,7 +9,7 @@ def dirname(name) end end -class DirnameTest < Minitest::Test +class RefinementsDirnameTest < Minitest::Test def setup @refined_file = RefinedFile.new end diff --git a/test/refinements/extname_test.rb b/test/refinements/extname_test.rb index 4603ce5..6cdc5fb 100644 --- a/test/refinements/extname_test.rb +++ b/test/refinements/extname_test.rb @@ -7,7 +7,7 @@ def extname(path) end end -class ExtnameTest < Minitest::Test +class RefinementsExtnameTest < Minitest::Test # Tests copied from https://searchcode.com/codesearch/view/12785140/ def test_extname assert_equal ".rb", RefinedFile.new.extname("foo.rb") @@ -27,7 +27,7 @@ def test_extname_edge_cases assert_equal "", RefinedFile.new.extname("..") assert_equal "", RefinedFile.new.extname("...") assert_equal "", RefinedFile.new.extname("....") - assert_equal "", RefinedFile.new.extname(".foo.") - assert_equal "", RefinedFile.new.extname("foo.") + assert_equal TRAILING_DOT_EXTNAME, RefinedFile.new.extname(".foo.") + assert_equal TRAILING_DOT_EXTNAME, RefinedFile.new.extname("foo.") end end diff --git a/test/refinements/has_trailing_separator_test.rb b/test/refinements/has_trailing_separator_test.rb index 848b135..4e40dcb 100644 --- a/test/refinements/has_trailing_separator_test.rb +++ b/test/refinements/has_trailing_separator_test.rb @@ -5,7 +5,7 @@ def has_trailing_separator?(path) end end -class HasTrailingSeparatorRefinementTest < Minitest::Test +class RefinementsHasTrailingSeparatorRefinementTest < Minitest::Test def test_refines_pathname_has_trailing_separator? assert RefinedPathname.new.has_trailing_separator? "a/" end diff --git a/test/refinements/plus_test.rb b/test/refinements/plus_test.rb index 3335737..94b37d3 100644 --- a/test/refinements/plus_test.rb +++ b/test/refinements/plus_test.rb @@ -7,7 +7,7 @@ def plus(*args) end end -class BasenameTest < Minitest::Test +class RefinementsPlusTest < Minitest::Test # Tests copied from https://searchcode.com/codesearch/view/12785140/ def test_it_creates_basename_correctly assert_equal('/', RefinedPathname.allocate.send(:plus, '/', '/')) diff --git a/test/refinements/relative_test.rb b/test/refinements/relative_test.rb index 29bdd54..720c8e0 100644 --- a/test/refinements/relative_test.rb +++ b/test/refinements/relative_test.rb @@ -9,7 +9,7 @@ def relative?(v) end end -class RelativeRefinementTest < Minitest::Test +class RefinementsRelativeRefinementTest < Minitest::Test def test_refines_pathname_relative? refute RefinedPathname.new.relative?("/") end diff --git a/test/ripple_effects/cleanpath_aggressive_test.rb b/test/ripple_effects/cleanpath_aggressive_test.rb index 1124e17..9de18f3 100644 --- a/test/ripple_effects/cleanpath_aggressive_test.rb +++ b/test/ripple_effects/cleanpath_aggressive_test.rb @@ -9,7 +9,7 @@ def cleanpath_aggressive(pth) end end -class CleanpathAggressiveTest < Minitest::Test +class RippleEffectsCleanpathAggressiveTest < Minitest::Test def test_clean_aggressive_defaults1 assert_equal RefinedPathname.new.cleanpath_aggressive('/'), '/' assert_equal RefinedPathname.new.cleanpath_aggressive(''), '.' diff --git a/test/ripple_effects/cleanpath_conservative_test.rb b/test/ripple_effects/cleanpath_conservative_test.rb index f2f8356..f924d5d 100644 --- a/test/ripple_effects/cleanpath_conservative_test.rb +++ b/test/ripple_effects/cleanpath_conservative_test.rb @@ -9,7 +9,7 @@ def cleanpath_conservative(pth) end end -class CleanpathConservativeTest < Minitest::Test +class RippleEffectsCleanpathConservativeTest < Minitest::Test def test_clean_conservative_defaults1 assert_equal RefinedPathname.new.cleanpath_conservative('/'), '/' assert_equal RefinedPathname.new.cleanpath_conservative(''), '.' diff --git a/test/ripple_effects/del_trailing_separator_test.rb b/test/ripple_effects/del_trailing_separator_test.rb index dfe1549..0f1e0e9 100644 --- a/test/ripple_effects/del_trailing_separator_test.rb +++ b/test/ripple_effects/del_trailing_separator_test.rb @@ -9,7 +9,7 @@ def del_trailing_separator(pth) end end -class DelTrailingSeparatorTest < Minitest::Test +class RippleEffectsDelTrailingSeparatorTest < Minitest::Test def test_del_trailing_separator assert_equal RefinedPathname.new.del_trailing_separator("/"), "/" assert_equal RefinedPathname.new.del_trailing_separator("/a"), "/a" diff --git a/test/ripple_effects/has_trailing_separator_test.rb b/test/ripple_effects/has_trailing_separator_test.rb index a999c7c..c6e6209 100644 --- a/test/ripple_effects/has_trailing_separator_test.rb +++ b/test/ripple_effects/has_trailing_separator_test.rb @@ -9,7 +9,7 @@ def has_trailing_separator?(pth) end end -class HasTrailingSeparatorTest < Minitest::Test +class RippleEffectsHasTrailingSeparatorTest < Minitest::Test def test_has_trailing_separator? refute RefinedPathname.new.has_trailing_separator?("/") refute RefinedPathname.new.has_trailing_separator?("///") diff --git a/test/ripple_effects/join_test.rb b/test/ripple_effects/join_test.rb index 3600a65..4f53b9a 100644 --- a/test/ripple_effects/join_test.rb +++ b/test/ripple_effects/join_test.rb @@ -10,7 +10,7 @@ def join(*args) end end -class JoinTest < Minitest::Test +class RippleEffectsJoinTest < Minitest::Test def test_join r = RefinedPathname.new.join("a", Pathname("b"), Pathname("c")) assert_equal(Pathname("a/b/c"), r) diff --git a/test/ripple_effects/plus_test.rb b/test/ripple_effects/plus_test.rb index b50cb3f..fae83df 100644 --- a/test/ripple_effects/plus_test.rb +++ b/test/ripple_effects/plus_test.rb @@ -13,7 +13,7 @@ def +(pth, other) end end -class CleanpathAggressiveTest < Minitest::Test +class RippleEffectsPlusTest < Minitest::Test def test_plus assert_kind_of(Pathname, RefinedPathname.new.+("a", "b")) end diff --git a/test/safety_test.rb b/test/safety_test.rb new file mode 100644 index 0000000..4ef9831 --- /dev/null +++ b/test/safety_test.rb @@ -0,0 +1,140 @@ +require 'test_helper' + +# Inputs that used to crash the Ruby process (aborts from Rust panics, or +# segfaults) must now return a value or raise a Ruby exception. +class SafetyTest < Minitest::Test + STRING_METHODS = %i[ + absolute? add_trailing_separator basename chop_basename cleanpath_aggressive + cleanpath_conservative del_trailing_separator directory? dirname entries + entries_compat extname has_trailing_separator? plus relative? relative_path_from + children children_compat + ].freeze + + class RaisingPath + def to_path + raise IOError, "to_path failed" + end + + def to_s + raise IOError, "to_s failed" + end + end + + class IntegerPath + def to_path + 42 + end + end + + def call(method, *args) + FasterPath.public_send(method, *args) + rescue StandardError => e + e + end + + def test_join_without_arguments_raises_argument_error + assert_raises(ArgumentError) { FasterPath.join } + end + + def test_join_under_gc_stress + GC.stress = true + result = FasterPath.join(Pathname("a"), "b", Pathname("c"), "d") + GC.stress = false + assert_equal Pathname("a/b/c/d"), result + ensure + GC.stress = false + end + + def test_children_and_entries_under_gc_stress + GC.stress = true + children = FasterPath.children_compat(__dir__, true) + entries = FasterPath.entries_compat(__dir__) + GC.stress = false + assert_equal Pathname(__dir__).children.sort, children.sort + assert_equal Pathname(__dir__).entries.sort, entries.sort + ensure + GC.stress = false + end + + def test_exceptions_from_ruby_code_propagate + assert_raises(IOError) { FasterPath.join("a", RaisingPath.new) } + assert_raises(IOError) { FasterPath.relative_path_from("a", RaisingPath.new) } + assert_raises(IOError) { FasterPath.basename(RaisingPath.new) } + end + + def test_to_path_must_return_a_string + assert_raises(TypeError) { FasterPath.basename(IntegerPath.new) } + assert_raises(TypeError) { FasterPath.join("a", IntegerPath.new) } + end + + def test_to_path_is_used + assert_equal "b", FasterPath.basename(Pathname("a/b")) + assert_equal ".rb", FasterPath.extname(Pathname("a/b.rb")) + assert_equal "a", FasterPath.dirname(Pathname("a/b")) + end + + def test_add_trailing_separator_requires_a_path + assert_raises(TypeError) { FasterPath.add_trailing_separator(nil) } + assert_raises(TypeError) { FasterPath.add_trailing_separator(1) } + assert_equal "a/", FasterPath.add_trailing_separator("a") + end + + def test_too_many_arguments_raise_argument_error + STRING_METHODS.each do |method| + assert_raises(ArgumentError, method.to_s) { FasterPath.public_send(method, "a", "b", "c") } + end + end + + def test_odd_arguments_never_crash + odd = [nil, 1, :sym, Object.new, [], "a\0b", "\xFF\xFE/\xFD".b, "a/b".encode("UTF-16LE"), + RaisingPath.new, IntegerPath.new, "", "/", "//", "."] + STRING_METHODS.each do |method| + odd.each do |a| + call(method, a) + odd.each { |b| call(method, a, b) } + end + end + odd.each { |a| odd.each { |b| call(:join, a, b) } } + end + + def test_null_bytes_raise_argument_error + assert_raises(ArgumentError) { FasterPath.basename("a\0b") } + assert_raises(ArgumentError) { FasterPath.directory?("a\0b") } + assert_raises(ArgumentError) { FasterPath.entries("a\0b") } + end + + def test_non_utf8_paths_keep_their_bytes_and_encoding + path = "\xFF\xFE/\xFD.\xFC".b + assert_equal "\xFD.\xFC".b, FasterPath.basename(path) + assert_equal Encoding::ASCII_8BIT, FasterPath.basename(path).encoding + assert_equal "\xFF\xFE".b, FasterPath.dirname(path) + assert_equal ".\xFC".b, FasterPath.extname(path) + assert_equal ["\xFF\xFE/".b, "\xFD.\xFC".b], FasterPath.chop_basename(path) + assert_equal File.basename(path), FasterPath.basename(path) + assert_equal File.dirname(path), FasterPath.dirname(path) + assert_equal File.extname(path), FasterPath.extname(path) + end + + def test_result_encoding_matches_ruby + path = "dir/fé.rb".encode("ISO-8859-1") + assert_equal File.basename(path).encoding, FasterPath.basename(path).encoding + assert_equal File.dirname(path).encoding, FasterPath.dirname(path).encoding + assert_equal File.extname(path).encoding, FasterPath.extname(path).encoding + end + + def test_missing_directories_raise_errno + missing = File.join(__dir__, "no_such_directory") + error = assert_raises(Errno::ENOENT) { FasterPath.entries(missing) } + assert_includes error.message, missing + assert_raises(Errno::ENOENT) { FasterPath.entries_compat(missing) } + assert_raises(Errno::ENOENT) { FasterPath.children(missing) } + assert_raises(Errno::ENOENT) { FasterPath.children_compat(missing) } + assert_raises(Errno::ENOTDIR) { FasterPath.entries(__FILE__) } + end + + def test_relative_path_from_errors_match_pathname + error = assert_raises(ArgumentError) { FasterPath.relative_path_from("a", "/b") } + pathname_error = assert_raises(ArgumentError) { Pathname("a").relative_path_from(Pathname("/b")) } + assert_equal pathname_error.message, error.message + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3eef859..514defb 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -22,6 +22,10 @@ require 'minitest/reporters' require 'color_pound_spec_reporter' +# color_pound_spec_reporter prints failures with the `MiniTest` name, which +# minitest 5.19 and later only define for compatibility +MiniTest = Minitest unless defined?(MiniTest) + Minitest::Reporters.use! [ColorPoundSpecReporter.new] ::Minitest::Assertions.module_eval do @@ -34,6 +38,13 @@ def assert_incompatible_encoding end ::Minitest::Test.class_eval do + # Ruby 2.7 changed File.extname("foo.") from "" to ".", except on Windows + TRAILING_DOT_EXTNAME = + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7") && /mingw|mswin/ !~ RUBY_PLATFORM + "." + else + "" + end DRIVE = Dir.pwd[%r{\A(?:[a-z]:|//[^/]+/[^/]+)}i] POSIX = /cygwin|mswin|bccwin|mingw|emx/ !~ RUBY_PLATFORM NTFS = !(/mingw|mswin|bccwin/ !~ RUBY_PLATFORM)