spec_forge 0.7.1 → 1.0.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +124 -202
  4. data/bin/spec_forge +1 -1
  5. data/flake.lock +76 -4
  6. data/flake.nix +5 -4
  7. data/lib/spec_forge/attribute/chainable.rb +6 -6
  8. data/lib/spec_forge/attribute/environment.rb +45 -0
  9. data/lib/spec_forge/attribute/factory.rb +26 -17
  10. data/lib/spec_forge/attribute/faker.rb +6 -1
  11. data/lib/spec_forge/attribute/generate.rb +114 -0
  12. data/lib/spec_forge/attribute/literal.rb +1 -14
  13. data/lib/spec_forge/attribute/matcher.rb +6 -2
  14. data/lib/spec_forge/attribute/parameterized.rb +20 -22
  15. data/lib/spec_forge/attribute/resolvable_array.rb +16 -16
  16. data/lib/spec_forge/attribute/resolvable_hash.rb +17 -16
  17. data/lib/spec_forge/attribute/resolvable_struct.rb +67 -0
  18. data/lib/spec_forge/attribute/template.rb +118 -0
  19. data/lib/spec_forge/attribute/transform.rb +14 -19
  20. data/lib/spec_forge/attribute/variable.rb +31 -31
  21. data/lib/spec_forge/attribute.rb +54 -100
  22. data/lib/spec_forge/blueprint.rb +27 -0
  23. data/lib/spec_forge/cli/docs/generate.rb +28 -8
  24. data/lib/spec_forge/cli/docs.rb +5 -2
  25. data/lib/spec_forge/cli/init.rb +4 -4
  26. data/lib/spec_forge/cli/new.rb +78 -27
  27. data/lib/spec_forge/cli/run.rb +84 -52
  28. data/lib/spec_forge/cli/serve.rb +5 -0
  29. data/lib/spec_forge/cli.rb +6 -14
  30. data/lib/spec_forge/configuration.rb +209 -79
  31. data/lib/spec_forge/documentation/{loader → builder}/cache.rb +26 -23
  32. data/lib/spec_forge/documentation/builder/compiler.rb +373 -0
  33. data/lib/spec_forge/documentation/builder/extractor.rb +75 -0
  34. data/lib/spec_forge/documentation/builder.rb +77 -329
  35. data/lib/spec_forge/documentation/document/operation.rb +4 -4
  36. data/lib/spec_forge/documentation/document.rb +0 -6
  37. data/lib/spec_forge/documentation/generator.rb +88 -0
  38. data/lib/spec_forge/documentation/{generators/openapi → openapi/v3_0}/error_formatter.rb +2 -2
  39. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +1 -1
  40. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +1 -1
  41. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +21 -5
  42. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +28 -6
  43. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +20 -2
  44. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +1 -1
  45. data/lib/spec_forge/documentation/openapi/v3_0.rb +116 -0
  46. data/lib/spec_forge/documentation/openapi.rb +40 -12
  47. data/lib/spec_forge/documentation.rb +1 -7
  48. data/lib/spec_forge/error.rb +215 -41
  49. data/lib/spec_forge/factory.rb +38 -18
  50. data/lib/spec_forge/forge/action.rb +41 -0
  51. data/lib/spec_forge/forge/actions/call.rb +33 -0
  52. data/lib/spec_forge/forge/actions/debug.rb +47 -0
  53. data/lib/spec_forge/forge/actions/expect.rb +44 -0
  54. data/lib/spec_forge/forge/actions/request.rb +65 -0
  55. data/lib/spec_forge/forge/actions/store.rb +31 -0
  56. data/lib/spec_forge/forge/callbacks.rb +80 -0
  57. data/lib/spec_forge/forge/context.rb +41 -0
  58. data/lib/spec_forge/forge/display.rb +503 -0
  59. data/lib/spec_forge/forge/hooks.rb +131 -0
  60. data/lib/spec_forge/forge/runner/array_io.rb +81 -0
  61. data/lib/spec_forge/forge/runner/content_validator.rb +92 -0
  62. data/lib/spec_forge/forge/runner/header_validator.rb +66 -0
  63. data/lib/spec_forge/forge/runner/reporter.rb +56 -0
  64. data/lib/spec_forge/forge/runner/schema_validator.rb +113 -0
  65. data/lib/spec_forge/forge/runner.rb +118 -0
  66. data/lib/spec_forge/forge/timer.rb +94 -0
  67. data/lib/spec_forge/forge/variables.rb +38 -0
  68. data/lib/spec_forge/forge.rb +207 -133
  69. data/lib/spec_forge/http/backend.rb +49 -146
  70. data/lib/spec_forge/http/client.rb +14 -17
  71. data/lib/spec_forge/http/request.rb +37 -84
  72. data/lib/spec_forge/http/verb.rb +4 -0
  73. data/lib/spec_forge/http.rb +0 -5
  74. data/lib/spec_forge/loader/filter.rb +85 -0
  75. data/lib/spec_forge/loader/step_processor.rb +282 -0
  76. data/lib/spec_forge/loader.rb +105 -220
  77. data/lib/spec_forge/normalizer/default.rb +1 -1
  78. data/lib/spec_forge/normalizer/structure.rb +140 -0
  79. data/lib/spec_forge/normalizer/transformers.rb +168 -0
  80. data/lib/spec_forge/normalizer/validators.rb +50 -8
  81. data/lib/spec_forge/normalizer.rb +76 -119
  82. data/lib/spec_forge/normalizers/callback.yml +38 -0
  83. data/lib/spec_forge/normalizers/configuration.yml +59 -9
  84. data/lib/spec_forge/normalizers/factory.yml +53 -2
  85. data/lib/spec_forge/normalizers/factory_reference.yml +63 -2
  86. data/lib/spec_forge/normalizers/json_schema.yml +79 -0
  87. data/lib/spec_forge/normalizers/step.yml +506 -0
  88. data/lib/spec_forge/step/call.rb +36 -0
  89. data/lib/spec_forge/step/expect.rb +110 -0
  90. data/lib/spec_forge/step/source.rb +22 -0
  91. data/lib/spec_forge/step.rb +129 -0
  92. data/lib/spec_forge/type.rb +115 -66
  93. data/lib/spec_forge/version.rb +1 -1
  94. data/lib/spec_forge.rb +44 -106
  95. data/lib/templates/forge_helper.rb.tt +43 -22
  96. data/lib/templates/new_blueprint.yml.tt +54 -0
  97. metadata +75 -44
  98. data/lib/spec_forge/attribute/global.rb +0 -96
  99. data/lib/spec_forge/attribute/store.rb +0 -65
  100. data/lib/spec_forge/backtrace_formatter.rb +0 -50
  101. data/lib/spec_forge/callbacks.rb +0 -88
  102. data/lib/spec_forge/context/callbacks.rb +0 -91
  103. data/lib/spec_forge/context/global.rb +0 -72
  104. data/lib/spec_forge/context/store.rb +0 -131
  105. data/lib/spec_forge/context/variables.rb +0 -91
  106. data/lib/spec_forge/context.rb +0 -36
  107. data/lib/spec_forge/core_ext/rspec.rb +0 -55
  108. data/lib/spec_forge/core_ext.rb +0 -5
  109. data/lib/spec_forge/documentation/generators/base.rb +0 -81
  110. data/lib/spec_forge/documentation/generators/openapi/base.rb +0 -100
  111. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +0 -65
  112. data/lib/spec_forge/documentation/generators/openapi.rb +0 -59
  113. data/lib/spec_forge/documentation/generators.rb +0 -17
  114. data/lib/spec_forge/documentation/loader.rb +0 -159
  115. data/lib/spec_forge/documentation/openapi/base.rb +0 -33
  116. data/lib/spec_forge/filter.rb +0 -86
  117. data/lib/spec_forge/normalizer/definition.rb +0 -248
  118. data/lib/spec_forge/normalizers/_shared.yml +0 -76
  119. data/lib/spec_forge/normalizers/constraint.yml +0 -8
  120. data/lib/spec_forge/normalizers/expectation.yml +0 -47
  121. data/lib/spec_forge/normalizers/global_context.yml +0 -28
  122. data/lib/spec_forge/normalizers/spec.yml +0 -50
  123. data/lib/spec_forge/runner/adapter.rb +0 -181
  124. data/lib/spec_forge/runner/callbacks.rb +0 -246
  125. data/lib/spec_forge/runner/debug_proxy.rb +0 -215
  126. data/lib/spec_forge/runner/listener.rb +0 -54
  127. data/lib/spec_forge/runner/metadata.rb +0 -58
  128. data/lib/spec_forge/runner/state.rb +0 -98
  129. data/lib/spec_forge/runner.rb +0 -75
  130. data/lib/spec_forge/spec/expectation/constraint.rb +0 -127
  131. data/lib/spec_forge/spec/expectation.rb +0 -68
  132. data/lib/spec_forge/spec.rb +0 -68
  133. data/lib/templates/new_spec.yml.tt +0 -43
