Skip to content
Open
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
15 changes: 15 additions & 0 deletions lib/proxies/param_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,22 @@ def error_type
end

def error_message(value:)
custom = custom_type_error_message(value:)
return custom unless custom.nil?

"Invalid argument type '#{output(value:)}' for parameter '#{@name}'. Valid types: '#{@expression.valid_types}'"
end

private

def custom_type_error_message(value:)
return unless @expression.respond_to?(:types)
return unless @expression.types.length == 1

type = @expression.types.first
return unless type.respond_to?(:error_message_for)

type.error_message_for(value:)
end
end
end
15 changes: 15 additions & 0 deletions lib/proxies/return_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,22 @@ def error_type
end

def error_message(value:)
custom = custom_type_error_message(value:)
return custom unless custom.nil?

"Invalid return type '#{output(value:)}' for method '#{@name}'. Valid types: '#{@expression.valid_types}'"
end

private

def custom_type_error_message(value:)
return unless @expression.respond_to?(:types)
return unless @expression.types.length == 1

type = @expression.types.first
return unless type.respond_to?(:error_message_for)

type.error_message_for(value:)
end
end
end
11 changes: 10 additions & 1 deletion lib/queries/type_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ def value?(value)
end

def complex_type?(expression:)
Low::Types::COMPLEX_TYPES.include?(expression) || typed_array?(expression:) || typed_hash?(expression:)
Low::Types::COMPLEX_TYPES.include?(expression) ||
matchable_type?(expression:) ||
typed_array?(expression:) ||
typed_hash?(expression:)
end

private
Expand All @@ -28,6 +31,12 @@ def basic_type?(expression:)
expression.instance_of?(Class)
end

def matchable_type?(expression:)
expression.is_a?(Class) &&
expression.respond_to?(:match?) &&
expression.ancestors.include?(Low::Types::MatchableType)
end

def typed_hash?(expression:)
expression.is_a?(Hash) && basic_type?(expression: expression.keys.first) && basic_type?(expression: expression.values.first)
end
Expand Down
28 changes: 28 additions & 0 deletions lib/types/boolean.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Low
module Types
class Boolean
include Low::Types::MatchableType

# LowType calls `.match?(value:)` for complex types.
def self.match?(value:)
value.equal?(true) || value.equal?(false)
end

# Optional helper for external call sites.
def self.valid?(value)
match?(value: value)
end

def self.error_message_for(value:)
"Expected true/false, got #{value.inspect}"
end

def self.inspect
'Low::Types::Boolean'
end
end
end
end

5 changes: 4 additions & 1 deletion lib/types/complex_types.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# frozen_string_literal: true

require_relative '../factories/type_factory'
require_relative 'matchable_type'
require_relative 'boolean'
require_relative 'enum'
require_relative 'status'

module Low
module Types
COMPLEX_TYPES = [
Boolean = TypeFactory.complex_type(Object),
Boolean,
Headers = TypeFactory.complex_type(Hash),
HTML = TypeFactory.complex_type(String),
JSON = TypeFactory.complex_type(String),
Expand Down
107 changes: 107 additions & 0 deletions lib/types/enum.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

require 'set'
require_relative 'error_types'

module Low
module Types
class Enum
# Holds the normalized enum definition and provides validation logic.
class Definition
def initialize(values:, strict:)
@strict = strict
@ordered = normalize_values(values)
@allowed = Set.new(@ordered)

validate!
end

# @return [Boolean]
def match?(value:)
@allowed.include?(value)
end

def valid?(value)
match?(value: value)
end

def expected_values
@ordered
end

def error_message_for(value:)
"Expected one of #{expected_values.inspect}, got #{value.inspect}"
end

def inspect
core = "Low::Types::Enum[#{expected_values.map(&:inspect).join(', ')}]"
return core unless @strict

"#{core}{strict: true}"
end

private

def normalize_values(values)
# Preserve first-seen order but ensure uniqueness.
values.each_with_object([]) { |v, acc| acc << v unless acc.include?(v) }
end

def validate!
return unless @strict && @allowed.empty?

raise Low::ConfigError, 'Enum[...] cannot be empty in strict mode'
end
end

# Creates an Enum type class that responds to `.match?(value:)` so it can be used by LowType.
#
# @example
# type = Low::Types::Enum[:draft, :published]
# type.match?(value: :draft) # => true
def self.[](*values, strict: false)
definition = Definition.new(values: values, strict: strict)
build_enum_type(definition)
end

# Convenience helper for symbol-only enums.
def self.symbols(*symbols, strict: false)
non_symbols = symbols.reject { |v| v.is_a?(Symbol) }
return self[*symbols, strict: strict] if non_symbols.empty?

raise Low::ConfigError, "Enum.symbols expects only symbols, got #{non_symbols.map(&:inspect).join(', ')}"
end

private_class_method

def self.build_enum_type(definition)
Class.new do
include Low::Types::MatchableType

@definition = definition

def self.match?(value:)
@definition.match?(value: value)
end

def self.valid?(value)
@definition.valid?(value)
end

def self.error_message_for(value:)
@definition.error_message_for(value: value)
end

def self.expected_values
@definition.expected_values
end

def self.inspect
@definition.inspect
end
end
end
end
end
end

9 changes: 9 additions & 0 deletions lib/types/matchable_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module Low
module Types
# Marker module: classes including this module can validate via `.match?(value:)`.
module MatchableType; end
end
end

65 changes: 65 additions & 0 deletions spec/units/boolean_enum_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

require_relative '../../lib/types/complex_types'
require_relative '../../lib/types/error_types'
require_relative '../../lib/expressions/type_expression'
require_relative '../../lib/syntax/union_types'

RSpec.describe 'Low::Types::Boolean and Low::Types::Enum' do
DummyProxy = Class.new do
def error_type
Low::ArgumentTypeError
end

def error_message(value:)
'type mismatch'
end

def backtrace(backtrace:, hidden_paths:)
backtrace
end
end

let(:proxy) { DummyProxy.new }

describe Low::Types::Boolean do
it 'matches only true/false' do
expect(described_class.match?(value: true)).to eq(true)
expect(described_class.match?(value: false)).to eq(true)

expect(described_class.match?(value: 1)).to eq(false)
expect(described_class.match?(value: 't')).to eq(false)
end

it 'provides a useful error message' do
expect(described_class.error_message_for(value: :nope)).to eq('Expected true/false, got :nope')
end
end

describe Low::Types::Enum do
it 'validates membership and formats errors' do
type = described_class.symbols(:draft, :published)

expect(type.match?(value: :draft)).to eq(true)
expect(type.match?(value: :deleted)).to eq(false)

expect(type.error_message_for(value: :deleted)).to eq(
'Expected one of [:draft, :published], got :deleted'
)
end

it 'raises in strict mode for empty enums' do
expect { described_class[strict: true] }.to raise_error(Low::ConfigError, /cannot be empty/i)
end

it 'supports union types with TypeExpression' do
enum_a = described_class.symbols(:draft, :published)
enum_b = described_class.symbols(:archived)

type_expression = (enum_a | enum_b)

expect { type_expression.validate!(value: :archived, proxy: proxy) }.not_to raise_error
end
end
end