fresco 0.0.1

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/exe/fresco +3 -0
  3. data/lib/fresco/application.rb +12 -0
  4. data/lib/fresco/cli/build.rb +682 -0
  5. data/lib/fresco/cli/dev.rb +17 -0
  6. data/lib/fresco/cli/dev_loop.rb +815 -0
  7. data/lib/fresco/cli/new.rb +120 -0
  8. data/lib/fresco/cli/release.rb +76 -0
  9. data/lib/fresco/cli.rb +56 -0
  10. data/lib/fresco/database_config.rb +34 -0
  11. data/lib/fresco/generators/app/Gemfile.tt +18 -0
  12. data/lib/fresco/generators/app/README.md.tt +32 -0
  13. data/lib/fresco/generators/app/app/action.rb.tt +20 -0
  14. data/lib/fresco/generators/app/app/actions/root_path.rb.tt +5 -0
  15. data/lib/fresco/generators/app/app/views/layouts/application.html.erb +29 -0
  16. data/lib/fresco/generators/app/app/views/root_path.html.erb +8 -0
  17. data/lib/fresco/generators/app/app.rb.tt +15 -0
  18. data/lib/fresco/generators/app/bin/build +2 -0
  19. data/lib/fresco/generators/app/bin/dev +2 -0
  20. data/lib/fresco/generators/app/bin/release +2 -0
  21. data/lib/fresco/generators/app/config/app.rb.tt +26 -0
  22. data/lib/fresco/generators/app/config/database.rb +17 -0
  23. data/lib/fresco/generators/app/config/routes.rb +11 -0
  24. data/lib/fresco/generators/app/db/schema.rb +14 -0
  25. data/lib/fresco/generators/app/public/404.html +87 -0
  26. data/lib/fresco/generators/app/public/500.html +84 -0
  27. data/lib/fresco/migration_builder.rb +55 -0
  28. data/lib/fresco/model_builder.rb +54 -0
  29. data/lib/fresco/paths.rb +20 -0
  30. data/lib/fresco/router.rb +67 -0
  31. data/lib/fresco/runtime/boot.rb +34 -0
  32. data/lib/fresco/runtime/db_postgres.rb +403 -0
  33. data/lib/fresco/runtime/db_sqlite.rb +495 -0
  34. data/lib/fresco/runtime/http.c +456 -0
  35. data/lib/fresco/runtime/postgres.c +339 -0
  36. data/lib/fresco/runtime/runtime.rb +1810 -0
  37. data/lib/fresco/runtime/sqlite.c +220 -0
  38. data/lib/fresco/runtime/welcome.rb +152 -0
  39. data/lib/fresco/schema_builder.rb +71 -0
  40. data/lib/fresco/templates/dispatch.rb.erb +32 -0
  41. data/lib/fresco/templates/layout_dispatch.rb.erb +16 -0
  42. data/lib/fresco/templates/manifest.rb.erb +5 -0
  43. data/lib/fresco/templates/migrations.rb.erb +152 -0
  44. data/lib/fresco/templates/model.rb.erb +223 -0
  45. data/lib/fresco/templates/view.rb.erb +5 -0
  46. data/lib/fresco/version.rb +3 -0
  47. data/lib/fresco.rb +61 -0
  48. metadata +115 -0
