first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import { SyntaxNode, Tree } from '@lezer/common'
import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs'
const HUNDRED_MS = 100
export type AncestorItem = {
node: SyntaxNode
label: string
type?: string
from: number
to: number
}
/**
* Get the stack of 'ancestor' nodes at the given position.
* The first element is the most distant ancestor, while the last element
* is the node at the position.
*/
export function getAncestorStack(
state: EditorState,
pos: number
): AncestorItem[] | null {
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
if (!tree) {
return null
}
const stack: AncestorItem[] = []
const selectedNode = tree.resolve(pos, 0)
let node: SyntaxNode | null = selectedNode
while (node) {
const name = node.type.name
switch (name) {
case 'Environment':
{
const data: AncestorItem = {
node,
label: name,
from: node.from,
to: node.to,
}
const child = node.getChild('EnvNameGroup')
if (child) {
data.type = state.doc.sliceString(child.from + 1, child.to - 1)
}
stack.push(data)
}
break
default:
stack.push({ node, label: name, from: node.from, to: node.to })
break
}
node = node.parent
}
return stack.reverse()
}
export const wrappedNodeOfType = (
state: EditorState,
range: SelectionRange,
type: string | number
): SyntaxNode | null => {
if (range.empty) {
return null
}
const ancestorNode = ancestorNodeOfType(state, range.from, type, 1)
if (
ancestorNode &&
ancestorNode.from === range.from &&
ancestorNode.to === range.to
) {
return ancestorNode
}
return null
}
export const ancestorNodeOfType = (
state: EditorState,
pos: number,
type: string | number,
side: -1 | 0 | 1 = 0
): SyntaxNode | null => {
const node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, side)
return ancestorOfNodeWithType(node, type)
}
export function* ancestorsOfNodeWithType(
node: SyntaxNode | null,
type: string | number
): Generator<SyntaxNode> {
for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
if (ancestor.type.is(type)) {
yield ancestor
}
}
}
export const ancestorOfNodeWithType = (
node: SyntaxNode | null | undefined,
...types: (string | number)[]
): SyntaxNode | null => {
for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
for (const type of types) {
if (ancestor.type.is(type)) {
return ancestor
}
}
}
return null
}
export const lastAncestorAtEndPosition = (
node: SyntaxNode | null | undefined,
to: number
): SyntaxNode | null => {
let lastAncestor: SyntaxNode | null = null
for (
let ancestor = node;
ancestor && ancestor.to === to;
ancestor = ancestor.parent
) {
lastAncestor = ancestor
}
return lastAncestor
}
export const descendantsOfNodeWithType = (
node: SyntaxNode,
type: string | number,
leaveType?: string | number
): SyntaxNode[] => {
const children: SyntaxNode[] = []
node.cursor().iterate(nodeRef => {
if (nodeRef.type.is(type)) {
children.push(nodeRef.node)
}
if (leaveType && nodeRef.type.is(leaveType) && nodeRef.node !== node) {
return false
}
})
return children
}
export const getBibkeyArgumentNode = (state: EditorState, pos: number) => {
return (
ancestorNodeOfType(state, pos, 'BibKeyArgument', -1) ??
ancestorNodeOfType(state, pos, 'BibKeyArgument')
)
}
export function* ancestorsOfSelectionWithType(
tree: Tree,
selection: EditorSelection,
type: string | number
) {
for (const range of selection.ranges) {
const node = tree.resolveInner(range.anchor)
for (const ancestor of ancestorsOfNodeWithType(node, type)) {
if (ancestor) {
yield ancestor
}
}
}
}
export const matchingAncestor = (
node: SyntaxNode,
predicate: (node: SyntaxNode) => boolean
) => {
for (
let ancestor: SyntaxNode | null | undefined = node;
ancestor;
ancestor = ancestor.parent
) {
if (predicate(ancestor)) {
return ancestor
}
}
return null
}
export const ancestorWithType = (
state: EditorState,
nodeType: string | number
) => {
const tree = syntaxTree(state)
const ancestors = ancestorsOfSelectionWithType(
tree,
state.selection,
nodeType
)
return ancestors.next().value
}
export const commonAncestor = (
nodeA: SyntaxNode,
nodeB: SyntaxNode
): SyntaxNode | null => {
let cursorA: SyntaxNode | null = nodeA
let cursorB: SyntaxNode | null = nodeB
while (cursorA && cursorB) {
if (cursorA === cursorB) {
return cursorA
}
if (cursorA.from < cursorB.from) {
cursorB = cursorB.parent
} else {
cursorA = cursorA.parent
}
}
return null
}
export type ListEnvironmentName = 'itemize' | 'enumerate' | 'description'
export const listDepthForNode = (node: SyntaxNode) => {
let depth = 0
for (const ancestor of ancestorsOfNodeWithType(node, ListEnvironment)) {
if (ancestor) {
depth++
}
}
return depth
}
export const minimumListDepthForSelection = (state: EditorState) => {
const depths = []
for (const range of state.selection.ranges) {
const tree = syntaxTree(state)
const node = tree.resolveInner(range.anchor)
depths.push(listDepthForNode(node))
}
return Math.min(...depths)
}
export const isDirectChildOfEnvironment = (
child?: SyntaxNode | null,
ancestor?: SyntaxNode | null
) => {
const possiblyAncestor = child?.parent?.parent?.parent // Text → Content → Environment
return ancestor === possiblyAncestor
}

View File

