Skip to content

Commit efdd2a5

Browse files
committed
fix(web/shell): #107 review — Tab no longer completes to another file's name, misreads where a command starts, or leaves a stray quote
The five findings of the review on completion.js, each reproduced against the real modules first. Every new test fails on e93bc09 and passes here. 1. A name with a character the editor does not type (U+200B and the rest of cleanText's invisible list) was offered: `rm a` + Tab gave `rm ab.txt ` for `a<ZWSP>b.txt`, another file's name, with only a dim note under the line. The completer now drops a candidate whose written-back text cleanText would change (completion.js createCompleter). Names whose invisible characters the editor keeps (an emoji's selector, a ZWJ family, a Persian ZWNJ) are offered as before. 2. A line end that is not quoted or escaped was read as a blank, so in a pasted `echo hi\ngre` the word `gre` asked for paths. A line end now ends a command, as `;` does (wordAt). 3. A leading assignment took the command position: `LC_ALL=C gre` asked for paths. A word that is `NAME=`, `NAME+=` or `NAME[i]=`, judged on the raw token so the name has to be unquoted as bash requires (`"LC_ALL"=C` is a command word), keeps the command position (wordAt, ASSIGNMENT). 4. A word written against `<` or `>` was dropped without ending the command position: `cat>out sr` asked for commands. The word now ends there like any other unless it is all digits (the `2` of `2>err`). `>&`, `<&` and `>|` (an `&` or `|` right after the operator) and `&>`, `&>>` are read as redirections, not as a list or a pipe: `2>&1 gre` and `>|out gre` complete commands, `cat &>out sr` a file. Every row of the new table was checked with `bash -xc` (bash 3.2, the only bash on this machine: Docker was down, so not the golden suite's 5.1). 5. Tab just before a closing quote left the quote behind: `cat "my fi|"` became `cat "my file.txt" "`, an open quote, and the line was held for more. GNU readline does no better (`cat "my file.txt""`), so bash is not the model here. wordAt now also returns where the word ends after the cursor and what it holds there. The completer offers only names that end with that rest (any name when the rest is empty) and gives each such candidate an `end`. The editor's `_replace` replaces through that end, so the closing quote and the tail of a name typed earlier are consumed, never split off. A folder at the end of a word still keeps its quote open, with the cursor before the quote that is there. In the editor (line-editor.js `_complete`), too: a file gets no blank when one follows already; a common part that is a whole candidate (`'it'` of `it` and `it's`) takes that candidate's end (`cat 'i|'` gave `cat 'it''`); an `end` before the cursor or past the line completes nothing. A cursor at the start of a word still inserts a word of its own (`cat |foo` gives `cat x.txt foo`, as before). NOTES.md: the completion section says what counts as a command position and what Tab inside a word does, and no longer that a name it cannot type "is typed without" the character.
1 parent 4a9803f commit efdd2a5

5 files changed

Lines changed: 307 additions & 39 deletions

File tree

‎web/shell/src/terminal/NOTES.md‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,23 @@ names: the working folder comes from the engine, and a command can make a direct
109109

110110
### Completion
111111

112-
Tab asks `session.complete('command' | 'path', prefix)`. One candidate is inserted (a folder gets no trailing space),
113-
several insert their common prefix, and a second Tab with nothing to add lists them in columns below and redraws the
114-
prompt. Names are written back escaped or quoted for the quote the word is in. A name that cannot be typed (a control
115-
character, a bidi override, any invisible character) is not offered or is typed without it. An answer that arrives after
116-
the line changed is ignored.
112+
Tab asks `session.complete('command' | 'path', prefix)` about what is before the cursor. It asks for commands for the
113+
first word of a command: at the start of the line, after `;` `|` `&` `(` and a line end that is not quoted or escaped,
114+
after `if` `then` `do` `!` and the like, and after what can come before a command without being it: assignments
115+
(`LC_ALL=C gre`; the name unquoted, as bash reads one) and redirections with their targets (`2>err`, `>out`, `2>&1`,
116+
`&>out`, `>|out`). A word written against a redirection is a word (`cat>out sr` completes a file: `cat` is the command),
117+
unless it is digits (the `2` of `2>err`). Checked against bash with `bash -xc`. Everything else asks for paths.
118+
119+
One candidate is inserted (a folder gets no trailing space; a file gets one unless a blank follows it already), several
120+
insert their common prefix, and a second Tab with nothing to add lists them in columns below and redraws the prompt.
121+
Names are written back escaped or quoted for the quote the word is in. **Tab inside a word** (anything after the cursor,
122+
a closing quote too) offers only the names that end with what the word has after the cursor, and such a name replaces
123+
the whole word: `cat "my fi|"` becomes `cat "my file.txt" `, not `cat "my file.txt" "` (bash's own readline leaves
124+
`cat "my file.txt""`), and nothing that was after the cursor is dropped or split off. A folder at the end of a word
125+
keeps its quote open, with the cursor before the closing quote that is there. A name that cannot be typed (a control
126+
character, a bidi override, any invisible character the editor would not type) is not offered: typed without it, it is
127+
another file's name (`rm a` must not become `rm ab.txt` for `a<ZWSP>b.txt`). A name whose invisible characters the editor
128+
keeps (an emoji's, a joiner inside a word) is offered. An answer that arrives after the line changed is ignored.
117129

118130
A Tab pressed before the answer to the previous one has arrived is **queued** (one at most; a third is dropped): it asks
119131
when that answer has been applied, about the line as it is then, so `Tab Tab` lists even when the first answer is slow.
@@ -419,7 +431,7 @@ Measured in the release build by the research, and re-checked here against the r
419431
| `width` | the 25 measured widths; the table equals the addon (when an xterm checkout is found) |
420432
| `line-editor` | the keys, the cursor on a newline, the full row, wide/combining characters, what may enter the buffer and what is kept (and the note), tabs (also at the top of a window), resize (scenarios on the model terminal, and the same on xterm.js), a tall buffer, Tab and the queued second Tab; a seeded differential against an oracle at three widths and with resizes; and, if xterm is found, against the real xterm.js: the editor's output, the model terminal's re-wrapping, seeded sessions with resizes in both directions, and a strict harness (after every step the screen is the text written once, nothing of the region is above it, and nothing that was on the terminal before it is lost: shrink and grow, a command, a note, Ctrl+C, Ctrl+L, windows, pinned sequences; and width and height in one event, with earlier output taller than the panel: pinned corner scenarios, each of which guards one of the estimates, and 200 seeded sessions on each terminal, aimed at the cursor above the end of a region on a terminal that has scrolled, in which a copy of the prompt may be left but nothing before it may be lost; then the same scenarios again through the verifier's generator's checker, which asks after every step that what the editor believes of the blank rows and of the pushed rows is a floor and that no copy of a pasted line is left (the corner ones), and 683 of its sessions in about two seconds; and the account of the pushed rows itself, `_ghostsAt` and `_headRows`, as units) |
421433
| `completeness` | the table of traps and open constructs |
422-
| `completion` | the word under the cursor and the write-back |
434+
| `completion` | the word under the cursor (command or path: line ends, assignments, redirections against a word; the rest of the word after the cursor), the write-back, the names that are not offered |
423435
| `output-filter` | the 25 queries, the modes, the OSC list, links, the cap, a seeded chunking fuzz |
424436
| `history` | the rules and the secret patterns (assembled from fragments) |
425437
| `sink` | the OSC 633 escaping, the nonce, the queue and a run's output |

‎web/shell/src/terminal/completion.js‎

Lines changed: 92 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,60 @@
55
// other word the entries of the directory the typed prefix points into, keeping what was typed before the name and
66
// ending a folder with '/'. What this module adds is the shell's part: the word is read the way the shell reads it
77
// (quotes and backslashes), and a candidate is written back quoted or escaped so the line still means what it says.
8+
// Tab inside a word (something after the cursor) offers only names that end with the rest of the word, and such a name
9+
// replaces the whole word, its closing quote too: what was after the cursor is never dropped, split off or left over.
810
// @ts-check
911
'use strict';
1012

13+
const { cleanText } = require('./line-editor');
14+
1115
/** Words after which the next word is a command again. */
1216
const COMMAND_WORDS = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', 'time', '!', '{']);
17+
/** A word that assigns a variable (`NAME=`, `NAME+=`, `NAME[i]=`, the name as typed: unquoted): before the command it is not one. */
18+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/;
19+
/** A word of digits written against `<` or `>` is the descriptor it redirects (`2>err`), not a word of the command. */
20+
const DESCRIPTOR = /^[0-9]+$/;
21+
/** What ends a word that is not quoted or escaped. */
22+
const WORD_END = /[ \t\n;|&()<>`]/;
1323
/** Characters that need a backslash in an unquoted word, wherever they are. */
1424
const ALWAYS_SPECIAL = /[\s"'\\$`&|;()<>*?[\]{}]/;
1525
/** Characters that need one only at the start of a word. */
1626
const START_SPECIAL = /[~#!^]/;
1727
const CONTROL = /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/;
1828

1929
/**
20-
* The word the cursor is in (or the empty word it is about to start).
30+
* The word the cursor is in (or the empty word it is about to start), and the rest of it after the cursor.
2131
* @param {string} line
2232
* @param {number} cursor in code points
23-
* @returns {{ start: number, prefix: string, quote: '' | '"' | "'", kind: 'command' | 'path' }}
33+
* @returns {{ start: number, prefix: string, quote: '' | '"' | "'", kind: 'command' | 'path', end: number, rest: string }}
34+
* `end` is where the word ends after the cursor, and `rest` what it holds there (unquoted, as `prefix` is)
2435
*/
2536
function wordAt(line, cursor) {
26-
const chars = Array.from(line).slice(0, cursor);
37+
const all = Array.from(line);
38+
const chars = all.slice(0, cursor);
2739
const n = chars.length;
2840
let start = 0;
2941
let inWord = false;
3042
let prefix = '';
3143
let quote = /** @type {'' | '"' | "'"} */ ('');
44+
let escaped = false; // the last character before the cursor is a backslash: it escapes the one after it
3245
let cmdPos = true; // the word being read is the first of a command
3346
let redirect = false; // the word being read is the target of > or <
47+
let opAt = -2; // where the last `<` or `>` is: `>&`, `<&` and `>|` are one operator with it
3448

3549
const begin = (/** @type {number} */ i) => {
3650
if (!inWord) { inWord = true; start = i; }
3751
};
52+
// The word that ends at `i`. The first word of a command takes the command position with it; the target of a
53+
// redirection, a keyword (`if`, `!`) and an assignment before the command (`LC_ALL=C`) do not.
54+
const endWord = (/** @type {number} */ i) => {
55+
if (inWord) {
56+
if (cmdPos && !redirect && !COMMAND_WORDS.has(prefix) && !ASSIGNMENT.test(chars.slice(start, i).join(''))) { cmdPos = false; }
57+
redirect = false;
58+
}
59+
inWord = false;
60+
prefix = '';
61+
};
3862

3963
for (let i = 0; i < n; i++) {
4064
const c = chars[i];
@@ -48,38 +72,74 @@ function wordAt(line, cursor) {
4872
}
4973
if (c === '\\') {
5074
begin(i);
51-
if (i + 1 < n) { prefix += chars[++i]; }
75+
if (i + 1 < n) { prefix += chars[++i]; } else { escaped = true; }
5276
continue;
5377
}
5478
if (c === "'" || c === '"') { begin(i); quote = c; continue; }
55-
if (c === ' ' || c === '\t' || c === '\n') {
56-
if (inWord) {
57-
if (cmdPos && !redirect && !COMMAND_WORDS.has(prefix)) { cmdPos = false; }
58-
redirect = false;
59-
}
60-
inWord = false;
61-
prefix = '';
62-
continue;
63-
}
64-
if (c === ';' || c === '|' || c === '&' || c === '(') {
79+
if (c === ' ' || c === '\t') { endWord(i); continue; }
80+
if ((c === '&' || c === '|') && opAt === i - 1) { continue; } // `>&2`, `<&0`, `>|`: the rest of the operator
81+
if (c === '&' && all[i + 1] === '>') { endWord(i); continue; } // `&>`, `&>>`: a redirection, not a list
82+
if (c === ';' || c === '|' || c === '&' || c === '(' || c === '\n') {
83+
// the end of a command (a line end too, when it is not quoted or escaped): what follows is a command again
6584
inWord = false;
6685
prefix = '';
6786
cmdPos = true;
6887
redirect = false;
6988
continue;
7089
}
7190
if (c === '<' || c === '>') {
72-
inWord = false;
73-
prefix = '';
91+
if (inWord && !redirect && DESCRIPTOR.test(chars.slice(start, i).join(''))) {
92+
inWord = false;
93+
prefix = '';
94+
} else {
95+
endWord(i); // `cat>out`: `cat` is the command
96+
}
7497
redirect = true;
98+
opAt = i;
7599
continue;
76100
}
77101
begin(i);
78102
prefix += c;
79103
}
80104
if (!inWord) { start = n; prefix = ''; }
81105
const kind = cmdPos && !redirect && !prefix.includes('/') && !/^[.~]/.test(prefix) ? 'command' : 'path';
82-
return { start, prefix, quote, kind };
106+
107+
// The rest of the word, read the same way, from the quote the cursor is in. A cursor that is not in a word (after a
108+
// blank, at the start of the line) starts a new one: what follows it is another word, not the rest of this one.
109+
let q = quote;
110+
let rest = '';
111+
let k = n;
112+
if (escaped && k < all.length) { rest += all[k++]; }
113+
for (; inWord && k < all.length; k++) {
114+
const c = all[k];
115+
if (q === "'") {
116+
if (c === "'") { q = ''; } else { rest += c; }
117+
continue;
118+
}
119+
if (q === '"') {
120+
if (c === '\\' && k + 1 < all.length && '"\\$`'.includes(all[k + 1])) { rest += all[++k]; } else if (c === '"') { q = ''; } else { rest += c; }
121+
continue;
122+
}
123+
if (c === '\\') {
124+
if (k + 1 < all.length) { rest += all[++k]; }
125+
continue;
126+
}
127+
if (c === "'" || c === '"') { q = c; continue; }
128+
if (WORD_END.test(c)) { break; }
129+
rest += c;
130+
}
131+
return { start, prefix, quote, kind, end: k, rest };
132+
}
133+
134+
/**
135+
* Does `name` hold the whole word: what was typed before the cursor at its start and what is after it at its end, apart
136+
* (a folder's name with or without its '/')?
137+
* @param {string} name @param {string} prefix @param {string} rest
138+
*/
139+
function fits(name, prefix, rest) {
140+
if (rest === '') { return true; }
141+
const base = name.endsWith('/') && !rest.endsWith('/') ? name.slice(0, -1) : name;
142+
return base.length >= prefix.length + rest.length && base.endsWith(rest);
83143
}
84144

85145
/**
@@ -104,7 +164,8 @@ function writeBack(name, quote, dir) {
104164
* The editor's completion hook, over a session.
105165
* @param {{ complete(kind: 'path' | 'command', prefix: string): Promise<string[]> }} session
106166
* @param {{ forTerminal(text: string, o?: { crlf?: boolean }): string }} sanitize
107-
* @returns {(line: string, cursor: number) => Promise<{ start: number, candidates: Array<{ text: string, display: string, dir: boolean }> } | null>}
167+
* @returns {(line: string, cursor: number) => Promise<{ start: number, candidates: Array<{ text: string, display: string, dir: boolean, end?: number }> } | null>}
168+
* a candidate with an `end` replaces the word up to there, past the cursor; one without, up to the cursor
108169
*/
109170
function createCompleter(session, sanitize) {
110171
return async function complete(line, cursor) {
@@ -117,11 +178,21 @@ function createCompleter(session, sanitize) {
117178
const candidates = [];
118179
for (const name of names) {
119180
if (typeof name !== 'string' || name === '' || CONTROL.test(name) || seen.has(name)) { continue; }
120-
// a candidate must continue what was typed
121-
if (!name.startsWith(w.prefix)) { continue; }
181+
// a candidate must continue what was typed, and end with what the word has after the cursor
182+
if (!name.startsWith(w.prefix) || !fits(name, w.prefix, w.rest)) { continue; }
122183
seen.add(name);
123184
const dir = name.endsWith('/');
124-
candidates.push({ text: writeBack(name, w.quote, dir), display: sanitize.forTerminal(name), dir });
185+
// A folder at the end of the word is left open for the next name; anything else is written whole and replaces
186+
// the rest of the word (a closing quote after the cursor, the end of a name typed earlier).
187+
const open = dir && w.rest === '';
188+
const text = writeBack(name, w.quote, open);
189+
// The editor types only what the screen shows: a name it would type without one of its characters (a zero-width
190+
// space) is another file's name, so it is not offered.
191+
if (cleanText(text).cps.join('') !== text) { continue; }
192+
/** @type {{ text: string, display: string, dir: boolean, end?: number }} */
193+
const candidate = { text, display: sanitize.forTerminal(name), dir };
194+
if (!open && w.end > cursor) { candidate.end = w.end; }
195+
candidates.push(candidate);
125196
}
126197
if (!candidates.length) { return null; }
127198
// The word as typed begins with its own opening quote; the candidates repeat it, so replace from the word start.

‎web/shell/src/terminal/line-editor.js‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ function droppedNote(n) {
118118
}
119119

120120
/**
121-
* @typedef {{ text: string, display?: string, dir?: boolean }} Candidate
121+
* A candidate replaces the line from the answer's `start` up to the cursor, or up to its own `end` past it (the rest of
122+
* the word: a closing quote, the end of a name), so that what was after the cursor is not left over.
123+
* @typedef {{ text: string, display?: string, dir?: boolean, end?: number }} Candidate
122124
* @typedef {{
123125
* write(s: string): void,
124126
* prompt(): string,
@@ -1069,9 +1071,14 @@ class LineEditor {
10691071
if (!this.active || version !== this.version || !r || !r.candidates || r.candidates.length === 0) { return; }
10701072
if (!(r.start >= 0 && r.start <= this.cur)) { return; }
10711073
const cs = r.candidates;
1074+
const upTo = (/** @type {Candidate} */ c) => (c.end === undefined ? this.cur : c.end);
1075+
if (!cs.every((c) => upTo(c) >= this.cur && upTo(c) <= this.buf.length)) { return; }
10721076
const typed = this.buf.slice(r.start, this.cur).join('');
10731077
if (cs.length === 1) {
1074-
this._replace(r.start, cs[0].text + (cs[0].dir ? '' : ' '));
1078+
const end = upTo(cs[0]);
1079+
// a file is followed by a blank, unless the one that separates it from the next word is there already
1080+
const blank = cs[0].dir || /^[ \t\n]$/.test(this.buf[end] || '') ? '' : ' ';
1081+
this._replace(r.start, cs[0].text + blank, end);
10751082
return;
10761083
}
10771084
const common = cs.map((c) => c.text).reduce((a, b) => {
@@ -1081,18 +1088,20 @@ class LineEditor {
10811088
});
10821089
// still ambiguous after this Tab: the next one, if nothing was typed, lists the candidates
10831090
if (common.length > typed.length) {
1084-
this._replace(r.start, common);
1091+
// a common part that is a whole candidate (`'it'`, of it and it's) is that one: it replaces what it would
1092+
const whole = cs.find((c) => c.text === common);
1093+
this._replace(r.start, common, whole ? upTo(whole) : this.cur);
10851094
this.lastTab = true;
10861095
return;
10871096
}
10881097
if (!second) { this.lastTab = true; return; }
10891098
this._list(cs.map((c) => (c.display !== undefined ? c.display : c.text)));
10901099
}
10911100

1092-
/** @param {number} start @param {string} text */
1093-
_replace(start, text) {
1094-
const { cps, dropped } = cleanText(text, this.buf[start - 1], this.buf[this.cur]);
1095-
this._put(start, this.cur - start, cps);
1101+
/** @param {number} start @param {string} text @param {number} [end] where the text it replaces ends: the cursor, or past it */
1102+
_replace(start, text, end = this.cur) {
1103+
const { cps, dropped } = cleanText(text, this.buf[start - 1], this.buf[end]);
1104+
this._put(start, end - start, cps);
10961105
this.cur = start + cps.length;
10971106
this._changed(dropped);
10981107
}

0 commit comments

Comments
 (0)