lowtype 1.5.0 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fa1f2cac7f1067dd6d9c7c8f9a2c9d206cc22638c0223259a39909b65b8169f
4
- data.tar.gz: bd3eaa654437ebabeecad44fccd7aa01cff8fb92d489318181c7d0fc0f757ebe
3
+ metadata.gz: 82aa9ca5209933447a6c144575a923487ed7a9454b24af79e814e96322657459
4
+ data.tar.gz: 1c589f63849d78d9f8f80acb02288192650fc115f294741d0ef4098d92d132bb
5
5
  SHA512:
6
- metadata.gz: ece1f0a96afaebdb246a4448875873fddd1eb8b2785a83f20c41f8960d46f6d37bab589743bec4fc945c237c8fef6cca5ce1a973133e81dcb08fddfdb39273a4
7
- data.tar.gz: 5e5d946234acf6666fc3671d7027368fce3f0bcfcdf665083d45b15a6ea8bd56111c9c61f5ad3bbfeb09995ebce1563160c0a3c3eebb46d0902638077637e3be
6
+ metadata.gz: f5dc29f5c922d64de71b94dc7e72bd263bdefe21fe2463f472893edd3ac37bd1ebcc1502038f1b719004b6db86b6c6884d906bca59bba8f6c48ff45e423e9ef7
7
+ data.tar.gz: 51f526a9ee47bae2d5018e9e7f8e3ecc60eddf4ce1f30bb2df439997912713dde8f30f073230d02a6b8ccc63d0a5245dfe0baee43d396c2b225e986f7f2e8c7b
@@ -11,7 +11,7 @@ module Low
11
11
 
12
12
  return unless ancestors.include?('Sinatra::Base')
13
13
 
14
- klass.prepend SinatraAdapter.new.module(file_path: class_proxy.file_path)
14
+ klass.prepend SinatraAdapter.new.module(class_proxy:)
15
15
  end
16
16
  end
17
17
  end
@@ -11,10 +11,11 @@ module Low
11
11
  module Adapter
12
12
  # We don't use https://sinatrarb.com/extensions.html because we need to type check all Ruby methods (not just Sinatra) at a lower level.
13
13
  class SinatraAdapter < AdapterInterface
14
- def module(file_path:) # rubocop:disable Metrics/AbcSize
15
- Module.new do
16
- @@file_path = file_path # rubocop:disable Style/ClassVars
14
+ # Route method proxies are captured on class load, so that validating a response doesn't need Lowkey's cache.
15
+ def module(class_proxy:) # rubocop:disable Metrics/AbcSize
16
+ route_proxies = class_proxy.keyed_methods
17
17
 
18
+ Module.new do
18
19
  # Unfortunately overriding invoke() is the best way to validate types for now. Though direct it's also very compute efficient.
19
20
  # I originally tried an after filter and it mostly worked but it only had access to Response which isn't the raw return value.
20
21
  # I suggest that Sinatra provide a hook that allows us to access the raw return value of a route before it becomes a Response.
@@ -36,9 +37,9 @@ module Low
36
37
  nil # avoid double setting the same response tuple twice
37
38
  end
38
39
 
39
- def lowtype_validate!(value:)
40
+ define_method(:lowtype_validate!) do |value:|
40
41
  route = "#{request.request_method} #{request.path}"
41
- if (method_proxy = Lowkey[@@file_path][self.class.name][route]) && (proxy = method_proxy.return_proxy)
42
+ if (method_proxy = route_proxies[route]) && (proxy = method_proxy.return_proxy)
42
43
  proxy.expression.validate!(value:, proxy:)
43
44
  end
44
45
  end
data/lib/cli/cli.rb CHANGED
@@ -22,8 +22,8 @@ module LowType
22
22
  end
23
23
 
24
24
  line('strip!') do
25
- s { 'Strips files from the current directory of their types' }
26
- e { 'lowtype strip' }
25
+ s { 'Strips types from files in the current directory' }
26
+ e { 'lowtype strip!' }
27
27
  x { TypeStripper.export(src: '.', dest: '.') }
28
28
  end
29
29
 
@@ -23,6 +23,8 @@ module LowType
23
23
  return COMPLEX_TYPE_MAP[type] if COMPLEX_TYPE_MAP.key?(type)
24
24
 
25
25
  case type
