syntropy 0.38.1 → 0.40.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +2 -0
  4. data/TODO.md +55 -144
  5. data/cmd/console.rb +2 -4
  6. data/cmd/new/template/config/production.rb +2 -1
  7. data/cmd/new.rb +1 -1
  8. data/cmd/serve.rb +29 -4
  9. data/cmd/test.rb +12 -4
  10. data/examples/agent/Dockerfile +25 -0
  11. data/examples/agent/Gemfile +3 -0
  12. data/examples/agent/README.md +40 -0
  13. data/examples/agent/TODO.md +5 -0
  14. data/examples/agent/app/_layout/default.rb +15 -0
  15. data/examples/agent/app/_lib/storage.rb +13 -0
  16. data/examples/agent/app/_schema/2026-01-01-initial.rb +9 -0
  17. data/examples/agent/app/assets/agent.js +63 -0
  18. data/examples/agent/app/assets/minigfm.js +207 -0
  19. data/examples/agent/app/assets/model.js +45 -0
  20. data/examples/agent/app/assets/style.css +25 -0
  21. data/examples/agent/app/assets/tools.js +74 -0
  22. data/examples/agent/app/assets/ui.js +50 -0
  23. data/examples/agent/app/index.rb +19 -0
  24. data/examples/agent/app/test.rb +7 -0
  25. data/examples/agent/config/Caddyfile +5 -0
  26. data/examples/agent/config/development.rb +5 -0
  27. data/examples/agent/config/production.rb +5 -0
  28. data/examples/agent/config/test.rb +5 -0
  29. data/examples/agent/docker-compose.yml +71 -0
  30. data/examples/agent/response_example.json +108 -0
  31. data/examples/agent/test/test_app.rb +14 -0
  32. data/examples/blog/app/foo.rb +1 -0
  33. data/examples/blog/app/posts/[id]/edit.rb +3 -3
  34. data/examples/blog/app/posts/[id]/index.rb +8 -10
  35. data/examples/blog/app/posts/index.rb +6 -8
  36. data/examples/blog/app/posts/new.rb +3 -3
  37. data/examples/blog/config/production.rb +2 -1
  38. data/lib/syntropy/app.rb +12 -60
  39. data/lib/syntropy/applets/builtin/auto_refresh/watch.js +1 -1
  40. data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +7 -0
  41. data/lib/syntropy/controller_extensions.rb +1 -1
  42. data/lib/syntropy/dev_mode.rb +8 -3
  43. data/lib/syntropy/http/client.rb +17 -1
  44. data/lib/syntropy/http/client_connection.rb +16 -0
  45. data/lib/syntropy/http/io_extensions.rb +32 -4
  46. data/lib/syntropy/http/server.rb +55 -24
  47. data/lib/syntropy/http/server_connection.rb +72 -2
  48. data/lib/syntropy/logger.rb +8 -0
  49. data/lib/syntropy/markdown.rb +139 -11
  50. data/lib/syntropy/module_loader.rb +110 -68
  51. data/lib/syntropy/request/request_info.rb +8 -0
  52. data/lib/syntropy/request/response.rb +76 -6
  53. data/lib/syntropy/request/validation.rb +6 -0
  54. data/lib/syntropy/storage/connection_pool.rb +34 -0
  55. data/lib/syntropy/test.rb +14 -5
  56. data/lib/syntropy/version.rb +1 -1
  57. data/lib/syntropy.rb +9 -2
  58. data/syntropy.gemspec +4 -4
  59. data/test/fixtures/app/_layout/default.rb +1 -1
  60. data/test/fixtures/app/_layout/kuku.rb +8 -0
  61. data/test/fixtures/app/_lib/circular/a.rb +2 -0
  62. data/test/fixtures/app/_lib/circular/b.rb +2 -0
  63. data/test/fixtures/app/_lib/circular/c.rb +2 -0
  64. data/test/fixtures/app/mod/concurrent.rb +9 -0
  65. data/test/test_app.rb +1 -1
  66. data/test/test_caching.rb +3 -3
  67. data/test/test_markdown.rb +268 -0
  68. data/test/test_module_loader.rb +32 -4
  69. data/test/test_request.rb +7 -0
  70. data/test/test_response.rb +30 -0
  71. data/test/test_schema.rb +1 -0
  72. metadata +40 -11
