markdown_composer 0.7.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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +278 -0
  5. data/ROADMAP.md +80 -0
  6. data/docs/_md_composer_architecture.md +50 -0
  7. data/docs/_md_composer_cheatsheet.md +72 -0
  8. data/docs/_md_composer_concepts.md +64 -0
  9. data/docs/_md_composer_dev_guide.md +55 -0
  10. data/docs/_md_composer_getting_started.md +114 -0
  11. data/docs/_md_composer_readme.md +93 -0
  12. data/docs/_md_composer_user_guide.md +65 -0
  13. data/docs/ai/md_composer_ai_audit.md +35 -0
  14. data/docs/ai/md_composer_ai_canonical_docs.md +44 -0
  15. data/docs/ai/md_composer_ai_source_map.md +39 -0
  16. data/docs/compose/md_composer_compose_actions.md +338 -0
  17. data/docs/compose/md_composer_compose_anatomy.md +156 -0
  18. data/docs/compose/md_composer_compose_buffer.md +81 -0
  19. data/docs/compose/md_composer_compose_examples.md +31 -0
  20. data/docs/compose/md_composer_compose_include.md +136 -0
  21. data/docs/compose/md_composer_compose_select.md +198 -0
  22. data/docs/compose/md_composer_compose_sources.md +161 -0
  23. data/docs/compose/md_composer_compose_targets.md +194 -0
  24. data/docs/examples/md_composer_example_basic_compose.md +57 -0
  25. data/docs/examples/md_composer_example_buffer_target_actions.md +83 -0
  26. data/docs/examples/md_composer_example_fixtures.md +62 -0
  27. data/docs/examples/md_composer_example_html_output.md +50 -0
  28. data/docs/examples/md_composer_example_modify.md +77 -0
  29. data/docs/examples/md_composer_example_multi_row_compose.md +67 -0
  30. data/docs/examples/md_composer_example_ruby_plans.md +62 -0
  31. data/docs/examples/md_composer_example_structured_data.md +68 -0
  32. data/docs/examples/md_composer_example_transforms.md +68 -0
  33. data/docs/examples/md_composer_example_yaml_json_rows.md +56 -0
  34. data/docs/examples/md_composer_examples_readme.md +45 -0
  35. data/docs/examples/md_composer_runnable_examples.md +374 -0
  36. data/docs/examples/md_composer_source_ruby_dsl.md +88 -0
  37. data/docs/reference/md_composer_nested.md +170 -0
  38. data/docs/reference/md_composer_reference_api.md +71 -0
  39. data/docs/reference/md_composer_reference_capabilities.md +63 -0
  40. data/docs/reference/md_composer_reference_diagnostics.md +54 -0
  41. data/docs/reference/md_composer_reference_plan_schema.md +75 -0
  42. data/docs/reference/md_composer_reference_registries.md +63 -0
  43. data/docs/reference/md_composer_take.md +221 -0
  44. data/docs/reference/md_composer_unit_tokens.md +228 -0
  45. data/docs/reference/md_composer_where.md +227 -0
  46. data/docs/transform/md_composer_transform_anatomy.md +112 -0
  47. data/docs/transform/md_composer_transform_examples.md +30 -0
  48. data/docs/transform/md_composer_transform_modes.md +83 -0
  49. data/docs/transform/md_composer_transform_options.md +142 -0
  50. data/docs/transform/md_composer_transform_scope.md +97 -0
  51. data/docs/transform/md_composer_transform_transforms.md +99 -0
  52. data/examples/README.md +20 -0
  53. data/examples/advanced_composer.rb +207 -0
  54. data/examples/basic_compose.rb +24 -0
  55. data/examples/complex_composer.rb +235 -0
  56. data/examples/example_support.rb +18 -0
  57. data/examples/fixtures/current.md +179 -0
  58. data/examples/fixtures/faq.md +58 -0
  59. data/examples/fixtures/guide.md +62 -0
  60. data/examples/fixtures/site_intro.md +29 -0
  61. data/examples/fixtures/source.html +22 -0
  62. data/examples/html_input.rb +26 -0
  63. data/examples/output/advanced_composer.md +76 -0
  64. data/examples/output/basic_compose.md +25 -0
  65. data/examples/output/complex_composer.md +85 -0
  66. data/examples/output/html_input.md +4 -0
  67. data/examples/output/source_list_dsl.md +126 -0
  68. data/examples/output/standard_composer.md +46 -0
  69. data/examples/output/standard_sources_buffer.md +31 -0
  70. data/examples/output/yaml_plan.md +43 -0
  71. data/examples/plans/basic.yml +20 -0
  72. data/examples/source_list_dsl.rb +41 -0
  73. data/examples/standard_composer.rb +42 -0
  74. data/examples/standard_sources_buffer.rb +62 -0
  75. data/examples/yaml_plan.rb +17 -0
  76. data/lib/markdown_composer/capabilities.rb +223 -0
  77. data/lib/markdown_composer/composition_buffer.rb +378 -0
  78. data/lib/markdown_composer/data_path.rb +313 -0
  79. data/lib/markdown_composer/diagnostics.rb +63 -0
  80. data/lib/markdown_composer/document_index/html_parser.rb +84 -0
  81. data/lib/markdown_composer/document_index/markdown_parser.rb +338 -0
  82. data/lib/markdown_composer/document_index.rb +94 -0
  83. data/lib/markdown_composer/executor.rb +284 -0
  84. data/lib/markdown_composer/markdown_renderer.rb +105 -0
  85. data/lib/markdown_composer/plan.rb +436 -0
  86. data/lib/markdown_composer/plan_builder.rb +111 -0
  87. data/lib/markdown_composer/registries/action_entries.rb +26 -0
  88. data/lib/markdown_composer/registries/condition_entries.rb +58 -0
  89. data/lib/markdown_composer/registries/registry.rb +69 -0
  90. data/lib/markdown_composer/registries/source_entries.rb +18 -0
  91. data/lib/markdown_composer/registries/support_values.rb +23 -0
  92. data/lib/markdown_composer/registries/take_entries.rb +31 -0
  93. data/lib/markdown_composer/registries/take_registry.rb +18 -0
  94. data/lib/markdown_composer/registries/target_entries.rb +40 -0
  95. data/lib/markdown_composer/registries/unit_token_entries.rb +62 -0
  96. data/lib/markdown_composer/registries/where_registry.rb +84 -0
  97. data/lib/markdown_composer/registries.rb +46 -0
  98. data/lib/markdown_composer/result.rb +34 -0
  99. data/lib/markdown_composer/selection_resolver.rb +181 -0
  100. data/lib/markdown_composer/source.rb +57 -0
  101. data/lib/markdown_composer/source_list_builder.rb +47 -0
  102. data/lib/markdown_composer/take.rb +129 -0
  103. data/lib/markdown_composer/transform_options.rb +66 -0
  104. data/lib/markdown_composer/transform_runner/content_placement.rb +63 -0
  105. data/lib/markdown_composer/transform_runner/field_interpolator.rb +213 -0
  106. data/lib/markdown_composer/transform_runner/heading_numbering.rb +106 -0
  107. data/lib/markdown_composer/transform_runner/scope_resolver.rb +87 -0
  108. data/lib/markdown_composer/transform_runner.rb +264 -0
  109. data/lib/markdown_composer/transforms/default_entries.rb +31 -0
  110. data/lib/markdown_composer/transforms/registry.rb +11 -0
  111. data/lib/markdown_composer/validator.rb +378 -0
  112. data/lib/markdown_composer/value_object.rb +15 -0
  113. data/lib/markdown_composer/version.rb +5 -0
  114. data/lib/markdown_composer/where.rb +313 -0
  115. data/lib/markdown_composer.rb +114 -0
  116. metadata +260 -0
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ class Validator
5
+ attr_reader :plan, :sources, :options, :diagnostics
6
+
7
+ def initialize(plan, sources: [], options: {}, diagnostics: Diagnostics.new)
8
+ @plan = plan
9
+ @sources = Array(sources)
10
+ @options = options
11
+ @diagnostics = diagnostics
12
+ end
13
+
14
+ def call
15
+ validate_output
16
+ validate_steps
17
+ validate_transforms
18
+ diagnostics.concat(plan.diagnostics)
19
+ diagnostics
20
+ end
21
+
22
+ private
23
+
24
+ def validate_output
25
+ diagnostics.error("output.invalid", "Output must be markdown or html", path: "output") unless %w[markdown html].include?(plan.output)
26
+ end
27
+
28
+ def validate_steps
29
+ buffer_available = options.fetch(:initial_buffer, nil).to_s.strip != ""
30
+ previous_source = nil
31
+ plan.steps.each_with_index do |step, index|
32
+ path = "compose[#{index}]"
33
+ source = effective_source_ref(step["source"], previous_source)
34
+ validate_source(source, path: "#{path}.source", buffer_available: buffer_available, original_type: step.dig("source", "type"))
35
+ validate_selector(step["select"], :select, path: "#{path}.select")
36
+ Array(step["include"]).each_with_index { |item, item_index| validate_include(item, path: "#{path}.include[#{item_index}]", inside_data_block: data_block_selector?(step["select"])) }
37
+ validate_action(step.merge("source" => source, "_original_source_type" => step.dig("source", "type")), path: path)
38
+ validate_target(step["target"], path: "#{path}.target") if step["target"]
39
+ validate_row_transforms(step, path: path) if step["transforms"]
40
+ buffer_available ||= action_produces_buffer?(step)
41
+ previous_source = source
42
+ end
43
+ end
44
+
45
+ def effective_source_ref(source, previous_source)
46
+ return source unless source&.fetch("type", nil) == "previous"
47
+
48
+ previous_source || { "type" => "current" }
49
+ end
50
+
51
+ def validate_source(source, path:, buffer_available: true, original_type: nil)
52
+ type = source&.fetch("type", nil)
53
+ diagnostics.error("source.unknown", "Unknown source type #{type.inspect}", path: path) unless Registries.default.sources[type]
54
+ case type
55
+ when "explicit"
56
+ diagnostics.error("source.identifier_missing", "explicit source requires key, id, or slug", path: path) unless source["key"] || source["id"] || source["slug"]
57
+ when "inherited"
58
+ diagnostics.error("source.inherited_key_missing", "inherited source requires from, key, id, or slug", path: path) unless source["from"] || source["key"] || source["id"] || source["slug"]
59
+ when "inline"
60
+ diagnostics.error("source.inline_empty", "inline source requires markdown or html content", path: path) if source["markdown"].to_s.empty? && source["html"].to_s.empty?
61
+ when "current"
62
+ current_sources = sources.select { |candidate| candidate.type == "current" }
63
+ diagnostics.error("source.current_ambiguous", "current source is ambiguous; provide a key", path: path) if current_sources.length > 1 && !source["key"]
64
+ diagnostics.error("source.previous_current_missing", "previous source has no previous row and no current source", path: path) if original_type == "previous" && current_sources.empty? && !source["key"]
65
+ when "buffer"
66
+ diagnostics.error("source.buffer_unavailable", "buffer source requires previous output", path: path) unless buffer_available
67
+ end
68
+ end
69
+
70
+ def validate_selector(selector, consumer, path:)
71
+ Array(selector["types"] || selector["type"]).each do |type|
72
+ validate_unit_token(type, consumer, path: path)
73
+ end
74
+ Take.validate(selector["take"] || {}).each do |message|
75
+ diagnostics.error("take.invalid", contextual_message("#{path}.take", take_message(message)), path: "#{path}.take")
76
+ end
77
+ Where.validate(selector["where"], diagnostics: diagnostics, path: "#{path}.where", options: options) if selector["where"]
78
+ Array(selector["include"]).each_with_index { |item, index| validate_include(item, path: "#{path}.include[#{index}]", inside_data_block: data_block_selector?(selector)) }
79
+ end
80
+
81
+ def validate_include(item, path:, inside_data_block: false)
82
+ if item["exclude"]
83
+ validate_include(item["exclude"], path: "#{path}.exclude", inside_data_block: inside_data_block)
84
+ return
85
+ end
86
+ if item["type"] == "data_path"
87
+ diagnostics.error("data_path.scope_invalid", "data_path is only valid inside data_block include scope", path: path) unless inside_data_block
88
+ diagnostics.error("data_path.path_missing", "data_path requires a path", path: "#{path}.path") if item["path"].to_s.strip.empty?
89
+ return
90
+ end
91
+ validate_selector(item, :include, path: path)
92
+ end
93
+
94
+ def validate_target(target, path:)
95
+ unless target
96
+ diagnostics.error("target.missing", "Target is missing", path: path)
97
+ return
98
+ end
99
+ return if %w[output start end in_place].include?(target["position"].to_s)
100
+
101
+ if target["placement"] == "between"
102
+ diagnostics.error("target.between_missing", "Between target requires start and end anchors", path: path) unless target["start"] && target["end"]
103
+ validate_target(target["start"], path: "#{path}.start")
104
+ validate_target(target["end"], path: "#{path}.end")
105
+ else
106
+ validate_selector(target, :target, path: path)
107
+ end
108
+ end
109
+
110
+ def validate_action(step, path:)
111
+ action = step["action"]
112
+ entry = Registries.default.actions[action]
113
+ unless entry
114
+ diagnostics.error("action.unknown", "Unknown action #{action.inspect}", path: "#{path}.action")
115
+ return
116
+ end
117
+
118
+ target_rule = entry.support[:target_rule]
119
+ diagnostics.error("action.target_required", "#{action} requires a target", path: "#{path}.target") if target_rule == :required && !step["target"]
120
+ if %w[modify transform_buffer_target].include?(action) && Array(step["transforms"]).empty?
121
+ diagnostics.error("action.transform_definition_missing", "#{action} requires transforms", path: "#{path}.transforms")
122
+ end
123
+ diagnostics.error("action.move_source_invalid", "move should use source type buffer", path: "#{path}.source") if action == "move" && step.dig("source", "type") != "buffer"
124
+ return unless step["target"]
125
+
126
+ placement = step.dig("target", "placement")
127
+ diagnostics.error("target.placement_conflict", "insert_before target placement must be before", path: "#{path}.target") if action == "insert_before" && placement && placement != "before"
128
+ diagnostics.error("target.placement_conflict", "insert_after target placement must be after", path: "#{path}.target") if action == "insert_after" && placement && placement != "after"
129
+ diagnostics.error("target.between_required", "insert_between requires a Between target", path: "#{path}.target") if action == "insert_between" && placement != "between"
130
+ diagnostics.error("target.selector_required", "#{action} requires selector target, not start/end", path: "#{path}.target") if %w[remove_buffer_target transform_buffer_target].include?(action) && step.dig("target", "position")
131
+ validate_in_place_target(step, path: path) if step.dig("target", "position") == "in_place"
132
+ end
133
+
134
+ def validate_in_place_target(step, path:)
135
+ action = step["action"]
136
+ diagnostics.error(
137
+ "target.in_place_action_invalid",
138
+ "target in_place is only valid with action modify",
139
+ path: "#{path}.target"
140
+ ) unless action == "modify"
141
+
142
+ return if step["_original_source_type"] == "buffer"
143
+
144
+ diagnostics.error(
145
+ "target.in_place_source_invalid",
146
+ "target in_place requires source type buffer",
147
+ path: "#{path}.source"
148
+ )
149
+ end
150
+
151
+ def validate_row_transforms(step, path:)
152
+ Array(step["transforms"]).each_with_index do |transform, index|
153
+ if step["action"] == "transform_buffer_target" && transform["_scope_missing"]
154
+ next unless step["target"]
155
+
156
+ transform = transform.merge("scope" => step["target"])
157
+ end
158
+ validate_transform(transform, path: "#{path}.transforms[#{index}]")
159
+ end
160
+ end
161
+
162
+ def validate_transforms
163
+ plan.transforms.each_with_index do |transform, index|
164
+ validate_transform(transform, path: "transform[#{index}]")
165
+ end
166
+ end
167
+
168
+ def validate_transform(transform, path:)
169
+ validate_selector(transform["scope"], :scope, path: "#{path}.scope")
170
+ entry = Registries.default.transforms[transform["transform"]]
171
+ unless entry
172
+ diagnostics.error("transform.unknown", "Unknown transform #{transform["transform"].inspect}", path: path)
173
+ return
174
+ end
175
+
176
+ support = entry.support[:transform]
177
+ diagnostics.error("transform.policy_disabled", "#{transform["transform"]} requires adapter policy", path: path) if support == :adapter_policy && !options.fetch(:adapter_transforms, false)
178
+ modes = entry.support[:modes]
179
+ diagnostics.error("transform.mode_invalid", "Invalid mode #{transform["mode"].inspect} for #{entry.token}", path: "#{path}.mode") if modes.any? && !modes.include?(transform["mode"].to_s)
180
+ validate_transform_options(transform, path: path)
181
+ end
182
+
183
+ def validate_transform_options(transform, path:)
184
+ options_hash = transform["options"] || {}
185
+ return if plan.diagnostics.errors.any? { |diagnostic| diagnostic.code == "transform.options_syntax" && diagnostic.path == "#{path}.options" }
186
+
187
+ allowed = allowed_transform_options(transform)
188
+ options_hash.each_key do |key|
189
+ diagnostics.error("transform.option_unknown", "Unknown option #{key} for #{transform["transform"]}", path: "#{path}.options.#{key}") unless allowed.include?(key)
190
+ end
191
+
192
+ required = TransformOptions.required(transform["transform"], transform["mode"])
193
+
194
+ required.each do |key|
195
+ diagnostics.error("transform.option_missing", "Missing required option #{key}", path: "#{path}.options.#{key}") unless options_hash.key?(key)
196
+ end
197
+ if options_hash["content"].to_s.match?(/\{\{|\}\}/)
198
+ diagnostics.error("transform.option_invalid", "content uses template braces; use Composer scoped fields such as field[text]", path: "#{path}.options.content")
199
+ end
200
+ validate_content_fields(options_hash["content"], path: "#{path}.options.content") if options_hash.key?("content")
201
+ if options_hash.key?("parse_as") && !%w[markdown text html].include?(options_hash["parse_as"].to_s)
202
+ diagnostics.error("transform.option_invalid", "parse_as must be markdown, text, or html", path: "#{path}.options.parse_as")
203
+ end
204
+ if options_hash.key?("case_sensitive") && !boolean?(options_hash["case_sensitive"])
205
+ diagnostics.error("transform.option_invalid", "case_sensitive must be true or false", path: "#{path}.options.case_sensitive")
206
+ end
207
+ if options_hash.key?("skip_if_present") && !boolean?(options_hash["skip_if_present"])
208
+ diagnostics.error("transform.option_invalid", "skip_if_present must be true or false", path: "#{path}.options.skip_if_present")
209
+ end
210
+
211
+ keys_requiring_unit = %w[as unit]
212
+ keys_requiring_unit << "to" if transform["transform"] == "heading_levels"
213
+ keys_requiring_unit.each do |key|
214
+ next unless options_hash[key].is_a?(String)
215
+ next if Registries.default.unit_tokens[options_hash[key]]
216
+
217
+ diagnostics.error("transform.option_invalid", "#{key} is not a valid unit token", path: "#{path}.options.#{key}")
218
+ end
219
+ %w[by limit start].each do |key|
220
+ next unless options_hash.key?(key)
221
+
222
+ diagnostics.error("transform.option_invalid", "#{key} must be a positive integer", path: "#{path}.options.#{key}") if options_hash[key].to_i <= 0
223
+ end
224
+ diagnostics.error("transform.output_invalid", "sanitise requires HTML output", path: path) if transform["transform"] == "sanitise" && plan.output != "html"
225
+ validate_target(options_hash["target"], path: "#{path}.options.target") if options_hash["target"].is_a?(Hash)
226
+ end
227
+
228
+ def allowed_transform_options(transform)
229
+ TransformOptions.allowed(transform["transform"], transform["mode"], adapter_option_keys: options[:adapter_option_keys])
230
+ end
231
+
232
+ def boolean?(value)
233
+ value == true || value == false
234
+ end
235
+
236
+ def validate_content_fields(content, path:)
237
+ content.to_s.scan(/field\[([a-z_][a-z0-9_]*(?::[a-z0-9_]+)?(?:\.[a-z_][a-z0-9_]*(?::[a-z0-9_]+)?)*(?:\[[^\]]+\])?)\]/).flatten.each do |reference|
238
+ next if valid_content_field_reference?(reference)
239
+
240
+ diagnostics.error("transform.option_invalid", "Unknown scoped content field field[#{reference}]", path: path)
241
+ end
242
+ end
243
+
244
+ def valid_content_field_reference?(reference)
245
+ reference = reference.to_s.sub(/\[[^\]]+\]\z/, "")
246
+ parts = reference.to_s.split(".")
247
+ return valid_current_field?(parts.first) if parts.length == 1
248
+
249
+ case parts.first
250
+ when "current"
251
+ parts.length == 2 && valid_current_field?(parts.last)
252
+ when "section"
253
+ valid_section_reference?(parts)
254
+ when "parent_section"
255
+ parts.length == 2 && valid_section_field?(parts.last)
256
+ else
257
+ false
258
+ end
259
+ end
260
+
261
+ def valid_current_field?(field)
262
+ %w[
263
+ text source_text type index start_line end_line title level number
264
+ href src alt language diagram_type format location item_count ordered
265
+ item_index row_count column_count row_index column_index section
266
+ path key value
267
+ ].include?(field.to_s)
268
+ end
269
+
270
+ def valid_section_field?(field)
271
+ %w[title level text source_text type index start_line end_line number].include?(field.to_s)
272
+ end
273
+
274
+ def valid_section_reference?(parts)
275
+ return parts.length == 2 && valid_section_field?(parts.last) if parts[1] && valid_section_field?(parts[1])
276
+ return true if parts.length == 3 && parts[1] == "parent" && valid_section_field?(parts.last)
277
+ return true if parts.length == 4 && parts[1] == "parent" && parts[2] == "parent" && valid_section_field?(parts.last)
278
+ return true if parts.length == 3 && valid_ancestor_reference?(parts[1]) && valid_section_field?(parts.last)
279
+
280
+ false
281
+ end
282
+
283
+ def valid_ancestor_reference?(part)
284
+ return false unless part.to_s.start_with?("ancestor:")
285
+
286
+ heading = part.split(":", 2).last
287
+ token = Registries.default.unit_tokens.normalise(heading) || heading.to_s
288
+ token.match?(/\Aheading_[1-6]\z/)
289
+ end
290
+
291
+ def validate_unit_token(type, consumer, path:)
292
+ entry = Registries.default.unit_tokens[type]
293
+ unless entry
294
+ diagnostics.error("token.unknown", contextual_message(path, unknown_token_message(type)), path: path)
295
+ return
296
+ end
297
+
298
+ diagnostics.error("token.unsupported", contextual_message(path, "#{type} is not supported for #{consumer}"), path: path) unless entry.supports?(consumer, options)
299
+ end
300
+
301
+ def data_block_selector?(selector)
302
+ Array(selector["types"] || selector["type"]).map(&:to_s).include?("data_block")
303
+ end
304
+
305
+ def action_produces_buffer?(step)
306
+ step["action"] != "remove_buffer_target"
307
+ end
308
+
309
+ def contextual_message(path, message)
310
+ "#{human_path(path)}: #{message}"
311
+ end
312
+
313
+ def human_path(path)
314
+ row_index = path[/compose\[(\d+)\]/, 1]
315
+ return path unless row_index
316
+
317
+ field = path.sub(/\Acompose\[\d+\]\.?/, "")
318
+ field = "row" if field.empty?
319
+ "Row #{row_index.to_i + 1} #{field}"
320
+ end
321
+
322
+ def unknown_token_message(type)
323
+ value = type.to_s
324
+ if value.match?(/\b(wher|whre|wheree|were)\b/i)
325
+ return "Unknown unit token #{value.inspect}. Possible error: the `where` keyword appears misspelled. Use `<unit>[take] where <condition>`."
326
+ end
327
+
328
+ suggestion = closest_unit_token(value)
329
+ if suggestion
330
+ "Unknown unit token #{value.inspect}. Did you mean #{suggestion.inspect}?"
331
+ else
332
+ "Unknown unit token #{value.inspect}. Check the token spelling against the unit token registry."
333
+ end
334
+ end
335
+
336
+ def take_message(message)
337
+ if message =~ /\AUnknown take operator "([^"]+)"\z/
338
+ operator = Regexp.last_match(1)
339
+ suggestion = closest_take_operator(operator)
340
+ return suggestion ? "#{message}. Did you mean #{suggestion.inspect}?" : "#{message}. Check the take modifier spelling."
341
+ end
342
+
343
+ message
344
+ end
345
+
346
+ def closest_unit_token(value)
347
+ candidates = Registries.default.unit_tokens.tokens
348
+ candidate = candidates.min_by { |item| edit_distance(value, item) }
349
+ candidate if candidate && edit_distance(value, candidate) <= 3
350
+ end
351
+
352
+ def closest_take_operator(value)
353
+ candidates = Registries.default.take.keys.map(&:to_s)
354
+ candidate = candidates.min_by { |item| edit_distance(value, item) }
355
+ candidate if candidate && edit_distance(value, candidate) <= 3
356
+ end
357
+
358
+ def edit_distance(left, right)
359
+ left = left.to_s
360
+ right = right.to_s
361
+ costs = (0..right.length).to_a
362
+ left.each_char.with_index(1) do |left_char, left_index|
363
+ previous = costs[0]
364
+ costs[0] = left_index
365
+ right.each_char.with_index(1) do |right_char, right_index|
366
+ current = costs[right_index]
367
+ costs[right_index] = if left_char == right_char
368
+ previous
369
+ else
370
+ [ costs[right_index] + 1, costs[right_index - 1] + 1, previous + 1 ].min
371
+ end
372
+ previous = current
373
+ end
374
+ end
375
+ costs[right.length]
376
+ end
377
+ end
378
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ def self.value_object(*members, &block)
5
+ Struct.new(*members, keyword_init: true) do
6
+ def with(attributes)
7
+ values = {}
8
+ each_pair { |name, value| values[name] = value }
9
+ self.class.new(**values.merge(attributes))
10
+ end
11
+
12
+ class_eval(&block) if block
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ VERSION = "0.7.0"
5
+ end