Skip to content

Commit

Permalink
Add Path.with_extension and refactor Path
Browse files Browse the repository at this point in the history
Path.with_extension returns a new path with a given extension. Path.tail
and Path.directory are refactored to use Path.components, ensuring they
behave correctly with a wider range of paths.

Changelog: added
  • Loading branch information
yorickpeterse committed Jan 16, 2024
1 parent 23495ba commit 4ee577a
Show file tree
Hide file tree
Showing 2 changed files with 175 additions and 35 deletions.
147 changes: 117 additions & 30 deletions std/src/std/fs/path.inko
Expand Up @@ -7,7 +7,7 @@ import std.hash.(Hash, Hasher)
import std.io.(Error, Size)
import std.iter.(Iter, Stream)
import std.libc.unix.dir.(ReadDirectory as ReadDirectoryInner) if unix
import std.string.(ToString, IntoString)
import std.string.(StringBuffer, ToString, IntoString)
import std.sys
import std.time.DateTime

Expand Down Expand Up @@ -170,23 +170,20 @@ impl Iter[Result[DirectoryEntry, Error]] for ReadDirectory {
class pub Components {
let @path: ref Path
let @index: Int
let @start: Int
let @size: Int
let @root: Bool

fn static new(path: ref Path) -> Components {
let mut index = 0
let size = path.path.size
let root = size > 0 and path.path.byte(0) == SEPARATOR_BYTE
let comp =
Components { @path = path, @index = 0, @size = size, @root = root }

# This ensures paths such as `./a/b` are treated the same way as `a/b`,
# without needing extra work while iterating.
if size >= 2
and path.path.byte_unchecked(0) == DOT_BYTE
and path.path.byte_unchecked(1) == SEPARATOR_BYTE
{
index += 2
}

Components { @path = path, @index = index, @start = index, @size = size }
# If we start with a sequence such as `.//././.`, we skip over those. We
# only need to do this once, so we take care of that here instead of
# performing this check every time `next` is called.
comp.skip_relative_start
comp
}

fn byte(index: Int) -> Int {
Expand All @@ -197,16 +194,28 @@ class pub Components {
byte(index) == SEPARATOR_BYTE
}

fn mut skip_relative_start {
if @size > 0
and byte(@index) == DOT_BYTE
and @index + 1 < @size
and separator?(@index + 1)
{
@index += 1
advance_separator
}
}

fn mut advance_separator {
while @index < @size and separator?(@index) {
@index += 1

let after = @index + 1

# This ensures that `a/./b` is treated as `a/b`, but `a/../b` isn't.
if @index < @size
# This turns sequences such as `a/./././b` into `a/b`, while leaving
# `a/../b` alone.
while @index < @size
and byte(@index) == DOT_BYTE
and (after < @size and separator?(after) or after == @size)
and (
@index + 1 < @size and separator?(@index + 1) or @index + 1 == @size
)
{
@index += 2
}
Expand All @@ -218,19 +227,25 @@ impl Iter[String] for Components {
fn pub mut next -> Option[String] {
if @index >= @size { return Option.None }

if @index == @start and separator?(@index) {
if @index == 0 and @root {
advance_separator
return Option.Some(SEPARATOR)
}

let start = @index
let mut size = 0

while @index < @size and separator?(@index).false? { @index += 1 }

let val = @path.path.slice(start, @index - start).into_string
while @index < @size and separator?(@index).false? {
@index += 1
size += 1
}

advance_separator
Option.Some(val)
if size > 0 {
advance_separator
Option.Some(@path.path.slice(start, size).into_string)
} else {
Option.None
}
}
}

Expand Down Expand Up @@ -404,6 +419,9 @@ class pub Path {
# This method does not touch the filesystem, and thus does not resolve paths
# like `..` and symbolic links to their real paths.
#
# This method normalizes the returned `Path` similar to `Path.components`.
# Refer to the documentation of `Path.components` for more details.
#
# # Examples
#
# Obtaining the directory of a path:
Expand All @@ -418,11 +436,27 @@ class pub Path {
#
# Path.new('/').directory # Path.new('/')
fn pub directory -> Path {
let size = bytes_before_last_separator(@path)
let buf = StringBuffer.new
let comp = Components.new(self).peekable
let mut root = false

loop {
match comp.next {
case Some(SEPARATOR) -> {
root = true
buf.push(SEPARATOR)
}
case Some(v) if comp.peek.some? -> {
let any = buf.size > 0

if size < 0 { return Path.new('.') }
if any and root { root = false } else if any { buf.push(SEPARATOR) }
buf.push(v)
}
case _ -> break
}
}

Path.new(@path.slice(start: 0, size: size).into_string)
if buf.size == 0 { Path.new('.') } else { Path.new(buf.into_string) }
}

# Returns the last component in `self`.
Expand All @@ -436,11 +470,27 @@ class pub Path {
#
# Path.new('foo/bar/baz.txt') # => 'baz.txt'
fn pub tail -> String {
let len = bytes_before_last_separator(@path)
let comp = Components.new(self)
let mut start = -1
let mut size = 0

# This finds the range of the last component, taking into account path
# normalization.
while comp.index < comp.size {
comp.advance_separator

if comp.index < comp.size {
start = comp.index
size = 0
}

if len < 0 { return @path }
while comp.index < comp.size and comp.separator?(comp.index).false? {
comp.index += 1
size += 1
}
}

@path.slice(start: len + 1, size: @path.size - len).into_string
if start == -1 { '' } else { @path.slice(start, size).into_string }
}

# Returns the file extension of this path (without the leading `.`), if there
Expand Down Expand Up @@ -496,6 +546,43 @@ class pub Path {
}
}

# Returns a copy of `self` with the given extension.
#
# If `self` already has an extension, it's overwritten the given extension. If
# the given extension is an empty `String`, the new `Path` contains no
# extension.
#
# # Panics
#
# This method panics if the extension contains a path separator.
#
# # Examples
#
# import std.fs.path.Path
#
# Path.new('a').with_extension('txt') # => Path.new('a.txt')
# Path.new('a.txt').with_extension('md') # => Path.new('a.md')
fn pub with_extension(name: String) -> Path {
if name.contains?(SEPARATOR) {
panic("file extensions can't contain path separators")
}

if @path.empty? { return clone }

let raw = match extension {
case Some(v) if name.empty? -> {
@path.slice(start: 0, size: @path.size - v.size - 1).into_string
}
case Some(v) -> {
"{@path.slice(start: 0, size: @path.size - v.size - 1)}.{name}"
}
case _ if name.empty? or @path.ends_with?(SEPARATOR) -> @path
case _ -> "{@path}.{name}"
}

Path.new(raw)
}

# Returns the canonical, absolute version of `self`.
#
# # Errors
Expand Down
63 changes: 58 additions & 5 deletions std/test/std/fs/test_path.inko
Expand Up @@ -104,10 +104,26 @@ fn pub tests(t: mut Tests) {
}

t.test('Path.directory') fn (t) {
t.equal(Path.new('foo').join('bar').directory, Path.new('foo'))
t.equal(Path.new('foo').directory, Path.new('.'))
t.equal(Path.new('/foo').directory, Path.new('/'))
t.equal(Path.new('~/foo').directory, Path.new('~'))
t.equal(Path.new('foo/').directory, Path.new('.'))
t.equal(Path.new('foo//').directory, Path.new('.'))
t.equal(Path.new('foo/bar').directory, Path.new('foo'))
t.equal(Path.new('foo/a/.').directory, Path.new('foo'))
t.equal(Path.new('foo/bar.txt').directory, Path.new('foo'))
t.equal(Path.new('foo//bar.txt').directory, Path.new('foo'))
t.equal(Path.new('foo/./bar.txt').directory, Path.new('foo'))
t.equal(Path.new('./foo.txt').directory, Path.new('.'))
t.equal(Path.new('/foo/./bar.txt').directory, Path.new('/foo'))
t.equal(Path.new('/foo/./bar/baz.txt').directory, Path.new('/foo/bar'))
t.equal(Path.new('a/b/..').directory, Path.new('a/b'))
t.equal(Path.new('').directory, Path.new('.'))
t.equal(Path.new('..').directory, Path.new('.'))
t.equal(Path.new('/..').directory, Path.new('/'))
t.equal(Path.new('/').directory, Path.new('/'))
t.equal(Path.new('/.').directory, Path.new('/'))
t.equal(Path.new('a/.').directory, Path.new('.'))
t.equal(Path.new('//').directory, Path.new('/'))
t.equal(Path.new('.//').directory, Path.new('.'))
}

t.test('Path.==') fn (t) {
Expand Down Expand Up @@ -157,10 +173,22 @@ fn pub tests(t: mut Tests) {

t.test('Path.tail') fn (t) {
t.equal(Path.new('foo').tail, 'foo')
t.equal(Path.new('foo').join('bar').tail, 'bar')
t.equal(Path.new('foo').join('bar.txt').tail, 'bar.txt')
t.equal(Path.new('foo/').tail, 'foo')
t.equal(Path.new('foo//').tail, 'foo')
t.equal(Path.new('foo/bar').tail, 'bar')
t.equal(Path.new('foo/a/.').tail, 'a')
t.equal(Path.new('foo/bar.txt').tail, 'bar.txt')
t.equal(Path.new('foo//bar.txt').tail, 'bar.txt')
t.equal(Path.new('foo/./bar.txt').tail, 'bar.txt')
t.equal(Path.new('/foo/./bar.txt').tail, 'bar.txt')
t.equal(Path.new('').tail, '')
t.equal(Path.new('..').tail, '..')
t.equal(Path.new('/..').tail, '..')
t.equal(Path.new('/').tail, '')
t.equal(Path.new('/.').tail, '')
t.equal(Path.new('a/.').tail, 'a')
t.equal(Path.new('//').tail, '')
t.equal(Path.new('.//').tail, '')
}

t.test('Path.list with a valid directory') fn (t) {
Expand Down Expand Up @@ -325,6 +353,7 @@ fn pub tests(t: mut Tests) {
t.equal(Path.new('//b.txt').extension, Option.Some('txt'))
t.equal(Path.new('foo.a😀a').extension, Option.Some('a😀a'))
t.equal(Path.new('...a').extension, Option.Some('a'))
t.equal(Path.new('/./b.txt').extension, Option.Some('txt'))
}

t.test('Path.hash') fn (t) {
Expand All @@ -344,7 +373,13 @@ fn pub tests(t: mut Tests) {
t.equal(Path.new('/.').components.to_array, ['/'])
t.equal(Path.new('/./').components.to_array, ['/'])
t.equal(Path.new('/./a').components.to_array, ['/', 'a'])
t.equal(Path.new('/./a/.').components.to_array, ['/', 'a'])
t.equal(Path.new('//').components.to_array, ['/'])
t.equal(Path.new('./').components.to_array, [])
t.equal(Path.new('.//').components.to_array, [])
t.equal(Path.new('.///').components.to_array, [])
t.equal(Path.new('.//./').components.to_array, [])
t.equal(Path.new('./././').components.to_array, [])
t.equal(Path.new('/a/b/c').components.to_array, ['/', 'a', 'b', 'c'])
t.equal(Path.new('/./a/b/c').components.to_array, ['/', 'a', 'b', 'c'])
t.equal(Path.new('//a/b/c').components.to_array, ['/', 'a', 'b', 'c'])
Expand Down Expand Up @@ -372,4 +407,22 @@ fn pub tests(t: mut Tests) {
t.equal(strip_prefix('foo/bar', 'wat'), Option.None)
t.equal(strip_prefix('', 'foo'), Option.None)
}

t.test('Path.with_extension') fn (t) {
t.equal(Path.new('a').with_extension('b'), Path.new('a.b'))
t.equal(Path.new('a.a').with_extension('b'), Path.new('a.b'))
t.equal(Path.new('a.a.b').with_extension('c'), Path.new('a.a.c'))
t.equal(Path.new('a.a.b').with_extension(''), Path.new('a.a'))
t.equal(Path.new('a.a').with_extension(''), Path.new('a'))
t.equal(Path.new('a').with_extension(''), Path.new('a'))
t.equal(Path.new('.a').with_extension('b'), Path.new('.a.b'))
t.equal(Path.new('').with_extension(''), Path.new(''))
t.equal(Path.new('').with_extension('a'), Path.new(''))
t.equal(Path.new('/').with_extension('a'), Path.new('/'))
t.equal(Path.new('./').with_extension('a'), Path.new('./'))
}

t.panic('Path.with_extension with an invalid extension') fn {
Path.new('a.txt').with_extension('txt/foo')
}
}

0 comments on commit 4ee577a

Please sign in to comment.