cm-tarnation
Version:
An alternative parser for CodeMirror 6
158 lines (140 loc) • 5.63 kB
text/typescript
/* 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
}