26
+ when ::Low::Types::Enum::Definition then map_enum(type)
27
+ when ::Low::Types::Status::StatusCode then 'Integer'
26
28
  when ::Low::TypeExpression then map_expression(type)
27
29
  when Array then map_array(type)
28
30
  when Hash then map_hash(type)
@@ -47,6 +49,17 @@ module LowType
47
49
  "Array[#{map(type.first)}]"
48
50
  end
49
51
 
52
+ def map_enum(type)
53
+ literals = type.values.map do |value|
54
+ case value
55
+ when Symbol, String, Integer, true, false, nil then value.inspect
56
+ else return 'untyped'
57
+ end
58
+ end
59
+
60
+ literals.length > 1 ? "(#{literals.join(' | ')})" : literals.first
61
+ end
62
+
50
63
  def map_hash(type)
51
64
  return 'Hash[untyped, untyped]' if type.empty?
52
65
 
@@ -1,9 +1,158 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+ require 'lowkey'
5
+
6
+ require_relative '../definitions/type_accessors'
7
+ require_relative '../queries/file_query'
8
+
3
9
  module LowType
10
+ # Removes LowType from source files, leaving plain Ruby:
11
+ # method: def greet(name = String | 'World') -> { String } => def greet(name = 'World')
12
+ # variable: greeting = type String | 'Hello' => greeting = 'Hello'
13
+ # accessor: type_accessor name: String => attr_accessor :name
14
+ #
15
+ # Files are loaded into Ruby to evaluate args and decide a type from a default value. Slim syntax may reduce this need.
16
+ # Lowkey then rewrites the method signatures and method calls.
4
17
  module TypeStripper
18
+ LOWTYPE_PATTERN = /^\s*include\s+LowType\b/
19
+ REQUIRE_PATTERN = /\A['"][^'"]*(?:lowtype|low_type)['"]\z/
20
+ ACCESSOR_METHODS = { type_reader: 'attr_reader', type_writer: 'attr_writer', type_accessor: 'attr_accessor' }.freeze
21
+ TYPE_METHODS = %i[type low_type].freeze
22
+
5
23
  module_function
6
24
 
