mdlint 0.1.0 → 0.2.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.pre-commit-hooks.yaml +6 -0
  3. data/CHANGELOG.md +33 -0
  4. data/README.md +136 -4
  5. data/Rakefile +14 -0
  6. data/Steepfile +17 -0
  7. data/action.yml +54 -0
  8. data/benchmark/compare.rb +49 -0
  9. data/benchmark/format.rb +29 -0
  10. data/lib/mdlint/cache_store.rb +85 -0
  11. data/lib/mdlint/cli/output_formatter.rb +150 -0
  12. data/lib/mdlint/cli.rb +268 -106
  13. data/lib/mdlint/config.rb +87 -1
  14. data/lib/mdlint/dialect.rb +53 -0
  15. data/lib/mdlint/linter/directive_filter.rb +91 -0
  16. data/lib/mdlint/linter/rule.rb +25 -5
  17. data/lib/mdlint/linter/rule_engine.rb +44 -7
  18. data/lib/mdlint/linter/rules/code_block_syntax.rb +128 -0
  19. data/lib/mdlint/linter/rules/first_line_heading.rb +10 -3
  20. data/lib/mdlint/linter/rules/heading_increment.rb +4 -3
  21. data/lib/mdlint/linter/rules/heading_style.rb +24 -2
  22. data/lib/mdlint/linter/rules/japanese.rb +201 -0
  23. data/lib/mdlint/linter/rules/line_length.rb +37 -0
  24. data/lib/mdlint/linter/rules/link_check.rb +151 -0
  25. data/lib/mdlint/linter/rules/no_multiple_blanks.rb +1 -0
  26. data/lib/mdlint/linter/rules/no_trailing_spaces.rb +1 -0
  27. data/lib/mdlint/linter/rules/source_style.rb +317 -0
  28. data/lib/mdlint/linter/violation.rb +16 -1
  29. data/lib/mdlint/linter.rb +9 -3
  30. data/lib/mdlint/lsp.rb +176 -0
  31. data/lib/mdlint/parallel_runner.rb +42 -0
  32. data/lib/mdlint/parser/block_parser.rb +627 -50
  33. data/lib/mdlint/parser/inline_parser.rb +259 -27
  34. data/lib/mdlint/parser/state.rb +21 -2
  35. data/lib/mdlint/parser.rb +5 -5
  36. data/lib/mdlint/plugin.rb +31 -0
  37. data/lib/mdlint/renderer/html_renderer.rb +346 -0
  38. data/lib/mdlint/renderer/md_renderer.rb +147 -11
  39. data/lib/mdlint/renderer.rb +5 -0
  40. data/lib/mdlint/text_width.rb +39 -0
  41. data/lib/mdlint/toc.rb +80 -0
  42. data/lib/mdlint/token.rb +3 -1
  43. data/lib/mdlint/version.rb +1 -1
  44. data/lib/mdlint.rb +25 -5
  45. data/script/commonmark_compatibility.rb +24 -0
  46. data/script/fetch_commonmark_spec.rb +14 -0
  47. data/sig/internal.rbs +405 -0
  48. data/sig/mdlint.rbs +107 -0
  49. metadata +26 -2
@@ -1,29 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "state"
4
+ require_relative "../dialect"
4
5
 
5
6
  module Mdlint
6
7
  module Parser
7
8
  class BlockParser
