rouge-carve 0.1.3 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b5dd23d4f12415b4c19ed7333a5bd1555ae21e4f547b4f0a8f52b7829f8ad94
4
- data.tar.gz: 1f9a6066c9c55b5457eda908909ff6b865d84fedaebbe18a37fdbe4f9e62a76d
3
+ metadata.gz: 56fbae7b1785bf82049bdeb87ee240391843fd025ae86d383ad7b48b3b3e9562
4
+ data.tar.gz: bc9a2e6b121c743dec62e18c059105fd2b3959a0d4fc09684849475c071a7e76
5
5
  SHA512:
6
- metadata.gz: bdc875e2a50141600798ff0efcd8741e751516dbb3de632f55039a62e881f4edbe4561942e8b39228e6603c1e6cef428d5320323b2617213d5f8ceed1053e550
7
- data.tar.gz: 8d1983500c85ecef17ac25bac9aad4ebf76b477bf75ee721d1343ef09a82cf424c86c4bf9cb65b948361183bf97cce2bffe90630f20fa519368d5253fed1f13c
6
+ metadata.gz: be5892326629840d6697bb02c14875b1b075b5220d393e88fd5f85af894af8ada36911dd81c90eb3bb8b4d2cb2c7bc182731704a18c6c2d0ef7f4ef459e360a5
7
+ data.tar.gz: c14089a1b08264317473a18c84fdad82a946499096f5ef0143952b073a74a4d37da745a53d469a635bae2a752c15a94076c16a2abb4429f85c15504581cce2f5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.5] - 2026-11-12
6
+
7
+ - Braced cell alignment (`|={>} Price |`, `|={<^ .x} Name |`, `|{~} 1 |`) lexes as an attribute with its alignment run as an operator, like the deprecated `|=>` prefix; cell pipes and escaped pipes stay outside it (#37).
8
+ - `->`, `<-`, `<->` and `=>` are text, and `-->` and `<--` need their exact hyphen run (#38).
9
+ - A mention or tag name needs an ASCII letter (#38).
10
+ - A first-line `---` opens frontmatter through the end of the document when no closer follows; a mid-document `---` never does (#38).
11
+ - A letter or roman marker with a dot needs two spaces, and a definition term is one line (#38).
12
+ - A code fence opener and a `%%%` comment span open without a closer lookahead, and a multiline attribute block continues only on lines indented past its `{` (#38).
13
+
14
+ ## [0.1.4] - 2026-10-23
15
+
16
+ - `csv` and `tsv` fence bodies are highlighted: every field has its own token type and the separator is an operator, colored apart from the fields in the default styles (#25, #26, #27, #28).
17
+ - A lone `+` is a continuation marker, tokenized as an operator, only where the engine reads one: at an open container's marker column with a non-list block below, or as the first block after an opener (#29, #30, #32). A prose line ending in `+` is not scoped (#31).
18
+ - A quote-prefixed `+` needs one space after each `>`: `>+`, `>>+` and `>> +` are paragraph text (#33).
19
+ - A quote opened on a list item line (`- > q`) scopes its `>`, and `- > +` is a marker when the next line starts a non-list block at column 0, at the line's column or at the owning quote's column (markup-carve/carve#3421). A stacked `> > +` takes its follower only at column 0 or the first quote's column (#34).
20
+ - A `:` left of the `::` column of the definition list it would continue is paragraph text (markup-carve/carve#3458) (#34).
21
+
5
22
  ## [0.1.3] - 2026-10-14
6
23
 
7
24
  0.1.2 was tagged without its `RougeCarve::VERSION` bump, so it never reached RubyGems. 0.1.3 is the first published release with its change.
data/README.md CHANGED
@@ -1,83 +1,26 @@
1
1
  # rouge-carve
2
2
 
3
- A [Rouge](https://github.com/rouge-ruby/rouge) lexer for the
4
- [Carve](https://markup-carve.github.io/carve/) markup language.
3
+ Rouge lexer for Carve 0.1 syntax. It registers `.crv`, `.carve`, and Carve fenced code for Rouge 3.30 or later. Read the [Carve documentation](https://markup-carve.github.io/carve/) for the language syntax.
5
4
 
6
- ```bash
5
+ ## Install
6
+
7
+ ```sh
7
8
  gem install rouge-carve
8
9
  ```
9
10
 
11
+ ## Usage
12
+
10
13
  ```ruby
11
14
  require 'rouge-carve'
12
-
13
- Rouge::Lexer.find('carve') # => Rouge::Lexers::Carve
14
- Rouge::Lexer.guess(filename: 'notes.crv') # => Rouge::Lexers::Carve
15
+ Rouge::Lexer.find('carve')
15
16
  ```
16
17
 
17
- It needs Rouge 3.30 or later and is tested against Rouge 3, 4 and 5.
18
-
19
- Requiring the gem registers the lexer. Anything that already highlights with
20
- Rouge - GitLab, Jekyll, Redcarpet, most Ruby static site generators - then
21
- handles `.crv` and `.carve` files, and ` ```carve ` fenced blocks, without
22
- further configuration.
23
-
24
- ## Why not just use the Markdown lexer
25
-
26
- Because it is not close enough to be wrong quietly. Carve deliberately changed
27
- the delimiters Markdown got backwards, and three of them **invert**:
28
-
29
- | Carve | means | Markdown reads it as |
30
- | --- | --- | --- |
31
- | `*bold*` | strong | emphasis |
32
- | `/italic/` | emphasis | literal slashes |
33
- | `_under_` | underline | emphasis |
34
- | `~strike~` | strikethrough | subscript, or literal |
35
- | `{=mark=}` | highlight | literal braces |
36
- | `{^sup^}` | superscript | literal braces |
37
-
38
- Highlighting a Carve document as Markdown does not degrade to plain text. It
39
- shows bold where the author wrote emphasis.
40
-
41
- ## What it covers
42
-
43
- Every construct in the language: headings, containers (`::: note`), fenced and
44
- raw blocks, tables with alignment and per-row attributes, definition lists,
45
- footnotes, citations, cross-references, attribute blocks, task items, critic
46
- markup, symbol shortcodes, mentions, tags and the typographic runs.
47
-
48
- **Fenced code is delegated.** A ` ```ruby ` block inside a `.crv` file is lexed
49
- as Ruby, because that is what a reader expects. A ` ```=html ` raw block is not:
50
- its info string names an output format, not a language, so the payload stays
51
- opaque.
52
-
53
- **Fence widths are respected.** A three-backtick line inside a four-backtick
54
- fence is content, not a closer. The width is closed over per block rather than
55
- approximated, which the sibling grammars in other ecosystems cannot do.
56
-
57
- ## What it cannot say
58
-
59
- Rouge has no underline token - the vocabulary stops at `Generic::Emph`,
60
- `Generic::Strong` and `Generic::EmphStrong`. Carve's `_x_` is underline, so its
61
- content shares `Generic::Emph` with italic. The delimiters stay `Punctuation`,
62
- so a consumer can still tell the two apart by the delimiter each carries.
63
-
64
- Block openers are matched at any indent. Carve opens a block at column 0 or at
65
- an enclosing container's content column and nowhere in between, which needs a
66
- container model this lexer does not carry. The trade-off is deliberate and
67
- shared with the Prism, highlight.js and Pygments grammars, so the four agree:
68
- it over-colours an indented-at-document-level opener rather than
69
- under-colouring the far more common indented construct inside a list item.
70
-
71
- ## Related
18
+ ## Documentation
72
19
 
73
- - [carve](https://github.com/markup-carve/carve) - the language and its spec
74
- - [pygments-carve](https://github.com/markup-carve/pygments-carve) - the same
75
- grammar for Pygments, which this was ported from
76
- - [carve-grammars](https://github.com/markup-carve/carve-grammars) - TextMate,
77
- Prism and highlight.js
78
- - [carve-css](https://github.com/markup-carve/carve-css) - styles for the HTML
79
- Carve renders
20
+ - [Carve documentation](https://markup-carve.github.io/carve/)
21
+ - [Documentation index](docs/README.md)
22
+ - [Development](docs/development.md)
80
23
 
81
- ## Development
24
+ ## License
82
25
 
83
- Contributor setup, testing, and maintenance notes are in the [development guide](docs/development.md).
26
+ [MIT](LICENSE)
@@ -21,13 +21,13 @@ A word with no registered handler renders as a generic div, which is what
21
21
  makes a whole family of constructs possible without touching the parser.
22
22
  :::
23
23
 
24
- ``` ruby "example.rb"
24
+ ```ruby "example.rb"
25
25
  def greet(name) = "Hello, #{name}!"
26
26
  ```
27
27
 
28
- |= Engine |= Status |
29
- | carve-js | shipped |
30
- | carve-rs | shipped |
28
+ |= Engine |={~} Status |
29
+ | carve-js | shipped |
30
+ | carve-rs | shipped |
31
31
 
32
32
  ^ Table 1: the reference implementations.
33
33
 
@@ -1,6 +1,8 @@
1
1
  # -*- coding: utf-8 -*- #
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative 'carve_continuation'
5
+
4
6
  module Rouge
5
7
  module Lexers
6
8
  # Carve is a lightweight markup language for documents and the web. Its
@@ -48,6 +50,74 @@ module Rouge
48
50
  #
49
51
  # Spec: https://markup-carve.github.io/carve/
50
52
  class Carve < RegexLexer
53
+ class DelimitedTable < RegexLexer
54
+ # Unquoted fields are Name::Variable (colored in the default Rouge
55
+ # styles, unlike a bare Name); the header record's unquoted fields are
56
+ # Name::Attribute.
57
+ start { @header = true }
58
+
59
+ def field_token
60
+ @header ? Name::Attribute : Name::Variable
61
+ end
62
+
63
+ state :root do
64
+ rule /"/ do
65
+ token Str
66
+ push :unquoted
67
+ push :quoted
68
+ end
69
+ rule /[,\t]/ do |m|
70
+ if m[0] == self.class::SEPARATOR
71
+ token Keyword
72
+ else
73
+ token field_token
74
+ push :unquoted
75
+ end
76
+ end
77
+ rule /[^,\t\n]+/ do
78
+ token field_token
79
+ push :unquoted
80
+ end
81
+ rule /\n/ do
82
+ token Text
83
+ @header = false
84
+ end
85
+ end
86
+
87
+ state :unquoted do
88
+ rule /[,\t]/ do |m|
89
+ if m[0] == self.class::SEPARATOR
90
+ token Keyword
91
+ pop!
92
+ else
93
+ token field_token
94
+ end
95
+ end
96
+ rule /[^,\t\n]+/ do
97
+ token field_token
98
+ end
99
+ rule /\n/ do
100
+ token Text
101
+ @header = false
102
+ pop!
103
+ end
104
+ end
105
+
106
+ state :quoted do
107
+ rule /""/, Str::Escape
108
+ rule /"/, Str, :pop!
109
+ rule /[^"]+/, Str
110
+ end
111
+ end
112
+
113
+ class CsvTable < DelimitedTable
114
+ SEPARATOR = ','.freeze
115
+ end
116
+
117
+ class TsvTable < DelimitedTable
118
+ SEPARATOR = "\t".freeze
119
+ end
120
+
51
121
  title 'Carve'
52
122
  desc 'Carve, a lightweight markup language for documents and the web'
53
123
 
@@ -88,6 +158,10 @@ module Rouge
88
158
  |#{LINE_BREAK}
89
159
  )*\}
90
160
  /x.freeze
161
+ # The same block as an interrupting line, whose continuations must be
162
+ # indented (carve#5080). Exact at column 0; an indented block is checked
163
+ # against its own `{` column in the :block rule.
164
+ ATTRS_INTERRUPT = Regexp.new(ATTRS_LINE.source.gsub(LINE_BREAK, '\n(?=[ \t]+[^ \t\n])'), Regexp::EXTENDED)
91
165
 
92
166
  # The prefix of a line inside a blockquote, nested markers included.
93
167
  # Each `>` needs a space or the line end after it: `>foo` and `>> x`
@@ -107,40 +181,50 @@ module Rouge
107
181
  end
108
182
 
109
183
  # An ordered marker: a number, one letter, or a roman numeral. `Body.`
110
- # is a word, not a marker.
111
- ORDERED = '(?:\\d+|[A-Za-z]|[ivxlcdm]+|[IVXLCDM]+)[.)]'
184
+ # is a word, not a marker, and a letter with `.` needs two spaces, so
185
+ # `J. R. R. Tolkien` is prose (carve#4811).
186
+ ORDERED = "(?:\\d+[.)]|(?:[A-Za-z]|[ivxlcdm]+|[IVXLCDM]+)(?:\\)|\\.(?=(?:#{ATTRS})? )))"
187
+
188
+ # The states read a column-0 `+` with a flush-left block below as an
189
+ # attachment. Whether a `+` is the marker is settled by CarveContinuation.
190
+ PLUS_NEXT = /(?=\n[^ \t\n])/.freeze
112
191
 
113
192
  # A list marker at the start of a line, as the list rules below read it.
114
193
  LIST_MARKER = "(?:[-*]|#{ORDERED}|\\.)(?:#{ATTRS})?(?:[ \\t]|$)"
194
+ OPENER_RUN = "(?:(?:[-*]|#{ORDERED}|\\.)(?:#{ATTRS})? +[ \\t]*|> )+"
195
+
196
+ # A cell's alignment symbols: horizontal, then an optional vertical one.
197
+ CELL_ALIGN = '(?:[<>~][\\^~v]?|\\?[\\^~v])'
198
+ # A cell's attribute block when it opens with alignment (carve#5091 D3),
199
+ # glued to its `|` or `|=` and followed by the cell's space.
200
+ CELL_BRACE = %(({)(#{CELL_ALIGN})((?:[ ]+(?:"(?:[^"\\\\\n]|\\\\.)*"|'(?:[^'\\\\\n]|\\\\.)*'|[^{}"'|\n])*)?)(})(?= ))
115
201
 
116
202
  # A table row needs its closing pipe: `| not a table` is a paragraph.
117
203
  ROW_END = /(?=(?:\\.|[^\\\n])*\|[ \t]*(?:#{ATTRS})?[ \t]*$)/.freeze
118
204
 
119
205
  # The block openers that interrupt a paragraph. A list marker and a
120
- # caption do not, and a fence does only when it is closed - that one is
121
- # checked in code, see the :paraline state.
206
+ # caption do not. A fence opener does without looking for its closer
207
+ # (carve#4018); a backtick in a backtick info string makes it a code span.
122
208
  INTERRUPT = %r/
123
209
  \#{1,6}[ \t] | >(?:[ \t]|$) | ::: | ::[ \t] | %%
210
+ | `{3,}[^`\n]*$ | ~{3,}
124
211
  | (?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$
125
212
  | \|=?[<>^v~]*#{ROW_END}
126
213
  | (?:\[\^|\*\[|\[)[^\]\n]+\]:[ ]
127
- | #{ATTRS_LINE}[ \t]*$
214
+ | #{ATTRS_INTERRUPT}[ \t]*$
128
215
  /x.freeze
129
216
 
130
217
  # Where a bracketed inline body must close, by the block it sits in: a
131
218
  # paragraph ends at a blank line or an interrupting block, a list item
132
219
  # also at the next marker, and a heading or table row at its line end.
133
- # A fence ends either only when it is closed, which is checked in code.
134
220
  BODY_ENDS = {
135
221
  para: /\n(?=[ \t]*(?:\n|\z)|#{MARGIN}(?:#{INTERRUPT}))/,
136
222
  item: /\n(?=[ \t]*(?:\n|\z)|#{MARGIN}(?:#{INTERRUPT}|#{LIST_MARKER}|:[ \t]))/,
137
223
  line: /\n/
138
224
  }.freeze
139
- FENCE_NEXT = /\n(?=#{MARGIN}(`{3,}|~{3,}))/.freeze
140
-
141
225
  # The block states that decide which of those ends applies.
142
226
  BODY_SCOPE = {
143
- paragraph: :para, listitem: :item, dlterm: :item, heading: :line,
227
+ paragraph: :para, listitem: :item, dlterm: :line, heading: :line,
144
228
  caption: :line, quoteline: :line, tablerow: :line, defline: :line
145
229
  }.freeze
146
230
 
@@ -184,8 +268,15 @@ module Rouge
184
268
  PLAIN_NO_PIPE = plain_run('|').freeze
185
269
  PLAIN_NO_BRACKET = plain_run('\\]').freeze
186
270
 
271
+ # The document start. Front matter opens only here: Rouge 3 and 4 scan
272
+ # without a fixed anchor, so \A alone would also match mid-document.
187
273
  state :root do
188
- mixin :block
274
+ rule %r/\A(?)(---)(?:( ?)([A-Za-z0-9]+))?([ \t]*(?:\n|\z))/ do
275
+ groups Text, Punctuation, Text, Keyword::Type, Text
276
+ goto :block
277
+ push :frontmatter
278
+ end
279
+ rule(//) { goto :block }
189
280
  end
190
281
 
191
282
  # ------------------------------------------------------------------
@@ -199,6 +290,7 @@ module Rouge
199
290
  # enters its state through #open_block, which knows which of the two it
200
291
  # is running in.
201
292
  def self.block_openers(bol)
293
+ root_margin = bol == '^' ? '()' : '( )'
202
294
  proc do
203
295
  # A raw block: the `=FORMAT` info string routes the payload to that
204
296
  # output format verbatim. Emitted as one token including the `=`,
@@ -233,14 +325,14 @@ module Rouge
233
325
  end
234
326
 
235
327
  # A caption line attaches to the block above or below it.
236
- rule %r/#{bol}(#{MARGIN})(\^)([ \t]+)/ do
328
+ rule %r/#{bol}(#{MARGIN})(\^)( +)(?=[ \t]*[^ \t\n])/ do
237
329
  groups Text, Punctuation, Text
238
330
  open_block :caption
239
331
  end
240
332
 
241
333
  # Headings. Carve has no setext form, so a `#` run is the only
242
334
  # spelling and a trailing `{...}` is NOT an attribute block here.
243
- rule %r/#{bol}(#{MARGIN})(\#{1,6})([ \t]+)/ do
335
+ rule %r/#{bol}(#{MARGIN})(\#{1,6})( +)(?=[ \t]*[^ \t\n])/ do
244
336
  groups Text, Punctuation, Text
245
337
  open_block :heading
246
338
  end
@@ -257,7 +349,7 @@ module Rouge
257
349
  # definition ends with its line.
258
350
  rule %r/#{bol}(#{MARGIN})(\[\^)([^\]\n]+)(\]:)( )/ do
259
351
  groups Text, Punctuation, Name::Label, Punctuation, Text
260
- open_block :defline
352
+ open_block(:defline, :defline, host: :footnote)
261
353
  end
262
354
  rule %r/#{bol}(#{MARGIN})(\*\[)([^\]\n]+)(\]:)( )/ do
263
355
  groups Text, Punctuation, Name::Entity, Punctuation, Text
@@ -270,47 +362,125 @@ module Rouge
270
362
 
271
363
  # A definition-list term (`::`) and its definition (`:`). A term
272
364
  # may wrap onto lazy lines, and a definition's text is an item's.
273
- rule %r/#{bol}(#{MARGIN})(::)([ \t]+)/ do
365
+ rule %r/#{bol}(#{MARGIN})(::)( +)(?=[ \t]*[^ \t\n])/ do
274
366
  groups Text, Punctuation, Text
367
+ remember_host(:term)
275
368
  open_block :dlterm, :heading
276
369
  end
277
- rule %r/#{bol}(#{MARGIN})(:)(?=[ \t])/ do
278
- groups Text, Punctuation
370
+ rule %r/#{bol}#{root_margin}(:)( +[ \t]*)(\+)()$#{PLUS_NEXT}/ do
371
+ if definition_term_before?
372
+ groups Text, Punctuation, Text, Operator, Text
373
+ else
374
+ groups Text, Punctuation, Text, Text, Text
375
+ end
279
376
  open_block :listitem, :quoteline
280
377
  end
378
+ # Without a term above, `: b` is paragraph text (a lazy line too).
379
+ rule %r/#{bol}(#{MARGIN})(:)(?= +[ \t]*[^ \t\n])/ do
380
+ # the scan from the last definition is short; the term scan runs once
381
+ if (definition_before? || definition_term_before?) && !separator_below_term?
382
+ groups Text, Punctuation
383
+ term = [@term_stream, @term_pos, @term_quote_depth]
384
+ open_block :listitem, :quoteline
385
+ # the next `: d` may still belong to the same term
386
+ @term_stream, @term_pos, @term_quote_depth = term
387
+ @def_stream = @current_stream
388
+ @def_pos = @last_host_pos
389
+ @def_quote_depth = @term_quote_depth
390
+ else
391
+ groups Text, Text
392
+ open_block :paragraph
393
+ end
394
+ end
281
395
 
282
396
  # A blockquote marker must be followed by a space or end the line;
283
397
  # `>foo` and `>> x` are paragraphs, and `> > x` nests. What follows
284
398
  # a marker may open a block of its own, so the openers are tried
285
399
  # again there.
286
- rule %r/#{bol}(#{MARGIN})(>)(?=[ \t]|$)/ do
400
+ rule %r/#{bol}(#{MARGIN})(>)(?= |$)/ do |m|
287
401
  groups Text, Punctuation
288
- state?(:quotestart) ? goto(:quotestart) : push(:quotestart)
402
+ record_quote_depth(quote_depth_on_line + 1)
403
+ remember_host(:quote)
404
+ rooted = state?(:quotestart) ? m[1] == ' ' : m[1].empty?
405
+ if rooted
406
+ state?(:quotestart) ? goto(:quotestart) : push(:quotestart)
407
+ else
408
+ state?(:quotestart) ? goto(:quoteline) : push(:quoteline)
409
+ end
410
+ end
411
+
412
+ # A task can attach its first block on the marker line.
413
+ rule %r/#{bol}#{root_margin}([-*](?:#{ATTRS})?)( +[ \t]*)(\[[^\]\n]\])( +[ \t]*)(\+)()$#{PLUS_NEXT}/ do
414
+ groups Text, Punctuation, Text, Name::Constant, Text, Operator, Text
415
+ open_block :listitem, :quoteline
416
+ end
417
+
418
+ rule %r/#{bol}#{root_margin}(#{OPENER_RUN})(\+)()$#{PLUS_NEXT}/ do |m|
419
+ token Text, m[1]
420
+ scanner = StringScanner.new(m[2])
421
+ until scanner.eos?
422
+ if (marker = scanner.scan(/(?:#{ORDERED}|\.)(?:#{ATTRS})?/))
423
+ token Num::Integer, marker
424
+ elsif (marker = scanner.scan(/[-*](?:#{ATTRS})?|>/))
425
+ token Punctuation, marker
426
+ else
427
+ token Text, scanner.getch
428
+ end
429
+ end
430
+ depth = quote_depth_on_line
431
+ token(state?(:quotestart) && !quote_level_follows?(depth) ? Text : Operator, m[3])
432
+ token Text, m[4]
433
+ m[2].rstrip.end_with?('>') ? open_block(:quoteline) : open_block(:listitem, :quoteline)
434
+ end
435
+
436
+ # A quote opened on a list item's marker line (`- > q`): its `>` is a
437
+ # marker, and a `+` behind it may be the quote's first block
438
+ # (markup-carve/carve#3421).
439
+ rule %r/#{bol}(#{MARGIN})((?:(?:[-*](?:#{ATTRS})?(?: +\[[^\]\n]\])?|#{ORDERED}(?:#{ATTRS})?|\.(?:#{ATTRS})?) +)+)((?:> +)*>)(?:( )(\+)()$|(?= |$))/ do |m|
440
+ token Text, m[1]
441
+ scanner = StringScanner.new(m[2] + m[3])
442
+ until scanner.eos?
443
+ if (marker = scanner.scan(/(?:#{ORDERED}|\.)(?:#{ATTRS})?/))
444
+ token Num::Integer, marker
445
+ elsif (marker = scanner.scan(/\[[^\]\n]\]/))
446
+ token Name::Constant, marker
447
+ elsif (marker = scanner.scan(/[-*](?:#{ATTRS})?|>/))
448
+ token Punctuation, marker
449
+ else
450
+ token Text, scanner.scan(/ +/)
451
+ end
452
+ end
453
+ if m[5]
454
+ token Text, m[4]
455
+ # CarveContinuation settles whether this `+` is the marker.
456
+ token Operator, m[5]
457
+ end
458
+ open_block(:quoteline)
289
459
  end
290
460
 
291
461
  # Task items before plain bullets, so the state marker is its own
292
462
  # token. The state is any single character, not only a space or an
293
463
  # x: `[>]` is deferred and `[-]` is dropped. A `+` is not a bullet in
294
464
  # Carve; it continues a container.
295
- rule %r/#{bol}(#{MARGIN})((?:[-*]|#{ORDERED})(?:#{ATTRS})?)([ \t]+)(\[[^\]\n]\])/ do
465
+ rule %r/#{bol}(#{MARGIN})((?:[-*]|#{ORDERED})(?:#{ATTRS})?)( +)(\[[^\]\n]\])(?= +[ \t]*[^ \t\n])/ do
296
466
  groups Text, Punctuation, Text, Name::Constant
297
467
  open_block :listitem, :quoteline
298
468
  end
299
469
 
300
470
  # Bullets. A run of markers on one line opens nested lists at once
301
471
  # (`- - A`), and attributes may be glued straight onto the marker.
302
- rule %r/#{bol}(#{MARGIN})((?:[-*][ \t]+)*[-*](?:#{ATTRS})?)(?=[ \t]|$)/ do
472
+ rule %r/#{bol}(#{MARGIN})((?:[-*] +)*[-*](?:#{ATTRS})?)(?= +[ \t]*[^ \t\n])/ do
303
473
  groups Text, Punctuation
304
474
  open_block :listitem, :quoteline
305
475
  end
306
476
 
307
477
  # Ordered markers: numeric, alphabetic, roman, and the bare `.` that
308
478
  # continues the enclosing sequence.
309
- rule %r/#{bol}(#{MARGIN})(#{ORDERED}(?:#{ATTRS})?)(?=[ \t]|$)/ do
479
+ rule %r/#{bol}(#{MARGIN})(#{ORDERED}(?:#{ATTRS})?)(?= +[ \t]*[^ \t\n])/ do
310
480
  groups Text, Num::Integer
311
481
  open_block :listitem, :quoteline
312
482
  end
313
- rule %r/#{bol}(#{MARGIN})(\.(?:#{ATTRS})?)(?=[ \t]|$)/ do
483
+ rule %r/#{bol}(#{MARGIN})(\.(?:#{ATTRS})?)(?= +[ \t]*[^ \t\n])/ do
314
484
  groups Text, Num::Integer
315
485
  open_block :listitem, :quoteline
316
486
  end
@@ -318,6 +488,11 @@ module Rouge
318
488
  # Tables. The header marker, the alignment run and the separator row
319
489
  # are their own tokens; cell content is lexed inline. A row needs its
320
490
  # closing pipe.
491
+ rule %r/#{bol}(#{MARGIN})(\|=?)#{CELL_BRACE}#{ROW_END}/ do |m|
492
+ token Text, m[1]
493
+ braced_cell(m)
494
+ open_block :tablerow
495
+ end
321
496
  rule %r/#{bol}(#{MARGIN})(\|=[<>^v~]*)#{ROW_END}/ do
322
497
  groups Text, Operator
323
498
  open_block :tablerow
@@ -333,32 +508,19 @@ module Rouge
333
508
  state(:quoteopeners, &block_openers(''))
334
509
 
335
510
  state :block do
336
- # Front matter, only at the very start of the document. \A is what
337
- # keeps a `---yaml` line mid-document from opening one.
338
- rule %r/\A(?)(---)([a-zA-Z][\w-]*)?([ \t]*\n)(?=[\s\S]*?^---[ \t]*$)/ do
339
- groups Text, Punctuation, Keyword::Type, Text
340
- push :frontmatter
341
- end
342
-
343
511
  # A comment fence (%%% or longer) versus a one-line comment. The fence
344
512
  # has to be tried first: %%% also matches the one-line form. Only a
345
513
  # run of the SAME length closes it, so the width is closed over the
346
- # way #push_fence does it, and an opener with no such closer below it
347
- # is the one-line form. Text after either run is part of the comment.
514
+ # way #push_fence does it. No closer is looked for: an unclosed span
515
+ # ends at a line left of its opener, which misses the opener's owner,
516
+ # or at the end of the document. Text after either run is comment.
348
517
  rule %r/^(#{MARGIN})(%%%+)([^\n]*)(\n)/ do |m|
349
518
  closer = %r/^([ \t]*)(%{#{m[2].length}})(?!%)([^\n]*)$/
350
- unless closer_below?(closer, m[2].length)
351
- # The tokens of the one-line rule below. Rouge 3 has no
352
- # fallthrough! to hand the line on to it.
353
- token Text, m[1]
354
- token Comment::Preproc, '%%'
355
- token Comment, m[2][2..] + m[3]
356
- token Text, m[4]
357
- next
358
- end
519
+ outside = m[1].empty? ? nil : %r/^(?=[ \t]{0,#{m[1].length - 1}}[^ \t\n])/
359
520
 
360
521
  groups Text, Comment::Preproc, Comment, Text
361
522
  push do
523
+ rule(outside) { pop! } if outside
362
524
  rule closer do
363
525
  groups Text, Comment::Preproc, Comment
364
526
  pop!
@@ -371,19 +533,31 @@ module Rouge
371
533
  groups Text, Comment::Preproc, Comment
372
534
  end
373
535
 
536
+ rule %r/^(\+)()$#{PLUS_NEXT}/ do |m|
537
+ if continuation_host_before?
538
+ groups Operator, Text
539
+ else
540
+ token Text, m[0]
541
+ push :paragraph
542
+ end
543
+ end
544
+
374
545
  mixin :blockopeners
375
546
 
376
- # A standalone attribute block, which may span lines.
377
- rule %r/^(#{MARGIN})(#{ATTRS_LINE})/ do
378
- groups Text, Name::Attribute
547
+ # A standalone attribute block, which may span lines indented past its
548
+ # `{` (carve#5080). One that does not is a paragraph.
549
+ rule %r/^(#{MARGIN})(#{ATTRS_LINE})/ do |m|
550
+ width = m[1].length
551
+ if m[2].split("\n").drop(1).all? { |line| line[/\A[ \t]*/].length > width }
552
+ groups Text, Name::Attribute
553
+ else
554
+ @current_stream.unscan
555
+ start_paragraph
556
+ end
379
557
  end
380
558
 
381
559
  # Any other line with content starts a paragraph.
382
- rule %r/^(?=#{MARGIN}\S)/ do
383
- @lone_image = !@current_stream.check(IMAGE_LINE).nil?
384
- @indented = @current_stream.check(/[ \t]/) ? true : false
385
- push :paragraph
386
- end
560
+ rule(%r/^(?=#{MARGIN}\S)/) { start_paragraph }
387
561
 
388
562
  mixin :inline
389
563
  end
@@ -400,8 +574,10 @@ module Rouge
400
574
 
401
575
  state :paraline do
402
576
  rule(%r/^(?=[ \t]*(?:\n|\z))/) { pop! }
577
+ rule(%r/^(?=\+$#{PLUS_NEXT})/) do
578
+ continuation_host_before? ? pop! : continue_paragraph(:paragraph)
579
+ end
403
580
  rule(%r/^(?=#{MARGIN}(?:#{INTERRUPT}))/) { pop! }
404
- rule(%r/^(?=#{MARGIN}(`{3,}|~{3,}))/) { |m| fence_closed_below?(m[1]) ? pop! : continue_paragraph(:paragraph) }
405
581
  rule(%r/^(?=#{MARGIN}\^[ \t])/) { @lone_image ? pop! : continue_paragraph(:paragraph) }
406
582
  # An indented paragraph sits in a container, so a list marker below it
407
583
  # opens a sublist or the next item rather than continuing it.
@@ -414,16 +590,26 @@ module Rouge
414
590
  { listitem: Text, dlterm: Generic::Heading }.each do |name, tok|
415
591
  line = :"#{name}line"
416
592
  state name do
417
- rule(/\n/) { token Text; goto line }
593
+ if name == :dlterm
594
+ rule(/\n/) { token Text; pop! }
595
+ else
596
+ rule(/\n/) { token Text; goto line }
597
+ end
418
598
  mixin :inlinecontent
419
599
  rule PLAIN, tok
420
600
  rule(/./) { token plain_token }
421
601
  end
602
+ next if name == :dlterm
422
603
 
423
604
  state line do
424
605
  rule(%r/^(?=[ \t]*(?:\n|\z))/) { pop! }
606
+ if name == :listitem
607
+ rule %r/^(\+)()$#{PLUS_NEXT}/ do
608
+ groups Operator, Text
609
+ goto name
610
+ end
611
+ end
425
612
  rule(%r/^(?=#{MARGIN}(?:#{INTERRUPT}|#{LIST_MARKER}|:[ \t]))/) { pop! }
426
- rule(%r/^(?=#{MARGIN}(`{3,}|~{3,}))/) { |m| fence_closed_below?(m[1]) ? pop! : goto(name) }
427
613
  rule(//) { goto name }
428
614
  end
429
615
  end
@@ -478,6 +664,9 @@ module Rouge
478
664
  rule %r/$/, Text, :pop!
479
665
  # A header cell marker, optionally carrying an alignment run. The pipe
480
666
  # is part of it, so `|=` reads as one construct.
667
+ # An escaped pipe is consumed by the escape rule first, so a `|` here
668
+ # always ends a cell.
669
+ rule(%r/(\|=?)#{CELL_BRACE}/) { |m| braced_cell(m) }
481
670
  rule %r/\|=[<>^v~]*/, Operator
482
671
  # A separator row cell, in the native and the GFM spelling.
483
672
  rule %r/(?<=\|)[ \t]*:?-{2,}:?[ \t]*(?=\|)/, Punctuation
@@ -705,12 +894,12 @@ module Rouge
705
894
 
706
895
  # A mention and a tag, each one token: the sigil is part of the name
707
896
  # rather than punctuation beside it.
708
- rule %r/(?<![\w\/])@[\w][\w.-]*/, Name::Variable::Magic
709
- rule %r/(?<![\w&])\#[\w][\w-]*/, Name::Variable::Instance
897
+ rule %r/(?<![\w\/])@(?=[\w.-]*?[A-Za-z])[\w][\w.-]*/, Name::Variable::Magic
898
+ rule %r/(?<![\w&])\#(?=[\w-]*?[A-Za-z])[\w][\w-]*/, Name::Variable::Instance
710
899
 
711
900
  # Typographic runs, longest first: an arrow is not an en dash plus a
712
901
  # stray angle bracket, and `---` is not `--` plus `-`.
713
- rule %r/<-->|<==>|<=>|-->|<--|==>|<==|->|<-/, Operator
902
+ rule %r/<-->|<--(?!-)|(?<!-)-->|<=>|==>|<==/, Operator
714
903
  rule %r/(?<!-)---(?!-)|(?<!-)--(?!-)/, Punctuation
715
904
  rule %r/\.\.\./, Punctuation
716
905
  end
@@ -832,15 +1021,6 @@ module Rouge
832
1021
  state :"#{body}_#{scope}" do
833
1022
  rule %r/\]/, Punctuation, :pop!
834
1023
  rule(%r/(?=#{ends.source}|\n?\z)/) { pop! }
835
- # The newline is handed back so the block reads the fence line.
836
- rule(FENCE_NEXT) do |m|
837
- if fence_closed_below?(m[1])
838
- m.pos -= 1
839
- pop!
840
- else
841
- token Text
842
- end
843
- end
844
1024
  mixin :inlinecontent
845
1025
  rule PLAIN_NO_BRACKET, tok
846
1026
  rule %r/[^\]\n]/, tok
@@ -849,8 +1029,50 @@ module Rouge
849
1029
  end
850
1030
  end
851
1031
 
1032
+ # The lexer states cannot carry the open containers' marker columns, so
1033
+ # the `+` scope is settled afterwards from a document-level pass: a `+`
1034
+ # it names is the continuation operator, every other `+` the states
1035
+ # gave Operator is text.
1036
+ def stream_tokens(str, &block)
1037
+ markers = CarveContinuation.markers(str).to_h { |offset| [offset, true] }
1038
+ candidates = CarveContinuation.candidates(str).to_h { |offset| [offset, true] }
1039
+ pos = 0
1040
+ super(str) do |tok, val|
1041
+ if val.include?('+') && candidates.any?
1042
+ retag_plus(tok, val, pos, markers, candidates, &block)
1043
+ else
1044
+ yield tok, val
1045
+ end
1046
+ pos += val.length
1047
+ end
1048
+ end
1049
+
852
1050
  private
853
1051
 
1052
+ def retag_plus(tok, val, pos, markers, candidates)
1053
+ run = +''
1054
+ val.each_char.with_index do |ch, i|
1055
+ scope = if ch != '+' || !candidates[pos + i] || opaque?(tok) then tok
1056
+ elsif markers[pos + i] then Operator
1057
+ elsif tok == Operator then Text
1058
+ else tok
1059
+ end
1060
+ if scope != tok
1061
+ yield tok, run unless run.empty?
1062
+ run = +''
1063
+ yield scope, ch
1064
+ else
1065
+ run << ch
1066
+ end
1067
+ end
1068
+ yield tok, run unless run.empty?
1069
+ end
1070
+
1071
+ # Comment and literal payloads keep their scope.
1072
+ def opaque?(tok)
1073
+ tok <= Comment || tok <= Literal
1074
+ end
1075
+
854
1076
  # The token a state gives a character that opens nothing.
855
1077
  PLAIN_TOKENS = {
856
1078
  heading: Generic::Heading, quoteline: Generic::Emph,
@@ -1033,20 +1255,10 @@ module Rouge
1033
1255
  entry[1].bsearch { |at| at >= pos } ? scope : nil
1034
1256
  end
1035
1257
 
1036
- # Where a body opened at +pos+ must end: the first block end, or the
1037
- # first closed fence, after it.
1258
+ # Where a body opened at +pos+ must end: the first block end after it.
1038
1259
  def body_end(text, pos, scope)
1039
1260
  ends = BODY_ENDS[scope]
1040
- last = text.index(ends, pos) || text.length
1041
- return last if scope == :line
1042
-
1043
- at = pos
1044
- while (fence = text.index(FENCE_NEXT, at)) && fence < last
1045
- return fence if fence_closed_below?($~[1], fence + 1)
1046
-
1047
- at = fence + 1
1048
- end
1049
- last
1261
+ text.index(ends, pos) || text.length
1050
1262
  end
1051
1263
 
1052
1264
  BRACKET_SCAN = /\\.|(?<!`)(`+)(?!`).*?(?<!`)\1(?!`)|\]/.freeze
@@ -1083,7 +1295,9 @@ module Rouge
1083
1295
  # opener that is a single token. After a blockquote marker the opener
1084
1296
  # replaces the quote's start state, and a single-token opener hands the
1085
1297
  # rest of the line to quoted text.
1086
- def open_block(name = nil, quoted = name || :quoteline)
1298
+ def open_block(name = nil, quoted = name || :quoteline, host: nil)
1299
+ kind = host || (name == :listitem ? :list : name == :quoteline ? :quote : nil)
1300
+ remember_host(kind) if kind
1087
1301
  if state?(:quotestart)
1088
1302
  goto quoted
1089
1303
  elsif name
@@ -1091,18 +1305,212 @@ module Rouge
1091
1305
  end
1092
1306
  end
1093
1307
 
1308
+ def remember_host(kind)
1309
+ @host_stream = @current_stream
1310
+ @last_host_pos = @current_stream.pos
1311
+ @last_host_kind = kind
1312
+ if kind == :term
1313
+ @term_stream = @current_stream
1314
+ @term_pos = @last_host_pos
1315
+ @term_quote_depth = state?(:quotestart) ? quote_depth_on_line : 0
1316
+ elsif kind != :quote
1317
+ @term_pos = nil
1318
+ end
1319
+ @host_scan = StringScanner.new(@current_stream.string)
1320
+ @host_scan.pos = @last_host_pos
1321
+ @host_scan.scan(/[^\n]*\n/)
1322
+ @host_scan_valid = true
1323
+ @host_scan_fence = nil
1324
+ @host_scan_previous = nil
1325
+ end
1326
+
1327
+ def quote_depth_on_line
1328
+ line_start = line_start_byte
1329
+ @quote_line_start == line_start ? @quote_depth_current : 0
1330
+ end
1331
+
1332
+ def record_quote_depth(depth)
1333
+ @quote_line_start = line_start_byte
1334
+ @quote_depth_current = depth
1335
+ end
1336
+
1337
+ def line_start_byte
1338
+ text = @current_stream.string
1339
+ pos = @current_stream.pos
1340
+ pos -= 1 while pos.positive? && text.getbyte(pos - 1) != 10
1341
+ pos
1342
+ end
1343
+
1344
+ def next_line_after_current
1345
+ scan = StringScanner.new(@current_stream.string)
1346
+ scan.pos = @current_stream.pos
1347
+ return nil unless scan.scan(/[^\n]*\n/)
1348
+
1349
+ scan.scan(/[^\n]*/)
1350
+ end
1351
+
1352
+ def quote_level_follows?(depth)
1353
+ line = next_line_after_current
1354
+ return false unless line
1355
+ depth.times do
1356
+ return false unless line.start_with?('> ')
1357
+
1358
+ line = line[2..]
1359
+ end
1360
+ !line.empty? && !line.match?(/\A[ \t]/)
1361
+ end
1362
+
1363
+ def continuation_host_before?
1364
+ return false unless @host_stream.equal?(@current_stream) && @last_host_pos
1365
+ return false if @last_host_kind == :term
1366
+ return false if @last_host_kind == :quote && !quote_block_follows?
1367
+
1368
+ return false unless @host_scan_valid
1369
+
1370
+ limit = @current_stream.pos
1371
+ while (line = @host_scan.check(/[^\n]*\n/))
1372
+ break if @host_scan.pos + line.bytesize > limit
1373
+
1374
+ @host_scan.scan(/[^\n]*\n/)
1375
+ line = line.chomp("\n")
1376
+ if @host_scan_fence
1377
+ fence = @host_scan_fence
1378
+ @host_scan_fence = nil if line.match?(/\A#{Regexp.escape(fence)}[#{Regexp.escape(fence[0])}]*[ \t]*\z/)
1379
+ elsif line.match?(/\A[ \t]*\z/)
1380
+ @host_scan_valid = false
1381
+ elsif (opening = line.match(/\A(`{3,}|~{3,})/))
1382
+ @host_scan_valid = false unless @host_scan_previous&.match?(/\A\+[ \t]*\z/)
1383
+ @host_scan_fence = opening[1]
1384
+ elsif line.match?(/\A(?:\#{1,6} +|::{1,}|\^ +|\||(?:[-*_][ \t]*){3,}|\[[^\]]+\]: )/)
1385
+ @host_scan_valid = false unless @host_scan_previous&.match?(/\A\+[ \t]*\z/)
1386
+ end
1387
+ @host_scan_previous = line
1388
+ return false unless @host_scan_valid
1389
+ end
1390
+ true
1391
+ end
1392
+
1393
+ def definition_term_before?
1394
+ return false unless @term_stream.equal?(@current_stream) && @term_pos
1395
+ depth = state?(:quotestart) ? quote_depth_on_line : 0
1396
+ return false unless depth == @term_quote_depth
1397
+
1398
+ between = @current_stream.string.byteslice(@term_pos...@current_stream.pos)
1399
+ lines = between.split("\n", -1)
1400
+ lines.shift
1401
+ lines.pop
1402
+ if depth.positive?
1403
+ prefix = '> ' * depth
1404
+ return false unless lines.all? { |line| line.empty? || line.start_with?(prefix) }
1405
+
1406
+ lines.map! { |line| line.empty? ? line : line[prefix.length..] }
1407
+ end
1408
+ # A term is one line (carve#4894): only a blank may stand between it
1409
+ # and its `: ` line.
1410
+ lines.size <= 1 && lines.all? { |line| line.match?(/\A[ \t]*\z/) }
1411
+ end
1412
+
1413
+ # A `:` left of the `::` column of every open term above it ends the
1414
+ # definition list and is paragraph text (markup-carve/carve#3458).
1415
+ def separator_below_term?
1416
+ text = @current_stream.string
1417
+ unless @separator_source.equal?(text)
1418
+ @separator_source = text
1419
+ @separator_bytes = text.b
1420
+ @separator_below = {}
1421
+ end
1422
+ start = line_start_byte
1423
+ depth = state?(:quotestart) ? quote_depth_on_line : 0
1424
+ strip = lambda do |line|
1425
+ depth.times { line = line.start_with?('> ') ? line[2..] : line.sub(/\A>\z/, '') }
1426
+ line
1427
+ end
1428
+ indent = ->(line) { line[/\A[ \t]*/].gsub("\t", ' ').length }
1429
+ column = indent.call(strip.call(text.byteslice(start...(@separator_bytes.index("\n", start) || text.bytesize))))
1430
+ term = nil
1431
+ below = nil
1432
+ finish = start - 1
1433
+ while finish.positive?
1434
+ from = (@separator_bytes.rindex("\n", finish - 1) || -1) + 1
1435
+ line = strip.call(text.byteslice(from...finish))
1436
+ finish = from - 1
1437
+ next if line.strip.empty?
1438
+
1439
+ if line.match?(/\A[ \t]*::[ \t]/)
1440
+ term = [term, indent.call(line)].compact.min
1441
+ break if term <= column
1442
+ elsif line.match?(/\A[ \t]*:[ \t]/)
1443
+ # An earlier separator at this column with no term between reads the same.
1444
+ if term.nil? && indent.call(line) == column && @separator_below.key?(from)
1445
+ below = @separator_below[from]
1446
+ break
1447
+ end
1448
+ elsif term && indent.call(line) < term
1449
+ break
1450
+ end
1451
+ end
1452
+ below = !term.nil? && column < term if below.nil?
1453
+ @separator_below[start] = below
1454
+ end
1455
+
1456
+ # After a definition, only a heading, quote, table or div ends the list.
1457
+ def definition_before?
1458
+ return false unless @def_stream.equal?(@current_stream) && @def_pos
1459
+ depth = state?(:quotestart) ? quote_depth_on_line : 0
1460
+ return false unless depth == @def_quote_depth
1461
+
1462
+ lines = @current_stream.string.byteslice(@def_pos...@current_stream.pos).split("\n", -1)
1463
+ lines.shift
1464
+ lines.pop
1465
+ prefix = '> ' * depth
1466
+ blank = false
1467
+ lines.all? do |line|
1468
+ if line.empty? || line == prefix.rstrip
1469
+ blank = true
1470
+ next true
1471
+ end
1472
+ next false unless line.start_with?(prefix)
1473
+
1474
+ rest = line[prefix.length..]
1475
+ # a flush-left block after a blank line, or one that interrupts, ends it
1476
+ next false if blank && rest.match?(/\A[^ \t]/)
1477
+
1478
+ blank = false
1479
+ !rest.match?(/\A(?:\#{1,6} +|:{3,}|> |\||::|`{3,}|~{3,}|(?:[-*_][ \t]*){3,}\z)/)
1480
+ end
1481
+ end
1482
+
1483
+ def quote_block_follows?
1484
+ line = next_line_after_current
1485
+ return false unless line
1486
+ return false if line.empty? || line.match?(/\A[ \t]/)
1487
+
1488
+ while line.start_with?('> ')
1489
+ line = line[2..]
1490
+ return false if line.empty? || line.match?(/\A[ \t]/)
1491
+ end
1492
+ line != '>'
1493
+ end
1494
+
1094
1495
  def continue_paragraph(name)
1095
1496
  @lone_image = false
1096
1497
  goto name
1097
1498
  end
1098
1499
 
1099
- # Whether a fence opened by +run+ on the current line is closed further
1100
- # down. Only a closed fence interrupts a paragraph.
1101
- def fence_closed_below?(run, line_start = @current_stream.charpos)
1102
- text = @current_stream.string
1103
- line_end = text.index("\n", line_start) || text.length
1104
- closer = %r/^[ \t]*#{Regexp.escape(run[0])}{#{run.length},}[ \t]*$/
1105
- closer_below?(closer, run, line_end)
1500
+ # The tokens of a cell marker and its braced alignment block, from a
1501
+ # match whose last five groups are the marker and CELL_BRACE.
1502
+ def braced_cell(m)
1503
+ pipe, open, align, rest, close = m.captures.last(5)
1504
+ token pipe == '|' ? Punctuation : Operator, pipe
1505
+ token Name::Attribute, open
1506
+ token Operator, align
1507
+ token Name::Attribute, rest + close
1508
+ end
1509
+
1510
+ def start_paragraph
1511
+ @lone_image = !@current_stream.check(IMAGE_LINE).nil?
1512
+ @indented = @current_stream.check(/[ \t]/) ? true : false
1513
+ push :paragraph
1106
1514
  end
1107
1515
 
1108
1516
  # Lex the tail of a fence or container opener line without leaving the
@@ -1199,6 +1607,9 @@ module Rouge
1199
1607
  def fence_lexer(language)
1200
1608
  return nil if language.nil? || language.empty?
1201
1609
 
1610
+ return CsvTable.new if language == 'csv'
1611
+ return TsvTable.new if language == 'tsv'
1612
+
1202
1613
  Lexer.find_fancy(language)
1203
1614
  rescue StandardError
1204
1615
  nil
@@ -0,0 +1,357 @@
1
+ # -*- coding: utf-8 -*- #
2
+ # frozen_string_literal: true
3
+
4
+ module Rouge
5
+ module Lexers
6
+ # Decides which `+` characters of a Carve document are continuation
7
+ # markers (Part 9 §17 L3, CARVE-P9-031). The marker is relative: it sits at
8
+ # an open container's marker column, so the answer needs the container
9
+ # stack the line-based lexer states do not carry. This pass walks the
10
+ # document once and returns the character offsets of the markers.
11
+ #
12
+ # It is a reduced block model: list items, quotes, footnotes, definitions
13
+ # and fences, with lazy paragraph lines. Shapes it cannot place (tables,
14
+ # divs, attribute blocks) read as text.
15
+ class CarveContinuation
16
+ TAB = 4
17
+ ORDERED = /\A(?:\d+|[A-Za-z]|[ivxlcdm]+|[IVXLCDM]+)[.)]/.freeze
18
+ BULLET = /\A[-*](?=[ \t]|\z)/.freeze
19
+ FENCE = /\A(`{3,}|~{3,})/.freeze
20
+
21
+ Container = Struct.new(:kind, :col, :content, :quotes, keyword_init: true)
22
+
23
+ # A line whose only content after quote prefixes and openers is `+`:
24
+ # the shapes where the `+` is a marker or a refused marker.
25
+ CANDIDATE = /
26
+ \A(?:[ \t]|>(?=[ \t])|(?:[-*]|(?:\d+|[A-Za-z]|[ivxlcdm]+|[IVXLCDM]+)[.)])(?:\{[^}\n]*\})?(?=[ \t])
27
+ |\[[^\]\n]\](?=[ \t])|::?(?=[ \t])|\[\^[^\]\n]+\]:(?=[ ]))*\+[ \t]*\r?\z
28
+ /x.freeze
29
+
30
+ def self.markers(source)
31
+ new(source).markers
32
+ end
33
+
34
+ # Offsets of every `+` that stands where a marker could.
35
+ def self.candidates(source)
36
+ offset = 0
37
+ source.split("\n", -1).each_with_object([]) do |line, found|
38
+ found << offset + line.rindex('+') if line.match?(CANDIDATE)
39
+ offset += line.length + 1
40
+ end
41
+ end
42
+
43
+ def initialize(source)
44
+ @lines = source.split("\n", -1)
45
+ @markers = []
46
+ end
47
+
48
+ def markers
49
+ stack = []
50
+ fence = nil
51
+ para = nil
52
+ force = 0
53
+ offset = 0
54
+ blank = false
55
+ @lines.each_with_index do |raw, index|
56
+ line = raw.chomp("\r")
57
+ cols = columns(line)
58
+ matched, pos, quotes = match(stack, line, cols, force)
59
+ rest = line[pos..] || ''
60
+
61
+ if fence && (fence[:forced] || matched >= fence[:depth])
62
+ fence = nil if rest.match?(/\A[ \t]*#{Regexp.escape(fence[:char])}{#{fence[:width]},}[ \t]*\z/)
63
+ offset += raw.length + 1
64
+ force = 0
65
+ next
66
+ end
67
+ fence = nil
68
+
69
+ lone = rest.match(/\A([ \t]*)\+[ \t]*\z/)
70
+ attached = 0
71
+ if rest.match?(/\A[ \t]*\z/)
72
+ # a blank line ends every quote it does not prefix
73
+ stack.slice!(matched..)
74
+ para = nil
75
+ elsif lone
76
+ col = cols[pos + lone[1].length]
77
+ # A blank line above closes a description or a footnote for a `+`.
78
+ target = stack.rindex do |c|
79
+ c.col == col && c.quotes == quotes && c.kind != :term &&
80
+ !(blank && %i[definition note].include?(c.kind))
81
+ end
82
+ # Only an item refuses a list below it (the sibling rule).
83
+ if target && follower?(index + 1, quotes, col, quote: quotes.zero?,
84
+ list: stack[target].kind != :item)
85
+ @markers << offset + pos + lone[1].length
86
+ stack.slice!((target + 1)..)
87
+ attached = stack.length
88
+ para = nil
89
+ else
90
+ stack.slice!(matched..) unless para
91
+ para ||= stack.length
92
+ end
93
+ elsif para && (!opener?(rest) || lazy_list?(stack, rest, quotes) || unclosed_fence?(rest, index))
94
+ # a lazy line keeps every container open
95
+ else
96
+ stack.slice!(matched..)
97
+ para, fence, plus = open_blocks(stack, line, cols, pos, quotes, para, index)
98
+ fence[:forced] = true if fence && force.positive?
99
+ if plus
100
+ @markers << offset + plus
101
+ attached = stack.length
102
+ end
103
+ end
104
+ force = attached
105
+ blank = rest.match?(/\A[ \t]*\z/)
106
+ offset += raw.length + 1
107
+ end
108
+ @markers
109
+ end
110
+
111
+ private
112
+
113
+ # The visual column of every character position, tabs expanded.
114
+ def columns(line)
115
+ cols = []
116
+ col = 0
117
+ line.each_char do |ch|
118
+ cols << col
119
+ col = ch == "\t" ? (col / TAB + 1) * TAB : col + 1
120
+ end
121
+ cols << col
122
+ cols
123
+ end
124
+
125
+ # How many containers the line continues, where its content starts, and
126
+ # how many quote prefixes it passed.
127
+ # The containers up to +force+ are the ones an attached line enters.
128
+ def match(stack, line, cols, force)
129
+ pos = 0
130
+ quotes = 0
131
+ matched = 0
132
+ stack.each do |c|
133
+ blank = line[pos..].to_s.match?(/\A[ \t]*\z/)
134
+ if c.kind == :quote
135
+ ws = line[pos..][/\A[ \t]*/]
136
+ at = pos + ws.length
137
+ unless line[at] == '>' && (line[at + 1].nil? || line[at + 1].match?(/[ \t]/))
138
+ break unless matched < force
139
+
140
+ matched += 1
141
+ next
142
+ end
143
+
144
+ pos = at + 1
145
+ pos += 1 if line[pos]&.match?(/[ \t]/)
146
+ quotes += 1
147
+ elsif blank
148
+ # a blank line keeps an item open
149
+ else
150
+ ws = line[pos..][/\A[ \t]*/]
151
+ if cols[pos + ws.length] >= c.content || matched < force
152
+ pos = advance(line, cols, pos, c.content)
153
+ else
154
+ break
155
+ end
156
+ end
157
+ matched += 1
158
+ end
159
+ [matched, pos, quotes]
160
+ end
161
+
162
+ # Move +pos+ over whitespace up to +col+.
163
+ def advance(line, cols, pos, col)
164
+ pos += 1 while line[pos]&.match?(/[ \t]/) && cols[pos + 1] <= col
165
+ pos
166
+ end
167
+
168
+ def opener?(rest)
169
+ text = rest.lstrip
170
+ text.match?(BULLET) || text.match?(ORDERED) && text.match?(/\A\S+[ \t]/) ||
171
+ text.start_with?('>', '|') || text.match?(FENCE) ||
172
+ text.match?(/\A(?:\#{1,6}[ \t]|:::|::?[ \t]|\[\^[^\]]+\]:[ ])/)
173
+ end
174
+
175
+ # A list marker cannot interrupt a paragraph unless it continues a list
176
+ # already open in the line's own quote context.
177
+ def lazy_list?(stack, rest, quotes)
178
+ text = rest.lstrip
179
+ return false unless list_marker?(text)
180
+
181
+ stack.none? { |c| c.kind == :item && c.quotes == quotes }
182
+ end
183
+
184
+ # Only a closed fence interrupts a paragraph.
185
+ def unclosed_fence?(rest, index)
186
+ f = rest.lstrip.match(FENCE)
187
+ return false unless f
188
+
189
+ @last_closer ||= {}
190
+ last = @last_closer[f[1]] ||= begin
191
+ closer = /\A[ \t>]*#{Regexp.escape(f[1][0])}{#{f[1].length},}[ \t]*\r?\z/
192
+ @lines.rindex { |l| l.match?(closer) } || -1
193
+ end
194
+ last <= index
195
+ end
196
+
197
+ def list_marker?(text)
198
+ text.match?(BULLET) || text.match?(/#{ORDERED.source}(?=[ \t]|\z)/)
199
+ end
200
+
201
+ # The block a line at +col+ opens is the one the marker attaches.
202
+ # Inside a quote, `> +` does not attach a quote right after the prefix.
203
+ def follower?(index, quotes, col, quote: true, list: false)
204
+ line = @lines[index]
205
+ return false if line.nil?
206
+
207
+ line = line.chomp("\r")
208
+ cols = columns(line)
209
+ pos = 0
210
+ quotes.times do
211
+ ws = line[pos..][/\A[ \t]*/]
212
+ return false unless line[pos + ws.length] == '>'
213
+
214
+ pos += ws.length + 1
215
+ pos += 1 if line[pos]&.match?(/[ \t]/)
216
+ end
217
+ ws = line[pos..][/\A[ \t]*/]
218
+ text = line[(pos + ws.length)..]
219
+ return false if text.nil? || text.empty?
220
+ return false unless cols[pos + ws.length] == col
221
+ return false if !quote && ws.empty? && text.start_with?('>')
222
+
223
+ list || !list_marker?(text)
224
+ end
225
+
226
+ # Push the containers a line opens. Returns the paragraph depth, an open
227
+ # fence and the offset of a first-block marker.
228
+ def open_blocks(stack, line, cols, pos, quotes, para, index)
229
+ base = pos
230
+ start = stack.length
231
+ first = true
232
+ last = nil
233
+ loop do
234
+ ws = line[pos..][/\A[ \t]*/]
235
+ at = pos + ws.length
236
+ text = line[at..]
237
+ col = cols[at]
238
+ # `>+`, `>>+` and `>> +` open no quote: each `>` needs a space after it.
239
+ if text.start_with?('>') && (text[1].nil? || text[1] == ' ')
240
+ break if first && at != base
241
+
242
+ stack << Container.new(kind: :quote, col: col, content: col + 2, quotes: quotes)
243
+ quotes += 1
244
+ pos = at + 1
245
+ pos += 1 if line[pos] == ' '
246
+ last = :quote
247
+ elsif (m = text.match(/\A(?:[-*]|#{ORDERED.source})(?:\{[^}\n]*\})?(?= |\z)/))
248
+
249
+ after = at + m[0].length
250
+ gap = line[after..][/\A[ \t]*/]
251
+ content = gap.empty? || cols[after + gap.length] - cols[after] > 4 ? cols[after] + 1 : cols[after + gap.length]
252
+ # attributes glued to the marker take no columns
253
+ content -= cols[after] - cols[at + m[0].index('{').to_i] if m[0].include?('{')
254
+ stack << Container.new(kind: :item, col: col, content: content, quotes: quotes)
255
+ pos = after + gap.length
256
+ last = m[0].start_with?('-', '*') ? :bullet : :ordered
257
+ elsif last == :bullet && (m = text.match(/\A\[[^\]\n]\](?= )/))
258
+ pos = at + m[0].length
259
+ pos += line[pos..][/\A[ \t]*/].length
260
+ last = :task
261
+ elsif (first || last == :quote) && text.match?(/\A\[\^[^\]\n]+\]: /)
262
+ m = text.match(/\A\[\^[^\]\n]+\]: /)
263
+ stack << Container.new(kind: :note, col: col, content: col + 2, quotes: quotes)
264
+ pos = at + m[0].length
265
+ last = :note
266
+ elsif (first || last == :quote) && text.match?(/\A:: /)
267
+ @term_open = stack.length
268
+ stack << Container.new(kind: :term, col: col, content: col + 2, quotes: quotes)
269
+ return [nil, nil, nil]
270
+ elsif (first || last == :quote) && @term_open == stack.length && text.match?(/\A: /)
271
+ stack << Container.new(kind: :definition, col: col, content: col + 2, quotes: quotes)
272
+ pos = at + 2
273
+ last = :definition
274
+ else
275
+ break
276
+ end
277
+ first = false
278
+ end
279
+
280
+ ws = line[pos..][/\A[ \t]*/]
281
+ text = line[(pos + ws.length)..] || ''
282
+ @term_open = last == :definition ? stack.length - 1 : nil
283
+ if last && text.match?(/\A\+[ \t]*\z/)
284
+ case last
285
+ when :quote
286
+ hosted = (start...stack.length).find { |i| stack[i].kind == :item }
287
+ spaced = !hosted || !line[base...pos].match?(/\S[ \t]{2,}/)
288
+ return [nil, nil, pos] if spaced && ws.empty? && first_block_follower?(index + 1, stack, cols[base], hosted, start)
289
+ when :note
290
+ return [nil, nil, pos + ws.length] if first_block_follower?(index + 1, stack, cols[base])
291
+ else
292
+ return [nil, nil, pos + ws.length]
293
+ end
294
+ end
295
+ return [nil, nil, nil] if text.empty?
296
+
297
+ # at document level a fence opens at column 0 only
298
+ if (f = text.match(FENCE)) && (stack.any? || ws.empty? || pos != base)
299
+ return [nil, { char: f[1][0], width: f[1].length, depth: stack.length }, nil]
300
+ end
301
+
302
+ [stack.length, nil, nil]
303
+ end
304
+
305
+ # A first-block marker after a quote or a footnote attaches a non-list
306
+ # block at document column 0 or at the column where the line's openers
307
+ # began; right after an enclosing quote's prefix counts as column 0 of
308
+ # that quote. A line that re-enters the opened container is its text.
309
+ # A quote opened behind a list item on the same line (+hosted+, the
310
+ # stack index of the first item the line opens) reads the follower at
311
+ # its raw column: the containers from that item on are not entered, and
312
+ # the item's and the owning quote's columns are eligible too
313
+ # (markup-carve/carve#3421, corpus 649 to 656). Its markers are one space
314
+ # apart: `- > +` is text.
315
+ def first_block_follower?(index, stack, base_col, hosted = nil, start = nil)
316
+ line = @lines[index]
317
+ return false if line.nil?
318
+
319
+ line = line.chomp("\r")
320
+ cols = columns(line)
321
+ pos = 0
322
+ reached = 0
323
+ after_quote = true
324
+ (hosted ? stack[0...hosted] : stack).each do |c|
325
+ ws = line[pos..][/\A[ \t]*/]
326
+ at = pos + ws.length
327
+ if c.kind == :quote
328
+ break unless line[at] == '>' && (line[at + 1].nil? || line[at + 1] == ' ')
329
+
330
+ pos = at + 1
331
+ pos += 1 if line[pos] == ' '
332
+ after_quote = true
333
+ else
334
+ break unless line[at] && cols[at] >= c.content
335
+
336
+ pos = advance(line, cols, pos, c.content)
337
+ after_quote = false
338
+ end
339
+ reached += 1
340
+ end
341
+ return false if !hosted && reached == stack.length
342
+
343
+ ws = line[pos..][/\A[ \t]*/]
344
+ at = pos + ws.length
345
+ text = line[at..] || ''
346
+ return false if text.empty? || list_marker?(text)
347
+ return false if hosted && text.match?(/\A\+[ \t]*\z/)
348
+
349
+ # Stacked quotes opened on one line (`> > +`) take the first one's column.
350
+ own = hosted || start.nil? ? stack.last.col : stack[start].col
351
+ return cols[at].zero? || cols[at] == own if reached.zero?
352
+
353
+ cols[at] == base_col || (after_quote && ws.empty?) || (hosted && [stack[hosted].col, stack.last.col].include?(cols[at]))
354
+ end
355
+ end
356
+ end
357
+ end
@@ -3,5 +3,5 @@
3
3
  # Kept apart from the entry file so the gemspec can read it without loading
4
4
  # Rouge, which is not installed yet when Bundler evaluates the gemspec.
5
5
  module RougeCarve
6
- VERSION = '0.1.3'
6
+ VERSION = '0.1.5'
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rouge-carve
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Scherer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-14 00:00:00.000000000 Z
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rouge
@@ -47,6 +47,7 @@ files:
47
47
  - lib/rouge-carve/version.rb
48
48
  - lib/rouge/demos/carve
49
49
  - lib/rouge/lexers/carve.rb
50
+ - lib/rouge/lexers/carve_continuation.rb
50
51
  homepage: https://github.com/markup-carve/rouge-carve
51
52
  licenses:
52
53
  - MIT