diff --git a/internal/lint/math.go b/internal/lint/math.go index 7dcec782..1200cbd5 100644 --- a/internal/lint/math.go +++ b/internal/lint/math.go @@ -1,6 +1,8 @@ package lint import ( + "bytes" + "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" @@ -15,14 +17,17 @@ import ( // them as elements vale skips -- so equations aren't spell-checked as prose // (#878, #839). // -// The inline parser is ours rather than goldmark-mathjax's: that one takes any -// two `$` on a line as delimiters, so `$5 and $10` reads as math and the prose -// between them is silently dropped from linting. See mathInlineParser. +// Neither parser is goldmark-mathjax's. Its inline parser takes any two `$` on +// a line as delimiters, so `$5 and $10` reads as math and the prose between +// them is silently dropped (see mathInlineParser). Its block parser only ever +// closes on a line that is nothing but `$$`, so `$$x=1$$`, a content line that +// ends in `$$`, and a `$$ … $$ {#eq-foo}` label all leave the block open -- and +// an unclosed block consumes the rest of the file (see mathBlockParser, #1148). type mathExtension struct{} func (mathExtension) Extend(m goldmark.Markdown) { m.Parser().AddOptions(parser.WithBlockParsers( - util.Prioritized(mathjax.NewMathJaxBlockParser(), 701), + util.Prioritized(mathBlockParser{}, 701), )) m.Parser().AddOptions(parser.WithInlineParsers( // '$' has no built-in owner. @@ -36,6 +41,146 @@ func (mathExtension) Extend(m goldmark.Markdown) { )) } +// mathBlockParser reads `$$…$$` display math into a mathjax.MathBlock, the node +// the renderer already knows how to skip. Unlike goldmark-mathjax's parser it +// recognizes every place Pandoc closes the block, not just a bare `$$` line: +// the opener may also close (`$$x=1$$`), a content line may end in `$$` +// (`\end{aligned}$$`), and a Pandoc/Quarto label may trail the close +// (`$$ … $$ {#eq-foo}`). Missing those left the block open, and an open block +// swallows the rest of the document unlinted (#1148). +type mathBlockParser struct{} + +// mathBlockData carries the opener's indent, so content lines dedent to match, +// and whether the opener already closed the block on its own line. +type mathBlockData struct { + indent int + complete bool +} + +var mathBlockInfoKey = parser.NewContextKey() + +func (mathBlockParser) Trigger() []byte { return []byte{'$'} } + +func (mathBlockParser) Open(_ ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + line, segment := reader.PeekLine() + pos := pc.BlockOffset() + if pos < 0 || line[pos] != '$' { + return nil, parser.NoChildren + } + i := pos + for i < len(line) && line[i] == '$' { + i++ + } + if i-pos < 2 { + return nil, parser.NoChildren + } + + node := mathjax.NewMathBlock() + data := &mathBlockData{indent: pos} + + // A close later on the opening line makes this single-line display math: + // `$$x=1$$` or `$$ … $$ {#eq-foo}`. Keep the whole line as content so none + // of it -- delimiters, equation, or label -- is linted as prose. + if mathCloseKind(line[i:]) != mathNoClose { + node.Lines().Append(text.NewSegment(segment.Start+pos, segment.Stop)) + data.complete = true + } + + pc.Set(mathBlockInfoKey, data) + return node, parser.NoChildren +} + +func (mathBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + data := pc.Get(mathBlockInfoKey).(*mathBlockData) //nolint:errcheck // set in Open + if data.complete { + // The opener closed the block; this line belongs to what follows. + return parser.Close + } + + line, segment := reader.PeekLine() + w, pos := util.IndentWidth(line, 0) + if w < 4 { + switch mathCloseKind(line[pos:]) { + case mathBareClose: + // A line of just `$$`: markup, not content -- drop it and close, + // as goldmark-mathjax did. + reader.Advance(segment.Stop - segment.Start - segment.Padding) + return parser.Close + case mathContentClose: + // `$$` ends a content line (`\end{aligned}$$`) or trails a label + // (`$$ {#eq-foo}`). Keep the whole line as content, then close. + node.Lines().Append(text.NewSegment(segment.Start+pos, segment.Stop)) + reader.Advance(segment.Stop - segment.Start - segment.Padding) + return parser.Close + case mathNoClose: + } + } + + pos, padding := util.DedentPosition(line, 0, data.indent) + seg := text.NewSegmentPadding(segment.Start+pos, segment.Stop, padding) + node.Lines().Append(seg) + reader.AdvanceAndSetPadding(segment.Stop-segment.Start-pos-1, padding) + return parser.Continue | parser.NoChildren +} + +func (mathBlockParser) Close(_ ast.Node, _ text.Reader, pc parser.Context) { + pc.Set(mathBlockInfoKey, nil) +} + +func (mathBlockParser) CanInterruptParagraph() bool { return true } +func (mathBlockParser) CanAcceptIndentedLine() bool { return false } + +// mathClose is how a display-math line ends: not a close, a bare `$$` line, or +// a `$$` that closes a line carrying other content (equation text or a label). +type mathClose int + +const ( + mathNoClose mathClose = iota + mathBareClose + mathContentClose +) + +// mathCloseKind reports whether s -- the tail of a line, with its trailing +// newline still attached -- closes display math. A bare close is a line of +// nothing but `$` delimiters; a content close ends in `$$` after an optional +// trailing Pandoc/Quarto `{…}` label, but carries other text as well. +func mathCloseKind(s []byte) mathClose { + trimmed := bytes.TrimRight(s, " \t\r\n") + body := trimMathLabel(trimmed) + if len(body) < 2 || !bytes.HasSuffix(body, []byte("$$")) { + return mathNoClose + } + if bytes.Equal(body, trimmed) && isAllDollars(body) { + return mathBareClose + } + return mathContentClose +} + +// trimMathLabel drops a single trailing `{…}` attribute list, the form Pandoc +// and Quarto use to label an equation (`$$ … $$ {#eq-foo}`). +func trimMathLabel(s []byte) []byte { + if len(s) == 0 || s[len(s)-1] != '}' { + return s + } + if i := bytes.LastIndexByte(s, '{'); i >= 0 { + return bytes.TrimRight(s[:i], " \t") + } + return s +} + +// isAllDollars reports whether s is two or more `$` and nothing else. +func isAllDollars(s []byte) bool { + if len(s) < 2 { + return false + } + for _, c := range s { + if c != '$' { + return false + } + } + return true +} + // kindInlineMath identifies a `$…$` span. var kindInlineMath = ast.NewNodeKind("ValeInlineMath") diff --git a/internal/lint/math_test.go b/internal/lint/math_test.go index d7383b1f..64ad5a77 100644 --- a/internal/lint/math_test.go +++ b/internal/lint/math_test.go @@ -86,3 +86,54 @@ func TestMathInline(t *testing.T) { }) } } + +// TestMathBlock pins where `$$…$$` display math closes. goldmark-mathjax closed +// only on a line of nothing but `$$`, so any other Pandoc shape left the block +// open and, as an open block does, hid the rest of the document from linting +// (#1148). Each case renders the equation as `pre` -- text vale skips -- and +// keeps the paragraph after it as prose vale still checks. +func TestMathBlock(t *testing.T) { + cases := []struct { + description string + content string + expected string + }{ + { + description: "opener also closes on one line", + content: "Intro.\n\n$$x=1$$\n\nAfter.\n", + expected: "
Intro.
\n$$x=1$$\n\n
After.
\n", + }, + { + description: "closing delimiter ends a content line", + content: "$$\n\\begin{aligned}\nx=1\n\\end{aligned}$$\n\nAfter.\n", + expected: "\\begin{aligned}\nx=1\n\\end{aligned}$$\n\nAfter.
\n", + }, + { + description: "single line with a trailing label", + content: "$$ x=1 $$ {#eq-foo}\n\nAfter.\n", + expected: "$$ x=1 $$ {#eq-foo}\n\nAfter.
\n", + }, + { + description: "label after the closing delimiter", + content: "$$\nx=1\n$$ {#eq-foo}\n\nAfter.\n", + expected: "x=1\n$$ {#eq-foo}\n\nAfter.
\n", + }, + { + description: "bare closing line still closes", + content: "Intro.\n\n$$\ng_i = g(p)_i\n$$\n\nAfter.\n", + expected: "Intro.
\ng_i = g(p)_i\n\n
After.
\n", + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + var buf bytes.Buffer + if err := goldQmd.Convert([]byte(c.content), &buf); err != nil { + t.Fatalf("Convert returned an error: %s", err) + } + if got := buf.String(); got != c.expected { + t.Fatalf("Expected %q, but got %q", c.expected, got) + } + }) + } +}