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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +900 -0
- data/doc/README.md +74 -0
- data/doc/bitfield.md +123 -0
- data/doc/charts.md +1370 -0
- data/doc/check.rb +75 -0
- data/doc/cli.md +483 -0
- data/doc/composition.md +599 -0
- data/doc/getting-started.md +121 -0
- data/doc/glyphs.md +134 -0
- data/doc/images.md +325 -0
- data/doc/library.md +239 -0
- data/doc/lint.md +270 -0
- data/doc/meter.md +210 -0
- data/doc/rule.md +51 -0
- data/doc/seq.md +169 -0
- data/doc/sparkline.md +63 -0
- data/doc/table.md +347 -0
- data/doc/targets.md +222 -0
- data/doc/text.md +84 -0
- data/doc/tree.md +108 -0
- data/doc/wave.md +122 -0
- data/examples/README.md +103 -0
- data/examples/ages.txt +9 -0
- data/examples/bad.csv +3 -0
- data/examples/boxes.txt +3 -0
- data/examples/broken.txt +4 -0
- data/examples/calibration.rb +12 -0
- data/examples/cities.csv +4 -0
- data/examples/cjk.rb +13 -0
- data/examples/comment.rb +9 -0
- data/examples/comment.sh +13 -0
- data/examples/cpu.txt +60 -0
- data/examples/dashboard.sh +25 -0
- data/examples/datasheet.rb +16 -0
- data/examples/disc.pgm +0 -0
- data/examples/disks.sh +18 -0
- data/examples/disks.txt +5 -0
- data/examples/dose.txt +7 -0
- data/examples/drift.txt +4 -0
- data/examples/ethernet.txt +3 -0
- data/examples/figure.c +5 -0
- data/examples/fit.rb +16 -0
- data/examples/floors.rb +21 -0
- data/examples/gallery.sh +20 -0
- data/examples/gauges.sh +31 -0
- data/examples/gradient.rb +16 -0
- data/examples/growth.txt +6 -0
- data/examples/hosts.csv +4 -0
- data/examples/icmp.txt +3 -0
- data/examples/ipv4.txt +12 -0
- data/examples/kinds.csv +4 -0
- data/examples/langs.csv +4 -0
- data/examples/lint.rb +16 -0
- data/examples/load.rb +14 -0
- data/examples/login.txt +10 -0
- data/examples/neg.csv +4 -0
- data/examples/normal.txt +200 -0
- data/examples/note.txt +2 -0
- data/examples/notes-fixed.md +17 -0
- data/examples/notes.md +17 -0
- data/examples/ohlc.txt +7 -0
- data/examples/os.csv +10 -0
- data/examples/outline.txt +8 -0
- data/examples/plot.txt +5 -0
- data/examples/refusals.rb +17 -0
- data/examples/report.sh +21 -0
- data/examples/rfc.sh +22 -0
- data/examples/samples.txt +7 -0
- data/examples/shapes.pgm +0 -0
- data/examples/sizes.tsv +3 -0
- data/examples/spi.txt +6 -0
- data/examples/status.sh +23 -0
- data/examples/stem.txt +12 -0
- data/examples/table-drift.txt +4 -0
- data/examples/temps.txt +31 -0
- data/examples/trig.sh +13 -0
- data/examples/trig.txt +102 -0
- data/examples/week.txt +8 -0
- data/examples/wide.txt +2 -0
- data/exe/tessella +6 -0
- data/lib/tessella/axis.rb +71 -0
- data/lib/tessella/bitfield.rb +194 -0
- data/lib/tessella/block.rb +56 -0
- data/lib/tessella/borders.rb +85 -0
- data/lib/tessella/canvas/area.rb +155 -0
- data/lib/tessella/canvas/ascii.rb +120 -0
- data/lib/tessella/canvas/block.rb +40 -0
- data/lib/tessella/canvas/braille.rb +30 -0
- data/lib/tessella/canvas/clip.rb +49 -0
- data/lib/tessella/canvas/density.rb +51 -0
- data/lib/tessella/canvas/dot.rb +27 -0
- data/lib/tessella/canvas/eighths.rb +43 -0
- data/lib/tessella/canvas/half.rb +51 -0
- data/lib/tessella/canvas/sextant.rb +36 -0
- data/lib/tessella/canvas.rb +168 -0
- data/lib/tessella/cell.rb +10 -0
- data/lib/tessella/chart.rb +211 -0
- data/lib/tessella/check.rb +52 -0
- data/lib/tessella/cli/bitfield.rb +121 -0
- data/lib/tessella/cli/chart.rb +615 -0
- data/lib/tessella/cli/chart_candle.rb +36 -0
- data/lib/tessella/cli/chart_heatmap.rb +21 -0
- data/lib/tessella/cli/chart_help.rb +85 -0
- data/lib/tessella/cli/chart_pie.rb +38 -0
- data/lib/tessella/cli/compose.rb +156 -0
- data/lib/tessella/cli/compose_options.rb +259 -0
- data/lib/tessella/cli/glyphs.rb +97 -0
- data/lib/tessella/cli/image.rb +250 -0
- data/lib/tessella/cli/input.rb +44 -0
- data/lib/tessella/cli/iso_time.rb +28 -0
- data/lib/tessella/cli/lint.rb +102 -0
- data/lib/tessella/cli/lint_options.rb +99 -0
- data/lib/tessella/cli/main.rb +121 -0
- data/lib/tessella/cli/meter.rb +80 -0
- data/lib/tessella/cli/rule.rb +57 -0
- data/lib/tessella/cli/seq.rb +117 -0
- data/lib/tessella/cli/sparkline.rb +72 -0
- data/lib/tessella/cli/table.rb +167 -0
- data/lib/tessella/cli/text.rb +78 -0
- data/lib/tessella/cli/tree.rb +100 -0
- data/lib/tessella/cli/wave.rb +148 -0
- data/lib/tessella/cli.rb +36 -0
- data/lib/tessella/color.rb +106 -0
- data/lib/tessella/compose.rb +243 -0
- data/lib/tessella/compose_fill.rb +176 -0
- data/lib/tessella/compose_grid.rb +36 -0
- data/lib/tessella/compose_shadow.rb +41 -0
- data/lib/tessella/error.rb +13 -0
- data/lib/tessella/frame.rb +170 -0
- data/lib/tessella/glyphs.rb +106 -0
- data/lib/tessella/image.rb +454 -0
- data/lib/tessella/image_octant.rb +46 -0
- data/lib/tessella/image_petscii.rb +116 -0
- data/lib/tessella/kinds/bar.rb +344 -0
- data/lib/tessella/kinds/box.rb +213 -0
- data/lib/tessella/kinds/box_upright.rb +110 -0
- data/lib/tessella/kinds/candlestick.rb +164 -0
- data/lib/tessella/kinds/columns.rb +259 -0
- data/lib/tessella/kinds/date_axis.rb +102 -0
- data/lib/tessella/kinds/error_bars.rb +71 -0
- data/lib/tessella/kinds/heatmap.rb +130 -0
- data/lib/tessella/kinds/line.rb +173 -0
- data/lib/tessella/kinds/multibar.rb +98 -0
- data/lib/tessella/kinds/multibar_columns.rb +130 -0
- data/lib/tessella/kinds/multibar_fine.rb +45 -0
- data/lib/tessella/kinds/multibar_rows.rb +93 -0
- data/lib/tessella/kinds/pie.rb +123 -0
- data/lib/tessella/kinds/pie_rim.rb +78 -0
- data/lib/tessella/kinds/sideways.rb +62 -0
- data/lib/tessella/kinds/stem.rb +134 -0
- data/lib/tessella/kinds/threshold.rb +73 -0
- data/lib/tessella/legend.rb +31 -0
- data/lib/tessella/lint/alphabet.rb +56 -0
- data/lib/tessella/lint/drift.rb +82 -0
- data/lib/tessella/lint/fences.rb +51 -0
- data/lib/tessella/lint/geometry.rb +120 -0
- data/lib/tessella/lint/geometry_checks.rb +224 -0
- data/lib/tessella/lint/lines.rb +115 -0
- data/lib/tessella/lint/mixing.rb +61 -0
- data/lib/tessella/lint/runner.rb +73 -0
- data/lib/tessella/lint/shadows.rb +120 -0
- data/lib/tessella/lint/target_rows.rb +66 -0
- data/lib/tessella/lint.rb +158 -0
- data/lib/tessella/meter.rb +133 -0
- data/lib/tessella/meter_upright.rb +43 -0
- data/lib/tessella/png.rb +456 -0
- data/lib/tessella/raster.rb +92 -0
- data/lib/tessella/rmagick_reader.rb +52 -0
- data/lib/tessella/rule.rb +59 -0
- data/lib/tessella/seq.rb +522 -0
- data/lib/tessella/sparkline.rb +57 -0
- data/lib/tessella/stats.rb +199 -0
- data/lib/tessella/table.rb +547 -0
- data/lib/tessella/table_cells.rb +225 -0
- data/lib/tessella/target.rb +105 -0
- data/lib/tessella/text.rb +76 -0
- data/lib/tessella/text_forms.rb +150 -0
- data/lib/tessella/tree.rb +221 -0
- data/lib/tessella/version.rb +5 -0
- data/lib/tessella/wave.rb +353 -0
- data/lib/tessella/width.rb +122 -0
- data/lib/tessella/width_class.rb +77 -0
- data/lib/tessella/width_invisible.rb +66 -0
- data/lib/tessella/width_invisible_high.rb +60 -0
- data/lib/tessella.rb +94 -0
- 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
|