7
- def export(src:, dest:); end
25
+ def export(src:, dest:)
26
+ src = File.expand_path(src)
27
+ dest = File.expand_path(dest)
28
+
29
+ # Stripping reads method calls from their Prism nodes, which Lowkey releases once a file loads by default.
30
+ keeping_nodes do
31
+ file_paths(src:, dest:).each do |file_path|
32
+ dest_path = File.file?(src) ? dest : File.join(dest, file_path.delete_prefix(src))
33
+ next copy(file_path:, dest_path:) unless file_path.end_with?('.rb') && File.read(file_path).match?(LOWTYPE_PATTERN)
34
+
35
+ export_file(file_path:, dest_path:)
36
+ end
37
+ end
38
+ end
39
+
40
+ def keeping_nodes
41
+ release_nodes = ::Lowkey.config.release_nodes
42
+ ::Lowkey.config.release_nodes = false
43
+ yield
44
+ ensure
45
+ ::Lowkey.config.release_nodes = release_nodes
46
+ end
47
+
48
+ def export_file(file_path:, dest_path:)
49
+ stripped = strip(file_path:)
50
+ write(path: dest_path, content: stripped)
51
+
52
+ puts "Stripped #{dest_path}"
53
+ warn "lowtype: Kept LowType in #{dest_path} as some type accessors have default values" if stripped.match?(LOWTYPE_PATTERN)
54
+ rescue ScriptError, StandardError => e
55
+ warn "lowtype: Skipped #{file_path} as it could not be loaded (#{e.class}: #{e.message.lines.first&.strip})"
56
+ copy(file_path:, dest_path:)
57
+ end
58
+
59
+ # Excludes the destination when it's inside the source, so that a previous export isn't exported again.
60
+ def file_paths(src:, dest: nil)
61
+ return [src] if File.file?(src)
62
+
63
+ Dir.glob('**/*', File::FNM_DOTMATCH, base: src).filter_map do |path|
64
+ file_path = File.join(src, path)
65
+ next if path.split('/').intersect?(%w[. .. .git]) || (dest != src && file_path.start_with?("#{dest}/"))
66
+
67
+ file_path if File.file?(file_path)
68
+ end
69
+ end
70
+
71
+ def strip(file_path:)
72
+ require file_path
73
+ raise ::Low::MissingFileError, "LowType did not load #{file_path}" unless (file_proxy = ::Lowkey[file_path])
74
+
75
+ # Method calls first, as a method call can be on the same line as a signature: "def method(arg = Type) = type(...)".
76
+ rewrite_method_calls(method_calls: file_proxy.definitions.values.flat_map(&:method_calls), file_path:)
77
+ method_proxies(file_proxy:).each { |method_proxy| method_proxy.rewrite_signature if method_proxy.expressions? }
78
+
79
+ file_proxy.lines.join
80
+ end
81
+
82
+ # Instance and class methods with the same name share a key in "keyed_methods".
83
+ def method_proxies(file_proxy:)
84
+ file_proxy.definitions.values.flat_map { |proxy| [*proxy.keyed_methods.values, *proxy.class_methods.values] }.uniq
85
+ end
86
+
87
+ # Method calls on the same line are rewritten from last to first so that their offsets stay valid.
88
+ # Type accessors with default values keep LowType included.
89
+ def rewrite_method_calls(method_calls:, file_path:)
90
+ # Filter before sorting, so that only the method calls being rewritten build their source.
91
+ method_calls = method_calls.filter { |method_call| rewritable?(method_call:) }
92
+
93
+ accessors = method_calls.filter { |method_call| accessor?(method_call:) }
94
+ attributes = accessors.filter { |method_call| attribute?(method_call:, file_path:) }
95
+ keep_lowtype = attributes.count < accessors.count
96
+
97
+ method_calls.sort_by { |method_call| -method_call.start_offset }.each do |method_call|
98
+ rewrite_method_call(method_call:, attribute: attributes.include?(method_call), keep_lowtype:)
99
+ end
100
+ end
101
+
102
+ def rewritable?(method_call:)
103
+ local_type?(method_call:) || accessor?(method_call:) || lowtype?(method_call:)
104
+ end
105
+
106
+ def rewrite_method_call(method_call:, attribute:, keep_lowtype:)
107
+ if local_type?(method_call:)
108
+ method_call.rewrite(method_call.default)
109
+ elsif attribute
110
+ method_call.rewrite("#{ACCESSOR_METHODS[method_call.name]} #{method_call.keywords.map(&:inspect).join(', ')}")
111
+ elsif !keep_lowtype && lowtype?(method_call:)
112
+ method_call.remove
113
+ end
114
+ end
115
+
116
+ # "type String | 'Hello'" becomes "'Hello'".
117
+ def local_type?(method_call:)
118
+ !method_call.receiver? && TYPE_METHODS.include?(method_call.name) && !method_call.default.nil?
119
+ end
120
+
121
+ # Only symbol keys can become attribute names. A double splat like "**expressions" can't be converted.
122
+ def accessor?(method_call:)
123
+ !method_call.receiver? && ACCESSOR_METHODS.key?(method_call.name)
124
+ end
125
+
126
+ # Type accessors are attributes when they have no default value. Their expressions are evaluated on class load.
127
+ def attribute?(method_call:, file_path:)
128
+ return false unless method_call.arguments.one? && method_call.keywords
129
+ return false unless (expressions = ::Low::TypeAccessors.expressions[[file_path, method_call.start_line]])
130
+
131
+ expressions.values.all? { |expression| expression.required? || expression.default_value.nil? }
132
+ end
133
+
134
+ # "include LowType", "using LowType::Syntax" and "require 'lowtype'".
135
+ def lowtype?(method_call:)
136
+ return false if method_call.receiver?
137
+
138
+ case method_call.name
139
+ when :include then method_call.arguments == ['LowType']
140
+ when :using then method_call.arguments == ['LowType::Syntax']
141
+ when :require, :require_relative then method_call.arguments.one? && method_call.arguments.first.match?(REQUIRE_PATTERN)
142
+ else false
143
+ end
144
+ end
145
+
146
+ def write(path:, content:)
147
+ FileUtils.mkdir_p(File.dirname(path))
148
+ File.write(path, content)
149
+ end
150
+
151
+ def copy(file_path:, dest_path:)
152
+ return if dest_path == file_path
153
+
154
+ FileUtils.mkdir_p(File.dirname(dest_path))
155
+ FileUtils.cp(file_path, dest_path, preserve: true)
156
+ end
8
157
  end
