raindown 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c619df9532ee1f014b8837488ac3fe8cc4b3183e7abad17791fb27bb74491499
4
- data.tar.gz: aad7d7aefc311af905d14afecd4960096b9cebab871c2da957e4ca05cb85c503
3
+ metadata.gz: baf58826a9f492870795c2cfb36104ffb214b7494c0b14a2b11aa5f5a7711c64
4
+ data.tar.gz: 136d56da8dd7574698769f971bcce51662c89f29b28e2b87acb758017f9ee708
5
5
  SHA512:
6
- metadata.gz: 91c9a12c3bd15d3c9f4ba9e41f645ee5f6ca8447b6208c38d3dd422702acc5f2395161ddb47ae60f248af9707d3c9d53edfa0d4d1dccb78d8aee99987e5c8de3
7
- data.tar.gz: cd30f02347aae3834e30c0d77e8ee62b74379f005b844ef8c6b6f03f0638ad11ee7da3980b0978c4145b67dbd3144ce395c4abc7c6d8e2f57102a8e2afeb5534
6
+ metadata.gz: 2ceb319ab5a5c1482a6d09f98b75e95bf22ece5ca8477b54621ee542691c6de3ee243dc0ef3f580971167cb116990a89a28ae5a4012b50c16ec659a8fcdf5cfa
7
+ data.tar.gz: 411f712343878e32a1efc7d99932254984e3f0dc45ba91a8b41316fcbf61e9aa0c404ea7d66fc75e0293975d60632e28bde04adcd044d8e3dc36c02ddf9390c2
@@ -33,12 +33,14 @@ module Raindown
33
33
 
34
34
  # The data is read every time, so the chart follows changes to its file.
35
35
  def rewrite(metadata: NoMetadata)
36
+ return Problem.alert('Needs a file path to a CSV, TSV, JSON or YAML file') if ['', nil].include?(@data)
37
+
36
38
  points = points(metadata:)
37
39
  return '' if points.empty?
38
40
 
39
41
  "```\n#{draw(points).map(&:rstrip).join("\n")}\n```"
40
42
  rescue Error => e
41
- "Can't chart #{@data.inspect}: #{e.message}"
43
+ Problem.alert("Can't chart #{@data.inspect}: #{e.message}")
42
44
  end
43
45
 
44
46
  # Each value with its label, or nil for numbers alone.
@@ -51,7 +51,7 @@ module Raindown
51
51
  end
52
52
 
53
53
  def read(path)
54
- text = File.read(path, encoding: Encoding::UTF_8)
54
+ text = File.read(path, encoding: 'bom|utf-8')
55
55
 
56
56
  case File.extname(path).downcase
57
57
  when '.csv' then CSV.parse(text, skip_blanks: true)
data/lib/raindown/cli.rb CHANGED
@@ -169,9 +169,10 @@ module Raindown
169
169
  Nodes.load(path)
170
170
 
171
171
  metadata = Pages.for(path)
172
- failures = Rewriter.update(path:, bare:, execute:, metadata:).failures
172
+ rewrite = Rewriter.update(path:, bare:, execute:, metadata:)
173
+ warn(*rewrite.problems.map { "raindown: #{path}: #{it}" })
173
174
  write(output, Raindown.render_file(path:, bare:, metadata:))
174
- check_endpoints(failures)
175
+ check_endpoints(rewrite.failures)
175
176
  end
176
177
 
177
178
  # Endpoint responses that fail their checks, like status: 200, are listed, and fail the command, for CI.
@@ -18,7 +18,7 @@ module Raindown
18
18
  def rewrite_markdown(markdown, path:, bare: false, execute: false)
19
19
  Nodes.load(path)
20
20
  rewrite = Rewriter.rewrite(content: markdown, path:, bare:, execute:, metadata: Pages.for(path))
21
- { output: rewrite.content, warnings: rewrite.failures.map { "Endpoint check failed: #{it}" } }
21
+ { output: rewrite.content, warnings: rewrite.failures.map { "Endpoint check failed: #{it}" } + rewrite.problems }
22
22
  end
