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.
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'csv'
4
3
  require 'digest'
5
4
  require 'json'
6
5
 
7
6
  require_relative '../node'
8
7
  require_relative '../utils'
8
+ require_relative '../written'
9
9
  require_relative 'markdown_table'
10
+ require_relative 'table_column'
11
+ require_relative 'table_file'
10
12
  require_relative 'table_formulas'
11
13
  require_relative 'written_table'
12
14
 
@@ -14,7 +16,7 @@ module Raindown
14
16
  # The node behind table:, a CSV or TSV file relative to the project as a Markdown table, after a comment of how many
15
17
  # rows it has and a hash of them, as they were when the table and the file last matched:
16
18
  # table: 'data/team.csv'
17
- # <!-- table 2 rows · 3f2a9c -->
19
+ # <!-- 2 rows · 3f2a9c -->
18
20
  # | name | role |
19
21
  # |------|----------|
20
22
  # | Ada | engineer |
@@ -25,25 +27,33 @@ module Raindown
25
27
  # cells in the file, like =SUM(B), show their values, and keep their formulas while their values are left alone. Delete
26
28
  # the padding around one's value and it shows its formula, to edit, until it's changed.
27
29
  class Table < Node
28
- COMMENT = /\A<!-- table (?:conflict|\d+ rows?) · (\h+) -->\z/
29
- SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
30
+ # The hash in its comment, like 3f2a9c in "2 rows · 3f2a9c".
31
+ HASH = /· (\h+)\z/
30
32
  BOLD = /\A\*\*.*\*\*\z/
33
+ # The table a new file starts with, when there's no table to make it from.
34
+ STARTER = [['Column 1', 'Column 2'], ['', '']].freeze
31
35
 
32
- def initialize(data, total: false)
36
+ def initialize(data = nil, total: false)
33
37
  @data = data
34
38
  @total = total
35
39
  end
36
40
 
37
- # Written is what it wrote last time, so it can tell which of the table and the file changed since.
38
- def rewrite(written: [], metadata: NoMetadata)
41
+ # Written is what it wrote last time, so it can tell which of the table and the file changed since. A file that's
42
+ # not there yet is made, from the table below it, or else one to fill in.
43
+ def rewrite(written: [], metadata: NoMetadata, comment: nil)
39
44
  path = data_path(metadata)
40
- data_error(path, metadata) || synced(path, written.map(&:chomp))
45
+ lines = written.map(&:chomp)
46
+ create_file(path, lines) if missing_file?(path, metadata)
47
+ data_error(path, metadata)&.then { Problem.alert(it) } || synced(path, lines, comment)
41
48
  end
42
49
 
43
50
  # Write-in, in <{ }>, the table only shows its file, and never writes to it.
44
51
  def render(metadata: NoMetadata)
45
52
  path = data_path(metadata)
46
- Raindown.commonmark(data_error(path, metadata) || MarkdownTable.format(with_total(TableFormulas.evaluate(read_rows(path)))))
53
+ error = data_error(path, metadata)
54
+ return Raindown.commonmark(Problem.alert(error)) if error
55
+
56
+ Raindown.commonmark(MarkdownTable.format(with_total(TableFormulas.evaluate(TableFile.read(path)))))
47
57
  end
48
58
 
49
59
  private
@@ -58,24 +68,36 @@ module Raindown
58
68
  Dir.pwd
59
69
  end
60
70
 
71
+ def missing_file?(path, metadata)
72
+ TableFile.supported?(path) && Utils.inside?(path, data_root(metadata)) && !File.exist?(path)
73
+ end
74
+
75
+ # From the table as it's written, below any alert saying why there was no file.
76
+ def create_file(path, lines)
77
+ rows = MarkdownTable.parse(lines.grep_v(/\A>/))
78
+ rows = STARTER if rows.empty?
79
+ TableFile.create(path, rows)
80
+ end
81
+
61
82
  # Why the file can't be a table, or nil when it can.
62
83
  def data_error(path, metadata)
63
- return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
84
+ return 'Needs a file path to a CSV/TSV that exists or will be created' if @data.to_s.strip.empty?
85
+ return "#{@data.inspect} must be a file path to a CSV/TSV that exists or will be created" unless TableFile.supported?(path)
64
86
  return "Can't show #{@data.inspect} as a table: it's outside the project" unless Utils.inside?(path, data_root(metadata))
65
87
 