@@ -0,0 +1,682 @@
1
+ # Fresco: codegen step. Reads config/routes.rb + app/actions/ +
2
+ # app/views/ from the user app's cwd, emits
3
+ # generated/{boot,runtime,dispatch,requires,views}.rb (plus per-view
4
+ # files under generated/views/) deterministically.
5
+
6
+ require "erb"
7
+ require "fileutils"
8
+ require "herb"
9
+ require "herb/engine"
10
+
11
+ require "fresco"
12
+
13
+ module Fresco
14
+ class CLI
15
+ class Build
16
+ GEN_DIR = "generated".freeze
17
+ VIEWS_GEN_DIR = "#{GEN_DIR}/views".freeze
18
+ MODELS_GEN_DIR = "#{GEN_DIR}/models".freeze
19
+ # C shims get copied here so the `ffi_cflags "generated/runtime/*.c"`
20
+ # strings in the runtime templates resolve from the user app's cwd.
21
+ RUNTIME_GEN_DIR = "#{GEN_DIR}/runtime".freeze
22
+
23
+ # In-gem source roots (set once at load — Fresco::Paths resolves
24
+ # relative to lib/fresco/paths.rb, which is stable per-install).
25
+ TEMPLATES_DIR = Fresco::Paths.template_root
26
+ RUNTIME_DIR = Fresco::Paths.runtime_root
27
+
28
+ NAME_CHAR = /[A-Za-z0-9_]/.freeze
29
+
30
+ # Partial render call rewrite — see resolve_partial / rewrite_partial_renders.
31
+ PARTIAL_RENDER_RE = /<%=\s*render(?:\s*\(\s*|\s+)partial:\s*["']([^"']+)["'](?:\s*,\s*locals:\s*\{(.*?)\})?\s*\)?\s*%>/m
32
+
33
+ # Fresco-supplied kwargs every layout receives.
34
+ LAYOUT_FRAMEWORK_KWARGS = "flash: Flash.new, content: \"\"".freeze
35
+
36
+ LOCALS_RE = /\A\s*<%#\s*locals:\s*(\(.*?\))\s*%>\s*\r?\n?/.freeze
37
+ LAYOUT_YIELD_RE = /<%=\s*yield\s*%>/.freeze
38
+
39
+ STATIC_APPEND_PREFIX = "_buf << '".freeze
40
+ STATIC_APPEND_SUFFIX = "'.freeze".freeze
41
+ MAX_VIEW_LITERAL_BYTES = 512
42
+
43
+ def run(argv = [])
44
+ FileUtils.mkdir_p(GEN_DIR)
45
+ FileUtils.mkdir_p(VIEWS_GEN_DIR)
46
+ FileUtils.mkdir_p(RUNTIME_GEN_DIR)
47
+
48
+ lint_actions!
49
+ action_files = stub_action_classes
50
+ route_entries = load_routes
51
+ has_root = route_entries.any? { |verb, pattern, _, _| verb == "GET" && pattern == "/" }
52
+
53
+ emit_welcome(has_root, route_entries)
54
+ emit_dispatch(route_entries)
55
+ emit_runtime
56
+ emit_c_shims
57
+ db_adapter = emit_db_adapter
58
+ emit_models(db_adapter)
59
+ generated_models = collect_generated_models
60
+ emit_migrations(db_adapter)
61
+ emit_requires_manifest(action_files, generated_models, has_root)
62
+ emit_boot
63
+ view_names, layout_names = emit_views
64
+ emit_layout_dispatch(layout_names)
65
+
66
+ puts "wrote #{GEN_DIR}/{boot,dispatch,layout_dispatch,requires,runtime,views}.rb " \
67
+ "(#{route_entries.size} routes, #{view_names.size} views, #{layout_names.size} layouts)"
68
+ 0
69
+ end
70
+
71
+ private
72
+
73
+ def render_template(name, **locals)
74
+ src = File.read(File.join(TEMPLATES_DIR, name))
75
+ ERB.new(src, trim_mode: "-").result_with_hash(locals)
76
+ end
77
+
78
+ # Lint user actions against the Spinel-acceptable Ruby subset before
79
+ # touching generated/. Anything the rubocop_spinel plugin flags is
80
+ # a hard build failure — generated output from un-compileable input
81
+ # would just push the error downstream into Spinel itself.
82
+ #
83
+ # Skip silently when rubocop_spinel isn't on the user's bundle (it's
84
+ # the recommended setup but not a hard requirement; freshly-scaffolded
85
+ # apps run their first build before `bundle install` adds it).
86
+ def lint_actions!
87
+ return unless File.exist?("Gemfile.lock") && File.read("Gemfile.lock").include?("rubocop_spinel")
88
+ unless system("bundle", "exec", "rubocop", "--no-color", "app/")
89
+ abort "[build] rubocop_spinel: violations in app/; aborting."
90
+ end
91
+ end
92
+
93
+ # Action stubs let config/routes.rb resolve `to: SomeAction` constants
94
+ # without actually loading user code (which would need runtime.rb).
95
+ def stub_action_classes
96
+ files = Dir.glob("app/actions/**/*.rb").sort
97
+ files.each do |file|
98
+ name = Fresco.path_to_class(file, "app/actions")
99
+ Fresco.const_set_path(name, Class.new)
100
+ end
101
+ files
102
+ end
103
+
104
+ def load_routes
105
+ load "config/routes.rb"
106
+ Fresco.app.routes.entries
107
+ end
108
+
109
+ # --- Default welcome route fallback ------------------------------
110
+ def emit_welcome(has_root, route_entries)
111
+ welcome_path = "#{GEN_DIR}/welcome.rb"
112
+ if has_root
113
+ # Stale welcome from a prior build (when the user had no root) —
114
+ # clear it so dev doesn't load a Fresco::Welcome nothing references.
115
+ File.delete(welcome_path) if File.exist?(welcome_path)
116
+ else
117
+ FileUtils.cp("#{RUNTIME_DIR}/welcome.rb", welcome_path)
118
+ Fresco.const_set_path("Fresco::Welcome", Class.new)
119
+ route_entries.unshift(["GET", "/", Fresco.const_get_path("Fresco::Welcome"), {}])
120
+ end
121
+ end
122
+
123
+ # --- Pattern → regex source + capture names -----------------------
124
+ #
125
+ # Char-by-char scan. Tokens:
126
+ # :name → named capture; default [^/]+, or constraints[:name].source
127
+ # *name → splat capture; matches .+ (incl. slashes)
128
+ # (...) → optional group, compiled as (?:...)? (may nest)
129
+ # / → literal slash, emitted as \/
130
+ # anything else → Regexp.escape
131
+ #
132
+ # For optional captures, the dispatcher emits `req.params[:name] = $N || ""`
133
+ # so CRuby (dev mode) and Spinel (release) both store "" when un-matched —
134
+ # Spinel already coerces NULL $N to "", but CRuby leaves it nil.
135
+ def compile_pattern(pattern, constraints = {})
136
+ captures = []
137
+ out = String.new('\A')
138
+ paren = 0
139
+ i = 0
140
+ n = pattern.length
141
+
142
+ while i < n
143
+ c = pattern[i]
144
+ case c
145
+ when "("
146
+ out << "(?:"
147
+ paren += 1
148
+ i += 1
149
+ when ")"
150
+ raise "unbalanced ')' in #{pattern.inspect}" if paren.zero?
151
+ out << ")?"
152
+ paren -= 1
153
+ i += 1
154
+ when "/"
155
+ out << '\/'
156
+ i += 1
157
+ when ":"
158
+ i += 1
159
+ name_start = i
160
+ i += 1 while i < n && NAME_CHAR.match?(pattern[i])
161
+ raise "empty :param name in #{pattern.inspect}" if i == name_start
162
+ name = pattern[name_start...i].to_sym
163
+ captures << name
164
+ out << (constraints.key?(name) ? "(#{constraints[name].source})" : '([^\/]+)')
165
+ when "*"
166
+ i += 1
167
+ name_start = i
168
+ i += 1 while i < n && NAME_CHAR.match?(pattern[i])
169
+ raise "empty *splat name in #{pattern.inspect}" if i == name_start
170
+ captures << pattern[name_start...i].to_sym
171
+ out << '(.+)'
172
+ else
173
+ out << Regexp.escape(c)
174
+ i += 1
175
+ end
176
+ end
177
+
178
+ raise "unclosed '(' in #{pattern.inspect}" unless paren.zero?
179
+
180
+ out << '\z'
181
+ [out, captures]
182
+ end
183
+
184
+ # --- Emit dispatch.rb --------------------------------------------
185
+ def emit_dispatch(route_entries)
186
+ compiled_routes = route_entries.map do |verb, pattern, klass, constraints|
187
+ regex_src, captures = compile_pattern(pattern, constraints)
188
+ { verb: verb, pattern: pattern, klass: klass, regex_src: regex_src, captures: captures }
189
+ end
190
+ File.write("#{GEN_DIR}/dispatch.rb", render_template("dispatch.rb.erb", routes: compiled_routes))
191
+ end
192
+
193
+ # --- Emit runtime.rb (copied from the gem's runtime/) ------------
194
+ def emit_runtime
195
+ FileUtils.cp("#{RUNTIME_DIR}/runtime.rb", "#{GEN_DIR}/runtime.rb")
196
+ end
197
+
198
+ # --- Copy C shims into generated/runtime/ ------------------------
199
+ #
200
+ # runtime.rb / db_*.rb reference these via `ffi_cflags
201
+ # "generated/runtime/<name>.c"`. The strings are evaluated by
202
+ # Spinel from the user app's cwd, so the .c files have to live
203
+ # there — they ship in the gem and get copied per-build.
204
+ def emit_c_shims
205
+ %w[http.c sqlite.c postgres.c].each do |c|
206
+ FileUtils.cp("#{RUNTIME_DIR}/#{c}", "#{RUNTIME_GEN_DIR}/#{c}")
207
+ end
208
+ end
209
+
210
+ # --- Emit db_adapter.rb (per-adapter template) -------------------
211
+ def emit_db_adapter
212
+ db_adapter = :none
213
+ if File.exist?("config/database.rb")
214
+ load "config/database.rb"
215
+ db_adapter = Fresco.database_adapter if Fresco.database_adapter
216
+ end
217
+
218
+ case db_adapter
219
+ when :sqlite
220
+ FileUtils.cp("#{RUNTIME_DIR}/db_sqlite.rb", "#{GEN_DIR}/db_adapter.rb")
221
+ when :postgres
222
+ FileUtils.cp("#{RUNTIME_DIR}/db_postgres.rb", "#{GEN_DIR}/db_adapter.rb")
223
+ when :none
224
+ # Even with no adapter, runtime.rb's request handler still calls
225
+ # `Fresco::Db.begin_request!` / `flush_log!`. Define them as no-ops.
226
+ File.write(
227
+ "#{GEN_DIR}/db_adapter.rb",
228
+ <<~RUBY,
229
+ # Fresco: no DB adapter configured. Auto-generated by `fresco build`.
230
+ # Add config/database.rb with `Fresco.database :sqlite` (or :postgres)
231
+ # to enable Fresco::Db::Active.
232
+ module Fresco
233
+ module Db
234
+ def self.begin_request!; end
235
+ def self.flush_log!; end
236
+ end
237
+ end
238
+ RUBY
239
+ )
240
+ else
241
+ abort "[build] unknown adapter #{db_adapter.inspect} in config/database.rb " \
242
+ "(expected :sqlite or :postgres)"
243
+ end
244
+
245
+ unless File.exist?("config/database.rb")
246
+ FileUtils.mkdir_p("config")
247
+ File.write(
248
+ "config/database.rb",
249
+ "# Fresco: no DB configured. Replace with `Fresco.database :sqlite, " \
250
+ "path: ENV.fetch(\"DATABASE_PATH\", \"db/app.sqlite3\")` (or :postgres) " \
251
+ "when you need one.\n",
252
+ )
253
+ end
254
+
255
+ db_adapter
256
+ end
257
+
258
+ # --- Emit generated models (per-adapter SQL baked in) ------------
259
+ def emit_models(db_adapter)
260
+ FileUtils.mkdir_p(MODELS_GEN_DIR)
261
+ Dir.glob("#{MODELS_GEN_DIR}/*.rb").each { |f| File.delete(f) }
262
+
263
+ # Schema + model + migration source files. Loaded under CRuby at
264
+ # build time — captured DSL state is read back below to drive
265
+ # codegen. Migrations need to load whether or not the user has
266
+ # models (e.g. early apps that haven't declared models yet still
267
+ # want db:migrate to work).
268
+ if File.exist?("db/schema.rb") && Dir.glob("app/models/*.rb").any?
269
+ load "db/schema.rb"
270
+ Dir.glob("app/models/*.rb").sort.each { |f| load f }
271
+ end
272
+
273
+ Fresco.models.each do |model|
274
+ write_model_file(model, db_adapter)
275
+ end
276
+ end
277
+
278
+ def collect_generated_models
279
+ Dir.glob("#{MODELS_GEN_DIR}/*.rb").sort.map { |f| File.basename(f, ".rb") }
280
+ end
281
+
282
+ def write_model_file(model, db_adapter)
283
+ table = Fresco.schema_tables.find { |t| t[:name] == model[:table] }
284
+ unless table
285
+ abort "[build] Fresco.model :#{model[:name]} references table :#{model[:table]} " \
286
+ "but db/schema.rb doesn't declare it"
287
+ end
288
+
289
+ columns = table[:columns]
290
+ pk_col = columns.find { |c| c[:primary_key] }
291
+ unless pk_col
292
+ abort "[build] table :#{table[:name]} has no primary_key column; " \
293
+ "declare one with `column :id, :int, primary_key: true`"
294
+ end
295
+
296
+ data_cols = columns.reject { |c| c[:primary_key] }
297
+
298
+ # `find(id = 0)` — positional ctor with defaulted PK. The default
299
+ # acts as the type pin for Spinel ([[spinel_initialize_kwargs]],
300
+ # [[spinel_param_type_collapse]]).
301
+ ctor_params = columns.map { |c| "#{c[:name]} = #{col_zero(c)}" }.join(", ") +
302
+ ", found = false"
303
+
304
+ pk_param = "#{pk_col[:name]} = 0"
305
+
306
+ row_ctor_args = columns.each_with_index.map { |c, i| col_read_expr(c, i, db_adapter) }.join(", ")
307
+
308
+ table_name = table[:name].to_s
309
+
310
+ select_by_pk_sql = "SELECT #{join_cols(columns)} FROM #{table_name} WHERE #{pk_col[:name]} = " \
311
+ "#{db_adapter == :postgres ? '$1' : '?'}"
312
+ select_all_sql = "SELECT #{join_cols(columns)} FROM #{table_name} ORDER BY #{pk_col[:name]}"
313
+
314
+ finders = model[:finders].map do |fname|
315
+ col = columns.find { |c| c[:name] == fname }
316
+ abort "[build] finder :#{fname} on :#{model[:name]} but no such column" unless col
317
+ {
318
+ name: fname,
319
+ sql: "SELECT #{join_cols(columns)} FROM #{table_name} WHERE #{fname} = " \
320
+ "#{db_adapter == :postgres ? '$1' : '?'}",
321
+ count_sql: "SELECT COUNT(*) FROM #{table_name} WHERE #{fname} = " \
322
+ "#{db_adapter == :postgres ? '$1' : '?'}",
323
+ bind_call: col_finder_bind_call(col),
324
+ default: col_zero(col),
325
+ unique: col[:index] == :unique,
326
+ }
327
+ end
328
+
329
+ count_sql = "SELECT COUNT(*) FROM #{table_name}"
330
+
331
+ validators = (model[:validators] || []).map do |v|
332
+ col = columns.find { |c| c[:name] == v[:column] }
333
+ abort "[build] validates :#{v[:column]} on :#{model[:name]} but no such column" unless col
334
+ case v[:rule]
335
+ when :presence
336
+ guard = case col[:type]
337
+ when :str then "#{col[:name]}.length == 0"
338
+ when :int then "#{col[:name]} == 0"
339
+ when :bool then "#{col[:name]} == false"
340
+ end
341
+ { column: v[:column], guard: guard, message: "#{col[:name]} required" }
342
+ else
343
+ abort "[build] unknown validation rule #{v[:rule].inspect} for :#{model[:name]}"
344
+ end
345
+ end
346
+
347
+ insert_bind_lines = data_cols.each_with_index.map { |c, i| col_bind_stmt(c, i + 1) }
348
+ insert_kwargs = data_cols.map { |c| "#{c[:name]}: #{col_zero(c)}" }.join(", ")
349
+ insert_kwarg_sig = data_cols.map { |c| "#{c[:name]}:" }.join(", ")
350
+
351
+ insert_sql = if db_adapter == :postgres
352
+ "INSERT INTO #{table_name} (#{join_cols(data_cols)}) VALUES (#{placeholders(data_cols.length, :postgres)}) RETURNING #{pk_col[:name]}"
353
+ else
354
+ "INSERT INTO #{table_name} (#{join_cols(data_cols)}) VALUES (#{placeholders(data_cols.length, :sqlite)})"
355
+ end
356
+
357
+ update_bind_lines = data_cols.each_with_index.map { |c, i| col_bind_stmt(c, i + 1) }
358
+ update_kwargs = data_cols.map { |c| "#{c[:name]}: #{col_zero(c)}" }.join(", ")
359
+ update_kwarg_sig = data_cols.map { |c| "#{c[:name]}:" }.join(", ")
360
+ pk_placeholder = db_adapter == :postgres ? "$#{data_cols.length + 1}" : "?"
361
+ update_sql = "UPDATE #{table_name} SET #{assignments(data_cols, db_adapter, 1)} WHERE #{pk_col[:name]} = #{pk_placeholder}"
362
+
363
+ delete_sql = "DELETE FROM #{table_name} WHERE #{pk_col[:name]} = " \
364
+ "#{db_adapter == :postgres ? '$1' : '?'}"
365
+
366
+ file_stem = model[:name].to_s.gsub(/([a-z0-9])([A-Z])/, '\1_\2').downcase
367
+
368
+ File.write(
369
+ "#{MODELS_GEN_DIR}/#{file_stem}.rb",
370
+ render_template("model.rb.erb",
371
+ model_name: model[:name].to_s,
372
+ file_stem: file_stem,
373
+ table_name: table_name,
374
+ adapter: db_adapter,
375
+ columns: columns,
376
+ pk_name: pk_col[:name],
377
+ pk_param: pk_param,
378
+ ctor_params: ctor_params,
379
+ row_ctor_args: row_ctor_args,
380
+ select_by_pk_sql: select_by_pk_sql,
381
+ select_all_sql: select_all_sql,
382
+ finders: finders,
383
+ count_sql: count_sql,
384
+ validators: validators,
385
+ insert_bind_lines: insert_bind_lines,
386
+ insert_kwargs: insert_kwargs,
387
+ insert_kwarg_signature: insert_kwarg_sig,
388
+ insert_sql: insert_sql,
389
+ update_bind_lines: update_bind_lines,
390
+ update_kwargs: update_kwargs,
391
+ update_kwarg_signature: update_kwarg_sig,
392
+ update_sql: update_sql,
393
+ delete_sql: delete_sql,
394
+ ),
395
+ )
396
+ end
397
+
398
+ # Per-column-type codegen helpers — see bin/build comment block in
399
+ # the pre-gem layout for the full rationale (col_str rotating-buffer
400
+ # quirk, bool adapter divergence, etc.).
401
+ def col_read_expr(col, idx, adapter)
402
+ case col[:type]
403
+ when :int then "Fresco::Db.col_int(cid, #{idx})"
404
+ when :str then "(Fresco::Db.col_str(cid, #{idx}) + \"\")"
405
+ when :bool
406
+ if adapter == :postgres
407
+ "(Fresco::Db.col_str(cid, #{idx}) == \"t\")"
408
+ else
409
+ "(Fresco::Db.col_int(cid, #{idx}) != 0)"
410
+ end
411
+ else abort "[build] unknown column type #{col[:type].inspect} on #{col[:name]}"
412
+ end
413
+ end
414
+
415
+ def col_bind_stmt(col, idx)
416
+ nm = col[:name].to_s.inspect
417
+ case col[:type]
418
+ when :int then "Fresco::Db.bind_int(cid, #{idx}, #{col[:name]}, #{nm})"
419
+ when :str then "Fresco::Db.bind_str(cid, #{idx}, #{col[:name]}, #{nm})"
420
+ when :bool then "Fresco::Db.bind_int(cid, #{idx}, #{col[:name]} ? 1 : 0, #{nm})"
421
+ else abort "[build] unknown column type #{col[:type].inspect} on #{col[:name]}"
422
+ end
423
+ end
424
+
425
+ def col_zero(col)
426
+ case col[:type]
427
+ when :int then "0"
428
+ when :str then '""'
429
+ when :bool then "false"
430
+ else abort "[build] unknown column type #{col[:type].inspect} on #{col[:name]}"
431
+ end
432
+ end
433
+
434
+ def col_finder_bind_call(col)
435
+ nm = col[:name].to_s.inspect
436
+ case col[:type]
437
+ when :int then "Fresco::Db.bind_int(cid, 1, #{col[:name]}, #{nm})"
438
+ when :str then "Fresco::Db.bind_str(cid, 1, #{col[:name]}, #{nm})"
439
+ when :bool then "Fresco::Db.bind_int(cid, 1, #{col[:name]} ? 1 : 0, #{nm})"
440
+ end
441
+ end
442
+
443
+ def join_cols(columns)
444
+ columns.map { |c| c[:name].to_s }.join(", ")
445
+ end
446
+
447
+ def placeholders(n, adapter)
448
+ if adapter == :postgres
449
+ (1..n).map { |i| "$#{i}" }.join(", ")
450
+ else
451
+ Array.new(n, "?").join(", ")
452
+ end
453
+ end
454
+
455
+ def assignments(columns, adapter, start_idx = 1)
456
+ columns.map.with_index do |c, i|
457
+ ph = adapter == :postgres ? "$#{start_idx + i}" : "?"
458
+ "#{c[:name]} = #{ph}"
459
+ end.join(", ")
460
+ end
461
+
462
+ # --- Emit migrations.rb (embedded SQL + runner) ------------------
463
+ def emit_migrations(db_adapter)
464
+ Dir.glob("db/migrations/*.rb").sort.each { |f| load f }
465
+
466
+ migrations_data = Fresco.migrations.map do |m|
467
+ suffix = m[:name].to_s.gsub(/[^A-Za-z0-9_]/, "_")
468
+ { name: m[:name], method_suffix: suffix, up_sql: m[:up_sql], down_sql: m[:down_sql] }
469
+ end
470
+
471
+ pl_q = db_adapter == :postgres ? "$1" : "?"
472
+
473
+ File.write(
474
+ "#{GEN_DIR}/migrations.rb",
475
+ render_template("migrations.rb.erb",
476
+ migrations: migrations_data,
477
+ applied_sql: "SELECT COUNT(*) FROM schema_migrations WHERE name = #{pl_q}",
478
+ insert_applied_sql: "INSERT INTO schema_migrations (name) VALUES (#{pl_q})",
479
+ delete_applied_sql: "DELETE FROM schema_migrations WHERE name = #{pl_q}",
480
+ ),
481
+ )
482
+ end
483
+
484
+ # --- Emit requires.rb (model + action manifest) ------------------
485
+ def emit_requires_manifest(action_files, generated_models, has_root)
486
+ model_require_paths = generated_models.map { |stem| "models/#{stem}" }
487
+ action_paths = model_require_paths + ["../app/action"] + action_files.map do |f|
488
+ f.sub(%r{\Aapp/actions/}, "../app/actions/").sub(/\.rb\z/, "")
489
+ end
490
+ action_paths.unshift("welcome") unless has_root
491
+ File.write("#{GEN_DIR}/requires.rb", render_template("manifest.rb.erb", kind: "action", paths: action_paths))
492
+ end
493
+
494
+ # --- Emit boot.rb ------------------------------------------------
495
+ def emit_boot
496
+ FileUtils.cp("#{RUNTIME_DIR}/boot.rb", "#{GEN_DIR}/boot.rb")
497
+ end
498
+
499
+ # --- Emit view modules (Herb::Engine → render_<name>) ------------
500
+ def emit_views
501
+ view_files = Dir.glob("app/views/**/*.html.erb").sort
502
+ Dir.glob("#{VIEWS_GEN_DIR}/*.rb").each { |f| File.delete(f) }
503
+
504
+ view_names = []
505
+ layout_names = []
506
+ view_files.each do |path|
507
+ rel = path.sub(%r{\Aapp/views/}, "")
508
+ source = File.read(path)
509
+
510
+ signature = +""
511
+ if (m = source.match(LOCALS_RE))
512
+ signature = m[1]
513
+ source = source.sub(LOCALS_RE, "")
514
+ end
515
+
516
+ if layout_file?(rel)
517
+ source = source.gsub(LAYOUT_YIELD_RE, "<%== content %>")
518
+ signature = inject_layout_kwargs(signature)
519
+ layout_names << layout_name_from(rel)
520
+ end
521
+
522
+ source = rewrite_partial_renders(source, rel)
523
+
524
+ src = Herb::Engine.new(
525
+ source,
526
+ filename: path,
527
+ escape: true,
528
+ escapefunc: "::Spinel::View.h",
529
+ attrfunc: "::Spinel::View.attr",
530
+ jsfunc: "::Spinel::View.js",
531
+ cssfunc: "::Spinel::View.css",
532
+ ).src
533
+ src = split_static_view_literals(src)
534
+
535
+ name = view_method_name(rel)
536
+ File.write(
537
+ "#{VIEWS_GEN_DIR}/#{name}.rb",
538
+ render_template("view.rb.erb", rel: rel, name: name, signature: signature, src: src),
539
+ )
540
+ view_names << name
541
+ end
542
+
543
+ view_paths = view_names.map { |n| "views/#{n}" }
544
+ File.write("#{GEN_DIR}/views.rb", render_template("manifest.rb.erb", kind: "view", paths: view_paths))
545
+
546
+ [view_names, layout_names]
547
+ end
548
+
549
+ def view_method_name(rel)
550
+ rel.sub(/\.html\.erb\z/, "").tr("/", "_")
551
+ end
552
+
553
+ # Build-time rewrite of `<%= render partial: "name", locals: {...} %>`
554
+ # to a typed direct call. Spinel's poly-hash collapse means we can't
555
+ # ship the locals hash to runtime — splice as kwargs instead.
556
+ def resolve_partial(name, source_rel)
557
+ candidates =
558
+ if name.include?("/")
559
+ parts = name.split("/")
560
+ parts[-1] = "_#{parts[-1]}"
561
+ ["#{parts.join('/')}.html.erb"]
562
+ else
563
+ list = []
564
+ folder = File.dirname(source_rel)
565
+ loop do
566
+ if folder == "."
567
+ list << "_#{name}.html.erb"
568
+ break
569
+ end
570
+ list << "#{folder}/_#{name}.html.erb"
571
+ folder = File.dirname(folder)
572
+ end
573
+ list
574
+ end
575
+ resolved = candidates.find { |c| File.exist?("app/views/#{c}") }
576
+ unless resolved
577
+ abort "[build] partial #{name.inspect} not found from app/views/#{source_rel} " \
578
+ "(tried: #{candidates.map { |c| "app/views/#{c}" }.join(", ")})"
579
+ end
580
+ view_method_name(resolved)
581
+ end
582
+
583
+ def rewrite_partial_renders(source, source_rel)
584
+ source.gsub(PARTIAL_RENDER_RE) do
585
+ method_name = "render_#{resolve_partial($1, source_rel)}"
586
+ locals_body = ($2 || "").strip
587
+ "<%== #{method_name}(#{locals_body}) %>"
588
+ end
589
+ end
590
+
591
+ def layout_file?(rel)
592
+ rel.start_with?("layouts/")
593
+ end
594
+
595
+ def layout_name_from(rel)
596
+ rel.sub(%r{\Alayouts/}, "").sub(/\.html\.erb\z/, "").tr("/", "_")
597
+ end
598
+
599
+ def inject_layout_kwargs(sig)
600
+ return "(#{LAYOUT_FRAMEWORK_KWARGS})" if sig.empty?
601
+ sig.sub(/\)\z/, ", #{LAYOUT_FRAMEWORK_KWARGS})")
602
+ end
603
+
604
+ def odd_backslashes_before?(src, idx)
605
+ count = 0
606
+ i = idx - 1
607
+ while i >= 0 && src[i] == "\\"
608
+ count += 1
609
+ i -= 1
610
+ end
611
+ count.odd?
612
+ end
613
+
614
+ def find_single_quote_end(src, start)
615
+ i = start
616
+ while i < src.length
617
+ return i if src[i] == "'" && !odd_backslashes_before?(src, i)
618
+ i += 1
619
+ end
620
+ raise "[build] unterminated Herb string literal in generated view source"
621
+ end
622
+
623
+ def trailing_backslashes_odd?(src)
624
+ count = 0
625
+ i = src.length - 1
626
+ while i >= 0 && src[i] == "\\"
627
+ count += 1
628
+ i -= 1
629
+ end
630
+ count.odd?
631
+ end
632
+
633
+ def split_single_quoted_content(content)
634
+ return [content] if content.length <= MAX_VIEW_LITERAL_BYTES
635
+
636
+ chunks = []
637
+ i = 0
638
+ while i < content.length
639
+ j = [i + MAX_VIEW_LITERAL_BYTES, content.length].min
640
+ if j < content.length
641
+ j -= 1 while j > i && trailing_backslashes_odd?(content[i...j])
642
+ j = [i + MAX_VIEW_LITERAL_BYTES, content.length].min if j == i
643
+ end
644
+ chunks << content[i...j]
645
+ i = j
646
+ end
647
+ chunks
648
+ end
649
+
650
+ def split_static_view_literals(src)
651
+ out = +""
652
+ i = 0
653
+ while (pos = src.index(STATIC_APPEND_PREFIX, i))
654
+ out << src[i...pos]
655
+ content_start = pos + STATIC_APPEND_PREFIX.length
656
+ quote_end = find_single_quote_end(src, content_start)
657
+ after_quote = quote_end + 1
658
+
659
+ unless src[after_quote, ".freeze".length] == ".freeze"
660
+ out << src[pos...after_quote]
661
+ i = after_quote
662
+ next
663
+ end
664
+
665
+ chunks = split_single_quoted_content(src[content_start...quote_end])
666
+ out << chunks.map { |chunk| "#{STATIC_APPEND_PREFIX}#{chunk}#{STATIC_APPEND_SUFFIX}" }.join("; ")
667
+ i = after_quote + ".freeze".length
668
+ end
669
+ out << src[i..] if i < src.length
670
+ out
671
+ end
672
+
673
+ # --- Emit layout_dispatch.rb -------------------------------------
674
+ def emit_layout_dispatch(layout_names)
675
+ File.write(
676
+ "#{GEN_DIR}/layout_dispatch.rb",
677
+ render_template("layout_dispatch.rb.erb", layout_names: layout_names),
678
+ )
679
+ end
680
+ end
681
+ end
682
+ end
@@ -0,0 +1,17 @@
1
+ module Fresco
2
+ class CLI
3
+ class Dev
4
+ # The dev loop relies on top-level method and constant definitions
5
+ # in the loaded generated/runtime.rb (Request, Response, Sock,
6
+ # dispatch_request, Color, parse_query_string!, …). Keeping the
7
+ # loop itself as a top-level script preserves that scoping —
8
+ # methods defined in dev_loop.rb live in the same Object scope as
9
+ # the things `load "generated/runtime.rb"` defines.
10
+ def run(argv)
11
+ ARGV.replace(argv)
12
+ Kernel.load(File.expand_path("dev_loop.rb", __dir__))
13
+ 0
14
+ end
15
+ end
16
+ end
17
+ end