hamster-web 0.1.0.pre.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +61 -0
- data/exe/hamster +24 -0
- data/lib/hamster/build/manifest.rb +53 -0
- data/lib/hamster/build/postgres_compatibility.rb +38 -0
- data/lib/hamster/build/query_accessors.rb +73 -0
- data/lib/hamster/build/view_compiler.rb +239 -0
- data/lib/hamster/cli.rb +166 -0
- data/lib/hamster/console.rb +34 -0
- data/lib/hamster/data/persistence.rb +152 -0
- data/lib/hamster/data/query.rb +106 -0
- data/lib/hamster/data/read/compiler.rb +101 -0
- data/lib/hamster/data/read/decoder.rb +101 -0
- data/lib/hamster/data/read/nodes.rb +118 -0
- data/lib/hamster/data/read/relation.rb +198 -0
- data/lib/hamster/data/read/schema.rb +40 -0
- data/lib/hamster/data/read/validator.rb +273 -0
- data/lib/hamster/data/record.rb +81 -0
- data/lib/hamster/data/table.rb +90 -0
- data/lib/hamster/data.rb +21 -0
- data/lib/hamster/database/admin.rb +39 -0
- data/lib/hamster/database/configuration.rb +84 -0
- data/lib/hamster/database/connection.rb +168 -0
- data/lib/hamster/database/database.rb +92 -0
- data/lib/hamster/database/errors.rb +21 -0
- data/lib/hamster/database/pool.rb +98 -0
- data/lib/hamster/database/postgres.rb +66 -0
- data/lib/hamster/database/result.rb +30 -0
- data/lib/hamster/schema_validation.rb +12 -0
- data/lib/hamster/web/action/base.rb +39 -0
- data/lib/hamster/web/application.rb +23 -0
- data/lib/hamster/web/context.rb +25 -0
- data/lib/hamster/web/html.rb +80 -0
- data/lib/hamster/web/http.rb +34 -0
- data/lib/hamster/web/instrumentation.rb +28 -0
- data/lib/hamster/web/router.rb +27 -0
- data/lib/hamster/web/routing/base.rb +131 -0
- data/lib/hamster/web/version.rb +7 -0
- data/lib/hamster/web/view/form.rb +119 -0
- data/lib/hamster/web/view.rb +196 -0
- data/lib/hamster/web.rb +13 -0
- data/templates/app/.gitignore +5 -0
- data/templates/app/AGENTS.md +8 -0
- data/templates/app/Gemfile +3 -0
- data/templates/app/README.md +32 -0
- data/templates/app/app/routes/home/index/action.rb +14 -0
- data/templates/app/app/routes/home/index/view.html.erb +2 -0
- data/templates/app/app/routes/mappings.rb +8 -0
- data/templates/app/app/views/layouts/application.html.erb +3 -0
- data/templates/app/bin/hamster +4 -0
- data/templates/app/bin/server +8 -0
- data/templates/app/config/application.rb +23 -0
- data/templates/app/config/commands.rb +16 -0
- data/templates/app/config/database.rb +20 -0
- data/templates/app/config/hamster.json +1 -0
- data/templates/app/db/migrations/.keep +0 -0
- data/templates/app/spin.lock +6 -0
- data/templates/app/spin.toml +2 -0
- metadata +133 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: da9a6aefcced6e728e844a09b04ebaab4d6970b0742b9e527be34c36bcc52de1
|
|
4
|
+
data.tar.gz: f063de9bfdd15a06f17992ed6efd23cb6e3ad50fbe99195ec2e82718e11b2aef
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1a06018d216e769b046d6ec6614ff110ab61fce962de8f35d38ee96a8cccbbf67fe73c5d1c6885e225e8f76b5757ef56b5a4b8f18f6f208fc7c2570a1e3b2a3c
|
|
7
|
+
data.tar.gz: 47d17563010f3acb9f3b678dd9c2027e2ebedb38cc31b7d28c4abecab57fa994f696a8d72f8551006dc7c5d374ebb36f37431ac466e910e1b0b0cc28b66b8ae9
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dillon Wyatt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Hamster Web
|
|
2
|
+
|
|
3
|
+
Hamster is a Spinel web framework under development. The `hamster-web` gem will
|
|
4
|
+
provide the `hamster` CLI and the `hamster/web` application runtime. Spinner is
|
|
5
|
+
its HTTP server dependency.
|
|
6
|
+
|
|
7
|
+
The starter generates and compiles with PostgreSQL support, explicit routes,
|
|
8
|
+
actions, compiled views, and SQL migrations. Database creation and migration
|
|
9
|
+
commands, the console, and schema-aware records/queries are available.
|
|
10
|
+
Telemetry extraction and asset delivery remain under development.
|
|
11
|
+
|
|
12
|
+
Build and install the local framework preview:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
gem build hamster-web.gemspec
|
|
16
|
+
gem install --local hamster-web-0.1.0.pre.2.gem
|
|
17
|
+
hamster new MyApp
|
|
18
|
+
cd my_app
|
|
19
|
+
bin/hamster setup
|
|
20
|
+
# Configure PostgreSQL credentials; these defaults use your local OS role.
|
|
21
|
+
export DATABASE_USERNAME="$USER"
|
|
22
|
+
export DATABASE_ADMIN_USERNAME="$USER"
|
|
23
|
+
export DATABASE_PASSWORD=""
|
|
24
|
+
export DATABASE_ADMIN_PASSWORD=""
|
|
25
|
+
bin/hamster db:create
|
|
26
|
+
bin/hamster db:migrate
|
|
27
|
+
bin/hamster build
|
|
28
|
+
bin/hamster serve
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Run framework tests with `ruby -Ilib -e 'Dir["test/*_test.rb"].sort.each { |file| require_relative file }'`.
|
|
32
|
+
|
|
33
|
+
## Records and queries
|
|
34
|
+
|
|
35
|
+
Load `hamster/data` (also loaded by the PostgreSQL adapter) and declare models
|
|
36
|
+
with `Hamster::Table`, `table`, and literal `column` definitions. Columns must
|
|
37
|
+
explicitly opt into writes with `write: :create`, `:update`, or `:both`.
|
|
38
|
+
Creation returns a result with `success?`, `record`, and `errors`; `create!`
|
|
39
|
+
returns the record or raises a typed error. Timestamps are maintained only
|
|
40
|
+
when the model declares created_at or updated_at columns.
|
|
41
|
+
|
|
42
|
+
`database.with_connection` binds the connection for record writes. Queries
|
|
43
|
+
such as `Post.where(title: "Hello").order(:id).load` return typed rows. Explicit
|
|
44
|
+
callers can also pass `connection:` to query terminal methods or scope writes
|
|
45
|
+
with `Hamster::Record.with_connection(connection)`.
|
|
46
|
+
|
|
47
|
+
Run `hamster manifest` after adding columns or query fields: it generates the
|
|
48
|
+
static getters and setters used by Spinel. Rebuild after model changes.
|
|
49
|
+
|
|
50
|
+
## Console and schema checks
|
|
51
|
+
|
|
52
|
+
Run `hamster console` (or `hamster c`) in an application. The IRB session exposes
|
|
53
|
+
`app`, `application`, and `database`; record operations use a connection scoped
|
|
54
|
+
to that session. Exit releases the connection and shuts down the application.
|
|
55
|
+
Database environment settings apply as they do to the server.
|
|
56
|
+
|
|
57
|
+
Run `hamster db:validate` to compare declared tables and columns against the
|
|
58
|
+
configured PostgreSQL database. It reports missing tables/columns, incompatible
|
|
59
|
+
types, and unexpected nullability with a nonzero exit status. It never migrates
|
|
60
|
+
or modifies the schema. Configuration is loaded through config/commands.rb,
|
|
61
|
+
a CRuby-only adapter generated with new applications.
|
data/exe/hamster
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
# Resolve the application's version before activating this executable's gem.
|
|
7
|
+
# A global preview can otherwise supply stale source paths to Spinel even
|
|
8
|
+
# when the application's Gemfile points at a newer framework checkout.
|
|
9
|
+
unless ARGV.first == "new"
|
|
10
|
+
root = Dir.pwd
|
|
11
|
+
loop do
|
|
12
|
+
binstub = File.join(root, "bin/hamster")
|
|
13
|
+
if File.file?(File.join(root, "config/hamster.json")) && File.file?(binstub)
|
|
14
|
+
exec(RbConfig.ruby, binstub, *ARGV) unless File.expand_path(binstub) == File.expand_path(__FILE__)
|
|
15
|
+
break
|
|
16
|
+
end
|
|
17
|
+
parent = File.dirname(root)
|
|
18
|
+
break if parent == root
|
|
19
|
+
root = parent
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
require "hamster/cli"
|
|
24
|
+
exit Hamster::CLI.run(ARGV)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
require_relative "../web"
|
|
5
|
+
require_relative "view_compiler"
|
|
6
|
+
require_relative "query_accessors"
|
|
7
|
+
|
|
8
|
+
module Hamster
|
|
9
|
+
module Build
|
|
10
|
+
module Manifest
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def outputs(root)
|
|
14
|
+
paths = Dir.glob("{app,db}/**/*.rb", base: root).sort_by do |path|
|
|
15
|
+
priority = path.start_with?("db/") ? 0 : path.start_with?("app/models/") ? 1 : 2
|
|
16
|
+
priority = 3 if path == "app/routes/mappings.rb"
|
|
17
|
+
[priority, path]
|
|
18
|
+
end
|
|
19
|
+
sources = {}
|
|
20
|
+
Dir.glob("{app/routes,app/views}/**/*.html.erb", base: root).sort.each do |path|
|
|
21
|
+
name = path.sub(%r{\Aapp/(?:routes|views)/}, "").sub(%r{/view\.html\.erb\z}, "").delete_suffix(".html.erb")
|
|
22
|
+
raise View::Error, "duplicate template #{name}" if sources.key?(name)
|
|
23
|
+
sources[name] = {path: path, source: File.read(File.join(root, path))}
|
|
24
|
+
end
|
|
25
|
+
lines = paths.map { |path| %(require_relative "../#{path.delete_suffix('.rb')}") }
|
|
26
|
+
paths.each { |path| lines.concat(QueryAccessors.lines(File.read(File.join(root, path)))) }
|
|
27
|
+
paths.each { |path| lines.concat(QueryAccessors.lines(File.read(File.join(root, path)), declaration: "column", writers: true)) }
|
|
28
|
+
lines << 'require_relative "view_registry"'
|
|
29
|
+
{
|
|
30
|
+
"config/manifest.rb" => "# frozen_string_literal: true\n# Generated by hamster manifest.\n" + lines.join("\n") + "\n",
|
|
31
|
+
"config/view_registry.rb" => ViewCompiler.compile(sources)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def generate(root)
|
|
36
|
+
outputs(root).each { |path, content| File.write(File.join(root, path), content) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def check(root)
|
|
40
|
+
issues = outputs(root).filter_map do |path, expected|
|
|
41
|
+
actual = File.file?(File.join(root, path)) ? File.read(File.join(root, path)) : nil
|
|
42
|
+
"#{path} is missing or stale; run bin/hamster manifest" unless actual == expected
|
|
43
|
+
end
|
|
44
|
+
Dir.glob("{app,db}/**/*.rb", base: root).each do |path|
|
|
45
|
+
Ripper.lex(File.read(File.join(root, path))).each do |position, type, token, _|
|
|
46
|
+
issues << "#{path}:#{position[0]}: load files through the manifest" if type == :on_ident && token == "require_relative"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
issues
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Build
|
|
5
|
+
module PostgresCompatibility
|
|
6
|
+
REVISION = "5ff0188b0e8dffc88337723411ac725ee2659ff7"
|
|
7
|
+
COPIES = [
|
|
8
|
+
'sev = PgDecode.error_field(body, "V")',
|
|
9
|
+
'sev = PgDecode.error_field(body, "S")',
|
|
10
|
+
'tag = PgDecode.command_tag(m.body)'
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
# The pinned driver predates its Spinel string-ownership workarounds.
|
|
14
|
+
# Apply explicit copies after vendoring, before compiling; never patch
|
|
15
|
+
# an unrecognized revision or silently accept a changed source shape.
|
|
16
|
+
def self.apply(root)
|
|
17
|
+
package = File.join(root, "vendor/packages/pg-0.2.0")
|
|
18
|
+
marker = File.join(package, ".spin-sha")
|
|
19
|
+
unless File.file?(marker) && File.read(marker).strip == REVISION
|
|
20
|
+
raise "PostgreSQL compatibility patch requires pg revision #{REVISION}; run bin/hamster setup with the supported lockfile"
|
|
21
|
+
end
|
|
22
|
+
path = File.join(package, "pg/client.rb")
|
|
23
|
+
source = File.read(path)
|
|
24
|
+
patched = source.lines.map do |line|
|
|
25
|
+
statement = line.strip
|
|
26
|
+
replacement = COPIES.find { |original| statement == original || statement == original + ".dup" }
|
|
27
|
+
replacement && statement == replacement ? line.sub(replacement, replacement + ".dup") : line
|
|
28
|
+
end.join
|
|
29
|
+
COPIES.each do |original|
|
|
30
|
+
unless patched.lines.count { |line| line.strip == original + ".dup" } == 1
|
|
31
|
+
raise "PostgreSQL compatibility patch source mismatch: #{original}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
File.write(path, patched) unless patched == source
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module Hamster
|
|
6
|
+
# Spinel's macro expander requires every argument to be literal. Query.field's
|
|
7
|
+
# source expression isn't, so emit ordinary getters before AOT compilation.
|
|
8
|
+
module QueryAccessors
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def lines(source, declaration: "field", writers: false)
|
|
12
|
+
tree = Ripper.sexp(source)
|
|
13
|
+
raise "Cannot parse Ruby while generating Query getters" unless tree
|
|
14
|
+
getters = {}
|
|
15
|
+
visit(tree, [], getters, declaration)
|
|
16
|
+
getters.flat_map do |path, names|
|
|
17
|
+
["class #{path}"] + names.uniq.flat_map { |name| [" def #{name}; self[:#{name}]; end"] + (writers ? [" def #{name}=(value); write_attribute(:#{name}, value); end"] : []) } + ["end"]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def constant(node)
|
|
22
|
+
return nil unless node.is_a?(Array)
|
|
23
|
+
case node[0]
|
|
24
|
+
when :@const then node[1]
|
|
25
|
+
when :const_ref, :var_ref then constant(node[1])
|
|
26
|
+
when :top_const_ref then "::#{constant(node[1])}"
|
|
27
|
+
when :const_path_ref, :const_path_field then "#{constant(node[1])}::#{constant(node[2])}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def visit(node, scope, getters, declaration)
|
|
32
|
+
return unless node.is_a?(Array)
|
|
33
|
+
if node[0] == :class || node[0] == :module
|
|
34
|
+
name = constant(node[1])
|
|
35
|
+
return unless name
|
|
36
|
+
path = name.start_with?("::") ? name.delete_prefix("::") : (scope + [name]).join("::")
|
|
37
|
+
body = node[node[0] == :class ? 3 : 2]
|
|
38
|
+
visit(body, [path], getters, declaration)
|
|
39
|
+
elsif [:def, :defs].include?(node[0])
|
|
40
|
+
return
|
|
41
|
+
elsif (node[0] == :command && node[1][1] == declaration) ||
|
|
42
|
+
(node[0] == :method_add_arg && node[1][0] == :fcall && node[1][1][1] == declaration)
|
|
43
|
+
args = node[2]
|
|
44
|
+
args = args[1] if args && args[0] == :arg_paren
|
|
45
|
+
first = args[1][0] if args && args[0] == :args_add_block
|
|
46
|
+
literal = first[1][1] if first && first[0] == :symbol_literal && first[1][0] == :symbol
|
|
47
|
+
name = literal[1] if literal && [:"@ident", :"@op", :"@kw"].include?(literal[0])
|
|
48
|
+
if first && first[0] == :string_literal && first[1][0] == :string_content && first[1].length == 2
|
|
49
|
+
name = first[1][1][1] if first[1][1][0] == :@tstring_content
|
|
50
|
+
end
|
|
51
|
+
unless name && name.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
52
|
+
raise "#{scope.join('::')}: Query field names must be literal accessor names for Spinel"
|
|
53
|
+
end
|
|
54
|
+
unless scope.empty?
|
|
55
|
+
getters[scope.join("::")] ||= []
|
|
56
|
+
getters[scope.join("::")] << name
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
node.each { |child| visit(child, scope, getters, declaration) if child.is_a?(Array) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Standalone scripts and compiled fixtures can use the same static getter pass.
|
|
66
|
+
# ruby tools/query_accessors.rb test/spinel_query_smoke.rb > /tmp/query-smoke.rb
|
|
67
|
+
if $PROGRAM_NAME == __FILE__
|
|
68
|
+
abort "Usage: ruby tools/query_accessors.rb SOURCE.rb [SOURCE.rb ...]" if ARGV.empty?
|
|
69
|
+
ARGV.each do |path|
|
|
70
|
+
puts "require_relative #{File.expand_path(path).inspect}"
|
|
71
|
+
puts Hamster::QueryAccessors.lines(File.read(path))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module Hamster
|
|
6
|
+
module ViewCompiler
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
NAME = /\A[a-z][a-z0-9_]*(?:\/[a-z][a-z0-9_]*)*\z/
|
|
10
|
+
TAG = /<%(=|#)?(.*?)%>/m
|
|
11
|
+
RENDER = /\Arender\s*(?:\(\s*)?["']([^"']+)["']/m
|
|
12
|
+
|
|
13
|
+
def compile(sources)
|
|
14
|
+
names = sources.keys.sort
|
|
15
|
+
paths = {}
|
|
16
|
+
method_names = {}
|
|
17
|
+
names.each do |name|
|
|
18
|
+
raise View::Error, "invalid template name #{name}" unless name.match?(NAME)
|
|
19
|
+
paths[name] = sources[name].fetch(:path)
|
|
20
|
+
method = method_name(name)
|
|
21
|
+
if method_names.key?(method)
|
|
22
|
+
raise View::Error, "#{paths[name]}:1: duplicate generated method for #{name} and #{method_names[method]}"
|
|
23
|
+
end
|
|
24
|
+
method_names[method] = name
|
|
25
|
+
end
|
|
26
|
+
methods = {}
|
|
27
|
+
dependencies = {}
|
|
28
|
+
required = names.to_h { |name| [name, declared_locals(sources[name].fetch(:source), paths[name])] }
|
|
29
|
+
names.each do |name|
|
|
30
|
+
methods[name], dependencies[name] = compile_one(name, sources[name].fetch(:source), paths[name], paths, required)
|
|
31
|
+
end
|
|
32
|
+
detect_cycles!(dependencies, paths)
|
|
33
|
+
out = ["# frozen_string_literal: true", "", "# Generated by bin/hamster manifest. Edit .html.erb files instead.", "module Hamster", " module CompiledViews", " module_function"]
|
|
34
|
+
names.each { |name| out.concat(methods.fetch(name)) }
|
|
35
|
+
out.concat([" def render(name, context, locals)", " Hamster::View.instrument_compiled(name) { dispatch(name, context, locals) }", " end", " def dispatch(name, context, locals)"])
|
|
36
|
+
if names.empty?
|
|
37
|
+
out << " raise Hamster::View::Error, \"missing template #{'#{name}'}\""
|
|
38
|
+
else
|
|
39
|
+
out << " case name"
|
|
40
|
+
names.each { |name| out << " when #{name.dump} then #{method_name(name)}(context, locals)" }
|
|
41
|
+
out.concat([" else raise Hamster::View::Error, \"missing template #{'#{name}'}\"", " end"])
|
|
42
|
+
end
|
|
43
|
+
out.concat([" end", " end", "end", "", "Hamster::View.use_compiled!"])
|
|
44
|
+
result = out.join("\n")
|
|
45
|
+
raise View::Error, "generated views contain invalid Ruby" unless Ripper.sexp(result)
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def compile_one(name, source, path, paths, required)
|
|
50
|
+
locals = declared_locals(source, path)
|
|
51
|
+
body = []
|
|
52
|
+
deps = []
|
|
53
|
+
blocks = []
|
|
54
|
+
cursor = 0
|
|
55
|
+
source.to_enum(:scan, TAG).each do
|
|
56
|
+
match = Regexp.last_match
|
|
57
|
+
literal = source[cursor...match.begin(0)]
|
|
58
|
+
body << " output << #{literal.dump}" unless literal.empty?
|
|
59
|
+
line = source[0...match.begin(0)].count("\n") + 1
|
|
60
|
+
tag = match[1]
|
|
61
|
+
code = match[2].strip
|
|
62
|
+
if tag == "#"
|
|
63
|
+
# Comments are removed before the native build.
|
|
64
|
+
elsif tag == "="
|
|
65
|
+
context = View.position(source[0...match.begin(0)].gsub(TAG, ""))
|
|
66
|
+
validate_position!(context, path, line)
|
|
67
|
+
if (partial = partial_name(code, path, line))
|
|
68
|
+
raise View::Error, "#{path}:#{line}: missing template #{partial}" unless paths.key?(partial)
|
|
69
|
+
deps << [partial, line]
|
|
70
|
+
expression = rewrite_render(code, partial, path, line, required.fetch(partial))
|
|
71
|
+
elsif code.match?(/(?:\A|[^.])\brender\b/)
|
|
72
|
+
raise View::Error, "#{path}:#{line}: render requires a literal template name"
|
|
73
|
+
elsif name.start_with?("layouts/") && (slot = yield_name(code, path, line))
|
|
74
|
+
expression = "context.slot(#{slot.dump})"
|
|
75
|
+
elsif code.match?(/\byield\b/)
|
|
76
|
+
raise View::Error, "#{path}:#{line}: yield is only supported in layouts"
|
|
77
|
+
elsif code.match?(/\Acontent_for\b/)
|
|
78
|
+
expression = rewrite_content_for(code, path, line)
|
|
79
|
+
else
|
|
80
|
+
expression = code
|
|
81
|
+
end
|
|
82
|
+
check_ruby!(expression, path, line)
|
|
83
|
+
body << " Hamster::View.append_value(output, (#{expression}), #{path.dump}, #{line}, #{context.inspect})"
|
|
84
|
+
else
|
|
85
|
+
raise View::Error, "#{path}:#{line}: unsupported ERB code" if code.match?(/\brender\b|\byield\b/)
|
|
86
|
+
if (slot = code.match(/\Acontent_for\s*(?:\(\s*)?:(\w+)\s*(?:\)\s*)?do\s*\z/))
|
|
87
|
+
index = blocks.length
|
|
88
|
+
blocks << [:content_for, slot[1], index, line]
|
|
89
|
+
body << " previous_output_#{index} = output"
|
|
90
|
+
body << " output = []"
|
|
91
|
+
elsif code == "end"
|
|
92
|
+
block = blocks.pop
|
|
93
|
+
raise View::Error, "#{path}:#{line}: unexpected end" unless block
|
|
94
|
+
if block.is_a?(Array) && block[0] == :content_for
|
|
95
|
+
body << " context.content_for(#{block[1].dump}, Hamster::HTML::Fragment.new(output.join))"
|
|
96
|
+
body << " output = previous_output_#{block[2]}"
|
|
97
|
+
else
|
|
98
|
+
body << " end"
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
check_ruby_fragment!(code, path, line)
|
|
102
|
+
blocks << [:ruby, line] if code.match?(/\A(?:if|unless|for)\b/) || code.match?(/\bdo\s*(?:\|[^|]*\|)?\s*\z/)
|
|
103
|
+
body << " #{code}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
cursor = match.end(0)
|
|
107
|
+
end
|
|
108
|
+
tail = source[cursor..-1]
|
|
109
|
+
if tail.include?("<%")
|
|
110
|
+
line = source[0...cursor].count("\n") + tail[0...tail.index("<%")].count("\n") + 1
|
|
111
|
+
raise View::Error, "#{path}:#{line}: unterminated ERB tag"
|
|
112
|
+
end
|
|
113
|
+
body << " output << #{tail.dump}" unless tail.empty?
|
|
114
|
+
raise View::Error, "#{path}:#{blocks.last[-1]}: unclosed ERB block" unless blocks.empty?
|
|
115
|
+
source_without_comments = source.gsub(/<%#.*?%>/m, "")
|
|
116
|
+
if (undeclared = inferred_locals(source_without_comments) - locals).any?
|
|
117
|
+
raise View::Error, "#{path}:1: undeclared locals: #{undeclared.join(', ')}"
|
|
118
|
+
end
|
|
119
|
+
lines = [" def #{method_name(name)}(context, locals)", " output = []"]
|
|
120
|
+
locals.each { |local| lines << " #{local} = context.fetch(locals, #{path.dump}, #{local.dump}, #{first_use_line(source, local)})" }
|
|
121
|
+
lines.concat(body)
|
|
122
|
+
lines.concat([" Hamster::HTML::Fragment.new(output.join)", " end"])
|
|
123
|
+
ruby = lines.join("\n")
|
|
124
|
+
parsed = Ripper.sexp("module X\n#{ruby}\nend")
|
|
125
|
+
unless parsed
|
|
126
|
+
raise View::Error, "#{path}: invalid ERB Ruby syntax"
|
|
127
|
+
end
|
|
128
|
+
if (unknown = unresolved_identifier(parsed))
|
|
129
|
+
raise View::Error, "#{path}:1: undeclared local #{unknown}"
|
|
130
|
+
end
|
|
131
|
+
[lines, deps]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def declared_locals(source, path = "template")
|
|
135
|
+
declaration = source.match(/\A\s*<%#\s*locals:\s*([^%]*?)\s*%>/m)
|
|
136
|
+
return inferred_locals(source) unless declaration
|
|
137
|
+
names = declaration[1].split(",").map(&:strip)
|
|
138
|
+
raise View::Error, "#{path}:1: invalid locals declaration" unless names.all? { |n| n.match?(/\A[a-z_][a-z0-9_]*\z/) } && names.uniq == names
|
|
139
|
+
names
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def inferred_locals(source)
|
|
143
|
+
source.scan(/<%=\s*([a-z_][a-z0-9_]*)\s*%>/).flatten.uniq.sort - %w[yield]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def first_use_line(source, local)
|
|
147
|
+
source.to_enum(:scan, TAG).each do
|
|
148
|
+
match = Regexp.last_match
|
|
149
|
+
next if match[1] == "#"
|
|
150
|
+
return source[0...match.begin(0)].count("\n") + 1 if match[2].match?(/\b#{Regexp.escape(local)}\b/)
|
|
151
|
+
end
|
|
152
|
+
1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def method_name(name)
|
|
156
|
+
"view_#{name.tr('/', '_')}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def partial_name(code, path, line)
|
|
160
|
+
return nil unless code.match?(/\Arender\b/)
|
|
161
|
+
match = code.match(RENDER)
|
|
162
|
+
raise View::Error, "#{path}:#{line}: render requires a literal template name" unless match
|
|
163
|
+
match[1]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def rewrite_render(code, name, path, line, required)
|
|
167
|
+
rest = code.sub(RENDER, "")
|
|
168
|
+
rest = rest.sub(/\A\s*,\s*locals:\s*/, "")
|
|
169
|
+
rest = rest.sub(/\A\s*locals:\s*/, "")
|
|
170
|
+
rest = rest.sub(/\)\s*\z/, "").strip
|
|
171
|
+
locals = rest.empty? ? "{}" : rest
|
|
172
|
+
unless locals.start_with?("{") && locals.end_with?("}") && Ripper.sexp(locals)
|
|
173
|
+
raise View::Error, "#{path}:#{line}: render requires locals: { ... }"
|
|
174
|
+
end
|
|
175
|
+
keys = locals.scan(/\b([a-z_][a-z0-9_]*):/).flatten
|
|
176
|
+
missing = required - keys
|
|
177
|
+
raise View::Error, "#{path}:#{line}: #{name} missing locals: #{missing.join(', ')}" unless missing.empty?
|
|
178
|
+
"Hamster::CompiledViews.render(#{name.dump}, context, #{locals})"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def rewrite_content_for(code, path, line)
|
|
182
|
+
match = code.match(/\Acontent_for\s*(?:\(\s*)?:(\w+)\s*,\s*(.+?)\s*\)?\z/m)
|
|
183
|
+
raise View::Error, "#{path}:#{line}: content_for requires a literal slot and value" unless match
|
|
184
|
+
"context.content_for(#{match[1].dump}, #{match[2]})"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def yield_name(code, path, line)
|
|
188
|
+
return nil unless code.match?(/\Ayield\b/)
|
|
189
|
+
return "body" if code == "yield"
|
|
190
|
+
match = code.match(/\Ayield\s*(?:\(\s*)?:(\w+)\s*\)?\z/)
|
|
191
|
+
raise View::Error, "#{path}:#{line}: yield requires a literal slot name" unless match
|
|
192
|
+
match[1]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def validate_position!(context, path, line)
|
|
196
|
+
raise View::Error, "#{path}:#{line}: dynamic script or style content" if context == :script
|
|
197
|
+
raise View::Error, "#{path}:#{line}: expression must be in text or a quoted attribute" if context == :tag
|
|
198
|
+
if context.is_a?(String) && (context.start_with?("on") || %w[style srcdoc].include?(context))
|
|
199
|
+
raise View::Error, "#{path}:#{line}: dynamic event or style attribute"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def check_ruby!(code, path, line)
|
|
204
|
+
raise View::Error, "#{path}:#{line}: invalid Ruby expression" unless Ripper.sexp(code)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def check_ruby_fragment!(code, path, line)
|
|
208
|
+
return if code.match?(/\A(?:if|unless|elsif|else)\b/) || code.match?(/\A[a-z_][\w.]*\.each\s+do(?:\s*\|[^|]+\|)?\s*\z/)
|
|
209
|
+
raise View::Error, "#{path}:#{line}: unsupported ERB code"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def detect_cycles!(deps, paths)
|
|
213
|
+
visited = {}
|
|
214
|
+
visit = lambda do |name, stack|
|
|
215
|
+
return if visited[name]
|
|
216
|
+
deps.fetch(name).each do |child, line|
|
|
217
|
+
if stack.include?(child) || child == name
|
|
218
|
+
raise View::Error, "#{paths[name]}:#{line}: partial dependency cycle: #{(stack + [name, child]).join(' -> ')}"
|
|
219
|
+
end
|
|
220
|
+
visit.call(child, stack + [name])
|
|
221
|
+
end
|
|
222
|
+
visited[name] = true
|
|
223
|
+
end
|
|
224
|
+
deps.keys.each { |name| visit.call(name, []) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def unresolved_identifier(node)
|
|
228
|
+
return nil unless node.is_a?(Array)
|
|
229
|
+
if node[0] == :vcall && node[1].is_a?(Array) && node[1][0] == :@ident
|
|
230
|
+
return node[1][1]
|
|
231
|
+
end
|
|
232
|
+
node.each do |child|
|
|
233
|
+
result = unresolved_identifier(child)
|
|
234
|
+
return result if result
|
|
235
|
+
end
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|