/** * Async completion engine for the Fish-style REPL (see {@link ./fish-readline.js}). * Gathers candidates from VFS, man.json, /proc, PATH, history signals — no TTY output. * `for NAME in …` gets **`in`** keyword completion and pathname-style completion for words after **`in`** (before **`do`**). */ import { expandArgvAliases, listBareOsShellBuiltins, tokenize } from '../shell/shell.js' import { bareOsFnmatch } from '../shell/shell-glob.js' import { bareLsColorOpenSgrFromMap, bareParseLsColors } from 'bare-os-lscolors' /** @typedef {{ type: 'word' | 'op', value: string }} ShellTok */ /** Fallback flags when man.options is empty */ export const COMPLETION_FLAG_MAP = { ls: ['-a', '-l', '-la', '-h', '-1', '-F', '-i', '--color'], grep: ['-i', '-v', '-n', '-c', '-r', '-E', '-F', '--color'], find: ['-name', '-type', '-size', '-path', '-iname', '-print0', '-delete'], sort: ['-r', '-n', '-u', '-h', '-V', '-f', '-k', '-t'], rm: ['-r', '-f', '-rf', '-d', '-v'], curl: ['-f', '-s', '-S', '-L', '-o', '-O', '-I', '-H', '-d', '-X'], wget: ['-q', '-O', '-c', '-S', '-T', '-U'], tar: ['-c', '-x', '-t', '-f', '-v', '-z', '-j', '-J', '-C'], ps: ['-e', '-f', '-u', '-p', '-o'], df: ['-h', '-T', '-i'], du: ['-h', '-s', '-a', '-d', '-c'], cat: ['-n', '-A', '-b', '-e', '-t', '-v'], sed: ['-n', '-e', '-i', '-r', '-E'], awk: ['-F', '-f', '-v'], head: ['-n', '-c', '-v'], tail: ['-n', '-c', '-f', '-F'], xargs: ['-0', '-n', '-I', '-P', '-r'], chmod: ['-R', '-v', '-c'], chown: ['-R', '-h'], kill: ['-l', '-s'] } const VFS_TIMEOUT_MS = 220 const PROC_TABLE_CACHE_MS = 400 const READDIR_CAP = 800 const STAT_CONCURRENCY = 12 const MENU_CAP = 200 /** * @param {unknown} buf * @param {Record} ctx */ function vfsBufToString(buf, ctx) { if (buf == null) return '' if (typeof buf === 'string') return buf const b4a = ctx.b4a if (b4a && typeof b4a.toString === 'function') return b4a.toString(buf) return String(buf) } const REDIRECT_OPS = new Set([ '>', '>>', '<', '2>', '2>>', '>&', '2>&1' ]) const INSPECT_COMMANDS = new Set([ 'cat', 'less', 'more', 'head', 'tail', 'hexdump', 'od', 'jq', 'stat', 'wc', 'sort', 'grep', 'sed', 'awk', 'strings', 'file' ]) const DIR_ONLY_COMMANDS = new Set(['cd', 'rmdir', 'pushd', 'popd']) const MAN_PATH = '/share/man/man.json' const PROCESS_TABLE_PATH = '/proc/bare_os/process_table.json' const METRICS_LIVE_PATH = '/proc/bare_os/metrics_live.json' const INITD_UNITS_PATH = '/run/bare-os/units' const PROC_BARE_OS = '/proc/bare_os' /** @type {WeakMap} */ const procTableCache = new WeakMap() /** * @template T * @param {Promise} p * @param {number} ms * @returns {Promise} */ export async function withVfsTimeout(p, ms = VFS_TIMEOUT_MS) { let to = null try { return await Promise.race([ p, new Promise((_, rej) => { to = setTimeout(() => rej(new Error('vfs-timeout')), ms) }) ]) } catch { return null } finally { if (to) clearTimeout(to) } } /** * @param {string} a * @param {string} b * @param {number} cap */ export function levenshtein(a, b, cap = 32) { const s = a.length > cap ? a.slice(0, cap) : a const t = b.length > cap ? b.slice(0, cap) : b const n = s.length const m = t.length if (n === 0) return m if (m === 0) return n /** @type {number[]} */ let row = [] for (let j = 0; j <= m; j++) row[j] = j for (let i = 1; i <= n; i++) { let prev = row[0] row[0] = i for (let j = 1; j <= m; j++) { const cur = row[j] const cost = s[i - 1] === t[j - 1] ? 0 : 1 row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost) prev = cur } } return row[m] } /** * @param {string} str * @param {string} pattern */ export function fuzzySubsequence(str, pattern) { let j = 0 for (let i = 0; i < str.length && j < pattern.length; i++) { if (str[i].toLowerCase() === pattern[j].toLowerCase()) j++ } return j === pattern.length } /** * @param {string} line * @param {number} cursor */ export function parseCompletionContext(line, cursor) { const c = Math.max(0, Math.min(cursor, line.length)) const prefix = line.slice(0, c) const endsWithWhitespace = prefix.length > 0 && /\s/.test(prefix[prefix.length - 1]) const parseInput = endsWithWhitespace ? prefix.trimEnd() : prefix /** @type {ShellTok[]} */ const all = /** @type {ShellTok[]} */ (tokenize(parseInput)) let pipeIdx = -1 for (let i = 0; i < all.length; i++) { if (all[i].type === 'op' && all[i].value === '|') pipeIdx = i } const seg = pipeIdx < 0 ? all : all.slice(pipeIdx + 1) /** @type {ShellTok[]} */ const words = [] for (const t of seg) { if (t.type === 'word') words.push(t) } let currentWord = '' let argIndex = 0 if (endsWithWhitespace) { argIndex = words.length currentWord = '' } else if (words.length) { argIndex = words.length - 1 currentWord = words[words.length - 1].value } let redirectTarget = false if (endsWithWhitespace && seg.length) { const last = seg[seg.length - 1] if (last.type === 'op' && REDIRECT_OPS.has(last.value)) redirectTarget = true } else if (!endsWithWhitespace && words.length >= 1) { const lastWordIdx = seg.lastIndexOf(words[words.length - 1]) if (lastWordIdx > 0) { const prev = seg[lastWordIdx - 1] if (prev && prev.type === 'op' && REDIRECT_OPS.has(prev.value)) redirectTarget = true } } const firstWord = words[0] ? words[0].value : '' return { prefix, endsWithWhitespace, segmentTokens: seg, words, argIndex, currentWord, redirectTarget, firstWord, pipelineSegment: true } } /** * Index of the **`in`** keyword in `for NAME in WORDS…` (must be at least word index 2). * @param {string[]} wordVals */ function bareOsForInKeywordIndex(wordVals) { if (!wordVals.length || wordVals[0] !== 'for') return -1 for (let i = 2; i < wordVals.length; i++) { if (wordVals[i] === 'in') return i } return -1 } /** * Cursor is completing a word in the `for … in` word-list (after `in`, before `do`). * @param {string[]} wordVals * @param {number} argIndex */ function bareOsForInListArg(wordVals, argIndex) { const inIdx = bareOsForInKeywordIndex(wordVals) if (inIdx < 0 || argIndex <= inIdx) return false const doIdx = wordVals.indexOf('do', inIdx + 1) if (doIdx >= 0 && argIndex >= doIdx) return false return true } /** * @param {string} name * @param {Record | null | undefined} aliases */ export function resolveAliasFirstName(name, aliases) { const n = String(name || '') const map = aliases && typeof aliases === 'object' ? aliases : {} try { const exp = expandArgvAliases([n], map) return exp[0] || n } catch { return n } } /** * @param {unknown} db * @param {string} cmd */ export function manPageForCommand(db, cmd) { if (!db || typeof db !== 'object') return null const pages = /** @type {{ pages?: unknown[], index?: Record }} */ ( db ).pages const index = /** @type {Record | undefined} */ ( /** @type {{ index?: Record }} */ (db).index ) if (!Array.isArray(pages) || !index || typeof index[cmd] !== 'number') return null const p = pages[index[cmd]] return p && typeof p === 'object' ? /** @type {Record} */ (p) : null } /** * @param {Record} page */ export function extractLongFlagsFromManText(page) { const blob = [page.synopsis, page.description] .flat() .filter((x) => typeof x === 'string') .join('\n') const found = new Set() const re = /--[a-zA-Z][a-zA-Z0-9_-]*/g let m while ((m = re.exec(blob))) { if (m[0].length <= 64) found.add(m[0]) } return [...found] } /** * @param {Record} page */ export function extractSubcommandsHeuristic(page) { const text = String(page.description || '') + '\n' + String(page.title || '') const out = new Set() const m1 = /(?:subcommands?|commands?):\s*([^\n.]+)/i.exec(text) if (m1) { for (const part of m1[1].split(/[,;|]/)) { const s = part.trim().replace(/^[`'"]+|[`'"]+$/g, '') if (/^[a-z][a-z0-9_-]{0,31}$/i.test(s)) out.add(s) } } return [...out] } /** * @param {Record} ctx */ export async function loadBareOsManDb(ctx) { const vfs = ctx.vfs if (!vfs || typeof vfs.readFile !== 'function') return null const key = '__bareOsManDbParsed' if (ctx[key]) return ctx[key] const buf = await withVfsTimeout(vfs.readFile(MAN_PATH)) if (buf == null) return null const text = vfsBufToString(buf, ctx) try { const j = JSON.parse(text) ctx[key] = j return j } catch { return null } } /** * @param {string} modeOct */ function modeToStr(modeOct) { const m = Number(modeOct) & 0o777 const r = (x) => (x & 4 ? 'r' : '-') + (x & 2 ? 'w' : '-') + (x & 1 ? 'x' : '-') return r((m >> 6) & 7) + r((m >> 3) & 7) + r(m & 7) } /** * @typedef {{ * value: string, * description?: string, * kind?: string, * ansiLabel?: string, * manFlag?: string, * scoreBase?: number * }} CompletionItem */ /** * @param {Record} ctx * @param {Record} env * @param {ReturnType} cx * @param {{ * binNames: string[], * pathNames: string[], * builtins: string[], * lsColorsMap: Record, * manDb: unknown | null, * registry?: Map, x: Record) => Promise> * }} sources * @returns {Promise} */ export async function gatherCompletionItems(ctx, env, cx, sources) { const vfs = ctx.vfs if (!vfs || typeof vfs.readdir !== 'function') return [] const { currentWord, argIndex, firstWord, redirectTarget, endsWithWhitespace } = cx const aliases = ctx.shellAliases && typeof ctx.shellAliases === 'object' ? /** @type {Record} */ (ctx.shellAliases) : {} /** @type {CompletionItem[]} */ const raw = [] const resolvedCmd = resolveAliasFirstName(firstWord, aliases) const manKey = resolvedCmd.replace(/\.js$/i, '') const pushCand = ( value, description, kind, ansiLabel, scoreBase = 0, manFlag ) => { raw.push({ value, description, kind, ansiLabel: ansiLabel || value, scoreBase, manFlag }) } /** * @param {string} cw * @param {boolean} wantDirsOnly * @param {{ scoreBoost?: number }} [pathOpts] */ async function collectPathCompletions(cw, wantDirsOnly, pathOpts) { const sb = pathOpts && typeof pathOpts.scoreBoost === 'number' ? pathOpts.scoreBoost : 0 const globPat = /[*?[]/.test(cw) ? cw : null let dir = '.' let filePrefix = cw const home = env.HOME || '/home/guest' if (cw.startsWith('~/')) { filePrefix = cw.slice(2) dir = home } else if (cw === '~') { pushCand('~/', 'home', 'path', '~/', 5) return } else if (cw.includes('/')) { const lastSlash = cw.lastIndexOf('/') dir = cw.slice(0, lastSlash) || (cw.startsWith('/') ? '/' : '.') filePrefix = cw.slice(lastSlash + 1) if (dir === '') dir = '/' } let entries = [] try { const r = await withVfsTimeout(vfs.readdir(dir)) if (Array.isArray(r)) entries = r.slice(0, READDIR_CAP) } catch { entries = [] } const matchName = (name) => { if (globPat) return bareOsFnmatch(name, globPat) if (!filePrefix) return true if (name.startsWith(filePrefix)) return true return fuzzySubsequence(name, filePrefix) } const matched = entries.filter(matchName) const statOne = async (name) => { const sep = dir.endsWith('/') ? '' : '/' const full = dir === '.' ? name : dir + sep + name try { const st = await withVfsTimeout(vfs.stat(full), 80) return { name, full, st } } catch { return { name, full, st: null } } } for (let i = 0; i < matched.length; i += STAT_CONCURRENCY) { const chunk = matched.slice(i, i + STAT_CONCURRENCY) const stats = await Promise.all(chunk.map(statOne)) for (const { name, st } of stats) { const isDir = st && st.type === 'directory' if (wantDirsOnly && st && !isDir) continue const suffix = isDir ? '/' : '' const insert = dir === '.' ? name + suffix : (dir.endsWith('/') ? dir : dir + '/') + name + suffix let desc = isDir ? 'directory' : 'file' if (st && typeof st.mode === 'number') { desc += ' ' + modeToStr(st.mode) if (st.type === 'symlink') desc = 'symlink ' + modeToStr(st.mode) } const open = bareLsColorOpenSgrFromMap( st ? { type: st.type, mode: st.mode, nlink: st.nlink, targetMissing: st.type === 'symlink' ? false : undefined } : null, name, sources.lsColorsMap ) const ansiLabel = open + name + suffix + '\x1b[0m' pushCand(insert, desc, 'path', ansiLabel, (isDir ? 2 : 1) + sb) } } } const wordVals = cx.words.map((w) => w.value) if (wordVals[0] === 'for') { if (bareOsForInListArg(wordVals, argIndex)) { await collectPathCompletions(currentWord, false, { scoreBoost: 55 }) return raw } if ( argIndex === 2 && (wordVals.length === 2 || (wordVals.length >= 3 && wordVals[2] !== 'in')) ) { const cw = endsWithWhitespace ? '' : currentWord if (!cw || 'in'.startsWith(cw) || fuzzySubsequence('in', cw)) { pushCand('in', 'for loop keyword', 'builtin', 'in', 130) } return raw } if (argIndex === 1 && !endsWithWhitespace && currentWord) { for (const k of Object.keys(env)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue if ( k.startsWith(currentWord) || fuzzySubsequence(k, currentWord) ) { pushCand(k, 'env name (loop variable)', 'env', k, 62) } } return raw } } if (argIndex === 0) { const cw = currentWord if (cw && !/[*?[]/.test(cw)) { await collectPathCompletions(cw, false, { scoreBoost: 108 }) } const bins = sources.binNames const seen = new Set() for (const b of sources.builtins) { if (!cw || b.startsWith(cw) || fuzzySubsequence(b, cw)) { pushCand(b, 'shell builtin', 'builtin', b, b.startsWith(cw) ? 100 : 40) seen.add(b) } } for (const name of Object.keys(aliases)) { if (!cw || name.startsWith(cw) || fuzzySubsequence(name, cw)) { const preview = (aliases[name] || '').slice(0, 48) pushCand( name, 'alias → ' + preview, 'alias', name, name.startsWith(cw) ? 95 : 38 ) seen.add(name) } } for (const b of bins) { if (seen.has(b)) continue if (!cw || b.startsWith(cw) || fuzzySubsequence(b, cw)) { pushCand(b, '/bin', 'bin', b, b.startsWith(cw) ? 90 : 35) seen.add(b) } } for (const p of sources.pathNames) { if (seen.has(p)) continue if (!cw || p.startsWith(cw) || fuzzySubsequence(p, cw)) { pushCand(p, 'PATH', 'pathcmd', p, p.startsWith(cw) ? 85 : 30) } } if (cw === 'man' && endsWithWhitespace) { const db = sources.manDb const idx = db && typeof db === 'object' && /** @type {{ index?: Record }} */ (db).index ? /** @type {Record} */ ( /** @type {{ index: Record }} */ (db).index ) : null if (idx) { for (const k of Object.keys(idx)) { pushCand(k, 'man page', 'manpage', k, 50) } } } return raw } if (manKey === 'man' && argIndex >= 1) { const db = sources.manDb const idx = db && typeof db === 'object' && /** @type {{ index?: Record }} */ (db).index ? /** @type {Record} */ ( /** @type {{ index: Record }} */ (db).index ) : null const cw = currentWord if (idx) { for (const k of Object.keys(idx)) { if (!cw || k.startsWith(cw) || fuzzySubsequence(k, cw)) { pushCand(k, 'man page', 'manpage', k, k.startsWith(cw) ? 92 : 50) } } } return raw } if (argIndex >= 1 && (manKey === 'kill' || manKey === 'wait')) { const now = Date.now() let j = null const ent = procTableCache.get(ctx) if (ent && now - ent.at < PROC_TABLE_CACHE_MS) j = ent.json else { try { const buf = await withVfsTimeout(vfs.readFile(PROCESS_TABLE_PATH)) const text = vfsBufToString(buf, ctx) j = JSON.parse(text) procTableCache.set(ctx, { at: now, json: j }) } catch { j = null } } const rows = j && typeof j === 'object' && Array.isArray(j.processes) ? j.processes : j && Array.isArray(j) ? j : [] const cw = currentWord for (const row of rows) { if (!row || typeof row !== 'object') continue const pid = String( /** @type {{ pid?: unknown }} */ (row).pid ?? '' ) const cmd = String( /** @type {{ name?: string, cmd?: string }} */ (row).name || /** @type {{ cmd?: string }} */ (row).cmd || '' ) if (!pid) continue if ( !cw || pid.startsWith(cw) || cmd.toLowerCase().includes(cw.toLowerCase()) || fuzzySubsequence(pid, cw) || fuzzySubsequence(cmd, cw) ) { pushCand(pid, cmd.slice(0, 60) || 'process', 'process', pid, 90) } } return raw } if (currentWord.startsWith('$')) { const varPart = currentWord.slice(1).replace(/^\{/, '').replace(/\}$/, '') for (const k of Object.keys(env)) { const v = '$' + k if ( !varPart || k.startsWith(varPart) || fuzzySubsequence(k, varPart) ) { pushCand(v, String(env[k] ?? '').slice(0, 40), 'env', v, 80) } } return raw } if ( argIndex >= 1 && currentWord.startsWith('-') && !redirectTarget ) { const page = sources.manDb != null ? manPageForCommand(sources.manDb, manKey) : null const seen = new Set() if (page && Array.isArray(page.options)) { for (const o of page.options) { if (!o || typeof o !== 'object') continue const flag = String(/** @type {{ flag?: string }} */ (o).flag || '') const meaning = String( /** @type {{ meaning?: string }} */ (o).meaning || '' ) if ( flag && (!currentWord || flag.startsWith(currentWord) || fuzzySubsequence(flag, currentWord)) ) { seen.add(flag) pushCand(flag, meaning, 'flag', flag, flag.startsWith(currentWord) ? 100 : 45, flag) } } } const fb = COMPLETION_FLAG_MAP[manKey] if (fb) { for (const f of fb) { if (seen.has(f)) continue if ( !currentWord || f.startsWith(currentWord) || fuzzySubsequence(f, currentWord) ) { seen.add(f) pushCand(f, 'common option', 'flag', f, 42, f) } } } if (page) { for (const lf of extractLongFlagsFromManText(page)) { if (seen.has(lf)) continue if ( !currentWord || lf.startsWith(currentWord) || fuzzySubsequence(lf, currentWord) ) { seen.add(lf) pushCand(lf, 'from manual', 'flag', lf, 44, lf) } } } return raw } if (redirectTarget) { await collectPathCompletions(currentWord, false) return raw } if ( argIndex >= 1 && INSPECT_COMMANDS.has(manKey) && (currentWord.startsWith('/proc') || currentWord.includes('proc/bare') || currentWord.startsWith('proc/')) ) { try { const names = await withVfsTimeout(vfs.readdir(PROC_BARE_OS)) if (Array.isArray(names)) { const pref = currentWord.replace(/^\/+/, '') for (const n of names.slice(0, READDIR_CAP)) { const full = '/proc/bare_os/' + n if ( !currentWord || full.startsWith(currentWord) || full.includes(pref) ) { pushCand(full, 'proc node', 'proc', full, 55) } } } } catch { /* ignore */ } return raw } if (argIndex >= 1 && (manKey === 'bare-initd' || firstWord === 'initctl')) { try { const buf = await withVfsTimeout(vfs.readFile(INITD_UNITS_PATH)) const text = vfsBufToString(buf, ctx) for (const line of text.split('\n')) { if (!line || line.startsWith('#')) continue const name = line.split('\t')[0].trim() if ( name && (!currentWord || name.startsWith(currentWord) || fuzzySubsequence(name, currentWord)) ) { pushCand(name, 'initd unit', 'initd', name, 88) } } } catch { /* ignore */ } return raw } const reg = sources.registry if (reg && reg.has(manKey)) { const fn = reg.get(manKey) if (typeof fn === 'function') { try { const extra = await fn(ctx, { env, cx, manKey, resolvedCmd }) if (Array.isArray(extra)) raw.push(...extra) } catch { /* ignore user completer */ } } } if (argIndex >= 1 && !currentWord.startsWith('-')) { const wantDirs = DIR_ONLY_COMMANDS.has(manKey) && !redirectTarget await collectPathCompletions(currentWord, wantDirs) } if ( argIndex >= 1 && (currentWord.includes('metrics_live') || currentWord.startsWith('/proc/bare_os/m')) ) { try { const buf = await withVfsTimeout(vfs.readFile(METRICS_LIVE_PATH)) if (buf) { pushCand( METRICS_LIVE_PATH, 'live metrics snapshot', 'metrics', METRICS_LIVE_PATH, 20 ) } } catch { /* ignore */ } } const page = sources.manDb != null ? manPageForCommand(sources.manDb, manKey) : null if (page && argIndex >= 1 && raw.length < 48) { for (const sc of extractSubcommandsHeuristic(page)) { if ( !currentWord || sc.startsWith(currentWord) || fuzzySubsequence(sc, currentWord) ) { pushCand(sc, 'subcommand (manual)', 'subcmd', sc, 75) } } } return raw } /** * @param {CompletionItem[]} items * @param {string} currentWord * @param {{ * freq?: Map, * manKeywords?: string[], * lastExecuted?: string | null * }} signals */ /** VFS path/file rows sort above builtins and /bin (see gatherCompletionItems path scoreBoost). */ const KIND_RANK_BOOST = { path: 320, builtin: 0, alias: 0, bin: 0, pathcmd: 0, flag: 0, manpage: 0, process: 0, initd: 0, env: 0, subcmd: 0 } export function rankCompletionItems(items, currentWord, signals = {}) { const cw = currentWord || '' const kw = signals.manKeywords || [] const freq = signals.freq || new Map() const last = signals.lastExecuted return items .map((it) => { const v = it.value let score = (it.scoreBase ?? 0) + (KIND_RANK_BOOST[it.kind || ''] ?? 0) if (cw && v === cw) score += 400 if (cw && v.startsWith(cw)) score += 200 else if (cw && fuzzySubsequence(v, cw)) score += 120 else if (cw) score += Math.max(0, 80 - levenshtein(v, cw, 24) * 5) const f = freq.get(v) || 0 score += Math.min(40, f * 3) if (last && !v.includes('/') && /^[\w.-]+$/.test(v)) { const bi = freq.get(last + '\0' + v) if (bi) score += Math.min(25, bi * 4) } for (const k of kw) { if (v.includes(k) || k.includes(v)) score += 8 } return { it, score } }) .sort((a, b) => b.score - a.score) .map((x) => x.it) } /** * Longest shared prefix of completion values that extends the word under the cursor. * @param {CompletionItem[]} items * @param {ReturnType} cx */ export function longestCommonCompletionPrefix(items, cx) { if (!items || !items.length) return '' const cw = cx.endsWithWhitespace ? '' : cx.currentWord const vals = items.map((i) => String(i.value)) let p = vals[0] for (let i = 1; i < vals.length; i++) { const v = vals[i] let j = 0 while (j < p.length && j < v.length && p[j] === v[j]) j++ p = p.slice(0, j) } if (!p.startsWith(cw)) return '' return p.length > cw.length ? p : '' } /** * @param {Array<{ command: string } | string>} history */ export function buildHistorySignals(history) { const freq = new Map() let prevFirst = null for (const e of history) { const cmd = typeof e === 'string' ? e : e.command if (!cmd || typeof cmd !== 'string') continue const first = cmd.trim().split(/\s+/)[0] || '' if (first) freq.set(first, (freq.get(first) || 0) + 1) if (prevFirst && first) { const k = prevFirst + '\0' + first freq.set(k, (freq.get(k) || 0) + 1) } prevFirst = first } return freq } /** * Replace the shell word under the cursor with `newToken` (same boundary rules as tab completion). * @param {string} line * @param {number} cursor * @param {string} newToken */ export function ghostReplaceCurrentWord(line, cursor, newToken) { let start = Math.max(0, Math.min(cursor, line.length)) while (start > 0 && !/\s/.test(line[start - 1])) start-- const before = line.slice(0, start) const after = line.slice(cursor) return before + newToken + after } /** * Async ghost from VFS (cwd, `~/`, and relative/absolute path prefixes). Returns a **full line** * extending the current word when a unique or common-prefix path match exists. * @param {Record} ctx * @param {Record} env * @param {string} line * @param {number} cursor */ export async function suggestGhostFromFs(ctx, env, line, cursor) { const vfs = ctx.vfs if ( !vfs || typeof vfs.readdir !== 'function' || typeof vfs.stat !== 'function' ) { return '' } const cx = parseCompletionContext(line, cursor) if (cx.endsWithWhitespace) return '' const cw = cx.currentWord if (cw.startsWith('$')) return '' const aliases = ctx.shellAliases && typeof ctx.shellAliases === 'object' ? /** @type {Record} */ (ctx.shellAliases) : {} const resolvedCmd = resolveAliasFirstName(cx.firstWord, aliases) const manKey = resolvedCmd.replace(/\.js$/i, '') if (cx.argIndex >= 1 && (manKey === 'kill' || manKey === 'wait')) return '' const home = env.HOME || '/home/guest' /** * @param {string} dir * @param {string} filePrefix * @param {(name: string, isDir: boolean) => string} makeWord */ async function tryDir(dir, filePrefix, makeWord) { if (/[*?[]/.test(filePrefix)) return '' let entries = [] try { const r = await withVfsTimeout(vfs.readdir(dir)) if (Array.isArray(r)) entries = r.slice(0, READDIR_CAP) } catch { return '' } const matches = entries.filter((n) => n.startsWith(filePrefix)).sort() if (matches.length === 0) return '' /** @param {string} name */ const statIsDir = async (name) => { const sep = dir.endsWith('/') ? '' : '/' const full = dir === '.' ? name : dir + sep + name try { const st = await withVfsTimeout(vfs.stat(full), 80) return st && st.type === 'directory' } catch { return false } } let newWord = '' if (matches.length === 1) { const name = matches[0] const isDir = await statIsDir(name) newWord = makeWord(name, isDir) } else { let lcp = matches[0] for (let i = 1; i < matches.length; i++) { const m = matches[i] let j = 0 while (j < lcp.length && j < m.length && lcp[j] === m[j]) j++ lcp = lcp.slice(0, j) } if (lcp.length <= filePrefix.length) return '' newWord = makeWord(lcp, false) } const next = ghostReplaceCurrentWord(line, cursor, newWord) const b = line.trimEnd() return next.startsWith(b) && next.length > b.length ? next : '' } if (cx.argIndex === 0) { if (cw.startsWith('~/')) { return tryDir(home, cw.slice(2), (name, isDir) => { const suf = isDir ? '/' : '' return '~/' + name + suf }) } if (cw === '~') { return ghostReplaceCurrentWord(line, cursor, '~/') } if (cw.includes('/')) { const lastSlash = cw.lastIndexOf('/') const dirPart = cw.slice(0, lastSlash) || (cw.startsWith('/') ? '/' : '.') const fp = cw.slice(lastSlash + 1) let dir = dirPart if (dir === '') dir = '/' const prefix = cw.slice(0, lastSlash + 1) return tryDir(dir, fp, (name, isDir) => { const suf = isDir ? '/' : '' return prefix + name + suf }) } if (!cw) return '' return tryDir('.', cw, (name, isDir) => { const suf = isDir ? '/' : '' return name + suf }) } if (!cw) return '' if (cw.startsWith('~/')) { return tryDir(home, cw.slice(2), (name, isDir) => { const suf = isDir ? '/' : '' return '~/' + name + suf }) } if (cw === '~') { return ghostReplaceCurrentWord(line, cursor, '~/') } if (cw.includes('/')) { const lastSlash = cw.lastIndexOf('/') const dirPart = cw.slice(0, lastSlash) || (cw.startsWith('/') ? '/' : '.') const fp = cw.slice(lastSlash + 1) let dir = dirPart if (dir === '') dir = '/' const prefix = cw.slice(0, lastSlash + 1) return tryDir(dir, fp, (name, isDir) => { const suf = isDir ? '/' : '' return prefix + name + suf }) } return tryDir('.', cw, (name, isDir) => { const suf = isDir ? '/' : '' return name + suf }) } /** * @param {Array<{ command: string } | string>} history * @param {string} line * @param {string | null} [lastExecutedFirst] first token of last run command (session) */ export function suggestGhostFromHistory(history, line, lastExecutedFirst) { const base = line.trimEnd() if (!base) return '' for (let i = history.length - 1; i >= 0; i--) { const e = history[i] const cmd = typeof e === 'string' ? e : e.command if (cmd.startsWith(base) && cmd.length > base.length) return cmd } if (lastExecutedFirst) { const freq = new Map() for (let i = 0; i < history.length - 1; i++) { const a = typeof history[i] === 'string' ? history[i] : history[i].command const b = typeof history[i + 1] === 'string' ? history[i + 1] : history[i + 1].command const af = a.trim().split(/\s+/)[0] || '' const bf = b.trim().split(/\s+/)[0] || '' if (af === lastExecutedFirst) freq.set(bf, (freq.get(bf) || 0) + 1) } const words = base.split(/\s+/).filter(Boolean) if (words.length === 1 && words[0] === lastExecutedFirst) { let best = '' let bestN = 0 for (const [k, n] of freq) { if (n > bestN) { bestN = n best = k } } if (best) return base + ' ' + best } } const tri = new Map() for (const e of history) { const cmd = typeof e === 'string' ? e : e.command if (fuzzySubsequence(cmd, base) && cmd.length > base.length) { tri.set(cmd, (tri.get(cmd) || 0) + 1) } } let tb = '' let tn = 0 for (const [k, n] of tri) { if (n > tn) { tn = n tb = k } } return tb && tb !== base ? tb : '' } export function menuVisibleCap() { return MENU_CAP } export function parseLsColorsFromEnv(env) { const raw = env?.LS_COLORS || '' return bareParseLsColors(String(raw)) } /** * @param {Record} ctx * @param {Record} env * @param {string} line * @param {number} cursor * @param {Array<{ command: string } | string>} history * @param {{ lastExecuted?: string | null, lastExecutedFirst?: string | null }} [opts] */ export async function completeLine(ctx, env, line, cursor, history, opts = {}) { const cx = parseCompletionContext(line, cursor) const vfs = ctx.vfs if (!vfs || typeof vfs.readdir !== 'function') { return { context: cx, items: [], total: 0, manPage: null } } const builtins = listBareOsShellBuiltins(env) const lsColorsMap = parseLsColorsFromEnv(env) /** @type {string[]} */ let binNames = [] try { const r = await withVfsTimeout(vfs.readdir('/bin')) if (Array.isArray(r)) binNames = r.map((e) => String(e).replace(/\.js$/i, '')) } catch { binNames = [] } const pathDirs = String(env.PATH || '/bin') .split(':') .map((d) => d.trim()) .filter(Boolean) const pathSeen = new Set(binNames) /** @type {string[]} */ const pathNames = [] for (const d of pathDirs) { try { const r = await withVfsTimeout(vfs.readdir(d)) if (!Array.isArray(r)) continue for (const f of r) { const base = String(f).replace(/\.js$/i, '') if (!pathSeen.has(base)) { pathSeen.add(base) pathNames.push(base) } } } catch { /* skip */ } } const manDb = await loadBareOsManDb(ctx) const resolved = resolveAliasFirstName(cx.firstWord, ctx.shellAliases) const page = manDb != null ? manPageForCommand(manDb, resolved) : null const manKeywords = page && Array.isArray(page.keywords) ? /** @type {string[]} */ (page.keywords).map(String) : [] if (!ctx.__bareOsCompleterReg) ctx.__bareOsCompleterReg = /** @type {Map} */ (new Map()) const rawItems = await gatherCompletionItems(ctx, env, cx, { binNames, pathNames, builtins, lsColorsMap, manDb, registry: ctx.__bareOsCompleterReg }) const freq = buildHistorySignals(history) const ranked = rankCompletionItems(rawItems, cx.currentWord, { freq, manKeywords, lastExecuted: opts.lastExecutedFirst ?? opts.lastExecuted ?? null }) return { context: cx, items: ranked.slice(0, MENU_CAP), total: ranked.length, manPage: page } } /** * @param {Record} ctx * @param {string} commandName * @param {(ctx: Record, meta: Record) => Promise} fn */ export function registerBareOsCompleter(ctx, commandName, fn) { if (!ctx || typeof commandName !== 'string' || typeof fn !== 'function') return if (!ctx.__bareOsCompleterReg) ctx.__bareOsCompleterReg = new Map() ctx.__bareOsCompleterReg.set(commandName.replace(/\.js$/i, ''), fn) } /** * @param {Record} ctx * @param {string} commandName */ export function unregisterBareOsCompleter(ctx, commandName) { const m = ctx && ctx.__bareOsCompleterReg if (m && m instanceof Map) m.delete(commandName.replace(/\.js$/i, '')) }