23
23
  end
24
24
  end
@@ -31,13 +31,15 @@ module Raindown
31
31
  # <{ Card }>, or <{ :Card }>, which a lowercase name like toc needs, as <{ toc }> is a variable.
32
32
  SELF_CLOSING = /\A( {0,3})<\{\s*(?::|(?=[A-Z]))(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
33
33
  BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
34
- # Unquoted text after name:, like YAML's, runs up to the next prop, so title: Hello world limit: 3 has two.
35
- UNQUOTED = /.+?(?=[ \t]+\w+(?:=|:[ \t])|[ \t]*\z)/
34
+ # Unquoted text after name:, like YAML's, runs up to the next prop, so title: Hello world limit: 3 has two. A comma
35
+ # before the next prop, like data.csv, total: true, separates them like a space, while one in text, like Hello,
36
+ # world, is part of it.
37
+ UNQUOTED = /.+?(?=(?:,[ \t]*|[ \t]+)\w+(?:=|:[ \t])|,?[ \t]*\z)/
36
38
  # A prop is name=value or name: value, where the value is quoted text, a number, true, false
37
- # or a variable like author.name. After name= it's one word.
38
- PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|(?<==)\S+|#{UNQUOTED})/
39
+ # or a variable like author.name. After name= it's one word, without a comma after it.
40
+ PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|(?<==)\S+?(?=,?(?:\s|\z))|#{UNQUOTED})/
39
41
  # A value before the props is the node's argument.
40
- ARG = /\A("[^"]*"|'[^']*'|[^\s"']#{UNQUOTED}|[^\s"'])(?:\s+|\z)/
42
+ ARG = /\A("[^"]*"|'[^']*'|[^\s"']#{UNQUOTED}|[^\s"'])(?:,\s*|\s+|\z)/
41
43
 
42
44
  module_function
43
45
 
@@ -51,11 +53,33 @@ module Raindown
51
53
  variables = Variables.new(front_matter)
52
54
 
53
55
  spans.map do |span|
54
- node = Nodes.instance(span[:class], *args(span[:args], variables, antlers: span[:antlers]))
56
+ node = node_for(span, args(span[:args], variables, antlers: span[:antlers]))
55
57
  span.except(:name, :class, :args, :antlers).merge(node:)
56
58
  end
57
59
  end
58
60
 
61
+ # The block's node, or a Problem in its place when it can't be made, so the rest of the file still writes back.
62
+ def node_for(span, (props, values))
63
+ problem = values_problem(span, values || [])
64
+ return Nodes.instance(Problem, {}, [problem, span[:open] + 1, span[:name]]) if problem
65
+
66
+ Nodes.instance(span[:class], props, *[values].compact)
67
+ rescue StandardError => e
68
+ Nodes.instance(Problem, {}, ["Couldn't start: #{e.message}", span[:open] + 1, span[:name]])
69
+ end
70
+
71
+ # A directive with more or fewer values than its node takes, like toc: extra, rather than Ruby's wrong number of
72
+ # arguments, or nil when it has as many as it takes.
73
+ def values_problem(span, values)
74
+ kinds = span[:class].instance_method(:initialize).parameters.map(&:first)
75
+ required = kinds.count(:req)
76
+ return if kinds.include?(:rest) || values.length.between?(required, required + kinds.count(:opt))
77
+
78
+ return 'Needs a value after it' if values.length < required
79
+
80
+ "Takes no value, so it can't read #{values.first.inspect}"
81
+ end
82
+
59
83
  # Each block like find's, with the node's name, class and props text in place of the node,
60
84
  # so a block can be found without making its node, like for a document's outline.
61
85
  def spans(lines, bare: false)
@@ -95,10 +119,13 @@ module Raindown
95
119
  [marker(lines[block[:open]]), *("#{output}\n".lines unless output.empty?), *close]
96
120
  end
97
121
 
122
+ # A node that raises shows why in its place, rather than stopping the page.
98
123
  def node_markdown(block, context)