@@ -0,0 +1,58 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import {
LongArg,
ShortArg,
ShortTextArgument,
TextArgument,
} from '../../lezer-latex/latex.terms.mjs'
// basic color definitions from the xcolor package
// https://github.com/latex3/xcolor/blob/849682246582946835d28c8f9b2081ff2c340e09/xcolor.dtx#L7051-L7093
const colors = new Map<string, string>([
['red', 'rgb(255,0,0)'],
['green', 'rgb(0,255,0)'],
['blue', 'rgb(0,0,255)'],
['brown', 'rgb(195,127,63)'],
['lime', 'rgb(195,255,0)'],
['orange', 'rgb(255,127,0)'],
['pink', 'rgb(255,195,195)'],
['purple', 'rgb(195,0,63)'],
['teal', 'rgb(0,127,127)'],
['violet', 'rgb(127,0,127)'],
['cyan', 'rgb(0,255,255)'],
['magenta', 'rgb(255,0,255)'],
['yellow', 'rgb(255,255,0)'],
['olive', 'rgb(127,127,0)'],
['black', 'rgb(0,0,0)'],
['darkgray', 'rgb(63,63,63)'],
['gray', 'rgb(127,127,127)'],
['lightgray', 'rgb(195,195,195)'],
['white', 'rgb(255,255,255)'],
])
export const parseColorArguments = (
state: EditorState,
node: SyntaxNode
): { color: string; from: number; to: number } | undefined => {
const colorArgumentNode = node.getChild(ShortTextArgument)?.getChild(ShortArg)
const contentArgumentNode = node.getChild(TextArgument)?.getChild(LongArg)
if (colorArgumentNode && contentArgumentNode) {
const { from, to } = contentArgumentNode
if (to > from) {
const colorName = state
.sliceDoc(colorArgumentNode.from, colorArgumentNode.to)
.trim()
if (colorName) {
const color = colors.get(colorName)
if (color) {
return { color, from, to }
}
}
}
}
}

View File

@@ -0,0 +1,211 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
import { childOfNodeWithType, getOptionalArgumentText } from './common'
import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
/**
* A projection of a command in the document
*/
export class Command extends ProjectionItem {
readonly title: string = ''
readonly optionalArgCount: number | undefined = 0
readonly requiredArgCount: number | undefined = 0
readonly type: 'usage' | 'definition' = 'usage'
readonly raw: string | undefined = undefined
readonly ignoreInAutocomplete?: boolean = false
}
const getCommandName = (
node: SyntaxNode,
state: EditorState,
childTypes: string[]
): string | null => {
const child = childOfNodeWithType(node, ...childTypes)
if (child) {
const commandName = state.doc.sliceString(child.from, child.to)
if (commandName.length > 0) {
return commandName
}
}
return null
}
/**
* Extracts Command instances from the syntax tree.
* `\newcommand`, `\renewcommand`, `\newenvironment`, `\renewenvironment`
* and `\def` are treated specially.
*/
export const enterNode = (
state: EditorState,
node: SyntaxNodeRef,
items: Command[],
nodeIntersectsChange: NodeIntersectsChangeFn
): any => {
if (node.type.is('NewCommand') || node.type.is('RenewCommand')) {
if (!nodeIntersectsChange(node.node)) {
// This should already be in `items`
return
}
const commandName = getCommandName(node.node, state, [
'LiteralArgContent',
'Csname',
])
if (commandName === null) {
return
}
const optionalArguments = node.node.getChildren('OptionalArgument')
let argCountNumber = 0
if (optionalArguments.length > 0) {
const argumentCountNode = optionalArguments[0]
const argCountText = getOptionalArgumentText(state, argumentCountNode)
if (argCountText) {
try {
argCountNumber = parseInt(argCountText, 10)
} catch (err) {}
}
}
const commandDefinitionHasOptionalArgument = optionalArguments.length === 2
if (commandDefinitionHasOptionalArgument && argCountNumber > 0) {
argCountNumber--
}
items.push({
line: state.doc.lineAt(node.from).number,
title: commandName,
from: node.from,
to: node.to,
optionalArgCount: commandDefinitionHasOptionalArgument ? 1 : 0,
requiredArgCount: argCountNumber,
type: 'definition',
raw: state.sliceDoc(node.from, node.to),
})
} else if (node.type.is('Def')) {
if (!nodeIntersectsChange(node.node)) {
// This should already be in `items`
return
}
const commandName = getCommandName(node.node, state, ['Csname', 'CtrlSym'])
if (commandName === null) {
return
}
const requiredArgCount = node.node.getChildren('MacroParameter').length
const optionalArgCount = node.node.getChildren(
'OptionalMacroParameter'
).length
items.push({
line: state.doc.lineAt(node.from).number,
title: commandName,
from: node.from,
to: node.to,
optionalArgCount,
requiredArgCount,
type: 'definition',
raw: state.sliceDoc(node.from, node.to),
})
} else if (node.type.is('Let')) {
if (!nodeIntersectsChange(node.node)) {
// This should already be in `items`
return
}
const commandName = getCommandName(node.node, state, ['Csname'])
if (commandName === null) {
return
}
items.push({
line: state.doc.lineAt(node.from).number,
title: commandName,
from: node.from,
to: node.to,
ignoreInAutocomplete: true, // Ignoring since we don't know the argument counts
optionalArgCount: undefined,
requiredArgCount: undefined,
type: 'definition',
raw: state.sliceDoc(node.from, node.to),
})
} else if (
node.type.is('UnknownCommand') ||
node.type.is('KnownCommand') ||
node.type.is('MathUnknownCommand') ||
node.type.is('DefinitionFragmentUnknownCommand')
) {
if (!nodeIntersectsChange(node.node)) {
// This should already be in `items`
return
}
let commandNode: SyntaxNode | null = node.node
if (node.type.is('KnownCommand')) {
// KnownCommands are defined as
//
// KnownCommand {
// CommandName {
// CommandCtrlSeq [args]
// }
// }
// So for a KnownCommand, use the first child as the actual command node
commandNode = commandNode.firstChild
}
if (!commandNode) {
return
}
const ctrlSeq = commandNode.getChild('$CtrlSeq')
if (!ctrlSeq) {
return
}
if (ctrlSeq.type.is('$CtrlSym')) {
return
}
const optionalArguments = commandNode.getChildren('OptionalArgument')
const commandArgumentsIncludingOptional =
commandNode.getChildren('$Argument')
const text = state.doc.sliceString(ctrlSeq.from, ctrlSeq.to)
items.push({
line: state.doc.lineAt(commandNode.from).number,
title: text,
from: commandNode.from,
to: commandNode.to,
optionalArgCount: optionalArguments.length,
requiredArgCount:
commandArgumentsIncludingOptional.length - optionalArguments.length,
type: 'usage',
raw: undefined,
})
}
}
const texOrPdfArgument = { tex: 0, pdf: 1 }
export const texOrPdfString = (
state: EditorState,
node: SyntaxNode,
version: keyof typeof texOrPdfArgument
) => {
const commandName = getCommandName(node.node, state, ['CtrlSeq'])
if (commandName === '\\texorpdfstring') {
const argumentNode = node
.getChildren('TextArgument')
[texOrPdfArgument[version]]?.getChild('LongArg')
if (argumentNode) {
return state.doc.sliceString(argumentNode.from, argumentNode.to)
}
}
}

