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
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 ``, 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(" 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
|