react_on_rails 16.7.0.rc.2 → 16.7.0.rc.3

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,1308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRails
4
+ module Generators
5
+ module RscSetup
6
+ module ClientReferences # rubocop:disable Metrics/ModuleLength
7
+ JS_STRING_DELIMITERS = ["'", '"', "`"].freeze
8
+ JS_COMMENT_STATES = %i[line_comment block_comment].freeze
9
+ # Known limitation: this list only covers single-character regex preceders. Multi-character
10
+ # JavaScript keywords that legally precede a regex literal (`return`, `typeof`, `void`,
11
+ # `delete`, `throw`, `case`, `in`, `instanceof`) are not represented. A regex like `/\{/`
12
+ # appearing after `return` may be misidentified as a division operator and let `{` / `}`
13
+ # inside the regex body throw off `matching_js_closing_brace`'s depth counter. The
14
+ # unparseable-section detection in `rsc_plugin_options_followed_by_close_paren?` catches
15
+ # the resulting corruption and flags the section as unparseable, so the migration falls
16
+ # back safely rather than producing a wrong rewrite.
17
+ REGEX_LITERAL_PRECEDERS = ["(", "{", "[", "=", ":", ",", ";", "!", "?", "&", "|", "+", "-", "*", "~", "^",
18
+ "<", ">"].freeze
19
+ # Matches `new RSCWebpackPlugin(` allowing whitespace/newlines between `new`, the class
20
+ # name, and the open paren. Shared by the partition scanner and the routing checks so
21
+ # both detect the same set of invocations.
22
+ RSC_PLUGIN_INVOCATION_REGEX = /new\s+RSCWebpackPlugin\s*\(/
23
+
24
+ private
25
+
26
+ def rsc_client_references_js
27
+ <<~'JS'.chomp
28
+ const rscClientReferences = {
29
+ directory: resolve(config.source_path),
30
+ recursive: true,
31
+ include: /\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/,
32
+ };
33
+ JS
34
+ end
35
+
36
+ def inject_rsc_client_imports(config_path, content, existing_imports_content)
37
+ replace_rsc_client_references_setup_anchor(config_path, content, is_server: false) do |anchor|
38
+ join_rsc_client_references_setup(
39
+ content,
40
+ [
41
+ anchor,
42
+ shakapacker_config_import_statement(existing_imports_content),
43
+ path_resolve_import_statement(existing_imports_content),
44
+ rsc_webpack_plugin_import_statement(content),
45
+ "",
46
+ rsc_client_references_js
47
+ ]
48
+ )
49
+ end
50
+ end
51
+
52
+ def inject_rsc_server_imports(config_path, content, existing_imports_content)
53
+ # Server from-scratch insertion only reaches this point after
54
+ # `shakapacker_config_blocker_reason` has confirmed `config` is already in scope.
55
+ # Adding `shakapacker_config_import_statement` here would duplicate that binding.
56
+ replace_rsc_client_references_setup_anchor(config_path, content, is_server: true) do |anchor|
57
+ join_rsc_client_references_setup(
58
+ content,
59
+ [
60
+ anchor,
61
+ path_resolve_import_statement(existing_imports_content),
62
+ rsc_webpack_plugin_import_statement(content),
63
+ "",
64
+ rsc_client_references_js
65
+ ]
66
+ )
67
+ end
68
+ end
69
+
70
+ def prepare_rsc_plugin_imports(config_path, content, existing_imports_content, is_server:)
71
+ if rsc_setup_blocked_by_later_imports?(
72
+ config_path,
73
+ content,
74
+ existing_imports_content,
75
+ is_server: is_server,
76
+ plugin_pending: true
77
+ )
78
+ return inject_rsc_webpack_plugin_import(config_path, content, is_server: is_server) ? :unscoped : :failed
79
+ end
80
+
81
+ # `rsc_client_references_defined?` covers both scoped and unscoped declarations.
82
+ # Re-emitting `rsc_client_references_js` here would produce a duplicate
83
+ # `const rscClientReferences = {...}` and fail the file with
84
+ # `Identifier 'rscClientReferences' has already been declared`. Route to the
85
+ # plugin-import-only path and let downstream callers honor the scoped/unscoped
86
+ # state of whatever the user already wrote.
87
+ if rsc_client_references_defined?(content)
88
+ return :failed unless inject_rsc_webpack_plugin_import(config_path, content, is_server: is_server)
89
+ return :scoped if scoped_rsc_client_references_defined?(content)
90
+
91
+ warn_unscoped_rsc_client_references_helper(config_path)
92
+ return :unscoped
93
+ end
94
+
95
+ return :failed unless inject_rsc_imports(config_path, content, existing_imports_content, is_server: is_server)
96
+ return :scoped if rsc_client_references_setup_ready?(config_path, plugin_pending: true)
97
+
98
+ :failed
99
+ end
100
+
101
+ def inject_rsc_imports(config_path, content, existing_imports_content, is_server:)
102
+ inserted =
103
+ if is_server
104
+ inject_rsc_server_imports(config_path, content, existing_imports_content)
105
+ else
106
+ inject_rsc_client_imports(config_path, content, existing_imports_content)
107
+ end
108
+ return true if inserted
109
+
110
+ warn_rsc_client_references_injection_failed(config_path, plugin_pending: true)
111
+ false
112
+ end
113
+
114
+ def join_rsc_client_references_setup(content, lines)
115
+ line_ending = js_line_ending(content)
116
+ lines.compact.map { |line| line.gsub(/\r?\n/, line_ending) }.join(line_ending)
117
+ end
118
+
119
+ def js_line_ending(content)
120
+ content.include?("\r\n") ? "\r\n" : "\n"
121
+ end
122
+
123
+ def update_existing_rsc_webpack_config(config_path, content, is_server:)
124
+ return unless rsc_plugin_sections_safe_to_rewrite?(config_path, content, is_server: is_server)
125
+ return if rsc_plugin_uses_scoped_client_references?(content, is_server: is_server)
126
+ return unless rsc_client_references_rewrite_needed?(config_path, content, is_server: is_server)
127
+
128
+ return if rewrite_rsc_plugin_client_references(config_path, is_server: is_server)
129
+
130
+ rollback_incomplete_rsc_client_references_setup(config_path, content)
131
+ warn_missing_rsc_plugin_target(config_path, is_server: is_server)
132
+ end
133
+
134
+ def rsc_client_references_rewrite_needed?(config_path, content, is_server:)
135
+ # This predicate prepares the rewrite too: when it returns true, the scoped helper
136
+ # may already have been injected on disk so `rewrite_rsc_plugin_client_references`
137
+ # can re-read fresh content with valid offsets.
138
+ if rsc_plugin_references_any_scoped_client_references?(content, is_server: is_server)
139
+ return false unless ensure_rsc_client_references_setup(config_path, content, is_server: is_server)
140
+
141
+ return rsc_plugin_without_client_references?(content, is_server: is_server)
142
+ end
143
+
144
+ rewritable_rsc_plugin?(config_path, content, is_server: is_server) &&
145
+ ensure_rsc_client_references_setup(config_path, content, is_server: is_server)
146
+ end
147
+
148
+ # Detects RSCWebpackPlugin option blocks that the lightweight JS scanner could not parse
149
+ # cleanly (most often a regex literal with an unmatched `{` / `}` that walks the depth
150
+ # counter past the real closing brace). When found, we warn and refuse to rewrite anything
151
+ # in the file so a sibling rewrite cannot accidentally splice into a wrong location.
152
+ def rsc_plugin_sections_safe_to_rewrite?(config_path, content, is_server:)
153
+ unparseable = rsc_plugin_option_sections_partition(content, is_server: is_server).fetch(:unparseable)
154
+ return true if unparseable.zero?
155
+
156
+ warn_unparseable_rsc_plugin_sections(config_path, unparseable)
157
+ false
158
+ end
159
+
160
+ def rewritable_rsc_plugin?(config_path, content, is_server:)
161
+ # Mixed same-target plugins are still rewritable: the later rewrite only updates plugins
162
+ # missing clientReferences and leaves sibling custom clientReferences untouched.
163
+ return true if rsc_plugin_without_client_references?(content, is_server: is_server)
164
+
165
+ if rsc_plugin_defines_client_references?(content, is_server: is_server)
166
+ GeneratorMessages.add_warning(
167
+ "Skipped scoped clientReferences migration for #{config_path} because all matching " \
168
+ "RSCWebpackPlugin instances already define clientReferences (some may already be " \
169
+ "correctly scoped to rscClientReferences). Please verify manually."
170
+ )
171
+ return false
172
+ end
173
+
174
+ warn_missing_rsc_plugin_target(config_path, is_server: is_server)
175
+ false
176
+ end
177
+
178
+ def warn_unparseable_rsc_plugin_sections(config_path, count)
179
+ GeneratorMessages.add_warning(
180
+ "Skipped scoped clientReferences migration for #{config_path}: #{count} RSCWebpackPlugin " \
181
+ "options block(s) contain characters this lightweight scanner cannot parse safely " \
182
+ "(most often a regex literal with an unmatched `{` or `}`, e.g. `/\\{/` or `/[{]/`, " \
183
+ "or a regex literal after a keyword context such as `return` or `typeof`). " \
184
+ "All RSCWebpackPlugin calls in the file must be parseable for auto-migration to proceed, " \
185
+ "including calls targeting the other bundle. " \
186
+ "Please add `clientReferences: rscClientReferences` manually to any RSCWebpackPlugin " \
187
+ "that is missing it."
188
+ )
189
+ end
190
+
191
+ def ensure_rsc_client_references_setup(config_path, content, is_server:)
192
+ return true if scoped_rsc_client_references_defined?(content)
193
+
194
+ if rsc_client_references_defined?(content)
195
+ warn_unscoped_rsc_client_references_helper(config_path)
196
+ return false
197
+ end
198
+
199
+ unless rsc_client_references_setup_anchor?(content, is_server: is_server)
200
+ warn_missing_rsc_client_references_anchor(config_path)
201
+ return false
202
+ end
203
+
204
+ existing_imports_content = content_through_rsc_setup_anchor(content, is_server: is_server)
205
+ return false if rsc_setup_blocked_by_later_imports?(config_path, content, existing_imports_content,
206
+ is_server: is_server)
207
+
208
+ return false unless add_rsc_client_references_setup(config_path, content, existing_imports_content,
209
+ is_server: is_server)
210
+ return true if options[:skip]
211
+
212
+ rsc_client_references_setup_ready?(config_path)
213
+ end
214
+
215
+ def rsc_plugin_uses_scoped_client_references?(content, is_server:)
216
+ scoped_rsc_client_references_defined?(content) &&
217
+ rsc_plugin_references_scoped_client_references?(content, is_server: is_server)
218
+ end
219
+
220
+ def rsc_plugin_references_scoped_client_references?(content, is_server:)
221
+ sections = rsc_plugin_option_sections(content, is_server: is_server)
222
+ return false if sections.empty?
223
+
224
+ sections.all? do |section|
225
+ rsc_plugin_body_has_top_level_scoped_client_references?(section.fetch(:body))
226
+ end
227
+ end
228
+
229
+ def rsc_plugin_references_any_scoped_client_references?(content, is_server:)
230
+ rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
231
+ rsc_plugin_body_has_top_level_scoped_client_references?(section.fetch(:body))
232
+ end
233
+ end
234
+
235
+ def rsc_plugin_client_references_configured?(content, is_server:)
236
+ sections = rsc_plugin_option_sections(content, is_server: is_server)
237
+ # No parseable `isServer: <bool>` section means this file's plugin call sits outside
238
+ # what the generator's scanner can match (e.g. options are computed at runtime, or the
239
+ # plugin is invoked without an options object). Verification callers intentionally
240
+ # under-report here: warning about "missing scoped clientReferences" when there's no
241
+ # section to inspect would only surface noise for dynamic invocations like
242
+ # `RSCWebpackPlugin(buildOptions())`, where the user has nothing actionable to do.
243
+ return true if sections.empty?
244
+
245
+ sections.all? do |section|
246
+ body = section.fetch(:body)
247
+ if rsc_plugin_body_has_top_level_scoped_client_references?(body)
248
+ scoped_rsc_client_references_defined?(content)
249
+ else
250
+ rsc_plugin_body_has_top_level_key?(body, "clientReferences")
251
+ end
252
+ end
253
+ end
254
+
255
+ def rsc_plugin_defines_client_references?(content, is_server:)
256
+ rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
257
+ rsc_plugin_body_has_top_level_key?(section.fetch(:body), "clientReferences")
258
+ end
259
+ end
260
+
261
+ def rsc_plugin_without_client_references?(content, is_server:)
262
+ rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
263
+ !rsc_plugin_body_has_top_level_key?(section.fetch(:body), "clientReferences")
264
+ end
265
+ end
266
+
267
+ # Strips JavaScript line and block comments while preserving string-literal contents,
268
+ # so `clientReferences:` / `isServer:` substrings inside strings are not mis-detected.
269
+ # Shares the `advance_js_scan_state` family used by `js_top_level_position?` and
270
+ # `matching_js_closing_brace` so all JS-aware passes follow the same comment/string rules.
271
+ # Regex literals (e.g. `/a{2}/`) are still outside this scanner's supported surface
272
+ # because brace quantifiers can confuse `matching_js_closing_brace`'s depth counter.
273
+ def rsc_plugin_options_without_comments(options)
274
+ result = String.new(capacity: options.length)
275
+ state = nil
276
+ escaped = false
277
+ index = 0
278
+
279
+ while index < options.length
280
+ char = options[index]
281
+ next_char = options[index + 1]
282
+ previous_state = state
283
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
284
+
285
+ # Emit when (a) we're outside comments and strings and not just entering one, or
286
+ # (b) we're inside or exiting a string (preserves the opening/closing quote and the
287
+ # string contents), or (c) we're closing a line comment so the trailing `\n` survives
288
+ # for line-anchored regex matching downstream (e.g. `^\s*isServer`).
289
+ emit_char = (previous_state.nil? && !JS_COMMENT_STATES.include?(state)) ||
290
+ JS_STRING_DELIMITERS.include?(previous_state) ||
291
+ (previous_state == :line_comment && char == "\n")
292
+ result << char if emit_char
293
+
294
+ index += 1
295
+ end
296
+
297
+ result
298
+ end
299
+
300
+ # NOTE: `js_code_position?` itself walks from index 0 on each call, so the partition runs
301
+ # in O(n³ × m) (content-length³ × plugin-count) — `js_regex_literal_start?` calls
302
+ # `last_js_code_char_index` on each character, making `js_code_position?` itself O(n²),
303
+ # and every plugin hit triggers a fresh rescan. Acceptable for small webpack configs
304
+ # (a few hundred lines, a handful of plugins); if this scanner is ever reused on larger
305
+ # inputs, carry scanner state forward between iterations before adopting it.
306
+ def rsc_plugin_option_sections(content, is_server:)
307
+ rsc_plugin_option_sections_partition(content, is_server: is_server).fetch(:safe)
308
+ end
309
+
310
+ # Returns the matching plugin sections plus a count of `RSCWebpackPlugin(` invocations
311
+ # whose options block could not be parsed cleanly. An invocation is treated as
312
+ # unparseable when the depth scanner cannot find a matching `}` (over-count caused by an
313
+ # unmatched `{` in a regex literal) or when the `}` it finds is not followed by the `)`
314
+ # that would close the `new RSCWebpackPlugin(...)` call (under-count caused by an
315
+ # unmatched `}` in a regex literal). Both cases mean a rewrite based on this section
316
+ # would corrupt the file, so callers must surface a warning instead of silently skipping.
317
+ def rsc_plugin_option_sections_partition(content, is_server:)
318
+ safe = []
319
+ unparseable = 0
320
+ search_from = 0
321
+
322
+ while (match = content.match(RSC_PLUGIN_INVOCATION_REGEX, search_from))
323
+ call_start = match.begin(0)
324
+ after_open_paren = match.end(0)
325
+ unless js_code_position?(content, call_start)
326
+ search_from = after_open_paren
327
+ next
328
+ end
329
+
330
+ options_start = first_significant_js_index(content, after_open_paren)
331
+ unless options_start && content[options_start] == "{"
332
+ search_from = after_open_paren
333
+ next
334
+ end
335
+
336
+ options_end = matching_js_closing_brace(content, options_start)
337
+ unless options_end
338
+ unparseable += 1
339
+ search_from = options_start + 1
340
+ next
341
+ end
342
+
343
+ unless rsc_plugin_options_followed_by_close_paren?(content, options_end)
344
+ unparseable += 1
345
+ search_from = options_end + 1
346
+ next
347
+ end
348
+
349
+ body = content[(options_start + 1)...options_end]
350
+ if rsc_plugin_is_server_match?(body, is_server: is_server)
351
+ safe << { body: body, body_start: options_start + 1, body_end: options_end }
352
+ end
353
+ search_from = options_end + 1
354
+ end
355
+
356
+ { safe: safe, unparseable: unparseable }
357
+ end
358
+
359
+ # Walks forward from the assumed closing `}` of an options object, skipping whitespace
360
+ # and JS comments, and confirms the next significant character is `)`. Used to detect
361
+ # when `matching_js_closing_brace` was confused by a regex literal and returned an
362
+ # earlier `}` than the real options-object close.
363
+ #
364
+ # String literals between `}` and `)` are not handled because no valid JS places one
365
+ # there in `new RSCWebpackPlugin({...})` — a leading string-delimiter character would
366
+ # simply be returned as a non-`)` and the section would be marked unparseable, which is
367
+ # the safe outcome.
368
+ def rsc_plugin_options_followed_by_close_paren?(content, options_end)
369
+ state = nil
370
+ escaped = false
371
+ index = options_end + 1
372
+ # Tolerate one trailing comma between the options object and `)` so configs formatted
373
+ # with Prettier's `trailingComma: "all"` (`new RSCWebpackPlugin({...},)`) aren't flagged
374
+ # as unparseable. A second comma still bails — that would be invalid JS.
375
+ comma_seen = false
376
+
377
+ while index < content.length
378
+ char = content[index]
379
+ next_char = content[index + 1]
380
+ prev_state = state
381
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
382
+ # Exiting a block comment leaves `char` as `*` and `index` pointing at the closing
383
+ # `/`; advance past it so the next iteration evaluates the first character after `*/`.
384
+ if state || prev_state == :block_comment
385
+ index += 1
386
+ next
387
+ end
388
+
389
+ unless char.match?(/\s/)
390
+ return true if char == ")"
391
+ return false if comma_seen || char != ","
392
+
393
+ comma_seen = true
394
+ end
395
+
396
+ index += 1
397
+ end
398
+
399
+ false
400
+ end
401
+
402
+ # Skips whitespace, JS line/block comments, and leading string literals so callers see the
403
+ # next structural character. Without comment skipping, configurations like
404
+ # `new RSCWebpackPlugin( /* opts */ {` would land on `/` and be rejected as "no plugin
405
+ # options" even though the options object is present.
406
+ def first_significant_js_index(content, start_index)
407
+ index = start_index
408
+ state = nil
409
+ escaped = false
410
+
411
+ while index < content.length
412
+ char = content[index]
413
+ next_char = content[index + 1]
414
+ prev_state = state
415
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
416
+ # Exiting a block comment leaves `char` as `*` and `index` pointing at the closing
417
+ # `/`; advance past it so the next iteration evaluates the first character after `*/`.
418
+ if state || prev_state == :block_comment
419
+ index += 1
420
+ next
421
+ end
422
+
423
+ return index unless char.match?(/\s/) || JS_STRING_DELIMITERS.include?(prev_state)
424
+
425
+ index += 1
426
+ end
427
+
428
+ nil
429
+ end
430
+
431
+ # Expects `content[open_index] == "{"`; callers pass the options-object opening brace.
432
+ # This lightweight scanner treats template literals as opaque strings (backtick to backtick).
433
+ # Simple `${...}` expressions are handled correctly: while in the backtick state every
434
+ # character — including `{` and `}` inside the expression — is consumed as string content
435
+ # and never reaches the depth counter. The real unsupported case is *nested* template
436
+ # literals (e.g. `` `outer ${`inner`}` ``) where the inner backtick falsely closes the outer
437
+ # string state, exposing later braces to the depth counter. Callers detect that via
438
+ # `rsc_plugin_options_followed_by_close_paren?` and mark the section unparseable rather
439
+ # than producing a corrupt rewrite. Regex literals are outside this scanner's supported
440
+ # surface for the same reason.
441
+ def matching_js_closing_brace(content, open_index)
442
+ depth = 0
443
+ index = open_index
444
+ state = nil
445
+ escaped = false
446
+
447
+ while index < content.length
448
+ char = content[index]
449
+ # Nil at EOF is safe because downstream comparisons treat it as a non-match.
450
+ next_char = content[index + 1]
451
+
452
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
453
+ if state
454
+ index += 1
455
+ next
456
+ end
457
+
458
+ # `block_comment_exit_guard`: relies on `*` matching neither `{` nor `}`, so the
459
+ # `*` returned at a `*/` exit is harmless here. If any future check below adds a
460
+ # branch that could fire on `*` (or `)`, `/`, etc.), guard it with
461
+ # `prev_state == :block_comment` per the contract on `advance_js_scan_state`.
462
+ depth += 1 if char == "{"
463
+ if char == "}"
464
+ depth -= 1
465
+ return index if depth.zero?
466
+ end
467
+ index += 1
468
+ end
469
+
470
+ nil
471
+ end
472
+
473
+ # Return index is the last consumed character. Line comments leave the newline
474
+ # for the caller's normal index increment; block comments consume the closing slash.
475
+ #
476
+ # IMPORTANT CALLER CONTRACT — block-comment exit:
477
+ # When this returns from a `*/` exit, `state` is cleared, but `char` is still the `*` and
478
+ # the returned `index` points at the closing `/` (so the caller's trailing `index += 1`
479
+ # lands on the first char after `*/`). Any caller whose post-call branch inspects `char`
480
+ # as a "significant character" (e.g. `==` checks against `*`, `/`, `)`, `{`, `}`) MUST
481
+ # explicitly guard on `prev_state == :block_comment` before that branch — otherwise the
482
+ # `*` from the comment terminator is misread as code. Callers that only update accumulator
483
+ # state (depth counters, string/comment booleans) are inherently safe because `*` doesn't
484
+ # affect those. Searchable invariant name: `block_comment_exit_guard`.
485
+ def advance_js_scan_state(state, escaped, char, next_char, index)
486
+ return [char == "\n" ? nil : :line_comment, escaped, index] if state == :line_comment
487
+ return advance_js_block_comment_state(escaped, char, next_char, index) if state == :block_comment
488
+ return advance_js_string_state(state, escaped, char, index) if JS_STRING_DELIMITERS.include?(state)
489
+
490
+ advance_js_default_scan_state(escaped, char, next_char, index)
491
+ end
492
+
493
+ def advance_js_block_comment_state(escaped, char, next_char, index)
494
+ return [nil, escaped, index + 1] if char == "*" && next_char == "/"
495
+
496
+ [:block_comment, escaped, index]
497
+ end
498
+
499
+ def advance_js_string_state(state, escaped, char, index)
500
+ return [state, false, index] if escaped
501
+ return [state, true, index] if char == "\\"
502
+ # Explicit `false` (rather than passing `escaped` through) so a caller starting mid-parse
503
+ # in a string state with a stale `escaped = true` cannot silently suppress the closing
504
+ # quote and leave the scanner stuck in string state.
505
+ return [nil, false, index] if char == state
506
+
507
+ [state, escaped, index]
508
+ end
509
+
510
+ def advance_js_default_scan_state(escaped, char, next_char, index)
511
+ return [:line_comment, escaped, index + 1] if char == "/" && next_char == "/"
512
+ return [:block_comment, escaped, index + 1] if char == "/" && next_char == "*"
513
+ return [char, escaped, index] if JS_STRING_DELIMITERS.include?(char)
514
+
515
+ [nil, escaped, index]
516
+ end
517
+
518
+ def js_code_position?(content, target_index)
519
+ state = nil
520
+ escaped = false
521
+ index = 0
522
+
523
+ while index < target_index
524
+ char = content[index]
525
+ next_char = content[index + 1]
526
+ if state.nil? && js_regex_literal_start?(content, index)
527
+ regex_end = js_regex_literal_end(content, index)
528
+ return false if regex_end >= target_index
529
+
530
+ index = regex_end + 1
531
+ next
532
+ end
533
+
534
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
535
+ index += 1
536
+ end
537
+
538
+ state.nil?
539
+ end
540
+
541
+ def js_top_level_position?(content, target_index)
542
+ state = nil
543
+ escaped = false
544
+ depth = 0
545
+ index = 0
546
+
547
+ while index < target_index
548
+ char = content[index]
549
+ next_char = content[index + 1]
550
+ if state.nil? && js_regex_literal_start?(content, index)
551
+ regex_end = js_regex_literal_end(content, index)
552
+ return false if regex_end >= target_index
553
+
554
+ index = regex_end + 1
555
+ next
556
+ end
557
+
558
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
559
+ if state
560
+ index += 1
561
+ next
562
+ end
563
+
564
+ depth += js_depth_delta(char, depth)
565
+ index += 1
566
+ end
567
+
568
+ state.nil? && depth.zero?
569
+ end
570
+
571
+ def js_depth_delta(char, depth)
572
+ return 1 if char == "{"
573
+ return -1 if char == "}" && depth.positive?
574
+
575
+ 0
576
+ end
577
+
578
+ def js_regex_literal_start?(content, index)
579
+ return false unless content[index] == "/"
580
+ return false if ["/", "*"].include?(content[index + 1])
581
+
582
+ previous = previous_significant_js_char(content, index)
583
+ previous.nil? || REGEX_LITERAL_PRECEDERS.include?(previous)
584
+ end
585
+
586
+ def previous_significant_js_char(content, index)
587
+ prefix = content[0...index]
588
+ previous_index = last_js_code_char_index(prefix)
589
+ previous_index ? prefix[previous_index] : nil
590
+ end
591
+
592
+ def js_regex_literal_end(content, start_index)
593
+ index = start_index + 1
594
+ escaped = false
595
+ in_character_class = false
596
+
597
+ while index < content.length
598
+ char = content[index]
599
+ return js_regex_literal_flag_end(content, index) if js_regex_literal_closing_slash?(
600
+ char, escaped, in_character_class
601
+ )
602
+
603
+ escaped, in_character_class = next_js_regex_literal_state(char, escaped, in_character_class)
604
+ index += 1
605
+ end
606
+
607
+ # Unterminated regex literals leave the rest of the file in invalid JS, so treat the
608
+ # remaining content as non-code instead of resuming the normal brace/import scanner.
609
+ content.length - 1
610
+ end
611
+
612
+ def js_regex_literal_closing_slash?(char, escaped, in_character_class)
613
+ char == "/" && !escaped && !in_character_class
614
+ end
615
+
616
+ def js_regex_literal_flag_end(content, slash_index)
617
+ index = slash_index + 1
618
+ index += 1 while content[index]&.match?(/[a-z]/i)
619
+ index - 1
620
+ end
621
+
622
+ def next_js_regex_literal_state(char, escaped, in_character_class)
623
+ return [false, in_character_class] if escaped
624
+ return [true, in_character_class] if char == "\\"
625
+ return [false, true] if char == "["
626
+ return [false, false] if char == "]"
627
+
628
+ [false, in_character_class]
629
+ end
630
+
631
+ # Depth-aware: a nested `metadata: { isServer: true }` inside another option's value
632
+ # must NOT route the section into the `is_server: true` partition bucket — otherwise
633
+ # the rewrite would correctly bail (the splice helper is also depth-aware) but
634
+ # `warn_missing_rsc_plugin_target` would still fire and the user sees a misleading
635
+ # "no plugin options with isServer: true could be rewritten" warning for a config that
636
+ # is actually fine. Mirrors `rsc_plugin_body_has_top_level_key?` so partitioning and
637
+ # splicing agree on what counts as a top-level `isServer:` pair. Comment/string skipping
638
+ # is handled by `js_top_level_position?` via the shared `advance_js_scan_state`.
639
+ def rsc_plugin_is_server_match?(body, is_server:)
640
+ pattern = /\bisServer\s*:\s*#{Regexp.escape(is_server.to_s)}\b/
641
+ search_from = 0
642
+ while (match = pattern.match(body, search_from))
643
+ return true if js_top_level_position?(body, match.begin(0))
644
+
645
+ search_from = match.end(0)
646
+ end
647
+ false
648
+ end
649
+
650
+ def rewrite_rsc_plugin_client_references(config_path, is_server:)
651
+ full_path = File.join(destination_root, config_path)
652
+ # Re-read because ensure_rsc_client_references_setup may have just inserted the helper,
653
+ # making the caller's in-memory body_start/body_end offsets stale.
654
+ content = File.read(full_path)
655
+ rewrites = rsc_plugin_option_sections(content, is_server: is_server).filter_map do |candidate|
656
+ body = candidate.fetch(:body)
657
+ # Depth-aware check: a nested `clientReferences:` (e.g. inside a sibling object
658
+ # literal) must not be mistaken for a configured top-level option, or we'd skip
659
+ # the migration and leave the real plugin unscoped.
660
+ next if rsc_plugin_body_has_top_level_key?(body, "clientReferences")
661
+
662
+ rewritten_body = add_client_references_to_rsc_plugin_body(body, is_server: is_server)
663
+ next if rewritten_body == body
664
+
665
+ [candidate, rewritten_body]
666
+ end
667
+
668
+ # The sole caller (`update_existing_rsc_webpack_config`) translates this `false` into
669
+ # a `warn_missing_rsc_plugin_target` warning, so silently returning here is intentional.
670
+ return false if rewrites.empty?
671
+
672
+ # Reverse order so earlier offsets stay valid as later sections are spliced.
673
+ rewrites.reverse_each do |section, rewritten_body|
674
+ content[section.fetch(:body_start)...section.fetch(:body_end)] = rewritten_body
675
+ end
676
+ write_existing_rsc_config(config_path, content, action: :rewrite)
677
+ true
678
+ end
679
+
680
+ def rollback_incomplete_rsc_client_references_setup(config_path, original_content)
681
+ return if options[:pretend] || options[:skip]
682
+ return if scoped_rsc_client_references_defined?(original_content)
683
+
684
+ full_path = File.join(destination_root, config_path)
685
+ return unless scoped_rsc_client_references_defined?(File.read(full_path))
686
+
687
+ say_status(:revert, config_path, :yellow)
688
+ File.write(full_path, original_content)
689
+ end
690
+
691
+ # Multi-line option objects get the new key on its own line just before the closing brace,
692
+ # with indentation matching the last existing key, so the result reads cleanly without a
693
+ # formatter pass. Single-line option objects keep the same-line splice immediately after
694
+ # `isServer:` because that's the only readable place for a one-line object literal.
695
+ def add_client_references_to_rsc_plugin_body(body, is_server:)
696
+ pattern = /\bisServer\s*:\s*#{Regexp.escape(is_server.to_s)}\b/
697
+ search_from = 0
698
+
699
+ while (matched_is_server = pattern.match(body, search_from))
700
+ # Require depth zero so a nested `isServer:` inside a sibling object literal
701
+ # doesn't cause the splice to land inside the wrong object — `body` is the
702
+ # content between the plugin options braces, so depth 0 == top-level options.
703
+ if js_top_level_position?(body, matched_is_server.begin(0))
704
+ return splice_client_references_into_rsc_plugin_body(body, matched_is_server)
705
+ end
706
+
707
+ search_from = matched_is_server.end(0)
708
+ end
709
+
710
+ body
711
+ end
712
+
713
+ def inject_rsc_webpack_plugin_import(config_path, content, is_server:)
714
+ replace_rsc_client_references_setup_anchor(config_path, content, is_server: is_server) do |anchor|
715
+ join_rsc_client_references_setup(
716
+ content,
717
+ [
718
+ anchor,
719
+ rsc_webpack_plugin_import_statement(content)
720
+ ]
721
+ )
722
+ end
723
+ end
724
+
725
+ # Walks every `<key>:` / shorthand `<key>` / quoted `"<key>":` match in the plugin options
726
+ # body and returns true when at least one sits at the top level of the options object
727
+ # (depth 0 from the body's perspective, outside strings and comments). Used to gate
728
+ # "already configured" checks so nested mentions and value references don't cause false
729
+ # positives.
730
+ def rsc_plugin_body_has_top_level_key?(body, key)
731
+ rsc_plugin_body_has_top_level_bare_key?(body, key) ||
732
+ rsc_plugin_body_has_top_level_quoted_key?(body, key)
733
+ end
734
+
735
+ def rsc_plugin_body_has_top_level_bare_key?(body, key)
736
+ pattern = /\b#{Regexp.escape(key)}\b/
737
+ search_from = 0
738
+
739
+ while (match = pattern.match(body, search_from))
740
+ return true if js_top_level_position?(body, match.begin(0)) &&
741
+ rsc_plugin_body_key_position?(body, match.begin(0), match.end(0))
742
+
743
+ search_from = match.end(0)
744
+ end
745
+
746
+ false
747
+ end
748
+
749
+ def rsc_plugin_body_has_top_level_quoted_key?(body, key)
750
+ pattern = /(['"`])#{Regexp.escape(key)}\1/
751
+ search_from = 0
752
+
753
+ while (match = pattern.match(body, search_from))
754
+ return true if js_top_level_position?(body, match.begin(0)) &&
755
+ rsc_plugin_body_key_position?(body, match.begin(0), match.end(0), quoted: true)
756
+
757
+ search_from = match.end(0)
758
+ end
759
+
760
+ false
761
+ end
762
+
763
+ def rsc_plugin_body_key_position?(body, key_start, key_end, quoted: false)
764
+ previous_char = previous_significant_js_char(body, key_start)
765
+ return false unless previous_char.nil? || previous_char == ","
766
+
767
+ next_index = first_significant_js_index(body, key_end)
768
+ next_char = next_index ? body[next_index] : nil
769
+ return next_char == ":" if quoted
770
+
771
+ next_char.nil? || next_char == ":" || next_char == ","
772
+ end
773
+
774
+ # Top-level depth-aware match for the migrated `clientReferences: rscClientReferences`
775
+ # pair. Mirrors `rsc_plugin_body_has_top_level_key?` so verification and gating share
776
+ # the same comment-, string-, and brace-aware semantics as the rewrite path.
777
+ def rsc_plugin_body_has_top_level_scoped_client_references?(body)
778
+ [
779
+ /\bclientReferences\s*:\s*rscClientReferences\b/,
780
+ /(['"`])clientReferences\1\s*:\s*rscClientReferences\b/
781
+ ].any? do |pattern|
782
+ search_from = 0
783
+
784
+ while (match = pattern.match(body, search_from))
785
+ return true if js_top_level_position?(body, match.begin(0))
786
+
787
+ search_from = match.end(0)
788
+ end
789
+ end
790
+
791
+ false
792
+ end
793
+
794
+ def splice_client_references_into_rsc_plugin_body(body, is_server_match)
795
+ return splice_client_references_at_close_brace(body) if body.include?("\n")
796
+ # Other options follow `isServer:` on the same line — append after the last option so
797
+ # `clientReferences` lands at the end of the object, matching the multi-line path's
798
+ # close-brace splice rather than landing mid-object.
799
+ return splice_client_references_at_single_line_end(body) if trailing_options_after?(body, is_server_match)
800
+
801
+ "#{body[0...is_server_match.end(0)]}, clientReferences: rscClientReferences" \
802
+ "#{body[is_server_match.end(0)..]}"
803
+ end
804
+
805
+ def trailing_options_after?(body, is_server_match)
806
+ rest = body[is_server_match.end(0)..] || ""
807
+ # A bare trailing comma (`isServer: false,`) is structural, not another option, so
808
+ # consider only non-whitespace beyond it as "trailing options".
809
+ rest.sub(/\A\s*,/, "").match?(/\S/)
810
+ end
811
+
812
+ def splice_client_references_at_single_line_end(body)
813
+ trailing = body[/\s*\z/]
814
+ content = body[0...(body.length - trailing.length)]
815
+ content_without_comments = rsc_plugin_options_without_comments(content).rstrip
816
+ separator = content_without_comments.end_with?(",") ? " " : ", "
817
+ "#{content}#{separator}clientReferences: rscClientReferences#{trailing}"
818
+ end
819
+
820
+ def splice_client_references_at_close_brace(body)
821
+ trailing = body[/\s*\z/]
822
+ content = body[0...(body.length - trailing.length)]
823
+ line_ending = js_line_ending(body)
824
+
825
+ last_code_index = last_js_code_char_index(content)
826
+ last_line_start = last_code_index ? last_js_code_line_start(content, last_code_index) : 0
827
+ # `[ \t]+` (one-or-more) so the regex returns nil when the last line is unindented,
828
+ # letting the `|| " "` fallback actually fire. With `*` the regex would always match
829
+ # the empty string and the fallback was unreachable dead code.
830
+ indent = content[last_line_start..][/\A[ \t]+/] || " "
831
+
832
+ content_without_comments = rsc_plugin_options_without_comments(content).rstrip
833
+ needs_comma = !content_without_comments.end_with?(",")
834
+ prefix = build_splice_prefix(content, needs_comma: needs_comma)
835
+
836
+ "#{prefix}#{line_ending}#{indent}clientReferences: rscClientReferences,#{trailing}"
837
+ end
838
+
839
+ def last_js_code_line_start(content, last_code_index)
840
+ state = nil
841
+ escaped = false
842
+ index = 0
843
+ line_start = 0
844
+
845
+ while index <= last_code_index
846
+ char = content[index]
847
+ next_char = content[index + 1]
848
+ prev_state = state
849
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
850
+ line_start = index + 1 if char == "\n" && !JS_STRING_DELIMITERS.include?(prev_state)
851
+
852
+ index += 1
853
+ end
854
+
855
+ line_start
856
+ end
857
+
858
+ # Inserts the trailing comma before any final line/block comment so the rewritten file reads
859
+ # cleanly to a human. Appending the comma to the raw `content` would tuck it inside a
860
+ # trailing `// note` (yielding `isServer: false // note,`) — syntactically valid because
861
+ # the comment hides the comma from the parser, but visually broken in code review and
862
+ # likely to confuse a linter.
863
+ def build_splice_prefix(content, needs_comma:)
864
+ return content unless needs_comma
865
+
866
+ last_code_index = last_js_code_char_index(content)
867
+ return "#{content}," unless last_code_index
868
+
869
+ "#{content[0..last_code_index]},#{content[(last_code_index + 1)..]}"
870
+ end
871
+
872
+ # Returns the index of the last character in `content` that is part of executable code —
873
+ # i.e. neither whitespace nor inside a JS line/block comment. Strings count as code so a
874
+ # value ending in `"foo"` keeps the closing quote in scope. Uses the shared
875
+ # `advance_js_scan_state` family so comment/string handling matches every other JS-aware
876
+ # pass in this file.
877
+ def last_js_code_char_index(content)
878
+ state = nil
879
+ escaped = false
880
+ index = 0
881
+ result = nil
882
+
883
+ while index < content.length
884
+ char = content[index]
885
+ next_char = content[index + 1]
886
+ prev_state = state
887
+ # Capture the pre-advance position so `result` always points at `char`'s index,
888
+ # not the post-advance value that `advance_js_scan_state` may bump for `//`/`/*`/`*/`
889
+ # transitions. The comment-state guard below covers those transitions, but capturing
890
+ # explicitly makes the invariant obvious without relying on that coupling.
891
+ char_index = index
892
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
893
+
894
+ in_comment_now = JS_COMMENT_STATES.include?(state)
895
+ was_in_comment = JS_COMMENT_STATES.include?(prev_state)
896
+ result = char_index if !in_comment_now && !was_in_comment && !char.match?(/\s/)
897
+
898
+ index += 1
899
+ end
900
+
901
+ result
902
+ end
903
+
904
+ def rsc_client_references_setup_anchor?(content, is_server:)
905
+ !!rsc_client_references_setup_anchor_match(content, is_server: is_server)
906
+ end
907
+
908
+ # Called from the existing-config migration path, which is only reached after the
909
+ # generator has already confirmed `RSCWebpackPlugin` is imported in the file. That's why
910
+ # this helper deliberately omits the `RSCWebpackPlugin` import that `inject_rsc_*_imports`
911
+ # adds on the from-scratch path — adding it here would produce a duplicate import.
912
+ def add_rsc_client_references_setup(config_path, content, existing_imports_content, is_server:)
913
+ # Belt-and-suspenders: the only caller, `ensure_rsc_client_references_setup`, already
914
+ # checks both `scoped_rsc_client_references_defined?` and `rsc_client_references_defined?`
915
+ # before delegating here. The guards are kept so the helper is safe to call directly.
916
+ return false if scoped_rsc_client_references_defined?(content)
917
+ return false if rsc_client_references_defined?(content)
918
+
919
+ replace_rsc_client_references_setup_anchor(config_path, content, is_server: is_server) do |anchor|
920
+ join_rsc_client_references_setup(
921
+ content,
922
+ [
923
+ anchor,
924
+ # On the server path `shakapacker_config_blocker_reason` has already blocked when
925
+ # `config` is missing from `existing_imports_content`, so this call returns `nil`.
926
+ # Kept (rather than skipped on `is_server`) so removing the upstream blocker does not
927
+ # silently drop the import on the client path, where its absence is permitted.
928
+ shakapacker_config_import_statement(existing_imports_content),
929
+ path_resolve_import_statement(existing_imports_content),
930
+ "",
931
+ rsc_client_references_js
932
+ ]
933
+ )
934
+ end
935
+ end
936
+
937
+ def replace_rsc_client_references_setup_anchor(config_path, content, is_server:)
938
+ anchor_match = rsc_client_references_setup_anchor_match(content, is_server: is_server)
939
+ return unless anchor_match
940
+
941
+ updated_content = content.dup
942
+ updated_content[anchor_match.begin(0)...anchor_match.end(0)] = yield anchor_match[0]
943
+ if options[:pretend]
944
+ # Pretend is handled here because this helper reports injection-specific wording;
945
+ # `write_existing_rsc_config` still handles pretend for direct rewrite calls.
946
+ say_status(:pretend, "Would inject rscClientReferences into #{config_path}", :yellow)
947
+ else
948
+ write_existing_rsc_config(config_path, updated_content, action: :insert)
949
+ end
950
+ true
951
+ end
952
+
953
+ def write_existing_rsc_config(config_path, content, action:)
954
+ if options[:pretend]
955
+ message =
956
+ if action == :insert
957
+ "Would inject rscClientReferences into #{config_path}"
958
+ else
959
+ "Would rewrite #{config_path}"
960
+ end
961
+ say_status(:pretend, message, :yellow)
962
+ return true
963
+ end
964
+
965
+ if options[:skip]
966
+ say_status(:skip, config_path, :yellow)
967
+ return true
968
+ end
969
+
970
+ # Direct write is intentional: multi-point rewrites cannot be expressed as one
971
+ # gsub_file call, so the raw write explicitly mirrors Thor's --skip behavior above.
972
+ File.write(File.join(destination_root, config_path), content)
973
+ say_status(action, config_path, :green)
974
+ true
975
+ end
976
+
977
+ def shakapacker_config_import_statement(existing_imports_content)
978
+ return if shakapacker_config_imported?(existing_imports_content)
979
+
980
+ "const { config } = require('shakapacker');"
981
+ end
982
+
983
+ def path_resolve_import_statement(existing_imports_content)
984
+ return if path_resolve_imported?(existing_imports_content)
985
+
986
+ "const { resolve } = require('path');"
987
+ end
988
+
989
+ # Unlike `shakapacker_config_import_statement` / `path_resolve_import_statement` which check
990
+ # `existing_imports_content` (the slice up through the anchor that they piggy-back on), this
991
+ # helper checks the FULL file content. A user's existing `RSCWebpackPlugin` import can sit
992
+ # below the anchor (e.g. a partially-edited or previously-failed migration), so dedup must
993
+ # see the whole file or it will inject a duplicate `const { RSCWebpackPlugin } = require(...)`
994
+ # and webpack will fail to load with `Identifier 'RSCWebpackPlugin' has already been declared`.
995
+ def rsc_webpack_plugin_import_statement(content)
996
+ return if commonjs_named_imported?(content, "react-on-rails-rsc/WebpackPlugin", "RSCWebpackPlugin")
997
+
998
+ "const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin');"
999
+ end
1000
+
1001
+ def rsc_client_references_setup_import_pattern(is_server:)
1002
+ if is_server
1003
+ # Matches the standard bundler ternary from the serverWebpackConfig template, including
1004
+ # harmless comments or extra whitespace around the ternary operators.
1005
+ # Rspack-only configs without the webpack fallback receive the manual-migration warning.
1006
+ js_gap = "(?:\\s|//[^\\n]*(?:\\n|\\r\\n?)|/\\*[\\s\\S]*?\\*/)*"
1007
+ Regexp.new(
1008
+ "(const\\s+bundler\\s*=\\s*config\\.assets_bundler#{js_gap}" \
1009
+ "===#{js_gap}['\"]rspack['\"]#{js_gap}" \
1010
+ "\\?#{js_gap}require\\(['\"]@rspack/core['\"]\\)#{js_gap}" \
1011
+ ":#{js_gap}require\\(['\"]webpack['\"]\\)#{js_gap};)"
1012
+ )
1013
+ else
1014
+ %r{(const commonWebpackConfig = require\(['"]\./commonWebpackConfig['"]\);)}
1015
+ end
1016
+ end
1017
+
1018
+ def rsc_client_references_defined?(content)
1019
+ # Module-scope guard mirrors the other detection helpers (e.g. `path_resolve_imported?`,
1020
+ # `shakapacker_config_imported?`) so a function-scoped `const rscClientReferences` does
1021
+ # not fool `ensure_rsc_client_references_setup` into skipping the helper injection — that
1022
+ # would leave the plugin rewrite referencing an out-of-scope binding.
1023
+ # `let` and `var` are matched alongside `const` because a hand-written
1024
+ # `let rscClientReferences = {...}` at module scope would otherwise slip past this check
1025
+ # and cause the migration to emit a second `const rscClientReferences = {...}`, producing
1026
+ # an `Identifier 'rscClientReferences' has already been declared` SyntaxError at config load.
1027
+ pattern = /^[ \t]*(?:const|let|var)\s+rscClientReferences\b/
1028
+ content.to_enum(:scan, pattern).any? do
1029
+ js_top_level_position?(content, Regexp.last_match.begin(0))
1030
+ end
1031
+ end
1032
+
1033
+ def scoped_rsc_client_references_defined?(content)
1034
+ # Locate the actual module-scope `const|let|var rscClientReferences = { ... }` site and
1035
+ # check the `directory:` key against the object literal body with comments stripped.
1036
+ # Running the regex against the raw file would treat a stale, commented-out
1037
+ # `// directory: resolve(config.source_path)` (e.g. left over from a prior failed
1038
+ # migration) as a real scoped declaration and silently short-circuit
1039
+ # `ensure_rsc_client_references_setup`, leaving the plugin unscoped without any warning.
1040
+ decl_pattern = /^[ \t]*(?:const|let|var)\s+rscClientReferences\s*=\s*\{/
1041
+ content.to_enum(:scan, decl_pattern).any? do
1042
+ match = Regexp.last_match
1043
+ next false unless js_top_level_position?(content, match.begin(0))
1044
+
1045
+ open_brace = match.end(0) - 1
1046
+ close_brace = matching_js_closing_brace(content, open_brace)
1047
+ next false unless close_brace
1048
+
1049
+ body = content[(open_brace + 1)...close_brace]
1050
+ rsc_plugin_options_without_comments(body)
1051
+ .match?(/\bdirectory\s*:\s*resolve\(\s*config\.source_path\s*\)/)
1052
+ end
1053
+ end
1054
+
1055
+ # Inclusive slice — the anchor itself is part of the returned content because callers
1056
+ # (`rsc_setup_blocked_by_later_imports?`, `inject_rsc_*_imports`, the import-detection
1057
+ # helpers) check whether required imports appear up to and including the anchor line.
1058
+ # Anything past the anchor is considered "later" and disqualifies an otherwise-valid import.
1059
+ def content_through_rsc_setup_anchor(content, is_server:)
1060
+ anchor = rsc_client_references_setup_anchor_match(content, is_server: is_server)
1061
+ return "" unless anchor
1062
+
1063
+ content[0...anchor.end(0)]
1064
+ end
1065
+
1066
+ def rsc_client_references_setup_anchor_match(content, is_server:)
1067
+ pattern = rsc_client_references_setup_import_pattern(is_server: is_server)
1068
+ content.to_enum(:scan, pattern).each do
1069
+ match = Regexp.last_match
1070
+ return match if js_code_position?(content, match.begin(0))
1071
+ end
1072
+ nil
1073
+ end
1074
+
1075
+ def rsc_setup_blocked_by_later_imports?(
1076
+ config_path,
1077
+ content,
1078
+ existing_imports_content,
1079
+ is_server:,
1080
+ plugin_pending: false
1081
+ )
1082
+ reason = rsc_setup_blocker_reason(content, existing_imports_content, is_server: is_server)
1083
+ return false unless reason
1084
+
1085
+ manual_action =
1086
+ if plugin_pending
1087
+ "RSCWebpackPlugin will be added without scoped clientReferences; please add clientReferences manually."
1088
+ elsif content.include?("RSCWebpackPlugin")
1089
+ "Please add clientReferences manually."
1090
+ else
1091
+ "RSCWebpackPlugin was not added to #{config_path}; please add the plugin and clientReferences manually."
1092
+ end
1093
+
1094
+ GeneratorMessages.add_warning(
1095
+ "Could not inject rscClientReferences into #{config_path}: #{reason}. " \
1096
+ "#{manual_action}"
1097
+ )
1098
+ true
1099
+ end
1100
+
1101
+ # Reports the first blocker that prevents the generator from injecting `rscClientReferences`.
1102
+ # Each branch returns a message specific enough that the user can act on it without re-deriving
1103
+ # the cause from a generic "imports unavailable" warning.
1104
+ def rsc_setup_blocker_reason(content, existing_imports_content, is_server:)
1105
+ path_resolve_blocker_reason(content, existing_imports_content) ||
1106
+ shakapacker_config_blocker_reason(content, existing_imports_content, is_server: is_server)
1107
+ end
1108
+
1109
+ def path_resolve_blocker_reason(content, existing_imports_content)
1110
+ return nil if path_resolve_imported?(existing_imports_content)
1111
+
1112
+ if top_level_resolve_binding?(content)
1113
+ "a top-level `resolve` binding already exists that would conflict with the injected " \
1114
+ "`const { resolve } = require('path')`"
1115
+ elsif path_resolve_imported?(content)
1116
+ "the `resolve` import from `path` appears after the expected anchor line — " \
1117
+ "move the import above it"
1118
+ end
1119
+ end
1120
+
1121
+ # Intentional server/client asymmetry: when shakapacker's `config` is absent from the
1122
+ # file entirely (not misplaced, not aliased), the client branch falls through and returns
1123
+ # `nil` so the generator proceeds and adds the import alongside `rscClientReferences`. The
1124
+ # server branch always blocks because the server anchor (`config.assets_bundler === 'rspack'`)
1125
+ # itself requires `config` to be in scope, so an absent shakapacker import is always a user
1126
+ # configuration problem there.
1127
+ def shakapacker_config_blocker_reason(content, existing_imports_content, is_server:)
1128
+ return nil if shakapacker_config_imported?(existing_imports_content)
1129
+
1130
+ shakapacker_anywhere = shakapacker_config_imported?(content)
1131
+
1132
+ if shakapacker_anywhere
1133
+ if is_server
1134
+ "shakapacker's `config` is imported after the bundler ternary anchor — " \
1135
+ "move the import above it"
1136
+ else
1137
+ "shakapacker's `config` is imported after the `commonWebpackConfig` anchor — " \
1138
+ "move the import above it"
1139
+ end
1140
+ elsif top_level_config_binding?(content)
1141
+ "a top-level `config` binding already exists that would conflict with the injected " \
1142
+ "`const { config } = require('shakapacker')`"
1143
+ elsif is_server
1144
+ "shakapacker's `config` is not imported in this file — add " \
1145
+ "`const { config } = require('shakapacker');` before the bundler ternary"
1146
+ end
1147
+ end
1148
+
1149
+ def shakapacker_config_imported?(content)
1150
+ return true if commonjs_named_imported?(content, "shakapacker", "config")
1151
+
1152
+ top_level_dot_access_import?(content,
1153
+ /^[ \t]*(?:const|let|var)\s+config\s*=\s*require\(['"]shakapacker['"]\)\.config/)
1154
+ end
1155
+
1156
+ def path_resolve_imported?(content)
1157
+ # Full-module imports (`const path = require('path')`) do not create the bare `resolve` binding
1158
+ # that rscClientReferences uses, so the generator may add a harmless named import alongside them.
1159
+ return true if commonjs_named_imported?(content, "path", "resolve")
1160
+
1161
+ top_level_dot_access_import?(content,
1162
+ /^[ \t]*(?:const|let|var)\s+resolve\s*=\s*require\(['"]path['"]\)\.resolve/)
1163
+ end
1164
+
1165
+ # Verifies that a regex match is at module-scope depth=0 to avoid false positives
1166
+ # from function-scoped `require` calls (which do not produce module-scope bindings).
1167
+ def top_level_dot_access_import?(content, pattern)
1168
+ content.to_enum(:scan, pattern).any? do
1169
+ js_top_level_position?(content, Regexp.last_match.begin(0))
1170
+ end
1171
+ end
1172
+
1173
+ def top_level_resolve_binding?(content)
1174
+ top_level_binding?(content, "resolve")
1175
+ end
1176
+
1177
+ def top_level_config_binding?(content)
1178
+ top_level_binding?(content, "config")
1179
+ end
1180
+
1181
+ def rsc_client_references_setup_anchor_available?(config_path, content, is_server:, plugin_pending: false)
1182
+ return true if rsc_client_references_setup_anchor?(content, is_server: is_server)
1183
+
1184
+ warn_missing_rsc_client_references_anchor(config_path, plugin_pending: plugin_pending)
1185
+ false
1186
+ end
1187
+
1188
+ def rsc_client_references_setup_ready?(config_path, plugin_pending: false)
1189
+ # In pretend/skip modes nothing is written to disk, so reading the file back would
1190
+ # report the helper as missing and the caller would fall through to a misleading
1191
+ # "injection failed" warning. Reporting success here lets callers show what *would*
1192
+ # be added (e.g. `, clientReferences: rscClientReferences` in the pretend plugin
1193
+ # output) instead.
1194
+ return true if options[:pretend]
1195
+ return true if options[:skip]
1196
+ return true if scoped_rsc_client_references_defined?(File.read(File.join(destination_root, config_path)))
1197
+
1198
+ warn_rsc_client_references_injection_failed(config_path, plugin_pending: plugin_pending)
1199
+ false
1200
+ end
1201
+
1202
+ def warn_missing_rsc_client_references_anchor(config_path, plugin_pending: false)
1203
+ GeneratorMessages.add_warning(
1204
+ "Could not inject rscClientReferences into #{config_path}: expected webpack import anchor was not found " \
1205
+ "(the generator looks for the single- or double-quoted CommonJS `require` anchor that the ROR " \
1206
+ "templates emit; backtick template-literal require paths and Rspack-only server configs without " \
1207
+ "the webpack fallback ternary must be migrated manually). " \
1208
+ "If your config uses ESM `import` syntax, the generator cannot migrate it automatically. " \
1209
+ "#{manual_rsc_plugin_action(config_path, plugin_pending: plugin_pending)}"
1210
+ )
1211
+ end
1212
+
1213
+ def warn_rsc_client_references_injection_failed(config_path, plugin_pending: false)
1214
+ GeneratorMessages.add_warning(
1215
+ "Could not inject rscClientReferences into #{config_path}: expected webpack import anchor was found, " \
1216
+ "but the generated scoped helper setup was not written. " \
1217
+ "#{manual_rsc_plugin_action(config_path, plugin_pending: plugin_pending)}"
1218
+ )
1219
+ end
1220
+
1221
+ def manual_rsc_plugin_action(config_path, plugin_pending:)
1222
+ if plugin_pending
1223
+ "RSCWebpackPlugin was not added to #{config_path}; please add the plugin and clientReferences manually."
1224
+ else
1225
+ "Please add clientReferences manually."
1226
+ end
1227
+ end
1228
+
1229
+ def warn_unscoped_rsc_client_references_helper(config_path)
1230
+ GeneratorMessages.add_warning(
1231
+ "Skipped scoped clientReferences migration for #{config_path} because rscClientReferences already exists " \
1232
+ "but does not point to resolve(config.source_path). Please verify it manually."
1233
+ )
1234
+ end
1235
+
1236
+ def warn_missing_rsc_plugin_target(config_path, is_server:)
1237
+ GeneratorMessages.add_warning(
1238
+ "Could not update RSCWebpackPlugin in #{config_path}: no plugin options with isServer: #{is_server} " \
1239
+ "could be rewritten. Please add clientReferences manually. Dynamic or computed plugin options cannot be " \
1240
+ "verified automatically, so verify this file manually after adding clientReferences."
1241
+ )
1242
+ end
1243
+
1244
+ def commonjs_named_imported?(content, package_name, binding_name)
1245
+ content_without_comments = rsc_plugin_options_without_comments(content)
1246
+ pattern = /^[ \t]*(?:const|let|var)\s+\{([^}]*)\}\s*=\s*require\(['"]#{Regexp.escape(package_name)}['"]\);?/
1247
+
1248
+ content_without_comments.to_enum(:scan, pattern).any? do |captures|
1249
+ # Module-scope check guards against false positives when the same destructuring
1250
+ # appears inside a function body (which does not produce a module-scope binding).
1251
+ next false unless js_top_level_position?(content_without_comments, Regexp.last_match.begin(0))
1252
+
1253
+ destructuring_declares_binding?(captures.first, binding_name)
1254
+ end
1255
+ end
1256
+
1257
+ def top_level_binding?(content, binding_name)
1258
+ direct_binding = top_level_direct_binding?(content, binding_name)
1259
+ destructured_binding = top_level_destructured_binding?(content, binding_name)
1260
+
1261
+ direct_binding || destructured_binding
1262
+ end
1263
+
1264
+ def top_level_direct_binding?(content, binding_name)
1265
+ binding_pattern = Regexp.escape(binding_name)
1266
+ pattern = /
1267
+ ^[ \t]*
1268
+ (?:
1269
+ (?:const|let|var)\s+#{binding_pattern}\b |
1270
+ function\s+#{binding_pattern}\s*\( |
1271
+ class\s+#{binding_pattern}\b
1272
+ )
1273
+ /x
1274
+
1275
+ content.to_enum(:scan, pattern).any? do
1276
+ js_top_level_position?(content, Regexp.last_match.begin(0))
1277
+ end
1278
+ end
1279
+
1280
+ def top_level_destructured_binding?(content, binding_name)
1281
+ content_without_comments = rsc_plugin_options_without_comments(content)
1282
+ pattern = /^[ \t]*(?:const|let|var)\s+\{([^}]*)\}/
1283
+
1284
+ content_without_comments.to_enum(:scan, pattern).any? do |captures|
1285
+ next false unless js_top_level_position?(content_without_comments, Regexp.last_match.begin(0))
1286
+
1287
+ destructuring_declares_binding?(captures.first, binding_name)
1288
+ end
1289
+ end
1290
+
1291
+ def destructuring_declares_binding?(bindings, binding_name)
1292
+ rsc_plugin_options_without_comments(bindings).split(",").any? do |binding|
1293
+ destructuring_binding_declares_name?(binding.strip, binding_name)
1294
+ end
1295
+ end
1296
+
1297
+ def destructuring_binding_declares_name?(binding, binding_name)
1298
+ binding_pattern = Regexp.escape(binding_name)
1299
+
1300
+ # Aliases (`config: alias`) do not provide the exact binding that rscClientReferences uses.
1301
+ # Self-aliases (`config: config`) and defaults (`config = fallback`, `config=fallback`) do.
1302
+ binding.match?(/\A#{binding_pattern}(?:\z|\s*=)/) ||
1303
+ binding.match?(/\A#{binding_pattern}\s*:\s*#{binding_pattern}(?:\z|\s*=)/)
1304
+ end
1305
+ end
1306
+ end
1307
+ end
1308
+ end