9
158
  end
@@ -66,6 +66,9 @@ module Low
66
66
  new.class_evaluate(proxy: param_proxy, class_binding:)
67
67
  end
68
68
  param_proxy.expression = cast_type_expression(expression:, param_proxy:)
69
+
70
+ polyfill_default(param_proxy:)
71
+ validate_default!(param_proxy:)
69
72
  end
70
73
  rescue NameError => e
71
74
  mp = method_proxy
@@ -99,6 +102,30 @@ module Low
99
102
 
100
103
  nil
101
104
  end
105
+
106
+ def polyfill_default(param_proxy:)
107
+ expression = param_proxy.expression
108
+
109
+ case expression.class.name
110
+ when 'Low::Dependency'
111
+ # Remap dependency expression to providers hash lookup method.
112
+ param_proxy.default = "Providers[#{expression.provider_key.inspect}]"
113
+ end
114
+ end
115
+
116
+ # Fail early on class load rather than on method call.
117
+ def validate_default!(param_proxy:)
118
+ expression = param_proxy.expression
119
+ return unless expression.is_a?(TypeExpression) && !expression.required?
120
+
121
+ # When an enum's default value isn't an allowed value.
122
+ if expression.types.any? { |type| type.is_a?(Types::Enum::Definition) }
123
+ expression.validate!(value: expression.default_value, proxy: param_proxy)
124
+ end
125
+ rescue ArgumentError
126
+ raise AllowedTypeError, "Invalid default value #{expression.display_default} for parameter '#{param_proxy.name}' " \
127
+ "at #{param_proxy.file_path}:#{param_proxy.start_line}. Valid types: '#{expression.valid_types}'"
128
+ end
102
129
  end
103
130
  end
104
131
  end
@@ -104,7 +104,10 @@ module Low
104
104
  method_proxies.values.filter(&:expressions?).each do |method_proxy|
105
105
  method_proxy.rewrite_signature
106
106
 
107
- klass.class_eval(method_proxy.export, method_proxy.file_path, method_proxy.start_line)
107
+ # Class methods are evaluated on the singleton class but "def self.method" would define a method on the
108
+ # singleton class's own singleton class. So methods with a receiver are evaluated in the class instead.
109
+ target = klass.singleton_class? && method_proxy.receiver? ? klass.attached_object : klass
110
+ target.class_eval(method_proxy.export, method_proxy.file_path, method_proxy.start_line)
108
111
 
109
112
  if class_proxy.private_start_line && method_proxy.start_line > class_proxy.private_start_line
110
113
  klass.send(:private, method_proxy.name)
@@ -17,53 +17,76 @@ module Low
17
17
  # type_writer name: String | nil # accepts String or nil
18
18
  #
19
19
  module TypeAccessors
20
- def type_reader(named_expressions)
21
- named_expressions.each do |name, exp|
22
- last_caller = caller_locations(1, 1).first
23
-
24
- file_path = last_caller.path
25
- scope = "#{self}##{name}"
26
-
27
- expression = cast_type_expression(exp)
28
- # Source usually defined on file load with access to lines in a source file, but type accessors are defined on class load.
29
- source = ::Lowkey::Source.new(file_path:, scope:, lines: [], start_line: last_caller.lineno, end_line: last_caller.lineno)
30
- proxy = ::Lowkey::ReturnProxy.new(name:, source:, expression:)
31
-
32
- define_method(name) do
33
- value = instance_variable_get("@#{name}")
34
- expression.validate!(value:, proxy:)
35
- value
36
- end
20
+ class << self
21
+ # Expressions by file path and line, so that tools like TypeStripper can find the expressions of a type accessor.
22
+ def expressions
23
+ @expressions ||= {}
24
+ end
25
+
26
+ # A default value is returned when the instance variable is nil. Mutable defaults are new copies each time.
27
+ def default_value(expression:)
28
+ return nil if expression.required?
29
+
30
+ value = expression.default_value
31
+ value.is_a?(ValueExpression) ? value : value.dup
37
32
  end
38
33
  end
39
34
 
35
+ def type_reader(named_expressions)
36
+ location = caller_locations(1, 1).first
37
+ named_expressions.each { |name, expression| define_type_reader(name:, expression:, location:) }
38
+ end
39
+
40
40
  def type_writer(named_expressions)
