Просмотр исходного кода

Include \def, \let, \newenvironment and \renewenvironment commands in the math preview (#20197)

* Include \def commands in command definitions

* Allow command name to be in CtrlSym

* Pull nodeIntersectsChange check out of branches

* Fix \def handling

* Add handling for \newenvironment and \renewenvironment

* Allow \def to have optional arguments

* :x
Revert "Add handling for \newenvironment and \renewenvironment"

This reverts commit a70d3a0a13ed552daf3b761893e3f8609f0b0fc8.

* Add let commands to defintions

* Add environment names to math preview

* Ignore let command definitions in autocomplete

* Move nodeIntersectsChange check back into each block

* Add childOfNodeWithType utility and use to get command names

* commandNameText -> commandName

* Only include environment definitions

* Rename documentEnvironmentNames to documentEnvironments

* EnvironmentName -> Environment

* Format

GitOrigin-RevId: 9c5d701423ae786e5ff91960b4bcd94cd35d21c8
David 2 лет назад
Родитель
Сommit
9cf94e57d3

+ 11 - 1
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) {

+ 20 - 7
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('')
 }

+ 4 - 4
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<string>()
 
-  const environmentNamesProjection: ProjectionResult<EnvironmentName> =
-    context.state.field(documentEnvironmentNames)
+  const environmentNamesProjection: ProjectionResult<Environment> =
+    context.state.field(documentEnvironments)
   if (!environmentNamesProjection || !environmentNamesProjection.items) {
     return result
   }

+ 3 - 3
services/web/frontend/js/features/source-editor/languages/latex/document-environment-names.ts → 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<EnvironmentName>(enterNode)
+export const documentEnvironments =
+  makeProjectionStateField<Environment>(enterNode)

+ 2 - 2
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(),

+ 85 - 21
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

+ 24 - 0
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
+}

+ 10 - 4
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)