import type { TokenDefinitionFact } from "../facts/index.js"; import { isApplicableTokenReference, matchesScale, nearestSignedScaleValue, normalizeLengthForScale, parseLengthValue, tokenReference, type ParsedLengthValue, } from "./utils.js"; export interface SpacingResolutionScale { name: string; unit: "px" | "rem"; rootFontSizePx?: number; emBasePx?: number; } /** Spacing tokens keyed by their value normalized into a given scale's unit. */ export type SpacingTokenLookup = Map; export interface CheckedSpacingValue { normalizedValue: number; /** Best-effort suggestion for display, even when no fix is emitted. */ suggestedValue?: string; /** Token name backing suggestedValue, when the canonical token vocabulary has one. */ suggestedToken?: string; /** Whether a fix object should be attached (suggestion reliable enough). */ fixEmittable: boolean; deterministicFix: boolean; /** Dominant reason for the finding — drives message wording. */ reason: "off-scale" | "token-equivalent"; /** Token name when the violation is a raw literal that equals a token. */ matchedToken?: string; assumedRootFontSizePx?: number; assumedEmBasePx?: number; } export function buildSpacingTokenLookup( tokens: readonly TokenDefinitionFact[], scale: SpacingResolutionScale ): SpacingTokenLookup { const out: SpacingTokenLookup = new Map(); for (const token of tokens) { const parsed = parseLengthValue(token.value); if (!parsed) continue; const normalized = normalizeLengthForScale(parsed, scale); if (normalized === null) continue; const key = Math.abs(normalized.value); // First token wins so the suggestion is stable across runs. if (!out.has(key)) out.set(key, token); } return out; } export function resolveSpacingValue(input: { raw: string; allowed: readonly number[]; scale: SpacingResolutionScale; tokens: SpacingTokenLookup; bareNumberFix?: boolean; }): CheckedSpacingValue | null { const parts = parseLengthParts(input.raw); if (!parts) return null; let firstViolation: CheckedSpacingValue | null = null; const suggestedParts: string[] = []; let anyViolation = false; let anyOffScale = false; let canFixAll = true; // A snap is only reliable enough to *offer* when the value normalization was // exact — an assumed em/rem base makes the px guess unreliable, so report the // suggestion but attach no deterministic fix. let reliableSuggestion = true; // Deterministic only when every violating part is a value-preserving, // verbatim-resolvable token swap. let allApplicableValuePreserving = true; for (const part of parts) { const normalized = normalizeLengthForScale(part.parsed, input.scale); if (normalized === null) return null; const magnitude = Math.abs(normalized.value); const onScale = matchesScale(normalized.value, input.allowed); const exactToken = input.tokens.get(magnitude); if (onScale) { if (!exactToken) { // On-grid value with no named token (e.g. `0`) — not a violation. suggestedParts.push(part.raw); continue; } // On-grid literal that equals a token: value-preserving swap. anyViolation = true; const reference = tokenReference(exactToken.name); const applicable = isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix; if (!applicable) allApplicableValuePreserving = false; suggestedParts.push(reference); if (!firstViolation) { firstViolation = { normalizedValue: normalized.value, suggestedValue: reference, suggestedToken: exactToken.name, fixEmittable: true, deterministicFix: applicable, reason: "token-equivalent", matchedToken: exactToken.name, assumedRootFontSizePx: normalized.assumedRootFontSizePx, assumedEmBasePx: normalized.assumedEmBasePx, }; } continue; } // Off the scale — flag, suggest the nearest scale value as its token when // one exists. Snapping changes rendered value, so this is not deterministic. anyViolation = true; anyOffScale = true; allApplicableValuePreserving = false; const nearest = nearestSignedScaleValue(normalized.value, input.allowed); const snapsToZero = nearest === 0 && normalized.value !== 0; let suggestedPart: string | undefined; let suggestedToken: string | undefined; if (nearest === null || snapsToZero) { suggestedPart = undefined; canFixAll = false; suggestedParts.push(part.raw); } else { const nearestToken = input.tokens.get(Math.abs(nearest)); suggestedToken = nearestToken?.name; suggestedPart = nearestToken ? tokenReference(nearestToken.name) : input.bareNumberFix ? `${nearest}` : `${nearest}${input.scale.unit}`; suggestedParts.push(suggestedPart); if (!normalized.deterministicFix) reliableSuggestion = false; } if (!firstViolation) { firstViolation = { normalizedValue: normalized.value, suggestedValue: suggestedPart, suggestedToken, fixEmittable: false, deterministicFix: false, reason: "off-scale", assumedRootFontSizePx: normalized.assumedRootFontSizePx, assumedEmBasePx: normalized.assumedEmBasePx, }; } } if (!firstViolation || !anyViolation) return null; const joined = suggestedParts.join(" "); const changes = joined !== input.raw.trim().replace(/\s+/g, " "); const fixEmittable = canFixAll && reliableSuggestion && changes; const deterministic = fixEmittable && !anyOffScale && allApplicableValuePreserving; return { ...firstViolation, reason: anyOffScale ? "off-scale" : "token-equivalent", fixEmittable, deterministicFix: deterministic, suggestedValue: canFixAll ? joined : firstViolation.suggestedValue, }; } function parseLengthParts(raw: string): Array<{ raw: string; parsed: ParsedLengthValue }> | null { const trimmed = raw.trim(); if (!trimmed || /var\(|calc\(|min\(|max\(|clamp\(|,/i.test(trimmed)) return null; const parts = trimmed.split(/\s+/); if (parts.length < 1 || parts.length > 4) return null; const parsed = parts.map((part) => { const length = parseLengthValue(part); return length ? { raw: part, parsed: length } : null; }); if (parsed.some((part) => part === null)) return null; return parsed as Array<{ raw: string; parsed: ParsedLengthValue }>; }