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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/action.yml +83 -0
- data/exe/dialecto-rails +6 -0
- data/lib/dialecto-rails.rb +1 -0
- data/lib/dialecto_rails/config.rb +206 -0
- data/lib/dialecto_rails/errors.rb +21 -0
- data/lib/dialecto_rails/in_context/args.rb +37 -0
- data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
- data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
- data/lib/dialecto_rails/in_context/csp.rb +72 -0
- data/lib/dialecto_rails/in_context/git_context.rb +103 -0
- data/lib/dialecto_rails/in_context/key_index.rb +128 -0
- data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
- data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
- data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
- data/lib/dialecto_rails/in_context/live_store.rb +14 -0
- data/lib/dialecto_rails/in_context/marker.rb +106 -0
- data/lib/dialecto_rails/in_context/marking.rb +122 -0
- data/lib/dialecto_rails/in_context/middleware.rb +202 -0
- data/lib/dialecto_rails/in_context/overrides.rb +61 -0
- data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
- data/lib/dialecto_rails/in_context/runtime.rb +52 -0
- data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
- data/lib/dialecto_rails/in_context/store.rb +41 -0
- data/lib/dialecto_rails/scan/catalogs.rb +46 -0
- data/lib/dialecto_rails/scan/cli.rb +339 -0
- data/lib/dialecto_rails/scan/client.rb +117 -0
- data/lib/dialecto_rails/scan/envelope.rb +100 -0
- data/lib/dialecto_rails/scan/git.rb +68 -0
- data/lib/dialecto_rails/scan/worktree.rb +41 -0
- data/lib/dialecto_rails/unmarked.rb +42 -0
- data/lib/dialecto_rails/usages/call_args.rb +78 -0
- data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
- data/lib/dialecto_rails/usages/check.rb +70 -0
- data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
- data/lib/dialecto_rails/usages/keys.rb +206 -0
- data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
- data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
- data/lib/dialecto_rails/usages/parse_check.rb +75 -0
- data/lib/dialecto_rails/usages/read_by_name.json +28 -0
- data/lib/dialecto_rails/usages/record.rb +91 -0
- data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
- data/lib/dialecto_rails/usages/scanner.rb +241 -0
- data/lib/dialecto_rails/usages/summary.rb +191 -0
- data/lib/dialecto_rails/usages/templates.rb +45 -0
- data/lib/dialecto_rails/usages/wrappers.rb +336 -0
- data/lib/dialecto_rails/version.rb +3 -0
- data/lib/dialecto_rails.rb +14 -0
- 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
|