core/analyzers/template.analyzer.ts

core/analyzers/template.analyzer.ts is a file in GovLab Context. 178 lines of code and 49 definitions.

import {
    ARRAY_CLOSE,
    COMMENT,
    FIELD_BOUNDARIES,
    FIELD_KINDS_ANYWHERE,
    FIELD_KINDS_BY_ARRAY,
    FIELD_OPEN,
    HEADER_KINDS,
    HEADER_OPEN,
    HEADER_SEPARATOR,
    LIST_KINDS_BY_ARRAY,
    MATH_JOINER,
    MATH_PLUS,
    MATH_TYPE_KIND,
    NODE_HEADER,
    PLACEHOLDER_MARKS,
    QUOTE,
    ROW_DASH,
    SET_ASSIGN,
    SET_OPEN,
    SPINE_COLUMNS,
    SPINE_HEAD,
} from "#configuration/constants/template.constants";
import type { TemplateRef } from "#types/grammar.types";

type KindMembers = ReadonlyMap<string, ReadonlySet<string>>;

interface SpineIndexes {
    readonly axis: number;
    readonly layer: number;
    readonly mathType: number;
}

interface ScanState {
    readonly array: string | null;
    readonly spine: SpineIndexes | null;
}

interface LineScan {
    readonly refs: TemplateRef[];
    readonly state: ScanState;
}

const LINE_FEED = "\n";
const WORD_SEPARATOR = " ";

const isPlaceholder = function isPlaceholder(value: string): boolean {
    return PLACEHOLDER_MARKS.some((mark) => value.includes(mark));
};

const quotedStrings = function quotedStrings(text: string): string[] {
    const values: string[] = [];
    let open = text.indexOf(QUOTE);
    while (open !== -1) {
        const close = text.indexOf(QUOTE, open + 1);
        if (close === -1) {
            break;
        }
        values.push(text.slice(open + 1, close));
        open = text.indexOf(QUOTE, close + 1);
    }
    return values;
};

const fieldValues = function fieldValues(text: string, field: string): string[] {
    const marker = `${field}${FIELD_OPEN}`;
    const values: string[] = [];
    let at = text.indexOf(marker);
    while (at !== -1) {
        const start = at + marker.length;
        const end = text.indexOf(QUOTE, start);
        if (end === -1) {
            break;
        }
        if (at === 0 || FIELD_BOUNDARIES.has(text.charAt(at - 1))) {
            values.push(text.slice(start, end));
        }
        at = text.indexOf(marker, end + 1);
    }
    return values;
};

const tokensOf = function tokensOf(text: string): string[] {
    return text.split(WORD_SEPARATOR).filter((token) => token.length > 0);
};

const headerRefs = function headerRefs(trimmed: string, line: number): TemplateRef[] {
    const open = trimmed.indexOf(HEADER_OPEN);
    const close = trimmed.lastIndexOf(ARRAY_CLOSE);
    if (open === -1 || close <= open) {
        return [];
    }
    const [layer, axis, math] = trimmed.slice(open + 1, close).split(HEADER_SEPARATOR);
    if (layer === undefined || axis === undefined || math === undefined) {
        return [];
    }
    const [layerKind, axisKind, mathKind] = HEADER_KINDS;
    return [
        { id: layer.trim(), kind: layerKind, line },
        { id: axis.trim(), kind: axisKind, line },
        ...math.split(MATH_JOINER).map((part) => ({ id: part.trim(), kind: mathKind, line })),
    ];
};

const spineIndexes = function spineIndexes(trimmed: string): SpineIndexes | null {
    const columns = tokensOf(trimmed.slice(COMMENT.length));
    const [layer = -1, axis = -1, mathType = -1] = SPINE_COLUMNS.map((name) => columns.indexOf(name));
    return [layer, axis, mathType].includes(-1) ? null : { axis, layer, mathType };
};