@@ -2,243 +2,128 @@
2
2
 
3
3
  module SpecForge
4
4
  #
5
- # Responsible for loading specs from YAML files and converting them to testable objects
5
+ # Loads and processes blueprint YAML files into executable Blueprint objects
6
6
  #
7
- # The Loader reads spec files, parses them as YAML, and transforms them into
8
- # a structure that can be used to create Forge objects. It also extracts
9
- # metadata like line numbers for error reporting.
10
- #
11
- # @example Loading all specs
12
- # specs = Loader.load_from_files
7
+ # The Loader handles the load-time phase of SpecForge, reading YAML files,
8
+ # parsing steps with line numbers, expanding includes, flattening hierarchies,
9
+ # and applying filters.
13
10
  #
14
11
  class Loader
15
- class << self
16
- #
17
- # Loads all spec YAML files and transforms them into normalized structures
18
- #
19
- # @return [Array<Array>] Array of [global, metadata, specs] for each loaded file
20
- #
21
- def load_from_files
22
- # metadata is not normalized because its not user managed
23
- load_specs_from_files.map do |global, metadata, specs|
24
- global =
25
- begin
26
- Normalizer.normalize!(global, using: :global_context)
27
- rescue => e
28
- raise Error::SpecLoadError.new(e, metadata[:relative_path])
29
- end
30
-
31
- specs =
32
- specs.map do |spec|
33
- Normalizer.normalize!(spec, using: :spec, label: "spec \"#{spec[:name]}\"")
34
- rescue => e
35
- raise Error::SpecLoadError.new(e, metadata[:relative_path], spec:)
36
- end
37
-
38
- [global, metadata, specs]
39
- end
40
- end
12
+ #
13
+ # Loads blueprints from disk with optional filtering
14
+ #
15
+ # @param base_path [Pathname, String, nil] Base directory for glob loading (defaults to blueprints/)
16
+ # @param paths [Array<Pathname, String>] Specific file paths to load (no globbing)
17
+ # @param tags [Array<String>] Tags to include
18
+ # @param skip_tags [Array<String>] Tags to exclude
19
+ #
20
+ # @return [Array<Blueprint>, Hash] Loaded blueprint objects and any forge hooks
21
+ #
22
+ def self.load_blueprints(base_path: nil, paths: [], tags: [], skip_tags: [])
23
+ new(base_path:, paths:, filter: {tags:, skip_tags:}).load
24
+ end
41
25
 
