PurePath.js

import pathlib, { relative } from "node:path"

/**
 * Pure path objects provide path-handling operations which don’t actually access a filesystem.
 * Leverages a Builder pattern to make the interface more fluid to use.
 * @see https://docs.python.org/3/library/pathlib.html#pure-paths
 * @class
 */
class PurePath {
  /**
   * Creates a PurePath instance.
   * @param {...(string|PurePath)} segments - Path segments (or canonical path) to create a PurePath with
   * @returns {PurePath} - A new instance of PurePath.
   */
  constructor(...segments) {
    const pathSegments = segments.map((segment) => {
      if (!(typeof segment === "string") && !(segment instanceof PurePath)) {
        throw TypeError(
          `Invalid type for argument path. ` +
            `Expected one of (string, PurePath), got ${segment.constructor.name}`,
        )
      }
      return `${segment}`
    })
    const path = pathlib.normalize(pathlib.join(...segments.map((s) => `${s}`)))
    /**
     * The normalized path string.
     * @type {string}
     */
    this.path = path

    /**
     * Array of path parts, as separated by node:path.sep
     * @type {string[] | null}
     * @private
     */
    this._parts = null
  }

  /**
   * Returns the last part of the path. Equivalent of `node:path.basename(string)`
   * @returns {string} - The name of the file or directory.
   */
  get name() {
    return pathlib.basename(this.path)
  }

  /**
   * Returns the parent directory path.
   * @returns {PurePath} - The parent directory path.
   */
  get parent() {
    return new PurePath(pathlib.dirname(this.path))
  }

  /**
   * Returns an array of path parts.
   * @returns {string[]} - Array of path parts.
   */
  get parts() {
    if (this._parts) {
      return this._parts
    }
    const root = this.root
    if (root === this.path) {
      this._parts = [root]
    } else {
      this._parts = [root, ...this.path.split(pathlib.sep).slice(1)]
    }
    return this._parts
  }

  /**
   * Returns the root of the path.
   * @returns {string} - The root of the path.
   */
  get root() {
    return pathlib.parse(this.path).root
  }

  /**
   * Returns the file stem (name without suffix).
   * @returns {string} - The stem of the file name.
   */
  get stem() {
    const name = this.name
    if (name.includes(".")) {
      let splits = name.split(".")
      if (name.startsWith(".")) {
        return [`.${splits[1]}`, ...splits.slice(2, -1)].join(".")
      } else {
        return splits.slice(0, -1).join(".")
      }
    }
    return name
  }

  /**
   * Returns the file extension of the final component, if any.
   * @returns {string} - The file extension.
   */
  get suffix() {
    const suffixes = this.suffixes
    return suffixes.length ? suffixes[suffixes.length - 1] : ""
  }

  /**
   * Returns an array the path's file extensions.
   * @returns {string[]} - Array of path's file extensions.
   */
  get suffixes() {
    const parts = this.parts
    const last = parts[parts.length - 1].startsWith(".")
      ? parts[parts.length - 1].substring(1)
      : parts[parts.length - 1]

    if (!last.includes(".")) {
      return []
    }
    return last
      .split(".")
      .slice(1)
      .map((s) => `.${s}`)
  }

  /**
   * Returns the path as a file URI.
   * @returns {string} - The file URI.
   */
  asURI() {
    return `file://${this.path.replace(pathlib.sep, "/")}`
  }

  /**
   * Checks if the path is absolute.
   * @returns {boolean} - True if the path is absolute, otherwise false.
   */
  isAbsolute() {
    return pathlib.isAbsolute(this.path)
  }

  /**
   * Joins segments to the PurePath object, creating a new one.
   * Equivalent of `node:path.join`, and python `pathlib.Path.__div__`.
   * @param {...string} segments - segments to join.
   * @returns {PurePath} - A new PurePath instance representing the joined segments.
   */
  join(...segments) {
    return new PurePath(pathlib.join(this.path, ...segments.map((p) => `${p}`)))
  }

  /**
   * Returns the relative path to another path.
   * @param {string|PurePath} other - The other path to which to calculate the relative path.
   * @returns {PurePath} - A new PurePath instance representing the relative path.
   * @throws {Error} - If the current path is not relative to the provided path.
   */
  relativeTo(other) {
    if (!`${this}`.startsWith(`${other}`)) {
      throw Error(`'${this}' does not start with '${other}'`)
    }
    let relativePath = this.path.substring(`${other}`.length)
    if (relativePath.startsWith(pathlib.sep) && relativePath !== pathlib.sep) {
      relativePath = relativePath.substring(1)
    }
    return new PurePath(relativePath)
  }

  /**
   * Returns a new path with a different name.
   * @param {string} name - The new name for the path.
   * @returns {PurePath} - A new PurePath instance with the specified name.
   * @throws {Error} - If the current path has an empty name.
   */
  withName(name) {
    if (this.root === this.path) {
      throw Error(`PurePath('${this.root}') has an empty name`)
    }
    const parts = this.parts.slice(0, -1)
    return new PurePath(pathlib.join(...parts, name))
  }

  /**
   * Returns a new path with a different stem.
   * @param {string} stem - The new stem for the path.
   * @returns {PurePath} - A new PurePath instance with the specified stem.
   * @throws {Error} - If the current path has an empty stem.
   */
  withStem(stem) {
    if (this.root === this.path) {
      throw Error(`PurePath('${this.root}') has an empty stem`)
    }
    let parts = this.parts
    parts[parts.length - 1] =
      `${stem}${parts[parts.length - 1].substring(this.stem.length)}`
    return new PurePath(parts.join(pathlib.sep))
  }

  /**
   * Returns a new path with a different suffix.
   * @param {string} value - The new suffix for the path.
   * @returns {PurePath} - A new PurePath instance with the specified suffix.
   * @throws {Error} - If the specified suffix is invalid or if the current path has an empty suffix.
   */
  withSuffix(value) {
    if (value !== "" && !/\.[a-zA-Z0-9]+/.test(value)) {
      throw Error(`Invalid suffix '${value}'`)
    }
    if (this.root === this.path) {
      throw Error(`PurePath('${this.root}') has an empty suffix`)
    }
    return new PurePath(
      `${this.path.substring(0, this.path.length - this.suffix.length)}${value}`,
    )
  }

  /**
   * Returns a new path with different suffixes.
   * @param {string[]} suffixes - Array of new suffixes for the path.
   * @returns {PurePath} - A new PurePath instance with the specified suffixes.
   * @throws {TypeError} - If suffixes is not an array of strings.
   * @throws {Error} - If any of the specified suffixes are invalid or if the current path has an empty suffix.
   */
  withSuffixes(suffixes) {
    if (!Array.isArray(suffixes)) {
      throw TypeError("suffixes should be an Array<String>")
    }
    for (let [i, value] of suffixes.entries()) {
      if (value !== "" && !/\.[a-zA-Z0-9]+/.test(value))
        throw Error(`Invalid suffix '${value}' at pos ${i}`)
    }
    if (this.root === this.path) {
      throw Error(`PurePath('${this.root}') has an empty suffix`)
    }
    return new PurePath(
      `${this.path.substring(0, this.path.length - this.suffixes.join("").length)}${suffixes.join("")}`,
    )
  }

  /**
   * Returns the path as a string.
   * @returns {string} - The path as a string.
   */
  toString() {
    return this.path
  }
}

export default PurePath