41
+ location = caller_locations(1, 1).first
42
+ named_expressions.each { |name, expression| define_type_writer(name:, expression:, location:) }
43
+ end
44
+
45
+ def type_accessor(named_expressions)
46
+ location = caller_locations(1, 1).first
47
+
41
48
  named_expressions.each do |name, expression|
42
- last_caller = caller_locations(1, 1).first
49
+ define_type_reader(name:, expression:, location:)
50
+ define_type_writer(name:, expression:, location:)
51
+ end
52
+ end
53
+
54
+ private
43
55
 
44
- file_path = last_caller.path
45
- scope = "#{self}##{name}"
56
+ def define_type_reader(name:, expression:, location:)
57
+ expression = register(name:, expression:, location:)
58
+ proxy = ::Lowkey::ReturnProxy.new(name:, source: accessor_source(name:, location:), expression:)
46
59
 
47
- expression = cast_type_expression(expression)
48
- # Source usually defined on file load with access to lines in a source file, but type accessors are defined on class load.
49
- source = ::Lowkey::Source.new(file_path:, scope:, lines: [], start_line: last_caller.lineno, end_line: last_caller.lineno)
50
- proxy = ::Lowkey::ParamProxy.new(name:, source:, type: :pos_req, position: nil, expression:)
60
+ define_method(name) do
61
+ value = instance_variable_get("@#{name}")
62
+ value = TypeAccessors.default_value(expression:) if value.nil?
51
63
 
52
- define_method("#{name}=") do |value|
53
- expression.validate!(value:, proxy:)
54
- instance_variable_set("@#{name}", value)
55
- end
64
+ expression.validate!(value:, proxy:)
65
+ value.is_a?(ValueExpression) ? value.value : value
56
66
  end
57
67
  end
58
68
 
59
- def type_accessor(named_expressions)
60
- named_expressions.each do |name, expression|
61
- type_reader({ name => expression })
62
- type_writer({ name => expression })
69
+ def define_type_writer(name:, expression:, location:)
70
+ expression = register(name:, expression:, location:)
71
+ proxy = ::Lowkey::ParamProxy.new(name:, source: accessor_source(name:, location:), type: :pos_req, position: nil, expression:)
72
+
73
+ define_method("#{name}=") do |value|
74
+ expression.validate!(value:, proxy:)
75
+ instance_variable_set("@#{name}", value)
63
76
  end
64
77
  end
65
78
 
66
- private
79
+ def register(name:, expression:, location:)
80
+ expression = cast_type_expression(expression)
81
+ expressions = TypeAccessors.expressions[[location.path, location.lineno]] ||= {}
82
+ expressions[name] = expression
83
+ end
84
+
85
+ # Type accessors are defined on class load (not file load) so Source needs to be simulated.
86
+ def accessor_source(name:, location:)
87
+ line = location.lineno
88
+ ::Lowkey::Source.new(file_path: location.path, scope: "#{self}##{name}", lines: [], start_line: line, end_line: line)
89
+ end
67
90
 
68
91
  def cast_type_expression(expression)
69
92
  if expression.is_a?(::Expressions::Expression)
@@ -55,7 +55,7 @@ module Low
55
55
  if type.is_a?(Array)
56
56
  "[#{type.map { |subtype| valid_subtype(subtype:) }.join(', ')}]"
57
57
  else
58
- type.inspect.to_s.delete_prefix('Low::Types::')
58
+ display_name(type:)
59
59
  end
60
60
  end
61
61
 
@@ -63,6 +63,13 @@ module Low
63
63
  types.join(' | ')
64
64
  end
65
65
 
66
+ def display_default
67
+ display_default = default_value
68
+ display_default = default_value.value if default_value.is_a?(ValueExpression)
69
+
70
+ display_default.inspect.to_s
71
+ end
72
+
66
73
  private
67
74
 
68
75
  def union_expression(expression)
@@ -85,14 +92,20 @@ module Low
85
92
 
86
93
  def valid_subtype(subtype:)
87
94
  if subtype.is_a?(TypeExpression)
88
- types = subtype.types
95
+ types = subtype.types.map { |type| display_name(type:) }
89
96
  types << 'nil' if subtype.default_value.nil?
90
97
  types.join(' | ')
91
98
  else
