diff --git a/CHANGELOG.md b/CHANGELOG.md index 727c2e0..8f3ac82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Gemfile.lock b/Gemfile.lock index c280fe3..476fae8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - contracts-rb (0.3.0) + contracts-rb (0.4.0) GEM remote: https://rubygems.org/ diff --git a/README.md b/README.md index 58dceaa..78d16dc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/contracts-rb.gemspec b/contracts-rb.gemspec index 88e86f3..9878a2a 100644 --- a/contracts-rb.gemspec +++ b/contracts-rb.gemspec @@ -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." diff --git a/docs/structured-constraints.md b/docs/structured-constraints.md index f8a86fe..9909b46 100644 --- a/docs/structured-constraints.md +++ b/docs/structured-constraints.md @@ -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") +``` + diff --git a/lib/contracts.rb b/lib/contracts.rb index f1025a4..cedfd42 100644 --- a/lib/contracts.rb +++ b/lib/contracts.rb @@ -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) @@ -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) diff --git a/lib/contracts/version.rb b/lib/contracts/version.rb index 5475b91..218feb7 100644 --- a/lib/contracts/version.rb +++ b/lib/contracts/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Contracts - VERSION = "0.3.0" + VERSION = "0.4.0" end diff --git a/spec/contracts/length_spec.rb b/spec/contracts/length_spec.rb new file mode 100644 index 0000000..41e6c9f --- /dev/null +++ b/spec/contracts/length_spec.rb @@ -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