42
- #
43
- # Internal method that handles loading specs from files
44
- #
45
- # This method coordinates the entire spec loading process by:
46
- # 1. Reading files from the specs directory
47
- # 2. Parsing them as YAML
48
- # 3. Transforming them into the proper structure
49
- #
50
- # @return [Array<Array>] Array of [global, metadata, specs] for each loaded file
51
- #
52
- # @private
53
- #
54
- def load_specs_from_files
55
- files = read_from_files
56
- parse_and_transform_specs(files)
57
- end
26
+ #
27
+ # Creates a new Loader with the specified base path and filter options
28
+ #
29
+ # @param base_path [Pathname, String, nil] Base directory for glob loading (defaults to blueprints/)
30
+ # @param paths [Array<Pathname, String>] Specific file paths to load (no globbing)
31
+ # @param filter [Hash] Filter options for tags and skip_tags
32
+ #
33
+ # @return [Loader] A new loader instance
34
+ #
35
+ def initialize(base_path: nil, paths: [], filter: {})
36
+ @base_path = base_path.present? ? Pathname.new(base_path) : SpecForge.forge_path.join("blueprints")
37
+ @paths = Array.wrap(paths).map { |p| Pathname.new(p) }
38
+ @filter = filter
39
+ end
58
40
 
59
- #
60
- # Reads spec files from the spec_forge/specs directory
61
- #
62
- # @return [Array<Array<String, String>>] Array of [file_path, file_content] pairs
63
- #
64
- # @private
65
- #
66
- def read_from_files
67
- path = SpecForge.forge_path.join("specs")
68
-
69
- Dir[path.join("**/*.yml")].map do |file_path|
70
- [file_path, File.read(file_path)]
71
- end
72
- end
41
+ #
42
+ # Loads and processes all blueprints, extracting any hook data at the same time
43
+ #
44
+ # @return [Array<Blueprint>, Hash] Loaded blueprint objects and any forge hooks
45
+ #
46
+ def load
47
+ blueprints, forge_hooks = read_blueprints
48
+ .index_by { |b| b[:name] }
49
+ .then { |blueprints| StepProcessor.new(blueprints).run }
73
50
 