92
- subtype.to_s.delete_prefix('Low::Types::')
99
+ display_name(type: subtype)
93
100
  end
94
101
  end
95
102
 
103
+ def display_name(type:)
104
+ return type.display_name if type.respond_to?(:display_name)
105
+
106
+ type.inspect.to_s.delete_prefix('Low::Types::')
107
+ end
108
+
96
109
  def array_types_match_values?(types:, values:, proxy:)
97
110
  # [X, Y, Z] An arbitrary amount of elements are arbitrary types in an arbitrary order.
98
111
  if types.length > 1
@@ -136,8 +149,9 @@ module Low
136
149
  end
137
150
 
138
151
  def type_matches_value?(type:, value:, proxy:)
139
- if type.instance_of?(Class)
140
- return type.match?(value:) if Low::TypeQuery.complex_type?(expression: type)
152
+ if Low::TypeQuery.complex_type?(expression: type)
153
+ return type.match?(value:)
154
+ elsif type.instance_of?(Class)
141
155
  return value.value <= type if value.instance_of?(ValueExpression)
142
156
 
143
157
  return value.is_a?(type)
data/lib/lowtype.rb CHANGED
@@ -32,10 +32,11 @@ require_relative 'types/complex_types'
32
32
  # │ │ │ │ │
33
33
  module LowType
34
34
  # Defers evaluation and redefinition until after the class body finishes loading via TracePoint :end.
35
- def self.included(klass)
36
- file_path = Low::FileQuery.file_path(klass:)
37
- file_proxy = Lowkey.load(file_path)
38
- class_proxy = file_proxy[klass.name]
35
+ def self.included(klass) # rubocop:disable Metrics/AbcSize
36
+ class_proxy = Lowkey[Low::FileQuery.file_path(klass:)][klass.name]
37
+
38
+ # Type accessors evaluate union types in the class body, before the class is evaluated.
39
+ require_relative 'syntax/union_types' if LowType.config.union_type_expressions
39
40
 
40
41
  klass.include Low::ExpressionHelpers
41
42
  klass.extend Low::ExpressionHelpers
@@ -45,7 +46,7 @@ module LowType
45
46
  # Use TracePoint :end to capture the class binding after the class body finishes loading.
46
47
  # At :end time, trace.self is the including class and trace.binding is the class body's binding,
47
48
  # stored on class_proxy.class_binding for use by LowType and other consumers.
48
- tp = TracePoint.new(:end) do |trace|
49
+ trace_point = TracePoint.new(:end) do |trace|
49
50
  next unless trace.self == klass
50
51
 
51
52
  class_proxy.class_binding = trace.binding
@@ -60,10 +61,10 @@ module LowType
60
61
 
61
62
  Low::Adapter::Loader.load(klass:, class_proxy:)
62
63
 
63
- tp.disable
64
+ trace_point.disable
64
65
  end
65
66
 
66
- tp.enable
67
+ trace_point.enable
67
68
  end
68
69
 
