permittable 0.11.0 → 0.11.1

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.
@@ -32,12 +32,18 @@ module Permittable
32
32
  # Building these once in columns_for keeps every drafting path (columns
33
33
  # alone, or a scan typed from columns) reading the same answers.
34
34
  #
35
+ # `assigned` marks a column the database or Rails fills in (a generated
36
+ # primary key, a timestamp): drafted from the columns alone it is left
37
+ # out, and a permit call that lists it gets it typed from the schema, but
38
+ # never `required`, since no client has to send it. A primary key the
39
+ # client must send (a natural key) is not assigned (see assigned?).
40
+ #
35
41
  # `rule` and `listed` are how column_line is told what it is drafting
36
42
  # for: the rule (see render) and whether the controller's permit call
37
43
  # lists the column. They ride on the column, set by in_rule, so the scan
38
44
  # path hands them to column_line without its own methods changing.
39
45
  DraftColumn = Struct.new(:name, :type, :null, :default, :default_function, :enum, :enum_integers, :sensitive,
40
- :unread, :rule, :listed, keyword_init: true) do
46
+ :unread, :rule, :listed, :assigned, keyword_init: true) do
41
47
  def in_rule(rule, listed: false)
42
48
  self.class.new(**to_h, rule: rule, listed: listed)
43
49
  end
@@ -86,11 +92,29 @@ module Permittable
86
92
  end
87
93
  end
88
94
 
