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.
data/docs/repairs.md ADDED
@@ -0,0 +1,391 @@
1
+ # Repairs
2
+
3
+ Every repair `maquina_remend` makes, and every place it refuses to make one.
4
+ One section per repair: the option that controls it, the broken input, the
5
+ repaired output.
6
+
7
+ All outputs below are real. Each is the return value of `MaquinaRemend.call`
8
+ on the input shown, at the stated options.
9
+
10
+ * [Emphasis](#emphasis) — `bold:`, `italic:`, `bold_italic:`
11
+ * [Inline code](#inline-code) — `inline_code:`
12
+ * [Strikethrough](#strikethrough) — `strikethrough:`
13
+ * [Links and images](#links-and-images) — `links:`, `images:`, `link_mode:`
14
+ * [Math](#math) — `block_math:`, `inline_math:`
15
+ * [Setext headings](#setext-headings) — `setext_headings:`
16
+ * [Truncated HTML tags](#truncated-html-tags) — `html_tags:`
17
+ * [Application tags](#application-tags) — `app_tags:`
18
+ * [Comparison operators](#comparison-operators) — `comparison_operators:`
19
+ * [Single tildes](#single-tildes) — `single_tilde:`
20
+ * [Dangling escapes](#dangling-escapes) — `dangling_escape:`
21
+ * [Guards](#guards) — what is never repaired
22
+
23
+ ---
24
+
25
+ ## Emphasis
26
+
27
+ `bold:`, `italic:` and `bold_italic:`, all on by default.
28
+
29
+ An emphasis run opened and not yet closed is closed at the end of the buffer.
30
+ The closing run matches the opening one: three asterisks are closed with three,
31
+ not one.
32
+
33
+ ```ruby
34
+ MaquinaRemend.call("**unclosed bold") # => "**unclosed bold**"
35
+ MaquinaRemend.call("*unclosed") # => "*unclosed*"
36
+ MaquinaRemend.call("_unclosed") # => "_unclosed_"
37
+ MaquinaRemend.call("***both") # => "***both***"
38
+ ```
39
+
40
+ Switch one off and its delimiter is left alone:
41
+
42
+ ```ruby
43
+ MaquinaRemend.call("**unclosed bold", bold: false) # => "**unclosed bold"
44
+ ```
45
+
46
+ The underscore form follows CommonMark's flanking rules, so an underscore
47
+ inside a word is an identifier and not an opener — see
48
+ [Guards](#guards).
49
+
50
+ ## Inline code
51
+
52
+ `inline_code:`, on by default.
53
+
54
+ ```ruby
55
+ MaquinaRemend.call("`code") # => "`code`"
56
+ ```
57
+
58
+ An opening backtick with no partner is given one. The repair does not fire
59
+ inside a fenced code block, where a backtick is content.
60
+
61
+ ## Strikethrough
62
+
63
+ `strikethrough:`, on by default.
64
+
65
+ ```ruby
66
+ MaquinaRemend.call("~~struck") # => "~~struck~~"
67
+ ```
68
+
69
+ A *single* tilde is a different problem with a different answer; see
70
+ [Single tildes](#single-tildes).
71
+
72
+ ## Links and images
73
+
74
+ `links:` for `[label](url)`, `images:` for `![alt](url)`, both on by default.
75
+ `link_mode:` decides what a half-finished one becomes.
76
+
77
+ A label that is still being typed is closed with an empty destination, so
78
+ nothing links anywhere until the real URL arrives:
79
+
80
+ ```ruby
81
+ MaquinaRemend.call("[label") # => "[label]()"
82
+ MaquinaRemend.call("![alt") # => "![alt]()"
83
+ ```
84
+
85
+ A destination that is still being typed is replaced by a placeholder:
86
+
87
+ ```ruby
88
+ MaquinaRemend.call("[label](http") # => "[label](#)"
89
+ MaquinaRemend.call("[label](https://exa") # => "[label](#)"
90
+ ```
91
+
92
+ **A half-streamed destination is never kept.** `https://exa` is a perfectly
93
+ valid URL to the wrong host, and model output is untrusted; the destination is
94
+ a placeholder until the whole of it has arrived.
95
+
96
+ ### `link_mode:`
97
+
98
+ | Value | `[label` | `[label](http` |
99
+ |---|---|---|
100
+ | `:protocol` (default) | `[label]()` | `[label](#)` |
101
+ | `:text_only` | `label` | `label` |
102
+
103
+ `:text_only` unwraps the markup and keeps the label, which is the right choice
104
+ for a host that would rather show no link than a dead one:
105
+
106
+ ```ruby
107
+ MaquinaRemend.call("[label", link_mode: :text_only) # => "label"
108
+ MaquinaRemend.call("[label](http", link_mode: :text_only) # => "label"
109
+ ```
110
+
111
+ Any other value raises:
112
+
113
+ ```ruby
114
+ MaquinaRemend.call("x", link_mode: :guess)
115
+ # MaquinaRemend::UnknownOption: link_mode must be one of protocol, text_only
116
+ ```
117
+
118
+ Each flag guards its own kind, so images can be left broken while links are
119
+ repaired:
120
+
121
+ ```ruby
122
+ MaquinaRemend.call("![alt", images: false) # => "![alt"
123
+ ```
124
+
125
+ ## Math
126
+
127
+ `block_math:` on by default, `inline_math:` **off** by default.
128
+
129
+ ```ruby
130
+ MaquinaRemend.call("$$x = 1") # => "$$x = 1$$"
131
+ ```
132
+
133
+ `$$` is unambiguous, so block math is repaired. A single `$` is not:
134
+
135
+ ```ruby
136
+ MaquinaRemend.call("$x = 1") # => "$x = 1"
137
+ MaquinaRemend.call("$x = 1", inline_math: true) # => "$x = 1$"
138
+ ```
139
+
140
+ **`inline_math:` is off because a bare dollar sign is currency far more often
141
+ than it is mathematics.** Turn it on only if you know your corpus. Even with it
142
+ on, a paired pair of dollar signs in prose is read as two prices rather than one
143
+ equation:
144
+
145
+ ```ruby
146
+ MaquinaRemend.call("costs $5 and $10", inline_math: true)
147
+ # => "costs $5 and $10"
148
+ ```
149
+
150
+ ## Setext headings
151
+
152
+ `setext_headings:`, on by default.
153
+
154
+ A setext underline arrives one character at a time, and a lone `=` under a line
155
+ of text is not yet a heading. It is padded out to the width of the heading text:
156
+
157
+ ```ruby
158
+ MaquinaRemend.call("Title\n=") # => "Title\n====="
159
+ ```
160
+
161
+ Padding to the text's own width is what keeps the repair idempotent: repairing
162
+ the result changes nothing, because the underline is already the right length.
163
+
164
+ ## Truncated HTML tags
165
+
166
+ `html_tags:`, on by default.
167
+
168
+ A tag whose `>` has not arrived is dropped, along with the whitespace in front
169
+ of it. Left in place, it makes the renderer swallow everything that streams in
170
+ afterwards as attribute soup:
171
+
172
+ ```ruby
173
+ MaquinaRemend.call("text <div cla") # => "text"
174
+ ```
175
+
176
+ The tag comes back on the frame where its `>` finally arrives. This handler is
177
+ also what makes [Application tags](#application-tags) simple: by the time that
178
+ handler runs, every tag it can see is a whole one.
179
+
180
+ ## Application tags
181
+
182
+ `app_tags:`, whose value is **a list of tag names** — `thinking`, `answer`,
183
+ `tool_call`, `citation` and `scratchpad` by default, or `false` to switch the
184
+ repair off.
185
+
186
+ Models emit XML-ish tags that carry application meaning rather than
187
+ presentation. An opening one with no closer is a broken document, and not
188
+ subtly:
189
+
190
+ ```ruby
191
+ MaquinaRemend.call("<thinking>\nstill reasoning")
192
+ # => "<thinking>\nstill reasoning</thinking>"
193
+ ```
194
+
195
+ ### Why an open tag is broken
196
+
197
+ CommonMark ends an HTML block at the first blank line. The moment the model
198
+ types one, the rest of the answer leaves the tag:
199
+
200
+ ```
201
+ <thinking> parses as <thinking>
202
+ reasoning reasoning
203
+ <p>the answer</p>
204
+ the answer
205
+ ```
206
+
207
+ In the parsed document those paragraphs are siblings of the tag. In the
208
+ browser's DOM they are its children, because an unknown element is never
209
+ implicitly closed — so the whole answer renders as reasoning. Appending the
210
+ closer makes the two agree, and it costs nothing once the model's own closer
211
+ arrives, because the buffer is then balanced and the repair does not fire.
212
+
213
+ ### Why it is a list and not a boolean
214
+
215
+ **Closing every unbalanced tag would close the ones that must not be closed.**
216
+ `<br>` and `<img>` have no closer at all. A `<div>` wrapper is routinely opened
217
+ in one streamed block and closed several blocks later, so a speculative
218
+ `</div>` would land in the middle of its own content. An application tag is
219
+ different in kind: the model emits it whole, it wraps one region of the answer,
220
+ and the host knows its name in advance. That is why the option takes names —
221
+ the host is the only party that knows which tags are its own.
222
+
223
+ So supply your own registry:
224
+
225
+ ```ruby
226
+ MaquinaRemend.call("<plan>\nstep one", app_tags: %w[plan])
227
+ # => "<plan>\nstep one</plan>"
228
+ ```
229
+
230
+ A name not in the list is left open:
231
+
232
+ ```ruby
233
+ MaquinaRemend.call("<thinking>\nreasoning", app_tags: %w[answer])
234
+ # => "<thinking>\nreasoning"
235
+ ```
236
+
237
+ And `false` switches the repair off entirely:
238
+
239
+ ```ruby
240
+ MaquinaRemend.call("<thinking>\nstill reasoning", app_tags: false)
241
+ # => "<thinking>\nstill reasoning"
242
+ ```
243
+
244
+ Anything that is neither a list of names nor `false` is refused:
245
+
246
+ ```ruby
247
+ MaquinaRemend.call("x", app_tags: 3)
248
+ # MaquinaRemend::UnknownOption: app_tags must be false or a list of tag names
249
+ ```
250
+
251
+ ### Nesting, placement and the cases it declines
252
+
253
+ Nested tags are closed innermost first, so the nesting the model opened is the
254
+ nesting the renderer sees:
255
+
256
+ ```ruby
257
+ MaquinaRemend.call("<answer>\n<citation>\nsrc")
258
+ # => "<answer>\n<citation>\nsrc</citation></answer>"
259
+ ```
260
+
261
+ Where the closer goes is not cosmetic. When the open tag is in the same block
262
+ as the tail, the closer goes flush against it. When the tail has moved on to a
263
+ later block, the closers go after a blank line, one per line — a line holding
264
+ two closing tags is not an HTML block, and would be wrapped in a paragraph:
265
+
266
+ ```ruby
267
+ MaquinaRemend.call("<thinking>\n\n```ruby\nputs \"<answer>\"\n```\n\nstill thinking")
268
+ # => "<thinking>\n\n```ruby\nputs \"<answer>\"\n```\n\nstill thinking\n\n</thinking>"
269
+ ```
270
+
271
+ That example also shows the fence winning: the `<answer>` inside the code block
272
+ is content, not markup, and opens nothing.
273
+
274
+ A self-closing tag opened nothing, so nothing is appended:
275
+
276
+ ```ruby
277
+ MaquinaRemend.call("<citation id=\"1\"/>\n\nThe next paragraph.")
278
+ # => "<citation id=\"1\"/>\n\nThe next paragraph."
279
+ ```
280
+
281
+ A tag whose attributes are still being typed has no `>` yet, so it is dropped
282
+ before this repair runs and there is nothing to close. The enclosing tag, which
283
+ did arrive whole, is closed:
284
+
285
+ ```ruby
286
+ MaquinaRemend.call("<answer>\nUsing <tool_call name=\"weather\" ci")
287
+ # => "<answer>\nUsing</answer>"
288
+ ```
289
+
290
+ The closer itself streams in one character at a time, and the document stays
291
+ well formed at every frame in between:
292
+
293
+ ```ruby
294
+ MaquinaRemend.call("<thinking>\nreasoning\n</thinkin")
295
+ # => "<thinking>\nreasoning</thinking>"
296
+
297
+ MaquinaRemend.call("<thinking>\nreasoning\n</thinking>")
298
+ # => "<thinking>\nreasoning\n</thinking>"
299
+ ```
300
+
301
+ On the first frame the truncated closer is dropped and a whole one is put back.
302
+ On the next, the model's own closer has landed and nothing is appended — the
303
+ speculative closer is *replaced* by the real one, never added to it.
304
+
305
+ ## Comparison operators
306
+
307
+ `comparison_operators:`, on by default.
308
+
309
+ A `>` inside a list item is escaped, so that a reflow or a continuation line
310
+ cannot turn it into a blockquote nested inside the item:
311
+
312
+ ```ruby
313
+ MaquinaRemend.call("- a > b") # => "- a \\> b"
314
+ ```
315
+
316
+ (That is Ruby's `inspect` form of a single backslash: the buffer becomes
317
+ `- a \> b`.)
318
+
319
+ ## Single tildes
320
+
321
+ `single_tilde:`, on by default.
322
+
323
+ A lone tilde must not be read as a half-typed `~~`. It is escaped so it renders
324
+ as a literal tilde instead of opening a strikethrough that never closes:
325
+
326
+ ```ruby
327
+ MaquinaRemend.call("20~25°C") # => "20\\~25°C"
328
+ ```
329
+
330
+ ## Dangling escapes
331
+
332
+ `dangling_escape:`, on by default.
333
+
334
+ A buffer cut in the middle of a LaTeX command, or after any lone backslash,
335
+ ends in a backslash that would escape whatever the next repair appends. It is
336
+ dropped first:
337
+
338
+ ```ruby
339
+ MaquinaRemend.call("$$x = \\frac{-b \\") # => "$$x = \\frac{-b $$"
340
+ MaquinaRemend.call("media ecuación \\") # => "media ecuación "
341
+ ```
342
+
343
+ Without it the math repair produces `\$$` — an escaped dollar that leaves the
344
+ math open, so the next pass appends another `$$`, and the pass after that
345
+ another. This is why the handler runs first.
346
+
347
+ ## Guards
348
+
349
+ A repair that cannot be made safe is not made. A false repair corrupts a
350
+ message that was never broken, and no later frame undoes it.
351
+
352
+ Nothing is ever completed inside a fenced code block, an inline code span, or
353
+ math, because in those places broken-looking markup is the content:
354
+
355
+ ```ruby
356
+ MaquinaRemend.call("```\n**not bold") # => "```\n**not bold"
357
+ MaquinaRemend.call("`a * b`") # => "`a * b`"
358
+ MaquinaRemend.call("$$a_1 + b_2$$") # => "$$a_1 + b_2$$"
359
+ MaquinaRemend.call("\\(a_1\\)") # => "\\(a_1\\)"
360
+ ```
361
+
362
+ The last one matters: math context is not only `$`. LaTeX `\( ... \)` is math
363
+ too, and the underscore in it is a subscript.
364
+
365
+ Underscores inside a word are an identifier, not emphasis:
366
+
367
+ ```ruby
368
+ MaquinaRemend.call("some_var_name") # => "some_var_name"
369
+ ```
370
+
371
+ Two dollar signs in a sentence are two prices:
372
+
373
+ ```ruby
374
+ MaquinaRemend.call("costs $5 and $10") # => "costs $5 and $10"
375
+ ```
376
+
377
+ And a complete document is returned byte for byte, whatever is in it. That is
378
+ the property every repair above is written to preserve; see
379
+ [streaming.md](streaming.md#the-three-properties).
380
+
381
+ | Input | Result | Why |
382
+ |---|---|---|
383
+ | `` ```\n**not bold `` | unchanged | never complete anything inside a fence |
384
+ | `` `a * b` `` | unchanged | the asterisk is literal inside code |
385
+ | `` `<thinking>` `` | unchanged | a tag inside code is content, not markup |
386
+ | `<citation id="1"/>` | unchanged | a self-closing tag opened nothing |
387
+ | `$$a_1 + b_2$$` | unchanged | underscores are subscripts |
388
+ | `\(a_1\)` | unchanged | math context is not only `$` |
389
+ | `some_var_name` | unchanged | underscores inside a word are an identifier |
390
+ | `costs $5 and $10` | unchanged | currency, not inline math |
391
+ | any complete document | byte-identical | the no-op property |
data/docs/streaming.md ADDED
@@ -0,0 +1,162 @@
1
+ # Using it in a stream
2
+
3
+ `maquina_remend` is one function with no memory. The whole of using it in a
4
+ stream is: on every frame, call it on the whole buffer so far, and render what
5
+ comes back.
6
+
7
+ ```ruby
8
+ require "maquina_remend"
9
+
10
+ buffer = +""
11
+
12
+ stream.each do |chunk|
13
+ buffer << chunk
14
+ render MaquinaRemend.call(buffer)
15
+ end
16
+ ```
17
+
18
+ The repaired string is for the renderer. **Keep appending chunks to `buffer`,
19
+ never to the repaired output** — the repair is a view of the buffer, not a
20
+ replacement for it. Appending the next chunk to `"**bold**"` gives you a
21
+ document the model never wrote.
22
+
23
+ ## What a frame looks like
24
+
25
+ The output of consecutive frames is not a growing prefix of one string; the
26
+ completion moves along as the tail does, and it disappears the moment the model
27
+ supplies its own closer. Every line below is a real frame:
28
+
29
+ ```
30
+ "" -> ""
31
+ "Reading" -> "Reading"
32
+ "Reading *" -> "Reading *"
33
+ "Reading **" -> "Reading **"
34
+ "Reading **t" -> "Reading **t**"
35
+ "Reading **the" -> "Reading **the**"
36
+ "Reading **the manua" -> "Reading **the manua**"
37
+ "Reading **the manual" -> "Reading **the manual**"
38
+ "Reading **the manual*" -> "Reading **the manual*"
39
+ "Reading **the manual**" -> "Reading **the manual**"
40
+ "Reading **the manual** now." -> "Reading **the manual** now."
41
+ ```
42
+
43
+ Two things to read out of that. The reader sees bold text from the frame after
44
+ the delimiter completes, instead of two literal asterisks for the length of the
45
+ phrase. And the last frame is byte-identical to the input: once the document is
46
+ finished, the gem is not in the picture at all.
47
+
48
+ Application tags behave the same way across frames:
49
+
50
+ ```
51
+ "" -> ""
52
+ "<thi" -> ""
53
+ "<thinkin" -> ""
54
+ "<thinking>\ns" -> "<thinking>\ns</thinking>"
55
+ "<thinking>\nshort" -> "<thinking>\nshort</thinking>"
56
+ "<thinking>\nshort\n</t" -> "<thinking>\nshort</thinking>"
57
+ "<thinking>\nshort\n</think" -> "<thinking>\nshort</thinking>"
58
+ "<thinking>\nshort\n</thinking>" -> "<thinking>\nshort\n</thinking>"
59
+ "<thinking>\nshort\n</thinking>\n\nDo" -> "<thinking>\nshort\n</thinking>\n\nDo"
60
+ ```
61
+
62
+ The half-typed opening tag is dropped rather than shown. The speculative closer
63
+ appears as soon as the opener is whole, and is *replaced* by the model's own
64
+ closer rather than added to it.
65
+
66
+ ## The three properties
67
+
68
+ Three properties hold over a corpus of whole documents at every truncation
69
+ point, and they are what a caller is allowed to rely on.
70
+
71
+ ### Idempotence
72
+
73
+ `call(call(x)) == call(x)`. Repairing a repaired buffer changes nothing.
74
+
75
+ This is what makes the function safe to put anywhere in a pipeline. You do not
76
+ have to know whether some earlier layer already called it, and a retried or
77
+ replayed frame cannot accumulate closers.
78
+
79
+ ### No-op on well-formed input
80
+
81
+ A document with nothing broken in it comes back byte for byte — including this
82
+ page, which the test suite runs through `MaquinaRemend.call` and compares to
83
+ itself.
84
+
85
+ So the finished message is never the gem's output in any meaningful sense: it
86
+ is the model's own bytes. Nothing needs to be undone at the end of a stream.
87
+
88
+ ### Prefix safety
89
+
90
+ Every truncation point of every document is repairable without raising. The
91
+ buffer can be cut anywhere — between the bytes of a multibyte character's
92
+ markup neighbourhood, inside a table row, inside a fence's info string — and
93
+ the call returns a String.
94
+
95
+ For a caller this means no rescue around the call, and no minimum chunk size.
96
+ Cut wherever the transport cuts.
97
+
98
+ A fourth property is measured rather than proved: an 8KB buffer is repaired in
99
+ under a millisecond, so calling it on every frame of a stream is not a cost
100
+ worth engineering around.
101
+
102
+ ## Deciding how to render the tail
103
+
104
+ A renderer often needs to know where the tail sits before it decides what to
105
+ do with it — whether to run a syntax highlighter over a code block whose closing
106
+ fence has not arrived, for one. That question has a public answer that does not
107
+ involve repairing anything:
108
+
109
+ ```ruby
110
+ context = MaquinaRemend.context(buffer)
111
+ context.in_code_fence? # => true
112
+ context.open_fence_info # => "ruby"
113
+ ```
114
+
115
+ `Context` is documented in [handlers.md](handlers.md#the-context-object).
116
+
117
+ ## Reusing a pipeline across frames
118
+
119
+ Options are validated once, in the constructor. When the same options are used
120
+ for every frame, build the pipeline once:
121
+
122
+ ```ruby
123
+ pipeline = MaquinaRemend::Pipeline.new(inline_math: true)
124
+
125
+ stream.each do |chunk|
126
+ buffer << chunk
127
+ render pipeline.call(buffer)
128
+ end
129
+ ```
130
+
131
+ It holds no state between buffers; this only avoids re-validating the option
132
+ hash, and turns a bad option into a startup error rather than a surprise
133
+ mid-stream.
134
+
135
+ ## What it deliberately does not do
136
+
137
+ **It does not parse markdown.** It repairs a tail and hands back a String. What
138
+ renders it is your business — CommonMark, a client-side renderer, anything.
139
+
140
+ **It does not render HTML.** Nothing here escapes or sanitizes anything. Model
141
+ output is untrusted, and it is still untrusted after this gem has seen it;
142
+ sanitize downstream exactly as you would have without it.
143
+
144
+ **It does not diff frames or compute deltas.** Every call is over the whole
145
+ buffer, from scratch. A caller that wants deltas builds them on top, over the
146
+ repaired output.
147
+
148
+ **It does not hold a session.** No object accumulates chunks, no state carries
149
+ from one frame to the next. Two frames repaired in either order, on two
150
+ different machines, give the same answers.
151
+
152
+ **It does not guess at a destination.** A half-arrived URL becomes a
153
+ placeholder rather than a link somewhere unintended — see
154
+ [repairs.md](repairs.md#links-and-images).
155
+
156
+ **It does not repair the middle of a document.** Everything is anchored at the
157
+ tail, because the tail is the only part of a streaming buffer that is
158
+ provisional. A construct broken in the middle was broken by the model, and
159
+ inventing a fix for it would silently rewrite a finished message.
160
+
161
+ **It does not require Rails, or anything else.** Zero runtime dependencies is a
162
+ property of the gem, asserted by the test suite in a bare Ruby process.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaRemend
4
+ # What a handler is allowed to know about where the cursor sits. A handler that
5
+ # needs more than this is a sign the scanner is missing a state, not a sign
6
+ # this object should grow.
7
+ #
8
+ # Every handler — built-in or custom — receives one of these as its second
9
+ # argument, and MaquinaRemend.context returns one for callers that want the
10
+ # reading without the repair.
11
+ #
12
+ # ```ruby
13
+ # context = MaquinaRemend.context(buffer)
14
+ # renderer.highlight! unless context.in_code_fence?
15
+ # ```
16
+ #
17
+ # The three predicates are the three places where broken-looking markup is the
18
+ # content rather than a defect, which is why they are the whole of what a
19
+ # handler may ask:
20
+ #
21
+ # | Predicate | True when the tail sits inside |
22
+ # |---|---|
23
+ # | #in_code_fence? | an unclosed fenced code block |
24
+ # | #in_inline_code? | an unclosed backtick span in the current paragraph |
25
+ # | #in_math? | an unclosed `$$` block-math span |
26
+ #
27
+ # Instances are built by MaquinaRemend::Scanner and are read-only; there is no
28
+ # reason for a host to construct one directly, but nothing stops it.
29
+ class Context
30
+ # The info string of the open fence, or `nil` when no fence is open or the
31
+ # fence carried no info string.
32
+ #
33
+ # ```ruby
34
+ # MaquinaRemend.context("~~~ruby\ndef call").open_fence_info # => "ruby"
35
+ # MaquinaRemend.context("~~~\nplain").open_fence_info # => nil
36
+ # ```
37
+ #
38
+ # This is what lets a renderer pick a highlighter for a code block whose
39
+ # closing fence has not streamed in yet.
40
+ attr_reader :open_fence_info
41
+
42
+ # The options hash the pipeline was built with — MaquinaRemend::DEFAULTS
43
+ # merged with whatever the caller passed. A custom handler that wants to
44
+ # read a host-specific setting can find it here.
45
+ #
46
+ # Empty when the context came from MaquinaRemend.context, which does no
47
+ # repair and therefore has no options.
48
+ attr_reader :options
49
+
50
+ # Builds a context. Called by MaquinaRemend::Scanner#context; a host has no
51
+ # reason to call it, but the arguments are the four states plus the options.
52
+ #
53
+ # * `in_code_fence:` — whether an unclosed fence is open
54
+ # * `in_inline_code:` — whether an unclosed backtick span is open
55
+ # * `in_math:` — whether an unclosed `$$` span is open
56
+ # * `open_fence_info:` — the open fence's info string, or `nil`
57
+ # * `options:` — the resolved option hash
58
+ def initialize(in_code_fence:, in_inline_code:, in_math:, open_fence_info: nil, options: {})
59
+ @in_code_fence = in_code_fence
60
+ @in_inline_code = in_inline_code
61
+ @in_math = in_math
62
+ @open_fence_info = open_fence_info
63
+ @options = options
64
+ end
65
+
66
+ # True when the buffer's tail sits inside a fenced code block whose closing
67
+ # fence has not arrived.
68
+ #
69
+ # Every built-in handler returns early on this: a fence is the one place
70
+ # where broken-looking markup is the content. A custom handler is *not*
71
+ # short-circuited, so it has to ask for itself.
72
+ def in_code_fence? = @in_code_fence
73
+
74
+ # True when the current paragraph has an odd number of backticks once the
75
+ # balanced spans are removed — an inline code span is still open.
76
+ def in_inline_code? = @in_inline_code
77
+
78
+ # True when an unclosed `$$` block-math span is open.
79
+ #
80
+ # Asked of the whole document rather than the current paragraph, because
81
+ # block math spans paragraphs — but only of the parts of the document that
82
+ # are prose, so a buffer that merely *writes about* `$$x = 1` inside a code
83
+ # span has no open math.
84
+ def in_math? = @in_math
85
+ end
86
+ end