74
- #
75
- # Parses YAML content and extracts line numbers for error reporting
76
- #
77
- # @param files [Array<Array<String, String>>] Array of [file_path, file_content] pairs
78
- #
79
- # @return [Array<Array>] Array of [global, metadata, specs] for each file
80
- #
81
- # @private
82
- #
83
- def parse_and_transform_specs(files)
84
- base_path = SpecForge.forge_path.join("specs")
85
-
86
- files.map do |file_path, content|
87
- relative_path = Pathname.new(file_path).relative_path_from(base_path)
88
-
89
- hash = YAML.safe_load(content, symbolize_names: true)
90
-
91
- file_line_numbers = extract_line_numbers(content, hash)
92
-
93
- # Currently, only holds onto global variables
94
- global = hash.delete(:global) || {}
95
-
96
- metadata = {
97
- file_name: relative_path.basename(".yml").to_s,
98
- relative_path: relative_path.to_s,
99
- file_path:
100
- }
101
-
102
- specs =
103
- hash.map do |spec_name, spec_hash|
104
- line_number, *expectation_line_numbers = file_line_numbers[spec_name]
105
-
106
- spec_hash[:id] = "spec_#{SpecForge.generate_id(spec_hash)}"
107
- spec_hash[:name] = spec_name.to_s
108
- spec_hash[:file_path] = metadata[:file_path]
109
- spec_hash[:file_name] = metadata[:file_name]
110
- spec_hash[:line_number] = line_number
111
-
112
- # Check for expectations instead of defaulting. I want it to error
113
- if (expectations = spec_hash[:expectations])
114
- expectations.zip(expectation_line_numbers) do |expectation_hash, line_number|
115
- expectation_hash[:id] = "expect_#{SpecForge.generate_id(expectation_hash)}"
116
- expectation_hash[:name] = build_expectation_name(spec_hash, expectation_hash)
117
- expectation_hash[:line_number] = line_number
118
- end
119
- end
120
-
121
- spec_hash
122
- end
123
-
124
- [global, metadata, specs]
125
- end
126
- end
51
+ blueprints = Filter.new(blueprints).run(**@filter).map { |b| Blueprint.new(**b) }
127
52
 
