UNPKG

cm-tarnation

Version:

An alternative parser for CodeMirror 6

158 lines (140 loc) 5.63 kB
/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import type { Matcher, VariableTable } from "../../types" import { escapeRegExp, hasCapturingGroups, isRegExpString, re } from "../../util" /** * `RegExp` wrapper class. Designed to improve performance by implementing * certain operations (like matching) with edge case handling and short * circuits. In order to assist with this goal, the internal `RegExp` is * always set to be `sticky`, so this class won't work if you want to use a * non-sticky `RegExp`. */ export class RegExpMatcher implements Matcher { /** * Internal `RegExp`. Can be null, which is so that even a malformed * input `RegExp` source won't throw. This is used to combat inconsistent * browser behavior. */ private declare regexp: RegExp /** * True if the source `RegExp` has an capturing groups. Used to short * circuit the `match` method's behavior, and improve performance. */ private declare hasCapturingGroups: boolean /** * @param src - The source `RegExp` to wrap. * @param ignoreCase - If `true`, the ignore case flag is set automatically. * @param variables - A variable table to use when expanding variables. */ constructor(src: string, ignoreCase = false, variables?: VariableTable) { const flags = ignoreCase ? "iymu" : "ymu" if (variables) src = expandVariables(src, variables) const regexp = re(src, flags) if (!regexp) throw new Error(`Invalid RegExp: ${src}`) this.regexp = regexp this.hasCapturingGroups = this.regexp ? hasCapturingGroups(this.regexp) : false } /** * Performs the standard `RegExp.test` operation on a string. * * @param str - The string to match. * @param pos - The position to start matching at. */ test(str: string, pos: number) { this.regexp.lastIndex = pos return this.regexp.test(str) } /** * Performs the standard `RegExp.exec` operation on a string. This is an * internal method because consumers should use the `match` method instead. * * @param str - The string to match. * @param pos - The position to start matching at. */ private exec(str: string, pos: number) { this.regexp.lastIndex = pos return this.regexp.exec(str) } /** * Returns the results of attempting to match a string against this * regular expression. * * @param str - The string to match. * @param pos - The position to start matching at. */ match(str: string, pos: number) { // if we don't have any capturing groups, we can cheat a little bit, // and only use the `test` method, which is much faster if (!this.hasCapturingGroups) { if (this.test(str, pos)) { // regexp remembers where it ended up, so we can use that to figure // out where the match ended const length = this.regexp!.lastIndex - pos const total = str.slice(pos, pos + length) return { total, captures: null, length } } return null } // this dumb looking code is actually faster than just calling `exec` by itself // I don't really know why - I guess because `test` is much faster than `exec` // and most of the time a regexp doesn't match anything, so it's not worth // trying a full match first if (this.test(str, pos)) { const match = this.exec(str, pos) if (match) { const total = match[0] const captures = match.length > 1 ? match.slice(1) : null const length = total.length // capturing groups can be undefined if an entire group was marked as optional // so we'll replace those with an empty string to keep the interface consistent if (captures) { let len = 0 for (let i = 0; i < captures.length; i++) { if (captures[i] === undefined) captures[i] = "" len += captures[i].length } // tarnation can only accept regexps that have their capturing groups // consist of contigous ranges covering the entirety of the match if (len !== length) throw new Error("Invalid capturing group lengths") } return { total, captures, length } } } return null } } /** * Expands the variables in a regex source using a variable table. * * @param src - The source to expand. * @param variables - The variable table to use. */ function expandVariables(src: string, variables: VariableTable) { let depth = 0 while (/@\w/.test(src) && depth < 5) { depth++ src = src.replace(/@(\w+)/g, (_, ident: string) => { const value = variables[ident] if (value === undefined) { throw new Error(`Variable ${ident} not found`) } else if (typeof value === "object" && "test" in value) { throw new Error(`Variable ${ident} is a Matcher`) } if (typeof value === "string" && isRegExpString(value)) { const regexp = re(value) if (!regexp) throw new Error(`Variable ${ident} is an invalid regex`) return `(?:${regexp.source})` } // not regex string const variable = typeof value === "string" ? escapeRegExp(value) : value instanceof RegExp ? value.source : value.map(str => escapeRegExp(str)).join("|") return `(?:${variable})` }) } return src }