# core/matchers/vocabulary.matcher.ts

> 142 lines of code and 33 definitions.

Tree: Site tree
Language: typescript
Layer: processing
Canonical: https://banes-lab.com/anatomy/tree#file-core-matchers-vocabulary-matcher-ts
Source text: https://banes-lab.com/assets/sources/source.a74989431921a393a50c458a675f86f9a08cff7b2ff21f0922e08fcd6bca2e07.generated.txt

## Definitions

- `admits` (lexical_declaration, line 106)
- `matchesAt` (lexical_declaration, line 68)
- `linkable` (lexical_declaration, line 102)
- `commaJointsOf` (lexical_declaration, line 13)
- `isAmbiguous` (lexical_declaration, line 38)
- `gapIsSoft` (lexical_declaration, line 58)
- `insideCompound` (lexical_declaration, line 88)
- `buildPhraseIndex` (lexical_declaration, line 42, exported)
- `hitAt` (lexical_declaration, line 117)
- `SPACE` (lexical_declaration, line 10)
- `COMMA` (lexical_declaration, line 11)
- `joints` (lexical_declaration, line 14)
- `inWord` (lexical_declaration, line 15)
- `seenWord` (lexical_declaration, line 16)
- `AMBIGUOUS_KEYS` (lexical_declaration, line 34)
- `byFirstWord` (lexical_declaration, line 43, exported)
- `words` (lexical_declaration, line 45, exported)
- `[first]` (lexical_declaration, line 46, exported)
- `char` (lexical_declaration, line 60)
- `offset` (lexical_declaration, line 74)
- `previous` (lexical_declaration, line 76)
- `comma` (lexical_declaration, line 80)
- `[words]` (lexical_declaration, line 108)
- `token` (lexical_declaration, line 124)
- `candidates` (lexical_declaration, line 128)
- `matchPhrases` (lexical_declaration, line 132, exported)
- `tokens` (lexical_declaration, line 137, exported)
- `matches` (lexical_declaration, line 138, exported)
- `at` (lexical_declaration, line 139, exported)
- `hit` (lexical_declaration, line 141, exported)
- `first` (lexical_declaration, line 142, exported)
- `[words, entry]` (lexical_declaration, line 147, exported)
- `last` (lexical_declaration, line 148, exported)

## Source

```typescript
import {
    AMBIGUOUS_PHRASES,
    COMPOUND_JOINER,
    GAP_CHARACTERS,
    WORD_CHARACTERS,
} from "#configuration/constants/vocabulary.constants";
import type { PhraseCandidate, PhraseIndex, PhraseMatch, VocabularyEntry, WordToken } from "#types/vocabulary.types";
import { tokensOf, wordsOf } from "#core/normalizers/word.normalizer";

const SPACE = " ";
const COMMA = ",";

const commaJointsOf = function commaJointsOf(phrase: string): readonly boolean[] {
    const joints: boolean[] = [];
    let inWord = false;
    let seenWord = false;
    let comma = false;
    for (const char of phrase.toLowerCase()) {
        if (WORD_CHARACTERS.includes(char)) {
            if (!inWord && seenWord) {
                joints.push(comma);
            }
            inWord = true;
            seenWord = true;
            comma = false;
            continue;
        }
        inWord = false;
        comma ||= char === COMMA;
    }
    return joints;
};

const AMBIGUOUS_KEYS: ReadonlySet<string> = new Set(
    [...AMBIGUOUS_PHRASES].map((phrase) => wordsOf(phrase).join(SPACE)),
);

const isAmbiguous = function isAmbiguous(words: readonly string[]): boolean {
    return AMBIGUOUS_KEYS.has(words.join(SPACE));
};

export const buildPhraseIndex = function buildPhraseIndex(vocabulary: readonly VocabularyEntry[]): PhraseIndex {
    const byFirstWord = new Map<string, PhraseCandidate[]>();
    for (const entry of vocabulary) {
        const words = wordsOf(entry.phrase);
        const [first] = words;
        if (first === undefined) {
            continue;
        }
        byFirstWord.set(first, [...(byFirstWord.get(first) ?? []), [words, entry, commaJointsOf(entry.phrase)]]);
    }
    for (const candidates of byFirstWord.values()) {
        candidates.sort((left, right) => right[0].length - left[0].length);
    }
    return { byFirstWord };
};

const gapIsSoft = function gapIsSoft(text: string, from: number, to: number, comma: boolean): boolean {
    for (let at = from; at < to; at += 1) {
        const char = text.charAt(at);
        if (!GAP_CHARACTERS.has(char) && !(comma && char === COMMA)) {
            return false;
        }
    }
    return true;
};

const matchesAt = function matchesAt(
    text: string,
    tokens: readonly WordToken[],
    at: number,
    [words, , joints]: PhraseCandidate,
): boolean {
    for (let offset = 0; offset < words.length; offset += 1) {
        const token = tokens[at + offset];
        const previous = tokens[at + offset - 1];
        if (token === undefined || token.word !== words[offset]) {
            return false;
        }
        const comma = joints[offset - 1] === true;
        if (offset > 0 && previous !== undefined && !gapIsSoft(text, previous.end, token.start, comma)) {
            return false;
        }
    }
    return true;
};

const insideCompound = function insideCompound(
    text: string,
    tokens: readonly WordToken[],
    at: number,
    length: number,
): boolean {
    const first = tokens[at];
    const last = tokens[at + length - 1];
    if (first === undefined || last === undefined) {
        return false;
    }
    return text.charAt(first.start - 1) === COMPOUND_JOINER || text.charAt(last.end) === COMPOUND_JOINER;
};

const linkable = function linkable([words, entry]: PhraseCandidate, seen: ReadonlySet<string>): boolean {
    return entry.prose && !isAmbiguous(words) && !seen.has(entry.ref);
};

const admits = function admits(text: string, tokens: readonly WordToken[], at: number, seen: ReadonlySet<string>) {
    return (candidate: PhraseCandidate): boolean => {
        const [words] = candidate;
        return (
            linkable(candidate, seen) &&
            matchesAt(text, tokens, at, candidate) &&
            !insideCompound(text, tokens, at, words.length)
        );
    };
};

const hitAt = function hitAt(
    text: string,
    tokens: readonly WordToken[],
    at: number,
    index: PhraseIndex,
    seen: ReadonlySet<string>,
): PhraseCandidate | null {
    const token = tokens[at];
    if (token === undefined) {
        return null;
    }
    const candidates = index.byFirstWord.get(token.word) ?? [];
    return candidates.find(admits(text, tokens, at, seen)) ?? null;
};

export const matchPhrases = function matchPhrases(
    text: string,
    index: PhraseIndex,
    seen: Set<string>,
): readonly PhraseMatch[] {
    const tokens = tokensOf(text);
    const matches: PhraseMatch[] = [];
    let at = 0;
    while (at < tokens.length) {
        const hit = hitAt(text, tokens, at, index, seen);
        const first = tokens[at];
        if (hit === null || first === undefined) {
            at += 1;
            continue;
        }
        const [words, entry] = hit;
        const last = tokens[at + words.length - 1] ?? first;
        matches.push({ end: last.end, entry, start: first.start });
        seen.add(entry.ref);
        at += words.length;
    }
    return matches;
};
```
