diff --git a/.changeset/witty-jays-jump.md b/.changeset/witty-jays-jump.md new file mode 100644 index 000000000..925b333ed --- /dev/null +++ b/.changeset/witty-jays-jump.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 727 +--- +**fish-shell support in the post-install PATH suggestion.** When a directory is not on your PATH, the installer now prints a fish-native `fish_add_path ''` line alongside the zsh/bash suggestions (the previous `export PATH=…` commands are inert in fish). It also stops the false-positive "not on your PATH" warning for fish users whose `fish_user_paths`/`config.fish` already covers the directory, detected via a read-only probe of fish's config (no fish subprocess, no writes). No change for bash/zsh/PowerShell/cmd/Git-Bash users. diff --git a/bin/install.js b/bin/install.js index d9cbfeacf..66a82f8c7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -11793,6 +11793,189 @@ function homePathCoveredByRc(globalBin, homeDir, rcFileNames) { return false; } +/** + * Decode fish's universal-variable value escaping (the inverse of fish's + * `full_escape`). fish serializes every non-`[A-Za-z0-9/_]` byte in + * `fish_variables` — e.g. space -> `\x20`, hyphen -> `\x2d`, dot -> `\x2e` — + * and joins list elements with the literal 4-char token `\x1e` (NOT a raw + * 0x1e byte). Callers split on `\x1e` first, then decode each element here. + * + * Pure and total: any unrecognised `\`-sequence is passed through verbatim, + * so `decode(fishEscape(p)) === p` holds for every path string. Exported for + * a fast-check round-trip property test (#323). + * + * @param {string} s A single (already `\x1e`-split) escaped value. + * @returns {string} The decoded literal. + */ +function decodeFishUniversalValue(s) { + let out = ''; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c !== '\\') { out += c; continue; } + const n = s[i + 1]; + if (n === 'n') { out += '\n'; i += 1; } + else if (n === 'r') { out += '\r'; i += 1; } + else if (n === 't') { out += '\t'; i += 1; } + else if (n === '\\') { out += '\\'; i += 1; } + else if (n === 'x' || n === 'X') { + const hex = s.slice(i + 2, i + 4); + if (/^[0-9a-fA-F]{2}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 3; } + else { out += c; } + } else if (n === 'u') { + const hex = s.slice(i + 2, i + 6); + if (/^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 5; } + else { out += c; } + } else if (n === 'U') { + const hex = s.slice(i + 2, i + 10); + if (/^[0-9a-fA-F]{8}$/.test(hex)) { out += String.fromCodePoint(parseInt(hex, 16)); i += 9; } + else { out += c; } + } else { out += c; } + } + return out; +} + +/** + * Check whether fish's configuration already places `globalBin` on PATH (#323). + * + * fish does not use the sh-style `export PATH=` rc files that + * `homePathCoveredByRc()` parses, so a fish user whose `fish_user_paths` + * already covers the global bin would otherwise see a false-positive + * "not on your PATH" warning on every install. Two detection routes, + * mirroring how `fish_add_path` actually persists: + * + * 1. The universal-variable store `fish_variables` — a + * `SETUVAR fish_user_paths:\x1e…` line whose `\x1e`-separated + * entries are absolute paths (fish does not HOME-expand them here). + * 2. `config.fish` — explicit `fish_add_path …`, `set -gx PATH …`, or + * `set -Ux fish_user_paths …` lines that name the directory after + * HOME expansion. + * + * Best-effort and side-effect-free: any unreadable / missing file is ignored + * (no fish subprocess is spawned). Honours `$XDG_CONFIG_HOME` and always also + * checks `~/.config/fish`. Pass `fishConfigDir` to override the lookup + * directory (tests). + * + * @param {string} globalBin Absolute path to npm's global bin directory. + * @param {string} homeDir Absolute path used to substitute HOME / ~. + * @param {string} [fishConfigDir] Override the fish config directory. + * @returns {boolean} true iff fish config adds globalBin to PATH. + */ +function homePathCoveredByFishConfig(globalBin, homeDir, fishConfigDir) { + if (!globalBin || !homeDir) return false; + const path = require('path'); + const fs = require('fs'); + + const normalise = (p) => { + if (!p) return ''; + let n = p.replace(/[\\/]+$/g, ''); + if (n === '') n = p.startsWith('/') ? '/' : p; + return n; + }; + + const targetAbs = normalise(path.resolve(globalBin)); + const homeAbs = path.resolve(homeDir); + + const baseDirs = []; + if (fishConfigDir) { + baseDirs.push(fishConfigDir); + } else { + if (process.env.XDG_CONFIG_HOME) { + baseDirs.push(path.join(process.env.XDG_CONFIG_HOME, 'fish')); + } + baseDirs.push(path.join(homeAbs, '.config', 'fish')); + } + + const expandHome = (segment) => { + let s = segment; + s = s.replace(/\$\{HOME\}/g, homeAbs).replace(/\$HOME/g, homeAbs); + if (s.startsWith('~/') || s === '~') { + s = s === '~' ? homeAbs : path.join(homeAbs, s.slice(2)); + } + return s; + }; + + // Compare an already-resolved absolute literal (a decoded fish_user_paths + // entry — fish stores these resolved, never as `$VAR`/`~`). A literal `$` + // here is part of the directory name, so it must NOT be treated as an + // unexpanded variable. + const matchesLiteral = (segment) => { + if (!segment || !path.isAbsolute(segment)) return false; + try { + return normalise(path.resolve(segment)) === targetAbs; + } catch { + return false; + } + }; + + // Compare a config.fish shell token: strip surrounding quotes, expand the + // common HOME forms, and skip anything still holding a `$` (an unexpanded + // variable such as `$PATH` / `$fish_user_paths`) or still relative. + const matchesTarget = (rawSegment) => { + if (!rawSegment) return false; + let seg = rawSegment.trim(); + if ((seg.startsWith('"') && seg.endsWith('"')) || + (seg.startsWith("'") && seg.endsWith("'"))) { + seg = seg.slice(1, -1); + } + const expanded = expandHome(seg); + if (expanded.includes('$')) return false; + return matchesLiteral(expanded); + }; + + const readLines = (filePath) => { + try { + return fs.readFileSync(filePath, 'utf8').split(/\r?\n/); + } catch { + return null; + } + }; + + for (const baseDir of baseDirs) { + // Route 1: universal variable store. + const uvarLines = readLines(path.join(baseDir, 'fish_variables')); + if (uvarLines) { + for (const rawLine of uvarLines) { + const m = /^SETUVAR(?:\s+--\S+)*\s+fish_user_paths:(.*)$/.exec(rawLine); + if (!m) continue; + // Elements are joined by the literal `\x1e` token; decode each. The + // decoded entry is an absolute literal — compare it directly. + for (const entry of m[1].split('\\x1e')) { + if (matchesLiteral(decodeFishUniversalValue(entry))) return true; + } + } + } + + // Route 2: config.fish explicit PATH mutations. + const configLines = readLines(path.join(baseDir, 'config.fish')); + if (configLines) { + for (const rawLine of configLines) { + const line = rawLine.replace(/^\s+/, ''); + if (line.startsWith('#')) continue; + + let rest = null; + let m; + if ((m = /^fish_add_path\s+(.+)$/.exec(line))) { + rest = m[1]; + } else if ((m = /^set\s+(?:-\S+\s+)*PATH\s+(.+)$/.exec(line))) { + rest = m[1]; + } else if ((m = /^set\s+(?:-\S+\s+)*fish_user_paths\s+(.+)$/.exec(line))) { + rest = m[1]; + } + if (rest === null) continue; + + // Tokens are whitespace-separated; flag tokens (`-g`, `--path`) and + // variable references are skipped by matchesTarget / the `-` guard. + for (const tok of rest.split(/\s+/)) { + if (!tok || tok.startsWith('-')) continue; + if (matchesTarget(tok)) return true; + } + } + } + } + + return false; +} + /** * Emit a PATH-export suggestion if globalBin is not already on PATH AND * the user's shell rc files do not already cover it via a HOME-relative @@ -11835,6 +12018,16 @@ function maybeSuggestPathExport(globalBin, homeDir) { return; } + // Same idea for fish users: fish_user_paths / config.fish already covers the + // dir, the current session just predates it. fish has no sh-style rc file so + // homePathCoveredByRc never sees it — check the fish config explicitly (#323). + if (homePathCoveredByFishConfig(globalBin, homeDir)) { + console.log(''); + console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset}'s directory is already on your PATH via fish's universal variables — open a new fish session (or run ${cyan}exec fish${reset}).`); + console.log(''); + return; + } + console.log(''); console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`); console.log(` Add it with one of:`); @@ -12172,6 +12365,8 @@ module.exports = { USER_OWNED_ARTIFACTS, finishInstall, homePathCoveredByRc, + homePathCoveredByFishConfig, + decodeFishUniversalValue, maybeSuggestPathExport, runtimeMap, allRuntimes, diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index ddacd3353..daed18174 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -495,6 +495,16 @@ Restart your runtime to pick up new commands and agents. Then start your first p If the command is not found after restart, verify the install directory matches the runtime's expected config path. The prerelease-editions section above covers the most common mismatch. +### "… is not on your PATH" after install + +If the installer's global bin directory is not on your `PATH`, it prints a one-time warning with a copy-paste command for your shell. The suggestion list covers `zsh`, `bash`, and `fish` (plus PowerShell, cmd.exe, and Git Bash on Windows). For fish, run the line it prints: + +```fish +fish_add_path '/path/to/global/bin' +``` + +If the directory is already on your PATH but the installer still warns, open a new fish session (`exec fish`) to pick up the change. + --- ## Related diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index ea6d6d3d7..ca762b3ed 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -376,6 +376,16 @@ export function projectPathActionProjection({ shell: 'bash', command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.bashrc`, }, + // #323: fish has no `export`/`$PATH`-list syntax. `fish_add_path` is the + // fish-native API (>= fish 3.2, 2021) that persists to the universal + // variable store and de-duplicates. The directory is single-quoted with + // the same POSIX literal escaping as the zsh/bash siblings — `'\''` is + // also a valid escaped single quote in fish between quote spans. + { + label: 'fish', + shell: 'fish', + command: `fish_add_path '${bashTargetDir}'`, + }, ]; } else { const posixTargetDir = escapePosixDoubleQuoted(targetDir); diff --git a/tests/bug-3441-path-action-projection.test.cjs b/tests/bug-3441-path-action-projection.test.cjs index 21eabd900..dff34c31a 100644 --- a/tests/bug-3441-path-action-projection.test.cjs +++ b/tests/bug-3441-path-action-projection.test.cjs @@ -39,11 +39,36 @@ describe('bug #3441: PATH guidance is projected from typed shell action IR', () platform: 'linux', }); assert.ok(Array.isArray(posix.shellActions)); - assert.equal(posix.shellActions.length, 2); + assert.equal(posix.shellActions.length, 3); assert.equal(posix.shellActions[0].label, 'zsh'); assert.equal(posix.shellActions[1].label, 'bash'); + assert.equal(posix.shellActions[2].label, 'fish'); assert.ok(posix.shellActions[0].command.includes('~/.zshrc')); assert.ok(posix.shellActions[1].command.includes('~/.bashrc')); + // #323: fish gets a fish-native fish_add_path suggestion, not `export`. + assert.ok(posix.shellActions[2].command.startsWith('fish_add_path ')); + assert.ok(!posix.shellActions[2].command.includes('export')); + }); + + // #323 (ported from the closed #721): the fish suggestion is POSIX-only. + // On win32 the persist branch projects PowerShell / cmd.exe / Git Bash — + // no fish action — locking the POSIX-only contract. + test('no fish action is projected on win32', () => { + const win = projection.projectPathActionProjection({ + mode: 'persist', + targetDir: 'C:\\Users\\me\\AppData\\npm', + platform: 'win32', + }); + assert.ok(Array.isArray(win.shellActions)); + assert.equal( + win.shellActions.some((a) => a.shell === 'fish' || a.label === 'fish'), + false, + 'win32 persist projection must not include a fish action', + ); + assert.deepEqual( + win.shellActions.map((a) => a.label), + ['PowerShell', 'cmd.exe', 'Git Bash'], + ); }); test('POSIX repair mode escapes double-quoted shell metacharacters', () => { @@ -67,6 +92,9 @@ describe('bug #3441: PATH guidance is projected from typed shell action IR', () }); assert.equal(projected.shellActions[0].command.includes("/tmp/O'\\''Neil/bin"), true); assert.equal(projected.shellActions[1].command.includes("/tmp/O'\\''Neil/bin"), true); + // #323: fish entry single-quotes the dir with the same POSIX literal + // escaping (`'\''` is also a valid escaped quote in fish unquoted context). + assert.equal(projected.shellActions[2].command, "fish_add_path '/tmp/O'\\''Neil/bin'"); }); test('maybeSuggestPathExport renders commands projected by path-action seam', () => { diff --git a/tests/install-path-detection.test.cjs b/tests/install-path-detection.test.cjs index d6dbe731a..faf9e8022 100644 --- a/tests/install-path-detection.test.cjs +++ b/tests/install-path-detection.test.cjs @@ -15,6 +15,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const fc = require('./helpers/fast-check-setup.cjs'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); @@ -317,4 +318,323 @@ describe('installer HOME-relative PATH detection (#2620)', cleanup(home); } }); + + // #323 — fish has no sh-style `export PATH=` rc file, so homePathCoveredByRc + // can never see a fish user's PATH. homePathCoveredByFishConfig parses fish's + // universal-variable store (fish_variables) and config.fish so the installer + // does not emit a false-positive warning for fish users whose + // fish_user_paths already covers globalBin. + describe('fish-shell PATH coverage detection (#323)', () => { + function writeFishFile(home, name, content) { + const fishDir = path.join(home, '.config', 'fish'); + fs.mkdirSync(fishDir, { recursive: true }); + fs.writeFileSync(path.join(fishDir, name), content); + } + + // Mirror fish's universal-variable serialization (`full_escape`): every + // byte outside [A-Za-z0-9/_] is written as `\xHH`, and list elements are + // joined by the literal 4-char token `\x1e` — NOT a raw 0x1e byte. + // Verified against fish 3.7.0 output (space -> \x20, `-` -> \x2d, `.` -> + // \x2e). Fixtures use this so the decoder is tested against real format. + function fishEncodeUniversalList(paths) { + const esc = (p) => p.replace(/[^A-Za-z0-9/_]/g, (ch) => + '\\x' + ch.charCodeAt(0).toString(16).padStart(2, '0')); + return paths.map(esc).join('\\x1e'); + } + + test('homePathCoveredByFishConfig is exported', () => { + assert.strictEqual( + typeof installer.homePathCoveredByFishConfig, + 'function', + 'bin/install.js must export homePathCoveredByFishConfig for #323', + ); + }); + + test('detects fish_user_paths in the universal-variable store', () => { + const home = createTempHome(); + try { + const globalBin = path.join(home, '.nvm', 'bin'); + writeFishFile( + home, + 'fish_variables', + [ + 'SETUVAR --export LANG:en_US', + `SETUVAR fish_user_paths:${fishEncodeUniversalList([globalBin, '/usr/local/bin'])}`, + '', + ].join('\n'), + ); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), true); + } finally { + cleanup(home); + } + }); + + // Regression: fish escapes `-`, `.`, and space in the universal-variable + // store, so the detector must decode `\xHH` before comparing. A raw + // string match (the original, naive implementation) fails here. Mirrors a + // real nvm path (dots + hyphens) plus a space-containing sibling. + test('decodes fish-escaped paths (dots, hyphens, spaces) in fish_variables', () => { + const home = createTempHome(); + try { + const globalBin = path.join(home, '.nvm', 'versions', 'node', 'v24.15.0', 'bin'); + const spaced = path.join(home, 'my tools', 'bin'); + const encoded = fishEncodeUniversalList([spaced, globalBin]); + // Sanity: the fixture really is escaped, not a plain path. + assert.ok(encoded.includes('\\x2e') && encoded.includes('\\x20') && encoded.includes('\\x1e')); + writeFishFile(home, 'fish_variables', `SETUVAR fish_user_paths:${encoded}\n`); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), true); + assert.strictEqual(installer.homePathCoveredByFishConfig(spaced, home), true); + assert.strictEqual( + installer.homePathCoveredByFishConfig(path.join(home, 'not', 'there'), home), + false, + ); + } finally { + cleanup(home); + } + }); + + // Adversarial regression: fish stores a literal `$` in a directory name as + // `\x24` in the universal store, so the decoded entry contains `$`. That + // `$` is part of the path, not an unexpanded variable — the uvar route + // must still match it. (config.fish tokens keep the `$VAR` guard.) + test('detects a fish_user_paths entry whose directory name contains a literal $', () => { + const home = createTempHome(); + try { + const globalBin = path.join(home, 'has $VAR dir', 'bin'); + writeFishFile(home, 'fish_variables', `SETUVAR fish_user_paths:${fishEncodeUniversalList([globalBin])}\n`); + assert.ok(fishEncodeUniversalList([globalBin]).includes('\\x24'), 'fixture must encode $ as \\x24'); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), true); + } finally { + cleanup(home); + } + }); + + test('detects fish_add_path in config.fish (with flag)', () => { + const home = createTempHome(); + try { + const globalBin = path.join(home, '.nvm', 'bin'); + writeFishFile(home, 'config.fish', `fish_add_path -g ${globalBin}\n`); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), true); + } finally { + cleanup(home); + } + }); + + test('detects set -gx PATH in config.fish', () => { + const home = createTempHome(); + try { + const globalBin = path.join(home, '.nvm', 'bin'); + writeFishFile(home, 'config.fish', `set -gx PATH $PATH ${globalBin}\n`); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), true); + } finally { + cleanup(home); + } + }); + + test('detects set -Ux fish_user_paths in config.fish', () => { + const home = createTempHome(); + try { + const globalBin = path.join(home, '.nvm', 'bin'); + writeFishFile(home, 'config.fish', `set -Ux fish_user_paths ${globalBin} /usr/bin\n`); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), true); + } finally { + cleanup(home); + } + }); + + test('ignores commented-out fish_add_path lines', () => { + const home = createTempHome(); + try { + const globalBin = path.join(home, '.nvm', 'bin'); + writeFishFile(home, 'config.fish', `# fish_add_path ${globalBin}\n`); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), false); + } finally { + cleanup(home); + } + }); + + test('returns false when fish config does not cover globalBin', () => { + const home = createTempHome(); + try { + writeFishFile(home, 'config.fish', 'fish_add_path /opt/some/other/bin\n'); + const globalBin = path.join(home, '.nvm', 'bin'); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), false); + } finally { + cleanup(home); + } + }); + + test('returns false when no fish config exists', () => { + const home = createTempHome(); + try { + const globalBin = path.join(home, '.nvm', 'bin'); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), false); + } finally { + cleanup(home); + } + }); + + test('does not resolve a bare relative fish_add_path segment against HOME', () => { + const home = createTempHome(); + try { + writeFishFile(home, 'config.fish', 'fish_add_path bin\n'); + const globalBin = path.join(home, 'bin'); + assert.strictEqual( + installer.homePathCoveredByFishConfig(globalBin, home), + false, + 'relative fish_add_path segments must not be resolved against $HOME', + ); + } finally { + cleanup(home); + } + }); + + test('swallows an unreadable fish config without throwing', () => { + const home = createTempHome(); + try { + const fishDir = path.join(home, '.config', 'fish'); + fs.mkdirSync(fishDir, { recursive: true }); + fs.mkdirSync(path.join(fishDir, 'config.fish')); // dir where a file is expected + const globalBin = path.join(home, '.nvm', 'bin'); + assert.doesNotThrow(() => installer.homePathCoveredByFishConfig(globalBin, home)); + assert.strictEqual(installer.homePathCoveredByFishConfig(globalBin, home), false); + } finally { + cleanup(home); + } + }); + + test('maybeSuggestPathExport suppresses suggestion when fish config covers globalBin', () => { + const home = createTempHome(); + const origPath = process.env.PATH; + try { + const globalBin = path.join(home, '.nvm', 'bin'); + fs.mkdirSync(globalBin, { recursive: true }); + // globalBin not on the current PATH, no sh rc files — only fish covers it. + process.env.PATH = '/usr/bin'; + writeFishFile(home, 'fish_variables', `SETUVAR fish_user_paths:${fishEncodeUniversalList([globalBin])}\n`); + + const logs = []; + const origLog = console.log; + console.log = (...args) => { logs.push(args.join(' ')); }; + try { + installer.maybeSuggestPathExport(globalBin, home); + } finally { + console.log = origLog; + } + + const joined = logs.join('\n'); + assert.ok( + !/fish_add_path/.test(joined) && !/Add it with one of/.test(joined), + `installer should not emit a PATH suggestion when fish already covers it; got:\n${joined}`, + ); + assert.ok( + /universal variables/.test(joined), + `installer should print the fish reopen note; got:\n${joined}`, + ); + } finally { + if (origPath === undefined) delete process.env.PATH; else process.env.PATH = origPath; + cleanup(home); + } + }); + + test('maybeSuggestPathExport emits fish_add_path suggestion when nothing covers globalBin', () => { + const home = createTempHome(); + const origPath = process.env.PATH; + try { + const globalBin = path.join(home, '.npm-global', 'bin'); + fs.mkdirSync(globalBin, { recursive: true }); + process.env.PATH = '/usr/bin'; + + const logs = []; + const origLog = console.log; + console.log = (...args) => { logs.push(args.join(' ')); }; + try { + installer.maybeSuggestPathExport(globalBin, home); + } finally { + console.log = origLog; + } + + const joined = logs.join('\n'); + const projected = projection.projectPersistentPathExportActions({ + targetDir: globalBin, + platform: process.platform, + }); + const fishAction = projected.shellActions.find((a) => a.shell === 'fish'); + assert.ok(fishAction, 'projection must include a fish action'); + assert.ok( + joined.includes(fishAction.command), + `installer should render the projected fish command "${fishAction.command}". Output:\n${joined}`, + ); + } finally { + if (origPath === undefined) delete process.env.PATH; else process.env.PATH = origPath; + cleanup(home); + } + }); + }); +}); + +// #323 — property-based coverage for the fish universal-variable decoder. +// `decodeFishUniversalValue` is the inverse of fish's `full_escape`; the +// example-based cases above cover dot/hyphen/space/$/unicode, this locks the +// bijection itself: decode(fishEscape(p)) === p over arbitrary strings. +// Platform-agnostic (a pure string transform), so this block is NOT skipped on +// Windows — unlike the rc/fish-config probes above. +describe('decodeFishUniversalValue: round-trip properties (#323)', () => { + let installer; + before(() => { installer = loadInstaller(); }); + + // Faithful inverse of decodeFishUniversalValue, matching fish's full_escape: + // keep [A-Za-z0-9/_] literal, \xHH for code units <= 0xFF, \uXXXX otherwise + // (each UTF-16 code unit is <= 0xFFFF, so astral code points encode as their + // two surrogate units and decode back identically). + function fishEscape(value) { + let out = ''; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (/[A-Za-z0-9/_]/.test(ch)) { out += ch; continue; } + const code = value.charCodeAt(i); + out += code <= 0xff + ? '\\x' + code.toString(16).padStart(2, '0') + : '\\u' + code.toString(16).padStart(4, '0'); + } + return out; + } + + test('decodeFishUniversalValue is exported', () => { + assert.strictEqual(typeof installer.decodeFishUniversalValue, 'function'); + }); + + // The bijection across arbitrary unicode (spaces, dots, hyphens, $, quotes, + // astral code points). + test('decode(fishEscape(p)) === p for arbitrary strings', () => { + fc.assert( + fc.property(fc.string({ unit: 'binary', maxLength: 64 }), (p) => { + assert.strictEqual(installer.decodeFishUniversalValue(fishEscape(p)), p); + }), + ); + }); + + // Realistic shape: absolute POSIX paths built from arbitrary segments — the + // actual fish_user_paths entries the detector compares — still round-trip. + test('decode(fishEscape(absPath)) === absPath for arbitrary path segments', () => { + const segment = fc.string({ unit: 'binary', minLength: 1, maxLength: 24 }) + .filter((s) => !s.includes('/')); + fc.assert( + fc.property(fc.array(segment, { minLength: 1, maxLength: 5 }), (segs) => { + const abs = '/' + segs.join('/'); + assert.strictEqual(installer.decodeFishUniversalValue(fishEscape(abs)), abs); + }), + ); + }); + + // Total function: any unrecognised `\`-sequence (incl. truncated escapes) + // passes through verbatim and it never throws. + test('never throws and is total over arbitrary escaped input', () => { + fc.assert( + fc.property(fc.string({ unit: 'binary', maxLength: 64 }), (raw) => { + assert.doesNotThrow(() => installer.decodeFishUniversalValue(raw)); + assert.strictEqual(typeof installer.decodeFishUniversalValue(raw), 'string'); + }), + ); + }); });