diff --git a/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts b/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts index b416f32e07..bc2988c9de 100644 --- a/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts +++ b/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts @@ -23,32 +23,53 @@ const LEVELS: NestingLevel[] = [ // after it, so this stays clear of code. const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/ +// Count unescaped '$' signs on a line to track math-mode parity. +function countDollars(text: string): number { + let count = 0 + for (let i = 0; i < text.length; i++) { + if (text[i] === '\\') { i++; continue } + if (text[i] === '$') count++ + } + return count +} + function computeOutline( state: EditorState ): ProjectionResult { const items: FlatOutlineItem[] = [] + // Track whether we are inside a multi-line display math block. + // Each line with an odd number of unescaped '$' toggles the flag. + let inMath = false for (let n = 1; n <= state.doc.lines; n++) { const line = state.doc.line(n) - const match = HEADING_REGEX.exec(line.text) - if (!match) continue + const text = line.text - const depth = match[1].length - const level = LEVELS[Math.min(depth, LEVELS.length) - 1] - // Strip a trailing line comment, then a trailing label. - const title = match[2] - .replace(/\s*\/\/.*$/, '') - .replace(/\s*<[\w-]+>\s*$/, '') - .trim() + // Only attempt heading detection when not inside a math block. + // (e.g. '= b+c$' on the second line of '$ a \n= b+c$' must be skipped.) + if (!inMath) { + const match = HEADING_REGEX.exec(text) + if (match) { + const depth = match[1].length + const level = LEVELS[Math.min(depth, LEVELS.length) - 1] + // Strip a trailing line comment, then a trailing label. + const title = match[2] + .replace(/\s*\/\/.*$/, '') + .replace(/\s*<[\w-]+>\s*$/, '') + .trim() - items.push({ - line: n, - toLine: n, - title, - from: line.from, - to: line.to, - level, - } as FlatOutlineItem) + items.push({ + line: n, + toLine: n, + title, + from: line.from, + to: line.to, + level, + } as FlatOutlineItem) + } + } + + if (countDollars(text) % 2 === 1) inMath = !inMath } return { items, status: ProjectionStatus.Complete } diff --git a/services/web/frontend/js/features/source-editor/languages/typst/index.ts b/services/web/frontend/js/features/source-editor/languages/typst/index.ts index ba3f3d7014..7824d929a8 100644 --- a/services/web/frontend/js/features/source-editor/languages/typst/index.ts +++ b/services/web/frontend/js/features/source-editor/languages/typst/index.ts @@ -49,10 +49,12 @@ export const TypstLanguage = LRLanguage.define({ CodeBool: t.atom, // Identifiers: - // - direct child of CallExpr → function/method name - // - CodeArgKey (named arg key, emitted by tokenizer before ':') → attributeName - // - everywhere else → plain variable + // CallExpr/CodeIdent — top-level #func or after keywords (#set text) → function style + // FuncExpr/CodeIdent — func call inside a value expr (has args/method) → function style + // CodeArgKey — named arg key (tokenizer pre-disambiguates on ':') → attributeName + // CodeIdent — plain variable/constant reference (e.g. 'left', 'center') → variable 'CallExpr/CodeIdent': t.function(t.variableName), + 'FuncExpr/CodeIdent': t.function(t.variableName), CodeArgKey: t.attributeName, CodeIdent: t.variableName, diff --git a/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs index 23e276d672..6b50dffc42 100644 --- a/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs +++ b/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs @@ -370,7 +370,12 @@ export const codeIdentTokenizer = new ExternalTokenizer( } for (let i = 0; i < len; i++) input.advance() - input.acceptToken(isArgKey ? CodeArgKey : CodeIdent) + if (isArgKey && couldBeKey) { + input.acceptToken(CodeArgKey) + } else if (couldBeIdent) { + input.acceptToken(CodeIdent) + } + // else: neither token is valid in this state — don't emit }, { contextual: true } ) diff --git a/services/web/frontend/js/features/source-editor/lezer-typst/typst.grammar b/services/web/frontend/js/features/source-editor/lezer-typst/typst.grammar index 52309e3324..259d93de0c 100644 --- a/services/web/frontend/js/features/source-editor/lezer-typst/typst.grammar +++ b/services/web/frontend/js/features/source-editor/lezer-typst/typst.grammar @@ -74,7 +74,13 @@ codeExprBody { KeywordExpr { CodeKeyword CallExpr? } AtomExpr { CodeBool } +// CallExpr allows zero suffixes — used at top level (#x) and after keywords +// (#set text(...)) where even a bare identifier is valid as a named reference. CallExpr { CodeIdent callSuffix* } +// FuncExpr requires at least one suffix — used inside codeValue so that +// 'table(...)' gets tok-function while plain identifiers like 'left'/'center' +// get tok-variableName instead of being false-positively styled as functions. +FuncExpr { CodeIdent callSuffix+ } callSuffix { CodeArgs | "." CodeIdent @@ -91,7 +97,8 @@ codeValue { CodeString | CodeNumber | CodeBool | - CallExpr | + FuncExpr | + CodeIdent | ContentBlock | CodeBlock | InlineMath