mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-08 22:34:03 +09:00
doc update/command synopses
This commit is contained in:
16
assets/disk0/tvdos/bin/color.js.synopsis
Normal file
16
assets/disk0/tvdos/bin/color.js.synopsis
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "color",
|
||||
"summary": "Set the screen background and foreground colours",
|
||||
"symbols": {
|
||||
"code": {
|
||||
"kind": "positional",
|
||||
"type": "string",
|
||||
"name": "BF",
|
||||
"summary": "Two hex digits: background then foreground",
|
||||
"validation": { "pattern": "^[0-9A-Fa-f]{2}$" },
|
||||
"completion": { "method": "none" }
|
||||
}
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "code" }
|
||||
}
|
||||
@@ -1013,6 +1013,22 @@ function getAutocompleteWin() {
|
||||
return _acWin
|
||||
}
|
||||
|
||||
// Lazily-resolved synopsis module (TSF loader/completion resolver). Held for
|
||||
// the whole session so its in-memory cache survives across keystrokes.
|
||||
// undefined = not probed yet, null = unavailable.
|
||||
let _acSyn = undefined
|
||||
function getSynopsisMod() {
|
||||
if (_acSyn !== undefined) return _acSyn
|
||||
_acSyn = null
|
||||
try {
|
||||
let m = require("synopsis") // resolved through INCLPATH (\tvdos\include\synopsis.mjs)
|
||||
if (m && typeof m.getCompletion === "function") _acSyn = m
|
||||
} catch (e) {
|
||||
debugprintln("command.js > autocomplete: synopsis unavailable: " + e)
|
||||
}
|
||||
return _acSyn
|
||||
}
|
||||
|
||||
// List a directory's entries, swallowing any IO error.
|
||||
function _acListDir(fullPath) {
|
||||
try {
|
||||
@@ -1103,15 +1119,53 @@ function _acPathCandidates(word) {
|
||||
return out
|
||||
}
|
||||
|
||||
// Candidates for an argument (not the command word). Consults the command's
|
||||
// TSF synopsis (via synopsis.mjs) for option flags, enum/list values and
|
||||
// subcommand names, and merges in filesystem entries when the synopsis says the
|
||||
// slot expects a path/file/directory. Falls back to plain path completion when
|
||||
// no synopsis exists, so behaviour is unchanged for commands without one.
|
||||
function _acArgCandidates(prefix, word) {
|
||||
let syn = getSynopsisMod()
|
||||
if (syn) {
|
||||
try {
|
||||
let toks = prefix.trim().split(/\s+/)
|
||||
let cmd = toks[0]
|
||||
let argToks = toks.slice(1)
|
||||
let r = syn.getCompletion(cmd, argToks, word)
|
||||
if (r && r.ok) {
|
||||
let out = (r.candidates || []).slice()
|
||||
if (r.filesystem) {
|
||||
_acPathCandidates(word).forEach(function(c) {
|
||||
if (r.filesystem === 'directory' && !c.isDir) return // dirs only
|
||||
out.push(c)
|
||||
})
|
||||
}
|
||||
// de-dupe by the text that would be inserted
|
||||
let seen = {}, dedup = []
|
||||
out.forEach(function(c) { if (seen[c.value]) return; seen[c.value] = true; dedup.push(c) })
|
||||
return dedup
|
||||
}
|
||||
} catch (e) {
|
||||
debugprintln("command.js > _acArgCandidates: " + e)
|
||||
}
|
||||
}
|
||||
return _acPathCandidates(word)
|
||||
}
|
||||
|
||||
// Work out what is being completed at `caret` within `line`.
|
||||
// Returns { wordStart, word, candidates } (candidates sorted by label).
|
||||
function computeCompletion(line, caret) {
|
||||
let wordStart = caret
|
||||
while (wordStart > 0 && line.charAt(wordStart - 1) !== ' ') wordStart -= 1
|
||||
let word = line.substring(wordStart, caret)
|
||||
let isFirstWord = (line.substring(0, wordStart).trim().length === 0)
|
||||
let prefix = line.substring(0, wordStart)
|
||||
let isFirstWord = (prefix.trim().length === 0)
|
||||
let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0)
|
||||
let candidates = (isFirstWord && !hasPathSep) ? _acCommandCandidates(word) : _acPathCandidates(word)
|
||||
let candidates
|
||||
if (isFirstWord)
|
||||
candidates = hasPathSep ? _acPathCandidates(word) : _acCommandCandidates(word)
|
||||
else
|
||||
candidates = _acArgCandidates(prefix, word)
|
||||
candidates.sort(function(a, b) { return (a.label < b.label) ? -1 : (a.label > b.label) ? 1 : 0 })
|
||||
return { wordStart: wordStart, word: word, candidates: candidates }
|
||||
}
|
||||
|
||||
7
assets/disk0/tvdos/bin/drives.js.synopsis
Normal file
7
assets/disk0/tvdos/bin/drives.js.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "drives",
|
||||
"summary": "List connected and mounted disk drives",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
12
assets/disk0/tvdos/bin/edit.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/edit.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "edit",
|
||||
"summary": "Full-screen text editor",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to edit; a new buffer when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/geturl.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/geturl.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "geturl",
|
||||
"summary": "Fetch a URL and print the response",
|
||||
"symbols": {
|
||||
"url": { "kind": "positional", "type": "url", "name": "URL", "summary": "Address to fetch" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "url" }
|
||||
}
|
||||
18
assets/disk0/tvdos/bin/gzip.js.synopsis
Normal file
18
assets/disk0/tvdos/bin/gzip.js.synopsis
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "gzip",
|
||||
"summary": "Compress or decompress a file (Zstd-backed)",
|
||||
"symbols": {
|
||||
"decompress": { "kind": "option", "short": "-d", "summary": "Decompress instead of compress" },
|
||||
"stdout": { "kind": "option", "short": "-c", "summary": "Write to the pipe instead of a file" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["decompress", "stdout"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to process" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||
{ "type": "reference", "symbol": "file" }
|
||||
]
|
||||
}
|
||||
}
|
||||
1
assets/disk0/tvdos/bin/help.alias
Normal file
1
assets/disk0/tvdos/bin/help.alias
Normal file
@@ -0,0 +1 @@
|
||||
synopsis $0
|
||||
12
assets/disk0/tvdos/bin/hexdump.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/hexdump.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "hexdump",
|
||||
"summary": "Print a file as a hexadecimal dump",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to dump; reads from the pipe when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
}
|
||||
12
assets/disk0/tvdos/bin/less.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/less.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "less",
|
||||
"summary": "View text a screen at a time",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to view; reads from the pipe when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
}
|
||||
34
assets/disk0/tvdos/bin/lfs.js.synopsis
Normal file
34
assets/disk0/tvdos/bin/lfs.js.synopsis
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "lfs",
|
||||
"summary": "Create, extract or list a Linear File Strip (.lfs) archive",
|
||||
"description": "Bundles a directory tree into a single TVDOS Linear File Strip archive, or unpacks one. Exactly one mode must be given: -c creates ARCHIVE from PATH, -x extracts ARCHIVE into PATH, and -t lists the files in ARCHIVE (PATH is not used). Individual files are stored uncompressed; gzip the whole .lfs to compress it.",
|
||||
"symbols": {
|
||||
"create": { "kind": "option", "short": "-c", "summary": "Create an archive from a directory" },
|
||||
"extract": { "kind": "option", "short": "-x", "summary": "Extract an archive into a directory" },
|
||||
"list": { "kind": "option", "short": "-t", "summary": "List the files stored in an archive" },
|
||||
"relative": { "kind": "option", "short": "-r", "summary": "Store paths relative to the source directory (with -c)" },
|
||||
"archive": { "kind": "positional", "type": "file", "name": "ARCHIVE", "summary": "The .lfs archive file" },
|
||||
"path": { "kind": "positional", "type": "directory", "name": "PATH", "summary": "Source directory (-c) or destination directory (-x); unused for -t" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"type": "choice",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "create" },
|
||||
{ "type": "reference", "symbol": "extract" },
|
||||
{ "type": "reference", "symbol": "list" }
|
||||
]
|
||||
},
|
||||
{ "type": "optional", "child": { "type": "reference", "symbol": "relative" } },
|
||||
{ "type": "reference", "symbol": "archive" },
|
||||
{ "type": "optional", "child": { "type": "reference", "symbol": "path" } }
|
||||
]
|
||||
},
|
||||
"constraints": [
|
||||
{ "type": "cardinality", "symbols": ["create", "extract", "list"], "minimum": 1, "maximum": 1 },
|
||||
{ "type": "requires", "subject": "relative", "targets": ["create"] }
|
||||
]
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/movprobe.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/movprobe.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "movprobe",
|
||||
"summary": "Print metadata about a movie file",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to inspect" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
17
assets/disk0/tvdos/bin/playmp2.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/playmp2.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playmp2",
|
||||
"summary": "Play an MP2 audio file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser)" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "MP2 file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
21
assets/disk0/tvdos/bin/playtad.js.synopsis
Normal file
21
assets/disk0/tvdos/bin/playtad.js.synopsis
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playtad",
|
||||
"summary": "Play a TAD audio file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser and progress bar)" },
|
||||
"dump": { "kind": "option", "short": "-d", "summary": "Dump coefficients (diagnostic)" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "dump"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TAD file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
},
|
||||
"constraints": [
|
||||
{ "type": "conflicts", "symbols": ["interactive", "dump"] }
|
||||
]
|
||||
}
|
||||
23
assets/disk0/tvdos/bin/playtav.js.synopsis
Normal file
23
assets/disk0/tvdos/bin/playtav.js.synopsis
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playtav",
|
||||
"summary": "Play a TAV video file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode" },
|
||||
"filmGrain": {
|
||||
"kind": "option",
|
||||
"long": "--filter-film-grain",
|
||||
"summary": "Apply a film-grain filter",
|
||||
"value": { "name": "LEVEL", "type": "integer", "required": false, "summary": "Grain intensity" }
|
||||
},
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "filmGrain"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TAV file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
18
assets/disk0/tvdos/bin/playtev.js.synopsis
Normal file
18
assets/disk0/tvdos/bin/playtev.js.synopsis
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playtev",
|
||||
"summary": "Play a TEV video file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode" },
|
||||
"debugMv": { "kind": "option", "long": "-debug-mv", "summary": "Show motion-vector debug overlay" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "debugMv"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TEV file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
17
assets/disk0/tvdos/bin/playwav.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/playwav.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playwav",
|
||||
"summary": "Play a WAV audio file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser)" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "WAV file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/printfile.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/printfile.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "printfile",
|
||||
"summary": "Print a text file with line numbers",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Text file to print" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
180
assets/disk0/tvdos/bin/synopsis.js
Normal file
180
assets/disk0/tvdos/bin/synopsis.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* synopsis.js -- system-wide help / tldr.
|
||||
*
|
||||
* Prints a command's human-targeted one-line summary and an auto-generated
|
||||
* synopsis (usage line, arguments, options and constraints) derived from its
|
||||
* TSF .synopsis document via the `synopsis` library (synopsis.mjs).
|
||||
*
|
||||
* Usage: synopsis PROGRAM
|
||||
* synopsis (describes itself)
|
||||
*/
|
||||
|
||||
let syn
|
||||
try {
|
||||
syn = require("synopsis")
|
||||
} catch (e) {
|
||||
printerrln("synopsis: the 'synopsis' library is not installed")
|
||||
return 1
|
||||
}
|
||||
|
||||
const termW = (con.getmaxyx()[1]) || 80
|
||||
|
||||
// Word-wrap plain text to `width`, returning an array of lines.
|
||||
function wrap(text, width) {
|
||||
if (!text) return []
|
||||
if (width < 8) width = 8
|
||||
let words = ('' + text).split(/\s+/).filter(function (w) { return w.length })
|
||||
let lines = [], line = ''
|
||||
words.forEach(function (w) {
|
||||
if (line.length === 0) line = w
|
||||
else if (line.length + 1 + w.length <= width) line += ' ' + w
|
||||
else { lines.push(line); line = w }
|
||||
})
|
||||
if (line.length) lines.push(line)
|
||||
return lines
|
||||
}
|
||||
|
||||
// Print a "left summary" row: the summary is wrapped into the right column and
|
||||
// continuation lines are aligned under it. An over-wide `left` spills onto its
|
||||
// own line.
|
||||
function row(left, summary, leftW, indent) {
|
||||
let pad = ' '.repeat(indent)
|
||||
let gap = 2
|
||||
let sumW = Math.max(8, termW - indent - leftW - gap)
|
||||
let wrapped = wrap(summary, sumW)
|
||||
if (left.length > leftW) {
|
||||
println(pad + left)
|
||||
wrapped.forEach(function (l) { println(pad + ' '.repeat(leftW + gap) + l) })
|
||||
} else {
|
||||
let first = wrapped.length ? wrapped[0] : ''
|
||||
println(pad + left + ' '.repeat(leftW - left.length + gap) + first)
|
||||
for (let i = 1; i < wrapped.length; i++)
|
||||
println(pad + ' '.repeat(leftW + gap) + wrapped[i])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- resolve the target ----------------------------------------------------
|
||||
let token = (exec_args[1] !== undefined && exec_args[1] !== '') ? exec_args[1] : "synopsis"
|
||||
|
||||
let model = syn.getModel(token)
|
||||
if (!model) {
|
||||
printerrln(`synopsis: no synopsis found for '${token}'`)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Display name for a referenced symbol id (used by the constraints section).
|
||||
function symDisplay(id) {
|
||||
let s = model.symbols[id]
|
||||
if (!s) return id
|
||||
if (s.kind === 'option') return s.long || s.short || id
|
||||
if (s.kind === 'positional') return s.name || id
|
||||
if (s.kind === 'subcommand') return s.name || id
|
||||
return id
|
||||
}
|
||||
|
||||
// Append a "{a, b, c}" hint of permitted values to a summary, if any.
|
||||
function withValues(summary, values) {
|
||||
if (!values || !values.length) return summary || ''
|
||||
let vs = values.map(function (v) {
|
||||
return (v && typeof v === 'object' && ('value' in v)) ? v.value : v
|
||||
}).join(', ')
|
||||
return (summary ? summary + ' ' : '') + '{' + vs + '}'
|
||||
}
|
||||
|
||||
// Left-column text for an option, e.g. "-o, --output=FILE".
|
||||
function optionLeft(e) {
|
||||
let forms = []
|
||||
if (e.short) forms.push(e.short)
|
||||
if (e.long) forms.push(e.long)
|
||||
let s = forms.join(', ')
|
||||
if (e.hasValue) {
|
||||
let vn = (e.value && (e.value.name || e.value.type)) || 'VALUE'
|
||||
if (e.long) s += e.valueRequired ? '=' + vn : '[=' + vn + ']'
|
||||
else s += e.valueRequired ? ' ' + vn : ' [' + vn + ']'
|
||||
}
|
||||
return s
|
||||
}
|
||||
function optionSummary(e) {
|
||||
let s = e.summary || ''
|
||||
if (e.negatable) s += (s ? ' ' : '') + '(negatable)'
|
||||
if (e.value && e.value.values && e.value.values.length) s = withValues(s, e.value.values)
|
||||
return s
|
||||
}
|
||||
|
||||
function constraintText(c) {
|
||||
let names = (c.symbols || []).map(symDisplay)
|
||||
if (c.type === 'conflicts') return 'Mutually exclusive: ' + names.join(', ')
|
||||
if (c.type === 'requires') return symDisplay(c.subject) + ' requires ' + (c.targets || []).map(symDisplay).join(', ')
|
||||
if (c.type === 'implies') return symDisplay(c.subject) + ' implies ' + (c.targets || []).map(symDisplay).join(', ')
|
||||
if (c.type === 'cardinality') {
|
||||
let mn = c.minimum, mx = c.maximum, q
|
||||
if (mn === 1 && mx === 1) q = 'Exactly one of'
|
||||
else if (mn === 1 && mx === undefined) q = 'At least one of'
|
||||
else if (mn === undefined && mx === 1) q = 'At most one of'
|
||||
else q = `Between ${mn} and ${mx} of`
|
||||
return q + ': ' + names.join(', ')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- gather rows -----------------------------------------------------------
|
||||
let argEntries = model.positionals.map(function (p) {
|
||||
return { left: (p.name || p.id) + (p.repeatable ? '...' : ''), summary: withValues(p.summary, p.values) }
|
||||
})
|
||||
let optEntries = model.flags.map(function (e) {
|
||||
return { left: optionLeft(e), summary: optionSummary(e) }
|
||||
})
|
||||
let subEntries = model.subcommands.map(function (s) {
|
||||
return { left: s.name, summary: s.summary || '' }
|
||||
})
|
||||
|
||||
// shared left-column width (capped so a long flag does not push everything out)
|
||||
let leftW = 4
|
||||
argEntries.concat(optEntries, subEntries).forEach(function (e) { if (e.left.length > leftW) leftW = e.left.length })
|
||||
if (leftW > 30) leftW = 30
|
||||
|
||||
// ---- render ----------------------------------------------------------------
|
||||
let title = model.name || token
|
||||
println(model.summary ? `${title} - ${model.summary}` : title)
|
||||
println()
|
||||
|
||||
let usage = syn.getUsage(token)
|
||||
if (usage) {
|
||||
println("Usage:")
|
||||
println(" " + usage)
|
||||
println()
|
||||
}
|
||||
|
||||
if (model.description) {
|
||||
wrap(model.description, termW).forEach(function (l) { println(l) })
|
||||
println()
|
||||
}
|
||||
|
||||
if (subEntries.length) {
|
||||
println("Commands:")
|
||||
subEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||
println()
|
||||
}
|
||||
|
||||
if (argEntries.length) {
|
||||
println("Arguments:")
|
||||
argEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||
println()
|
||||
}
|
||||
|
||||
if (optEntries.length) {
|
||||
println("Options:")
|
||||
optEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||
println()
|
||||
}
|
||||
|
||||
if (model.constraints && model.constraints.length) {
|
||||
let lines = model.constraints.map(constraintText).filter(function (t) { return t })
|
||||
if (lines.length) {
|
||||
println("Constraints:")
|
||||
lines.forEach(function (l) { println(" " + l) })
|
||||
println()
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
13
assets/disk0/tvdos/bin/synopsis.js.synopsis
Normal file
13
assets/disk0/tvdos/bin/synopsis.js.synopsis
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "synopsis",
|
||||
"summary": "Print a command's summary and auto-generated synopsis",
|
||||
"description": "Prints the one-line summary and an auto-generated usage line for PROGRAM, derived from its TSF .synopsis document, together with its arguments, options and constraints. With no PROGRAM, describes itself.",
|
||||
"symbols": {
|
||||
"program": { "kind": "positional", "type": "command", "name": "PROGRAM", "summary": "Command to describe; describes synopsis itself when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "program" }
|
||||
}
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "tee",
|
||||
"summary": "Copy a pipe's stream to a file and pass it on",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "touch",
|
||||
"summary": "Update a file's modification time, creating it if absent",
|
||||
"symbols": {
|
||||
"noCreate": { "kind": "option", "short": "-c", "summary": "Do not create the file if it does not exist" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["noCreate"] },
|
||||
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to touch" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||
{ "type": "reference", "symbol": "file" }
|
||||
]
|
||||
}
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "writeto",
|
||||
"summary": "Write a pipe's stream to a file",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
581
assets/disk0/tvdos/include/synopsis.mjs
Normal file
581
assets/disk0/tvdos/include/synopsis.mjs
Normal file
@@ -0,0 +1,581 @@
|
||||
/*
|
||||
* synopsis.mjs -- TVDOS Synopsis Format (TSF) loader, cache and completion
|
||||
* resolver.
|
||||
*
|
||||
* A TSF document (see the "Command Synopsis Format" chapter of the manual and
|
||||
* tvdos_synopsis_format_draft.md) is a JSON file describing a command's
|
||||
* command-line interface: its options, positional arguments, subcommands,
|
||||
* argument types, completion sources and validation constraints. This module
|
||||
* turns those documents into the answers command.js needs while the user is
|
||||
* typing -- chiefly "what can come next at the caret?".
|
||||
*
|
||||
* Where the documents live
|
||||
* ------------------------
|
||||
* * Apps : colocated with the executable, full filename + ".synopsis"
|
||||
* e.g. \tvdos\bin\geturl.js -> \tvdos\bin\geturl.js.synopsis
|
||||
* * Built-in : the shell coreutils are not files, so their synopses live
|
||||
* coreutils in a dedicated directory, \tvdos\synopsis\<name>.synopsis.
|
||||
* Aliases (ls -> dir, rm -> del, ...) resolve to the
|
||||
* canonical command's file automatically.
|
||||
*
|
||||
* Caching (two layers)
|
||||
* --------------------
|
||||
* Parsing JSON and compiling a completion model on every TAB would be wasteful,
|
||||
* so results are cached:
|
||||
* 1. In memory, for the life of the shell session (command.js keeps the
|
||||
* require() handle, so this object persists across keystrokes).
|
||||
* 2. On disk, under \tvdos\cache\synopsis\, as a compiled-model blob. The
|
||||
* TSVM file layer exposes no reliable modification time, so the cache is
|
||||
* validated against the source file's *byte size* plus a CACHE_VERSION
|
||||
* stamp. A source edit that preserves the byte count will not invalidate
|
||||
* the disk cache -- an accepted trade-off. Every disk operation is
|
||||
* best-effort: a failure never breaks completion, it just falls back to
|
||||
* re-parsing.
|
||||
*
|
||||
* Public API
|
||||
* ----------
|
||||
* getCompletion(commandToken, prefixTokens, word) -> result | { ok:false }
|
||||
* getModel(commandToken) -> compiled model | null
|
||||
* getSummary(commandToken) -> one-line summary | null
|
||||
* getUsage(commandToken) -> generated usage string | null
|
||||
* resolveSynopsisPath(commandToken) -> full path | null
|
||||
* registerProvider(name, fn) -> register an `internal` completion source
|
||||
* clearCache() -> drop the in-memory caches
|
||||
*/
|
||||
|
||||
const TSF_VERSION = "1.0"
|
||||
const CACHE_VERSION = 1 // bump when compile()'s output shape changes
|
||||
const SYN_DIR = "\\tvdos\\synopsis" // built-in / coreutil synopses
|
||||
const CACHE_PARENT = "\\tvdos\\cache"
|
||||
const CACHE_DIR = "\\tvdos\\cache\\synopsis" // compiled-model disk cache
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// small local helpers (deliberately mirror command.js internals)
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function drive() { return (typeof _G !== "undefined" && _G.shell) ? _G.shell.getCurrentDrive() : "A" }
|
||||
|
||||
function trimStartRevSlash(s) {
|
||||
let cnt = 0
|
||||
while (cnt < s.length && s[cnt] === '\\') cnt += 1
|
||||
return s.substring(cnt)
|
||||
}
|
||||
|
||||
function isValidDriveLetter(l) {
|
||||
if (typeof l === 'string' || l instanceof String) {
|
||||
let lc = l.charCodeAt(0)
|
||||
return (l == '$' || 65 <= lc && lc <= 90 || 97 <= lc && lc <= 122)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function fileExists(p) { try { return files.open(p).exists } catch (e) { return false } }
|
||||
function fileSize(p) { try { return files.open(p).size | 0 } catch (e) { return 0 } }
|
||||
function readText(p) { try { let f = files.open(p); return f.exists ? f.sread() : null } catch (e) { return null } }
|
||||
|
||||
let _cacheDirReady = false
|
||||
function ensureCacheDir() {
|
||||
if (_cacheDirReady) return
|
||||
let d = drive()
|
||||
let segs = [CACHE_PARENT, CACHE_DIR]
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
try { let f = files.open(`${d}:${segs[i]}`); if (!f.exists) f.mkDir() } catch (e) { /* best-effort */ }
|
||||
}
|
||||
_cacheDirReady = true
|
||||
}
|
||||
function writeText(p, s) {
|
||||
try { ensureCacheDir(); files.open(p).swrite(s); return true } catch (e) { return false }
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// executable + synopsis-path resolution
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Find the runnable file a bare command name would resolve to, mirroring the
|
||||
// search order command.js uses (current directory, then PATH, with PATHEXT).
|
||||
function findExecutable(cmd) {
|
||||
let d = drive()
|
||||
if (isValidDriveLetter(cmd[0]) && cmd[1] === ':') {
|
||||
try { let f = files.open(cmd); return f.exists ? f.fullPath : null } catch (e) { return null }
|
||||
}
|
||||
let pwd = (typeof _G !== "undefined" && _G.shell) ? _G.shell.getPwd() : [""]
|
||||
let searchDir = (cmd.charAt(0) === '/') ? [""] : ["/" + pwd.join("/")].concat(_TVDOS.getPath())
|
||||
let pathExt = []
|
||||
if (cmd.split(".")[1] === undefined) {
|
||||
(_TVDOS.variables.PATHEXT || "").split(';').forEach(function (it) {
|
||||
if (it.length) { pathExt.push(it); pathExt.push(it.toUpperCase()) }
|
||||
})
|
||||
} else {
|
||||
pathExt.push("")
|
||||
}
|
||||
for (let i = 0; i < searchDir.length; i++) {
|
||||
for (let j = 0; j < pathExt.length; j++) {
|
||||
let search = searchDir[i]; if (!search.endsWith('\\')) search += '\\'
|
||||
let sp = trimStartRevSlash(search + cmd + pathExt[j])
|
||||
try { let f = files.open(`${d}:\\${sp}`); if (f.exists) return f.fullPath } catch (e) { /* keep looking */ }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve a command token to the full path of its .synopsis document, or null.
|
||||
function resolveSynopsisPath(token) {
|
||||
if (!token) return null
|
||||
let d = drive()
|
||||
let lower = token.toLowerCase()
|
||||
|
||||
// built-in coreutil? -> \tvdos\synopsis\<name>.synopsis
|
||||
// try the typed name first, then any alias that shares the same function so
|
||||
// `ls` finds dir.synopsis without a duplicate file.
|
||||
if (typeof _G !== "undefined" && _G.shell && _G.shell.coreutils &&
|
||||
typeof _G.shell.coreutils[lower] === 'function') {
|
||||
let fn = _G.shell.coreutils[lower]
|
||||
let names = [lower]
|
||||
Object.keys(_G.shell.coreutils).forEach(function (k) {
|
||||
if (_G.shell.coreutils[k] === fn && names.indexOf(k) < 0) names.push(k)
|
||||
})
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
let p = `${d}:${SYN_DIR}\\${names[i]}.synopsis`
|
||||
if (fileExists(p)) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// app -> <executable>.synopsis colocated with the program
|
||||
let exe = findExecutable(token)
|
||||
if (!exe) return null
|
||||
let p = exe + ".synopsis"
|
||||
return fileExists(p) ? p : null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// TSF compilation -- raw document -> completion model
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function compile(doc) {
|
||||
if (!doc || typeof doc !== 'object') return null
|
||||
let symbols = doc.symbols || {}
|
||||
|
||||
// ---- options: every symbol of kind "option" is an offerable flag ----
|
||||
let flags = [] // one entry per option symbol
|
||||
let flagMap = {} // flag string ("-r", "--recursive", "--no-recursive") -> entry
|
||||
Object.keys(symbols).forEach(function (id) {
|
||||
let s = symbols[id]
|
||||
if (!s || s.kind !== 'option') return
|
||||
let value = s.value || null
|
||||
let hasValue = !!value
|
||||
let entry = {
|
||||
id: id,
|
||||
long: s.long || null,
|
||||
short: s.short || null,
|
||||
summary: s.summary || '',
|
||||
negatable: !!s.negatable,
|
||||
hasValue: hasValue,
|
||||
valueRequired: hasValue ? (value.required !== false) : false,
|
||||
value: value
|
||||
}
|
||||
flags.push(entry)
|
||||
if (entry.long) flagMap[entry.long] = entry
|
||||
if (entry.short) flagMap[entry.short] = entry
|
||||
if (entry.negatable && entry.long) flagMap['--no-' + entry.long.replace(/^--/, '')] = entry
|
||||
})
|
||||
|
||||
// ---- positionals + subcommands, in grammar order ----
|
||||
let positionals = []
|
||||
let subcommands = []
|
||||
let seenSub = {}
|
||||
function walk(node, inRepeat) {
|
||||
if (!node || typeof node !== 'object') return
|
||||
switch (node.type) {
|
||||
case 'sequence':
|
||||
case 'choice':
|
||||
(node.children || []).forEach(function (c) { walk(c, inRepeat) }); break
|
||||
case 'optional': walk(node.child, inRepeat); break
|
||||
case 'repeat': walk(node.child, true); break
|
||||
case 'oneOrMore': walk(node.child, true); break
|
||||
case 'reference': {
|
||||
let sym = symbols[node.symbol]
|
||||
if (!sym) return
|
||||
if (sym.kind === 'positional') {
|
||||
positionals.push({
|
||||
id: node.symbol,
|
||||
name: sym.name || node.symbol,
|
||||
type: sym.type || 'string',
|
||||
values: sym.values || null,
|
||||
completion: sym.completion || null,
|
||||
summary: sym.summary || '',
|
||||
repeatable: !!inRepeat
|
||||
})
|
||||
} else if (sym.kind === 'subcommand') {
|
||||
if (!seenSub[node.symbol]) {
|
||||
seenSub[node.symbol] = true
|
||||
subcommands.push({ name: sym.name || node.symbol, summary: sym.summary || '', tsf: sym.tsf || null })
|
||||
}
|
||||
}
|
||||
break // option / group references add no positional ordering
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
walk(doc.synopsis, false)
|
||||
|
||||
return {
|
||||
cacheVersion: CACHE_VERSION,
|
||||
tsfVersion: doc.tsfVersion || null,
|
||||
name: doc.name || null,
|
||||
summary: doc.summary || '',
|
||||
description: doc.description || '',
|
||||
symbols: symbols,
|
||||
synopsisNode: doc.synopsis || null,
|
||||
flags: flags,
|
||||
flagMap: flagMap,
|
||||
positionals: positionals,
|
||||
subcommands: subcommands,
|
||||
constraints: doc.constraints || []
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// loading + caching
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let _mem = {} // synopsisPath -> { srcSize, model }
|
||||
let _resolveMemo = {} // "drive|pwd|token" -> synopsisPath | null
|
||||
|
||||
function cacheKey(p) {
|
||||
// FNV-1a 32-bit hash, prefixed with a sanitised basename for readability.
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < p.length; i++) { h ^= p.charCodeAt(i); h = (h * 16777619) >>> 0 }
|
||||
let base = (p.split(/[\\/]/).pop() || 'syn').replace(/[^A-Za-z0-9._-]/g, '_')
|
||||
return base + '_' + ('00000000' + h.toString(16)).slice(-8)
|
||||
}
|
||||
function cachePath(synPath) { return `${drive()}:${CACHE_DIR}\\${cacheKey(synPath)}.json` }
|
||||
|
||||
function loadModel(synPath) {
|
||||
if (!synPath) return null
|
||||
let srcSize = fileSize(synPath)
|
||||
|
||||
// 1. in-memory
|
||||
let mem = _mem[synPath]
|
||||
if (mem && mem.srcSize === srcSize) return mem.model
|
||||
|
||||
// 2. disk cache (size + version validated)
|
||||
let cachedText = readText(cachePath(synPath))
|
||||
if (cachedText) {
|
||||
try {
|
||||
let c = JSON.parse(cachedText)
|
||||
if (c && c.cacheVersion === CACHE_VERSION && c.srcSize === srcSize && c.model) {
|
||||
_mem[synPath] = { srcSize: srcSize, model: c.model }
|
||||
return c.model
|
||||
}
|
||||
} catch (e) { /* corrupt cache -> re-parse */ }
|
||||
}
|
||||
|
||||
// 3. parse the source
|
||||
let src = readText(synPath)
|
||||
if (src === null) return null
|
||||
let doc
|
||||
try { doc = JSON.parse(src) }
|
||||
catch (e) { try { serial.printerr("synopsis: bad JSON in " + synPath + ": " + e) } catch (_) {} ; return null }
|
||||
let model = compile(doc)
|
||||
if (!model) return null
|
||||
|
||||
_mem[synPath] = { srcSize: srcSize, model: model }
|
||||
writeText(cachePath(synPath), JSON.stringify({ cacheVersion: CACHE_VERSION, srcSize: srcSize, model: model }))
|
||||
return model
|
||||
}
|
||||
|
||||
function getModel(token) {
|
||||
if (!token) return null
|
||||
let key = drive() + '|' + ((typeof _G !== "undefined" && _G.shell) ? _G.shell.getPwdString() : '') + '|' + token
|
||||
let synPath
|
||||
if (Object.prototype.hasOwnProperty.call(_resolveMemo, key)) synPath = _resolveMemo[key]
|
||||
else { synPath = resolveSynopsisPath(token); _resolveMemo[key] = synPath }
|
||||
return synPath ? loadModel(synPath) : null
|
||||
}
|
||||
|
||||
function clearCache() { _mem = {}; _resolveMemo = {}; _cacheDirReady = false }
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// internal completion providers (for `"completion": { "method": "internal" }`)
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let _providers = {}
|
||||
function registerProvider(name, fn) { _providers[name] = fn }
|
||||
function safeProvider(name, word, model) {
|
||||
let fn = _providers[name]
|
||||
if (!fn) return []
|
||||
try { return fn(word, model) || [] } catch (e) { return [] }
|
||||
}
|
||||
|
||||
// "commands" -- runnable command names (coreutils + PATH executables).
|
||||
registerProvider('commands', function (word) {
|
||||
word = (word || '').toLowerCase()
|
||||
let out = [], seen = {}
|
||||
function add(n) { let k = n.toLowerCase(); if (seen[k]) return; seen[k] = true; out.push(n) }
|
||||
if (typeof _G !== "undefined" && _G.shell && _G.shell.coreutils)
|
||||
Object.keys(_G.shell.coreutils).forEach(function (k) { if (k.toLowerCase().indexOf(word) === 0) add(k) })
|
||||
try {
|
||||
let d = drive()
|
||||
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
|
||||
.filter(function (e) { return e.length }).map(function (e) { return e.toLowerCase() })
|
||||
_TVDOS.getPath().forEach(function (dir) {
|
||||
let full = (dir === '') ? `${d}:\\` : `${d}:${dir.charAt(0) === '\\' ? dir : '\\' + dir}`
|
||||
try {
|
||||
let f = files.open(full); if (!f.exists || !f.isDirectory) return
|
||||
;(f.list() || []).forEach(function (it) {
|
||||
if (it.isDirectory) return
|
||||
let nl = (it.name || '').toLowerCase()
|
||||
if (!exts.some(function (e) { return nl.endsWith(e) })) return
|
||||
let nm = it.name
|
||||
exts.forEach(function (e) { if (nm.toLowerCase().endsWith(e)) nm = nm.substring(0, nm.length - e.length) })
|
||||
if (nm.toLowerCase().indexOf(word) === 0) add(nm)
|
||||
})
|
||||
} catch (e) { /* skip unreadable dir */ }
|
||||
})
|
||||
} catch (e) { /* ignore */ }
|
||||
return out
|
||||
})
|
||||
|
||||
// "envvars" -- environment variable names.
|
||||
registerProvider('envvars', function (word) {
|
||||
word = word || ''
|
||||
try {
|
||||
return Object.keys(_TVDOS.variables || {}).filter(function (k) {
|
||||
return k.toLowerCase().indexOf(word.toLowerCase()) === 0
|
||||
})
|
||||
} catch (e) { return [] }
|
||||
})
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// completion query
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Turn a `values` array (bare values or { value, summary } objects) into
|
||||
// completion candidates whose value matches `word` as a prefix.
|
||||
function valuesToCandidates(values, word) {
|
||||
if (!values) return []
|
||||
word = word || ''
|
||||
let out = []
|
||||
values.forEach(function (v) {
|
||||
let val, sum
|
||||
if (v && typeof v === 'object' && ('value' in v)) { val = '' + v.value; sum = v.summary || '' }
|
||||
else { val = '' + v; sum = '' }
|
||||
if (val.indexOf(word) === 0) out.push({ label: val, value: val + ' ', summary: sum, isDir: false })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Candidates implied by an argument descriptor (a positional, or an option's
|
||||
// `value`). Returns { candidates, filesystem } where `filesystem` is false or
|
||||
// one of 'path' | 'file' | 'directory' -- a request that the caller ALSO offer
|
||||
// matching filesystem entries.
|
||||
function descriptorCandidates(desc, word, model) {
|
||||
word = word || ''
|
||||
let none = { candidates: [], filesystem: false }
|
||||
if (!desc) return none
|
||||
|
||||
let method = (desc.completion && desc.completion.method) || (desc.type === 'enum' ? 'enum' : null)
|
||||
|
||||
// explicit completion block
|
||||
if (method === 'none') return none
|
||||
if (method === 'enum') return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||
if (method === 'list') {
|
||||
let items = (desc.completion && (desc.completion.items || desc.completion.values)) || desc.values || []
|
||||
return { candidates: valuesToCandidates(items, word), filesystem: false }
|
||||
}
|
||||
if (method === 'internal') {
|
||||
let prov = desc.completion && desc.completion.provider
|
||||
return { candidates: valuesToCandidates(safeProvider(prov, word, model), word), filesystem: false }
|
||||
}
|
||||
// method 'command' (run a program for candidates) is intentionally not
|
||||
// executed here -- side-effect / latency safety -- so it falls through to
|
||||
// the type defaults below.
|
||||
|
||||
// no completion block (or unhandled method): default behaviour by type
|
||||
switch (desc.type) {
|
||||
case 'path': return { candidates: [], filesystem: 'path' }
|
||||
case 'file': return { candidates: [], filesystem: 'file' }
|
||||
case 'directory': return { candidates: [], filesystem: 'directory' }
|
||||
case 'boolean': return { candidates: valuesToCandidates(['true', 'false'], word), filesystem: false }
|
||||
case 'command': return { candidates: valuesToCandidates(safeProvider('commands', word, model), word), filesystem: false }
|
||||
case 'enum': return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||
case 'user': if (_providers['users']) return { candidates: valuesToCandidates(safeProvider('users', word, model), word), filesystem: false }; break
|
||||
case 'group': if (_providers['groups']) return { candidates: valuesToCandidates(safeProvider('groups', word, model), word), filesystem: false }; break
|
||||
default: break
|
||||
}
|
||||
// string / integer / float / url / hostname / unknown: a soft `values`
|
||||
// list may still help; otherwise there is nothing to offer.
|
||||
if (desc.values) return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||
return none
|
||||
}
|
||||
|
||||
// Every textual form a flag may be typed as (long, short, and the --no- form).
|
||||
function flagForms(entry) {
|
||||
let forms = []
|
||||
if (entry.long) forms.push(entry.long)
|
||||
if (entry.short) forms.push(entry.short)
|
||||
if (entry.negatable && entry.long) forms.push('--no-' + entry.long.replace(/^--/, ''))
|
||||
return forms
|
||||
}
|
||||
|
||||
// Count how many positional arguments `tokens` (the args already typed before
|
||||
// the caret) have consumed, skipping option flags and the values they take.
|
||||
function countPositionals(tokens, model) {
|
||||
let n = 0, skip = false
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
let t = tokens[i]
|
||||
if (skip) { skip = false; continue } // this token was an option's value
|
||||
if (t.length > 0 && t.charAt(0) === '-') {
|
||||
if (t.indexOf('=') >= 0) continue // inline value -- no following value token
|
||||
let e = model.flagMap[t]
|
||||
if (e && e.hasValue && e.valueRequired) skip = true
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function finalise(r) { return { ok: true, candidates: r.candidates, filesystem: r.filesystem } }
|
||||
|
||||
/*
|
||||
* Main entry point used by command.js.
|
||||
*
|
||||
* commandToken : the command (first word on the line)
|
||||
* prefixTokens : the argument tokens already typed, in order, EXCLUDING the
|
||||
* word currently under the caret
|
||||
* word : the partial word under the caret (may be "")
|
||||
*
|
||||
* Returns { ok:false } when there is no synopsis for the command (the caller
|
||||
* should fall back to its own default completion). Otherwise returns
|
||||
* { ok:true, candidates:[{label,value,summary,isDir}], filesystem:<flag> }
|
||||
* where `filesystem` (false | 'path' | 'file' | 'directory') asks the caller to
|
||||
* additionally offer matching filesystem entries.
|
||||
*/
|
||||
function getCompletion(commandToken, prefixTokens, word) {
|
||||
let model = getModel(commandToken)
|
||||
if (!model) return { ok: false }
|
||||
word = word || ''
|
||||
prefixTokens = prefixTokens || []
|
||||
|
||||
// (1) the caret is on an option flag
|
||||
if (word.length > 0 && word.charAt(0) === '-') {
|
||||
// inline value form: --flag=partial
|
||||
if (word.indexOf('--') === 0 && word.indexOf('=') >= 0) {
|
||||
let eq = word.indexOf('=')
|
||||
let flagPart = word.substring(0, eq)
|
||||
let valPart = word.substring(eq + 1)
|
||||
let entry = model.flagMap[flagPart]
|
||||
if (entry && entry.hasValue) {
|
||||
let r = descriptorCandidates(entry.value, valPart, model)
|
||||
r.candidates = r.candidates.map(function (c) {
|
||||
return { label: c.label, value: flagPart + '=' + c.value.replace(/ $/, '') + ' ', summary: c.summary, isDir: false }
|
||||
})
|
||||
return { ok: true, candidates: r.candidates, filesystem: false }
|
||||
}
|
||||
return { ok: true, candidates: [], filesystem: false }
|
||||
}
|
||||
// list flags matching the prefix
|
||||
let out = []
|
||||
model.flags.forEach(function (e) {
|
||||
flagForms(e).forEach(function (f) {
|
||||
if (f.indexOf(word) === 0) out.push({ label: f, value: f + ' ', summary: e.summary, isDir: false })
|
||||
})
|
||||
})
|
||||
return { ok: true, candidates: out, filesystem: false }
|
||||
}
|
||||
|
||||
// (2) the caret is on the value of the immediately preceding option
|
||||
let prev = prefixTokens.length > 0 ? prefixTokens[prefixTokens.length - 1] : null
|
||||
if (prev && prev.charAt(0) === '-' && prev.indexOf('=') < 0) {
|
||||
let entry = model.flagMap[prev]
|
||||
if (entry && entry.hasValue && entry.valueRequired)
|
||||
return finalise(descriptorCandidates(entry.value, word, model))
|
||||
}
|
||||
|
||||
// (3) a positional argument (or a subcommand in the first slot)
|
||||
let posIndex = countPositionals(prefixTokens, model)
|
||||
if (posIndex === 0 && model.subcommands.length > 0) {
|
||||
let out = model.subcommands
|
||||
.filter(function (s) { return s.name.indexOf(word) === 0 })
|
||||
.map(function (s) { return { label: s.name, value: s.name + ' ', summary: s.summary, isDir: false } })
|
||||
return { ok: true, candidates: out, filesystem: false }
|
||||
}
|
||||
let desc = null
|
||||
if (model.positionals.length > 0) {
|
||||
if (posIndex < model.positionals.length) desc = model.positionals[posIndex]
|
||||
else {
|
||||
let last = model.positionals[model.positionals.length - 1]
|
||||
if (last && last.repeatable) desc = last
|
||||
}
|
||||
}
|
||||
// No descriptor for this slot -> let the caller use its default completion.
|
||||
if (!desc) return { ok: false }
|
||||
return finalise(descriptorCandidates(desc, word, model))
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// generated help (per the spec, usage text is derived output, not normative)
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function grammarToText(node, symbols) {
|
||||
if (!node || typeof node !== 'object') return ''
|
||||
switch (node.type) {
|
||||
case 'sequence':
|
||||
return (node.children || []).map(function (c) { return grammarToText(c, symbols) })
|
||||
.filter(function (s) { return s.length }).join(' ')
|
||||
case 'choice':
|
||||
return '(' + (node.children || []).map(function (c) { return grammarToText(c, symbols) }).join(' | ') + ')'
|
||||
case 'optional':
|
||||
return '[' + grammarToText(node.child, symbols) + ']'
|
||||
case 'repeat': {
|
||||
// a repeat over a group is the familiar [OPTION...] slot
|
||||
let child = node.child
|
||||
if (child && child.type === 'reference' && symbols[child.symbol] && symbols[child.symbol].kind === 'group')
|
||||
return '[' + grammarToText(child, symbols) + '...]'
|
||||
return grammarToText(child, symbols) + '...'
|
||||
}
|
||||
case 'oneOrMore': {
|
||||
let t = grammarToText(node.child, symbols)
|
||||
return t + ' [' + t + '...]'
|
||||
}
|
||||
case 'reference': {
|
||||
let s = symbols[node.symbol]
|
||||
if (!s) return node.symbol
|
||||
if (s.kind === 'group') return 'OPTION'
|
||||
if (s.kind === 'option') return s.long || s.short || node.symbol
|
||||
if (s.kind === 'subcommand') return s.name || node.symbol
|
||||
if (s.kind === 'positional') return s.name || node.symbol
|
||||
return node.symbol
|
||||
}
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getUsage(token) {
|
||||
let m = getModel(token)
|
||||
if (!m) return null
|
||||
let body = grammarToText(m.synopsisNode, m.symbols)
|
||||
return ((m.name || token) + (body ? ' ' + body : '')).trim()
|
||||
}
|
||||
|
||||
function getSummary(token) {
|
||||
let m = getModel(token)
|
||||
return m ? (m.summary || '') : null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Module exports
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
exports = {
|
||||
getCompletion,
|
||||
getModel,
|
||||
getSummary,
|
||||
getUsage,
|
||||
resolveSynopsisPath,
|
||||
registerProvider,
|
||||
clearCache,
|
||||
TSF_VERSION,
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/cat.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/cat.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cat",
|
||||
"summary": "Print a file, or pipe its contents onward",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to read; reads from the pipe when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/cd.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/cd.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cd",
|
||||
"summary": "Change the current working directory",
|
||||
"symbols": {
|
||||
"dir": { "kind": "positional", "type": "directory", "name": "DIR", "summary": "Directory to change into; prints the current directory when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "dir" }
|
||||
}
|
||||
}
|
||||
22
assets/disk0/tvdos/synopsis/chvt.synopsis
Normal file
22
assets/disk0/tvdos/synopsis/chvt.synopsis
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "chvt",
|
||||
"summary": "Switch to virtual console N (1-6)",
|
||||
"symbols": {
|
||||
"console": {
|
||||
"kind": "positional",
|
||||
"type": "enum",
|
||||
"name": "N",
|
||||
"summary": "Target virtual console",
|
||||
"values": [
|
||||
{ "value": "1", "summary": "Virtual console 1" },
|
||||
{ "value": "2", "summary": "Virtual console 2" },
|
||||
{ "value": "3", "summary": "Virtual console 3" },
|
||||
{ "value": "4", "summary": "Virtual console 4" },
|
||||
{ "value": "5", "summary": "Virtual console 5" },
|
||||
{ "value": "6", "summary": "Virtual console 6" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "console" }
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/cls.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/cls.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cls",
|
||||
"summary": "Clear the screen",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
16
assets/disk0/tvdos/synopsis/cp.synopsis
Normal file
16
assets/disk0/tvdos/synopsis/cp.synopsis
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cp",
|
||||
"summary": "Copy a file",
|
||||
"symbols": {
|
||||
"source": { "kind": "positional", "type": "file", "name": "SOURCE", "summary": "File to copy from" },
|
||||
"dest": { "kind": "positional", "type": "path", "name": "DEST", "summary": "Destination path" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "source" },
|
||||
{ "type": "reference", "symbol": "dest" }
|
||||
]
|
||||
}
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/date.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/date.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "date",
|
||||
"summary": "Print the system date and time",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
9
assets/disk0/tvdos/synopsis/del.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/del.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "del",
|
||||
"summary": "Delete a file",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to delete" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/dir.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/dir.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "dir",
|
||||
"summary": "List the contents of a directory",
|
||||
"symbols": {
|
||||
"path": { "kind": "positional", "type": "directory", "name": "PATH", "summary": "Directory to list; the working directory when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "path" }
|
||||
}
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/echo.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/echo.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "echo",
|
||||
"summary": "Print text, expanding $VARIABLE references",
|
||||
"symbols": {
|
||||
"text": { "kind": "positional", "type": "string", "name": "TEXT", "summary": "Text to print", "completion": { "method": "none" } }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "repeat",
|
||||
"child": { "type": "reference", "symbol": "text" }
|
||||
}
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/exit.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/exit.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "exit",
|
||||
"summary": "Exit the command processor",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
9
assets/disk0/tvdos/synopsis/mkdir.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/mkdir.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "mkdir",
|
||||
"summary": "Create a directory",
|
||||
"symbols": {
|
||||
"dir": { "kind": "positional", "type": "path", "name": "DIR", "summary": "Directory to create" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "dir" }
|
||||
}
|
||||
16
assets/disk0/tvdos/synopsis/mv.synopsis
Normal file
16
assets/disk0/tvdos/synopsis/mv.synopsis
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "mv",
|
||||
"summary": "Move or rename a file",
|
||||
"symbols": {
|
||||
"source": { "kind": "positional", "type": "file", "name": "SOURCE", "summary": "File to move" },
|
||||
"dest": { "kind": "positional", "type": "path", "name": "DEST", "summary": "Destination path" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "source" },
|
||||
{ "type": "reference", "symbol": "dest" }
|
||||
]
|
||||
}
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/panic.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/panic.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "panic",
|
||||
"summary": "Deliberately raise an error (diagnostic aid)",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/rem.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/rem.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "rem",
|
||||
"summary": "A comment; the line is ignored",
|
||||
"symbols": {
|
||||
"text": { "kind": "positional", "type": "string", "name": "TEXT", "summary": "Comment text", "completion": { "method": "none" } }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "repeat",
|
||||
"child": { "type": "reference", "symbol": "text" }
|
||||
}
|
||||
}
|
||||
18
assets/disk0/tvdos/synopsis/set.synopsis
Normal file
18
assets/disk0/tvdos/synopsis/set.synopsis
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "set",
|
||||
"summary": "Set or display an environment variable",
|
||||
"symbols": {
|
||||
"assignment": {
|
||||
"kind": "positional",
|
||||
"type": "string",
|
||||
"name": "NAME=VALUE",
|
||||
"summary": "Variable assignment, or a name to display; lists all variables when omitted",
|
||||
"completion": { "method": "internal", "provider": "envvars" }
|
||||
}
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "assignment" }
|
||||
}
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/ver.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/ver.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "ver",
|
||||
"summary": "Print the operating system version",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
9
assets/disk0/tvdos/synopsis/which.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/which.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "which",
|
||||
"summary": "Report how a command name resolves",
|
||||
"symbols": {
|
||||
"program": { "kind": "positional", "type": "command", "name": "PROGRAM", "summary": "Command name to resolve" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "program" }
|
||||
}
|
||||
BIN
assets/disk0/tvdos/tsvm.chr
Normal file
BIN
assets/disk0/tvdos/tsvm.chr
Normal file
Binary file not shown.
Reference in New Issue
Block a user