dialecto-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +196 -0
  4. data/action.yml +83 -0
  5. data/exe/dialecto-rails +6 -0
  6. data/lib/dialecto-rails.rb +1 -0
  7. data/lib/dialecto_rails/config.rb +206 -0
  8. data/lib/dialecto_rails/errors.rb +21 -0
  9. data/lib/dialecto_rails/in_context/args.rb +37 -0
  10. data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
  11. data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
  12. data/lib/dialecto_rails/in_context/csp.rb +72 -0
  13. data/lib/dialecto_rails/in_context/git_context.rb +103 -0
  14. data/lib/dialecto_rails/in_context/key_index.rb +128 -0
  15. data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
  16. data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
  17. data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
  18. data/lib/dialecto_rails/in_context/live_store.rb +14 -0
  19. data/lib/dialecto_rails/in_context/marker.rb +106 -0
  20. data/lib/dialecto_rails/in_context/marking.rb +122 -0
  21. data/lib/dialecto_rails/in_context/middleware.rb +202 -0
  22. data/lib/dialecto_rails/in_context/overrides.rb +61 -0
  23. data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
  24. data/lib/dialecto_rails/in_context/runtime.rb +52 -0
  25. data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
  26. data/lib/dialecto_rails/in_context/store.rb +41 -0
  27. data/lib/dialecto_rails/scan/catalogs.rb +46 -0
  28. data/lib/dialecto_rails/scan/cli.rb +339 -0
  29. data/lib/dialecto_rails/scan/client.rb +117 -0
  30. data/lib/dialecto_rails/scan/envelope.rb +100 -0
  31. data/lib/dialecto_rails/scan/git.rb +68 -0
  32. data/lib/dialecto_rails/scan/worktree.rb +41 -0
  33. data/lib/dialecto_rails/unmarked.rb +42 -0
  34. data/lib/dialecto_rails/usages/call_args.rb +78 -0
  35. data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
  36. data/lib/dialecto_rails/usages/check.rb +70 -0
  37. data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
  38. data/lib/dialecto_rails/usages/keys.rb +206 -0
  39. data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
  40. data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
  41. data/lib/dialecto_rails/usages/parse_check.rb +75 -0
  42. data/lib/dialecto_rails/usages/read_by_name.json +28 -0
  43. data/lib/dialecto_rails/usages/record.rb +91 -0
  44. data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
  45. data/lib/dialecto_rails/usages/scanner.rb +241 -0
  46. data/lib/dialecto_rails/usages/summary.rb +191 -0
  47. data/lib/dialecto_rails/usages/templates.rb +45 -0
  48. data/lib/dialecto_rails/usages/wrappers.rb +336 -0
  49. data/lib/dialecto_rails/version.rb +3 -0
  50. data/lib/dialecto_rails.rb +14 -0
  51. metadata +119 -0