128
- #
129
- # Extracts line numbers from each YAML section for error reporting
130
- #
131
- # @param content [String] The raw file content
132
- # @param input_hash [Hash] The parsed YAML structure
133
- #
134
- # @return [Hash] A mapping of spec names to line numbers
135
- #
136
- # @private
137
- #
138
- def extract_line_numbers(content, input_hash)
139
- # I hate this code, lol, and it hates me.
140
- # I've tried to make it better, I've tried to clean it up, but every time I break it.
141
- # If you know how to make this better, please submit a PR and save me.
142
- spec_names = input_hash.keys
143
- keys = {}
144
-
145
- current_spec_name = nil
146
- expectations_line = nil
147
- expectations_indent = nil
148
-
149
- content.lines.each_with_index do |line, index|
150
- line_number = index + 1
151
- clean_line = line.rstrip
152
- indentation = line[/^\s*/].size
153
-
154
- # Skip blank lines
155
- next if clean_line.empty?
156
-
157
- # Reset on top-level elements
158
- if indentation == 0
159
- current_spec_name = nil
160
- expectations_line = nil
161
- expectations_indent = nil
162
-
163
- # Check if this line starts a spec we're interested in
164
- spec_names.each do |spec_name|
165
- next unless clean_line.start_with?("#{spec_name}:")
166
-
167
- current_spec_name = spec_name
168
- keys[current_spec_name] = [line_number]
169
- break
170
- end
171
-
172
- next
173
- end
53
+ [blueprints, forge_hooks]
54
+ end
174
55
 
175
- # Skip if we're not in a relevant spec
176
- next unless current_spec_name
56
+ private
177
57
 
