/*
Tier 2 locale typography rules (RFC-1070). Implements 7 rules across
3 families: NUM (3), ABBR (2), APOS (2). Rules operate on TextSegment objects
produced by the text-surface extractor (RFC-1068), never on raw file bytes.
All regexes use the `u` flag and Unicode property escapes where applicable.
- Do not extract text — that is text-surface.ts.
- Do not handle file I/O or command registration — that is the command adapter.
- Do not auto-fix — autofix is RFC-1072.
- Do not validate unicode hygiene, link text, or sentence length — RFC-1071.
- RFC-1070: initial creation with 7 Tier 2 locale rules.
- RFC-1072: populated fix on ABBR-01, ABBR-02, APOS-01, APOS-02.
- RFC-1324 cold-run defect: TYPO-ABBR-01 false-positive — bare `Art` is a common German noun (jede Art der Verwertung); DIGIT_REQUIRED_BARE gates it to citation context (Art 6 DSGVO).
- RFC-1324 cold-run defect: same class in `en` — bare `No` is the determiner (No preview available); joins DIGIT_REQUIRED_BARE (No. 5 stays flagged).
*/
import { finding, type TypographyFinding, type TypographyRule } from "./rules-tier1.ts";
import type { FixAction } from "./fix.ts";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type Tier2LocaleRuleId = `TYPO-${"NUM" | "ABBR" | "APOS"}-${string}`;
// ---------------------------------------------------------------------------
// TYPO-NUM-01: German number with dot decimal separator (wrong: 1,234.56)
// Pattern: \d{1,3}(?:,\d{3})*\.\d{2}
// Exclusions: dates, version numbers, times, IP addresses, code spans
// ---------------------------------------------------------------------------
const NUM01_PATTERN = /\d{1,3}(?:,\d{3})*\.\d{2}/gu;
const TIME_SUFFIXES = [" Uhr", " h", " min", " sec", " s"];
function isPartOfDate(text: string, matchStart: number, matchEnd: number): boolean {
const after = text.slice(matchEnd, matchEnd + 6);
if (/\.\d{2,4}/.test(after)) return true;
const before = text.slice(Math.max(0, matchStart - 6), matchStart);
if (/\d{1,2}\.$/.test(before)) return true;
return false;
}
function isPartOfIpAddress(text: string, matchStart: number, matchEnd: number): boolean {
if (matchStart > 0 && /[\d.]/.test(text[matchStart - 1])) return true;
if (matchEnd < text.length && /\d/.test(text[matchEnd])) return true;
return false;
}
function isVersionNumber(text: string, matchStart: number): boolean {
if (matchStart > 0) {
const before = text[matchStart - 1];
if (before === "v" || before === "V") return true;
}
return false;
}
function isTimeValue(text: string, matchEnd: number): boolean {
const after = text.slice(matchEnd, matchEnd + 6);
return TIME_SUFFIXES.some((suffix) => after.startsWith(suffix));
}
const num01: TypographyRule = {
id: "TYPO-NUM-01",
family: "NUM",
tier: 2,
severity: "error",
check(segment, _ctx) {
if (segment.locale !== "de") return [];
const text = segment.text;
const findings: TypographyFinding[] = [];
for (const m of text.matchAll(NUM01_PATTERN)) {
const matchStart = m.index;
const matchEnd = m.index + m[0].length;
if (isPartOfDate(text, matchStart, matchEnd)) continue;
if (isPartOfIpAddress(text, matchStart, matchEnd)) continue;
if (isVersionNumber(text, matchStart)) continue;
if (isTimeValue(text, matchEnd)) continue;
findings.push(
finding(
this.id,
segment,
m[0],
m.index,
`German number with dot decimal separator: "${m[0]}" — German uses comma as decimal separator (e.g. "1.234,56").`,
"Replace the dot with a comma and the comma thousands separator with a dot.",
),
);
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-NUM-02: Ukrainian number with dot as thousands separator (wrong: 1.234,56)
// Pattern: \d{1,3}(?:\.\d{3})*,\d{2}
// Exclusions: dates (naturally excluded — dates use dots, not commas)
// ---------------------------------------------------------------------------
const NUM02_PATTERN = /\d{1,3}(?:\.\d{3})+,\d{2}/gu;
const num02: TypographyRule = {
id: "TYPO-NUM-02",
family: "NUM",
tier: 2,
severity: "error",
check(segment, _ctx) {
if (segment.locale !== "uk") return [];
const text = segment.text;
const findings: TypographyFinding[] = [];
for (const m of text.matchAll(NUM02_PATTERN)) {
const matchStart = m.index;
const matchEnd = m.index + m[0].length;
if (isPartOfIpAddress(text, matchStart, matchEnd)) continue;
if (isVersionNumber(text, matchStart)) continue;
findings.push(
finding(
this.id,
segment,
m[0],
m.index,
`Ukrainian number with dot as thousands separator: "${m[0]}" — Ukrainian uses NNBSP (U+202F) as thousands separator (e.g. "1\u202F234,56").`,
"Replace the dot thousands separator with a narrow no-break space (U+202F).",
),
);
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-NUM-03: Space-separated thousands without NNBSP (regular space in 1 234,56)
// Pattern: \d{1,3}\x20\d{3}(?:,\d{2})?
// Exclusions: NBSP (U+00A0) and NNBSP (U+202F) are allowed; short references
// ---------------------------------------------------------------------------
const NUM03_PATTERN = /\d{1,3}\x20\d{3}(?:,\d{2})?/gu;
function isPartOfPhoneNumber(text: string, matchStart: number, _matchEnd: number): boolean {
// Preceded by + (e.g. "+49 711")
if (matchStart > 0 && text[matchStart - 1] === "+") return true;
// Part of a phone number group: preceded by digits+space with + earlier in the line
const lineStart = text.lastIndexOf("\n", matchStart - 1) + 1;
const line = text.slice(lineStart, matchStart);
if (/\d\s$/.test(line) && line.includes("+")) return true;
return false;
}
const STANDARD_PREFIXES = ["EN", "ISO", "DIN", "IEC", "ETSI", "RFC", "WCAG", "W3C"];
function isPartOfStandardReference(text: string, matchStart: number): boolean {
const lineStart = text.lastIndexOf("\n", matchStart - 1) + 1;
const before = text.slice(lineStart, matchStart).trimEnd();
for (const prefix of STANDARD_PREFIXES) {
if (before.endsWith(prefix)) return true;
}
return false;
}
const num03: TypographyRule = {
id: "TYPO-NUM-03",
family: "NUM",
tier: 2,
severity: "error",
check(segment, _ctx) {
if (segment.locale !== "uk") return [];
const text = segment.text;
const findings: TypographyFinding[] = [];
for (const m of text.matchAll(NUM03_PATTERN)) {
const matchStart = m.index;
const matchEnd = m.index + m[0].length;
if (isPartOfIpAddress(text, matchStart, matchEnd)) continue;
if (isPartOfPhoneNumber(text, matchStart, matchEnd)) continue;
if (isPartOfStandardReference(text, matchStart)) continue;
findings.push(
finding(
this.id,
segment,
m[0],
m.index,
`Regular space (U+0020) as thousands separator: "${m[0]}" — Ukrainian uses NNBSP (U+202F) as thousands separator.`,
"Replace the regular space with a narrow no-break space (U+202F).",
),
);
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-ABBR-01: Known abbreviation missing required trailing period
// Algorithm: build reverse map bareForm → fullForm by stripping trailing "."
// from each abbreviation in ctx.localeDefaults.abbreviations. Search for each
// bare form as a word-boundary-delimited token. If not followed by ".", emit.
// ---------------------------------------------------------------------------
function buildBareFormMap(abbreviations: ReadonlySet): Map {
const map = new Map();
for (const abbr of abbreviations) {
if (abbr.endsWith(".")) {
const bare = abbr.slice(0, -1);
if (bare.length > 0) {
map.set(bare, abbr);
}
}
}
return map;
}
/** Bare abbreviation forms that are also common nouns/determiners — require a
digit after the match (citation context) to count as the abbreviation. */
const DIGIT_REQUIRED_BARE = new Set(["Art", "No"]);
const abbr01: TypographyRule = {
id: "TYPO-ABBR-01",
family: "ABBR",
tier: 2,
severity: "error",
check(segment, ctx) {
const bareMap = buildBareFormMap(ctx.localeDefaults.abbreviations);
if (bareMap.size === 0) return [];
const text = segment.text;
const findings: TypographyFinding[] = [];
for (const [bare, full] of bareMap) {
const pattern = new RegExp(
`(? = {
"z.B": "z. B",
"z.b": "z. b",
"u.A": "u. A",
"u.a": "u. a",
"d.H": "d. H",
"d.h": "d. h",
"v.S": "v. S",
"v.s": "v. s",
"u.Ä": "u. Ä",
"u.ä": "u. ä",
};
const abbr02: TypographyRule = {
id: "TYPO-ABBR-02",
family: "ABBR",
tier: 2,
severity: "error",
check(segment, _ctx) {
if (segment.locale !== "de") return [];
const text = segment.text;
const findings: TypographyFinding[] = [];
for (const pattern of ABBR02_PATTERNS) {
for (const m of text.matchAll(new RegExp(pattern.source, "gu"))) {
const matched = m[0];
const fixed = ABBR02_FIXES[matched] ?? "";
const fixHint = ABBR02_FIXES[matched] ?? "Add a space after the period.";
findings.push(
finding(
this.id,
segment,
matched,
m.index,
`German abbreviation with wrong internal spacing: "${matched}" — should be "${fixHint}".`,
`Add a space after the period: "${fixHint}".`,
fixed ? ({ type: "replace", old: matched, new: fixed } satisfies FixAction) : null,
),
);
}
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-APOS-01: Ukrainian word with curly apostrophe (U+2019) instead of straight '
// Pattern: \p{L}+\u2019\p{L}+
// Exclusions: code spans (stripped by extractor); allowedTokens
// ---------------------------------------------------------------------------
const APOS01_PATTERN = /\p{L}+\u2019\p{L}+/gu;
const apos01: TypographyRule = {
id: "TYPO-APOS-01",
family: "APOS",
tier: 2,
severity: "error",
check(segment, ctx) {
if (segment.locale !== "uk") return [];
const text = segment.text;
const findings: TypographyFinding[] = [];
for (const m of text.matchAll(APOS01_PATTERN)) {
const matched = m[0];
if (ctx.allowedTokens.has(matched)) continue;
findings.push(
finding(
this.id,
segment,
matched,
m.index,
`Ukrainian word with curly apostrophe (U+2019): "${matched}" — use a straight apostrophe (').`,
"Replace the curly apostrophe with a straight apostrophe (').",
{
type: "replace",
old: matched,
new: matched.replace(/\u2019/g, "'"),
} satisfies FixAction,
),
);
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-APOS-02: Ukrainian word with modifier letter apostrophe (U+02BC) in source
// Pattern: \p{L}+\u02BC\p{L}+
// Exclusions: code spans (stripped by extractor); allowedTokens
// ---------------------------------------------------------------------------
const APOS02_PATTERN = /\p{L}+\u02BC\p{L}+/gu;
const apos02: TypographyRule = {
id: "TYPO-APOS-02",
family: "APOS",
tier: 2,
severity: "error",
check(segment, ctx) {
if (segment.locale !== "uk") return [];
const text = segment.text;
const findings: TypographyFinding[] = [];
for (const m of text.matchAll(APOS02_PATTERN)) {
const matched = m[0];
if (ctx.allowedTokens.has(matched)) continue;
findings.push(
finding(
this.id,
segment,
matched,
m.index,
`Ukrainian word with modifier letter apostrophe (U+02BC): "${matched}" — use a straight apostrophe (').`,
"Replace the modifier letter apostrophe with a straight apostrophe (').",
{
type: "replace",
old: matched,
new: matched.replace(/\u02BC/g, "'"),
} satisfies FixAction,
),
);
}
return findings;
},
};
// ---------------------------------------------------------------------------
// Helper: escape regex special characters in a literal string
// ---------------------------------------------------------------------------
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// ---------------------------------------------------------------------------
// Export all Tier 2 locale rules
// ---------------------------------------------------------------------------
export const TIER2_LOCALE_RULES: readonly TypographyRule[] = [
num01,
num02,
num03,
abbr01,
abbr02,
apos01,
apos02,
];