rouge-carve 0.1.3 → 0.1.4

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: 300be9217acf879b361b092d5bd9f814858ba169272f98f730cdbfa86a2e75bf
4
+ data.tar.gz: 1b4e02989bd311b9d0423e1021250d5ddac1982420c15016d80600ddb82386b0
5
5
  SHA512:
6
- metadata.gz: bdc875e2a50141600798ff0efcd8741e751516dbb3de632f55039a62e881f4edbe4561942e8b39228e6603c1e6cef428d5320323b2617213d5f8ceed1053e550
7
- data.tar.gz: 8d1983500c85ecef17ac25bac9aad4ebf76b477bf75ee721d1343ef09a82cf424c86c4bf9cb65b948361183bf97cce2bffe90630f20fa519368d5253fed1f13c
6
+ metadata.gz: 63d049afb99f11ac50d7b64a2829c8fec7bc220b1501b621cbbe2e6a87160e75cc326bc94f3724f3280650e12e9be2ce0a00bb160f1c9012e717fc2d538916b9
7
+ data.tar.gz: d3dce00b0304ec63f85dc9e258f78e7c516d7a9048e1ac9e76e8745140c2931a8ee3e0de70abc7a7a804203b511707d901f7ecd44c152ad08bdaeaa3e22dd35d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.4] - 2026-10-23
6
+
7
+ - `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).
8
+ - 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).
9
+ - A quote-prefixed `+` needs one space after each `>`: `>+`, `>>+` and `>> +` are paragraph text (#33).
10
+ - 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).
11
+ - A `:` left of the `::` column of the definition list it would continue is paragraph text (markup-carve/carve#3458) (#34).
12
+
5
13
  ## [0.1.3] - 2026-10-14
6
14
 
7
15
  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
@@ -49,6 +49,7 @@ markup, symbol shortcodes, mentions, tags and the typographic runs.
49
49
  as Ruby, because that is what a reader expects. A ` ```=html ` raw block is not:
50
50
  its info string names an output format, not a language, so the payload stays
51
51
  opaque.
52
+ CSV and TSV fences highlight separators, quoted fields, escaped quotes, and unquoted fields (the header record apart from the data rows).
52
53
 
53
54
  **Fence widths are respected.** A three-backtick line inside a four-backtick
54
55
  fence is content, not a closer. The width is closed over per block rather than
@@ -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
 
@@ -110,8 +180,13 @@ module Rouge
110
180
  # is a word, not a marker.
111
181
  ORDERED = '(?:\\d+|[A-Za-z]|[ivxlcdm]+|[IVXLCDM]+)[.)]'
112
182
 
183
+ # The states read a column-0 `+` with a flush-left block below as an
184
+ # attachment. Whether a `+` is the marker is settled by CarveContinuation.
185
+ PLUS_NEXT = /(?=\n[^ \t\n])/.freeze
186
+
113
187
  # A list marker at the start of a line, as the list rules below read it.
114
188
  LIST_MARKER = "(?:[-*]|#{ORDERED}|\\.)(?:#{ATTRS})?(?:[ \\t]|$)"
189
+ OPENER_RUN = "(?:(?:[-*]|#{ORDERED}|\\.)(?:#{ATTRS})? +[ \\t]*|> )+"
115
190
 
116
191
  # A table row needs its closing pipe: `| not a table` is a paragraph.