66
88
  "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
67
89
  end
68
90
 
69
- def synced(path, written)
70
- file = read_rows(path)
91
+ def synced(path, written, comment)
92
+ file = TableFile.read(path)
71
93
  shown = TableFormulas.evaluate(file)
72
- synced_hash = written.first&.[](COMMENT, 1)
73
- table = WrittenTable.new(written_rows(written), file)
74
- return markdown(file, table.revealed) if unchanged?(table.rows, synced_hash, shown)
75
- return markdown(write_rows(path, with_file_cells(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
94
+ synced_hash = comment&.[](HASH, 1)
95
+ table = WrittenTable.new(written_rows(written), file, MarkdownTable.delimiter(written))
96
+ return markdown(file, table) if unchanged?(table.rows, synced_hash, shown)
97
+ return markdown(TableFile.write(path, table.saved_rows), table) if hash_rows(shown) == synced_hash
76
98
 
77
99
  warn "raindown: #{@data} and its table both changed, so neither is written until they match"
78
- ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
100
+ Written.new(written.join("\n"), comment: "conflict · #{synced_hash}")
79
101
  end
80
102
 
81
103
  # Whether there's nothing to write back: the table's rows are as it wrote them, or as the file's are, or it's new.
@@ -85,37 +107,27 @@ module Raindown
85
107
 
86
108
  # The table's rows as it was written, padding and all, leaving out its total.
87
109
  def written_rows(written)
88
- header, *body = MarkdownTable.padded_rows(written.drop(1))
110
+ header, *body = MarkdownTable.padded_rows(written)
89
111
  [header, *body.reject { total_row?(it.map(&:strip)) }].compact
90
112
  end
91
113
 
92
- def markdown(rows, revealed = [])
114
+ # Written with the delimiter it was written with, its cells fit to its fixed columns.
115
+ def markdown(rows, written)
93
116
  return '' if rows.empty?
94
117
 
95
118
  shown = TableFormulas.evaluate(rows)
96
119
  count = rows.length - 1
97
- table = MarkdownTable.format(with_total(with_revealed(shown, rows, revealed)))
98
- "<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(shown)} -->\n#{table}"
120
+ cells = with_total(with_revealed(shown, rows, written.revealed))
121
+ columns = TableColumn.all(written.delimiter, rows)
122
+ table = MarkdownTable.format(TableColumn.fit(cells, columns, written.revealed), written.delimiter)
123
+ Written.new(table, comment: "#{count} row#{'s' unless count == 1} · #{hash_rows(shown)}")
99
124
  end
100
125
 
101
- # The values, but for each revealed cell, which shows its formula.
126
+ # The values, but for each revealed cell, which shows its formula, or its full text.
102
127
  def with_revealed(values, rows, revealed)
103
128
  revealed.each_with_object(values.map(&:dup)) { |(row, column), shown| shown[row][column] = rows[row][column] }
104
129
  end
105
130
 
106
- # The table's rows, keeping the file's cell where the table still shows it, like a formula as its value, or a cell
107
- # with line breaks on one line.
108
- def with_file_cells(table, file, shown)
109
- table.each_with_index.map do |row, row_index|
110
- row.each_with_index.map do |cell, column|
111
- kept = file.dig(row_index, column)
112
- next kept if kept && MarkdownTable.one_line(shown.dig(row_index, column).to_s) == cell
113
-
114
- cell
115
- end
116
- end
117
- end
118
-
119
131
  def with_total(rows)
120
132
  return rows unless @total && rows.length > 1
121
133
 
@@ -144,17 +156,6 @@ module Raindown
144
156
  row.any? { !it.empty? } && row.all? { it.empty? || it.match?(BOLD) }
145
157
  end
146
158
 
147
- def read_rows(path)
148
- rows = CSV.read(path, col_sep: SEPARATORS[File.extname(path).downcase], skip_blanks: true, encoding: Encoding::UTF_8)
149
- width = rows.map(&:length).max.to_i
150
- rows.map { |row| Array.new(width) { row[it].to_s.strip } }
151
- end
152
-
153
- def write_rows(path, rows)
154
- File.write(path, rows.map { CSV.generate_line(it, col_sep: SEPARATORS[File.extname(path).downcase]) }.join)
155
- rows
156
- end
157
-
158
159
  # As the table shows them, so a cell with line breaks hashes the same in both.
159
160
  def hash_rows(rows)
160
161
  Digest::SHA256.hexdigest(JSON.generate(rows.map { |row| row.map { MarkdownTable.one_line(it.to_s) } }))[0, 6]
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ module Raindown
6
+ # A table's column: its alignment, from its delimiter cell's colons, and its width, that of its widest cell, or as wide
7
+ # as its delimiter when that's equals signs, like |====|, which is fixed. An equals sign typed into dashes, like
8
+ # |-=---|, makes it fixed as wide as it was, and a dash typed into equals signs, like |==-==|, makes it widen again.
9
+ TableColumn = Data.define(:delimiter, :width) do
10
+ # The columns of the delimiter's cells, while it has one for each of the rows' columns.
11
+ def self.all(delimiter, rows)
12
+ return [] unless delimiter&.length == rows.first&.length
13
+
14
+ delimiter.each_with_index.map { |cell, i| from(cell, rows.map { it[i].to_s }) }
15
+ end
16
+
17
+ # The rows with each cell clipped to fit its fixed column, but for those revealed, which are wrapped to fit it as
18
+ # lines instead, but for the header's, as + rows can only be below the delimiter.
19
+ def self.fit(rows, columns, revealed)
20
+ rows.each_with_index.map do |row, row_index|
21
+ row.each_with_index.map do |cell, column|
22
+ next cell unless columns[column]&.fixed?
23
+ next columns[column].clip(cell) unless revealed.include?([row_index, column])
24
+ next cell if row_index.zero?
25
+
26
+ columns[column].wrap(cell)
27
+ end
28
+ end
29
+ end
30
+
31
+ def self.from(delimiter, cells)
32
+ return new(delimiter:, width: delimiter.length - fixing(delimiter)) if fixed?(delimiter)
33
+
34
+ new(delimiter:, width: [*cells.map { display_width(it) }, 3].max)
35
+ end
36
+
37
+ # Wide characters like CJK and emoji take two columns. Emoji are always measured as two,
38
+ # instead of guessing from the terminal, so a file formats the same on every machine.
39
+ def self.display_width(cell)
40
+ Unicode::DisplayWidth.of(cell, emoji: :all)
41
+ end
42
+
43
+ # Equals signs, or one typed into dashes, as fewer of them than dashes.
44
+ def self.fixed?(delimiter)
45
+ equals = delimiter.count('=')
46
+ equals.positive? && (delimiter.count('-').zero? || equals < delimiter.count('-'))
47
+ end
48
+
49
+ # The equals signs typed into dashes, which aren't part of its width, or none when it's all equals signs.
50
+ def self.fixing(delimiter)
51
+ return 0 if delimiter.count('-').zero?
52
+
53
+ delimiter.count('=')
54
+ end
55
+
56
+ def fixed?
57
+ TableColumn.fixed?(delimiter)
58
+ end
59
+
60
+ def alignment
61
+ if delimiter.start_with?(':') && delimiter.end_with?(':') then :center
62
+ elsif delimiter.end_with?(':') then :right
63
+ elsif delimiter.start_with?(':') then :left
64
+ end
65
+ end
66
+
67
+ def delimiter_cell
68
+ case alignment
69
+ when :center then ":#{dashes * (width - 2)}:"
70
+ when :right then "#{dashes * (width - 1)}:"
71
+ when :left then ":#{dashes * (width - 1)}"
72
+ else dashes * width
73
+ end
74
+ end
75
+
76
+ def pad(cell)
77
+ padding = [width - TableColumn.display_width(cell), 0].max
78
+
79
+ case alignment
80
+ when :center then "#{' ' * (padding / 2)}#{cell}#{' ' * (padding - (padding / 2))}"
81
+ when :right then "#{' ' * padding}#{cell}"
82
+ else "#{cell}#{' ' * padding}"
83
+ end
84
+ end
85
+
86
+ # The cell cut to fit a fixed column, ending in an ellipsis, or as it is when it fits.
87
+ def clip(cell)
88
+ return cell unless fixed? && TableColumn.display_width(cell) > width
89
+
90
+ kept = cell.grapheme_clusters.each_with_object(+'') do |grapheme, text|
91
+ break text if TableColumn.display_width(text + grapheme) > width - 1
92
+
93
+ text << grapheme
94
+ end
95
+ "#{kept.rstrip}…"
96
+ end
97
+
98
+ # The cell's words as lines that fit the column, each as many as fit, and a word longer than it on a line of its own.
99
+ def wrap(cell)
100
+ cell.split(' ').each_with_object([]) do |word, lines|
101
+ next lines.last << " #{word}" if lines.any? && TableColumn.display_width("#{lines.last} #{word}") <= width
102
+
103
+ lines << +word
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def dashes
110
+ return '=' if fixed?
111
+
112
+ '-'
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'fileutils'
5
+
6
+ module Raindown
7
+ # The CSV or TSV file behind a table:, as rows of cells as wide as each other, written back in its own format.
8
+ module TableFile
9
+ SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
10
+ BYTE_ORDER_MARK = "\uFEFF"
11
+
12
+ module_function
13
+
14
+ def supported?(path)
15
+ SEPARATORS.key?(File.extname(path).downcase)
16
+ end
17
+
18
+ def read(path)
19
+ rows = CSV.read(path, col_sep: separator(path), skip_blanks: true, encoding: 'bom|utf-8')
20
+ width = rows.map(&:length).max.to_i
21
+ rows.map { |row| Array.new(width) { row[it].to_s.strip } }
22
+ end
23
+
24
+ # A new file, and the folders it's in, which has no format of its own to keep yet.
25
+ def create(path, rows)
26
+ FileUtils.mkdir_p(File.dirname(path))
27
+ File.binwrite(path, rows.map { CSV.generate_line(without_empty_cells(it), col_sep: separator(path)) }.join)
28
+ end
29
+
30
+ # Keeps the file's line endings and any byte order mark, like Excel's.
31
+ def write(path, rows)
32
+ text = File.binread(path).force_encoding(Encoding::UTF_8)
33
+ options = { col_sep: separator(path), row_sep: text[/\r?\n/] || "\n" }
34
+ lines = rows.map { CSV.generate_line(without_empty_cells(it), **options) }
35
+ File.binwrite(path, [(BYTE_ORDER_MARK if text.start_with?(BYTE_ORDER_MARK)), *lines].join)
36
+ rows
37
+ end
38
+
39
+ def separator(path)
40
+ SEPARATORS[File.extname(path).downcase]
41
+ end
42
+
43
+ # Empty cells as nil, which CSV leaves empty rather than quoting, but for a row of one, as it would be a blank line.
44
+ def without_empty_cells(row)
45
+ return row if row.one?
46
+
47
+ row.map { it unless it.empty? }
48
+ end
49
+ end
50
+ end
@@ -1,22 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'unicode/display_width'
3
+ require_relative 'table_column'
4
4
 
5
5
  module Raindown
6
- # Pads every column of each Markdown table to the width of its widest cell.
6
+ # Pads every column of each Markdown table to the width of its widest cell, but for a column whose delimiter is
7
+ # equals signs, like |====|, which is as wide as they are, letting a longer cell run past it. A row starting with |+,
8
+ # like "|+more | |", carries on the row above it, which Raindown joins into its cells when it renders. Its |+ takes
9
+ # the place of a row's "| ", so its columns line up.
7
10
  class TableFormatter
8
- DELIMITER_CELL = /\A:?-+:?\z/
11
+ DELIMITER_CELL = /\A:?[-=]+:?\z/
9
12
  FENCE = /\A\s*(```|~~~)/
13
+ CONTINUATION = /\A\s*\|\+/
10
14
 
11
15
  def self.rewrite(content:, **)
12
16
  new(content:).format
13
17
  end
14
18
 
19
+ # The Markdown as Commonmarker reads it: each table's equals signs as dashes, and its + rows joined into the row
20
+ # above them, each cell carrying on with a space.
21
+ def self.gfm(markdown)
22
+ new(content: markdown).gfm
23
+ end
24
+
15
25
  def initialize(content:)
16
26
  @lines = content.lines
17
27
  end
18
28
 
19
29
  def format
30
+ replace_tables { align(it) }
31
+ end
32
+
33
+ def gfm
34
+ replace_tables { gfm_table(it) }
35
+ end
36
+
37
+ private
38
+
39
+ # The content with each table, outside code, replaced by the block's lines for it.
40
+ def replace_tables
20
41
  output = []
21
42
  index = 0
22
43
  in_fence = false
@@ -27,7 +48,7 @@ module Raindown
27
48
 
28
49
  if !in_fence && table_start?(index)
29
50
  table_end = find_table_end(index)
30
- output.concat(align(@lines[index...table_end]))
51
+ output.concat(yield(@lines[index...table_end]))
31
52
  index = table_end
32
53
  else
33
54
  output << line
@@ -38,12 +59,26 @@ module Raindown
38
59
  output.join
39
60
  end
40
61
 
41
- private
62
+ # Split on pipes that aren't escaped, dropping the optional outer pipes, or the |+ starting a continuation row.
63
+ def cells(line)
64
+ content = line.strip
65
+ content = content.delete_prefix(row_lead(line).rstrip)
66
+ content = content.delete_suffix('|') unless content.end_with?('\|')
67
+
68
+ content.split(/(?<!\\)\|/, -1).map(&:strip)
69
+ end
70
+
71
+ # How the row's line starts, before its first cell: |+ when it carries on the row above, which is as wide as "| ".
72
+ def row_lead(line)
73
+ return '|+' if line.match?(CONTINUATION)
74
+
75
+ '| '
76
+ end
42
77
 
43
78
  def table_start?(index)
44
79
  header = @lines[index]
45
80
  delimiter = @lines[index + 1]
46
- return false unless header&.include?('|') && delimiter
81
+ return false unless header&.include?('|') && delimiter && !header.match?(CONTINUATION)
47
82
 
48
83
  delimiter_cells = cells(delimiter)
49
84
  delimiter_cells.any? && delimiter_cells.all? { |cell| cell.match?(DELIMITER_CELL) } &&
@@ -56,79 +91,40 @@ module Raindown
56
91
  table_end
57
92
  end
58
93
 
59
- def align(table)
60
- indent = table.first[/\A\s*/]
61
- rows = table.map { |line| cells(line) }
62
- header, delimiter, *body = rows
63
- return table if body.any? { |row| row.length > header.length }
64
-
65
- table_lines(indent:, header:, body:, alignments: delimiter.map { |cell| alignment(cell) })
94
+ def gfm_table(table)
95
+ header, delimiter, *body = table.map { |line| cells(line) }
96
+ rows = joined_rows(table.drop(2), header, body)
97
+ rows.insert(1, delimiter.map { it.tr('=', '-') }).map { "#{table.first[/\A\s*/]}| #{it.join(' | ')} |\n" }
66
98
  end
67
99
 
68
- def table_lines(indent:, header:, body:, alignments:)
69
- body = body.map { |row| row + ([''] * (header.length - row.length)) }
70
- widths = column_widths(rows: [header, *body])
71
-
72
- [
73
- row_line(indent:, cells: header, widths:, alignments:),
74
- delimiter_line(indent:, widths:, alignments:),
75
- *body.map { |row| row_line(indent:, cells: row, widths:, alignments:) }
76
- ].map { |line| "#{line}\n" }
77
- end
78
-
79
- # Split on pipes that aren't escaped, dropping the optional outer pipes.
80
- def cells(line)
81
- content = line.strip
82
- content = content.delete_prefix('|')
83
- content = content.delete_suffix('|') unless content.end_with?('\|')
84
-
85
- content.split(/(?<!\\)\|/, -1).map(&:strip)
86
- end
100
+ # The rows, each with the + rows below it joined into its cells.
101
+ def joined_rows(lines, header, body)
102
+ lines.zip(body).each_with_object([header]) do |(line, row), joined|
103
+ next joined << row unless line.match?(CONTINUATION)
87
104
 
88
- def alignment(cell)
89
- if cell.start_with?(':') && cell.end_with?(':') then :center
90
- elsif cell.end_with?(':') then :right
91
- elsif cell.start_with?(':') then :left
105
+ joined[-1] = joined.last.zip(row).map { |above, more| [above, more].reject { it.to_s.empty? }.join(' ') }
92
106
  end
93
107
  end
94
108
 
95
- # Three is the shortest delimiter cell, which also fits ':-:'.
96
- def column_widths(rows:)
97
- rows.transpose.map { |column| [column.map { |cell| display_width(cell) }.max, 3].max }
98
- end
99
-
100
- # Wide characters like CJK and emoji take two columns. Emoji are always measured as two,
101
- # instead of guessing from the terminal, so a file formats the same on every machine.
102
- def display_width(cell)
103
- Unicode::DisplayWidth.of(cell, emoji: :all)
104
- end
109
+ def align(table)
110
+ header, delimiter, *body = table.map { |line| cells(line) }
111
+ return table if body.any? { |row| row.length > header.length }
105
112
 
106
- def row_line(indent:, cells:, widths:, alignments:)
107
- padded = cells.each_with_index.map { |cell, i| pad(cell, widths[i], alignments[i]) }
108
- "#{indent}| #{padded.join(' | ')} |"
113
+ rows = [header, *body.map { |row| row + ([''] * (header.length - row.length)) }]
114
+ aligned_lines(table, rows, TableColumn.all(delimiter, rows))
109
115
  end
110
116
 
111
- def delimiter_line(indent:, widths:, alignments:)
112
- dashes = widths.each_with_index.map do |width, i|
113
- case alignments[i]
114
- when :center then ":#{'-' * (width - 2)}:"
115
- when :right then "#{'-' * (width - 1)}:"
116
- when :left then ":#{'-' * (width - 1)}"
117
- else '-' * width
118
- end
117
+ def aligned_lines(table, rows, columns)
118
+ indent = table.first[/\A\s*/]
119
+ lines = rows.zip(table.values_at(0, 2...table.length)).map do |row, line|
120
+ row_line(indent, line, columns.zip(row).map { |column, cell| column.pad(cell) })
119
121
  end
120
-
121
- "#{indent}| #{dashes.join(' | ')} |"
122
+ lines.insert(1, row_line(indent, table[1], columns.map(&:delimiter_cell)))
122
123
  end
123
124
 
124
- def pad(cell, width, alignment)
125
- padding = width - display_width(cell)
126
-
127
- case alignment
128
- when :center then "#{' ' * (padding / 2)}#{cell}#{' ' * (padding - (padding / 2))}"
129
- when :right then "#{' ' * padding}#{cell}"
130
- else "#{cell}#{' ' * padding}"
131
- end
125
+ # The row's line, starting with |+ when it carries on the row above, as the line it was did.
126
+ def row_line(indent, line, cells)
127
+ "#{indent}#{row_lead(line)}#{cells.join(' | ')} |\n"
132
128
  end
133
129
  end
134
130
  end
@@ -2,32 +2,47 @@
2
2
 
3
3
  require 'unicode/display_width'
4
4
 
5
+ require_relative 'file_cell'
5
6
  require_relative 'markdown_table'
6
- require_relative 'table_formulas'
7
+ require_relative 'matched_rows'
8
+ require_relative 'table_column'
7
9
 
8
10
  module Raindown
9
- # A table: block's table as it was written, its cells padded as they were, against its file's rows. A formula cell
10
- # shows its formula, so it can be edited, when its value's padding was deleted, no longer the width of its column's
11
- # header, and keeps showing it, until it's changed from the file's, ignoring whitespace.
11
+ # A table: block's table as it was written, its cells padded as they were, against its file's cells, as its columns
12
+ # show them. Each of its rows is matched to the file's row it shows, so a row added or removed above a formula
13
+ # doesn't move it onto another. A formula's value is no longer lined up when its padding's deleted, the width of its
14
+ # column's header.
12
15
  class WrittenTable
13
- def initialize(padded_rows, file)
16
+ # The delimiter's cells, like ':===', as it was written, or nil when it's not a table yet.
17
+ attr_reader :delimiter
18
+
19
+ def initialize(padded_rows, file, delimiter = nil)
14
20
  @padded_rows = padded_rows
15
21
  @file = file
16
22
  @values = TableFormulas.evaluate(file)
23
+ @delimiter = delimiter
24
+ @columns = TableColumn.all(delimiter, file)
17
25
  end
18
26
 
19
27
  # Its rows, as wide as each other, each formula still shown as it's saved being its value, as they're compared with the file's.
20
28
  def rows
21
- cells.each_with_index.map do |row, row_index|
22
- row.each_with_index.map do |cell, column|
23
- next @values.dig(row_index, column) if same_formula?(cell, @file.dig(row_index, column))
29
+ each_cell { |cell, row, column| file_cell(row, column)&.value_from(cell) || cell }
30
+ end
31
+
32
+ # Its rows to write to the file, keeping the file's cell where the table still shows it, like a formula as its
33
+ # value, or a cell with line breaks on one line.
34
+ def saved_rows
35
+ rows.each_with_index.map do |cells, row|
36
+ cells.each_with_index.map do |cell, column|
37
+ kept = file_cell(row, column)
38
+ next kept.saved if kept && MarkdownTable.one_line(kept.value) == cell
24
39
 
25
40
  cell
26
41
  end
27
42
  end
28
43
  end
29
44
 
30
- # The row and column of each cell to show as its formula.
45
+ # The row and column of each cell to show as its formula, or in full.
31
46
  def revealed
32
47
  cells.each_with_index.flat_map do |row, row_index|
33
48
  row.each_index.select { reveal?(row_index, it) }.map { [row_index, it] }
@@ -40,21 +55,39 @@ module Raindown
40
55
  @cells ||= @padded_rows.map { |row| Array.new(width_in_cells) { MarkdownTable.cell(row[it].to_s) } }
41
56
  end
42
57
 
58
+ def each_cell
59
+ cells.each_with_index.map { |row, row_index| row.each_with_index.map { |cell, column| yield cell, row_index, column } }
60
+ end
61
+
43
62
  # As wide as its widest row, as its file's rows are read, so a row still being typed reads the same in both.
44
63
  def width_in_cells
45
64
  @padded_rows.map(&:length).max
46
65
  end
47
66
 
48
- def reveal?(row, column)
49
- formula = @file.dig(row, column)
50
- return false unless TableFormulas.formula?(formula)
67
+ def file_cell(row, column)
68
+ file_rows[row]&.then { cell_in_file(it, column) }
69
+ end
51
70
 
52
- cell = cells[row][column]
53
- same_formula?(cell, formula) || (cell == @values.dig(row, column) && squeezed?(row, column))
71
+ def cell_in_file(file_row, column)
72
+ saved = @file.dig(file_row, column)
73
+ FileCell.new(saved:, value: @values.dig(file_row, column), column: @columns[column]) if saved
54
74
  end
55
75
 
56
- def same_formula?(cell, formula)
57
- TableFormulas.formula?(formula) && cell.gsub(/\s+/, '') == formula.gsub(/\s+/, '')
76
+ # The index of each of its rows' file row, or nil for a row that's new.
77
+ def file_rows
78
+ @file_rows ||= MatchedRows.new(cells.length, @file.length) { |row, file_row| same_row?(row, file_row) }.file_rows
79
+ end
80
+
81
+ # Whether the table's row shows the file's row, each cell unchanged, or blank where the file has none.
82
+ def same_row?(row, file_row)
83
+ cells[row].each_with_index.all? do |cell, column|
84
+ cell_in_file(file_row, column)&.unchanged_by?(cell) || (cell.empty? && !@file.dig(file_row, column))
85
+ end
86
+ end
87
+
88
+ def reveal?(row, column)
89
+ padded = @padded_rows[row][column].to_s
90
+ file_cell(row, column)&.revealed_by?(cells[row][column], squeezed: squeezed?(row, column), wrapped: padded.include?("\n"))
58
91
  end
59
92
 
60
93
  def squeezed?(row, column)
@@ -53,6 +53,7 @@ module Raindown
53
53
  def rewrite
54
54
  rewrite = Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
55
55
  rewrite.failures.each { warn "Endpoint check failed: #{it}" }
56
+ rewrite.problems.each { warn "raindown: #{path}: #{it}" }
56
57
  rewrite.changed?
57
58
  end
58
59
  end
@@ -30,7 +30,7 @@ module Raindown
30
30
  def update(path)
31
31
  rewrite = Rewriter.update(path:, bare: @bare, execute: @execute, metadata: Pages.for(path))
32
32
  puts "[#{name}] Updated #{relative(path)}" if rewrite.changed?
33
- rewrite.failures.each { warn "[#{name}] Endpoint check failed: #{it}" }
33
+ report(rewrite, path)
34
34
  rescue StandardError => e
35
35
  raise if ENV['RAINDOWN_DEBUG']
36
36
 
@@ -40,6 +40,12 @@ module Raindown
40
40
 
41
41
  private
42
42
 
43
+ # What writing back found wrong: failed endpoint checks, and directives that couldn't run, written in their place.
44
+ def report(rewrite, path)
45
+ rewrite.failures.each { warn "[#{name}] Endpoint check failed: #{it}" }
46
+ rewrite.problems.each { warn "[#{name}] raindown: #{relative(path)}: #{it}" }
47
+ end
48
+
43
49
  def relative(path)
44
50
  File.expand_path(path).delete_prefix(File.join(root, ''))
45
51
  end