99
124
  return Nodes.rendered(block[:node], context) if !block[:writes] || Nodes.html?(block[:node])
100
125
 
101
126
  Nodes.call(block[:node], :rewrite, **context.to_h).to_s
127
+ rescue StandardError => e
128
+ Problem.alert("Couldn't write: #{e.message}")
102
129
  end
103
130
 
104
131
  def marker(line)
@@ -155,7 +182,7 @@ module Raindown
155
182
 
156
183
  # Text that isn't a prop, like world in title: 'Hello' world or tag=a b, is an error rather than left out.
157
184
  def props(text, variables, antlers: true)
158
- unread = text.gsub(PROP, '').split
185
+ unread = text.gsub(PROP, '').delete(',').split
159
186
  raise BrokenText, "Can't read #{unread.join(' ')} in #{text.strip}. Text with spaces needs quotes" if unread.any?
160
187
 
161
188
  text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables, antlers:)] }
@@ -4,6 +4,7 @@ require_relative '../raindown'
4
4
  require_relative 'bare_directives'
5
5
  require_relative 'front_matter'
6
6
  require_relative 'node_blocks'
7
+ require_relative 'written'
7
8
 
8
9
  module Raindown
9
10
  # Writes each node block's Markdown below its directive, replacing what it wrote before, when it's in comments or
@@ -11,13 +12,22 @@ module Raindown
11
12
  class NodeInjector
12
13
  class BlankLine < StandardError; end
13
14
 
15
+ # A comment on its own line that isn't a directive, like <!-- 2 rows · 3f2a9c -->, which straight below a directive
16
+ # is its node's comment, passed to its rewrite as comment:.
17
+ COMMENT = /\A\s*<!--(?!\s*(?::[A-Za-z]|[A-Za-z][\w-]*(?:::[A-Z]\w*)*:))\s*(.*?)\s*-->\s*\z/
18
+
14
19
  # Rewriter interface. Content without a node block is returned unchanged.
15
20
  def self.rewrite(content:, path: nil, bare: false, metadata: NoMetadata, **)
16
21
  new(content:, path:, bare:, metadata:).call
17
22
  end
18
23
 
24
+ # Warnings for the directives that couldn't run, written in their place, each with its line, like
25
+ # "line 6: toc: takes no value".
26
+ attr_reader :problems
27
+
19
28
  def initialize(content:, path: nil, bare: false, metadata: NoMetadata)
20
29
  @content = content
30
+ @problems = []
21
31
  @bare = BareDirectives.enabled?(path:, content:, bare:)
22
32
  @metadata = metadata
23
33
  end
@@ -28,21 +38,36 @@ module Raindown
28
38
  .select { |block| block[:writes] }
29
39
  return @content if blocks.empty?
30
40
 
41
+ # From the bottom up, so the lines above each block stay where they are, so its problems are put back in order.
31
42
  blocks.reverse_each { |block| lines[block[:open]..block[:last]] = block_lines(lines, block) }
43
+ @problems.reverse!
32
44
  lines.join
33
45
  end
34
46
 
35
47
  private
36
48
 
37
49
  def block_lines(lines, block)
38
- context = { document: @content, metadata: @metadata, bare: @bare, written: written_lines(lines, block) }
39
- markdown = Nodes.call(block[:node], :rewrite, **context).to_s.chomp
40
- written = markdown.empty? ? [] : "#{markdown}\n".lines
50
+ comment, written = comment_and_written(written_lines(lines, block))
51
+ context = { document: @content, metadata: @metadata, bare: @bare, comment:, written: }
52
+ written = written_lines_of(node_written(block, context))
41
53
  return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
42
54
 
43
55
  delimited(lines, block, written)
44
56
  end
45
57
 