View File

@@ -0,0 +1,96 @@
import { ensureSyntaxTree } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
const HUNDRED_MS = 100
/**
* Does this Comment node look like '% {' ?
* */
export const commentIsOpenFold = (
node: SyntaxNode,
state: EditorState
): boolean => {
const content = state.doc.sliceString(node.from, node.to)
return !!content.match(/%\s*\{\s*/)
}
/**
* Does this Comment node look like '% }' ?
* */
export const commentIsCloseFold = (
node: SyntaxNode,
state: EditorState
): boolean => {
const content = state.doc.sliceString(node.from, node.to)
return !!content.match(/%\s*\}\s*/)
}
const SEARCH_FORWARD_LIMIT = 6000
/**
* Given an opening fold Comment, find its corresponding closing Comment,
* accounting for nesting.
* */
export const findClosingFoldComment = (
node: SyntaxNode,
state: EditorState
): SyntaxNode | undefined => {
const start = node.to + 1
const upto = Math.min(start + SEARCH_FORWARD_LIMIT, state.doc.length)
const tree = ensureSyntaxTree(state, upto, HUNDRED_MS)
if (!tree) {
return
}
let closingFoldNode: SyntaxNode | undefined
let nestingLevel = 0
tree.iterate({
from: start,
to: upto,
enter: n => {
if (closingFoldNode) {
return false
}
if (n.node.type.is('Comment')) {
if (commentIsOpenFold(n.node, state)) {
nestingLevel++
} else if (commentIsCloseFold(n.node, state)) {
if (nestingLevel > 0) {
nestingLevel--
} else {
closingFoldNode = n.node
return false
}
}
}
},
})
return closingFoldNode
}
/**
* Given two Comment nodes, get the positions we want to actually fold between,
* accounting for the opening and closing brace.
*
* The resulting fold looks like `% {----}` in the editor.
*
*/
export const getFoldRange = (
startNode: SyntaxNode,
endNode: SyntaxNode,
state: EditorState
): { from: number; to: number } | null => {
const startContent = state.doc.sliceString(startNode.from, startNode.to)
const endContent = state.doc.sliceString(endNode.from, endNode.to)
const openBracePos = startContent.indexOf('{')
const closeBracePos = endContent.indexOf('}')
if (openBracePos < 0 || closeBracePos < 0) {
return null
}
return {
from: startNode.from + openBracePos + 1,
to: endNode.from + closeBracePos,
}
}

View File

