maquina_remend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Closes an application tag whose closing tag has not streamed in yet.
8
+ #
9
+ # Models emit XML-ish tags that carry application meaning rather than
10
+ # presentation — `<thinking>`, `<answer>`, `<tool_call>`, `<citation>`,
11
+ # `<scratchpad>`. MaquinaRemend::Handlers::HtmlTags already drops one that
12
+ # is still half-typed. This handler covers the other half of the problem: a
13
+ # *complete* opening tag whose closer is still minutes of tokens away.
14
+ #
15
+ # Enabled by `app_tags:`, whose value is the list of tag names to close;
16
+ # MaquinaRemend::APP_TAGS by default, `false` to switch the handler off.
17
+ #
18
+ # ```ruby
19
+ # MaquinaRemend.call("<thinking>\nI am partway thro")
20
+ # # => "<thinking>\nI am partway thro</thinking>"
21
+ # ```
22
+ #
23
+ # ## Why an unclosed app tag is a broken document
24
+ #
25
+ # CommonMark ends an HTML block at the first blank line (spec §4.6,
26
+ # condition 7). An unclosed `<thinking>` therefore stops containing the
27
+ # stream as soon as the model types a blank line — measured with
28
+ # commonmarker 2.10:
29
+ #
30
+ # ```
31
+ # "<thinking>\nreasoning\n\nmore\n\nDone."
32
+ # # <thinking>
33
+ # # reasoning
34
+ # # <p>more</p>
35
+ # # <p>Done.</p>
36
+ # ```
37
+ #
38
+ # The paragraphs escape the tag in the AST, while in the browser's DOM they
39
+ # are swallowed by it — an unknown element is never implicitly closed, so
40
+ # every later block nests inside `<thinking>` and the whole answer renders
41
+ # as reasoning. Closing the tag makes the two agree.
42
+ #
43
+ # ## Where the closer goes
44
+ #
45
+ # Placement is not cosmetic; the wrong separator produces
46
+ # `<p></thinking></p>`, which nests the closer inside a paragraph:
47
+ #
48
+ # | Innermost open tag | Appended | Renders as |
49
+ # |---|---|---|
50
+ # | in the current paragraph | `</thinking>` flush | one balanced HTML block |
51
+ # | before the last blank line | a blank line, then one closer per line | a closing HTML block per tag |
52
+ #
53
+ # Nested tags are closed innermost first. A tag inside a fenced code block
54
+ # or an inline code span is content, not markup: those spans are masked out
55
+ # by MaquinaRemend::Scanner before the stack is built, so the fence wins.
56
+ # A self-closing `<citation id="1"/>` opens nothing and is skipped. While
57
+ # the tail is inside an *unterminated* fence nothing is repaired at all, this
58
+ # handler included: the tag stays open until the fence closes, because a
59
+ # closer appended there would land inside the code block.
60
+ #
61
+ # ## Why it runs last
62
+ #
63
+ # It appends past the end of every other repair. Running it earlier would
64
+ # let MaquinaRemend::Handlers::Emphasis close a bold run *after* the closing
65
+ # tag — `<thinking>\n**bold\n</thinking>**` — putting the emphasis outside
66
+ # the element it belongs to.
67
+ class AppTags < Base
68
+ # Any complete tag: group 1 is the closing slash, group 2 the name,
69
+ # group 3 the self-closing slash. Attributes are skipped over rather than
70
+ # parsed; a tag whose `>` has not arrived is not matched at all, because
71
+ # MaquinaRemend::Handlers::HtmlTags has already dropped it.
72
+ TAG = %r{<(/?)([A-Za-z][A-Za-z0-9_:-]*)(?:\s[^<>]*?)?(/?)>}
73
+
74
+ private
75
+ def option_key = :app_tags
76
+
77
+ def names
78
+ @names ||= Array(options[:app_tags]).map { |name| name.to_s.downcase }
79
+ end
80
+
81
+ def repair(text, scanner)
82
+ return text if names.empty?
83
+
84
+ stack = unclosed(scanner)
85
+ return text if stack.empty?
86
+
87
+ candidate = text + closers(stack, scanner)
88
+ stable?(candidate) ? candidate : text
89
+ end
90
+
91
+ # The closers land past the end of every other repair, so the *next*
92
+ # pass reads them as content: a buffer ending in a lone `*` has nothing
93
+ # to emphasise until `</answer>` is appended after it, and then the pass
94
+ # after that emphasises the closing tag. Rather than guess which handler
95
+ # is at risk, ask them. If replaying the rest of the pipeline over the
96
+ # closed buffer would change it, the tag is left open for this one frame
97
+ # and closed on the next, when the tail has moved on — an unstable
98
+ # repair is worse than a tag that is closed a character late.
99
+ #
100
+ # Custom handlers are left out of the probe: they are the host's to keep
101
+ # idempotent, and one that rewrites unconditionally would otherwise
102
+ # switch this repair off for good.
103
+ def stable?(candidate)
104
+ Pipeline.new(**options.merge(app_tags: false, handlers: [])).call(candidate) == candidate
105
+ end
106
+
107
+ # The tags still open at the end of the buffer, outermost first, each as
108
+ # a `[name, offset]` pair. Built over the masked text so that a tag
109
+ # written inside a fence or a code span never opens anything.
110
+ def unclosed(scanner)
111
+ stack = []
112
+
113
+ scanner.masked_text.to_enum(:scan, TAG).each do
114
+ match = Regexp.last_match
115
+ name = match[2].downcase
116
+ next unless names.include?(name)
117
+
118
+ if match[1].empty?
119
+ stack << [name, match.begin(0)] if match[3].empty?
120
+ elsif (index = stack.rindex { |open, _| open == name })
121
+ stack.slice!(index..)
122
+ end
123
+ end
124
+
125
+ stack
126
+ end
127
+
128
+ # The text to append. Flush against the tail when the innermost open tag
129
+ # is in the same block as the tail, and otherwise after a blank line,
130
+ # one closer per line — a line holding two closing tags is not an HTML
131
+ # block and would be wrapped in a paragraph instead.
132
+ def closers(stack, scanner)
133
+ tags = stack.reverse.map { |name, _| "</#{name}>" }
134
+ return tags.join if stack.last[1] >= scanner.paragraph_offset
135
+
136
+ "\n" * [2 - scanner.text[/\n*\z/].length, 0].max + tags.join("\n")
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaRemend
4
+ # One class per repair. Each is listed against the option that switches it on
5
+ # in MaquinaRemend::DEFAULTS, and each runs in the fixed order given by
6
+ # MaquinaRemend::Pipeline::BUILT_INS.
7
+ #
8
+ # A *custom* handler does not live here and does not inherit from
9
+ # MaquinaRemend::Handlers::Base — it only has to respond to
10
+ # `#call(text, context)`. See MaquinaRemend.call for that contract. This
11
+ # namespace is documented because reading a built-in is the fastest way to
12
+ # see what a careful repair looks like.
13
+ module Handlers
14
+ # A handler sees the buffer and the context, and returns a buffer. It rescans
15
+ # rather than trusting an earlier handler's view of the text, because an
16
+ # earlier handler may have appended to the tail it is about to read.
17
+ #
18
+ # ## Writing one
19
+ #
20
+ # A built-in overrides two private methods:
21
+ #
22
+ # * `#option_key` — the MaquinaRemend::DEFAULTS key that enables it. A
23
+ # handler covering more than one option overrides `#enabled?` instead, as
24
+ # MaquinaRemend::Handlers::Links and MaquinaRemend::Handlers::Math do.
25
+ # * `#repair(text, scanner)` — the repair itself, returning the new buffer
26
+ # or `text` unchanged. It is only reached when the handler is enabled and
27
+ # the tail is not inside a fence; #call has already checked both.
28
+ #
29
+ # Two private helpers are available to a subclass: `#streaming_tail?`, true
30
+ # when the buffer's last line has not been terminated by a newline, and
31
+ # `#escape_matches`, which backslash-escapes a pattern in the paragraph
32
+ # without ever touching a character inside inline code or math.
33
+ #
34
+ # None of this applies to a host's own handler, which is a plain object with
35
+ # a two-argument `#call`; MaquinaRemend.call has that contract.
36
+ class Base
37
+ # The resolved option hash the pipeline was built with.
38
+ attr_reader :options
39
+
40
+ # * `options` — the resolved hash from MaquinaRemend::Pipeline#options,
41
+ # not a partial one. Handlers read their own flags out of it.
42
+ def initialize(options)
43
+ @options = options
44
+ end
45
+
46
+ # Returns the buffer, repaired or untouched.
47
+ #
48
+ # Returns `text` unchanged when this handler's option is off, or when the
49
+ # tail sits inside a fenced code block — the built-ins never complete
50
+ # anything inside a fence. Otherwise the work is delegated to the
51
+ # subclass's `#repair`.
52
+ #
53
+ # The scanner is passed in when the pipeline already has a current one:
54
+ # scanning is the expensive part, and most handlers leave the text alone.
55
+ # Custom handlers keep the two-argument contract documented on
56
+ # MaquinaRemend.call.
57
+ def call(text, context, scanner = nil)
58
+ return text unless enabled?
59
+ return text if context.in_code_fence?
60
+
61
+ repair(text, scanner || Scanner.new(text))
62
+ end
63
+
64
+ private
65
+ # Which option switches this handler off. Handlers covering more than one
66
+ # option override #enabled? instead.
67
+ def option_key
68
+ raise NotImplementedError
69
+ end
70
+
71
+ def enabled?
72
+ options.fetch(option_key, false)
73
+ end
74
+
75
+ def repair(text, _scanner)
76
+ text
77
+ end
78
+
79
+ # True when the buffer's last line is still arriving, rather than having
80
+ # been terminated by a newline. Repairs that would rewrite a complete
81
+ # document belong behind this.
82
+ def streaming_tail?(text)
83
+ !text.end_with?("\n")
84
+ end
85
+
86
+ # Backslash-escapes every match of `pattern` in the paragraph.
87
+ #
88
+ # Matches are located in the masked paragraph and applied to the raw one,
89
+ # so a character inside inline code or math is never escaped: the mask
90
+ # blanks those spans but preserves their length, which keeps the offsets
91
+ # usable against the original text.
92
+ def escape_matches(scanner, pattern, skip_line: ->(_line) { false })
93
+ raw = scanner.paragraph.dup
94
+ offsets = []
95
+ line_offset = 0
96
+
97
+ scanner.masked_paragraph.lines.each do |line|
98
+ unless skip_line.call(line)
99
+ line.to_enum(:scan, pattern).each { offsets << line_offset + Regexp.last_match.begin(0) }
100
+ end
101
+ line_offset += line.length
102
+ end
103
+
104
+ offsets.reverse_each { |index| raw.insert(index, "\\") }
105
+ scanner.prefix + raw
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Case 27. A ">" used as a comparison inside a line is escaped, so it cannot
8
+ # be mistaken for a blockquote marker when the line is re-parsed. A ">" that
9
+ # genuinely starts a line is left alone: that one really is a blockquote.
10
+ #
11
+ # Enabled by `comparison_operators:`, on by default.
12
+ #
13
+ # ```ruby
14
+ # MaquinaRemend.call("- a > b") # => "- a \\> b"
15
+ # MaquinaRemend.call("> quoted") # => "> quoted"
16
+ # ```
17
+ class ComparisonOperators < Base
18
+ # A `>` surrounded by whitespace and not already escaped — a comparison,
19
+ # not a blockquote marker.
20
+ COMPARISON = /(?<=\s)(?<!\\)>(?=\s)/
21
+ # A line that genuinely starts with a blockquote marker. Lines matching
22
+ # this are skipped entirely.
23
+ BLOCKQUOTE_LINE = /\A {0,3}>/
24
+
25
+ private
26
+ def option_key = :comparison_operators
27
+
28
+ def repair(_text, scanner)
29
+ escape_matches(scanner, COMPARISON, skip_line: ->(line) { line.match?(BLOCKQUOTE_LINE) })
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Case 14. A backslash at the very end of the buffer is half a token — the
8
+ # start of "\frac", "\neq", "\*" — and the rest of it has not arrived.
9
+ #
10
+ # It has to go before anything else is appended, because a backslash escapes
11
+ # whatever lands next: closing block math after a dangling backslash yields
12
+ # "\$$", an escaped dollar that leaves the math still open, so the next pass
13
+ # appends another "$$", and the next, and the next. Found by replaying real
14
+ # streamed model output, not by reading the spec.
15
+ #
16
+ # Only at end of buffer. A backslash at the end of a *line* is a hard line
17
+ # break and is left exactly where it is.
18
+ #
19
+ # Enabled by `dangling_escape:`, on by default. Runs first in
20
+ # MaquinaRemend::Pipeline::BUILT_INS.
21
+ #
22
+ # ```ruby
23
+ # MaquinaRemend.call("$$x = \\frac{-b \\") # => "$$x = \\frac{-b $$"
24
+ # ```
25
+ class DanglingEscape < Base
26
+ # The run of backslashes at the very end of the buffer. The run is
27
+ # captured whole because its parity decides everything: an even run is a
28
+ # complete escaped backslash, an odd one has a dangling half-token.
29
+ TRAILING_BACKSLASHES = /(\\+)\z/
30
+
31
+ private
32
+ def option_key = :dangling_escape
33
+
34
+ def repair(text, _scanner)
35
+ return text unless streaming_tail?(text)
36
+
37
+ match = TRAILING_BACKSLASHES.match(text)
38
+ return text unless match
39
+ return text if match[1].length.even? # an escaped backslash is complete
40
+
41
+ text[0...-1]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Cases 01-04. Closes emphasis runs the stream has opened but not finished.
8
+ #
9
+ # Delimiters are matched with a stack rather than counted, so nesting works:
10
+ # "**bold *italic" leaves two openers and gets "*" then "**" appended, in
11
+ # that order. Underscores between word characters are skipped entirely —
12
+ # they are identifiers, not emphasis (case 24).
13
+ #
14
+ # Enabled by `italic:` for one-character runs, `bold:` for two and
15
+ # `bold_italic:` for three or more; any one of the three switches the
16
+ # handler on, and each run is then checked against the flag for its own
17
+ # length.
18
+ #
19
+ # ```ruby
20
+ # MaquinaRemend.call("**bold *italic") # => "**bold *italic***"
21
+ # MaquinaRemend.call("some_var_name") # => "some_var_name"
22
+ # ```
23
+ class Emphasis < Base
24
+ # One delimiter run: a `*` or `_` and every repeat of the same character
25
+ # immediately after it.
26
+ RUN = /(?<char>[*_])\k<char>*/
27
+
28
+ # Run length to the option that governs it. Runs longer than three are
29
+ # clamped to three, so `****` is governed by `bold_italic:`.
30
+ LENGTH_OPTIONS = {1 => :italic, 2 => :bold, 3 => :bold_italic}.freeze
31
+
32
+ private
33
+ def enabled?
34
+ LENGTH_OPTIONS.values.any? { |key| options[key] }
35
+ end
36
+
37
+ def repair(text, scanner)
38
+ openers = unclosed_runs(scanner.masked_paragraph)
39
+ return text if openers.empty?
40
+
41
+ text + openers.reverse.map { |run| completion_for(run) }.join
42
+ end
43
+
44
+ def completion_for(run)
45
+ run
46
+ end
47
+
48
+ def unclosed_runs(paragraph)
49
+ stack = []
50
+
51
+ paragraph.to_enum(:scan, RUN).each do
52
+ match = Regexp.last_match
53
+ run = match[0]
54
+ next if skip?(paragraph, match)
55
+ next unless completable?(run)
56
+
57
+ if closes?(stack, paragraph, match) || closing_in_progress?(stack, paragraph, match)
58
+ stack.pop
59
+ elsif opens?(paragraph, match)
60
+ stack.push(run)
61
+ end
62
+ end
63
+
64
+ stack
65
+ end
66
+
67
+ def completable?(run)
68
+ key = LENGTH_OPTIONS[[run.length, 3].min]
69
+ options.fetch(key, false)
70
+ end
71
+
72
+ # Underscores inside a word never open or close emphasis.
73
+ def skip?(paragraph, match)
74
+ return false unless match[0].start_with?("_")
75
+
76
+ word_char?(preceding(paragraph, match)) && word_char?(following(paragraph, match))
77
+ end
78
+
79
+ def opens?(paragraph, match)
80
+ char = following(paragraph, match)
81
+ !char.nil? && !char.match?(/\s/)
82
+ end
83
+
84
+ # A delimiter run sitting at the very end of the buffer is the closer
85
+ # being typed, not a new opener: "**Best Use **" is a bold run whose
86
+ # closer arrived with a space in front of it. Appending another "**"
87
+ # would both mangle the text and break idempotence, since the appended
88
+ # run would itself dangle on the next pass.
89
+ def closing_in_progress?(stack, paragraph, match)
90
+ return false if stack.empty?
91
+ return false unless paragraph[match.end(0)..].to_s.match?(/\A\s*\z/)
92
+
93
+ stack.last[0] == match[0][0]
94
+ end
95
+
96
+ def closes?(stack, paragraph, match)
97
+ return false if stack.empty?
98
+
99
+ char = preceding(paragraph, match)
100
+ !char.nil? && !char.match?(/\s/) && stack.last[0] == match[0][0]
101
+ end
102
+
103
+ def preceding(paragraph, match)
104
+ index = match.begin(0) - 1
105
+ index.negative? ? nil : paragraph[index]
106
+ end
107
+
108
+ def following(paragraph, match)
109
+ paragraph[match.end(0)]
110
+ end
111
+
112
+ def word_char?(char)
113
+ !char.nil? && char.match?(/[[:alnum:]]/)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Case 13. A tag whose ">" has not arrived yet is dropped, along with the
8
+ # whitespace in front of it. Leaving it in place makes the renderer swallow
9
+ # everything that streams in afterwards as attribute soup.
10
+ #
11
+ # Enabled by `html_tags:`, on by default.
12
+ #
13
+ # ```ruby
14
+ # MaquinaRemend.call("text <div cla") # => "text"
15
+ # ```
16
+ class HtmlTags < Base
17
+ # An HTML tag opened at the tail of the buffer whose `>` has not arrived.
18
+ TRUNCATED_TAG = %r{<[a-zA-Z/][^<>]*\z}
19
+
20
+ private
21
+ def option_key = :html_tags
22
+
23
+ def repair(text, scanner)
24
+ return text unless streaming_tail?(text)
25
+
26
+ paragraph = scanner.masked_paragraph
27
+ match = TRUNCATED_TAG.match(paragraph)
28
+ return text unless match
29
+
30
+ scanner.prefix + scanner.paragraph[0...match.begin(0)].rstrip
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Case 05. An odd number of backticks in the paragraph means a code span is
8
+ # still open; close it so the renderer does not treat the rest of the
9
+ # message as code.
10
+ #
11
+ # Enabled by `inline_code:`, on by default. Appends one backtick, so
12
+ # `` `code `` becomes `` `code` ``.
13
+ class InlineCode < Base
14
+ private
15
+ def option_key = :inline_code
16
+
17
+ def repair(text, scanner)
18
+ scanner.inline_code_open? ? text + "`" : text
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Cases 07-09. A link or image whose label or destination is still arriving.
8
+ #
9
+ # `link_mode: :protocol` closes the syntax and points the destination at a
10
+ # placeholder. A half-streamed destination is never kept: "https://exa" is a
11
+ # perfectly valid URL to the wrong host, and model output is untrusted.
12
+ #
13
+ # `link_mode: :text_only` unwraps the whole thing to its label, which is the
14
+ # right choice for a host that would rather show no link than a dead one.
15
+ #
16
+ # Enabled by `links:` for `[label](url)` and by `images:` for `![alt](url)` —
17
+ # either flag switches the handler on, and each match is then checked
18
+ # against the flag for its own kind. Shaped by `link_mode:`; see
19
+ # MaquinaRemend::DEFAULTS.
20
+ #
21
+ # ```ruby
22
+ # MaquinaRemend.call("[label") # => "[label]()"
23
+ # MaquinaRemend.call("[label](https://exa") # => "[label](#)"
24
+ # MaquinaRemend.call("![alt", images: false) # => "![alt"
25
+ # ```
26
+ #
27
+ # Runs before MaquinaRemend::Handlers::Emphasis, for the reason
28
+ # MaquinaRemend::Pipeline::BUILT_INS gives.
29
+ class Links < Base
30
+ # What a half-streamed destination is replaced by under
31
+ # `link_mode: :protocol`.
32
+ PLACEHOLDER = "#"
33
+
34
+ # A link or image whose label is still being typed: `[label` or `![alt`,
35
+ # running to the end of the buffer.
36
+ LABEL_OPEN = /(?<image>!)?\[(?<label>[^\[\]]*)\z/
37
+ # A link or image whose label closed but whose destination is still
38
+ # arriving: `[label](https://exa`.
39
+ DESTINATION_OPEN = /(?<image>!)?\[(?<label>[^\[\]]*)\]\((?<destination>[^()\s]*)\z/
40
+
41
+ private
42
+ def enabled? = options[:links] || options[:images]
43
+
44
+ def repair(text, scanner)
45
+ paragraph = scanner.masked_paragraph
46
+
47
+ if (match = DESTINATION_OPEN.match(paragraph))
48
+ close_destination(text, match)
49
+ elsif (match = LABEL_OPEN.match(paragraph))
50
+ close_label(text, match)
51
+ else
52
+ text
53
+ end
54
+ end
55
+
56
+ def close_destination(text, match)
57
+ return text unless allowed?(match)
58
+
59
+ if text_only?
60
+ text[0...-match[0].length] + match[:label]
61
+ else
62
+ text[0...-match[:destination].length] + PLACEHOLDER + ")"
63
+ end
64
+ end
65
+
66
+ def close_label(text, match)
67
+ return text unless allowed?(match)
68
+
69
+ text_only? ? text[0...-match[0].length] + match[:label] : text + "]()"
70
+ end
71
+
72
+ def allowed?(match)
73
+ match[:image] ? options[:images] : options[:links]
74
+ end
75
+
76
+ def text_only? = options[:link_mode] == :text_only
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Cases 10 and 11. Block math is closed by default; inline math is not,
8
+ # because "$5 and $10" is currency far more often than it is mathematics
9
+ # (case 26). Turning inline_math on is a decision about a corpus, and only
10
+ # the host knows its corpus.
11
+ #
12
+ # Enabled by `block_math:` (on) for `$$...$$` and by `inline_math:` (off)
13
+ # for `$...$`.
14
+ #
15
+ # ```ruby
16
+ # MaquinaRemend.call("$$x = 1") # => "$$x = 1$$"
17
+ # MaquinaRemend.call("costs $5 and $10") # => "costs $5 and $10"
18
+ # MaquinaRemend.call("$x = 1", inline_math: true) # => "$x = 1$"
19
+ # ```
20
+ class Math < Base
21
+ private
22
+ def enabled? = options[:block_math] || options[:inline_math]
23
+
24
+ def repair(text, scanner)
25
+ text = close_block(text, scanner)
26
+ close_inline(text, Scanner.new(text))
27
+ end
28
+
29
+ def close_block(text, scanner)
30
+ return text unless options[:block_math]
31
+
32
+ scanner.math_open? ? text + "$$" : text
33
+ end
34
+
35
+ def close_inline(text, scanner)
36
+ return text unless options[:inline_math]
37
+
38
+ paragraph = scanner.masked_paragraph.gsub("$$", "")
39
+ paragraph.scan(/(?<!\\)\$/).length.odd? ? text + "$" : text
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MaquinaRemend
6
+ module Handlers
7
+ # Case 12. A setext underline that is still arriving — "Title\n=" — already
8
+ # parses as a heading, so the repair is not about the block type. It is
9
+ # about the underline being visibly half-drawn while the rest of it streams
10
+ # in: pad it to the width of the heading text and it stops growing on screen.
11
+ #
12
+ # Only fires on a tail that is still streaming. A buffer that ended with a
13
+ # newline has a complete underline, and rewriting it would break the
14
+ # byte-identical passthrough that case 28 asserts.
15
+ #
16
+ # Enabled by `setext_headings:`, on by default.
17
+ #
18
+ # ```ruby
19
+ # MaquinaRemend.call("Title\n=") # => "Title\n====="
20
+ # ```
21
+ class SetextHeading < Base
22
+ # A setext underline on its own line: up to three spaces of indent, then
23
+ # a run of `=` or `-`, then nothing but trailing whitespace.
24
+ UNDERLINE = /\A( {0,3})(=+|-+)[ \t]*\z/
25
+
26
+ private
27
+ def option_key = :setext_headings
28
+
29
+ def repair(text, _scanner)
30
+ return text unless streaming_tail?(text)
31
+
32
+ lines = text.lines
33
+ return text if lines.length < 2
34
+
35
+ underline = lines.last.chomp
36
+ title = lines[-2].chomp
37
+ return text unless (match = UNDERLINE.match(underline))
38
+ return text if title.strip.empty?
39
+ return text if match[2].length >= title.strip.length
40
+
41
+ lines[-1] = match[1] + match[2][0] * title.strip.length
42
+ lines.join
43
+ end
44
+ end
45
+ end
46
+ end