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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +118 -0
- data/exe/bitgram +6 -0
- data/lib/bitgram/cli/doctor.rb +34 -0
- data/lib/bitgram/cli/options.rb +66 -0
- data/lib/bitgram/cli.rb +190 -0
- data/lib/bitgram/config.rb +58 -0
- data/lib/bitgram/diagram.rb +90 -0
- data/lib/bitgram/dsl.rb +35 -0
- data/lib/bitgram/errors.rb +29 -0
- data/lib/bitgram/issue.rb +11 -0
- data/lib/bitgram/layout/cell.rb +8 -0
- data/lib/bitgram/layout/engine.rb +74 -0
- data/lib/bitgram/layout/result.rb +35 -0
- data/lib/bitgram/layout/row.rb +9 -0
- data/lib/bitgram/loader.rb +59 -0
- data/lib/bitgram/model/document.rb +8 -0
- data/lib/bitgram/model/field.rb +13 -0
- data/lib/bitgram/normalizer.rb +46 -0
- data/lib/bitgram/presets/arp.yml +13 -0
- data/lib/bitgram/presets/icmp.yml +10 -0
- data/lib/bitgram/presets/ipv4.yml +20 -0
- data/lib/bitgram/presets/ipv6.yml +12 -0
- data/lib/bitgram/presets/tcp.yml +23 -0
- data/lib/bitgram/presets/udp.yml +9 -0
- data/lib/bitgram/rasterizers/base.rb +43 -0
- data/lib/bitgram/rasterizers/image_magick.rb +44 -0
- data/lib/bitgram/rasterizers/registry.rb +33 -0
- data/lib/bitgram/rasterizers/rsvg.rb +35 -0
- data/lib/bitgram/rasterizers/vips.rb +51 -0
- data/lib/bitgram/renderers/ascii/box_chars.rb +44 -0
- data/lib/bitgram/renderers/ascii.rb +130 -0
- data/lib/bitgram/renderers/svg/cells.rb +93 -0
- data/lib/bitgram/renderers/svg/decorations.rb +98 -0
- data/lib/bitgram/renderers/svg/geometry.rb +53 -0
- data/lib/bitgram/renderers/svg/theme.rb +69 -0
- data/lib/bitgram/renderers/svg/xml_builder.rb +33 -0
- data/lib/bitgram/renderers/svg.rb +137 -0
- data/lib/bitgram/text/fitter.rb +132 -0
- data/lib/bitgram/text/measurer.rb +31 -0
- data/lib/bitgram/themes/dark.yml +11 -0
- data/lib/bitgram/themes/default.yml +11 -0
- data/lib/bitgram/themes/mono.yml +9 -0
- data/lib/bitgram/util/command.rb +53 -0
- data/lib/bitgram/validator.rb +174 -0
- data/lib/bitgram/version.rb +6 -0
- data/lib/bitgram.rb +82 -0
- data/schema/bitgram.schema.json +388 -0
- 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,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
|
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
|