mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Add "Jump to command definition" to the editor context menu (#35116)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> GitOrigin-RevId: 5fcaef2004495a5e71bc083cf8fdc2bffce579d5
This commit is contained in:
committed by
Copybot
co-authored by
Claude Opus 4.8
parent
d20052bf3e
commit
f0b8e4af11
+78
@@ -0,0 +1,78 @@
|
||||
import type { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||
import { CommandDefinition, isIndexablePath } from './command-definition'
|
||||
import type {
|
||||
CommandDefinitionWorkerRequest,
|
||||
CommandDefinitionWorkerResponse,
|
||||
} from './command-definition.worker'
|
||||
|
||||
type SnapshotLike = Pick<ProjectSnapshot, 'getDocPaths' | 'getDocContents'>
|
||||
|
||||
/**
|
||||
* Locates the definition of a single command in the project by parsing the
|
||||
* snapshot in a worker, off the main thread, so opening the context menu is
|
||||
* never blocked. A single worker is reused across lookups so opening the menu
|
||||
* never spawns one.
|
||||
*/
|
||||
class CommandDefinitionIndexer {
|
||||
private worker: Worker
|
||||
private nextId = 0
|
||||
private resolvers = new Map<
|
||||
number,
|
||||
(definition: CommandDefinition | null) => void
|
||||
>()
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(
|
||||
/* webpackChunkName: "command-definition-worker" */
|
||||
new URL('./command-definition.worker.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
)
|
||||
this.worker.addEventListener('message', event => {
|
||||
const data = event.data as CommandDefinitionWorkerResponse
|
||||
if (data.type === 'definition') {
|
||||
this.resolvers.get(data.id)?.(data.definition)
|
||||
this.resolvers.delete(data.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
findDefinition(
|
||||
snapshot: SnapshotLike,
|
||||
name: string
|
||||
): Promise<CommandDefinition | null> {
|
||||
// Only send documents that mention the command (its defined name always
|
||||
// appears literally in its definition), to avoid cloning the whole project
|
||||
// to the worker.
|
||||
const docs: { path: string; content: string }[] = []
|
||||
for (const path of snapshot.getDocPaths()) {
|
||||
if (!isIndexablePath(path)) {
|
||||
continue
|
||||
}
|
||||
const content = snapshot.getDocContents(path)
|
||||
if (content != null && content.includes(name)) {
|
||||
docs.push({ path, content })
|
||||
}
|
||||
}
|
||||
|
||||
const id = this.nextId++
|
||||
const request: CommandDefinitionWorkerRequest = {
|
||||
id,
|
||||
type: 'find',
|
||||
name,
|
||||
docs,
|
||||
}
|
||||
this.worker.postMessage(request)
|
||||
return new Promise(resolve => {
|
||||
this.resolvers.set(id, resolve)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let indexer: CommandDefinitionIndexer | undefined
|
||||
|
||||
export function getCommandDefinitionIndexer(): CommandDefinitionIndexer {
|
||||
if (!indexer) {
|
||||
indexer = new CommandDefinitionIndexer()
|
||||
}
|
||||
return indexer
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import { parser } from '../../lezer-latex/latex.mjs'
|
||||
import { childOfNodeWithType } from '../tree-operations/common'
|
||||
|
||||
/**
|
||||
* Parsing helpers for locating where a custom command is defined across the
|
||||
* project. These are pure and worker-safe: the parsing runs in
|
||||
* command-definition.worker.ts, off the main thread, so opening the context menu
|
||||
* is never blocked.
|
||||
*
|
||||
* Definitions are parsed with the same lezer-latex grammar the editor uses, so
|
||||
* commented-out or verbatim definitions are ignored, and are named by their
|
||||
* control sequence, e.g. `\mycmd`.
|
||||
*/
|
||||
|
||||
export type CommandDefinition = {
|
||||
name: string
|
||||
path: string
|
||||
// Document offset of the definition, resolved to a line/cursor position when
|
||||
// the defining file is opened.
|
||||
pos: number
|
||||
}
|
||||
|
||||
// File extensions worth scanning for command definitions.
|
||||
const INDEXABLE_EXTENSIONS = ['.tex', '.sty', '.cls', '.def', '.ltx']
|
||||
|
||||
export function isIndexablePath(path: string): boolean {
|
||||
const lower = path.toLowerCase()
|
||||
return INDEXABLE_EXTENSIONS.some(ext => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
function sliceChild(
|
||||
content: string,
|
||||
node: SyntaxNode,
|
||||
...childTypes: string[]
|
||||
): string | null {
|
||||
const child = childOfNodeWithType(node, ...childTypes)
|
||||
if (!child) {
|
||||
return null
|
||||
}
|
||||
const text = content.slice(child.from, child.to)
|
||||
return text.length > 0 ? text : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a document and return the command definitions it contains.
|
||||
*/
|
||||
export function parseDefinitionsFromDoc(
|
||||
path: string,
|
||||
content: string
|
||||
): CommandDefinition[] {
|
||||
const definitions: CommandDefinition[] = []
|
||||
const tree = parser.parse(content)
|
||||
|
||||
const push = (name: string | null, pos: number) => {
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
definitions.push({ name, path, pos })
|
||||
}
|
||||
|
||||
tree.iterate({
|
||||
enter(nodeRef) {
|
||||
const node = nodeRef.node
|
||||
switch (nodeRef.type.name) {
|
||||
case 'NewCommand':
|
||||
case 'RenewCommand':
|
||||
push(
|
||||
sliceChild(content, node, 'LiteralArgContent', 'Csname'),
|
||||
node.from
|
||||
)
|
||||
break
|
||||
case 'Def':
|
||||
push(sliceChild(content, node, 'Csname', 'CtrlSym'), node.from)
|
||||
break
|
||||
case 'Let':
|
||||
push(sliceChild(content, node, 'Csname'), node.from)
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return definitions
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first definition of `name` across the given documents. Documents that
|
||||
* don't mention the control sequence at all are skipped without parsing (the
|
||||
* defined name always appears literally in its definition), and the search stops
|
||||
* at the first match.
|
||||
*/
|
||||
export function findDefinition(
|
||||
docs: { path: string; content: string }[],
|
||||
name: string
|
||||
): CommandDefinition | null {
|
||||
for (const { path, content } of docs) {
|
||||
if (!content.includes(name)) {
|
||||
continue
|
||||
}
|
||||
const match = parseDefinitionsFromDoc(path, content).find(
|
||||
definition => definition.name === name
|
||||
)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { CommandDefinition, findDefinition } from './command-definition'
|
||||
|
||||
export type CommandDefinitionWorkerRequest = {
|
||||
id: number
|
||||
type: 'find'
|
||||
name: string
|
||||
docs: { path: string; content: string }[]
|
||||
}
|
||||
|
||||
export type CommandDefinitionWorkerResponse = {
|
||||
id: number
|
||||
type: 'definition'
|
||||
definition: CommandDefinition | null
|
||||
}
|
||||
|
||||
self.addEventListener('message', (event: MessageEvent) => {
|
||||
const message = event.data as CommandDefinitionWorkerRequest
|
||||
if (message.type !== 'find') {
|
||||
return
|
||||
}
|
||||
|
||||
const response: CommandDefinitionWorkerResponse = {
|
||||
id: message.id,
|
||||
type: 'definition',
|
||||
definition: findDefinition(message.docs, message.name),
|
||||
}
|
||||
self.postMessage(response)
|
||||
})
|
||||
Reference in New Issue
Block a user