Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

## 0.4.0 - 2026-08-14

- Added `Contracts.all` to require every nested constraint to match.
- Added `Contracts.length(min:, max:, exactly:)` for String, Array, Hash, and other sized values.

## 0.3.0 - 2026-08-11

- Enforce `must_change` `from:` and `to:` bounds for scalar and array values.
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
contracts-rb (0.3.0)
contracts-rb (0.4.0)

GEM
remote: https://rubygems.org/
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class Account
end
```

Constraints include `Contracts.nilable`, `any`, `matching`, `range`, `one_of`, `array_of`, `hash_of`, `respond_to`, `duck_type`, `anything`, `nothing`, and `predicate`.
Constraints include `Contracts.nilable`, `any`, `all`, `matching`, `range`, `one_of`, `array_of`, `hash_of`, `length`, `respond_to`, `duck_type`, `anything`, `nothing`, and `predicate`.

## Stateful contracts

Expand All @@ -69,7 +69,8 @@ Use `observe` to capture relevant receiver fields; `changes` permits a subset, `
| `one_of` | `Contracts.one_of(:draft, :published)` | one literal value |
| `array_of` | `Contracts.array_of(String)` | array whose elements match |
| `hash_of` | `Contracts.hash_of(Symbol, Numeric)` | hash with matching keys/values |
| `predicate` | `Contracts.predicate("valid ID") { ... }` | custom runtime rule |
| `all` | `Contracts.all(String, Contracts.matching(/A/))` | every nested constraint matches |
| `length` | `Contracts.length(min: 1, max: 80)` | `size`/`length` within bounds |

## Runtime configuration

Expand Down
2 changes: 1 addition & 1 deletion contracts-rb.gemspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Gem::Specification.new do |spec|
spec.name = "contracts-rb"
spec.version = "0.3.0"
spec.version = "0.4.0"
spec.authors = ["Magnexis"]
spec.summary = "Behavioral contracts for Ruby methods and objects"
spec.description = "Expressive runtime contracts for parameters, results, state, invariants, exceptions, tuples, and structured hash shapes."
Expand Down
8 changes: 8 additions & 0 deletions docs/structured-constraints.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,11 @@ OpenPayload = Contracts::Constraints.shape(
```

Both constraints implement the normal Contracts-rb constraint interface: `matches?`, `description`, and `to_h`.

`Contracts.all` and `Contracts.length` live on the core gem (no extra require) for conjunctions and sized values:

```ruby
Contracts.all(String, Contracts.matching(/\A[A-Z]/)).matches?("OK")
Contracts.length(min: 2, max: 4).matches?("ab")
```

55 changes: 55 additions & 0 deletions lib/contracts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,59 @@ def matches?(_) = false
def description = "nothing"
end

class All < Base
def initialize(*items) = @items = items.map { |item| Constraints.coerce(item) }
def matches?(value) = @items.all? { |item| item.matches?(value) }
def description = @items.map(&:description).join(" and ")
def to_h = super.merge(items: @items.map(&:description))
end

class Length < Base
attr_reader :min, :max

def initialize(min: nil, max: nil, exactly: nil)
if exactly
raise DefinitionError, "length exactly cannot be combined with min or max" unless min.nil? && max.nil?

@min = @max = Integer(exactly)
else
@min = min && Integer(min)
@max = max && Integer(max)
end
raise DefinitionError, "length requires min, max, or exactly" if @min.nil? && @max.nil?
raise DefinitionError, "length min cannot exceed max" if @min && @max && @min > @max
end

def matches?(value)
size = length_of(value)
return false unless size
return false if min && size < min
return false if max && size > max

true
end

def description
return "length #{min}" if min && max && min == max
return "length >= #{min}" if min && max.nil?
return "length <= #{max}" if max && min.nil?

"length #{min}..#{max}"
end

def to_h = super.merge(min: min, max: max)

private

def length_of(value)
return nil if value.is_a?(Numeric)
return value.length if value.respond_to?(:length)
return value.size if value.respond_to?(:size)

nil
end
end

module_function

def coerce(value) = value.respond_to?(:matches?) && value.respond_to?(:description) ? value : Type.new(value)
Expand Down Expand Up @@ -527,6 +580,8 @@ def respond_to(*methods) = Constraints::RespondTo.new(*methods)
def duck_type(*methods) = Constraints::DuckType.new(*methods)
def anything = Constraints::Anything.new
def nothing = Constraints::Nothing.new
def all(*items) = Constraints::All.new(*items)
def length(min: nil, max: nil, exactly: nil) = Constraints::Length.new(min: min, max: max, exactly: exactly)

def invoke(receiver, contract, args, kwargs, block)
return yield unless active?(contract, receiver, args, kwargs)
Expand Down
2 changes: 1 addition & 1 deletion lib/contracts/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Contracts
VERSION = "0.3.0"
VERSION = "0.4.0"
end
40 changes: 40 additions & 0 deletions spec/contracts/length_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true
require "spec_helper"

RSpec.describe "all and length constraints" do
describe Contracts::Constraints::All do
subject(:constraint) { Contracts.all(String, Contracts.matching(/\A[A-Z]/)) }

it "requires every nested constraint to match" do
expect(constraint.matches?("OK")).to be(true)
expect(constraint.matches?("ok")).to be(false)
expect(constraint.matches?(1)).to be(false)
end

it "describes the conjunction" do
expect(constraint.description).to include(" and ")
end
end

describe Contracts::Constraints::Length do
it "enforces min and max length" do
constraint = Contracts.length(min: 2, max: 4)
expect(constraint.matches?("ab")).to be(true)
expect(constraint.matches?([1, 2, 3, 4])).to be(true)
expect(constraint.matches?("a")).to be(false)
expect(constraint.matches?("abcde")).to be(false)
expect(constraint.matches?(12)).to be(false)
end

it "enforces exact length" do
constraint = Contracts.length(exactly: 3)
expect(constraint.matches?("hey")).to be(true)
expect(constraint.matches?({ a: 1, b: 2, c: 3 })).to be(true)
expect(constraint.matches?("hi")).to be(false)
end

it "rejects inverted bounds" do
expect { Contracts.length(min: 5, max: 1) }.to raise_error(Contracts::DefinitionError)
end
end
end