cm-tarnation
Version:
An alternative parser for CodeMirror 6
242 lines (205 loc) • 7.59 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 {
continuedIndent,
delimitedIndent,
flatIndent,
foldInside,
foldNodeProp,
indentNodeProp,
TreeIndentContext
} from "@codemirror/language"
import type { EditorState } from "@codemirror/state"
import { NodeProp, NodePropSource, NodeType, SyntaxNode } from "@lezer/common"
import { styleTags, Tag, tags } from "@lezer/highlight"
import { createID, EmbeddedParserProp, re } from "./../util"
import type * as DF from "./definition"
export const NodeTypeProp = new NodeProp<Node>()
/** Effectively a light wrapper around a CodeMirror `NodeType`. */
export class Node {
/** The unique ID for this node. */
declare id: number
/** The name of this node, which may be different to what it emits in the AST. */
declare name: string
/** The `NodeType` used by CodeMirror. */
declare type: NodeType
/** The name of an autocomplete handler for this node, if any. */
declare autocomplete?: string
/** @param id - The ID to assign to this node. */
constructor(
id: number,
{
type,
emit,
tag,
openedBy,
closedBy,
group,
nest,
fold,
indent,
autocomplete
}: DF.Node
) {
if (!type) {
if (!autocomplete || typeof autocomplete === "boolean") {
throw new Error("Node name/type is required")
}
type = createID(autocomplete)
}
if (emit === false) throw new Error("Node cannot be emitted")
this.id = id
this.name = type
if (autocomplete) {
if (typeof autocomplete === "boolean") autocomplete = type
this.autocomplete = autocomplete
}
if (typeof emit !== "string") emit = type
const props: NodePropSource[] = []
props.push(NodeTypeProp.add({ [emit]: this }))
// prettier-ignore
{
if (tag) props.push(styleTags(parseTag(emit, tag)))
if (nest) props.push(EmbeddedParserProp.add({ [emit]: nest }))
if (openedBy) props.push(NodeProp.openedBy .add({ [emit]: [openedBy].flat() }))
if (closedBy) props.push(NodeProp.closedBy .add({ [emit]: [closedBy].flat() }))
if (group) props.push(NodeProp.group .add({ [emit]: [group].flat() }))
if (fold) props.push(foldNodeProp .add({ [emit]: parseFold(fold) }))
if (indent) props.push(indentNodeProp .add({ [emit]: parseIndent(indent) }))
}
this.type = NodeType.define({ id, name: emit, props })
}
/** Special `Node` used for when a rule doesn't emit anything. */
static None = new Node(-1, { type: "_none", emit: "None" })
}
/**
* 1. Tag modifier text
* 2. Tag function name
* 3. Tag function argument
* 4. Tag name, no function
*/
const PARSE_TAG_REGEX = /^(?:\((\S*?)\))?(?:\s+|^)(?:(?:(\S+?)\((\S+)\))|(\S+))$/
/**
* Parses a tag string, and converts it into an object that can be fed into
* CodeMirror's `styleTags` function.
*
* Examples:
*
* ```text
* tag
* func(tag)
* (!) tag
* (!) func(tag)
* (...) tag
* (...) func(tag)
* (parent/) tag
* (parent/) func(tag)
* (grandparent/parent) tag
* (grandparent/parent) func(tag)
* ```
*/
function parseTag(node: string, str: DF.Tag) {
const [, modifier, func, arg, last] = PARSE_TAG_REGEX.exec(str)!
if (last && !(last in tags)) throw new Error(`Unknown tag: ${last}`)
if (func && !(func in tags)) throw new Error(`Unknown tag function: ${func}`)
if (arg && !(arg in tags)) throw new Error(`Unknown tag argument: ${arg}`)
let name = arg ? arg : last
let prefix = ""
let suffix = ""
// @ts-ignore TS doesn't realize I've checked for this
let tag: Tag = tags[name]
// @ts-ignore ditto
if (func) tag = tags[func](tag)
if (modifier) {
if (modifier.endsWith("...")) suffix = "/..."
if (modifier.endsWith("!")) suffix = "!"
if (modifier.endsWith("/")) prefix = modifier
// check for parents
else {
const split = modifier.split("/")
const last = split[split.length - 1]
if (last === "..." || last === "!") split.pop()
if (split.length) prefix = `${split.join("/")}/`
}
}
// e.g. foo/... or foo/bar/... etc.
const style = `${prefix}${node}${suffix}`
return { [style]: tag }
}
/**
* `offset(n n)`, `offset(-2 -5)`, `offset(+1 2)`, `offset(0 0)`, etc.
*
* 1. Left offset
* 2. Right offset
*/
const PARSE_OFFSET_FOLD_REGEX = /^offset\(([+-]?\d+),\s+([+-]?\d+)\)$/
/** Parses a `fold` string, returning a CodeMirror `foldNodeProp` compatible function. */
function parseFold(
fold: true | string
): (node: SyntaxNode, state: EditorState) => { from: number; to: number } | null {
// prettier-ignore
switch (fold) {
// folds entire node
case true: return node => ({ from: node.from, to: node.to })
// folds between two delimiters, which are the first and last child
case "inside": return foldInside
// folds everything past the first-ish line
case "past_first_line": return (node, state) => ({
from: Math.min(node.from + 20, state.doc.lineAt(node.from).to),
to: node.to - 1
})
// like the "true" case, except with an offset
// (or the fold string is invalid)
default: {
if (fold.startsWith("offset")) {
const match = PARSE_OFFSET_FOLD_REGEX.exec(fold)
if (!match) throw new Error("Invalid fold offset")
const left = parseInt(match[1], 10)
const right = parseInt(match[2], 10)
return node => ({ from: node.from + left, to: node.to + right })
} else {
throw new Error(`Unknown fold option: ${fold}`)
}
}
}
}
/** 1. Closing */
const PARSE_DELIMITED_INDENT_REGEX = /^delimited\((.+?)\)$/
/** 1. Except Regex */
const PARSE_CONTINUED_INDENT_REGEX = /^continued(?:\((.+?)\))?$/
/** 1. Units */
const PARSE_ADD_INDENT_REGEX = /^add\(([+-]?\d+)\)$/
/** 1. Units */
const PARSE_SET_INDENT_REGEX = /^set\(([+-]?\d+)\)$/
/** Parses an indent string, returning a `indentNodeProp` compatible function. */
function parseIndent(indent: string): (context: TreeIndentContext) => number {
if (indent === "flat") return flatIndent
if (indent === "continued") return continuedIndent()
if (indent.startsWith("delimited")) {
const match = PARSE_DELIMITED_INDENT_REGEX.exec(indent)
if (!match) throw new Error("Invalid delimited indent")
const [, closing] = match
return delimitedIndent({ closing })
}
if (indent.startsWith("continued")) {
const match = PARSE_CONTINUED_INDENT_REGEX.exec(indent)
if (!match) throw new Error("Invalid continued indent")
const except = re(match[1])
if (!except) throw new Error("Invalid continued indent except regex")
return continuedIndent({ except })
}
if (indent.startsWith("add")) {
const match = PARSE_ADD_INDENT_REGEX.exec(indent)
if (!match) throw new Error("Invalid add indent")
const units = parseInt(match[1], 10)
return cx => cx.baseIndent + cx.unit * units
}
if (indent.startsWith("set")) {
const match = PARSE_SET_INDENT_REGEX.exec(indent)
if (!match) throw new Error("Invalid set indent")
const units = parseInt(match[1], 10)
return () => units
}
throw new Error(`Unknown indent option: ${indent}`)
}