@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
985 lines • 42.2 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { specOf } from "../../spec/builtins.js";
import { FlagView } from "../../spec/flag_view.js";
import { mountKey, mountPrefixOf, underPath } from "../../../utils/key_prefix.js";
import { IOResult } from "../../../io/types.js";
import { LINK_TARGET_KEY, FileStat, FileType, Follow, LineFacts, PathSpec, } from "../../../types.js";
import { STAT_FAILED_KEY, formatLsLong, lsName, lsPrefix, timeOf, } from "../utils/formatting.js";
import { UsageError } from "../../errors.js";
import { isEntryError } from "../../../errors/classify.js";
import { identityOf } from "../utils/identity.js";
import { fsStrerror, isDotWalkError, isEacces, isMissError, isMissingPath, isWalkError, strerrorOf, } from "../../../errors/fs.js";
import { posixPhrase } from "../../../errors/posix.js";
import { followedStat } from "../../../view/namespace_view.js";
import { rstripSlash } from "../../../utils/slash.js";
import { respellOne, posixNormpath } from "../../../utils/path.js";
import { formatRecords } from "../utils/output.js";
import { compareCodePoints } from "../../../utils/sort.js";
import { contentSize } from "../../../utils/stat_view.js";
import { escapeName } from "../../../utils/quote.js";
import { textWidth } from "../../../utils/width.js";
import { linksOf, mountsOf, owedNames } from "../utils/links.js";
import { vfsStatPath } from "../utils/paths.js";
import { WalkBudget } from "../utils/tree.js";
import { openStream } from "../../../rs/stream.js";
import { concat } from "../../../utils/bytes.js";
import { scan, scanned } from "../../../rs/scan.js";
import { textView } from "../../../shell/bytes.js";
import { engineConfig } from "./sort.js";
export const LS_OK = 0;
export const LS_MINOR_PROBLEM = 1;
export const LS_FAILURE = 2;
function errText(err) {
return fsStrerror(err) ?? (err instanceof Error ? err.message : String(err));
}
// GNU ratchets the status upward: a serious problem always wins, a minor one
// only upgrades a clean run.
export function exitStatusFor(warnings) {
if (warnings.some((w) => w.serious))
return LS_FAILURE;
return warnings.length > 0 ? LS_MINOR_PROBLEM : LS_OK;
}
function childSpec(entryPath, prefix) {
return new PathSpec({
virtual: entryPath,
directory: entryPath,
resolved: false,
vfsPath: mountKey(entryPath, prefix),
});
}
/**
* The mark GNU appends to a name for a style (coreutils 9.7). A directory is
* `/` in every style but none; `slash` marks nothing else. A link is `@` and a FIFO `|`; an executable regular file is
* `*`, but only under classify. The mark rides the row's own type and mode, so
* it needs no separate lookup; a row nothing could stat (a dangling link's
* target) takes none. Mirrors Python's type_indicator.
*/
export function typeIndicator(entry, style) {
if (entry === null || style === 'none')
return '';
if (entry.type === FileType.DIRECTORY)
return '/';
if (style === 'slash')
return '';
if (entry.type === FileType.SYMLINK)
return '@';
if (entry.type === FileType.FIFO)
return '|';
if (style === 'classify' &&
entry.type === FileType.FILE &&
entry.mode !== null &&
(entry.mode & 0o111) !== 0)
return '*';
return '';
}
// Short rows: the name, the indicator style's mark, and -i/-Z's lead.
function formatSimple(s, indicator, columns, name) {
return `${lsPrefix(columns)}${name ?? s.name}${typeIndicator(s, indicator)}`;
}
/**
* The long format's name column with the indicator style's marks. GNU marks
* a row after its name, but a link after its target, with the target's own
* mark, and only under file-type and classify: `dl -> sub/`, `dang ->
* nowhere` (coreutils 9.7). Null when no mark is asked for, so the row's own
* column stands. Mirrors Python's long_names.
*/
function longNames(stats, names, indicator, targets) {
if (indicator === 'none')
return names === null ? null : [...names];
return stats.map((s, i) => {
const name = names?.[i] ?? lsName(s);
if (s.type !== FileType.SYMLINK)
return name + typeIndicator(s, indicator);
if (indicator === 'slash')
return name;
return name + typeIndicator(targets[i] ?? null, indicator);
});
}
// What each link row leads to, when the long format marks it. Mirrors
// Python's link_targets.
async function linkTargets(stats, virtuals, links, render) {
const wanted = render.long &&
links !== null &&
(render.indicator === 'file-type' || render.indicator === 'classify');
const out = [];
for (const [i, s] of stats.entries()) {
const virtual = virtuals[i];
out.push(wanted && virtual !== undefined && s.type === FileType.SYMLINK
? await links.targetStat(virtual)
: null);
}
return out;
}
// A name wrapped in the OSC 8 hyperlink GNU emits under --hyperlink,
// pointing at the entry's virtual path.
function hyperlinked(name, virtual) {
return `\x1b]8;;file://${uriEscape(virtual)}\x07${name}\x1b]8;;\x07`;
}
const URI_SAFE = /[A-Za-z0-9~_\-./]/;
// Percent-encode a path for a file: URI the way GNU ls does: every byte
// outside the unreserved set and `/` is %xx in lowercase hex, so a
// space, `?` or `#` cannot end the path.
export function uriEscape(path) {
let out = '';
for (const ch of path) {
if (URI_SAFE.test(ch)) {
out += ch;
continue;
}
for (const byte of ENC.encode(ch))
out += `%${byte.toString(16).padStart(2, '0')}`;
}
return out;
}
// The name column with escapes and hyperlinks, null when undecorated.
function decoratedNames(stats, hrefs, long, escape) {
if (hrefs === null && !escape)
return null;
return stats.map((s, i) => {
let name = escape ? escapeName(s.name) : s.name;
if (hrefs !== null)
name = hyperlinked(name, hrefs[i] ?? '');
const target = s.extra[LINK_TARGET_KEY];
if (long && s.type === FileType.SYMLINK && typeof target === 'string')
name += ' -> ' + (escape ? escapeName(target) : target);
return name;
});
}
function renderGroup(stats, render, lines, hrefs = null, targets = []) {
const names = decoratedNames(stats, hrefs, render.long, render.escape);
if (render.long) {
const marked = longNames(stats, names, render.indicator, targets);
const ctx = {
human: render.human,
identity: render.identity,
columns: render.columns,
...(marked !== null ? { names: marked } : {}),
};
for (const line of formatLsLong(stats, ctx))
lines.push(line);
return;
}
stats.forEach((s, i) => lines.push(formatSimple(s, render.indicator, render.columns, names?.[i])));
}
const ENC = new TextEncoder();
// A name may start with U+FEFF, which a default decoder drops from the front
// of what it decodes.
const DEC = new TextDecoder('utf-8', { ignoreBOM: true });
// GNU 9.7's `ls -v` lists names in the order `sort -zV` prints them, ties
// broken by bytes as sort's last resort does (1,500 random directories
// agreed), so the names go through mirage-rs's sort with one version key.
// A run holds every name, so the sort writes one run and never merges. Read
// once, on the first `ls -v`. Mirrors _version_sort in ls.py.
let versionSortConfig = null;
function versionSort() {
versionSortConfig ??= {
...engineConfig(scan('sort', [
['version_sort', ''],
['zero_terminated', ''],
])),
mode: 'sort',
run_bytes: Number.MAX_SAFE_INTEGER,
};
return versionSortConfig;
}
/**
* Indices of `names` (none holding a NUL) in `ls -v` order, each once;
* equal names keep their input order. Mirrors sort_by_version in ls.py.
*/
export async function sortByVersion(names) {
const places = new Map();
names.forEach((name, index) => {
const key = DEC.decode(ENC.encode(name));
const queue = places.get(key);
if (queue === undefined)
places.set(key, [index]);
else
queue.push(index);
});
const stream = await openStream('sort', versionSort());
const out = [];
try {
out.push(stream.feed(ENC.encode(names.map((name) => `${name}\0`).join(''))), stream.finish());
while (stream.busy())
out.push(stream.drain());
}
finally {
stream.close();
}
const sorted = DEC.decode(concat(out)).split('\0');
sorted.pop();
return sorted.map((key) => {
const index = places.get(key)?.shift();
if (index === undefined) {
throw new Error(`ls: the version sort returned a name it was not given: ${key}`);
}
return index;
});
}
// The key `ls -X` compares first: the name from its last dot, empty for
// a name without one.
function extensionOf(name) {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot) : '';
}
function primaryValue(entry, sortBy, timeKind) {
if (sortBy === 'time')
return timeOf(entry, timeKind) ?? '';
// A row whose stat failed sorts as 0, as GNU's zeroed stat does.
return entry.extra[STAT_FAILED_KEY] === true ? 0 : contentSize(entry);
}
// GNU's -t/-S comparators fall back to the name when the timestamps or sizes
// tie, so the order is total. `-r` negates the whole comparison, tie-break
// included, which is why callers fold the sign into this comparator instead of
// reversing the finished array. -X and --sort=width are stable sorts over
// the name order too; -v goes through sortByVersion instead. `widths` holds
// each name's --sort=width key, measured once per sort as Python's key is.
function compareStats(a, b, sortBy, timeKind, widths) {
if (sortBy === 'extension') {
const byExt = compareCodePoints(extensionOf(a.name), extensionOf(b.name));
if (byExt !== 0)
return byExt;
}
else if (sortBy === 'width') {
const byWidth = (widths.get(a.name) ?? 0) - (widths.get(b.name) ?? 0);
if (byWidth !== 0)
return byWidth;
}
else if (sortBy !== 'name' && sortBy !== 'none') {
const av = primaryValue(a, sortBy, timeKind);
const bv = primaryValue(b, sortBy, timeKind);
// -t and -S list newest/largest first.
if (av < bv)
return 1;
if (av > bv)
return -1;
}
return compareCodePoints(a.name, b.name);
}
// -U keeps the listing order, and -r does not reverse it (GNU's -r
// reverses while sorting, and -U does not sort);
// --group-directories-first partitions the finished order, so the
// directories come first in every sort but -U, where GNU ignores it.
export async function sortStats(stats, sortBy, reverse, timeKind = 'mtime', groupDirsFirst = false, escape = false) {
let ordered;
if (sortBy === 'none') {
ordered = [...stats];
}
else if (sortBy === 'version') {
const order = await sortByVersion(stats.map((s) => s.name));
ordered = order.flatMap((i) => stats[i] ?? []);
if (reverse)
ordered.reverse();
}
else {
const sign = reverse ? -1 : 1;
const widths = new Map(sortBy === 'width'
? stats.map((s) => [s.name, textWidth(escape ? escapeName(s.name) : s.name)])
: []);
ordered = [...stats].sort((a, b) => sign * compareStats(a, b, sortBy, timeKind, widths));
}
if (groupDirsFirst && sortBy !== 'none') {
ordered = [
...ordered.filter((s) => s.type === FileType.DIRECTORY),
...ordered.filter((s) => s.type !== FileType.DIRECTORY),
];
}
return ordered;
}
// A file operand whose readdir came back empty: backends without real
// directories (e.g. S3) list the "<file>/" prefix and find nothing rather than
// raising ENOTDIR. Return the stat only when it is a non-directory, so an empty
// directory still lists as empty. Mirrors Python ls `_file_entry`.
// The row of an operand that is no directory, null for a directory or a path
// nothing is at; a stat that fails other than by absence throws.
async function fileEntry(stat, path) {
const s = await operandStat(stat, path);
return s !== null && s.type !== FileType.DIRECTORY ? asOperand(s, path) : null;
}
// An operand's stat, null for a path nothing is at; a stat that fails other
// than by absence throws. Mirrors Python ls `_operand_stat`.
async function operandStat(stat, path) {
try {
return await stat(path);
}
catch (err) {
if (!isMissError(err) && !isMissingPath(err))
throw err;
return null;
}
}
// GNU ls prints a file operand as given (`ls sub/x.txt` shows sub/x.txt,
// not x.txt); the row carries the operand spelling. `with` preserves
// every other field (mode/uid/gid/atime overlay attrs included), the
// mirror of Python `s.model_copy(update={"name": ...})`.
function asOperand(s, path) {
return s.with({ name: path.rawPath });
}
// The row for a child mount, carrying its real type.
//
// No backend can supply it: the parent's cannot see into the child mount,
// and the child's own answers its root with that mount's name for itself
// ('/'), which is why the row is renamed here the way the relay renames
// one. So the fact comes through the dispatcher, which routes to whichever
// mount owns the path.
//
// A mount root is not always a directory — every workspace mounts
// `/.bash_history` as a whole mount serving one file — and calling one a
// directory suffixes it with '/' under -F, renders it `drwxr-xr-x` under
// -l, and offers it to -R as something to descend. GNU lists a file that
// happens to be a mountpoint as an ordinary file row (pinned on coreutils
// 9.7 over a `mount --bind` of one file onto another). Directory is the
// fallback for a caller with no dispatcher, which is the only thing that
// absence can mean.
async function mountRow(directory, name, statPath) {
if (statPath !== null) {
const row = await statPath(`${rstripSlash(directory.virtual)}/${name}`);
if (row !== null)
return row.with({ name });
}
return new FileStat({ name, type: FileType.DIRECTORY });
}
// The row for an entry the listing named but its stat could not describe.
// GNU keeps it, since readdir is what named it, and `?` stands for every
// fact only the stat knows; its type is a directory's when the listing
// slash-marked it, and unknown otherwise.
function statFailedRow(entry) {
const trimmed = rstripSlash(entry);
return new FileStat({
name: trimmed.slice(trimmed.lastIndexOf('/') + 1),
type: entry.endsWith('/') ? FileType.DIRECTORY : FileType.FILE,
extra: { [STAT_FAILED_KEY]: true },
});
}
// Stat every name in a directory, plus the mounts below it. An entry whose
// stat fails keeps its row and never fails the whole directory, whatever the
// error: a dropped connection on a mount whose stat is a request costs that
// entry alone, as any failed stat does in a GNU listing. It is reported,
// and the exit is 1, only when the listing prints something the stat
// supplies (`statNeeded`). One entry at a time, as GNU ls and find stat
// them: on a mount that keeps no listing index each stat is a
// backend request, and firing a whole directory's worth together is a burst
// the backend may refuse. `spell` gives an entry's path as the operand
// spelled it, for a diagnostic. Mirrors Python ls `_stat_entries`.
async function statEntries(readdir, stat, dir, ctx, warnings, spell) {
const { all, links, childMounts } = ctx;
let entries;
let structureOnly = false;
try {
const limit = ctx.budget?.listingLimit(dir.virtual) ?? null;
entries = await (limit === null ? readdir(dir) : readdir(dir, false, limit));
const budget = ctx.budget;
if (budget !== undefined)
entries = entries.filter(() => budget.spend(dir.virtual));
}
catch (err) {
// A directory whose own `.` and `..` did not resolve is not one the
// namespace can owe children to, and one the session may not open is
// refused whole: the names the namespace owes it (a link in it, a mount
// below it) are as much its listing as the backend's are.
if (!isWalkError(err) ||
isDotWalkError(err) ||
isEacces(err) ||
(childMounts?.(dir.virtual) ?? []).length === 0)
throw err;
// No backend serves it, but the namespace owes it children (a
// nested mount, a link's ancestors), so the dispatcher lists it as a
// directory and ls must agree: the merge below renders those rows
// from an empty backend listing.
entries = [];
structureOnly = true;
}
const entryStat = (p) => followedStat(stat, ctx.deref ? links : null, p);
const prefix = mountPrefixOf(dir.virtual, dir.vfsPath);
const stats = [];
for (const entry of entries) {
let s;
try {
s = await entryStat(childSpec(entry, prefix));
}
catch (err) {
if (!isEntryError(err))
throw err;
const row = statFailedRow(entry);
stats.push(row);
if (!all && row.name.startsWith('.'))
continue;
if (!ctx.statNeeded)
continue;
// An entry below an operand is never a command-line arg.
warnings.push({
message: `ls: cannot access '${spell(rstripSlash(entry))}': ${strerrorOf(err)}`,
serious: false,
});
continue;
}
stats.push(s);
if (ctx.deref &&
ctx.statNeeded &&
s.type === FileType.SYMLINK &&
(all || !s.name.startsWith('.'))) {
// -L reports what each link leads to; one leading nowhere is listed
// by name and reported, a minor problem (exit 1), where the listing
// stats an entry at all.
warnings.push({
message: `ls: cannot access '${spell(rstripSlash(entry))}': ${posixPhrase('ENOENT')}`,
serious: false,
});
}
}
const seen = new Set(stats.map((s) => s.name));
for (const name of childMounts?.(dir.virtual) ?? []) {
if (seen.has(name))
continue;
seen.add(name);
stats.push(await mountRow(dir, name, ctx.statPath));
}
return {
stats: all ? stats : stats.filter((s) => !s.name.startsWith('.')),
structureOnly,
};
}
// The row for an operand that is itself a symlink, else null, named with the
// operand's own spelling like every other ls row. Renaming a link row copies
// every field, not a hand-picked few: a link carries real ownership (chown -h
// writes it), and listing the fields by hand silently drops whatever the node
// grows next.
function linkRow(path, links) {
const row = links?.statAt(path.virtual) ?? null;
if (row === null)
return null;
return row.with({ name: path.rawPath });
}
// The row `ls -d` prints for one operand, or why it cannot. Mirrors Python's
// _operand_row.
async function operandRow(path, stat, links, childMounts) {
const link = linkRow(path, links);
if (link !== null)
return link;
try {
// GNU ls -d prints the operand as given.
return asOperand(await stat(path), path);
}
catch (err) {
if (!isWalkError(err))
throw err;
if (!isDotWalkError(err) && (childMounts?.(path.virtual) ?? []).length > 0) {
// No backend serves it, but the namespace owes it children,
// so the dispatcher stats it as a directory and -d must print the
// same row.
return new FileStat({ name: path.rawPath, type: FileType.DIRECTORY });
}
return { message: `ls: cannot access '${path.rawPath}': ${errText(err)}`, serious: true };
}
}
// List one operand and report whether it turned out to be a directory.
// `chain` holds, under -RL, the directories open above this one, each as the
// path it resolves to; `spell` gives a path below the operand as the operand
// spelled it, null at the operand itself.
async function probeOperand(readdir, stat, path, ctx, warnings, commandLineArg, chain = [], spell = null) {
const spelled = spell ?? ((virtual) => respellOne(virtual, path.virtual, path.rawPath));
const links = ctx.links;
const ownStat = (p) => followedStat(stat, ctx.deref ? links : null, p);
if (path.walkError === null && (commandLineArg || !ctx.deref)) {
// The table's readdir opens what a link leads to, as opendir(3) does,
// so a link the line left unresolved is asked first: the router
// already resolved every one this ls follows.
const link = linkRow(path, ctx.links);
if (link !== null)
return { path, row: link, groups: [] };
}
let stats;
let structureOnly = false;
try {
const listed = await statEntries(readdir, stat, path, ctx, warnings, spelled);
stats = listed.stats;
structureOnly = listed.structureOnly;
}
catch (err) {
if (!isWalkError(err))
throw err;
// The operand did not resolve, so neither the path it simplifies to
// nor a link standing there answers for it. A command-line link whose
// stat loops is the exception: GNU lstats it then and lists the link
// itself, unless -L or -H asked for the target (coreutils 9.7).
const walkRefused = isDotWalkError(err);
const looped = walkRefused && err.code === 'ELOOP';
let found = null;
let refused = null;
if (!walkRefused) {
try {
found = await operandStat(ownStat, path);
}
catch (statErr) {
if (!isWalkError(statErr))
throw statErr;
refused = statErr;
}
}
if (found !== null && found.type !== FileType.DIRECTORY) {
return { path, row: asOperand(found, path), groups: [] };
}
const link = looped && commandLineArg && !ctx.followArgs ? linkRow(path, links) : null;
if (link !== null)
return { path, row: link, groups: [] };
// GNU 9.7 stats an operand before it opens one: a stat that fails is what
// it names, and a directory it may not read is worded apart, the entry
// being there.
const verb = found !== null && isEacces(err) ? 'cannot open directory' : 'cannot access';
warnings.push({
message: `ls: ${verb} '${path.rawPath}': ${errText(refused ?? err)}`,
serious: commandLineArg,
});
return { path, row: null, groups: [] };
}
if (stats.length === 0 && !structureOnly) {
let row = null;
try {
row = await fileEntry(ownStat, path);
}
catch (err) {
if (!isWalkError(err))
throw err;
console.debug(`ls: ${path.virtual} listed empty, its stat refused: ${String(err)}`);
}
if (row !== null)
return { path, row, groups: [] };
}
const entries = await sortStats(stats, ctx.sortBy, ctx.reverse, ctx.timeKind, ctx.groupDirsFirst, ctx.escape);
const groups = [[path, entries]];
if (ctx.recursive) {
const real = ctx.deref && links !== null ? (virtual) => links.resolve(virtual) : null;
const open = real !== null && chain.length === 0 ? [real(path.virtual)] : chain;
for (const s of entries) {
if (s.type !== FileType.DIRECTORY)
continue;
const childPath = `${rstripSlash(path.virtual)}/${s.name}`;
if (ctx.mounts?.isRoot(childPath) ?? false) {
// A nested mount's root. Its row belongs here, but its listing
// is another backend's, which this walk cannot read: the
// cross-mount fan-out renders that group.
continue;
}
if (ctx.budget !== undefined && !ctx.budget.opens(childPath))
continue;
let below = open;
if (real !== null) {
// Under -L a directory is the one it resolves to, and one open
// above this one is never listed again (exit 2).
const here = real(childPath);
if (open.includes(here)) {
warnings.push({
message: `ls: ${spelled(childPath)}: not listing already-listed directory`,
serious: true,
});
continue;
}
below = [...open, here];
}
const child = await probeOperand(readdir, stat, childSpec(childPath, mountPrefixOf(path.virtual, path.vfsPath)), ctx, warnings, false, below, spelled);
// Appended one at a time: `push(...child.groups)` would spread the
// child's whole pre-order subtree as call arguments and overflow the
// engine's argument limit on a very wide tree. Mirrors Python's
// `groups.extend(child.groups)`.
for (const group of child.groups)
groups.push(group);
}
}
return { path, row: null, groups };
}
// Sort row for one operand, named with the operand's own spelling.
async function operandKey(operand, sortBy, stat) {
if (operand.row !== null)
return operand.row;
if (sortBy === 'name') {
return new FileStat({ name: operand.path.rawPath, type: FileType.DIRECTORY });
}
try {
return asOperand(await stat(operand.path), operand.path);
}
catch (err) {
if (!isWalkError(err))
throw err;
// The stat only supplies a sort key; an operand that cannot be statted
// sorts as if it had none rather than failing the listing.
return new FileStat({ name: operand.path.rawPath, type: FileType.DIRECTORY });
}
}
async function sortOperands(operands, sortBy, reverse, stat, timeKind, escape) {
const keyed = [];
for (const operand of operands) {
keyed.push({ key: await operandKey(operand, sortBy, stat), operand });
}
if (sortBy === 'none')
return keyed.map((k) => k.operand);
if (sortBy === 'version') {
const order = await sortByVersion(keyed.map((k) => k.key.name));
const ordered = order.flatMap((i) => keyed[i]?.operand ?? []);
return reverse ? ordered.reverse() : ordered;
}
const sign = reverse ? -1 : 1;
const widths = new Map(sortBy === 'width'
? keyed.map(({ key }) => [key.name, textWidth(escape ? escapeName(key.name) : key.name)])
: []);
keyed.sort((a, b) => sign * compareStats(a.key, b.key, sortBy, timeKind, widths));
return keyed.map((k) => k.operand);
}
// The three spellings of a command-line link policy; the last one wins.
const FOLLOWS = {
all: Follow.LAST,
directory: Follow.DIRECTORY,
none: Follow.NONE,
};
const DEREF_DESTS = [
'dereference',
'dereference_command_line',
'dereference_command_line_symlink_to_dir',
];
// The four spellings of an indicator style; the last one typed wins.
const INDICATOR_DESTS = ['classify', 'file_type', 'p', 'indicator_style'];
// What mirage-rs reads: every option that carries a word, a size or a style,
// in the order typed.
const SCANNED = [
'sort',
't',
'S',
'X',
'v',
'U',
'time',
'c',
'u',
'hyperlink',
'color',
'classify',
'indicator_style',
'p',
'file_type',
'block_size',
'human_readable',
'time_style',
];
// GNU reads a bare --classify, --hyperlink or --color as `always`.
const BARE_ALWAYS = new Set(['classify', 'hyperlink', 'color']);
function oneOf(words, value, what) {
const found = words.find((word) => word === value);
if (found === undefined) {
throw new Error(`ls: mirage-rs read ${what} as ${JSON.stringify(value)}`);
}
return found;
}
// The size scale mirage-rs answered, checked for its shape.
function blockSizeOf(raw) {
if (raw === null)
return null;
if (typeof raw === 'object' && !Array.isArray(raw)) {
const { divisor, suffix, human_base: base } = raw;
if (typeof divisor === 'string' &&
typeof suffix === 'string' &&
(base === null || typeof base === 'number')) {
return { divisor: Number(divisor), suffix, humanBase: base };
}
}
throw new Error(`ls: mirage-rs read --block-size as ${JSON.stringify(raw)}`);
}
// The typed options as the scan reads them: a switch empty, except the bare
// spelling of an option that takes WHEN, which is the word GNU reads it as and
// stays apart from an explicit empty value.
function tape(fl, names) {
return fl
.occurrences(...names)
.filter(([, value]) => value !== false)
.map(([dest, value]) => [
dest,
value === true && BARE_ALWAYS.has(dest) ? 'always' : typeof value === 'string' ? value : '',
]);
}
/**
* ls's option values, read by mirage-rs in one pass over the line; `names`
* picks which options to read, the others keeping their defaults. Throws
* UsageError for the first value ls refuses, in its words and with its exit
* status. Mirrors Python's `scan_values`.
*/
function scanValues(fl, names = SCANNED) {
const values = scanned('ls', tape(fl, names));
const { sort, hyperlink } = values;
const style = values.time_style;
if (typeof hyperlink !== 'boolean' || typeof style !== 'string') {
throw new Error(`ls: mirage-rs read the options as ${JSON.stringify(values)}`);
}
return {
sortBy: sort === null
? null
: oneOf(['name', 'time', 'size', 'version', 'extension', 'width', 'none'], sort, '--sort'),
timeKind: oneOf(['mtime', 'atime', 'ctime', 'birth'], values.time, '--time'),
indicator: oneOf(['none', 'slash', 'file-type', 'classify'], values.indicator, 'the indicator'),
hyperlink,
blockSize: blockSizeOf(values.block_size),
timeStyle: textView(style),
};
}
/**
* The indicator style a line asks for: the last of -F/--classify[=WHEN], -p,
* --file-type and --indicator-style wins, and every one is checked on the
* way, as GNU checks each value while it reads the options (coreutils 9.7).
* Mirrors Python's indicator_flag.
*/
export function indicatorFlag(fl) {
return scanValues(fl, INDICATOR_DESTS).indicator;
}
/**
* Which command-line links GNU ls resolves before it lists them. GNU settles
* it once: the last of -L, -H and --dereference-command-line-symlink-to-dir
* wins, and with none of them -d, a long format or the classify style resolve
* no link while anything else resolves one that leads to a directory
* (coreutils 9.7). A value the command refuses leaves the style unclassified,
* since that line fails before it lists anything. `indicator` is the style a
* scan of the whole line already read; without it the indicator options are
* read here. Mirrors Python's link_mode.
*/
export function linkMode(fl, indicator) {
const typed = fl.typedOrder(...DEREF_DESTS);
const last = typed.at(-1);
if (last !== undefined) {
return last === 'dereference_command_line_symlink_to_dir' ? 'directory' : 'all';
}
const long = ['args_l', 'g', 'o', 'numeric_uid_gid'].some((dest) => fl.asBool(dest));
let classify = false;
try {
classify = (indicator ?? indicatorFlag(fl)) === 'classify';
}
catch (err) {
// The command refuses this line itself, before it lists anything.
if (!(err instanceof UsageError))
throw err;
}
return fl.asBool('directory') || long || classify ? 'none' : 'directory';
}
/**
* An ls line resolves the command-line links `linkMode` says, and walks, and
* reads, below its operands under `-R`. Mirrors Python's `facts`.
*/
export function facts(fl, _operands) {
const recursive = fl.asBool('recursive');
return new LineFacts({
follows: FOLLOWS[linkMode(fl)],
walks: recursive,
readsSubtree: recursive,
});
}
// Parse the ls flag bag once into a frozen struct. GNU's rules that are
// easy to get wrong: -g, -o and -n imply the long format, and -1 never
// undoes it in either order (GNU ignores -1 beside -l, and with no
// terminal there are never columns, so -1 has nothing else to do); -n prints the
// same columns as -l, because a mirage owner is already the id (an agent,
// a profile) and never a name looked up from one; and -c or -u with neither
// -l nor a sort sorts by that time. The words, sizes and styles (the last
// sort, time and indicator spelling, the later of -h and --block-size) are
// mirage-rs's, read in one pass. Throws UsageError for a value GNU refuses,
// with GNU's exit status for that option.
export function parseFlags(fl) {
const values = scanValues(fl);
const noOwner = fl.asBool('g');
const noGroup = fl.asBool('o');
const long = fl.asBool('args_l') || noOwner || noGroup || fl.asBool('numeric_uid_gid');
const sortBy = values.sortBy === null && values.timeKind !== 'mtime' && !long
? 'time'
: (values.sortBy ?? 'name');
const columns = Object.freeze({
owner: !noOwner,
group: !noGroup,
inode: fl.asBool('inode'),
context: fl.asBool('context'),
timeKind: values.timeKind,
timeStyle: values.timeStyle,
blockSize: values.blockSize,
});
return Object.freeze({
long,
all: fl.asBool('all') || fl.asBool('almost_all'),
showDotEntries: fl.asBool('all'),
human: fl.asBool('human_readable'),
reverse: fl.asBool('reverse'),
indicator: values.indicator,
recursive: fl.asBool('recursive'),
listDir: fl.asBool('directory'),
// -L alone dereferences what a listing finds; -H and its sibling stop at
// the command line, and the last of the three wins.
deref: fl.typedOrder(...DEREF_DESTS).at(-1) === 'dereference',
followArgs: linkMode(fl, values.indicator) === 'all',
sortBy,
timeKind: values.timeKind,
groupDirsFirst: fl.asBool('group_directories_first'),
columns,
hyperlink: values.hyperlink,
escape: fl.asBool('escape'),
});
}
// Whether GNU's ls would stat a listed entry to print this listing, which
// is when a failed stat reaches stderr: the long format, a time or size
// sort, -i, -Z and --hyperlink read the stat, and -R, -F and
// --group-directories-first read the type, which a mirage listing never
// carries, as a readdir without d_type does not. A plain listing prints the
// names readdir gave and nothing else.
function statNeeded(flags) {
return (flags.long ||
flags.sortBy === 'time' ||
flags.sortBy === 'size' ||
flags.columns.inode ||
flags.columns.context ||
flags.hyperlink ||
flags.recursive ||
flags.indicator !== 'none' ||
flags.groupDirsFirst);
}
function finish(lines, warnings) {
const out = formatRecords(lines);
const exitCode = exitStatusFor(warnings);
if (warnings.length > 0) {
const stderr = formatRecords(warnings.map((w) => w.message));
return [out, new IOResult({ stderr, exitCode })];
}
return [out, new IOResult({ exitCode })];
}
// `statPath` stats a mount root or dot entry across mounts; the cross-mount relay passes none.
export async function lsGeneric(paths, ctx, readdir, stat, statPath = vfsStatPath(ctx.vfs) ?? null, flags = parseFlags(new FlagView(ctx.flags, specOf('ls')))) {
const targets = paths.length > 0
? paths
: [
new PathSpec({
virtual: ctx.cwd.virtual,
directory: ctx.cwd.virtual,
resolved: false,
vfsPath: ctx.cwd.vfsPath,
}),
];
const { long, all, human, reverse, indicator, recursive, sortBy, timeKind, groupDirsFirst, deref, followArgs, } = flags;
const listDirItself = flags.listDir;
const links = linksOf(ctx) ?? null;
const identity = identityOf(ctx);
const render = {
long,
human,
indicator,
identity,
columns: flags.columns,
escape: flags.escape,
};
const warnings = [];
const lines = [];
const childMounts = ctx.vfs === undefined ? null : (dir) => owedNames(ctx.vfs, dir);
if (listDirItself) {
// -d turns every operand into a plain row, sorted together and printed
// with no headers.
const collected = [];
for (const p of targets) {
const row = await operandRow(p, stat, links, childMounts);
if (row instanceof FileStat)
collected.push({ row, href: p.virtual });
else
warnings.push(row);
}
const byRow = new Map(collected.map((c) => [c.row, c.href]));
const rows = collected.length > 1
? await sortStats(collected.map((c) => c.row), sortBy, reverse, timeKind, groupDirsFirst, flags.escape)
: collected.map((c) => c.row);
const rowPaths = rows.map((r) => byRow.get(r) ?? '');
renderGroup(rows, render, lines, flags.hyperlink ? rowPaths : null, await linkTargets(rows, rowPaths, links, render));
return finish(lines, warnings);
}
const walkOpts = {
escape: flags.escape,
all,
sortBy,
reverse,
timeKind,
groupDirsFirst,
recursive,
links,
deref,
followArgs,
childMounts,
mounts: mountsOf(ctx) ?? null,
statPath,
statNeeded: statNeeded(flags),
};
const probed = [];
for (const p of targets) {
const budget = recursive ? new WalkBudget(p, walkOpts.mounts) : undefined;
const opts = budget === undefined ? walkOpts : { ...walkOpts, budget };
probed.push(await probeOperand(readdir, stat, p, opts, warnings, true));
for (const line of budget?.stopped ?? []) {
warnings.push({ message: `ls: ${line}`, serious: false });
}
}
const operands = probed.length > 1
? await sortOperands(probed, sortBy, reverse, stat, timeKind, flags.escape)
: probed;
// GNU names every listed directory once there is more than one operand
// (or under -R); a lone directory operand is listed bare.
const headed = recursive || targets.length > 1;
const rowed = operands.filter((o) => o.row !== null);
const rows = rowed.flatMap((o) => (o.row !== null ? [o.row] : []));
const rowPaths = rowed.map((o) => o.path.virtual);
renderGroup(rows, render, lines, flags.hyperlink ? rowPaths : null, await linkTargets(rows, rowPaths, links, render));
let printed = rows.length > 0;
for (const operand of operands) {
for (const [dirSpec, group] of operand.groups) {
let entries = group;
if (flags.showDotEntries) {
const dots = [];
const prefix = mountPrefixOf(dirSpec.virtual, dirSpec.vfsPath);
for (const name of ['.', '..']) {
const target = posixNormpath(`${dirSpec.virtual}/${name}`);
let row = new FileStat({ name, type: FileType.DIRECTORY });
// Only the namespace can stat a parent outside this mount.
// Without `statPath`, keep the synthetic directory row.
if (statNeeded(flags) && (statPath !== null || underPath(target, prefix))) {
try {
const found = statPath !== null ? await statPath(target) : await stat(childSpec(target, prefix));
if (found !== null)
row = found.with({ name });
}
catch (err) {
if (!isWalkError(err))
throw err;
row = statFailedRow(name);
warnings.push({
message: `ls: cannot access '${name}': ${errText(err)}`,
serious: false,
});
}
}
dots.push(row);
}
entries = await sortStats([...dots, ...entries], sortBy, reverse, timeKind, groupDirsFirst, flags.escape);
}
if (headed) {
if (printed)
lines.push('');
const header = respellOne(dirSpec.virtual, operand.path.virtual, operand.path.rawPath);
lines.push(`${flags.escape ? escapeName(header) : header}:`);
}
// GNU 9.7 prints allocated blocks; VFS has no allocation metadata.
if (long)
lines.push(entries.length > 0 ? 'total ?' : 'total 0');
const entryPaths = entries.map((e) => `${rstripSlash(dirSpec.virtual)}/${e.name}`);
renderGroup(entries, render, lines, flags.hyperlink ? entryPaths : null, await linkTargets(entries, entryPaths, links, render));
printed = true;
}
}
return finish(lines, warnings);
}
//# sourceMappingURL=ls.js.map