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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.codespellignore +2 -0
  3. data/.yamllint +16 -0
  4. data/README.md +109 -6
  5. data/data/boards/double_full.yml +18 -0
  6. data/data/boards/stripboard.yml +9 -0
  7. data/data/boards/universal.yml +8 -0
  8. data/data/parts/2n3904.yml +11 -0
  9. data/data/parts/2n7000_onsemi_to92.yml +10 -0
  10. data/data/parts/74hc00.yml +22 -0
  11. data/data/parts/74hc04.yml +22 -0
  12. data/data/parts/74hc08.yml +22 -0
  13. data/data/parts/74hc14.yml +22 -0
  14. data/data/parts/74hc165.yml +24 -0
  15. data/data/parts/74hc32.yml +22 -0
  16. data/data/parts/74hc595.yml +24 -0
  17. data/data/parts/active_buzzer.yml +8 -0
  18. data/data/parts/ams1117_3v3.yml +12 -0
  19. data/data/parts/arduino_nano.yml +70 -0
  20. data/data/parts/arduino_uno.yml +29 -26
  21. data/data/parts/atmega328p.yml +37 -0
  22. data/data/parts/attiny85.yml +16 -0
  23. data/data/parts/battery_box.yml +8 -0
  24. data/data/parts/cd4017.yml +24 -0
  25. data/data/parts/ck_bd04.yml +29 -0
  26. data/data/parts/crystal.yml +7 -0
  27. data/data/parts/dc_jack_2wire.yml +8 -0
  28. data/data/parts/diode.yml +1 -0
  29. data/data/parts/esp32_devkitc_v4_wroom32e.yml +86 -0
  30. data/data/parts/irlz44n.yml +10 -0
  31. data/data/parts/l293d.yml +24 -0
  32. data/data/parts/led.yml +1 -0
  33. data/data/parts/lm358.yml +16 -0
  34. data/data/parts/lm393.yml +16 -0
  35. data/data/parts/ne555.yml +2 -2
  36. data/data/parts/ntc_thermistor.yml +7 -0
  37. data/data/parts/photoresistor.yml +8 -0
  38. data/data/parts/pico.yml +89 -0
  39. data/data/parts/resistor.yml +1 -0
  40. data/data/parts/sc56_11ewa.yml +30 -0
  41. data/data/parts/seeed_xiao_samd21.yml +37 -0
  42. data/data/parts/shillehtek_mb102_4pin.yml +23 -0
  43. data/data/parts/slide_switch_spst.yml +8 -0
  44. data/data/parts/sparkfun_pro_micro_5v.yml +58 -0
  45. data/data/parts/transistor.yml +0 -1
  46. data/data/parts/ua7805.yml +10 -0
  47. data/data/parts/uln2003a.yml +23 -0
  48. data/data/parts/wp154a4sureqbfzgc.yml +11 -0
  49. data/docs/CLI.md +108 -0
  50. data/docs/COMPATIBILITY.md +60 -0
  51. data/docs/DECLARATIVE.md +138 -0
  52. data/docs/IR.md +42 -0
  53. data/docs/JUMPER_KIT.md +45 -0
  54. data/docs/LOCK.md +75 -0
  55. data/docs/LSP.md +59 -0
  56. data/docs/MB102_POWER.md +11 -0
  57. data/docs/MCP.md +44 -0
  58. data/docs/RAKE.md +19 -0
  59. data/docs/dsl.md +197 -6
  60. data/exe/breadkit-lsp +13 -0
  61. data/exe/breadkit-mcp +12 -0
  62. data/lib/breadkit/analysis.rb +174 -50
  63. data/lib/breadkit/board.rb +152 -4
  64. data/lib/breadkit/cli.rb +229 -13
  65. data/lib/breadkit/color.rb +38 -0
  66. data/lib/breadkit/dc_analysis.rb +261 -0
  67. data/lib/breadkit/dsl.rb +205 -24
  68. data/lib/breadkit/exporters.rb +202 -0
  69. data/lib/breadkit/formatter.rb +52 -0
  70. data/lib/breadkit/fritzing_export.rb +256 -0
  71. data/lib/breadkit/ir.rb +154 -22
  72. data/lib/breadkit/jumper_kit.rb +127 -0
  73. data/lib/breadkit/lsp_server.rb +248 -0
  74. data/lib/breadkit/mcp_server.rb +133 -0
  75. data/lib/breadkit/model.rb +47 -5
  76. data/lib/breadkit/part_library.rb +156 -3
  77. data/lib/breadkit/part_lock.rb +107 -0
  78. data/lib/breadkit/patterns.rb +119 -0
  79. data/lib/breadkit/rake_task.rb +25 -0
  80. data/lib/breadkit/resolver.rb +160 -28
  81. data/lib/breadkit/structured_input.rb +275 -0
  82. data/lib/breadkit/templates/555.bk.rb +34 -0
  83. data/lib/breadkit/templates/arduino.bk.rb +17 -0
  84. data/lib/breadkit/templates/led.bk.rb +21 -0
  85. data/lib/breadkit/value.rb +42 -3
  86. data/lib/breadkit/version.rb +1 -1
  87. data/lib/breadkit/wire_suggestions.rb +106 -0
  88. data/lib/breadkit.rb +28 -3
  89. data/package.json +1 -1
  90. data/schema/board-v1.json +38 -0
  91. data/schema/ir-v1.json +56 -11
  92. data/schema/ir-v2.json +173 -0
  93. data/schema/part-v1.json +52 -4
  94. data/sig/breadkit.rbs +107 -5
  95. metadata +119 -14
  96. data/docs/assets/01_led_button.svg +0 -12
  97. data/site/assets/01_led_button.svg +0 -14
  98. data/site/assets/03_arduino_blink.svg +0 -14
  99. data/site/favicon.svg +0 -7
  100. data/site/guide/components/index.html +0 -113
  101. data/site/guide/dsl/index.html +0 -132
  102. data/site/guide/index.html +0 -163
  103. data/site/index.html +0 -95
  104. 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, source: source(item.location) } },
20
- labels: circuit.labels.map { |item| { net: item.name, at: item.at, source: source(item.location) } },
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.pwd) if path.absolute?
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.board = { type: data.dig("board", "type") || "full", options: (data.dig("board", "options") || {}).transform_keys(&:to_sym) }
72
- doc.board_definitions = [data["board_definition"]] if data["board_definition"]
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"), minus: item.fetch("minus"), location: location(item["source"]) }
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"] == 1
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
- board = require_hash(data["board"], "board")
107
- require_string(board["type"], "board.type")
108
- options = require_hash(board["options"], "board.options")
109
- unless !options.key?("split_rails") || [true, false].include?(options["split_rails"])
110
- raise DSLError, "invalid IR: board.options.split_rails must be boolean"
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
- validate_board_definition(data["board_definition"]) if data.key?("board_definition")
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
- SourceLocation.new(path: source["path"], line: source["line"])
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