shared/matchers/filename.matcher.ts

shared/matchers/filename.matcher.ts is a file in GovLab Extension Host. 185 lines of code and 48 definitions.

import type { ExemptName, ParseFailure, ParseResult, ParsedName } from "../../types/taxonomy.types.ts";
import { ambiguousCoordinate, noSplitter, unresolvedCoordinate } from "../strings/taxonomy.strings.ts";
import {
    isCompoundMarker,
    isConcern,
    isDeclaredSubject,
    isDeclaredVariant,
    isLegalSubject,
    splitterFor,
    vocabularyFor,
} from "../manifests/taxonomy.manifest.ts";
import { slotWordOf, wordsOf } from "./segment.matcher.ts";

const KEBAB_EXTRA = "-";

const KEBAB_CASE = "kebab";

const isKebabChar = function isKebabChar(char: string): boolean {
    const isLower = char >= "a" && char <= "z";
    const isDigit = char >= "0" && char <= "9";
    return isLower || isDigit || char === KEBAB_EXTRA;
};

const hasKebabEdges = function hasKebabEdges(word: string): boolean {
    if (word === "" || word.startsWith(KEBAB_EXTRA) || word.endsWith(KEBAB_EXTRA)) {
        return false;
    }
    return !word.includes(`${KEBAB_EXTRA}${KEBAB_EXTRA}`);
};

export const isKebab = function isKebab(word: string, root?: string): boolean {
    if (vocabularyFor(root).case !== KEBAB_CASE) {
        return true;
    }
    if (!hasKebabEdges(word)) {
        return false;
    }
    for (let at = 0; at < word.length; at += 1) {
        if (!isKebabChar(word.charAt(at))) {
            return false;
        }
    }
    return true;
};

const slotBounds = function slotBounds(root: string | undefined): { min: number; max: number } {
    const vocabulary = vocabularyFor(root);
    const counts = vocabulary.fileShapes.map((shape) => shape.split(vocabulary.separator).length);
    return { max: Math.max(...counts), min: Math.min(...counts) };
};

const shapeError = function shapeError(
    basename: string,
    segments: readonly string[],
    concern: string,
    root: string | undefined,
): ParseFailure | null {
    const shapes = vocabularyFor(root).fileShapes;
    const { max, min } = slotBounds(root);
    if (segments.length < min) {
        return { reason: `a governed file needs at least ${shapes[0] ?? ""}`, word: basename };
    }
    if (segments.length > max) {
        return { reason: `more slots than the grammar allows (${shapes.join(" | ")})`, word: basename };
    }
    if (!isConcern(concern, root)) {
        return { reason: "last segment before the extension is not a declared concern tag", word: concern };
    }
    return null;
};

const slotError = function slotError(
    slots: { subject: string; variant: string | null; concern: string },
    root: string | undefined,
): ParseFailure | null {
    const { concern, subject, variant } = slots;
    if (!isLegalSubject(subject, root)) {
        return { reason: "subject is neither a declared subject nor a concern tag", word: subject };
    }
    if (subject === concern) {
        return { reason: "subject must not equal concern; the subject is carrying no information", word: subject };
    }
    if (variant !== null && !isDeclaredVariant(variant, root) && !isDeclaredSubject(variant, root)) {
        return { reason: "variant is neither a declared variant nor a declared subject", word: variant };
    }
    const offending = (variant === null ? [subject] : [subject, variant]).find((slot) => !isKebab(slot, root));
    return offending === undefined ? null : { reason: `slot is not ${vocabularyFor(root).case}-case`, word: offending };
};

const concernRunOf = function concernRunOf(words: readonly string[], root: string | undefined): number {
    for (let take = words.length - 1; take >= 1; take -= 1) {
        if (isConcern(slotWordOf(words.slice(-take)), root)) {
            return take;
        }
    }
    return 0;
};

