Files
bare-operating-system/packages/bare-os-booter/lib/cli/completion-engine.js
T
2026-08-18 18:11:34 -04:00

1251 lines
34 KiB
JavaScript

/**
* 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<string, unknown>} 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<object, { at: number, json: unknown }>} */
const procTableCache = new WeakMap()
/**
* @template T
* @param {Promise<T>} p
* @param {number} ms
* @returns {Promise<T | null>}
*/
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<string, string> | 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<string, number> }} */ (
db
).pages
const index = /** @type {Record<string, number> | undefined} */ (
/** @type {{ index?: Record<string, number> }} */ (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<string, unknown>} */ (p) : null
}
/**
* @param {Record<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} ctx
* @param {Record<string, string>} env
* @param {ReturnType<typeof parseCompletionContext>} cx
* @param {{
* binNames: string[],
* pathNames: string[],
* builtins: string[],
* lsColorsMap: Record<string, string>,
* manDb: unknown | null,
* registry?: Map<string, (c: Record<string, unknown>, x: Record<string, unknown>) => Promise<CompletionItem[]>>
* }} sources
* @returns {Promise<CompletionItem[]>}
*/
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<string, string>} */ (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<string, number> }} */ (db).index
? /** @type {Record<string, number>} */ (
/** @type {{ index: Record<string, number> }} */ (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<string, number> }} */ (db).index
? /** @type {Record<string, number>} */ (
/** @type {{ index: Record<string, number> }} */ (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<string, number>,
* 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<typeof parseCompletionContext>} 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<string, unknown>} ctx
* @param {Record<string, string>} 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<string, string>} */ (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<string, unknown>} ctx
* @param {Record<string, string>} 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<string, Function>} */ (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<string, unknown>} ctx
* @param {string} commandName
* @param {(ctx: Record<string, unknown>, meta: Record<string, unknown>) => Promise<CompletionItem[]>} 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<string, unknown>} 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, ''))
}