58
+ # A node that raises writes why in its place, like a Problem, so the rest of the file still writes back.
59
+ def node_written(block, context)
60
+ @problems << block[:node].warning if block[:node].is_a?(Problem)
61
+ written = Nodes.call(block[:node], :rewrite, **context)
62
+ return written if written.is_a?(Written)
63
+
64
+ Written.new(written.to_s)
65
+ rescue StandardError => e
66
+ problem = Problem.new("Couldn't write: #{e.message}", block[:open] + 1)
67
+ @problems << problem.warning
68
+ Written.new(problem.rewrite)
69
+ end
70
+
46
71
  # The directive, what the node wrote and the end tag, straight after each other, as a comment on its own line ends
47
72
  # at its -->, so what's written below it, like a list, is its own block.
48
73
  def delimited(lines, block, written) = [line(lines[block[:open]]), *written, line(lines[block[:close]])]
@@ -55,6 +80,22 @@ module Raindown
55
80
  raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
56
81
  end
57
82
 
83
+ # Its lines: its comment, straight below its directive, then its Markdown.
84
+ def written_lines_of(written)
85
+ markdown = written.to_s.chomp
86
+ lines = markdown.empty? ? [] : "#{markdown}\n".lines
87
+ return lines unless written.comment
88
+
89
+ ["<!-- #{written.comment} -->\n", *lines]
90
+ end
91
+
92
+ # The comment the block's node wrote straight below its directive, if it wrote one, and the lines it wrote below that.
93
+ def comment_and_written(lines)
94
+ return [nil, lines] unless lines.first&.match?(COMMENT)
95
+
96
+ [lines.first[COMMENT, 1], lines.drop(1)]
97
+ end
98
+
58
99
  # What the block's node wrote last time, between its directive and its end tag, or the next blank line.
59
100
  def written_lines(lines, block)
60
101
  lines[(block[:open] + 1)...(block[:close] || (block[:last] + 1))]
@@ -6,6 +6,7 @@ require_relative 'js/js_nodes'
6
6
  require_relative 'list'
7
7
  require_relative 'markdown_nodes'
8
8
  require_relative 'node'
9
+ require_relative 'problem'
9
10
  require_relative 'stat'
10
11
  require_relative 'table/table'
11
12
  require_relative 'toc'
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'node'
4
+
5
+ module Raindown
6
+ # What a directive writes in place of its output when it can't run, like toc: extra, or a node that raised, so the
7
+ # rest of the file still writes back. In the file it's under its directive, so only its warning says its line and
8
+ # name. It never starts with the name, like toc:, which would read as a directive once the one above it's deleted.
9
+ class Problem < Node
10
+ attr_reader :message, :line
11
+
12
+ def initialize(message, line, name = nil)
13
+ @message = message
14
+ @line = line
15
+ @name = name
16
+ super()
17
+ end
18
+
19
+ # A message as a Markdown alert, which Obsidian, GitHub and Raindown show as a warning, and which can't read as a
20
+ # directive, as it starts with >.
21
+ def self.alert(message)
22
+ "> [!WARNING]\n#{message.lines.map { "> #{it}" }.join}"
23
+ end
24
+
25
+ def rewrite
26
+ self.class.alert(@message)
27
+ end
28
+
29
+ # For a terminal, or an editor to underline the line, like "line 6: toc: takes no value".
30
+ def warning
31
+ ["line #{@line}", @name, @message.sub(/\A[A-Z](?![A-Z])/, &:downcase)].compact.join(': ')
32
+ end
33
+ end
34
+ end
@@ -9,8 +9,9 @@ module Raindown
9
9
  # Rewrites the generated parts of a Markdown file in place before it's rendered:
10
10
  # node output, like the TOC, endpoint responses and table alignment.
11
11
  module Rewriter
12
- # The content written back, and the endpoint checks it failed, like "api.md:3: expected status 200, received 404".
13
- Rewrite = Data.define(:original, :content, :failures) do
12
+ # The content written back, the endpoint checks it failed, like "api.md:3: expected status 200, received 404", and
13
+ # the directives that couldn't run, like "line 4: toc: takes no value", written in their place.
14
+ Rewrite = Data.define(:original, :content, :failures, :problems) do
14
15
  def changed?
15
16
  content != original
16
17
  end
@@ -19,9 +20,11 @@ module Raindown
19
20
  module_function
