breadkit 0.1.0 → 0.2.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 +4 -4
- data/.codespellignore +2 -0
- data/.yamllint +16 -0
- data/README.md +109 -6
- data/data/boards/double_full.yml +18 -0
- data/data/boards/stripboard.yml +9 -0
- data/data/boards/universal.yml +8 -0
- data/data/parts/2n3904.yml +11 -0
- data/data/parts/2n7000_onsemi_to92.yml +10 -0
- data/data/parts/74hc00.yml +22 -0
- data/data/parts/74hc04.yml +22 -0
- data/data/parts/74hc08.yml +22 -0
- data/data/parts/74hc14.yml +22 -0
- data/data/parts/74hc165.yml +24 -0
- data/data/parts/74hc32.yml +22 -0
- data/data/parts/74hc595.yml +24 -0
- data/data/parts/active_buzzer.yml +8 -0
- data/data/parts/ams1117_3v3.yml +12 -0
- data/data/parts/arduino_nano.yml +70 -0
- data/data/parts/arduino_uno.yml +29 -26
- data/data/parts/atmega328p.yml +37 -0
- data/data/parts/attiny85.yml +16 -0
- data/data/parts/battery_box.yml +8 -0
- data/data/parts/cd4017.yml +24 -0
- data/data/parts/ck_bd04.yml +29 -0
- data/data/parts/crystal.yml +7 -0
- data/data/parts/dc_jack_2wire.yml +8 -0
- data/data/parts/diode.yml +1 -0
- data/data/parts/esp32_devkitc_v4_wroom32e.yml +86 -0
- data/data/parts/irlz44n.yml +10 -0
- data/data/parts/l293d.yml +24 -0
- data/data/parts/led.yml +1 -0
- data/data/parts/lm358.yml +16 -0
- data/data/parts/lm393.yml +16 -0
- data/data/parts/ne555.yml +2 -2
- data/data/parts/ntc_thermistor.yml +7 -0
- data/data/parts/photoresistor.yml +8 -0
- data/data/parts/pico.yml +89 -0
- data/data/parts/resistor.yml +1 -0
- data/data/parts/sc56_11ewa.yml +30 -0
- data/data/parts/seeed_xiao_samd21.yml +37 -0
- data/data/parts/shillehtek_mb102_4pin.yml +23 -0
- data/data/parts/slide_switch_spst.yml +8 -0
- data/data/parts/sparkfun_pro_micro_5v.yml +58 -0
- data/data/parts/transistor.yml +0 -1
- data/data/parts/ua7805.yml +10 -0
- data/data/parts/uln2003a.yml +23 -0
- data/data/parts/wp154a4sureqbfzgc.yml +11 -0
- data/docs/CLI.md +108 -0
- data/docs/COMPATIBILITY.md +60 -0
- data/docs/DECLARATIVE.md +138 -0
- data/docs/IR.md +42 -0
- data/docs/JUMPER_KIT.md +45 -0
- data/docs/LOCK.md +75 -0
- data/docs/LSP.md +59 -0
- data/docs/MB102_POWER.md +11 -0
- data/docs/MCP.md +44 -0
- data/docs/RAKE.md +19 -0
- data/docs/dsl.md +197 -6
- data/exe/breadkit-lsp +13 -0
- data/exe/breadkit-mcp +12 -0
- data/lib/breadkit/analysis.rb +174 -50
- data/lib/breadkit/board.rb +152 -4
- data/lib/breadkit/cli.rb +229 -13
- data/lib/breadkit/color.rb +38 -0
- data/lib/breadkit/dc_analysis.rb +261 -0
- data/lib/breadkit/dsl.rb +205 -24
- data/lib/breadkit/exporters.rb +202 -0
- data/lib/breadkit/formatter.rb +52 -0
- data/lib/breadkit/fritzing_export.rb +256 -0
- data/lib/breadkit/ir.rb +154 -22
- data/lib/breadkit/jumper_kit.rb +127 -0
- data/lib/breadkit/lsp_server.rb +248 -0
- data/lib/breadkit/mcp_server.rb +133 -0
- data/lib/breadkit/model.rb +47 -5
- data/lib/breadkit/part_library.rb +156 -3
- data/lib/breadkit/part_lock.rb +107 -0
- data/lib/breadkit/patterns.rb +119 -0
- data/lib/breadkit/rake_task.rb +25 -0
- data/lib/breadkit/resolver.rb +160 -28
- data/lib/breadkit/structured_input.rb +275 -0
- data/lib/breadkit/templates/555.bk.rb +34 -0
- data/lib/breadkit/templates/arduino.bk.rb +17 -0
- data/lib/breadkit/templates/led.bk.rb +21 -0
- data/lib/breadkit/value.rb +42 -3
- data/lib/breadkit/version.rb +1 -1
- data/lib/breadkit/wire_suggestions.rb +106 -0
- data/lib/breadkit.rb +28 -3
- data/package.json +1 -1
- data/schema/board-v1.json +38 -0
- data/schema/ir-v1.json +56 -11
- data/schema/ir-v2.json +173 -0
- data/schema/part-v1.json +52 -4
- data/sig/breadkit.rbs +107 -5
- metadata +119 -14
- data/docs/assets/01_led_button.svg +0 -12
- data/site/assets/01_led_button.svg +0 -14
- data/site/assets/03_arduino_blink.svg +0 -14
- data/site/favicon.svg +0 -7
- data/site/guide/components/index.html +0 -113
- data/site/guide/dsl/index.html +0 -132
- data/site/guide/index.html +0 -163
- data/site/index.html +0 -95
- data/site/styles.css +0 -14
data/lib/breadkit/ir.rb
CHANGED
|
@@ -6,41 +6,59 @@ module Breadkit
|
|
|
6
6
|
module IR
|
|
7
7
|
class Writer
|
|
8
8
|
def write(circuit)
|
|
9
|
+
@source_root = circuit.source_root || Dir.pwd
|
|
9
10
|
library = PartLibrary.new
|
|
10
11
|
part_definitions = circuit.components.values.map(&:part).uniq.filter_map do |part|
|
|
11
12
|
standard = library.find(part.id, pin_count: part.pins.length)
|
|
12
13
|
part.data unless standard&.data == part.data
|
|
13
14
|
end
|
|
14
15
|
result = {
|
|
15
|
-
schema_version: 1,
|
|
16
|
+
schema_version: circuit.multi_board? ? 2 : 1,
|
|
17
|
+
source_root: @source_root,
|
|
16
18
|
title: circuit.title,
|
|
17
19
|
board: { type: circuit.board.definition.id, options: { split_rails: circuit.board.split_rails } },
|
|
18
20
|
board_definition: circuit.board.definition.data,
|
|
19
|
-
supplies: circuit.supplies.map { |item| { name: item.name, voltage: item.voltage, plus: item.plus, minus: item.minus,
|
|
20
|
-
|
|
21
|
+
supplies: circuit.supplies.map { |item| { name: item.name, voltage: item.voltage, plus: item.plus, minus: item.minus,
|
|
22
|
+
isolated: item.isolated == true, voltage_range: item.voltage_range,
|
|
23
|
+
current_limit: item.current_limit,
|
|
24
|
+
source: source(item.location) }.merge(step_field(item)) },
|
|
25
|
+
labels: circuit.labels.map { |item| { net: item.name, at: item.at, source: source(item.location) }.merge(step_field(item)) },
|
|
21
26
|
components: circuit.components.values.map do |component|
|
|
22
27
|
{ ref: component.ref, part: component.part.id, value: component.value,
|
|
23
28
|
attrs: stringify(component.attrs), pins: component.pins.transform_values(&:hole_id),
|
|
24
|
-
unused: component.unused, source: source(component.location) }
|
|
29
|
+
unused: component.unused, source: source(component.location) }.merge(step_field(component))
|
|
25
30
|
end,
|
|
26
31
|
wires: circuit.wires.map { |wire| { id: wire.id, from: wire.from, to: wire.to, color: wire.color, route: wire.route,
|
|
27
32
|
layer: wire.layer, electrical: wire.electrical, dashed: wire.dashed,
|
|
28
|
-
source: source(wire.location) } },
|
|
33
|
+
source: source(wire.location) }.merge(step_field(wire)) },
|
|
29
34
|
expectations: stringify(circuit.expectations),
|
|
30
35
|
lint_disables: stringify(circuit.lint_disables),
|
|
31
36
|
analysis: { nets: circuit.nets.map { |net| { name: net.name, members: net.members, holes: net.holes, potential: circuit.potentials.values[net.name] } } }
|
|
32
37
|
}
|
|
38
|
+
if circuit.multi_board?
|
|
39
|
+
result.delete(:board)
|
|
40
|
+
result.delete(:board_definition)
|
|
41
|
+
result[:boards] = circuit.boards.map do |name, board|
|
|
42
|
+
{ name: name, type: board.definition.id, options: { split_rails: board.split_rails },
|
|
43
|
+
definition: board.definition.data }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
result[:steps] = circuit.steps.map { |item| { number: item[:number], title: item[:title], source: source(item[:source]) } } unless circuit.steps.empty?
|
|
33
47
|
result[:part_definitions] = part_definitions unless part_definitions.empty?
|
|
34
48
|
result
|
|
35
49
|
end
|
|
36
50
|
|
|
37
51
|
private
|
|
38
52
|
|
|
53
|
+
def step_field(item)
|
|
54
|
+
item.step ? { step: item.step } : {}
|
|
55
|
+
end
|
|
56
|
+
|
|
39
57
|
def source(location)
|
|
40
58
|
return nil unless location && location.path && location.line
|
|
41
59
|
|
|
42
60
|
path = Pathname.new(location.path)
|
|
43
|
-
path = path.relative_path_from(Pathname.
|
|
61
|
+
path = path.relative_path_from(Pathname.new(@source_root)) if path.absolute?
|
|
44
62
|
{ path: path.to_s, line: location.line }
|
|
45
63
|
end
|
|
46
64
|
|
|
@@ -66,26 +84,41 @@ module Breadkit
|
|
|
66
84
|
def read(data)
|
|
67
85
|
data = stringify_keys(data)
|
|
68
86
|
validate!(data)
|
|
87
|
+
@source_root = data["source_root"]
|
|
69
88
|
doc = Document.new
|
|
89
|
+
doc.source_root = @source_root
|
|
70
90
|
doc.title = data["title"]
|
|
71
|
-
doc.
|
|
72
|
-
|
|
91
|
+
doc.steps = Array(data["steps"]).map do |item|
|
|
92
|
+
{ number: item.fetch("number"), title: item["title"], source: location(item["source"]) }
|
|
93
|
+
end
|
|
94
|
+
if data["schema_version"] == 2
|
|
95
|
+
doc.boards = data.fetch("boards").map do |item|
|
|
96
|
+
{ name: item.fetch("name"), type: item.fetch("type"), options: item.fetch("options").transform_keys(&:to_sym) }
|
|
97
|
+
end
|
|
98
|
+
doc.board_definitions = data.fetch("boards").map { |item| item.fetch("definition") }.uniq
|
|
99
|
+
else
|
|
100
|
+
doc.board = { type: data.dig("board", "type") || "full", options: (data.dig("board", "options") || {}).transform_keys(&:to_sym) }
|
|
101
|
+
doc.board_definitions = [data["board_definition"]] if data["board_definition"]
|
|
102
|
+
end
|
|
73
103
|
doc.supplies = Array(data["supplies"]).map do |item|
|
|
74
|
-
{ name: item.fetch("name"), voltage: Value.parse(item.fetch("voltage")), plus: item.fetch("plus"),
|
|
104
|
+
{ name: item.fetch("name"), voltage: Value.parse(item.fetch("voltage")), plus: item.fetch("plus"),
|
|
105
|
+
minus: item.fetch("minus"), isolated: item["isolated"] == true,
|
|
106
|
+
voltage_range: item["voltage_range"], current_limit: item["current_limit"],
|
|
107
|
+
location: location(item["source"]), step: item["step"] }
|
|
75
108
|
end
|
|
76
109
|
doc.labels = Array(data["labels"]).map do |item|
|
|
77
|
-
{ name: item.fetch("net"), at: item.fetch("at"), location: location(item["source"]) }
|
|
110
|
+
{ name: item.fetch("net"), at: item.fetch("at"), location: location(item["source"]), step: item["step"] }
|
|
78
111
|
end
|
|
79
112
|
doc.components = Array(data["components"]).map do |item|
|
|
80
113
|
{ ref: item.fetch("ref"), type: item.fetch("part"), value: item["value"],
|
|
81
114
|
pins: item["pins"] || {}, at: nil, attrs: (item["attrs"] || {}).transform_keys(&:to_sym),
|
|
82
|
-
unused: item["unused"] || [], location: location(item["source"]) }
|
|
115
|
+
unused: item["unused"] || [], location: location(item["source"]), step: item["step"] }
|
|
83
116
|
end
|
|
84
117
|
doc.part_definitions = Array(data["part_definitions"])
|
|
85
118
|
doc.wires = Array(data["wires"]).map do |item|
|
|
86
119
|
{ id: item["id"], from: item.fetch("from"), to: item.fetch("to"), color: item["color"],
|
|
87
120
|
route: item["route"] || "straight", layer: item["layer"], electrical: item["electrical"] != false,
|
|
88
|
-
dashed: item["dashed"] == true, location: location(item["source"]) }
|
|
121
|
+
dashed: item["dashed"] == true, location: location(item["source"]), step: item["step"] }
|
|
89
122
|
end
|
|
90
123
|
doc.expectations = Array(data["expectations"])
|
|
91
124
|
doc.lint_disables = Array(data["lint_disables"]).map do |item|
|
|
@@ -100,17 +133,40 @@ module Breadkit
|
|
|
100
133
|
|
|
101
134
|
def validate!(data)
|
|
102
135
|
require_hash(data, "root")
|
|
103
|
-
raise DSLError, "unsupported IR schema_version" unless data["schema_version"]
|
|
136
|
+
raise DSLError, "unsupported IR schema_version" unless [1, 2].include?(data["schema_version"])
|
|
104
137
|
raise DSLError, "invalid IR: title must be text or null" unless data["title"].nil? || data["title"].is_a?(String)
|
|
138
|
+
raise DSLError, "invalid IR: source_root must be text" if data.key?("source_root") && !data["source_root"].is_a?(String)
|
|
105
139
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
if data["schema_version"] == 2
|
|
141
|
+
validate_named_boards(data)
|
|
142
|
+
else
|
|
143
|
+
board = require_hash(data["board"], "board")
|
|
144
|
+
require_string(board["type"], "board.type")
|
|
145
|
+
validate_board_options(board["options"], "board.options")
|
|
146
|
+
validate_board_definition(data["board_definition"]) if data.key?("board_definition")
|
|
111
147
|
end
|
|
112
|
-
|
|
148
|
+
validate_steps(data)
|
|
113
149
|
validate_records(data, "supplies", %w[name plus minus], %w[voltage])
|
|
150
|
+
data.fetch("supplies").each_with_index do |item, index|
|
|
151
|
+
voltage = item["voltage"]
|
|
152
|
+
unless voltage.is_a?(Numeric) && voltage.finite? && voltage.positive?
|
|
153
|
+
raise DSLError, "invalid IR: supplies[#{index}].voltage must be positive"
|
|
154
|
+
end
|
|
155
|
+
unless !item.key?("isolated") || [true, false].include?(item["isolated"])
|
|
156
|
+
raise DSLError, "invalid IR: supplies[#{index}].isolated must be boolean"
|
|
157
|
+
end
|
|
158
|
+
range = item["voltage_range"]
|
|
159
|
+
unless range.nil? || (range.is_a?(Array) && range.length == 2 && range.all? { |value| value.is_a?(Numeric) && value.finite? && value.positive? } && range[0] <= range[1])
|
|
160
|
+
raise DSLError, "invalid IR: supplies[#{index}].voltage_range must be two ascending positive numbers"
|
|
161
|
+
end
|
|
162
|
+
if range && (voltage < range[0] || voltage > range[1])
|
|
163
|
+
raise DSLError, "invalid IR: supplies[#{index}].voltage must be within voltage_range"
|
|
164
|
+
end
|
|
165
|
+
limit = item["current_limit"]
|
|
166
|
+
unless limit.nil? || (limit.is_a?(Numeric) && limit.real? && limit.finite? && limit.positive?)
|
|
167
|
+
raise DSLError, "invalid IR: supplies[#{index}].current_limit must be positive"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
114
170
|
validate_records(data, "labels", %w[net at])
|
|
115
171
|
validate_records(data, "components", %w[ref part])
|
|
116
172
|
validate_records(data, "wires", %w[id from to])
|
|
@@ -135,19 +191,93 @@ module Breadkit
|
|
|
135
191
|
end
|
|
136
192
|
data.fetch("expectations").each_with_index do |item, index|
|
|
137
193
|
require_source(item["location"], "expectations[#{index}].location")
|
|
194
|
+
require_string(item["when"], "expectations[#{index}].when") if item.key?("when")
|
|
138
195
|
require_array(item["entries"], "expectations[#{index}].entries")
|
|
139
196
|
item["entries"].each_with_index do |entry, entry_index|
|
|
140
197
|
require_hash(entry, "expectations[#{index}].entries[#{entry_index}]")
|
|
141
|
-
unless %w[connected isolated net].include?(entry["kind"])
|
|
198
|
+
unless %w[connected isolated net voltage current].include?(entry["kind"])
|
|
142
199
|
raise DSLError, "invalid IR: expectations[#{index}].entries[#{entry_index}].kind is unknown"
|
|
143
200
|
end
|
|
144
|
-
require_array(entry["refs"], "expectations[#{index}].entries[#{entry_index}].refs")
|
|
201
|
+
refs = require_array(entry["refs"], "expectations[#{index}].entries[#{entry_index}].refs")
|
|
202
|
+
if %w[voltage current].include?(entry["kind"])
|
|
203
|
+
range = entry["range"]
|
|
204
|
+
valid_refs = refs.length == 1 && refs.first.is_a?(String) && !refs.first.empty?
|
|
205
|
+
unless valid_refs && range.is_a?(Array) && range.length == 2 && range.all? { |value| value.is_a?(Numeric) && value.finite? } && range[0] <= range[1] && (entry["kind"] != "current" || range[0] >= 0)
|
|
206
|
+
raise DSLError, "invalid IR: expectations[#{index}].entries[#{entry_index}].range is invalid"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
145
209
|
require_source(entry["location"], "expectations[#{index}].entries[#{entry_index}].location")
|
|
146
210
|
end
|
|
147
211
|
end
|
|
148
212
|
require_array(require_hash(data["analysis"], "analysis")["nets"], "analysis.nets") if data.key?("analysis")
|
|
149
213
|
end
|
|
150
214
|
|
|
215
|
+
def validate_named_boards(data)
|
|
216
|
+
raise DSLError, "invalid IR: v2 must use boards instead of board" if data.key?("board") || data.key?("board_definition")
|
|
217
|
+
|
|
218
|
+
boards = require_array(data["boards"], "boards")
|
|
219
|
+
raise DSLError, "invalid IR: boards must not be empty" if boards.empty?
|
|
220
|
+
|
|
221
|
+
names = {}
|
|
222
|
+
definitions = {}
|
|
223
|
+
boards.each_with_index do |item, index|
|
|
224
|
+
item = require_hash(item, "boards[#{index}]")
|
|
225
|
+
name = item["name"]
|
|
226
|
+
unless name.is_a?(String) && /\A[A-Za-z][A-Za-z0-9_]*\z/.match?(name)
|
|
227
|
+
raise DSLError, "invalid IR: boards[#{index}].name is invalid"
|
|
228
|
+
end
|
|
229
|
+
raise DSLError, "invalid IR: duplicate board name #{name}" if names[name.downcase]
|
|
230
|
+
names[name.downcase] = true
|
|
231
|
+
require_string(item["type"], "boards[#{index}].type")
|
|
232
|
+
validate_board_options(item["options"], "boards[#{index}].options")
|
|
233
|
+
definition = item["definition"]
|
|
234
|
+
validate_board_definition(definition)
|
|
235
|
+
unless definition["id"] == item["type"]
|
|
236
|
+
raise DSLError, "invalid IR: boards[#{index}].definition.id must match type"
|
|
237
|
+
end
|
|
238
|
+
begin
|
|
239
|
+
board = Board.new(BoardDef.new(definition), split_rails: item.fetch("options").fetch("split_rails", false))
|
|
240
|
+
raise ArgumentError, "board has no holes" if board.holes.empty?
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
raise DSLError, "invalid IR: boards[#{index}].definition: #{e.message}"
|
|
243
|
+
end
|
|
244
|
+
if definitions.key?(item["type"]) && definitions[item["type"]] != definition
|
|
245
|
+
raise DSLError, "invalid IR: conflicting definitions for board type #{item['type']}"
|
|
246
|
+
end
|
|
247
|
+
definitions[item["type"]] = definition
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def validate_board_options(value, path)
|
|
252
|
+
options = require_hash(value, path)
|
|
253
|
+
unless !options.key?("split_rails") || [true, false].include?(options["split_rails"])
|
|
254
|
+
raise DSLError, "invalid IR: #{path}.split_rails must be boolean"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def validate_steps(data)
|
|
259
|
+
steps = data.fetch("steps", [])
|
|
260
|
+
require_array(steps, "steps").each_with_index do |item, index|
|
|
261
|
+
require_hash(item, "steps[#{index}]")
|
|
262
|
+
raise DSLError, "invalid IR: steps[#{index}].number must be #{index + 1}" unless item["number"] == index + 1
|
|
263
|
+
unless item["title"].nil? || (item["title"].is_a?(String) && !item["title"].empty?)
|
|
264
|
+
raise DSLError, "invalid IR: steps[#{index}].title must be nonempty text or null"
|
|
265
|
+
end
|
|
266
|
+
require_source(item["source"], "steps[#{index}].source") if item.key?("source")
|
|
267
|
+
end
|
|
268
|
+
%w[supplies labels components wires].each do |key|
|
|
269
|
+
next unless data[key].is_a?(Array)
|
|
270
|
+
|
|
271
|
+
data[key].each_with_index do |item, index|
|
|
272
|
+
next unless item.is_a?(Hash) && item.key?("step")
|
|
273
|
+
number = item["step"]
|
|
274
|
+
unless number.is_a?(Integer) && number.positive? && number <= steps.length
|
|
275
|
+
raise DSLError, "invalid IR: #{key}[#{index}].step must reference a declared step"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
151
281
|
def validate_board_definition(value)
|
|
152
282
|
board = require_hash(value, "board_definition")
|
|
153
283
|
require_string(board["id"], "board_definition.id")
|
|
@@ -200,7 +330,9 @@ module Breadkit
|
|
|
200
330
|
|
|
201
331
|
def location(source)
|
|
202
332
|
return nil unless source
|
|
203
|
-
|
|
333
|
+
path = source["path"]
|
|
334
|
+
path = File.expand_path(path, @source_root) if @source_root && !Pathname.new(path).absolute?
|
|
335
|
+
SourceLocation.new(path: path, line: source["line"])
|
|
204
336
|
end
|
|
205
337
|
|
|
206
338
|
def stringify_keys(value)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
class JumperKit
|
|
5
|
+
MAX_JUMPERS = 1000
|
|
6
|
+
VERIFIED_BOARDS = %w[mini half full double_full].freeze
|
|
7
|
+
|
|
8
|
+
def self.load(path)
|
|
9
|
+
source = File.read(path, encoding: "UTF-8")
|
|
10
|
+
data = case File.extname(path).downcase
|
|
11
|
+
when ".yml", ".yaml" then YAML.safe_load(source, aliases: false)
|
|
12
|
+
when ".json" then JSON.parse(source)
|
|
13
|
+
else raise ArgumentError, "jumper inventory must be YAML or JSON"
|
|
14
|
+
end
|
|
15
|
+
new(data)
|
|
16
|
+
rescue Psych::Exception, JSON::ParserError => error
|
|
17
|
+
raise ArgumentError, "invalid jumper inventory: #{error.message}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(data)
|
|
21
|
+
unless data.is_a?(Hash) && (data.keys - %w[wires measured_routes]).empty? && data["wires"].is_a?(Array)
|
|
22
|
+
raise ArgumentError, "jumper inventory needs a wires list and optional measured_routes"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
measured = data.fetch("measured_routes", {})
|
|
26
|
+
unless measured.is_a?(Hash) && measured.all? { |id, span| id.is_a?(String) && !id.empty? &&
|
|
27
|
+
span.is_a?(Numeric) && span.real? && span.finite? && span.positive? }
|
|
28
|
+
raise ArgumentError, "invalid measured_routes; use positive millimeter spans keyed by wire ID"
|
|
29
|
+
end
|
|
30
|
+
@measured_routes = measured.transform_values(&:to_f)
|
|
31
|
+
|
|
32
|
+
@slots = data.fetch("wires").flat_map do |item|
|
|
33
|
+
unless item.is_a?(Hash) && (item.keys - %w[color usable_span_mm count]).empty? &&
|
|
34
|
+
%w[color usable_span_mm count].all? { |key| item.key?(key) }
|
|
35
|
+
raise ArgumentError, "invalid jumper inventory entry"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
color, span, count = item.values_at("color", "usable_span_mm", "count")
|
|
39
|
+
unless color.is_a?(String) && !color.strip.empty? && span.is_a?(Numeric) && span.real? &&
|
|
40
|
+
span.finite? && span.positive? && count.is_a?(Integer) && count.positive? && count <= MAX_JUMPERS
|
|
41
|
+
raise ArgumentError, "invalid jumper color, usable_span_mm, or count"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Array.new(count) { { color: color.downcase, usable_span_mm: span.to_f } }
|
|
45
|
+
end
|
|
46
|
+
raise ArgumentError, "jumper inventory exceeds #{MAX_JUMPERS} pieces" if @slots.length > MAX_JUMPERS
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def allocate(circuit)
|
|
50
|
+
raise ArgumentError, "circuit has errors" if circuit.diagnostics.any? { |item| item.severity == "error" }
|
|
51
|
+
unknown = @measured_routes.keys - circuit.wires.map(&:id)
|
|
52
|
+
raise ArgumentError, "measured_routes names unknown wire #{unknown.first}" unless unknown.empty?
|
|
53
|
+
|
|
54
|
+
eligible, skipped = [], []
|
|
55
|
+
circuit.wires.each do |wire|
|
|
56
|
+
left, right = [wire.from, wire.to].map { |endpoint| circuit.board.hole(endpoint) }
|
|
57
|
+
reason = skip_reason(circuit, wire, left, right)
|
|
58
|
+
measured_span = @measured_routes[wire.id]
|
|
59
|
+
raise ArgumentError, "measured route #{wire.id} refers to a visual-only wire" if measured_span && wire.electrical == false
|
|
60
|
+
|
|
61
|
+
if reason && !measured_span
|
|
62
|
+
skipped << { wire: wire.id, reason: reason }
|
|
63
|
+
else
|
|
64
|
+
modeled_span = Math.hypot(left.x - right.x, left.y - right.y) * 2.54 unless reason
|
|
65
|
+
if modeled_span && measured_span && measured_span + 1e-9 < modeled_span
|
|
66
|
+
raise ArgumentError, "measured route #{wire.id} is shorter than its modeled endpoint span"
|
|
67
|
+
end
|
|
68
|
+
eligible << { wire: wire.id, color: wire.color&.downcase,
|
|
69
|
+
minimum_span_mm: modeled_span, required_span_mm: measured_span || modeled_span,
|
|
70
|
+
span_source: measured_span ? "measured" : "board_geometry" }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
candidates = eligible.to_h do |wire|
|
|
75
|
+
compatible = @slots.each_index.select do |index|
|
|
76
|
+
slot = @slots.fetch(index)
|
|
77
|
+
(!wire[:color] || slot[:color] == wire[:color]) && slot[:usable_span_mm] + 1e-9 >= wire[:required_span_mm]
|
|
78
|
+
end.sort_by { |index| [@slots[index][:usable_span_mm], @slots[index][:color], index] }
|
|
79
|
+
[wire[:wire], compatible]
|
|
80
|
+
end
|
|
81
|
+
owners, assigned = {}, {}
|
|
82
|
+
eligible.sort_by { |wire| [candidates.fetch(wire[:wire]).length, -wire[:required_span_mm], wire[:wire]] }
|
|
83
|
+
.each { |wire| assign(wire[:wire], candidates, owners, assigned, {}) }
|
|
84
|
+
|
|
85
|
+
assignments = eligible.filter_map do |wire|
|
|
86
|
+
slot = @slots[assigned[wire[:wire]]] if assigned.key?(wire[:wire])
|
|
87
|
+
{ wire: wire[:wire], color: slot[:color], usable_span_mm: slot[:usable_span_mm],
|
|
88
|
+
minimum_span_mm: wire[:minimum_span_mm]&.round(2),
|
|
89
|
+
required_span_mm: wire[:required_span_mm].round(2), span_source: wire[:span_source] } if slot
|
|
90
|
+
end
|
|
91
|
+
unassigned = eligible.reject { |wire| assigned.key?(wire[:wire]) }
|
|
92
|
+
.map { |wire| { wire: wire[:wire], reason: "no compatible jumper in inventory" } }
|
|
93
|
+
{ assignments: assignments, unassigned: unassigned, skipped: skipped }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def skip_reason(circuit, wire, left, right)
|
|
99
|
+
return "visual-only wire" if wire.electrical == false
|
|
100
|
+
return "route length is not modeled" unless wire.route == "straight"
|
|
101
|
+
return "both endpoints must be board holes" unless left && right
|
|
102
|
+
return "cross-board distance is not modeled" if circuit.multi_board? && left.id.split(".", 2).first != right.id.split(".", 2).first
|
|
103
|
+
return "rail distance is not modeled" unless left.kind == :terminal && right.kind == :terminal
|
|
104
|
+
return "solder attachment is not a jumper socket" if circuit.board.solder_pad?(left.id) || circuit.board.solder_pad?(right.id)
|
|
105
|
+
board = circuit.multi_board? ? circuit.boards.fetch(left.id.split(".", 2).first) : circuit.board
|
|
106
|
+
return "board pitch is not verified" unless VERIFIED_BOARDS.include?(board.definition.id) &&
|
|
107
|
+
board.definition.data == BoardDef.load(board.definition.id).data
|
|
108
|
+
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def assign(wire_id, candidates, owners, assigned, seen)
|
|
113
|
+
candidates.fetch(wire_id).each do |index|
|
|
114
|
+
next if seen[index]
|
|
115
|
+
|
|
116
|
+
seen[index] = true
|
|
117
|
+
previous = owners[index]
|
|
118
|
+
next if previous && !assign(previous, candidates, owners, assigned, seen)
|
|
119
|
+
|
|
120
|
+
owners[index] = wire_id
|
|
121
|
+
assigned[wire_id] = index
|
|
122
|
+
return true
|
|
123
|
+
end
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../breadkit"
|
|
5
|
+
|
|
6
|
+
module Breadkit
|
|
7
|
+
# A small LSP 3.18 server. Only full document synchronization is advertised.
|
|
8
|
+
class LSPServer
|
|
9
|
+
MAX_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
10
|
+
CIRCUIT_SUFFIXES = %w[.bk.yml .bk.yaml .bk.toml .bk.rb].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(input: $stdin, output: $stdout, root: nil, trusted_ruby: false)
|
|
13
|
+
raise ArgumentError, "--trusted-ruby requires --root" if trusted_ruby && !root
|
|
14
|
+
|
|
15
|
+
@input, @output = input, output
|
|
16
|
+
@root = File.realpath(root) if root
|
|
17
|
+
raise ArgumentError, "LSP root must be a directory" if @root && !File.directory?(@root)
|
|
18
|
+
@trusted_ruby = trusted_ruby
|
|
19
|
+
@documents = {}
|
|
20
|
+
@running = true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
while @running && (message = read_message)
|
|
25
|
+
handle(message)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def read_message
|
|
32
|
+
headers = {}
|
|
33
|
+
while (line = @input.gets)
|
|
34
|
+
break if line == "\r\n" || line == "\n"
|
|
35
|
+
key, value = line.split(":", 2)
|
|
36
|
+
headers[key.downcase] = value&.strip if key
|
|
37
|
+
end
|
|
38
|
+
return unless line
|
|
39
|
+
|
|
40
|
+
size = Integer(headers.fetch("content-length"))
|
|
41
|
+
raise ArgumentError, "LSP message exceeds 8 MiB" unless size.between?(1, MAX_MESSAGE_BYTES)
|
|
42
|
+
|
|
43
|
+
body = @input.read(size)
|
|
44
|
+
raise EOFError, "truncated LSP message" unless body && body.bytesize == size
|
|
45
|
+
JSON.parse(body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def send_message(message)
|
|
49
|
+
body = JSON.generate({ jsonrpc: "2.0" }.merge(message))
|
|
50
|
+
@output.write("Content-Length: #{body.bytesize}\r\n\r\n")
|
|
51
|
+
@output.write(body)
|
|
52
|
+
@output.flush
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reply(id, result)
|
|
56
|
+
send_message(id: id, result: result)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle(message)
|
|
60
|
+
method = message["method"]
|
|
61
|
+
params = message["params"] || {}
|
|
62
|
+
case method
|
|
63
|
+
when "initialize"
|
|
64
|
+
reply(message["id"], { capabilities: { textDocumentSync: 1, hoverProvider: true,
|
|
65
|
+
completionProvider: { triggerCharacters: [".", ":", "\"", "'"] } },
|
|
66
|
+
serverInfo: { name: "breadkit", version: VERSION } })
|
|
67
|
+
when "shutdown" then reply(message["id"], nil)
|
|
68
|
+
when "exit" then @running = false
|
|
69
|
+
when "textDocument/didOpen" then open_document(params.fetch("textDocument"))
|
|
70
|
+
when "textDocument/didChange" then change_document(params)
|
|
71
|
+
when "textDocument/didClose" then close_document(params.fetch("textDocument").fetch("uri"))
|
|
72
|
+
when "textDocument/completion" then reply(message["id"], completion(params))
|
|
73
|
+
when "textDocument/hover" then reply(message["id"], hover(params))
|
|
74
|
+
else
|
|
75
|
+
send_message(id: message["id"], error: { code: -32_601, message: "Method not found" }) if message.key?("id")
|
|
76
|
+
end
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
send_message(id: message["id"], error: { code: -32_603, message: e.message }) if message.key?("id")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def open_document(document)
|
|
82
|
+
uri = document.fetch("uri")
|
|
83
|
+
path = file_path(uri)
|
|
84
|
+
return unless path && CIRCUIT_SUFFIXES.any? { |suffix| path.end_with?(suffix) }
|
|
85
|
+
|
|
86
|
+
@documents[uri] = { path: path, text: document.fetch("text"), version: document["version"] }
|
|
87
|
+
analyze(uri)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def change_document(params)
|
|
91
|
+
uri = params.fetch("textDocument").fetch("uri")
|
|
92
|
+
document = @documents[uri]
|
|
93
|
+
return unless document
|
|
94
|
+
|
|
95
|
+
# Incremental changes are not advertised. Clients must send the full text.
|
|
96
|
+
change = params.fetch("contentChanges").last
|
|
97
|
+
return if change.key?("range")
|
|
98
|
+
|
|
99
|
+
document[:text] = change.fetch("text")
|
|
100
|
+
document[:version] = params["textDocument"]["version"]
|
|
101
|
+
analyze(uri)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def close_document(uri)
|
|
105
|
+
return unless @documents.delete(uri)
|
|
106
|
+
|
|
107
|
+
send_message(method: "textDocument/publishDiagnostics", params: { uri: uri, diagnostics: [] })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def analyze(uri)
|
|
111
|
+
document = @documents.fetch(uri)
|
|
112
|
+
path, source = document.values_at(:path, :text)
|
|
113
|
+
circuit = if path.end_with?(".bk.rb")
|
|
114
|
+
Resolver.new.call(DSL.load_file(path, source: source, timeout: 2)) if @trusted_ruby && @root
|
|
115
|
+
else
|
|
116
|
+
parsed = StructuredInput.load_source(path, source)
|
|
117
|
+
(parsed.part_paths + parsed.board_paths).each { |dependency| ensure_inside_root(dependency) } if @root
|
|
118
|
+
PartLock.verify(parsed, path)
|
|
119
|
+
Resolver.new.call(parsed)
|
|
120
|
+
end
|
|
121
|
+
document[:circuit] = circuit
|
|
122
|
+
diagnostics = circuit ? circuit.diagnostics.map { |item| diagnostic(item, source) } : []
|
|
123
|
+
publish(uri, document, diagnostics)
|
|
124
|
+
rescue DSLError, ArgumentError, Psych::Exception => e
|
|
125
|
+
document[:circuit] = nil
|
|
126
|
+
location = e.respond_to?(:location) ? e.location : nil
|
|
127
|
+
line = location&.line || (e.respond_to?(:line) && e.line) || 1
|
|
128
|
+
publish(uri, document, [diagnostic_for("invalid_source", e.message, "error", line, source)])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def publish(uri, document, diagnostics)
|
|
132
|
+
send_message(method: "textDocument/publishDiagnostics",
|
|
133
|
+
params: { uri: uri, version: document[:version], diagnostics: diagnostics })
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def diagnostic(item, source)
|
|
137
|
+
diagnostic_for(item.code, item.message, item.severity, item.location&.line, source)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def diagnostic_for(code, message, severity, line, source)
|
|
141
|
+
index = [[line.to_i - 1, 0].max, source.lines.length - 1].min
|
|
142
|
+
index = 0 if index.negative?
|
|
143
|
+
end_character = source.lines[index].to_s.chomp.encode("UTF-16LE").bytesize / 2
|
|
144
|
+
{ range: { start: { line: index, character: 0 }, end: { line: index, character: end_character } },
|
|
145
|
+
severity: { "error" => 1, "warning" => 2, "info" => 3 }.fetch(severity, 3),
|
|
146
|
+
code: code, source: "breadkit", message: message }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def completion(params)
|
|
150
|
+
document = @documents[params.dig("textDocument", "uri")]
|
|
151
|
+
return [] unless document
|
|
152
|
+
|
|
153
|
+
prefix = token_prefix(document[:text], params.fetch("position"))
|
|
154
|
+
return [] if prefix.empty?
|
|
155
|
+
|
|
156
|
+
circuit = document[:circuit]
|
|
157
|
+
board = circuit&.board || board_from_source(document)
|
|
158
|
+
candidates = board ? board.holes.keys + board.rail_ids : []
|
|
159
|
+
if circuit
|
|
160
|
+
circuit.components.each_value do |component|
|
|
161
|
+
component.part.pins.each do |pin|
|
|
162
|
+
([pin["num"], pin["name"]] + Array(pin["aliases"])).compact.each do |name|
|
|
163
|
+
candidates << "#{component.ref}.#{name}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
candidates.concat(circuit.labels.map(&:name))
|
|
168
|
+
end
|
|
169
|
+
candidates.uniq.grep(/\A#{Regexp.escape(prefix)}/i).first(200).map do |name|
|
|
170
|
+
{ label: name, kind: name.include?(".") ? 5 : 6 }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def hover(params)
|
|
175
|
+
document = @documents[params.dig("textDocument", "uri")]
|
|
176
|
+
circuit = document && document[:circuit]
|
|
177
|
+
return unless circuit && circuit.diagnostics.none? { |item| item.severity == "error" }
|
|
178
|
+
|
|
179
|
+
reference = token_at(document[:text], params.fetch("position"))
|
|
180
|
+
net = circuit.net_of(reference) if reference
|
|
181
|
+
return unless net
|
|
182
|
+
|
|
183
|
+
voltage = net.potential.nil? ? "unknown" : "#{net.potential} V"
|
|
184
|
+
{ contents: { kind: "markdown", value: "**#{reference}** · net `#{net.name}`\n\nPotential: #{voltage}" } }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def token_prefix(source, position)
|
|
188
|
+
before = before_position(source, position)
|
|
189
|
+
before[/[A-Za-z0-9_.+\-]+\z/] || ""
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def token_at(source, position)
|
|
193
|
+
line = source.lines[position.fetch("line")].to_s
|
|
194
|
+
before = before_position(source, position)
|
|
195
|
+
after = line[before.length..].to_s
|
|
196
|
+
left = before[/[A-Za-z0-9_.+\-]+\z/].to_s
|
|
197
|
+
right = after[/\A[A-Za-z0-9_.+\-]+/].to_s
|
|
198
|
+
value = left + right
|
|
199
|
+
value unless value.empty?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def before_position(source, position)
|
|
203
|
+
line = source.lines[position.fetch("line")].to_s
|
|
204
|
+
units = position.fetch("character")
|
|
205
|
+
count = 0
|
|
206
|
+
line.each_char.take_while do |char|
|
|
207
|
+
count += char.encode("UTF-16LE").bytesize / 2
|
|
208
|
+
count <= units
|
|
209
|
+
end.join
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def board_from_source(document)
|
|
213
|
+
source = document[:text]
|
|
214
|
+
type = if document[:path].end_with?(".bk.rb")
|
|
215
|
+
source[/^\s*board\s+:(\w+)/, 1]
|
|
216
|
+
elsif document[:path].end_with?(".toml")
|
|
217
|
+
source[/^\s*board\s*=\s*["']([\w-]+)["']/, 1]
|
|
218
|
+
else
|
|
219
|
+
source[/^\s*board:\s*([\w-]+)/, 1]
|
|
220
|
+
end
|
|
221
|
+
Board.new(BoardDef.load(type)) if type
|
|
222
|
+
rescue ArgumentError
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def file_path(uri)
|
|
227
|
+
parsed = URI.parse(uri)
|
|
228
|
+
return unless parsed.scheme == "file"
|
|
229
|
+
|
|
230
|
+
path = URI::DEFAULT_PARSER.unescape(parsed.path)
|
|
231
|
+
path = path.delete_prefix("/") if Gem.win_platform? && path.match?(%r{\A/[A-Za-z]:/})
|
|
232
|
+
expanded = File.expand_path(path)
|
|
233
|
+
ensure_inside_root(expanded) if @root
|
|
234
|
+
expanded
|
|
235
|
+
rescue URI::InvalidURIError
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def ensure_inside_root(path)
|
|
240
|
+
expanded = File.expand_path(path)
|
|
241
|
+
ancestor = expanded
|
|
242
|
+
ancestor = File.dirname(ancestor) until File.exist?(ancestor)
|
|
243
|
+
expanded = File.realpath(ancestor) + expanded.delete_prefix(ancestor)
|
|
244
|
+
prefix = @root == File::SEPARATOR ? @root : "#{@root}#{File::SEPARATOR}"
|
|
245
|
+
raise ArgumentError, "path is outside the LSP root" unless expanded.start_with?(prefix)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|