bitgram 0.1.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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +118 -0
  5. data/exe/bitgram +6 -0
  6. data/lib/bitgram/cli/doctor.rb +34 -0
  7. data/lib/bitgram/cli/options.rb +66 -0
  8. data/lib/bitgram/cli.rb +190 -0
  9. data/lib/bitgram/config.rb +58 -0
  10. data/lib/bitgram/diagram.rb +90 -0
  11. data/lib/bitgram/dsl.rb +35 -0
  12. data/lib/bitgram/errors.rb +29 -0
  13. data/lib/bitgram/issue.rb +11 -0
  14. data/lib/bitgram/layout/cell.rb +8 -0
  15. data/lib/bitgram/layout/engine.rb +74 -0
  16. data/lib/bitgram/layout/result.rb +35 -0
  17. data/lib/bitgram/layout/row.rb +9 -0
  18. data/lib/bitgram/loader.rb +59 -0
  19. data/lib/bitgram/model/document.rb +8 -0
  20. data/lib/bitgram/model/field.rb +13 -0
  21. data/lib/bitgram/normalizer.rb +46 -0
  22. data/lib/bitgram/presets/arp.yml +13 -0
  23. data/lib/bitgram/presets/icmp.yml +10 -0
  24. data/lib/bitgram/presets/ipv4.yml +20 -0
  25. data/lib/bitgram/presets/ipv6.yml +12 -0
  26. data/lib/bitgram/presets/tcp.yml +23 -0
  27. data/lib/bitgram/presets/udp.yml +9 -0
  28. data/lib/bitgram/rasterizers/base.rb +43 -0
  29. data/lib/bitgram/rasterizers/image_magick.rb +44 -0
  30. data/lib/bitgram/rasterizers/registry.rb +33 -0
  31. data/lib/bitgram/rasterizers/rsvg.rb +35 -0
  32. data/lib/bitgram/rasterizers/vips.rb +51 -0
  33. data/lib/bitgram/renderers/ascii/box_chars.rb +44 -0
  34. data/lib/bitgram/renderers/ascii.rb +130 -0
  35. data/lib/bitgram/renderers/svg/cells.rb +93 -0
  36. data/lib/bitgram/renderers/svg/decorations.rb +98 -0
  37. data/lib/bitgram/renderers/svg/geometry.rb +53 -0
  38. data/lib/bitgram/renderers/svg/theme.rb +69 -0
  39. data/lib/bitgram/renderers/svg/xml_builder.rb +33 -0
  40. data/lib/bitgram/renderers/svg.rb +137 -0
  41. data/lib/bitgram/text/fitter.rb +132 -0
  42. data/lib/bitgram/text/measurer.rb +31 -0
  43. data/lib/bitgram/themes/dark.yml +11 -0
  44. data/lib/bitgram/themes/default.yml +11 -0
  45. data/lib/bitgram/themes/mono.yml +9 -0
  46. data/lib/bitgram/util/command.rb +53 -0
  47. data/lib/bitgram/validator.rb +174 -0
  48. data/lib/bitgram/version.rb +6 -0
  49. data/lib/bitgram.rb +82 -0
  50. data/schema/bitgram.schema.json +388 -0
  51. metadata +108 -0
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "measurer"
4
+
5
+ module Bitgram
6
+ # @api private
7
+ module Text
8
+ # @api private
9
+ FitResult = Data.define(:lines, :font_size, :strategy, :footnote)
10
+
11
+ # @api private
12
+ class Fitter
13
+ attr_reader :footnotes, :issues
14
+
15
+ def initialize(measurer:, font_size: nil, min_font_size: nil, footnote_marker: "*{n}")
16
+ @measurer = measurer
17
+ @font_size = font_size
18
+ @min_font_size = min_font_size
19
+ @footnote_marker = footnote_marker.gsub(/\s+/, " ")
20
+ @footnotes = []
21
+ @issues = []
22
+ end
23
+
24
+ def fit(text:, inner_width:, order:, **options)
25
+ text = text.gsub(/\s+/, " ").strip
26
+ options[:short] = options[:short].gsub(/\s+/, " ").strip if options[:short]
27
+ order.each do |strategy|
28
+ result = attempt(strategy.to_sym, text, inner_width, options)
29
+ return result if result
30
+ end
31
+ truncate(text, inner_width, options.fetch(:path, "$.fields"))
32
+ end
33
+
34
+ private
35
+
36
+ def attempt(strategy, text, width, options)
37
+ max_lines = options.fetch(:max_lines, 4)
38
+ short = options[:short]
39
+ case strategy
40
+ when :single then result([text], :single) if fits?(text, width)
41
+ when :short then result([short], :short) if short && fits?(short, width)
42
+ when :shrink then shrink(text, width)
43
+ when :wrap, :vertical then multiline(text, width, max_lines, strategy)
44
+ when :footnote then footnote(options[:description] || text, width, max_lines)
45
+ when :truncate then truncate(text, width, options.fetch(:path, "$.fields"))
46
+ end
47
+ end
48
+
49
+ def multiline(text, width, max_lines, strategy)
50
+ lines = strategy == :wrap ? wrap(text, width) : text.scan(/\X/).grep_v(/\s/)
51
+ return unless lines && lines.size <= max_lines && lines.all? { |line| fits?(line, width) }
52
+
53
+ result(lines, strategy)
54
+ end
55
+
56
+ def fits?(text, width)
57
+ @measurer.width(text) <= width
58
+ end
59
+
60
+ def result(lines, strategy, size = @font_size, footnote = nil)
61
+ FitResult.new(lines.freeze, size, strategy, footnote)
62
+ end
63
+
64
+ def shrink(text, width)
65
+ return unless @font_size && @min_font_size
66
+
67
+ size = @font_size - 0.5
68
+ while size >= @min_font_size
69
+ return result([text], :shrink, size) if @measurer.width(text, size: size) <= width
70
+
71
+ size -= 0.5
72
+ end
73
+ nil
74
+ end
75
+
76
+ def wrap(text, width)
77
+ tokens = text.scan(/[\p{Han}\p{Hiragana}\p{Katakana}]|[^\s\p{Han}\p{Hiragana}\p{Katakana}]+|\s+/)
78
+ tokens = join_punctuation(tokens)
79
+ lines = []
80
+ line = ""
81
+ tokens.each do |token|
82
+ next if line.empty? && token.match?(/\A\s+\z/)
83
+
84
+ if fits?(line + token, width)
85
+ line += token
86
+ else
87
+ lines << line.rstrip unless line.empty?
88
+ line = token.lstrip
89
+ return nil unless fits?(line, width)
90
+ end
91
+ end
92
+ lines << line.rstrip unless line.empty?
93
+ lines.empty? ? [""] : lines
94
+ end
95
+
96
+ def join_punctuation(tokens)
97
+ tokens.each_with_object([]) do |token, joined|
98
+ if token.match?(/\A[、。,.)」]/) && !joined.empty?
99
+ joined[-1] += token
100
+ else
101
+ joined << token
102
+ end
103
+ end
104
+ end
105
+
106
+ def footnote(body, width, max_lines)
107
+ marker = @footnote_marker.gsub("{n}", (@footnotes.size + 1).to_s)
108
+ lines = fits?(marker, width) ? [marker] : marker.scan(/\X/)
109
+ return unless lines.size <= max_lines && lines.all? { |line| fits?(line, width) }
110
+
111
+ @footnotes << "#{marker}: #{body}"
112
+ result(lines, :footnote, @font_size, body)
113
+ end
114
+
115
+ def truncate(text, width, path)
116
+ suffix = fits?("…", width) ? "…" : ""
117
+ remaining = width - @measurer.width(suffix)
118
+ prefix = +""
119
+ text.scan(/\X/) do |char|
120
+ char_width = @measurer.width(char)
121
+ # SVG widths can accumulate subpixel floating-point roundoff.
122
+ break if char_width > remaining + 1e-9
123
+
124
+ prefix << char
125
+ remaining -= char_width
126
+ end
127
+ @issues << Issue.new(:warning, "V-09", path, nil, "label was truncated to fit its cell")
128
+ result([prefix + suffix], :truncate)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Bitgram
6
+ module Text
7
+ # @api private
8
+ class AsciiMeasurer
9
+ def initialize(ambiguous: 1)
10
+ @display_width = Unicode::DisplayWidth.new(ambiguous: ambiguous, emoji: :all)
11
+ end
12
+
13
+ def width(string)
14
+ @display_width.of(string)
15
+ end
16
+ end
17
+
18
+ # @api private
19
+ class SvgMeasurer < AsciiMeasurer
20
+ def initialize(font_size:, ratio:, ambiguous: 1)
21
+ super(ambiguous: ambiguous)
22
+ @font_size = font_size
23
+ @ratio = ratio
24
+ end
25
+
26
+ def width(string, size: @font_size)
27
+ super(string) * size * @ratio
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ background: "#0d1117"
2
+ stroke: "#c9d1d9"
3
+ text: "#e6edf3"
4
+ header_text: "#8b949e"
5
+ cell_fill: "#161b22"
6
+ reserved_fill: "#30363d"
7
+ hatch: "#6e7681"
8
+ palette: []
9
+ classes:
10
+ header:
11
+ fill: "#0c2d6b"
@@ -0,0 +1,11 @@
1
+ background: "#ffffff"
2
+ stroke: "#1f2328"
3
+ text: "#1f2328"
4
+ header_text: "#57606a"
5
+ cell_fill: "#ffffff"
6
+ reserved_fill: "#eaeef2"
7
+ hatch: "#8c959f"
8
+ palette: []
9
+ classes:
10
+ header:
11
+ fill: "#ddf4ff"
@@ -0,0 +1,9 @@
1
+ background: "#ffffff"
2
+ stroke: "#000000"
3
+ text: "#000000"
4
+ header_text: "#000000"
5
+ cell_fill: "#ffffff"
6
+ reserved_fill: "#eeeeee"
7
+ hatch: "#777777"
8
+ palette: []
9
+ classes: {}
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Bitgram
7
+ # @api private
8
+ module Util
9
+ # @api private
10
+ module Command
11
+ module_function
12
+
13
+ def which(name)
14
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
15
+ .map { |directory| File.join(directory, name) }
16
+ .find { |path| File.executable?(path) && !File.directory?(path) }
17
+ end
18
+
19
+ def run(command, stdin_data:, timeout:)
20
+ Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, process|
21
+ threads = [Thread.new { write_input(stdin, stdin_data) },
22
+ Thread.new { stdout.read }, Thread.new { stderr.read }]
23
+ begin
24
+ Timeout.timeout(timeout, RasterizerTimeout, "#{command.first} timed out after #{timeout}s") do
25
+ threads.each(&:join)
26
+ raise RasterizeError, "#{command.first} failed: #{threads[2].value.strip}" unless process.value.success?
27
+
28
+ threads[1].value
29
+ end
30
+ ensure
31
+ # Kill the process group as delegates can otherwise retain open pipes.
32
+ begin
33
+ Process.kill(:KILL, -process.pid)
34
+ rescue Errno::ESRCH
35
+ nil
36
+ end
37
+ threads.each { |thread| thread.kill.join }
38
+ end
39
+ end
40
+ rescue Errno::ENOENT => e
41
+ raise RasterizeError, e.message
42
+ end
43
+
44
+ def write_input(stdin, data)
45
+ stdin.write(data)
46
+ rescue Errno::EPIPE, IOError
47
+ nil # The process exit status reports early input rejection.
48
+ ensure
49
+ stdin.close
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+
5
+ module Bitgram
6
+ # @api private
7
+ # Uses the shipped schema for structural checks, then checks relationships between fields.
8
+ class Validator
9
+ SCHEMA = Config.deep_freeze(JSON.parse(File.read(File.expand_path("../../schema/bitgram.schema.json", __dir__))))
10
+
11
+ def initialize(line_map: {})
12
+ @line_map = line_map
13
+ end
14
+
15
+ def call(data)
16
+ @issues = []
17
+ check(data, SCHEMA, "$")
18
+ check_fields(data) if data.is_a?(Hash) && data["fields"].is_a?(Array)
19
+ @issues
20
+ end
21
+
22
+ private
23
+
24
+ def add(code, path, message, severity = :error)
25
+ parent = path
26
+ until @line_map.key?(parent) || parent == "$" || parent.empty?
27
+ ancestor = parent.sub(/(?:\.[^.\[]+|\[\d+\])\z/, "")
28
+ parent = ancestor == parent ? "$" : ancestor
29
+ end
30
+ @issues << Issue.new(severity, code, path, @line_map[parent], message)
31
+ end
32
+
33
+ def check(value, rule, path)
34
+ rule = SCHEMA.fetch("$defs").fetch(rule["$ref"].split("/").last) if rule["$ref"]
35
+ if rule["type"] && Array(rule["type"]).none? { |type| type_match?(value, type) }
36
+ add("V-03", path, "must be #{Array(rule['type']).join(' or ')}")
37
+ return
38
+ end
39
+ check_scalar(value, rule, path)
40
+ check_object(value, rule, path) if value.is_a?(Hash)
41
+ check_array(value, rule, path) if value.is_a?(Array)
42
+ check_choice(value, rule, path) if rule["oneOf"]
43
+ end
44
+
45
+ def check_choice(value, rule, path)
46
+ if path.match?(/\A\$\.fields\[\d+\]\z/)
47
+ add("V-04", path, "specify exactly one of bits and bytes") unless (value.keys & %w[bits bytes]).size == 1
48
+ elsif rule["oneOf"].count { |branch| matches?(value, branch) } != 1
49
+ add("V-03", path, "value does not match an allowed type or range")
50
+ end
51
+ end
52
+
53
+ def type_match?(value, type)
54
+ case type
55
+ when "object" then value.is_a?(Hash)
56
+ when "array" then value.is_a?(Array)
57
+ when "string" then value.is_a?(String)
58
+ when "integer" then value.is_a?(Integer)
59
+ when "number" then value.is_a?(Numeric) && value.real? && value.finite?
60
+ when "boolean" then [true, false].include?(value)
61
+ end
62
+ end
63
+
64
+ def matches?(value, rule)
65
+ previous = @issues
66
+ @issues = []
67
+ check(value, rule, "$")
68
+ @issues.empty?
69
+ ensure
70
+ @issues = previous
71
+ end
72
+
73
+ def check_scalar(value, rule, path)
74
+ if rule.key?("const") && value != rule["const"]
75
+ add(path == "$.version" ? "V-01" : "V-03", path, "must equal #{rule['const'].inspect}")
76
+ end
77
+ add("V-03", path, "must be one of #{rule['enum'].join(', ')}") if rule["enum"] && !rule["enum"].include?(value)
78
+ check_number(value, rule, path) if value.is_a?(Numeric)
79
+ check_string(value, rule, path) if value.is_a?(String)
80
+ end
81
+
82
+ def check_number(value, rule, path)
83
+ unless value.real? && value.finite?
84
+ add("V-03", path, "must be finite")
85
+ return
86
+ end
87
+ add("V-03", path, "must be at least #{rule['minimum']}") if rule["minimum"] && value < rule["minimum"]
88
+ add("V-07", path, "must be at most #{rule['maximum']}") if rule["maximum"] && value > rule["maximum"]
89
+ end
90
+
91
+ def check_string(value, rule, path)
92
+ unless value.valid_encoding? && !value.match?(/[\x00-\x08\x0b\x0c\x0e-\x1f\uFFFE\uFFFF]/)
93
+ add("V-03", path,
94
+ "must be valid UTF-8 text without control characters")
95
+ return
96
+ end
97
+ if rule["minLength"] && value.length < rule["minLength"]
98
+ add("V-03", path,
99
+ "must have at least #{rule['minLength']} characters")
100
+ end
101
+ if rule["maxLength"] && value.length > rule["maxLength"]
102
+ add("V-07", path,
103
+ "must have at most #{rule['maxLength']} characters")
104
+ end
105
+ pattern = rule["pattern"]
106
+ return unless pattern && !Regexp.new("\\A#{pattern.delete_prefix('^').delete_suffix('$')}\\z").match?(value)
107
+
108
+ add("V-03", path,
109
+ "invalid format")
110
+ end
111
+
112
+ def check_object(value, rule, path)
113
+ properties = rule.fetch("properties", {})
114
+ if rule["additionalProperties"] == false
115
+ (value.keys - properties.keys).each do |key|
116
+ hint = DidYouMean::SpellChecker.new(dictionary: properties.keys).correct(key.to_s).first
117
+ add("V-02", "#{path}.#{key}", "unknown key #{key.inspect}#{" (did you mean #{hint.inspect}?)" if hint}")
118
+ end
119
+ end
120
+ Array(rule["required"]).each { |key| add("V-03", "#{path}.#{key}", "is required") unless value.key?(key) }
121
+ properties.each { |key, child| check(value[key], child, "#{path}.#{key}") if value.key?(key) }
122
+ end
123
+
124
+ def check_array(value, rule, path)
125
+ add("V-03", path, "must not be empty") if rule["minItems"] && value.size < rule["minItems"]
126
+ if rule["maxItems"] && value.size > rule["maxItems"]
127
+ add("V-07", path,
128
+ "too many items (maximum #{rule['maxItems']})")
129
+ return
130
+ end
131
+ add("V-03", path, "items must be unique") if rule["uniqueItems"] && value.uniq.size != value.size
132
+ value.each_with_index { |item, i| check(item, rule["items"], "#{path}[#{i}]") } if rule["items"]
133
+ end
134
+
135
+ def check_fields(data)
136
+ return if data["fields"].size > 10_000
137
+
138
+ fields = data["fields"].grep(Hash)
139
+ seen = {}
140
+ fields.each_with_index do |field, i|
141
+ id = field["id"]
142
+ add("V-05", "$.fields[#{i}].id", "duplicate id #{id.inspect}") if id && seen[id]
143
+ seen[id] = true if id
144
+ end
145
+ return if @issues.any? { |issue| issue.severity == :error }
146
+
147
+ check_positions(fields, data.fetch("layout", {}))
148
+ end
149
+
150
+ def check_positions(fields, layout)
151
+ width = layout.fetch("row_bits", 32)
152
+ pos = 0
153
+ fields.each_with_index do |field, i|
154
+ variable = field["bits"] == "variable"
155
+ bits = variable ? field.fetch("display_bits", width) : field.fetch("bits") { field["bytes"] * 8 }
156
+ check_variable(field, i, pos, width, bits) if variable
157
+ pos += bits
158
+ end
159
+ add("V-07", "$.fields", "diagram exceeds 4096 logical rows") if (pos.to_f / width).ceil > 4096
160
+ return unless pos % width != 0 && !layout["allow_partial_row"]
161
+
162
+ add("V-08", "$.fields", "last row is #{pos % width}/#{width} bits; set layout.allow_partial_row to silence",
163
+ :warning)
164
+ end
165
+
166
+ def check_variable(field, index, position, width, bits)
167
+ if !field.key?("display_bits") && position % width != 0
168
+ add("V-06", "$.fields[#{index}]", "full-width variable field must start at a row boundary")
169
+ elsif bits > width - (position % width)
170
+ add("V-10", "$.fields[#{index}].display_bits", "variable field extends past the row boundary", :warning)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ # Current gem version; the definition format has its own version field.
5
+ VERSION = "0.1.0"
6
+ end
data/lib/bitgram.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bitgram/version"
4
+ require_relative "bitgram/errors"
5
+ require_relative "bitgram/issue"
6
+ require_relative "bitgram/config"
7
+ require_relative "bitgram/loader"
8
+ require_relative "bitgram/validator"
9
+ require_relative "bitgram/model/field"
10
+ require_relative "bitgram/model/document"
11
+ require_relative "bitgram/normalizer"
12
+ require_relative "bitgram/layout/engine"
13
+ require_relative "bitgram/diagram"
14
+
15
+ # Declarative packet and bit-field diagrams with shared layout across output formats.
16
+ module Bitgram
17
+ autoload :CLI, "bitgram/cli"
18
+ # Load a YAML/JSON string or Ruby Hash and apply nested configuration overrides.
19
+ # @return [Diagram]
20
+ def self.load(input = nil, format: nil, **overrides)
21
+ if input.nil?
22
+ input = overrides
23
+ overrides = {}
24
+ end
25
+ source = if input.is_a?(Hash)
26
+ Loader::Source.new(Config.stringify(input), {},
27
+ nil)
28
+ else
29
+ Loader.new.load_string(input, format: format)
30
+ end
31
+ build(source, overrides)
32
+ end
33
+
34
+ # Load a definition from disk.
35
+ # @return [Diagram]
36
+ def self.load_file(path, format: nil, **overrides)
37
+ build(Loader.new.load_file(path, format: format), overrides)
38
+ end
39
+
40
+ # Validate a file without raising definition or parse exceptions.
41
+ # @return [Array<Issue>]
42
+ def self.validate_file(path, format: nil, **overrides)
43
+ source = Loader.new.load_file(path, format: format)
44
+ data = source.data.is_a?(Hash) ? Config.deep_merge(source.data, Config.stringify(overrides)) : source.data
45
+ Validator.new(line_map: source.line_map).call(data)
46
+ rescue ParseError => e
47
+ [Issue.new(:error, "PARSE", "$", nil, e.message)]
48
+ end
49
+
50
+ # Names of bundled protocol definitions.
51
+ # @return [Array<String>]
52
+ def self.presets
53
+ Dir[File.join(__dir__, "bitgram/presets/*.yml")].map { |path| File.basename(path, ".yml") }.sort
54
+ end
55
+
56
+ # Load a bundled protocol definition.
57
+ # @return [Diagram]
58
+ def self.preset(name, **overrides)
59
+ raise ArgumentError, "unknown preset: #{name}; choose #{presets.join(', ')}" unless presets.include?(name.to_s)
60
+
61
+ load_file(File.join(__dir__, "bitgram/presets/#{name}.yml"), **overrides)
62
+ end
63
+
64
+ # Build a definition with the Ruby DSL, using the same validation as file inputs.
65
+ # @return [Diagram]
66
+ def self.define(**, &)
67
+ require_relative "bitgram/dsl"
68
+ dsl = DSL.new(**)
69
+ dsl.instance_eval(&)
70
+ load(dsl.to_h)
71
+ end
72
+
73
+ # @api private
74
+ def self.build(source, overrides)
75
+ data = source.data.is_a?(Hash) ? Config.deep_merge(source.data, Config.stringify(overrides)) : source.data
76
+ issues = Validator.new(line_map: source.line_map).call(data)
77
+ raise ValidationError, issues if issues.any? { |issue| issue.severity == :error }
78
+
79
+ Diagram.new(Normalizer.new.call(data), issues: issues, source: Config.deep_freeze(Config.stringify(data)))
80
+ end
81
+ private_class_method :build
82
+ end