Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 149 additions & 4 deletions internal/lint/math.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package lint

import (
"bytes"

"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
Expand All @@ -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.
Expand All @@ -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")

Expand Down
51 changes: 51 additions & 0 deletions internal/lint/math_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>Intro.</p>\n<pre>$$x=1$$\n</pre>\n<p>After.</p>\n",
},
{
description: "closing delimiter ends a content line",
content: "$$\n\\begin{aligned}\nx=1\n\\end{aligned}$$\n\nAfter.\n",
expected: "<pre>\\begin{aligned}\nx=1\n\\end{aligned}$$\n</pre>\n<p>After.</p>\n",
},
{
description: "single line with a trailing label",
content: "$$ x=1 $$ {#eq-foo}\n\nAfter.\n",
expected: "<pre>$$ x=1 $$ {#eq-foo}\n</pre>\n<p>After.</p>\n",
},
{
description: "label after the closing delimiter",
content: "$$\nx=1\n$$ {#eq-foo}\n\nAfter.\n",
expected: "<pre>x=1\n$$ {#eq-foo}\n</pre>\n<p>After.</p>\n",
},
{
description: "bare closing line still closes",
content: "Intro.\n\n$$\ng_i = g(p)_i\n$$\n\nAfter.\n",
expected: "<p>Intro.</p>\n<pre>g_i = g(p)_i\n</pre>\n<p>After.</p>\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)
}
})
}
}