spec_forge 0.5.0 → 0.6.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +3 -3
  3. data/CHANGELOG.md +106 -1
  4. data/README.md +34 -22
  5. data/flake.lock +3 -3
  6. data/flake.nix +8 -2
  7. data/lib/spec_forge/attribute/chainable.rb +208 -20
  8. data/lib/spec_forge/attribute/factory.rb +91 -14
  9. data/lib/spec_forge/attribute/faker.rb +62 -13
  10. data/lib/spec_forge/attribute/global.rb +96 -0
  11. data/lib/spec_forge/attribute/literal.rb +15 -2
  12. data/lib/spec_forge/attribute/matcher.rb +186 -11
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -12
  14. data/lib/spec_forge/attribute/regex.rb +55 -5
  15. data/lib/spec_forge/attribute/resolvable.rb +48 -5
  16. data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
  17. data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
  18. data/lib/spec_forge/attribute/store.rb +65 -0
  19. data/lib/spec_forge/attribute/transform.rb +33 -5
  20. data/lib/spec_forge/attribute/variable.rb +37 -6
  21. data/lib/spec_forge/attribute.rb +166 -66
  22. data/lib/spec_forge/backtrace_formatter.rb +26 -3
  23. data/lib/spec_forge/callbacks.rb +79 -0
  24. data/lib/spec_forge/cli/actions.rb +27 -0
  25. data/lib/spec_forge/cli/command.rb +78 -24
  26. data/lib/spec_forge/cli/init.rb +11 -1
  27. data/lib/spec_forge/cli/new.rb +54 -3
  28. data/lib/spec_forge/cli/run.rb +20 -0
  29. data/lib/spec_forge/cli.rb +16 -5
  30. data/lib/spec_forge/configuration.rb +94 -22
  31. data/lib/spec_forge/context/callbacks.rb +91 -0
  32. data/lib/spec_forge/context/global.rb +72 -0
  33. data/lib/spec_forge/context/store.rb +148 -0
  34. data/lib/spec_forge/context/variables.rb +91 -0
  35. data/lib/spec_forge/context.rb +36 -0
  36. data/lib/spec_forge/core_ext/rspec.rb +22 -4
  37. data/lib/spec_forge/error.rb +267 -113
  38. data/lib/spec_forge/factory.rb +33 -14
  39. data/lib/spec_forge/filter.rb +87 -0
  40. data/lib/spec_forge/forge.rb +170 -0
  41. data/lib/spec_forge/http/backend.rb +99 -29
  42. data/lib/spec_forge/http/client.rb +23 -13
  43. data/lib/spec_forge/http/request.rb +74 -62
  44. data/lib/spec_forge/http/verb.rb +79 -0
  45. data/lib/spec_forge/http.rb +105 -0
  46. data/lib/spec_forge/loader.rb +254 -0
  47. data/lib/spec_forge/matchers.rb +130 -0
  48. data/lib/spec_forge/normalizer/configuration.rb +24 -11
  49. data/lib/spec_forge/normalizer/constraint.rb +21 -8
  50. data/lib/spec_forge/normalizer/expectation.rb +31 -12
  51. data/lib/spec_forge/normalizer/factory.rb +24 -11
  52. data/lib/spec_forge/normalizer/factory_reference.rb +27 -13
  53. data/lib/spec_forge/normalizer/global_context.rb +88 -0
  54. data/lib/spec_forge/normalizer/spec.rb +39 -16
  55. data/lib/spec_forge/normalizer.rb +255 -41
  56. data/lib/spec_forge/runner/callbacks.rb +246 -0
  57. data/lib/spec_forge/runner/debug_proxy.rb +213 -0
  58. data/lib/spec_forge/runner/listener.rb +54 -0
  59. data/lib/spec_forge/runner/metadata.rb +58 -0
  60. data/lib/spec_forge/runner/state.rb +99 -0
  61. data/lib/spec_forge/runner.rb +132 -123
  62. data/lib/spec_forge/spec/expectation/constraint.rb +91 -20
  63. data/lib/spec_forge/spec/expectation.rb +43 -51
  64. data/lib/spec_forge/spec.rb +83 -96
  65. data/lib/spec_forge/type.rb +36 -4
  66. data/lib/spec_forge/version.rb +4 -1
  67. data/lib/spec_forge.rb +161 -76
  68. metadata +20 -5
  69. data/spec_forge/factories/user.yml +0 -4
  70. data/spec_forge/forge_helper.rb +0 -48
  71. data/spec_forge/specs/users.yml +0 -65
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f51faa712bafdcb48e3940273ec494eb3f39e43c0d122ff0a6f97e425026438
4
- data.tar.gz: fe5cf7dff0a44cf5cfc466189fb38c3ed66da21637e18ebcdb44e31f9f6a8b73
3
+ metadata.gz: 6b2d5790d4797a63b8fbad4dd296b9c9beb657b30a98bda5fab8ca7c635abd5a
4
+ data.tar.gz: b22a8f664dd676846e703f5312383c9c9b1b9cabab52b2cc3c8a8d28830490c0
5
5
  SHA512:
