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:
Alf Eaton
2026-09-17 11:47:09 +00:00
committed by Copybot
co-authored by Claude Opus 4.8
parent d20052bf3e
commit f0b8e4af11
9 changed files with 402 additions and 0 deletions
@@ -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
}
@@ -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
}
@@ -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)
})