tep 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
data/bin/tep ADDED
@@ -0,0 +1,2156 @@
1
+ #!/usr/bin/env ruby
2
+ # tep -- build-time translator + driver for the Tep framework.
3
+ #
4
+ # `tep build app.rb [-o out]` -- translate Sinatra-style source to
5
+ # spinel-compilable Ruby and compile
6
+ # it to a native binary via spinel.
7
+ #
8
+ # `tep run app.rb [-p PORT] [-w WORKERS]` -- build, then run the result.
9
+ #
10
+ # Translation: top-level `get '/' do ... end` (and post/put/patch/
11
+ # delete/before/after/not_found) blocks are extracted by Prism and
12
+ # emitted as Tep::Handler / Tep::Filter subclasses. Common Sinatra
13
+ # DSL idioms inside blocks are textually rewritten:
14
+ #
15
+ # params -> req.params
16
+ # request -> req
17
+ # response -> res
18
+ # redirect 'x' -> res.set_status(302); res.headers['Location']='x'; ''
19
+ # halt N, 'msg' -> res.set_status(N); 'msg'
20
+ # content_type 'x' -> res.headers['Content-Type'] = 'x'
21
+ # set :public_dir, 'p' -> Tep.public_dir 'p'
22
+ #
23
+ # Spinel-incompatible patterns (template engines, helpers blocks,
24
+ # rack middleware, etc.) get warned about; they don't translate.
25
+
26
+ # The translator parses the user's Sinatra source with Prism. Prism
27
+ # ships with Ruby 3.4+, but tep keeps it a *development-only* gem
28
+ # dependency (it's a C extension Spinel can't compile, so a runtime dep
29
+ # would drag it into a spinelgems consumer's lock + fail the compat
30
+ # gate). So on a `gem install tep` under Ruby 3.2/3.3 it may be absent
31
+ # -- fail with an actionable message instead of a bare LoadError.
32
+ begin
33
+ require "prism"
34
+ rescue LoadError
35
+ abort "tep: the translator needs the `prism` parser, which isn't " \
36
+ "available here.\n" \
37
+ " - Ruby 3.4+ bundles it (no action needed).\n" \
38
+ " - On Ruby 3.2/3.3: gem install prism\n" \
39
+ "Prism is a build-time-only dep (a C extension Spinel can't " \
40
+ "compile), so tep deliberately doesn't pull it into your lock."
41
+ end
42
+ require "fileutils"
43
+ require "tmpdir"
44
+ require "pathname"
45
+
46
+ LIB_DIR = File.expand_path("../lib", __dir__)
47
+ REPO_ROOT = File.dirname(LIB_DIR)
48
+
49
+ # The @TEP_*@ placeholder shape lives in spinel-ext.json at the gem
50
+ # root -- the single source of truth shared with `spinel-compat
51
+ # vendor` (which wires the C-ext for downstream consumers), so the
52
+ # substitution list isn't duplicated per consumer. See
53
+ # OriPekelman/tep#97 + #98 and spinelgems' docs/c-ext.md.
54
+ require "json"
55
+
56
+ # Resolve spinel: explicit env var wins, then a sibling
57
+ # `../spinel/spinel` checkout (matches the typical dev layout where
58
+ # tep and spinel are checked out side by side), then plain `spinel`
59
+ # on PATH. The Makefile exports SPINEL=spinel by default; treat the
60
+ # literal placeholder as unset so the sibling fallback still fires.
61
+ def self.locate_spinel
62
+ e = ENV["SPINEL"]
63
+ return e if e && !e.empty? && e != "spinel"
64
+ sibling = File.expand_path("../../spinel/spinel", __dir__)
65
+ return sibling if File.executable?(sibling)
66
+ "spinel"
67
+ end
68
+ SPINEL = locate_spinel
69
+
70
+ DSL_VERBS = %w[get post put patch delete options head]
71
+ HOOK_NAMES = %w[before after not_found]
72
+ WS_EVENTS = %w[on_open on_message on_close on_ping on_pong on_error]
73
+
74
+ def fatal(msg)
75
+ warn "tep: #{msg}"
76
+ exit 1
77
+ end
78
+
79
+ # Build the translated Ruby source from a Sinatra-style file.
80
+ def translate(input_path)
81
+ raw_source = File.read(input_path)
82
+
83
+ # Split off `__END__` inline templates. Sinatra's convention is
84
+ # `@@ name\n<template body>` repeated. Pre-__END__ goes through
85
+ # Prism; post-__END__ is parsed by us into a name->body map.
86
+ source, inline_views = split_inline_views(raw_source)
87
+
88
+ parsed = Prism.parse(source)
89
+ fatal "parse errors in #{input_path}\n #{parsed.errors.map(&:message).join("\n ")}" unless parsed.success?
90
+
91
+ prog = parsed.value
92
+ routes = []
93
+ websockets = []
94
+ live_views = []
95
+ mcp_tools = []
96
+ mcp_resources = []
97
+ proxies = [] # Tep::Proxy block-DSL (#88): one entry per
98
+ # `var = Tep::Proxy.new(...)` + its .before/.after/
99
+ # .on_stream_chunk/.on_stream_end/.stream_request?
100
+ # block calls, lowered to a generated subclass.
101
+ filters = { "before" => [], "after" => [] }
102
+ not_founds = []
103
+ startup_block = nil
104
+ config = { public_dir: nil, mcp_server_name: nil, mcp_server_version: nil }
105
+ warnings = []
106
+ anon_id = 0
107
+
108
+ # We only walk the top-level statements; handlers inside Sinatra's
109
+ # `class App < Sinatra::Base` are not supported in v0.1.
110
+ unwrapped_apps = []
111
+ passthrough = []
112
+ # Dedupe set for recursive require_relative inlining (deps.rb chains;
113
+ # shared deps must inline once). Keyed by absolute path; build-wide.
114
+ inlined_seen = {}
115
+ # Scan ALL `erb :name` and `mustache :name` references up front;
116
+ # every referenced view gets compiled and emitted at the top of
117
+ # the synthesized output. ERB views compile to `tep_view_<name>`,
118
+ # mustache views to `tep_mustache_<name>` -- separate namespaces
119
+ # so a project can mix engines without name collisions.
120
+ views_used = source.scan(/(?<![\w.])erb\s+:(\w+)/).flatten.uniq
121
+ mustaches_used = source.scan(/(?<![\w.])mustache\s+:(\w+)/).flatten.uniq
122
+ process = lambda do |node|
123
+ # `Tep.live "/path", ViewClass` -- the one Tep::*-receiver call
124
+ # the translator owns. Lowers to a GET route (render_page) +
125
+ # WS route (per-connection view instance, event dispatch +
126
+ # re-render). Other Tep.* calls (Tep.before, Tep.get, ...)
127
+ # pass through as runtime method calls.
128
+ if call?(node) && node.name == :live &&
129
+ node.receiver.is_a?(Prism::ConstantReadNode) &&
130
+ node.receiver.name == :Tep
131
+ handle_tep_live(node, live_views, warnings)
132
+ return
133
+ end
134
+
135
+ # Proxy block-DSL (#88), part 1: `var = Tep::Proxy.new(...)`.
136
+ # Record the proxy var + rewrite the assignment to instantiate
137
+ # the generated subclass (TepProxy_<idx>), which carries the
138
+ # before/after/stream hooks collected below. The base
139
+ # `Tep::Proxy.new` would forward with no customization; the
140
+ # subclass is only emitted when at least one hook block is seen,
141
+ # but we always rewrite so the var name stays consistent.
142
+ if node.is_a?(Prism::LocalVariableWriteNode) && proxy_new_call?(node.value)
143
+ idx = proxies.length
144
+ up_src = proxy_upstream_src(node.value, warnings)
145
+ proxies << { var: node.name.to_s, idx: idx, upstream: up_src, hooks: {} }
146
+ # Assign to a CONSTANT, not the original local. A Tep::Proxy
147
+ # subclass instance flowing through a *local* into Tep.<verb>
148
+ # widens rewrite_path's virtual `path` param to poly (poisoning
149
+ # Tep::App.guess_mime's `path` file-wide); the same instance via
150
+ # a constant compiles clean. Mounts are rewritten to the
151
+ # constant below.
152
+ passthrough << "TEPPROXY_#{idx} = TepProxy_#{idx}.new(#{up_src})"
153
+ return
154
+ end
155
+
156
+ # Proxy block-DSL (#88), part 2: `proxyvar.before do ... end`
157
+ # (and .after / .on_stream_chunk / .on_stream_end /
158
+ # .stream_request?). Collect the block onto the proxy entry +
159
+ # suppress from passthrough (a receiver-method call with a block
160
+ # can't lower -- spinel has no PtrArray<Block>; the block becomes
161
+ # an imeth on the generated subclass instead).
162
+ if call?(node) && node.receiver.is_a?(Prism::LocalVariableReadNode) &&
163
+ PROXY_HOOK_IMETH.key?(node.name.to_s) && node.block.is_a?(Prism::BlockNode)
164
+ pv = proxies.find { |p| p[:var] == node.receiver.name.to_s }
165
+ if pv
166
+ pv[:hooks][node.name.to_s] = {
167
+ params: block_param_list(node.block),
168
+ src: block_source(node.block),
169
+ }
170
+ return
171
+ end
172
+ end
173
+
174
+ # Proxy block-DSL (#88), part 3: rewrite a mount of a proxy var --
175
+ # `Tep.<verb> "path", proxyvar` or bare `<verb> "path", proxyvar`
176
+ # (no block) -- to reference the generated constant instead of the
177
+ # (dropped) local. Reconstructed structurally so the handler arg
178
+ # becomes TEPPROXY_<idx>.
179
+ if call?(node) && PROXY_MOUNT_VERBS.include?(node.name.to_s) && node.block.nil?
180
+ margs = node.arguments&.arguments || []
181
+ if margs.length == 2 && margs[1].is_a?(Prism::LocalVariableReadNode)
182
+ pv = proxies.find { |p| p[:var] == margs[1].name.to_s }
183
+ if pv
184
+ recv = node.receiver ? "#{node.receiver.location.slice}." : ""
185
+ passthrough << "#{recv}#{node.name}(#{margs[0].location.slice}, TEPPROXY_#{pv[:idx]})"
186
+ return
187
+ end
188
+ end
189
+ end
190
+
191
+ # Only bare top-level method calls (no receiver) are candidates
192
+ # for DSL handling -- `get '/'`, `before do`, `set :public_dir`,
193
+ # etc. A call with a receiver (`$arr.delete_at(0)`,
194
+ # `something.frob`) is just user code: pass it through verbatim
195
+ # so spinel sees it at program load.
196
+ if call?(node) && node.receiver.nil?
197
+ result = handle_top_call(node, routes, websockets, mcp_tools, mcp_resources, filters, not_founds, config, warnings, passthrough, input_path, inlined_seen)
198
+ startup_block = result if result.is_a?(Hash) && result[:kind] == :startup
199
+ return
200
+ end
201
+
202
+ # Modular Sinatra::Base: `class MyApp < Sinatra::Base; ...; end`.
203
+ # Unwrap the body so `get '/'`, `before do`, etc. inside register
204
+ # against the global Tep app -- v0.1 doesn't run multiple apps
205
+ # in parallel, but the source-level shape is preserved.
206
+ if node.is_a?(Prism::ClassNode) && sinatra_base_class?(node)
207
+ unwrapped_apps << node.constant_path.location.slice
208
+ stmts = node.body
209
+ if stmts.is_a?(Prism::StatementsNode)
210
+ stmts.body.each { |inner| process.call(inner) }
211
+ end
212
+ return
213
+ end
214
+
215
+ # Drop `MyApp.run!` (etc.) when MyApp was just unwrapped.
216
+ # Without this the passthrough emits `MyApp.run!` and spinel
217
+ # has no class by that name.
218
+ if call?(node) && node.receiver
219
+ recv_src = node.receiver.location.slice
220
+ if unwrapped_apps.include?(recv_src)
221
+ return
222
+ end
223
+ end
224
+
225
+ passthrough << node.location.slice
226
+ end
227
+ prog.statements.body.each { |n| process.call(n) }
228
+
229
+ # Spinel's require_relative is finicky about long paths and absolute
230
+ # paths -- both fail silently. So we inline the whole tep library
231
+ # text into the generated file instead of relying on require_relative
232
+ # at build time.
233
+ out = []
234
+ out << "# Generated by tep #{Time.now.strftime('%Y-%m-%d %H:%M:%S')} from #{File.basename(input_path)}"
235
+ out << inlined_tep_library
236
+ out << ""
237
+
238
+ # Compile-time asset bundling: anything under `<app_dir>/assets/`
239
+ # is read into a Tep::Assets _add call. The bytes ride in the
240
+ # binary as Ruby string literals; mime is inferred by extension.
241
+ # See lib/tep/assets.rb for the runtime side.
242
+ asset_lines = embed_assets(input_path, warnings)
243
+ unless asset_lines.empty?
244
+ out << "# --- bundled assets ---"
245
+ out.concat(asset_lines)
246
+ out << ""
247
+ end
248
+
249
+ # Top-level non-DSL statements (constants, classes, defs, ...).
250
+ # Emitted verbatim before any DSL registrations so handler bodies
251
+ # can refer to them.
252
+ # Proxy block-DSL subclasses (#88). Emitted BEFORE passthrough so
253
+ # the rewritten `var = TepProxy_<idx>.new(...)` assignment (which
254
+ # stays in passthrough, in its original position so its upstream
255
+ # argument can reference earlier top-level code) finds the class
256
+ # defined. Each subclass's imeths are the user's `.before` /
257
+ # `.after` / `.on_stream_chunk` / `.on_stream_end` /
258
+ # `.stream_request?` block bodies, lowered verbatim with the user's
259
+ # param names -- a thin 1:1 sugar over the subclass-override form.
260
+ # Method bodies run at request time, so referencing user constants
261
+ # defined later in passthrough is fine. A hookless proxy still gets
262
+ # a (trivial) subclass so its assignment resolves.
263
+ unless proxies.empty?
264
+ out << "# --- proxy block-DSL subclasses (#88) ---"
265
+ proxies.each do |p|
266
+ out << "class TepProxy_#{p[:idx]} < Tep::Proxy"
267
+ p[:hooks].each do |dsl_name, info|
268
+ imeth = PROXY_HOOK_IMETH[dsl_name]
269
+ out << " def #{imeth}(#{info[:params]})"
270
+ out << indent(rewrite_block(info[:src], force_string: false), 4)
271
+ out << " end"
272
+ end
273
+ out << "end"
274
+ end
275
+ out << ""
276
+ end
277
+
278
+ unless passthrough.empty?
279
+ out << "# --- top-level passthrough from #{File.basename(input_path)} ---"
280
+ passthrough.each { |s| out << s }
281
+ out << ""
282
+ end
283
+
284
+ # Build-time ERB compile. Each referenced view becomes a top-level
285
+ # method `tep_view_<name>(locals)` that returns the rendered string.
286
+ # Resolution order: file-based (views/<name>.erb) > inline (after
287
+ # `__END__`, `@@ name` blocks).
288
+ unless views_used.empty?
289
+ views_dir = config[:views] || File.join(File.dirname(input_path), "views")
290
+ views_used.each do |vn|
291
+ vp = File.join(views_dir, "#{vn}.erb")
292
+ tmpl = nil
293
+ if File.exist?(vp)
294
+ tmpl = File.read(vp)
295
+ elsif inline_views.key?(vn)
296
+ tmpl = inline_views[vn]
297
+ end
298
+ if tmpl.nil?
299
+ warnings << "view :#{vn} -- not found in #{vp} nor in inline `@@` templates; emitting empty stub"
300
+ out << "def tep_view_#{vn}(locals, ivars); \"\"; end"
301
+ next
302
+ end
303
+ out << "def tep_view_#{vn}(locals, ivars)"
304
+ out << " " + erb_to_ruby_body(tmpl)
305
+ out << "end"
306
+ out << ""
307
+ end
308
+ end
309
+
310
+ # Same loop for mustache views: file-based (.mustache) > inline
311
+ # (`@@ name` blocks). Documented subset only -- `{{var}}`,
312
+ # `{{{var}}}`, `{{!comment}}`, `{{@ivar}}`. Sections / partials /
313
+ # lambdas are deliberately not implemented (they'd need iterable
314
+ # locals, which clashes with tep's String=>String view-arg model).
315
+ unless mustaches_used.empty?
316
+ views_dir = config[:views] || File.join(File.dirname(input_path), "views")
317
+ mustaches_used.each do |vn|
318
+ vp = File.join(views_dir, "#{vn}.mustache")
319
+ tmpl = nil
320
+ if File.exist?(vp)
321
+ tmpl = File.read(vp)
322
+ elsif inline_views.key?(vn)
323
+ tmpl = inline_views[vn]
324
+ end
325
+ if tmpl.nil?
326
+ warnings << "mustache :#{vn} -- not found in #{vp} nor in inline `@@` templates; emitting empty stub"
327
+ out << "def tep_mustache_#{vn}(locals, ivars); \"\"; end"
328
+ next
329
+ end
330
+ out << "def tep_mustache_#{vn}(locals, ivars)"
331
+ out << " " + mustache_to_ruby_body(tmpl)
332
+ out << "end"
333
+ out << ""
334
+ end
335
+ end
336
+
337
+ # Build Handler subclasses for routes. For regex routes we bake the
338
+ # regex literal directly into the same Handler subclass, exposing
339
+ # is_regex? / re_match? / re_capture for the router to use; this
340
+ # lets us keep a single routes table (one class type) and rely on
341
+ # spinel's cls_id virtual dispatch on the handler.
342
+ routes.each_with_index do |r, i|
343
+ cname = "TepRoute_#{i}"
344
+ body = rewrite_block(r[:block_src])
345
+ out << "class #{cname} < Tep::Handler"
346
+ out << " def handle(req, res)"
347
+ out << indent(body, 4)
348
+ out << " end"
349
+ if r[:regex]
350
+ lit = "%r{#{r[:regex]}}"
351
+ out << " def is_regex?; true; end"
352
+ out << " def re_match?(path); !!(path =~ #{lit}); end"
353
+ out << " def re_capture(path)"
354
+ out << " path =~ #{lit}"
355
+ out << " [$1.to_s, $2.to_s, $3.to_s, $4.to_s, $5.to_s, $6.to_s, $7.to_s, $8.to_s, $9.to_s]"
356
+ out << " end"
357
+ end
358
+ out << "end"
359
+ out << ""
360
+ r[:cls] = cname
361
+ end
362
+
363
+ # Multiple `before do` / `after do` blocks fold into a single
364
+ # composite Filter subclass per kind -- spinel's PtrArray of
365
+ # mixed Filter subclasses can't dispatch virtually, and the App
366
+ # holds a single before/after slot.
367
+ filter_classes = {}
368
+ filters.each do |kind, fs|
369
+ next if fs.empty?
370
+ cname = "TepFilters_#{kind}"
371
+ out << "class #{cname} < Tep::Filter"
372
+ out << " def #{kind}(req, res)"
373
+ fs.each_with_index do |f, i|
374
+ body = rewrite_block(f[:block_src], force_string: false)
375
+ out << " # filter #{kind} ##{i}"
376
+ out << indent(body, 4)
377
+ end
378
+ out << " 0"
379
+ out << " end"
380
+ out << "end"
381
+ out << ""
382
+ filter_classes[kind] = cname
383
+ end
384
+
385
+ not_founds.each_with_index do |n, i|
386
+ cname = "TepNotFound_#{i}"
387
+ body = rewrite_block(n[:block_src])
388
+ out << "class #{cname} < Tep::Handler"
389
+ out << " def handle(req, res)"
390
+ out << indent(body, 4)
391
+ out << " end"
392
+ out << "end"
393
+ out << ""
394
+ n[:cls] = cname
395
+ end
396
+
397
+ # WebSocket route synthesis. Each `websocket '/path' do |ws|
398
+ # on_open { ... }; on_message { ... }; ... end` becomes:
399
+ # - one Tep::WebSocket::Handler subclass per on_X event with the
400
+ # user's block body as handle_event(evt), with `<ws_name> = @ws`
401
+ # bound at the top so the user's references just work.
402
+ # - one Tep::Handler subclass for the upgrade route that runs the
403
+ # handshake check, wires the per-event callbacks onto a fresh
404
+ # Driver, and flips res.start_websocket so the server runs the
405
+ # recv loop in lieu of writing a normal response.
406
+ # WS only works under Tep::Server::Scheduled; the runtime warns
407
+ # (and the blocking server returns 501) if the route fires there.
408
+ websockets.each_with_index do |w, i|
409
+ base = "TepWS_#{i}"
410
+ cbs = {}
411
+ w[:events].each do |ev, info|
412
+ ev_cls = "#{base}_#{ev}"
413
+ body = rewrite_block(info[:src], force_string: false)
414
+ out << "class #{ev_cls} < Tep::WebSocket::Handler"
415
+ out << " def initialize"
416
+ out << " super"
417
+ out << " @ws = Tep::WebSocket::Driver.new(0)"
418
+ out << " end"
419
+ out << " attr_accessor :ws"
420
+ out << " def handle_event(#{info[:evt_param]})"
421
+ out << " #{w[:ws_param]} = @ws"
422
+ out << " req = @req"
423
+ out << indent(body, 4)
424
+ out << " 0"
425
+ out << " end"
426
+ out << "end"
427
+ out << ""
428
+ cbs[ev] = ev_cls
429
+ end
430
+
431
+ rt_cls = "#{base}_Route"
432
+ out << "class #{rt_cls} < Tep::Handler"
433
+ out << " def handle(req, res)"
434
+ out << " hs = Tep::WebSocket::Handshake.check(req)"
435
+ out << " if !hs.valid"
436
+ out << " res.set_status(400)"
437
+ out << " return \"\""
438
+ out << " end"
439
+ out << " drv = Tep::WebSocket::Driver.new(0)"
440
+ cbs.each do |ev, cls|
441
+ slot = ev.sub("on_", "set_on_")
442
+ out << " _cb_#{ev} = #{cls}.new"
443
+ out << " _cb_#{ev}.ws = drv"
444
+ out << " _cb_#{ev}.req = req"
445
+ out << " drv.#{slot}(_cb_#{ev})"
446
+ end
447
+ out << " res.start_websocket(hs.accept_key, drv)"
448
+ out << " \"\""
449
+ out << " end"
450
+ out << "end"
451
+ out << ""
452
+ w[:cls] = rt_cls
453
+ end
454
+
455
+ # Tep.live emission. For each `Tep.live "/path", ViewClass`:
456
+ # * TepLive_N_Get handles GET /path -- instantiate view per
457
+ # request, mount(req), wrap in render_page targeting /path/ws.
458
+ # * TepLive_N_WsOpen / TepLive_N_WsMessage are Handler subclasses;
459
+ # each connection's route handler creates ONE ViewClass instance
460
+ # and stashes it on both handlers so on_open and on_message
461
+ # share state. on_open mounts + subscribes to view.topic (when
462
+ # non-empty) + sends initial render; on_message dispatches the
463
+ # JSON event + sends re-render.
464
+ # * TepLive_N_WsRoute handles the WS upgrade at /path/ws.
465
+ live_views.each_with_index do |lv, i|
466
+ base = "TepLive_#{i}"
467
+ klass = lv[:view_class]
468
+ path = lv[:path]
469
+ ws_path = path + "/ws"
470
+
471
+ get_cls = "#{base}_Get"
472
+ out << "class #{get_cls} < Tep::Handler"
473
+ out << " def handle(req, res)"
474
+ out << " v = #{klass}.new"
475
+ out << " v.mount(req)"
476
+ out << " Tep::LiveView.render_page(v.render, #{ws_path.inspect})"
477
+ out << " end"
478
+ out << "end"
479
+ out << ""
480
+
481
+ open_cls = "#{base}_WsOpen"
482
+ out << "class #{open_cls} < Tep::WebSocket::Handler"
483
+ out << " attr_accessor :ws, :view"
484
+ out << " def initialize"
485
+ out << " super"
486
+ out << " @ws = Tep::WebSocket::Driver.new(0)"
487
+ out << " @view = #{klass}.new"
488
+ out << " end"
489
+ out << " def handle_event(evt)"
490
+ out << " ws = @ws"
491
+ out << " req = @req"
492
+ out << " view = @view"
493
+ out << " view.mount(req)"
494
+ out << " t = view.topic"
495
+ out << " if t.length > 0"
496
+ out << " Tep::Broadcast.subscribe_ws(t, ws.fd)"
497
+ out << " end"
498
+ out << " ws.text(view.render)"
499
+ out << " 0"
500
+ out << " end"
501
+ out << "end"
502
+ out << ""
503
+
504
+ msg_cls = "#{base}_WsMessage"
505
+ out << "class #{msg_cls} < Tep::WebSocket::Handler"
506
+ out << " attr_accessor :ws, :view"
507
+ out << " def initialize"
508
+ out << " super"
509
+ out << " @ws = Tep::WebSocket::Driver.new(0)"
510
+ out << " @view = #{klass}.new"
511
+ out << " end"
512
+ out << " def handle_event(evt)"
513
+ out << " ws = @ws"
514
+ out << " req = @req"
515
+ out << " view = @view"
516
+ out << " view.dispatch_event_json(evt.data, req)"
517
+ out << " ws.text(view.render)"
518
+ out << " 0"
519
+ out << " end"
520
+ out << "end"
521
+ out << ""
522
+
523
+ ws_route_cls = "#{base}_WsRoute"
524
+ out << "class #{ws_route_cls} < Tep::Handler"
525
+ out << " def handle(req, res)"
526
+ out << " hs = Tep::WebSocket::Handshake.check(req)"
527
+ out << " if !hs.valid"
528
+ out << " res.set_status(400)"
529
+ out << " return \"\""
530
+ out << " end"
531
+ out << " drv = Tep::WebSocket::Driver.new(0)"
532
+ out << " v = #{klass}.new"
533
+ out << " _cb_on_open = #{open_cls}.new"
534
+ out << " _cb_on_open.ws = drv"
535
+ out << " _cb_on_open.req = req"
536
+ out << " _cb_on_open.view = v"
537
+ out << " drv.set_on_open(_cb_on_open)"
538
+ out << " _cb_on_message = #{msg_cls}.new"
539
+ out << " _cb_on_message.ws = drv"
540
+ out << " _cb_on_message.req = req"
541
+ out << " _cb_on_message.view = v"
542
+ out << " drv.set_on_message(_cb_on_message)"
543
+ out << " res.start_websocket(hs.accept_key, drv)"
544
+ out << " \"\""
545
+ out << " end"
546
+ out << "end"
547
+ out << ""
548
+
549
+ lv[:get_cls] = get_cls
550
+ lv[:ws_cls] = ws_route_cls
551
+ lv[:ws_path] = ws_path
552
+ end
553
+
554
+ # Tep::MCP emission. For each `mcp_tool 'name', "desc" do ... end`:
555
+ #
556
+ # * TepMCP_Tools.call_<i>(req, args_json) -- static cmeth that
557
+ # parses args out of args_json (via Tep::Json.get_str/get_int),
558
+ # binds them as locals, and runs the user's on_call body. The
559
+ # body is rewritten the same way route bodies are (req / res /
560
+ # params rewrites). Returns Tep::MCP::Result.
561
+ # * TepMCP_<i>_HttpRoute -- POST /tools/<name>, takes the args
562
+ # as the JSON request body, returns the Result's text directly
563
+ # (with the right status / content-type).
564
+ #
565
+ # After all tools, two more classes:
566
+ #
567
+ # * TepMCP_Dispatcher -- POST /mcp, parses the JSON-RPC envelope
568
+ # and dispatches `initialize` / `tools/list` / `tools/call` by
569
+ # statically-elif'd name. No PtrArray<Tool> involvement -- each
570
+ # tool's call cmeth is referenced literally so spinel can
571
+ # trace types end-to-end.
572
+ # * TepMCP_LlmsTxt -- GET /llms.txt, returns the tool catalog as
573
+ # a markdown index any LLM-friendly client can fetch.
574
+ if !mcp_tools.empty? || !mcp_resources.empty?
575
+ # Build the tools/list JSON once, at translate time. Each entry
576
+ # is {name, description, inputSchema}; inputSchema follows the
577
+ # JSON Schema object form with property type/description per
578
+ # param. Keep the schema flat (no $ref, no nested objects); MCP
579
+ # clients all accept this minimal shape.
580
+ tools_list_json_parts = []
581
+ mcp_tools.each do |t|
582
+ props_parts = []
583
+ t[:params].each do |p|
584
+ json_type =
585
+ case p[:type]
586
+ when "Integer" then "integer"
587
+ when "Float" then "number"
588
+ else "string"
589
+ end
590
+ props_parts << %("#{p[:name]}":{"type":"#{json_type}","description":#{p[:desc].inspect}})
591
+ end
592
+ required_arr = t[:params].map { |p| %("#{p[:name]}") }.join(",")
593
+ schema_json = %({"type":"object","properties":{#{props_parts.join(",")}},"required":[#{required_arr}]})
594
+ tools_list_json_parts << %({"name":"#{t[:name]}","description":#{t[:description].inspect},"inputSchema":#{schema_json}})
595
+ end
596
+ tools_list_json = "[" + tools_list_json_parts.join(",") + "]"
597
+
598
+ out << "module TepMCP_Tools"
599
+ mcp_tools.each_with_index do |t, i|
600
+ body = rewrite_block(t[:call_body], force_string: false)
601
+ out << " def self.call_#{i}(req, args_json)"
602
+ # Per-cap may? checks (chunk 5.2). One inline check per cap
603
+ # rather than a loop -- spinel's symbol-array iteration is
604
+ # uneven, and a flat sequence of identical branches is the
605
+ # safest emit. Anonymous identity has empty caps so any
606
+ # capped tool denies anonymous callers cleanly.
607
+ t[:caps].each do |cap|
608
+ out << " if !req.identity.may?(:#{cap})"
609
+ out << " return Tep::MCP.error(\"missing capability: #{cap}\")"
610
+ out << " end"
611
+ end
612
+ t[:params].each do |p|
613
+ if p[:type] == "Integer"
614
+ out << " #{p[:name]} = Tep::Json.get_int(args_json, #{p[:name].inspect})"
615
+ elsif p[:type] == "Float"
616
+ out << " #{p[:name]} = Tep::Json.get_str(args_json, #{p[:name].inspect}).to_f"
617
+ else
618
+ out << " #{p[:name]} = Tep::Json.get_str(args_json, #{p[:name].inspect})"
619
+ end
620
+ end
621
+ out << indent(body, 4)
622
+ out << " end"
623
+ out << ""
624
+ end
625
+ out << "end"
626
+ out << ""
627
+
628
+ mcp_tools.each_with_index do |t, i|
629
+ http_cls = "TepMCP_#{i}_HttpRoute"
630
+ out << "class #{http_cls} < Tep::Handler"
631
+ out << " def handle(req, res)"
632
+ out << " args_json = req.raw_body"
633
+ out << " r = TepMCP_Tools.call_#{i}(req, args_json)"
634
+ out << " res.headers[\"Content-Type\"] = \"text/plain; charset=utf-8\""
635
+ out << " if r.is_error == 1"
636
+ out << " res.set_status(400)"
637
+ out << " end"
638
+ out << " r.text"
639
+ out << " end"
640
+ out << "end"
641
+ out << ""
642
+ t[:http_cls] = http_cls
643
+ end
644
+
645
+ tools_const = "TepMCP_TOOLS_LIST_JSON"
646
+ out << "#{tools_const} = #{tools_list_json.inspect}"
647
+ out << ""
648
+
649
+ # Per-resource read cmeths + HTTP-direct Handler subclasses
650
+ # (chunk 5.3). One Handler per resource for GET /resources/<name>
651
+ # plus a sibling cmeth `TepMCP_Resources.read_<i>(req)` that the
652
+ # dispatcher's resources/read arm calls statically (no
653
+ # PtrArray<Resource> dispatch, same pattern as tools).
654
+ unless mcp_resources.empty?
655
+ out << "module TepMCP_Resources"
656
+ mcp_resources.each_with_index do |rdef, i|
657
+ body = rewrite_block(rdef[:read_body], force_string: false)
658
+ out << " def self.read_#{i}(req)"
659
+ out << indent(body, 4)
660
+ out << " end"
661
+ out << ""
662
+ end
663
+ out << "end"
664
+ out << ""
665
+
666
+ mcp_resources.each_with_index do |rdef, i|
667
+ http_cls = "TepMCP_Res_#{i}_HttpRoute"
668
+ out << "class #{http_cls} < Tep::Handler"
669
+ out << " def handle(req, res)"
670
+ out << " c = TepMCP_Resources.read_#{i}(req)"
671
+ out << " res.headers[\"Content-Type\"] = c.mime"
672
+ out << " c.text"
673
+ out << " end"
674
+ out << "end"
675
+ out << ""
676
+ rdef[:http_cls] = http_cls
677
+ end
678
+
679
+ # resources/list array, pre-built at translate time.
680
+ res_list_parts = mcp_resources.map do |rdef|
681
+ %({"uri":#{rdef[:name].inspect},"name":#{rdef[:name].inspect},"description":#{rdef[:description].inspect},"mimeType":"text/plain"})
682
+ end
683
+ resources_list_json = "[" + res_list_parts.join(",") + "]"
684
+ resources_const = "TepMCP_RESOURCES_LIST_JSON"
685
+ out << "#{resources_const} = #{resources_list_json.inspect}"
686
+ out << ""
687
+ end
688
+
689
+ server_name = (config[:mcp_server_name] || "tep-mcp-server").to_s
690
+ server_version = config[:mcp_server_version] || begin
691
+ vfile = File.read(File.expand_path("../lib/tep/version.rb", __dir__))
692
+ vfile[/VERSION\s*=\s*"([^"]+)"/, 1] || "0.0.0"
693
+ rescue
694
+ "0.0.0"
695
+ end
696
+
697
+ out << "class TepMCP_Dispatcher < Tep::Handler"
698
+ out << " def handle(req, res)"
699
+ out << " res.headers[\"Content-Type\"] = \"application/json\""
700
+ out << " body = req.raw_body"
701
+ out << " method = Tep::Json.get_str(body, \"method\")"
702
+ out << " req_id = Tep::Json.get_int(body, \"id\")"
703
+ out << " if method == \"initialize\""
704
+ out << " return Tep::MCP.initialize_envelope(req_id, #{server_name.inspect}, #{server_version.inspect})"
705
+ out << " end"
706
+ # notifications/initialized is a JSON-RPC notification (no
707
+ # response per spec). Return 204 No Content with an empty body
708
+ # to satisfy HTTP while honoring the no-response semantics.
709
+ out << " if method == \"notifications/initialized\""
710
+ out << " res.set_status(204)"
711
+ out << " return \"\""
712
+ out << " end"
713
+ out << " if method == \"tools/list\""
714
+ out << " return Tep::MCP.tools_list_envelope(req_id, #{tools_const})"
715
+ out << " end"
716
+ out << " if method == \"tools/call\""
717
+ out << " params = Tep::MCP.nested_extract(body, \"params\")"
718
+ out << " tool_name = Tep::Json.get_str(params, \"name\")"
719
+ out << " args = Tep::MCP.nested_extract(params, \"arguments\")"
720
+ mcp_tools.each_with_index do |t, i|
721
+ out << " if tool_name == #{t[:name].inspect}"
722
+ out << " r = TepMCP_Tools.call_#{i}(req, args)"
723
+ out << " return Tep::MCP.tools_call_envelope(req_id, r.text, r.is_error)"
724
+ out << " end"
725
+ end
726
+ out << " return Tep::MCP.unknown_tool_envelope(req_id, tool_name)"
727
+ out << " end"
728
+ unless mcp_resources.empty?
729
+ out << " if method == \"resources/list\""
730
+ out << " return Tep::MCP.resources_list_envelope(req_id, TepMCP_RESOURCES_LIST_JSON)"
731
+ out << " end"
732
+ out << " if method == \"resources/read\""
733
+ out << " res_params = Tep::MCP.nested_extract(body, \"params\")"
734
+ out << " res_uri = Tep::Json.get_str(res_params, \"uri\")"
735
+ mcp_resources.each_with_index do |rdef, i|
736
+ out << " if res_uri == #{rdef[:name].inspect}"
737
+ out << " c = TepMCP_Resources.read_#{i}(req)"
738
+ out << " return Tep::MCP.resources_read_envelope(req_id, c.uri, c.mime, c.text)"
739
+ out << " end"
740
+ end
741
+ out << " return Tep::MCP.unknown_resource_envelope(req_id, res_uri)"
742
+ out << " end"
743
+ end
744
+ out << " Tep::MCP.method_not_found_envelope(req_id, method)"
745
+ out << " end"
746
+ out << "end"
747
+ out << ""
748
+
749
+ # openapi.json: OpenAPI 3.0 description of the HTTP-direct
750
+ # surface (tools at POST /tools/<name>, resources at GET
751
+ # /resources/<name>). Generated at translate time as a single
752
+ # constant string the handler returns verbatim. Mirrors what
753
+ # MCP clients see at tools/list + resources/list, but in the
754
+ # universal OpenAPI shape so non-MCP agents (and ordinary
755
+ # OpenAPI tooling) can consume the same catalog.
756
+ paths_parts = []
757
+ mcp_tools.each do |t|
758
+ props_parts = t[:params].map do |p|
759
+ json_type =
760
+ case p[:type]
761
+ when "Integer" then "integer"
762
+ when "Float" then "number"
763
+ else "string"
764
+ end
765
+ %("#{p[:name]}":{"type":"#{json_type}","description":#{p[:desc].inspect}})
766
+ end
767
+ required_arr = t[:params].map { |p| %("#{p[:name]}") }.join(",")
768
+ schema_json = %({"type":"object","properties":{#{props_parts.join(",")}},"required":[#{required_arr}]})
769
+ paths_parts << %("/tools/#{t[:name]}":{"post":{"summary":#{t[:description].inspect},"requestBody":{"required":true,"content":{"application/json":{"schema":#{schema_json}}}},"responses":{"200":{"description":"tool text result","content":{"text/plain":{}}},"400":{"description":"tool error"}}}})
770
+ end
771
+ mcp_resources.each do |rdef|
772
+ paths_parts << %("/resources/#{rdef[:name]}":{"get":{"summary":#{rdef[:description].inspect},"responses":{"200":{"description":"resource content","content":{"text/plain":{}}}}}})
773
+ end
774
+ openapi_json =
775
+ %({"openapi":"3.0.3","info":{"title":#{server_name.inspect},"version":#{server_version.inspect}},) +
776
+ %("paths":{#{paths_parts.join(",")}}})
777
+ openapi_const = "TepMCP_OPENAPI_JSON"
778
+ out << "#{openapi_const} = #{openapi_json.inspect}"
779
+ out << ""
780
+ out << "class TepMCP_OpenAPI < Tep::Handler"
781
+ out << " def handle(req, res)"
782
+ out << " res.headers[\"Content-Type\"] = \"application/json\""
783
+ out << " #{openapi_const}"
784
+ out << " end"
785
+ out << "end"
786
+ out << ""
787
+
788
+ # llms.txt: minimal markdown index of the tool + resource
789
+ # catalogs. Stable ASCII so even a non-LLM reader can scan it.
790
+ llms_lines = ["# " + server_name, "", "MCP-endpoint: /mcp", "OpenAPI: /openapi.json", ""]
791
+ unless mcp_tools.empty?
792
+ llms_lines << "## Tools"
793
+ llms_lines << ""
794
+ mcp_tools.each do |t|
795
+ llms_lines << "- " + t[:name] + " -- " + t[:description]
796
+ end
797
+ llms_lines << ""
798
+ end
799
+ unless mcp_resources.empty?
800
+ llms_lines << "## Resources"
801
+ llms_lines << ""
802
+ mcp_resources.each do |rdef|
803
+ llms_lines << "- " + rdef[:name] + " -- " + rdef[:description]
804
+ end
805
+ llms_lines << ""
806
+ end
807
+ llms_body = llms_lines.join("\n")
808
+
809
+ out << "class TepMCP_LlmsTxt < Tep::Handler"
810
+ out << " def handle(req, res)"
811
+ out << " res.headers[\"Content-Type\"] = \"text/markdown; charset=utf-8\""
812
+ out << " #{llms_body.inspect}"
813
+ out << " end"
814
+ out << "end"
815
+ out << ""
816
+ end
817
+
818
+ # Registrations.
819
+ out << Tep_public_dir(config[:public_dir]) if config[:public_dir]
820
+ out << %(Tep.before #{filter_classes["before"]}.new) if filter_classes["before"]
821
+ out << %(Tep.after #{filter_classes["after"]}.new) if filter_classes["after"]
822
+ not_founds.each { |n| out << %(Tep.not_found #{n[:cls]}.new) }
823
+ routes.each do |r|
824
+ if r[:regex]
825
+ # Regex routes go through the literal-route DSL with an unused
826
+ # `pattern` arg (the matcher uses re_match? on the handler).
827
+ out << %(Tep.#{r[:verb].downcase} "" , #{r[:cls]}.new)
828
+ else
829
+ # Optional segments `(/:foo)`: expand to the cartesian product
830
+ # of "include each optional or not", registering one route per
831
+ # combination with the same handler class. Bodies share state
832
+ # via the single class; missing optional captures show up as
833
+ # empty strings in `params[...]`.
834
+ paths = expand_optional_segments(r[:path])
835
+ paths.each do |p|
836
+ out << %(Tep.#{r[:verb].downcase} #{p.inspect}, #{r[:cls]}.new)
837
+ end
838
+ end
839
+ end
840
+ websockets.each do |w|
841
+ out << %(Tep.get #{w[:path].inspect}, #{w[:cls]}.new)
842
+ end
843
+ live_views.each do |lv|
844
+ out << %(Tep.get #{lv[:path].inspect}, #{lv[:get_cls]}.new)
845
+ out << %(Tep.get #{lv[:ws_path].inspect}, #{lv[:ws_cls]}.new)
846
+ end
847
+ if !mcp_tools.empty? || !mcp_resources.empty?
848
+ mcp_tools.each do |t|
849
+ out << %(Tep.post "/tools/#{t[:name]}", #{t[:http_cls]}.new)
850
+ end
851
+ mcp_resources.each do |rdef|
852
+ out << %(Tep.get "/resources/#{rdef[:name]}", #{rdef[:http_cls]}.new)
853
+ end
854
+ out << %(Tep.post "/mcp", TepMCP_Dispatcher.new)
855
+ out << %(Tep.get "/llms.txt", TepMCP_LlmsTxt.new)
856
+ out << %(Tep.get "/openapi.json", TepMCP_OpenAPI.new)
857
+ end
858
+ out << ""
859
+
860
+ # `on_start do ... end` body runs once before the accept loop.
861
+ # Inline as plain top-level code so spinel sees it as program-load
862
+ # statements, no class/handler wrapping needed.
863
+ if startup_block
864
+ out << "# --- on_start ---"
865
+ out << rewrite_block(startup_block[:src], force_string: false)
866
+ out << ""
867
+ end
868
+
869
+ # CLI option parsing -- emitted at top level so spinel's codegen
870
+ # actually declares `sp_argv` (it skips it for method-local ARGV
871
+ # references). Every translated app picks up `-p`, `-w`, `-q`.
872
+ scheduled = config[:scheduler] == :scheduled
873
+ out << <<~RB
874
+ __port = #{config[:port] || 4567}
875
+ __workers = #{config[:workers] || 1}
876
+ __quiet = false
877
+ __scheduled = #{scheduled ? 'true' : 'false'}
878
+ __i = 0
879
+ while __i < ARGV.length
880
+ __a = ARGV[__i]
881
+ if __a == "-p" && __i + 1 < ARGV.length
882
+ __port = ARGV[__i + 1].to_i
883
+ __i += 2
884
+ elsif __a == "-w" && __i + 1 < ARGV.length
885
+ __workers = ARGV[__i + 1].to_i
886
+ __i += 2
887
+ elsif __a == "-q"
888
+ __quiet = true
889
+ __i += 1
890
+ elsif __a == "-s"
891
+ # Phase 1.5 opt-in: scheduled fiber-per-connection server
892
+ # (vs the default prefork-blocking-one-conn-per-worker shape).
893
+ __scheduled = true
894
+ __i += 1
895
+ else
896
+ __i += 1
897
+ end
898
+ end
899
+ Tep.run!(__port, __workers, __quiet, __scheduled)
900
+ RB
901
+ out << ""
902
+
903
+ warnings.each { |w| warn "tep: #{w}" }
904
+
905
+ out.join("\n")
906
+ end
907
+
908
+ def Tep_public_dir(path)
909
+ abs = File.expand_path(path, Dir.pwd)
910
+ %(Tep.public_dir #{abs.inspect})
911
+ end
912
+
913
+ def call?(node)
914
+ node.is_a?(Prism::CallNode)
915
+ end
916
+
917
+ # `mcp_tool 'name', "description" do ... end` — collect the tool's
918
+ # input-param declarations + the on_call body. Each `param :sym,
919
+ # Type, "desc"` line inside the block records one parameter (with
920
+ # optional `default:` and `enum:` keyword args, kept verbatim into
921
+ # the JSON Schema we emit). Each `on_call do |kwargs| ... end`
922
+ # block becomes the tool's invocation body.
923
+ # `mcp_resource 'name', "description" do; on_read do ... end; end`
924
+ # (chunk 5.3). Each resource gets a read-only fetch endpoint at
925
+ # `GET /resources/<name>` plus a `resources/list` + `resources/read`
926
+ # dispatch arm via the /mcp dispatcher. The on_read body returns
927
+ # Tep::MCP::ResourceContent (built via Tep::MCP.resource_text). No
928
+ # URI templating + no streaming in this chunk -- both defer.
929
+ def handle_mcp_resource(node, mcp_resources, warnings)
930
+ args = node.arguments&.arguments || []
931
+ block = node.block
932
+ unless block.is_a?(Prism::BlockNode)
933
+ return warnings << "skipped mcp_resource -- needs a `do ... end` block"
934
+ end
935
+ if args.length < 2
936
+ return warnings << "skipped mcp_resource -- needs (\"name\", \"description\", &block)"
937
+ end
938
+ name_node = args[0]
939
+ desc_node = args[1]
940
+ unless name_node.is_a?(Prism::StringNode)
941
+ return warnings << "skipped mcp_resource -- first arg must be a string literal name"
942
+ end
943
+ unless desc_node.is_a?(Prism::StringNode)
944
+ return warnings << "skipped mcp_resource -- second arg must be a string literal description"
945
+ end
946
+
947
+ read_body = nil
948
+ body = block.body
949
+ if body.is_a?(Prism::StatementsNode)
950
+ body.body.each do |stmt|
951
+ next unless call?(stmt) && stmt.receiver.nil?
952
+ cname = stmt.name.to_s
953
+ if cname == "on_read"
954
+ inner = stmt.block
955
+ unless inner.is_a?(Prism::BlockNode)
956
+ warnings << "mcp_resource #{name_node.unescaped}: on_read needs a `do ... end` block"
957
+ next
958
+ end
959
+ read_body = block_source(inner)
960
+ else
961
+ warnings << "mcp_resource #{name_node.unescaped}: ignored unrecognized statement `#{cname}`"
962
+ end
963
+ end
964
+ end
965
+
966
+ if read_body.nil?
967
+ return warnings << "skipped mcp_resource #{name_node.unescaped} -- missing on_read do ... end"
968
+ end
969
+
970
+ mcp_resources << {
971
+ name: name_node.unescaped,
972
+ description: desc_node.unescaped,
973
+ read_body: read_body,
974
+ }
975
+ end
976
+
977
+ def handle_mcp_tool(node, mcp_tools, warnings)
978
+ args = node.arguments&.arguments || []
979
+ block = node.block
980
+ unless block.is_a?(Prism::BlockNode)
981
+ return warnings << "skipped mcp_tool -- needs a `do ... end` block"
982
+ end
983
+ if args.length < 2
984
+ return warnings << "skipped mcp_tool -- needs (\"name\", \"description\", &block)"
985
+ end
986
+ name_node = args[0]
987
+ desc_node = args[1]
988
+ unless name_node.is_a?(Prism::StringNode)
989
+ return warnings << "skipped mcp_tool -- first arg must be a string literal name"
990
+ end
991
+ unless desc_node.is_a?(Prism::StringNode)
992
+ return warnings << "skipped mcp_tool -- second arg must be a string literal description"
993
+ end
994
+
995
+ # Optional caps: [:foo, :bar] keyword (chunk 5.2). Required
996
+ # capabilities checked against req.identity.may?(...) at the
997
+ # top of call_<i>. Missing any -> Tep::MCP.error("missing
998
+ # capability: <name>") returned without running the on_call
999
+ # body.
1000
+ caps = []
1001
+ if args.length >= 3 && args[2].is_a?(Prism::KeywordHashNode)
1002
+ args[2].elements.each do |el|
1003
+ next unless el.is_a?(Prism::AssocNode)
1004
+ k = el.key
1005
+ next unless k.is_a?(Prism::SymbolNode) && k.value.to_s == "caps"
1006
+ v = el.value
1007
+ unless v.is_a?(Prism::ArrayNode)
1008
+ warnings << "mcp_tool #{name_node.unescaped}: caps: must be an array literal of symbols"
1009
+ next
1010
+ end
1011
+ v.elements.each do |sym|
1012
+ unless sym.is_a?(Prism::SymbolNode)
1013
+ warnings << "mcp_tool #{name_node.unescaped}: caps: entry must be a symbol literal"
1014
+ next
1015
+ end
1016
+ caps << sym.value.to_s
1017
+ end
1018
+ end
1019
+ end
1020
+
1021
+ params = []
1022
+ call_body = nil
1023
+ call_kwargs = []
1024
+ body = block.body
1025
+ if body.is_a?(Prism::StatementsNode)
1026
+ body.body.each do |stmt|
1027
+ next unless call?(stmt) && stmt.receiver.nil?
1028
+ cname = stmt.name.to_s
1029
+ if cname == "param"
1030
+ pargs = stmt.arguments&.arguments || []
1031
+ if pargs.length < 3
1032
+ warnings << "mcp_tool #{name_node.unescaped}: skipped param -- needs (:sym, Type, \"desc\", default:, enum:)"
1033
+ next
1034
+ end
1035
+ unless pargs[0].is_a?(Prism::SymbolNode)
1036
+ warnings << "mcp_tool #{name_node.unescaped}: param name must be a symbol"
1037
+ next
1038
+ end
1039
+ unless pargs[1].is_a?(Prism::ConstantReadNode)
1040
+ warnings << "mcp_tool #{name_node.unescaped}: param type must be a bare constant (String/Integer/Float)"
1041
+ next
1042
+ end
1043
+ unless pargs[2].is_a?(Prism::StringNode)
1044
+ warnings << "mcp_tool #{name_node.unescaped}: param description must be a string literal"
1045
+ next
1046
+ end
1047
+ type_name = pargs[1].name.to_s
1048
+ unless %w[String Integer Float].include?(type_name)
1049
+ warnings << "mcp_tool #{name_node.unescaped}: param type #{type_name} not supported (use String / Integer / Float)"
1050
+ next
1051
+ end
1052
+ # Optional `default:` keyword (chunk 5.1 only honors default
1053
+ # for input-schema annotation; the on_call body still needs
1054
+ # to handle missing values defensively via the zero default).
1055
+ params << {
1056
+ name: pargs[0].value.to_s,
1057
+ type: type_name,
1058
+ desc: pargs[2].unescaped,
1059
+ }
1060
+ elsif cname == "on_call"
1061
+ inner = stmt.block
1062
+ unless inner.is_a?(Prism::BlockNode)
1063
+ warnings << "mcp_tool #{name_node.unescaped}: on_call needs a `do |kwargs| ... end` block"
1064
+ next
1065
+ end
1066
+ call_body = block_source(inner)
1067
+ # Capture the keyword-param names from the block signature
1068
+ # so the generated cmeth can pass them by-keyword to the
1069
+ # rewritten body. v1 supports keyword params only.
1070
+ ibp = inner.parameters
1071
+ if ibp.is_a?(Prism::BlockParametersNode)
1072
+ ipp = ibp.parameters
1073
+ if ipp.is_a?(Prism::ParametersNode)
1074
+ ipp.keywords.each do |kw|
1075
+ call_kwargs << kw.name.to_s
1076
+ end
1077
+ end
1078
+ end
1079
+ else
1080
+ warnings << "mcp_tool #{name_node.unescaped}: ignored unrecognized statement `#{cname}`"
1081
+ end
1082
+ end
1083
+ end
1084
+
1085
+ if call_body.nil?
1086
+ return warnings << "skipped mcp_tool #{name_node.unescaped} -- missing on_call do ... end"
1087
+ end
1088
+
1089
+ mcp_tools << {
1090
+ name: name_node.unescaped,
1091
+ description: desc_node.unescaped,
1092
+ caps: caps,
1093
+ params: params,
1094
+ call_kwargs: call_kwargs,
1095
+ call_body: call_body,
1096
+ }
1097
+ end
1098
+
1099
+ def handle_tep_live(node, live_views, warnings)
1100
+ args = node.arguments&.arguments || []
1101
+ if args.length < 2
1102
+ return warnings << "skipped Tep.live -- needs (\"/path\", ViewClass)"
1103
+ end
1104
+ path = args[0]
1105
+ view = args[1]
1106
+ unless path.is_a?(Prism::StringNode)
1107
+ return warnings << "skipped Tep.live -- first arg must be a string literal path"
1108
+ end
1109
+ unless view.is_a?(Prism::ConstantReadNode) || view.is_a?(Prism::ConstantPathNode)
1110
+ return warnings << "skipped Tep.live -- second arg must be a class constant"
1111
+ end
1112
+ live_views << {
1113
+ path: path.unescaped,
1114
+ view_class: view.slice,
1115
+ }
1116
+ end
1117
+
1118
+ # Recursively inline an app-local `require_relative` target. Reads the
1119
+ # file; for each `require_relative` IT contains, resolves the path
1120
+ # relative to THAT file's own directory and inlines it too. This is what
1121
+ # lets `require_relative "vendor/spinel/deps"` work: bundler-spinel's
1122
+ # generated deps.rb is itself a list of `require_relative`s (one per
1123
+ # vendored gem, in lock order), so a single app-level require pulls in
1124
+ # the whole dependency set. `seen` (keyed by absolute path) dedupes
1125
+ # shared deps and breaks cycles. Returns the spliced source; a missing
1126
+ # target becomes a dropped-marker comment + a warning. Plain `require`
1127
+ # lines are left verbatim -- spinel drops them (no load path), which is
1128
+ # correct for a self-contained gem; a gem that needs an unvendored
1129
+ # require won't link, same as today.
1130
+ def inline_require_relative_tree(abs_path, seen, warnings)
1131
+ return "" if seen[abs_path]
1132
+ seen[abs_path] = true
1133
+ dir = File.dirname(abs_path)
1134
+ out = []
1135
+ File.read(abs_path).each_line do |line|
1136
+ m = line.match(/\A\s*require_relative\s+["']([^"']+)["']\s*\z/)
1137
+ if m
1138
+ target = m[1]
1139
+ cand = File.expand_path(target.end_with?(".rb") ? target : target + ".rb", dir)
1140
+ if File.file?(cand)
1141
+ out << "# --- inlined require_relative #{target.inspect} (#{File.basename(cand)}) ---"
1142
+ out << inline_require_relative_tree(cand, seen, warnings)
1143
+ else
1144
+ out << "# (require_relative #{target.inspect} -- not found at #{cand}, dropped)"
1145
+ warnings << "require_relative #{target.inspect} (from #{File.basename(abs_path)}) not found at #{cand}; dropped"
1146
+ end
1147
+ else
1148
+ out << line.rstrip
1149
+ end
1150
+ end
1151
+ out.join("\n")
1152
+ end
1153
+
1154
+ def handle_top_call(node, routes, websockets, mcp_tools, mcp_resources, filters, not_founds, config, warnings, passthrough, input_path = nil, inlined_seen = nil)
1155
+ inlined_seen ||= {}
1156
+ name = node.name.to_s
1157
+
1158
+ if name == "mcp_tool"
1159
+ handle_mcp_tool(node, mcp_tools, warnings)
1160
+ return
1161
+ end
1162
+
1163
+ if name == "mcp_resource"
1164
+ handle_mcp_resource(node, mcp_resources, warnings)
1165
+ return
1166
+ end
1167
+
1168
+ if name == "websocket"
1169
+ args = node.arguments&.arguments || []
1170
+ block = node.block
1171
+ first = args.first
1172
+ unless block.is_a?(Prism::BlockNode)
1173
+ return warnings << "skipped websocket -- needs a `do |ws| ... end` block"
1174
+ end
1175
+ unless first.is_a?(Prism::StringNode)
1176
+ return warnings << "skipped websocket -- first arg must be a string literal path"
1177
+ end
1178
+
1179
+ # Extract the block parameter name (`do |ws|` -> "ws"). Used to
1180
+ # let the user's on_X bodies reference the driver by their chosen
1181
+ # name -- we bind `<name> = @ws` at the top of every handler.
1182
+ ws_param = "ws"
1183
+ bparams = block.parameters
1184
+ if bparams.is_a?(Prism::BlockParametersNode)
1185
+ pp = bparams.parameters
1186
+ if pp.is_a?(Prism::ParametersNode) && pp.requireds.first.is_a?(Prism::RequiredParameterNode)
1187
+ ws_param = pp.requireds.first.name.to_s
1188
+ end
1189
+ end
1190
+
1191
+ # Walk the block body looking for `on_<event> do |evt| ... end`
1192
+ # calls; collect each event's block body as its own callback class.
1193
+ events = {}
1194
+ body = block.body
1195
+ if body.is_a?(Prism::StatementsNode)
1196
+ body.body.each do |stmt|
1197
+ next unless call?(stmt) && stmt.receiver.nil?
1198
+ ev = stmt.name.to_s
1199
+ unless WS_EVENTS.include?(ev)
1200
+ warnings << "websocket #{first.unescaped}: unrecognised event `#{ev}` -- ignored"
1201
+ next
1202
+ end
1203
+ inner = stmt.block
1204
+ unless inner.is_a?(Prism::BlockNode)
1205
+ warnings << "websocket #{first.unescaped}: `#{ev}` needs a `do |evt| ... end` block"
1206
+ next
1207
+ end
1208
+ # Capture the event-block param name too, so a user who
1209
+ # writes `|message|` instead of `|evt|` keeps their name.
1210
+ evt_param = "evt"
1211
+ ibp = inner.parameters
1212
+ if ibp.is_a?(Prism::BlockParametersNode)
1213
+ ipp = ibp.parameters
1214
+ if ipp.is_a?(Prism::ParametersNode) && ipp.requireds.first.is_a?(Prism::RequiredParameterNode)
1215
+ evt_param = ipp.requireds.first.name.to_s
1216
+ end
1217
+ end
1218
+ events[ev] = { src: block_source(inner), evt_param: evt_param }
1219
+ end
1220
+ end
1221
+
1222
+ websockets << {
1223
+ path: first.unescaped,
1224
+ ws_param: ws_param,
1225
+ events: events,
1226
+ }
1227
+ return
1228
+ end
1229
+
1230
+ if DSL_VERBS.include?(name)
1231
+ args = node.arguments&.arguments || []
1232
+ block = node.block
1233
+ first = args.first
1234
+ return warnings << "skipped #{name} -- needs a `do ... end` block" unless block.is_a?(Prism::BlockNode)
1235
+ if first.is_a?(Prism::StringNode)
1236
+ routes << {
1237
+ verb: name.upcase,
1238
+ path: first.unescaped,
1239
+ block_src: block_source(block),
1240
+ }
1241
+ return
1242
+ end
1243
+ if first.is_a?(Prism::RegularExpressionNode)
1244
+ routes << {
1245
+ verb: name.upcase,
1246
+ regex: first.unescaped,
1247
+ block_src: block_source(block),
1248
+ }
1249
+ return
1250
+ end
1251
+ return warnings << "skipped #{name} -- first arg must be a string or regex literal"
1252
+ end
1253
+
1254
+ if HOOK_NAMES.include?(name)
1255
+ block = node.block
1256
+ return warnings << "skipped #{name} -- needs a `do ... end` block" unless block.is_a?(Prism::BlockNode)
1257
+ if name == "not_found"
1258
+ not_founds << { block_src: block_source(block) }
1259
+ else
1260
+ filters[name] << { block_src: block_source(block) }
1261
+ end
1262
+ return
1263
+ end
1264
+
1265
+ if name == "on_start"
1266
+ block = node.block
1267
+ return warnings << "skipped on_start -- needs a `do ... end` block" unless block.is_a?(Prism::BlockNode)
1268
+ return { kind: :startup, src: block_source(block) }
1269
+ end
1270
+
1271
+ if name == "on_stop"
1272
+ # tep doesn't have a graceful shutdown path -- the server runs
1273
+ # until SIGINT/SIGTERM ends the process. Honour the spirit by
1274
+ # warning loudly; the block content is dropped.
1275
+ warnings << "on_stop -- tep has no graceful shutdown path; block ignored"
1276
+ return
1277
+ end
1278
+
1279
+ if name == "configure"
1280
+ block = node.block
1281
+ return warnings << "skipped configure -- needs a `do ... end` block" unless block.is_a?(Prism::BlockNode)
1282
+ body = block_source(block)
1283
+ args = node.arguments&.arguments || []
1284
+ envs = args.map { |a| a.is_a?(Prism::SymbolNode) ? a.unescaped : nil }.compact
1285
+ if envs.empty?
1286
+ passthrough << "# --- configure ---"
1287
+ passthrough << body
1288
+ else
1289
+ # spinel doesn't have ENV.fetch with a default; ENV[k] returns
1290
+ # nil for missing keys, but we'd then `.include?(nil)` which
1291
+ # works (returns false). Default env "development" is matched
1292
+ # only when TEP_ENV is unset; users who want dev-specific
1293
+ # `configure :development` should set TEP_ENV explicitly.
1294
+ env_lits = envs.map(&:inspect).join(", ")
1295
+ passthrough << "# --- configure :#{envs.join(', :')} ---"
1296
+ passthrough << "_tep_env = ENV[\"TEP_ENV\"]"
1297
+ passthrough << "_tep_env = \"development\" if _tep_env.nil? || _tep_env.length == 0"
1298
+ passthrough << "if [#{env_lits}].include?(_tep_env)"
1299
+ passthrough << body
1300
+ passthrough << "end"
1301
+ end
1302
+ return
1303
+ end
1304
+
1305
+ if name == "erb"
1306
+ # Track which views are referenced; the translator compiles the
1307
+ # corresponding .erb files into top-level Ruby methods. A bare
1308
+ # top-level `erb :name` is unusual but harmless.
1309
+ args = node.arguments&.arguments || []
1310
+ if args.first.is_a?(Prism::SymbolNode)
1311
+ # handled below in the rewrite_block textual pass; no-op here
1312
+ end
1313
+ return
1314
+ end
1315
+
1316
+ if name == "set"
1317
+ args = node.arguments&.arguments || []
1318
+ if args.length == 2 && args[0].is_a?(Prism::SymbolNode)
1319
+ key = args[0].unescaped
1320
+ val = literal_string(args[1])
1321
+ case key
1322
+ when "public_dir", "public_folder", "public"
1323
+ config[:public_dir] = val if val
1324
+ when "views"
1325
+ config[:views] = val if val
1326
+ when "port"
1327
+ config[:port] = literal_int(args[1]) || config[:port]
1328
+ when "workers"
1329
+ # Default worker count; the runtime CLI's `-w N` still
1330
+ # overrides. Useful for apps whose handlers can block --
1331
+ # SSE streamers, long-poll endpoints -- to default to
1332
+ # something more useful than 1 and keep the homepage
1333
+ # responsive while one worker is held by a stream.
1334
+ config[:workers] = literal_int(args[1]) || config[:workers]
1335
+ when "bind"
1336
+ # silently accept; tep always binds 0.0.0.0
1337
+ when "scheduler"
1338
+ # `set :scheduler, :scheduled` opts the app into the fiber-
1339
+ # per-connection server (Tep::Server::Scheduled). Default
1340
+ # stays the prefork-blocking Tep::Server until the next major
1341
+ # release, when this flips and Blocking is deleted. The CLI
1342
+ # `-s` flag overrides the same way `-w` overrides `:workers`.
1343
+ sym = args[1].is_a?(Prism::SymbolNode) ? args[1].unescaped : nil
1344
+ if sym == "scheduled"
1345
+ config[:scheduler] = :scheduled
1346
+ elsif sym == "blocking" || sym.nil?
1347
+ # default; explicit blocking is a no-op
1348
+ else
1349
+ warnings << "unsupported `set :scheduler, :#{sym}` -- choose :scheduled or :blocking"
1350
+ end
1351
+ else
1352
+ warnings << "unsupported `set :#{key}` -- ignored"
1353
+ end
1354
+ end
1355
+ return
1356
+ end
1357
+
1358
+ case name
1359
+ when "require", "require_relative"
1360
+ # tep's own library is inlined wholesale (see inlined_tep_library), so
1361
+ # `require_relative "tep"` / "tep/..." is a no-op. A plain `require
1362
+ # "gem"` (by name) is dropped -- spinel has no gem load path. But an
1363
+ # app-local `require_relative "vendor/foo"` IS honoured: we read the
1364
+ # target now and splice its source straight into the program, exactly
1365
+ # as we do for tep's library. spinel can't be relied on to resolve
1366
+ # require_relative at build time, but build-time inlining is reliable
1367
+ # -- and it lets a tep app vendor and use a real Ruby gem (e.g.
1368
+ # examples/geohash/vendor/pr_geohash.rb). Only genuinely-missing files
1369
+ # warn, so a typo still surfaces early instead of fifty spinel
1370
+ # "uninitialized constant" lines later.
1371
+ if name == "require_relative"
1372
+ args = node.arguments&.arguments || []
1373
+ first = args.first
1374
+ if first.is_a?(Prism::StringNode)
1375
+ path = first.unescaped
1376
+ base = input_path ? File.dirname(input_path) : Dir.pwd
1377
+ cand = File.expand_path(path.end_with?(".rb") ? path : path + ".rb", base)
1378
+ # tep's own library is already inlined wholesale at the top
1379
+ # (inlined_tep_library), so requiring it here must be a no-op --
1380
+ # otherwise we inline the whole library a SECOND time. Beyond the
1381
+ # by-name forms ("tep" / "tep/foo"), an example loads it by PATH
1382
+ # (`require_relative "../lib/tep"`); since the app-local inliner
1383
+ # became recursive (#166) that path form would re-pull every
1384
+ # tep/*.rb, duplicating `module Pg` + its ffi_const into a C
1385
+ # `redefinition` error. Resolve and compare against LIB_DIR so all
1386
+ # spellings that land inside tep's own lib are skipped.
1387
+ is_tep_lib = path == "tep" || path.start_with?("tep/") ||
1388
+ cand == File.join(LIB_DIR, "tep.rb") ||
1389
+ cand.start_with?(LIB_DIR + File::SEPARATOR)
1390
+ if !is_tep_lib
1391
+ if File.file?(cand)
1392
+ passthrough << "# --- inlined require_relative #{path.inspect} (#{File.basename(cand)}) ---"
1393
+ passthrough << inline_require_relative_tree(cand, inlined_seen, warnings)
1394
+ else
1395
+ warnings << "external `require_relative #{path.inspect}` ignored " \
1396
+ "(no file at #{cand}). For a published gem, declare it in " \
1397
+ "a Gemfile and run `spinel-compat vendor` (bundler-spinel), " \
1398
+ "then require_relative \"vendor/spinel/deps\"."
1399
+ end
1400
+ end
1401
+ end
1402
+ end
1403
+ return
1404
+ when "enable", "disable" then return # ignore (sessions, etc.)
1405
+ when "configure" then return # we run as a single env
1406
+ when "use"
1407
+ warnings << "unsupported `use` (Rack middleware) -- ignored"
1408
+ return
1409
+ when "helpers"
1410
+ warnings << "unsupported `helpers do ... end` -- ignored"
1411
+ return
1412
+ end
1413
+
1414
+ # Unknown top-level call -- skip with a warning.
1415
+ warnings << "unrecognised top-level call `#{name}` -- ignored"
1416
+ end
1417
+
1418
+ def block_source(block_node)
1419
+ body = block_node.body
1420
+ return "" if body.nil?
1421
+ src = body.location.slice
1422
+ src
1423
+ end
1424
+
1425
+ # ---- Tep::Proxy block-DSL lowering (#88) ----
1426
+
1427
+ # Maps a proxy block-DSL method name to the Tep::Proxy imeth the
1428
+ # generated subclass overrides. `before`/`after` rename to
1429
+ # `before_forward`/`after_forward` (the runtime hook names -- bare
1430
+ # before/after collide with Filter/Security/Auth under spinel's
1431
+ # same-name dispatch); the rest map 1:1.
1432
+ PROXY_HOOK_IMETH = {
1433
+ "before" => "before_forward",
1434
+ "after" => "after_forward",
1435
+ "on_stream_chunk" => "on_stream_chunk",
1436
+ "on_stream_end" => "on_stream_end",
1437
+ "stream_request?" => "stream_request?",
1438
+ # 6.4: per-request upstream picker. `pick_upstream do |req| ... end`
1439
+ # lowers to a subclass override; default behavior (return @upstream)
1440
+ # is preserved when the block isn't supplied.
1441
+ "pick_upstream" => "pick_upstream",
1442
+ # 6.5: per-request retry policy. `retry_policy do |req| ... end`
1443
+ # lowers to a subclass override returning a Tep::Proxy::RetryPolicy.
1444
+ # Default (no override) returns a no-retry policy.
1445
+ "retry_policy" => "retry_policy",
1446
+ }.freeze
1447
+
1448
+ # Route verbs a proxy var can be mounted on (`Tep.<verb> "p", api`).
1449
+ PROXY_MOUNT_VERBS = %w[get post put patch delete head].freeze
1450
+
1451
+ # True for a `Tep::Proxy.new(...)` / `Proxy.new(...)` call node.
1452
+ def proxy_new_call?(node)
1453
+ return false unless node.is_a?(Prism::CallNode) && node.name == :new
1454
+ recv = node.receiver
1455
+ return false if recv.nil?
1456
+ slice = recv.location.slice
1457
+ slice == "Tep::Proxy" || slice == "Proxy"
1458
+ end
1459
+
1460
+ # Extract the upstream argument source from a `Tep::Proxy.new(...)`
1461
+ # call, normalising the doc's keyword form (`upstream: "..."`) to the
1462
+ # positional arg the runtime initialize(upstream) takes. Returns the
1463
+ # raw source slice of the value expression.
1464
+ def proxy_upstream_src(node, warnings)
1465
+ args = node.arguments&.arguments || []
1466
+ if args.empty?
1467
+ warnings << "Tep::Proxy.new needs an upstream argument; emitting empty"
1468
+ return '""'
1469
+ end
1470
+ first = args.first
1471
+ if first.is_a?(Prism::KeywordHashNode)
1472
+ first.elements.each do |el|
1473
+ next unless el.is_a?(Prism::AssocNode)
1474
+ k = el.key
1475
+ kn = k.is_a?(Prism::SymbolNode) ? k.unescaped : nil
1476
+ return el.value.location.slice if kn == "upstream"
1477
+ end
1478
+ warnings << "Tep::Proxy.new: no `upstream:` key found; emitting empty"
1479
+ return '""'
1480
+ end
1481
+ first.location.slice
1482
+ end
1483
+
1484
+ # Comma-joined block parameter names ("req, res, ureq"). The generated
1485
+ # imeth uses the user's chosen names verbatim, so their block body
1486
+ # references resolve. Empty string for a paramless block.
1487
+ def block_param_list(block_node)
1488
+ bp = block_node.parameters
1489
+ return "" unless bp.is_a?(Prism::BlockParametersNode)
1490
+ pp = bp.parameters
1491
+ return "" unless pp.is_a?(Prism::ParametersNode)
1492
+ pp.requireds.map { |r| r.is_a?(Prism::RequiredParameterNode) ? r.name.to_s : nil }.compact.join(", ")
1493
+ end
1494
+
1495
+ def literal_string(node)
1496
+ case node
1497
+ when Prism::StringNode then node.unescaped
1498
+ when Prism::SymbolNode then node.unescaped
1499
+ else nil
1500
+ end
1501
+ end
1502
+
1503
+ def literal_int(node)
1504
+ return node.value if node.is_a?(Prism::IntegerNode)
1505
+ nil
1506
+ end
1507
+
1508
+ # ---- block body rewrites ----
1509
+ #
1510
+ # We do simple, conservative textual substitutions. Anything more
1511
+ # elaborate (full AST rewriting) is out of scope -- the user can
1512
+ # fall back to writing explicit `Tep::Handler` subclasses.
1513
+ def rewrite_block(src, force_string: true)
1514
+ s = src.dup
1515
+
1516
+ # `@name = expr` (Sinatra-style instance variables) -> store on the
1517
+ # per-request ivars bag. The string-coerce wrap (`(...).to_s`) keeps
1518
+ # the str_hash uniform-typed even when handlers stash an int / bool
1519
+ # in `@count` / `@flag` for a template to render. Excluded: `==`,
1520
+ # `=>` (which can't appear at this position anyway), `=~`. The
1521
+ # write rewrite runs before the bare `@name` read rewrite so the
1522
+ # LHS `@x` doesn't get caught by the read pass.
1523
+ #
1524
+ # Also: spinel doesn't support multi-statement single-line ivar
1525
+ # assignments (`@a=1; @b=2`); the `(.+)$` capture is line-greedy.
1526
+ # Users who need that should split across lines, which is the more
1527
+ # idiomatic style anyway.
1528
+ s = s.gsub(/^(\s*)@(\w+)\s*=(?![=~])\s*(.+)$/) do
1529
+ %{#{$1}req.ivars["#{$2}"] = (#{$3}).to_s}
1530
+ end
1531
+
1532
+ # Bare `@name` read (anywhere) -> `req.ivars["name"]`. Negative
1533
+ # lookbehind avoids `@@x` (class var) and the suffix of identifier-
1534
+ # like preceding text.
1535
+ s = rewrite_ivar_reads(s, sink: "req.ivars")
1536
+
1537
+ # `redirect 'url', code?` (line)
1538
+ s = s.gsub(/^(\s*)redirect\s+(['"][^'"]*['"])(?:\s*,\s*(\d+))?/) do
1539
+ indent = $1
1540
+ url = $2
1541
+ code = $3 || "302"
1542
+ "#{indent}res.set_status(#{code})\n#{indent}res.headers[\"Location\"] = #{url}\n#{indent}return \"\""
1543
+ end
1544
+
1545
+ # `halt N, 'msg'` -> set status, return string
1546
+ s = s.gsub(/^(\s*)halt\s+(\d+)\s*,\s*(['"][^'"]*['"])/) do
1547
+ "#{$1}res.set_status(#{$2})\n#{$1}return #{$3}"
1548
+ end
1549
+ s = s.gsub(/^(\s*)halt\s+(\d+)$/) do
1550
+ "#{$1}res.set_status(#{$2})\n#{$1}return \"\""
1551
+ end
1552
+
1553
+ # `content_type 'foo'`
1554
+ s = s.gsub(/^(\s*)content_type\s+(['"][^'"]*['"])/) do
1555
+ "#{$1}res.headers[\"Content-Type\"] = #{$2}"
1556
+ end
1557
+
1558
+ # `status N`
1559
+ s = s.gsub(/^(\s*)status\s+(\d+)/) do
1560
+ "#{$1}res.set_status(#{$2})"
1561
+ end
1562
+
1563
+ # `headers["X"] = "y"` -> `res.headers[...]` when not already qualified
1564
+ s = s.gsub(/(?<![\w.])headers\[/, "res.headers[")
1565
+
1566
+ # `cookies["k"]` -> `req.cookies["k"]` when not already qualified
1567
+ s = s.gsub(/(?<![\w.])cookies\[/, "req.cookies[")
1568
+
1569
+ # `erb :name` -> `tep_view_name(Tep.str_hash, req.ivars)`
1570
+ # `erb :name, locals: {a: "b", c: "d"}` -> build a String=>String
1571
+ # hash from the literal-only kwarg form, then call. The second
1572
+ # arg threads the per-request ivars bag so templates can read
1573
+ # `<%= @name %>` style references the handler set with `@name = v`.
1574
+ s = s.gsub(/(?<![\w.])erb\s+:(\w+)\s*,\s*locals:\s*\{([^}]*)\}/) do
1575
+ view = $1
1576
+ # Value runs up to the next comma or closing brace; quoted strings
1577
+ # are handled separately so they can contain commas or spaces.
1578
+ pairs = $2.scan(/(\w+):\s*("[^"]*"|'[^']*'|[^,}]+?)\s*(?=,|\z)/)
1579
+ setters = pairs.map { |k, v| %{__l["#{k}"] = (#{v.strip}).to_s} }.join("; ")
1580
+ "(__l = Tep.str_hash; #{setters}; tep_view_#{view}(__l, req.ivars))"
1581
+ end
1582
+ s = s.gsub(/(?<![\w.])erb\s+:(\w+)/, 'tep_view_\1(Tep.str_hash, req.ivars)')
1583
+
1584
+ # Same shape for `mustache :name` and `mustache :name, locals: {...}`.
1585
+ s = s.gsub(/(?<![\w.])mustache\s+:(\w+)\s*,\s*locals:\s*\{([^}]*)\}/) do
1586
+ view = $1
1587
+ pairs = $2.scan(/(\w+):\s*("[^"]*"|'[^']*'|[^,}]+?)\s*(?=,|\z)/)
1588
+ setters = pairs.map { |k, v| %{__l["#{k}"] = (#{v.strip}).to_s} }.join("; ")
1589
+ "(__l = Tep.str_hash; #{setters}; tep_mustache_#{view}(__l, req.ivars))"
1590
+ end
1591
+ s = s.gsub(/(?<![\w.])mustache\s+:(\w+)/, 'tep_mustache_\1(Tep.str_hash, req.ivars)')
1592
+
1593
+ # `pass` -> request a skip to the next matching route. The
1594
+ # framework's dispatch loop watches `req.passed`. Both the bare
1595
+ # form and `pass if cond` (modifier-if) are translated.
1596
+ s = s.gsub(/^(\s*)pass\s+if\s+(.+)$/) do
1597
+ "#{$1}if (#{$2})\n#{$1} req.set_passed\n#{$1} return \"\"\n#{$1}end"
1598
+ end
1599
+ s = s.gsub(/^(\s*)pass\s*$/) do
1600
+ "#{$1}req.set_passed\n#{$1}return \"\""
1601
+ end
1602
+
1603
+ # `send_file 'path'` -> `res.send_file 'path'`; reuses the static-dir
1604
+ # write path. The framework streams the file's bytes after the
1605
+ # headers go out, so the handler doesn't need to read it itself.
1606
+ s = s.gsub(/^(\s*)send_file\s+(['"][^'"]*['"])/) do
1607
+ "#{$1}res.send_file(#{$2})\n#{$1}return \"\""
1608
+ end
1609
+
1610
+ # `stream X.new` -> `res.start_stream(X.new); return ""` -- ships the
1611
+ # subclass-style streaming DSL. The Sinatra `stream do |out| ... end`
1612
+ # block form isn't supported yet (closures aren't first-class in
1613
+ # Spinel; you'd write a `class X < Tep::Streamer; def pump(out); ...`
1614
+ # instead and pass `X.new` to `stream`).
1615
+ s = s.gsub(/^(\s*)stream\s+(.+)$/) do
1616
+ indent = $1
1617
+ expr = $2
1618
+ "#{indent}res.start_stream(#{expr})\n#{indent}return \"\""
1619
+ end
1620
+
1621
+ # `session[k] = v` and `session[k]` -> .set / .get on req.session.
1622
+ # Spinel doesn't dispatch user-defined []= on user classes, and
1623
+ # without the .set() form the dirty flag never trips.
1624
+ s = s.gsub(/(?<![\w.])session\[:(\w+)\]\s*=\s*([^\n]+?)$/m,
1625
+ 'req.session.set("\1", \2)')
1626
+ s = s.gsub(/(?<![\w.])session\[(['"])(\w+)\1\]\s*=\s*([^\n]+?)$/m,
1627
+ 'req.session.set("\2", \3)')
1628
+ s = s.gsub(/(?<![\w.])session\[:(\w+)\]/, 'req.session.get("\1")')
1629
+ s = s.gsub(/(?<![\w.])session\[(['"])(\w+)\1\]/, 'req.session.get("\2")')
1630
+
1631
+ # `set_cookie "name", "value"` -> `res.set_cookie("name", "value", {})`
1632
+ # Two-arg form only; richer kwarg forms aren't translated.
1633
+ s = s.gsub(/^(\s*)set_cookie\s+(['"][^'"]*['"])\s*,\s*(['"][^'"]*['"])/) do
1634
+ "#{$1}res.set_cookie(#{$2}, #{$3}, Tep.str_hash)"
1635
+ end
1636
+
1637
+ # `params['x']` / `params["x"]` and bare `params`
1638
+ s = s.gsub(/(?<![\w.])params(?=[\[\.]|$|\s)/, "req.params")
1639
+
1640
+ # `request.body.read` -> `req.body` (a Sinatra app's request.body
1641
+ # is an IO and apps commonly call `.read` on it; tep's req.body
1642
+ # is already a String, so `.read` is a no-op). Run BEFORE the bare
1643
+ # `request -> req` rewrite so we can pin the `.body.read` suffix
1644
+ # exactly and not have to deal with the intermediate `req.body.read`
1645
+ # state. The bare `request.body` form (no `.read`) is handled by
1646
+ # the next rewrite, which leaves it as `req.body` -- already a
1647
+ # String via Tep::Request#body's alias to @raw_body.
1648
+ s = s.gsub(/(?<![\w.])request\.body\.read\b/, "req.body")
1649
+
1650
+ # `request` -> req, `response` -> res, when used as bare identifiers
1651
+ s = s.gsub(/(?<![\w.])request(?=[\.\[\s,)]|$)/, "req")
1652
+ s = s.gsub(/(?<![\w.])response(?=[\.\[\s,)]|$)/, "res")
1653
+
1654
+ # Symbol keys -- Sinatra accepts both `params[:name]` and `params["name"]`.
1655
+ # Spinel doesn't poly-convert symbols to strings inside Hash[]; rewrite
1656
+ # to string keys.
1657
+ s = s.gsub(/(req\.params)\[:(\w+)\]/, '\1["\2"]')
1658
+
1659
+ # `"#{params['x']}"` interpolation works fine after the params rewrite.
1660
+
1661
+ # String-coerce the last expression of the block body. Spinel's
1662
+ # whole-program type inference unifies handler return types across
1663
+ # all subclasses; if any one subclass returns `Hash#[]` directly
1664
+ # (poly value), the union widens and the eventual `iv_body` read
1665
+ # in the worker miscompiles. Wrapping the last expression in
1666
+ # `"" + (...)` forces it to a String at the source level.
1667
+ if force_string
1668
+ lines = s.lines
1669
+ last_idx = lines.rindex { |l| !l.strip.empty? }
1670
+ if last_idx
1671
+ last = lines[last_idx]
1672
+ stripped = last.lstrip
1673
+ indent = last[0, last.length - stripped.length]
1674
+ # Skip if the last line is an explicit non-string control flow:
1675
+ # bare `end`/`else`/`when` (handled by the surrounding block),
1676
+ # or an explicit `return`/`raise`/`break`/`next`. Setter calls
1677
+ # like `res.headers[...]=...` are also skipped because the
1678
+ # assignment's value (the RHS) is captured but `"" + setter` is
1679
+ # ill-typed.
1680
+ unless stripped =~ /\A(end|else|elsif|when|return|raise|break|next)\b/ ||
1681
+ stripped =~ /\A(res|response)\.[a-zA-Z_]+\s*=|\A(res|response)\.\w+\s*\(/
1682
+ body_expr = stripped.sub(/\s*(#.*)?\Z/, "")
1683
+ comment = stripped[body_expr.length..-1] || ""
1684
+ lines[last_idx] = "#{indent}\"\" + (#{body_expr.rstrip})#{comment.rstrip.empty? ? "\n" : " #{comment}"}"
1685
+ end
1686
+ end
1687
+ s = lines.join
1688
+ end
1689
+
1690
+ s
1691
+ end
1692
+
1693
+ def indent(text, n)
1694
+ pad = " " * n
1695
+ text.lines.map { |l| l.strip.empty? ? l.rstrip : pad + l.rstrip }.join("\n")
1696
+ end
1697
+
1698
+ def relative_path(from_dir, target)
1699
+ Pathname.new(File.expand_path(target))
1700
+ .relative_path_from(Pathname.new(File.expand_path(from_dir))).to_s
1701
+ end
1702
+
1703
+ # Compile an ERB template to a Ruby method body. The method takes
1704
+ # two String=>String Hash arguments: `locals` (passed by the
1705
+ # handler's explicit `erb :v, locals: {...}` form) and `ivars`
1706
+ # (the per-request bag carrying `@name = ...` Sinatra-style ivars
1707
+ # from handler / before-filter scope). Inside the template:
1708
+ #
1709
+ # <%= locals["k"] %> # explicit locals
1710
+ # <%= @name %> # rewrites to `ivars["name"]`
1711
+ # <% if/else/end %> # control flow, also gets ivar rewrite
1712
+ # <%# ... %> # comment, dropped
1713
+ #
1714
+ # Spinel can't `eval`, so all ERB rendering is build-time AOT --
1715
+ # this is what makes `erb :name` work on a spinel-compiled binary.
1716
+ def erb_to_ruby_body(template)
1717
+ parts = []
1718
+ parts << 'out = ""'
1719
+ i = 0
1720
+ literal = String.new
1721
+ flush = -> {
1722
+ if !literal.empty?
1723
+ parts << "out += #{literal.dump}"
1724
+ literal = String.new
1725
+ end
1726
+ }
1727
+ while i < template.length
1728
+ open_at = template.index("<%", i)
1729
+ if open_at.nil?
1730
+ literal << template[i..-1]
1731
+ break
1732
+ end
1733
+ literal << template[i...open_at]
1734
+ close_at = template.index("%>", open_at + 2)
1735
+ raise "unterminated <% in template" if close_at.nil?
1736
+ flush.call
1737
+ code = template[(open_at + 2)...close_at]
1738
+ if code.start_with?("=")
1739
+ expr = rewrite_ivar_reads(code[1..-1].strip)
1740
+ parts << "out += (#{expr}).to_s"
1741
+ elsif code.start_with?("#")
1742
+ # comment; ignore
1743
+ else
1744
+ parts << rewrite_ivar_reads(code.strip)
1745
+ end
1746
+ i = close_at + 2
1747
+ end
1748
+ flush.call
1749
+ parts << "out"
1750
+ parts.join("\n ")
1751
+ end
1752
+
1753
+ # Compile a Mustache template (documented subset) to a Ruby method
1754
+ # body. Same `(locals, ivars)` signature as ERB views; the call site
1755
+ # rewrites are parallel.
1756
+ #
1757
+ # Supported tags
1758
+ # --------------
1759
+ #
1760
+ # {{name}} escaped string interpolation
1761
+ # -> out += Tep.h(locals["name"])
1762
+ # {{{name}}} raw / unescaped interpolation
1763
+ # -> out += locals["name"]
1764
+ # {{& name}} raw alias (Mustache spec)
1765
+ # -> same as {{{name}}}
1766
+ # {{@name}} ivar form, escaped
1767
+ # -> out += Tep.h(ivars["name"])
1768
+ # {{{@name}}} ivar form, raw
1769
+ # -> out += ivars["name"]
1770
+ # {{! comment}} dropped at compile time
1771
+ #
1772
+ # Out of scope (deliberately)
1773
+ # ---------------------------
1774
+ #
1775
+ # {{#section}}{{/section}} sections (need iterable locals;
1776
+ # tep's view args are String=>String)
1777
+ # {{^section}}{{/section}} inverted sections (same reason)
1778
+ # {{>partial}} partials -- could be added later as
1779
+ # sugar for tep_mustache_<partial>(...)
1780
+ # {{=<% %>=}} delimiter swaps -- niche
1781
+ # Lambdas require Proc / closures
1782
+ #
1783
+ # When user code reaches for an unsupported tag the compiler raises
1784
+ # at build time so the build fails fast with a clear message,
1785
+ # instead of silently rendering literal `{{...}}` to the client.
1786
+ def mustache_to_ruby_body(template)
1787
+ parts = []
1788
+ parts << 'out = ""'
1789
+ i = 0
1790
+ literal = String.new
1791
+ flush = -> {
1792
+ if !literal.empty?
1793
+ parts << "out += #{literal.dump}"
1794
+ literal = String.new
1795
+ end
1796
+ }
1797
+ while i < template.length
1798
+ open_at = template.index("{{", i)
1799
+ if open_at.nil?
1800
+ literal << template[i..-1]
1801
+ break
1802
+ end
1803
+ literal << template[i...open_at]
1804
+ # Triple-stache `{{{name}}}` -- raw interpolation. Consume {{{ ... }}}.
1805
+ if template[open_at + 2] == "{"
1806
+ close_at = template.index("}}}", open_at + 3)
1807
+ raise "unterminated {{{ in mustache template" if close_at.nil?
1808
+ flush.call
1809
+ key = template[(open_at + 3)...close_at].strip
1810
+ parts << mustache_emit_value(key, escaped: false)
1811
+ i = close_at + 3
1812
+ next
1813
+ end
1814
+ close_at = template.index("}}", open_at + 2)
1815
+ raise "unterminated {{ in mustache template" if close_at.nil?
1816
+ flush.call
1817
+ inner = template[(open_at + 2)...close_at].strip
1818
+ if inner.start_with?("!")
1819
+ # comment, drop
1820
+ elsif inner.start_with?("#")
1821
+ raise "mustache section `{{##{inner[1..-1].strip}}}` unsupported -- tep's view args are String=>String, sections need iterable locals. See SINATRA_COMPAT.md > Mustache subset."
1822
+ elsif inner.start_with?("^")
1823
+ raise "mustache inverted section `{{^#{inner[1..-1].strip}}}` unsupported. See SINATRA_COMPAT.md > Mustache subset."
1824
+ elsif inner.start_with?("/")
1825
+ raise "mustache closing `{{/#{inner[1..-1].strip}}}` without a section opener (sections aren't supported)."
1826
+ elsif inner.start_with?(">")
1827
+ raise "mustache partial `{{>#{inner[1..-1].strip}}}` unsupported -- call `mustache :#{inner[1..-1].strip}` from the handler instead."
1828
+ elsif inner.start_with?("&")
1829
+ key = inner[1..-1].strip
1830
+ parts << mustache_emit_value(key, escaped: false)
1831
+ elsif inner.start_with?("=")
1832
+ raise "mustache delimiter swap `{{=...=}}` unsupported."
1833
+ else
1834
+ parts << mustache_emit_value(inner, escaped: true)
1835
+ end
1836
+ i = close_at + 2
1837
+ end
1838
+ flush.call
1839
+ parts << "out"
1840
+ parts.join("\n ")
1841
+ end
1842
+
1843
+ def mustache_emit_value(key, escaped:)
1844
+ # An `@name` key (with or without leading whitespace) reads from
1845
+ # `ivars`; bare names read from `locals`. Mirrors the ERB ivar
1846
+ # convention so a project can mix engines and the per-request
1847
+ # `req.ivars` bag flows uniformly.
1848
+ if key.start_with?("@")
1849
+ bag = "ivars"
1850
+ key = key[1..-1].strip
1851
+ else
1852
+ bag = "locals"
1853
+ end
1854
+ # `.to_s` to coerce the StrStrHash lookup back to String. Spinel
1855
+ # widens the hash value type when the param-inference pass (#542
1856
+ # cascade) decides the value side is poly; `.to_s` is a no-op for
1857
+ # an actual String and gives "" for nil.
1858
+ expr = %{#{bag}["#{key}"].to_s}
1859
+ escaped ? "out += Tep.h(#{expr})" : "out += #{expr}"
1860
+ end
1861
+
1862
+ # Replace `@name` references with `ivars["name"]`. Used inside ERB
1863
+ # template chunks (which receive `ivars` as a parameter) and as a
1864
+ # read-side helper for handler-body rewriting (where `@name` ->
1865
+ # `req.ivars["name"]`, see `rewrite_block`).
1866
+ #
1867
+ # The negative lookbehind `(?<![\w.@])` skips matches preceded by an
1868
+ # identifier character, a dot, or another `@` -- so `@@class_var`
1869
+ # isn't accidentally split into `@` + `@class_var` and a stray `@`
1870
+ # inside a string literal at the start of a line still gets caught
1871
+ # (callers mostly invoke this on small Ruby fragments where literal
1872
+ # strings containing `@x` are uncommon).
1873
+ def rewrite_ivar_reads(src, sink: 'ivars')
1874
+ src.gsub(/(?<![\w.@])@(\w+)/) { %{#{sink}["#{$1}"]} }
1875
+ end
1876
+
1877
+ # Read tep.rb plus everything it require_relatives, in order, with
1878
+ # the require lines stripped. Used to inline the framework into a
1879
+ # build-temp file so spinel doesn't have to resolve any requires.
1880
+ #
1881
+ # Also substitutes `@TEP_SPHTTP_O@` / `@TEP_SQLITE_O@` / `@TEP_PG_O@`
1882
+ # with the absolute paths to the built .o files; `@TEP_PG_CFLAGS@`
1883
+ # carries the libpq cflags+libs (pkg-config / pg_config output).
1884
+ # Override via TEP_SPHTTP_O / TEP_SQLITE_O / TEP_PG_O / TEP_PG_CFLAGS
1885
+ # env vars; defaults live under `<LIB_DIR>/tep/`.
1886
+ # Crypto symbols (sp_crypto_*) live in spinel's libspinel_rt.a since
1887
+ # matz/spinel#514, so the spinel driver auto-links them and no
1888
+ # separate placeholder is needed.
1889
+ def inlined_tep_library
1890
+ loaded = []
1891
+ visit = lambda do |abs_path|
1892
+ return if loaded.any? { |l| l[:path] == abs_path }
1893
+ text = File.read(abs_path)
1894
+ deps = []
1895
+ body = text.lines.reject do |line|
1896
+ m = line.match(/^\s*require_relative\s+"([^"]+)"/)
1897
+ next false unless m
1898
+ deps << File.expand_path(m[1] + ".rb", File.dirname(abs_path))
1899
+ true
1900
+ end.join
1901
+ deps.each { |d| visit.call(d) }
1902
+ loaded << { path: abs_path, body: body }
1903
+ end
1904
+ visit.call(File.join(LIB_DIR, "tep.rb"))
1905
+ combined = loaded.map { |l| "# --- inlined: " + relative_to_lib(l[:path]) + " ---\n" + l[:body] }.join("\n")
1906
+ # Resolve the @TEP_*@ placeholders from spinel-ext.json (the single
1907
+ # source of truth) rather than a hardcoded list. Same env-first
1908
+ # behavior as before -- the Makefile exports TEP_SPHTTP_O /
1909
+ # TEP_SQLITE_O / TEP_PG_O / TEP_PG_CFLAGS / TEP_PG_LIBS, which this
1910
+ # reads, defaulting `.o` paths to the entry's source-derived path.
1911
+ tep_ext_subs.each do |placeholder, value|
1912
+ combined = combined.gsub(placeholder, value)
1913
+ end
1914
+ combined
1915
+ end
1916
+
1917
+ # Build the {@TEP_*@ => value} substitution dict from spinel-ext.json
1918
+ # for tep's OWN builds. Faithful to the historical env-first behavior:
1919
+ # * a `.o` placeholder (entry has "source") resolves to its env
1920
+ # override (TEP_<NAME>, the placeholder sans @) else the prebuilt
1921
+ # .o derived from the entry's source (sphttp.c -> sphttp.o);
1922
+ # * a cflags-only placeholder (no "source", e.g. @TEP_PG_CFLAGS@)
1923
+ # resolves to TEP_PG_CFLAGS + TEP_PG_LIBS, the libs defaulting from
1924
+ # the entry's pkg_config (libpq -> -lpq).
1925
+ # `spinel-compat vendor` reads the same file but resolves differently
1926
+ # (compiles the .c, runs pkg-config at the consumer) -- that's the
1927
+ # point: one declaration, two build paths. See spinelgems/docs/c-ext.md.
1928
+ def tep_ext_subs
1929
+ ext_path = File.join(REPO_ROOT, "spinel-ext.json")
1930
+ return {} unless File.exist?(ext_path)
1931
+ subs = {}
1932
+ JSON.parse(File.read(ext_path)).each do |entry|
1933
+ placeholder = entry["placeholder"]
1934
+ env_var = placeholder.delete("@") # @TEP_SPHTTP_O@ -> TEP_SPHTTP_O
1935
+ if entry["source"]
1936
+ default_o = File.expand_path(entry["source"].sub(/\.c\z/, ".o"), REPO_ROOT)
1937
+ subs[placeholder] = ENV.fetch(env_var, default_o)
1938
+ else
1939
+ cflags = ENV.fetch(env_var, "")
1940
+ libs_var = env_var.sub(/_CFLAGS\z/, "_LIBS")
1941
+ default_libs = entry["pkg_config"] ? "-l" + entry["pkg_config"].sub(/\Alib/, "") : ""
1942
+ libs = ENV.fetch(libs_var, default_libs)
1943
+ subs[placeholder] = "#{cflags} #{libs}".strip
1944
+ end
1945
+ end
1946
+ subs
1947
+ end
1948
+
1949
+ # Split a Sinatra-style source on `__END__` and parse the trailing
1950
+ # `@@ name` blocks into a {name => template_body} hash.
1951
+ def split_inline_views(raw)
1952
+ if raw =~ /^__END__\s*\n/m
1953
+ head = $`
1954
+ tail = $'
1955
+ else
1956
+ return [raw, {}]
1957
+ end
1958
+ views = {}
1959
+ current = nil
1960
+ buf = String.new
1961
+ tail.each_line do |line|
1962
+ m = line.match(/^@@\s+(\S+)\s*$/)
1963
+ if m
1964
+ views[current] = buf if current
1965
+ current = m[1]
1966
+ buf = String.new
1967
+ elsif current
1968
+ buf << line
1969
+ end
1970
+ end
1971
+ views[current] = buf if current
1972
+ [head, views]
1973
+ end
1974
+
1975
+ # Expand a Sinatra-style path with optional `(...)` segments into the
1976
+ # Cartesian product of include/skip choices. `/say(/:greeting)` ->
1977
+ # ["/say", "/say/:greeting"]. Two optionals -> four paths, etc.
1978
+ def expand_optional_segments(path)
1979
+ return [path] unless path.include?("(")
1980
+ results = [""]
1981
+ i = 0
1982
+ while i < path.length
1983
+ c = path[i]
1984
+ if c == "("
1985
+ close = path.index(")", i + 1)
1986
+ raise "unmatched '(' in path: #{path}" unless close
1987
+ inside = path[(i + 1)...close]
1988
+ # Include first, skip second: when a request matches multiple
1989
+ # optional-arrangements (e.g. `/items/42` matches both
1990
+ # `/items/:id` and `/items/:section`), the earlier-named
1991
+ # capture fills first -- matches Sinatra's intuition.
1992
+ results = results.flat_map { |r| [r + inside, r] }
1993
+ i = close + 1
1994
+ else
1995
+ results = results.map { |r| r + c }
1996
+ i += 1
1997
+ end
1998
+ end
1999
+ results.uniq
2000
+ end
2001
+
2002
+ def sinatra_base_class?(class_node)
2003
+ sup = class_node.superclass
2004
+ return false if sup.nil?
2005
+ src = sup.location.slice
2006
+ src == "Sinatra::Base" || src == "Sinatra::Application" || src == "::Sinatra::Base"
2007
+ end
2008
+
2009
+ # Walk `<input_dir>/assets/` recursively and return a list of
2010
+ # `Tep::Assets._add` lines, one per file. Inferred mime per
2011
+ # extension. Files containing NUL bytes get warned + skipped --
2012
+ # spinel's :str type doesn't track length alongside the pointer,
2013
+ # so a NUL truncates the served body. For binary assets that need
2014
+ # to round-trip exactly (PNGs with embedded NULs, fonts) the
2015
+ # right path is `Tep.public_dir` at runtime, not embed.
2016
+ ASSET_MIME_BY_EXT = {
2017
+ ".css" => "text/css; charset=utf-8",
2018
+ ".js" => "application/javascript; charset=utf-8",
2019
+ ".json" => "application/json",
2020
+ ".html" => "text/html; charset=utf-8",
2021
+ ".htm" => "text/html; charset=utf-8",
2022
+ ".txt" => "text/plain; charset=utf-8",
2023
+ ".md" => "text/markdown; charset=utf-8",
2024
+ ".svg" => "image/svg+xml",
2025
+ ".xml" => "application/xml",
2026
+ ".ico" => "image/x-icon",
2027
+ ".png" => "image/png",
2028
+ ".jpg" => "image/jpeg",
2029
+ ".jpeg" => "image/jpeg",
2030
+ ".gif" => "image/gif",
2031
+ ".webp" => "image/webp",
2032
+ ".woff" => "font/woff",
2033
+ ".woff2" => "font/woff2",
2034
+ ".ttf" => "font/ttf",
2035
+ ".otf" => "font/otf",
2036
+ ".pdf" => "application/pdf",
2037
+ }.freeze
2038
+
2039
+ def embed_assets(input_path, warnings)
2040
+ assets_dir = File.join(File.dirname(input_path), "assets")
2041
+ return [] unless File.directory?(assets_dir)
2042
+ lines = []
2043
+ count = 0
2044
+ bytes = 0
2045
+ Dir.glob(File.join(assets_dir, "**", "*")).sort.each do |abs|
2046
+ next unless File.file?(abs)
2047
+ rel = abs.sub(assets_dir, "") # leading "/"
2048
+ body = File.binread(abs)
2049
+ if body.include?("\x00")
2050
+ warnings << "asset #{rel}: contains NUL byte; skipped (use Tep.public_dir for binary assets)"
2051
+ next
2052
+ end
2053
+ ext = File.extname(abs).downcase
2054
+ mime = ASSET_MIME_BY_EXT[ext] || "application/octet-stream"
2055
+ # Ruby's `String#dump` produces a literal that re-parses to the
2056
+ # exact same bytes (\xHH for non-printable, \uXXXX where
2057
+ # applicable). Spinel reads it as a const char *.
2058
+ lines << "Tep::Assets._add(#{rel.inspect}, #{body.dump}, #{mime.inspect})"
2059
+ count += 1
2060
+ bytes += body.bytesize
2061
+ end
2062
+ if count > 0
2063
+ lines.unshift("# bundled #{count} asset(s), #{bytes} bytes total")
2064
+ end
2065
+ lines
2066
+ end
2067
+
2068
+ def relative_to_lib(abs_path)
2069
+ Pathname.new(abs_path).relative_path_from(Pathname.new(LIB_DIR)).to_s rescue abs_path
2070
+ end
2071
+
2072
+ # ---- driver ----
2073
+
2074
+ def cmd_build(args)
2075
+ input = nil
2076
+ out_path = nil
2077
+ c_only = false
2078
+ i = 0
2079
+ while i < args.length
2080
+ a = args[i]
2081
+ case a
2082
+ when "-o" then out_path = args[i + 1]; i += 2
2083
+ when "-c" then c_only = true; i += 1
2084
+ when /^-/ then fatal "unknown option: #{a}"
2085
+ else
2086
+ input ||= a
2087
+ i += 1
2088
+ end
2089
+ end
2090
+ fatal "usage: tep build app.rb [-o out] [-c]" unless input
2091
+
2092
+ base = File.basename(input, ".rb")
2093
+ out_path ||= File.join(File.dirname(input), base)
2094
+ translated = translate(input)
2095
+
2096
+ # Spinel's require_relative resolves against the source file's
2097
+ # directory and chokes on absolute paths. Drop the generated file
2098
+ # next to the user's source so the require_relative emitted by the
2099
+ # translator (a path computed relative to the input dir) works.
2100
+ rb_path = File.join(File.dirname(input), ".#{base}.tep.rb")
2101
+ File.write(rb_path, translated)
2102
+
2103
+ spinel_args = [SPINEL, rb_path, "-o", out_path]
2104
+ spinel_args << "-c" if c_only
2105
+ warn "tep: building -> #{out_path}" unless ENV["TEP_QUIET"]
2106
+ unless system(*spinel_args)
2107
+ fatal "spinel failed; translated source kept at #{rb_path}"
2108
+ end
2109
+ File.unlink(rb_path) unless ENV["TEP_KEEP_TMP"]
2110
+ warn "tep: built #{out_path}"
2111
+ end
2112
+
2113
+ def cmd_run(args)
2114
+ build_args = []
2115
+ run_args = []
2116
+ i = 0
2117
+ while i < args.length
2118
+ a = args[i]
2119
+ case a
2120
+ when "-p", "-w" then run_args << a << args[i + 1]; i += 2
2121
+ else
2122
+ build_args << a; i += 1
2123
+ end
2124
+ end
2125
+ fatal "usage: tep run app.rb [-p PORT] [-w WORKERS]" if build_args.empty?
2126
+ out = File.join(Dir.pwd, "tep-run-bin")
2127
+ cmd_build(build_args + ["-o", out])
2128
+ exec(out, *run_args)
2129
+ end
2130
+
2131
+ def cmd_translate(args)
2132
+ input = args.first or fatal "usage: tep translate app.rb"
2133
+ puts translate(input)
2134
+ end
2135
+
2136
+ case ARGV.shift
2137
+ when "build" then cmd_build(ARGV)
2138
+ when "run" then cmd_run(ARGV)
2139
+ when "translate" then cmd_translate(ARGV)
2140
+ when nil, "-h", "--help"
2141
+ puts <<~USAGE
2142
+ tep -- Sinatra-flavoured framework that compiles to a native binary.
2143
+
2144
+ Usage:
2145
+ tep build app.rb [-o out] [-c] translate + spinel-compile
2146
+ tep run app.rb [-p PORT] [-w N] build then exec
2147
+ tep translate app.rb print the translated Ruby
2148
+
2149
+ Source compatibility: top-level `get '/path' do ... end`,
2150
+ `post`, `put`, `patch`, `delete`, `before`, `after`, `not_found`,
2151
+ plus `set :public_dir, ...`. Inside blocks: params, request,
2152
+ response, redirect, halt, content_type all rewrite cleanly.
2153
+ USAGE
2154
+ else
2155
+ fatal "unknown command. Try `tep --help`."
2156
+ end