6
- metadata.gz: be068681e3c2ce8b2f62fa21ae03281d40b34c19e190acce4f90796dd526197b6ae54cc6edaa04d6715e347a67e2c193e2b99b0e16e45b47b3183915001790f3
7
- data.tar.gz: 644f2c1a49d3ed45a201cb53d731176a418c134679f324ef96f331427481643b2744bd6c27c0f326df32c1db751c412137ad209a3f0b0ce3add9cc68eb6a660e
6
+ metadata.gz: 858e7dfd4546bc34e7dd8f899d99b5cf03d26cfbe9042f238de44566e2981a82bcd06803a0319f6d937d7c509dc3c02e8a962415c8e6fbb8e3c515c693c5e176
7
+ data.tar.gz: 3b246fda2d4bbbc5ce12fa0f683e27e1cb0e2950fe3b7c03b978f6d88662d1b65b0037b00931fe598415ee3d3c8477f7f76fd3f523547e061f86ac000e617d52
data/.standard.yml CHANGED
@@ -2,6 +2,6 @@
2
2
  # https://github.com/standardrb/standard
3
3
  ruby_version: 3.3
4
4
  ignore:
5
- - ".direnv/**/*"
6
- - "vendor/**/*"
7
- - "spec/integration/**/*"
5
+ - ".direnv/**/*"
6
+ - "vendor/**/*"
7
+ - "spec/integration/vendor/**/*"
data/CHANGELOG.md CHANGED
@@ -19,9 +19,114 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
19
 
20
20
  ### Added
21
21
 
22
+ - Added new context system for managing shared state between tests
23
+ - Introduced `SpecForge.context` global accessor for accessing test context
24
+ - Created `Context` class with modular components:
25
+ - `Context::Global` for file-level shared variables
26
+ - `Context::Variables` for managing variables with overlay support
27
+ - `Context::Store` for storing the results of the tests
28
+ - Added support for defining and referencing global variables
29
+ ```yaml
30
+ global:
31
+ variables:
32
+ api_version: "v2"
33
+ environment: "test"
34
+
35
+ index_user:
36
+ path: "/{api_version}/users"
37
+ query:
38
+ api_version: "global.variables.api_version"
39
+ ```
40
+ - Added compound matcher support via `matcher.and` for combining multiple matchers
41
+ ```yaml
42
+ email:
43
+ matcher.and:
44
+ - kind_of.string
45
+ - /@/
46
+ - matcher.end_with: ".com"
47
+ ```
48
+ - Added custom RSpec matcher `have_size` for checking an object's size via `matcher.have_size`
49
+ - Added new `Loader` class for improved spec file processing
50
+ - Added new `Filter` class for more flexible test filtering
51
+ - Added normalizer for global context validation
52
+ - Added line number tracking for specs and expectations
53
+ - Added support for defining and referencing callbacks
54
+ ```ruby
55
+ # Configuration level
56
+ SpecForge.configure do |config|
57
+ config.register_callback("callback_name") { |context| }
58
+ # These are aliases
59
+ # config.define_callback("callback_name") { |context| }
60
+ # config.callback("callback_name") { |context| }
61
+ end
62
+
63
+ # Module level (no aliases)
64
+ SpecForge.register_callback("callback_name") { |context| }
65
+ ```
66
+ Once defined, callbacks can be referenced in spec files via the global context
67
+ ```yaml
68
+ global:
69
+ callbacks:
70
+ - before: callback_name
71
+ after: cleanup_database_state
72
+ ```
73
+ - Added support for storing and retrieving test data via the `store_as` directive and `store` attribute
74
+ ```yaml
75
+ create_user:
76
+ path: "/users"
77
+ method: "post"
78
+ expectations:
79
+ - variables:
80
+ name: "John"
81
+ email: "john@example.com"
82
+ store_as: "created_user"
83
+ expect:
84
+ status: 200
85
+
86
+ show_user:
87
+ path: "/users/:id"
88
+ query:
89
+ id: store.created_user.response.id
90
+ - expect:
91
+ status: 200
92
+ ```
93
+ - Added `UndefinedMatcherError` for clearer error messaging when invalid matchers are used
94
+ - Enhanced debugging capabilities with improved DebugProxy methods and store access
95
+ - Added HTTP status descriptions for better error messages
96
+ - Added support for string values in `query`, `body`, and `variables` attributes
97
+ - Added print statement when filtering tests for better visibility
98
+
22
99
  ### Changed
