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,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