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,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# `tessella image` (DESIGN.md): draws a picture as text -- tones,
|
|
5
|
+
# blocks, half/quadrant/sextant/braille pixels, or edges -- from a
|
|
6
|
+
# file or stdin, ported from the ascii-art skill's
|
|
7
|
+
# scripts/img2art.rb. Its --pad is dropped: composition pads a
|
|
8
|
+
# block itself. Exit 0 drawn; 1 bad input (an unreadable file, a
|
|
9
|
+
# malformed or unsupported image, output past --max-width); 2
|
|
10
|
+
# usage (a bad flag, an unknown --style/--dither, or any other bad
|
|
11
|
+
# option value -- an ArgumentError Tessella.image raises once the
|
|
12
|
+
# image itself has decoded cleanly).
|
|
13
|
+
class ImageCLI
|
|
14
|
+
class UsageError < StandardError; end
|
|
15
|
+
class DataError < StandardError; end
|
|
16
|
+
|
|
17
|
+
# Raised by --help to short-circuit parsing with the text and
|
|
18
|
+
# status already decided.
|
|
19
|
+
class Halt < StandardError
|
|
20
|
+
attr_reader :status, :text
|
|
21
|
+
|
|
22
|
+
def initialize(status, text)
|
|
23
|
+
@status = status
|
|
24
|
+
@text = text
|
|
25
|
+
super(text)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
BANNER = "Usage: tessella image [options] [file] " \
|
|
30
|
+
"(stdin if no file)\n"
|
|
31
|
+
|
|
32
|
+
def self.run(argv, stdin:, stdout:, stderr:)
|
|
33
|
+
draw(argv, stdin, stdout)
|
|
34
|
+
rescue Halt => e
|
|
35
|
+
CLI.halt(e, stdout, stderr)
|
|
36
|
+
rescue OptionParser::ParseError, UsageError => e
|
|
37
|
+
fail_with(stderr, e.message, 2)
|
|
38
|
+
rescue DataError => e
|
|
39
|
+
fail_with(stderr, e.message, 1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.draw(argv, stdin, stdout)
|
|
43
|
+
opts, path = Options.parse(argv.dup)
|
|
44
|
+
raster = decode(Reader.bytes(path, stdin))
|
|
45
|
+
block = render(raster, opts)
|
|
46
|
+
Overflow.check!(block, opts[:max_width]) if opts[:max_width]
|
|
47
|
+
stdout.print(block.to_s)
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A bad file is bad input, before any option is judged: the
|
|
52
|
+
# image itself never decoded, so there is nothing to draw.
|
|
53
|
+
def self.decode(bytes)
|
|
54
|
+
Raster.decode(bytes)
|
|
55
|
+
rescue ArgumentError => e
|
|
56
|
+
raise DataError, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Past this point the source is a Raster, so Tessella.image
|
|
60
|
+
# can only still refuse an option's own value -- usage, not
|
|
61
|
+
# bad input.
|
|
62
|
+
def self.render(raster, opts)
|
|
63
|
+
Tessella.image(raster, **opts.except(:max_width))
|
|
64
|
+
rescue Image::Blank => e
|
|
65
|
+
raise DataError, e.message
|
|
66
|
+
rescue ArgumentError => e
|
|
67
|
+
raise UsageError, e.message
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.fail_with(stderr, message, status)
|
|
71
|
+
stderr.puts("tessella image: #{message}")
|
|
72
|
+
status
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# `tessella image`'s numeric options, kept apart so Options stays
|
|
77
|
+
# under budget.
|
|
78
|
+
class ImageCLI
|
|
79
|
+
module NumericOptions
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def add_width
|
|
83
|
+
@parser.on("--width N", Integer, "columns (default 78)") do |n|
|
|
84
|
+
@opts[:width] = n
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add_gamma
|
|
89
|
+
@parser.on("--gamma F", Float,
|
|
90
|
+
"tone curve (default 1.0)") do |f|
|
|
91
|
+
@opts[:gamma] = f
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def add_threshold
|
|
96
|
+
@parser.on("--threshold F", Float,
|
|
97
|
+
"on/off cut, 0..1 (default 0.5)") do |f|
|
|
98
|
+
@opts[:threshold] = f
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def add_edge_threshold
|
|
103
|
+
@parser.on("--edge-threshold F", Float,
|
|
104
|
+
"edge strength to draw (default 0.3)") do |f|
|
|
105
|
+
@opts[:edge_threshold] = f
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Parses argv into Tessella.image's options plus :max_width and
|
|
112
|
+
# the one file argument, or nil for stdin. Raises Halt for
|
|
113
|
+
# --help, or lets OptionParser::ParseError escape for a bad flag,
|
|
114
|
+
# missing argument, or disallowed --style/--dither value.
|
|
115
|
+
class ImageCLI
|
|
116
|
+
class Options
|
|
117
|
+
include NumericOptions
|
|
118
|
+
|
|
119
|
+
STYLES = %w[ramp blocks half quadrant sextant braille octant petscii
|
|
120
|
+
edges]
|
|
121
|
+
.freeze
|
|
122
|
+
DITHERS = %w[none fs atkinson bayer].freeze
|
|
123
|
+
|
|
124
|
+
def self.parse(argv)
|
|
125
|
+
new.parse(argv)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def initialize
|
|
129
|
+
@opts = { style: :ramp, width: 78, ramp: nil, gamma: 1.0,
|
|
130
|
+
threshold: 0.5, dither: :fs, invert: false,
|
|
131
|
+
edge_threshold: 0.3,
|
|
132
|
+
ascii: Target.ascii? }
|
|
133
|
+
@parser = OptionParser.new
|
|
134
|
+
@parser.banner = ImageCLI::BANNER
|
|
135
|
+
build
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse(argv)
|
|
139
|
+
@parser.parse!(argv)
|
|
140
|
+
raise ImageCLI::UsageError, "too many arguments" if
|
|
141
|
+
argv.size > 1
|
|
142
|
+
|
|
143
|
+
[@opts, argv.first]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def build
|
|
149
|
+
add_style
|
|
150
|
+
add_dither
|
|
151
|
+
add_numeric_opts
|
|
152
|
+
add_ramp
|
|
153
|
+
add_flags
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def add_numeric_opts
|
|
157
|
+
add_width
|
|
158
|
+
add_gamma
|
|
159
|
+
add_threshold
|
|
160
|
+
add_edge_threshold
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def add_flags
|
|
164
|
+
add_invert
|
|
165
|
+
add_ascii
|
|
166
|
+
add_max_width
|
|
167
|
+
add_help
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def add_style
|
|
171
|
+
@parser.on("--style S", STYLES,
|
|
172
|
+
"ramp, blocks, half, quadrant,",
|
|
173
|
+
"sextant, braille, octant (Unicode 16),",
|
|
174
|
+
"petscii (best-fit shapes) or edges") do |s|
|
|
175
|
+
@opts[:style] = s.to_sym
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def add_dither
|
|
180
|
+
@parser.on("--dither M", DITHERS, "none, fs (Floyd-Steinberg),",
|
|
181
|
+
"atkinson or bayer") do |m|
|
|
182
|
+
@opts[:dither] = m.to_sym
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def add_ramp
|
|
187
|
+
@parser.on("--ramp STR", "tone glyphs, light to dense, or a",
|
|
188
|
+
"name: ramp, blocks, long, hatch") do |r|
|
|
189
|
+
@opts[:ramp] = r
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def add_invert
|
|
194
|
+
@parser.on("--invert", "for a light background") do
|
|
195
|
+
@opts[:invert] = true
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def add_ascii
|
|
200
|
+
@parser.on("--[no-]ascii",
|
|
201
|
+
"ASCII only (ramp style, ASCII ramp)") do |v|
|
|
202
|
+
@opts[:ascii] = v
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def add_max_width
|
|
207
|
+
@parser.on("--max-width N", Integer,
|
|
208
|
+
"refuse lines wider than N") do |n|
|
|
209
|
+
@opts[:max_width] = n
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def add_help
|
|
214
|
+
@parser.on("--help", "print this help") do
|
|
215
|
+
raise ImageCLI::Halt.new(0, @parser.to_s)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The file argument, or stdin when there is none, read as binary
|
|
222
|
+
# bytes. Errors opening it (a missing file, a directory) are bad
|
|
223
|
+
# input, named by the system error's message.
|
|
224
|
+
class ImageCLI
|
|
225
|
+
module Reader
|
|
226
|
+
def self.bytes(path, stdin)
|
|
227
|
+
path ? File.binread(path) : stdin.binmode.read
|
|
228
|
+
rescue SystemCallError => e
|
|
229
|
+
raise ImageCLI::DataError, CLI.cannot_read(path, e)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# --max-width, after the image is drawn: the first line over it
|
|
235
|
+
# refuses the whole figure, naming the line and its width.
|
|
236
|
+
class ImageCLI
|
|
237
|
+
module Overflow
|
|
238
|
+
def self.check!(block, max)
|
|
239
|
+
block.lines.each_with_index do |line, i|
|
|
240
|
+
w = Width.of(line.rstrip)
|
|
241
|
+
next if w <= max
|
|
242
|
+
|
|
243
|
+
raise ImageCLI::DataError,
|
|
244
|
+
"line #{i + 1}: #{w} columns exceeds " \
|
|
245
|
+
"--max-width #{max}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# Reading a command's input (DESIGN.md, "Command line"): a file or
|
|
5
|
+
# stdin, as UTF-8, an unreadable file or a bad byte named as bad
|
|
6
|
+
# input. Kept apart so CLI's own class body stays under budget.
|
|
7
|
+
class CLI
|
|
8
|
+
# The 1-based number of the first line of text that is not
|
|
9
|
+
# valid UTF-8, or nil: the readers name it as bad input.
|
|
10
|
+
def self.invalid_line(text)
|
|
11
|
+
return nil if text.valid_encoding?
|
|
12
|
+
|
|
13
|
+
text.each_line.find_index { !it.valid_encoding? } + 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# text, or error (a command's DataError) naming the first line
|
|
17
|
+
# that is not valid UTF-8: bad input, not a crash.
|
|
18
|
+
def self.utf8!(text, error, where = "line ")
|
|
19
|
+
text = text.dup.force_encoding(Encoding::UTF_8) unless
|
|
20
|
+
text.encoding == Encoding::UTF_8
|
|
21
|
+
bad = invalid_line(text)
|
|
22
|
+
raise error, "#{where}#{bad}: invalid UTF-8" if bad
|
|
23
|
+
|
|
24
|
+
text
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A command's input: the file at path, or stdin when path is
|
|
28
|
+
# nil, as UTF-8 text; an unreadable file or invalid UTF-8 is
|
|
29
|
+
# error (the command's DataError), the bad line named after
|
|
30
|
+
# where.
|
|
31
|
+
def self.read_text(path, stdin, error, where = "line ")
|
|
32
|
+
utf8!(path ? File.read(path) : stdin.read, error, where)
|
|
33
|
+
rescue SystemCallError => e
|
|
34
|
+
raise error, cannot_read(path, e)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# "cannot read PATH: reason" for a file a command cannot open,
|
|
38
|
+
# the reason in the system's own words (ENOENT's "No such file
|
|
39
|
+
# or directory"), not Ruby's "... @ rb_sysopen - PATH".
|
|
40
|
+
def self.cannot_read(path, error)
|
|
41
|
+
"cannot read #{path || "stdin"}: #{error.class.new(nil).message}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# ISO 8601 dates and times for `chart --xdate` (DESIGN.md, "Command
|
|
5
|
+
# line"): 2026-11-05, 2026-11-05T14:30, 2026-11-05 14:30:15.5, with
|
|
6
|
+
# Z or +hh:mm (UTC when absent). Plain Ruby, so the command line
|
|
7
|
+
# needs no require "time".
|
|
8
|
+
module IsoTime
|
|
9
|
+
FORM = /\A(\d{4})-(\d\d)-(\d\d)
|
|
10
|
+
(?:[T\ ](\d\d):(\d\d)(?::(\d\d(?:\.\d+)?))?)?
|
|
11
|
+
\s*(Z|[+-]\d\d:?\d\d)?\z/x
|
|
12
|
+
|
|
13
|
+
# The Time, or nil when text is not one of the forms above.
|
|
14
|
+
def self.parse(text)
|
|
15
|
+
m = FORM.match(text.strip) or return nil
|
|
16
|
+
date = m[1..5].map(&:to_i) + [m[6].to_f]
|
|
17
|
+
Time.new(*date, offset(m[7]))
|
|
18
|
+
rescue ArgumentError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.offset(zone)
|
|
23
|
+
return "+00:00" if zone.nil? || zone == "Z"
|
|
24
|
+
|
|
25
|
+
zone.include?(":") ? zone : "#{zone[0, 3]}:#{zone[3, 2]}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# `tessella lint` (DESIGN.md, "Lint"): checks text figures in files
|
|
5
|
+
# or stdin, as the ascii-art skill's scripts/artlint.rb did -- its
|
|
6
|
+
# options and output contract kept, --paranoid dropped (--ascii is
|
|
7
|
+
# the flag). Findings on stderr as NAME:LINE: message, --ruler rows
|
|
8
|
+
# on stdout; --alphabet prints NAME: ascii|utf-8 ... on stdout and
|
|
9
|
+
# lints nothing. Exit 0 clean (or --alphabet); 1 a finding, or a
|
|
10
|
+
# file that cannot be read; 2 usage.
|
|
11
|
+
class LintCLI
|
|
12
|
+
class UsageError < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Raised by --help to short-circuit parsing with the text and
|
|
15
|
+
# status already decided.
|
|
16
|
+
class Halt < StandardError
|
|
17
|
+
attr_reader :status, :text
|
|
18
|
+
|
|
19
|
+
def initialize(status, text)
|
|
20
|
+
@status = status
|
|
21
|
+
@text = text
|
|
22
|
+
super(text)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
BANNER = "Usage: tessella lint [options] [file ...] " \
|
|
27
|
+
"(stdin if none)\n"
|
|
28
|
+
|
|
29
|
+
def self.run(argv, stdin:, stdout:, stderr:)
|
|
30
|
+
opts, mode, paths = Options.parse(argv.dup)
|
|
31
|
+
new(opts, stdout, stderr).run(mode, Inputs.list(paths, stdin))
|
|
32
|
+
rescue Halt => e
|
|
33
|
+
(e.status.zero? ? stdout : stderr).print(e.text)
|
|
34
|
+
e.status
|
|
35
|
+
rescue OptionParser::ParseError, UsageError, ArgumentError => e
|
|
36
|
+
stderr.puts("tessella lint: #{e.message}")
|
|
37
|
+
2
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(opts, stdout, stderr)
|
|
41
|
+
@opts = opts
|
|
42
|
+
@stdout = stdout
|
|
43
|
+
@stderr = stderr
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Every input, in order; an unreadable one is named and the
|
|
47
|
+
# rest still linted.
|
|
48
|
+
def run(mode, inputs)
|
|
49
|
+
Tessella.lint("", **@opts) # an option's own value: usage
|
|
50
|
+
statuses = inputs.map do |name, read|
|
|
51
|
+
one(mode, name, read.call)
|
|
52
|
+
rescue SystemCallError => e
|
|
53
|
+
@stderr.puts("tessella lint: #{CLI.cannot_read(name, e)}")
|
|
54
|
+
1
|
|
55
|
+
end
|
|
56
|
+
statuses.max || 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def one(mode, name, bytes)
|
|
62
|
+
return alphabet(name, bytes) if mode == :alphabet
|
|
63
|
+
|
|
64
|
+
report = Tessella.lint(bytes, **@opts.except(:ruler))
|
|
65
|
+
rulers(name, report) if @opts[:ruler]
|
|
66
|
+
report.findings.each do |f|
|
|
67
|
+
@stderr.puts("#{name}:#{f.line}: #{f.message}")
|
|
68
|
+
end
|
|
69
|
+
report.clean? ? 0 : 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def alphabet(name, bytes)
|
|
73
|
+
@stdout.puts("#{name}: #{Tessella.alphabet(bytes)}")
|
|
74
|
+
0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rulers(name, report)
|
|
78
|
+
report.rulers.each do |r|
|
|
79
|
+
tail = if r.verticals.empty?
|
|
80
|
+
""
|
|
81
|
+
else
|
|
82
|
+
" verticals=" \
|
|
83
|
+
"#{r.verticals.join(",")}"
|
|
84
|
+
end
|
|
85
|
+
@stdout.puts("#{name}:#{r.line}: width=#{r.width}#{tail}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The inputs as [name, reader] pairs: each file by its path, or
|
|
91
|
+
# stdin named "stdin"; reading is deferred so an unreadable file
|
|
92
|
+
# fails alone.
|
|
93
|
+
class LintCLI
|
|
94
|
+
module Inputs
|
|
95
|
+
def self.list(paths, stdin)
|
|
96
|
+
return [["stdin", -> { stdin.binmode.read }]] if paths.empty?
|
|
97
|
+
|
|
98
|
+
paths.map { |path| [path, -> { File.binread(path) }] }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# Parses `tessella lint`'s argv into Tessella.lint's options (plus
|
|
5
|
+
# :ruler), the mode (:lint or :alphabet), and the file arguments.
|
|
6
|
+
# Raises Halt for --help; lets OptionParser::ParseError escape for
|
|
7
|
+
# a bad flag or a missing argument.
|
|
8
|
+
class LintCLI
|
|
9
|
+
class Options
|
|
10
|
+
def self.parse(argv)
|
|
11
|
+
new.parse(argv)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@opts = { width: 78, prefix: "", suffix: "", ruler: false,
|
|
16
|
+
forbid: [], borders: [] }
|
|
17
|
+
@mode = :lint
|
|
18
|
+
@parser = OptionParser.new
|
|
19
|
+
@parser.banner = LintCLI::BANNER
|
|
20
|
+
build
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse(argv)
|
|
24
|
+
@parser.parse!(argv)
|
|
25
|
+
[@opts, @mode, argv]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build
|
|
31
|
+
add_budget
|
|
32
|
+
add_forbid
|
|
33
|
+
add_glyph_flags
|
|
34
|
+
add_figure_flags
|
|
35
|
+
add_modes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_budget
|
|
39
|
+
@parser.on("--width N", Integer,
|
|
40
|
+
"column budget (default 78)") { @opts[:width] = it }
|
|
41
|
+
@parser.on("--prefix STR", "string rendered before each line,",
|
|
42
|
+
"charged to the budget") { @opts[:prefix] = it }
|
|
43
|
+
@parser.on("--suffix STR", "string rendered after each line,",
|
|
44
|
+
"charged to the budget") { @opts[:suffix] = it }
|
|
45
|
+
@parser.on("--ruler", "print each line's width and the",
|
|
46
|
+
"columns of its verticals") { @opts[:ruler] = true }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def add_forbid
|
|
50
|
+
presets = Lint::FORBID_PRESETS.keys.each_slice(5)
|
|
51
|
+
.map { "#{it.join(", ")}," }
|
|
52
|
+
presets[-1] = presets[-1].chomp(",")
|
|
53
|
+
@parser.on("--forbid SEQ", "flag lines holding SEQ, or a",
|
|
54
|
+
"preset's sequences:", *presets) do |seq|
|
|
55
|
+
@opts[:forbid] << seq
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_glyph_flags
|
|
60
|
+
@parser.on("--target T", Target::TARGETS.map(&:to_s),
|
|
61
|
+
"term (default), cjk: ambiguous glyphs 2",
|
|
62
|
+
"columns, or web: as --web") do |t|
|
|
63
|
+
@opts[:target] = t.to_sym
|
|
64
|
+
end
|
|
65
|
+
flag("--web", :web, "web-viewed target: flag emoji-capable",
|
|
66
|
+
"glyphs, and wide glyphs on aligned rows")
|
|
67
|
+
flag("--no-wide", :no_wide, "flag wide (2-cell) glyphs")
|
|
68
|
+
flag("--ascii", :ascii, "flag every non-ASCII glyph",
|
|
69
|
+
"(implies --no-wide)")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add_figure_flags
|
|
73
|
+
flag("--allow-mixed", :allow_mixed, "skip the check for ASCII",
|
|
74
|
+
"and Unicode structure mixed")
|
|
75
|
+
flag("--fences", :fences, "lint each Markdown fenced block",
|
|
76
|
+
"on its own")
|
|
77
|
+
flag("--geometry", :geometry, "check box sides, and that",
|
|
78
|
+
"connectors hang from something")
|
|
79
|
+
@parser.on("--border GLYPHS", "a custom border set geometry",
|
|
80
|
+
"reads (6 glyphs: tl tr bl br h v)") do |set|
|
|
81
|
+
@opts[:borders] << set
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def add_modes
|
|
86
|
+
@parser.on("--alphabet", "print each input's alphabet (ascii,",
|
|
87
|
+
"or utf-8 and its glyph classes);",
|
|
88
|
+
"lint nothing, exit 0") { @mode = :alphabet }
|
|
89
|
+
@parser.on("--help", "print this help") do
|
|
90
|
+
raise LintCLI::Halt.new(0, @parser.to_s)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def flag(name, key, *text)
|
|
95
|
+
@parser.on(name, *text) { @opts[key] = true }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# The tessella command (DESIGN.md, "Command line"): `tessella
|
|
5
|
+
# COMMAND [options]`, one command per drawing module, each with its
|
|
6
|
+
# own options and --help. The command is the first argument, so a
|
|
7
|
+
# module's options never collide with another's.
|
|
8
|
+
class CLI
|
|
9
|
+
COMMANDS = {
|
|
10
|
+
"chart" => ChartCLI, "compose" => ComposeCLI, "tree" => TreeCLI,
|
|
11
|
+
"table" => TableCLI, "bitfield" => BitfieldCLI, "wave" => WaveCLI,
|
|
12
|
+
"seq" => SeqCLI, "image" => ImageCLI, "lint" => LintCLI,
|
|
13
|
+
"glyphs" => GlyphsCLI, "meter" => MeterCLI, "rule" => RuleCLI,
|
|
14
|
+
"text" => TextCLI, "sparkline" => SparklineCLI
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
USAGE = <<~TEXT
|
|
18
|
+
Usage: tessella [--target T] COMMAND [options] [file]
|
|
19
|
+
|
|
20
|
+
Commands:
|
|
21
|
+
chart line, scatter, stairs, density, bar, histogram,
|
|
22
|
+
box and stem charts
|
|
23
|
+
compose frame, beside, stack, indent, caption: assemble
|
|
24
|
+
blocks into one figure
|
|
25
|
+
tree an indented outline as a box-drawing tree
|
|
26
|
+
table CSV or TSV rows as a framed table
|
|
27
|
+
bitfield an RFC-style packet / bit-field diagram
|
|
28
|
+
wave a timing diagram from WaveDrom-like signals
|
|
29
|
+
seq a sequence diagram from "A -> B: msg" lines
|
|
30
|
+
image a picture as text: tones, blocks, braille, edges
|
|
31
|
+
lint check text figures: tabs, widths, invisible
|
|
32
|
+
glyphs, mixed or misaligned structure
|
|
33
|
+
glyphs the named glyph sets and their width classes
|
|
34
|
+
meter a one-line progress bar or gauge
|
|
35
|
+
rule a divider line, with an optional ornament
|
|
36
|
+
text text in a Unicode style: bold, script, small
|
|
37
|
+
caps, circled, fullwidth, ...
|
|
38
|
+
sparkline one line of values, ▁ to █
|
|
39
|
+
|
|
40
|
+
--target term|cjk|web (or TESSELLA_TARGET) names where the
|
|
41
|
+
figure is read: term (default); cjk, a CJK terminal, whose
|
|
42
|
+
ambiguous glyphs (box drawing, blocks) are two columns, so
|
|
43
|
+
structure is ASCII; web, a browser, where emoji-capable
|
|
44
|
+
glyphs are kept as text with U+FE0E.
|
|
45
|
+
|
|
46
|
+
tessella COMMAND --help shows a command's options;
|
|
47
|
+
tessella --version prints the version.
|
|
48
|
+
TEXT
|
|
49
|
+
|
|
50
|
+
# A --target or TESSELLA_TARGET that names no target: usage.
|
|
51
|
+
class TargetError < ArgumentError; end
|
|
52
|
+
|
|
53
|
+
META = %w[--help -h --version].freeze
|
|
54
|
+
|
|
55
|
+
def self.run(argv, stdin:, stdout:, stderr:, env: ENV)
|
|
56
|
+
target, argv = target(argv, env)
|
|
57
|
+
command, *rest = argv
|
|
58
|
+
return meta(command, stdout) if META.include?(command)
|
|
59
|
+
|
|
60
|
+
klass = COMMANDS[command] or return refuse(command, stderr)
|
|
61
|
+
Target.with(target) do
|
|
62
|
+
klass.run(rest, stdin: stdin, stdout: stdout, stderr: stderr)
|
|
63
|
+
end
|
|
64
|
+
rescue TargetError => e
|
|
65
|
+
usage_error(e, stderr)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.usage_error(error, stderr)
|
|
69
|
+
stderr.puts("tessella: #{error.message}")
|
|
70
|
+
2
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A leading --target T (or --target=T), else TESSELLA_TARGET
|
|
74
|
+
# (set and not empty), else term -- checked before anything
|
|
75
|
+
# runs, --help and --version included; and the arguments after.
|
|
76
|
+
def self.target(argv, env)
|
|
77
|
+
name, rest = given_target(argv)
|
|
78
|
+
name ||= env["TESSELLA_TARGET"].to_s
|
|
79
|
+
[check_target!(name.empty? ? "term" : name), rest]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.given_target(argv)
|
|
83
|
+
first, *rest = argv
|
|
84
|
+
if first == "--target"
|
|
85
|
+
raise TargetError, "--target needs term, cjk or web" if
|
|
86
|
+
rest.empty?
|
|
87
|
+
|
|
88
|
+
return [rest.first, rest.drop(1)]
|
|
89
|
+
end
|
|
90
|
+
return [first.delete_prefix("--target="), rest] if
|
|
91
|
+
first&.start_with?("--target=")
|
|
92
|
+
|
|
93
|
+
[nil, argv]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.check_target!(name)
|
|
97
|
+
Target.check!(name)
|
|
98
|
+
rescue ArgumentError => e
|
|
99
|
+
raise TargetError, e.message
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.meta(flag, stdout)
|
|
103
|
+
stdout.print(flag == "--version" ? "#{VERSION}\n" : USAGE)
|
|
104
|
+
0
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A command's Halt (--help, --version, a usage message): its
|
|
108
|
+
# text on stdout when its status is 0, else on stderr.
|
|
109
|
+
def self.halt(error, stdout, stderr)
|
|
110
|
+
(error.status.zero? ? stdout : stderr).print(error.text)
|
|
111
|
+
error.status
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.refuse(command, stderr)
|
|
115
|
+
what = command ? "unknown command: #{command}" : "no command"
|
|
116
|
+
stderr.puts("tessella: #{what}")
|
|
117
|
+
stderr.print(USAGE)
|
|
118
|
+
2
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|