23
100
 
24
- ### Removed
101
+ - Renamed `SpecForge.forge` to `SpecForge.forge_path`
102
+ - Renamed attribute `http_method` to `http_verb` (`http_method` is now an alias)
103
+ - Refactored attribute resolution methods:
104
+ - Renamed `Attribute#resolve` to `#resolved` (memoized version)
105
+ - Renamed `Attribute#resolve_value` to `#resolve` (immediate resolution)
106
+ - Added `Attribute#resolve_as_matcher` for resolving attributes into RSpec matchers
107
+ - Refactored variable resolution to use the new context system
108
+ - Updated `Runner` to properly initialize and manage context between tests
109
+ - Improved error messages with more contextual information about the execution environment
110
+ - Updated YARD comments with better API descriptions and examples
111
+ - Restructured internal architecture for better separation of concerns
112
+ - Moved all error classes under `SpecForge::Error`
113
+ - Fixed issue where nesting expanded matchers (such as `matcher.include`) would cause an error
114
+ - Improved response body validation for hash expectations:
115
+ - Each root-level key is now checked individually for more precise error messages
116
+ - Nested hashes still use the `include` matcher for flexibility
117
+ - Adjusted `Attribute::Matcher` to accept either `matcher` or `matchers` namespace
118
+ - Changed empty array matcher from using `contain_exactly` to `eq([])`
119
+ - Changed empty hash matcher from using `include` to `eq({})`
120
+ - Changed `forge_and` description from "matches all of:" to "match all:"
121
+ - Improved error handling for chainable attributes with better descriptions for various object types
122
+ - Limited error backtrace to 50 lines for cleaner output
123
+ - Enhanced spec loading error messages with more detailed information
124
+ - Improved RSpec example descriptions for better test output
125
+ - Added support for overwriting headers at the request level
126
+
127
+ ## Removed
128
+
129
+ - Removed `Configuration.overlay_options`
25
130
 
26
131
  ## [0.5.0] - 12025-02-28
27
132
 
