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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +61 -0
  4. data/exe/hamster +24 -0
  5. data/lib/hamster/build/manifest.rb +53 -0
  6. data/lib/hamster/build/postgres_compatibility.rb +38 -0
  7. data/lib/hamster/build/query_accessors.rb +73 -0
  8. data/lib/hamster/build/view_compiler.rb +239 -0
  9. data/lib/hamster/cli.rb +166 -0
  10. data/lib/hamster/console.rb +34 -0
  11. data/lib/hamster/data/persistence.rb +152 -0
  12. data/lib/hamster/data/query.rb +106 -0
  13. data/lib/hamster/data/read/compiler.rb +101 -0
  14. data/lib/hamster/data/read/decoder.rb +101 -0
  15. data/lib/hamster/data/read/nodes.rb +118 -0
  16. data/lib/hamster/data/read/relation.rb +198 -0
  17. data/lib/hamster/data/read/schema.rb +40 -0
  18. data/lib/hamster/data/read/validator.rb +273 -0
  19. data/lib/hamster/data/record.rb +81 -0
  20. data/lib/hamster/data/table.rb +90 -0
  21. data/lib/hamster/data.rb +21 -0
  22. data/lib/hamster/database/admin.rb +39 -0
  23. data/lib/hamster/database/configuration.rb +84 -0
  24. data/lib/hamster/database/connection.rb +168 -0
  25. data/lib/hamster/database/database.rb +92 -0
  26. data/lib/hamster/database/errors.rb +21 -0
  27. data/lib/hamster/database/pool.rb +98 -0
  28. data/lib/hamster/database/postgres.rb +66 -0
  29. data/lib/hamster/database/result.rb +30 -0
  30. data/lib/hamster/schema_validation.rb +12 -0
  31. data/lib/hamster/web/action/base.rb +39 -0
  32. data/lib/hamster/web/application.rb +23 -0
  33. data/lib/hamster/web/context.rb +25 -0
  34. data/lib/hamster/web/html.rb +80 -0
  35. data/lib/hamster/web/http.rb +34 -0
  36. data/lib/hamster/web/instrumentation.rb +28 -0
  37. data/lib/hamster/web/router.rb +27 -0
  38. data/lib/hamster/web/routing/base.rb +131 -0
  39. data/lib/hamster/web/version.rb +7 -0
  40. data/lib/hamster/web/view/form.rb +119 -0
  41. data/lib/hamster/web/view.rb +196 -0
  42. data/lib/hamster/web.rb +13 -0
  43. data/templates/app/.gitignore +5 -0
  44. data/templates/app/AGENTS.md +8 -0
  45. data/templates/app/Gemfile +3 -0
  46. data/templates/app/README.md +32 -0
  47. data/templates/app/app/routes/home/index/action.rb +14 -0
  48. data/templates/app/app/routes/home/index/view.html.erb +2 -0
  49. data/templates/app/app/routes/mappings.rb +8 -0
  50. data/templates/app/app/views/layouts/application.html.erb +3 -0
  51. data/templates/app/bin/hamster +4 -0
  52. data/templates/app/bin/server +8 -0
  53. data/templates/app/config/application.rb +23 -0
  54. data/templates/app/config/commands.rb +16 -0
  55. data/templates/app/config/database.rb +20 -0
  56. data/templates/app/config/hamster.json +1 -0
  57. data/templates/app/db/migrations/.keep +0 -0
  58. data/templates/app/spin.lock +6 -0
  59. data/templates/app/spin.toml +2 -0
  60. 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