69
70
  Config = Struct.new(
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../expressions/type_expression'
4
+ require_relative '../types/type_definition'
4
5
 
5
6
  module Low
6
7
  # TODO: Unit test.
7
8
  class TypeQuery
8
9
  class << self
9
10
  def type?(expression)
10
- basic_type?(expression:) || complex_type?(expression:)
11
+ basic_type?(expression:) || complex_type?(expression:) || typed_array?(expression:) || typed_hash?(expression:)
11
12
  end
12
13
 
13
14
  def typed_array?(expression:)
@@ -15,11 +16,17 @@ module Low
15
16
  end
16
17
 
17
18
  def value?(value)
18
- !basic_type?(expression: value) && !complex_type?(expression: value)
19
+ !type?(value)
19
20
  end
20
21
 
22
+ # Complex types and type definitions respond to "match?(value:)".
21
23
  def complex_type?(expression:)
22
- Low::Types::COMPLEX_TYPES.include?(expression) || typed_array?(expression:) || typed_hash?(expression:)
24
+ Low::Types::COMPLEX_TYPES.include?(expression) || typed_definition?(expression:)
25
+ end
26
+
27
+ # A parameterized type such as "Status[200]" or "Enum[:a, :b]".
28
+ def typed_definition?(expression:)
29
+ expression.is_a?(Low::TypeDefinition)
23
30
  end
24
31
 
25
32
  private
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Low
4
+ module Types
5
+ class Boolean
6
+ class << self
7
+ def match?(value:)
8
+ value.equal?(true) || value.equal?(false)
9
+ end
10
+
11
+ def display_name
12
+ 'Boolean'
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../factories/type_factory'
4
+ require_relative 'boolean'
5
+ require_relative 'enum'
4
6
  require_relative 'status'
5
7
 
6
8
  module Low
7
9
  module Types
8
10
  COMPLEX_TYPES = [
9
- Boolean = TypeFactory.complex_type(Object),
11
+ Boolean,
10
12
  Headers = TypeFactory.complex_type(Hash),
11
13
  HTML = TypeFactory.complex_type(String),
12
14
  JSON = TypeFactory.complex_type(String),
data/lib/types/enum.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error_types'
4
+ require_relative 'type_definition'
5
+
6
+ module Low
7
+ module Types
8
+ # A set of allowed values like "Enum[:value, 'value', 123]".
9
+ class Enum
10
+ def self.[](*values)
11
+ Definition.new(values:)
12
+ end
13
+
14
+ class Definition
15
+ include TypeDefinition
16
+
17
+ attr_reader :values
18
+
19
+ def initialize(values:)
20
+ raise AllowedTypeError, 'Enum requires at least one allowed value' if values.empty?
21
+
22
+ @values = values.freeze
23
+ end
24
+
25
+ def match?(value:)
26
+ @values.any? { |allowed| allowed == value }
27
+ end
28
+
29
+ def display_name
30
+ "Enum[#{@values.map(&:inspect).join(', ')}]"
31
+ end
32
+
33
+ def ==(other)
34
+ other.class == self.class && other.values == @values
35
+ end
36
+
37
+ def eql?(other)
38
+ self == other
39
+ end
40
+
41
+ def hash
42
+ [self.class, @values].hash
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/types/status.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'complex_type'
4
4
  require_relative 'error_types'
5
+ require_relative 'type_definition'
5
6
 
6
7
  module Low
7
8
  module Types
@@ -15,6 +16,8 @@ module Low
15
16
  end
16
17
 
17
18
  class StatusCode
19
+ include TypeDefinition
20
+
18
21
  attr_reader :status_code
19
22
 
20
23
  STATUS_CODES = [
@@ -37,6 +40,14 @@ module Low
37
40
  @status_code = status_code
38
41
  end
39
42
 
43
+ def match?(value:)
44
+ value == @status_code
45
+ end
46
+
47
+ def display_name
48
+ "Status[#{@status_code}]"
49
+ end
50
+
40
51
  def ==(other)
41
52
  other.class == self.class && other.status_code == @status_code
42
53
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Low
4
+ # A complex type with params, created via the "Type[params]" syntax.
5
+ module TypeDefinition
6
+ # For "Type[params] | [type_expression/type/value]" situations, convert definition into a type expression.
7
+ def |(other)
8
+ ::Low::TypeExpression.new(type: self) | other
9
+ end
10
+
11
+ def match?(value:)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def display_name
16
+ raise NotImplementedError
17
+ end
18
+ end
19
+ end
data/lib/version.rb CHANGED
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Low
4
4
  module Type
5
- VERSION = '1.5.0'
5
+ VERSION = '1.6.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lowtype
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dirload
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.4'
46
+ version: '0.5'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.4'
53
+ version: '0.5'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: trees
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -101,10 +101,13 @@ files:
101
101
  - lib/queries/type_query.rb
102
102
  - lib/syntax/syntax.rb
103
103
  - lib/syntax/union_types.rb
104
+ - lib/types/boolean.rb
104
105
  - lib/types/complex_type.rb
105
106
  - lib/types/complex_types.rb
107
+ - lib/types/enum.rb
106
108
  - lib/types/error_types.rb
107
109
  - lib/types/status.rb
110
+ - lib/types/type_definition.rb
108
111
  - lib/version.rb
109
112
  homepage: https://github.com/low-rb/lowtype
110
113
  licenses: []
@@ -125,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
125
128
  - !ruby/object:Gem::Version
126
129
  version: '0'
127
130
  requirements: []
128
- rubygems_version: 4.0.6
131
+ rubygems_version: 3.6.2
129
132
  specification_version: 4
130
133
  summary: Elegant types in Ruby
131
134
  test_files: []