Suggestions for converting workspace path

I need convert the workspace path from “/Volumes/Macintosh HD/Users/…” to just “/Users/…” for the purpose of running a process. I know this could be done by splitting the path and removing the “Volumes” and “Macintosh HD” portions, but I don’t want to presume that the workspace is within the Users directory. Does anyone know of an easier way to shorten this path using common JS or the command line?

/Volumes/Macintosh HD is a symbolic link to / (for those who inexplicably don’t give their volumes personalized names). Looks like you can tell if something is a symlink with https://docs.nova.app/api-reference/file-stats/ but not sure if you can tell what the target of the link is. The readlink shell command will tell you though.

1 Like

Thanks, as always, @jrf for the help! :smiley:

Upon further experimentation it appears that, unlike linux, readlink -f is not possible in macOS without adding coreutils via homebrew :confused:

I’ve been literally removing the /Volumes/Macintosh HD prefix, since I’m not sure what’ll happen if someone’s using a non-standard volume name. (https://github.com/apexskier/nova-extension-utils/blob/7c087fc19575bd2890732d39fba381f38a8d364e/src/cleanPath.ts#L8)

You could do something like this, which won’t mess with the Users part of the path

"/" + "/Volumes/Macintosh HD/Users/…".split("/").slice(3).join("/")

… but if somebody used a non-standard volume name, wouldn’t the path returned by Nova’s API reflect that? Considering that the API is Cocoa masquerading as JS, and that the underlying macOS APIs handle this without a hitch?

This wraps what I believe to be the stock macOS readlink:

function readlink(path) {
  return new Promise((resolve, reject) => {
    let stdout = [];
    let cmd = new Process("/usr/bin/readlink", { args: [path] });
    cmd.onStdout((line) => {
      stdout.push(line.trim());
    });
    cmd.onDidExit((status) => {
      if (status !== 0) {
        reject(`${path} is not a link`);
      } else {
        resolve(stdout[0]);
      }
    });
    try {
      cmd.start();
    } catch (e) {
      reject(e);
    }
  });
}

With this, where gamut is my root volume name, and is a symlink to /:

  ["/badpath", "/Volumes", "/Volumes/gamut"].forEach((p) => {
    readlink(p)
      .then((target) => {
        console.log("link target:", target);
      })
      .catch((err) => {
        console.log("readlink error:", err);
      });
  });

I get:

readlink error: /badpath is not a link
readlink error: /Volumes is not a link
link target: /

The documentation of nova.path.normalize() is vague about exactly what it promises, but it would be nice if it automatically handled this specific root filesystem case. As of Nova 3.0 it doesn’t.

Thanks for suggestions guys! I really appreciate the time you all spent. I hope to get some time to work on this again this week and I will keep you posted.

I’ve had to address this specific problem with my µESLint extension because it was one of two issues breaking compatibility with the TypeScript ESLint parser plugin. I’ve come up with the following code:

function nixalize (path) {
  const normalised = nova.path.normalize(path)
  if (!nova.path.isAbsolute(normalised)) return normalised

  const parts = nova.path.split(normalised)
  if (parts.length < 3 || parts[0] !== '/' || parts[1] !== 'Volumes') return normalised

  const root = nova.path.normalize('/')  // expands to “/Volumes/<Drive name>”
  const same = nova.path.split(root).every((el, idx) => parts[idx] === el)
  return same
    ? nova.path.join(...['/'].concat(parts.slice(3)))
    : normalised
}

which seems to handle removing the “Volumes” mount point from filesystem root paths whatever the actual drive name, and leave other mount points alone, without having to check for symbolic links or some such.

By the way: besides this, I have build a whole little collection of utility functions complementing (and in some case supplementing) Nova’s API. Would it make sense for me to put these up in their own repo or module, for others’ usage?

1 Like

Clever! I hadn’t noticed that behavior of normalize(). I do worry about future contract changes in the behavior of normalize() as the documentation is pretty vague about the existing behavior. One invariant of publishing an API is that clients will depend the implemented behavior, not the documented behavior.

Your collection of functions absolutely has nuggets similar to ones I’ve written. I’m at the point of wanting to pull a few of mine out to more easily share between extensions.

I do worry about future contract changes in the behavior of normalize() as the documentation is pretty vague about the existing behavior.

I’ve mulled this over, too, but I’m pretty sure this behaviour is a result of the underlying Cocoa file APIs, so it is one Panic would have to change intentionally. I’m betting on getting some kind of notice when the entire file management API changes (even if Panic have decided to eschew semantic versioning, which is meant to do exactly that). In my actual module, the nova.path.normalize('/') bit is its own function, which would allow implementing fallback behaviour there.

Your collection of functions absolutely has nuggets similar to ones I’ve written. I’m at the point of wanting to pull a few of mine out to more easily share between extensions.

Yes, I’m basically copying my Scripts/lib directory between extensions by now (I put extension specific modules into Scripts/core). Maybe we should pool our efforts? I’m pretty sure everybody in this forum wrote some of these at some point …

1 Like

To better handle breaches of normalize()’s current contract, I’ve expanded the function returning the root path mount point to this:

function rootDrive () {
  const expanded = nova.path.normalize('/') // expands to “/Volumes/<Drive name>”
  if (expanded !== '/') return expanded // … but that is undocumented behaviour
  const macDefault = nova.path.join('/', 'Volumes', 'Macintosh HD')
  if (nova.fs.stat(macDefault).isSymbolicLink()) return macDefault
  throw new Error('Unable to locate mount point for root path “/”')
}

That should smooth over possible API changes as users who haven’t renamed the OS drive should not be affected at all, and all others at least get an error message, which should lead to issue reporting. The module is only pushed to my dev branch yet, but anybody fancying a look can peek into that.

EDIT: corrected fallback path creation.

3 Likes

Thanks @apexskier. I ended up doing something similar to this for now. I might have been overthinking this issue.

Thanks all for the great suggestions! I love the idea of creating a collection of shared utility functions and would be happy to contribute in any way I can.

1 Like

Heads up to anybody still interested in this topic, Nova 9 changed the contract for its path expansion APIs (i.e. normalize() and expanduser()). Where, before, they would return a path that included the “/Volumes” mount point, they now return bog standard *nix paths with “/” as the root.

If you need a “nixalised” version of your path, starting with Nova 9, path.normalize() is the way. If you still need to get the root mount point, the following modification of my code works on both old and new versions of Nova:

function rootDrive () {
  // Until Nova 9, path expansion functions like “normalize()” and “expanduser()”
  // returned paths including the root volume mount point (by default,
  // “/Volumes/Macintosh HD”). Expanding “/” thus gave us the mount point.
  const expanded = nova.path.normalize(sep)
  if (expanded !== sep) return expanded

  // Since Nova 9, path expansion functions like “normalize()” and “expanduser()”
  // return paths in standard *nix notation, i.e. anchored at “/”. Normalising
  // the mount point gives us “/”, while other volumes are unaffected.
  const root = nova.path.join(sep, 'Volumes')
  for (const name of nova.fs.listdir(root)) {
    const path = nova.path.join(root, name)
    if (nova.path.normalize(path) === sep) return path
  }

  // Our Hail Mary against contract breaches.
  const macDefault = nova.path.join(root, 'Macintosh HD')
  if (nova.fs.stat(macDefault).isSymbolicLink()) return macDefault
  throw new Error(`Unable to locate mount point for root path “${sep}”`)
}

(sep is a module level constant with a value of “/”). Also, if you need to consistently get *nix root type paths on versions of Nova below and above 9, this will work:

function nixalize (path) {
  const normalised = nova.path.normalize(path)
  if (!nova.path.isAbsolute(normalised)) return normalised

  const parts = nova.path.split(normalised)
  if (parts.length < 3 || parts[1] !== 'Volumes') return normalised

  const root = rootDrive()
  const same = nova.path.split(root).every((el, idx) => parts[idx] === el)
  return same ? nova.path.join(sep, ...parts.slice(3)) : normalised
}

Shameless plug: the reason I noticed this is that I am currently writing a kind of meta-extension – a Nova extension for Nova extension development, tentatively called NovaNova. One part of it is a library of utility functions, compatibility components and higher level abstractions for several areas of extension development than the raw Nova API provides, basically pulled out of other extensions I am working on. The functions above are part of that.

The other part is the integration of the QUnit test framework into Nova, and by this, I do not mean the ability to run QUnit tests from Nova, but the ability to test extension code running in the Nova runtime. As a start, I had written some tests for the library, notably one that tested the contract for nova.path.expand('/'). Guess what failed right after updating to Nova 9 …

EDIT: Aaaaaaand guess what contract change was rolled back in 9.1? Yup. Got it in one. I was wrong about t this, the contract change has not been rolled back in 9.1. TL;DR:

  • Nova path expansion functions return a path including the notional root volume mount point (/Volumes /Macintosh HD by default) in Nova versions < 9 && >= 9.1.
  • Nova path expansion functions return a path anchored at the *nix root (/, i.e. without the notional root volume mount point) on Nova version >= 9.

My function above works in both cases.