typst: distinguish function calls from value identifiers; fix math outline
Build and Deploy Verso / deploy (push) Successful in 9m49s

Named value identifiers like 'left', 'center', 'right' were being
highlighted as tok-function (blue) because codeValue used CallExpr
(callSuffix*), which styled any identifier in value position as a
function.  Fix: add FuncExpr { CodeIdent callSuffix+ } (requires at
least one argument list or method suffix) and use it in codeValue
instead of CallExpr.  Plain identifiers in value position now fall
through to CodeIdent → tok-variableName.  CallExpr (callSuffix*) is
kept for codeExprBody and KeywordExpr where zero-suffix idents are
valid.

Tokenizer safety: only acceptToken(CodeIdent) when canShift(CodeIdent)
is true, preventing emission in LALR-merged states where neither
CodeArgKey nor CodeIdent is expected.

Outline: track '$'-parity across lines so that lines inside a display
math block (e.g. '= b+c$') are not incorrectly reported as headings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-09 12:04:33 +00:00
parent 4c6032bce0
commit f2b7034b51
4 changed files with 57 additions and 22 deletions
@@ -23,32 +23,53 @@ const LEVELS: NestingLevel[] = [
// after it, so this stays clear of code. // after it, so this stays clear of code.
const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/ 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( function computeOutline(
state: EditorState state: EditorState
): ProjectionResult<FlatOutlineItem> { ): ProjectionResult<FlatOutlineItem> {
const items: FlatOutlineItem[] = [] 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++) { for (let n = 1; n <= state.doc.lines; n++) {
const line = state.doc.line(n) const line = state.doc.line(n)
const match = HEADING_REGEX.exec(line.text) const text = line.text
if (!match) continue
const depth = match[1].length // Only attempt heading detection when not inside a math block.
const level = LEVELS[Math.min(depth, LEVELS.length) - 1] // (e.g. '= b+c$' on the second line of '$ a \n= b+c$' must be skipped.)
// Strip a trailing line comment, then a trailing label. if (!inMath) {
const title = match[2] const match = HEADING_REGEX.exec(text)
.replace(/\s*\/\/.*$/, '') if (match) {
.replace(/\s*<[\w-]+>\s*$/, '') const depth = match[1].length
.trim() 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({ items.push({
line: n, line: n,
toLine: n, toLine: n,
title, title,
from: line.from, from: line.from,
to: line.to, to: line.to,
level, level,
} as FlatOutlineItem) } as FlatOutlineItem)
}
}
if (countDollars(text) % 2 === 1) inMath = !inMath
} }
return { items, status: ProjectionStatus.Complete } return { items, status: ProjectionStatus.Complete }
@@ -49,10 +49,12 @@ export const TypstLanguage = LRLanguage.define({
CodeBool: t.atom, CodeBool: t.atom,
// Identifiers: // Identifiers:
// - direct child of CallExpr → function/method name // CallExpr/CodeIdent — top-level #func or after keywords (#set text) → function style
// - CodeArgKey (named arg key, emitted by tokenizer before ':') → attributeName // FuncExpr/CodeIdent — func call inside a value expr (has args/method) → function style
// - everywhere else → plain variable // 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), 'CallExpr/CodeIdent': t.function(t.variableName),
'FuncExpr/CodeIdent': t.function(t.variableName),
CodeArgKey: t.attributeName, CodeArgKey: t.attributeName,
CodeIdent: t.variableName, CodeIdent: t.variableName,
@@ -370,7 +370,12 @@ export const codeIdentTokenizer = new ExternalTokenizer(
} }
for (let i = 0; i < len; i++) input.advance() 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 } { contextual: true }
) )
@@ -74,7 +74,13 @@ codeExprBody {
KeywordExpr { CodeKeyword CallExpr? } KeywordExpr { CodeKeyword CallExpr? }
AtomExpr { CodeBool } 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* } 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 { callSuffix {
CodeArgs | CodeArgs |
"." CodeIdent "." CodeIdent
@@ -91,7 +97,8 @@ codeValue {
CodeString | CodeString |
CodeNumber | CodeNumber |
CodeBool | CodeBool |
CallExpr | FuncExpr |
CodeIdent |
ContentBlock | ContentBlock |
CodeBlock | CodeBlock |
InlineMath InlineMath