codemods/analyzers/word.analyzer.ts
codemods/analyzers/word.analyzer.ts is a file in GovLab Extension Host. 141 lines of code and 41 definitions.
import type { LiteralFinding, WordScope } from "../../types/analyzer.types.ts";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { ROOT } from "@ssot/paths";
import { americanOf } from "@govlab/constants";
import path from "node:path";
import { relPath } from "../selectors/program.selector.ts";
const MARKDOWN_EXTENSION = ".md";
const FENCE = "```";
const TICK = "`";
const NEWLINE = "\n";
const inCase = function inCase(original: string, american: string): string {
if (original === original.toUpperCase()) {
return american.toUpperCase();
}
const first = original.charAt(0);
return first === first.toUpperCase() ? american.charAt(0).toUpperCase() + american.slice(1) : american;
};
const isLetter = function isLetter(char: string): boolean {
return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z");
};
const isUpper = function isUpper(char: string): boolean {
return char >= "A" && char <= "Z";
};
const isLower = function isLower(char: string): boolean {
return char >= "a" && char <= "z";
};
const startsPart = function startsPart(run: string, at: number): boolean {
const here = run.charAt(at);
const before = run.charAt(at - 1);
const after = run.charAt(at + 1);
return isUpper(here) && (isLower(before) || (isUpper(before) && isLower(after)));
};
const codeSpans = function codeSpans(line: string): [number, number][] {
const spans: [number, number][] = [];
let open = line.indexOf(TICK);
while (open !== -1) {
const close = line.indexOf(TICK, open + 1);
if (close === -1) {
break;
}
spans.push([open, close]);
open = line.indexOf(TICK, close + 1);
}
return spans;
};
const insideSpan = function insideSpan(spans: readonly [number, number][], index: number): boolean {
return spans.some(([open, close]) => index > open && index < close);
};
interface WordSite {
readonly start: number;
readonly word: string;
}
const partsOf = function partsOf(run: string, offset: number): WordSite[] {
const sites: WordSite[] = [];
let start = 0;
for (let at = 1; at <= run.length; at += 1) {
if (at === run.length || startsPart(run, at)) {
sites.push({ start: offset + start, word: run.slice(start, at) });
start = at;
}
}
return sites;
};
const wordsOf = function wordsOf(line: string): WordSite[] {
const sites: WordSite[] = [];
let index = 0;
while (index < line.length) {
if (!isLetter(line.charAt(index))) {
index += 1;
continue;
}
let end = index;
while (end < line.length && isLetter(line.charAt(end))) {
end += 1;
}
sites.push(...partsOf(line.slice(index, end), index));
index = end;
}
return sites;
};
interface LineSite {
readonly fileName: string;
readonly line: string;
readonly index: number;
readonly offset: number;
}
const lineFindings = function lineFindings(site: LineSite): LiteralFinding[] {
const spans = path.extname(site.fileName) === MARKDOWN_EXTENSION ? codeSpans(site.line) : [];
return wordsOf(site.line).flatMap((word) => {
const lower = word.word.toLowerCase();
const american = americanOf(lower);
if (american === lower || insideSpan(spans, word.start)) {
return [];
}
return [
{
end: site.offset + word.start + word.word.length,
file: relPath(site.fileName),
fileName: site.fileName,
from: word.word,
line: site.index + 1,
reason: null,
start: site.offset + word.start,
to: inCase(word.word, american),
},
];
});
};
export const spellingFindingsIn = function spellingFindingsIn(fileName: string, content: string): LiteralFinding[] {
const markdown = path.extname(fileName) === MARKDOWN_EXTENSION;
const findings: LiteralFinding[] = [];
let offset = 0;
let fenced = false;
for (const [index, line] of content.split(NEWLINE).entries()) {
const fence = markdown && line.trimStart().startsWith(FENCE);
fenced = fence ? !fenced : fenced;
const scanned = fence || fenced ? [] : lineFindings({ fileName, index, line, offset });
findings.push(...scanned);
offset += line.length + NEWLINE.length;
}
return findings;
};
export const filesUnder = function filesUnder(target: string, skipped: (relPath: string) => boolean): string[] {
if (!statSync(target).isDirectory()) {
return [target];
}
return readdirSync(target, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(target, entry.name);
if (skipped(full)) {
return [];
}
return entry.isDirectory() ? filesUnder(full, skipped) : [full];
});
};
export const collectSpellingFindings = function collectSpellingFindings(scope: WordScope): LiteralFinding[] {
const wanted = (fileName: string): boolean => scope.extensions.includes(path.extname(fileName));
return scope.roots
.flatMap((root) => filesUnder(path.resolve(ROOT, root), scope.skipped))
.filter(wanted)
.flatMap((fileName) => spellingFindingsIn(fileName, readFileSync(fileName, "utf8")));
};