diff --git a/compiler/test/stdlib/path.test.gr b/compiler/test/stdlib/path.test.gr new file mode 100644 index 0000000000..9d8fc5bb4c --- /dev/null +++ b/compiler/test/stdlib/path.test.gr @@ -0,0 +1,271 @@ +import Path from "path" +import Result from "result" +import Option from "option" +import List from "list" + +let fs = Path.fromString + +// test that a variety of properties hold for valid relative/absolute paths +record ParseFileTestData { + pathStr: String, + expParent: String, + expStr: String, + expName: Option, + expStem: String, + expExt: String, +} + +let parseFileTests = [ + { + pathStr: "./dir/file.txt", + expParent: "./dir/", + expStr: "./dir/file.txt", + expName: Some("file.txt"), + expStem: "file", + expExt: ".txt", + }, + { + pathStr: "f", + expParent: ".", + expStr: "./f", + expName: Some("f"), + expStem: "f", + expExt: "", + }, + { + pathStr: "a.tar.gz", + expParent: ".", + expStr: "./a.tar.gz", + expName: Some("a.tar.gz"), + expStem: "a", + expExt: ".tar.gz", + }, + { + pathStr: ".gulprc.babel.js", + expParent: ".", + expStr: "./.gulprc.babel.js", + expName: Some(".gulprc.babel.js"), + expStem: ".gulprc", + expExt: ".babel.js", + }, + { + pathStr: "../../file", + expParent: "../..", + expStr: "../../file", + expName: Some("file"), + expStem: "file", + expExt: "", + }, + { + pathStr: ".\\dir\\file.txt", + expParent: ".", + expStr: "./.\\dir\\file.txt", + expName: Some(".\\dir\\file.txt"), + expStem: ".\\dir\\file", + expExt: ".txt", + }, + { + pathStr: "/dir/file.txt", + expParent: "/dir/", + expStr: "/dir/file.txt", + expName: Some("file.txt"), + expStem: "file", + expExt: ".txt", + }, + { + pathStr: "/dir/../file", + expParent: "/", + expStr: "/file", + expName: Some("file"), + expStem: "file", + expExt: "", + }, + { + pathStr: "C:/Users/me.txt", + expParent: "C:/Users/", + expStr: "C:/Users/me.txt", + expName: Some("me.txt"), + expStem: "me", + expExt: ".txt", + }, +] + +List.forEach(({ pathStr, expParent, expStr, expName, expStem, expExt }) => { + let path = fs(pathStr) + assert Path.toString(path) == expStr + assert fs(expParent) == Path.parent(path) + assert expName == Path.basename(path) + assert Ok(expStem) == Path.stem(path) + assert Ok(expExt) == Path.extension(path) +}, parseFileTests) + +record ParseDirTestData { + pathStr: String, + expParent: String, + expStr: String, + expName: Option, +} + +let parseDirTests = [ + { pathStr: "dir/../../", expParent: "../..", expStr: "../", expName: None }, + { + pathStr: ".git/", + expParent: ".", + expStr: "./.git/", + expName: Some(".git"), + }, + { pathStr: ".", expParent: "..", expStr: "./", expName: None }, + { pathStr: ".////", expParent: "..", expStr: "./", expName: None }, + { pathStr: "../", expParent: "../..", expStr: "../", expName: None }, + { pathStr: "", expParent: "..", expStr: "./", expName: None }, + { pathStr: "/../..", expParent: "/", expStr: "/", expName: None }, + { pathStr: "/", expParent: "/", expStr: "/", expName: None }, + { + pathStr: "/bin/dir/..", + expParent: "/", + expStr: "/bin/", + expName: Some("bin"), + }, + { pathStr: "C:/", expParent: "C:/", expStr: "C:/", expName: None }, + { pathStr: "c:/.././..", expParent: "c:/", expStr: "c:/", expName: None }, +] + +List.forEach(({ pathStr, expParent, expStr, expName }: ParseDirTestData) => { + let path = fs(pathStr) + assert Path.toString(path) == expStr + assert fs(expParent) == Path.parent(path) + assert expName == Path.basename(path) +}, parseDirTests) + +// miscellaneous parsing tests +assert fs("") == fs(".") +assert fs(".") == fs("./") +assert fs("dir") != fs("dir/") +assert Path.toPlatformString(fs("C:/Users/me/"), Path.Windows) == + "C:\\Users\\me\\" +assert Path.toPlatformString( + Path.fromPlatformString("C:\\Users/me\\", Path.Windows), + Path.Windows +) == + "C:\\Users\\me\\" +assert Path.toPlatformString( + Path.fromPlatformString(".\\dir/me\\", Path.Windows), + Path.Posix +) == + "./dir/me/" + +assert Path.stem(fs("dir/")) == Err(Path.IncompatiblePathType) +assert Path.extension(fs("dir/")) == Err(Path.IncompatiblePathType) + +// tests properties about the types of paths + +record PathTypeTest { + pathStr: String, + isDir: Bool, + isAbs: Bool, +} + +let pathTypeTests = [ + { pathStr: "/", isDir: true, isAbs: true }, + { pathStr: "/file", isDir: false, isAbs: true }, + { pathStr: ".", isDir: true, isAbs: false }, + { pathStr: "./file", isDir: false, isAbs: false }, +] + +List.forEach(({ pathStr, isDir, isAbs }) => { + let path = fs(pathStr) + assert isDir == Path.isDirectory(path) + assert isAbs == Path.isAbsolute(path) +}, pathTypeTests) + +assert Path.root(fs("/dir/")) == Ok(Path.Root) +assert Path.root(fs("C:/dir/")) == Ok(Path.Drive('C')) +assert Path.root(fs("c:/dir/")) == Ok(Path.Drive('c')) +assert Path.root(fs("file")) == Err(Path.IncompatiblePathType) + +record AppendTestData { + base: String, + toAppend: String, + final: String, +} + +// test appending onto relative and absolute paths +let appendTests = [ + { base: "dir/", toAppend: "inner", final: "dir/inner" }, + { base: "dir /", toAppend: " inner", final: "dir / inner" }, + { base: "./dir/", toAppend: "../../other", final: "../other" }, + { base: ".", toAppend: ".././../", final: "../.." }, + { base: "dir/", toAppend: "f.txt", final: "dir/f.txt" }, + { base: "./dir/", toAppend: "../../script", final: "../script" }, + { base: ".", toAppend: ".././../file", final: "../../file" }, + { base: "/usr/", toAppend: "./bin/", final: "/usr/bin/" }, + { base: "/usr/", toAppend: "../bin/", final: "/bin/" }, + { base: "/", toAppend: "../..", final: "/" }, + { base: "C:/", toAppend: "../..", final: "C:/" }, + { base: "/etc/", toAppend: "./f.txt", final: "/etc/f.txt" }, + { base: "/usr/", toAppend: "../../file", final: "/file" }, +] + +List.forEach(({ base, toAppend, final }) => { + let path = fs(base) + let expPath = fs(final) + let append = Path.append(path, fs(toAppend)) + assert append == Ok(expPath) +}, appendTests) + +Path.append(fs("file"), fs("f")) == Err(Path.AppendToFile) +Path.append(fs("/d/"), fs("/f")) == Err(Path.AppendAbsolute) + +record RelativeToDirTestData { + source: String, + dest: String, + result: Result, +} + +// test the relativeTo function +let valid = path => Ok(fs(path)) +let relativeToTests = [ + { source: "./dir", dest: ".", result: valid("..") }, + { source: ".", dest: ".", result: valid(".") }, + { source: "..", dest: ".", result: Err(Path.ImpossibleRelativization) }, + { source: "/", dest: "/", result: valid(".") }, + { source: "/usr/bin", dest: "/usr/bin", result: valid("../bin") }, + { + source: "/bin", + dest: "C:/Users", + result: Err(Path.Incompatible(Path.DifferentRoots)), + }, + { source: "./dir", dest: "./dir/inner", result: valid("./inner") }, + { source: "..", dest: "../../other", result: valid("../other") }, + { source: "..", dest: "./f", result: Err(Path.ImpossibleRelativization) }, + { source: "/bin", dest: "/bin/f", result: valid("./f") }, + { source: "/a/b", dest: "/c/d.txt", result: valid("../../c/d.txt") }, + { + source: "/bin", + dest: "C:/a.txt", + result: Err(Path.Incompatible(Path.DifferentRoots)), + }, + { + source: "/bin", + dest: "./a.txt", + result: Err(Path.Incompatible(Path.DifferentBases)), + }, +] + +List.forEach(({ source, dest, result }) => { + let source = fs(source) + let dest = fs(dest) + assert Path.relativeTo(source, dest) == result +}, relativeToTests) + +// ancestry tests +assert Path.ancestry(fs("/usr"), fs("/usr/bin/../sbin")) == Ok(Path.Descendant) +assert Path.ancestry(fs("dir"), fs("dir/inner/file.txt")) == Ok(Path.Descendant) +assert Path.ancestry(fs("/usr/bin"), fs("/usr")) == Ok(Path.Ancestor) +assert Path.ancestry(fs("/usr"), fs("/usr")) == Ok(Path.Self) +assert Path.ancestry(fs("/usr/"), fs("/usr")) == Ok(Path.Self) +assert Path.ancestry(fs("/usr"), fs("/etc")) == Ok(Path.NoLineage) +assert Path.ancestry(fs("../dir1"), fs("./dir2")) == Ok(Path.NoLineage) +assert Path.ancestry(fs("./dir1"), fs("../../dir2")) == Ok(Path.NoLineage) +assert Path.ancestry(fs("./dir1"), fs("/dir2")) == Err(Path.DifferentBases) +assert Path.ancestry(fs("C:/dir1"), fs("/dir2")) == Err(Path.DifferentRoots) diff --git a/compiler/test/suites/stdlib.re b/compiler/test/suites/stdlib.re index 0dd5f260bd..df8f98ba87 100644 --- a/compiler/test/suites/stdlib.re +++ b/compiler/test/suites/stdlib.re @@ -104,6 +104,7 @@ describe("stdlib", ({test, testSkip}) => { assertStdlib("marshal.test"); assertStdlib("number.test"); assertStdlib("option.test"); + assertStdlib("path.test"); assertStdlib("pervasives.test"); assertStdlib("queue.test"); assertStdlib("range.test"); diff --git a/stdlib/path.gr b/stdlib/path.gr new file mode 100644 index 0000000000..973cffd06b --- /dev/null +++ b/stdlib/path.gr @@ -0,0 +1,787 @@ +/** + * @module Path: Utilities for working with system paths. + * + * This module treats paths purely as a data representation and does not + * provide functionality for interacting with the file system. + * + * This module explicitly encodes whether a path is absolute or relative, and + * whether it refers to a file or a directory, as part of the `Path` type. + * + * Paths in this module abide by a special POSIX-like representation/grammar + * rather than one defined by a specific operating system. The rules are as + * follows: + * + * - Path separators are denoted by `/` for POSIX-like paths + * - Absolute paths may be rooted either at the POSIX-like root `/` or at Windows-like drive roots like `C:/` + * - Paths referencing files must not include trailing forward slashes, but paths referencing directories may + * - The path segment `.` indicates the relative "current" directory of a path, and `..` indicates the parent directory of a path + * + * @example import Path from "path" + * + * @since v0.5.5 + */ +import String from "string" +import List from "list" +import Option from "option" +import Result from "result" +import Char from "char" + +// this module is influenced by https://github.com/reasonml/reason-native/blob/a0ddab6ab25237961e32d8732b0a222ec2372d4a/src/fp/Fp.re +// with some modifications; reason-native license: + +// MIT License +// +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +enum Token { + Slash, + Dot, + Dotdot, + DriveTok(Char), + Text(String), +} + +enum DirsUp { + Zero, + Positive, +} + +enum FileType { + File, + Dir, +} + +// hack to be able to concretely distinguish TypedPath from PathInfo and +// enforce TypedPath's type parameters +record TFileType { + fileType: FileType, +} + +// Rel(Number) represents the number of directories up from the base point +enum Base { + Rel(Number), + Abs(AbsoluteRoot), +}, +type PathInfo = (Base, FileType, List), +record TBase { + base: Base, +}, +/** + * @section Types: Type declarations included in the Path module. + */ + +/** + * Represents an absolute path's anchor point. + */ +export enum AbsoluteRoot { + Root, + Drive(Char), +} + +// Dummy record names put here just to distinguish the two. These could be +// replaced with opaque types if they get added to the language +/** + * Represents a relative path. + */ +record Relative { + _rel: Void, +} + +/** + * Represents an absolute path. + */ +record Absolute { + _abs: Void, +} + +/** + * Represents a path referencing a file. + */ +record File { + _file: Void, +} + +/** + * Represents a path referencing a directory. + */ +record Directory { + _directory: Void, +} + +/** + * Represents a path typed on (`Absolute` or `Relative`) and (`File` or + * `Directory`) + */ +type TypedPath = (TBase, TFileType, List), +/** + * Represents a system path. + */ +export enum Path { + AbsoluteFile(TypedPath), + AbsoluteDir(TypedPath), + RelativeFile(TypedPath), + RelativeDir(TypedPath), +} + +/** + * Represents a platform-specific path encoding scheme. + */ +export enum Platform { + Windows, + Posix, +} + +/** + * Represents an error that can occur when finding a property of a path. + */ +export enum PathOperationError { + IncompatiblePathType, +} + +/** + * Represents an error that can occur when appending paths. + */ +export enum AppendError { + AppendToFile, + AppendAbsolute, +} + +/** + * Represents the status of an ancestry check between two paths. + */ +export enum AncestryStatus { + Descendant, + Ancestor, + Self, + NoLineage, +} + +/** + * Represents an error that can occur when the types of paths are incompatible + * for an operation. + */ +export enum IncompatibilityError { + DifferentRoots, + DifferentBases, +} + +/** + * Represents possible errors for the `relativeTo` operation. + */ +export enum RelativizationError { + Incompatible(IncompatibilityError), + ImpossibleRelativization, +} + +/** + * @section Values: Functions for working with Paths. + */ + +let makeToken = str => { + match (str) { + "." => Dot, + ".." => Dotdot, + _ when String.length(str) == 2 && String.charAt(1, str) == ':' => + DriveTok(String.charAt(0, str)), + _ => Text(str), + } +} + +let parseNextToken = (path: PathInfo, nextToken) => { + let (base, ft, subs) = path + match (nextToken) { + Slash | Dot => path, + DriveTok(label) => (base, ft, [Char.toString(label) ++ ":", ...subs]), + Text(str) => (base, ft, [str, ...subs]), + Dotdot => { + match (path) { + (_, _, [_, ...rest]) => (base, ft, rest), + (Rel(upDirs), _, []) => (Rel(upDirs + 1), ft, []), + (Abs(_), _, []) => path, + } + }, + } +} + +// splits a path on forward slashes +let lexPath = (pathStr, platform) => { + let isSeparator = char => { + char == '/' || platform == Windows && char == '\\' + } + let len = String.length(pathStr) + let mut revTokens = [] + let mut segBeginI = 0 + for (let mut i = 0; i < len; i += 1) { + if (isSeparator(String.charAt(i, pathStr))) { + if (segBeginI != i) { + let tok = makeToken(String.slice(segBeginI, i, pathStr)) + revTokens = [tok, ...revTokens] + } + revTokens = [Slash, ...revTokens] + segBeginI = i + 1 + } + } + if (segBeginI < len) { + let lastPart = String.slice(segBeginI, len, pathStr) + revTokens = [makeToken(lastPart), ...revTokens] + } + List.reverse(revTokens) +} + +let isFilePath = tokens => { + let revTokens = List.reverse(tokens) + match (revTokens) { + [Dot | Dotdot | Slash, ..._] => false, + _ => true, + } +} + +// utility functions to translate path types + +let toTyped = (pathInfo: PathInfo) => { + let (base, fileType, subs) = pathInfo + ({ base, }, { fileType, }, subs): TypedPath +} + +let toUntyped = (typedPath: TypedPath) => { + let (base, fileType, subs) = typedPath + let { base } = base + let { fileType } = fileType + (base, fileType, subs): PathInfo +} + +let pathInfo = (path: Path) => { + match (path) { + AbsoluteDir(x) => toUntyped(x), + AbsoluteFile(x) => toUntyped(x), + RelativeDir(x) => toUntyped(x), + RelativeFile(x) => toUntyped(x), + } +} + +let toPath = (path: PathInfo) => { + match (path) { + (Abs(_), File, _) as p => AbsoluteFile(toTyped(p)), + (Abs(_), Dir, _) as p => AbsoluteDir(toTyped(p)), + (Rel(_), File, _) as p => RelativeFile(toTyped(p)), + (Rel(_), Dir, _) as p => RelativeDir(toTyped(p)), + } +} + +let parseAbs = (tokens, fileType) => { + match (tokens) { + [] => None, + [first, ...rest] as tokens => { + if (fileType == File && !isFilePath(tokens)) { + None + } else { + let init = match (first) { + Slash => Some((Abs(Root), fileType, [])), + DriveTok(label) => Some((Abs(Drive(label)), fileType, [])), + _ => None, + } + Option.map(init => List.reduce(parseNextToken, init, rest), init) + } + }, + } +} + +// TODO(#1496): expose these functions once module system added + +let absoluteFile = tokens => { + let pathOpt = parseAbs(tokens, File) + Option.map(toTyped, pathOpt): Option> +} + +let absoluteDir = tokens => { + let pathOpt = parseAbs(tokens, Dir) + Option.map(toTyped, pathOpt): Option> +} + +let parseRel = (tokens, fileType) => { + let (first, rest) = match (tokens) { + [] => (Dot, []), + [first, ...rest] => (first, rest), + } + let tokens = [first, ...rest] + + if (fileType == File && !isFilePath(tokens)) { + None + } else { + let init = match (first) { + Dot => Some((Rel(0), fileType, [])), + Dotdot => Some((Rel(1), fileType, [])), + Text(str) => Some((Rel(0), fileType, [str])), + _ => None, + } + Option.map(init => List.reduce(parseNextToken, init, rest), init) + } +} + +let relativeFile = tokens => { + let pathOpt = parseRel(tokens, File) + Option.map(toTyped, pathOpt): Option> +} + +let relativeDir = tokens => { + let pathOpt = parseRel(tokens, Dir) + Option.map(toTyped, pathOpt): Option> +} + +// TODO(#1496): reuse this and the other helper functions for the typed implementations +let fromStringHelper = (pathStr, platform) => { + let tokens = match (lexPath(pathStr, platform)) { + // will cause empty strings to get parsed as relative directory '.' + [] => [Dot], + tokens => tokens, + } + let isAbs = match (tokens) { + [Slash | DriveTok(_), ..._] => true, + _ => false, + } + let isDir = !isFilePath(tokens) + + let path = (variant, mkPath, pathType) => { + variant( + Option.expect("Impossible: failed parse of " ++ pathType, mkPath(tokens)) + ) + } + + match ((isAbs, isDir)) { + (true, true) => path(AbsoluteDir, absoluteDir, "absolute dir"), + (true, false) => path(AbsoluteFile, absoluteFile, "absolute file"), + (false, true) => path(RelativeDir, relativeDir, "relative dir"), + (false, false) => path(RelativeFile, relativeFile, "relative file"), + } +} + +/** + * Parses a path string into a `Path`. Paths will be parsed as file paths + * rather than directory paths if there is ambiguity. + * + * @param pathStr: The string to parse as a path + * @returns The path wrapped with details encoded within the type + * + * @example fromString("/bin/") // an absolute Path referencing the directory /bin/ + * @example fromString("file.txt") // a relative Path referencing the file ./file.txt + * @example fromString(".") // a relative Path referencing the current directory + * + * @since v0.5.5 + */ +export let fromString = pathStr => { + fromStringHelper(pathStr, Posix) +} + +/** + * Parses a path string into a `Path` using the path separators appropriate to + * the given platform (`/` for `Posix` and either `/` or `\` for `Windows`). + * Paths will be parsed as file paths rather than directory paths if there is + * ambiguity. + * + * @param pathStr: The string to parse as a path + * @param platform: The platform whose path separators should be used for parsing + * @returns The path wrapped with details encoded within the type + * + * @example fromPlatformString("/bin/", Posix) // an absolute Path referencing the directory /bin/ + * @example fromPlatformString("C:\\file.txt", Windows) // a relative Path referencing the file C:\file.txt + * + * @since v0.5.5 + */ +export let fromPlatformString = (pathStr, platform) => { + fromStringHelper(pathStr, platform) +} + +let toStringHelper = (path, platform) => { + let (base, fileType, revSegs) = path + let sep = match (platform) { + Windows => "\\", + Posix => "/", + } + let segs = List.reverse(revSegs) + let segs = match (base) { + Abs(absFrom) => { + let prefix = match (absFrom) { + Root => "", + Drive(label) => Char.toString(label) ++ ":", + } + [prefix, ...segs] + }, + Rel(upDirs) => { + let goUp = List.init(upDirs, (_) => "..") + if (goUp != []) List.append(goUp, segs) else [".", ...segs] + }, + } + let segs = match (fileType) { + File => segs, + Dir => List.append(segs, [""]), + } + List.join(sep, segs) +} + +/** + * Converts the given `Path` into a string, using the `/` path separator. + * A trailing slash is added to directory paths. + * + * @param path: The path to convert to a string + * @returns A string representing the given path + * + * @example toString(fromString("/file.txt")) == "/file.txt" + * @example toString(fromString("dir/")) == "./dir/" + * + * @since v0.5.5 + */ +export let toString = path => { + toStringHelper(pathInfo(path), Posix) +} + +/** + * Converts the given `Path` into a string, using the canonical path separator + * appropriate to the given platform (`/` for `Posix` and `\` for `Windows`). + * A trailing slash is added to directory paths. + * + * @param path: The path to convert to a string + * @param platform: The `Platform` to use to represent the path as a string + * @returns A string representing the given path + * + * @example toPlatformString(fromString("dir/"), Posix) == "./dir/" + * @example toPlatformString(fromString("C:/file.txt"), Windows) == "C:\\file.txt" + * + * @since v0.5.5 + */ +export let toPlatformString = (path, platform) => { + toStringHelper(pathInfo(path), platform) +} + +/** + * Determines whether the path is a directory path. + * + * @param path: The path to inspect + * @returns `true` if the path is a directory path or `false` otherwise + * + * @example isDirectory(fromString("file.txt")) == false + * @example isDirectory(fromString("/bin/")) == true + * + * @since v0.5.5 + */ +export let isDirectory = path => { + let (_, fileType, _) = pathInfo(path) + fileType == Dir +} + +/** + * Determines whether the path is an absolute path. + * + * @param path: The path to inspect + * @returns `true` if the path is absolute or `false` otherwise + * + * @example isAbsolute(fromString("/Users/me")) == true + * @example isAbsolute(fromString("./file.txt")) == false + */ +export let isAbsolute = path => { + let (base, _, _) = pathInfo(path) + match (base) { + Abs(_) => true, + _ => false, + } +} + +// should only be used on relative path appended to directory path +let rec appendHelper = (path: PathInfo, toAppend: PathInfo) => + match (toAppend) { + (Rel(up2), ft, s2) => + match (path) { + (Rel(up1), _, []) => (Rel(up1 + up2), ft, s2), + (Abs(_) as d, _, []) => (d, ft, s2), + (d, pft, [_, ...rest] as s1) => { + if (up2 > 0) appendHelper((d, pft, rest), (Rel(up2 - 1), ft, s2)) + else (d, ft, List.append(s2, s1)) + }, + }, + (Abs(_), _, _) => fail "Impossible: relative path encoded as absolute path", + }: PathInfo + +/** + * Creates a new path by appending a relative path segment to a directory path. + * + * @param path: The base path + * @param toAppend: The relative path to append + * @returns `Ok(path)` combining the base and appended paths or `Err(err)` if the paths are incompatible + * + * @example append(fromString("./dir/"), fromString("file.txt")) == Ok(fromString("./dir/file.txt")) + * @example append(fromString("a.txt"), fromString("b.sh")) == Err(AppendToFile) // cannot append to file path + * @example append(fromString("./dir/"), fromString("/dir2")) == Err(AppendAbsolute) // cannot append an absolute path + * + * @since v0.5.5 + */ +export let append = (path: Path, toAppend: Path) => { + match ((pathInfo(path), pathInfo(toAppend))) { + ((_, File, _), _) => Err(AppendToFile), + (_, (Abs(_), _, _)) => Err(AppendAbsolute), + (pathInfo1, pathInfo2) => Ok(toPath(appendHelper(pathInfo1, pathInfo2))), + } +} + +let dirsUp = x => if (x == 0) Zero else Positive + +// helper function for relativizing paths; handles the correct number of +// directories to "go up" from one path to another +let rec relativizeDepth = ((up1, s1), (up2, s2)) => + match ((dirsUp(up1), dirsUp(up2), s1, s2)) { + (Zero, Zero, [hd1, ...tl1], [hd2, ...tl2]) when hd1 == hd2 => + relativizeDepth((0, tl1), (0, tl2)), + (Zero, Zero, [], _) => Ok((up2, s2)), + (Zero, Zero, _, _) => Ok((List.length(s1), s2)), + (Positive, Positive, _, _) => relativizeDepth((up1 - 1, s1), (up2 - 1, s2)), + (Zero, Positive, _, _) => Ok((List.length(s1) + up2, s2)), + (Positive, Zero, _, _) => Err(ImpossibleRelativization), + } + +let relativeToHelper = (source: PathInfo, dest: PathInfo) => { + // first branch handles special case of two identical file paths; to return + // '../' instead of '.' (a directory path) because the result file type + // is expected to be the same as the second arg + let result = match ((source, dest)) { + ((_, File, [name, ..._]), _) when source == dest => Ok((1, [name])), + ((Abs(r1), _, s1), (Abs(r2), _, s2)) => + if (r1 != r2) Err(Incompatible(DifferentRoots)) + else relativizeDepth((0, List.reverse(s1)), (0, List.reverse(s2))), + ((Rel(up1), _, s1), (Rel(up2), _, s2)) => + relativizeDepth((up1, List.reverse(s1)), (up2, List.reverse(s2))), + _ => fail "Impossible: paths should have both been absolute or relative", + } + + let (_, fileType, _) = dest + match (result) { + Ok((depth, segs)) => Ok((Rel(depth), fileType, List.reverse(segs))), + Err(err) => Err(err), + }: Result +} + +/** + * Attempts to construct a new relative path which will lead to the destination + * path from the source path. + * + * If the source and destination are incompatible in their bases, the result + * will be `Err(IncompatibilityError)`. + * + * If the route to the destination cannot be concretely determined from the + * source, the result will be `Err(ImpossibleRelativization)`. + * + * @param source: The source path + * @param dest: The destination path to resolve + * @returns `Ok(path)` containing the relative path if successfully resolved or `Err(err)` otherwise + * + * @example relativeTo(fromString("/usr"), fromString("/usr/bin")) == Ok(fromString("./bin")) + * @example relativeTo(fromString("/home/me"), fromString("/home/me")) == Ok(fromString(".")) + * @example relativeTo(fromString("/file.txt"), fromString("/etc/")) == Ok(fromString("../etc/")) + * @example relativeTo(fromString(".."), fromString("../../thing")) Ok(fromString("../thing")) + * @example relativeTo(fromString("/usr/bin"), fromString("C:/Users")) == Err(Incompatible(DifferentRoots)) + * @example relativeTo(fromString("../here"), fromString("./there")) == Err(ImpossibleRelativization) + * + * @since v0.5.5 + */ +export let relativeTo = (source, dest) => { + let pathInfo1 = pathInfo(source) + let (base1, _, _) = pathInfo1 + let pathInfo2 = pathInfo(dest) + let (base2, _, _) = pathInfo2 + match ((base1, base2)) { + (Abs(_), Rel(_)) | (Abs(_), Rel(_)) => Err(Incompatible(DifferentBases)), + _ => Result.map(toPath, relativeToHelper(pathInfo1, pathInfo2)), + } +} + +let rec segsAncestry = (baseSegs, pathSegs) => + match ((baseSegs, pathSegs)) { + ([], []) => Self, + ([], _) => Descendant, + (_, []) => Ancestor, + ([first1, ..._], [first2, ..._]) when first1 != first2 => NoLineage, + ([_, ...rest1], [_, ...rest2]) => segsAncestry(rest1, rest2), + } + +// should be used on paths with same absolute/relativeness +let ancestryHelper = (base: PathInfo, path: PathInfo) => { + let (b1, _, s1) = base + let (b2, _, s2) = path + match ((b1, b2)) { + (Abs(d1), Abs(d2)) when d1 != d2 => Err(DifferentRoots), + _ => Ok(segsAncestry(List.reverse(s1), List.reverse(s2))), + } +} + +/** + * Determines the relative ancestry betwen two paths. + * + * @param base: The first path to consider + * @param path: The second path to consider + * @returns `Ok(ancestryStatus)` with the relative ancestry between the paths if they are compatible or `Err(err)` if they are incompatible + * + * @example ancestry(fromString("/usr"), fromString("/usr/bin/bash")) == Ok(Ancestor) + * @example ancestry(fromString("/Users/me"), fromString("/Users")) == Ok(Descendant) + * @example ancestry(fromString("/usr"), fromString("/etc")) == Ok(Neither) + * @example ancestry(fromString("C:/dir1"), fromString("/dir2")) == Err(DifferentRoots) + * + * @since v0.5.5 + */ +export let ancestry = (path1: Path, path2: Path) => { + let pathInfo1 = pathInfo(path1) + let (base1, _, _) = pathInfo1 + let pathInfo2 = pathInfo(path2) + let (base2, _, _) = pathInfo2 + match ((base1, base2)) { + (Rel(_), Abs(_)) | (Abs(_), Rel(_)) => Err(DifferentBases), + _ => ancestryHelper(pathInfo1, pathInfo2), + } +} + +let parentHelper = (path: PathInfo) => + match (path) { + (base, _, [_, ...rest]) => (base, Dir, rest), + (Rel(upDirs), _, []) => (Rel(upDirs + 1), Dir, []), + (Abs(_) as base, _, []) => (base, Dir, []), + }: PathInfo + +/** + * Retrieves the path corresponding to the parent directory of the given path. + * + * @param path: The path to inspect + * @returns A path corresponding to the parent directory of the given path + * + * @example parent(fromString("./dir/inner")) == fromString("./dir/") + * @example parent(fromString("/")) == fromString("/") + * + * @since v0.5.5 + */ +export let parent = (path: Path) => { + toPath(parentHelper(pathInfo(path))) +} + +let basenameHelper = (path: PathInfo) => + match (path) { + (_, _, [name, ..._]) => Some(name), + _ => None, + } + +/** + * Retrieves the basename (named final segment) of a path. + * + * @param path: The path to inspect + * @returns `Some(path)` containing the basename of the path or `None` if the path does not have one + * + * @example basename(fromString("./dir/file.txt")) == Some("file.txt") + * @example basename(fromString(".."))) == None + * + * @since v0.5.5 + */ +export let basename = (path: Path) => { + basenameHelper(pathInfo(path)) +} + +// should only be used on file paths +let stemExtHelper = (path: PathInfo) => + match (path) { + (_, _, [name, ..._]) => { + let len = String.length(name) + // trim first character (which is possibly a .) off as trick for + // splitting .a.b.c into .a, .b.c + match (String.indexOf(".", String.slice(1, len, name))) { + Some(dotI) => { + let dotI = dotI + 1 + (String.slice(0, dotI, name), String.slice(dotI, len, name)) + }, + None => (name, ""), + } + }, + _ => ("", ""), + } + +/** + * Retrieves the basename of a file path without the extension. + * + * @param path: The path to inspect + * @returns `Ok(path)` containing the stem of the file path or `Err(err)` if the path is a directory path + * + * @example stem(fromString("file.txt")) == Ok("file") + * @example stem(fromString(".gitignore")) == Ok(".gitignore") + * @example stem(fromString(".a.tar.gz")) == Ok(".a") + * @example stem(fromString("/dir/")) == Err(IncompatiblePathType) // can only take stem of a file path + * + * @since v0.5.5 + */ +export let stem = (path: Path) => { + match (pathInfo(path)) { + (_, Dir, _) => Err(IncompatiblePathType), + pathInfo => { + let (stem, _) = stemExtHelper(pathInfo) + Ok(stem) + }, + } +} + +/** + * Retrieves the extension on the basename of a file path. + * + * @param path: The path to inspect + * @returns `Ok(path)` containing the extension of the file path or `Err(err)` if the path is a directory path + * + * @example extension(fromString("file.txt")) == Ok(".txt") + * @example extension(fromString(".gitignore")) == Ok("") + * @example extension(fromString(".a.tar.gz")) == Ok(".tar.gz") + * @example extension(fromString("/dir/")) == Err(IncompatiblePathType) // can only take extension of a file path + * + * @since v0.5.5 + */ +export let extension = (path: Path) => { + match (pathInfo(path)) { + (_, Dir, _) => Err(IncompatiblePathType), + pathInfo => { + let (_, ext) = stemExtHelper(pathInfo) + Ok(ext) + }, + } +} + +// should only be used on absolute paths +let rootHelper = (path: PathInfo) => + match (path) { + (Abs(root), _, _) => root, + _ => fail "Impossible: malformed absolute path data", + } + +/** + * Retrieves the root of the absolute path. + * + * @param path: The path to inspect + * @returns `Ok(root)` containing the root of the path or `Err(err)` if the path is a relative path + * + * @example root(fromString("C:/Users/me/")) == Ok(Drive('C')) + * @example root(fromString("/home/me/")) == Ok(Root) + * @example root(fromString("./file.txt")) == Err(IncompatiblePathType) + * + * @since v0.5.5 + */ +export let root = (path: Path) => { + match (pathInfo(path)) { + (Rel(_), _, _) => Err(IncompatiblePathType), + pathInfo => Ok(rootHelper(pathInfo)), + } +} diff --git a/stdlib/path.md b/stdlib/path.md new file mode 100644 index 0000000000..34f7ae406d --- /dev/null +++ b/stdlib/path.md @@ -0,0 +1,727 @@ +--- +title: Path +--- + +Utilities for working with system paths. + +This module treats paths purely as a data representation and does not +provide functionality for interacting with the file system. + +This module explicitly encodes whether a path is absolute or relative, and +whether it refers to a file or a directory, as part of the `Path` type. + +Paths in this module abide by a special POSIX-like representation/grammar +rather than one defined by a specific operating system. The rules are as +follows: + +- Path separators are denoted by `/` for POSIX-like paths +- Absolute paths may be rooted either at the POSIX-like root `/` or at Windows-like drive roots like `C:/` +- Paths referencing files must not include trailing forward slashes, but paths referencing directories may +- The path segment `.` indicates the relative "current" directory of a path, and `..` indicates the parent directory of a path + +
+Added in next +No other changes yet. +
+ +```grain +import Path from "path" +``` + +## Types + +Type declarations included in the Path module. + +### Path.**AbsoluteRoot** + +```grain +enum AbsoluteRoot { + Root, + Drive(Char), +} +``` + +Represents an absolute path's anchor point. + +### Path.**Relative** + +```grain +type Relative +``` + +Represents a relative path. + +### Path.**Absolute** + +```grain +type Absolute +``` + +Represents an absolute path. + +### Path.**File** + +```grain +type File +``` + +Represents a path referencing a file. + +### Path.**Directory** + +```grain +type Directory +``` + +Represents a path referencing a directory. + +### Path.**TypedPath** + +```grain +type TypedPath +``` + +Represents a path typed on (`Absolute` or `Relative`) and (`File` or +`Directory`) + +### Path.**Path** + +```grain +enum Path { + AbsoluteFile(TypedPath), + AbsoluteDir(TypedPath), + RelativeFile(TypedPath), + RelativeDir(TypedPath), +} +``` + +Represents a system path. + +### Path.**Platform** + +```grain +enum Platform { + Windows, + Posix, +} +``` + +Represents a platform-specific path encoding scheme. + +### Path.**PathOperationError** + +```grain +enum PathOperationError { + IncompatiblePathType, +} +``` + +Represents an error that can occur when finding a property of a path. + +### Path.**AppendError** + +```grain +enum AppendError { + AppendToFile, + AppendAbsolute, +} +``` + +Represents an error that can occur when appending paths. + +### Path.**AncestryStatus** + +```grain +enum AncestryStatus { + Descendant, + Ancestor, + Self, + NoLineage, +} +``` + +Represents the status of an ancestry check between two paths. + +### Path.**IncompatibilityError** + +```grain +enum IncompatibilityError { + DifferentRoots, + DifferentBases, +} +``` + +Represents an error that can occur when the types of paths are incompatible +for an operation. + +### Path.**RelativizationError** + +```grain +enum RelativizationError { + Incompatible(IncompatibilityError), + ImpossibleRelativization, +} +``` + +Represents possible errors for the `relativeTo` operation. + +## Values + +Functions for working with Paths. + +### Path.**fromString** + +
+Added in next +No other changes yet. +
+ +```grain +fromString : String -> Path +``` + +Parses a path string into a `Path`. Paths will be parsed as file paths +rather than directory paths if there is ambiguity. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`pathStr`|`String`|The string to parse as a path| + +Returns: + +|type|description| +|----|-----------| +|`Path`|The path wrapped with details encoded within the type| + +Examples: + +```grain +fromString("/bin/") // an absolute Path referencing the directory /bin/ +``` + +```grain +fromString("file.txt") // a relative Path referencing the file ./file.txt +``` + +```grain +fromString(".") // a relative Path referencing the current directory +``` + +### Path.**fromPlatformString** + +
+Added in next +No other changes yet. +
+ +```grain +fromPlatformString : (String, Platform) -> Path +``` + +Parses a path string into a `Path` using the path separators appropriate to +the given platform (`/` for `Posix` and either `/` or `\` for `Windows`). +Paths will be parsed as file paths rather than directory paths if there is +ambiguity. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`pathStr`|`String`|The string to parse as a path| +|`platform`|`Platform`|The platform whose path separators should be used for parsing| + +Returns: + +|type|description| +|----|-----------| +|`Path`|The path wrapped with details encoded within the type| + +Examples: + +```grain +fromPlatformString("/bin/", Posix) // an absolute Path referencing the directory /bin/ +``` + +```grain +fromPlatformString("C:\\file.txt", Windows) // a relative Path referencing the file C:\file.txt +``` + +### Path.**toString** + +
+Added in next +No other changes yet. +
+ +```grain +toString : Path -> String +``` + +Converts the given `Path` into a string, using the `/` path separator. +A trailing slash is added to directory paths. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to convert to a string| + +Returns: + +|type|description| +|----|-----------| +|`String`|A string representing the given path| + +Examples: + +```grain +toString(fromString("/file.txt")) == "/file.txt" +``` + +```grain +toString(fromString("dir/")) == "./dir/" +``` + +### Path.**toPlatformString** + +
+Added in next +No other changes yet. +
+ +```grain +toPlatformString : (Path, Platform) -> String +``` + +Converts the given `Path` into a string, using the canonical path separator +appropriate to the given platform (`/` for `Posix` and `\` for `Windows`). +A trailing slash is added to directory paths. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to convert to a string| +|`platform`|`Platform`|The `Platform` to use to represent the path as a string| + +Returns: + +|type|description| +|----|-----------| +|`String`|A string representing the given path| + +Examples: + +```grain +toPlatformString(fromString("dir/"), Posix) == "./dir/" +``` + +```grain +toPlatformString(fromString("C:/file.txt"), Windows) == "C:\\file.txt" +``` + +### Path.**isDirectory** + +
+Added in next +No other changes yet. +
+ +```grain +isDirectory : Path -> Bool +``` + +Determines whether the path is a directory path. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to inspect| + +Returns: + +|type|description| +|----|-----------| +|`Bool`|`true` if the path is a directory path or `false` otherwise| + +Examples: + +```grain +isDirectory(fromString("file.txt")) == false +``` + +```grain +isDirectory(fromString("/bin/")) == true +``` + +### Path.**isAbsolute** + +```grain +isAbsolute : Path -> Bool +``` + +Determines whether the path is an absolute path. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to inspect| + +Returns: + +|type|description| +|----|-----------| +|`Bool`|`true` if the path is absolute or `false` otherwise| + +Examples: + +```grain +isAbsolute(fromString("/Users/me")) == true +``` + +```grain +isAbsolute(fromString("./file.txt")) == false +``` + +### Path.**append** + +
+Added in next +No other changes yet. +
+ +```grain +append : (Path, Path) -> Result +``` + +Creates a new path by appending a relative path segment to a directory path. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The base path| +|`toAppend`|`Path`|The relative path to append| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(path)` combining the base and appended paths or `Err(err)` if the paths are incompatible| + +Examples: + +```grain +append(fromString("./dir/"), fromString("file.txt")) == Ok(fromString("./dir/file.txt")) +``` + +```grain +append(fromString("a.txt"), fromString("b.sh")) == Err(AppendToFile) // cannot append to file path +``` + +```grain +append(fromString("./dir/"), fromString("/dir2")) == Err(AppendAbsolute) // cannot append an absolute path +``` + +### Path.**relativeTo** + +
+Added in next +No other changes yet. +
+ +```grain +relativeTo : (Path, Path) -> Result +``` + +Attempts to construct a new relative path which will lead to the destination +path from the source path. + +If the source and destination are incompatible in their bases, the result +will be `Err(IncompatibilityError)`. + +If the route to the destination cannot be concretely determined from the +source, the result will be `Err(ImpossibleRelativization)`. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`source`|`Path`|The source path| +|`dest`|`Path`|The destination path to resolve| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(path)` containing the relative path if successfully resolved or `Err(err)` otherwise| + +Examples: + +```grain +relativeTo(fromString("/usr"), fromString("/usr/bin")) == Ok(fromString("./bin")) +``` + +```grain +relativeTo(fromString("/home/me"), fromString("/home/me")) == Ok(fromString(".")) +``` + +```grain +relativeTo(fromString("/file.txt"), fromString("/etc/")) == Ok(fromString("../etc/")) +``` + +```grain +relativeTo(fromString(".."), fromString("../../thing")) Ok(fromString("../thing")) +``` + +```grain +relativeTo(fromString("/usr/bin"), fromString("C:/Users")) == Err(Incompatible(DifferentRoots)) +``` + +```grain +relativeTo(fromString("../here"), fromString("./there")) == Err(ImpossibleRelativization) +``` + +### Path.**ancestry** + +
+Added in next +No other changes yet. +
+ +```grain +ancestry : (Path, Path) -> Result +``` + +Determines the relative ancestry betwen two paths. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`base`|`Path`|The first path to consider| +|`path`|`Path`|The second path to consider| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(ancestryStatus)` with the relative ancestry between the paths if they are compatible or `Err(err)` if they are incompatible| + +Examples: + +```grain +ancestry(fromString("/usr"), fromString("/usr/bin/bash")) == Ok(Ancestor) +``` + +```grain +ancestry(fromString("/Users/me"), fromString("/Users")) == Ok(Descendant) +``` + +```grain +ancestry(fromString("/usr"), fromString("/etc")) == Ok(Neither) +``` + +```grain +ancestry(fromString("C:/dir1"), fromString("/dir2")) == Err(DifferentRoots) +``` + +### Path.**parent** + +
+Added in next +No other changes yet. +
+ +```grain +parent : Path -> Path +``` + +Retrieves the path corresponding to the parent directory of the given path. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to inspect| + +Returns: + +|type|description| +|----|-----------| +|`Path`|A path corresponding to the parent directory of the given path| + +Examples: + +```grain +parent(fromString("./dir/inner")) == fromString("./dir/") +``` + +```grain +parent(fromString("/")) == fromString("/") +``` + +### Path.**basename** + +
+Added in next +No other changes yet. +
+ +```grain +basename : Path -> Option +``` + +Retrieves the basename (named final segment) of a path. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to inspect| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(path)` containing the basename of the path or `None` if the path does not have one| + +Examples: + +```grain +basename(fromString("./dir/file.txt")) == Some("file.txt") +``` + +```grain +basename(fromString(".."))) == None +``` + +### Path.**stem** + +
+Added in next +No other changes yet. +
+ +```grain +stem : Path -> Result +``` + +Retrieves the basename of a file path without the extension. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to inspect| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(path)` containing the stem of the file path or `Err(err)` if the path is a directory path| + +Examples: + +```grain +stem(fromString("file.txt")) == Ok("file") +``` + +```grain +stem(fromString(".gitignore")) == Ok(".gitignore") +``` + +```grain +stem(fromString(".a.tar.gz")) == Ok(".a") +``` + +```grain +stem(fromString("/dir/")) == Err(IncompatiblePathType) // can only take stem of a file path +``` + +### Path.**extension** + +
+Added in next +No other changes yet. +
+ +```grain +extension : Path -> Result +``` + +Retrieves the extension on the basename of a file path. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to inspect| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(path)` containing the extension of the file path or `Err(err)` if the path is a directory path| + +Examples: + +```grain +extension(fromString("file.txt")) == Ok(".txt") +``` + +```grain +extension(fromString(".gitignore")) == Ok("") +``` + +```grain +extension(fromString(".a.tar.gz")) == Ok(".tar.gz") +``` + +```grain +extension(fromString("/dir/")) == Err(IncompatiblePathType) // can only take extension of a file path +``` + +### Path.**root** + +
+Added in next +No other changes yet. +
+ +```grain +root : Path -> Result +``` + +Retrieves the root of the absolute path. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`path`|`Path`|The path to inspect| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(root)` containing the root of the path or `Err(err)` if the path is a relative path| + +Examples: + +```grain +root(fromString("C:/Users/me/")) == Ok(Drive('C')) +``` + +```grain +root(fromString("/home/me/")) == Ok(Root) +``` + +```grain +root(fromString("./file.txt")) == Err(IncompatiblePathType) +``` +