@@ -0,0 +1,206 @@
1
+ require "prism"
2
+ require_relative "record"
3
+
4
+ module DialectoRails
5
+ module Usages
6
+ # Reads the key argument of a translate call (spec-0023 §6.3): what text it can name, which literals carry
7
+ # that text, and why it can't be resolved when it can't.
8
+ #
9
+ # :static one key (`text`), with its literal if the literal can be rewritten
10
+ # :set an exact list of `keys` (a ternary, an `||`, an array, an interpolation over a literal list)
11
+ # :pattern literal parts around unknown ones (`items`), with the head literal
12
+ # :unresolved nothing to read (`reason`)
13
+ # :param the key is one of a wrapper's parameters (`index`), only when `params` is given
14
+ #
15
+ # `items` hold `{lit:}`, `{wild: true}` and, for a wrapper, `{param: index}`.
16
+ module Keys
17
+ Analysis = Struct.new(:kind, :text, :items, :keys, :literals, :reason, :index, keyword_init: true)
18
+
19
+ MAX_KEYS = 5_000
20
+ TRANSPARENT = %i[to_s to_sym to_str].freeze
21
+
22
+ module_function
23
+
24
+ # `env`: local name → literal values (a block parameter over a literal list). `params`: local name →
25
+ # positional index, when scouting a wrapper's body.
26
+ def analyze(node, env: {}, params: nil, role: :key)
27
+ case node
28
+ when Prism::StringNode then static_string(node, role)
29
+ when Prism::SymbolNode then static_symbol(node, role)
30
+ when Prism::InterpolatedStringNode, Prism::InterpolatedSymbolNode then interpolated(node, env, params, role)
31
+ when Prism::ParenthesesNode then parenthesized(node, env, params, role)
32
+ when Prism::ArrayNode then choices(node.elements, env, params, role)
33
+ when Prism::OrNode then choices([node.left, node.right], env, params, role)
34
+ when Prism::IfNode then choices(branches(node), env, params, role)
35
+ when Prism::LocalVariableReadNode
36
+ if params&.key?(node.name)
37
+ Analysis.new(kind: :param, index: params[node.name], items: [{param: params[node.name]}])
38
+ else
39
+ unresolved(:variable)
40
+ end
41
+ when Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
42
+ unresolved(:variable)
43
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then unresolved(:constant)
44
+ when Prism::CallNode
45
+ return unresolved(:variable) if node.variable_call?
46
+ return concatenated(node, env, params, role) if concatenation?(node)
47
+
48
+ transparent?(node) ? analyze(node.receiver, env: env, params: params, role: role) : unresolved(:method_call)
49
+ else unresolved(:variable)
50
+ end
51
+ end
52
+
53
+ def unresolved(reason) = Analysis.new(kind: :unresolved, reason: reason, items: [{wild: true}], literals: [])
54
+
55
+ def transparent?(node)
56
+ node.receiver && TRANSPARENT.include?(node.name) && node.arguments.nil? &&
57
+ (node.receiver.is_a?(Prism::LocalVariableReadNode))
58
+ end
59
+
60
+ # `"users." + role`: a string literal on the left of `+` with one argument. It names the same keys as
61
+ # `"users.#{role}"`, so it locks the same family. It carries no head literal: the contract's head span
62
+ # ends where an interpolation begins, so a group rename that reaches this call is refused, not rewritten.
63
+ def concatenation?(node)
64
+ node.name == :+ && node.receiver.is_a?(Prism::StringNode) && node.block.nil? &&
65
+ node.arguments&.arguments&.length == 1
66
+ end
67
+
68
+ def concatenated(node, env, params, role)
69
+ rest = analyze(node.arguments.arguments.first, env: env, params: params, role: role)
70
+ tail = rest.kind == :param ? {param: rest.index} : {wild: true}
71
+ Analysis.new(kind: :pattern, items: [{lit: node.receiver.unescaped}, tail], literals: [],
72
+ reason: :interpolation)
73
+ end
74
+
75
+ def parenthesized(node, env, params, role)
76
+ body = node.body
77
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : []
78
+ statements.length == 1 ? analyze(statements.first, env: env, params: params, role: role) : unresolved(:variable)
79
+ end
80
+
81
+ def branches(node)
82
+ out = []
83
+ out.concat(node.statements&.body.to_a.last(1))
84
+ tail = node.subsequent
85
+ case tail
86
+ when Prism::ElseNode then out.concat(tail.statements&.body.to_a.last(1))
87
+ when Prism::IfNode then out.concat(branches(tail))
88
+ end
89
+ out
90
+ end
91
+
92
+ # `cond ? "a" : "b"`, `"a" || "b"`, `[:a, :b]`: each literal branch is a use.
93
+ def choices(nodes, env, params, role)
94
+ analyses = nodes.map { |branch| analyze(branch, env: env, params: params, role: role) }
95
+ return unresolved(:variable) if analyses.empty? || analyses.any? { |item| item.kind != :static }
96
+
97
+ keys = analyses.map(&:text).uniq.sort
98
+ items = common_items(keys)
99
+ Analysis.new(kind: :set, keys: keys, items: items, literals: analyses.flat_map(&:literals))
100
+ end
101
+
102
+ # The segments every key shares at the start and at the end; whatever lies between is wild.
103
+ def common_items(keys)
104
+ return [{lit: keys.first}] if keys.length == 1
105
+
106
+ split = keys.map { |key| key.split(".", -1) }
107
+ head = split.first.zip(*split.drop(1)).take_while { |segments| segments.uniq.length == 1 }.length
108
+ tail_limit = split.map(&:length).min - head
109
+ tail = 0
110
+ tail += 1 while tail < tail_limit && split.map { |segments| segments[-1 - tail] }.uniq.length == 1
111
+ items = []
112
+ items << {lit: split.first.first(head).join(".") + "."} if head.positive?
113
+ items << {wild: true}
114
+ items << {lit: "." + split.first.last(tail).join(".")} if tail.positive?
115
+ items
116
+ end
117
+
118
+ def static_string(node, role)
119
+ opening = node.opening_loc&.slice
120
+ text = node.unescaped
121
+ literal = nil
122
+ if ['"', "'"].include?(opening) && node.content_loc.slice == text
123
+ literal = Literal.new(text_start: node.content_loc.start_offset, text_end: node.content_loc.end_offset,
124
+ delim: opening, role: role, anchor_end: node.location.end_offset)
125
+ end
126
+ Analysis.new(kind: :static, text: text, items: [{lit: text}], literals: [literal].compact)
127
+ end
128
+
129
+ def static_symbol(node, role)
130
+ opening = node.opening_loc&.slice
131
+ text = node.unescaped
132
+ literal = nil
133
+ if [":", ':"', ":'"].include?(opening) && node.value_loc && node.value_loc.slice == text
134
+ literal = Literal.new(text_start: node.value_loc.start_offset, text_end: node.value_loc.end_offset,
135
+ delim: opening, role: role, anchor_end: node.location.end_offset)
136
+ end
137
+ Analysis.new(kind: :static, text: text, items: [{lit: text}], literals: [literal].compact)
138
+ end
139
+
140
+ def interpolated(node, env, params, role)
141
+ parts = node.parts
142
+ if node.opening_loc.nil? && parts.all?(Prism::StringNode)
143
+ return unresolved(:concatenated_literal)
144
+ end
145
+
146
+ items = []
147
+ values = []
148
+ parts.each do |part|
149
+ case part
150
+ when Prism::StringNode
151
+ items << {lit: part.unescaped}
152
+ values << [part.unescaped]
153
+ when Prism::EmbeddedStatementsNode
154
+ resolved = embedded(part, env, params)
155
+ if resolved.is_a?(Array)
156
+ items << {wild: true}
157
+ values << resolved
158
+ elsif resolved.is_a?(Integer)
159
+ items << {param: resolved}
160
+ values << nil
161
+ else
162
+ items << {wild: true}
163
+ values << nil
164
+ end
165
+ else
166
+ items << {wild: true}
167
+ values << nil
168
+ end
169
+ end
170
+
171
+ literal = head_literal(node, role)
172
+ if values.none?(&:nil?)
173
+ keys = values.inject([""]) { |acc, options| acc.product(options).map(&:join) }.uniq.sort
174
+ if keys.length <= MAX_KEYS
175
+ return Analysis.new(kind: :set, keys: keys, items: items, literals: [literal].compact, reason: :interpolation)
176
+ end
177
+ end
178
+ Analysis.new(kind: :pattern, items: items, literals: [literal].compact, reason: :interpolation)
179
+ end
180
+
181
+ # An embedded `#{...}`: the literal values a loop variable stands for (Array), a wrapper parameter's
182
+ # index (Integer), or nil.
183
+ def embedded(part, env, params)
184
+ statements = part.statements&.body
185
+ return nil unless statements&.length == 1
186
+
187
+ node = statements.first
188
+ node = node.receiver if node.is_a?(Prism::CallNode) && transparent?(node)
189
+ return nil unless node.is_a?(Prism::LocalVariableReadNode)
190
+ return env[node.name] if env.key?(node.name)
191
+
192
+ params&.fetch(node.name, nil)
193
+ end
194
+
195
+ def head_literal(node, role)
196
+ first = node.parts.first
197
+ opening = node.opening_loc&.slice
198
+ return nil unless first.is_a?(Prism::StringNode) && [%("), ':"'].include?(opening)
199
+ return nil unless first.location.slice == first.unescaped
200
+
201
+ Literal.new(text_start: first.location.start_offset, text_end: first.location.end_offset,
202
+ delim: opening, role: role, head: true, anchor_end: node.location.end_offset)
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,31 @@
1
+ module DialectoRails
2
+ module Usages
3
+ # Translate wrappers that come from a gem (an app that depends on spree never has `Spree.t`'s definition in its
4
+ # tree), switched on by the gems in `Gemfile.lock` (spec-0023 §6.3). A project adds its own with the `wrappers`
5
+ # setting; a declared wrapper takes the key as its first argument and puts `scope` in front of it.
6
+ module KnownWrappers
7
+ SPREE = [{call: "Spree.t", scope: "spree"}, {call: "Spree.translate", scope: "spree"}].freeze
8
+
9
+ BY_GEM = {
10
+ "spree_core" => SPREE,
11
+ "solidus_core" => SPREE
12
+ }.freeze
13
+
14
+ # The declared entries as the scan uses them (`receiver:`, `name:`, `scope:`), from the project's `wrappers`
15
+ # setting and the table for the locked gems. An entry that isn't `Const.method` is dropped.
16
+ def self.declared(configured, gem_names)
17
+ entries = Array(configured) + gem_names.flat_map { |name| BY_GEM.fetch(name, []) }
18
+ entries.filter_map { |entry| parse(entry) }.uniq
19
+ end
20
+
21
+ def self.parse(entry)
22
+ entry = entry.to_h.transform_keys(&:to_sym)
23
+ match = entry[:call].to_s.strip.match(/\A(?:::)?((?:[A-Z]\w*::)*[A-Z]\w*)\.([a-z_]\w*[?!]?)\z/)
24
+ return nil unless match
25
+
26
+ scope = Array(entry[:scope]).map(&:to_s).reject(&:empty?).join(".")
27
+ {receiver: match[1], name: match[2], scope: scope.empty? ? nil : scope}
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,52 @@
1
+ module DialectoRails
2
+ module Usages
3
+ # Where a lazy key (`t(".title")`) is read from decides what it means (spec-0023 §6.4): Rails resolves it
4
+ # from the rendering view, or from a controller's or mailer's action, so the scan derives that context
5
+ # from the file and the class around the call.
6
+ module LazyScope
7
+ VIEW_ROOT = %r{(?:\A|/)app/views/(.+)\z}
8
+ ACTIONS = %w[index show new create edit update destroy].freeze
9
+
10
+ module_function
11
+
12
+ # `{base:, from:}` for a file under `app/views/`, or nil. `users/_row.html.erb` is `users.row`, a partial.
13
+ def view(path)
14
+ relative = path[VIEW_ROOT, 1]
15
+ return nil unless relative
16
+
17
+ *directories, file = relative.split("/")
18
+ name = file.split(".").first.to_s
19
+ partial = name.start_with?("_")
20
+ name = name.delete_prefix("_")
21
+ {base: (directories + [name]).join(".").delete_prefix("."), from: partial ? "partial" : "view"}
22
+ end
23
+
24
+ # :controller, :mailer or nil, from a class's own name and its superclass's.
25
+ def kind(class_name, superclass_name)
26
+ names = [class_name, superclass_name].compact
27
+ return :controller if names.any? { |name| name.end_with?("Controller") }
28
+ return :mailer if names.any? { |name| name.end_with?("Mailer") }
29
+
30
+ nil
31
+ end
32
+
33
+ # `Admin::UsersController` → `admin.users`; `UserMailer` → `user_mailer`.
34
+ def path(constant_path, kind)
35
+ segments = constant_path.split("::").map { |segment| underscore(segment) }
36
+ segments[-1] = segments[-1].delete_suffix("_controller") if kind == :controller
37
+ segments.join(".")
38
+ end
39
+
40
+ def underscore(name)
41
+ name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
42
+ end
43
+
44
+ def action?(kind, method_name, visibility)
45
+ return false unless method_name
46
+ return ACTIONS.include?(method_name) if kind == :controller
47
+
48
+ kind == :mailer && visibility == :public
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,75 @@
1
+ require "prism"
2
+ require_relative "scanner"
3
+ require_relative "templates"
4
+
5
+ module DialectoRails
6
+ module Usages
7
+ # `dialecto-rails parse-check`: do these files still parse with the gem's own readers? Ruby goes through Prism;
8
+ # ERB and HAML through the same extractors the usage scan uses, and the Ruby they yield through Prism, with the
9
+ # constructs a template may hold outside a method tolerated as the scan tolerates them. A rewritten call site
10
+ # must keep every file readable, so the rename check runs this over the bytes it would commit.
11
+ class ParseCheck
12
+ Failure = Struct.new(:path, :line, :message, keyword_init: true)
13
+
14
+ RUBY_NAMES = %w[Rakefile Gemfile].freeze
15
+
16
+ # `{checked: n, skipped: n, failures: [Failure]}`; a file of a kind the scan doesn't read is skipped.
17
+ def self.call(files) = new(files).call
18
+
19
+ def initialize(files)
20
+ @files = files
21
+ end
22
+
23
+ def call
24
+ result = {checked: 0, skipped: 0, failures: []}
25
+ @files.each do |path, bytes|
26
+ failure = check(path, bytes.dup.force_encoding(Encoding::UTF_8), result)
27
+ result[:failures] << failure if failure
28
+ end
29
+ result
30
+ end
31
+
32
+ private
33
+
34
+ def check(path, bytes, result)
35
+ lang = language(path)
36
+ return skip(result) unless lang
37
+
38
+ ruby = reduce(lang, bytes)
39
+ return skip(result) if ruby.nil?
40
+
41
+ result[:checked] += 1
42
+ errors = Prism.parse(ruby, version: "latest").errors
43
+ errors = errors.reject { |error| Scanner::TEMPLATE_TOLERATED.include?(error.message) } unless lang == :ruby
44
+ return if errors.empty?
45
+
46
+ Failure.new(path: path, line: errors.first.location.start_line, message: errors.first.message)
47
+ rescue => e
48
+ # A parser's message may quote the source; only the class and our own line-number messages are kept.
49
+ message = e.is_a?(Templates::Unreadable) ? e.message : "#{e.class.name} while reading the file"
50
+ Failure.new(path: path, line: nil, message: message)
51
+ end
52
+
53
+ def skip(result)
54
+ result[:skipped] += 1
55
+ nil
56
+ end
57
+
58
+ def reduce(lang, bytes)
59
+ case lang
60
+ when :ruby then bytes
61
+ when :erb then Templates.erb(bytes)
62
+ when :haml then Templates.haml(bytes)
63
+ end
64
+ end
65
+
66
+ def language(path)
67
+ extension = File.extname(path).downcase
68
+ return :ruby if Scanner::RUBY_EXTENSIONS.include?(extension) || RUBY_NAMES.include?(File.basename(path))
69
+ return :erb if extension == ".erb"
70
+
71
+ :haml if extension == ".haml"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,28 @@
1
+ {
2
+ "namespaces": [
3
+ { "prefix": "activerecord.", "reader": "Rails reads this key by its name, for model names, attributes and errors" },
4
+ { "prefix": "activemodel.", "reader": "Rails reads this key by its name, for model names, attributes and errors" },
5
+ { "prefix": "attributes.", "reader": "Rails reads this key by its name, for attribute names" },
6
+ { "prefix": "errors.", "reader": "Rails reads this key by its name, for validation messages" },
7
+ { "prefix": "helpers.", "reader": "Rails reads this key by its name, for form helpers" },
8
+ { "prefix": "date.", "reader": "Rails reads this key by its name, for dates" },
9
+ { "prefix": "time.", "reader": "Rails reads this key by its name, for times" },
10
+ { "prefix": "datetime.", "reader": "Rails reads this key by its name, for dates and times" },
11
+ { "prefix": "number.", "reader": "Rails reads this key by its name, for numbers" },
12
+ { "prefix": "support.", "reader": "Rails reads this key by its name, for lists and array formatting" },
13
+ { "prefix": "i18n.", "reader": "Rails reads this key by its name, for the i18n library" }
14
+ ],
15
+ "gems": {
16
+ "activeadmin": ["active_admin."],
17
+ "administrate": ["administrate."],
18
+ "devise": ["devise."],
19
+ "devise-i18n": ["devise."],
20
+ "doorkeeper": ["doorkeeper."],
21
+ "kaminari": ["views.", "helpers.page_entries_info."],
22
+ "pundit": ["pundit."],
23
+ "rails_admin": ["admin."],
24
+ "responders": ["flash."],
25
+ "simple_form": ["simple_form."],
26
+ "will_paginate": ["will_paginate."]
27
+ }
28
+ }
@@ -0,0 +1,91 @@
1
+ module DialectoRails
2
+ module Usages
3
+ # A literal in the source that carries part of a key: where its text sits (absolute UTF-8 byte
4
+ # offsets), what delimits it, and where an anchor that contains it must reach (`anchor_end`).
5
+ Literal = Struct.new(:text_start, :text_end, :delim, :role, :head, :anchor_end, keyword_init: true) do
6
+ def head? = head == true
7
+ end
8
+
9
+ # Pattern parts (spec-0018 §3): `{lit: String}` and `{wild: true}`, adjacent ones merged.
10
+ module Parts
11
+ module_function
12
+
13
+ def build(items)
14
+ merged = []
15
+ items.each do |item|
16
+ if item[:wild]
17
+ merged << {wild: true} unless merged.last&.key?(:wild)
18
+ elsif !item[:lit].to_s.empty?
19
+ if merged.last&.key?(:lit)
20
+ merged[-1] = {lit: merged.last[:lit] + item[:lit]}
21
+ else
22
+ merged << {lit: item[:lit]}
23
+ end
24
+ end
25
+ end
26
+ collapse(merged)
27
+ end
28
+
29
+ # `*` `.` `*` can only read what `*` reads: an unknown scope in front of an unknown key.
30
+ def collapse(parts)
31
+ index = 0
32
+ while index <= parts.length - 3
33
+ if parts[index][:wild] && parts[index + 1] == {lit: "."} && parts[index + 2][:wild]
34
+ parts.slice!(index + 1, 2)
35
+ else
36
+ index += 1
37
+ end
38
+ end
39
+ parts
40
+ end
41
+
42
+ def pattern(parts) = parts.map { |part| part[:lit] || "*" }.join
43
+
44
+ def json(parts) = parts.map { |part| part[:wild] ? {"wild" => true} : {"lit" => part[:lit]} }
45
+ end
46
+
47
+ # Byte offset → 1-based line, for one file.
48
+ class LineIndex
49
+ def initialize(bytes)
50
+ @newlines = []
51
+ offset = -1
52
+ @newlines << offset while (offset = bytes.byteindex("\n", offset + 1))
53
+ end
54
+
55
+ def line(offset) = @newlines.bsearch_index { |position| position >= offset } || @newlines.length
56
+
57
+ def line_of(offset) = line(offset) + 1
58
+ end
59
+
60
+ # The key-bearing literals of one record, turned into the contract's `spans` and the anchor around them.
61
+ class Anchor
62
+ MAX_BYTES = 2_000
63
+
64
+ attr_reader :start, :finish, :literals
65
+
66
+ def initialize(start:, call_end:, literals:, extra_end: nil)
67
+ @start = start
68
+ @literals = literals.sort_by(&:text_start)
69
+ ends = @literals.map(&:anchor_end)
70
+ ends << extra_end if extra_end
71
+ @finish = ends.empty? ? call_end : ends.max
72
+ end
73
+
74
+ def too_long? = finish - start > MAX_BYTES
75
+
76
+ def index_of(literal) = literals.index { |candidate| candidate.equal?(literal) }
77
+
78
+ def spans
79
+ literals.map do |literal|
80
+ span = {"start" => literal.text_start - start, "end" => literal.text_end - start, "delim" => literal.delim,
81
+ "role" => literal.role.to_s, "lang" => "ruby"}
82
+ if literal.head?
83
+ span["head"] = true
84
+ span["closed"] = false
85
+ end
86
+ span
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end