sandals 0.1.0 → 0.1.2
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 +4 -4
- data/README.md +73 -1
- data/lib/sandals/action_error.rb +1 -9
- data/lib/sandals/cli.rb +120 -8
- data/lib/sandals/code_reloader.rb +2 -32
- data/lib/sandals/error_details.rb +67 -0
- data/lib/sandals/version.rb +1 -1
- data/lib/sandals.rb +1 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79d2b3d1395db396acd1aad4055141b75c955ca0ff0462e5fcf700db819af32e
|
|
4
|
+
data.tar.gz: 1cc17abdffbbc21540ada84f192cf9b1139c63c9422ef8152c2767ad6091473b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6002d0c758974e5e797e153ebd50c0422b2a706b8329d6831cd44c7a13daabee0c9f9bd55aa49b3389e7798c00195ad92156f8f205bc74afb1fcab7395c0df90
|
|
7
|
+
data.tar.gz: 075fb63042fd98661f107e249ff3af9c3c82bf203d44f7a58c937a8bdf7ac71fc235d4ae22246972b26ecb8cde00a246f9968bb343a9ffb9306a4f509e52d32f
|
data/README.md
CHANGED
|
@@ -19,12 +19,84 @@ Sandals requires Ruby 3.1 or later.
|
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
21
|
gem install sandals
|
|
22
|
-
sandals
|
|
22
|
+
sandals new hello
|
|
23
|
+
sandals run hello.rb
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
The server listens on `127.0.0.1`. All required assets are included in the gem,
|
|
26
27
|
so a local app does not need an internet connection.
|
|
27
28
|
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
Create your first application:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
sandals new hello
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This creates two files:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
hello.rb
|
|
41
|
+
hello_test.rb
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`hello.rb` is a small application with a text field and a greeting. Run it:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
sandals run hello.rb
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Keep Sandals running and open `hello.rb` in your editor. Change:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
title "Hello"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
to:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
title "Hello from Ruby"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Save the file. The browser reloads automatically and shows the change.
|
|
63
|
+
|
|
64
|
+
If the file contains a syntax or load error, Sandals keeps the last valid
|
|
65
|
+
version running and reports the error with its location. Fix the file and save
|
|
66
|
+
it again; the application recovers automatically.
|
|
67
|
+
|
|
68
|
+
The generated test interacts with the application through visible labels and
|
|
69
|
+
text. Run it with:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
ruby hello_test.rb
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The test fills in `"Your name"` and verifies that the greeting appears. It uses
|
|
76
|
+
Minitest, which is installed with Sandals.
|
|
77
|
+
|
|
78
|
+
## Startup errors
|
|
79
|
+
|
|
80
|
+
Sandals reports startup failures with the error type, application file, and
|
|
81
|
+
line number instead of printing an internal framework backtrace:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
sandals: SyntaxError at app.rb:12
|
|
85
|
+
app.rb:12: syntax error, unexpected end-of-input
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Missing dependencies are reported in the same way:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
sandals: LoadError at app.rb:1
|
|
92
|
+
cannot load such file -- sqlite3
|
|
93
|
+
Add the missing dependency to the app and try again.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Sandals also evaluates the initial view before opening the server, so an
|
|
97
|
+
exception during the first render points to the relevant line in the
|
|
98
|
+
application.
|
|
99
|
+
|
|
28
100
|
## The experience
|
|
29
101
|
|
|
30
102
|
An application should normally fit in a single file:
|
data/lib/sandals/action_error.rb
CHANGED
|
@@ -17,15 +17,7 @@ module Sandals
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def location
|
|
20
|
-
|
|
21
|
-
relevant_location = locations.find do |candidate|
|
|
22
|
-
!candidate.absolute_path.to_s.include?("/lib/sandals/")
|
|
23
|
-
end || locations.first
|
|
24
|
-
return "Location unavailable" unless relevant_location
|
|
25
|
-
|
|
26
|
-
path = relevant_location.absolute_path || relevant_location.path
|
|
27
|
-
path = path.delete_prefix("#{Dir.pwd}/")
|
|
28
|
-
"#{path}:#{relevant_location.lineno}"
|
|
20
|
+
ErrorDetails.for(original_error).fetch(:location)
|
|
29
21
|
end
|
|
30
22
|
end
|
|
31
23
|
|
data/lib/sandals/cli.rb
CHANGED
|
@@ -5,6 +5,16 @@ require_relative "../sandals"
|
|
|
5
5
|
|
|
6
6
|
module Sandals
|
|
7
7
|
class CLI
|
|
8
|
+
class InitialAppError < StandardError
|
|
9
|
+
attr_reader :original_error, :path
|
|
10
|
+
|
|
11
|
+
def initialize(original_error, path)
|
|
12
|
+
@original_error = original_error
|
|
13
|
+
@path = path
|
|
14
|
+
super(original_error.message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
8
18
|
def self.start(arguments)
|
|
9
19
|
new(arguments).run
|
|
10
20
|
end
|
|
@@ -15,18 +25,58 @@ module Sandals
|
|
|
15
25
|
|
|
16
26
|
def run
|
|
17
27
|
command = @arguments.shift
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
case command
|
|
29
|
+
when "run" then run_app
|
|
30
|
+
when "new" then new_app
|
|
31
|
+
else usage
|
|
32
|
+
end
|
|
33
|
+
rescue InitialAppError => error
|
|
34
|
+
report_initial_app_error(error)
|
|
23
35
|
rescue StandardError => error
|
|
24
36
|
warn "sandals: #{error.message}"
|
|
25
37
|
1
|
|
38
|
+
rescue Interrupt
|
|
39
|
+
0
|
|
26
40
|
end
|
|
27
41
|
|
|
28
42
|
private
|
|
29
43
|
|
|
44
|
+
def new_app
|
|
45
|
+
name = @arguments.shift
|
|
46
|
+
raise ArgumentError, "missing app name" unless name
|
|
47
|
+
raise ArgumentError, "unexpected arguments: #{@arguments.join(' ')}" unless @arguments.empty?
|
|
48
|
+
|
|
49
|
+
base_name = name.delete_suffix(".rb")
|
|
50
|
+
unless base_name.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z/)
|
|
51
|
+
raise ArgumentError, "app name must contain only letters, numbers, underscores, or hyphens"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
app_path = "#{base_name}.rb"
|
|
55
|
+
test_path = "#{base_name}_test.rb"
|
|
56
|
+
existing_path = [app_path, test_path].find { |path| File.exist?(path) }
|
|
57
|
+
raise ArgumentError, "#{existing_path} already exists" if existing_path
|
|
58
|
+
|
|
59
|
+
title = base_name.split(/[_-]+/).map(&:capitalize).join(" ")
|
|
60
|
+
File.open(app_path, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
61
|
+
file.write(app_template(title))
|
|
62
|
+
end
|
|
63
|
+
File.open(test_path, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
64
|
+
file.write(test_template(base_name))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
puts "Created #{app_path}"
|
|
68
|
+
puts "Created #{test_path}"
|
|
69
|
+
puts
|
|
70
|
+
puts "Run the app:"
|
|
71
|
+
puts " sandals run #{app_path}"
|
|
72
|
+
puts
|
|
73
|
+
puts "Run the test:"
|
|
74
|
+
puts " ruby #{test_path}"
|
|
75
|
+
0
|
|
76
|
+
rescue Errno::EEXIST
|
|
77
|
+
raise ArgumentError, "could not create app because a generated file already exists"
|
|
78
|
+
end
|
|
79
|
+
|
|
30
80
|
def run_app
|
|
31
81
|
options = { open: true, port: 0 }
|
|
32
82
|
parser = OptionParser.new do |opts|
|
|
@@ -36,17 +86,36 @@ module Sandals
|
|
|
36
86
|
parser.parse!(@arguments)
|
|
37
87
|
|
|
38
88
|
path = resolve_app(@arguments.shift || "app")
|
|
39
|
-
application =
|
|
89
|
+
application = load_initial_application(path)
|
|
40
90
|
server = Server.new(application, port: options[:port], reload_path: path)
|
|
41
91
|
|
|
42
92
|
trap("INT") { server.stop }
|
|
43
|
-
puts "Sandals
|
|
93
|
+
puts "Sandals is running #{path}"
|
|
44
94
|
puts server.url
|
|
45
95
|
open_browser(server.url) if options[:open]
|
|
46
96
|
server.start
|
|
47
97
|
0
|
|
48
98
|
end
|
|
49
99
|
|
|
100
|
+
def load_initial_application(path)
|
|
101
|
+
application = Sandals.load(path)
|
|
102
|
+
application.render(development_reload: true)
|
|
103
|
+
application
|
|
104
|
+
rescue SyntaxError, LoadError, StandardError => error
|
|
105
|
+
raise InitialAppError.new(error, File.expand_path(path))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def report_initial_app_error(error)
|
|
109
|
+
original = error.original_error
|
|
110
|
+
details = ErrorDetails.for(original, fallback_path: error.path)
|
|
111
|
+
warn "sandals: #{details.fetch(:class)} at #{details.fetch(:location)}"
|
|
112
|
+
warn details.fetch(:message)
|
|
113
|
+
if original.is_a?(LoadError)
|
|
114
|
+
warn "Add the missing dependency to the app and try again."
|
|
115
|
+
end
|
|
116
|
+
1
|
|
117
|
+
end
|
|
118
|
+
|
|
50
119
|
def resolve_app(name)
|
|
51
120
|
candidates = [name]
|
|
52
121
|
candidates << "#{name}.rb" unless name.end_with?(".rb")
|
|
@@ -67,8 +136,51 @@ module Sandals
|
|
|
67
136
|
system(*command, out: File::NULL, err: File::NULL)
|
|
68
137
|
end
|
|
69
138
|
|
|
139
|
+
def app_template(title)
|
|
140
|
+
<<~RUBY
|
|
141
|
+
require "sandals"
|
|
142
|
+
|
|
143
|
+
Sandals.app title: "#{title}" do
|
|
144
|
+
@name = ""
|
|
145
|
+
|
|
146
|
+
view do
|
|
147
|
+
stack do
|
|
148
|
+
title "#{title}"
|
|
149
|
+
|
|
150
|
+
text_field "Your name", value: @name do |value|
|
|
151
|
+
@name = value
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
para "Hello, \#{@name}!" unless @name.empty?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
RUBY
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def test_template(base_name)
|
|
162
|
+
<<~RUBY
|
|
163
|
+
require "minitest/autorun"
|
|
164
|
+
require_relative "#{base_name}"
|
|
165
|
+
|
|
166
|
+
class GeneratedAppTest < Minitest::Test
|
|
167
|
+
def test_greets_the_user
|
|
168
|
+
session = Sandals.test(Sandals.application)
|
|
169
|
+
|
|
170
|
+
session.field("Your name").fill("Ada")
|
|
171
|
+
|
|
172
|
+
assert session.text?("Hello, Ada!")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
RUBY
|
|
176
|
+
end
|
|
177
|
+
|
|
70
178
|
def usage
|
|
71
|
-
warn
|
|
179
|
+
warn <<~USAGE
|
|
180
|
+
Usage:
|
|
181
|
+
sandals new NAME
|
|
182
|
+
sandals run APP [--no-open] [--port PORT]
|
|
183
|
+
USAGE
|
|
72
184
|
1
|
|
73
185
|
end
|
|
74
186
|
end
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Sandals
|
|
4
4
|
class CodeReloader
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Result = Data.define(:version, :application, :error, :changed)
|
|
5
|
+
Result = Struct.new(:version, :application, :error, :changed)
|
|
8
6
|
|
|
9
7
|
attr_reader :path
|
|
10
8
|
|
|
@@ -46,35 +44,7 @@ module Sandals
|
|
|
46
44
|
end
|
|
47
45
|
|
|
48
46
|
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, "")
|
|
47
|
+
ErrorDetails.for(error, fallback_path: path)
|
|
78
48
|
end
|
|
79
49
|
end
|
|
80
50
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class ErrorDetails
|
|
5
|
+
ANSI_ESCAPE = /\e\[[0-9;]*m/
|
|
6
|
+
|
|
7
|
+
def self.for(error, fallback_path: nil)
|
|
8
|
+
new(error, fallback_path:).to_h
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(error, fallback_path:)
|
|
12
|
+
@error = error
|
|
13
|
+
@fallback_path = fallback_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
{
|
|
18
|
+
class: @error.class.to_s,
|
|
19
|
+
message: clean_message,
|
|
20
|
+
location: error_location
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def error_location
|
|
27
|
+
path, line = syntax_location || backtrace_location
|
|
28
|
+
return @fallback_path || "Location unavailable" unless path
|
|
29
|
+
|
|
30
|
+
relative_location(path, line)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def syntax_location
|
|
34
|
+
return unless @error.is_a?(SyntaxError)
|
|
35
|
+
|
|
36
|
+
line = clean_message.lines.first&.[](/:(\d+):/, 1)
|
|
37
|
+
[@error.path || @fallback_path, line] if line
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def backtrace_location
|
|
41
|
+
locations = @error.backtrace_locations || []
|
|
42
|
+
location =
|
|
43
|
+
locations.find { |candidate| same_path?(candidate, @fallback_path) } ||
|
|
44
|
+
locations.find { |candidate| !sandals_path?(candidate) } ||
|
|
45
|
+
locations.first
|
|
46
|
+
|
|
47
|
+
[location&.absolute_path || location&.path, location&.lineno]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def same_path?(location, path)
|
|
51
|
+
path && File.expand_path(location.absolute_path || location.path) == File.expand_path(path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def sandals_path?(location)
|
|
55
|
+
location.absolute_path.to_s.include?("/lib/sandals/")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def relative_location(path, line)
|
|
59
|
+
source_path = path.delete_prefix("#{Dir.pwd}/")
|
|
60
|
+
"#{source_path}:#{line}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def clean_message
|
|
64
|
+
@error.message.gsub(ANSI_ESCAPE, "").strip
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/sandals/version.rb
CHANGED
data/lib/sandals.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sandals
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Benito Serna
|
|
@@ -9,6 +9,20 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '6.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '6.0'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: webrick
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -42,6 +56,7 @@ files:
|
|
|
42
56
|
- lib/sandals/binding_resolver.rb
|
|
43
57
|
- lib/sandals/cli.rb
|
|
44
58
|
- lib/sandals/code_reloader.rb
|
|
59
|
+
- lib/sandals/error_details.rb
|
|
45
60
|
- lib/sandals/event_dispatcher.rb
|
|
46
61
|
- lib/sandals/html_renderer.rb
|
|
47
62
|
- lib/sandals/server.rb
|