Skip to content

Commit db945c3

Browse files
committed
web/shell engine: fix round 4 (tr NUL and escapes, quoted "$@" as a command, relative dates, diff -r, option tables against GNU getopt, xargs -x -s, sort -k signs, TZ as a zoneinfo path)
1 parent 01f4255 commit db945c3

25 files changed

Lines changed: 3833 additions & 222 deletions

‎web/shell/engine/NOTES.md‎

Lines changed: 229 additions & 46 deletions
Large diffs are not rendered by default.

‎web/shell/engine/compat.js‎

Lines changed: 191 additions & 63 deletions
Large diffs are not rendered by default.

‎web/shell/engine/datestr.js‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
//
99
// The engine's own `date -d` takes `@N`, `now`, `yesterday`, `tomorrow` and a wall clock; this module reads what it
1010
// reads and the rest, and hands back an instant, which the `date` wrapper gives it as `@SECONDS`. A string with a word
11-
// or a shape this module does not know is `null`: the engine decides.
11+
// or a shape this module does not know is "invalid date", as it is in GNU's grammar (the engine's own reader was
12+
// lenient with junk: `last hr`, `ago first yr 1999-01-01`).
1213
'use strict';
1314
// @ts-check
1415

@@ -134,11 +135,11 @@ function daysIn(year, month) {
134135
* @param {string} input the string given to `-d`
135136
* @param {number} nowMs the clock
136137
* @param {string} tz the zone key of the command (zone.getZone())
137-
* @returns {{ ms: number, nanos: number } | { invalid: true } | null} the instant (whole seconds; `nanos` is the fraction of the time given)
138+
* @returns {{ ms: number, nanos: number } | { invalid: true }} the instant (whole seconds; `nanos` is the fraction of the time given)
138139
*/
139140
function parse(input, nowMs, tz) {
140141
const tokens = lex(input);
141-
if (tokens === null) { return null; }
142+
if (tokens === null) { return { invalid: true }; }
142143
const base = zone.partsAt(nowMs, tz);
143144
const pc = {
144145
year: base.year, yearDigits: 0, month: base.month + 1, day: base.day,
@@ -175,13 +176,26 @@ function parse(input, nowMs, tz) {
175176
seconds = t.value * 3600;
176177
if (isChar(peek(1), ':') && isNum(peek(2), false)) { seconds += peek(2).value * 60; p += 2; }
177178
} else { seconds = Math.floor(t.value / 100) * 3600 + (t.value % 100) * 60; }
179+
// more than a day east or west is not a zone: GNU counts one more zone for it, which is "invalid date" at the end
180+
// (`12:00 +42 days` is, `12:00 +1 day` is not)
181+
if (seconds > 24 * 3600) { pc.zones++; }
178182
return sign * seconds;
179183
};
184+
/**
185+
* the item before this one was a bare number (a year after `Jul 4`, `20240704`, `1200`) or the name of a zone without
186+
* daylight saving time in it (`UTC`, `EST`; not `EDT`): a signed
187+
* count of a unit after it is read as a zone's offset first, and `ago` after it is a syntax error in GNU's parser
188+
* (`Jul 4 2024 +1 day ago`, `18:00 UTC +1 day ago`; `+1 day` alone, and `Jul 4, 2024 +1 day ago`, are fine)
189+
*/
190+
let bare = false;
180191
while (p < tokens.length) {
192+
const was = bare;
193+
bare = false;
181194
const t = tokens[p];
182195
if (t.t === 'char') {
183-
if (t.v === ',') { p++; continue; }
184-
return null;
196+
// (a comma belongs to a day name, a `Jul 4, 2024` and a fraction of a second, each read where it is: anywhere else
197+
// it is a syntax error)
198+
return invalid;
185199
}
186200
if (t.t === 'num') {
187201
const next = peek(1);
@@ -272,12 +286,17 @@ function parse(input, nowMs, tz) {
272286
if (c && c.k === 'unit') {
273287
addRel(c.unit, (t.sign === '-' ? -1 : 1) * t.value * c.mult);
274288
p += 2;
275-
if (peek() && peek().t === 'word' && peek().v === 'ago') { /** @type {any} */ (lastRel)(); p++; }
289+
if (peek() && peek().t === 'word' && peek().v === 'ago') {
290+
if (was) { return invalid; }
291+
/** @type {any} */ (lastRel)();
292+
p++;
293+
}
276294
continue;
277295
}
278296
}
279297
// a number of its own
280298
if (!t.signed) {
299+
bare = true;
281300
if (pc.dates && !pc.yearDigits && !pc.rels && (pc.times || t.digits > 2)) {
282301
pc.year = t.value;
283302
pc.yearDigits = t.digits;
@@ -299,7 +318,6 @@ function parse(input, nowMs, tz) {
299318
// the `T` of 2024-07-04T12:00 (a date, then a `T`, then a time) is only the separator
300319
if (t.v === 't' && pc.dates && !pc.times && isNum(peek(1), false) && isChar(peek(2), ':')) { p++; continue; }
301320
const c = classify(t.v);
302-
if (!c) { return null; }
303321
if (c.k === 'unknown') { return invalid; }
304322
p++;
305323
if (c.k === 'dayshift') { pc.rels++; pc.rel.day += c.n; continue; }
@@ -308,14 +326,14 @@ function parse(input, nowMs, tz) {
308326
const d = w && w.t === 'word' ? classify(w.v) : null;
309327
if (d && d.k === 'unit') { addRel(d.unit, c.n * d.mult); p++; if (peek() && peek().t === 'word' && peek().v === 'ago') { /** @type {any} */ (lastRel)(); p++; } continue; }
310328
if (d && d.k === 'day') { pc.days++; pc.dayOrdinal = c.n; pc.dayNumber = d.n; p++; continue; }
311-
return null;
329+
return invalid;
312330
}
313331
if (c.k === 'unit') { addRel(c.unit, c.mult); if (peek() && peek().t === 'word' && peek().v === 'ago') { /** @type {any} */ (lastRel)(); p++; } continue; }
314332
if (c.k === 'day') { pc.days++; pc.dayOrdinal = 0; pc.dayNumber = c.n; if (isChar(peek(), ',')) { p++; } continue; }
315333
if (c.k === 'month') {
316334
// Mon D [, YYYY] (Mon YYYY is not a date)
317335
const d = peek();
318-
if (!isNum(d, false)) { return null; }
336+
if (!isNum(d, false)) { return invalid; }
319337
pc.dates++;
320338
pc.month = c.n;
321339
pc.day = /** @type {any} */ (d).value;
@@ -328,10 +346,12 @@ function parse(input, nowMs, tz) {
328346
pc.zoneSeconds = c.hours * 3600;
329347
pc.zoneDay = c.day;
330348
pc.zoneNamed = true;
349+
// (a name with no daylight saving in it is followed by `relunit_snumber` in the grammar; `EDT` is not)
350+
bare = !c.day;
331351
// a zone's name may be followed by a number that adds to it (`UTC +0100`), when no unit follows the number
332352
const z = peek();
333353
const after = peek(1);
334-
if (isNum(z, true) && !(after && after.t === 'word' && classify(after.v).k === 'unit')) { pc.zoneSeconds += zoneOffset(z); p++; }
354+
if (isNum(z, true) && !(after && after.t === 'word' && classify(after.v).k === 'unit')) { pc.zoneSeconds += zoneOffset(z); p++; bare = false; }
335355
continue;
336356
}
337357
// `DST` after a zone name that has no daylight saving in it: an hour more east
@@ -341,7 +361,7 @@ function parse(input, nowMs, tz) {
341361
pc.zoneNamed = false;
342362
continue;
343363
}
344-
return null;
364+
return invalid;
345365
}
346366
// the grammar's own refusals
347367
if (pc.times > 1 || pc.dates > 1 || pc.days > 1 || pc.zones > 1) { return invalid; }
@@ -361,7 +381,6 @@ function parse(input, nowMs, tz) {
361381
// the fixed offset of a zone given: no daylight rule is in it
362382
const zoneKey = pc.zones ? null : tz;
363383
const toInstant = (wallMs) => (zoneKey === null ? wallMs - pc.zoneSeconds * 1000 : zone.instantOf(wallMs, zoneKey));
364-
const wallOf = (ms) => (zoneKey === null ? zone.partsAt(ms + pc.zoneSeconds * 1000, 'UTC') : zone.partsAt(ms, zoneKey));
365384
// a day name when there is no date comes first: that day, today or after (an ordinal moves it by weeks); then the
366385
// relative years, months and days move the wall clock, and the instant is read once
367386
let day = pc.day;

‎web/shell/engine/diff.js‎

Lines changed: 126 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ const { decodeBytes } = require('./text.js');
3232
function parseOptions(args) {
3333
/** @type {DiffOptions} */
3434
const o = { r: false, N: false, q: false, s: false, i: false, w: false, b: false, B: false, Z: false, stripCr: false, text: false, format: 'normal', context: 3, labels: [], exclude: [], excludeFrom: [] };
35+
// the number of context lines is the greatest of what -U N and -C N said, or the number of -NUM; a bare -u or -c is
36+
// three lines, but only when there is no -NUM (`-u -U1` is three, `-U0 -u` is three, `-1 -u` is one, `-U1` is one)
37+
let contextValue = -1;
38+
let contextDigits = -1;
39+
let bareStyle = false;
40+
const more = (n) => { if (n > contextValue) { contextValue = n; } };
3541
/** @type {string[]} */
3642
const operands = [];
3743
/** @type {string[]} */
@@ -64,13 +70,13 @@ function parseOptions(args) {
6470
case 'unified': case 'context': {
6571
const e = style(name);
6672
if (e) { return e; }
67-
if (value !== null) { if (!/^\d+$/.test(value)) { return { error: `diff: invalid context length '${value}'\n` }; } o.context = Number(value); }
73+
if (value !== null) { if (!/^\d+$/.test(value)) { return { error: `diff: invalid context length '${value}'\ndiff: Try 'diff --help' for more information.\n` }; } more(Number(value)); } else { bareStyle = true; }
6874
break;
6975
}
7076
case 'label': { const v = need(); if (v === undefined) { return { unknown: true }; } o.labels.push(v); break; }
7177
case 'exclude': { const v = need(); if (v === undefined) { return { unknown: true }; } o.exclude.push(v); break; }
7278
case 'exclude-from': { const v = need(); if (v === undefined) { return { unknown: true }; } o.excludeFrom.push(v); break; }
73-
case 'no-dereference': break;
79+
case 'no-dereference': case 'minimal': case 'speed-large-files': break;
7480
default: return { unknown: true };
7581
}
7682
continue;
@@ -90,7 +96,7 @@ function parseOptions(args) {
9096
if (ch >= '0' && ch <= '9') {
9197
// -NUM is the number of context lines, and nothing more: the format is what -c or -u says
9298
const m = /^\d+/.exec(a.slice(c));
93-
o.context = Number(m && m[0]);
99+
contextDigits = Number(m && m[0]);
94100
c += (m ? m[0].length : 1) - 1;
95101
continue;
96102
}
@@ -106,15 +112,15 @@ function parseOptions(args) {
106112
case 'Z': o.Z = true; break;
107113
case 'a': o.text = true; break;
108114
case 'd': case 'H': case 'v': break;
109-
case 'u': { const e = style('unified'); if (e) { return e; } break; }
110-
case 'c': { const e = style('context'); if (e) { return e; } break; }
115+
case 'u': { const e = style('unified'); if (e) { return e; } bareStyle = true; break; }
116+
case 'c': { const e = style('context'); if (e) { return e; } bareStyle = true; break; }
111117
case 'U': case 'C': {
112118
const v = takeValue();
113119
if (v === undefined) { return { unknown: true }; }
114-
if (!/^\d+$/.test(v)) { return { error: `diff: invalid context length '${v}'\n` }; }
120+
if (!/^\d+$/.test(v)) { return { error: `diff: invalid context length '${v}'\ndiff: Try 'diff --help' for more information.\n` }; }
115121
const e = style(ch === 'U' ? 'unified' : 'context');
116122
if (e) { return e; }
117-
o.context = Number(v);
123+
more(Number(v));
118124
break;
119125
}
120126
case 'L': { const v = takeValue(); if (v === undefined) { return { unknown: true }; } o.labels.push(v); break; }
@@ -125,6 +131,8 @@ function parseOptions(args) {
125131
}
126132
}
127133
}
134+
if (o.labels.length > 2) { return { error: 'diff: too many file label options\n' }; }
135+
o.context = contextDigits >= 0 ? Math.max(contextValue, contextDigits) : bareStyle ? Math.max(contextValue, 3) : contextValue >= 0 ? contextValue : 3;
128136
return { o, operands, words };
129137
}
130138

@@ -173,6 +181,88 @@ function parseNormal(text) {
173181
return hunks;
174182
}
175183

184+
/**
185+
* GNU's `shift_boundaries` (analyze.c): where a change could be put in more than one place (the added line is the same as
186+
* the one before or after it, a blank line, a `}`), a run of changed lines is slid back as far as the line before it
187+
* equals its last line, which joins it with a change it meets, and then forward as far as its first line equals the one
188+
* after it, and so on until it does not move; a run that was joined is put back to where it lines up with a change on the
189+
* other side. Two diffs of the same size that differ only in where such a run sits come out as GNU's. The ranges are the
190+
* engine's own (its comparison is what finds them); `same(x, y)` says two lines are equal for the options.
191+
*
192+
* @param {Array<{ a1: number, a2: number, b1: number, b2: number }>} changes
193+
* @param {number} n lines on the left
194+
* @param {number} m lines on the right
195+
* @param {(left: boolean, i: number, j: number) => boolean} same lines i and j (0-based) of one side are equal
196+
* @returns {Array<{ a1: number, a2: number, b1: number, b2: number }>}
197+
*/
198+
function shiftBoundaries(changes, n, m, same) {
199+
const ca = new Uint8Array(n + 2);
200+
const cb = new Uint8Array(m + 2);
201+
for (const c of changes) {
202+
for (let k = c.a1; k <= c.a2; k++) { ca[k - 1] = 1; }
203+
for (let k = c.b1; k <= c.b2; k++) { cb[k - 1] = 1; }
204+
}
205+
// the same pass for each side, with the other side's marks as `other`
206+
const pass = (changed, other, len, left) => {
207+
let i = 0;
208+
let j = 0;
209+
const end = len;
210+
for (;;) {
211+
let runlength;
212+
let start;
213+
let corresponding;
214+
// to the beginning of the next run, keeping the place on the other side
215+
while (i < end && !changed[i]) {
216+
while (other[j++]) { /* skip */ }
217+
i++;
218+
}
219+
if (i === end) { break; }
220+
start = i;
221+
while (changed[++i]) { /* the end of the run */ }
222+
while (other[j]) { j++; }
223+
do {
224+
runlength = i - start;
225+
// back, while the line before the run is the run's last: merges with what is before
226+
while (start > 0 && same(left, start - 1, i - 1)) {
227+
changed[--start] = 1;
228+
changed[--i] = 0;
229+
while (start > 0 && changed[start - 1]) { start--; }
230+
while (j > 0 && other[--j]) { /* skip */ }
231+
}
232+
corresponding = j > 0 && other[j - 1] ? i : end;
233+
// forward, while the run's first line is the one after it: merges with what is after
234+
while (i !== end && same(left, start, i)) {
235+
changed[start++] = 0;
236+
changed[i++] = 1;
237+
while (changed[i]) { i++; }
238+
while (other[++j]) { corresponding = i; }
239+
}
240+
} while (runlength !== i - start);
241+
// a merged run goes back to where it lines up with a change on the other side
242+
while (corresponding < i) {
243+
changed[--start] = 1;
244+
changed[--i] = 0;
245+
while (j > 0 && other[--j]) { /* skip */ }
246+
}
247+
}
248+
};
249+
pass(ca, cb, n, true);
250+
pass(cb, ca, m, false);
251+
// the script: runs that sit at the same place on both sides are one change
252+
const out = [];
253+
let i = 0;
254+
let j = 0;
255+
while (i < n || j < m) {
256+
if (!ca[i] && !cb[j]) { i++; j++; continue; }
257+
const i0 = i;
258+
const j0 = j;
259+
while (i < n && ca[i]) { i++; }
260+
while (j < m && cb[j]) { j++; }
261+
out.push({ a1: i0 + 1, a2: i, b1: j0 + 1, b2: j });
262+
}
263+
return out;
264+
}
265+
176266
/**
177267
* The hunks of a unified or context diff: changed ranges that are close are one. GNU puts two changes in one hunk when
178268
* no more than twice the number of context lines separates them.
@@ -222,7 +312,7 @@ function unifiedBody(changes, a, b, eof, context) {
222312
const endB = last.b2 + (endA - last.a2);
223313
const countA = endA - startA + 1;
224314
const countB = endB - startB + 1;
225-
out += `@@ -${range(countA === 0 ? startA : startA, countA)} +${range(startB, countB)} @@\n`;
315+
out += `@@ -${range(startA, countA)} +${range(startB, countB)} @@\n`;
226316
let at = startA;
227317
for (const c of group) {
228318
for (; at < c.a1; at++) { out += lineA(' ', at); }
@@ -379,6 +469,9 @@ async function run(args, io) {
379469
* (`/dev/null` for a missing one), `shownA` and `shownB` what is said; `show` puts the `diff ...` line first.
380470
*/
381471
const compareFiles = async (nameA, nameB, shownA, shownB, show) => {
472+
// what the one-line messages call the files: the label of each, when there is one
473+
const saidA = o.labels[0] !== undefined ? o.labels[0] : shownA;
474+
const saidB = o.labels[1] !== undefined ? o.labels[1] : shownB;
382475
const load = async (name) => {
383476
if (name === '-') { return { b: Uint8Array.from(io.stdin, (ch) => ch.charCodeAt(0) & 0xff), t: null }; }
384477
if (name === '/dev/null') { return { b: new Uint8Array(0), t: 0, missing: true }; }
@@ -399,8 +492,8 @@ async function run(args, io) {
399492
const identical = same(A.b, B.b);
400493
const say = (m) => { out += m + '\n'; };
401494
if (!o.text && (hasNul(A.b) || hasNul(B.b))) {
402-
if (identical) { if (o.s) { say(`Files ${shownA} and ${shownB} are identical`); } return; }
403-
say(o.q ? `Files ${shownA} and ${shownB} differ` : `Binary files ${shownA} and ${shownB} differ`);
495+
if (identical) { if (o.s) { say(`Files ${saidA} and ${saidB} are identical`); } return; }
496+
say(o.q ? `Files ${saidA} and ${saidB} differ` : `Binary files ${saidA} and ${saidB} differ`);
404497
status = Math.max(status, 1);
405498
return;
406499
}
@@ -431,12 +524,22 @@ async function run(args, io) {
431524
}
432525
}
433526
}
527+
// where the engine put a change that could be somewhere else is moved to where GNU puts it
528+
if (changes.length) {
529+
const key = (side, k) => {
530+
const lines = side === sa ? sa : sb;
531+
return canonical(lines.lines[k], o) + (!(o.w || o.b || o.Z) && lines.noNewline && k === lines.lines.length - 1 ? '\uE000' : '');
532+
};
533+
const keysA = sa.lines.map((_l, k) => key(sa, k));
534+
const keysB = sb.lines.map((_l, k) => key(sb, k));
535+
changes = shiftBoundaries(changes, keysA.length, keysB.length, (left, x, y) => (left ? keysA[x] === keysA[y] : keysB[x] === keysB[y]));
536+
}
434537
if (!changes.length) {
435-
if (o.s) { say(`Files ${shownA} and ${shownB} are identical`); }
538+
if (o.s) { say(`Files ${saidA} and ${saidB} are identical`); }
436539
return;
437540
}
438541
status = Math.max(status, 1);
439-
if (o.q) { say(`Files ${shownA} and ${shownB} differ`); return; }
542+
if (o.q) { say(`Files ${saidA} and ${saidB} differ`); return; }
440543
if (show) { say(`${header} ${shownA} ${shownB}`); }
441544
const eof = { noNlA: sa.noNewline, noNlB: sb.noNewline };
442545
const labelA = o.labels[0] !== undefined ? o.labels[0] : `${shownA}\t${io.stamp(A.t)}`;
@@ -465,8 +568,9 @@ async function run(args, io) {
465568
const compareDirs = async (A, B) => {
466569
let namesA;
467570
let namesB;
468-
try { namesA = await io.readdir(A); } catch { trouble(`diff: ${A}: No such file or directory\n`); return; }
469-
try { namesB = await io.readdir(B); } catch { trouble(`diff: ${B}: No such file or directory\n`); return; }
571+
// with -N a folder that is not there is an empty one
572+
try { namesA = await io.readdir(A); } catch { if (o.N && !(await io.stat(A))) { namesA = []; } else { trouble(`diff: ${A}: No such file or directory\n`); return; } }
573+
try { namesB = await io.readdir(B); } catch { if (o.N && !(await io.stat(B))) { namesB = []; } else { trouble(`diff: ${B}: No such file or directory\n`); return; } }
470574
const names = [...new Set([...namesA, ...namesB])].filter((n) => !isExcluded(n)).sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
471575
for (const name of names) {
472576
const pa = join(A, name);
@@ -480,7 +584,8 @@ async function run(args, io) {
480584
out += `Only in ${sa ? A : B}: ${name}\n`;
481585
status = Math.max(status, 1);
482586
} else if (dirA || dirB) {
483-
await compareOneSided(sa ? pa : pb, sa ? pb : pa, !sa);
587+
// a folder that one side lacks is, with -N, an empty one: with -r it is walked, and without it is "common"
588+
if (o.r) { await compareOneSided(sa ? pa : pb, sa ? pb : pa, !sa); } else { out += `Common subdirectories: ${pa} and ${pb}\n`; }
484589
} else {
485590
await compareFiles(sa ? pa : '/dev/null', sb ? pb : '/dev/null', pa, pb, !o.q);
486591
}
@@ -506,7 +611,11 @@ async function run(args, io) {
506611
await compareFiles(sa ? A : '/dev/null', sb ? B : '/dev/null', A, B, false);
507612
return { stdout: out, stderr: err, exitCode: status };
508613
}
509-
return { stdout: '', stderr: `diff: ${!sa ? A : B}: No such file or directory\n`, exitCode: 2 };
614+
if (o.N && (sa ? sa.isDirectory : sb && sb.isDirectory)) {
615+
await compareDirs(A, B);
616+
return { stdout: out, stderr: err, exitCode: status };
617+
}
618+
return { stdout: '', stderr: (!sa ? `diff: ${A}: No such file or directory\n` : '') + (!sb ? `diff: ${B}: No such file or directory\n` : ''), exitCode: 2 };
510619
}
511620
if (sa.isDirectory && sb.isDirectory) {
512621
await compareDirs(A, B);
@@ -527,4 +636,4 @@ async function run(args, io) {
527636
return { stdout: out, stderr: err, exitCode: status };
528637
}
529638

530-
module.exports = { run, parseOptions, splitLines, canonical, parseNormal, groupChanges, unifiedBody, contextBody, normalBody, globToRegExp };
639+
module.exports = { run, parseOptions, splitLines, canonical, parseNormal, shiftBoundaries, groupChanges, unifiedBody, contextBody, normalBody, globToRegExp };

0 commit comments

Comments
 (0)