@@ -13,6 +13,14 @@ module Syntropy
13
13
  class ServerConnection
14
14
  attr_reader :fd, :response_headers, :logger
15
15
 
16
+ # Initializes a server connection.
17
+ #
18
+ # @param machine [UringMachine] machine instance
19
+ # @param fd [Integer] file descriptor
20
+ # @param env [Hash] app environment
21
+ # @param io_mode [Symbol] IO mode
22
+ # @param app [Proc, Syntropy::App] server app
23
+ # @return [void]
16
24
  def initialize(machine, fd, env, io_mode: :socket, &app)
17
25
  @machine = machine
18
26
  @fd = fd
@@ -26,6 +34,9 @@ module Syntropy
26
34
  @response_cookies = nil
27
35
  end
28
36
 
37
+ # Runs the connection.
38
+ #
39
+ # @return [void]
29
40
  def run
30
41
  loop do
31
42
  persist = serve_request
@@ -33,6 +44,10 @@ module Syntropy
33
44
  end
34
45
  rescue UM::Terminate
35
46
  # server is terminated, do nothing
47
+ rescue SystemCallError
48
+ @logger&.error(
49
+ message: 'IO Error, closing...'
50
+ )
36
51
  rescue StandardError => e
37
52
  @logger&.error(
38
53
  message: 'Uncaught error while running connection',
@@ -99,6 +114,10 @@ module Syntropy
99
114
  @logger&.error(message: "#{message}, closing connection", error: err)
100
115
  end
101
116
 
117
+ # Reads the request body.
118
+ #
119
+ # @param req [Syntropy::Request] request
120
+ # @return [String, nil] request body
102
121
  def get_body(req)
103
122
  headers = req.headers
104
123
  return nil if headers[':body-done-reading']
@@ -108,6 +127,10 @@ module Syntropy
108
127
  body
109
128
  end
110
129
 
130
+ # Reads a request body chunk.
131
+ #
132
+ # @param req [Syntropy::Request] request
133
+ # @return [String, nil] request body chunk
111
134
  def get_body_chunk(req)
112
135
  headers = req.headers
113
136
  return nil if headers[':body-done-reading']
@@ -117,6 +140,9 @@ module Syntropy
117
140
  chunk
118
141
  end
119
142
 
143
+ # Returns true if the request is done
144
+ #
145
+ # @return [bool] whether the request is complete
120
146
  def complete?(req)
121
147
  req.headers[':body-done-reading']
122
148
  end
@@ -135,6 +161,11 @@ module Syntropy
135
161
 
136
162
  DELETE_COOKIE = "; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Max-Age=0; HttpOnly"
137
163
 
164
+ # Adds a Set-Cookie header to the response headers.
165
+ #
166
+ # @param key [String] cookie name
167
+ # @param value [String] cookie value
168
+ # @return [void]
138
169
  def set_cookie(key, value)
139
170
  (@response_cookies ||= {})[key] = value || DELETE_COOKIE
140
171
  end
@@ -210,6 +241,13 @@ module Syntropy
210
241
  @done = true
211
242
  end
212
243
 
244
+ # Responds by rendering a static file.
245
+ #
246
+ # @param req [Syntropy::Request] request
247
+ # @param path [String] file path
248
+ # @param env [Hash] app environment
249
+ # @param cache_headers [Hash] cache headers
250
+ # @return [void]
213
251
  def respond_with_static_file(req, path, env, cache_headers)
214
252
  fd = @machine.open(path, UM::O_RDONLY)
215
253
  env ||= {}
@@ -240,6 +278,9 @@ module Syntropy
240
278
  end
241
279
  end
242
280
 
281
+ # Closes the connection.
282
+ #
283
+ # @return [void]
243
284
  def close
244
285
  return if @closed
245
286
 
@@ -248,6 +289,9 @@ module Syntropy
248
289
  @machine.close_async(@fd)
249
290
  end
250
291
 
292
+ # Yields the uhnderlying connection IO and fd to the given block.
293
+ #
294
+ # @return [void]
251
295
  def with_stream
252
296
  yield @io, @fd
253
297
  end
@@ -260,6 +304,9 @@ module Syntropy
260
304
  MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
261
305
  MAX_CHUNK_SIZE_LEN = 16
262
306
 
307
+ # Returns true if the connection should be persisted.
308
+ #
309
+ # @param headers [Hash] request headers
263
310
  def persist_connection?(headers)
264
311
  connection = headers['connection']&.downcase
265
312
  return connection != 'close'
@@ -285,14 +332,23 @@ module Syntropy
285
332
  lines
286
333
  end
287
334
 
335
+ # Formats a response status line.
336
+ #
337
+ # @param body [String] response body
338
+ # @param status [Integer] HTTP status code
339
+ # @return [String] rendered status line
288
340
  def format_status_line(body, status)
289
341
  if !body
290
342
  empty_status_line(status)
291
343
  else
292
- with_body_status_line(status, body)
344
+ with_body_status_line(body, status)
293
345
  end
294
346
  end
295
347
 
348
+ # Returns the status line for an empty response.
349
+ #
350
+ # @param status [Integer] HTTP status code
351
+ # @return [String] rendered status line
296
352
  def empty_status_line(status)
297
353
  if status == 204
298
354
  +"HTTP/1.1 #{status}\r\n"
@@ -301,10 +357,21 @@ module Syntropy
301
357
  end
302
358
  end
303
359
 
304
- def with_body_status_line(status, body)
360
+ # Returns a status line for responses with body.
361
+ #
362
+ # @param body [String] response body
363
+ # @param status [Integer] HTTP status code
364
+ # @return [String] rendered status line
365
+ def with_body_status_line(body, status)
305
366
  +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
306
367
  end
307
368
 
369
+ # Renders headers into a lines array.
370
+ #
371
+ # @param lines [Array<String>] array of header lines
372
+ # @param key [String] header name
373
+ # @param value [String, Array<String>] header value(s)
374
+ # @return [void]
308
375
  def collect_header_lines(lines, key, value)
309
376
  if value.is_a?(Array)
310
377
  value.inject(lines) { |_, item| lines << "#{key}: #{item}\r\n" }
@@ -313,6 +380,9 @@ module Syntropy
313
380
  end
314
381
  end
315
382
 
383
+ # Adds Set-Cookie headers to the response headers.
384
+ #
385
+ # @return [void]
316
386
  def add_set_cookie_headers
317
387
  @response_headers ||= {}
318
388
  sc = (@response_headers['Set-Cookie'] ||= [])
@@ -17,6 +17,14 @@ module Syntropy
17
17
  @opts = opts
18
18
  end
19
19
 
20
+ # Logs an DEBUG entry.
21
+ #
22
+ # @param o [Hash] log entry
23
+ # @return [void]
24
+ def debug(o)
25
+ call(:DEBUG, o)
26
+ end
27
+
20
28
  # Logs an INFO entry.
21
29
  #
22
30
  # @param o [Hash] log entry
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require 'securerandom'
4
5
 
5
6
  module Syntropy
6
7
  # Markdown parsing.
@@ -11,30 +12,153 @@ module Syntropy
11
12
  symbolize_names: true
12
13
  }.freeze