const spineRefs = function spineRefs(trimmed: string, spine: SpineIndexes, line: number): TemplateRef[] {
    const tokens = tokensOf(trimmed.slice(COMMENT.length));
    const layer = tokens[spine.layer];
    const axis = tokens[spine.axis];
    const math = tokens[spine.mathType];
    if (layer === undefined || axis === undefined || math === undefined) {
        return [];
    }
    const second = tokens[spine.mathType + 1] === MATH_PLUS ? tokens[spine.mathType + 2] : undefined;
    return [
        { id: layer, kind: "layer", line },
        { id: axis, kind: "axis", line },
        ...[math, ...(second === undefined ? [] : [second])].map((id) => ({ id, kind: MATH_TYPE_KIND, line })),
    ];
};

const isSeparatorRow = function isSeparatorRow(trimmed: string): boolean {
    return tokensOf(trimmed.slice(COMMENT.length)).every((cell) => cell.replaceAll(ROW_DASH, "").length === 0);
};

const arrayNameOf = function arrayNameOf(trimmed: string): string | null {
    const assign = trimmed.indexOf(SET_ASSIGN);
    return trimmed.startsWith(SET_OPEN) && assign !== -1 ? trimmed.slice(SET_OPEN.length, assign) : null;
};

const fieldRefs = function fieldRefs(text: string, fields: ReadonlyMap<string, string>, line: number): TemplateRef[] {
    return [...fields].flatMap(([field, kind]) => fieldValues(text, field).map((id) => ({ id, kind, line })));
};

const arrayRefs = function arrayRefs(trimmed: string, array: string | null, line: number): TemplateRef[] {
    const listKind = array === null ? undefined : LIST_KINDS_BY_ARRAY.get(array);
    const assign = trimmed.indexOf(SET_ASSIGN);
    const listText = assign === -1 ? trimmed : trimmed.slice(assign);
    const listed = listKind === undefined ? [] : quotedStrings(listText).map((id) => ({ id, kind: listKind, line }));
    const fields = array === null ? undefined : FIELD_KINDS_BY_ARRAY.get(array);
    return [
        ...listed,
        ...(fields === undefined ? [] : fieldRefs(trimmed, fields, line)),
        ...fieldRefs(trimmed, FIELD_KINDS_ANYWHERE, line),
    ];
};

const spineScan = function spineScan(trimmed: string, state: ScanState, line: number): LineScan | null {
    const opened = trimmed.startsWith(SPINE_HEAD) ? spineIndexes(trimmed) : null;
    if (opened !== null) {
        return { refs: [], state: { ...state, spine: opened } };
    }
    if (state.spine === null) {
        return null;
    }
    if (!trimmed.startsWith(COMMENT) || trimmed === COMMENT) {
        return null;
    }
    return { refs: isSeparatorRow(trimmed) ? [] : spineRefs(trimmed, state.spine, line), state };
};

const scanLine = function scanLine(trimmed: string, state: ScanState, line: number): LineScan {
    const spine = spineScan(trimmed, state, line);
    if (spine !== null) {
        return spine;
    }
    const open = { ...state, spine: null };
    if (trimmed.startsWith(NODE_HEADER)) {
        return { refs: headerRefs(trimmed, line), state: open };
    }
    const opened = arrayNameOf(trimmed);
    const array = opened ?? open.array;
    const closes = trimmed.startsWith(ARRAY_CLOSE) || (opened !== null && trimmed.endsWith(ARRAY_CLOSE));
    return { refs: arrayRefs(trimmed, array, line), state: { array: closes ? null : array, spine: null } };
};

export const templateRefsOf = function templateRefsOf(text: string): TemplateRef[] {
    let state: ScanState = { array: null, spine: null };
    const refs: TemplateRef[] = [];
    for (const [index, raw] of text.split(LINE_FEED).entries()) {
        const scan = scanLine(raw.trim(), state, index + 1);
        refs.push(...scan.refs);
        ({ state } = scan);
    }
    return refs.filter((ref) => ref.id.length > 0 && !isPlaceholder(ref.id));
};

export const unresolvedTemplateRefsOf = function unresolvedTemplateRefsOf(
    text: string,
    members: KindMembers,
): TemplateRef[] {
    return templateRefsOf(text).filter((ref) => !(members.get(ref.kind)?.has(ref.id) ?? false));
};