89
- # One permit call, with an optional leading `.require(:root)`. The args
90
- # capture tolerates brackets and newlines but not parentheses — a call
91
- # whose arguments contain a method call is skipped entirely rather than
92
- # half-read.
93
- PERMIT_CALL = /params\s*(?:\.\s*require\(\s*:(\w+)\s*\))?\s*\.\s*permit\(([^()]*)\)/m
95
+ # `params` itself, not the tail of another name: `user_params.permit(...)`
96
+ # is called on a helper's result (`params.require(:user)`, say), whose
97
+ # root a rootless read of it would drop. `@params` and `$params` are
98
+ # variables, except `@_params`, the one Rails itself keeps the controller's
99
+ # params in. A `.` before it is fine: `self.params.permit(...)` is the
100
+ # real thing.
101
+ PARAMS = /(?<![\w@$])(?:@_)?params/
102
+
103
+ # One permit call, with an optional leading `.require(:root)` — the root
104
+ # spelled `:user`, `"user"` or `'user'` (groups 1-3; a String root's
105
+ # content is masked, so it is read back by offset like any argument).
106
+ # The args capture (group 4) tolerates brackets and newlines but not
107
+ # parentheses — a call whose arguments contain a method call is skipped
108
+ # entirely rather than half-read. Without parentheses (`permit :name,
109
+ # :email`, common in older controllers) group 4 is empty and the
110
+ # arguments run to the end of the statement: see call_args.
111
+ PERMIT_CALL = /#{PARAMS}\s*(?:\.\s*require\(\s*(?::(\w+)|"([^"\n]*)"|'([^'\n]*)')\s*\))?\s*\.\s*
112
+ permit(?:\(([^()]*)\)|[ \t]+(?=[^\s(]))/mx
113
+
114
+ # A trailing `if`/`unless`/`rescue` modifier on a paren-less call, which
115
+ # is not one of its arguments.
116
+ # At the start too: `permit if admin?` has no arguments at all.
117
+ BARE_MODIFIER = /(?:\A|\s+)(?:if|unless|rescue)\s.*\z/m
94
118
 
95
119
  # One Rails 8 `params.expect` call — the replacement for
96
120
  # `require(...).permit(...)`, and the reason this scanner exists twice: a
@@ -100,21 +124,40 @@ module Permittable
100
124
  # PERMIT_CALL: brackets and newlines are fine, a parenthesis means a
101
125
  # method call in the arguments and the whole call is skipped rather than
102
126
  # half-read.
103
- EXPECT_CALL = /params\s*\.\s*expect\(([^()]*)\)/m
127
+ EXPECT_CALL = /#{PARAMS}\s*\.\s*expect(?:\(([^()]*)\)|[ \t]+(?=[^\s(]))/m
104
128
 
105
129
  # The required root envelope of an expect call: `user: [...]`, where the
106
- # brackets hold fields — not the empty `tag_names: []` of an
107
- # array-of-scalars root, and not the `comments: [[...]]` of an
108
- # array-of-hashes root, neither of which a rooted contract can express.
109
- # The lookahead skips whitespace itself: `\[\s*(?!...)` let `\s*` match
110
- # nothing and waved the spaced `tag_names: [ ]` through as an envelope
111
- # with no fields.
112
- EXPECT_ENVELOPE = /\A(\w+):\s*\[(?!\s*[\[\]])\s*(.*?)\s*\]\z/m
113
-
114
- # An envelope whose fields live in a constant: `post: PERMITTED_PARAMS`.
115
- # The fields cannot be read, but the root can, and read as a rootless
116
- # argument it let a `params.permit(:page)` beside it take the root.
117
- EXPECT_CONSTANT_ENVELOPE = /\A(\w+):\s*((?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)\z/
130
+ # brackets hold fields. Which brackets hold fields is `envelope`'s call,
131
+ # not this pattern's: not the empty `tag_names: []` of an array-of-scalars
132
+ # root, and not the `comments: [[...]]` of an array-of-hashes root,
133
+ # neither of which a rooted contract can express. Rails reads `[[...]]` as
134
+ # an array of hashes only when the outer list holds that one element;
135
+ # `[[:name, :email], :age]` is the flat list `[:name, :email, :age]`.
136
+ EXPECT_ENVELOPE = /\A(\w+):\s*\[(.*)\]\z/m
137
+
138
+ # An envelope whose fields cannot be read: they live in a constant
139
+ # (`post: PERMITTED_PARAMS`) or a local (`post: permitted_keys`), or a
140
+ # word list holds a key that is not a plain one (`post: %i[first-name]`).
141
+ # Rails requires `post` whatever its value holds, so the root can still
142
+ # be read; read as a rootless argument it let a `params.permit(:page)`
143
+ # beside it take the root, and dropped the root when it stood alone. A
144
+ # value that opens with `[` is an array: EXPECT_ENVELOPE's fields, or a
145
+ # root a rooted contract cannot express (see there), never this.
146
+ EXPECT_OPAQUE_ENVELOPE = /\A(\w+):\s*([^\[\s].*)\z/m
147
+
148
+ # A %i/%w word list, or %I/%W: RuboCop's default Style/SymbolArray and
149
+ # Style/WordArray rewrite `[:name, :email]` into `%i[name email]`, and
150
+ # Rails reads the two alike. Delimited by `[]`, `{}`, `<>`, or the same
151
+ # punctuation at both ends — not `()`, as a parenthesis already makes the
152
+ # whole call unreadable (see PERMIT_CALL). The words are the text between
153
+ # the delimiters, whichever alternative matched.
154
+ WORD_LIST = /%[iIwW](?:\[[^\[\]]*\]|\{[^{}]*\}|<[^<>]*>|([^\w\s(),\[\]{}<>])(?:(?!\1).)*\1)/m
155
+
156
+ # A list among the filters, splatted or not: `[:name, :email]`, or a word
157
+ # list once bracket_word_lists has spelled it so. Rails flattens the
158
+ # filters it is given, so its elements are filters in their own right.
159
+ # Only a flat one: a list holding a list is left whole, unparsed.
160
+ LIST_ARG = /\A(?:\*\s*)?\[([^\[\]]*)\]\z/
118
161
 
119
162
  # The key of a single-key rootless EXPECT lookup that is a route param,
120
163
  # not input: the Rails 8 scaffold's `Post.find(params.expect(:id))`, or a
@@ -222,11 +265,19 @@ module Permittable
222
265
  # The lexed tokens shared by executable_source and masked_source, with
223
266
  # comments already dropped — nil when Ripper could not lex `source` at
224
267
  # all, or found nothing.
268
+ # A comment token carries its line's newline ("# note\n"), so dropping
269
+ # it whole joined that line to the next. A parenthesised call never
270
+ # noticed; a paren-less one ends at its line (see bare_args_end), so the
271
+ # newline is kept.
225
272
  def code_tokens(source)
226
273
  tokens = Ripper.lex(source)
227
274
  return nil if tokens.nil? || tokens.empty?
228
275
 
229
- tokens.reject { |token| COMMENT_TOKENS.include?(token[1]) }
276
+ tokens.filter_map do |token|
277
+ next token unless COMMENT_TOKENS.include?(token[1])
278
+
279
+ [token[0], :on_nl, "\n", token[3]] if token[2].end_with?("\n")
280
+ end
230
281
  end
231
282
 
232
283
  # Merge every `params.permit` and `params.expect` call found in the
@@ -261,8 +312,11 @@ module Permittable
261
312
  raw = source.to_s
262
313
  source = executable_source(raw)
263
314
  masked = masked_source(raw)
264
- permits = matches(masked, PERMIT_CALL).map { |match| permit_call(source, match) }
265
- expects = matches(masked, EXPECT_CALL).map { |match| expect_calls(match.begin(0), split_args(group(source, match, 1))) }
315
+ permits = matches(masked, PERMIT_CALL).filter_map { |match| permit_call(source, masked, match) }
316
+ expects = matches(masked, EXPECT_CALL).filter_map do |match|
317
+ args, = call_args(source, masked, match, 1)
318
+ expect_calls(match.begin(0), split_args(args)) if args
319
+ end
266
320
  result.calls = permits.size + expects.size
267
321
  calls = (permits + expects).flatten.sort_by.with_index { |call, index| [call.position, index] }
268
322
  result.root = choose_root(calls, model&.name && default_root(model), exclude)
@@ -321,18 +375,53 @@ module Permittable
321
375
  # ActiveStorage's direct uploads controller is the case this exists for —
322
376
  # it was drafted, and a contract pasted into a gem's controller is not
323
377
  # one the app can keep. A "controller#action" entry takes only that
324
- # action out of the draft, and a controller with no drafted action left
325
- # is skipped. Naming a controller to the task drafts it regardless, the
326
- # way it drafts a covered one.
378
+ # action out of the draft, and so does an action the controller does not
379
+ # define (see undefined_actions): a SessionsController with only `create`
380
+ # and `destroy` gets no `:update` rule, and ApplicationController, which
381
+ # defines neither, would otherwise hand its rule to every controller
382
+ # below it. A controller with no drafted action left is skipped. Naming a
383
+ # controller to the task drafts it regardless (see named_target), the way
384
+ # it drafts a covered one.
327
385
  def targets(controllers, ignore: Permittable.audit_ignore, root: Audit.app_root)
328
386
  controllers.each_with_object({}) do |controller, found|
329
387
  next if controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any?
330
388
 
331
- skipped = DEFAULT_ACTIONS.select { |action| Audit.ignore_reason(controller, action, ignore: ignore, root: root) }
389
+ undefined = undefined_actions(controller)
390
+ skipped = DEFAULT_ACTIONS.select do |action|
391
+ Audit.ignore_reason(controller, action, ignore: ignore, root: root) || undefined.include?(action)
392
+ end
332
393
  found[controller] = skipped unless skipped == DEFAULT_ACTIONS
333
394
  end
334
395
  end
335
396
 
397
+ # What to leave out of the draft of a controller named to the task: the
398
+ # drafted actions it does not define, as in the sweep — unless it defines
399
+ # none of them. Then the name is all there is to go on, and it is drafted
400
+ # whole, as it always was.
401
+ def named_target(controller)
402
+ undefined = undefined_actions(controller)
403
+ undefined == DEFAULT_ACTIONS ? [] : undefined
404
+ end
405
+
406
+ # The drafted actions `controller` does not define, by the audit's own
407
+ # reading of it (Audit.missing_lookup): not just its action_methods, since
408
+ # an `action_missing` handler or a template answers an action with no
409
+ # method, and a controller that cannot list its actions is assumed to
410
+ # define them all. A second definition here would drift from what
411
+ # permittable:audit then calls stale.
412
+ #
413
+ # Reading it can build the controller (see Audit.dispatchable?), which
414
+ # rescues only a StandardError. A controller whose `initialize` raises a
415
+ # ScriptError, such as NotImplementedError, would abort the whole sweep,
416
+ # which never built a controller before. It is inconclusive, so it is
417
+ # assumed to define both, the same answer as one that cannot list them.
418
+ def undefined_actions(controller)
419
+ missing = Audit.missing_lookup(controller)
420
+ DEFAULT_ACTIONS.select { |action| missing[action.to_s] }
421
+ rescue StandardError, ScriptError
422
+ []
423
+ end
424
+
336
425
  # The core: knowledge in (columns and/or a scan), snippet out. Returns a
337
426
  # String of valid Ruby, or nil when neither source of knowledge exists —
338
427
  # or when neither can declare a single field, since a contract of only
@@ -342,16 +431,22 @@ module Permittable
342
431
  columns = columns_for(model)
343
432
  scan = nil unless scan&.found?
344
433
  return nil unless columns || scan
345
- return column_draft(model, columns.values, [], except: except) unless scan
434
+ return column_draft(model, unassigned(columns), [], except: except) unless scan
346
435
 
347
- shared = scanned_lines(scan, listed_columns(columns, :shared))
436
+ shared = scanned_lines(scan, listed_columns(columns, :shared), model: model)
348
437
  return fallback_draft(model, scan, columns, except: except) unless declares_field?(shared)
349
438
 
350
439
  render(root: scan.root, model: columns && model, except: except) do |rule|
351
- scanned_lines(scan, listed_columns(columns, rule))
440
+ scanned_lines(scan, listed_columns(columns, rule), model: model)
352
441
  end
353
442
  end
354
443
 
444
+ # The columns worth drafting without a permit call to name them: not the
445
+ # ones the database or Rails assigns (see DraftColumn).
446
+ def unassigned(columns)
447
+ columns.values.reject(&:assigned)
448
+ end
449
+
355
450
  # The columns as one rule drafts them (see DraftColumn#in_rule), each
356
451
  # marked listed: on the scan path a column is drafted only because the
357
452
  # controller's own calls name it.
@@ -387,7 +482,7 @@ module Permittable
387
482
 
388
483
  root = scan.root || (rootless_body?(scan) ? nil : default_root(model))
389
484
  undrafted = (Array(scan.undecided) + Array(scan.route_params).filter_map { |arg| scalar_key(arg) }).map(&:to_s)
390
- drafted = columns.except(*undrafted)
485
+ drafted = columns.reject { |_, column| column.assigned }.except(*undrafted)
391
486
  # As in drop_drafted_todos: a rootless key the columns now declare is
392
487
  # not also a "not in this contract" TODO.
393
488
  scan = scan.dup.tap { |copy| copy.rootless = Array(scan.rootless).reject { |arg| drafted.key?(scalar_key(arg).to_s) } }
@@ -416,9 +511,13 @@ module Permittable
416
511
  model.respond_to?(:columns) ? model : nil
417
512
  end
418
513
 
419
- # The columns a contract should cover, keyed by name — or nil when there
420
- # is no model or its schema is unreachable (same philosophy as the drift
421
- # guard: never let generation crash on a half-migrated database).
514
+ # Every column, keyed by name — or nil when there is no model or its
515
+ # schema is unreachable (same philosophy as the drift guard: never let
516
+ # generation crash on a half-migrated database). All of them, the
517
+ # primary key and timestamps too: this is also where a permitted key's
518
+ # type is looked up, and a column left out here read as "not a
519
+ # database column". What is drafted without a permit call is narrowed
520
+ # later (see DraftColumn#assigned).
422
521
  def columns_for(model)
423
522
  columns = schema_columns(model)
424
523
  columns&.to_h { |column| [column.name, draft_column(model, column)] }
@@ -441,10 +540,7 @@ module Permittable
441
540
  return nil unless model.respond_to?(:columns)
442
541
  return nil unless model.table_exists?
443
542
 
444
- # Array() flattens a composite primary key (an Array in Rails 7.1+)
445
- # into its column names; a nil primary key becomes [].
446
- skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
447
- model.columns.reject { |c| skipped.include?(c.name) }
543
+ model.columns
448
544
  rescue StandardError => e
449
545
  raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
450
546
 
@@ -458,7 +554,38 @@ module Permittable
458
554
  def draft_column(model, column)
459
555
  DraftColumn.new(name: column.name, type: column.type, null: column.null,
460
556
  default_function: column.respond_to?(:default_function) && column.default_function,
461
- sensitive: sensitive_role(model, column.name), **model_facts(model, column))
557
+ sensitive: sensitive_role(model, column.name), assigned: assigned?(model, column),
558
+ **model_facts(model, column))
559
+ end
560
+
561
+ # Whether the database or Rails fills the column in, so no client has to
562
+ # send it. A timestamp is; a primary key only when the database really
563
+ # generates it: an auto-increment column (the adapter says so, wherever
564
+ # it sits in the key: MySQL allows `PRIMARY KEY (region, id)` with `id
565
+ # AUTO_INCREMENT`), or a key with a default or a default function (a
566
+ # uuid). An adapter without that flag falls back to the integer of a
567
+ # single-column key. A natural key (`sku`, a `create_table ..., id:
568
+ # :string`) or a part of a composite key with no such flag is the
569
+ # client's to send, and is drafted like any other column.
570
+ # Array() flattens a composite primary key (an Array in Rails 7.1+) into
571
+ # its column names; a nil primary key becomes [].
572
+ def assigned?(model, column)
573
+ return true if SKIPPED_COLUMNS.include?(column.name)
574
+
575
+ keys = Array(model.primary_key).map(&:to_s)
576
+ return false unless keys.include?(column.name)
577
+
578
+ auto_increment?(column, sole_key: keys.one?) || !column.default.nil? || default_function?(column)
579
+ end
580
+
581
+ def auto_increment?(column, sole_key:)
582
+ return column.auto_increment? if column.respond_to?(:auto_increment?)
583
+
584
+ sole_key && column.type == :integer
585
+ end
586
+
587
+ def default_function?(column)
588
+ column.respond_to?(:default_function) && !column.default_function.nil?
462
589
  end
463
590
 
464
591
  # What the model adds to one column. An error reading it degrades THAT
@@ -592,20 +719,43 @@ module Permittable
592
719
  # -- scan parsing -------------------------------------------------------
593
720
 
594
721
  # Split a permit argument list on top-level commas only, so `address:
595
- # [:city, :zip]` stays one argument.
722
+ # [:city, :zip]` stays one argument. A list argument is replaced by its
723
+ # elements (see LIST_ARG).
596
724
  def split_args(args)
725
+ split_top_level(args).flat_map do |part|
726
+ list = LIST_ARG.match(bracket_word_lists(part))
727
+ list ? split_args(list[1]) : [part]
728
+ end
729
+ end
730
+
731
+ # The top-level, comma-separated parts of `args`, as written. A word list
732
+ # delimited by commas (`%w,name email,`) is kept whole, so it stays one
733
+ # unreadable argument rather than the TODOs of its pieces (see WORD_LIST).
734
+ def split_top_level(args)
597
735
  parts = [+""]
598
736
  depth = 0
599
- args.each_char do |char|
600
- depth += 1 if "[{".include?(char)
601
- depth -= 1 if "]}".include?(char)
602
- next parts << +"" if char == "," && depth.zero?
737
+ args.scan(/%[iIwW],[^,]*,|./m) do |token|
738
+ depth += 1 if token.size == 1 && "[{".include?(token)
739
+ depth -= 1 if token.size == 1 && "]}".include?(token)
740
+ next parts << +"" if token == "," && depth.zero?
603
741
 
604
- parts.last << char
742
+ parts.last << token
605
743
  end
606
744
  parts.map(&:strip).reject(&:empty?)
607
745
  end
608
746
 
747
+ # `text` with each word list of plain keys spelled as the list it equals:
748
+ # `%i[name email]` as `[:name, :email]`, `%w[]` as `[]`, so the shapes
749
+ # above read it. A word list holding any other word — quoted, escaped or
750
+ # interpolated — is left as written, and stays unparsed. Only for
751
+ # matching: a TODO quotes the argument as the source spells it.
752
+ def bracket_word_lists(text)
753
+ text.gsub(WORD_LIST) do |list|
754
+ words = list[3...-1].split
755
+ words.all? { |word| word.match?(/\A\w+\z/) } ? "[#{words.map { |word| ":#{word}" }.join(', ')}]" : list
756
+ end
757
+ end
758
+
609
759
  # An expect call's arguments, as one Call per envelope. The bracketed
610
760
  # arguments are required root envelopes and their contents are fields.
611
761
  # Plain symbols are fields only when there is no envelope
@@ -625,12 +775,26 @@ module Permittable
625
775
  end
626
776
 
627
777
  # An expect argument as `[root, fields]` when it is an envelope, else nil.
628
- # A constant envelope's one "field" is the constant, kept as unparsable.
778
+ # An opaque envelope's one "field" is its value, kept as unparsable.
779
+ #
780
+ # The shape is judged with word lists bracketed: `user: %i[name email]`
781
+ # is the envelope `user: [:name, :email]`, and `items: [%i[sku]]` the
782
+ # array-of-hashes root `items: [[:sku]]`. It is judged on the elements
783
+ # the outer list holds, as Rails does, not on the flattened fields: the
784
+ # list is an array of hashes (or empty) only when it holds nothing but one
785
+ # list, so `user: [%i[name email], :age]` is still the `user` envelope
786
+ # with three fields. A bracketed envelope's fields are split as the source
787
+ # spells them, so a TODO for one quotes it as written.
629
788
  def envelope(arg)
630
- if (match = EXPECT_ENVELOPE.match(arg))
631
- [match[1].to_sym, split_args(match[2])]
632
- elsif (match = EXPECT_CONSTANT_ENVELOPE.match(arg))
633
- [match[1].to_sym, [match[2]]]
789
+ listed = bracket_word_lists(arg)
790
+ if (match = EXPECT_ENVELOPE.match(listed))
791
+ elements = split_top_level(match[2])
792
+ return if elements.empty? || (elements.one? && elements.first.start_with?("["))
793
+
794
+ written = EXPECT_ENVELOPE.match(arg) || match
795
+ [match[1].to_sym, split_args(written[2])]
796
+ elsif (match = EXPECT_OPAQUE_ENVELOPE.match(listed))
797
+ [match[1].to_sym, [arg.sub(/\A\w+:\s*/, "")]]
634
798
  end
635
799
  end
636
800
 
@@ -640,13 +804,59 @@ module Permittable
640
804
  # own text may hold placeholders rather than a real string argument's
641
805
  # characters (see masked_source), so every group is read back out of
642
806
  # `source` by position instead of off `match` directly.
643
- def permit_call(source, match)
644
- args = split_args(group(source, match, 2))
645
- root = group(source, match, 1)
646
- return Call.new(match.begin(0), nil, args, []) unless root
807
+ # nil when the call cannot be read: a paren-less one whose arguments
808
+ # hold a method call, or a String root that is not a plain key.
809
+ def permit_call(source, masked, match)
810
+ args, finish = call_args(source, masked, match, 4)
811
+ return nil unless args
812
+
813
+ root = [1, 2, 3].filter_map { |index| group(source, match, index) }.first
814
+ return Call.new(match.begin(0), nil, split_args(args), []) unless root
815
+ return nil unless root.match?(/\A\w+\z/)
816
+
817
+ spelling = unparsed_arg(source[match.begin(0)...finish]).gsub(/\(\s+/, "(").gsub(/\s+\)/, ")")
818
+ Call.new(match.begin(0), root.to_sym, split_args(args), [], spelling)
819
+ end
820
+
821
+ # A call's argument text and where the call ends, or nil when it cannot
822
+ # be read. Parenthesised, that is the paren group. Without parentheses
823
+ # it runs from the match to the end of the statement (bare_args_end),
824
+ # less any trailing modifier.
825
+ def call_args(source, masked, match, paren_group)
826
+ return [group(source, match, paren_group), match.end(0)] if match.begin(paren_group)
827
+
828
+ finish = bare_args_end(masked, match.end(0))
829
+ return nil unless finish
830
+
831
+ # The call ends where its arguments do, so a TODO quoting it never
832
+ # quotes the modifier.
833
+ args = source[match.end(0)...finish].sub(BARE_MODIFIER, "").rstrip
834
+ args.empty? ? nil : [args, match.end(0) + args.length]
835
+ end
836
+
837
+ # Where a paren-less argument list ends: the first newline (or `;`)
838
+ # outside brackets that does not follow a `,` or a `\` — Ruby's own rule
839
+ # for continuing a command call onto the next line. A parenthesis means
840
+ # a method call in the arguments, and the call is skipped as the
841
+ # parenthesised form skips one; so are unbalanced brackets. Read on the
842
+ # masked source, where string content cannot open a bracket.
843
+ def bare_args_end(text, start)
844
+ depth = 0
845
+ last = nil
846
+ index = start
847
+ while index < text.length
848
+ char = text[index]
849
+ return nil if "()".include?(char)
647
850
 
648
- spelling = unparsed_arg(group(source, match, 0)).gsub(/\(\s+/, "(").gsub(/\s+\)/, ")")
649
- Call.new(match.begin(0), root.to_sym, args, [], spelling)
851
+ depth += 1 if "[{".include?(char)
852
+ depth -= 1 if "]}".include?(char)
853
+ return nil if depth.negative?
854
+ break if depth.zero? && (char == ";" || (char == "\n" && !(last && ",\\".include?(last))))
855
+
856
+ last = char unless char.match?(/\s/)
857
+ index += 1
858
+ end
859
+ depth.zero? ? index : nil
650
860
  end
651
861
 
652
862
  # A rootless expect call — or, when its one key is a route param
@@ -809,8 +1019,10 @@ module Permittable
809
1019
 
810
1020
  # One permit argument as `[shape, key]`, or `[shape, key, sub_keys]` for
811
1021
  # the two hash shapes — nil when the conservative parser cannot read it,
812
- # including a nested list with any sub-key it cannot read.
1022
+ # including a nested list with any sub-key it cannot read. A word list
1023
+ # reads as the list it equals: `address: %i[city zip]` is a nested hash.
813
1024
  def parse_arg(arg)
1025
+ arg = bracket_word_lists(arg)
814
1026
  if (key = scalar_key(arg))
815
1027
  [:scalars, key]
816
1028
  elsif (match = ARRAY_ARG.match(arg))
@@ -924,9 +1136,15 @@ module Permittable
924
1136
  # it back to its key. Left as a TODO rather than drafted: forms, the
925
1137
  # common client, send the key. Only for an enum that stores Integers — a
926
1138
  # string-backed one has no second spelling to admit.
1139
+ #
1140
+ # The transform is spelled out because the obvious one is wrong: with
1141
+ # `key(v.to_i) || v`, "shipped".to_i is 0, so a NAME became the first
1142
+ # key. Integer(v, exception: false) is nil for a name, which key() maps
1143
+ # to nil. The type guard accepts this in: only beside a transform:.
927
1144
  def enum_todo(column)
928
1145
  "TODO: Rails also assigns the stored integers (#{column.name}: 1) — if API clients send them, add " \
929
- "#{column.enum}.values.map(&:to_s) to in: and map them back to keys with transform:"
1146
+ "#{column.enum}.values.map(&:to_s) to in: and map them back to keys with " \
1147
+ "transform: ->(v) { #{column.enum}.key(Integer(v, exception: false)) || v }"
930
1148
  end
931
1149
 
932
1150
  def unread_todo(column)
@@ -940,7 +1158,7 @@ module Permittable
940
1158
  # request that omits the field, which would overwrite columns on partial
941
1159
  # updates; the database already handles creation.
942
1160
  def required_column?(column)
943
- return false if column.null
1161
+ return false if column.null || column.assigned
944
1162
  return false unless column.default.nil?
945
1163
 
946
1164
  !column.default_function
@@ -949,13 +1167,13 @@ module Permittable
949
1167
  # Scanned names are emitted with Symbol#inspect, not as `:#{name}`: a
950
1168
  # string-keyed `permit("2fa")` scans to a name that is not a bare symbol
951
1169
  # literal, and `:2fa` would make the whole draft a SyntaxError.
952
- def scanned_lines(scan, columns)
1170
+ def scanned_lines(scan, columns, model: nil)
953
1171
  lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
954
1172
  lines += scan.arrays.map do |name|
955
1173
  "array #{name.to_sym.inspect}, of: :string " \
956
1174
  "# TODO: confirm the element type, and declare length: — an array without one is unbounded"
957
1175
  end
958
- scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
1176
+ scan.nested.each { |name, keys| lines += nested_lines(name, keys, model: model) }
959
1177
  scan.nested_arrays.each { |name, keys| lines += nested_array_lines(name, keys) }
960
1178
  lines + scan_todo_lines(scan)
961
1179
  end
@@ -994,13 +1212,28 @@ module Permittable
994
1212
  "#{field} # TODO: confirm the type"
995
1213
  end
996
1214
 
997
- def nested_lines(name, keys)
1215
+ # `permit(addresses_attributes: [...])` cannot say whether it permits one
1216
+ # hash or many; the model can. When the key is a collection's nested
1217
+ # attributes (has_many + accepts_nested_attributes_for), fields_for
1218
+ # sends its records as `{"0" => {...}, "1" => {...}}`, which a nested
1219
+ # block reads as unknown keys and silently drops, and an `array ... do`
1220
+ # field reads as the records it encodes. A has_one's stay a nested block.
1221
+ def nested_lines(name, keys, model: nil)
1222
+ return nested_array_lines(name, keys) if collection_attributes?(model, name)
1223
+
998
1224
  ["optional #{name.to_sym.inspect} do # TODO: drafted from `#{name}: [...]` — " \
999
1225
  "if this is an array of hashes, use `array #{name.to_sym.inspect} do`"] +
1000
1226
  sub_field_lines(keys) +
1001
1227
  ["end"]
1002
1228
  end
1003
1229
 
1230
+ def collection_attributes?(model, name)
1231
+ base = name.to_s.delete_suffix("_attributes")
1232
+ return false if base == name.to_s || !model.respond_to?(:reflect_on_association)
1233
+
1234
+ model.reflect_on_association(base.to_sym)&.collection? || false
1235
+ end
1236
+
1004
1237
  # No TODO on the kind here, unlike nested_lines: `params.expect` spells an
1005
1238
  # array of hashes `#{name}: [[...]]`, which says definitively what the
1006
1239
  # equivalent permit call (`#{name}: [...]`) leaves ambiguous.