13
14
 
15
+ # Implements a Markdown file controller.
16
+ class Controller
17
+ def initialize(env, atts, md)
18
+ @env = env
19
+ @atts = atts
20
+ @md = md
21
+ @module_loader = env[:module_loader]
22
+ end
23
+
24
+ # Returns the controller proc.
25
+ #
26
+ # @return [Proc] controller proc
27
+ def to_proc
28
+ ->(req) {
29
+ case req.method
30
+ when 'head'
31
+ req.respond_html(nil)
32
+ when 'get'
33
+ md = process_md_embeds
34
+ html = render(md)
35
+ req.respond_html(html)
36
+ else
37
+ req.respond(nil, ':status' => HTTP::METHOD_NOT_ALLOWED)
38
+ end
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ # Process any Papercraft embeds in the markdown file by rendering them
45
+ # into the markdown code.
46
+ #
47
+ # @return [String] Markdown with rendered embeds
48
+ def process_md_embeds
49
+ return @md if @embedded_templates&.empty?
50
+
51
+ @embedded_templates = {}
52
+ @md.gsub(/^```ruby\n# render: true\n(.*?)\r?\n```\n/m) {
53
+ snippet = Regexp.last_match[1]
54
+ templ = @embedded_templates[snippet] ||= prepare_snippet_template(snippet)
55
+ Papercraft.html(templ)
56
+ }
57
+ end
58
+
59
+ # Prepares a snippet template by writing it to a temporary file and
60
+ # instance_eval'ing the code, returning a Papercraft template.
61
+ #
62
+ # @param snippet [String] Papercraft snippet
63
+ # @param _location [String] currently not used
64
+ # @return [Proc] Papercraft template
65
+ def prepare_snippet_template(snippet, _location = nil)
66
+ fn = "/tmp/snippet-#{SecureRandom.hex(8)}.rb"
67
+ src = "->() do\n#{snippet}\nend"
68
+ IO.write(fn, src)
69
+ instance_eval src, fn
70
+ end
71
+
72
+ # Renders the given markdown to HTML.
73
+ #
74
+ # @param md [String] markdown
75
+ # @return [String] HTML
76
+ def render(md)
77
+ @template ||= make_template
78
+ Papercraft.html(@template, md: md, **@atts)
79
+ end
80
+
81
+ # Makes a Markdown rendering template based on the corresponding layout.
82
+ #
83
+ # @return [Proc] Papercraft template
84
+ def make_template
85
+ layout = make_layout
86
+ Papercraft.apply(layout) { |md:, **| markdown(md) }
87
+ end
88
+
89
+ # Makes a layout template by loading the layout module. If no layout
90
+ # module was specified, returns the default layout.
91
+ #
92
+ # @return [Proc] Layout template
93
+ def make_layout
94
+ return default_layout if !@atts[:layout]
95
+ raise Error, 'Missing module loader' if !@module_loader
96
+
97
+ @module_loader.load("_layout/#{@atts[:layout]}")
98
+ end
99
+
100
+ # Creates a default layout template.
101
+ #
102
+ # @return [Proc] Default layout template
103
+ def default_layout
104
+ ->(**atts) {
105
+ html5 {
106
+ head {
107
+ title atts[:title] if atts[:title]
108
+ }
109
+ body {
110
+ render_children(**atts)
111
+ auto_refresh!
112
+ }
113
+ }
114
+ }
115
+ end
116
+ end
117
+
14
118
  class << self
15
119
  # Parses the markdown file at the given path.
16
120
  #
17
121
  # @param path [String] file path
18
122
  # @return [Array] an tuple containing properties<Hash>, contents<String>
19
- def parse(path, env)
20
- content = IO.read(path) || ''
123
+ def parse_file(path, env)
124
+ md = IO.read(path) || ''
21
125
  atts = {}
22
-
23
- parse_date(path, atts)
24
- content = parse_content(content, atts)
25
126
  atts[:url] = path_to_url(path, env[:app_root]) if env[:app_root]
127
+ parse_date(atts, path)
128
+
129
+ parse_md(atts, md)
130
+ end
26
131
 
27
- [atts, content]
132
+ # Parses the markdown content, returning the attributes and cleaned up
133
+ # markdown.
134
+ #
135
+ # @param atts [Hash] attributes hash
136
+ # @param md [String] markdown file
137
+ # @return [Array<atts, md>] Array containing attributes and clean markdown
138
+ def parse_md(atts, md)
139
+ md = parse_content(atts, md)
140
+ [atts, md]
141
+ end
142
+
143
+ # Creates a Markdown file contoroller for the given environment,
144
+ # attributes and markdown.
145
+ #
146
+ # @param env [Hash] app environment
147
+ # @param atts [Hash] markdown file attributes
148
+ # @param md [String] markdown
149
+ # @return [Markdown::Controller] controller instance
150
+ def make_controller(env, atts, md)
151
+ Controller.new(env, atts, md).to_proc
28
152
  end
29
153
 
30
154
  private
31
155
 
32
156
  # Parses date information from the given path.
33
157
  #
34
- # @param path [String] file path
35
158
  # @param atts [Hash] file attributes
159
+ # @param path [String] file path
36
160
  # @return [void]
37
- def parse_date(path, atts)
161
+ def parse_date(atts, path)
38
162
  # Parse date from file name
39
163
  if (m = path.match(/(\d{4}-\d{2}-\d{2})/))
40
164
  atts[:date] ||= Date.parse(m[1])
@@ -43,10 +167,10 @@ module Syntropy
43
167
 
44
168
  # Parses the markdown content and front matter attributes from the given content.
45
169
  #
46
- # @param content [String] file content
47
170
  # @param atts [Hash] file attributes
171
+ # @param content [String] file content
48
172
  # @return [String] parsed markdown content
49
- def parse_content(content, atts)
173
+ def parse_content(atts, content)
50
174
  if (m = content.match(FRONT_MATTER_REGEXP))
51
175
  front_matter = m[1]
52
176
  content = m.post_match
@@ -63,7 +187,11 @@ module Syntropy
63
187
  # @param app_root [String] app root directory
64
188
  # @return [String] url
65
189
  def path_to_url(path, app_root)
66
- path.gsub(/#{app_root}/, '').gsub(/\.md$/, '')
190
+ if app_root == '/'
191
+ path.gsub(/\.md$/, '')
192
+ else
193
+ path.gsub(/#{app_root}/, '').gsub(/\.md$/, '')
194
+ end
67
195
  end
68
196
  end
69
197
  end
@@ -21,7 +21,7 @@ module Syntropy
21
21
  # reference `_lib/foo` will point to a module residing in
22
22
  # `/my_site/_lib/foo.rb`.
23
23
  class ModuleLoader
24
- attr_reader :modules
24
+ attr_reader :modules, :app_root
25
25
 
26
26
  # Instantiates a module loader
27
27
  #
@@ -30,25 +30,31 @@ module Syntropy
30
30
  # @return [void]
31
31
  def initialize(env, extensions: nil)
32
32
  @env = env
33
+ @machine = env[:machine]
33
34
  @app_root = env[:app_root]
34
35
  @modules = {} # maps ref to module entry
35
36
  @fn_map = {} # maps filename to ref
36
37
  @extensions = extensions
38
+ @loading = Set.new
39
+ @lock = UM::Mutex.new
40
+ @lock_holder = nil
37
41
  end
38
42
 
39
43
  # Loads a module (if not already loaded) and returns its export value.
40
44
  #
41
45
  # @param ref [String] module reference
42
46
  # @return [any] export value
43
- def load(ref, raise_on_missing: true)
44
- ref = "/#{ref}" if ref !~ /^\//
45
- if !(entry = @modules[ref])
46
- entry = load_module(ref, raise_on_missing:)
47
- return if !entry
48
-
49
- @modules[ref] = entry
47
+ def load(ref, raise_on_missing_export: true)
48
+ lock do
49
+ ref = "/#{ref}" if ref !~ /^\//
50
+ if !(entry = @modules[ref])
51
+ entry = load_module(ref, raise_on_missing_export:)
52
+ return if !entry
53
+
54
+ @modules[ref] = entry
55
+ end
56
+ entry[:export_value]
50
57
  end
51
- entry[:export_value]
52
58
  end
53
59
 
54
60
  # Returns a list of modules found in the given relative path. The module
@@ -70,13 +76,29 @@ module Syntropy
70
76
  # @param fn [String] module filename
71
77
  # @return [void]
72
78
  def invalidate_fn(fn)
73
- ref = @fn_map[fn]
74
- invalidate_ref(ref) if ref
75
- invalidate_collection_modules
79
+ lock do
80
+ ref = @fn_map[fn]
81
+ invalidate_ref(ref) if ref
82
+ invalidate_collection_modules
83
+ end
76
84
  end
77
85
 
78
86
  private
79
87
 
88
+ # Synchronizes access to the module loader state
89
+ #
90
+ # @return [any] block return value
91
+ def lock
92
+ return yield if @lock_holder == Fiber.current
93
+
94
+ @machine.synchronize(@lock) do
95
+ @lock_holder = Fiber.current
96
+ yield
97
+ ensure
98
+ @lock_holder = nil
99
+ end
100
+ end
101
+
80
102
  # Invalidates a module by its reference, normally following a change to the
81
103
  # underlying file (in order to cause reloading of the module). The module
82
104
  # will be removed from the modules map, as well as modules dependending on
@@ -122,23 +144,35 @@ module Syntropy
122
144
  #
123
145
  # @param ref [String] module reference
124
146
  # @return [Hash] module entry
125
- def load_module(ref, raise_on_missing: true)
147
+ def load_module(ref, raise_on_missing_export: true)
126
148
  ref = "/#{ref}" if ref !~ /^\//
127
149
  fn = File.expand_path(File.join(@app_root, "#{ref}.rb"))
128
150
  if !File.file?(fn)
129
- raise Syntropy::Error, "File not found #{fn}" if raise_on_missing
151
+ raise Syntropy::Error, "File not found #{fn}" if raise_on_missing_export
130
152
 
131
153
  return
132
154
  end
133
155
 
156
+ raise Syntropy::Error, "Circular dependency detected" if @loading.include?(ref)
157
+ do_load_module(ref, fn, raise_on_missing_export:)
158
+ end
159
+
160
+ # Loads a module.
161
+ #
162
+ # @param ref [String] module reference
163
+ # @param fn [String] module filename
164
+ # @param raise_on_missing_export [bool] whether to raise on missing export
165
+ def do_load_module(ref, fn, raise_on_missing_export:)
166
+ @loading << ref
134
167
  @fn_map[fn] = ref
135
- code = IO.read(fn)
168
+ code = read_file(fn)
136
169
  env = @env.merge(module_loader: self, ref: clean_ref(ref))
137
- mod = Syntropy::ModuleContext.load(env, code, fn, @extensions)
170
+ mod = Syntropy::ModuleContext.new(env, code, fn, @extensions)
138
171
  add_dependencies(ref, mod.__dependencies__)
139
172
  export_value = transform_module_export_value(
140
- mod.__export_value__, fn, raise_on_missing:
173
+ mod, fn, raise_on_missing_export:
141
174
  )
175
+ @env[:logger]&.info(message: "Loaded module at #{fn}")
142
176
 
143
177
  {
144
178
  fn: fn,
@@ -146,6 +180,23 @@ module Syntropy
146
180
  export_value: export_value,
147
181
  reverse_deps: []
148
182
  }
183
+ rescue StandardError, SyntaxError => e
184
+ env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
185
+ e.is_a?(SyntaxError) ? handle_syntax_error(fn, e) : (raise e)
186
+ ensure
187
+ @loading.delete(ref)
188
+ end
189
+
190
+ # Returns a file's content.
191
+ #
192
+ # @param fn [String] filename
193
+ # @return [String] file content
194
+ def read_file(fn)
195
+ @machine.open(fn, UM::O_RDONLY) { |fd|
196
+ buf = +''
197
+ res = @machine.read(fd, buf, 1 << 20)
198
+ buf
199
+ }
149
200
  end
150
201
 
151
202
  # Cleans up a module reference specifier, turning /index into /
@@ -159,21 +210,34 @@ module Syntropy
159
210
  (clean == '') ? '/' : clean
160
211
  end
161
212
 
162
- # Transforms the given export value. If the value is nil, an exception is
163
- # raised.
213
+ # Transforms the given module's export value. If the value is nil, an
214
+ # exception is raised.
164
215
  #
165
- # @param export_value [any] module's export value
216
+ # @param mod [Syntropy::ModuleContext] module context
166
217
  # @return [any] transformed value
167
- def transform_module_export_value(export_value, fn, raise_on_missing:)
168
- case export_value
218
+ def transform_module_export_value(mod, fn, raise_on_missing_export:)
219
+ case (value = mod.__export_value__)
169
220
  when nil
170
- raise Syntropy::Error, "No export found in #{fn}" if raise_on_missing
221
+ mod
171
222
  when String
172
- ->(req) { req.respond(export_value) }
223
+ ->(req) { req.respond(value) }
173
224
  else
174
- export_value
225
+ value
175
226
  end
176
227
  end
228
+
229
+ # Handles a syntax error encountered while loading a module by generating a
230
+ # new exception with a backtrace including the syntax error location.
231
+ def handle_syntax_error(fn, e)
232
+ $stderr.puts("\n#{e.message}") if !Syntropy.test_mode
233
+ m = e.message.match(/^(.+): syntax/)
234
+ raise e if !m
235
+
236
+ location = m[1]
237
+ e2 = SyntaxError.new("Syntax errors found in module at #{fn}")
238
+ e2.set_backtrace([location] + e.backtrace)
239
+ raise e2
240
+ end
177
241
  end
178
242
 
179
243
  # The Syntropy::ModuleContext class provides a context for loading a module. A
@@ -193,44 +257,14 @@ module Syntropy
193
257
  # is set to `self`, and may be used to refer to various methods defined in the
194
258
  # module.
195
259
  class ModuleContext
196
- # Loads a module, returning the module instance
197
- # @param env [Hash] app environment
198
- # @param code [String] module source code
199
- # @param fn [String] module file name
200
- # @param extensions [Module, Array<Module>] extension module(s)
201
- # @return [Syntropy::ModuleContext] created module context
202
- def self.load(env, code, fn, extensions)
203
- mod = new(env)
204
- apply_extensions(mod, extensions)
205
- mod.instance_eval(code, fn)
206
- env[:logger]&.info(message: "Loaded module at #{fn}")
207
- mod
208
- rescue StandardError, SyntaxError => e
209
- env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
210
- e.is_a?(SyntaxError) ? handle_syntax_error(env, e) : (raise e)
211
- end
212
-
213
- # Applies the given extension(s) to the given module context.
214
- #
215
- # @param mod [Syntropy::ModuleContext] module context
216
- # @param extensions [Module, Array<Module>] extension module(s)
217
- def self.apply_extensions(mod, extensions)
218
- case extensions
219
- when Array
220
- extensions.each { mod.extend(it) }
221
- when Module
222
- mod.extend(extensions)
223
- when nil # return
224
- else
225
- raise Syntropy::Error, "Invalid module extensions: #{extensions.inspect}"
226
- end
227
- end
228
-
229
260
  # Initializes a module with the given environment hash.
230
261
  #
231
262
  # @param env [Hash] environment hash
263
+ # @param code [String] module source code
264
+ # @param fn [String] module filename
265
+ # @param extensions [Array<Module>, Module]
232
266
  # @return [void]
233
- def initialize(env)
267
+ def initialize(env, code, fn, extensions)
234
268
  @env = env
235
269
  @machine = env[:machine]
236
270
  @module_loader = env[:module_loader]
@@ -239,6 +273,9 @@ module Syntropy
239
273
  @logger = env[:logger]
240
274
  @__dependencies__ = []
241
275
  singleton_class.const_set(:MODULE, self)
276
+
277
+ apply_extensions(extensions)
278
+ instance_eval(code, fn)
242
279
  end
243
280
 
244
281
  attr_reader :__export_value__, :__dependencies__
@@ -334,15 +371,20 @@ module Syntropy
334
371
  Syntropy::App.new(**env)
335
372
  end
336
373
 
337
- def handle_syntax_error(env, e)
338
- $stderr.puts("\n#{e.message}") if !Syntropy.test_mode
339
- m = e.message.match(/^(.+): syntax/)
340
- raise e if !m
341
-
342
- location = m[1]
343
- e2 = SyntaxError.new("Syntax errors found in module #{env[:ref]}")
344
- e2.set_backtrace([location] + e.backtrace)
345
- raise e2
374
+ # Applies the given extension(s) to the given module context.
375
+ #
376
+ # @param extensions [Module, Array<Module>] extension module(s)
377
+ # @return [void]
378
+ def apply_extensions(extensions)
379
+ case extensions
380
+ when Array
381
+ extensions.each { extend(it) }
382
+ when Module
383
+ extend(extensions)
384
+ when nil # return
385
+ else
386
+ raise Syntropy::Error, "Invalid module extensions: #{extensions.inspect}"
387
+ end
346
388
  end
347
389
  end
348
390
  end
@@ -228,6 +228,14 @@ module Syntropy
228
228
  nil
229
229
  end
230
230
 
231
+ # Expands a relative path based on the request's path.
232
+ #
233
+ # param rel_path [String]
234
+ # @return [String]
235
+ def rel(rel_path)
236
+ File.expand_path(File.join(path, rel_path))
237
+ end
238
+
231
239
  private
232
240
 
233
241
  # Parses an accept string into an array of accepted MIME types.