scarpe-components 0.1.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +22 -0
- data/Gemfile.lock +86 -0
- data/lib/scarpe/components/base64.rb +25 -0
- data/lib/scarpe/components/calzini/alert.rb +49 -0
- data/lib/scarpe/components/calzini/art_widgets.rb +203 -0
- data/lib/scarpe/components/calzini/button.rb +39 -0
- data/lib/scarpe/components/calzini/misc.rb +146 -0
- data/lib/scarpe/components/calzini/para.rb +35 -0
- data/lib/scarpe/components/calzini/slots.rb +155 -0
- data/lib/scarpe/components/calzini/text_widgets.rb +65 -0
- data/lib/scarpe/components/calzini.rb +149 -0
- data/lib/scarpe/components/errors.rb +20 -0
- data/lib/scarpe/components/file_helpers.rb +66 -0
- data/lib/scarpe/components/html.rb +131 -0
- data/lib/scarpe/components/minitest_export_reporter.rb +75 -0
- data/lib/scarpe/components/minitest_import_runnable.rb +98 -0
- data/lib/scarpe/components/minitest_result.rb +86 -0
- data/lib/scarpe/{logger.rb → components/modular_logger.rb} +11 -6
- data/lib/scarpe/components/print_logger.rb +47 -0
- data/lib/scarpe/components/promises.rb +454 -0
- data/lib/scarpe/components/segmented_file_loader.rb +189 -0
- data/lib/scarpe/components/string_helpers.rb +10 -0
- data/lib/scarpe/components/tiranti.rb +225 -0
- data/lib/scarpe/components/unit_test_helpers.rb +257 -0
- data/lib/scarpe/components/version.rb +7 -0
- metadata +28 -4
@@ -0,0 +1,454 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Scarpe; end
|
4
|
+
module Scarpe::Components; end
|
5
|
+
module Scarpe
|
6
|
+
# Scarpe::Promise is a promises library, but one with no form of built-in
|
7
|
+
# concurrency. Instead, promise callbacks are executed synchronously.
|
8
|
+
# Even execution is usually synchronous, but can also be handled manually
|
9
|
+
# for forms of execution not controlled in Ruby (like Webview.)
|
10
|
+
#
|
11
|
+
# Funny thing... We need promises as an API concept since we have a JS event
|
12
|
+
# loop doing its thing, and we need to respond to actions that it takes.
|
13
|
+
# But there's not really a Ruby implementation of Promises *without* an
|
14
|
+
# attached form of concurrency. So here we are, writing our own :-/
|
15
|
+
#
|
16
|
+
# In theory you could probably write some kind of "no-op thread pool"
|
17
|
+
# for the ruby-concurrency gem, pass it manually to every promise we
|
18
|
+
# created and then raise an exception any time we tried to do something
|
19
|
+
# in the background. That's probably more code than writing our own, though,
|
20
|
+
# and we'd be fighting it constantly.
|
21
|
+
#
|
22
|
+
# This class is inspired by concurrent-ruby [Promise](https://ruby-concurrency.github.io/concurrent-ruby/1.1.5/Concurrent/Promise.html)
|
23
|
+
# which is inspired by Javascript Promises, which is what we actually need
|
24
|
+
# for our use case. We can't easily tell when our WebView begins processing
|
25
|
+
# our request, which removes the :processing state. This can be used for
|
26
|
+
# executing JS, but also generally waiting on events.
|
27
|
+
#
|
28
|
+
# We don't fully control ordering here, so it *is* conceivable that a
|
29
|
+
# child waiting on a parent can be randomly fulfilled, even if we didn't
|
30
|
+
# expect it. We don't consider that an error. Similarly, we'll call
|
31
|
+
# on_scheduled callbacks if a promise is fulfilled, even though we
|
32
|
+
# never explicitly scheduled it. If a promise is *rejected* without
|
33
|
+
# ever being scheduled, we won't call those callbacks.
|
34
|
+
class Promise
|
35
|
+
include Shoes::Log
|
36
|
+
|
37
|
+
# The unscheduled promise state means it's waiting on a parent promise that
|
38
|
+
# hasn't completed yet. The pending state means it's waiting to execute.
|
39
|
+
# Fulfilled means it has completed successfully and returned a value,
|
40
|
+
# while rejected means it has failed, normally producing a reason.
|
41
|
+
PROMISE_STATES = [:unscheduled, :pending, :fulfilled, :rejected]
|
42
|
+
|
43
|
+
# The state of the promise, which should be one of PROMISE_STATES
|
44
|
+
attr_reader :state
|
45
|
+
|
46
|
+
# The parent promises of this promise, sometimes an empty array
|
47
|
+
attr_reader :parents
|
48
|
+
|
49
|
+
# If the promise is fulfilled, this is the value returned
|
50
|
+
attr_reader :returned_value
|
51
|
+
|
52
|
+
# If the promise is rejected, this is the reason, sometimes an exception
|
53
|
+
attr_reader :reason
|
54
|
+
|
55
|
+
# Create a promise and then instantly fulfill it.
|
56
|
+
def self.fulfilled(return_val = nil, parents: [], &block)
|
57
|
+
p = Promise.new(parents: parents, &block)
|
58
|
+
p.fulfilled!(return_val)
|
59
|
+
p
|
60
|
+
end
|
61
|
+
|
62
|
+
# Create a promise and then instantly reject it.
|
63
|
+
def self.rejected(reason = nil, parents: [])
|
64
|
+
p = Promise.new(parents: parents)
|
65
|
+
p.rejected!(reason)
|
66
|
+
p
|
67
|
+
end
|
68
|
+
|
69
|
+
# Fulfill the promise, setting the returned_value to value
|
70
|
+
def fulfilled!(value = nil)
|
71
|
+
set_state(:fulfilled, value)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Reject the promise, setting the reason to reason
|
75
|
+
def rejected!(reason = nil)
|
76
|
+
set_state(:rejected, reason)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Create a new promise with this promise as a parent. It runs the
|
80
|
+
# specified code in block when scheduled.
|
81
|
+
def then(&block)
|
82
|
+
Promise.new(parents: [self], &block)
|
83
|
+
end
|
84
|
+
|
85
|
+
# The Promise.new method, along with all the various handlers,
|
86
|
+
# are pretty raw. They'll do what promises do, but they're not
|
87
|
+
# the prettiest. However, they ensure that guarantees are made
|
88
|
+
# and so on, so they're great as plumbing under the syntactic
|
89
|
+
# sugar above.
|
90
|
+
#
|
91
|
+
# Note that the state passed in may not be the actual initial
|
92
|
+
# state. If a parent is rejected, the state will become
|
93
|
+
# rejected. If no parents are waiting or failed then a state
|
94
|
+
# of nil or :unscheduled will become :pending.
|
95
|
+
#
|
96
|
+
# @param state [Symbol] One of PROMISE_STATES for the initial state
|
97
|
+
# @param parents [Array] A list of promises that must be fulfilled before this one is scheduled
|
98
|
+
# @yield A block that executes when this promise is scheduled - when its parents, if any, are all fulfilled
|
99
|
+
def initialize(state: nil, parents: [], &scheduler)
|
100
|
+
log_init("Promise")
|
101
|
+
|
102
|
+
# These are as initially specified, and effectively immutable
|
103
|
+
@state = state
|
104
|
+
@parents = parents
|
105
|
+
|
106
|
+
# These are what we're waiting on, and will be
|
107
|
+
# removed as time goes forward.
|
108
|
+
@waiting_on = parents.select { |p| !p.complete? }
|
109
|
+
@on_fulfilled = []
|
110
|
+
@on_rejected = []
|
111
|
+
@on_scheduled = []
|
112
|
+
@scheduler = scheduler
|
113
|
+
@executor = nil
|
114
|
+
@returned_value = nil
|
115
|
+
@reason = nil
|
116
|
+
|
117
|
+
if complete?
|
118
|
+
# Did we start out already fulfilled or rejected?
|
119
|
+
# If so, we can skip a lot of fiddly checking.
|
120
|
+
# Don't need a scheduler or to care about parents
|
121
|
+
# or anything.
|
122
|
+
@waiting_on = []
|
123
|
+
@scheduler = nil
|
124
|
+
elsif @parents.any? { |p| p.state == :rejected }
|
125
|
+
@state = :rejected
|
126
|
+
@waiting_on = []
|
127
|
+
@scheduler = nil
|
128
|
+
elsif @state == :pending
|
129
|
+
# Did we get an explicit :pending? Then we don't need
|
130
|
+
# to schedule ourselves, or care about the scheduler
|
131
|
+
# in general.
|
132
|
+
@scheduler = nil
|
133
|
+
elsif @state.nil? || @state == :unscheduled
|
134
|
+
# If no state was given or we're unscheduled, we'll
|
135
|
+
# wait until our parents have all completed to
|
136
|
+
# schedule ourselves.
|
137
|
+
|
138
|
+
if @waiting_on.empty?
|
139
|
+
# No further dependencies, we can schedule ourselves
|
140
|
+
@state = :pending
|
141
|
+
|
142
|
+
# We have no on_scheduled handlers yet, but this will
|
143
|
+
# call and clear the scheduler.
|
144
|
+
call_handlers_for(:pending)
|
145
|
+
else
|
146
|
+
# We're still waiting on somebody, no scheduling yet
|
147
|
+
@state = :unscheduled
|
148
|
+
@waiting_on.each do |dep|
|
149
|
+
dep.on_fulfilled { parent_fulfilled!(dep) }
|
150
|
+
dep.on_rejected { parent_rejected!(dep) }
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Return true if the Promise is either fulfilled or rejected.
|
157
|
+
#
|
158
|
+
# @return [Boolean] true if the promise is fulfilled or rejected
|
159
|
+
def complete?
|
160
|
+
@state == :fulfilled || @state == :rejected
|
161
|
+
end
|
162
|
+
|
163
|
+
# Return true if the promise is already fulfilled.
|
164
|
+
#
|
165
|
+
# @return [Boolean] true if the promise is fulfilled
|
166
|
+
def fulfilled?
|
167
|
+
@state == :fulfilled
|
168
|
+
end
|
169
|
+
|
170
|
+
# Return true if the promise is already rejected.
|
171
|
+
#
|
172
|
+
# @return [Boolean] true if the promise is rejected
|
173
|
+
def rejected?
|
174
|
+
@state == :rejected
|
175
|
+
end
|
176
|
+
|
177
|
+
# An inspect method to give slightly smaller output, for ease of reading in irb
|
178
|
+
def inspect
|
179
|
+
"#<Scarpe::Promise:#{object_id} " +
|
180
|
+
"@state=#{@state.inspect} @parents=#{@parents.inspect} " +
|
181
|
+
"@waiting_on=#{@waiting_on.inspect} @on_fulfilled=#{@on_fulfilled.size} " +
|
182
|
+
"@on_rejected=#{@on_rejected.size} @on_scheduled=#{@on_scheduled.size} " +
|
183
|
+
"@scheduler=#{@scheduler ? "Y" : "N"} @executor=#{@executor ? "Y" : "N"} " +
|
184
|
+
"@returned_value=#{@returned_value.inspect} @reason=#{@reason.inspect}" +
|
185
|
+
">"
|
186
|
+
end
|
187
|
+
|
188
|
+
# These promises are mostly designed for external execution.
|
189
|
+
# You could put together your own thread-pool, or use RPC,
|
190
|
+
# a WebView, a database or similar source of external calculation.
|
191
|
+
# But in many cases it's reasonable to execute locally.
|
192
|
+
# In those cases, you can register an executor which will be
|
193
|
+
# called when the promise is ready to execute but has not yet
|
194
|
+
# done so. Registering an executor on a promise that is
|
195
|
+
# already fulfilled is an error. Registering an executor on
|
196
|
+
# a promise that has already rejected is a no-op.
|
197
|
+
def to_execute(&block)
|
198
|
+
case @state
|
199
|
+
when :fulfilled
|
200
|
+
# Should this be a no-op instead?
|
201
|
+
raise Scarpe::NoOperationError, "Registering an executor on an already fulfilled promise means it will never run!"
|
202
|
+
when :rejected
|
203
|
+
return
|
204
|
+
when :unscheduled
|
205
|
+
@executor = block # save for later
|
206
|
+
when :pending
|
207
|
+
@executor = block
|
208
|
+
call_executor
|
209
|
+
else
|
210
|
+
raise Scarpe::InternalError, "Internal error, illegal state!"
|
211
|
+
end
|
212
|
+
|
213
|
+
self
|
214
|
+
end
|
215
|
+
|
216
|
+
private
|
217
|
+
|
218
|
+
# set_state looks at the old and new states of the promise. It calls handlers and updates tracking
|
219
|
+
# data accordingly.
|
220
|
+
def set_state(new_state, value_or_reason = nil)
|
221
|
+
old_state = @state
|
222
|
+
|
223
|
+
# First, filter out illegal input
|
224
|
+
unless PROMISE_STATES.include?(old_state)
|
225
|
+
raise Scarpe::InternalError, "Internal Promise error! Internal state was #{old_state.inspect}! Legal states: #{PROMISE_STATES.inspect}"
|
226
|
+
end
|
227
|
+
|
228
|
+
unless PROMISE_STATES.include?(new_state)
|
229
|
+
raise Scarpe::InternalError, "Internal Promise error! Internal state was set to #{new_state.inspect}! " +
|
230
|
+
"Legal states: #{PROMISE_STATES.inspect}"
|
231
|
+
end
|
232
|
+
|
233
|
+
if new_state != :fulfilled && new_state != :rejected && !value_or_reason.nil?
|
234
|
+
raise Scarpe::InternalError, "Internal promise error! Non-completed state transitions should not specify a value or reason!"
|
235
|
+
end
|
236
|
+
|
237
|
+
# Here's our state-transition grid for what we're doing here.
|
238
|
+
# "From" state is on the left, "to" state is on top.
|
239
|
+
#
|
240
|
+
# U P F R
|
241
|
+
#
|
242
|
+
# U - 1 . .
|
243
|
+
# P X - . .
|
244
|
+
# F X X - X
|
245
|
+
# R X X X -
|
246
|
+
#
|
247
|
+
# - Change from same to same, no effect
|
248
|
+
# X Illegal for one reason or another, raise error
|
249
|
+
# . Great, no problem, run handlers but not @scheduler or @executor
|
250
|
+
# 1 Interesting case - if we have an executor, actually change to a *different* state instead
|
251
|
+
|
252
|
+
# Transitioning from our state to our same state? No-op.
|
253
|
+
return if new_state == old_state
|
254
|
+
|
255
|
+
# Transitioning to any *different* state after being fulfilled or rejected? Nope. Those states are final.
|
256
|
+
if complete?
|
257
|
+
raise Scarpe::InternalError, "Internal Promise error! Trying to change state from #{old_state.inspect} to #{new_state.inspect}!"
|
258
|
+
end
|
259
|
+
|
260
|
+
if old_state == :pending && new_state == :unscheduled
|
261
|
+
raise Shoes::Errors::InvalidAttributeValueError, "Can't change state from :pending to :unscheduled! Scheduling is not reversible!"
|
262
|
+
end
|
263
|
+
|
264
|
+
# The next three checks should all be followed by calling handlers for the newly-changed state.
|
265
|
+
# See call_handlers_for below.
|
266
|
+
|
267
|
+
# Okay, we're getting scheduled.
|
268
|
+
if old_state == :unscheduled && new_state == :pending
|
269
|
+
@state = new_state
|
270
|
+
call_handlers_for(new_state)
|
271
|
+
|
272
|
+
# It's not impossible for the scheduler to do something that fulfills or rejects the promise.
|
273
|
+
# In that case it *also* called the appropriate handlers. Let's get out of here.
|
274
|
+
return if @state == :fulfilled || @state == :rejected
|
275
|
+
|
276
|
+
if @executor
|
277
|
+
# In this case we're still pending, but we have a synchronous executor. Let's do it.
|
278
|
+
call_executor
|
279
|
+
end
|
280
|
+
|
281
|
+
return
|
282
|
+
end
|
283
|
+
|
284
|
+
# Setting to rejected calls the rejected handlers. But no scheduling ever occurs, so on_scheduled handlers
|
285
|
+
# will never be called.
|
286
|
+
if new_state == :rejected
|
287
|
+
@state = :rejected
|
288
|
+
@reason = value_or_reason
|
289
|
+
call_handlers_for(new_state)
|
290
|
+
end
|
291
|
+
|
292
|
+
# If we go straight from :unscheduled to :fulfilled we *will* run the on_scheduled callbacks,
|
293
|
+
# because we pretend the scheduling *did* occur at some point. Normally that'll be no callbacks,
|
294
|
+
# of course.
|
295
|
+
#
|
296
|
+
# Fun-but-unfortunate trivia: you *can* fulfill a promise before all its parents are fulfilled.
|
297
|
+
# If you do, the unfinished parents will result in nil arguments to the on_fulfilled handler,
|
298
|
+
# because we have no other value to provide. The scheduler callback will never be called, but
|
299
|
+
# the on_scheduled callbacks, if any, will be.
|
300
|
+
if new_state == :fulfilled
|
301
|
+
@state = :fulfilled
|
302
|
+
@returned_value = value_or_reason
|
303
|
+
call_handlers_for(new_state)
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
# This private method calls handlers for a new state, removing those handlers
|
308
|
+
# since they have now been called. This interacts subtly with set_state()
|
309
|
+
# above, particularly in the case of fulfilling a promise without it ever being
|
310
|
+
# properly scheduled.
|
311
|
+
#
|
312
|
+
# The rejected handlers will be cleared if the promise is fulfilled and vice-versa.
|
313
|
+
# After rejection, no on_fulfilled handler should ever be called and vice-versa.
|
314
|
+
#
|
315
|
+
# When we go from :unscheduled to :pending, the scheduler, if any, should be
|
316
|
+
# called and cleared. That should *not* happen when going from :unscheduled to
|
317
|
+
# :fulfilled.
|
318
|
+
def call_handlers_for(state)
|
319
|
+
case state
|
320
|
+
when :fulfilled
|
321
|
+
@on_scheduled.each { |h| h.call(*@parents.map(&:returned_value)) }
|
322
|
+
@on_fulfilled.each { |h| h.call(*@parents.map(&:returned_value)) }
|
323
|
+
@on_scheduled = @on_rejected = @on_fulfilled = []
|
324
|
+
@scheduler = @executor = nil
|
325
|
+
when :rejected
|
326
|
+
@on_rejected.each { |h| h.call(*@parents.map(&:returned_value)) }
|
327
|
+
@on_fulfilled = @on_scheduled = @on_rejected = []
|
328
|
+
@scheduler = @executor = nil
|
329
|
+
when :pending
|
330
|
+
# A scheduler can get an exception. If so, treat it as rejection
|
331
|
+
# and the exception as the provided reason.
|
332
|
+
if @scheduler
|
333
|
+
begin
|
334
|
+
@scheduler.call(*@parents.map(&:returned_value))
|
335
|
+
rescue => e
|
336
|
+
@log.error("Error while running scheduler! #{e.full_message}")
|
337
|
+
rejected!(e)
|
338
|
+
end
|
339
|
+
@scheduler = nil
|
340
|
+
end
|
341
|
+
@on_scheduled.each { |h| h.call(*@parents.map(&:returned_value)) }
|
342
|
+
@on_scheduled = []
|
343
|
+
else
|
344
|
+
raise Scarpe::InternalError, "Internal error! Trying to call handlers for #{state.inspect}!"
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
def parent_fulfilled!(parent)
|
349
|
+
@waiting_on.delete(parent)
|
350
|
+
|
351
|
+
# Last parent? If so, schedule ourselves.
|
352
|
+
if @waiting_on.empty? && !self.complete?
|
353
|
+
# This will result in :pending if there's no executor,
|
354
|
+
# or fulfilled/rejected if there is an executor.
|
355
|
+
set_state(:pending)
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
def parent_rejected!(parent)
|
360
|
+
@waiting_on = []
|
361
|
+
|
362
|
+
unless self.complete?
|
363
|
+
# If our parent was rejected and we were waiting on them,
|
364
|
+
# now we're rejected too.
|
365
|
+
set_state(:rejected)
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
def call_executor
|
370
|
+
raise(Scarpe::InternalError, "Internal error! Should not call_executor with no executor!") unless @executor
|
371
|
+
|
372
|
+
begin
|
373
|
+
result = @executor.call(*@parents.map(&:returned_value))
|
374
|
+
fulfilled!(result)
|
375
|
+
rescue => e
|
376
|
+
@log.error("Error running executor! #{e.full_message}")
|
377
|
+
rejected!(e)
|
378
|
+
end
|
379
|
+
ensure
|
380
|
+
@executor = nil
|
381
|
+
end
|
382
|
+
|
383
|
+
public
|
384
|
+
|
385
|
+
# Register a handler to be called when the promise is fulfilled.
|
386
|
+
# If called on a fulfilled promise, the handler will be called immediately.
|
387
|
+
#
|
388
|
+
# @yield Handler to be called on fulfilled
|
389
|
+
# @return [Scarpe::Promise] self
|
390
|
+
def on_fulfilled(&handler)
|
391
|
+
unless handler
|
392
|
+
raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_fulfilled!"
|
393
|
+
end
|
394
|
+
|
395
|
+
case @state
|
396
|
+
when :fulfilled
|
397
|
+
handler.call(*@parents.map(&:returned_value))
|
398
|
+
when :pending, :unscheduled
|
399
|
+
@on_fulfilled << handler
|
400
|
+
when :rejected
|
401
|
+
# Do nothing
|
402
|
+
end
|
403
|
+
|
404
|
+
self
|
405
|
+
end
|
406
|
+
|
407
|
+
# Register a handler to be called when the promise is rejected.
|
408
|
+
# If called on a rejected promise, the handler will be called immediately.
|
409
|
+
#
|
410
|
+
# @yield Handler to be called on rejected
|
411
|
+
# @return [Scarpe::Promise] self
|
412
|
+
def on_rejected(&handler)
|
413
|
+
unless handler
|
414
|
+
raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_rejected!"
|
415
|
+
end
|
416
|
+
|
417
|
+
case @state
|
418
|
+
when :rejected
|
419
|
+
handler.call(*@parents.map(&:returned_value))
|
420
|
+
when :pending, :unscheduled
|
421
|
+
@on_rejected << handler
|
422
|
+
when :fulfilled
|
423
|
+
# Do nothing
|
424
|
+
end
|
425
|
+
|
426
|
+
self
|
427
|
+
end
|
428
|
+
|
429
|
+
# Register a handler to be called when the promise is scheduled.
|
430
|
+
# If called on a promise that was scheduled earlier, the handler
|
431
|
+
# will be called immediately.
|
432
|
+
#
|
433
|
+
# @yield Handler to be called on scheduled
|
434
|
+
# @return [Scarpe::Promise] self
|
435
|
+
def on_scheduled(&handler)
|
436
|
+
unless handler
|
437
|
+
raise Shoes::Errors::InvalidAttributeValueError, "You must pass a block to on_scheduled!"
|
438
|
+
end
|
439
|
+
|
440
|
+
# Add a pending handler or call it now
|
441
|
+
case @state
|
442
|
+
when :fulfilled, :pending
|
443
|
+
handler.call(*@parents.map(&:returned_value))
|
444
|
+
when :unscheduled
|
445
|
+
@on_scheduled << handler
|
446
|
+
when :rejected
|
447
|
+
# Do nothing
|
448
|
+
end
|
449
|
+
|
450
|
+
self
|
451
|
+
end
|
452
|
+
end
|
453
|
+
Components::Promise = Promise
|
454
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "scarpe/components/file_helpers"
|
4
|
+
|
5
|
+
module Scarpe::Components
|
6
|
+
class SegmentedFileLoader
|
7
|
+
include Scarpe::Components::FileHelpers
|
8
|
+
|
9
|
+
# Add a new segment type (e.g. "catscradle") with a different
|
10
|
+
# file handler.
|
11
|
+
#
|
12
|
+
# @param type [String] the new name for this segment type
|
13
|
+
# @param handler [Object] an object that will be called as obj.call(filename) - often a proc
|
14
|
+
# @return <void>
|
15
|
+
def add_segment_type(type, handler)
|
16
|
+
if segment_type_hash.key?(type)
|
17
|
+
raise Shoes::Errors::InvalidAttributeValueError, "Segment type #{type.inspect} already exists!"
|
18
|
+
end
|
19
|
+
|
20
|
+
segment_type_hash[type] = handler
|
21
|
+
end
|
22
|
+
|
23
|
+
# Return an Array of segment type labels, such as "code" and "app_test".
|
24
|
+
#
|
25
|
+
# @return [Array<String>] the segment type labels
|
26
|
+
def segment_types
|
27
|
+
segment_type_hash.keys
|
28
|
+
end
|
29
|
+
|
30
|
+
# Normally a Shoes application will want to keep the default segment types,
|
31
|
+
# which allow loading a Shoes app and running a test inside. But sometimes
|
32
|
+
# the default handler will be wrong and a library will want to register
|
33
|
+
# its own "shoes" and "app_test" segment handlers, or not have any at all.
|
34
|
+
# For those applications, it makes sense to clear all segment types before
|
35
|
+
# registering its own.
|
36
|
+
#
|
37
|
+
# @return <void>
|
38
|
+
def remove_all_segment_types!
|
39
|
+
@segment_type_hash = {}
|
40
|
+
end
|
41
|
+
|
42
|
+
# Load a .sca file with an optional YAML frontmatter prefix and
|
43
|
+
# multiple file sections which can be treated differently.
|
44
|
+
#
|
45
|
+
# The file loader acts like a proc, being called with .call()
|
46
|
+
# and returning true or false for whether it has handled the
|
47
|
+
# file load. This allows chaining loaders in order and the
|
48
|
+
# first loader to recognise a file will run it.
|
49
|
+
#
|
50
|
+
# @param path [String] the file or directory to treat as a Scarpe app
|
51
|
+
# @return [Boolean] return true if the file is loaded as a segmented Scarpe app file
|
52
|
+
def call(path)
|
53
|
+
return false unless path.end_with?(".scas") || path.end_with?(".sspec")
|
54
|
+
|
55
|
+
file_load(path)
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
# Segment type handlers can call this to perform an operation after the load
|
60
|
+
# has completed. This is important for ordering, and because loading a Shoes
|
61
|
+
# app often doesn't return. So to have a later section (e.g. tests, additional
|
62
|
+
# data) do something that affects Shoes app loading (e.g. set an env var,
|
63
|
+
# affect the display service) it's important that app loading take place later
|
64
|
+
# in the sequence.
|
65
|
+
def after_load(&block)
|
66
|
+
@after_load ||= []
|
67
|
+
@after_load << block
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.gen_name(segmap)
|
71
|
+
ctr = (1..10_000).detect { |i| !segmap.key?("%5d" % i) }
|
72
|
+
"%5d" % ctr
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.front_matter_and_segments_from_file(contents)
|
76
|
+
require "yaml" # Only load when needed
|
77
|
+
require "English"
|
78
|
+
|
79
|
+
segments = contents.split(/\n-{5,}/)
|
80
|
+
front_matter = {}
|
81
|
+
|
82
|
+
# The very first segment can start with front matter, or with a divider, or with no divider.
|
83
|
+
if segments[0].start_with?("---\n") || segments[0] == "---"
|
84
|
+
# We have YAML front matter at the start. All later segments will have a divider.
|
85
|
+
front_matter = YAML.load segments[0]
|
86
|
+
front_matter ||= {} # If the front matter is just the three dashes it returns nil
|
87
|
+
segments = segments[1..-1]
|
88
|
+
elsif segments[0].start_with?("-----")
|
89
|
+
# We have a divider at the start. Great! We're already well set up for this case.
|
90
|
+
elsif segments.size == 1
|
91
|
+
# No front matter, no divider, a single unnamed segment. No more parsing needed.
|
92
|
+
return [{}, { "" => segments[0] }]
|
93
|
+
else
|
94
|
+
# No front matter, no divider before the first segment, multiple segments.
|
95
|
+
# We'll add an artificial divider to the first segment for uniformity.
|
96
|
+
segments = ["-----\n" + segments[0]] + segments[1..-1]
|
97
|
+
end
|
98
|
+
|
99
|
+
segmap = {}
|
100
|
+
segments.each do |segment|
|
101
|
+
if segment =~ /\A-* +(.*?)\n/
|
102
|
+
# named segment with separator
|
103
|
+
name = ::Regexp.last_match(1)
|
104
|
+
|
105
|
+
raise("Duplicate segment name: #{name.inspect}!") if segmap.key?(name)
|
106
|
+
|
107
|
+
segmap[name] = ::Regexp.last_match.post_match
|
108
|
+
elsif segment =~ /\A-* *\n/
|
109
|
+
# unnamed segment with separator
|
110
|
+
segmap[gen_name(segmap)] = ::Regexp.last_match.post_match
|
111
|
+
else
|
112
|
+
raise Scarpe::InternalError, "Internal error when parsing segments in segmented app file! seg: #{segment.inspect}"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
[front_matter, segmap]
|
117
|
+
end
|
118
|
+
|
119
|
+
def file_load(path)
|
120
|
+
contents = File.read(path)
|
121
|
+
|
122
|
+
front_matter, segmap = self.class.front_matter_and_segments_from_file(contents)
|
123
|
+
|
124
|
+
if segmap.empty?
|
125
|
+
raise Scarpe::FileContentError, "Illegal segmented Scarpe file: must have at least one code segment, not just front matter!"
|
126
|
+
end
|
127
|
+
|
128
|
+
if front_matter[:segments]
|
129
|
+
if front_matter[:segments].size != segmap.size
|
130
|
+
raise Scarpe::FileContentError, "Number of front matter :segments must equal number of file segments!"
|
131
|
+
end
|
132
|
+
else
|
133
|
+
if segmap.size > 2
|
134
|
+
raise Scarpe::FileContentError, "Segmented files with more than two segments have to specify what they're for!"
|
135
|
+
end
|
136
|
+
|
137
|
+
# Set to default of shoes code only or shoes code and app test code.
|
138
|
+
front_matter[:segments] = segmap.size == 2 ? ["shoes", "app_test"] : ["shoes"]
|
139
|
+
end
|
140
|
+
|
141
|
+
# Match up front_matter[:segments] with the segments, or use the default of shoes and app_test.
|
142
|
+
|
143
|
+
sth = segment_type_hash
|
144
|
+
sv = segmap.values
|
145
|
+
|
146
|
+
tf_specs = []
|
147
|
+
front_matter[:segments].each.with_index do |seg_type, idx|
|
148
|
+
unless sth.key?(seg_type)
|
149
|
+
raise Scarpe::FileContentError, "Unrecognized segment type #{seg_type.inspect}! No matching segment type available!"
|
150
|
+
end
|
151
|
+
|
152
|
+
tf_specs << ["scarpe_#{seg_type}_segment_contents", sv[idx]]
|
153
|
+
end
|
154
|
+
|
155
|
+
with_tempfiles(tf_specs) do |filenames|
|
156
|
+
filenames.each.with_index do |filename, idx|
|
157
|
+
seg_name = front_matter[:segments][idx]
|
158
|
+
sth[seg_name].call(filename)
|
159
|
+
end
|
160
|
+
|
161
|
+
# Need to call @after_load hooks while tempfiles still exist
|
162
|
+
if @after_load && !@after_load.empty?
|
163
|
+
@after_load.each(&:call)
|
164
|
+
@after_load = []
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# The hash of segment type labels mapped to handlers which will be called.
|
170
|
+
# This could be called by a display service, a test framework or similar
|
171
|
+
# code that wants to define a non-Scarpe-standard file format.
|
172
|
+
#
|
173
|
+
# @return [Hash<String, Object>] the name/handler pairs
|
174
|
+
def segment_type_hash
|
175
|
+
@segment_handlers ||= {
|
176
|
+
"shoes" => proc { |seg_file| after_load { load seg_file } },
|
177
|
+
"app_test" => proc do |seg_file|
|
178
|
+
ENV["SHOES_SPEC_TEST"] = seg_file
|
179
|
+
ENV["SHOES_MINITEST_EXPORT_FILE"] = "sspec.json"
|
180
|
+
end,
|
181
|
+
}
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
# You can add additional segment types to the segmented file loader
|
187
|
+
# loader = Scarpe::Components::SegmentedFileLoader.new
|
188
|
+
# loader.add_segment_type "capybara", proc { |seg_file| load_file_as_capybara(seg_file) }
|
189
|
+
# Shoes.add_file_loader loader
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Scarpe; module Components; end; end
|
4
|
+
module Scarpe::Components::StringHelpers
|
5
|
+
# Cut down from Rails camelize
|
6
|
+
def self.camelize(string)
|
7
|
+
string = string.sub(/^[a-z\d]*/, &:capitalize)
|
8
|
+
string.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{::Regexp.last_match(1)}#{::Regexp.last_match(2).capitalize}" }
|
9
|
+
end
|
10
|
+
end
|