sandals 0.1.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.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class BindingResolver
5
+ UNSET = Object.new.freeze
6
+ Resolution = Data.define(:value, :error, :action)
7
+
8
+ def self.resolve(**options)
9
+ new(**options).resolve
10
+ end
11
+
12
+ def initialize(model:, bind:, value:, error:, action:, default:)
13
+ @model = model
14
+ @bind = bind
15
+ @value = value
16
+ @error = error
17
+ @action = action
18
+ @default = default
19
+ end
20
+
21
+ def resolve
22
+ return explicit_resolution unless binding_requested?
23
+
24
+ validate_binding
25
+
26
+ attribute = @bind.to_sym
27
+ writer = :"#{attribute}="
28
+ validate_accessors(attribute, writer)
29
+
30
+ Resolution.new(
31
+ @model.public_send(attribute),
32
+ resolved_error(attribute),
33
+ ->(new_value) { @model.public_send(writer, new_value) }
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def binding_requested?
40
+ !@model.equal?(UNSET) || !@bind.equal?(UNSET)
41
+ end
42
+
43
+ def explicit_resolution
44
+ value = @value.equal?(UNSET) ? @default : @value
45
+ error = @error.equal?(UNSET) ? nil : @error
46
+ Resolution.new(value, error, @action)
47
+ end
48
+
49
+ def validate_binding
50
+ raise ArgumentError, "bind requires model" if @model.equal?(UNSET)
51
+ raise ArgumentError, "model requires bind" if @bind.equal?(UNSET)
52
+ raise ArgumentError, "bind must be a method name" unless @bind.respond_to?(:to_sym)
53
+ raise ArgumentError, "bind cannot be combined with value" unless @value.equal?(UNSET)
54
+ raise ArgumentError, "bind cannot be combined with an action" if @action
55
+ end
56
+
57
+ def validate_accessors(attribute, writer)
58
+ return if @model.respond_to?(attribute) && @model.respond_to?(writer)
59
+
60
+ raise ArgumentError,
61
+ "#{@model.class} must respond to #{attribute} and #{writer}"
62
+ end
63
+
64
+ def resolved_error(attribute)
65
+ return @error unless @error.equal?(UNSET)
66
+ return unless @model.respond_to?(:errors)
67
+
68
+ errors = @model.errors
69
+ return unless errors.respond_to?(:[])
70
+
71
+ error = errors[attribute]
72
+ return if error.respond_to?(:empty?) && error.empty?
73
+
74
+ error
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../sandals"
5
+
6
+ module Sandals
7
+ class CLI
8
+ def self.start(arguments)
9
+ new(arguments).run
10
+ end
11
+
12
+ def initialize(arguments)
13
+ @arguments = arguments.dup
14
+ end
15
+
16
+ def run
17
+ command = @arguments.shift
18
+ return usage unless command == "run"
19
+
20
+ run_app
21
+ rescue Interrupt
22
+ 0
23
+ rescue StandardError => error
24
+ warn "sandals: #{error.message}"
25
+ 1
26
+ end
27
+
28
+ private
29
+
30
+ def run_app
31
+ options = { open: true, port: 0 }
32
+ parser = OptionParser.new do |opts|
33
+ opts.on("--no-open") { options[:open] = false }
34
+ opts.on("-pPORT", "--port=PORT", Integer) { |port| options[:port] = port }
35
+ end
36
+ parser.parse!(@arguments)
37
+
38
+ path = resolve_app(@arguments.shift || "app")
39
+ application = Sandals.load(path)
40
+ server = Server.new(application, port: options[:port], reload_path: path)
41
+
42
+ trap("INT") { server.stop }
43
+ puts "Sandals está corriendo #{path}"
44
+ puts server.url
45
+ open_browser(server.url) if options[:open]
46
+ server.start
47
+ 0
48
+ end
49
+
50
+ def resolve_app(name)
51
+ candidates = [name]
52
+ candidates << "#{name}.rb" unless name.end_with?(".rb")
53
+ path = candidates.find { |candidate| File.file?(candidate) }
54
+ return path if path
55
+
56
+ raise ArgumentError, "could not find #{candidates.join(' or ')}"
57
+ end
58
+
59
+ def open_browser(url)
60
+ command =
61
+ case RUBY_PLATFORM
62
+ when /darwin/ then ["open", url]
63
+ when /mingw|mswin/ then ["cmd", "/c", "start", url]
64
+ else ["xdg-open", url]
65
+ end
66
+
67
+ system(*command, out: File::NULL, err: File::NULL)
68
+ end
69
+
70
+ def usage
71
+ warn "Usage: sandals run APP [--no-open] [--port PORT]"
72
+ 1
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class CodeReloader
5
+ ANSI_ESCAPE = /\e\[[0-9;]*m/
6
+
7
+ Result = Data.define(:version, :application, :error, :changed)
8
+
9
+ attr_reader :path
10
+
11
+ def initialize(path)
12
+ @path = File.expand_path(path)
13
+ @signature = signature
14
+ @version = 1
15
+ @error = nil
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def check
20
+ @mutex.synchronize do
21
+ current_signature = signature
22
+ return result unless current_signature != @signature
23
+
24
+ @signature = current_signature
25
+ application = Sandals.load(path)
26
+ @version += 1
27
+ @error = nil
28
+ Result.new(@version, application, nil, true)
29
+ rescue SyntaxError, LoadError, StandardError => error
30
+ @error = error_details(error)
31
+ result(changed: true)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def result(changed: false)
38
+ Result.new(@version, nil, @error, changed)
39
+ end
40
+
41
+ def signature
42
+ stat = File.stat(path)
43
+ [stat.mtime.to_f, stat.size]
44
+ rescue Errno::ENOENT
45
+ [:missing]
46
+ end
47
+
48
+ def error_details(error)
49
+ {
50
+ class: error.class.to_s,
51
+ message: clean_message(error),
52
+ location: error_location(error)
53
+ }
54
+ end
55
+
56
+ def error_location(error)
57
+ syntax_location =
58
+ clean_message(error).lines.first&.match(/\A(.+?):(\d+):/) if
59
+ error.is_a?(SyntaxError)
60
+ if syntax_location
61
+ source_path = syntax_location[1].delete_prefix("#{Dir.pwd}/")
62
+ return "#{source_path}:#{syntax_location[2]}"
63
+ end
64
+
65
+ locations = error.backtrace_locations || []
66
+ location = locations.find do |candidate|
67
+ !candidate.absolute_path.to_s.include?("/lib/sandals/")
68
+ end || locations.first
69
+ return path unless location
70
+
71
+ source_path = location.absolute_path || location.path
72
+ source_path = source_path.delete_prefix("#{Dir.pwd}/")
73
+ "#{source_path}:#{location.lineno}"
74
+ end
75
+
76
+ def clean_message(error)
77
+ error.message.gsub(ANSI_ESCAPE, "")
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class EventDispatcher
5
+ def initialize(controls)
6
+ @controls = controls
7
+ end
8
+
9
+ def dispatch(events)
10
+ events.each { |event| dispatch_event(event) }
11
+ end
12
+
13
+ private
14
+
15
+ def dispatch_event(event)
16
+ target = event.fetch("target")
17
+ name = event.fetch("event")
18
+ control = @controls.find { |candidate| candidate.id == target }
19
+ raise ArgumentError, "unknown control: #{target}" unless control
20
+
21
+ case [control, name]
22
+ in [TextField | NumberField | TextArea | Checkbox | Select, "change"]
23
+ control.change(event["value"])
24
+ in [FileField, "change"]
25
+ control.change(event.fetch("value"))
26
+ in [Submit, "submit"]
27
+ control.submit
28
+ in [Button, "click"]
29
+ control.activate
30
+ else
31
+ raise ArgumentError, "#{control.class} does not respond to #{name}"
32
+ end
33
+ end
34
+ end
35
+ end