abstracta-contracts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +203 -0
- data/lib/abstracta_contracts/errors.rb +43 -0
- data/lib/abstracta_contracts/internal/class_methods.rb +126 -0
- data/lib/abstracta_contracts/internal/constructor_guard.rb +12 -0
- data/lib/abstracta_contracts/internal/contract.rb +21 -0
- data/lib/abstracta_contracts/internal/interface.rb +41 -0
- data/lib/abstracta_contracts/version.rb +5 -0
- data/lib/abstracta_contracts.rb +249 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ec5dbea4ff031eb0fc61d786f5e5c443b0f9ecb6570e4f914531d4db4ae9e19d
|
|
4
|
+
data.tar.gz: 5c03262e0801b1be7cec8f63ef3d9a8338ef363888abbbc95d135d0ec5df4155
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 78c6dd7e8fe309f40d7e58a6485b01c3852a921c95064a9bb452ae834f3d81365bfe92f8abc254a17eedcef62993be46417ca6db26cf5d2f7b18cf8ea23eb727
|
|
7
|
+
data.tar.gz: 0c2218ef59335cf982a665184427c103aba124d78a7ee8e14966ec50db413196273ac9c0712c42ad5db45636c3264c8d87b711545443084d3f47a81edff9083f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to AbstractaContracts will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - Unreleased
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Declarative abstract classes with `AbstractaContracts.with_methods`.
|
|
10
|
+
- Explicit abstract class, instance-method, and class-method contracts.
|
|
11
|
+
- Reusable interfaces with `AbstractaContracts.interface` and `implements`.
|
|
12
|
+
- Inherited and composable contracts across class and interface hierarchies.
|
|
13
|
+
- Runtime instantiation validation and contract introspection.
|
|
14
|
+
- Private implementation namespace under `AbstractaContracts::Internal`.
|
|
15
|
+
- RSpec, RuboCop, branch-aware SimpleCov thresholds, CI matrix, Dependabot, and Trusted Publishing release verification.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Juan Furattini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Abstracta Contracts
|
|
2
|
+
|
|
3
|
+
## Public API
|
|
4
|
+
|
|
5
|
+
AbstractaContracts exposes one supported entry point:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "abstracta_contracts"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The supported root API is intentionally small:
|
|
12
|
+
|
|
13
|
+
- `AbstractaContracts.with_methods`
|
|
14
|
+
- `AbstractaContracts.interface`
|
|
15
|
+
- `AbstractaContracts::Error`
|
|
16
|
+
- `AbstractaContracts::VERSION`
|
|
17
|
+
- the class DSL installed by `include AbstractaContracts`: `abstract_class!`, `abstract_method`, `abstract_class_method`, and `implements`
|
|
18
|
+
- the documented introspection methods installed by `include AbstractaContracts`
|
|
19
|
+
|
|
20
|
+
Implementation modules, contract objects, guards, and specialized errors live behind `AbstractaContracts::Internal` and are not public API. No pre-release compatibility entry points are shipped.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Declarative abstract classes and interfaces for Ruby.
|
|
24
|
+
|
|
25
|
+
> **Gem:** `abstracta-contracts`
|
|
26
|
+
> **Namespace:** `AbstractaContracts`
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
gem "abstracta-contracts", require: "abstracta_contracts"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
require "abstracta_contracts"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Abstract classes
|
|
39
|
+
|
|
40
|
+
The compact API is the recommended form:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class FeatureProvider
|
|
44
|
+
include AbstractaContracts.with_methods(:enabled?, :features)
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A class with an incomplete contract cannot be instantiated. A descendant becomes concrete when it implements every required method.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class RedisProvider < FeatureProvider
|
|
52
|
+
def enabled?(feature)
|
|
53
|
+
features.include?(feature)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def features
|
|
57
|
+
[:search, :reports]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Class-method contracts use `class_methods:`:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class Provider
|
|
66
|
+
include AbstractaContracts.with_methods(:call, class_methods: [:provider_name])
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
For dynamic or incremental declarations, use the explicit DSL:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class Provider
|
|
74
|
+
include AbstractaContracts
|
|
75
|
+
|
|
76
|
+
abstract_class!
|
|
77
|
+
abstract_method :enabled?
|
|
78
|
+
abstract_method :features
|
|
79
|
+
abstract_class_method :provider_name
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`abstract_class!` marks only the declaring class as explicitly abstract. The marker is not inherited. Method requirements are inherited and can be extended or redeclared by descendants.
|
|
84
|
+
|
|
85
|
+
## Reusable abstract contracts
|
|
86
|
+
|
|
87
|
+
`with_methods` returns a reusable module-like contract:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
cache_contract = AbstractaContracts.with_methods(:read, :write, :delete)
|
|
91
|
+
|
|
92
|
+
class RedisCache
|
|
93
|
+
include cache_contract
|
|
94
|
+
|
|
95
|
+
def read(key) = nil
|
|
96
|
+
def write(key, value) = value
|
|
97
|
+
def delete(key) = nil
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Interfaces
|
|
102
|
+
|
|
103
|
+
Interfaces are separate from abstract classes:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
module Cacheable
|
|
107
|
+
include AbstractaContracts.interface(
|
|
108
|
+
:read,
|
|
109
|
+
:write,
|
|
110
|
+
:delete,
|
|
111
|
+
class_methods: [:adapter_name]
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Classes opt into AbstractaContracts and explicitly declare interfaces:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
class RedisCache
|
|
120
|
+
include AbstractaContracts
|
|
121
|
+
implements Cacheable
|
|
122
|
+
|
|
123
|
+
def read(key) = nil
|
|
124
|
+
def write(key, value) = value
|
|
125
|
+
def delete(key) = nil
|
|
126
|
+
def self.adapter_name = :redis
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
AbstractaContracts deliberately does not add `implements` to every Ruby class.
|
|
131
|
+
|
|
132
|
+
### Interface inheritance and defaults
|
|
133
|
+
|
|
134
|
+
An interface may include another AbstractaContracts interface. Interface modules may also provide default instance methods; those methods satisfy their requirements.
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
module Readable
|
|
138
|
+
include AbstractaContracts.interface(:read)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
module Cacheable
|
|
142
|
+
include Readable
|
|
143
|
+
include AbstractaContracts.interface(:write)
|
|
144
|
+
|
|
145
|
+
def read(key) = nil
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Introspection
|
|
150
|
+
|
|
151
|
+
Abstract classes expose:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
Provider.abstract?
|
|
155
|
+
Provider.concrete?
|
|
156
|
+
Provider.explicitly_abstract?
|
|
157
|
+
Provider.abstract_methods
|
|
158
|
+
Provider.abstract_class_methods
|
|
159
|
+
Provider.missing_abstract_methods
|
|
160
|
+
Provider.missing_abstract_class_methods
|
|
161
|
+
Provider.valid_implementation?
|
|
162
|
+
Provider.validate_implementation!
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Classes implementing interfaces also expose:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
RedisCache.interfaces
|
|
169
|
+
RedisCache.direct_interfaces
|
|
170
|
+
RedisCache.implements?(Cacheable)
|
|
171
|
+
RedisCache.interface_methods
|
|
172
|
+
RedisCache.interface_class_methods
|
|
173
|
+
RedisCache.missing_interface_methods
|
|
174
|
+
RedisCache.missing_interface_class_methods
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`missing_methods` and `missing_class_methods` return the combined unresolved abstract-class and interface requirements.
|
|
178
|
+
|
|
179
|
+
## Contract rules
|
|
180
|
+
|
|
181
|
+
- Abstract method contracts accumulate through inheritance.
|
|
182
|
+
- Redeclaring a method as abstract requires a fresh implementation below that declaration.
|
|
183
|
+
- Private and protected methods can satisfy contracts.
|
|
184
|
+
- Modules included below an abstract declaration can satisfy instance-method contracts.
|
|
185
|
+
- Interfaces remain distinct from abstract classes.
|
|
186
|
+
- Interface requirements can be satisfied by the class, inherited implementations, or interface defaults.
|
|
187
|
+
- AbstractaContracts has no runtime dependencies.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
bundle install
|
|
193
|
+
bundle exec rake
|
|
194
|
+
bundle exec gem build abstracta-contracts.gemspec
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Release
|
|
198
|
+
|
|
199
|
+
The repository includes CI and a RubyGems Trusted Publishing workflow. Repository synchronization, branches, commits, tags, and other version-control operations are intentionally outside the gem's responsibilities.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AbstractaContracts
|
|
4
|
+
# Public base exception for all AbstractaContracts errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
module Internal
|
|
8
|
+
class InvalidMethodNameError < Error; end
|
|
9
|
+
class InvalidInterfaceError < Error; end
|
|
10
|
+
class InterfaceAlreadyDefinedError < Error; end
|
|
11
|
+
|
|
12
|
+
class AbstractClassInstantiationError < Error
|
|
13
|
+
attr_reader :abstract_class
|
|
14
|
+
|
|
15
|
+
def initialize(abstract_class, message: nil)
|
|
16
|
+
@abstract_class = abstract_class
|
|
17
|
+
super(message || "#{AbstractaContracts.class_name(abstract_class)} is abstract and cannot be instantiated")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class UnimplementedMethodsError < AbstractClassInstantiationError
|
|
22
|
+
attr_reader :missing_instance_methods, :missing_class_methods
|
|
23
|
+
|
|
24
|
+
def initialize(abstract_class, instance_methods:, class_methods:)
|
|
25
|
+
@missing_instance_methods = instance_methods.freeze
|
|
26
|
+
@missing_class_methods = class_methods.freeze
|
|
27
|
+
super(abstract_class, message: unimplemented_message(abstract_class, instance_methods, class_methods))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def unimplemented_message(abstract_class, instance_methods, class_methods)
|
|
33
|
+
details = []
|
|
34
|
+
unless instance_methods.empty?
|
|
35
|
+
details << "instance methods: #{instance_methods.map { |name| "##{name}" }.join(', ')}"
|
|
36
|
+
end
|
|
37
|
+
details << "class methods: #{class_methods.map { |name| ".#{name}" }.join(', ')}" unless class_methods.empty?
|
|
38
|
+
|
|
39
|
+
"#{AbstractaContracts.class_name(abstract_class)} has unimplemented contract #{details.join('; ')}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AbstractaContracts
|
|
4
|
+
module Internal
|
|
5
|
+
module ClassMethods
|
|
6
|
+
def abstract_class!
|
|
7
|
+
AbstractaContracts.synchronize(self) { @abstracta_contracts_explicitly_abstract = true }
|
|
8
|
+
self
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def abstract_method(*names)
|
|
12
|
+
AbstractaContracts.register_instance_methods(self, AbstractaContracts.normalize_method_names(names))
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def abstract_class_method(*names)
|
|
17
|
+
AbstractaContracts.register_class_methods(self, AbstractaContracts.normalize_method_names(names))
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def implements(*interfaces)
|
|
22
|
+
normalized = AbstractaContracts.normalize_interfaces(interfaces)
|
|
23
|
+
|
|
24
|
+
AbstractaContracts.synchronize(self) do
|
|
25
|
+
direct = abstracta_contracts_direct_interfaces
|
|
26
|
+
normalized.each { |interface| direct << interface unless direct.include?(interface) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
normalized.each { |interface| include(interface) unless self < interface }
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def explicitly_abstract?
|
|
34
|
+
@abstracta_contracts_explicitly_abstract == true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def abstract?
|
|
38
|
+
explicitly_abstract? || !missing_methods.empty? || !missing_class_methods.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def concrete?
|
|
42
|
+
!abstract?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def abstract_methods
|
|
46
|
+
AbstractaContracts.required_instance_methods_for(self).keys.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def abstract_class_methods
|
|
50
|
+
AbstractaContracts.required_class_methods_for(self).keys.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def missing_abstract_methods
|
|
54
|
+
AbstractaContracts.missing_instance_methods_for(self).freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def missing_abstract_class_methods
|
|
58
|
+
AbstractaContracts.missing_class_methods_for(self).freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def direct_interfaces
|
|
62
|
+
abstracta_contracts_direct_interfaces.dup.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def interfaces
|
|
66
|
+
AbstractaContracts.interfaces_for(self).freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def implements?(interface)
|
|
70
|
+
AbstractaContracts.validate_interface!(interface)
|
|
71
|
+
interfaces.include?(interface)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def interface_methods
|
|
75
|
+
AbstractaContracts.required_interface_instance_methods_for(self).freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def interface_class_methods
|
|
79
|
+
AbstractaContracts.required_interface_class_methods_for(self).freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def missing_interface_methods
|
|
83
|
+
AbstractaContracts.missing_interface_instance_methods_for(self).freeze
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def missing_interface_class_methods
|
|
87
|
+
AbstractaContracts.missing_interface_class_methods_for(self).freeze
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def missing_methods
|
|
91
|
+
(missing_abstract_methods + missing_interface_methods).uniq.freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def missing_class_methods
|
|
95
|
+
(missing_abstract_class_methods + missing_interface_class_methods).uniq.freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def valid_implementation?
|
|
99
|
+
concrete?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_implementation!
|
|
103
|
+
return true if concrete?
|
|
104
|
+
|
|
105
|
+
missing = { instance_methods: missing_methods, class_methods: missing_class_methods }
|
|
106
|
+
raise Internal::UnimplementedMethodsError.new(self, **missing) unless missing.values.all?(&:empty?)
|
|
107
|
+
|
|
108
|
+
raise Internal::AbstractClassInstantiationError, self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def abstracta_contracts_declared_instance_methods
|
|
114
|
+
@abstracta_contracts_declared_instance_methods ||= []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def abstracta_contracts_declared_class_methods
|
|
118
|
+
@abstracta_contracts_declared_class_methods ||= []
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def abstracta_contracts_direct_interfaces
|
|
122
|
+
@abstracta_contracts_direct_interfaces ||= []
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AbstractaContracts
|
|
4
|
+
module Internal
|
|
5
|
+
class Contract < Module
|
|
6
|
+
attr_reader :instance_methods, :class_methods
|
|
7
|
+
|
|
8
|
+
def initialize(instance_methods:, class_methods:)
|
|
9
|
+
super()
|
|
10
|
+
@instance_methods = instance_methods.freeze
|
|
11
|
+
@class_methods = class_methods.freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def included(base)
|
|
15
|
+
AbstractaContracts.install(base)
|
|
16
|
+
base.abstract_method(*instance_methods) unless instance_methods.empty?
|
|
17
|
+
base.abstract_class_method(*class_methods) unless class_methods.empty?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AbstractaContracts
|
|
4
|
+
module Internal
|
|
5
|
+
module InterfaceDefinition
|
|
6
|
+
def interface?
|
|
7
|
+
true
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def interface_methods
|
|
11
|
+
AbstractaContracts.interface_instance_methods_for(self)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def interface_class_methods
|
|
15
|
+
AbstractaContracts.interface_class_methods_for(self)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Interface < Module
|
|
20
|
+
attr_reader :instance_methods, :class_methods
|
|
21
|
+
|
|
22
|
+
def initialize(instance_methods:, class_methods:)
|
|
23
|
+
super()
|
|
24
|
+
@instance_methods = instance_methods.freeze
|
|
25
|
+
@class_methods = class_methods.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def included(base)
|
|
29
|
+
unless base.is_a?(Module) && !base.is_a?(Class)
|
|
30
|
+
raise TypeError, "AbstractaContracts.interface must be included in a module"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
AbstractaContracts.define_interface(
|
|
34
|
+
base,
|
|
35
|
+
instance_methods: instance_methods,
|
|
36
|
+
class_methods: class_methods
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "abstracta_contracts/version"
|
|
4
|
+
require_relative "abstracta_contracts/errors"
|
|
5
|
+
require_relative "abstracta_contracts/internal/class_methods"
|
|
6
|
+
require_relative "abstracta_contracts/internal/constructor_guard"
|
|
7
|
+
require_relative "abstracta_contracts/internal/contract"
|
|
8
|
+
require_relative "abstracta_contracts/internal/interface"
|
|
9
|
+
|
|
10
|
+
module AbstractaContracts
|
|
11
|
+
MUTEX_CREATION_LOCK = Mutex.new
|
|
12
|
+
private_constant :MUTEX_CREATION_LOCK
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def included(base)
|
|
16
|
+
install(base)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def with_methods(*methods, class_methods: [])
|
|
20
|
+
instance_methods = normalize_method_names(methods)
|
|
21
|
+
singleton_methods = normalize_method_names(Array(class_methods))
|
|
22
|
+
|
|
23
|
+
if instance_methods.empty? && singleton_methods.empty?
|
|
24
|
+
raise ArgumentError, "at least one abstract instance or class method is required"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Internal::Contract.new(instance_methods: instance_methods, class_methods: singleton_methods)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def interface(*methods, class_methods: [])
|
|
31
|
+
instance_methods = normalize_method_names(methods)
|
|
32
|
+
singleton_methods = normalize_method_names(Array(class_methods))
|
|
33
|
+
|
|
34
|
+
if instance_methods.empty? && singleton_methods.empty?
|
|
35
|
+
raise ArgumentError, "at least one interface instance or class method is required"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Internal::Interface.new(instance_methods: instance_methods, class_methods: singleton_methods)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def install(base)
|
|
42
|
+
raise TypeError, "AbstractaContracts can only be included in classes" unless base.is_a?(Class)
|
|
43
|
+
|
|
44
|
+
base.extend(Internal::ClassMethods) unless base.singleton_class < Internal::ClassMethods
|
|
45
|
+
base.singleton_class.prepend(Internal::ConstructorGuard) unless base.singleton_class < Internal::ConstructorGuard
|
|
46
|
+
mutex_for(base)
|
|
47
|
+
base
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def define_interface(base, instance_methods:, class_methods:)
|
|
51
|
+
if base.instance_variable_defined?(:@abstracta_contracts_interface_defined)
|
|
52
|
+
raise Internal::InterfaceAlreadyDefinedError,
|
|
53
|
+
"#{interface_name(base)} is already an AbstractaContracts interface"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
base.instance_variable_set(:@abstracta_contracts_interface_defined, true)
|
|
57
|
+
base.instance_variable_set(:@abstracta_contracts_interface_instance_methods, instance_methods.freeze)
|
|
58
|
+
base.instance_variable_set(:@abstracta_contracts_interface_class_methods, class_methods.freeze)
|
|
59
|
+
base.extend(Internal::InterfaceDefinition) unless base.singleton_class < Internal::InterfaceDefinition
|
|
60
|
+
base
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def interface?(object)
|
|
64
|
+
object.is_a?(Module) && object.respond_to?(:interface?) && object.interface?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_interface!(interface)
|
|
68
|
+
return interface if interface?(interface)
|
|
69
|
+
|
|
70
|
+
raise Internal::InvalidInterfaceError, "#{interface.inspect} is not an AbstractaContracts interface"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_interfaces(interfaces)
|
|
74
|
+
interfaces.flatten.map { |interface| validate_interface!(interface) }.uniq.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize_method_names(names)
|
|
78
|
+
names.flatten.map do |name|
|
|
79
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
80
|
+
raise Internal::InvalidMethodNameError, "abstract method names must be Strings or Symbols, got #{name.class}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
normalized = name.to_s
|
|
84
|
+
if normalized.empty? || normalized.match?(/\s/)
|
|
85
|
+
raise Internal::InvalidMethodNameError, "invalid abstract method name: #{name.inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
normalized.to_sym
|
|
89
|
+
end.uniq.freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def register_instance_methods(base, names)
|
|
93
|
+
install(base)
|
|
94
|
+
synchronize(base) do
|
|
95
|
+
declared = base.send(:abstracta_contracts_declared_instance_methods)
|
|
96
|
+
names.each { |name| declared << name unless declared.include?(name) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def register_class_methods(base, names)
|
|
101
|
+
install(base)
|
|
102
|
+
synchronize(base) do
|
|
103
|
+
declared = base.send(:abstracta_contracts_declared_class_methods)
|
|
104
|
+
names.each { |name| declared << name unless declared.include?(name) }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def required_instance_methods_for(klass)
|
|
109
|
+
required_methods_for(klass, :abstracta_contracts_declared_instance_methods)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def required_class_methods_for(klass)
|
|
113
|
+
required_methods_for(klass, :abstracta_contracts_declared_class_methods)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def missing_instance_methods_for(klass)
|
|
117
|
+
required_instance_methods_for(klass).filter_map do |name, declaration_owner|
|
|
118
|
+
name unless implemented_after_declaration?(klass, name, declaration_owner, singleton: false)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def missing_class_methods_for(klass)
|
|
123
|
+
required_class_methods_for(klass).filter_map do |name, declaration_owner|
|
|
124
|
+
name unless implemented_after_declaration?(klass, name, declaration_owner, singleton: true)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def interfaces_for(klass)
|
|
129
|
+
result = []
|
|
130
|
+
|
|
131
|
+
klass.ancestors.reverse_each do |ancestor|
|
|
132
|
+
next unless ancestor.is_a?(Class)
|
|
133
|
+
next unless ancestor.respond_to?(:abstracta_contracts_direct_interfaces, true)
|
|
134
|
+
|
|
135
|
+
ancestor.send(:abstracta_contracts_direct_interfaces).each do |interface|
|
|
136
|
+
interface_hierarchy(interface).each { |candidate| result << candidate unless result.include?(candidate) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
result
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def interface_instance_methods_for(interface)
|
|
144
|
+
validate_interface!(interface)
|
|
145
|
+
interface_hierarchy(interface).flat_map do |candidate|
|
|
146
|
+
candidate.instance_variable_get(:@abstracta_contracts_interface_instance_methods) || []
|
|
147
|
+
end.uniq.freeze
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def interface_class_methods_for(interface)
|
|
151
|
+
validate_interface!(interface)
|
|
152
|
+
interface_hierarchy(interface).flat_map do |candidate|
|
|
153
|
+
candidate.instance_variable_get(:@abstracta_contracts_interface_class_methods) || []
|
|
154
|
+
end.uniq.freeze
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def required_interface_instance_methods_for(klass)
|
|
158
|
+
interfaces_for(klass).flat_map { |interface| interface_instance_methods_for(interface) }.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def required_interface_class_methods_for(klass)
|
|
162
|
+
interfaces_for(klass).flat_map { |interface| interface_class_methods_for(interface) }.uniq
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def missing_interface_instance_methods_for(klass)
|
|
166
|
+
required_interface_instance_methods_for(klass).reject do |name|
|
|
167
|
+
method_available?(klass, name, singleton: false)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def missing_interface_class_methods_for(klass)
|
|
172
|
+
required_interface_class_methods_for(klass).reject do |name|
|
|
173
|
+
method_available?(klass, name, singleton: true)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def synchronize(base, &)
|
|
178
|
+
mutex_for(base).synchronize(&)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def class_name(klass)
|
|
182
|
+
klass.name || klass.inspect
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def interface_name(interface)
|
|
186
|
+
interface.name || interface.inspect
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def mutex_for(base)
|
|
192
|
+
if base.instance_variable_defined?(:@abstracta_contracts_mutex)
|
|
193
|
+
return base.instance_variable_get(:@abstracta_contracts_mutex)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
MUTEX_CREATION_LOCK.synchronize do
|
|
197
|
+
base.instance_variable_get(:@abstracta_contracts_mutex) ||
|
|
198
|
+
base.instance_variable_set(:@abstracta_contracts_mutex, Mutex.new)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def required_methods_for(klass, reader)
|
|
203
|
+
declarations = {}
|
|
204
|
+
|
|
205
|
+
klass.ancestors.reverse_each do |ancestor|
|
|
206
|
+
next unless ancestor.is_a?(Class)
|
|
207
|
+
next unless ancestor.respond_to?(reader, true)
|
|
208
|
+
|
|
209
|
+
ancestor.send(reader).each { |name| declarations[name] = ancestor }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
declarations.freeze
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def implemented_after_declaration?(klass, name, declaration_owner, singleton:)
|
|
216
|
+
lookup_class = singleton ? klass.singleton_class : klass
|
|
217
|
+
declaration_lookup_owner = singleton ? declaration_owner.singleton_class : declaration_owner
|
|
218
|
+
|
|
219
|
+
implementation_owner = lookup_class.instance_method(name).owner
|
|
220
|
+
return true if implementation_owner == declaration_lookup_owner
|
|
221
|
+
|
|
222
|
+
ancestors = lookup_class.ancestors
|
|
223
|
+
implementation_index = ancestors.index(implementation_owner)
|
|
224
|
+
declaration_index = ancestors.index(declaration_lookup_owner)
|
|
225
|
+
|
|
226
|
+
implementation_index && declaration_index && implementation_index < declaration_index
|
|
227
|
+
rescue NameError
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def method_available?(klass, name, singleton:)
|
|
232
|
+
lookup = singleton ? klass.singleton_class : klass
|
|
233
|
+
lookup.instance_method(name)
|
|
234
|
+
true
|
|
235
|
+
rescue NameError
|
|
236
|
+
false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def interface_hierarchy(interface)
|
|
240
|
+
validate_interface!(interface)
|
|
241
|
+
|
|
242
|
+
interface.ancestors.reverse_each.with_object([]) do |ancestor, result|
|
|
243
|
+
next unless interface?(ancestor)
|
|
244
|
+
|
|
245
|
+
result << ancestor unless result.include?(ancestor)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: abstracta-contracts
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Juan Furattini
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
AbstractaContracts provides lightweight, dependency-free abstract class contracts for Ruby,
|
|
14
|
+
including declarative instance/class methods, reusable interfaces, inherited contracts,
|
|
15
|
+
runtime validation, and introspection.
|
|
16
|
+
email: []
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE.txt
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/abstracta_contracts.rb
|
|
25
|
+
- lib/abstracta_contracts/errors.rb
|
|
26
|
+
- lib/abstracta_contracts/internal/class_methods.rb
|
|
27
|
+
- lib/abstracta_contracts/internal/constructor_guard.rb
|
|
28
|
+
- lib/abstracta_contracts/internal/contract.rb
|
|
29
|
+
- lib/abstracta_contracts/internal/interface.rb
|
|
30
|
+
- lib/abstracta_contracts/version.rb
|
|
31
|
+
homepage: https://github.com/Rubcraft/abstracta-contracts
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
homepage_uri: https://github.com/Rubcraft/abstracta-contracts
|
|
36
|
+
source_code_uri: https://github.com/Rubcraft/abstracta-contracts
|
|
37
|
+
changelog_uri: https://github.com/Rubcraft/abstracta-contracts/blob/main/CHANGELOG.md
|
|
38
|
+
allowed_push_host: https://rubygems.org
|
|
39
|
+
rubygems_mfa_required: 'true'
|
|
40
|
+
rdoc_options: []
|
|
41
|
+
require_paths:
|
|
42
|
+
- lib
|
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.2'
|
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
requirements: []
|
|
54
|
+
rubygems_version: 4.0.16
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: Declarative abstract classes, interfaces, and method contracts for Ruby.
|
|
57
|
+
test_files: []
|