barefoot_js 0.18.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.
@@ -0,0 +1,1155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'barefoot_js/evaluator'
5
+ require 'barefoot_js/search_params'
6
+
7
+ # BarefootJS - engine- and framework-agnostic server runtime for BarefootJS
8
+ # marked templates (ERB port).
9
+ #
10
+ # Ruby port of BarefootJS.pm (@barefootjs/perl), keeping method names 1:1
11
+ # with the Perl runtime (already snake_case) so the ERB compile-time
12
+ # adapter and this runtime share one naming contract. This module is
13
+ # deliberately template-engine- and framework-agnostic: every operation
14
+ # that depends on *how* a template is rendered -- JSON marshalling,
15
+ # raw-string marking, JSX-children materialisation, and named-template
16
+ # rendering -- is delegated to a pluggable `backend` (see
17
+ # BarefootJS::Backend::Erb for the ERB reference implementation), which
18
+ # is the only component that knows about a specific template engine.
19
+ #
20
+ # Value domain: JSON-shaped Ruby data with SYMBOL hash keys throughout
21
+ # (props, env hashes, array-of-hash records). Ruby's real Integer/Float/
22
+ # String/true/false/nil type system maps onto the JS value domain far more
23
+ # directly than Perl's blurred numeric-string scalars, so this port needs
24
+ # none of the `JSON::PP::Boolean` sentinel-detection machinery the Perl
25
+ # runtime carries -- a Ruby `true`/`false` IS a boolean, distinguishable
26
+ # from `0`/`1` for free.
27
+ module BarefootJS
28
+ # Context is the `bf` object every compiled `.erb` template receives as a
29
+ # local. One instance per render (root or child); `render_child` /
30
+ # `register_components_from_manifest` construct a fresh child instance
31
+ # per nested render, chaining scope/slot identity off the caller.
32
+ class Context
33
+ # ---------------------------------------------------------------------
34
+ # Minimal get/set accessors (Perl-style: no-arg reads, one-arg writes,
35
+ # matching BarefootJS.pm's hand-rolled accessor base so the exact same
36
+ # calling convention -- `bf._scope_id`, `bf._scope_id('root_s0')` --
37
+ # ports across languages unchanged).
38
+ # ---------------------------------------------------------------------
39
+ def self.bf_accessor(name, default: nil)
40
+ ivar = :"@#{name.to_s.sub(/\A_/, '')}"
41
+ define_method(name) do |*args|
42
+ if args.empty?
43
+ unless instance_variable_defined?(ivar)
44
+ instance_variable_set(ivar, default ? default.call : nil)
45
+ end
46
+ instance_variable_get(ivar)
47
+ else
48
+ instance_variable_set(ivar, args.first)
49
+ self
50
+ end
51
+ end
52
+ end
53
+ private_class_method :bf_accessor
54
+
55
+ bf_accessor :backend
56
+ bf_accessor :_scripts, default: -> { [] }
57
+ bf_accessor :_script_seen, default: -> { {} }
58
+ bf_accessor :_scope_id
59
+ bf_accessor :_is_child, default: -> { false }
60
+ bf_accessor :_bf_parent
61
+ bf_accessor :_bf_mount
62
+ bf_accessor :_props
63
+ bf_accessor :_data_key
64
+ bf_accessor :_child_renderers, default: -> { {} }
65
+
66
+ def initialize(backend = nil)
67
+ @backend = backend
68
+ end
69
+
70
+ # search_params(query = '') -- request-scoped reader for the reactive
71
+ # searchParams() environment signal (router v0.5, #1922), built from a
72
+ # raw query string. The compiled template reads it via
73
+ # `v[:searchParams].get('key')`.
74
+ def search_params(query = '')
75
+ SearchParams.new(query)
76
+ end
77
+
78
+ # -----------------------------------------------------------------
79
+ # Scope & Props
80
+ # -----------------------------------------------------------------
81
+
82
+ # bf-s is the addressable scope id only (#1249).
83
+ def scope_attr
84
+ _scope_id || ''
85
+ end
86
+
87
+ # Emits `bf-h="<host>" bf-m="<slot>" bf-r=""` conditionally. See
88
+ # spec/compiler.md "Slot identity".
89
+ def hydration_attrs
90
+ parts = []
91
+ host = _bf_parent
92
+ mount = _bf_mount
93
+ parts << %(bf-h="#{host.gsub('"', '&quot;')}") if host && !host.empty?
94
+ parts << %(bf-m="#{mount.gsub('"', '&quot;')}") if mount && !mount.empty?
95
+ parts << 'bf-r=""' unless _is_child
96
+ parts.join(' ')
97
+ end
98
+
99
+ # Emits ` data-key="<key>"` for a keyed loop item, else ''. See
100
+ # BarefootJS.pm's docstring on the client reconciliation contract.
101
+ def data_key_attr
102
+ k = _data_key
103
+ return '' if k.nil?
104
+
105
+ escaped = k.to_s.gsub('&', '&amp;').gsub('"', '&quot;')
106
+ %( data-key="#{escaped}")
107
+ end
108
+
109
+ def props_attr
110
+ props = _props
111
+ return '' unless props && !props.empty?
112
+
113
+ # The JSON must be attribute-escaped: a raw `'` inside a string value
114
+ # (e.g. a blog paragraph) terminates the single-quoted attribute and
115
+ # truncates the hydration payload. The browser entity-decodes the
116
+ # attribute value, so the client's JSON.parse sees the original text.
117
+ json = html_escape(backend.encode_json(props))
118
+ %( bf-p='#{json}')
119
+ end
120
+
121
+ # -----------------------------------------------------------------
122
+ # Context (SSR mirror of the client `provideContext` / `useContext`)
123
+ # -----------------------------------------------------------------
124
+ #
125
+ # A `<Ctx.Provider value>` seeds a value that descendant
126
+ # `useContext(Ctx)` consumers read during the same render. The stacks
127
+ # live at module scope (like the Perl `my %CONTEXT_STACKS`), not on
128
+ # `self`, because a parent template and the child templates it renders
129
+ # via `render_child` are separate `Context` instances. SSR rendering is
130
+ # synchronous, and push/pop are perfectly balanced, so each provider
131
+ # subtree's stack always unwinds to empty by the time its render
132
+ # finishes -- keeping concurrent root renders isolated.
133
+ CONTEXT_STACKS = Hash.new { |h, k| h[k] = [] }
134
+ private_constant :CONTEXT_STACKS
135
+
136
+ def provide_context(name, value)
137
+ CONTEXT_STACKS[name].push(value)
138
+ ''
139
+ end
140
+
141
+ def revoke_context(name)
142
+ stack = CONTEXT_STACKS[name]
143
+ stack.pop unless stack.empty?
144
+ ''
145
+ end
146
+
147
+ def use_context(name, default = nil)
148
+ stack = CONTEXT_STACKS[name]
149
+ stack.empty? ? default : stack.last
150
+ end
151
+
152
+ # -----------------------------------------------------------------
153
+ # Comment Markers
154
+ # -----------------------------------------------------------------
155
+
156
+ def comment(text)
157
+ "<!--bf-#{text}-->"
158
+ end
159
+
160
+ # Map a JS-boolean-shaped value to the JS `String(bool)` form. See
161
+ # BarefootJS.pm's `bool_str` docstring for the boolean-only contract --
162
+ # callers must have already classified the expression as boolean-
163
+ # result; non-boolean attribute bindings never reach this helper.
164
+ def bool_str(value)
165
+ value ? 'true' : 'false'
166
+ end
167
+
168
+ def text_start(slot_id)
169
+ "<!--bf:#{slot_id}-->"
170
+ end
171
+
172
+ def text_end
173
+ '<!--/-->'
174
+ end
175
+
176
+ # See spec/compiler.md "Slot identity" for the comment-scope wire format.
177
+ def scope_comment
178
+ scope_id = _scope_id || ''
179
+ host_segment = ''
180
+ host = _bf_parent
181
+ mount = _bf_mount
182
+ host_segment = "|h=#{host}|m=#{mount || ''}" if host && !host.empty?
183
+ props_json = ''
184
+ props_json = "|#{backend.encode_json(_props)}" if _props && !_props.empty?
185
+ "<!--bf-scope:#{scope_id}#{host_segment}#{props_json}-->"
186
+ end
187
+
188
+ # -----------------------------------------------------------------
189
+ # Script Registration
190
+ # -----------------------------------------------------------------
191
+
192
+ def register_script(path)
193
+ return if _script_seen.key?(path)
194
+
195
+ _script_seen[path] = true
196
+ _scripts.push(path)
197
+ end
198
+
199
+ def scripts
200
+ _scripts.map { |path| %(<script type="module" src="#{path}"></script>) }.join("\n")
201
+ end
202
+
203
+ # -----------------------------------------------------------------
204
+ # Child Component Rendering
205
+ # -----------------------------------------------------------------
206
+
207
+ # Register a renderer for `render_child(name, ...)`. `renderer` is
208
+ # called as `renderer.call(props_hash, invoking_bf)` -- the invoking
209
+ # `Context` matters because a renderer registered on the root may be
210
+ # called from a nested child render, and the grandchild's scope / slot
211
+ # identity must chain off the CALLER's scope id, not the registrant's
212
+ # (#1897).
213
+ def register_child_renderer(name, renderer)
214
+ _child_renderers[name] = renderer
215
+ end
216
+
217
+ def render_child(name, *args)
218
+ renderer = _child_renderers[name]
219
+ raise "No renderer registered for child component '#{name}'" unless renderer
220
+
221
+ # Accept both `render_child(name, k: v, ...)` (kwargs collapse into a
222
+ # trailing Hash under Ruby's argument-splat rules) and the explicit
223
+ # single-Hash form `render_child(name, { k: v })`.
224
+ props = (args.length == 1 && args[0].is_a?(Hash)) ? args[0].dup : Hash[*args]
225
+ # JSX children come in via the ERB backend's content-capture buffer
226
+ # slice; materialize it through the backend so the child renderer sees
227
+ # `props[:children]` as already-rendered HTML. Guard on `key?` so a
228
+ # childless invocation doesn't gain a spurious `children: nil` key.
229
+ props[:children] = backend.materialize(props[:children]) if props.key?(:children)
230
+ renderer.call(props, self)
231
+ end
232
+
233
+ # -----------------------------------------------------------------
234
+ # Bulk registration from build manifest
235
+ # -----------------------------------------------------------------
236
+ #
237
+ # `bf build` emits dist/templates/manifest.json describing every
238
+ # component the page might invoke. This walks that manifest and
239
+ # registers one child renderer per UI registry entry (`ui/<name>/index`
240
+ # -> slot key `<name>`), seeding each child's template vars from the
241
+ # manifest's statically-derived `ssrDefaults` (prop destructure
242
+ # defaults + signal/memo initial values). `signal_init[slot_key]` is an
243
+ # opt-in override for defaults the static extractor can't see through.
244
+ def register_components_from_manifest(manifest, signal_init: {})
245
+ parent_scope = _scope_id
246
+ parent = self
247
+
248
+ manifest.each do |entry_name, entry|
249
+ next if entry_name.to_s == '__barefoot__'
250
+
251
+ m = entry_name.to_s.match(%r{\Aui/([^/]+)/index\z})
252
+ next unless m
253
+
254
+ slot_key = m[1]
255
+ marked = entry[:markedTemplate] || ''
256
+ next if marked.empty?
257
+
258
+ template_name = marked.sub(%r{\Atemplates/}, '').sub(/\.erb\z/, '')
259
+ sig_init = signal_init[slot_key]
260
+ manifest_defaults = entry[:ssrDefaults]
261
+
262
+ register_child_renderer(slot_key, lambda do |props, caller|
263
+ host = caller || parent
264
+ host_scope = host._scope_id || parent_scope
265
+ child_bf = self.class.new(parent.backend)
266
+ slot_id = props.delete(:_bf_slot)
267
+ data_key = props.delete(:key)
268
+ child_bf._data_key(data_key) unless data_key.nil?
269
+ child_bf._scope_id(
270
+ slot_id ? "#{host_scope}_#{slot_id}" : "#{template_name}_#{rand.to_s[2, 6]}",
271
+ )
272
+ child_bf._is_child(true)
273
+ if slot_id
274
+ child_bf._bf_parent(host_scope)
275
+ child_bf._bf_mount(slot_id)
276
+ end
277
+ # Share the root registry so the child's own template can render
278
+ # further imported components (#1897).
279
+ child_bf._child_renderers(parent._child_renderers)
280
+ child_bf._scripts(parent._scripts)
281
+ child_bf._script_seen(parent._script_seen)
282
+
283
+ extra =
284
+ if sig_init
285
+ sig_init.call(props)
286
+ elsif manifest_defaults
287
+ self.class.send(:derive_vars_from_defaults, manifest_defaults, props)
288
+ else
289
+ {}
290
+ end
291
+
292
+ html = parent.backend.render_named(template_name, child_bf, props.merge(extra))
293
+ html.chomp
294
+ end)
295
+ end
296
+ end
297
+
298
+ # Derive template-var kvs from a manifest entry's `ssrDefaults` section.
299
+ # Each entry shape: `{ value:, propName:, isRestProps: }`. For
300
+ # `isRestProps`, the rest bag passes through unchanged (or the static
301
+ # `{}` if the caller didn't supply one). For ordinary entries the
302
+ # caller's `props[propName]` wins when present, otherwise the static
303
+ # `value` does. `propName`-less entries (signal / memo locals) always
304
+ # use the static value.
305
+ #
306
+ # Public (not `private_class_method`): `register_components_from_manifest`
307
+ # above uses it for the `ui/*` registry path, but a page that composes
308
+ # *flat* (non-`ui/*`) components by hand -- e.g. the blog islands in the
309
+ # Sinatra/xslate/Mojolicious integrations -- needs the exact same
310
+ # ssrDefaults-seeding logic for its own manual `register_child_renderer`
311
+ # calls. Mirrors the Perl runtime's `BarefootJS::_derive_stash_from_defaults`,
312
+ # which is likewise callable from integration code (Perl has no enforced
313
+ # privacy; the leading underscore is convention only) -- see
314
+ # integrations/xslate/app.psgi's `_register_blog_child` and
315
+ # integrations/mojolicious/app.pl's equivalent.
316
+ def self.derive_vars_from_defaults(defaults, props)
317
+ extra = {}
318
+ defaults.each do |name, d|
319
+ unless d.is_a?(Hash)
320
+ extra[name] = d
321
+ next
322
+ end
323
+ if d[:isRestProps]
324
+ extra[name] = props.key?(name) ? props[name] : d[:value]
325
+ next
326
+ end
327
+ prop_name = d[:propName]
328
+ extra[name] =
329
+ if !prop_name.nil? && props.key?(prop_name) && !props[prop_name].nil?
330
+ props[prop_name]
331
+ else
332
+ d[:value]
333
+ end
334
+ end
335
+ extra
336
+ end
337
+
338
+ # -----------------------------------------------------------------
339
+ # Streaming SSR (Out-of-Order)
340
+ # -----------------------------------------------------------------
341
+
342
+ def streaming_bootstrap
343
+ %{<script>(function(){function s(id){var a=document.querySelector('[bf-async="'+id+'"]');var t=document.querySelector('template[bf-async-resolve="'+id+'"]');if(!a||!t)return;a.replaceChildren(t.content.cloneNode(true));a.removeAttribute('bf-async');t.remove();requestAnimationFrame(function(){if(window.__bf_hydrate)window.__bf_hydrate()})};window.__bf_swap=s})()</script>}
344
+ end
345
+
346
+ def async_boundary(id, fallback_html)
347
+ fallback_html = backend.materialize(fallback_html)
348
+ %(<div bf-async="#{id}">#{fallback_html}</div>)
349
+ end
350
+
351
+ def async_resolve(id, content_html)
352
+ %(<template bf-async-resolve="#{id}">#{content_html}</template><script>__bf_swap("#{id}")</script>)
353
+ end
354
+
355
+ # -----------------------------------------------------------------
356
+ # JS-compat callees -- invoked from generated ERB templates as
357
+ # `bf.json(val)`, `bf.floor(val)`, etc. Numeric coercion follows JS
358
+ # semantics (NaN propagates; non-numeric input yields NaN rather than
359
+ # silently 0). `json` bubbles backend/marshalling errors loudly rather
360
+ # than producing an empty payload.
361
+ # -----------------------------------------------------------------
362
+
363
+ NUMERIC_STRING_RE = /\A\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\s*\z/.freeze
364
+
365
+ def json(value)
366
+ backend.encode_json(value)
367
+ end
368
+
369
+ # JS `String(v)` mirror, EXCEPT `nil` renders as '' (not the literal
370
+ # "null") so an unset prop doesn't surface as literal text in
371
+ # user-facing HTML -- the same divergence the Go/Perl adapters document
372
+ # for their `string` helper. This is the canonical JS-ish stringifier
373
+ # used throughout this file (join, spread_attrs, `h`, reduce's string
374
+ # fold, ...).
375
+ def string(value)
376
+ return '' if value.nil?
377
+ return value ? 'true' : 'false' if value.is_a?(TrueClass) || value.is_a?(FalseClass)
378
+ return Evaluator.number_to_string(value) if value.is_a?(Numeric)
379
+
380
+ value.to_s
381
+ end
382
+
383
+ # HTML-escaping helper for text interpolation (`<%= bf.h(expr) %>` --
384
+ # stdlib ERB does not auto-escape). JS-style stringification via
385
+ # `string` (numbers per JS Number#toString, nil -> "", booleans ->
386
+ # "true"/"false"), then HTML-escaped.
387
+ def h(value)
388
+ html_escape(string(value))
389
+ end
390
+
391
+ # JS truthiness (falsy: false, nil, 0, NaN, ""). Delegates to the
392
+ # shared evaluator so template-emitted conditionals
393
+ # (`if bf.truthy?(x)`) and callback-body evaluation agree byte-for-byte.
394
+ def truthy?(value)
395
+ Evaluator.truthy?(value)
396
+ end
397
+
398
+ # JS `Number(v)` mirror: numeric / boolean inputs convert as expected;
399
+ # non-numeric / nil yield real numeric NaN so downstream arithmetic
400
+ # propagates correctly (`Math.floor(NaN) === NaN`).
401
+ def number(value)
402
+ return Float::NAN if value.nil?
403
+ return value ? 1 : 0 if value.is_a?(TrueClass) || value.is_a?(FalseClass)
404
+ return value if value.is_a?(Numeric)
405
+ return Float(value.strip) if value.is_a?(String) && value.strip =~ NUMERIC_STRING_RE
406
+
407
+ Float::NAN
408
+ end
409
+
410
+ def floor(value)
411
+ n = number(value)
412
+ finite_number?(n) ? n.floor : n
413
+ end
414
+
415
+ def ceil(value)
416
+ n = number(value)
417
+ finite_number?(n) ? n.ceil : n
418
+ end
419
+
420
+ def round(value)
421
+ n = number(value)
422
+ finite_number?(n) ? (n + 0.5).floor : n
423
+ end
424
+
425
+ # -----------------------------------------------------------------
426
+ # Array / String method helpers
427
+ # -----------------------------------------------------------------
428
+
429
+ # `Array.prototype.includes(x)` / `String.prototype.includes(sub)`
430
+ # share a method name in JS; dispatch on Ruby class the way BarefootJS.pm
431
+ # dispatches on `ref()`. The Array arm scans with
432
+ # `Evaluator.same_value_zero?` (SameValueZero: no cross-type coercion,
433
+ # e.g. `[2].includes("2")` is false; `NaN` matches `NaN`) -- the same
434
+ # algorithm the evaluator's serialized-callback `array-method` path uses
435
+ # for `.includes`, so both positions agree.
436
+ def includes(recv, elem)
437
+ return recv.any? { |item| Evaluator.same_value_zero?(item, elem) } if recv.is_a?(Array)
438
+ return false if recv.is_a?(Hash)
439
+
440
+ s = recv.nil? ? '' : string(recv)
441
+ needle = elem.nil? ? '' : string(elem)
442
+ s.include?(needle)
443
+ end
444
+
445
+ # `.filter(fn)` / `.every(fn)` / `.some(fn)` / `.find(fn)` /
446
+ # `.findIndex(fn)` / `.findLast(fn)` / `.findLastIndex(fn)` -- legacy
447
+ # block-predicate path for shapes the compiler lowers to a native
448
+ # callable (e.g. a Kolon-style lambda literal). `pred` is anything
449
+ # responding to `#call(item)`. The `_eval` family below is the
450
+ # evaluator-driven generalisation used for arbitrary pure bodies.
451
+ def filter(recv, pred)
452
+ return [] unless recv.is_a?(Array)
453
+
454
+ recv.select { |item| pred.call(item) }
455
+ end
456
+
457
+ def every(recv, pred)
458
+ return true unless recv.is_a?(Array)
459
+
460
+ recv.all? { |item| pred.call(item) }
461
+ end
462
+
463
+ def some(recv, pred)
464
+ return false unless recv.is_a?(Array)
465
+
466
+ recv.any? { |item| pred.call(item) }
467
+ end
468
+
469
+ def find(recv, pred)
470
+ return nil unless recv.is_a?(Array)
471
+
472
+ recv.find { |item| pred.call(item) }
473
+ end
474
+
475
+ def find_index(recv, pred)
476
+ return -1 unless recv.is_a?(Array)
477
+
478
+ recv.each_index { |i| return i if pred.call(recv[i]) }
479
+ -1
480
+ end
481
+
482
+ def find_last(recv, pred)
483
+ return nil unless recv.is_a?(Array)
484
+
485
+ recv.reverse_each { |item| return item if pred.call(item) }
486
+ nil
487
+ end
488
+
489
+ def find_last_index(recv, pred)
490
+ return -1 unless recv.is_a?(Array)
491
+
492
+ (recv.length - 1).downto(0) { |i| return i if pred.call(recv[i]) }
493
+ -1
494
+ end
495
+
496
+ # `String.prototype.toLowerCase()` / `.toUpperCase()`.
497
+ def lc(s)
498
+ s.nil? ? '' : string(s).downcase
499
+ end
500
+
501
+ def uc(s)
502
+ s.nil? ? '' : string(s).upcase
503
+ end
504
+
505
+ # `Array.prototype.join(sep)` with JS semantics: separator defaults to
506
+ # ",", undefined/null elements render as empty.
507
+ def join(recv, sep = nil)
508
+ return '' unless recv.is_a?(Array)
509
+
510
+ sep = ',' if sep.nil?
511
+ recv.map { |el| string(el) }.join(sep)
512
+ end
513
+
514
+ # `.length` works on both arrays (element count) and strings (character
515
+ # count).
516
+ def length(recv)
517
+ return recv.length if recv.is_a?(Array)
518
+ return 0 if recv.is_a?(Hash) || recv.nil?
519
+
520
+ string(recv).length
521
+ end
522
+
523
+ # `Array.prototype.indexOf(x)` / `.lastIndexOf(x)` -- value-equality
524
+ # search. Non-array receivers return -1.
525
+ def index_of(recv, elem)
526
+ array_index_of(recv, elem, false)
527
+ end
528
+
529
+ def last_index_of(recv, elem)
530
+ array_index_of(recv, elem, true)
531
+ end
532
+
533
+ # `Array.prototype.at(i)` -- negative indices count from the end;
534
+ # out-of-bounds -> nil (renders as '' via `h`, matching JS `undefined`).
535
+ def at(recv, i)
536
+ return nil unless recv.is_a?(Array)
537
+ return nil if i.nil?
538
+
539
+ idx = i.to_i
540
+ len = recv.length
541
+ return nil if len.zero?
542
+
543
+ idx = len + idx if idx.negative?
544
+ return nil if idx.negative? || idx >= len
545
+
546
+ recv[idx]
547
+ end
548
+
549
+ # `Array.prototype.concat(other)` -- merges two arrays in order into a
550
+ # new Array. Non-array operands collapse to empty.
551
+ def concat(a, b)
552
+ out = []
553
+ out.concat(a) if a.is_a?(Array)
554
+ out.concat(b) if b.is_a?(Array)
555
+ out
556
+ end
557
+
558
+ # `Array.prototype.slice(start, end?)`. Mirrors the Go/Perl `bf_slice` /
559
+ # `slice` arithmetic so adapter output stays symmetric.
560
+ def slice(recv, start, end_ = nil)
561
+ return [] unless recv.is_a?(Array)
562
+
563
+ len = recv.length
564
+ return [] if len.zero?
565
+
566
+ s = start.nil? ? 0 : start.to_i
567
+ s = len + s if s.negative?
568
+ s = 0 if s.negative?
569
+ s = len if s > len
570
+
571
+ e = end_.nil? ? len : end_.to_i
572
+ e = len + e if e.negative?
573
+ e = 0 if e.negative?
574
+ e = len if e > len
575
+
576
+ return [] if s >= e
577
+
578
+ recv[s...e]
579
+ end
580
+
581
+ # `Array.prototype.reverse()` / `.toReversed()` -- always returns a new
582
+ # Array (SSR renders a snapshot; the mutate-vs-copy JS distinction is
583
+ # moot here).
584
+ def reverse(recv)
585
+ recv.is_a?(Array) ? recv.reverse : []
586
+ end
587
+
588
+ # `Array.prototype.flat(depth?)` -- flatten nested arrays `depth` levels
589
+ # deep. `depth` of -1 is the `Infinity` sentinel (flatten fully); 0
590
+ # returns a shallow copy.
591
+ def flat(recv, depth = 1)
592
+ return [] unless recv.is_a?(Array)
593
+
594
+ out = []
595
+ recv.each do |el|
596
+ if !depth.zero? && el.is_a?(Array)
597
+ out.concat(flat(el, depth.positive? ? depth - 1 : depth))
598
+ else
599
+ out << el
600
+ end
601
+ end
602
+ out
603
+ end
604
+
605
+ # `Array.prototype.flat(depth)` where `depth` is a DYNAMIC value (#2094)
606
+ # -- e.g. `items.flat(props.depth)` -- rather than a compile-time
607
+ # literal. Coerces `depth` via JS `ToIntegerOrInfinity` (truncate toward
608
+ # zero; NaN/non-numeric -> 0; negative -> 0; +Infinity or a huge finite
609
+ # value -> flatten fully) and delegates to `flat`.
610
+ #
611
+ # This is a SEPARATE method from `flat`, not a smarter overload of it:
612
+ # `flat`'s `depth` parameter treats `-1` as a compile-time SENTINEL
613
+ # meaning "the source literally wrote `Infinity`" (the parser's own
614
+ # normalisation, baked into the emitted template). A genuinely dynamic
615
+ # depth value that happens to be `-1` at render time means the
616
+ # JS-correct OPPOSITE: `.flat(-1)` never recurses (same as `.flat(0)`,
617
+ # a shallow copy), because real JS only recurses when depth > 0.
618
+ # Reusing `flat`'s int contract for a raw dynamic value would silently
619
+ # invert that case, so this coerces FIRST -- mapping a real `+Infinity`
620
+ # / huge finite value to `flat`'s own `-1` sentinel, and a real negative
621
+ # value to `0` -- and only then delegates to `flat`'s recursion. Mirrors
622
+ # Go's `FlatDynamicDepth`/`coerceFlatDepth` (adapter-go-template/runtime/bf.go).
623
+ def flat_dynamic(recv, depth)
624
+ flat(recv, coerce_flat_depth(depth))
625
+ end
626
+
627
+ # `Array.prototype.flatMap(fn)` value-returning field projection: map
628
+ # each element through a self/field projection, then flatten one level.
629
+ def flat_map(recv, key_kind, key)
630
+ return [] unless recv.is_a?(Array)
631
+
632
+ projected = recv.map { |el| key_kind == 'field' ? field_value(el, key) : el }
633
+ flat(projected, 1)
634
+ end
635
+
636
+ # `Array.prototype.flatMap(i => [i.a, i.b])` -- array-literal tuple
637
+ # projection. Each spec is `[kind, key]` (`['self', '']` or
638
+ # `['field', 'a']`).
639
+ def flat_map_tuple(recv, *specs)
640
+ return [] unless recv.is_a?(Array)
641
+
642
+ out = []
643
+ recv.each do |el|
644
+ specs.each do |kind, key|
645
+ out << (kind == 'field' ? field_value(el, key) : el)
646
+ end
647
+ end
648
+ out
649
+ end
650
+
651
+ # `String.prototype.trim()`.
652
+ def trim(recv)
653
+ return '' if recv.nil? || recv.is_a?(Array) || recv.is_a?(Hash)
654
+
655
+ string(recv).gsub(/\A\p{Space}+|\p{Space}+\z/, '')
656
+ end
657
+
658
+ # `Number.prototype.toFixed(digits)` -- fixed-decimal string with
659
+ # zero-padding, rounding half toward +Infinity (matching `round`).
660
+ def to_fixed(value, digits = 0)
661
+ n = number(value)
662
+ return 'NaN' if n.respond_to?(:nan?) && n.nan?
663
+ return n.negative? ? '-Infinity' : 'Infinity' if n.respond_to?(:infinite?) && n.infinite?
664
+
665
+ digits = 0 if digits.nil? || digits.negative?
666
+ factor = 10.0**digits
667
+ rounded = (n * factor + 0.5).floor
668
+ format("%.#{digits}f", rounded / factor)
669
+ end
670
+
671
+ # `String.prototype.split(sep)` -- string -> Array. An empty separator
672
+ # splits into individual characters; a nil separator returns the whole
673
+ # string in a single-element Array; trailing empty fields are kept
674
+ # (JS parity -- Ruby's `String#split(str, -1)` already matches this,
675
+ # and (unlike a Regexp) a String separator is matched literally).
676
+ def split(recv, sep = nil, limit = nil)
677
+ s = (recv.nil? || recv.is_a?(Array) || recv.is_a?(Hash)) ? '' : string(recv)
678
+ parts =
679
+ if sep.nil?
680
+ [s]
681
+ elsif string(sep).empty?
682
+ s.chars
683
+ elsif s.empty?
684
+ ['']
685
+ else
686
+ s.split(string(sep), -1)
687
+ end
688
+ unless limit.nil?
689
+ n = limit.to_i
690
+ if n.zero?
691
+ parts = []
692
+ elsif n.positive? && n < parts.length
693
+ parts = parts[0...n]
694
+ end
695
+ end
696
+ parts
697
+ end
698
+
699
+ # `String.prototype.startsWith(prefix, position?)`.
700
+ def starts_with(recv, prefix, position = nil)
701
+ s = recv.nil? ? '' : string(recv)
702
+ p = prefix.nil? ? '' : string(prefix)
703
+ unless position.nil?
704
+ n = clamp_index(position.to_i, s.length)
705
+ s = s[n..] || ''
706
+ end
707
+ s.start_with?(p)
708
+ end
709
+
710
+ # `String.prototype.endsWith(suffix, endPosition?)`.
711
+ def ends_with(recv, suffix, end_position = nil)
712
+ s = recv.nil? ? '' : string(recv)
713
+ x = suffix.nil? ? '' : string(suffix)
714
+ unless end_position.nil?
715
+ e = clamp_index(end_position.to_i, s.length)
716
+ s = s[0...e]
717
+ end
718
+ s.end_with?(x)
719
+ end
720
+
721
+ # `String.prototype.replace(pattern, replacement)` -- string-pattern
722
+ # form only, replacing the FIRST occurrence, literally (no regex
723
+ # metacharacters, no `$1`-style replacement interpolation).
724
+ def replace(recv, pattern, replacement)
725
+ s = recv.nil? ? '' : string(recv)
726
+ o = pattern.nil? ? '' : string(pattern)
727
+ n = replacement.nil? ? '' : string(replacement)
728
+ return n + s if o.empty?
729
+
730
+ idx = s.index(o)
731
+ return s if idx.nil?
732
+
733
+ s[0...idx] + n + s[(idx + o.length)..]
734
+ end
735
+
736
+ # `queryHref(base, { ... })` (#2042) -- build "base?k=v&..." from a flat
737
+ # list of (guard, key, value) triples. A pair is included iff its guard
738
+ # is truthy AND its value is a non-empty string. A value may instead be
739
+ # an Array, which APPENDS one pair per non-empty member. Repeating a key
740
+ # overwrites the value at its first position (`URLSearchParams.set`
741
+ # semantics); array members always append (`.append` semantics).
742
+ def query(base, *triples)
743
+ b = base.nil? ? '' : string(base)
744
+ pairs = []
745
+ pos = {}
746
+ i = 0
747
+ while i + 2 < triples.length
748
+ guard, key, val = triples[i], triples[i + 1], triples[i + 2]
749
+ i += 3
750
+ next unless truthy?(guard)
751
+
752
+ k = key.nil? ? '' : string(key)
753
+ if val.is_a?(Array)
754
+ val.each do |m|
755
+ sm = string(m)
756
+ pairs << [k, sm] unless sm.empty?
757
+ end
758
+ next
759
+ end
760
+ v = val.nil? ? '' : string(val)
761
+ next if v.empty?
762
+
763
+ if pos.key?(k)
764
+ pairs[pos[k]][1] = v
765
+ else
766
+ pos[k] = pairs.length
767
+ pairs << [k, v]
768
+ end
769
+ end
770
+ return b if pairs.empty?
771
+
772
+ "#{b}?#{pairs.map { |pk, pv| "#{form_escape(pk)}=#{form_escape(pv)}" }.join('&')}"
773
+ end
774
+
775
+ # `String.prototype.repeat(n)` -- a count <= 0 degrades to '' rather
776
+ # than raising (JS throws RangeError for negative counts; SSR
777
+ # templates degrade instead of dying mid-render).
778
+ def repeat(recv, count)
779
+ s = recv.nil? ? '' : string(recv)
780
+ n = count.nil? ? 0 : count.to_i
781
+ n.positive? ? s * n : ''
782
+ end
783
+
784
+ # `String.prototype.padStart` / `padEnd`.
785
+ def pad_start(recv, target, pad_str = nil)
786
+ pad_string(recv.nil? ? '' : string(recv), target, pad_str, true)
787
+ end
788
+
789
+ def pad_end(recv, target, pad_str = nil)
790
+ pad_string(recv.nil? ? '' : string(recv), target, pad_str, false)
791
+ end
792
+
793
+ # `Array.prototype.sort(cmp)` / `.toSorted(cmp)` -- fixed comparator
794
+ # catalogue (legacy, pre-#2018 path; `sort_eval` below handles arbitrary
795
+ # comparator bodies). `opts[:keys]` is a priority-ordered list of
796
+ # `{ key_kind:, key:, compare_type:, direction: }`. Stable (ties break
797
+ # on original index) and non-mutating.
798
+ def sort(recv, opts = {})
799
+ return [] unless recv.is_a?(Array)
800
+
801
+ spec = (opts[:keys] || []).map do |k|
802
+ {
803
+ key_kind: k[:key_kind] || 'self',
804
+ key: k[:key] || '',
805
+ compare_type: k[:compare_type] || 'numeric',
806
+ direction: k[:direction] || 'asc',
807
+ }
808
+ end
809
+ return recv.dup if spec.empty?
810
+
811
+ decorated = recv.each_with_index.map do |item, idx|
812
+ keys = spec.map { |sp| sp[:key_kind] == 'field' ? field_value(item, sp[:key]) : item }
813
+ [keys, item, idx]
814
+ end
815
+
816
+ sorted = decorated.sort do |x, y|
817
+ result = 0
818
+ spec.each_index do |i|
819
+ c = compare_sort_key(x[0][i], y[0][i], spec[i][:compare_type])
820
+ next if c.zero?
821
+
822
+ result = spec[i][:direction] == 'desc' ? -c : c
823
+ break
824
+ end
825
+ result.zero? ? (x[2] <=> y[2]) : result
826
+ end
827
+
828
+ sorted.map { |pair| pair[1] }
829
+ end
830
+
831
+ # Fold an array into a scalar via the arithmetic-fold catalogue
832
+ # (legacy, pre-#2018 path; `reduce_eval` below handles arbitrary
833
+ # reducer bodies). `opts`: `{ op: '+'|'*', key_kind:, key:,
834
+ # type: 'numeric'|'string', init:, direction: 'left'|'right' }`.
835
+ def reduce(recv, opts = {})
836
+ op = opts[:op] || '+'
837
+ key_kind = opts[:key_kind] || 'self'
838
+ key = opts[:key] || ''
839
+ type = opts[:type] || 'numeric'
840
+ direction = opts[:direction] || 'left'
841
+
842
+ items = recv.is_a?(Array) ? recv.dup : []
843
+ items.reverse! if direction == 'right'
844
+ project = lambda { |item| key_kind == 'field' ? field_value(item, key) : item }
845
+
846
+ if type == 'string'
847
+ acc = opts[:init].nil? ? '' : string(opts[:init])
848
+ items.each { |item| acc += string(project.call(item)) }
849
+ return acc
850
+ end
851
+
852
+ # `init` rides through the adapter as whatever literal the template
853
+ # emits -- often a numeric-looking String (JSON-decoded, not a Ruby
854
+ # numeric literal) -- so route it through the same numeric coercion
855
+ # as every per-element projection rather than trusting its Ruby class.
856
+ acc = opts[:init].nil? ? 0 : numeric_or_zero(opts[:init])
857
+ items.each do |item|
858
+ n = numeric_or_zero(project.call(item))
859
+ acc = op == '*' ? acc * n : acc + n
860
+ end
861
+ acc
862
+ end
863
+
864
+ # -----------------------------------------------------------------
865
+ # Evaluator-driven sort / reduce / higher-order predicates (#2018):
866
+ # the comparator / reducer / predicate body rides as a serialized-
867
+ # ParsedExpr JSON string and is evaluated per element, delegating to
868
+ # the shared BarefootJS::Evaluator. `find_eval` / `find_index_eval`
869
+ # take a `forward` flag (false -> findLast / findLastIndex).
870
+ # -----------------------------------------------------------------
871
+
872
+ def sort_eval(recv, cmp_json, param_a, param_b, base_env = {})
873
+ Evaluator.sort_by_json(recv, cmp_json, param_a, param_b, base_env)
874
+ end
875
+
876
+ def reduce_eval(recv, body_json, acc_name, item_name, init, direction = 'left', base_env = {})
877
+ Evaluator.fold_json(recv, body_json, acc_name, item_name, init, direction, base_env)
878
+ end
879
+
880
+ def filter_eval(recv, pred_json, param, base_env = {})
881
+ Evaluator.filter_json(recv, pred_json, param, base_env)
882
+ end
883
+
884
+ def every_eval(recv, pred_json, param, base_env = {})
885
+ Evaluator.every_json(recv, pred_json, param, base_env)
886
+ end
887
+
888
+ def some_eval(recv, pred_json, param, base_env = {})
889
+ Evaluator.some_json(recv, pred_json, param, base_env)
890
+ end
891
+
892
+ def find_eval(recv, pred_json, param, forward = true, base_env = {})
893
+ Evaluator.find_json(recv, pred_json, param, forward, base_env)
894
+ end
895
+
896
+ def find_index_eval(recv, pred_json, param, forward = true, base_env = {})
897
+ Evaluator.find_index_json(recv, pred_json, param, forward, base_env)
898
+ end
899
+
900
+ def flat_map_eval(recv, proj_json, param, base_env = {})
901
+ Evaluator.flat_map_json(recv, proj_json, param, base_env)
902
+ end
903
+
904
+ def map_eval(recv, proj_json, param, base_env = {})
905
+ Evaluator.map_json(recv, proj_json, param, base_env)
906
+ end
907
+
908
+ # -----------------------------------------------------------------
909
+ # JSX intrinsic-element spread (#1407)
910
+ # -----------------------------------------------------------------
911
+ #
912
+ # Mirrors the JS `spreadAttrs` runtime and the Go/Perl adapters'
913
+ # spread helpers so SSR output stays byte-equal across adapters.
914
+ # Generated ERB templates invoke this as `<%= bf.spread_attrs(bag) %>`.
915
+ #
916
+ # Skip rules: nil/false values, event handlers (`on[A-Z]...`), and
917
+ # `children`. `ref` is intentionally NOT filtered (matches the JS
918
+ # reference). Key remap: className -> class, htmlFor -> for; SVG
919
+ # camelCase attrs preserved; other camelCase keys lowered to
920
+ # kebab-case. `style` routes through `style_to_css`. Output is
921
+ # deterministic: keys are sorted alphabetically before emission.
922
+ #
923
+ # Unlike the Perl/Go ports, no boolean-sentinel detection is needed --
924
+ # Ruby's `true`/`false` are real booleans, distinct from `0`/`1`, so a
925
+ # bag value's Ruby class alone tells JS-boolean from JS-number.
926
+ def spread_attrs(bag)
927
+ return '' unless bag.is_a?(Hash)
928
+
929
+ parts = []
930
+ bag.keys.sort_by(&:to_s).each do |key|
931
+ key_s = key.to_s
932
+ if key_s.length > 2 && key_s.start_with?('on')
933
+ c = key_s[2]
934
+ next if c.upcase == c
935
+ end
936
+ next if key_s == 'children'
937
+
938
+ val = bag[key]
939
+ next if val.nil?
940
+
941
+ if val.is_a?(TrueClass) || val.is_a?(FalseClass)
942
+ next unless val
943
+
944
+ parts << to_attr_name(key_s)
945
+ next
946
+ end
947
+
948
+ if key_s == 'style'
949
+ css = style_to_css(val)
950
+ next if css.nil? || css.empty?
951
+
952
+ parts << %(style="#{html_escape(css)}")
953
+ next
954
+ end
955
+
956
+ parts << %(#{to_attr_name(key_s)}="#{html_escape(string(val))}")
957
+ end
958
+ return '' if parts.empty?
959
+
960
+ # Mark the result raw so the calling template's plain `<%=` doesn't
961
+ # need a second escape pass (the backend decides how "raw" is
962
+ # represented for its engine; ERB's own emit has no auto-escape, so
963
+ # BarefootJS::Backend::Erb's `mark_raw` is the identity function).
964
+ backend.mark_raw(parts.join(' '))
965
+ end
966
+
967
+ private
968
+
969
+ def finite_number?(n)
970
+ !(n.respond_to?(:nan?) && n.nan?) && !(n.respond_to?(:infinite?) && n.infinite?)
971
+ end
972
+
973
+ def html_escape(s)
974
+ s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&#34;').gsub("'", '&#39;')
975
+ end
976
+
977
+ SVG_CAMEL_CASE_ATTRS = Set.new(%w[
978
+ allowReorder attributeName attributeType autoReverse
979
+ baseFrequency baseProfile calcMode clipPathUnits
980
+ contentScriptType contentStyleType diffuseConstant edgeMode
981
+ externalResourcesRequired filterRes filterUnits glyphRef
982
+ gradientTransform gradientUnits kernelMatrix kernelUnitLength
983
+ keyPoints keySplines keyTimes lengthAdjust limitingConeAngle
984
+ markerHeight markerUnits markerWidth maskContentUnits
985
+ maskUnits numOctaves pathLength patternContentUnits
986
+ patternTransform patternUnits pointsAtX pointsAtY pointsAtZ
987
+ preserveAlpha preserveAspectRatio primitiveUnits refX refY
988
+ repeatCount repeatDur requiredExtensions requiredFeatures
989
+ specularConstant specularExponent spreadMethod startOffset
990
+ stdDeviation stitchTiles surfaceScale systemLanguage
991
+ tableValues targetX targetY textLength viewBox viewTarget
992
+ xChannelSelector yChannelSelector zoomAndPan
993
+ ]).freeze
994
+ private_constant :SVG_CAMEL_CASE_ATTRS
995
+
996
+ def to_attr_name(key)
997
+ return 'class' if key == 'className'
998
+ return 'for' if key == 'htmlFor'
999
+ return key if SVG_CAMEL_CASE_ATTRS.include?(key)
1000
+
1001
+ # camelCase -> kebab-case, with a leading '-' for an initial uppercase
1002
+ # letter (JS-reference parity, even though that case produces an
1003
+ # HTML-invalid attribute name -- same documented behaviour as the
1004
+ # Go/Perl adapters' `toAttrName`).
1005
+ key.gsub(/([A-Z])/) { "-#{Regexp.last_match(1).downcase}" }
1006
+ end
1007
+
1008
+ def style_to_css(value)
1009
+ return nil if value.nil?
1010
+ unless value.is_a?(Hash)
1011
+ s = string(value)
1012
+ return s.empty? ? nil : s
1013
+ end
1014
+ parts = value.keys.sort_by(&:to_s).filter_map do |key|
1015
+ v = value[key]
1016
+ next if v.nil?
1017
+
1018
+ prop = key.to_s.gsub(/([A-Z])/) { "-#{Regexp.last_match(1).downcase}" }
1019
+ "#{prop}:#{string(v)}"
1020
+ end
1021
+ parts.empty? ? nil : parts.join(';')
1022
+ end
1023
+
1024
+ # application/x-www-form-urlencoded serialisation, matching the
1025
+ # browser's URLSearchParams (which the SSR query render must equal):
1026
+ # keep ASCII alphanumerics and `* - . _`; encode every other byte as
1027
+ # `%XX` (upper hex); space -> `+`. Non-ASCII is encoded byte-wise over
1028
+ # its UTF-8 bytes.
1029
+ def form_escape(s)
1030
+ bytes = (s || '').to_s.encode('UTF-8').b
1031
+ escaped = bytes.gsub(/[^A-Za-z0-9*\-._ ]/n) { |c| format('%%%02X', c.ord) }
1032
+ escaped.tr(' ', '+')
1033
+ end
1034
+
1035
+ def field_value(item, key)
1036
+ item.is_a?(Hash) ? item[field_key(key)] : nil
1037
+ end
1038
+
1039
+ def field_key(key)
1040
+ key.is_a?(Symbol) ? key : key.to_s.to_sym
1041
+ end
1042
+
1043
+ def numeric_like?(v)
1044
+ return true if v.is_a?(Numeric)
1045
+
1046
+ v.is_a?(String) && v.strip =~ NUMERIC_STRING_RE
1047
+ end
1048
+
1049
+ def numeric_value(v)
1050
+ return 0 if v.nil?
1051
+ return v if v.is_a?(Numeric)
1052
+ return Float(v.strip) if v.is_a?(String) && v.strip =~ NUMERIC_STRING_RE
1053
+
1054
+ 0
1055
+ end
1056
+
1057
+ def numeric_or_zero(v)
1058
+ numeric_like?(v) ? numeric_value(v) : 0
1059
+ end
1060
+
1061
+ # ToIntegerOrInfinity-ish coercion for a dynamic `.flat(depth)` argument
1062
+ # (#2094), returning an int in `flat`'s own contract (`-1` = unbounded,
1063
+ # `>= 0` = that many levels). Mirrors Go's `coerceFlatDepth`.
1064
+ def coerce_flat_depth(depth)
1065
+ f = flat_depth_to_float(depth)
1066
+ return 0 if f.nil? || f.nan?
1067
+ return -1 if f.infinite? == 1
1068
+ return 0 if f.infinite? == -1
1069
+
1070
+ trunc = f.truncate
1071
+ return 0 if trunc.negative?
1072
+ # A huge finite depth behaves identically to "flatten fully" in
1073
+ # practice -- capping it here avoids an absurd countdown without
1074
+ # needing a second sentinel (mirrors Go's `coerceFlatDepth`).
1075
+ return -1 if trunc > 1_000_000
1076
+
1077
+ trunc
1078
+ end
1079
+
1080
+ # ToNumber-ish coercion feeding `coerce_flat_depth`, mirroring JS across
1081
+ # the value shapes a dynamic `.flat(depth)` argument can carry: nil ->
1082
+ # not coercible (signalled as `nil`, treated as NaN by the caller);
1083
+ # Numeric -> its float value; bool -> 1/0; a numeric string -> its
1084
+ # value, INCLUDING the "Infinity"/"-Infinity" spellings (unlike Go's
1085
+ # `strconv.ParseFloat`, Ruby's own `Float()` does not parse those, so
1086
+ # they are special-cased here first); any other string -> not coercible.
1087
+ def flat_depth_to_float(v)
1088
+ return nil if v.nil?
1089
+ return v.to_f if v.is_a?(Numeric)
1090
+ return v ? 1.0 : 0.0 if v.is_a?(TrueClass) || v.is_a?(FalseClass)
1091
+ return nil unless v.is_a?(String)
1092
+
1093
+ s = v.strip
1094
+ return 0.0 if s.empty?
1095
+ return Float::INFINITY if s == 'Infinity' || s == '+Infinity'
1096
+ return -Float::INFINITY if s == '-Infinity'
1097
+ return Float(s) if s =~ NUMERIC_STRING_RE
1098
+
1099
+ nil
1100
+ end
1101
+
1102
+ # Compare two projected sort keys, ascending orientation (-1/0/1); the
1103
+ # caller negates for 'desc'. 'auto' compares numerically when both keys
1104
+ # look like numbers, else lexically.
1105
+ def compare_sort_key(av, bv, compare_type)
1106
+ case compare_type
1107
+ when 'string'
1108
+ (av.nil? ? '' : string(av)) <=> (bv.nil? ? '' : string(bv))
1109
+ when 'auto'
1110
+ if numeric_like?(av) && numeric_like?(bv)
1111
+ numeric_value(av) <=> numeric_value(bv)
1112
+ else
1113
+ (av.nil? ? '' : string(av)) <=> (bv.nil? ? '' : string(bv))
1114
+ end
1115
+ else
1116
+ numeric_value(av) <=> numeric_value(bv)
1117
+ end
1118
+ end
1119
+
1120
+ def array_index_of(recv, elem, reverse)
1121
+ return -1 unless recv.is_a?(Array)
1122
+
1123
+ indices = reverse ? (recv.length - 1).downto(0).to_a : (0...recv.length).to_a
1124
+ indices.each do |i|
1125
+ item = recv[i]
1126
+ if item.nil?
1127
+ return i if elem.nil?
1128
+
1129
+ next
1130
+ end
1131
+ return i if !elem.nil? && item == elem
1132
+ end
1133
+ -1
1134
+ end
1135
+
1136
+ def clamp_index(n, len)
1137
+ n = 0 if n.negative?
1138
+ n = len if n > len
1139
+ n
1140
+ end
1141
+
1142
+ def pad_string(s, target, pad_str, at_start)
1143
+ pad_str = pad_str.nil? ? ' ' : string(pad_str)
1144
+ return s if pad_str.empty?
1145
+
1146
+ len = s.length
1147
+ t = target.nil? ? 0 : target.to_i
1148
+ return s if len >= t
1149
+
1150
+ need = t - len
1151
+ fill = (pad_str * ((need / pad_str.length) + 1))[0, need]
1152
+ at_start ? fill + s : s + fill
1153
+ end
1154
+ end
1155
+ end