///|
/// `path` as one joined path.
///
/// Kept dumb on purpose: both sides come from git metadata (`commondir: ../..`
/// above all), and there is no path library on every toolchain this package is
/// type-checked for. Trailing separators are dropped, `..` is resolved halfway
/// in `normalize` where the whole path is known.
pub fn join_path(left : String, right : String) -> String {
  if right.has_prefix("/") {
    return normalize(right)
  }
  if left.is_empty() || left == "." {
    return normalize(right)
  }
  let base = if left.has_suffix("/") {
    left[:left.length() - 1].to_owned()
  } else {
    left
  }
  normalize(base + "/" + right)
}

///|
/// `path` with separators made `/` and `.` / `..` resolved textually.
///
/// Textually rather than by `getcwd` on purpose: `commondir` can name a
/// directory git would refuse to enter, and following it with the filesystem is
/// how a build tool ends up walking somewhere it should not have.
pub fn normalize(path : String) -> String {
  let absolute = path.has_prefix("/")
  let parts : Array[String] = []
  for part in path.replace_all(old="\\", new="/").split("/") {
    if part.is_empty() || part == "." {
      continue
    }
    if part == ".." {
      if parts.length() > 0 && parts[parts.length() - 1] != ".." {
        let _ = parts.pop()
      } else if !absolute {
        parts.push(part.to_owned())
      }
      continue
    }
    parts.push(part.to_owned())
  }
  let joined = parts.join("/")
  if absolute {
    "/" + joined
  } else if joined.is_empty() {
    "."
  } else {
    joined
  }
}

///|
/// `path` names a directory. Missing and unreadable are the same answer: there
/// is nothing to descend into either way.
fn is_dir(path : String) -> Bool {
  @fs.is_dir(path) catch {
    _ => false
  }
}

///|
/// `path` names a regular file.
fn is_file(path : String) -> Bool {
  @fs.is_file(path) catch {
    _ => false
  }
}

///|
/// The contents of `path` with line noise stripped, or `""` when it is not
/// there.
///
/// `@fs` rather than an `fopen` of our own because this package is also
/// type-checked for wasm-gc, where a native-only FFI would not resolve.
pub fn read_text(path : String) -> String {
  let bytes = @fs.read_file_to_bytes(path) catch { _ => return "" }
  trim(@utf8.decode_lossy(bytes))
}

///|
fn is_whitespace(ch : Char) -> Bool {
  ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'
}

///|
/// Whitespace off both ends, everything else kept.
///
/// Takes a `StringView` so `split` results do not have to be copied first, and
/// trims rather than `String::trim` because a ref name may hold any byte the
/// filesystem allows — only the spaces git wrote are noise there.
pub fn trim(text : StringView) -> String {
  let chars = text.to_array()
  let mut end = chars.length()
  while end > 0 && is_whitespace(chars[end - 1]) {
    end = end - 1
  }
  let mut start = 0
  while start < end && is_whitespace(chars[start]) {
    start = start + 1
  }
  let buf = StringBuilder()
  for i in start..