20
21
 
21
22
  def rewrite(content:, path:, bare: false, execute: false, metadata: NoMetadata)
22
- injected = NodeInjector.rewrite(content:, path:, bare:, metadata:)
23
+ injector = NodeInjector.new(content:, path:, bare:, metadata:)
24
+ injected = injector.call
23
25
  endpoints = EndpointRunner.new(content: injected, path:, bare: BareDirectives.enabled?(path:, content: injected, bare:), execute:)
24
- Rewrite.new(original: content, content: TableFormatter.rewrite(content: endpoints.rewrite), failures: endpoints.failures)
26
+ Rewrite.new(original: content, content: TableFormatter.rewrite(content: endpoints.rewrite), failures: endpoints.failures,
27
+ problems: injector.problems)
25
28
  end
26
29
 
27
30
  # Writes the file only when it changed.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'table_formulas'
4
+
5
+ module Raindown
6
+ # A cell of a table: block's file, as its table shows it: its value, worked out from its formula, and clipped to fit
7
+ # its column, ending in an ellipsis, when the column's fixed. Deleting the padding around a formula's value shows its
8
+ # formula, and deleting a clipped cell's ellipsis shows its full text, wrapped onto |+ rows, each until it's changed,
9
+ # or, for its full text, an ellipsis is typed into it again, which clips it.
10
+ FileCell = Data.define(:saved, :value, :column) do
11
+ # As the table shows it, unless it's revealed.
12
+ def shown
13
+ return value unless column
14
+
15
+ column.clip(value)
16
+ end
17
+
18
+ # Whether the written cell still shows it: its value, as shown or in full, or clipped at another width, as it was
19
+ # before its column's width changed, or in full with an ellipsis typed into it, its formula, ignoring whitespace, or
20
+ # its clipped start, with the ellipsis deleted.
21
+ def unchanged_by?(cell)
22
+ [shown, value, revealing].include?(cell) || formula_in?(cell) || clip_of_value?(cell) || collapsing?(cell)
23
+ end
24
+
25
+ # Whether the written cell shows its formula, or its full text, in place of what the table shows. A full text written
26
+ # on one line, before its column was fixed, isn't, so it's clipped.
27
+ def revealed_by?(cell, squeezed:, wrapped:)
28
+ return formula_in?(cell) || (cell == value && squeezed) if formula?
29
+
30
+ clipped? && (cell == revealing || (cell == value && wrapped))
31
+ end
32
+
33
+ # The value the written cell gives it: its own while it's unchanged, or else what was typed, with the end that was
34
+ # clipped off back in place of the ellipsis, while it has one, like when its start's edited, or more typed after it.
35
+ def value_from(cell)
36
+ return value if unchanged_by?(cell)
37
+ return cell unless cell.include?('…')
38
+
39
+ before, _, after = cell.partition('…')
40
+ hidden = clipped_off(before)
41
+ return cell unless hidden
42
+
43
+ "#{before}#{hidden}#{after}"
44
+ end
45
+
46
+ private
47
+
48
+ def formula?
49
+ TableFormulas.formula?(saved)
50
+ end
51
+
52
+ def formula_in?(cell)
53
+ formula? && cell.gsub(/\s+/, '') == saved.gsub(/\s+/, '')
54
+ end
55
+
56
+ # Its full text with an ellipsis typed into it, like … or ..., anywhere between its words, to clip it again.
57
+ def collapsing?(cell)
58
+ ellipsis = /\s*(?:…|\.\.\.)\s*/
59
+ cell.match?(ellipsis) && cell.sub(ellipsis, ' ').strip == value
60
+ end
61
+
62
+ def clip_of_value?(cell)
63
+ cell.end_with?('…') && value.length >= cell.length && value.start_with?(cell.delete_suffix('…'))
64
+ end
65
+
66
+ # The end clipped off after the start before the ellipsis: the rest of its value when that's how it starts, or else,
67
+ # when its start's been edited, what's after the start it's clipped to now.
68
+ def clipped_off(before)
69
+ return value.delete_prefix(before) if value.start_with?(before)
70
+
71
+ value.delete_prefix(revealing) if clipped?
72
+ end
73
+
74
+ def clipped?
75
+ shown != value
76
+ end
77
+
78
+ # Its clipped start, without the ellipsis, which shows its full text when that's deleted.
79
+ def revealing
80
+ shown.delete_suffix('…') if clipped?
81
+ end
82
+ end
83
+ end
@@ -2,19 +2,36 @@
2
2
 
