funicular 0.0.1 → 0.2.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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. metadata +154 -8
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises the SSR half of Funicular under CRuby: the shared HTMLSerializer
6
+ # and the full path of loading the mrblib runtime + a fixture app, then
7
+ # rendering a routed component to HTML with injected state.
8
+ class SSRTest < Minitest::Test
9
+ APP_DIR = File.expand_path("fixtures/funicular_app", __dir__)
10
+
11
+ def setup
12
+ Funicular::SSR::Runtime.load_framework!
13
+ end
14
+
15
+ # --- HTMLSerializer (pure Ruby) ---------------------------------------
16
+
17
+ def serialize(vnode)
18
+ Funicular::VDOM::HTMLSerializer.serialize(vnode)
19
+ end
20
+
21
+ def el(tag, props = {}, children = [])
22
+ Funicular::VDOM::Element.new(tag, props, children)
23
+ end
24
+
25
+ def test_serializes_element_with_attributes
26
+ assert_equal '<div class="box" id="x"></div>',
27
+ serialize(el("div", { class: "box", id: "x" }))
28
+ end
29
+
30
+ def test_escapes_text_content
31
+ assert_equal "<p>a &amp; b &lt;c&gt;</p>",
32
+ serialize(el("p", {}, ["a & b <c>"]))
33
+ end
34
+
35
+ def test_escapes_attribute_values
36
+ assert_equal '<div title="&quot;hi&quot;"></div>',
37
+ serialize(el("div", { title: '"hi"' }))
38
+ end
39
+
40
+ def test_skips_event_handlers
41
+ html = serialize(el("button", { onclick: :handle }, ["Go"]))
42
+ assert_equal "<button>Go</button>", html
43
+ end
44
+
45
+ def test_boolean_attribute_present_and_absent
46
+ assert_equal '<input disabled="disabled">',
47
+ serialize(el("input", { disabled: true }))
48
+ assert_equal "<input>",
49
+ serialize(el("input", { disabled: false }))
50
+ end
51
+
52
+ def test_void_element_self_closes
53
+ assert_equal "<br>", serialize(el("br"))
54
+ assert_equal '<img src="/a.png">', serialize(el("img", { src: "/a.png" }))
55
+ end
56
+
57
+ def test_blocks_javascript_uri
58
+ assert_equal "<a>x</a>",
59
+ serialize(el("a", { href: "javascript:alert(1)" }, ["x"]))
60
+ end
61
+
62
+ # --- Full SSR render (runtime + fixture app) --------------------------
63
+
64
+ def test_render_injects_server_state
65
+ result = Funicular::SSR.render(
66
+ path: "/greet",
67
+ state: { title: "Channels", items: [{ "id" => 1, "name" => "general" },
68
+ { "id" => 2, "name" => "random" }] },
69
+ source_dir: APP_DIR
70
+ )
71
+
72
+ assert_includes result[:html], "<h1>Channels</h1>"
73
+ assert_includes result[:html], "general"
74
+ assert_includes result[:html], "random"
75
+ assert_equal GreetingComponent, result[:component]
76
+ end
77
+
78
+ def test_render_uses_initialize_state_without_injection
79
+ result = Funicular::SSR.render(path: "/greet", source_dir: APP_DIR)
80
+ assert_includes result[:html], "<h1>Default Title</h1>"
81
+ end
82
+
83
+ def test_render_unmatched_route_returns_empty
84
+ result = Funicular::SSR.render(path: "/no/such/path", source_dir: APP_DIR)
85
+ assert_equal "", result[:html]
86
+ assert_nil result[:component]
87
+ end
88
+
89
+ def test_route_params_become_props
90
+ # Renders without raising; :id from the path is passed as a prop.
91
+ result = Funicular::SSR.render(path: "/greet/42", source_dir: APP_DIR)
92
+ assert_includes result[:html], "greeting"
93
+ end
94
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "funicular"
5
+ require "minitest/autorun"
6
+ require "minitest/reporters"
7
+ Minitest::Reporters.use!
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Load the mrblib runtime before defining model subclasses below, since their
6
+ # class bodies reference Funicular::Model and call `validates` at load time.
7
+ Funicular::SSR::Runtime.load_framework!
8
+
9
+ # Exercises the client-side validation framework under CRuby (the same code
10
+ # runs in the browser under PicoRuby; see test/validations_test.rb).
11
+ class ValidationsTest < Minitest::Test
12
+ # A fresh, isolated model class with the given schema so validators declared
13
+ # in one test never leak into another.
14
+ def model_class(attributes = { "name" => false, "age" => false }, endpoints = {})
15
+ klass = Class.new(Funicular::Model)
16
+ attrs = {}
17
+ attributes.each { |name, readonly| attrs[name] = { "type" => "string", "readonly" => readonly } }
18
+ klass.load_schema("attributes" => attrs, "endpoints" => endpoints)
19
+ klass
20
+ end
21
+
22
+ # --- individual validators -------------------------------------------
23
+
24
+ def test_presence
25
+ k = model_class
26
+ k.class_eval { validates :name, presence: true }
27
+ blank = k.new("name" => "")
28
+ assert_equal false, blank.valid?
29
+ assert_equal ["can't be blank"], blank.errors[:name]
30
+ assert_equal true, k.new("name" => "Alice").valid?
31
+ end
32
+
33
+ def test_length_maximum_and_minimum
34
+ k = model_class
35
+ k.class_eval { validates :name, length: { minimum: 2, maximum: 5 } }
36
+
37
+ short = k.new("name" => "a"); short.valid?
38
+ assert_equal ["is too short (minimum is 2 characters)"], short.errors[:name]
39
+
40
+ long = k.new("name" => "abcdef"); long.valid?
41
+ assert_equal ["is too long (maximum is 5 characters)"], long.errors[:name]
42
+
43
+ assert_equal true, k.new("name" => "abc").valid?
44
+ end
45
+
46
+ def test_format
47
+ k = model_class
48
+ k.class_eval { validates :name, format: { with: /^[a-z]+$/ } }
49
+ assert_equal true, k.new("name" => "abc").valid?
50
+ bad = k.new("name" => "ABC"); bad.valid?
51
+ assert_equal ["is invalid"], bad.errors[:name]
52
+ end
53
+
54
+ def test_numericality
55
+ k = model_class
56
+ k.class_eval { validates :age, numericality: { only_integer: true, greater_than: 0 } }
57
+ nan = k.new("age" => "abc"); nan.valid?
58
+ assert_equal ["is not a number"], nan.errors[:age]
59
+ assert_equal true, k.new("age" => "10").valid?
60
+ low = k.new("age" => "0"); low.valid?
61
+ assert_equal ["must be greater than 0"], low.errors[:age]
62
+ frac = k.new("age" => "1.5"); frac.valid?
63
+ assert_equal ["must be an integer"], frac.errors[:age]
64
+ end
65
+
66
+ def test_inclusion_and_exclusion
67
+ inc = model_class
68
+ inc.class_eval { validates :name, inclusion: { in: ["a", "b"] } }
69
+ assert_equal true, inc.new("name" => "a").valid?
70
+ assert_equal false, inc.new("name" => "z").valid?
71
+
72
+ exc = model_class
73
+ exc.class_eval { validates :name, exclusion: { in: ["admin"] } }
74
+ assert_equal false, exc.new("name" => "admin").valid?
75
+ assert_equal true, exc.new("name" => "alice").valid?
76
+ end
77
+
78
+ def test_allow_blank_skips_validation
79
+ k = model_class
80
+ k.class_eval { validates :name, length: { minimum: 3 }, allow_blank: true }
81
+ assert_equal true, k.new("name" => "").valid?
82
+ assert_equal false, k.new("name" => "ab").valid?
83
+ end
84
+
85
+ def test_valid_clears_previous_errors
86
+ k = model_class
87
+ k.class_eval { validates :name, presence: true }
88
+ m = k.new("name" => "")
89
+ m.valid?
90
+ m.instance_variable_set("@name", "now set")
91
+ assert_equal true, m.valid?
92
+ assert_equal [], m.errors[:name]
93
+ end
94
+
95
+ # --- schema-derived validators + merge -------------------------------
96
+
97
+ def test_load_schema_merges_and_dedupes
98
+ k = Class.new(Funicular::Model)
99
+ k.class_eval { validates :name, presence: true } # client-declared
100
+ k.load_schema(
101
+ "attributes" => {
102
+ "name" => { "type" => "string", "readonly" => false },
103
+ "age" => { "type" => "string", "readonly" => false }
104
+ },
105
+ "endpoints" => {},
106
+ "validations" => {
107
+ "name" => { "presence" => true, "length" => { "maximum" => 3 } },
108
+ "age" => { "presence" => true }
109
+ }
110
+ )
111
+
112
+ name_kinds = k.validators_on(:name).map(&:kind).sort
113
+ # presence appears once (client wins, schema presence deduped) + length merged
114
+ assert_equal [:length, :presence], name_kinds
115
+ assert_equal [:presence], k.validators_on(:age).map(&:kind)
116
+ end
117
+
118
+ def test_inline_attribute_validations_are_registered
119
+ # The shape produced by Funicular::Schema.build: validations nested inside
120
+ # each attribute entry.
121
+ k = Class.new(Funicular::Model)
122
+ k.load_schema(
123
+ "attributes" => {
124
+ "name" => {
125
+ "type" => "string", "readonly" => false,
126
+ "validations" => { "presence" => true, "length" => { "maximum" => 3 } }
127
+ }
128
+ },
129
+ "endpoints" => {}
130
+ )
131
+ assert_equal [:length, :presence], k.validators_on(:name).map(&:kind).sort
132
+ assert_equal false, k.new("name" => "toolong").valid?
133
+ assert_equal true, k.new("name" => "ok").valid?
134
+ end
135
+
136
+ def test_format_from_schema_rebuilds_regexp
137
+ k = Class.new(Funicular::Model)
138
+ k.load_schema(
139
+ "attributes" => { "name" => { "type" => "string", "readonly" => false } },
140
+ "endpoints" => {},
141
+ "validations" => { "name" => { "format" => { "with" => "^[a-z]+$", "flags" => "i" } } }
142
+ )
143
+ assert_equal true, k.new("name" => "ABC").valid? # 'i' flag honored
144
+ assert_equal false, k.new("name" => "123").valid?
145
+ end
146
+
147
+ # --- create/update auto-validation -----------------------------------
148
+
149
+ def test_create_short_circuits_when_invalid
150
+ k = model_class({ "name" => false }, "create" => { "method" => "POST", "path" => "/things" })
151
+ k.class_eval { validates :name, presence: true }
152
+
153
+ got_instance = :unset
154
+ got_error = :unset
155
+ # Invalid -> returns before any HTTP call, yields the errors.
156
+ k.create({ "name" => "" }) do |instance, error|
157
+ got_instance = instance
158
+ got_error = error
159
+ end
160
+
161
+ assert_nil got_instance
162
+ assert_instance_of Funicular::Model::Errors, got_error
163
+ assert_equal ["can't be blank"], got_error[:name]
164
+ end
165
+
166
+ def test_update_short_circuits_when_invalid
167
+ k = model_class({ "name" => false }, "update" => { "method" => "PATCH", "path" => "/things/:id" })
168
+ k.class_eval { validates :name, presence: true }
169
+
170
+ m = k.new("name" => "ok")
171
+ m.instance_variable_set("@id", 1)
172
+
173
+ got_success = :unset
174
+ got_result = :unset
175
+ m.update("name" => "") do |success, result|
176
+ got_success = success
177
+ got_result = result
178
+ end
179
+
180
+ assert_equal false, got_success
181
+ assert_equal ["can't be blank"], got_result[:name]
182
+ end
183
+ end
data/mrbgem.rake ADDED
@@ -0,0 +1,16 @@
1
+ MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
2
+ spec.license = 'MIT'
3
+ spec.author = 'HASUMI Hitoshi'
4
+ spec.summary = 'Browser application framework with VDOM for PicoRuby.wasm'
5
+
6
+ unless ENV['TEST_TASK']
7
+ spec.add_dependency 'picoruby-wasm'
8
+ end
9
+ spec.add_dependency 'picoruby-indexeddb'
10
+ spec.add_dependency 'picoruby-json'
11
+ spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
12
+ spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
13
+ spec.add_dependency 'mruby-array-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-array-ext"
14
+ spec.add_dependency 'mruby-string-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-string-ext"
15
+ spec.add_dependency 'mruby-metaprog', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-metaprog"
16
+ end
@@ -0,0 +1,206 @@
1
+ # Attribute validations for Funicular::Model, modeled on ActiveModel.
2
+ #
3
+ # This file is named with a "0_" prefix on purpose: the mruby build
4
+ # concatenates mrblib/**/*.rb in alphabetical order, so this loads before
5
+ # model.rb (which does `include Validations`) and before 1_validators.rb
6
+ # (the concrete validators). Keep this file free of Ruby-only regexp features
7
+ # (\A, \z, $1, ...): on the client, Regexp is a thin JS RegExp wrapper.
8
+ module Funicular
9
+ class Model
10
+ # A small slice of ActiveModel::Errors: messages keyed by attribute.
11
+ # Consumed by FormBuilder, which reads errors[:field].
12
+ class Errors
13
+ def initialize
14
+ @messages = {}
15
+ end
16
+
17
+ def add(attribute, message)
18
+ key = attribute.to_sym
19
+ (@messages[key] ||= []) << message
20
+ message
21
+ end
22
+
23
+ # Always returns an array (possibly empty) for the attribute.
24
+ def [](attribute)
25
+ @messages[attribute.to_sym] || []
26
+ end
27
+
28
+ def added?(attribute)
29
+ !self[attribute].empty?
30
+ end
31
+
32
+ # { attribute_symbol => ["message", ...] }
33
+ def messages
34
+ @messages
35
+ end
36
+
37
+ def full_messages
38
+ result = [] #: Array[String]
39
+ @messages.each do |attribute, msgs|
40
+ human = humanize(attribute)
41
+ msgs.each { |m| result << "#{human} #{m}" }
42
+ end
43
+ result
44
+ end
45
+
46
+ def clear
47
+ @messages = {}
48
+ end
49
+
50
+ def empty?
51
+ @messages.all? { |_attr, msgs| msgs.empty? }
52
+ end
53
+
54
+ def any?
55
+ !empty?
56
+ end
57
+
58
+ private
59
+
60
+ def humanize(attribute)
61
+ words = attribute.to_s.split("_")
62
+ first = words[0].to_s
63
+ first = first[0].to_s.upcase + first[1..-1].to_s
64
+ ([first] + words[1..-1].to_a).join(" ")
65
+ end
66
+ end
67
+
68
+ module Validations
69
+ def self.included(base)
70
+ base.extend(ClassMethods)
71
+ end
72
+
73
+ # Base class for attribute validators, mirroring
74
+ # ActiveModel::Validations::EachValidator.
75
+ class EachValidator
76
+ attr_reader :attributes, :options
77
+
78
+ def initialize(attributes:, **options)
79
+ @attributes = attributes
80
+ @options = options
81
+ end
82
+
83
+ # PresenceValidator -> :presence. Validator names are single words,
84
+ # so a plain downcase is enough (no regexp needed).
85
+ def kind
86
+ name = self.class.to_s.split("::").last.to_s
87
+ name = name[0...-9] if name.end_with?("Validator")
88
+ name.to_s.downcase.to_sym
89
+ end
90
+
91
+ def validate(record)
92
+ attributes.each do |attribute|
93
+ value = record.read_attribute_for_validation(attribute)
94
+ next if options[:allow_nil] && value.nil?
95
+ next if options[:allow_blank] && blank?(value)
96
+ validate_each(record, attribute, value)
97
+ end
98
+ end
99
+
100
+ def validate_each(record, attribute, value)
101
+ raise "#{self.class} must implement validate_each"
102
+ end
103
+
104
+ private
105
+
106
+ def blank?(value)
107
+ return true if value.nil?
108
+ return value.strip.empty? if value.is_a?(String)
109
+ return value.empty? if value.respond_to?(:empty?)
110
+ false
111
+ end
112
+
113
+ def present?(value)
114
+ !blank?(value)
115
+ end
116
+ end
117
+
118
+ module ClassMethods
119
+ # Validators declared on this model class.
120
+ def validators
121
+ @validators ||= []
122
+ end
123
+
124
+ def validators_on(attribute)
125
+ attr = attribute.to_sym
126
+ validators.select { |v| v.attributes.include?(attr) }
127
+ end
128
+
129
+ # Options that configure every validator in a `validates` call rather
130
+ # than naming a validator of their own (mirrors ActiveModel).
131
+ SHARED_OPTIONS = [:allow_nil, :allow_blank, :if, :unless, :on, :strict]
132
+
133
+ # validates :a, :b, presence: true, length: { maximum: 30 }, allow_blank: true
134
+ def validates(*attributes, **options)
135
+ return if attributes.empty?
136
+ attrs = attributes.map { |a| a.to_sym }
137
+
138
+ shared = {} #: Hash[Symbol, untyped]
139
+ SHARED_OPTIONS.each { |k| shared[k] = options[k] if options.key?(k) }
140
+
141
+ options.each do |key, opts|
142
+ next if SHARED_OPTIONS.include?(key)
143
+ klass = validator_class_for(key)
144
+ next unless klass
145
+ base = {} #: Hash[Symbol, untyped]
146
+ base = opts unless opts == true
147
+ validator_options = shared.merge(base) # per-validator options win
148
+ validators << klass.new(attributes: attrs, **validator_options)
149
+ end
150
+ end
151
+
152
+ # Append a single schema-derived validator (from load_schema), unless a
153
+ # validator of the same kind is already declared for the attribute in
154
+ # the subclass body. Frontend declarations win, avoiding duplicates.
155
+ def add_schema_validator(attribute, kind, opts)
156
+ attr = attribute.to_sym
157
+ k = kind.to_sym
158
+ return if validators.any? { |v| v.attributes.include?(attr) && v.kind == k }
159
+ klass = validator_class_for(kind)
160
+ return unless klass
161
+ validator_options = {} #: Hash[Symbol, untyped]
162
+ validator_options = symbolize_keys(opts) unless opts == true
163
+ validators << klass.new(attributes: [attr], **validator_options)
164
+ end
165
+
166
+ private
167
+
168
+ def validator_class_for(key)
169
+ class_name = "#{key.to_s.capitalize}Validator"
170
+ return nil unless Funicular::Model::Validations.const_defined?(class_name)
171
+ Funicular::Model::Validations.const_get(class_name)
172
+ end
173
+
174
+ def symbolize_keys(hash)
175
+ return hash unless hash.is_a?(Hash)
176
+ out = {} #: Hash[Symbol, untyped]
177
+ hash.each { |k, v| out[k.to_sym] = v }
178
+ out
179
+ end
180
+ end
181
+
182
+ # --- instance API ---
183
+
184
+ def errors
185
+ @errors ||= Funicular::Model::Errors.new
186
+ end
187
+
188
+ def read_attribute_for_validation(attribute)
189
+ send(attribute)
190
+ end
191
+
192
+ def valid?
193
+ errors.clear
194
+ # Steep does not know whether `self.class` extends ClassMethods
195
+ # @type var cls: untyped
196
+ cls = self.class
197
+ cls.validators.each { |validator| validator.validate(self) }
198
+ errors.empty?
199
+ end
200
+
201
+ def invalid?
202
+ !valid?
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,180 @@
1
+ # Concrete validators for Funicular::Model, mirroring the standard Rails set.
2
+ #
3
+ # Named "1_" so the mruby build loads it after 0_validations.rb (which defines
4
+ # EachValidator) but before model.rb. Keep this free of Ruby-only regexp
5
+ # features: on the client, Regexp is a JS RegExp wrapper.
6
+ module Funicular
7
+ class Model
8
+ module Validations
9
+ class PresenceValidator < EachValidator
10
+ def validate_each(record, attribute, value)
11
+ record.errors.add(attribute, options[:message] || "can't be blank") if blank?(value)
12
+ end
13
+ end
14
+
15
+ class AbsenceValidator < EachValidator
16
+ def validate_each(record, attribute, value)
17
+ record.errors.add(attribute, options[:message] || "must be blank") if present?(value)
18
+ end
19
+ end
20
+
21
+ class LengthValidator < EachValidator
22
+ def validate_each(record, attribute, value)
23
+ length = value.nil? ? 0 : value.to_s.length
24
+
25
+ if (min = options[:minimum]) && length < min
26
+ record.errors.add(attribute, options[:message] ||
27
+ "is too short (minimum is #{min} characters)")
28
+ end
29
+ if (max = options[:maximum]) && length > max
30
+ record.errors.add(attribute, options[:message] ||
31
+ "is too long (maximum is #{max} characters)")
32
+ end
33
+ if (exact = options[:is]) && length != exact
34
+ record.errors.add(attribute, options[:message] ||
35
+ "is the wrong length (should be #{exact} characters)")
36
+ end
37
+ if (range = options[:in] || options[:within]) && !range_include?(range, length)
38
+ record.errors.add(attribute, options[:message] || "is the wrong length")
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def range_include?(range, length)
45
+ if range.respond_to?(:include?)
46
+ range.include?(length)
47
+ else
48
+ false
49
+ end
50
+ end
51
+ end
52
+
53
+ class FormatValidator < EachValidator
54
+ def validate_each(record, attribute, value)
55
+ str = value.to_s
56
+ if (with = options[:with]) && !str.match?(with)
57
+ record.errors.add(attribute, options[:message] || "is invalid")
58
+ end
59
+ if (without = options[:without]) && str.match?(without)
60
+ record.errors.add(attribute, options[:message] || "is invalid")
61
+ end
62
+ end
63
+ end
64
+
65
+ class NumericalityValidator < EachValidator
66
+ CHECKS = {
67
+ greater_than: ">",
68
+ greater_than_or_equal_to: ">=",
69
+ equal_to: "==",
70
+ less_than: "<",
71
+ less_than_or_equal_to: "<=",
72
+ other_than: "!="
73
+ }
74
+
75
+ def validate_each(record, attribute, value)
76
+ number = to_number(value)
77
+ if number.nil?
78
+ record.errors.add(attribute, options[:message] || "is not a number")
79
+ return
80
+ end
81
+
82
+ if options[:only_integer] && !number.is_a?(Integer)
83
+ record.errors.add(attribute, options[:message] || "must be an integer")
84
+ return
85
+ end
86
+
87
+ CHECKS.each do |option, _operator|
88
+ next unless options.key?(option)
89
+ unless compare(number, option, options[option])
90
+ record.errors.add(attribute, options[:message] || message_for(option, options[option]))
91
+ end
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def compare(number, option, target)
98
+ case option
99
+ when :greater_than then number > target
100
+ when :greater_than_or_equal_to then number >= target
101
+ when :equal_to then number == target
102
+ when :less_than then number < target
103
+ when :less_than_or_equal_to then number <= target
104
+ when :other_than then number != target
105
+ else true
106
+ end
107
+ end
108
+
109
+ def message_for(option, target)
110
+ case option
111
+ when :greater_than then "must be greater than #{target}"
112
+ when :greater_than_or_equal_to then "must be greater than or equal to #{target}"
113
+ when :equal_to then "must be equal to #{target}"
114
+ when :less_than then "must be less than #{target}"
115
+ when :less_than_or_equal_to then "must be less than or equal to #{target}"
116
+ when :other_than then "must be other than #{target}"
117
+ else "is invalid"
118
+ end
119
+ end
120
+
121
+ def to_number(value)
122
+ return value if value.is_a?(Numeric)
123
+ str = value.to_s.strip
124
+ return nil if str.empty?
125
+ begin
126
+ Integer(str)
127
+ rescue
128
+ begin
129
+ Float(str)
130
+ rescue
131
+ nil
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ class InclusionValidator < EachValidator
138
+ def validate_each(record, attribute, value)
139
+ list = options[:in] || options[:within]
140
+ return unless list.respond_to?(:include?)
141
+ unless list.include?(value)
142
+ record.errors.add(attribute, options[:message] || "is not included in the list")
143
+ end
144
+ end
145
+ end
146
+
147
+ class ExclusionValidator < EachValidator
148
+ def validate_each(record, attribute, value)
149
+ list = options[:in] || options[:within]
150
+ return unless list.respond_to?(:include?)
151
+ if list.include?(value)
152
+ record.errors.add(attribute, options[:message] || "is reserved")
153
+ end
154
+ end
155
+ end
156
+
157
+ class AcceptanceValidator < EachValidator
158
+ def validate_each(record, attribute, value)
159
+ accepted = options[:accept] || ["1", "true", true]
160
+ accepted = [accepted] unless accepted.is_a?(Array)
161
+ unless accepted.include?(value)
162
+ record.errors.add(attribute, options[:message] || "must be accepted")
163
+ end
164
+ end
165
+ end
166
+
167
+ class ConfirmationValidator < EachValidator
168
+ def validate_each(record, attribute, value)
169
+ confirmation_attr = "#{attribute}_confirmation"
170
+ return unless record.respond_to?(confirmation_attr)
171
+ confirmation = record.send(confirmation_attr)
172
+ return if confirmation.nil?
173
+ if value != confirmation
174
+ record.errors.add(attribute, options[:message] || "doesn't match confirmation")
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end