@@ -0,0 +1,104 @@
import { ensureSyntaxTree } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { IterMode, SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common'
const HUNDRED_MS = 100
export function iterateDescendantsOf(
tree: Tree,
ancestors: (string | number)[],
spec: {
enter(node: SyntaxNodeRef): boolean | void
leave?(node: SyntaxNodeRef): void
from?: number | undefined
to?: number | undefined
mode?: IterMode | undefined
}
) {
const filteredEnter = (node: SyntaxNodeRef): boolean | void => {
if (!ancestors.some(x => node.type.is(x))) {
return false
}
return spec.enter(node)
}
tree.iterate({ ...spec, enter: filteredEnter })
}
export const previousSiblingIs = (
state: EditorState,
pos: number,
expectedName: string
): boolean | null => {
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
if (!tree) {
return null
}
const thisNode = tree.resolve(pos)
const previousNode = thisNode?.prevSibling
return previousNode?.type.name === expectedName
}
export const nextSiblingIs = (
state: EditorState,
pos: number,
expectedName: string
): boolean | null => {
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
if (!tree) {
return null
}
const thisNode = tree.resolve(pos)
const previousNode = thisNode?.nextSibling
return previousNode?.type.name === expectedName
}
export const getOptionalArgumentText = (
state: EditorState,
optionalArgumentNode: SyntaxNode
): string | undefined => {
const shortArgNode = optionalArgumentNode.getChild('ShortOptionalArg')
if (shortArgNode) {
return state.doc.sliceString(shortArgNode.from, shortArgNode.to)
}
}
export const nodeHasError = (node: SyntaxNode): boolean => {
let hasError = false
node.cursor().iterate(({ type }) => {
if (hasError) return false
if (type.isError) {
hasError = true
return false
}
return true
})
return hasError
}
export const childOfNodeWithType = (
node: SyntaxNode,
...types: (string | number)[]
): SyntaxNode | null => {
let childOfType: SyntaxNode | null = null
node.cursor().iterate(child => {
if (childOfType !== null) {
return false
}
for (const type of types) {
if (child.type.is(type)) {
childOfType = child.node
return false
}
}
return true
})
return childOfType
}

View File

@@ -0,0 +1,70 @@
import { CompletionContext, CompletionSource } from '@codemirror/autocomplete'
import { syntaxTree } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import { ancestorOfNodeWithType } from './ancestors'
export const ifInType = (
type: string,
source: CompletionSource
): CompletionSource => {
return (context: CompletionContext) => {
const tree = syntaxTree(context.state)
let node: SyntaxNode | null = tree.resolveInner(context.pos, -1)
while (node) {
if (node.type.is(type)) {
return source(context)
}
node = node.parent
}
return null
}
}
function isInEmptyArgumentNodeOfType(state: EditorState, types: string[]) {
const main = state.selection.main
if (!main.empty) {
return false
}
const pos = main.anchor
const tree = syntaxTree(state)
if (tree.length < pos) {
return false
}
const nodeLeft = tree.resolveInner(pos, -1)
if (!nodeLeft.type.is('OpenBrace')) {
return false
}
const nodeRight = tree.resolveInner(pos, 1)
if (!nodeRight.type.is('CloseBrace')) {
return false
}
const ancestor = ancestorOfNodeWithType(nodeLeft, ...types)
if (!ancestor) {
return false
}
return ancestor.from === nodeLeft.from && ancestor.to === nodeRight.to
}
export function isInEmptyArgumentNodeForAutocomplete(state: EditorState) {
return isInEmptyArgumentNodeOfType(state, [
'EnvNameGroup',
'BibliographyStyleArgument',
'BibliographyArgument',
'BibKeyArgument',
'DocumentClassArgument',
'FilePathArgument',
'RefArgument',
'PackageArgument',
])
}
export function isInEmptyCiteArgumentNode(state: EditorState) {
return isInEmptyArgumentNodeOfType(state, ['BibKeyArgument'])
}

View File

@@ -0,0 +1,307 @@
import { ensureSyntaxTree } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
import { previousSiblingIs } from './common'
import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
import { FigureData } from '../../extensions/figure-modal'
const HUNDRED_MS = 100
export class Environment extends ProjectionItem {
readonly title: string = ''
readonly type: 'usage' | 'definition' = 'usage'
readonly raw: string = ''
}
export const enterNode = (
state: EditorState,
node: SyntaxNodeRef,
items: Environment[],
nodeIntersectsChange: NodeIntersectsChangeFn
): any => {
if (node.type.is('EnvNameGroup')) {
if (!nodeIntersectsChange(node.node)) {
return false
}
if (!node.node.prevSibling?.type.is('Begin')) {
return false
}
const openBraceNode = node.node.getChild('OpenBrace')
if (!openBraceNode) {
return false
}
const envNameNode = openBraceNode.node.nextSibling
if (!envNameNode) {
return false
}
const envNameText = state.doc.sliceString(envNameNode.from, envNameNode.to)
if (envNameText.length < 1) {
return false
}
const thisEnvironmentName: Environment = {
title: envNameText,
from: envNameNode.from,
to: envNameNode.to,
line: state.doc.lineAt(envNameNode.from).number,
type: 'usage',
raw: state.sliceDoc(node.from, node.to),
}
items.push(thisEnvironmentName)
} else if (
node.type.is('NewEnvironment') ||
node.type.is('RenewEnvironment')
) {
if (!nodeIntersectsChange(node.node)) {
// This should already be in `items`
return false
}
const envNameNode = node.node.getChild('LiteralArgContent')
if (!envNameNode) {
return
}
const envNameText = state.doc.sliceString(envNameNode.from, envNameNode.to)
if (!envNameText) {
return
}
const thisEnvironmentName: Environment = {
title: envNameText,
from: envNameNode.from,
to: envNameNode.to,
line: state.doc.lineAt(envNameNode.from).number,
type: 'definition',
raw: state.sliceDoc(node.from, node.to),
}
items.push(thisEnvironmentName)
}
}
export const cursorIsAtBeginEnvironment = (
state: EditorState,
pos: number
): boolean | undefined => {
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
if (!tree) {
return
}
let thisNode = tree.resolve(pos)
if (!thisNode) {
return
}
if (
thisNode.type.is('EnvNameGroup') &&
previousSiblingIs(state, pos, 'Begin')
) {
return true
} else if (
thisNode.type.is('$Environment') ||
(thisNode.type.is('LaTeX') && pos === state.doc.length) // We're at the end of the document
) {
// We're at a malformed `\begin{`, resolve leftward
thisNode = tree.resolve(pos, -1)
if (!thisNode) {
return
}
// TODO: may need to handle various envnames
if (thisNode.type.is('OpenBrace') || thisNode.type.is('$EnvName')) {
return true
}
}
}
export const cursorIsAtEndEnvironment = (
state: EditorState,
pos: number
): boolean | undefined => {
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
if (!tree) {
return
}
let thisNode = tree.resolve(pos)
if (!thisNode) {
return
}
if (
thisNode.type.is('EnvNameGroup') &&
previousSiblingIs(state, pos, 'End')
) {
return true
} else if (thisNode.type.is('$Environment') || thisNode.type.is('Content')) {
// We're at a malformed `\end{`, resolve leftward
thisNode = tree.resolve(pos, -1)
if (!thisNode) {
return
}
// TODO: may need to handle various envnames
if (thisNode.type.is('OpenBrace') || thisNode.type.is('EnvName')) {
return true
}
}
}
/**
*
* @param node A node of type `$Environment`, `BeginEnv`, or `EndEnv`
* @param state The editor state to read the name from
* @returns The editor name or null if a name cannot be found
*/
export function getEnvironmentName(
node: SyntaxNode | null,
state: EditorState
): string | null {
if (node?.type.is('$Environment')) {
node = node.getChild('BeginEnv')
}
if (!node?.type.is('BeginEnv') && !node?.type.is('EndEnv')) {
return null
}
const nameNode = node
?.getChild('EnvNameGroup')
?.getChild('OpenBrace')?.nextSibling
if (!nameNode) {
return null
}
// the name node is a parameter in the grammar, so we have no good way to
// target the specific type
if (nameNode.type.is('CloseBrace')) {
return null
}
return state.sliceDoc(nameNode.from, nameNode.to)
}
export const getUnstarredEnvironmentName = (
node: SyntaxNode | null,
state: EditorState
): string | undefined => getEnvironmentName(node, state)?.replace(/\*$/, '')
export function getEnvironmentArguments(environmentNode: SyntaxNode) {
return environmentNode.getChild('BeginEnv')?.getChildren('TextArgument')
}
export function parseFigureData(
figureEnvironmentNode: SyntaxNode,
state: EditorState
): FigureData | null {
let caption: FigureData['caption'] = null
let label: FigureData['label'] = null
let file: FigureData['file'] | undefined
let width: FigureData['width']
let unknownGraphicsArguments: FigureData['unknownGraphicsArguments']
let graphicsCommand: FigureData['graphicsCommand'] | undefined
let graphicsCommandArguments: FigureData['graphicsCommandArguments'] = null
const from = figureEnvironmentNode.from
const to = figureEnvironmentNode.to
let error = false
figureEnvironmentNode.cursor().iterate((node: SyntaxNodeRef) => {
if (error) {
return false
}
if (node.type.is('Caption')) {
if (caption) {
// Multiple captions
error = true
return false
}
caption = {
from: node.from,
to: node.to,
}
}
if (node.type.is('Label')) {
if (label) {
// Multiple labels
error = true
return false
}
label = {
from: node.from,
to: node.to,
}
}
if (node.type.is('IncludeGraphics')) {
if (file) {
// Multiple figure
error = true
return false
}
graphicsCommand = {
from: node.from,
to: node.to,
}
const content = node.node
.getChild('IncludeGraphicsArgument')
?.getChild('FilePathArgument')
?.getChild('LiteralArgContent')
if (!content) {
error = true
return false
}
file = {
from: content.from,
to: content.to,
path: state.sliceDoc(content.from, content.to),
}
const optionalArgs = node.node
.getChild('OptionalArgument')
?.getChild('ShortOptionalArg')
if (!optionalArgs) {
width = undefined
return false
}
graphicsCommandArguments = {
from: optionalArgs.from,
to: optionalArgs.to,
}
const optionalArgContent = state.sliceDoc(
optionalArgs.from,
optionalArgs.to
)
const widthMatch = optionalArgContent.match(
/^width=([0-9]|(?:[0-9]*\.[0-9]+)|(?:[0-9]+\.))\\(linewidth|pagewidth|textwidth|hsize|columnwidth)$/
)
if (widthMatch) {
width = parseFloat(widthMatch[1])
if (widthMatch[2] !== 'linewidth') {
// We shouldn't edit any width other that linewidth
unknownGraphicsArguments = optionalArgContent
}
} else {
unknownGraphicsArguments = optionalArgContent
}
}
})
if (error) {
return null
}
if (graphicsCommand === undefined || file === undefined) {
return null
}
return new FigureData({
caption,
label,
file,
from,
to,
width,
unknownGraphicsArguments,
graphicsCommand,
graphicsCommandArguments,
})
}
export const getBeginEnvSuffix = (state: EditorState, node: SyntaxNode) => {
const argumentNode = node
.getChild('OptionalArgument')
?.getChild('ShortOptionalArg')
return argumentNode && state.sliceDoc(argumentNode.from, argumentNode.to)
}

View File

@@ -0,0 +1,26 @@
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
import { CenteringCtrlSeq } from '../../lezer-latex/latex.terms.mjs'
export function centeringNodeForEnvironment(
environmentNodeRef: SyntaxNodeRef
): SyntaxNode | null {
let centeringNode: SyntaxNode | null = null
const cursor = environmentNodeRef.node.cursor()
cursor.iterate(nodeRef => {
if (centeringNode) {
return false
}
if (nodeRef.type.is(CenteringCtrlSeq)) {
centeringNode = nodeRef.node
return false
}
// don't descend into nested environments
if (
nodeRef.node !== environmentNodeRef.node &&
nodeRef.type.is('$Environment')
) {
return false
}
})
return centeringNode
}

View File

@@ -0,0 +1,50 @@
import { EditorState, SelectionRange } from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import {
commonAncestor,
matchingAncestor,
} from '@/features/source-editor/utils/tree-operations/ancestors'
export type FormattingCommand = '\\textbf' | '\\textit'
export type FormattingNodeType = string | number
export const formattingCommandMap: Record<
FormattingCommand,
FormattingNodeType
> = {
'\\textbf': 'TextBoldCommand',
'\\textit': 'TextItalicCommand',
}
export const withinFormattingCommand = (state: EditorState) => {
const tree = syntaxTree(state)
return (command: FormattingCommand): boolean => {
const nodeType = formattingCommandMap[command]
const isFormattedText = (range: SelectionRange): boolean => {
const nodeLeft = tree.resolveInner(range.from, -1)
const formattingCommandLeft = matchingAncestor(nodeLeft, node =>
node.type.is(nodeType)
)
if (!formattingCommandLeft) {
return false
}
// We need to check the other end of the selection, and ensure that they
// share a common formatting command ancestor
const nodeRight = tree.resolveInner(range.to, 1)
const ancestor = commonAncestor(formattingCommandLeft, nodeRight)
if (!ancestor) {
return false
}
const formattingAncestor = matchingAncestor(ancestor, node =>
node.type.is(nodeType)
)
return Boolean(formattingAncestor)
}
return state.selection.ranges.every(isFormattedText)
}
}

View File

@@ -0,0 +1,21 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
export const getListType = (
state: EditorState,
listEnvironmentNode: SyntaxNode
) => {
const beginEnvNameNode = listEnvironmentNode
.getChild('BeginEnv')
?.getChild('EnvNameGroup')
?.getChild('ListEnvName')
const endEnvNameNode = listEnvironmentNode
.getChild('EndEnv')
?.getChild('EnvNameGroup')
?.getChild('ListEnvName')
if (beginEnvNameNode && endEnvNameNode) {
return state.sliceDoc(beginEnvNameNode.from, beginEnvNameNode.to).trim()
}
}

View File

@@ -0,0 +1,65 @@
import { getEnvironmentName } from './environments'
import { EditorState } from '@codemirror/state'
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
import { ancestorNodeOfType } from './ancestors'
export type MathContainer = {
content: string
displayMode: boolean
passToMathJax: boolean
pos: number
}
export const mathAncestorNode = (state: EditorState, pos: number) =>
ancestorNodeOfType(state, pos, '$MathContainer') ||
ancestorNodeOfType(state, pos, 'EquationEnvironment') ||
// NOTE: EquationArrayEnvironment can be nested inside EquationEnvironment
ancestorNodeOfType(state, pos, 'EquationArrayEnvironment')
export const parseMathContainer = (
state: EditorState,
nodeRef: SyntaxNodeRef,
ancestorNode: SyntaxNode
): MathContainer | null => {
// the content of the Math element, without braces
const innerContent = state.doc.sliceString(nodeRef.from, nodeRef.to).trim()
if (!innerContent.length) {
return null
}
let content = innerContent
let displayMode = false
let passToMathJax = true
let pos = nodeRef.from
if (ancestorNode.type.is('$Environment')) {
const environmentName = getEnvironmentName(ancestorNode, state)
if (environmentName) {
// use the outer content of environments that MathJax supports
// https://docs.mathjax.org/en/latest/input/tex/macros/index.html#environments
if (environmentName === 'tikzcd') {
passToMathJax = false
}
if (environmentName !== 'math' && environmentName !== 'displaymath') {
content = state.doc
.sliceString(ancestorNode.from, ancestorNode.to)
.trim()
pos = ancestorNode.from
}
if (environmentName !== 'math') {
displayMode = true
}
}
} else {
if (
ancestorNode.type.is('BracketMath') ||
Boolean(ancestorNode.getChild('DisplayMath'))
) {
displayMode = true
}
}
return { content, displayMode, passToMathJax, pos }
}

View File

@@ -0,0 +1,235 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
import * as tokens from '../../lezer-latex/latex.terms.mjs'
import { getEnvironmentArguments, getEnvironmentName } from './environments'
import { PartialFlatOutline } from '@/features/ide-react/context/outline-context'
import { texOrPdfString } from './commands'
export type Outline = {
line: number
title: string
level: number
children?: Outline[]
}
/**
* A projection of a part of the file outline, typically a (sub)section heading
*/
export class FlatOutlineItem extends ProjectionItem {
readonly level: number = 0
readonly title: string = ''
}
export type FlatOutline = FlatOutlineItem[]
/* eslint-disable no-unused-vars */
enum NestingLevel {
Book = 1,
Part = 2,
Chapter = 3,
Section = 4,
SubSection = 5,
SubSubSection = 6,
Paragraph = 7,
SubParagraph = 8,
Frame = 9,
Invalid = -1,
}
const fallbackSectionNames: { [index: string]: NestingLevel } = {
book: NestingLevel.Book,
part: NestingLevel.Part,
chapter: NestingLevel.Part,
section: NestingLevel.Section,
subsection: NestingLevel.SubSection,
subsubsection: NestingLevel.SubSubSection,
paragraph: NestingLevel.Paragraph,
subparagraph: NestingLevel.SubParagraph,
frame: NestingLevel.Frame,
}
export const getNestingLevel = (token: number | string): NestingLevel => {
if (typeof token === 'string') {
return fallbackSectionNames[token] ?? NestingLevel.Invalid
}
switch (token) {
case tokens.Book:
return NestingLevel.Book
case tokens.Part:
return NestingLevel.Part
case tokens.Chapter:
return NestingLevel.Chapter
case tokens.Section:
return NestingLevel.Section
case tokens.SubSection:
return NestingLevel.SubSection
case tokens.SubSubSection:
return NestingLevel.SubSubSection
case tokens.Paragraph:
return NestingLevel.Paragraph
case tokens.SubParagraph:
return NestingLevel.SubParagraph
default:
return NestingLevel.Invalid
}
}
const getEntryText = (state: EditorState, node: SyntaxNodeRef): string => {
const titleParts: string[] = []
node.node.cursor().iterate(token => {
// For some reason, iterate can possibly visit sibling nodes as well as
// child nodes
if (token.from >= node.to) {
return false
}
// Hide label definitions within the sectioning command
if (token.type.is('Label')) {
return false
}
// Handle the texorpdfstring command
if (token.type.is('UnknownCommand')) {
const pdfString = texOrPdfString(state, token.node, 'pdf')
if (pdfString) {
titleParts.push(pdfString)
return false
}
}
// Only add text from leaf nodes
if (token.node.firstChild) {
return true
}
titleParts.push(state.doc.sliceString(token.from, token.to))
})
return titleParts.join('')
}
/**
* Extracts FlatOutlineItem instances from the syntax tree
*/
export const enterNode = (
state: EditorState,
node: SyntaxNodeRef,
items: FlatOutlineItem[],
nodeIntersectsChange: NodeIntersectsChangeFn
): any => {
if (node.type.is('SectioningCommand')) {
const command = node.node
const parent = command.parent
if (!nodeIntersectsChange(command)) {
// This should already be in `items`
return
}
const name = command.getChild('SectioningArgument')?.getChild('LongArg')
if (!name) {
return
}
// Filter out descendants of newcommand/renewcommand
for (
let ancestor: SyntaxNode | null = parent;
ancestor;
ancestor = ancestor.parent
) {
if (ancestor.type.is('NewCommand') || ancestor.type.is('RenewCommand')) {
return false
}
}
const getCommandName = () => {
const ctrlSeq = command.firstChild
if (!ctrlSeq) return ''
// Ignore the \
return state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
}
const nestingLevel = parent?.type.is('$Section')
? getNestingLevel(parent.type.id)
: getNestingLevel(getCommandName())
const thisNode = {
line: state.doc.lineAt(command.from).number,
title: getEntryText(state, name),
from: command.from,
to: command.to,
level: nestingLevel,
}
items.push(thisNode)
}
if (node.type.is('$Environment')) {
const environmentNode = node.node
if (getEnvironmentName(environmentNode, state) === 'frame') {
const beginEnv = environmentNode.getChild('BeginEnv')!
if (!nodeIntersectsChange(beginEnv)) {
// This should already be in `items`
return
}
const args = getEnvironmentArguments(environmentNode)?.map(textArg =>
textArg.getChild('LongArg')
)
if (args?.length) {
const titleNode = args[0]
const title = titleNode
? state.sliceDoc(titleNode.from, titleNode.to)
: ''
const thisNode = {
line: state.doc.lineAt(beginEnv.from).number,
title,
from: beginEnv.from,
to: beginEnv.to,
level: NestingLevel.Frame,
}
items.push(thisNode)
}
}
}
}
const flatItemToOutline = (item: {
title: string
line: number
level: number
}): Outline => ({
title: item.title,
line: item.line,
level: item.level,
})
export const nestOutline = (flatOutline: PartialFlatOutline): Outline[] => {
const parentStack: Outline[] = []
const outline = []
for (const item of flatOutline) {
const outlineItem = flatItemToOutline(item)
// Pop all higher-leveled potential parents from the parent stack
while (
parentStack.length &&
parentStack[parentStack.length - 1].level >= outlineItem.level
) {
parentStack.pop()
}
// Append to parent if any, and otherwise add root element
if (!parentStack.length) {
parentStack.push(outlineItem)
outline.push(outlineItem)
} else {
const parent = parentStack[parentStack.length - 1]
if (!parent.children) {
parent.children = [outlineItem]
} else {
parent.children.push(outlineItem)
}
parentStack.push(outlineItem)
}
}
return outline
}

View File

@@ -0,0 +1,161 @@
import { ensureSyntaxTree } from '@codemirror/language'
import { EditorState, Transaction } from '@codemirror/state'
import { IterMode, SyntaxNodeRef } from '@lezer/common'
const TWENTY_MS = 20
const FIVE_HUNDRED_MS = 500
/**
* A single item in the projection
*/
export abstract class ProjectionItem {
readonly from: number = 0
readonly to: number = 0
readonly line: number = 0
}
/* eslint-disable no-unused-vars */
export enum ProjectionStatus {
Pending,
Partial,
Complete,
}
/* eslint-enable no-unused-vars */
/*
* Result of extracting a projection from the document.
* Holds the list of ProjectionItems and the status of
* the projection
*/
export interface ProjectionResult<T extends ProjectionItem> {
items: T[]
status: ProjectionStatus
}
const intersects = (fromA: number, toA: number, fromB: number, toB: number) => {
return !(toA < fromB || fromA > toB)
}
export type NodeIntersectsChangeFn = (node: SyntaxNodeRef) => boolean
export function updatePosition<T extends ProjectionItem>(
item: T,
transaction?: Transaction
): T {
if (!transaction) {
return item
}
const { from, to } = item
const newFrom = transaction.changes.mapPos(from)
const newTo = transaction.changes.mapPos(to)
const lineNumber = transaction.state.doc.lineAt(newFrom).number
if (newFrom === from && newTo === to && lineNumber === item.line) {
// Optimisation - if the item hasn't moved, don't create a new object
// If items are not immutable this can introduce problems
return item
}
return {
...item,
from: newFrom,
to: newTo,
line: lineNumber,
}
}
export type EnterNodeFn<T> = (
state: EditorState,
node: SyntaxNodeRef,
items: T[],
nodeIntersectsChange: NodeIntersectsChangeFn
) => any
/**
* Calculates an updated projection of an editor state. Passing a previous ProjectionResult
* will reuse the existing projection elements (though updating their position to
* point correctly into the latest EditorState), outside of the changed range.
*
* @param state The current editor state
* @param fromA The start of the modified range in the previous state.
* Ignored if `previousResult` is not provided
* @param toA The end of the modified range in the previous state.
* Ignored if `previousResult` is not provided
* @param fromB The start of the modified range in the `state`
* @param toB The end of the modified range in the `state`
* @param initialParse If this is the intial parse of the document. If that's
* the case, we allow 500ms parse time instead of 20ms
* @param enterNode A function to call when 'enter'ing a node while traversing the syntax tree,
* used to identify nodes we are interested in.
* @param transaction Optional, used to update item positions in `previousResult`
* @param previousResult A previous ProjectionResult that will be reused for
* projection elements outside of the range of [fromA; toA]
* @returns A ProjectionResult<T> pointing to locations in `state`
*/
export function getUpdatedProjection<T extends ProjectionItem>(
state: EditorState,
fromA: number,
toA: number,
fromB: number,
toB: number,
initialParse = false,
enterNode: EnterNodeFn<T>,
transaction?: Transaction,
previousResult: ProjectionResult<T> = {
items: [],
status: ProjectionStatus.Pending,
}
): ProjectionResult<T> {
// Only reuse results from a Complete parse, otherwise we may drop entries.
// We keep items that lie outside the change range, and update their positions.
const items: T[] =
previousResult.status === ProjectionStatus.Complete
? previousResult
.items!.filter(item => !intersects(item.from, item.to, fromA, toA))
.map(x => updatePosition(x, transaction))
: []
if (previousResult.status !== ProjectionStatus.Complete) {
// We have previously tried to compute the projection, but unsuccessfully,
// so we should try to parse the whole file again.
toB = state.doc.length
fromB = 0
}
const tree = ensureSyntaxTree(
state,
toB,
initialParse ? FIVE_HUNDRED_MS : TWENTY_MS
)
if (tree) {
tree.iterate({
from: fromB,
to: toB,
enter(node) {
const nodeIntersectsChange = (n: SyntaxNodeRef) => {
return intersects(n.from, n.to, fromB, toB)
}
return enterNode(state, node, items, nodeIntersectsChange)
},
mode: IterMode.IgnoreMounts | IterMode.IgnoreOverlays,
})
// We know the exact projection. Return it.
return {
status: ProjectionStatus.Complete,
items: items.sort((a, b) => a.from - b.from),
}
} else if (previousResult.status !== ProjectionStatus.Pending) {
// We don't know the latest projection, but we have an idea of a previous
// projection.
return {
status: ProjectionStatus.Partial,
items: previousResult.items,
}
} else {
// We have no previous projection, and no idea of the current projection.
// Return pending.
return {
items: [],
status: ProjectionStatus.Pending,
}
}
}

View File

@@ -0,0 +1,79 @@
import { syntaxTree } from '@codemirror/language'
import { Line } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { SyntaxNodeRef } from '@lezer/common'
import OError from '@overleaf/o-error'
import { noSpellCheckProp } from '@/features/source-editor/utils/node-props'
/* A convenient wrapper around 'Normal' tokens */
export class NormalTextSpan {
public from: number
public to: number
public lineNumber: number
public text: string
public node: SyntaxNodeRef
constructor(options: {
from: number
to: number
lineNumber: number
text: string
node: SyntaxNodeRef
}) {
const { from, to, lineNumber, text, node } = options
if (
text == null ||
from == null ||
to == null ||
lineNumber == null ||
node == null
) {
throw new OError('TreeQuery: invalid NormalTextSpan').withInfo({
options,
})
}
this.from = from
this.to = to
this.text = text
this.node = node
this.lineNumber = lineNumber
}
}
export const getNormalTextSpansFromLine = (
view: EditorView,
line: Line
): Array<NormalTextSpan> => {
const lineNumber = line.number
const lineStart = line.from
const lineEnd = line.to
const tree = syntaxTree(view.state)
const normalTextSpans: Array<NormalTextSpan> = []
tree?.iterate({
from: lineStart,
to: lineEnd,
enter: (node: SyntaxNodeRef) => {
if (
node.type.prop(noSpellCheckProp)?.some(context => {
return node.matchContext(context)
})
) {
return false
}
if (node.type.name === 'Normal') {
normalTextSpans.push(
new NormalTextSpan({
from: node.from,
to: node.to,
text: view.state.doc.sliceString(node.from, node.to),
lineNumber,
node,
})
)
return false
}
return true
},
})
return normalTextSpans
}

View File

@@ -0,0 +1,71 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode, Tree } from '@lezer/common'
import {
LongArg,
ShortArg,
ShortTextArgument,
TextArgument,
} from '../../lezer-latex/latex.terms.mjs'
export const parseTheoremArguments = (
state: EditorState,
node: SyntaxNode
): { name: string; label: string } | undefined => {
const nameArgumentNode = node.getChild(ShortTextArgument)?.getChild(ShortArg)
const labelArgumentNode = node.getChild(TextArgument)?.getChild(LongArg)
if (nameArgumentNode && labelArgumentNode) {
const name = state
.sliceDoc(nameArgumentNode.from, nameArgumentNode.to)
.trim()
const label = state
.sliceDoc(labelArgumentNode.from, labelArgumentNode.to)
.trim()
if (name && label) {
return { name, label }
}
}
}
export const parseTheoremStyles = (state: EditorState, tree: Tree) => {
// TODO: only scan for styles if amsthm is present?
let currentTheoremStyle = 'plain'
const theoremStyles = new Map<string, string>()
const topNode = tree.topNode
if (topNode && topNode.name === 'LaTeX') {
const textNode = topNode.getChild('Text')
const topLevelCommands = textNode
? textNode.getChildren('Command')
: topNode.getChildren('Command')
for (const command of topLevelCommands) {
const node = command.getChild('KnownCommand')?.getChild('$Command')
if (node) {
if (node.type.is('TheoremStyleCommand')) {
const theoremStyle = argumentNodeContent(state, node)
if (theoremStyle) {
currentTheoremStyle = theoremStyle
}
} else if (node.type.is('NewTheoremCommand')) {
const theoremEnvironmentName = argumentNodeContent(state, node)
if (theoremEnvironmentName) {
theoremStyles.set(theoremEnvironmentName, currentTheoremStyle)
}
}
}
}
}
return theoremStyles
}
const argumentNodeContent = (
state: EditorState,
node: SyntaxNode
): string | null => {
const argumentNode = node.getChild(ShortTextArgument)?.getChild(ShortArg)
return argumentNode
? state.sliceDoc(argumentNode.from, argumentNode.to)
: null
}

View File

@@ -0,0 +1,9 @@
import * as termsModule from '../../lezer-latex/latex.terms.mjs'
export const tokenNames: Array<string> = Object.keys(termsModule)
export const Tokens: Record<string, Array<string>> = {
ctrlSeq: tokenNames.filter(name => name.match(/^(Begin|End|.*CtrlSeq)$/)),
ctrlSym: tokenNames.filter(name => name.match(/^.*CtrlSym$/)),
envName: tokenNames.filter(name => name.match(/^.*EnvName$/)),
}