The page seals files and folders, chosen or dropped, with a comment and a declared author, into a capsule of format 3. - create-files.ts, with the first load: the files as the person chose them, a dropped folder walked with webkitGetAsEntry and named first in each path as webkitRelativePath does, the files of a system left out of folders as collect.go leaves them out (strings.EqualFold for .DS_Store, Thumbs.db and desktop.ini, ._* and __MACOSX), and the list of editable paths. - create-check.ts, on demand with the tables: every problem of every path, and of the comment and the author, in Spanish, from the violations of pathrule.ts. A property test holds that the page sees no problem exactly when checkPath and checkTree accept the paths. - lengths.ts: measureFiles, headLengthOf and bodyLengthOf, so that the exact size is planned again without sorting the files again. - creator.ts: several files, the comment and the author, the progress of both readings of encryptFiles, the cancellation in the first one, and the room checked before reading anything. Checked in Chromium: Go's datekeys decrypt and /inspect open a capsule that the page wrote, with its six files, their mtimes, the author and the comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
651178a740
commit
7dc88ef240
@ -0,0 +1,161 @@
|
||||
// Tests of create-check.ts: the words of each rule of the paths and the
|
||||
// texts of a format 3 head, as the create page shows them, and the problems
|
||||
// of a list of paths, which pass exactly when the writer accepts them.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareBytes, utf8Bytes } from '../dkc/bytes.ts';
|
||||
import { checkPath, checkTree } from '../dkc/pathrule.ts';
|
||||
import { authorProblem, checkPaths, commentProblem, Memo, pathProblem } from './create-check.ts';
|
||||
|
||||
const R7 = 'se distinguen solo por mayúsculas, por cómo se escribe un acento o por un invisible, y en Windows o en macOS serían el mismo nombre (R7).';
|
||||
|
||||
describe('pathProblem', () => {
|
||||
it.each([
|
||||
['', 'Escribe la ruta del fichero (R1).'],
|
||||
['\ud800', 'La ruta tiene un carácter mal formado (R1).'],
|
||||
['a'.repeat(1025), 'La ruta ocupa 1.025 bytes en UTF-8, más de 1.024 (R1).'],
|
||||
[`${'a/'.repeat(32)}a`, 'La ruta tiene 33 niveles, más de 32 (R2).'],
|
||||
['a//b', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).'],
|
||||
['/a', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).'],
|
||||
['a'.repeat(256), `El nombre «${'a'.repeat(57)}…» ocupa 256 bytes en UTF-8, más de 255 (R3).`],
|
||||
['.', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'],
|
||||
['fotos/..', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'],
|
||||
['.\u200d', 'El nombre «.\\u200d», sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..» (R3).'],
|
||||
['\u0390'.repeat(127), `El nombre «${'\u0390'.repeat(57)}…», descompuesto en NFD como lo guarda macOS, mide 381 unidades UTF-16, más de 255 (R3).`],
|
||||
['a\u0001b', 'El nombre «a\\u0001b» lleva el carácter de control U+0001 (R4).'],
|
||||
['a:b', 'El nombre «a:b» lleva «:», que Windows no admite en un nombre (R4).'],
|
||||
['x:y/b', 'La carpeta «x:y» lleva «:», que Windows no admite en un nombre (R4).'],
|
||||
['a\u2028b', 'El nombre «a\\u2028b» lleva el separador U+2028 (R4).'],
|
||||
['a\u200bb', 'El nombre «a\\u200bb» lleva el carácter invisible U+200B, con el que dos nombres distintos se ven iguales (R4).'],
|
||||
['a\uf03ab', 'El nombre «a\\uf03ab» lleva U+F03A, del área de uso privado, con el que Cygwin, WSL y macOS representan un carácter que Windows no admite (R4).'],
|
||||
['a\u0378', 'El nombre «a\\u0378» lleva U+0378, que Unicode 18.0.0 no asigna (R4).'],
|
||||
['a\ufe0f', 'El nombre «a\ufe0f» lleva el selector de variante U+FE0F sin un emoji delante que lo admita (R4b).'],
|
||||
['\u200da', 'El nombre «\\u200da» empieza por el invisible U+200D (R4b).'],
|
||||
['a\u200c/b', 'La carpeta «a\\u200c» acaba en el invisible U+200C (R4b).'],
|
||||
['a\u200d\u200cb', 'El nombre «a\\u200d\\u200cb» lleva dos invisibles seguidos, U+200D y U+200C (R4b).'],
|
||||
[' a', 'El nombre « a» empieza por un espacio (R5).'],
|
||||
['a ', 'El nombre «a » acaba en un espacio, que Windows quita (R5).'],
|
||||
['a.', 'El nombre «a.» acaba en punto, que Windows quita (R5).'],
|
||||
['con.txt', 'El nombre «con.txt» usa CON, un nombre que Windows reserva para un dispositivo (R6).'],
|
||||
['COM\u00b9.txt', 'El nombre «COM\u00b9.txt» usa COM\u00b9, un nombre que Windows reserva para un dispositivo (R6).'],
|
||||
['nul .txt', 'El nombre «nul .txt» usa NUL, un nombre que Windows reserva para un dispositivo (R6).'],
|
||||
['ABCDEF~1.TXT', 'El nombre «ABCDEF~1.TXT» tiene la forma de un nombre corto de Windows, como «PROGRA~1», que puede apuntar a otro fichero (R6b).'],
|
||||
['a\u2236b', 'En la página de códigos 1250 de Windows, el nombre «a\u2236b» se convierte en un nombre con «:» (R6c).'],
|
||||
['x\u00a5y', 'En la página de códigos 932 de Windows, el nombre «x\u00a5y» se convierte en un nombre con «\\» (R6c).'],
|
||||
['\uff23\uff2f\uff2e.txt', 'En la página de códigos 874 de Windows, el nombre «\uff23\uff2f\uff2e.txt» se convierte en un nombre que incumple R6 (R6c).'],
|
||||
['\u3000a', 'En la página de códigos 1250 de Windows, el nombre «\\u3000a» se convierte en un nombre que incumple R5 (R6c).'],
|
||||
['fotos/\uff0e\uff0e', 'En la página de códigos 874 de Windows, el nombre «\uff0e\uff0e» se convierte en un nombre que incumple R3 (R6c).'],
|
||||
['.datekeys-x/a', 'La carpeta «.datekeys-x» empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).'],
|
||||
['.DATEKEYS-a', 'El nombre «.DATEKEYS-a» empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).'],
|
||||
])('%j', (path, want) => {
|
||||
expect(pathProblem(path)).toBe(want);
|
||||
});
|
||||
|
||||
it('accepts a path that every rule accepts, emoji sequences included', () => {
|
||||
for (const p of ['fotos/2025/playa.jpg', '\u2764\ufe0f.txt', 'm\u00fasica/canci\u00f3n.txt', '.bashrc', 'a b/c d.txt', '\u0930\u094d\u200d\u092f']) {
|
||||
expect(pathProblem(p), p).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPaths', () => {
|
||||
it('names each collision of R7 on both paths, and leaves out of the tree a path with a problem of its own', () => {
|
||||
expect(checkPaths(['A.txt', 'a.txt'])).toEqual({ problems: [`Choca con «a.txt»: ${R7}`, `Choca con «A.txt»: ${R7}`] });
|
||||
expect(checkPaths(['\u00e9.txt', 'e\u0301.txt']).problems).toEqual([`Choca con «e\u0301.txt»: ${R7}`, `Choca con «\u00e9.txt»: ${R7}`]);
|
||||
expect(checkPaths(['ab', 'a\u200cb']).problems).toEqual([`Choca con «a\\u200cb»: ${R7}`, `Choca con «ab»: ${R7}`]);
|
||||
expect(checkPaths(['Fotos/a', 'fotos/b']).problems).toEqual([`Choca con «fotos/b»: ${R7}`, `Choca con «Fotos/a»: ${R7}`]);
|
||||
expect(checkPaths(['a', 'a/b']).problems).toEqual(['Choca con «a/b»: «a» sería a la vez un fichero y una carpeta (R7).', 'Choca con «a»: «a» sería a la vez un fichero y una carpeta (R7).']);
|
||||
expect(checkPaths(['x/y/z', 'x/y']).problems).toEqual(['Choca con «x/y»: «x/y» sería a la vez un fichero y una carpeta (R7).', 'Choca con «x/y/z»: «x/y» sería a la vez un fichero y una carpeta (R7).']);
|
||||
expect(checkPaths(['x', 'x']).problems).toEqual(['Repite la ruta de otro fichero de la lista.', 'Repite la ruta de otro fichero de la lista.']);
|
||||
// The first path keeps its first collision; the third repeats the first.
|
||||
expect(checkPaths(['a', 'A', 'a']).problems).toEqual([`Choca con «A»: ${R7}`, `Choca con «a»: ${R7}`, 'Repite la ruta de otro fichero de la lista.']);
|
||||
expect(checkPaths(['a:b', 'A:B', 'ok']).problems).toEqual([
|
||||
'El nombre «a:b» lleva «:», que Windows no admite en un nombre (R4).',
|
||||
'El nombre «A:B» lleva «:», que Windows no admite en un nombre (R4).',
|
||||
undefined,
|
||||
]);
|
||||
expect(checkPaths(['fotos/a.jpg', 'fotos/b.jpg', 'fotos/2025/a.jpg', 'carta.txt'])).toEqual({ problems: [undefined, undefined, undefined, undefined] });
|
||||
expect(checkPaths([])).toEqual({ problems: [] });
|
||||
});
|
||||
|
||||
it('counts the folders of R9', () => {
|
||||
const paths = Array.from({ length: 65535 }, (_, i) => `d${i}/f`);
|
||||
expect(checkPaths(paths).overall).toBeUndefined();
|
||||
expect(checkPaths([...paths, 'e/f']).overall).toBe('Las rutas forman 65.536 carpetas, más de 65.535 (R9).');
|
||||
// A path with a problem does not count.
|
||||
expect(checkPaths([...paths, 'e:/f']).overall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds no problem exactly when checkPath and checkTree accept the paths, in the byte order of the writer', () => {
|
||||
let x = 12345;
|
||||
const next = (n: number): number => {
|
||||
x = (x * 1664525 + 1013904223) >>> 0;
|
||||
return x % n;
|
||||
};
|
||||
const segments = ['a', 'A', 'b', '\u00e9', 'e\u0301', 'ab', 'a\u200cb', 'x.txt', 'X.TXT', 'k', '\u212a', 'stra\u00dfe', 'STRASSE', 'a:b', '.', 'con', '\u0131', 'I'];
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const paths = Array.from({ length: 1 + next(5) }, () => Array.from({ length: 1 + next(3) }, () => segments[next(segments.length)]).join('/'));
|
||||
const mine = checkPaths(paths);
|
||||
const ours = mine.overall === undefined && mine.problems.every((p) => p === undefined);
|
||||
let theirs = true;
|
||||
try {
|
||||
paths.forEach((p) => checkPath(p));
|
||||
checkTree([...paths].sort((a, b) => compareBytes(utf8Bytes(a), utf8Bytes(b))));
|
||||
} catch {
|
||||
theirs = false;
|
||||
}
|
||||
expect(ours, JSON.stringify(paths)).toBe(theirs);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memo', () => {
|
||||
it('computes each value once, undefined included, and forgets them all at its limit', () => {
|
||||
const seen: string[] = [];
|
||||
const memo = new Memo<string | undefined>(2);
|
||||
const get = (k: string) => memo.get(k, (key) => (seen.push(key), key === 'x' ? undefined : key.toUpperCase()));
|
||||
expect([get('a'), get('x'), get('a'), get('x')]).toEqual(['A', undefined, 'A', undefined]);
|
||||
expect(seen).toEqual(['a', 'x']);
|
||||
expect(get('b')).toBe('B');
|
||||
expect(get('a')).toBe('A');
|
||||
expect(seen).toEqual(['a', 'x', 'b', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the texts', () => {
|
||||
it.each([
|
||||
['hola\nqué tal\tadiós', undefined],
|
||||
['a\r\n'.repeat(5462), undefined],
|
||||
['\ud800', 'El comentario tiene un carácter mal formado (§29.6).'],
|
||||
['x'.repeat(16385), 'El comentario ocupa 16.385 bytes en UTF-8, más de 16.384 (§29.4).'],
|
||||
['a\u0007', 'El comentario lleva el carácter de control U+0007 (§29.6).'],
|
||||
['a\u202e', 'El comentario lleva el control bidireccional U+202E, que cambia el orden en que se ve el texto (§29.6).'],
|
||||
['a\u2029', 'El comentario lleva el separador U+2029 (§29.6).'],
|
||||
['\ufeffa', 'El comentario lleva la marca de orden de bytes U+FEFF (§29.6).'],
|
||||
['a\ufdd0', 'El comentario lleva U+FDD0, que Unicode reserva como no carácter (§29.6).'],
|
||||
['a\u200b', 'El comentario lleva el carácter invisible U+200B, con el que se puede esconder texto (§29.6).'],
|
||||
['hola\n\u200dadiós', 'La línea 2 del comentario empieza por el invisible U+200D (§29.6).'],
|
||||
['a\ufe0e', 'La línea 1 del comentario lleva el selector de variante U+FE0E sin un emoji delante que lo admita (§29.6).'],
|
||||
])('comment %j', (s, want) => {
|
||||
expect(commentProblem(s)).toBe(want);
|
||||
});
|
||||
|
||||
it('counts the comment as the head stores it, with LF for CR LF', () => {
|
||||
expect(commentProblem(`${'x'.repeat(16383)}\r\n`)).toBeUndefined();
|
||||
expect(commentProblem('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Ana García', undefined],
|
||||
['', undefined],
|
||||
['Ana\tB', 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'],
|
||||
['Ana\nB', 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'],
|
||||
[' Ana', 'El autor no puede empezar ni acabar por un espacio (§29.6).'],
|
||||
['x'.repeat(257), 'El autor ocupa 257 bytes en UTF-8, más de 256 (§29.4).'],
|
||||
['Ana\u200d', 'El autor acaba en el invisible U+200D (§29.6).'],
|
||||
['A\u200fB', 'El autor lleva el control bidireccional U+200F, que cambia el orden en que se ve el texto (§29.6).'],
|
||||
['\ud800', 'El autor tiene un carácter mal formado (§29.6).'],
|
||||
])('author %j', (s, want) => {
|
||||
expect(authorProblem(s)).toBe(want);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,272 @@
|
||||
// The rules of the paths and the texts of a format 3 head (spec §29.5,
|
||||
// §29.6) as the create page explains them while the person fills the form:
|
||||
// every problem of every path, in Spanish, where the writer stops at the
|
||||
// first one in the words of the reference. The rules themselves are those of
|
||||
// pathrule.ts, which the writer applies again; this module only words their
|
||||
// violations. It brings the Unicode tables of the paths, so the page loads
|
||||
// it on demand.
|
||||
|
||||
import { utf8Length } from '../dkc/bytes.ts';
|
||||
import { headComment } from '../dkc/lengths.ts';
|
||||
import {
|
||||
checkAuthor,
|
||||
checkComment,
|
||||
checkPath,
|
||||
MAX_AUTHOR_LEN,
|
||||
MAX_COMMENT_LEN,
|
||||
MAX_IMPLICIT_DIRS,
|
||||
MAX_PATH_LEN,
|
||||
MAX_SEGMENT_LEN,
|
||||
MAX_SEGMENT_UTF16,
|
||||
MAX_SEGMENTS,
|
||||
pathKey,
|
||||
PathRuleError,
|
||||
} from '../dkc/pathrule.ts';
|
||||
import { escapeInvisible, formatInteger } from './format.ts';
|
||||
|
||||
/** The problems of a list of paths. */
|
||||
export interface PathsCheck {
|
||||
/** The problem of each path, in the order given, or undefined. */
|
||||
readonly problems: readonly (string | undefined)[];
|
||||
/** The problem of all of them together: more folders than R9 allows. */
|
||||
readonly overall?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem of `paths`: each path alone with R1 to R6c and R10, and
|
||||
* then, among the paths that pass, the collisions of R7, named on both
|
||||
* paths, and R9. The paths pass here exactly when checkPath and checkTree
|
||||
* accept them all, whatever their order.
|
||||
*/
|
||||
export function checkPaths(paths: readonly string[]): PathsCheck {
|
||||
const problems: (string | undefined)[] = paths.map((p) => PROBLEMS.get(p, pathProblem));
|
||||
// The nodes of the tree, by the keys of R7 of their segments, as
|
||||
// checkTree builds them, with the path that reached each one first.
|
||||
const nodes = new Map<string, { name: string; dir: boolean; path: number }>();
|
||||
paths.forEach((path, n) => {
|
||||
if (problems[n] !== undefined) return;
|
||||
const segs = path.split('/');
|
||||
let parent = '';
|
||||
for (const [i, s] of segs.entries()) {
|
||||
const k = `${parent}/${KEYS.get(s, pathKey)}`;
|
||||
const dir = i < segs.length - 1;
|
||||
const old = nodes.get(k);
|
||||
if (old === undefined) nodes.set(k, { name: s, dir, path: n });
|
||||
else if (old.name !== s) {
|
||||
collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: se distinguen solo por mayúsculas, por cómo se escribe un acento o por un invisible, y en Windows o en macOS serían el mismo nombre (R7).`, paths);
|
||||
return;
|
||||
} else if (old.dir !== dir) {
|
||||
const prefix = segs.slice(0, i + 1).join('/');
|
||||
collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: «${shown(prefix)}» sería a la vez un fichero y una carpeta (R7).`, paths);
|
||||
return;
|
||||
} else if (!dir) {
|
||||
collide(problems, n, old.path, () => 'Repite la ruta de otro fichero de la lista.', paths);
|
||||
return;
|
||||
}
|
||||
parent = k;
|
||||
}
|
||||
});
|
||||
const dirs = new Set<string>();
|
||||
paths.forEach((path, n) => {
|
||||
if (problems[n] !== undefined) return;
|
||||
for (let i = path.indexOf('/'); i >= 0; i = path.indexOf('/', i + 1)) dirs.add(path.slice(0, i));
|
||||
});
|
||||
return {
|
||||
problems,
|
||||
...(dirs.size > MAX_IMPLICIT_DIRS ? { overall: `Las rutas forman ${formatInteger(dirs.size)} carpetas, más de ${formatInteger(MAX_IMPLICIT_DIRS)} (R9).` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// The problem of a collision on both paths, each naming the other, unless
|
||||
// the earlier one has a problem already.
|
||||
function collide(problems: (string | undefined)[], later: number, earlier: number, text: (other: string) => string, paths: readonly string[]): void {
|
||||
problems[later] = text(paths[earlier]!);
|
||||
problems[earlier] ??= text(paths[later]!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Values computed once for each key, and forgotten all together once there
|
||||
* are `limit` of them: the page checks the whole list again at every change,
|
||||
* and checkPath takes some microseconds a path.
|
||||
*/
|
||||
export class Memo<V> {
|
||||
readonly #values = new Map<string, V>();
|
||||
readonly #limit: number;
|
||||
|
||||
constructor(limit: number) {
|
||||
this.#limit = limit;
|
||||
}
|
||||
|
||||
get(key: string, compute: (key: string) => V): V {
|
||||
if (this.#values.has(key)) return this.#values.get(key)!;
|
||||
if (this.#values.size >= this.#limit) this.#values.clear();
|
||||
const v = compute(key);
|
||||
this.#values.set(key, v);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
// The problem of each path alone, and the key of R7 of each segment, which
|
||||
// a list of thousands of files repeats in its folders.
|
||||
const PROBLEMS = new Memo<string | undefined>(1 << 18);
|
||||
const KEYS = new Memo<string>(1 << 18);
|
||||
|
||||
/** The problem of one path alone, R1 to R6c and R10, or undefined. */
|
||||
export function pathProblem(path: string): string | undefined {
|
||||
if (path === '') return 'Escribe la ruta del fichero (R1).';
|
||||
if (!path.isWellFormed()) return 'La ruta tiene un carácter mal formado (R1).';
|
||||
const n = utf8Length(path);
|
||||
if (n > MAX_PATH_LEN) return `La ruta ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(MAX_PATH_LEN)} (R1).`;
|
||||
try {
|
||||
checkPath(path);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
/* v8 ignore next -- @preserve: checkPath throws only PathRuleError */
|
||||
if (!(err instanceof PathRuleError)) throw err;
|
||||
return explainPath(err, path.split('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// A text as the messages show it: its invisible characters escaped, and cut
|
||||
// when it is long.
|
||||
function shown(s: string): string {
|
||||
const t = escapeInvisible(s);
|
||||
return t.length > 60 ? `${t.slice(0, 57)}…` : t;
|
||||
}
|
||||
|
||||
// U+XXXX, as codePointName writes it.
|
||||
const CODE_POINT = 'U\\+([0-9A-F]{4,6})';
|
||||
// The character of a U+XXXX: R4 names this way only '"', '*', ':', '<', '>',
|
||||
// '?', '\' and '|', and R6c '/', '\', ':' and U+0000, which no best-fit
|
||||
// table gives; escaped all the same.
|
||||
const character = (hex: string): string => `«${escapeInvisible(String.fromCodePoint(Number.parseInt(hex, 16)))}»`;
|
||||
|
||||
// The segment i of a path, as the messages name it.
|
||||
function subject(segs: readonly string[], i: number): string {
|
||||
return `${i === segs.length - 1 ? 'El nombre' : 'La carpeta'} «${shown(segs[i]!)}»`;
|
||||
}
|
||||
|
||||
type Rule = readonly [RegExp, (who: string, m: RegExpExecArray) => string];
|
||||
|
||||
// The details of each rule on a segment, from pathrule.ts, and their words.
|
||||
const SEGMENT_RULES: readonly (readonly [string, ...Rule])[] = [
|
||||
['R3', /^(\d+) bytes, more than \d+$/, (who, m) => `${who} ocupa ${formatInteger(Number(m[1]))} bytes en UTF-8, más de ${MAX_SEGMENT_LEN} (R3).`],
|
||||
['R3', /^the segment is (a dot|two dots)$/, () => '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'],
|
||||
['R3', /^the segment is .* without ZWNJ, ZWJ, VS15 and VS16$/, (who) => `${who}, sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..» (R3).`],
|
||||
[
|
||||
'R3',
|
||||
/^its NFD is (\d+) UTF-16 code units, more than \d+$/,
|
||||
(who, m) => `${who}, descompuesto en NFD como lo guarda macOS, mide ${formatInteger(Number(m[1]))} unidades UTF-16, más de ${MAX_SEGMENT_UTF16} (R3).`,
|
||||
],
|
||||
['R4', new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]} (R4).`],
|
||||
['R4', new RegExp(`^character ${CODE_POINT}$`), (who, m) => `${who} lleva ${character(m[1]!)}, que Windows no admite en un nombre (R4).`],
|
||||
['R4', new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]} (R4).`],
|
||||
['R4', new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que dos nombres distintos se ven iguales (R4).`],
|
||||
[
|
||||
'R4',
|
||||
new RegExp(`^private use ${CODE_POINT}$`),
|
||||
(who, m) => `${who} lleva U+${m[1]}, del área de uso privado, con el que Cygwin, WSL y macOS representan un carácter que Windows no admite (R4).`,
|
||||
],
|
||||
['R4', new RegExp(`^unassigned ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode 18.0.0 no asigna (R4).`],
|
||||
...placementRules('R4b').map((r) => ['R4b', ...r] as const),
|
||||
['R5', /^the segment starts with U\+0020$/, (who) => `${who} empieza por un espacio (R5).`],
|
||||
['R5', /^the segment ends with U\+0020$/, (who) => `${who} acaba en un espacio, que Windows quita (R5).`],
|
||||
['R5', /^the segment ends with '\.'$/, (who) => `${who} acaba en punto, que Windows quita (R5).`],
|
||||
['R6', /^(.+) is a reserved device name$/, (who, m) => `${who} usa ${m[1]}, un nombre que Windows reserva para un dispositivo (R6).`],
|
||||
['R6b', /^the segment has the form of an 8\.3 alias$/, (who) => `${who} tiene la forma de un nombre corto de Windows, como «PROGRA~1», que puede apuntar a otro fichero (R6b).`],
|
||||
[
|
||||
'R6c',
|
||||
new RegExp(`^code page (\\d+) maps the segment to one with ${CODE_POINT}$`),
|
||||
(who, m) => `En la página de códigos ${m[1]} de Windows, ${lower(who)} se convierte en un nombre con ${character(m[2]!)} (R6c).`,
|
||||
],
|
||||
['R6c', /^code page (\d+) maps the segment to one that breaks (R\w+): /, (who, m) => `En la página de códigos ${m[1]} de Windows, ${lower(who)} se convierte en un nombre que incumple ${m[2]} (R6c).`],
|
||||
];
|
||||
|
||||
const lower = (who: string): string => who.charAt(0).toLowerCase() + who.slice(1);
|
||||
|
||||
// R4b on a segment, or on a line of a text: its details, with the rule
|
||||
// named at the end.
|
||||
function placementRules(rule: string): readonly Rule[] {
|
||||
return [
|
||||
[new RegExp(`^${CODE_POINT} is not part of an emoji variation sequence$`), (who, m) => `${who} lleva el selector de variante U+${m[1]} sin un emoji delante que lo admita (${rule}).`],
|
||||
[new RegExp(`^${CODE_POINT} at the start$`), (who, m) => `${who} empieza por el invisible U+${m[1]} (${rule}).`],
|
||||
[new RegExp(`^${CODE_POINT} at the end$`), (who, m) => `${who} acaba en el invisible U+${m[1]} (${rule}).`],
|
||||
[new RegExp(`^${CODE_POINT} right after ${CODE_POINT}$`), (who, m) => `${who} lleva dos invisibles seguidos, U+${m[2]} y U+${m[1]} (${rule}).`],
|
||||
];
|
||||
}
|
||||
|
||||
// The words of a violation of checkPath.
|
||||
function explainPath(err: PathRuleError, segs: readonly string[]): string {
|
||||
const m = /^segment (\d+): (.*)$/s.exec(err.detail);
|
||||
if (m !== null) {
|
||||
const who = subject(segs, Number(m[1]) - 1);
|
||||
for (const [rule, re, text] of SEGMENT_RULES) {
|
||||
const d = rule === err.rule ? re.exec(m[2]!) : null;
|
||||
if (d !== null) return text(who, d);
|
||||
}
|
||||
}
|
||||
if (err.rule === 'R2') {
|
||||
const count = /^(\d+) segments/.exec(err.detail);
|
||||
return count === null
|
||||
? 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).'
|
||||
: `La ruta tiene ${formatInteger(Number(count[1]))} niveles, más de ${MAX_SEGMENTS} (R2).`;
|
||||
}
|
||||
/* v8 ignore next -- @preserve: every detail of pathrule.ts has its words, and R10 is the last rule */
|
||||
if (err.rule !== 'R10') return `La ruta no vale: ${err.message}.`;
|
||||
return `${subject(segs, 0)} empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).`;
|
||||
}
|
||||
|
||||
// The details of the rules of the texts, from pathrule.ts, and their words.
|
||||
const TEXT_RULES: readonly Rule[] = [
|
||||
[new RegExp(`^control ${CODE_POINT} in the declared author$`), () => 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'],
|
||||
[new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]} (§29.6).`],
|
||||
[new RegExp(`^bidirectional control ${CODE_POINT}$`), (who, m) => `${who} lleva el control bidireccional U+${m[1]}, que cambia el orden en que se ve el texto (§29.6).`],
|
||||
[new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]} (§29.6).`],
|
||||
[new RegExp(`^byte order mark ${CODE_POINT}$`), (who) => `${who} lleva la marca de orden de bytes U+FEFF (§29.6).`],
|
||||
[new RegExp(`^noncharacter ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode reserva como no carácter (§29.6).`],
|
||||
[new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que se puede esconder texto (§29.6).`],
|
||||
[/^the declared author starts or ends with U\+0020$/, () => 'El autor no puede empezar ni acabar por un espacio (§29.6).'],
|
||||
];
|
||||
|
||||
/** The problem of the comment of the form, or undefined; '' has none. */
|
||||
export function commentProblem(s: string): string | undefined {
|
||||
return textProblem(headComment(s), 'El comentario', MAX_COMMENT_LEN, checkComment);
|
||||
}
|
||||
|
||||
/** The problem of the declared author of the form, or undefined; '' has none. */
|
||||
export function authorProblem(s: string): string | undefined {
|
||||
return textProblem(s, 'El autor', MAX_AUTHOR_LEN, checkAuthor);
|
||||
}
|
||||
|
||||
function textProblem(s: string, who: string, max: number, check: (s: string) => void): string | undefined {
|
||||
if (s === '') return undefined;
|
||||
if (!s.isWellFormed()) return `${who} tiene un carácter mal formado (§29.6).`;
|
||||
const n = utf8Length(s);
|
||||
if (n > max) return `${who} ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(max)} (§29.4).`;
|
||||
try {
|
||||
check(s);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
/* v8 ignore next -- @preserve: the rules throw only PathRuleError */
|
||||
if (!(err instanceof PathRuleError)) throw err;
|
||||
return explainText(err, who);
|
||||
}
|
||||
}
|
||||
|
||||
// The words of a violation of checkComment or checkAuthor.
|
||||
function explainText(err: PathRuleError, who: string): string {
|
||||
const line = /^line (\d+): (.*)$/s.exec(err.detail);
|
||||
if (line !== null) {
|
||||
const where = who === 'El autor' ? who : `La línea ${line[1]} del comentario`;
|
||||
for (const [re, text] of placementRules('§29.6')) {
|
||||
const m = re.exec(line[2]!);
|
||||
if (m !== null) return text(where, m);
|
||||
}
|
||||
}
|
||||
for (const [re, text] of TEXT_RULES) {
|
||||
const m = re.exec(err.detail);
|
||||
if (m !== null) return text(who, m);
|
||||
}
|
||||
/* v8 ignore next -- @preserve: every detail of pathrule.ts has its words above */
|
||||
return `${who} no vale: ${err.message}.`;
|
||||
}
|
||||
Loading…
Reference in new issue