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.
- checksums.yaml +7 -0
- data/.rdoc_options +16 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +169 -0
- data/docs/handlers.md +223 -0
- data/docs/repairs.md +391 -0
- data/docs/streaming.md +162 -0
- data/lib/maquina_remend/context.rb +86 -0
- data/lib/maquina_remend/handlers/app_tags.rb +140 -0
- data/lib/maquina_remend/handlers/base.rb +109 -0
- data/lib/maquina_remend/handlers/comparison_operators.rb +33 -0
- data/lib/maquina_remend/handlers/dangling_escape.rb +45 -0
- data/lib/maquina_remend/handlers/emphasis.rb +117 -0
- data/lib/maquina_remend/handlers/html_tags.rb +34 -0
- data/lib/maquina_remend/handlers/inline_code.rb +22 -0
- data/lib/maquina_remend/handlers/links.rb +79 -0
- data/lib/maquina_remend/handlers/math.rb +43 -0
- data/lib/maquina_remend/handlers/setext_heading.rb +46 -0
- data/lib/maquina_remend/handlers/single_tilde.rb +28 -0
- data/lib/maquina_remend/handlers/strikethrough.rb +25 -0
- data/lib/maquina_remend/pipeline.rb +123 -0
- data/lib/maquina_remend/scanner.rb +187 -0
- data/lib/maquina_remend/version.rb +8 -0
- data/lib/maquina_remend.rb +255 -0
- metadata +80 -0
|
@@ -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 `` —
|
|
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
|