1251 lines
34 KiB
JavaScript
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, ''))
|
|
}
|