8
9
  ATX_HEADING_REGEXP = /\A {0,3}(\#{1,6})(?:\s+(.*))?$/
9
10
  SETEXT_HEADING_REGEXP = /\A {0,3}(=+|-+)\s*\z/
10
- FENCE_OPEN_REGEXP = /\A {0,3}(`{3,}|~{3,})([^`]*)\z/
11
+ FENCE_OPEN_REGEXP = /\A {0,3}(`{3,}|~{3,})(.*)\z/
11
12
  BLOCKQUOTE_REGEXP = /\A {0,3}> ?/
12
13
  HR_REGEXP = /\A {0,3}([-*_])(?:\s*\1){2,}\s*\z/
13
14
  BULLET_LIST_REGEXP = /\A( {0,3})([-*+])\s+/
14
15
  ORDERED_LIST_REGEXP = /\A( {0,3})(\d{1,9})([.)])\s+/
15
16
  CODE_BLOCK_INDENT = /\A {4}/
16
- HTML_BLOCK_START_1 = /\A {0,3}<(script|pre|style|textarea)[\s>]/i
17
+ HTML_BLOCK_START_1 = /\A {0,3}<(script|pre|style|textarea)(?:[\s>]|\z)/i
17
18
  HTML_BLOCK_START_2 = /\A {0,3}<!--/
18
19
  HTML_BLOCK_START_3 = /\A {0,3}<\?/
19
20
  HTML_BLOCK_START_4 = /\A {0,3}<![A-Z]/
20
21
  HTML_BLOCK_START_5 = /\A {0,3}<!\[CDATA\[/
21
22
  HTML_BLOCK_START_6 = /\A {0,3}<\/?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|\/?>|$)/i
23
+ HTML_BLOCK_START_7 = /\A {0,3}<\/?[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9_-]+)?(?:\s[^>]*>|\/?>)\s*\z/
22
24
  # Reference definition: [label]: url "title"
23
- REFERENCE_DEF_REGEXP = /\A {0,3}\[([^\]]+)\]:\s*<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?\s*$/
25
+ REFERENCE_DEF_REGEXP = /\A {0,3}\[((?:\\.|[^\]])+)\]:/
26
+ FOOTNOTE_DEF_REGEXP = /\A {0,3}\[\^([^\]]+)\]:\s*(.*?)\s*$/
27
+ MATH_BLOCK_REGEXP = /\A {0,3}\$\$\s*$/
24
28
 
25
29
  def initialize(options = {})
26
30
  @options = options
31
+ @dialect = Dialect.resolve(options[:dialect])
27
32
  end
28
33
 
29
34
  def parse(src)
@@ -41,20 +46,176 @@ module Mdlint
41
46
  def parse_block(state)
42
47
  return if state.eof?
43
48
 
44
- parse_blank_line(state) ||
49
+ parse_front_matter(state) ||
50
+ parse_blank_line(state) ||
45
51
  parse_atx_heading(state) ||
52
+ parse_directive(state) ||
53
+ parse_math_block(state) ||
46
54
  parse_fence(state) ||
47
55
  parse_hr(state) ||
48
56
  parse_blockquote(state) ||
49
57
  parse_bullet_list(state) ||
50
58
  parse_ordered_list(state) ||
59
+ parse_table(state) ||
51
60
  parse_html_block(state) ||
61
+ parse_footnote_definition(state) ||
52
62
  parse_reference_definition(state) ||
53
63
  parse_code_block(state) ||
54
64
  parse_setext_heading(state) ||
55
65
  parse_paragraph(state)
56
66
  end
57
67
 
68
+ def parse_directive(state)
69
+ line = state.current_line
70
+ match = line.match(/\A {0,3}:::([A-Za-z][\w-]*)(?:\s+(.*?))?\s*\z/)
71
+ return false unless match
72
+
73
+ closing_line = find_directive_closing_line(state, match[1])
74
+ return false unless closing_line
75
+
76
+ start_line = state.line
77
+ content = state.raw_lines[start_line..closing_line].join("\n")
78
+ content += "\n" if closing_line < state.lines.length - 1 || state.src.end_with?("\n")
79
+ state.tokens << Token.new(
80
+ type: :directive,
81
+ content: content,
82
+ meta: { name: match[1], title: match[2] },
83
+ map: [start_line, closing_line + 1]
84
+ )
85
+ state.line = closing_line + 1
86
+ true
87
+ end
88
+
89
+ def parse_math_block(state)
90
+ return false unless state.current_line.match?(MATH_BLOCK_REGEXP)
91
+
92
+ start_line = state.line
93
+ content_lines = [state.current_line]
94
+ state.next_line
95
+ until state.eof?
96
+ content_lines << state.raw_line
97
+ state.next_line
98
+ break if content_lines.last.match?(MATH_BLOCK_REGEXP)
99
+ end
100
+
101
+ state.tokens << Token.new(
102
+ type: :math_block,
103
+ content: content_lines.join("\n") + "\n",
104
+ map: [start_line, state.line]
105
+ )
106
+ true
107
+ end
108
+
109
+ def parse_table(state)
110
+ return false unless @dialect.feature?(:tables)
111
+ return false unless table_delimiter?(state.peek_line)
112
+ return false unless state.current_line.include?("|")
113
+
114
+ start_line = state.line
115
+ header = split_table_row(state.current_line)
116
+ alignments = split_table_row(state.peek_line).map { |cell| table_alignment(cell) }
117
+ rows = [header]
118
+ state.next_line
119
+ state.next_line
120
+
121
+ while !state.eof? && table_row?(state.current_line)
122
+ rows << split_table_row(state.current_line)
123
+ state.next_line
124
+ end
125
+
126
+ state.tokens << Token.new(
127
+ type: :table,
128
+ meta: { rows: rows, alignments: alignments },
129
+ map: [start_line, state.line]
130
+ )
131
+ true
132
+ end
133
+
134
+ def table_delimiter?(line)
135
+ return false unless line
136
+
137
+ cells = split_table_row(line)
138
+ cells.length > 0 && cells.all? { |cell| cell.match?(/\A\s*:?-{3,}:?\s*\z/) }
139
+ end
140
+
141
+ def table_row?(line)
142
+ line && line.include?("|") && !line.strip.empty?
143
+ end
144
+
145
+ def split_table_row(line)
146
+ value = line.to_s.strip
147
+ value = value[1..] if value.start_with?("|")
148
+ value = value[0...-1] if value.end_with?("|") && !value.end_with?("\\|")
149
+ cells = []
150
+ current = +""
151
+ escaped = false
152
+ value.each_char do |character|
153
+ if character == "|" && !escaped
154
+ cells << current.strip
155
+ current = +""
156
+ else
157
+ current << character
158
+ end
159
+ escaped = character == "\\" && !escaped
160
+ escaped = false if character != "\\"
161
+ end
162
+ cells << current.strip
163
+ cells
164
+ end
165
+
166
+ def table_alignment(cell)
167
+ value = cell.strip
168
+ return :center if value.start_with?(":") && value.end_with?(":")
169
+ return :left if value.start_with?(":")
170
+ return :right if value.end_with?(":")
171
+
172
+ nil
173
+ end
174
+
175
+ def parse_front_matter(state)
176
+ return false unless state.line.zero?
177
+
178
+ opener = state.current_line
179
+ format, closing_marker = front_matter_markers(opener)
180
+ return false unless format
181
+
182
+ closing_line = state.lines[(state.line + 1)..]&.index do |line|
183
+ line.strip == closing_marker
184
+ end
185
+ return false unless closing_line
186
+
187
+ closing_line += state.line + 1
188
+ start_line = state.line
189
+ raw_lines = state.raw_lines[start_line..closing_line]
190
+ payload = state.lines[(start_line + 1)...closing_line].to_a
191
+ return false unless front_matter_payload?(format, payload)
192
+
193
+ content = raw_lines.join("\n")
194
+ content += "\n" if closing_line < state.lines.length - 1 || state.src.end_with?("\n")
195
+
196
+ state.tokens << Token.new(
197
+ type: :front_matter,
198
+ content: content,
199
+ meta: { format: format, delimiter: closing_marker },
200
+ map: [start_line, closing_line + 1]
201
+ )
202
+ state.line = closing_line + 1
203
+ true
204
+ end
205
+
206
+ def front_matter_markers(opener)
207
+ case opener.strip
208
+ when "---"
209
+ [:yaml, "---"]
210
+ when "+++"
211
+ [:toml, "+++"]
212
+ when ";;;"
213
+ [:json, ";;;"]
214
+ when "{"
215
+ [:json, "}"]
216
+ end
217
+ end
218
+
58
219
  def parse_blank_line(state)
59
220
  return false unless state.blank_line?
60
221
 
@@ -68,7 +229,8 @@ module Mdlint
68
229
  return false unless match
69
230
 
70
231
  level = match[1].length
71
- content = match[2]&.gsub(/\s+#+\s*\z/, "")&.strip || ""
232
+ content = match[2].to_s.gsub(/\s+#+\s*\z/, "").strip
233
+ content = "" if content.match?(/\A#+\s*\z/)
72
234
 
73
235
  start_line = state.line
74
236
 
@@ -102,19 +264,23 @@ module Mdlint
102
264
  end
103
265
 
104
266
  def parse_setext_heading(state)
105
- return false if state.line.zero?
106
-
107
267
  line = state.current_line
108
268
  return false if line.match?(/\A\s*\z/)
109
269
 
110
- next_line = state.peek_line
111
- return false unless next_line
270
+ underline_index = state.line + 1
271
+ while underline_index < state.lines.length && !state.blank_line?(underline_index)
272
+ break if state.lines[underline_index].match?(SETEXT_HEADING_REGEXP)
273
+ break if block_boundary?(state.lines[underline_index])
274
+
275
+ underline_index += 1
276
+ end
277
+ return false if underline_index >= state.lines.length
112
278
 
113
- match = next_line.match(SETEXT_HEADING_REGEXP)
279
+ match = state.lines[underline_index].match(SETEXT_HEADING_REGEXP)
114
280
  return false unless match
115
281
 
116
282
  level = match[1][0] == "=" ? 1 : 2
117
- content = line.strip
283
+ content = state.lines[state.line...underline_index].join("\n").strip
118
284
  start_line = state.line
119
285
 
120
286
  state.tokens << Token.new(
@@ -123,14 +289,14 @@ module Mdlint
123
289
  nesting: 1,
124
290
  level: state.level,
125
291
  markup: match[1][0],
126
- map: [start_line, start_line + 2]
292
+ map: [start_line, underline_index + 1]
127
293
  )
128
294
 
129
295
  state.tokens << Token.new(
130
296
  type: :inline,
131
297
  content: content,
132
298
  level: state.level + 1,
133
- map: [start_line, start_line + 1]
299
+ map: [start_line, underline_index]
134
300
  )
135
301
 
136
302
  state.tokens << Token.new(
@@ -141,32 +307,35 @@ module Mdlint
141
307
  markup: match[1][0]
142
308
  )
143
309
 
144
- state.next_line
145
- state.next_line
310
+ state.line = underline_index + 1
146
311
  true
147
312
  end
148
313
 
149
314
  def parse_fence(state)
150
315
  line = state.current_line
151
- match = line.match(FENCE_OPEN_REGEXP)
316
+ match = fence_match(line)
152
317
  return false unless match
153
318
 
154
319
  marker = match[1]
155
320
  info = match[2].strip
321
+
156
322
  fence_char = marker[0]
157
323
  fence_length = marker.length
324
+ opening_indent = line[/\A */].length
158
325
  start_line = state.line
159
326
  state.next_line
160
327
 
161
328
  content_lines = []
162
329
  until state.eof?
163
330
  current = state.current_line
331
+ break if state.line == state.lines.length - 1 && current.empty?
332
+
164
333
  close_match = current.match(/\A {0,3}#{fence_char}{#{fence_length},}\s*\z/)
165
334
  if close_match
166
335
  state.next_line
167
336
  break
168
337
  end
169
- content_lines << current
338
+ content_lines << strip_fence_indent(state.raw_line, opening_indent)
170
339
  state.next_line
171
340
  end
172
341
 
@@ -204,9 +373,16 @@ module Mdlint
204
373
  start_line = state.line
205
374
  content_lines = []
206
375
 
207
- while !state.eof? && state.current_line.match?(BLOCKQUOTE_REGEXP)
208
- content_lines << state.current_line.sub(BLOCKQUOTE_REGEXP, "")
209
- state.next_line
376
+ while !state.eof?
377
+ if state.current_line.match?(BLOCKQUOTE_REGEXP)
378
+ content_lines << state.raw_line.sub(BLOCKQUOTE_REGEXP, "")
379
+ state.next_line
380
+ elsif !state.blank_line? && lazy_blockquote_continuation?(state.current_line)
381
+ content_lines << state.raw_line.sub(/\A\s+/, "")
382
+ state.next_line
383
+ else
384
+ break
385
+ end
210
386
  end
211
387
 
212
388
  state.tokens << Token.new(
@@ -215,9 +391,12 @@ module Mdlint
215
391
  nesting: 1,
216
392
  level: state.level,
217
393
  markup: ">",
394
+ attrs: alert_attributes(content_lines),
218
395
  map: [start_line, state.line]
219
396
  )
220
397
 
398
+ content_lines[0] = content_lines[0].sub(/\A\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i, "") if alert_attributes(content_lines)[:alert]
399
+
221
400
  state.level += 1
222
401
  inner_content = content_lines.join("\n")
223
402
  inner_parser = BlockParser.new(@options)
@@ -258,12 +437,13 @@ module Mdlint
258
437
  nesting: 1,
259
438
  level: state.level,
260
439
  markup: marker,
440
+ attrs: { tight: true },
261
441
  map: [start_line, nil]
262
442
  )
263
443
  list_token_index = state.tokens.length - 1
264
444
 
265
445
  state.level += 1
266
- parse_list_items(state, BULLET_LIST_REGEXP, marker)
446
+ parse_list_items(state, BULLET_LIST_REGEXP, marker, match[1].length)
267
447
  state.level -= 1
268
448
 
269
449
  state.tokens[list_token_index].map[1] = state.line
@@ -294,13 +474,13 @@ module Mdlint
294
474
  nesting: 1,
295
475
  level: state.level,
296
476
  markup: delimiter,
297
- attrs: { start: start_num },
477
+ attrs: { start: start_num, tight: true },
298
478
  map: [start_line, nil]
299
479
  )
300
480
  list_token_index = state.tokens.length - 1
301
481
 
302
482
  state.level += 1
303
- parse_ordered_list_items(state, delimiter)
483
+ parse_ordered_list_items(state, delimiter, match[1].length)
304
484
  state.level -= 1
305
485
 
306
486
  state.tokens[list_token_index].map[1] = state.line
@@ -316,11 +496,12 @@ module Mdlint
316
496
  true
317
497
  end
318
498
 
319
- def parse_list_items(state, pattern, _marker)
499
+ def parse_list_items(state, pattern, _marker, base_indent = 0)
320
500
  while !state.eof?
321
501
  line = state.current_line
322
502
  match = line.match(pattern)
323
503
  break unless match
504
+ break if line.match?(HR_REGEXP) && match[1].length <= base_indent
324
505
 
325
506
  item_start = state.line
326
507
  content = line.sub(pattern, "")
@@ -330,6 +511,7 @@ module Mdlint
330
511
  tag: "li",
331
512
  nesting: 1,
332
513
  level: state.level,
514
+ attrs: task_attributes(content).merge(tight: true),
333
515
  map: [item_start, nil]
334
516
  )
335
517
  item_token_index = state.tokens.length - 1
@@ -337,17 +519,44 @@ module Mdlint
337
519
  state.level += 1
338
520
  state.next_line
339
521
 
522
+ content = strip_task_marker(content)
523
+
340
524
  item_content_lines = [content]
525
+ nested_lines = []
526
+ nested_start_line = state.line
341
527
  while !state.eof? && !state.blank_line? && !state.current_line.match?(pattern)
342
- if state.current_line.match?(/\A\s+/)
343
- item_content_lines << state.current_line.sub(/\A\s+/, "")
528
+ if nested_lines.any? && state.current_line.match?(/\A\s+/)
529
+ nested_lines << dedent_list_line(state.current_line, base_indent)
530
+ state.next_line
531
+ elsif state.current_line.match?(/\A\s+/)
532
+ item_content_lines << state.raw_line.sub(/\A\s+/, "")
344
533
  state.next_line
345
534
  else
346
535
  break
347
536
  end
348
537
  end
349
538
 
539
+ while !state.eof? && !state.blank_line?
540
+ nested_match = state.current_line.match(pattern)
541
+ break unless nested_match && nested_match[1].length > base_indent
542
+
543
+ nested_lines << dedent_list_line(state.current_line, base_indent)
544
+ state.next_line
545
+ while !state.eof? && !state.blank_line? && !state.current_line.match?(pattern)
546
+ break unless state.current_line.match?(/\A\s+/)
547
+
548
+ nested_lines << dedent_list_line(state.current_line, base_indent)
549
+ state.next_line
550
+ end
551
+ end
552
+
553
+ loose_content = collect_loose_item_content(state, pattern, base_indent, nested_lines, match[0].length)
554
+
350
555
  paragraph_content = item_content_lines.join("\n").strip
556
+ if paragraph_content.match?(HR_REGEXP)
557
+ state.tokens << Token.new(type: :hr, tag: "hr", markup: paragraph_content.strip[0], map: [item_start, state.line])
558
+ paragraph_content = ""
559
+ end
351
560
  unless paragraph_content.empty?
352
561
  state.tokens << Token.new(
353
562
  type: :paragraph_open,
@@ -372,6 +581,9 @@ module Mdlint
372
581
  )
373
582
  end
374
583
 
584
+ append_nested_tokens(state, nested_lines, nested_start_line)
585
+ append_loose_paragraph(state, loose_content, item_start) if loose_content
586
+
375
587
  state.level -= 1
376
588
  state.tokens[item_token_index].map[1] = state.line
377
589
 
@@ -386,13 +598,14 @@ module Mdlint
386
598
  end
387
599
  end
388
600
 
389
- def parse_ordered_list_items(state, delimiter)
601
+ def parse_ordered_list_items(state, delimiter, base_indent = 0)
390
602
  pattern = /\A( {0,3})(\d{1,9})([#{Regexp.escape(delimiter)}])\s+/
391
603
 
392
604
  while !state.eof?
393
605
  line = state.current_line
394
606
  match = line.match(pattern)
395
607
  break unless match
608
+ break if line.match?(HR_REGEXP) && match[1].length <= base_indent
396
609
 
397
610
  item_start = state.line
398
611
  content = line.sub(pattern, "")
@@ -402,6 +615,7 @@ module Mdlint
402
615
  tag: "li",
403
616
  nesting: 1,
404
617
  level: state.level,
618
+ attrs: task_attributes(content).merge(tight: true),
405
619
  map: [item_start, nil]
406
620
  )
407
621
  item_token_index = state.tokens.length - 1
@@ -409,17 +623,44 @@ module Mdlint
409
623
  state.level += 1
410
624
  state.next_line
411
625
 
626
+ content = strip_task_marker(content)
627
+
412
628
  item_content_lines = [content]
629
+ nested_lines = []
630
+ nested_start_line = state.line
413
631
  while !state.eof? && !state.blank_line? && !state.current_line.match?(pattern)
414
- if state.current_line.match?(/\A\s+/)
415
- item_content_lines << state.current_line.sub(/\A\s+/, "")
632
+ if nested_lines.any? && state.current_line.match?(/\A\s+/)
633
+ nested_lines << dedent_list_line(state.current_line, base_indent)
634
+ state.next_line
635
+ elsif state.current_line.match?(/\A\s+/)
636
+ item_content_lines << state.raw_line.sub(/\A\s+/, "")
416
637
  state.next_line
417
638
  else
418
639
  break
419
640
  end
420
641
  end
421
642
 
643
+ while !state.eof? && !state.blank_line?
644
+ nested_match = state.current_line.match(pattern)
645
+ break unless nested_match && nested_match[1].length > base_indent
646
+
647
+ nested_lines << dedent_list_line(state.current_line, base_indent)
648
+ state.next_line
649
+ while !state.eof? && !state.blank_line? && !state.current_line.match?(pattern)
650
+ break unless state.current_line.match?(/\A\s+/)
651
+
652
+ nested_lines << dedent_list_line(state.current_line, base_indent)
653
+ state.next_line
654
+ end
655
+ end
656
+
657
+ loose_content = collect_loose_item_content(state, pattern, base_indent, nested_lines, match[0].length)
658
+
422
659
  paragraph_content = item_content_lines.join("\n").strip
660
+ if paragraph_content.match?(HR_REGEXP)
661
+ state.tokens << Token.new(type: :hr, tag: "hr", markup: paragraph_content.strip[0], map: [item_start, state.line])
662
+ paragraph_content = ""
663
+ end
423
664
  unless paragraph_content.empty?
424
665
  state.tokens << Token.new(
425
666
  type: :paragraph_open,
@@ -444,6 +685,9 @@ module Mdlint
444
685
  )
445
686
  end
446
687
 
688
+ append_nested_tokens(state, nested_lines, nested_start_line)
689
+ append_loose_paragraph(state, loose_content, item_start) if loose_content
690
+
447
691
  state.level -= 1
448
692
  state.tokens[item_token_index].map[1] = state.line
449
693
 
@@ -458,23 +702,128 @@ module Mdlint
458
702
  end
459
703
  end
460
704
 
705
+ def dedent_list_line(line, base_indent)
706
+ line[(base_indent + 2)..] || line.lstrip
707
+ end
708
+
709
+ def append_nested_tokens(state, nested_lines, start_line)
710
+ return if nested_lines.empty?
711
+
712
+ nested_tokens = BlockParser.new(@options).parse(nested_lines.join("\n"))
713
+ nested_tokens.each do |token|
714
+ token.level += state.level
715
+ token.map = token.map.map { |line| line + start_line } if token.map
716
+ state.tokens << token
717
+ end
718
+ end
719
+
720
+ def collect_loose_item_content(state, pattern, base_indent, nested_lines = nil, content_indent = base_indent + 2)
721
+ return unless state.blank_line?
722
+
723
+ state.skip_blank_lines
724
+ return if state.eof?
725
+
726
+ next_match = state.current_line.match(pattern)
727
+ if next_match && next_match[1].length <= base_indent
728
+ mark_current_list_loose(state)
729
+ return
730
+ end
731
+ return unless state.current_line.match?(/\A {#{content_indent},}/)
732
+
733
+ mark_current_list_loose(state)
734
+ if state.current_line.match?(/\A {#{base_indent + 4},}/) && nested_lines
735
+ while !state.eof?
736
+ if state.blank_line?
737
+ index = state.line
738
+ index += 1 while index < state.lines.length && state.blank_line?(index)
739
+ break if index >= state.lines.length || !state.lines[index].match?(/\A\s+/)
740
+
741
+ nested_lines << ""
742
+ state.next_line
743
+ elsif state.current_line.match?(/\A\s+/)
744
+ nested_lines << dedent_list_line(state.current_line, base_indent)
745
+ state.next_line
746
+ else
747
+ break
748
+ end
749
+ end
750
+ return nil
751
+ end
752
+
753
+ content_lines = []
754
+ while !state.eof? && !state.blank_line? && !state.current_line.match?(pattern)
755
+ break unless state.current_line.match?(/\A {#{content_indent},}/)
756
+
757
+ content_lines << state.raw_line.sub(/\A\s+/, "")
758
+ state.next_line
759
+ end
760
+ content_lines.join("\n").strip
761
+ end
762
+
763
+ def append_loose_paragraph(state, content, start_line)
764
+ return if content.to_s.empty?
765
+
766
+ state.tokens << Token.new(type: :paragraph_open, tag: "p", nesting: 1, level: state.level, map: [start_line, state.line])
767
+ state.tokens << Token.new(type: :inline, content: content, level: state.level + 1, map: [start_line, state.line])
768
+ state.tokens << Token.new(type: :paragraph_close, tag: "p", nesting: -1, level: state.level)
769
+ end
770
+
771
+ def mark_current_list_loose(state)
772
+ list_index = state.tokens.rindex { |token| %i[bullet_list_open ordered_list_open].include?(token.type) }
773
+ return unless list_index
774
+
775
+ state.tokens[list_index].attrs[:tight] = false
776
+ state.tokens[(list_index + 1)..].to_a.reverse_each do |token|
777
+ break if %i[bullet_list_open ordered_list_open].include?(token.type)
778
+ token.attrs[:tight] = false if token.type == :list_item_open
779
+ end
780
+ end
781
+
461
782
  def parse_html_block(state)
462
783
  line = state.current_line
463
784
 
464
- return false unless line.match?(HTML_BLOCK_START_1) ||
465
- line.match?(HTML_BLOCK_START_2) ||
466
- line.match?(HTML_BLOCK_START_3) ||
467
- line.match?(HTML_BLOCK_START_4) ||
468
- line.match?(HTML_BLOCK_START_5) ||
469
- line.match?(HTML_BLOCK_START_6)
785
+ return false unless html_block_start?(line)
470
786
 
471
787
  start_line = state.line
472
788
  content_lines = []
473
789
 
474
- until state.eof?
475
- content_lines << state.current_line
476
- state.next_line
477
- break if state.blank_line?
790
+ if line.match?(HTML_BLOCK_START_2)
791
+ until state.eof?
792
+ current_line = state.current_line
793
+ content_lines << current_line
794
+ state.next_line
795
+ break if current_line.include?("-->")
796
+ end
797
+ elsif line.match?(HTML_BLOCK_START_1)
798
+ tag = line[/\A {0,3}<([A-Za-z][A-Za-z0-9]*)/i, 1]
799
+ until state.eof?
800
+ current_line = state.current_line
801
+ content_lines << state.raw_line
802
+ state.next_line
803
+ break if current_line.match?(%r{</#{Regexp.escape(tag)}\s*>}i)
804
+ end
805
+ elsif line.match?(HTML_BLOCK_START_3)
806
+ until state.eof?
807
+ current_line = state.current_line
808
+ content_lines << state.raw_line
809
+ state.next_line
810
+ break if current_line.include?("?>")
811
+ end
812
+ elsif line.match?(HTML_BLOCK_START_5)
813
+ until state.eof?
814
+ current_line = state.current_line
815
+ content_lines << state.raw_line
816
+ state.next_line
817
+ break if current_line.include?("]]>")
818
+ end
819
+ else
820
+ until state.eof?
821
+ current_line = state.current_line
822
+ content_lines << state.raw_line
823
+ state.next_line
824
+ break if line.match?(HTML_BLOCK_START_7) && current_line.match?(%r{</[A-Z][A-Za-z0-9]*(?:\.[A-Za-z0-9_-]+)?>})
825
+ break if state.blank_line?
826
+ end
478
827
  end
479
828
 
480
829
  state.tokens << Token.new(
@@ -486,16 +835,36 @@ module Mdlint
486
835
  true
487
836
  end
488
837
 
838
+ def html_block_start?(line)
839
+ [HTML_BLOCK_START_1, HTML_BLOCK_START_2, HTML_BLOCK_START_3, HTML_BLOCK_START_4,
840
+ HTML_BLOCK_START_5, HTML_BLOCK_START_6, HTML_BLOCK_START_7].any? { |pattern| line.match?(pattern) }
841
+ end
842
+
843
+ def lazy_blockquote_continuation?(line)
844
+ !line.match?(ATX_HEADING_REGEXP) && !fence_match(line) && !line.match?(HR_REGEXP) &&
845
+ !line.match?(BLOCKQUOTE_REGEXP) && !line.match?(BULLET_LIST_REGEXP) &&
846
+ !line.match?(ORDERED_LIST_REGEXP) && !html_block_start?(line) &&
847
+ !line.match?(REFERENCE_DEF_REGEXP) && !line.match?(FOOTNOTE_DEF_REGEXP)
848
+ end
849
+
489
850
  def parse_code_block(state)
490
851
  return false unless state.current_line.match?(CODE_BLOCK_INDENT)
491
852
 
492
853
  start_line = state.line
493
854
  content_lines = []
494
855
 
495
- while !state.eof? && state.current_line.match?(CODE_BLOCK_INDENT)
496
- content_lines << state.current_line.sub(CODE_BLOCK_INDENT, "")
497
- state.next_line
856
+ while !state.eof?
857
+ if state.current_line.match?(CODE_BLOCK_INDENT)
858
+ content_lines << strip_code_indent(state.raw_line, state.current_line)
859
+ state.next_line
860
+ elsif state.blank_line? && state.line < state.lines.length - 1
861
+ content_lines << ""
862
+ state.next_line
863
+ else
864
+ break
865
+ end
498
866
  end
867
+ content_lines.pop while content_lines.last == ""
499
868
 
500
869
  state.tokens << Token.new(
501
870
  type: :code_block,
@@ -508,13 +877,33 @@ module Mdlint
508
877
  end
509
878
 
510
879
  def parse_reference_definition(state)
511
- line = state.current_line
880
+ line = state.raw_line
512
881
  match = line.match(REFERENCE_DEF_REGEXP)
513
882
  return false unless match
514
883
 
515
- label = match[1].downcase
516
- url = match[2]
517
- title = match[3] || match[4] || match[5]
884
+ label = normalize_reference_label(match[1])
885
+ tail = line[match[0].length..].to_s.strip
886
+ parsed = parse_reference_tail(tail)
887
+ consumed = 1
888
+
889
+ if parsed.nil? && tail.empty?
890
+ url_line = state.peek_line
891
+ return false if url_line.nil? || state.blank_line?(state.line + 1)
892
+
893
+ parsed = parse_reference_tail(url_line.to_s.strip)
894
+ consumed += 1 if parsed
895
+ end
896
+ if parsed && parsed[1].nil?
897
+ title_line = state.peek_line(consumed)
898
+ if title_line && !state.blank_line?(state.line + consumed) && title_line.match?(/\A\s+/)
899
+ title = parse_reference_title(title_line.to_s.strip)
900
+ parsed = [parsed[0], title] if title
901
+ consumed += 1 if title
902
+ end
903
+ end
904
+ return false unless parsed
905
+
906
+ url, title = parsed
518
907
 
519
908
  state.tokens << Token.new(
520
909
  type: :reference_definition,
@@ -526,7 +915,101 @@ module Mdlint
526
915
  map: [state.line, state.line + 1]
527
916
  )
528
917
 
918
+ state.line += consumed
919
+ true
920
+ end
921
+
922
+ def reference_definition_candidate?(state)
923
+ match = state.raw_line.match(REFERENCE_DEF_REGEXP)
924
+ return false unless match
925
+
926
+ tail = state.raw_line[match[0].length..].to_s.strip
927
+ return true if parse_reference_tail(tail)
928
+ return false unless tail.empty?
929
+
930
+ next_line = state.peek_line
931
+ next_line && !state.blank_line?(state.line + 1) && parse_reference_tail(next_line.to_s.strip)
932
+ end
933
+
934
+ def normalize_reference_label(label)
935
+ label.to_s.gsub(/\\([!"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~])/, '\\1').gsub(/\s+/, " ").strip.downcase
936
+ end
937
+
938
+ def parse_reference_tail(tail)
939
+ value = tail.to_s
940
+ return nil if value.empty?
941
+
942
+ if value.start_with?("<")
943
+ closing = value.index(">", 1)
944
+ return nil unless closing
945
+
946
+ url = value[1...closing]
947
+ raw_remainder = value[(closing + 1)..].to_s
948
+ return nil unless raw_remainder.empty? || raw_remainder.match?(/\A\s/)
949
+
950
+ remainder = raw_remainder.strip
951
+ else
952
+ index = 0
953
+ depth = 0
954
+ while index < value.length
955
+ character = value[index]
956
+ if character == "\\"
957
+ index += 2
958
+ next
959
+ end
960
+ if character == "("
961
+ depth += 1
962
+ elsif character == ")"
963
+ break if depth.zero?
964
+
965
+ depth -= 1
966
+ elsif character.match?( /\s/ ) && depth.zero?
967
+ break
968
+ end
969
+ index += 1
970
+ end
971
+ return nil if index.zero?
972
+
973
+ url = value[0...index]
974
+ remainder = value[index..].to_s.strip
975
+ end
976
+
977
+ return [url, nil] if remainder.empty?
978
+
979
+ title = parse_reference_title(remainder)
980
+ title ? [url, title] : nil
981
+ end
982
+
983
+ def parse_reference_title(value)
984
+ return nil unless value.length >= 2
985
+
986
+ opener = value[0]
987
+ closer = { '"' => '"', "'" => "'", "(" => ")" }[opener]
988
+ return nil unless closer && value.end_with?(closer)
989
+
990
+ value[1...-1]
991
+ end
992
+
993
+ def parse_footnote_definition(state)
994
+ match = state.current_line.match(FOOTNOTE_DEF_REGEXP)
995
+ return false unless match
996
+
997
+ start_line = state.line
998
+ raw_lines = [state.current_line]
999
+ content = match[2]
529
1000
  state.next_line
1001
+ while !state.eof? && state.current_line.match?(/\A {4}/)
1002
+ raw_lines << state.current_line
1003
+ content = "#{content}\n#{state.current_line.sub(/\A {4}/, "")}"
1004
+ state.next_line
1005
+ end
1006
+
1007
+ state.tokens << Token.new(
1008
+ type: :footnote_definition,
1009
+ content: raw_lines.join("\n") + "\n",
1010
+ attrs: { label: match[1].downcase, content: content },
1011
+ map: [start_line, state.line]
1012
+ )
530
1013
  true
531
1014
  end
532
1015
 
@@ -539,18 +1022,25 @@ module Mdlint
539
1022
  while !state.eof? && !state.blank_line?
540
1023
  line = state.current_line
541
1024
  break if line.match?(ATX_HEADING_REGEXP) ||
542
- line.match?(FENCE_OPEN_REGEXP) ||
1025
+ line.match?(MATH_BLOCK_REGEXP) ||
1026
+ fence_match(line) ||
543
1027
  line.match?(HR_REGEXP) ||
544
1028
  line.match?(BLOCKQUOTE_REGEXP) ||
545
1029
  line.match?(BULLET_LIST_REGEXP) ||
546
1030
  line.match?(ORDERED_LIST_REGEXP) ||
547
- line.match?(REFERENCE_DEF_REGEXP)
1031
+ (html_block_start?(line) && !line.match?(HTML_BLOCK_START_7)) ||
1032
+ (reference_definition_candidate?(state) && content_lines.empty?) ||
1033
+ line.match?(FOOTNOTE_DEF_REGEXP)
548
1034
 
549
1035
  if state.peek_line&.match?(SETEXT_HEADING_REGEXP)
550
1036
  break if content_lines.any?
551
1037
  end
552
1038
 
553
- content_lines << line
1039
+ content_lines << if content_lines.empty?
1040
+ state.raw_line.sub(/\A {0,3}/, "")
1041
+ else
1042
+ state.raw_line.sub(/\A\s+/, "")
1043
+ end
554
1044
  state.next_line
555
1045
  end
556
1046
 
@@ -566,7 +1056,7 @@ module Mdlint
566
1056
 
567
1057
  state.tokens << Token.new(
568
1058
  type: :inline,
569
- content: content_lines.join("\n"),
1059
+ content: content_lines.join("\n").strip.gsub(/(?<! ) \n/, "\n"),
570
1060
  level: state.level + 1,
571
1061
  map: [start_line, state.line]
572
1062
  )
@@ -580,6 +1070,93 @@ module Mdlint
580
1070
 
581
1071
  true
582
1072
  end
1073
+
1074
+ def alert_attributes(content_lines)
1075
+ match = content_lines.first.to_s.match(/\A\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i)
1076
+ match ? { alert: match[1].downcase } : {}
1077
+ end
1078
+
1079
+ def block_boundary?(line)
1080
+ line.match?(ATX_HEADING_REGEXP) || fence_match(line) ||
1081
+ line.match?(HR_REGEXP) || line.match?(BLOCKQUOTE_REGEXP) ||
1082
+ line.match?(BULLET_LIST_REGEXP) || line.match?(ORDERED_LIST_REGEXP) ||
1083
+ line.match?(REFERENCE_DEF_REGEXP) || line.match?(FOOTNOTE_DEF_REGEXP)
1084
+ end
1085
+
1086
+ def find_directive_closing_line(state, name)
1087
+ depth = 1
1088
+ index = state.line + 1
1089
+ while index < state.lines.length
1090
+ candidate = state.lines[index]
1091
+ nested = candidate.match(/\A {0,3}:::([A-Za-z][\w-]*)(?:\s+.*)?\s*\z/)
1092
+ if nested
1093
+ depth += 1
1094
+ elsif candidate.strip == ":::"
1095
+ depth -= 1
1096
+ return index if depth.zero?
1097
+ end
1098
+ index += 1
1099
+ end
1100
+ nil
1101
+ end
1102
+
1103
+ def strip_code_indent(raw_line, expanded_line)
1104
+ return raw_line unless expanded_line.start_with?(" ")
1105
+
1106
+ consumed = 0
1107
+ index = 0
1108
+ raw_line.each_char do |character|
1109
+ break if consumed >= 4
1110
+
1111
+ consumed += character == "\t" ? 4 - (consumed % 4) : 1
1112
+ index += 1
1113
+ end
1114
+ raw_line[index..] || ""
1115
+ end
1116
+
1117
+ def fence_match(line)
1118
+ match = line.to_s.match(FENCE_OPEN_REGEXP)
1119
+ return unless match
1120
+ return if match[1].start_with?("`") && match[2].include?("`")
1121
+
1122
+ match
1123
+ end
1124
+
1125
+ def front_matter_payload?(format, lines)
1126
+ return false if lines.empty? || lines.all? { |line| line.strip.empty? }
1127
+
1128
+ case format
1129
+ when :yaml
1130
+ lines.any? { |line| line.match?(/\A\s*[^#\s][^:]*:/) }
1131
+ when :toml
1132
+ lines.any? { |line| line.match?(/\A\s*[^#\s=]+\s*=/) }
1133
+ when :json
1134
+ lines.join.match?(/[{}\[\]]|\A\s*\"[^\"]+\"\s*:/)
1135
+ else
1136
+ false
1137
+ end
1138
+ end
1139
+
1140
+ def strip_fence_indent(raw_line, indent)
1141
+ return raw_line if indent.zero?
1142
+
1143
+ raw_line.sub(/\A {0,#{indent}}/, "")
1144
+ end
1145
+
1146
+ def task_attributes(content)
1147
+ return {} unless @dialect.feature?(:task_lists)
1148
+
1149
+ match = content.match(/\A\[([ xX])\]\s+/)
1150
+ return {} unless match
1151
+
1152
+ { task: true, checked: match[1].downcase == "x" }
1153
+ end
1154
+
1155
+ def strip_task_marker(content)
1156
+ return content unless @dialect.feature?(:task_lists)
1157
+
1158
+ content.sub(/\A\[[ xX]\]\s+/, "")
1159
+ end
583
1160
  end
584
1161
  end
585
1162
  end