/* * The matching and the comparison behind lint:rtl (scripts/rtl-lint.mjs). */ import { join } from 'node:path'; import ts from 'typescript'; import { countMap, listFiles, lowerCounts, readBaseline, readText, TEST_FILE, toKey, writeBaseline, } from './ratchet.mjs'; // Tailwind utilities that name a physical side, with any variant prefix // (sm:, hover:, group-hover:) and the negative form (-ml-2). const CLASS = new RegExp( String.raw`(? { for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' '; }; if (file.endsWith('.css')) { for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length); return chars.join(''); } const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); const seen = new Set(); const take = (ranges) => { for (const r of ranges ?? []) { if (seen.has(r.pos)) continue; seen.add(r.pos); blank(r.pos, r.end); } }; const visit = (node) => { take(ts.getLeadingCommentRanges(source, node.pos)); take(ts.getTrailingCommentRanges(source, node.end)); // JSX text holds no comments, and asking for them there would read `//` in a URL as one. if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child); }; visit(sf); take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos)); return chars.join(''); } /** Whether the line carries the marker inside a comment; in a string or in code it is no marker. */ function markedInComment(line, code) { for (let at = line.indexOf(DISABLE); at !== -1; at = line.indexOf(DISABLE, at + 1)) { if (!code.slice(at, at + DISABLE.length).trim()) return true; } return false; } /** * One file read once: every physical use as `line: match`, skipping the lines * marked as physical on purpose, and how many lines carry that mark. */ export function inspect(source, file) { const isCss = file.endsWith('.css'); const uses = []; let markers = 0; const lines = source.split('\n'); withoutComments(source, file) .split('\n') .forEach((code, i) => { if (markedInComment(lines[i], code)) { markers++; return; } const patterns = isCss ? [CSS] : [CLASS, STYLE]; for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`); }); return { uses, markers }; } /** Every physical use in one file, as `line: match`. */ export function physicalUses(source, file) { return inspect(source, file).uses; } /** The physical uses and the disable markers per file under root/src, keyed by the path from src/. */ export function scan(root) { const counts = {}; const listed = {}; const markers = {}; const accept = (key) => /\.(?:tsx?|css)$/.test(key) && !TEST_FILE.test(key); for (const path of listFiles(root, ['src'], accept)) { const found = inspect(readText(path), path); const key = toKey(join(root, 'src'), path); if (found.uses.length) { counts[key] = found.uses.length; listed[key] = found.uses; } if (found.markers) markers[key] = found.markers; } return { counts, listed, markers }; } const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0); /** * Runs the check against both baselines: rtl-baseline.json for the physical * sides, rtl-disable-baseline.json for the lines that opt out of the check. A * marker hides a side from the count, so the markers are a ratchet as well: * a file holds no more of them than its entry. Returns the exit code. */ export function check({ root, baselinePath = join(root, 'scripts/rtl-baseline.json'), disableBaselinePath = join(root, 'scripts/rtl-disable-baseline.json'), update = false, list = false, log = console.log, error = console.error, }) { const { counts, listed, markers } = scan(root); let baseline = readBaseline(baselinePath, countMap); let allowedMarkers = readBaseline(disableBaselinePath, countMap); if (list) { for (const [file, uses] of Object.entries(listed).sort()) { log(`${file} (${uses.length}, baseline ${baseline[file] ?? 0})`); for (const use of uses) log(` ${use}`); } } if (update) { baseline = lowerCounts(baseline, counts); allowedMarkers = lowerCounts(allowedMarkers, markers); writeBaseline(baselinePath, baseline); writeBaseline(disableBaselinePath, allowedMarkers); } const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0)); const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0)); const lowerable = Object.entries(baseline).filter(([file, n]) => (counts[file] ?? 0) < n).length + Object.entries(allowedMarkers).filter(([file, n]) => (markers[file] ?? 0) < n).length; for (const [file, n] of grown) { error( `FAIL ${file}: ${n} physical side(s), baseline ${baseline[file] ?? 0}. ` + 'Use ms-/me-/ps-/pe-/start-/end-/text-start/text-end/border-s/border-e/rounded-s/rounded-e ' + 'or marginInlineStart/paddingInlineEnd/insetInlineStart/textAlign: start. ' + `If the side is geometry, mark the line with ${DISABLE} in a comment.` ); } for (const [file, n] of marked) { error( `FAIL ${file}: ${n} line(s) marked ${DISABLE}, scripts/rtl-disable-baseline.json allows ${allowedMarkers[file] ?? 0}. ` + 'Use the logical form where the side follows the text. A side that is geometry on purpose ' + '(a map, a measured position, a time axis) needs a reviewer to raise the entry by hand.' ); } if (lowerable && !update) { log(`${lowerable} entr(ies) are above what the files hold now: run with --update to lower them.`); } log( `rtl: ${sum(counts)} physical side(s) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` + `${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}` ); return grown.length || marked.length ? 1 : 0; }