data/README.md CHANGED
@@ -4,30 +4,50 @@
4
4
  ![Ruby Version](https://img.shields.io/badge/ruby-3.3.7-ruby)
5
5
  [![Tests](https://github.com/itsthedevman/spec_forge/actions/workflows/main.yml/badge.svg)](https://github.com/itsthedevman/spec_forge/actions/workflows/main.yml)
6
6
 
7
+ > Note: The code in this repository represents the latest development version with new features and improvements that are being prepared for future releases. For the current stable version, check out [v0.6.0](https://github.com/itsthedevman/spec_forge/releases/tag/v0.6.0) on GitHub releases.
8
+
7
9
  Write API tests in YAML that read like documentation:
8
10
 
9
11
  ```yaml
10
- user_profile:
11
- path: /users/1
12
+ show_user:
13
+ path: /users/{id}
14
+ variables:
15
+ expected_status: 200
16
+ user_id: 1
17
+ query:
18
+ id: variables.user_id
12
19
  expectations:
13
20
  - expect:
14
- status: 200
21
+ status: variables.expected_status
15
22
  json:
16
23
  name: kind_of.string
17
- email: /@/
24
+ email:
25
+ matcher.and:
26
+ - kind_of.string
27
+ - /@/
18
28
  ```
19
29
 
20
30
  That's a complete test. No Ruby code, no configuration files, no HTTP client setup - just a clear description of what you're testing. Under the hood, you get all the power of RSpec's matchers, Faker's data generation, and FactoryBot's test objects.
21
31
 
22
32
  ## Why SpecForge?
23
33
 
24
- SpecForge shines when you need:
34
+ 1. **Living Documentation**: Your tests should serve as clear, readable documentation of your API's behavior.
35
+ 2. **Reduce Boilerplate**: Write tests without repetitive setup code and HTTP configuration.
36
+ 3. **Quick Setup**: Start testing APIs in minutes instead of spending hours on test infrastructure.
37
+ 4. **Gradual Adoption**: Use alongside your existing test suite, introducing it incrementally where it makes sense.
38
+ 5. **Developer & QA Collaboration**: Create a testing format that everyone can understand and maintain, regardless of Ruby expertise.
39
+
40
+ ## Key Features
25
41
 
26
- 1. **Living Documentation**: Tests serve as clear, maintainable documentation of your API's expected behavior.
27
- 2. **Power Without Complexity**: Get the benefits of Ruby-based tests (dynamic data, factories, matchers) without writing Ruby code.
28
- 3. **Quick Setup**: Start testing APIs without configuring HTTP clients or writing boilerplate code.
29
- 4. **Gradual Adoption**: Use alongside your existing test suite. Keep complex tests in RSpec while making simple API tests more accessible.
30
- 5. **Accessible API Testing**: Non-developers can write and maintain tests without Ruby knowledge. The YAML syntax reads like documentation.
42
+ - **YAML-Based Tests**: Write clear, declarative tests that read like documentation
43
+ - **RSpec Integration**: Leverage all the power of RSpec matchers and expectations
44
+ - **FactoryBot Integration**: Generate test data with FactoryBot integration
45
+ - **Faker Integration**: Create realistic test data with Faker
46
+ - **Variable System**: Define and reference variables for dynamic test data
47
+ - **Context Storage**: Store API responses and reference them in subsequent tests
48
+ - **Compound Matchers**: Combine multiple validations with `matcher.and` for precise expectations
49
+ - **Global Variables**: Define shared configuration at the file level
50
+ - **Callback System**: Hook into the test lifecycle using Ruby for setup, teardown, and much more!
31
51
 
32
52
  ## When Not to Use SpecForge
33
53
 
@@ -85,25 +105,17 @@ For comprehensive documentation, visit the [SpecForge Wiki](https://github.com/i
85
105
  - [Getting Started Guide](https://github.com/itsthedevman/spec_forge/wiki/Getting-Started)
86
106
  - [Configuration Options](https://github.com/itsthedevman/spec_forge/wiki/Configuration)
87
107
  - [Writing Tests](https://github.com/itsthedevman/spec_forge/wiki/Writing-Tests)
108
+ - [Running Tests](https://github.com/itsthedevman/spec_forge/wiki/Running-Tests)
109
+ - [Debugging Guide](https://github.com/itsthedevman/spec_forge/wiki/Debugging)
88
110
  - [Dynamic Features](https://github.com/itsthedevman/spec_forge/wiki/Dynamic-Features)
89
111
  - [Factory Support](https://github.com/itsthedevman/spec_forge/wiki/Factory-Support)
90
112
  - [RSpec Matchers](https://github.com/itsthedevman/spec_forge/wiki/RSpec-Matchers)
91
113
 
92
114
  Also see the [API Documentation](https://itsthedevman.com/docs/spec_forge).
93
115
 
94
- ## Roadmap
95
-
96
- Current development priorities:
97
- - [ ] Negated matchers: `matcher.not`
98
- - [ ] `create_list/build_list` factory strategies
99
- - [ ] `transform.map` support
100
- - [ ] XML/HTML response handling
101
- - [ ] OpenAPI generation from tests
102
- - [x] Array support for `json` expectations
103
- - [x] Support for running individual specs
104
- - [x] Improved error handling
116
+ ## Future Development
105
117
 
106
- Have a feature request? Open an issue on GitHub!
118
+ For the latest development priorities and feature ideas, check out our [Github Project](https://github.com/itsthedevman/spec_forge/projects?query=is%3Aopen). Have a feature request? Open an issue on GitHub!
107
119
 
108
120
  ## Contributing
109
121
 
data/flake.lock CHANGED
@@ -20,11 +20,11 @@
20
20
  },
21
21
  "nixpkgs": {
22
22
  "locked": {
23
- "lastModified": 1737469691,
24
- "narHash": "sha256-nmKOgAU48S41dTPIXAq0AHZSehWUn6ZPrUKijHAMmIk=",
23
+ "lastModified": 1742422364,
24
+ "narHash": "sha256-mNqIplmEohk5jRkqYqG19GA8MbQ/D4gQSK0Mu4LvfRQ=",
25
25
  "owner": "NixOS",
26
26
  "repo": "nixpkgs",
27
- "rev": "9e4d5190a9482a1fb9d18adf0bdb83c6e506eaab",
27
+ "rev": "a84ebe20c6bc2ecbcfb000a50776219f48d134cc",
28
28
  "type": "github"
29
29
  },
30
30
  "original": {
data/flake.nix CHANGED
@@ -6,8 +6,14 @@
6
6
  flake-utils.url = "github:numtide/flake-utils";
7
7
  };
8
8
 
9
- outputs = { self, nixpkgs, flake-utils }:
10
- flake-utils.lib.eachDefaultSystem (system:
9
+ outputs =
10
+ {
11
+ self,
12
+ nixpkgs,
13
+ flake-utils,
14
+ }:
15
+ flake-utils.lib.eachDefaultSystem (
16
+ system:
11
17
  let
12
18
  pkgs = nixpkgs.legacyPackages.${system};
13
19
  in
@@ -2,84 +2,272 @@
2
2
 
3
3
  module SpecForge
4
4
  class Attribute
5
+ #
6
+ # Adds support for an attribute to accept n-number of chained calls. It supports chaining
7
+ # methods, hash keys, and array indexes. It also works well alongside Parameterized attributes
8
+ #
9
+ # This module requires being included into a class first before it can be used
10
+ #
11
+ # @example Basic usage in YAML
12
+ # my_variable: variable.users.first
13
+ #
14
+ # @example Advanced usage in YAML
15
+ # my_variable: variable.users.0.posts.second.author.name
16
+ #
17
+ # @example Basic usage in code
18
+ # faker = SpecForge::Attribute.from("faker.name.name.upcase")
19
+ # faker.resolved #=> BENDING UNIT 22
20
+ #
5
21
  module Chainable
22
+ #
23
+ # Regular expression that matches pure numeric strings
24
+ # Used for detecting potential array index operations
25
+ #
26
+ # @return [Regexp] A case-insensitive regex matching strings containing only digits
27
+ #
6
28
  NUMBER_REGEX = /^\d+$/i
7
29
 
8
- attr_reader :header, :invocation_chain, :base_object
30
+ #
31
+ # The first part of the chained attribute
32
+ #
33
+ # @return [Symbol] The first component of the chained attribute
34
+ #
35
+ attr_reader :keyword
36
+
37
+ #
38
+ # The second part of the chained attribute
39
+ #
40
+ # @return [Symbol] The second component of the chained attribute
41
+ #
42
+ attr_reader :header
43
+
44
+ #
45
+ # The remaining parts of the attribute chain after the header
46
+ #
47
+ # @return [Array<Symbol>] The remaining method/key invocations in the chain
48
+ #
49
+ attr_reader :invocation_chain
9
50
 
10
51
  #
11
- # Represents any attribute that is a series of chained invocations:
52
+ # The initial object from which the chain will start traversing
53
+ #
54
+ # @return [Object] The base object that starts the method/attribute chain
12
55
  #
13
- # <keyword>.<header>.<segment(hash_key | method | index)>...
56
+ attr_reader :base_object
57
+
14
58
  #
15
- # This module is not used as is, but is included in another class.
16
- # Note: There can be any n number of segments.
59
+ # Initializes a new chainable attribute by parsing the input into components
17
60
  #
18
61
  def initialize(...)
19
62
  super
20
63
 
21
- # Drop the keyword
22
- sections = input.split(".")[1..]
64
+ sections = input.split(".")
23
65
 
24
- @header = sections.first&.to_sym
25
- @invocation_chain = sections[1..] || []
66
+ @keyword = sections.first.to_sym
67
+ @header = sections.second&.to_sym
68
+ @invocation_chain = sections[2..] || []
26
69
  end
27
70
 
71
+ #
72
+ # Returns the value of this attribute by resolving the chain
73
+ # Will return a new value on each call for dynamic attributes like Faker
74
+ #
75
+ # @return [Object] The result of invoking the chain on the base object
76
+ #
28
77
  def value
29
78
  invoke_chain
30
79
  end
31
80
 
32
- def resolve
81
+ #
82
+ # Resolves the chain and stores the result
83
+ # The result is memoized, so subsequent calls return the same value
84
+ # even for dynamic attributes like Faker and Factory
85
+ #
86
+ # @return [Object] The fully resolved and memoized value
87
+ #
88
+ def resolved
33
89
  @resolved ||= resolve_chain
34
90
  end
35
91
 
36
92
  private
37
93
 
94
+ #
95
+ # Invokes the chain by calling #value on each object
96
+ #
97
+ # @return [Object] The result of invoking the chain
98
+ #
99
+ # @private
100
+ #
38
101
  def invoke_chain
39
102
  traverse_chain(resolve: false)
40
103
  end
41
104
 
105
+ #
106
+ # Resolves the chain by calling #resolve on each object
107
+ #
108
+ # @return [Object] The fully resolved result
109
+ #
110
+ # @private
111
+ #
42
112
  def resolve_chain
43
- __resolve(traverse_chain(resolve: true))
113
+ traverse_chain(resolve: true)
44
114
  end
45
115
 
116
+ #
117
+ # Traverses the chain of invocations step by step
118
+ #
119
+ # @param resolve [Boolean] Whether to use resolve during traversal
120
+ #
121
+ # @return [Object] The result of the traversal
122
+ #
123
+ # @private
124
+ #
46
125
  def traverse_chain(resolve:)
47
- result = invocation_chain.reduce(base_object) do |current_value, step|
48
- next_value = retrieve_value(current_value, resolve:)
126
+ resolution_path = {}
127
+
128
+ current_path = "#{keyword}.#{header}"
129
+ current_object = base_object
130
+
131
+ invocation_chain.each do |step|
132
+ next_value = retrieve_value(current_object, resolve:)
133
+
134
+ # Store this step's resolution for error reporting
135
+ resolution_path[current_path] = describe_value(next_value)
136
+ current_path += ".#{step}"
137
+
138
+ # Try to invoke the next step
139
+ current_object = invoke(step, next_value)
140
+ rescue Error::InvalidInvocationError => e
141
+ resolution_path[current_path] = "Error: #{e.message}"
49
142
 
50
- invoke(step, next_value)
143
+ raise e.with_resolution_path(resolution_path)
51
144
  end
52
145
 
53
- retrieve_value(result, resolve:)
146
+ # Return final result
147
+ retrieve_value(current_object, resolve:)
54
148
  end
55
149
 
150
+ #
151
+ # Retrieves the value from an object, resolving it if needed
152
+ #
153
+ # @param object [Object] The object to retrieve a value from
154
+ # @param resolve [Boolean] Whether to resolve the object's value
155
+ #
156
+ # @return [Object] The retrieved value
157
+ #
158
+ # @private
159
+ #
56
160
  def retrieve_value(object, resolve:)
57
161
  return object unless object.is_a?(Attribute)
58
162
 
59
- resolve ? object.resolve : object.value
163
+ resolve ? object.resolved : object.value
164
+ end
165
+
166
+ #
167
+ # Creates a description of a value for error messages
168
+ #
169
+ # @param value [Object] The value to describe
170
+ #
171
+ # @return [String] A description
172
+ #
173
+ # @private
174
+ #
175
+ def describe_value(value)
176
+ case value
177
+ when Context::Store::Entry
178
+ "Store with attributes: #{value.available_methods.join_map(", ", &:in_quotes)}"
179
+ when OpenStruct
180
+ "Object with attributes: #{value.table.keys.join_map(", ", &:in_quotes)}"
181
+ when Struct, Data
182
+ "Object with attributes: #{value.members.join_map(", ", &:in_quotes)}"
183
+ when ArrayLike
184
+ # Preview the first 5 value's classes
185
+ preview = value.take(5).map(&:class)
186
+ preview << "..." if value.size > 5
187
+
188
+ "Array with #{value.size} #{"element".pluralize(value.size)}: #{preview}"
189
+ when HashLike
190
+ # Preview the first 5 keys
191
+ keys = value.keys.take(5)
192
+
193
+ preview = keys.join_map(", ") { |key| "\"#{key}\"" }
194
+ preview += ", ..." if value.keys.size > 5
195
+
196
+ "Hash with #{"key".pluralize(keys.size)}: #{preview}"
197
+ when String
198
+ "\"#{value.truncate(50)}\""
199
+ when NilClass
200
+ "nil"
201
+ when Proc
202
+ "Proc defined at #{value.source_location.join(":")}"
203
+ else
204
+ "#{value.class}: #{value.inspect[0..50]}"
205
+ end
60
206
  end
61
207
 
208
+ #
209
+ # Invokes an operation on an object based on the step type (hash key, array index, or method)
210
+ #
211
+ # @param step [String] The step to invoke
212
+ # @param object [Object] The object to invoke the step on
213
+ #
214
+ # @return [Object] The result of the invocation
215
+ #
216
+ # @raise [Error::InvalidInvocationError] If the step cannot be invoked on the object
217
+ #
218
+ # @private
219
+ #
62
220
  def invoke(step, object)
63
221
  if hash_key?(object, step)
64
- object[step.to_sym]
222
+ object[step.to_s] || object[step.to_sym]
65
223
  elsif index?(object, step)
66
224
  object[step.to_i]
67
225
  elsif method?(object, step)
68
226
  object.public_send(step)
69
227
  else
70
- raise InvalidInvocationError.new(step, object)
228
+ raise Error::InvalidInvocationError.new(step, object)
71
229
  end
72
230
  end
73
231
 
232
+ #
233
+ # Checks if the object can be accessed with the given key
234
+ #
235
+ # @param object [Object] The object to check
236
+ # @param key [String] The key to check
237
+ #
238
+ # @return [Boolean] Whether the object supports hash-like access with the key
239
+ #
240
+ # @private
241
+ #
74
242
  def hash_key?(object, key)
75
- # This is to support the silly delegator
76
- method?(object, :key?) && object.key?(key.to_sym)
243
+ # This is to support the silly delegator and both symbol/string
244
+ method?(object, :key?) && (object.key?(key.to_s) || object.key?(key.to_sym))
77
245
  end
78
246
 
247
+ #
248
+ # Checks if the object responds to the given method
249
+ #
250
+ # @param object [Object] The object to check
251
+ # @param method_name [String, Symbol] The method name to check
252
+ #
253
+ # @return [Boolean] Whether the object responds to the method
254
+ #
255
+ # @private
256
+ #
79
257
  def method?(object, method_name)
80
258
  object.respond_to?(method_name)
81
259
  end
82
260
 
261
+ #
262
+ # Checks if the object supports array-like access with the given index
263
+ #
264
+ # @param object [Object] The object to check
265
+ # @param step [String] The potential index
266
+ #
267
+ # @return [Boolean] Whether the object supports array-like access with the step
268
+ #
269
+ # @private
270
+ #
83
271
  def index?(object, step)
84
272
  # This is to support the silly delegator
85
273
  method?(object, :index) && step.match?(NUMBER_REGEX)