diff --git a/services/web/frontend/js/features/source-editor/extensions/math-preview.tsx b/services/web/frontend/js/features/source-editor/extensions/math-preview.tsx index ab704f3e19..9a520444c0 100644 --- a/services/web/frontend/js/features/source-editor/extensions/math-preview.tsx +++ b/services/web/frontend/js/features/source-editor/extensions/math-preview.tsx @@ -26,6 +26,7 @@ import ReactDOM from 'react-dom' import { SplitTestProvider } from '@/shared/context/split-test-context' import SplitTestBadge from '@/shared/components/split-test-badge' import { nodeHasError } from '../utils/tree-operations/common' +import { documentEnvironments } from '../languages/latex/document-environments' const REPOSITION_EVENT = 'editor:repositionMathTooltips' @@ -150,8 +151,17 @@ const buildTooltipContent = ( element.textContent = math.content let definitions = '' - const commandState = state.field(documentCommands, false) + const environmentState = state.field(documentEnvironments, false) + if (environmentState?.items) { + for (const environment of environmentState.items) { + if (environment.type === 'definition') { + definitions += `${environment.raw}\n` + } + } + } + + const commandState = state.field(documentCommands, false) if (commandState?.items) { for (const command of commandState.items) { if (command.type === 'definition' && command.raw) { diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts index c37bee1cf0..8d02c5d1b3 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts @@ -22,7 +22,10 @@ export function customCommandCompletions( const items = countCommandUsage(context) for (const item of items.values()) { - if (!existingCommands.has(commandNameFromLabel(item.label))) { + if ( + !existingCommands.has(commandNameFromLabel(item.label)) && + !item.ignoreInAutoComplete + ) { output.push({ type: 'cmd', label: item.label, @@ -42,7 +45,12 @@ const countCommandUsage = (context: CompletionContext) => { const result = new Map< string, - { label: string; snippet: string; count: number } + { + label: string + snippet: string + count: number + ignoreInAutoComplete?: boolean + } >() const commandListProjection = context.state.field(documentCommands) @@ -58,7 +66,12 @@ const countCommandUsage = (context: CompletionContext) => { const label = buildLabel(command) const snippet = buildSnippet(command) - const item = result.get(label) || { label, snippet, count: 0 } + const item = result.get(label) || { + label, + snippet, + count: 0, + ignoreInAutoComplete: command.ignoreInAutocomplete, + } item.count++ result.set(label, item) } @@ -69,15 +82,15 @@ const countCommandUsage = (context: CompletionContext) => { const buildLabel = (command: Command): string => { return [ `${command.title}`, - '[]'.repeat(command.optionalArgCount), - '{}'.repeat(command.requiredArgCount), + '[]'.repeat(command.optionalArgCount ?? 0), + '{}'.repeat(command.requiredArgCount ?? 0), ].join('') } const buildSnippet = (command: Command): string => { return [ `${command.title}`, - '[#{}]'.repeat(command.optionalArgCount), - '{#{}}'.repeat(command.requiredArgCount), + '[#{}]'.repeat(command.optionalArgCount ?? 0), + '{#{}}'.repeat(command.requiredArgCount ?? 0), ].join('') } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-environments.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-environments.ts index 654a66938d..27299ed991 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-environments.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-environments.ts @@ -1,8 +1,8 @@ import { customBeginCompletion } from './environments' import { CompletionContext } from '@codemirror/autocomplete' -import { documentEnvironmentNames } from '../document-environment-names' +import { documentEnvironments } from '../document-environments' import { ProjectionResult } from '../../../utils/tree-operations/projection' -import { EnvironmentName } from '../../../utils/tree-operations/environments' +import { Environment } from '../../../utils/tree-operations/environments' /** * Environments from the current doc @@ -25,8 +25,8 @@ export function customEnvironmentCompletions(context: CompletionContext) { export const findEnvironmentsInDoc = (context: CompletionContext) => { const result = new Set() - const environmentNamesProjection: ProjectionResult = - context.state.field(documentEnvironmentNames) + const environmentNamesProjection: ProjectionResult = + context.state.field(documentEnvironments) if (!environmentNamesProjection || !environmentNamesProjection.items) { return result } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/document-environment-names.ts b/services/web/frontend/js/features/source-editor/languages/latex/document-environments.ts similarity index 56% rename from services/web/frontend/js/features/source-editor/languages/latex/document-environment-names.ts rename to services/web/frontend/js/features/source-editor/languages/latex/document-environments.ts index aab7903440..8346e77003 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/document-environment-names.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/document-environments.ts @@ -1,8 +1,8 @@ import { - EnvironmentName, + Environment, enterNode, } from '../../utils/tree-operations/environments' import { makeProjectionStateField } from '../../utils/projection-state-field' -export const documentEnvironmentNames = - makeProjectionStateField(enterNode) +export const documentEnvironments = + makeProjectionStateField(enterNode) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/index.ts b/services/web/frontend/js/features/source-editor/languages/latex/index.ts index cc25b9d5b4..9bdd0f01d7 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/index.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/index.ts @@ -15,7 +15,7 @@ import { documentCommands } from './document-commands' import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' import { documentOutline } from './document-outline' import { LaTeXLanguage } from './latex-language' -import { documentEnvironmentNames } from './document-environment-names' +import { documentEnvironments } from './document-environments' import { figureModal, figureModalPasteHandler, @@ -36,7 +36,7 @@ export const latex = () => { shortcuts(), documentOutline, documentCommands, - documentEnvironmentNames, + documentEnvironments, latexIndentService(), linting(), metadata(), diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts index 5eb8ca2857..54fbe97f3a 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts @@ -1,6 +1,6 @@ import { EditorState } from '@codemirror/state' import { SyntaxNode, SyntaxNodeRef } from '@lezer/common' -import { getOptionalArgumentText } from './common' +import { childOfNodeWithType, getOptionalArgumentText } from './common' import { NodeIntersectsChangeFn, ProjectionItem } from './projection' /** @@ -8,15 +8,34 @@ import { NodeIntersectsChangeFn, ProjectionItem } from './projection' */ export class Command extends ProjectionItem { readonly title: string = '' - readonly optionalArgCount: number = 0 - readonly requiredArgCount: number = 0 + 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` and `\renewcommand` are treated specially + * `\newcommand`, `\renewcommand`, `\newenvironment`, `\renewenvironment` + * and `\def` are treated specially. */ export const enterNode = ( state: EditorState, @@ -29,19 +48,13 @@ export const enterNode = ( // This should already be in `items` return } - let commandName = node.node.getChild('LiteralArgContent') - if (!commandName) { - commandName = node.node.getChild('Csname') - } - if (!commandName) { - return - } - const commandNameText = state.doc.sliceString( - commandName.from, - commandName.to - ) - if (commandNameText.length < 1) { + const commandName = getCommandName(node.node, state, [ + 'LiteralArgContent', + 'Csname', + ]) + + if (commandName === null) { return } @@ -66,7 +79,7 @@ export const enterNode = ( items.push({ line: state.doc.lineAt(node.from).number, - title: commandNameText, + title: commandName, from: node.from, to: node.to, optionalArgCount: commandDefinitionHasOptionalArgument ? 1 : 0, @@ -74,12 +87,66 @@ export const enterNode = ( 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 @@ -96,10 +163,7 @@ export const enterNode = ( if (!commandNode) { return } - if (!nodeIntersectsChange(node.node)) { - // This should already be in `items` - return - } + const ctrlSeq = commandNode.getChild('$CtrlSeq') if (!ctrlSeq) { return diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts index 69fe55dfcb..c8d3cbd4c7 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts @@ -78,3 +78,27 @@ export const nodeHasError = (node: SyntaxNode): boolean => { 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 +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts index 74dbde5ca5..0fe75ca893 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts @@ -7,14 +7,16 @@ import { FigureData } from '../../extensions/figure-modal' const HUNDRED_MS = 100 -export class EnvironmentName extends ProjectionItem { +export class Environment extends ProjectionItem { readonly title: string = '' + readonly type: 'usage' | 'definition' = 'usage' + readonly raw: string = '' } export const enterNode = ( state: EditorState, node: SyntaxNodeRef, - items: EnvironmentName[], + items: Environment[], nodeIntersectsChange: NodeIntersectsChangeFn ): any => { if (node.type.is('EnvNameGroup')) { @@ -38,11 +40,13 @@ export const enterNode = ( return false } - const thisEnvironmentName = { + 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) @@ -65,11 +69,13 @@ export const enterNode = ( return } - const thisEnvironmentName = { + 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)