178
- # Found expectations section
179
- if clean_line.match?(/^[^#]\s*expectations:/i)
180
- expectations_line = line_number
181
- expectations_indent = indentation
182
- next
183
- end
58
+ def read_blueprints
59
+ # Use specific paths if provided, otherwise glob from base_path
60
+ file_paths =
61
+ if @paths.present?
62
+ @paths
63
+ else
64
+ Dir.glob(@base_path.join("**", "*.{yml,yaml}")).map { |p| Pathname.new(p) }
65
+ end
184
66
 
185
- # Found an expectation item
186
- if expectations_line && clean_line.start_with?("#{" " * expectations_indent}- ")
187
- keys[current_spec_name] << line_number
67
+ file_paths.map do |file_path|
68
+ content = File.read(file_path)
69
+
70
+ # Determine the relative path for naming
71
+ relative_path =
72
+ if @paths.present?
73
+ # For specific paths, use the filename as the base
74
+ file_path.basename
75
+ else
76
+ # For glob loading, use path relative to base_path
77
+ file_path.relative_path_from(@base_path)
188
78
  end
189
- end
190
79
 
191
- keys
80
+ name = relative_path.to_s.delete_suffix(".yml").delete_suffix(".yaml")
81
+ steps = parse_steps(content)
82
+
83
+ {file_path: relative_path, name:, steps:}
192
84
  end
85
+ end
86
+
87
+ def parse_steps(content)
88
+ # Parse with Psych to make it easier to extract line numbers
89
+ yaml = Psych.parse(content)
193
90
 
194
- #
195
- # Builds a name for an expectation based on HTTP verb, URL, and optional name
196
- #
197
- # @param spec_hash [Hash] The spec configuration
198
- # @param expectation_hash [Hash] The expectation configuration
199
- #
200
- # @return [String] A formatted expectation name (e.g., "GET /users - Find User")
201
- #
202
- # @private
203
- #
204
- def build_expectation_name(spec_hash, expectation_hash)
205
- # Create a structure for http_verb and url
206
- # Removing the defaults and validators to avoid triggering extra logic
207
- structure = Normalizer.structures[:spec][:structure].slice(:http_verb, :url)
208
- .transform_values { |v| v.except(:default, :validator) }
209
-
210
- # Ignore any errors. These will be validated later
211
- normalized_spec, _ = Normalizer.normalize(spec_hash, using: structure, label: "n/a")
212
- normalized_expectation, _ = Normalizer.normalize(
213
- expectation_hash,
214
- using: structure, label: "n/a"
215
- )
216
-
217
- request_data = normalized_spec.deep_merge(normalized_expectation)
218
-
219
- url = request_data[:url]
220
- http_verb = request_data[:http_verb].presence || "GET"
221
-
222
- # Finally generate the name
223
- generate_expectation_name(http_verb:, url:, name: expectation_hash[:name])
91
+ steps = yaml.to_ruby(symbolize_names: true)
92
+ inject_line_numbers(yaml.root, steps)
93
+ end
94
+
95
+ def inject_line_numbers(yaml_node, ruby_object)
96
+ case ruby_object
97
+ when Array
98
+ inject_line_numbers_into_array(yaml_node, ruby_object)
99
+ when Hash
100
+ inject_line_numbers_into_hash(yaml_node, ruby_object)
101
+ else
102
+ ruby_object
224
103
  end
104
+ end
225
105
 
226
- #
227
- # Generates an expectation name from its components
228
- #
229
- # @param http_verb [String] The HTTP verb (GET, POST, etc.)
230
- # @param url [String] The URL path
231
- # @param name [String, nil] Optional descriptive name
232
- #
233
- # @return [String] A formatted expectation name
234
- #
235
- # @private
236
- #
237
- def generate_expectation_name(http_verb:, url:, name: nil)
238
- base = "#{http_verb.upcase} #{url}" # GET /users
239
- base += " - #{name}" if name.present? # GET /users - Returns 404 because y not?
240
- base
106
+ def inject_line_numbers_into_array(yaml_node, array)
107
+ yaml_node.children
108
+ .map
109
+ .with_index { |node, index| inject_line_numbers(node, array[index]) }
110
+ end
111
+
112
+ def inject_line_numbers_into_hash(yaml_node, hash)
113
+ # Psych uses 0-indexed line numbers
114
+ hash[:line_number] = yaml_node.start_line + 1
115
+
116
+ # Walk through key-value pairs in the YAML tree
117
+ yaml_node.children.each_slice(2) do |key_node, value_node|
118
+ key = key_node.value.to_sym
119
+
120
+ # Only recursively add line numbers to substeps.
121
+ next unless key == :steps
122
+
123
+ hash[key] = inject_line_numbers(value_node, hash[key])
241
124
  end
125
+
126
+ hash
242
127
  end
243
128
  end
244
129
  end
@@ -15,7 +15,7 @@ module SpecForge
15
15
  structure.each_with_object({}) do |(attribute_name, attribute), hash|
16
16
  type = attribute[:type]
17
17
  has_default = attribute.key?(:default)
18
- required = attribute[:required] != false
18
+ required = attribute[:required] == true
19
19
 
20
20
  next if !(include_optional || required || has_default)
21
21
 
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ #
6
+ # Represents a parsed structure definition for normalization
7
+ #
8
+ # Structure definitions specify the expected shape of input data,
9
+ # including types, defaults, aliases, and nested structures.
10
+ # They are loaded from YAML files and normalized themselves.
11
+ #
12
+ class Structure < Hash
13
+ # Meta-structure defining the format of structure definitions
14
+ #
15
+ # @return [Hash]
16
+ STRUCTURE = {
17
+ type: {
18
+ type: [String, Array, Class],
19
+ default: nil, # Important to default this to nil so other logic can handle it
20
+ required: true,
21
+ validator: :present?
22
+ },
23
+ default: {
24
+ type: [String, NilClass, Numeric, Array, Hash, TrueClass, FalseClass]
25
+ },
26
+ required: {
27
+ type: [TrueClass, FalseClass]
28
+ },
29
+ aliases: {
30
+ type: Array,
31
+ structure: {type: String}
32
+ },
33
+ structure: {
34
+ type: Hash
35
+ },
36
+ validator: {
37
+ type: String
38
+ },
39
+ transformer: {
40
+ type: String
41
+ },
42
+ description: {
43
+ type: String
44
+ },
45
+ examples: {
46
+ type: Array,
47
+ structure: {
48
+ type: [String, Integer, Float, Hash, Array, TrueClass, FalseClass]
49
+ }
50
+ }
51
+ }.freeze
52
+
53
+ # @return [String] Human-readable label for this structure
54
+ attr_reader :label
55
+
56
+ #
57
+ # Creates a new Structure from the given input definition
58
+ #
59
+ # @param input [Hash] The raw structure definition from YAML
60
+ # @param label [String] Human-readable label for error messages
61
+ #
62
+ # @return [Structure] A new structure instance
63
+ #
64
+ def initialize(input, label: "")
65
+ @label = label
66
+
67
+ # Pull in the data
68
+ deep_merge!(input)
69
+
70
+ # And normalize
71
+ normalize
72
+ end
73
+
74
+ private
75
+
76
+ def normalize
77
+ # Normalize the root level keys
78
+ transform_values!(with_key: true) do |attribute, name|
79
+ normalize_attribute(name, attribute)
80
+ end
81
+
82
+ self
83
+ end
84
+
85
+ def normalize_attribute(attribute_name, attribute)
86
+ case attribute
87
+ # Shorthands for single/multiple types
88
+ when String, Array
89
+ hash = {type: resolve_type(attribute)}
90
+
91
+ default = Normalizer.default(structure: STRUCTURE)
92
+ hash.merge!(default)
93
+ # Full syntax
94
+ when Hash
95
+ hash = Normalizer.raise_errors! do
96
+ Normalizer.new(
97
+ "#{attribute_name.in_quotes} in #{@label.in_quotes}",
98
+ attribute,
99
+ structure: STRUCTURE
100
+ ).normalize
101
+ end
102
+
103
+ hash[:type] = resolve_type(attribute[:type])
104
+
105
+ if hash[:structure].present?
106
+ hash[:structure] = normalize_structure(attribute_name, hash) || {}
107
+ end
108
+
109
+ hash
110
+ else
111
+ raise ArgumentError, "Attribute #{attribute_name.in_quotes}: Expected String, Array, or Hash. Got #{attribute.inspect}"
112
+ end
113
+ end
114
+
115
+ def normalize_structure(name, hash)
116
+ if hash[:type] == Array
117
+ normalize_attribute(name, hash[:structure])
118
+ elsif hash[:type] == Hash
119
+ hash[:structure].transform_values(with_key: true) { |v, k| normalize_attribute(k, v) }
120
+ end
121
+ end
122
+
123
+ def resolve_type(type)
124
+ if type == "boolean"
125
+ [FalseClass, TrueClass]
126
+ elsif type == "any"
127
+ [Array, FalseClass, Hash, NilClass, Numeric, String, TrueClass]
128
+ elsif type.instance_of?(Array)
129
+ type.map { |t| resolve_type(t) }
130
+ elsif type.is_a?(String)
131
+ type.classify.constantize
132
+ else
133
+ type
134
+ end
135
+ rescue NameError => e
136
+ raise Error, "#{e}. #{type.inspect} is not a valid type found in #{@label.in_quotes}"
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ #
6
+ # Provides transformation functions for normalizer structure definitions
7
+ #
8
+ # Transformers modify values during normalization, such as converting
9
+ # shorthand syntax into full structures or normalizing type definitions.
10
+ #
11
+ class Transformers
12
+ include Singleton
13
+
14
+ #
15
+ # Calls a transformer method with the given value
16
+ #
17
+ # @param method_name [Symbol, String] The transformer method to call
18
+ # @param value [Object] The value to transform
19
+ #
20
+ # @return [Object] The transformed value
21
+ #
22
+ def self.call(method_name, value)
23
+ instance.public_send(method_name, value)
24
+ end
25
+
26
+ #
27
+ # Normalizes include values to an array of blueprint names
28
+ #
29
+ # @param value [String, Array<String>] Include value(s)
30
+ #
31
+ # @return [Array<String>] Normalized blueprint names without extensions
32
+ #
33
+ def normalize_includes(value)
34
+ Array(value).map! { |name| name.delete_suffix(".yml").delete_suffix(".yaml") }
35
+ end
36
+
37
+ #
38
+ # Normalizes callback shorthand into full hash format
39
+ #
40
+ # Converts string callback names into the full hash structure with
41
+ # a :name key. Hashes pass through unchanged. Arrays are processed
42
+ # recursively to normalize each element.
43
+ #
44
+ # @param value [String, Hash, Array] Callback name, full definition, or array of callbacks
45
+ #
46
+ # @return [Array<Hash>] Normalized callback hash(es) with :name key
47
+ #
48
+ def normalize_callback(value)
49
+ return if value.blank?
50
+
51
+ case value
52
+ when Hash
53
+ [value]
54
+ when Array
55
+ value.map { |v| normalize_callback(v) }.flatten
56
+ else
57
+ [{name: value}]
58
+ end
59
+ end
60
+
61
+ #
62
+ # Normalizes a shape definition into a structured schema format
63
+ #
64
+ # Converts shorthand shape syntax (arrays, hashes, type strings) into
65
+ # the full schema structure with :type, :pattern, and :structure keys.
66
+ #
67
+ # @param value [Array, Hash, String] The shape definition to normalize
68
+ #
69
+ # @return [Hash] Normalized schema structure
70
+ #
71
+ # @raise [ArgumentError] If value is nil
72
+ #
73
+ def normalize_shape(value)
74
+ raise ArgumentError, "Shape cannot be nil" if value.nil?
75
+
76
+ case value
77
+ when Array
78
+ shape = {type: [Array]}
79
+
80
+ if value.size == 1
81
+ shape[:pattern] = normalize_shape(value.first)
82
+ elsif value.size > 1
83
+ shape[:structure] = value.map { |i| normalize_shape(i) }
84
+ else
85
+ []
86
+ end
87
+
88
+ shape
89
+ when Hash
90
+ {
91
+ type: [Hash],
92
+ structure: value.transform_values { |v| normalize_shape(v) }
93
+ }
94
+ when String
95
+ result = Type.from_string(value)
96
+ {type: result[:types], optional: result[:optional]}
97
+ end
98
+ end
99
+
100
+ #
101
+ # Normalizes a schema definition by converting type strings to classes
102
+ #
103
+ # Recursively processes schema definitions, converting string type
104
+ # specifications into their Ruby class equivalents.
105
+ #
106
+ # @param value [Array, Hash, String] The schema definition to normalize
107
+ #
108
+ # @return [Hash, Array] Normalized schema with type classes
109
+ #
110
+ # @raise [ArgumentError] If value is nil
111
+ #
112
+ def normalize_schema(value)
113
+ raise ArgumentError, "Schema cannot be nil" if value.nil?
114
+
115
+ case value
116
+ when Array
117
+ value.each { |v| normalize_schema(v) }
118
+ when Hash
119
+ if (type = value[:type]) && type.is_a?(String)
120
+ result = Type.from_string(type)
121
+
122
+ value[:type] = result[:types]
123
+ value[:optional] = result[:optional] unless value.key?(:optional)
124
+ end
125
+
126
+ # Handle explicit nullable: true (sugar for adding NilClass to type)
127
+ if value.delete(:nullable)
128
+ value[:type] ||= []
129
+ value[:type] << NilClass unless value[:type].include?(NilClass)
130
+ end
131
+
132
+ # Default optional to false if not set
133
+ value[:optional] ||= false
134
+
135
+ if (structure = value[:structure])
136
+ value[:structure] =
137
+ case structure
138
+ when Array
139
+ structure.map { |v| normalize_schema(v) }
140
+ when Hash
141
+ structure.transform_values { |v| normalize_schema(v) }
142
+ end
143
+ end
144
+
145
+ if (pattern = value[:pattern])
146
+ value[:pattern] = normalize_schema(pattern)
147
+ end
148
+
149
+ value
150
+ when String
151
+ result = Type.from_string(value)
152
+ {type: result[:types], optional: result[:optional]}
153
+ end
154
+ end
155
+
156
+ #
157
+ # Returns the absolute value of a number
158
+ #
159
+ # @param value [Numeric, nil] The value to convert
160
+ #
161
+ # @return [Numeric, nil] The absolute value, or nil if input is nil
162
+ #
163
+ def abs(value)
164
+ value&.abs
165
+ end
166
+ end
167
+ end
168
+ end