tessella 0.3.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 (188) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +900 -0
  4. data/doc/README.md +74 -0
  5. data/doc/bitfield.md +123 -0
  6. data/doc/charts.md +1370 -0
  7. data/doc/check.rb +75 -0
  8. data/doc/cli.md +483 -0
  9. data/doc/composition.md +599 -0
  10. data/doc/getting-started.md +121 -0
  11. data/doc/glyphs.md +134 -0
  12. data/doc/images.md +325 -0
  13. data/doc/library.md +239 -0
  14. data/doc/lint.md +270 -0
  15. data/doc/meter.md +210 -0
  16. data/doc/rule.md +51 -0
  17. data/doc/seq.md +169 -0
  18. data/doc/sparkline.md +63 -0
  19. data/doc/table.md +347 -0
  20. data/doc/targets.md +222 -0
  21. data/doc/text.md +84 -0
  22. data/doc/tree.md +108 -0
  23. data/doc/wave.md +122 -0
  24. data/examples/README.md +103 -0
  25. data/examples/ages.txt +9 -0
  26. data/examples/bad.csv +3 -0
  27. data/examples/boxes.txt +3 -0
  28. data/examples/broken.txt +4 -0
  29. data/examples/calibration.rb +12 -0
  30. data/examples/cities.csv +4 -0
  31. data/examples/cjk.rb +13 -0
  32. data/examples/comment.rb +9 -0
  33. data/examples/comment.sh +13 -0
  34. data/examples/cpu.txt +60 -0
  35. data/examples/dashboard.sh +25 -0
  36. data/examples/datasheet.rb +16 -0
  37. data/examples/disc.pgm +0 -0
  38. data/examples/disks.sh +18 -0
  39. data/examples/disks.txt +5 -0
  40. data/examples/dose.txt +7 -0
  41. data/examples/drift.txt +4 -0
  42. data/examples/ethernet.txt +3 -0
  43. data/examples/figure.c +5 -0
  44. data/examples/fit.rb +16 -0
  45. data/examples/floors.rb +21 -0
  46. data/examples/gallery.sh +20 -0
  47. data/examples/gauges.sh +31 -0
  48. data/examples/gradient.rb +16 -0
  49. data/examples/growth.txt +6 -0
  50. data/examples/hosts.csv +4 -0
  51. data/examples/icmp.txt +3 -0
  52. data/examples/ipv4.txt +12 -0
  53. data/examples/kinds.csv +4 -0
  54. data/examples/langs.csv +4 -0
  55. data/examples/lint.rb +16 -0
  56. data/examples/load.rb +14 -0
  57. data/examples/login.txt +10 -0
  58. data/examples/neg.csv +4 -0
  59. data/examples/normal.txt +200 -0
  60. data/examples/note.txt +2 -0
  61. data/examples/notes-fixed.md +17 -0
  62. data/examples/notes.md +17 -0
  63. data/examples/ohlc.txt +7 -0
  64. data/examples/os.csv +10 -0
  65. data/examples/outline.txt +8 -0
  66. data/examples/plot.txt +5 -0
  67. data/examples/refusals.rb +17 -0
  68. data/examples/report.sh +21 -0
  69. data/examples/rfc.sh +22 -0
  70. data/examples/samples.txt +7 -0
  71. data/examples/shapes.pgm +0 -0
  72. data/examples/sizes.tsv +3 -0
  73. data/examples/spi.txt +6 -0
  74. data/examples/status.sh +23 -0
  75. data/examples/stem.txt +12 -0
  76. data/examples/table-drift.txt +4 -0
  77. data/examples/temps.txt +31 -0
  78. data/examples/trig.sh +13 -0
  79. data/examples/trig.txt +102 -0
  80. data/examples/week.txt +8 -0
  81. data/examples/wide.txt +2 -0
  82. data/exe/tessella +6 -0
  83. data/lib/tessella/axis.rb +71 -0
  84. data/lib/tessella/bitfield.rb +194 -0
  85. data/lib/tessella/block.rb +56 -0
  86. data/lib/tessella/borders.rb +85 -0
  87. data/lib/tessella/canvas/area.rb +155 -0
  88. data/lib/tessella/canvas/ascii.rb +120 -0
  89. data/lib/tessella/canvas/block.rb +40 -0
  90. data/lib/tessella/canvas/braille.rb +30 -0
  91. data/lib/tessella/canvas/clip.rb +49 -0
  92. data/lib/tessella/canvas/density.rb +51 -0
  93. data/lib/tessella/canvas/dot.rb +27 -0
  94. data/lib/tessella/canvas/eighths.rb +43 -0
  95. data/lib/tessella/canvas/half.rb +51 -0
  96. data/lib/tessella/canvas/sextant.rb +36 -0
  97. data/lib/tessella/canvas.rb +168 -0
  98. data/lib/tessella/cell.rb +10 -0
  99. data/lib/tessella/chart.rb +211 -0
  100. data/lib/tessella/check.rb +52 -0
  101. data/lib/tessella/cli/bitfield.rb +121 -0
  102. data/lib/tessella/cli/chart.rb +615 -0
  103. data/lib/tessella/cli/chart_candle.rb +36 -0
  104. data/lib/tessella/cli/chart_heatmap.rb +21 -0
  105. data/lib/tessella/cli/chart_help.rb +85 -0
  106. data/lib/tessella/cli/chart_pie.rb +38 -0
  107. data/lib/tessella/cli/compose.rb +156 -0
  108. data/lib/tessella/cli/compose_options.rb +259 -0
  109. data/lib/tessella/cli/glyphs.rb +97 -0
  110. data/lib/tessella/cli/image.rb +250 -0
  111. data/lib/tessella/cli/input.rb +44 -0
  112. data/lib/tessella/cli/iso_time.rb +28 -0
  113. data/lib/tessella/cli/lint.rb +102 -0
  114. data/lib/tessella/cli/lint_options.rb +99 -0
  115. data/lib/tessella/cli/main.rb +121 -0
  116. data/lib/tessella/cli/meter.rb +80 -0
  117. data/lib/tessella/cli/rule.rb +57 -0
  118. data/lib/tessella/cli/seq.rb +117 -0
  119. data/lib/tessella/cli/sparkline.rb +72 -0
  120. data/lib/tessella/cli/table.rb +167 -0
  121. data/lib/tessella/cli/text.rb +78 -0
  122. data/lib/tessella/cli/tree.rb +100 -0
  123. data/lib/tessella/cli/wave.rb +148 -0
  124. data/lib/tessella/cli.rb +36 -0
  125. data/lib/tessella/color.rb +106 -0
  126. data/lib/tessella/compose.rb +243 -0
  127. data/lib/tessella/compose_fill.rb +176 -0
  128. data/lib/tessella/compose_grid.rb +36 -0
  129. data/lib/tessella/compose_shadow.rb +41 -0
  130. data/lib/tessella/error.rb +13 -0
  131. data/lib/tessella/frame.rb +170 -0
  132. data/lib/tessella/glyphs.rb +106 -0
  133. data/lib/tessella/image.rb +454 -0
  134. data/lib/tessella/image_octant.rb +46 -0
  135. data/lib/tessella/image_petscii.rb +116 -0
  136. data/lib/tessella/kinds/bar.rb +344 -0
  137. data/lib/tessella/kinds/box.rb +213 -0
  138. data/lib/tessella/kinds/box_upright.rb +110 -0
  139. data/lib/tessella/kinds/candlestick.rb +164 -0
  140. data/lib/tessella/kinds/columns.rb +259 -0
  141. data/lib/tessella/kinds/date_axis.rb +102 -0
  142. data/lib/tessella/kinds/error_bars.rb +71 -0
  143. data/lib/tessella/kinds/heatmap.rb +130 -0
  144. data/lib/tessella/kinds/line.rb +173 -0
  145. data/lib/tessella/kinds/multibar.rb +98 -0
  146. data/lib/tessella/kinds/multibar_columns.rb +130 -0
  147. data/lib/tessella/kinds/multibar_fine.rb +45 -0
  148. data/lib/tessella/kinds/multibar_rows.rb +93 -0
  149. data/lib/tessella/kinds/pie.rb +123 -0
  150. data/lib/tessella/kinds/pie_rim.rb +78 -0
  151. data/lib/tessella/kinds/sideways.rb +62 -0
  152. data/lib/tessella/kinds/stem.rb +134 -0
  153. data/lib/tessella/kinds/threshold.rb +73 -0
  154. data/lib/tessella/legend.rb +31 -0
  155. data/lib/tessella/lint/alphabet.rb +56 -0
  156. data/lib/tessella/lint/drift.rb +82 -0
  157. data/lib/tessella/lint/fences.rb +51 -0
  158. data/lib/tessella/lint/geometry.rb +120 -0
  159. data/lib/tessella/lint/geometry_checks.rb +224 -0
  160. data/lib/tessella/lint/lines.rb +115 -0
  161. data/lib/tessella/lint/mixing.rb +61 -0
  162. data/lib/tessella/lint/runner.rb +73 -0
  163. data/lib/tessella/lint/shadows.rb +120 -0
  164. data/lib/tessella/lint/target_rows.rb +66 -0
  165. data/lib/tessella/lint.rb +158 -0
  166. data/lib/tessella/meter.rb +133 -0
  167. data/lib/tessella/meter_upright.rb +43 -0
  168. data/lib/tessella/png.rb +456 -0
  169. data/lib/tessella/raster.rb +92 -0
  170. data/lib/tessella/rmagick_reader.rb +52 -0
  171. data/lib/tessella/rule.rb +59 -0
  172. data/lib/tessella/seq.rb +522 -0
  173. data/lib/tessella/sparkline.rb +57 -0
  174. data/lib/tessella/stats.rb +199 -0
  175. data/lib/tessella/table.rb +547 -0
  176. data/lib/tessella/table_cells.rb +225 -0
  177. data/lib/tessella/target.rb +105 -0
  178. data/lib/tessella/text.rb +76 -0
  179. data/lib/tessella/text_forms.rb +150 -0
  180. data/lib/tessella/tree.rb +221 -0
  181. data/lib/tessella/version.rb +5 -0
  182. data/lib/tessella/wave.rb +353 -0
  183. data/lib/tessella/width.rb +122 -0
  184. data/lib/tessella/width_class.rb +77 -0
  185. data/lib/tessella/width_invisible.rb +66 -0
  186. data/lib/tessella/width_invisible_high.rb +60 -0
  187. data/lib/tessella.rb +94 -0
  188. metadata +226 -0
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ module Lint
5
+ # Box geometry (geometry: true), ported from the skills shelf's
6
+ # tests/lib/check-readme.py: a box opened by a top-left corner
7
+ # keeps its sides at the same two columns down to its bottom
8
+ # edge -- a side wandering by one column is the commonest
9
+ # hand-drawn defect -- and a connector (a vertical or an arrow
10
+ # head) hangs from something above it: a junction, a vertical,
11
+ # an arrow, a label, never a plain edge or blank.
12
+ #
13
+ # check-readme.py knew the light set only. Here every
14
+ # box-drawing glyph is read by its arms (which of up, down,
15
+ # left, right it reaches), so light, heavy, double, rounded,
16
+ # dashed and mixed junctions (╤ ╫ ...) are all one case, and a
17
+ # custom border set (borders:) gives its six glyphs the arms
18
+ # of their role. ASCII boxes (+ - |) keep their own rules.
19
+ # Columns are display columns, so a wide glyph is two; a
20
+ # vertical under a horizontal that has the same vertical above
21
+ # it is a crossing (a lifeline under an arrow), not a hanger.
22
+ module Geometry
23
+ include Boxes
24
+ include Connectors
25
+
26
+ UP = 1
27
+ DOWN = 2
28
+ LEFT = 4
29
+ RIGHT = 8
30
+
31
+ # The arms of U+2500..U+257F, one hex digit each (UP 1,
32
+ # DOWN 2, LEFT 4, RIGHT 8), generated from the Unicode 15.1
33
+ # character names: HORIZONTAL is left and right, VERTICAL
34
+ # up and down, the diagonals none.
35
+ ARMS = "cc33cc33cc33aaaa666699995555bbbbbbbb77777777eeeeeeee" \
36
+ "ddddddddffffffffffffffffcc33c3aaa666999555bbb777eeed" \
37
+ "ddfffa65900041824182c3c3"
38
+ .each_char.with_index.to_h do |digit, i|
39
+ [(0x2500 + i).chr(Encoding::UTF_8), digit.to_i(16)]
40
+ end.freeze
41
+
42
+ # Arms by role in a custom border set.
43
+ ROLE_ARMS = {
44
+ tl: DOWN | RIGHT, tr: DOWN | LEFT, bl: UP | RIGHT,
45
+ br: UP | LEFT, h: LEFT | RIGHT, v: UP | DOWN
46
+ }.freeze
47
+
48
+ SIDE_A = "|+"
49
+ ABOVE_VERT_A = "|+-"
50
+ ABOVE_ARROW_A = "|+-v▼▾"
51
+ ARROWS = "▼▾"
52
+ # The second column of a wide glyph: structural to nothing.
53
+ TAIL = "\u0000"
54
+
55
+ # A row as one glyph per display column.
56
+ def self.cells(line)
57
+ line.each_char.with_object([]) do |ch, out|
58
+ case Width.char(ch.ord)
59
+ when 0 then next
60
+ when 2 then out.push(ch, TAIL)
61
+ else out << ch
62
+ end
63
+ end
64
+ end
65
+
66
+ def self.at(row, col) = (col.negative? ? nil : row[col]) || " "
67
+
68
+ # Each declared border set's glyphs by the roles they play
69
+ # (ASCII + - | stay with the ASCII rules): a glyph used for
70
+ # several roles, as * for all four corners, plays them all.
71
+ def self.roles_for(sets)
72
+ roles = Hash.new { |h, k| h[k] = [] }
73
+ sets.each do |set|
74
+ ROLE_ARMS.each_key do |role|
75
+ glyph = set.public_send(role)
76
+ roles[glyph] |= [role] unless "+-|".include?(glyph)
77
+ end
78
+ end
79
+ roles
80
+ end
81
+
82
+ # The arms table for one run: the box-drawing glyphs, and
83
+ # each custom glyph with the union of its roles' arms.
84
+ def self.arms_for(roles)
85
+ roles.each_with_object(ARMS.dup) do |(glyph, list), arms|
86
+ arms[glyph] = list.sum { ROLE_ARMS[it] } |
87
+ arms.fetch(glyph, 0)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def check_geometry(sec)
94
+ @roles ||= Geometry.roles_for(@opts[:borders])
95
+ @arms ||= Geometry.arms_for(@roles)
96
+ rows = sec.lines.map { Geometry.cells(it) }
97
+ rows.each_index { check_boxes(sec, rows, it) }
98
+ (1...rows.length).each { check_connectors(sec, rows, it) }
99
+ end
100
+
101
+ def arms(ch) = @arms.fetch(ch, 0)
102
+
103
+ def role?(ch, role) = @roles.fetch(ch, []).include?(role)
104
+
105
+ # A corner: the box-drawing glyph with exactly its arms, or
106
+ # a custom glyph declared for it.
107
+ def corner?(ch, role)
108
+ arms?(ch, ROLE_ARMS[role], exact: true) || role?(ch, role)
109
+ end
110
+
111
+ # Every arm in bits, and none outside them when exact.
112
+ def arms?(ch, bits, exact: false)
113
+ a = arms(ch)
114
+ a.allbits?(bits) && (!exact || a == bits)
115
+ end
116
+
117
+ def geo_col(col) = @overhead + col + 1
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ module Lint
5
+ # Geometry's two checks, on rows already split into display
6
+ # columns (Geometry.cells) and glyphs read by their arms
7
+ # (Geometry#arms): boxes keep their sides, connectors hang from
8
+ # something.
9
+ module Geometry
10
+ # A box opened on row i: scanned left to right, each opening
11
+ # corner followed down to its bottom edge. :uni is any
12
+ # box-drawing set, :asc a +-| box.
13
+ module Boxes
14
+ private
15
+
16
+ def check_boxes(sec, rows, i)
17
+ j = 0
18
+ j = box_at(sec, rows, [i, j]) while j < rows[i].length
19
+ end
20
+
21
+ # The column to resume the scan from.
22
+ def box_at(sec, rows, pos)
23
+ kind = box_kind(rows, *pos) or return pos[1] + 1
24
+ k = box_end(rows[pos[0]], pos[1], kind)
25
+ return pos[1] + 1 unless
26
+ top_right?(Geometry.at(rows[pos[0]], k), kind)
27
+
28
+ follow_box(sec, rows, [*pos, k], kind) if
29
+ side_below?(rows, pos, kind)
30
+ k + 1
31
+ end
32
+
33
+ # :uni for a box-drawing top-left corner, :asc for a +
34
+ # opening a -- run that is not the bottom edge of the box
35
+ # above; nil otherwise.
36
+ def box_kind(rows, i, j)
37
+ return :uni if corner?(rows[i][j], :tl)
38
+
39
+ :asc if ascii_corner?(rows[i], j) &&
40
+ !(i.positive? &&
41
+ SIDE_A.include?(Geometry.at(rows[i - 1], j)))
42
+ end
43
+
44
+ def ascii_corner?(row, j)
45
+ row[j] == "+" && Geometry.at(row, j + 1) == "-" &&
46
+ !(j.positive? && "-+".include?(Geometry.at(row, j - 1)))
47
+ end
48
+
49
+ # The column after the top edge's run of horizontals.
50
+ def box_end(row, j, kind)
51
+ k = j + 1
52
+ k += 1 while k < row.length && top_run?(row, k, kind)
53
+ k
54
+ end
55
+
56
+ def top_run?(row, k, kind)
57
+ if kind == :uni
58
+ return arms?(row[k], LEFT | RIGHT) &&
59
+ !role?(row[k], :tr)
60
+ end
61
+
62
+ row[k] == "-" ||
63
+ (row[k] == "+" && Geometry.at(row, k + 1) == "-")
64
+ end
65
+
66
+ def top_right?(ch, kind)
67
+ return ch == "+" if kind == :asc
68
+
69
+ corner?(ch, :tr)
70
+ end
71
+
72
+ # A box's side: a vertical, or a junction on one.
73
+ def side?(ch, kind)
74
+ return SIDE_A.include?(ch) if kind == :asc
75
+
76
+ vertical = UP | DOWN
77
+ arms?(ch, vertical) ||
78
+ (arms?(ch, LEFT | RIGHT) && arms(ch).anybits?(vertical))
79
+ end
80
+
81
+ def side_below?(rows, pos, kind)
82
+ below = rows[pos[0] + 1] or return false
83
+ side?(Geometry.at(below, pos[1]), kind)
84
+ end
85
+
86
+ # Down from the opening row to the first row that settles
87
+ # the box: its bottom edge, or a side off its column.
88
+ def follow_box(sec, rows, box, kind)
89
+ last = ((box[0] + 1)...rows.length).find do |i2|
90
+ box_settled?(sec, rows[i2], [*box, i2], kind)
91
+ end
92
+ return if last
93
+
94
+ report(sec, box[0], "box opened at col " \
95
+ "#{geo_col(box[1])} has no bottom " \
96
+ "edge")
97
+ end
98
+
99
+ def box_settled?(sec, row, pos, kind)
100
+ if bottom_left?(row, pos[1], kind)
101
+ check_bottom(sec, row, pos, kind)
102
+ return true
103
+ end
104
+ bad = off_side(row, pos, kind) or return false
105
+
106
+ report(sec, pos[3], "side of #{opened(sec, pos)} is off " \
107
+ "at col #{geo_col(bad)}")
108
+ true
109
+ end
110
+
111
+ # The column of a side not where the box opened it, or
112
+ # nil when both sides are in place.
113
+ def off_side(row, pos, kind)
114
+ _, j, k, = pos
115
+ return j unless side?(Geometry.at(row, j), kind)
116
+
117
+ k unless side?(Geometry.at(row, k), kind)
118
+ end
119
+
120
+ def bottom_left?(row, j, kind)
121
+ left = Geometry.at(row, j)
122
+ return corner?(left, :bl) if kind == :uni
123
+
124
+ left == "+" && Geometry.at(row, j + 1) == "-"
125
+ end
126
+
127
+ def check_bottom(sec, row, pos, kind)
128
+ right = Geometry.at(row, pos[2])
129
+ return if closes?(right, kind)
130
+
131
+ report(sec, pos[3], "#{opened(sec, pos)} does not close " \
132
+ "at col #{geo_col(pos[2])} (found " \
133
+ "'#{right}')")
134
+ end
135
+
136
+ # A bottom-right corner, or a junction rising from it.
137
+ def closes?(ch, kind)
138
+ return ch == "+" if kind == :asc
139
+
140
+ (arms?(ch, UP | LEFT) && !arms(ch).anybits?(DOWN)) ||
141
+ role?(ch, :br)
142
+ end
143
+
144
+ def opened(sec, pos)
145
+ "box opened at line #{sec.first + pos[0]} col " \
146
+ "#{geo_col(pos[1])}"
147
+ end
148
+ end
149
+
150
+ # A vertical or an arrow head hangs from something above:
151
+ # a glyph reaching down, or (ASCII) a | + or - edge.
152
+ module Connectors
153
+ private
154
+
155
+ def check_connectors(sec, rows, i)
156
+ # Under a text row a stem may hang anywhere.
157
+ return unless rows[i - 1].any? do |ch|
158
+ arms(ch).positive? || SIDE_A.include?(ch)
159
+ end
160
+
161
+ rows[i].each_with_index do |ch, c|
162
+ check_connector(sec, rows, i, [ch, c])
163
+ end
164
+ end
165
+
166
+ def check_connector(sec, rows, i, glyph)
167
+ ch, c = glyph
168
+ ascii = connector_kind(rows[i], ch, c) or return
169
+ above = Geometry.at(rows[i - 1], c)
170
+ return if hangs?(above, ascii) ||
171
+ crossing?(rows, i, c, above)
172
+
173
+ report(sec, i, "connector '#{ch}' at col #{geo_col(c)} " \
174
+ "hangs from '#{above}'")
175
+ end
176
+
177
+ # The ASCII glyphs that may carry this connector (an
178
+ # arrow head's, or a vertical's), or nil when the glyph
179
+ # is no connector: a vertical with a horizontal beside it
180
+ # is a junction drawn in two cells.
181
+ def connector_kind(row, ch, c)
182
+ return ABOVE_ARROW_A if arrow_head?(row, ch, c)
183
+ return SIDE_A if arms?(ch, UP | DOWN, exact: true) &&
184
+ !beside_horizontal?(row, c)
185
+
186
+ ABOVE_VERT_A if ch == "|" && !beside?(row, c, "-")
187
+ end
188
+
189
+ def hangs?(above, ascii)
190
+ arms(above).anybits?(DOWN) || ascii.include?(above)
191
+ end
192
+
193
+ def arrow_head?(row, ch, c)
194
+ ARROWS.include?(ch) || (ch == "v" && lone?(row, c))
195
+ end
196
+
197
+ def lone?(row, c)
198
+ Geometry.at(row, c - 1) == " " &&
199
+ Geometry.at(row, c + 1) == " "
200
+ end
201
+
202
+ def beside?(row, c, glyph)
203
+ Geometry.at(row, c - 1) == glyph ||
204
+ Geometry.at(row, c + 1) == glyph
205
+ end
206
+
207
+ def beside_horizontal?(row, c)
208
+ [c - 1, c + 1].any? do |x|
209
+ arms?(Geometry.at(row, x), LEFT | RIGHT, exact: true)
210
+ end
211
+ end
212
+
213
+ # A lifeline crossed by an arrow: a plain horizontal
214
+ # above, the same vertical on the row above that.
215
+ def crossing?(rows, i, c, above)
216
+ return false unless i >= 2 &&
217
+ arms?(above, LEFT | RIGHT, exact: true)
218
+
219
+ hangs?(Geometry.at(rows[i - 2], c), SIDE_A)
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ module Lint
5
+ # The checks one line at a time: invalid UTF-8, tabs, forbidden
6
+ # sequences, trailing blanks, wide / invisible / emoji-capable /
7
+ # non-ASCII glyphs, spaces other than U+0020, wide glyphs on an
8
+ # aligned row (web) and ambiguous ones (cjk), and the column
9
+ # budget (prefix and suffix charged), which also records the
10
+ # line's ruler row.
11
+ module Lines
12
+ private
13
+
14
+ # A line that is not valid UTF-8 is named once, then linted
15
+ # with each bad byte as "?".
16
+ def sanitized(sec)
17
+ lines = sec.lines.each_with_index.map do |line, i|
18
+ next line if line.valid_encoding?
19
+
20
+ report(sec, i, "invalid UTF-8 byte sequence")
21
+ line.scrub("?")
22
+ end
23
+ sec.with(lines: lines)
24
+ end
25
+
26
+ def check_line(sec, i, line)
27
+ report(sec, i, "tab character") if line.include?("\t")
28
+ check_forbidden(sec, i, line)
29
+ report(sec, i, "trailing whitespace") if
30
+ line.match?(/[ \t]+\z/)
31
+ check_chars(sec, i, line)
32
+ check_target_row(sec, i, line)
33
+ check_budget(sec, i, line)
34
+ end
35
+
36
+ def check_forbidden(sec, i, line)
37
+ @opts[:forbid].each do |seq|
38
+ if seq == :trailing_backslash
39
+ if line.end_with?("\\")
40
+ report(sec, i, "row ends in backslash (line " \
41
+ "splicing)")
42
+ end
43
+ elsif line.include?(seq)
44
+ report(sec, i, "forbidden sequence #{seq.inspect}")
45
+ end
46
+ end
47
+ end
48
+
49
+ def check_chars(sec, i, line)
50
+ skip = text_styled(line)
51
+ line.each_char.with_index do |ch, k|
52
+ check_char(sec, i, ch) unless skip.include?(k)
53
+ end
54
+ end
55
+
56
+ # Under :web, an emoji-capable glyph followed by U+FE0E is
57
+ # kept as text: the indices of both, neither a finding.
58
+ def text_styled(line)
59
+ return [] unless @opts[:web]
60
+
61
+ line.enum_for(:scan, /(.)\u{FE0E}/).flat_map do
62
+ match = Regexp.last_match
63
+ k = match.begin(0)
64
+ Lint.emoji_risk?(match[1].ord) ? [k, k + 1] : []
65
+ end
66
+ end
67
+
68
+ def check_char(sec, i, ch)
69
+ wide = Lint.wide?(ch.ord)
70
+ report(sec, i, glyph("wide char", ch)) if wide && no_wide?
71
+ check_blank(sec, i, ch)
72
+ check_web_char(sec, i, ch) if @opts[:web]
73
+ check_ascii_char(sec, i, ch) if @opts[:ascii] && !wide
74
+ end
75
+
76
+ # Names a glyph drawn as nothing, or as a blank that is not
77
+ # U+0020.
78
+ def check_blank(sec, i, ch)
79
+ if Lint.invisible?(ch.ord)
80
+ report(sec, i, code_point("invisible char", ch))
81
+ elsif Lint.look_alike_space?(ch)
82
+ report(sec, i, code_point("look-alike space", ch))
83
+ end
84
+ end
85
+
86
+ # A look-alike space is named by check_blank already.
87
+ def check_ascii_char(sec, i, ch)
88
+ return if ch.ord <= 0x7F || Lint.look_alike_space?(ch)
89
+
90
+ report(sec, i, glyph("non-ASCII", ch))
91
+ end
92
+
93
+ def check_budget(sec, i, line)
94
+ cols = end_column(line) + @tail
95
+ @rulers << Ruler.new(line: sec.first + i, width: cols,
96
+ verticals: vertical_columns(line))
97
+ return if cols <= @opts[:width]
98
+
99
+ report(sec, i, "#{cols} columns rendered (budget " \
100
+ "#{@opts[:width]})")
101
+ end
102
+
103
+ def code_point(what, ch)
104
+ format("%<what>s U+%<code>04X", what: what, code: ch.ord)
105
+ end
106
+
107
+ def no_wide? = @opts[:no_wide] || @opts[:ascii]
108
+
109
+ def glyph(what, ch)
110
+ format("%<what>s U+%<code>04X %<ch>s", what: what,
111
+ code: ch.ord, ch: ch)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ module Lint
5
+ # ASCII box structure and Unicode box drawing in one figure: a
6
+ # figure is a run of non-blank lines holding a box-drawing
7
+ # glyph (so a Markdown table beside it stays out), inline code
8
+ # spans stripped first (prose naming a glyph is not art). One
9
+ # finding per figure, at its first ASCII-structured line. An
10
+ # allow-mixed marker line closes the group before it and
11
+ # exempts the next group to form, however far below.
12
+ module Mixing
13
+ # A line using ASCII as structure: a pair from
14
+ # ASCII_STRUCT_PAIR, or a | at its first or last non-blank.
15
+ def self.ascii_structural?(line)
16
+ return true if line.match?(ASCII_STRUCT_PAIR)
17
+
18
+ trimmed = line.strip
19
+ !trimmed.empty? &&
20
+ (trimmed.start_with?("|") || trimmed.end_with?("|"))
21
+ end
22
+
23
+ private
24
+
25
+ def check_vocab_mixing(sec)
26
+ group = []
27
+ exempt = sec.exempt
28
+ sec.lines.each_with_index do |line, i|
29
+ next group << [i, line.gsub(INLINE_CODE, "")] unless
30
+ line.match?(MIXED_MARKER) || line.strip.empty?
31
+
32
+ exempt = flush_vocab_group(sec, group, exempt) ||
33
+ line.match?(MIXED_MARKER)
34
+ group = []
35
+ end
36
+ flush_vocab_group(sec, group, exempt)
37
+ end
38
+
39
+ # Checks the group unless exempt; an empty group (blank
40
+ # after blank, marker after marker) keeps the exemption.
41
+ def flush_vocab_group(sec, group, exempt)
42
+ return exempt if group.empty?
43
+
44
+ flag_mixed_vocab(sec, group) unless exempt
45
+ false
46
+ end
47
+
48
+ def flag_mixed_vocab(sec, group)
49
+ return unless group.any? { |_, line| line.match?(BOX_CHAR) }
50
+
51
+ i, = group.find { |_, line| Mixing.ascii_structural?(line) }
52
+ return unless i
53
+
54
+ report(sec, i, "ASCII structure (a +-/-+/--/|-/-|/+|/|+/->/" \
55
+ "<- pair, or | at the line edge) mixed " \
56
+ "with Unicode box-drawing (U+2500-257F) " \
57
+ "in the same figure")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ module Lint
5
+ # One lint run over one text: splits it into sections (the
6
+ # whole text, or each fenced block), runs every check on each
7
+ # in artlint.rb's order -- line by line, then shadows, mixing,
8
+ # anchor drift, and geometry when asked -- and collects the
9
+ # findings and ruler rows.
10
+ class Runner
11
+ include Lines
12
+ include TargetRows
13
+ include Shadows
14
+ include Mixing
15
+ include Drift
16
+ include Geometry
17
+
18
+ def initialize(opts)
19
+ @opts = opts
20
+ @overhead = Lint.width_of(opts[:prefix])
21
+ @tail = Lint.width_of(opts[:suffix])
22
+ @findings = []
23
+ @rulers = []
24
+ end
25
+
26
+ def run(text)
27
+ lines = text.each_line.map(&:chomp)
28
+ sections(lines).each { check(it) }
29
+ Report.new(findings: @findings, rulers: @rulers)
30
+ end
31
+
32
+ private
33
+
34
+ # Fences are found on a scrubbed copy (a pattern cannot read
35
+ # an invalid byte), each section keeping its raw lines, so
36
+ # the bad byte is a finding there, not an abort.
37
+ def sections(lines)
38
+ whole = Section.new(lines: lines, first: 1, exempt: false)
39
+ return [whole] unless @opts[:fences]
40
+
41
+ scrubbed = lines.map { it.valid_encoding? ? it : it.scrub("?") }
42
+ Fences.sections(scrubbed).map do |sec|
43
+ sec.with(lines: lines[sec.first - 1, sec.lines.size])
44
+ end
45
+ end
46
+
47
+ def check(sec)
48
+ sec = sanitized(sec)
49
+ sec.lines.each_with_index { |line, i| check_line(sec, i, line) }
50
+ check_shadows(sec)
51
+ check_vocab_mixing(sec) unless @opts[:allow_mixed]
52
+ check_anchor_drift(sec)
53
+ check_geometry(sec) if @opts[:geometry]
54
+ end
55
+
56
+ def report(sec, index, message)
57
+ @findings << Finding.new(line: sec.first + index,
58
+ message: message)
59
+ end
60
+
61
+ # A row's last display column: the prefix, then the row.
62
+ def end_column(line) = @overhead + Lint.width_of(line)
63
+
64
+ def vertical_columns(line)
65
+ col = @overhead
66
+ line.each_char.filter_map do |ch|
67
+ col += Width.char(ch.ord)
68
+ col if VERTICALS.include?(ch)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end