const headSlotsOf = function headSlotsOf(
    head: readonly string[],
    root: string | undefined,
): { subject: string; variant: string | null } | null {
    const whole = slotWordOf(head);
    if (isLegalSubject(whole, root)) {
        return { subject: whole, variant: null };
    }
    for (let take = head.length - 1; take >= 1; take -= 1) {
        const subject = slotWordOf(head.slice(0, take));
        const variant = slotWordOf(head.slice(take));
        if (isLegalSubject(subject, root) && (isDeclaredVariant(variant, root) || isDeclaredSubject(variant, root))) {
            return { subject, variant };
        }
    }
    return null;
};

export const parseDialect = function parseDialect(basename: string, splitterName: string, root?: string): ParseResult {
    const { separator, splitters } = vocabularyFor(root);
    const splitter = splitters.get(splitterName);
    if (splitter === undefined) {
        throw new Error(noSplitter(splitterName));
    }
    const cut = basename.lastIndexOf(separator);
    const stem = cut === -1 ? basename : basename.slice(0, cut);
    const ext = cut === -1 ? "" : basename.slice(cut + 1);
    const words = wordsOf(stem, splitter);
    if (words === null) {
        return { reason: `a ${splitterName} dialect file is one ${splitterName} word run`, word: basename };
    }
    const marker = words.at(-1) ?? "";
    if (isCompoundMarker(marker, root)) {
        return { exempt: true, marker };
    }
    const taken = concernRunOf(words, root);
    if (taken === 0) {
        return { reason: "the trailing words are not a declared concern tag", word: marker };
    }
    const concern = slotWordOf(words.slice(-taken));
    const slots = headSlotsOf(words.slice(0, -taken), root);
    if (slots === null) {
        const head = slotWordOf(words.slice(0, -taken));
        return {
            reason: "the leading words are neither a legal subject nor a subject and a declared variant",
            word: head,
        };
    }
    return slotError({ concern, ...slots }, root) ?? { concern, ext, ...slots };
};

export const parseFilename = function parseFilename(basename: string, root?: string): ParseResult {
    const segments = basename.split(vocabularyFor(root).separator);
    const ext = segments.at(-1) ?? "";
    const splitter = splitterFor(ext, root);
    if (splitter !== null) {
        return parseDialect(basename, splitter.name, root);
    }
    const concern = segments.at(-2) ?? "";

    if (isCompoundMarker(concern, root)) {
        return { exempt: true, marker: concern };
    }
    const shape = shapeError(basename, segments, concern, root);
    if (shape !== null) {
        return shape;
    }

    const head = segments.slice(0, -2);
    const subject = head[0] ?? "";
    const variant = head.length === 2 ? (head[1] ?? "") : null;

    const slot = slotError({ concern, subject, variant }, root);
    return slot ?? { concern, ext, subject, variant };
};

export const isExempt = function isExempt(result: ParseResult): result is ExemptName {
    return Object.hasOwn(result, "exempt");
};

export const isParsed = function isParsed(result: ParseResult): result is ParsedName {
    return Object.hasOwn(result, "concern");
};

export const hasConcern = function hasConcern(basename: string, tag: string): boolean {
    const parsed = parseFilename(basename);
    return isParsed(parsed) && parsed.concern === tag;
};

export const filesWithConcern = function filesWithConcern(tag: string, files: readonly string[]): string[] {
    return files.filter((file) => hasConcern(file.slice(file.lastIndexOf("/") + 1), tag));
};

export const resolveFile = function resolveFile(subject: string, tag: string, files: readonly string[]): string {
    const matches = filesWithConcern(tag, files).filter((file) => {
        const basename = file.slice(file.lastIndexOf("/") + 1);
        return basename.slice(0, basename.indexOf(".")) === subject;
    });
    const [first] = matches;
    if (first === undefined) {
        throw new Error(unresolvedCoordinate(subject, tag));
    }
    if (matches.length > 1) {
        throw new Error(ambiguousCoordinate(subject, tag, matches));
    }
    return first;
};