UNPKG

@slugkit/sdk

Version:

SlugKit SDK for JavaScript/TypeScript applications

1,324 lines (1,217 loc) 58.7 kB
// Partial Parser for SlugKit patterns // Based on the EBNF grammar import { DictionaryStats, DictionaryTag } from './types'; import { CompareOperator, NumberBase, ShortNumberBase, SizeLimit, Selector, NumberGen, SpecialCharGen, EmojiGen, PatternElement, GlobalSettings, ParsedPattern } from "./parser-types"; export const enum ExpectedToken { NONE = 0, WHITESPACE = 1 << 0, // whitespace // Punctuation OPEN_BRACE = 1 << 1, // { CLOSE_BRACE = 1 << 2, // } OPEN_BRACKET = 1 << 3, // [ CLOSE_BRACKET = 1 << 4, // ] COLON = 1 << 5, // : TAG_START = 1 << 7, // + or - DASH = 1 << 8, // - (for ranges) EQUALS = 1 << 9, // = COMPARISON_OP = 1 << 10, // >, <, >=, <=, ==, != COMMA = 1 << 11, // , AT_SIGN = 1 << 12, // @ // Identifiers and literals IDENTIFIER = 1 << 13, // identifier [A-Za-z_][A-Za-z0-9_]* TAG = 1 << 14, // tag [A-Za-z0-9_]+ NUMBER = 1 << 15, // number STRING = 1 << 16, // string "[^} ]*" BOOLEAN = 1 << 17, // boolean true or false // Specific identifiers GENERATOR = 1 << 18, // generator (number, special, emoji, dictionary name) OPTION = 1 << 19, // option (count, unique, tone, gender) NUMBER_BASE_SHORT = 1 << 20, // short number base (d, x, X, r, R) NUMBER_BASE_FULL = 1 << 21, // full number base (dec, hex, HEX, roman, ROMAN) LANGUAGE = 1 << 22, // language // Escaped characters ESCAPED_CHARACTER = 1 << 31, // escaped character []{}\ } // @ts-ignore function debugExpectedTokens(tokens: ExpectedToken): string { // Since ExpectedToken is a const enum, we need to define the token names manually // but we can do it more concisely with an array of [value, name] pairs const tokenDefinitions: [ExpectedToken, string][] = [ [ExpectedToken.WHITESPACE, 'WHITESPACE'], [ExpectedToken.OPEN_BRACE, 'OPEN_BRACE'], [ExpectedToken.CLOSE_BRACE, 'CLOSE_BRACE'], [ExpectedToken.OPEN_BRACKET, 'OPEN_BRACKET'], [ExpectedToken.CLOSE_BRACKET, 'CLOSE_BRACKET'], [ExpectedToken.COLON, 'COLON'], [ExpectedToken.TAG_START, 'TAG_START'], [ExpectedToken.DASH, 'DASH'], [ExpectedToken.EQUALS, 'EQUALS'], [ExpectedToken.COMPARISON_OP, 'COMPARISON_OP'], [ExpectedToken.COMMA, 'COMMA'], [ExpectedToken.AT_SIGN, 'AT_SIGN'], [ExpectedToken.IDENTIFIER, 'IDENTIFIER'], [ExpectedToken.TAG, 'TAG'], [ExpectedToken.NUMBER, 'NUMBER'], [ExpectedToken.STRING, 'STRING'], [ExpectedToken.BOOLEAN, 'BOOLEAN'], [ExpectedToken.GENERATOR, 'GENERATOR'], [ExpectedToken.OPTION, 'OPTION'], [ExpectedToken.NUMBER_BASE_SHORT, 'NUMBER_BASE_SHORT'], [ExpectedToken.NUMBER_BASE_FULL, 'NUMBER_BASE_FULL'], [ExpectedToken.LANGUAGE, 'LANGUAGE'], [ExpectedToken.ESCAPED_CHARACTER, 'ESCAPED_CHARACTER'], ]; const parts = tokenDefinitions .filter(([tokenValue]) => tokens & tokenValue) .map(([, tokenName]) => tokenName); return parts.length > 0 ? parts.join(' | ') : 'NONE'; } export const enum ParserContext { ARBITRARY = 1 << 0, // outside of placeholder IN_PLACEHOLDER = 1 << 1, // inside of placeholder, between { and } IN_GLOBAL_SETTINGS = 1 << 2, // inside global settings, between [ and ] // PARTIAL_GENERATOR_NAME = 1 << 3, // partial generator name NUMBER_GEN = 1 << 4, // inside number generator, after generator name SPECIAL_GEN = 1 << 5, // inside special generator, after generator name EMOJI_GEN = 1 << 6, // inside emoji generator, after generator name SELECTOR = 1 << 7, // inside selector, after generator name // SETTING_OPTIONS = 1 << 8, // inside setting or options, between : and } TAGS = 1 << 9, // inside tags OPTIONS = 1 << 10, // inside options SIZE_LIMIT = 1 << 11, // inside size limit } export const enum PossibleCases { NONE = 0, LOWER = 1 << 0, UPPER = 1 << 1, TITLE = 1 << 2, MIXED = 1 << 3, } export const enum CaseTransformation { NONE = 0, LOWER = 1 << 0, UPPER = 1 << 1, } function nextTransformation(currentTransformation: CaseTransformation): CaseTransformation { if (currentTransformation === CaseTransformation.LOWER) { return CaseTransformation.UPPER; } return CaseTransformation.LOWER; } export class ParserError extends Error { constructor( message: string, public position: number, public expectedTokens: ExpectedToken, public context: ParserContext ) { super(message); this.name = 'ParserError'; } } export const enum PredefinedGenerators { NUMBER = 'number', SPECIAL = 'special', EMOJI = 'emoji', } interface PartialSearchResult { fullMatch: boolean; suggestions: string[]; } // Interface for the methods that PartialParser actually uses interface SlugKitInterface { getDictionaries(): Promise<DictionaryStats[]>; getDictionaryTags(): Promise<DictionaryTag[]>; } interface TaggedElement { kind: string; includeTags: string[]; excludeTags: string[]; exectedTagTokens(): ExpectedToken; } interface OptionedElement { options: Record<string, string>; exectedOptionTokens(): ExpectedToken; getOptionType(input: string): OptionType | undefined; getOptionSuggestions(input: string): PartialSearchResult; } const enum OptionType { RANGE = "range", RANGE_OR_SINGLE = "range_or_single", BOOLEAN = "boolean", TONE = "tone", GENDER = "gender", }; interface RangeOrSingle { min: number; max: number; } function rangeToString(rangeOrSingle: RangeOrSingle): string { if (rangeOrSingle.min === rangeOrSingle.max) { return rangeOrSingle.min.toString(); } return `${rangeOrSingle.min}-${rangeOrSingle.max}`; } class BaseElement { public validate(_pos: number, _expectedTokens: ExpectedToken, _context: ParserContext): void { } public getOptions(): Record<string, OptionType> { return {}; } public getOptionSuggestions(input: string): PartialSearchResult { const allOptions = this.getOptions(); if (input === '') { return { fullMatch: false, suggestions: Object.keys(allOptions) }; } const fullMatch = Object.keys(allOptions).some((option) => option === input); const suggestions = Object.entries(allOptions).filter(([option, _type]) => option.startsWith(input)).map(([option, _type]) => option); return { fullMatch, suggestions }; } public getOptionType(input: string): OptionType | undefined { const allOptions = this.getOptions(); if (input in allOptions) { return allOptions[input]; } return undefined; } } // Type guard functions function isNumberGenClass(element: BaseElement): element is NumberGenClass { return element instanceof NumberGenClass; } function isSpecialCharGenClass(element: BaseElement): element is SpecialCharGenClass { return element instanceof SpecialCharGenClass; } function isEmojiGenClass(element: BaseElement): element is EmojiGenClass { return element instanceof EmojiGenClass; } function isSelectorClass(element: BaseElement): element is SelectorClass { return element instanceof SelectorClass; } function isGlobalSettingsClass(element: BaseElement): element is GlobalSettingsClass { return element instanceof GlobalSettingsClass; } // Type guard functions for interfaces function isTaggedElement(element: any): element is TaggedElement { return element && typeof element.kind === 'string' && Array.isArray(element.includeTags) && Array.isArray(element.excludeTags) && typeof element.exectedTagTokens === 'function'; } function isOptionedElement(element: any): element is OptionedElement { return element && typeof element.options === 'object' && typeof element.exectedOptionTokens === 'function' && typeof element.getOptionType === 'function' && typeof element.getOptionSuggestions === 'function'; } class SelectorClass extends BaseElement implements Selector { constructor( public kind: string, public language: string | undefined, public includeTags: string[], public excludeTags: string[], public sizeLimit: SizeLimit | undefined, public options: Record<string, string>) { super(); } exectedTagTokens(): ExpectedToken { return ExpectedToken.TAG_START | ExpectedToken.COMPARISON_OP | ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACE; } exectedOptionTokens(): ExpectedToken { return ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACE; } } class NumberGenClass extends BaseElement implements NumberGen { constructor(public maxLength: number, public base: NumberBase) { super(); } validate(pos: number, expectedTokens: ExpectedToken, context: ParserContext): void { if (this.maxLength <= 0) { throw new ParserError('Max length must be greater than 0', pos, expectedTokens, context); } if (this.base === undefined) { throw new ParserError('Base must be defined', pos, expectedTokens, context); } } } class SpecialCharGenClass extends BaseElement implements SpecialCharGen { constructor(public minLength: number, public maxLength: number) { super(); } validate(pos: number, expectedTokens: ExpectedToken, context: ParserContext): void { if (this.minLength < 0) { throw new ParserError('Min length must be greater than or equal to 0', pos, expectedTokens, context); } if (this.maxLength <= 0) { throw new ParserError('Max length must be greater than 0', pos, expectedTokens, context); } if (this.minLength > this.maxLength) { throw new ParserError('Min length must be less than or equal to max length', pos, expectedTokens, context); } } } class EmojiGenClass extends BaseElement implements EmojiGen { constructor( public kind: 'emoji', public includeTags: string[], public excludeTags: string[], public options: Record<string, string>) { super(); } validate(): void { } getOptions(): Record<string, OptionType> { return { count: OptionType.RANGE_OR_SINGLE, unique: OptionType.BOOLEAN, // tone: OptionType.TONE, // Not implemented on backend yet // gender: OptionType.GENDER, // Not implemented on backend yet }; } exectedTagTokens(): ExpectedToken { return ExpectedToken.TAG_START | ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACE; } exectedOptionTokens(): ExpectedToken { return ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACE; } } class GlobalSettingsClass extends BaseElement implements GlobalSettings { public kind: string = '__global__'; constructor( public language: string | undefined, public includeTags: string[], public excludeTags: string[], public sizeLimit: SizeLimit | undefined, public options: Record<string, string>) { super(); } exectedTagTokens(): ExpectedToken { return ExpectedToken.TAG_START | ExpectedToken.COMPARISON_OP | ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACKET; } exectedOptionTokens(): ExpectedToken { return ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACKET; } } export interface Suggestion { text: string; type: 'generator' | 'tag' | 'operator' | 'symbol' | 'language' | 'base'; } const ESCAPED_CHARS = new Set(['{', '}', '[', ']', '\\']); const NON_ARBITRARY_CHARS = new Set(['{', '}', '[', ']', '\\']); const COMPARISON_START_CHARS = new Set(['>', '<', '=', '!']); const REQUIRE_EQUAL_CHARS = new Set(['=', '!']); interface ParserState { context: ParserContext; expectedTokens: ExpectedToken; lastParsedToken: string | null; lastParsedTokenStart: number; // Position where the last token starts lastParsedTokenEnd: number; // Position where the last token ends currentElement?: BaseElement; parsedSoFar: ParsedPattern; } export class PartialParser { private pos = 0; private input: string; private currentContext: ParserContext = ParserContext.ARBITRARY; private expectedTokens: ExpectedToken = ExpectedToken.OPEN_BRACE; private dictionaryNames: string[] = []; private tagsByDictionary: Record<string, string[]> = {}; private parsedSoFar: ParsedPattern = { elements: [], textChunks: [], globalSettings: undefined, }; private lastParsedToken: string | null = null; private lastParsedTokenStart: number = 0; private lastParsedTokenEnd: number = 0; private currentElement?: BaseElement; // TODO: add dictionary names and tags by dictionary constructor(input: string, dictionaryNames: string[] = [], tagsByDictionary: Record<string, string[]> = {}) { this.input = input; if (dictionaryNames && tagsByDictionary) { this.dictionaryNames = dictionaryNames; this.tagsByDictionary = tagsByDictionary; } this.run(); } public parse(input: string): void { this.input = input; this.pos = 0; this.currentContext = ParserContext.ARBITRARY; this.expectedTokens = ExpectedToken.OPEN_BRACE; this.lastParsedToken = null; this.lastParsedTokenStart = 0; this.lastParsedTokenEnd = 0; this.currentElement = undefined; this.parsedSoFar = { elements: [], textChunks: [], globalSettings: undefined, }; this.run(); } public getState(): ParserState { return { context: this.currentContext, expectedTokens: this.expectedTokens, lastParsedToken: this.lastParsedToken, lastParsedTokenStart: this.lastParsedTokenStart, lastParsedTokenEnd: this.lastParsedTokenEnd, currentElement: this.currentElement, parsedSoFar: this.parsedSoFar, }; } public getDictionaryData(): { dictionaryNames: string[]; tagsByDictionary: Record<string, string[]> } { return { dictionaryNames: this.dictionaryNames, tagsByDictionary: this.tagsByDictionary, }; } public static async withMetadata(input: string, slugkit: SlugKitInterface): Promise<PartialParser> { const dictionaries = await slugkit.getDictionaries(); const dictionaryNames = dictionaries.map((dictionary) => dictionary.kind) .filter((name) => name !== 'emoji') // emoji is a special kind, we don't need to show it as a dictionary .sort(); // const languagesByDictionary = dictionaries.reduce((acc, dictionary) => { // acc[dictionary.kind] = dictionary.language; // return acc; // }, {} as Record<string, string>); const tags = await slugkit.getDictionaryTags(); let tagsByDictionary = tags.sort((a, b) => b.word_count - a.word_count).reduce((acc, tag) => { acc[tag.kind] = [...(acc[tag.kind] || []), tag.tag]; return acc; }, {} as Record<string, string[]>); // find an intersection of tags for global settings let globalTags: string[] = []; for (const dictionary of tags) { globalTags.push(...tagsByDictionary[dictionary.kind]); } globalTags = globalTags.filter((tag, index, self) => self.indexOf(tag) === index); tagsByDictionary['__global__'] = globalTags; return new PartialParser(input, dictionaryNames, tagsByDictionary); } public static toTitleCase(identifier: string): string { if (!identifier) { return ''; } return identifier.charAt(0).toUpperCase() + identifier.slice(1).toLowerCase(); } private static getCaseTransformation(userInput: string, position: number): CaseTransformation { if (position >= userInput.length) { return CaseTransformation.NONE; } if (userInput[position].toUpperCase() === userInput[position]) { return CaseTransformation.UPPER; } return CaseTransformation.LOWER; } /// Converts the identifier to mixed case, preserving the casing of the user partial input /// If the user input is empty, the identifier converted to aBcD (lower, upper, lower, upper) /// While the user input is not empty, the identifier is converted to the same casing as the user input public static toMixedCase(identifier: string, userInput: string = ''): string { if (!identifier) { return ''; } let result = ''; let currentTransformation = CaseTransformation.UPPER; for (let i = 0; i < identifier.length; i++) { const userTransformation = this.getCaseTransformation(userInput, i); if (userTransformation !== CaseTransformation.NONE) { currentTransformation = userTransformation; } else { currentTransformation = nextTransformation(currentTransformation); } result += currentTransformation === CaseTransformation.UPPER ? identifier[i].toUpperCase() : identifier[i].toLowerCase(); } return result; } public static detectCase(identifier: string): PossibleCases { if (!identifier) { return PossibleCases.NONE; } if (identifier.length === 1) { if (identifier.toLowerCase() === identifier) { return PossibleCases.LOWER | PossibleCases.MIXED; } if (identifier.toUpperCase() === identifier) { return PossibleCases.UPPER | PossibleCases.TITLE | PossibleCases.MIXED; } } else if (identifier.length === 2) { if (identifier.toLowerCase() === identifier) { return PossibleCases.LOWER; } if (identifier.toUpperCase() === identifier) { return PossibleCases.UPPER; } if (this.toTitleCase(identifier) === identifier) { return PossibleCases.TITLE | PossibleCases.MIXED; } return PossibleCases.MIXED; } if (identifier.toLowerCase() === identifier) { return PossibleCases.LOWER; } if (identifier.toUpperCase() === identifier) { return PossibleCases.UPPER; } if (this.toTitleCase(identifier) === identifier) { return PossibleCases.TITLE; } return PossibleCases.MIXED; } public static generateCasingVariants(identifier: string, cases: PossibleCases, userInput: string = ''): string[] { let variants: string[] = []; if (cases & PossibleCases.LOWER) { variants.push(identifier.toLowerCase()); } if (cases & PossibleCases.UPPER) { variants.push(identifier.toUpperCase()); } if (cases & PossibleCases.TITLE) { variants.push(this.toTitleCase(identifier)); } if (cases & PossibleCases.MIXED) { variants.push(this.toMixedCase(identifier, userInput)); } return variants; } public allGenerators(): string[] { // TODO mutate case for the dictionaries to title, upper or mixed case variants const allDicts: string[] = []; for (const name of this.dictionaryNames) { if (name) { // Add null check allDicts.push(...PartialParser.generateCasingVariants(name, PossibleCases.LOWER | PossibleCases.TITLE | PossibleCases.UPPER | PossibleCases.MIXED, '')); } } return [PredefinedGenerators.NUMBER, PredefinedGenerators.SPECIAL, PredefinedGenerators.EMOJI, ...allDicts]; } /// Returns true if the string matches a beginning of a generator name /// dictionaries are matched case insensitively /// Also special generator names are matched case sensitively (number, special, emoji) public partialGeneratorSearch(identifier: string): PartialSearchResult { if (!identifier || identifier.length === 0) { return { fullMatch: false, suggestions: this.allGenerators() }; } const search = identifier.toLowerCase(); const fullMatch = this.dictionaryNames.some(name => name.toLowerCase() === search); const baseSuggestions = this.dictionaryNames.filter(name => name.toLowerCase().startsWith(search) && name.toLowerCase() !== search); // TODO mutate case for the dictionaries to title, upper or mixed case variants const possibleCases = PartialParser.detectCase(identifier); /// Generate suggestions grouped by casing: all names in one case first, then other cases const caseArrays: string[][] = []; // Generate casing variants for each suggestion and collect by case position for (const suggestion of baseSuggestions) { const variants = PartialParser.generateCasingVariants(suggestion, possibleCases, identifier); variants.forEach((variant, index) => { if (!caseArrays[index]) { caseArrays[index] = []; } caseArrays[index].push(variant); }); } // Join all case arrays in order const dictSuggestions = caseArrays.flat(); const specialMatches = [PredefinedGenerators.NUMBER, PredefinedGenerators.SPECIAL, PredefinedGenerators.EMOJI].filter(name => name.startsWith(identifier)); const suggestions = [...specialMatches, ...dictSuggestions]; return { fullMatch, suggestions }; } public partialTagSearch(dictionaryName: string, identifier: string): PartialSearchResult { const lc_dictionaryName = dictionaryName.toLowerCase(); if (!identifier) { return { fullMatch: false, suggestions: this.tagsByDictionary[lc_dictionaryName] || [] }; } const search = identifier.toLowerCase(); const fullMatch = this.tagsByDictionary[lc_dictionaryName].some(tag => tag.toLowerCase() === search); const suggestions = this.tagsByDictionary[lc_dictionaryName].filter(tag => tag.toLowerCase().startsWith(search) && tag.toLowerCase() !== search); return { fullMatch, suggestions }; } public isValid(): boolean { return this.currentContext === ParserContext.ARBITRARY && (this.expectedTokens === ExpectedToken.NONE || (this.expectedTokens & ExpectedToken.OPEN_BRACE) !== 0 || (this.expectedTokens & ExpectedToken.OPEN_BRACKET) !== 0); } private isEof(): boolean { return this.peek() === null; } private isEndOfPlaceholder(): boolean { return this.peek() === '}'; } private peek(): string | null { if (this.pos >= this.input.length) { return null; } return this.input[this.pos]; } private next(): void { const char = this.peek(); // Only update lastParsedToken for significant characters (not whitespace) if (char && char.trim() !== '') { this.lastParsedToken = char; // Don't update token positions here - they should be updated in extractToken } this.pos++; } /** * Extract and track a token from start position to current position */ private extractToken(start: number): string { const result = this.input.slice(start, this.pos); if (result) { this.lastParsedToken = result; this.lastParsedTokenStart = start; this.lastParsedTokenEnd = this.pos; } return result; } private match(expected: string): boolean { const result = this.peek() === expected; if (result) { this.next(); } return result; } private skipWhitespace(): void { while (this.peek() === ' ' || this.peek() === '\t' || this.peek() === '\n') { this.next(); } } private finishElement(): void { if (!this.currentElement) { throw new ParserError('Current element is undefined', this.pos, this.expectedTokens, this.currentContext); } this.currentElement.validate(this.pos, this.expectedTokens, this.currentContext); this.parsedSoFar.elements.push(this.currentElement as unknown as PatternElement); this.currentElement = undefined; this.expectedTokens = ExpectedToken.OPEN_BRACE | ExpectedToken.OPEN_BRACKET; this.currentContext = ParserContext.ARBITRARY; } private finishGlobalSettings(): void { if (!this.currentElement) { throw new ParserError('Current element is undefined', this.pos, this.expectedTokens, this.currentContext); } this.parsedSoFar.globalSettings = this.currentElement as unknown as GlobalSettings; this.currentElement = undefined; this.expectedTokens = ExpectedToken.NONE; this.currentContext = ParserContext.ARBITRARY; } /// Store arbitrary text chunk to parsed so far private storeTextChunk(start: number): void { this.parsedSoFar.textChunks.push(this.input.slice(start, this.pos)); } private skipArbitrary(): void { // need to check for escaped characters const start = this.pos; while (true) { while (!this.isEof() && !NON_ARBITRARY_CHARS.has(this.peek() || '')) { this.next(); } if (this.isEof()) { break; } if (this.peek() === '\\') { this.next(); if (this.isEof()) { this.storeTextChunk(start) this.expectedTokens = ExpectedToken.ESCAPED_CHARACTER; return; } if (ESCAPED_CHARS.has(this.peek() || '')) { // valid escape sequence this.next(); continue; } throw new ParserError('Invalid escape sequence', this.pos, ExpectedToken.ESCAPED_CHARACTER, this.currentContext); } else { break; } } this.storeTextChunk(start) } private parseIdentifier(): string { const start = this.pos; if (this.isEof()) { // we don't throw in partial parsing, except for invalid input return ''; } // the first character must be a letter or underscore if (!/[A-Za-z_]/.test(this.peek() || '')) { return ''; } while (!this.isEof() && /[A-Za-z0-9_]/.test(this.peek() || '')) { this.next(); } return this.extractToken(start); } private parseTag(): string { const start = this.pos; if (this.isEof()) { // we don't throw in partial parsing, except for invalid input return ''; } while (!this.isEof() && /[A-Za-z0-9_]/.test(this.peek() || '')) { this.next(); } return this.extractToken(start); } private parseNumber(): number { const start = this.pos; while (!this.isEof() && /[0-9]/.test(this.peek() || '')) { this.next(); } const result = this.extractToken(start); return parseInt(result); } private parseRangeOrSingle(): RangeOrSingle | undefined { this.expectedTokens = ExpectedToken.NUMBER; if (this.isEof()) { return undefined; } let min = 0; let max = 0; min = this.parseNumber(); this.expectedTokens = ExpectedToken.DASH | ExpectedToken.CLOSE_BRACE; // need more tokens depending on the context if (this.isEof()) { return { min, max: min }; } if (this.match('-')) { this.expectedTokens = ExpectedToken.NUMBER; if (this.isEof()) { return undefined; } max = this.parseNumber(); } else { max = min; } this.expectedTokens = ExpectedToken.CLOSE_BRACE; return { min, max }; } private parseBoolean(): boolean | undefined { this.expectedTokens = ExpectedToken.BOOLEAN; if (this.isEof()) { return undefined; } const value = this.parseIdentifier(); if (!value) { return undefined; } if (value === 'true') { return true; } if (value === 'false') { return false; } throw new ParserError('Expected boolean', this.pos, ExpectedToken.BOOLEAN, this.currentContext); } private parseStringLiteral(): string | undefined { this.expectedTokens = ExpectedToken.STRING; if (this.isEof()) { return undefined; } const start = this.pos; // grab anythins expcept whitespace or } while (!this.isEof() && !/\s|\}/.test(this.peek() || '')) { this.next(); } const value = this.extractToken(start); return value || undefined; } /// Returns undefined if we don't have a number base private parseNumberBase(): NumberBase | undefined { // if we have a comma, then we parse the full number base if (this.match(',')) { this.expectedTokens = ExpectedToken.NUMBER_BASE_FULL; if (this.isEof()) { return undefined; } const identifier = this.parseIdentifier(); if (!identifier) { throw new ParserError('Expected number base', this.pos, ExpectedToken.NUMBER_BASE_FULL, this.currentContext); } this.expectedTokens = ExpectedToken.CLOSE_BRACE; const base = NumberBase.fromString(identifier); if (!base) { throw new ParserError('Invalid number base', this.pos, ExpectedToken.NUMBER_BASE_FULL, this.currentContext); } return base; } else { // match short number base if (!/[dxXrR]/.test(this.peek() || '')) { this.expectedTokens = ExpectedToken.CLOSE_BRACE; return NumberBase.Dec; } const base = NumberBase.fromShort(this.peek() as ShortNumberBase); this.next(); this.expectedTokens = ExpectedToken.CLOSE_BRACE; return base; } } private parseLanguage(): string | undefined { this.expectedTokens = ExpectedToken.LANGUAGE; if (this.isEof()) { return undefined; } const language = this.parseIdentifier(); if (!language) { throw new ParserError('Expected language', this.pos, ExpectedToken.LANGUAGE, this.currentContext); } return language; } private parseComparisonOp(): CompareOperator | undefined { if (this.isEof()) { return undefined; } if (!COMPARISON_START_CHARS.has(this.peek() || '')) { return undefined; } let op = this.peek() || ''; this.next(); if (REQUIRE_EQUAL_CHARS.has(op)) { this.expectedTokens = ExpectedToken.EQUALS; if (this.isEof()) { return undefined; } if (!this.match('=')) { throw new ParserError('Expected = after comparison operator', this.pos, ExpectedToken.EQUALS, this.currentContext); } op += '='; this.expectedTokens = ExpectedToken.NUMBER; return op as CompareOperator; } else { this.expectedTokens = ExpectedToken.EQUALS | ExpectedToken.NUMBER; if (this.isEof()) { return op as CompareOperator; } this.expectedTokens = ExpectedToken.NUMBER; if (this.match('=')) { op += '='; return op as CompareOperator; } return op as CompareOperator; } } private parseSizeLimit(): SizeLimit | undefined { if (this.isEof()) { return undefined; } this.currentContext |= ParserContext.SIZE_LIMIT; const op = this.parseComparisonOp(); if (!op) { if (!(this.expectedTokens & ExpectedToken.EQUALS)) { // we expect the comparison op to be fully parsed this.currentContext &= ~ParserContext.SIZE_LIMIT; } return undefined; } if (this.isEof()) { return undefined; } const value = this.parseNumber(); this.currentContext &= ~ParserContext.SIZE_LIMIT; return { op, value }; } private parseTags(element: TaggedElement): void { this.skipWhitespace() while (!this.isEof()) { let tagType: string | null = null; if (this.peek() == '+' || this.peek() == '-') { this.currentContext |= ParserContext.TAGS; tagType = this.peek(); this.next(); this.expectedTokens = ExpectedToken.TAG; if (this.isEof()) { return; } const tag = this.parseTag(); if (!tag) { return; } // now search if we have full match for the tag const searchResult = this.partialTagSearch(element.kind, tag); if (searchResult.fullMatch) { if (tagType == '+') { element.includeTags.push(tag); } else { element.excludeTags.push(tag); } this.currentContext &= ~ParserContext.TAGS; if (searchResult.suggestions.length === 0) { // We've got a full match, no more tag options available for this search // Clear lastParsedToken so option suggestions start fresh this.lastParsedToken = ''; // But we should still allow adding more tags with + or - if (isTaggedElement(element)) { this.expectedTokens = element.exectedTagTokens(); } } else { // We've got additional suggestions, so we can continue parsing this tag name or start new ones if (isTaggedElement(element)) { this.expectedTokens = element.exectedTagTokens() | ExpectedToken.TAG; } } } else { // We've got a partial match, so we need to continue parsing } } else { break; } this.skipWhitespace(); if (this.isEof() || this.isEndOfPlaceholder()) { return; } } } private parseOptions(element: OptionedElement): void { while (!this.isEof()) { this.skipWhitespace(); this.currentContext |= ParserContext.OPTIONS; if (isOptionedElement(element)) { this.expectedTokens = element.exectedOptionTokens(); } const option = this.parseIdentifier(); if (!option) { // finished parsing options break; } const optionSearch = element.getOptionSuggestions(option); if (optionSearch.fullMatch) { // first we check if the option is not already there if (option in element.options) { throw new ParserError('Option already set', this.pos, ExpectedToken.OPTION, this.currentContext); } // we've got an option full name this.expectedTokens = ExpectedToken.EQUALS; if (this.isEof()) { return; } if (!this.match('=')) { throw new ParserError('Expected = after option name', this.pos, ExpectedToken.EQUALS, this.currentContext); } // now we need to grab the option type const optionType = element.getOptionType(option); if (!optionType) { throw new ParserError('Invalid option', this.pos, ExpectedToken.OPTION, this.currentContext); } // now we need to parse the option value switch (optionType) { case OptionType.RANGE_OR_SINGLE: { const range = this.parseRangeOrSingle(); if (!range) { // we can run out of input, so we break return; } element.options[option] = rangeToString(range); continue; } case OptionType.BOOLEAN: { const value = this.parseBoolean(); if (value === undefined) { // we can run out of input, so we break return; } element.options[option] = `${value}`; continue; } default: { const value = this.parseStringLiteral(); if (value === undefined) { // we can run out of input, so we break return; } element.options[option] = value; continue; } } } else if (optionSearch.suggestions.length > 0) { // we've got an option partial name this.expectedTokens = ExpectedToken.OPTION; return; } else { // we've got an invalid option throw new ParserError('Invalid option', this.pos, ExpectedToken.OPTION, this.currentContext); } } this.currentContext &= ~ParserContext.OPTIONS; } /// Parse number generator private parseNumberGen(): void { this.currentElement = new NumberGenClass(0, NumberBase.Dec); this.expectedTokens = ExpectedToken.COLON; this.currentContext = ParserContext.IN_PLACEHOLDER | ParserContext.NUMBER_GEN; this.skipWhitespace(); if (this.isEof()) { return; } if (!this.match(':')) { throw new ParserError('Expected : after number generator name', this.pos, ExpectedToken.COLON, this.currentContext); } this.expectedTokens = ExpectedToken.NUMBER; this.currentContext |= ParserContext.SETTING_OPTIONS; this.skipWhitespace(); if (this.isEof()) { return; } const number = this.parseNumber(); if (number === 0) { throw new ParserError('Number must be greater than 0', this.pos, ExpectedToken.NUMBER, this.currentContext); } if (isNumberGenClass(this.currentElement)) { this.currentElement.maxLength = number; } this.expectedTokens = ExpectedToken.NUMBER_BASE_SHORT | ExpectedToken.COMMA | ExpectedToken.CLOSE_BRACE; if (this.isEof()) { return; } const numberBase = this.parseNumberBase(); if (numberBase && isNumberGenClass(this.currentElement)) { this.currentElement.base = numberBase; } else { // nothing parsed if (isNumberGenClass(this.currentElement)) { this.currentElement.base = NumberBase.Dec; } // we don't change the expected tokens, since we don't have a number base // but we can default to dec } this.skipWhitespace(); if (this.isEof()) { return; } if (this.expectedTokens !== ExpectedToken.CLOSE_BRACE) { // we are expecting something else, e.g. number base full, that failed to parse throw new ParserError('Expected number base after comma', this.pos, this.expectedTokens, this.currentContext); } if (!this.match('}')) { throw new ParserError('Expected `}` after number generator', this.pos, ExpectedToken.CLOSE_BRACE, this.currentContext); } // we are done parsing the number generator this.finishElement(); } private parseSpecialGen(): void { this.currentElement = new SpecialCharGenClass(0, 0); this.expectedTokens = ExpectedToken.COLON | ExpectedToken.CLOSE_BRACE; this.currentContext = ParserContext.IN_PLACEHOLDER | ParserContext.SPECIAL_GEN; this.skipWhitespace(); if (this.isEof()) { return; } if (this.isEndOfPlaceholder()) { if (isSpecialCharGenClass(this.currentElement)) { this.currentElement.minLength = 1; this.currentElement.maxLength = 1; } this.next(); this.finishElement(); return; } if (!this.match(':')) { throw new ParserError('Expected `:` after special generator name', this.pos, ExpectedToken.COLON, this.currentContext); } this.expectedTokens = ExpectedToken.NUMBER; // we can apply default of 1-1 this.currentContext |= ParserContext.SETTING_OPTIONS; this.skipWhitespace(); if (this.isEof()) { return; } const range = this.parseRangeOrSingle(); if (!range) { // we can run out of input, so we break return; } if (isSpecialCharGenClass(this.currentElement)) { this.currentElement.minLength = range.min; this.currentElement.maxLength = range.max; } // if (range.min != range.max) { // // reset expected tokens to close brace // this.expectedTokens = ExpectedToken.CLOSE_BRACE; // } if (this.isEof()) { return; } if (!this.match('}')) { throw new ParserError('Expected `}` after special generator', this.pos, ExpectedToken.CLOSE_BRACE, this.currentContext); } // we are done parsing the special generator this.finishElement(); } private parseEmojiGen(): void { this.currentElement = new EmojiGenClass("emoji", [], [], {}); this.expectedTokens = ExpectedToken.COLON | ExpectedToken.CLOSE_BRACE; this.currentContext = ParserContext.IN_PLACEHOLDER | ParserContext.EMOJI_GEN; this.skipWhitespace(); if (this.isEof()) { return; } if (this.isEndOfPlaceholder()) { this.next(); this.finishElement(); return; } if (!this.match(':')) { throw new ParserError('Expected `:` after emoji generator name', this.pos, ExpectedToken.COLON, this.currentContext); } this.expectedTokens = ExpectedToken.TAG_START | ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACE; this.currentContext |= ParserContext.SETTING_OPTIONS; this.skipWhitespace(); if (this.isEof()) { return; } if (isTaggedElement(this.currentElement)) { this.parseTags(this.currentElement); } if (this.isEof()) { return; } if (isOptionedElement(this.currentElement)) { this.parseOptions(this.currentElement); } if (!(this.currentContext & ParserContext.OPTIONS)) { this.expectedTokens = ExpectedToken.CLOSE_BRACE; } this.skipWhitespace(); if (this.isEof()) { return; } if (!this.match('}')) { throw new ParserError('Expected `}` after emoji generator', this.pos, ExpectedToken.CLOSE_BRACE, this.currentContext); } this.finishElement(); } private parseSelector(): void { this.currentElement = new SelectorClass(this.lastParsedToken || '', undefined, [], [], undefined, {}); this.expectedTokens = ExpectedToken.COLON | ExpectedToken.AT_SIGN | ExpectedToken.CLOSE_BRACE; this.currentContext = ParserContext.IN_PLACEHOLDER | ParserContext.SELECTOR; if (this.isEof()) { return; } if (this.match('@')) { const language = this.parseLanguage(); if (!language) { // we ran out of input, so we break return; } if (isSelectorClass(this.currentElement)) { this.currentElement.language = language; } this.expectedTokens = ExpectedToken.COLON | ExpectedToken.CLOSE_BRACE; } this.skipWhitespace(); if (this.isEof()) { return; } if (this.isEndOfPlaceholder()) { this.next(); this.finishElement(); return; } if (!this.match(':')) { throw new ParserError('Expected `:` after selector name', this.pos, ExpectedToken.COLON, this.currentContext); } this.currentContext |= ParserContext.SETTING_OPTIONS; if (isSelectorClass(this.currentElement) || isEmojiGenClass(this.currentElement) || isGlobalSettingsClass(this.currentElement)) { this.expectedTokens = this.currentElement.exectedTagTokens(); } if (this.isEof()) { return; } if (isTaggedElement(this.currentElement)) { this.parseTags(this.currentElement); } if (!(this.currentContext & ParserContext.TAGS)) { // Preserve TAG and TAG_START bits if they were set (indicates tag completions/additions available) const hadTagExpected = this.expectedTokens & ExpectedToken.TAG; const hadTagStartExpected = this.expectedTokens & ExpectedToken.TAG_START; this.expectedTokens = ExpectedToken.COMPARISON_OP | ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACE; if (hadTagExpected) { this.expectedTokens |= ExpectedToken.TAG; } if (hadTagStartExpected) { this.expectedTokens |= ExpectedToken.TAG_START; } } this.skipWhitespace(); if (this.isEof()) { return; } const sizeLimit = this.parseSizeLimit(); if (sizeLimit) { if (isSelectorClass(this.currentElement) || isGlobalSettingsClass(this.currentElement)) { this.currentElement.sizeLimit = sizeLimit; } } if (!(this.currentContext & ParserContext.SIZE_LIMIT)) { if (isSelectorClass(this.currentElement) || isEmojiGenClass(this.currentElement) || isGlobalSettingsClass(this.currentElement)) { this.expectedTokens = this.currentElement.exectedOptionTokens(); } } this.skipWhitespace(); if (this.isEof()) { return; } if (isOptionedElement(this.currentElement)) { this.parseOptions(this.currentElement); } if (!(this.currentContext & ParserContext.OPTIONS)) { this.expectedTokens = ExpectedToken.CLOSE_BRACE; } this.skipWhitespace(); if (this.isEof()) { return; } if (!this.match('}')) { throw new ParserError('Expected `}` after selector', this.pos, ExpectedToken.CLOSE_BRACE, this.currentContext); } this.finishElement(); } private parsePlaceholder(): void { if (this.parsedSoFar.globalSettings) { throw new ParserError('Unexpected placeholder after global settings', this.pos, ExpectedToken.GENERATOR, this.currentContext); } this.currentContext = ParserContext.IN_PLACEHOLDER; this.expectedTokens = ExpectedToken.GENERATOR; this.next(); this.skipWhitespace(); if (this.isEof()) { return; } const identifier = this.parseIdentifier(); if (identifier === '') { return; } if (identifier === PredefinedGenerators.NUMBER) { this.parseNumberGen(); } else if (identifier === PredefinedGenerators.SPECIAL) { this.parseSpecialGen(); } else if (identifier === PredefinedGenerators.EMOJI) { this.parseEmojiGen(); } else { if (this.dictionaryNames.includes(identifier.toLowerCase())) { this.parseSelector(); } else { this.currentContext |= ParserContext.PARTIAL_GENERATOR_NAME; } } } private parseGlobalOptions(): void { if (this.parsedSoFar.elements.length == 0) { throw new ParserError('Unexpected global settings', this.pos, ExpectedToken.OPEN_BRACKET, this.currentContext); } this.currentElement = new GlobalSettingsClass(undefined, [], [], undefined, {}); this.currentContext = ParserContext.IN_GLOBAL_SETTINGS; this.expectedTokens = ExpectedToken.AT_SIGN | ExpectedToken.TAG_START | ExpectedToken.COMPARISON_OP | ExpectedToken.OPTION | ExpectedToken.CLOSE_BRACKET; this.next(); this.skipWhitespace(); if (this.isEof()) { return; } if