3
3
  module Raindown
4
4
  # Rows of cells as a Markdown table, its first row the header, and back again. A | in a cell is escaped as \|, and
5
- # a line break is written as a space, as a table's cells are one line. The TableFormatter lines up its columns.
5
+ # a line break is written as a space, as a table's cells are one line. A row starting with |+, carrying on the row
6
+ # above, is read as part of it. The TableFormatter lines up its columns.
6
7
  module MarkdownTable
7
- SEPARATOR = /\A\|?(\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\z/
8
+ SEPARATOR = /\A\|?(\s*:?[-=]+:?\s*\|)*\s*:?[-=]+:?\s*\|?\z/
8
9
  # A | that isn't escaped.
9
10
  PIPE = /(?<!\\)\|/
10
11
 
11
12
  module_function
12
13
 
13
- def format(rows)
14
+ # With the delimiter's cells, like ':===', when it has one for each column, keeping their alignment and widths. A
15
+ # cell can be lines, wrapped onto |+ rows below its row.
16
+ def format(rows, delimiter = nil)
14
17
  return '' if rows.empty?
15
18
 
16
- header, *body = rows.map { |row| "| #{row.map { escape(it) }.join(' | ')} |" }
17
- [header, "|#{Array.new(rows.first.length, '---').join('|')}|", *body].join("\n")
19
+ delimiter = Array.new(rows.first.length, '---') unless delimiter&.length == rows.first.length
20
+ header, *body = rows.flat_map { row_lines(it) }
21
+ [header, "|#{delimiter.join('|')}|", *body].join("\n")
22
+ end
23
+
24
+ def row_lines(row)
25
+ lines = row.map { Array(it) }
26
+ starts = ['| ', *Array.new(lines.map(&:length).max - 1, '|+')]
27
+ starts.each_with_index.map { |start, line| "#{start}#{lines.map { escape(it[line].to_s) }.join(' | ')} |" }
28
+ end
29
+
30
+ # The cells of the table's delimiter row, like ['---', ':==='], or nil when the lines aren't a table.
31
+ def delimiter(lines)
32
+ return unless lines[1]&.strip&.match?(SEPARATOR)
33
+
34
+ lines[1].strip.delete_prefix('|').delete_suffix('|').split('|').map(&:strip)
18
35
  end
19
36
 
20
37
  # The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
@@ -22,16 +39,40 @@ module Raindown
22
39
  padded_rows(lines).map { |row| row.map { cell(it) } }
23
40
  end
24
41
 
25
- # The rows of the table in the lines, their cells as written, padding and all, leaving out its separator.
42
+ # The rows of the table in the lines, their cells as written, padding and all, leaving out its separator, with each
43
+ # |+ row's cells joined into the row above's, on lines of their own.
26
44
  def padded_rows(lines)
27
- return [] unless lines[1]&.strip&.match?(SEPARATOR)
45
+ return [] unless delimiter(lines)
46
+
47
+ table_lines = [lines[0], *lines.drop(2)].map(&:strip).take_while { it.start_with?('|') }
48
+ table_lines.each_with_object([]) do |line, rows|
49
+ cells = padded_cells(line)
50
+ next rows << cells unless line.start_with?('|+') && rows.any?
51
+
52
+ rows[-1] = rows.last.zip(cells).map { |above, more| joined_cell(above, more.to_s) }
53
+ end
54
+ end
55
+
56
+ # The line's cells, padding and all, without its outer pipes, or the |+ starting it.
57
+ def padded_cells(line)
58
+ line.delete_prefix(lead(line)).delete_suffix('|').split(PIPE, -1)
59
+ end
60
+
61
+ def lead(line)
62
+ return '|+' if line.start_with?('|+')
63
+
64
+ '|'
65
+ end
66
+
67
+ # The cell carried on by the one below it, or as it was, padding and all, when that's blank.
68
+ def joined_cell(above, more)
69
+ return above if more.strip.empty?
28
70
 
29
- table_lines = [lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }
30
- table_lines.map { it.strip.delete_prefix('|').delete_suffix('|').split(PIPE, -1) }
71
+ [above.strip, more.strip].reject(&:empty?).join("\n")
31
72
  end
32
73
 
33
74
  def cell(padded)
34
- padded.strip.gsub('\|', '|')
75
+ one_line(padded.strip).gsub('\|', '|')
35
76
  end
36
77
 
37
78
  def escape(cell)
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # Which of a file's rows each of a table's rows shows, by index, given whether a table row shows a file row, so a row
5
+ # added or removed doesn't move the rows after it onto others. Rows that are the same in both are matched, in order,
6
+ # then those left between them are paired in order, like a row that's been edited.
7
+ class MatchedRows
8
+ def initialize(rows, file_rows, &same)
9
+ @rows = rows
10
+ @file_rows = file_rows
11
+ @same = same
12
+ end
13
+
14
+ # Each table row's file row, or nil for a row that's new.
15
+ def file_rows
16
+ [].tap do |matched|
17
+ [[-1, -1], *common_rows, [@rows, @file_rows]].each_cons(2) do |(row, file_row), (next_row, next_file_row)|
18
+ matched[row] = file_row unless row.negative?
19
+ (row + 1...next_row).zip(file_row + 1...next_file_row) { |gap_row, gap_file_row| matched[gap_row] = gap_file_row }
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ # The longest run of rows, in order, the same in both, as pairs of their indexes. Rows the same at the start and
27
+ # end are matched first, so only the few between them, being edited, are compared each with each.
28
+ def common_rows
29
+ head = matching_run((0...@rows).zip(0...@file_rows))
30
+ rows, file_rows = [@rows, @file_rows].map { (head.length...it).to_a }
31
+ tail = matching_run(rows.reverse.zip(file_rows.reverse)).reverse
32
+ rows, file_rows = [rows, file_rows].map { it.take(it.length - tail.length) }
33
+ [*head, *common_pairs(rows, file_rows, common_lengths(rows, file_rows)), *tail]
34
+ end
35
+
36
+ def matching_run(pairs)
37
+ pairs.take_while { |row, file_row| file_row && @same.call(row, file_row) }
38
+ end
39
+
40
+ # How many rows, at most, the rows and file rows from each on have in common.
41
+ def common_lengths(rows, file_rows)
42
+ rows.reverse.each_with_object([Array.new(file_rows.length + 1, 0)]) do |row, lengths|
43
+ lengths.unshift(common_lengths_from(row, file_rows, lengths.first))
44
+ end
45
+ end
46
+
47
+ # The common lengths from the row, from those from the row below it.
48
+ def common_lengths_from(row, file_rows, below)
49
+ file_rows.each_index.reverse_each.with_object(Array.new(file_rows.length + 1, 0)) do |j, lengths|
50
+ lengths[j] = @same.call(row, file_rows[j]) ? below[j + 1] + 1 : [below[j], lengths[j + 1]].max
51
+ end
52
+ end
53
+
54
+ def common_pairs(rows, file_rows, lengths)
55
+ i = j = 0
56
+ pairs = []
57
+ while i < rows.length && j < file_rows.length
58
+ if @same.call(rows[i], file_rows[j])
59
+ pairs << [rows[i], file_rows[j]]
60
+ i += 1
61
+ j += 1
62
+ elsif lengths[i + 1][j] >= lengths[i][j + 1]
63
+ i += 1
64
+ else
65
+ j += 1
66
+ end
67
+ end
68
+ pairs
69
+ end
70
+ end
71
+ end