117
192
  ROW_END = /(?=(?:\\.|[^\\\n])*\|[ \t]*(?:#{ATTRS})?[ \t]*$)/.freeze
@@ -199,6 +274,7 @@ module Rouge
199
274
  # enters its state through #open_block, which knows which of the two it
200
275
  # is running in.
201
276
  def self.block_openers(bol)
277
+ root_margin = bol == '^' ? '()' : '( )'
202
278
  proc do
203
279
  # A raw block: the `=FORMAT` info string routes the payload to that
204
280
  # output format verbatim. Emitted as one token including the `=`,
@@ -233,14 +309,14 @@ module Rouge
233
309
  end
234
310
 
235
311
  # A caption line attaches to the block above or below it.
236
- rule %r/#{bol}(#{MARGIN})(\^)([ \t]+)/ do
312
+ rule %r/#{bol}(#{MARGIN})(\^)( +)(?=[ \t]*[^ \t\n])/ do
237
313
  groups Text, Punctuation, Text
238
314
  open_block :caption
239
315
  end
240
316
 
241
317
  # Headings. Carve has no setext form, so a `#` run is the only
242
318
  # spelling and a trailing `{...}` is NOT an attribute block here.
243
- rule %r/#{bol}(#{MARGIN})(\#{1,6})([ \t]+)/ do
319
+ rule %r/#{bol}(#{MARGIN})(\#{1,6})( +)(?=[ \t]*[^ \t\n])/ do
244
320
  groups Text, Punctuation, Text
245
321
  open_block :heading
246
322
  end
@@ -257,7 +333,7 @@ module Rouge
257
333
  # definition ends with its line.
258
334
  rule %r/#{bol}(#{MARGIN})(\[\^)([^\]\n]+)(\]:)( )/ do
259
335
  groups Text, Punctuation, Name::Label, Punctuation, Text
260
- open_block :defline
336
+ open_block(:defline, :defline, host: :footnote)
261
337
  end
262
338
  rule %r/#{bol}(#{MARGIN})(\*\[)([^\]\n]+)(\]:)( )/ do
263
339
  groups Text, Punctuation, Name::Entity, Punctuation, Text
@@ -270,47 +346,125 @@ module Rouge
270
346
 
271
347
  # A definition-list term (`::`) and its definition (`:`). A term
272
348
  # may wrap onto lazy lines, and a definition's text is an item's.
273
- rule %r/#{bol}(#{MARGIN})(::)([ \t]+)/ do
349
+ rule %r/#{bol}(#{MARGIN})(::)( +)(?=[ \t]*[^ \t\n])/ do
274
350
  groups Text, Punctuation, Text
351
+ remember_host(:term)
275
352
  open_block :dlterm, :heading
276
353
  end
277
- rule %r/#{bol}(#{MARGIN})(:)(?=[ \t])/ do
278
- groups Text, Punctuation
354
+ rule %r/#{bol}#{root_margin}(:)( +[ \t]*)(\+)()$#{PLUS_NEXT}/ do
355
+ if definition_term_before?
356
+ groups Text, Punctuation, Text, Operator, Text
357
+ else
358
+ groups Text, Punctuation, Text, Text, Text
359
+ end
279
360
  open_block :listitem, :quoteline
280
361
  end
362
+ # Without a term above, `: b` is paragraph text (a lazy line too).
363
+ rule %r/#{bol}(#{MARGIN})(:)(?= +[ \t]*[^ \t\n])/ do
364
+ # the scan from the last definition is short; the term scan runs once
365
+ if (definition_before? || definition_term_before?) && !separator_below_term?
366
+ groups Text, Punctuation
367
+ term = [@term_stream, @term_pos, @term_quote_depth]
368
+ open_block :listitem, :quoteline
369
+ # the next `: d` may still belong to the same term
370
+ @term_stream, @term_pos, @term_quote_depth = term
371
+ @def_stream = @current_stream
372
+ @def_pos = @last_host_pos
373
+ @def_quote_depth = @term_quote_depth
374
+ else
375
+ groups Text, Text
376
+ open_block :paragraph
377
+ end
378
+ end
281
379
 
282
380
  # A blockquote marker must be followed by a space or end the line;
283
381
  # `>foo` and `>> x` are paragraphs, and `> > x` nests. What follows
284
382
  # a marker may open a block of its own, so the openers are tried
285
383
  # again there.
286
- rule %r/#{bol}(#{MARGIN})(>)(?=[ \t]|$)/ do
384
+ rule %r/#{bol}(#{MARGIN})(>)(?= |$)/ do |m|
287
385
  groups Text, Punctuation
288
- state?(:quotestart) ? goto(:quotestart) : push(:quotestart)
386
+ record_quote_depth(quote_depth_on_line + 1)
387
+ remember_host(:quote)
388
+ rooted = state?(:quotestart) ? m[1] == ' ' : m[1].empty?
389
+ if rooted
390
+ state?(:quotestart) ? goto(:quotestart) : push(:quotestart)
391
+ else
392
+ state?(:quotestart) ? goto(:quoteline) : push(:quoteline)
393
+ end
394
+ end
395
+
396
+ # A task can attach its first block on the marker line.
397
+ rule %r/#{bol}#{root_margin}([-*](?:#{ATTRS})?)( +[ \t]*)(\[[^\]\n]\])( +[ \t]*)(\+)()$#{PLUS_NEXT}/ do
398
+ groups Text, Punctuation, Text, Name::Constant, Text, Operator, Text
399
+ open_block :listitem, :quoteline
400
+ end
401
+
402
+ rule %r/#{bol}#{root_margin}(#{OPENER_RUN})(\+)()$#{PLUS_NEXT}/ do |m|
403
+ token Text, m[1]
404
+ scanner = StringScanner.new(m[2])
405
+ until scanner.eos?
406
+ if (marker = scanner.scan(/(?:#{ORDERED}|\.)(?:#{ATTRS})?/))
407
+ token Num::Integer, marker
408
+ elsif (marker = scanner.scan(/[-*](?:#{ATTRS})?|>/))
409
+ token Punctuation, marker
410
+ else
411
+ token Text, scanner.getch
412
+ end
413
+ end
414
+ depth = quote_depth_on_line
415
+ token(state?(:quotestart) && !quote_level_follows?(depth) ? Text : Operator, m[3])
416
+ token Text, m[4]
417
+ m[2].rstrip.end_with?('>') ? open_block(:quoteline) : open_block(:listitem, :quoteline)
418
+ end
419
+
420
+ # A quote opened on a list item's marker line (`- > q`): its `>` is a
421
+ # marker, and a `+` behind it may be the quote's first block
422
+ # (markup-carve/carve#3421).
423
+ rule %r/#{bol}(#{MARGIN})((?:(?:[-*](?:#{ATTRS})?(?: +\[[^\]\n]\])?|#{ORDERED}(?:#{ATTRS})?|\.(?:#{ATTRS})?) +)+)((?:> +)*>)(?:( )(\+)()$|(?= |$))/ do |m|
424
+ token Text, m[1]
425
+ scanner = StringScanner.new(m[2] + m[3])
426
+ until scanner.eos?
427
+ if (marker = scanner.scan(/(?:#{ORDERED}|\.)(?:#{ATTRS})?/))
428
+ token Num::Integer, marker
429
+ elsif (marker = scanner.scan(/\[[^\]\n]\]/))
430
+ token Name::Constant, marker
431
+ elsif (marker = scanner.scan(/[-*](?:#{ATTRS})?|>/))
432
+ token Punctuation, marker
433
+ else
434
+ token Text, scanner.scan(/ +/)
435
+ end
436
+ end
437
+ if m[5]
438
+ token Text, m[4]
439
+ # CarveContinuation settles whether this `+` is the marker.
440
+ token Operator, m[5]
441
+ end
442
+ open_block(:quoteline)
289
443
  end
290
444
 
291
445
  # Task items before plain bullets, so the state marker is its own
292
446
  # token. The state is any single character, not only a space or an
293
447
  # x: `[>]` is deferred and `[-]` is dropped. A `+` is not a bullet in
294
448
  # Carve; it continues a container.
295
- rule %r/#{bol}(#{MARGIN})((?:[-*]|#{ORDERED})(?:#{ATTRS})?)([ \t]+)(\[[^\]\n]\])/ do
449
+ rule %r/#{bol}(#{MARGIN})((?:[-*]|#{ORDERED})(?:#{ATTRS})?)( +)(\[[^\]\n]\])(?= +[ \t]*[^ \t\n])/ do
296
450
  groups Text, Punctuation, Text, Name::Constant
297
451
  open_block :listitem, :quoteline
298
452
  end
299
453
 
300
454
  # Bullets. A run of markers on one line opens nested lists at once
301
455
  # (`- - A`), and attributes may be glued straight onto the marker.
302
- rule %r/#{bol}(#{MARGIN})((?:[-*][ \t]+)*[-*](?:#{ATTRS})?)(?=[ \t]|$)/ do
456
+ rule %r/#{bol}(#{MARGIN})((?:[-*] +)*[-*](?:#{ATTRS})?)(?= +[ \t]*[^ \t\n])/ do
303
457
  groups Text, Punctuation
304
458
  open_block :listitem, :quoteline
305
459
  end
306
460
 
307
461
  # Ordered markers: numeric, alphabetic, roman, and the bare `.` that
308
462
  # continues the enclosing sequence.
309
- rule %r/#{bol}(#{MARGIN})(#{ORDERED}(?:#{ATTRS})?)(?=[ \t]|$)/ do
463
+ rule %r/#{bol}(#{MARGIN})(#{ORDERED}(?:#{ATTRS})?)(?= +[ \t]*[^ \t\n])/ do
310
464
  groups Text, Num::Integer
311
465
  open_block :listitem, :quoteline
312
466
  end
313
- rule %r/#{bol}(#{MARGIN})(\.(?:#{ATTRS})?)(?=[ \t]|$)/ do
467
+ rule %r/#{bol}(#{MARGIN})(\.(?:#{ATTRS})?)(?= +[ \t]*[^ \t\n])/ do
314
468
  groups Text, Num::Integer
315
469
  open_block :listitem, :quoteline
316
470
  end
@@ -371,6 +525,15 @@ module Rouge
371
525
  groups Text, Comment::Preproc, Comment
372
526
  end
373
527
 
528
+ rule %r/^(\+)()$#{PLUS_NEXT}/ do |m|
529
+ if continuation_host_before?
530
+ groups Operator, Text
531
+ else
532
+ token Text, m[0]
533
+ push :paragraph
534
+ end
535
+ end
536
+
374
537
  mixin :blockopeners
375
538
 
376
539
  # A standalone attribute block, which may span lines.
@@ -400,6 +563,9 @@ module Rouge
400
563
 
401
564
  state :paraline do
402
565
  rule(%r/^(?=[ \t]*(?:\n|\z))/) { pop! }
566
+ rule(%r/^(?=\+$#{PLUS_NEXT})/) do
567
+ continuation_host_before? ? pop! : continue_paragraph(:paragraph)
568
+ end
403
569
  rule(%r/^(?=#{MARGIN}(?:#{INTERRUPT}))/) { pop! }
404
570
  rule(%r/^(?=#{MARGIN}(`{3,}|~{3,}))/) { |m| fence_closed_below?(m[1]) ? pop! : continue_paragraph(:paragraph) }
405
571
  rule(%r/^(?=#{MARGIN}\^[ \t])/) { @lone_image ? pop! : continue_paragraph(:paragraph) }
@@ -422,6 +588,12 @@ module Rouge
422
588
 
423
589
  state line do
424
590
  rule(%r/^(?=[ \t]*(?:\n|\z))/) { pop! }
591
+ if name == :listitem
592
+ rule %r/^(\+)()$#{PLUS_NEXT}/ do
593
+ groups Operator, Text
594
+ goto name
595
+ end
596
+ end
425
597
  rule(%r/^(?=#{MARGIN}(?:#{INTERRUPT}|#{LIST_MARKER}|:[ \t]))/) { pop! }
426
598
  rule(%r/^(?=#{MARGIN}(`{3,}|~{3,}))/) { |m| fence_closed_below?(m[1]) ? pop! : goto(name) }
427
599
  rule(//) { goto name }
@@ -849,8 +1021,50 @@ module Rouge
849
1021
  end
850
1022
  end
851
1023
 
1024
+ # The lexer states cannot carry the open containers' marker columns, so
1025
+ # the `+` scope is settled afterwards from a document-level pass: a `+`
1026
+ # it names is the continuation operator, every other `+` the states
1027
+ # gave Operator is text.
1028
+ def stream_tokens(str, &block)
1029
+ markers = CarveContinuation.markers(str).to_h { |offset| [offset, true] }
1030
+ candidates = CarveContinuation.candidates(str).to_h { |offset| [offset, true] }
1031
+ pos = 0
1032
+ super(str) do |tok, val|
1033
+ if val.include?('+') && candidates.any?
1034
+ retag_plus(tok, val, pos, markers, candidates, &block)
1035
+ else
1036
+ yield tok, val
1037
+ end
1038
+ pos += val.length
1039
+ end
1040
+ end
1041
+
852
1042
  private
853
1043
 
1044
+ def retag_plus(tok, val, pos, markers, candidates)
1045
+ run = +''
1046
+ val.each_char.with_index do |ch, i|
1047
+ scope = if ch != '+' || !candidates[pos + i] || opaque?(tok) then tok
1048
+ elsif markers[pos + i] then Operator
1049
+ elsif tok == Operator then Text
1050
+ else tok
1051
+ end
1052
+ if scope != tok
1053
+ yield tok, run unless run.empty?
1054
+ run = +''
1055
+ yield scope, ch
1056
+ else
1057
+ run << ch
1058
+ end
1059
+ end
1060
+ yield tok, run unless run.empty?
1061
+ end
1062
+
1063
+ # Comment and literal payloads keep their scope.
1064
+ def opaque?(tok)
1065
+ tok <= Comment || tok <= Literal
1066
+ end
1067
+
854
1068
  # The token a state gives a character that opens nothing.
855
1069
  PLAIN_TOKENS = {
856
1070
  heading: Generic::Heading, quoteline: Generic::Emph,
@@ -1083,7 +1297,9 @@ module Rouge
1083
1297
  # opener that is a single token. After a blockquote marker the opener
1084
1298
  # replaces the quote's start state, and a single-token opener hands the
1085
1299
  # rest of the line to quoted text.
1086
- def open_block(name = nil, quoted = name || :quoteline)
1300
+ def open_block(name = nil, quoted = name || :quoteline, host: nil)
1301
+ kind = host || (name == :listitem ? :list : name == :quoteline ? :quote : nil)
1302
+ remember_host(kind) if kind
1087
1303
  if state?(:quotestart)
1088
1304
  goto quoted
1089
1305
  elsif name
@@ -1091,6 +1307,193 @@ module Rouge
1091
1307
  end
1092
1308
  end
1093
1309
 
1310
+ def remember_host(kind)
1311
+ @host_stream = @current_stream
1312
+ @last_host_pos = @current_stream.pos
1313
+ @last_host_kind = kind
1314
+ if kind == :term
1315
+ @term_stream = @current_stream
1316
+ @term_pos = @last_host_pos
1317
+ @term_quote_depth = state?(:quotestart) ? quote_depth_on_line : 0
1318
+ elsif kind != :quote
1319
+ @term_pos = nil
1320
+ end
1321
+ @host_scan = StringScanner.new(@current_stream.string)
1322
+ @host_scan.pos = @last_host_pos
1323
+ @host_scan.scan(/[^\n]*\n/)
1324
+ @host_scan_valid = true
1325
+ @host_scan_fence = nil
1326
+ @host_scan_previous = nil
1327
+ end
1328
+
1329
+ def quote_depth_on_line
1330
+ line_start = line_start_byte
1331
+ @quote_line_start == line_start ? @quote_depth_current : 0
1332
+ end
1333
+
1334
+ def record_quote_depth(depth)
1335
+ @quote_line_start = line_start_byte
1336
+ @quote_depth_current = depth
1337
+ end
1338
+
1339
+ def line_start_byte
1340
+ text = @current_stream.string
1341
+ pos = @current_stream.pos
1342
+ pos -= 1 while pos.positive? && text.getbyte(pos - 1) != 10
1343
+ pos
1344
+ end
1345
+
1346
+ def next_line_after_current
1347
+ scan = StringScanner.new(@current_stream.string)
1348
+ scan.pos = @current_stream.pos
1349
+ return nil unless scan.scan(/[^\n]*\n/)
1350
+
1351
+ scan.scan(/[^\n]*/)
1352
+ end
1353
+
1354
+ def quote_level_follows?(depth)
1355
+ line = next_line_after_current
1356
+ return false unless line
1357
+ depth.times do
1358
+ return false unless line.start_with?('> ')
1359
+
1360
+ line = line[2..]
1361
+ end
1362
+ !line.empty? && !line.match?(/\A[ \t]/)
1363
+ end
1364
+
1365
+ def continuation_host_before?
1366
+ return false unless @host_stream.equal?(@current_stream) && @last_host_pos
1367
+ return false if @last_host_kind == :term
1368
+ return false if @last_host_kind == :quote && !quote_block_follows?
1369
+
1370
+ return false unless @host_scan_valid
1371
+
1372
+ limit = @current_stream.pos
1373
+ while (line = @host_scan.check(/[^\n]*\n/))
1374
+ break if @host_scan.pos + line.bytesize > limit
1375
+
1376
+ @host_scan.scan(/[^\n]*\n/)
1377
+ line = line.chomp("\n")
1378
+ if @host_scan_fence
1379
+ fence = @host_scan_fence
1380
+ @host_scan_fence = nil if line.match?(/\A#{Regexp.escape(fence)}[#{Regexp.escape(fence[0])}]*[ \t]*\z/)
1381
+ elsif line.match?(/\A[ \t]*\z/)
1382
+ @host_scan_valid = false
1383
+ elsif (opening = line.match(/\A(`{3,}|~{3,})/))
1384
+ @host_scan_valid = false unless @host_scan_previous&.match?(/\A\+[ \t]*\z/)
1385
+ @host_scan_fence = opening[1]
1386
+ elsif line.match?(/\A(?:\#{1,6} +|::{1,}|\^ +|\||(?:[-*_][ \t]*){3,}|\[[^\]]+\]: )/)
1387
+ @host_scan_valid = false unless @host_scan_previous&.match?(/\A\+[ \t]*\z/)
1388
+ end
1389
+ @host_scan_previous = line
1390
+ return false unless @host_scan_valid
1391
+ end
1392
+ true
1393
+ end
1394
+
1395
+ def definition_term_before?
1396
+ return false unless @term_stream.equal?(@current_stream) && @term_pos
1397
+ depth = state?(:quotestart) ? quote_depth_on_line : 0
1398
+ return false unless depth == @term_quote_depth
1399
+
1400
+ between = @current_stream.string.byteslice(@term_pos...@current_stream.pos)
1401
+ lines = between.split("\n", -1)
1402
+ lines.shift
1403
+ lines.pop
1404
+ if depth.positive?
1405
+ prefix = '> ' * depth
1406
+ return false unless lines.all? { |line| line.empty? || line.start_with?(prefix) }
1407
+
1408
+ lines.map! { |line| line.empty? ? line : line[prefix.length..] }
1409
+ end
1410
+ return false if lines.count { |line| line.match?(/\A[ \t]*\z/) } > 1
1411
+
1412
+ lines.none? { |line| line.match?(/\A(?:\#{1,6} +|:{3,}|[-*] +|> |\||`{3,}|~{3,}|(?:[-*_][ \t]*){3,}\z)/) }
1413
+ end
1414
+
1415
+ # A `:` left of the `::` column of every open term above it ends the
1416
+ # definition list and is paragraph text (markup-carve/carve#3458).
1417
+ def separator_below_term?
1418
+ text = @current_stream.string
1419
+ unless @separator_source.equal?(text)
1420
+ @separator_source = text
1421
+ @separator_bytes = text.b
1422
+ @separator_below = {}
1423
+ end
1424
+ start = line_start_byte
1425
+ depth = state?(:quotestart) ? quote_depth_on_line : 0
1426
+ strip = lambda do |line|
1427
+ depth.times { line = line.start_with?('> ') ? line[2..] : line.sub(/\A>\z/, '') }
1428
+ line
1429
+ end
1430
+ indent = ->(line) { line[/\A[ \t]*/].gsub("\t", ' ').length }
1431
+ column = indent.call(strip.call(text.byteslice(start...(@separator_bytes.index("\n", start) || text.bytesize))))
1432
+ term = nil
1433
+ below = nil
1434
+ finish = start - 1
1435
+ while finish.positive?
1436
+ from = (@separator_bytes.rindex("\n", finish - 1) || -1) + 1
1437
+ line = strip.call(text.byteslice(from...finish))
1438
+ finish = from - 1
1439
+ next if line.strip.empty?
1440
+
1441
+ if line.match?(/\A[ \t]*::[ \t]/)
1442
+ term = [term, indent.call(line)].compact.min
1443
+ break if term <= column
1444
+ elsif line.match?(/\A[ \t]*:[ \t]/)
1445
+ # An earlier separator at this column with no term between reads the same.
1446
+ if term.nil? && indent.call(line) == column && @separator_below.key?(from)
1447
+ below = @separator_below[from]
1448
+ break
1449
+ end
1450
+ elsif term && indent.call(line) < term
1451
+ break
1452
+ end
1453
+ end
1454
+ below = !term.nil? && column < term if below.nil?
1455
+ @separator_below[start] = below
1456
+ end
1457
+
1458
+ # After a definition, only a heading, quote, table or div ends the list.
1459
+ def definition_before?
1460
+ return false unless @def_stream.equal?(@current_stream) && @def_pos
1461
+ depth = state?(:quotestart) ? quote_depth_on_line : 0
1462
+ return false unless depth == @def_quote_depth
1463
+
1464
+ lines = @current_stream.string.byteslice(@def_pos...@current_stream.pos).split("\n", -1)
1465
+ lines.shift
1466
+ lines.pop
1467
+ prefix = '> ' * depth
1468
+ blank = false
1469
+ lines.all? do |line|
1470
+ if line.empty? || line == prefix.rstrip
1471
+ blank = true
1472
+ next true
1473
+ end
1474
+ next false unless line.start_with?(prefix)
1475
+
1476
+ rest = line[prefix.length..]
1477
+ # a flush-left block after a blank line, or one that interrupts, ends it
1478
+ next false if blank && rest.match?(/\A[^ \t]/)
1479
+
1480
+ blank = false
1481
+ !rest.match?(/\A(?:\#{1,6} +|:{3,}|> |\||::|`{3,}|~{3,}|(?:[-*_][ \t]*){3,}\z)/)
1482
+ end
1483
+ end
1484
+
1485
+ def quote_block_follows?
1486
+ line = next_line_after_current
1487
+ return false unless line
1488
+ return false if line.empty? || line.match?(/\A[ \t]/)
1489
+
1490
+ while line.start_with?('> ')
1491
+ line = line[2..]
1492
+ return false if line.empty? || line.match?(/\A[ \t]/)
1493
+ end
1494
+ line != '>'
1495
+ end
1496
+
1094
1497
  def continue_paragraph(name)
1095
1498
  @lone_image = false
1096
1499
  goto name
@@ -1199,6 +1602,9 @@ module Rouge
1199
1602
  def fence_lexer(language)
1200
1603
  return nil if language.nil? || language.empty?
1201
1604
 
1605
+ return CsvTable.new if language == 'csv'
1606
+ return TsvTable.new if language == 'tsv'
1607
+
1202
1608
  Lexer.find_fancy(language)
1203
1609
  rescue StandardError
1204
1610
  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.4'
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.4
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-10-23 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