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,336 @@
1
+ require "prism"
2
+ require_relative "keys"
3
+ require_relative "call_args"
4
+
5
+ module DialectoRails
6
+ module Usages
7
+ # Wrapper methods (spec-0023 §6.3): a method whose parameter flows into a translate call's key
8
+ # (`def tt(key) = t("nav.#{key}")`). The call inside the wrapper is not a use of its own: its literal head
9
+ # becomes a `template` record, and each call to the wrapper becomes a use. Found by repeating until nothing
10
+ # new is found, so a wrapper of a wrapper counts.
11
+ class Wrappers
12
+ # items: `{lit:}`, `{wild: true}` and `{param: index}`; templates: ids of the template records that hold
13
+ # the text; opaque: the key can't be worked out (an option such as `scope:` changes it).
14
+ # fixed: the key prefix a declared wrapper adds on its own (`Spree.t` reads under `spree.`), for a call whose
15
+ # key can't be worked out (it is then a pattern under that prefix, not under everything).
16
+ Builder = Struct.new(:items, :templates, :opaque, :fixed, keyword_init: true) do
17
+ def pass_through? = !opaque && items.length == 1 && items.first.key?(:param)
18
+ end
19
+
20
+ # One translate-like call inside a method, whose key mentions the method's parameters.
21
+ Call = Struct.new(:offset, :name, :kind, :items, :args, :opaque, :template, :template_id, keyword_init: true)
22
+
23
+ # `owner`: the class or module the method sits in (`{name:, module:}`), nil at the top level. `receiver`: the
24
+ # constant it is called on (`Spree` for `def self.t`, `class << self` or `module_function`), else nil.
25
+ Definition = Struct.new(:file, :offset, :name, :calls, :builders, :owner, :receiver, keyword_init: true)
26
+
27
+ # Where a template record is written: the key literal's start up to its first interpolation.
28
+ Template = Struct.new(:start, :text_end, :literal, :id, keyword_init: true)
29
+
30
+ # `declared`: methods called on a constant that the scan can't read the body of (a gem's `Spree.t`): hashes
31
+ # of `receiver:` ("Spree"), `name:` ("t") and `scope:` ("spree" or nil). They take the key as their first
32
+ # argument, like `t`, and put `scope` in front of it.
33
+ def initialize(i18n_functions: [], declared: [])
34
+ @i18n_functions = i18n_functions.map(&:to_sym)
35
+ @definitions = []
36
+ @table = {}
37
+ @by_name = {}
38
+ @parents = {}
39
+ @aliases = []
40
+ @declared = declared.map { |entry| [entry[:receiver], entry[:name].to_sym, declared_builder(entry[:scope])] }
41
+ end
42
+
43
+ def scout(file, program)
44
+ Scout.new(file, @i18n_functions, @definitions, @parents, @aliases).visit(program)
45
+ end
46
+
47
+ # The builders of a wrapper called on a constant (`Spree.t("x")`, `::Spree.t`), or nil. A declared wrapper
48
+ # matches the constant as written; one found in the source matches by the constant's last name.
49
+ def lookup_receiver(path, name)
50
+ name = name.to_sym
51
+ builders = @declared.filter_map { |receiver, declared, builder| builder if receiver == path && declared == name }
52
+ last = path.split("::").last
53
+ (@by_name[name] || []).each { |definition, builder| builders << builder if definition.receiver == last }
54
+ builders.uniq!
55
+ builders.empty? ? nil : builders
56
+ end
57
+
58
+ def resolve!
59
+ expand_aliases
60
+ 10.times do
61
+ changed = false
62
+ @definitions.each do |definition|
63
+ builders = definition.calls.filter_map { |call| builder_for(call, definition) }.uniq
64
+ next if builders.empty? || builders == definition.builders
65
+
66
+ definition.builders = builders
67
+ changed = true
68
+ end
69
+ rebuild_table
70
+ break unless changed
71
+ end
72
+ self
73
+ end
74
+
75
+ # The builders of the wrappers a call can reach, or nil. A bare call reaches a method of its own file, of a
76
+ # module (a helper or concern mixes in anywhere), of a class it descends from, or a top-level one. Without
77
+ # this, `validate :x` in any model would match some unrelated `def validate(key)`. Several different
78
+ # builders make the name ambiguous.
79
+ def lookup(name, file:, klass: nil)
80
+ reachable = (@by_name[name.to_sym] || []).select { |definition, _| reaches?(definition, file, klass) }
81
+ builders = reachable.map(&:last).uniq
82
+ builders.empty? ? nil : builders
83
+ end
84
+
85
+ def any? = !@table.empty?
86
+
87
+ # [file, call offset] → the template that call writes, or false when it has none: the call is suppressed.
88
+ def suppressed(file, offset)
89
+ @suppressed ||= build_suppressed
90
+ @suppressed[[file, offset]]
91
+ end
92
+
93
+ private
94
+
95
+ def declared_builder(scope)
96
+ prefix = Array(scope).map(&:to_s).reject(&:empty?).join(".")
97
+ return Builder.new(items: [{param: 0}], templates: [], opaque: false) if prefix.empty?
98
+
99
+ Builder.new(items: [{lit: "#{prefix}."}, {param: 0}], templates: [], opaque: false, fixed: "#{prefix}.")
100
+ end
101
+
102
+ # `alias t translate` and `alias_method :t, :translate` make a second name for a wrapper.
103
+ def expand_aliases
104
+ return if @aliases_done
105
+
106
+ @aliases_done = true
107
+ @aliases.each do |entry|
108
+ @definitions.dup.each do |definition|
109
+ next unless definition.file == entry[:file] && definition.name == entry[:old] &&
110
+ definition.owner&.dig(:name) == entry[:owner]&.dig(:name)
111
+
112
+ copy = definition.dup
113
+ copy.name = entry[:new]
114
+ copy.receiver = entry[:owner]&.dig(:name) if entry[:singleton]
115
+ @definitions << copy
116
+ end
117
+ end
118
+ end
119
+
120
+ def build_suppressed
121
+ out = {}
122
+ @definitions.each do |definition|
123
+ next if definition.builders.nil?
124
+
125
+ definition.calls.each { |call| out[[definition.file, call.offset]] = call.template || false }
126
+ end
127
+ out
128
+ end
129
+
130
+ def rebuild_table
131
+ @table = {}
132
+ @by_name = {}
133
+ @definitions.each do |definition|
134
+ next if definition.builders.nil? || definition.builders.empty?
135
+
136
+ (@table[definition.name.to_sym] ||= []).concat(definition.builders)
137
+ definition.builders.each { |builder| (@by_name[definition.name.to_sym] ||= []) << [definition, builder] }
138
+ end
139
+ @table.each_value(&:uniq!)
140
+ end
141
+
142
+ def reaches?(definition, file, klass)
143
+ owner = definition.owner
144
+ return true if owner.nil? || owner[:module] || definition.file == file
145
+ return true if definition.file.match?(%r{(?:\A|/)app/helpers/})
146
+
147
+ ancestors?(klass, owner[:name])
148
+ end
149
+
150
+ # Does `klass` (a simple class name) descend from `owner`, by the superclasses seen in the scan?
151
+ def ancestors?(klass, owner)
152
+ seen = {}
153
+ while klass && !seen[klass]
154
+ return true if klass == owner
155
+
156
+ seen[klass] = true
157
+ klass = @parents[klass]
158
+ end
159
+ false
160
+ end
161
+
162
+ def builder_for(call, definition)
163
+ if call.kind == :builtin
164
+ return Builder.new(items: call.items, templates: [call.template_id].compact, opaque: true) if call.opaque
165
+
166
+ return Builder.new(items: call.items, templates: [call.template_id].compact, opaque: false)
167
+ end
168
+
169
+ return nil if definition.name.to_sym == call.name.to_sym
170
+
171
+ callee = lookup(call.name, file: definition.file, klass: definition.owner&.dig(:name))
172
+ return nil if callee.nil?
173
+
174
+ if callee.length != 1 || callee.first.opaque || call.opaque
175
+ return Builder.new(items: [{wild: true}], templates: [], opaque: true)
176
+ end
177
+
178
+ built = callee.first
179
+ items = built.items.flat_map do |item|
180
+ item.key?(:param) ? (call.args[item[:param]] || [{wild: true}]) : [item]
181
+ end
182
+ Builder.new(items: items, templates: ([call.template_id] + built.templates).compact, opaque: false)
183
+ end
184
+
185
+ # Walks a file's methods, noting the translate-like calls whose key mentions a parameter.
186
+ class Scout < Prism::Visitor
187
+ def initialize(file, i18n_functions, definitions, parents, aliases)
188
+ super()
189
+ @file = file
190
+ @i18n_functions = i18n_functions
191
+ @definitions = definitions
192
+ @parents = parents
193
+ @aliases = aliases
194
+ @frames = []
195
+ @owners = []
196
+ @singleton = 0
197
+ end
198
+
199
+ # `class << self` makes every method in it callable on the class.
200
+ def visit_singleton_class_node(node)
201
+ return super unless node.expression.is_a?(Prism::SelfNode)
202
+
203
+ @singleton += 1
204
+ super
205
+ ensure
206
+ @singleton -= 1 if node.expression.is_a?(Prism::SelfNode)
207
+ end
208
+
209
+ def visit_alias_method_node(node)
210
+ note_alias(node.new_name, node.old_name)
211
+ super
212
+ end
213
+
214
+ def visit_class_node(node)
215
+ name = node.constant_path.slice.split("::").last
216
+ superclass = node.superclass&.slice&.split("::")&.last
217
+ @parents[name] ||= superclass if superclass
218
+ @owners << {name: name, module: false}
219
+ super
220
+ ensure
221
+ @owners.pop
222
+ end
223
+
224
+ def visit_module_node(node)
225
+ @owners << {name: node.constant_path.slice.split("::").last, module: true, functions: module_functions(node)}
226
+ super
227
+ ensure
228
+ @owners.pop
229
+ end
230
+
231
+ def visit_def_node(node)
232
+ parameters = node.parameters
233
+ params = {}
234
+ positional = parameters ? parameters.requireds + parameters.optionals : []
235
+ positional.each_with_index do |parameter, index|
236
+ params[parameter.name] = index if parameter.respond_to?(:name) && parameter.name
237
+ end
238
+ forward = [parameters&.rest, parameters&.keyword_rest, parameters&.block].filter_map { |item| item.name if item.respond_to?(:name) }
239
+ frame = {definition: Definition.new(file: @file, offset: node.location.start_offset, name: node.name.to_s,
240
+ calls: [], owner: @owners.last, receiver: receiver_of(node)), params: params, forward: forward}
241
+ @frames << frame
242
+ super
243
+ @frames.pop
244
+ @definitions << frame[:definition] if !frame[:definition].calls.empty? && !params.empty?
245
+ end
246
+
247
+ def visit_call_node(node)
248
+ frame = @frames.last
249
+ note(node, frame) if frame
250
+ note_alias(*node.arguments.arguments.first(2)) if node.name == :alias_method && node.receiver.nil? && node.arguments&.arguments&.length == 2
251
+ super
252
+ end
253
+
254
+ private
255
+
256
+ def note_alias(new_name, old_name)
257
+ names = [new_name, old_name].map { |item| item.unescaped if item.is_a?(Prism::SymbolNode) }
258
+ return if names.any?(&:nil?)
259
+
260
+ @aliases << {file: @file, owner: @owners.last, new: names[0], old: names[1], singleton: @singleton > 0}
261
+ end
262
+
263
+ # The constant a method is called on: `def self.t`, `def Spree.t`, a method under `class << self`, or one
264
+ # `module_function` (or `extend self`) turns into a module function. nil for an ordinary instance method.
265
+ def receiver_of(node)
266
+ owner = @owners.last
267
+ case node.receiver
268
+ when Prism::SelfNode then owner&.dig(:name)
269
+ when nil
270
+ functions = owner&.dig(:functions)
271
+ callable = @singleton > 0 || functions == true || (functions.is_a?(Array) && functions.include?(node.name.to_s))
272
+ owner&.dig(:name) if callable
273
+ else CallArgs.const_path(node.receiver)&.split("::")&.last
274
+ end
275
+ end
276
+
277
+ # `true` when the module says a bare `module_function` or `extend self`, else the names it lists.
278
+ def module_functions(node)
279
+ statements = node.body.respond_to?(:body) ? node.body.body : []
280
+ names = []
281
+ statements.each do |statement|
282
+ next unless statement.is_a?(Prism::CallNode) && statement.receiver.nil?
283
+
284
+ arguments = statement.arguments&.arguments || []
285
+ return true if statement.name == :module_function && arguments.empty?
286
+ return true if statement.name == :extend && arguments.first.is_a?(Prism::SelfNode)
287
+
288
+ names.concat(arguments.filter_map { |item| item.unescaped if item.is_a?(Prism::SymbolNode) }) if statement.name == :module_function
289
+ end
290
+ names.empty? ? nil : names
291
+ end
292
+
293
+ def note(node, frame)
294
+ return if frame[:params].empty?
295
+
296
+ kind = kind_of(node)
297
+ return unless kind
298
+
299
+ positional = CallArgs.positional(node)
300
+ key_node = positional.first
301
+ return unless key_node
302
+
303
+ analysis = Keys.analyze(key_node, params: frame[:params])
304
+ return unless Array(analysis.items).any? { |item| item.key?(:param) }
305
+
306
+ options = CallArgs.options(node, forward: frame[:forward])
307
+ args = positional.map { |argument| Keys.analyze(argument, params: frame[:params]).items || [{wild: true}] }
308
+ opaque = !options.scope.nil? || options.splat || !options.separator.nil?
309
+ template = template_for(key_node, analysis)
310
+ frame[:definition].calls << Call.new(offset: node.location.start_offset, name: node.name.to_s,
311
+ kind: kind, items: analysis.items, args: args, opaque: opaque, template: template,
312
+ template_id: template&.id)
313
+ end
314
+
315
+ def kind_of(node)
316
+ receiver = CallArgs.receiver_kind(node)
317
+ return nil if receiver == :other
318
+
319
+ if CallArgs::TRANSLATE_NAMES.include?(node.name) || (receiver == :i18n && node.name == :exists?)
320
+ :builtin
321
+ elsif receiver == :none
322
+ @i18n_functions.include?(node.name) ? :builtin : :wrapper_call
323
+ end
324
+ end
325
+
326
+ def template_for(key_node, analysis)
327
+ head = analysis.literals&.find(&:head?)
328
+ return nil unless head
329
+
330
+ start = key_node.location.start_offset
331
+ Template.new(start: start, text_end: head.text_end, literal: head, id: "#{@file}@#{start}")
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,3 @@
1
+ module DialectoRails
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "dialecto_rails/version"
2
+ require_relative "dialecto_rails/unmarked"
3
+
4
+ # What Bundler.require loads: the version and, inside a Rails app, the Railtie stub. The scanner and the
5
+ # usage scan (and their native Prism and Herb) are required by the CLI only.
6
+ module DialectoRails
7
+ autoload :Config, "dialecto_rails/config"
8
+ autoload :Error, "dialecto_rails/errors"
9
+ autoload :ConfigError, "dialecto_rails/errors"
10
+ autoload :RefusedError, "dialecto_rails/errors"
11
+ autoload :RetryableError, "dialecto_rails/errors"
12
+ end
13
+
14
+ require_relative "dialecto_rails/in_context/railtie_stub" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dialecto-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dialecto
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: prism
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: herb
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: 'The Rails side of Dialecto''s minimal-diff translation pull requests:
41
+ a scanner that sends config/locales to Dialecto from CI, a static scan of where
42
+ the code reads each key, and a development-only in-context editor.'
43
+ executables:
44
+ - dialecto-rails
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE
49
+ - README.md
50
+ - action.yml
51
+ - exe/dialecto-rails
52
+ - lib/dialecto-rails.rb
53
+ - lib/dialecto_rails.rb
54
+ - lib/dialecto_rails/config.rb
55
+ - lib/dialecto_rails/errors.rb
56
+ - lib/dialecto_rails/in_context/args.rb
57
+ - lib/dialecto_rails/in_context/assets/overlay.js
58
+ - lib/dialecto_rails/in_context/backend_hook.rb
59
+ - lib/dialecto_rails/in_context/csp.rb
60
+ - lib/dialecto_rails/in_context/git_context.rb
61
+ - lib/dialecto_rails/in_context/key_index.rb
62
+ - lib/dialecto_rails/in_context/launch_token.rb
63
+ - lib/dialecto_rails/in_context/live_middleware.rb
64
+ - lib/dialecto_rails/in_context/live_runtime.rb
65
+ - lib/dialecto_rails/in_context/live_store.rb
66
+ - lib/dialecto_rails/in_context/marker.rb
67
+ - lib/dialecto_rails/in_context/marking.rb
68
+ - lib/dialecto_rails/in_context/middleware.rb
69
+ - lib/dialecto_rails/in_context/overrides.rb
70
+ - lib/dialecto_rails/in_context/railtie_stub.rb
71
+ - lib/dialecto_rails/in_context/runtime.rb
72
+ - lib/dialecto_rails/in_context/sanitizer.rb
73
+ - lib/dialecto_rails/in_context/store.rb
74
+ - lib/dialecto_rails/scan/catalogs.rb
75
+ - lib/dialecto_rails/scan/cli.rb
76
+ - lib/dialecto_rails/scan/client.rb
77
+ - lib/dialecto_rails/scan/envelope.rb
78
+ - lib/dialecto_rails/scan/git.rb
79
+ - lib/dialecto_rails/scan/worktree.rb
80
+ - lib/dialecto_rails/unmarked.rb
81
+ - lib/dialecto_rails/usages/call_args.rb
82
+ - lib/dialecto_rails/usages/catalog_keys.rb
83
+ - lib/dialecto_rails/usages/check.rb
84
+ - lib/dialecto_rails/usages/haml_extractor.rb
85
+ - lib/dialecto_rails/usages/keys.rb
86
+ - lib/dialecto_rails/usages/known_wrappers.rb
87
+ - lib/dialecto_rails/usages/lazy_scope.rb
88
+ - lib/dialecto_rails/usages/parse_check.rb
89
+ - lib/dialecto_rails/usages/read_by_name.json
90
+ - lib/dialecto_rails/usages/record.rb
91
+ - lib/dialecto_rails/usages/ruby_visitor.rb
92
+ - lib/dialecto_rails/usages/scanner.rb
93
+ - lib/dialecto_rails/usages/summary.rb
94
+ - lib/dialecto_rails/usages/templates.rb
95
+ - lib/dialecto_rails/usages/wrappers.rb
96
+ - lib/dialecto_rails/version.rb
97
+ homepage: https://dialecto.eu
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ rubygems_mfa_required: 'true'
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '3.2'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 4.0.16
117
+ specification_version: 4
118
+ summary: 'Dialecto for Rails: scan locale files, find call sites, edit text in context.'
119
+ test_files: []