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,42 @@
1
+ module DialectoRails
2
+ # The per-request flag of live-site editing. It lives in the execution state of the thread (or fiber) that
3
+ # renders the page, so jobs and other threads never inherit it. It is here, outside `InContext`, because app
4
+ # code calls `DialectoRails.unmarked` and a process that never turns live mode on must not load any of the
5
+ # in-context machinery to do so.
6
+ LIVE_MARKING = :dialecto_live_marking
7
+
8
+ class << self
9
+ # True while this thread renders a page for an editor.
10
+ def live_marking?
11
+ state = execution_state
12
+ !state.nil? && state[LIVE_MARKING] == true
13
+ end
14
+
15
+ # Runs the block with live-site markers off and restores the flag after, even on a raise. Wrap whatever
16
+ # renders text that leaves the page, such as a mail sent while an editor's request is being handled.
17
+ def unmarked
18
+ state = execution_state
19
+ return yield unless state
20
+
21
+ previous = state[LIVE_MARKING]
22
+ state[LIVE_MARKING] = false
23
+ begin
24
+ yield
25
+ ensure
26
+ state[LIVE_MARKING] = previous
27
+ end
28
+ end
29
+
30
+ # The middleware's side of the flag.
31
+ def live_marking=(on)
32
+ state = execution_state
33
+ state[LIVE_MARKING] = on if state
34
+ end
35
+
36
+ private
37
+
38
+ def execution_state
39
+ defined?(::ActiveSupport::IsolatedExecutionState) ? ::ActiveSupport::IsolatedExecutionState : nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,78 @@
1
+ require "prism"
2
+
3
+ module DialectoRails
4
+ module Usages
5
+ # What a translate call is made of: its positional arguments and the options of its trailing keyword hash.
6
+ module CallArgs
7
+ Options = Struct.new(:scope, :default, :separator, :splat, keyword_init: true)
8
+
9
+ TRANSLATE_NAMES = %i[t translate t! translate!].freeze
10
+ HELPER_RECEIVERS = %i[helpers view_context view controller].freeze
11
+
12
+ module_function
13
+
14
+ def positional(call)
15
+ arguments = call.arguments&.arguments || []
16
+ arguments.reject { |argument| argument.is_a?(Prism::KeywordHashNode) }
17
+ end
18
+
19
+ def keyword_hash(call)
20
+ (call.arguments&.arguments || []).find { |argument| argument.is_a?(Prism::KeywordHashNode) }
21
+ end
22
+
23
+ # `forward`: names of the enclosing method's own `*rest`, `**opts` and `&block`, whose splats pass the
24
+ # caller's options straight on and say nothing about the key.
25
+ def options(call, forward: [])
26
+ options = Options.new(splat: false)
27
+ keyword_hash(call)&.elements&.each do |element|
28
+ case element
29
+ when Prism::AssocNode
30
+ name = element.key.is_a?(Prism::SymbolNode) ? element.key.unescaped : nil
31
+ case name
32
+ when "scope" then options.scope = element.value
33
+ when "default" then options.default = element.value
34
+ when "separator" then options.separator = element.value
35
+ end
36
+ when Prism::AssocSplatNode
37
+ value = element.value
38
+ forwarded = value.is_a?(Prism::LocalVariableReadNode) && forward.include?(value.name)
39
+ options.splat = true unless forwarded
40
+ end
41
+ end
42
+ positional(call).drop(1).each do |extra|
43
+ options.splat = true if extra.is_a?(Prism::LocalVariableReadNode) || extra.is_a?(Prism::InstanceVariableReadNode)
44
+ end
45
+ options
46
+ end
47
+
48
+ def const_named?(node, name)
49
+ case node
50
+ when Prism::ConstantReadNode then node.name == name
51
+ when Prism::ConstantPathNode then node.parent.nil? && node.name == name
52
+ else false
53
+ end
54
+ end
55
+
56
+ # `Spree`, `::Spree` and `Admin::Helpers` as written (a leading `::` dropped), or nil for anything else.
57
+ def const_path(node)
58
+ case node
59
+ when Prism::ConstantReadNode then node.name.to_s
60
+ when Prism::ConstantPathNode
61
+ parent = node.parent.nil? ? "" : const_path(node.parent)
62
+ parent && (parent.empty? ? node.name.to_s : "#{parent}::#{node.name}")
63
+ end
64
+ end
65
+
66
+ # :none for a bare call (or `self.`), :i18n for `I18n.`, :helper for `helpers.`, `view_context.`…
67
+ # and :other for anything else.
68
+ def receiver_kind(call)
69
+ receiver = call.receiver
70
+ return :none if receiver.nil? || receiver.is_a?(Prism::SelfNode)
71
+ return :i18n if const_named?(receiver, :I18n)
72
+ return :helper if receiver.is_a?(Prism::CallNode) && HELPER_RECEIVERS.include?(receiver.name)
73
+
74
+ :other
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,67 @@
1
+ require "psych"
2
+ require "set"
3
+
4
+ module DialectoRails
5
+ module Usages
6
+ # The keys the project's catalogs hold, as Dialecto counts them: every leaf, and a plural's root as one key.
7
+ # Read as a YAML syntax tree (never loaded), so tags and aliases can't run or expand.
8
+ class CatalogKeys
9
+ PLURAL_FORMS = %w[zero one two few many other].freeze
10
+
11
+ def initialize
12
+ @keys = Set.new
13
+ @prefixes = Set.new
14
+ end
15
+
16
+ attr_reader :keys
17
+
18
+ def add(content)
19
+ document = Psych.parse(content)
20
+ root = document&.root
21
+ return self unless root.is_a?(Psych::Nodes::Mapping)
22
+
23
+ pairs(root).each { |_locale, value| walk(value, []) }
24
+ self
25
+ rescue Psych::SyntaxError, EncodingError
26
+ self
27
+ end
28
+
29
+ def include?(key) = @keys.include?(key)
30
+
31
+ # True when `text` ends in a dot and some key starts with it (`"map."` in a prop).
32
+ def prefix?(text)
33
+ return false unless text.end_with?(".")
34
+
35
+ @prefixes.include?(text)
36
+ end
37
+
38
+ def empty? = @keys.empty?
39
+
40
+ private
41
+
42
+ def pairs(mapping) = mapping.children.each_slice(2).to_a
43
+
44
+ def walk(node, path)
45
+ case node
46
+ when Psych::Nodes::Mapping
47
+ entries = pairs(node).select { |key, _value| key.is_a?(Psych::Nodes::Scalar) && key.value != "<<" }
48
+ if !path.empty? && plural?(entries)
49
+ add_key(path)
50
+ else
51
+ entries.each { |key, value| walk(value, path + [key.value]) }
52
+ end
53
+ when Psych::Nodes::Scalar, Psych::Nodes::Sequence then add_key(path) unless path.empty?
54
+ end
55
+ end
56
+
57
+ def plural?(entries)
58
+ entries.any? && entries.all? { |key, value| PLURAL_FORMS.include?(key.value) && !value.is_a?(Psych::Nodes::Mapping) }
59
+ end
60
+
61
+ def add_key(path)
62
+ @keys << path.join(".")
63
+ path[0...-1].each_index { |index| @prefixes << (path[0..index].join(".") + ".") }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,70 @@
1
+ module DialectoRails
2
+ module Usages
3
+ # `dialecto-rails check` (spec-0023 §6.8): code that reads a key the catalogs no longer hold. Runtime-only
4
+ # keys (`pattern` `*`) are never findings.
5
+ class Check
6
+ Finding = Struct.new(:code, :file, :line, :message, :warning, keyword_init: true)
7
+
8
+ # Namespaces Rails and common gems supply themselves, so a missing app catalog entry isn't a finding.
9
+ FRAMEWORK = %w[date time datetime number helpers errors activerecord activemodel activesupport support
10
+ views flash devise].freeze
11
+ TEST_FILE = %r{\A(?:test|spec|features)/}
12
+
13
+ def initialize(envelope, catalog)
14
+ @usages = envelope["usages"]
15
+ @catalog = catalog
16
+ end
17
+
18
+ def findings
19
+ @findings ||= @usages.flat_map { |usage| inspect_usage(usage) }
20
+ end
21
+
22
+ def failed? = findings.any? { |finding| !finding.warning }
23
+
24
+ private
25
+
26
+ def inspect_usage(usage)
27
+ case usage["kind"]
28
+ when "static" then static(usage)
29
+ when "set" then set(usage)
30
+ when "pattern" then pattern(usage)
31
+ else []
32
+ end
33
+ end
34
+
35
+ def static(usage)
36
+ key = usage["key"]
37
+ return [] if present?(key) || framework?(key) || Array(usage["defaults"]).any? { |entry| present?(entry["key"]) }
38
+
39
+ [finding("missing_key", usage, "reads #{key}, which no catalog has")]
40
+ end
41
+
42
+ def set(usage)
43
+ keys = usage["keys"]
44
+ return [] if keys.any? { |key| present?(key) || framework?(key) }
45
+
46
+ [finding("missing_keys", usage, "reads one of #{keys.first(3).join(", ")}#{keys.length > 3 ? ", …" : ""}; no catalog has any")]
47
+ end
48
+
49
+ def pattern(usage)
50
+ parts = usage["parts"]
51
+ return [] unless parts.any? { |part| part.key?("lit") }
52
+ return [] if framework?(usage["pattern"])
53
+
54
+ regexp = Regexp.new("\\A" + parts.map { |part| part["lit"] ? Regexp.escape(part["lit"]) : ".*" }.join + "\\z")
55
+ return [] if @catalog.keys.any? { |key| regexp.match?(key) }
56
+
57
+ [finding("no_matching_keys", usage, "reads #{usage["pattern"]}, which matches no catalog key")]
58
+ end
59
+
60
+ def present?(key) = @catalog.include?(key) || @catalog.prefix?("#{key}.")
61
+
62
+ def framework?(key) = FRAMEWORK.include?(key.to_s.split(".").first)
63
+
64
+ def finding(code, usage, message)
65
+ Finding.new(code: code, file: usage["file"], line: usage["start_line"], message: message,
66
+ warning: usage["file"].match?(TEST_FILE))
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,260 @@
1
+ require "haml"
2
+ require_relative "templates"
3
+
4
+ module DialectoRails
5
+ module Usages
6
+ # Reduces a HAML template to the Ruby inside it, byte for byte where it was (spec-0023 §6.2): everything
7
+ # that isn't Ruby becomes a space, each piece of Ruby is written back at its own offset, and the `end`s the
8
+ # indentation implied are appended after the last byte, so no offset moves. The scan doesn't follow control
9
+ # flow, so `else`, `when` and `rescue` (which would need an `end` in the middle of the file) are left out and
10
+ # the branches simply follow one another; `case` loses its keyword for the same reason. The pieces come from
11
+ # `Haml::Parser`'s tree; where they sit comes from the source lines.
12
+ #
13
+ # Read: `= code`, `- code`, `%tag= code`, `%tag{attributes}`, `#{interpolation}` in text, and `:ruby` filters.
14
+ # A construct whose Ruby can't be put back in place raises `Unreadable`, and the file is reported unscanned.
15
+ class HamlExtractor
16
+ Unreadable = Templates::Unreadable
17
+
18
+ MID_BLOCK = %w[else elsif when in rescue ensure].freeze
19
+
20
+ def self.extract(bytes)
21
+ new(bytes).call
22
+ end
23
+
24
+ def initialize(bytes)
25
+ @source = bytes.b
26
+ @lines = []
27
+ position = 0
28
+ @source.each_line do |line|
29
+ @lines << position
30
+ position += line.bytesize
31
+ end
32
+ @out = @source.gsub(/[^\n]/n, " ")
33
+ @used = Hash.new(0)
34
+ @opens = 0
35
+ @last_end = 0
36
+ end
37
+
38
+ def call
39
+ root = Haml::Parser.new({}).call(@source.dup.force_encoding(Encoding::UTF_8))
40
+ walk(root)
41
+ (@out << "\n" << ("end\n" * @opens)).force_encoding(Encoding::UTF_8)
42
+ rescue Haml::SyntaxError => e
43
+ raise Unreadable, "HAML syntax error#{" on line #{e.line + 1}" if e.respond_to?(:line) && e.line}"
44
+ end
45
+
46
+ private
47
+
48
+ def walk(node)
49
+ node.children.each do |child|
50
+ visit(child)
51
+ walk(child)
52
+ end
53
+ end
54
+
55
+ def script?(node) = %i[script silent_script].include?(node.type)
56
+
57
+ def mid_block?(node) = script?(node) && MID_BLOCK.include?(node.value[:keyword].to_s)
58
+
59
+ # A node whose Ruby opens a block that needs an `end` (`case` is written without its keyword).
60
+ def opens_block?(node)
61
+ script?(node) && !node.children.empty? && !mid_block?(node) && node.value[:keyword].to_s != "case"
62
+ end
63
+
64
+ def visit(node)
65
+ value = node.value
66
+ case node.type
67
+ when :script, :silent_script then code(node, value[:text])
68
+ when :tag then tag(node, value)
69
+ when :plain then interpolations(node.line)
70
+ when :filter then ruby_filter(node, value) if value[:name] == "ruby"
71
+ end
72
+ @opens += 1 if opens_block?(node)
73
+ end
74
+
75
+ def tag(node, value)
76
+ line = node.line
77
+ attributes = value[:dynamic_attributes]
78
+ line = attribute_hash(line) if attributes&.old
79
+ return unless value[:parse] && value[:value]
80
+
81
+ found = locate(line, value[:value].b)
82
+ if found
83
+ write(found, value[:value])
84
+ else
85
+ interpolations(line)
86
+ end
87
+ end
88
+
89
+ # `%tag{ … }`: the hash as written, from its `{` to the matching `}` (it may span lines). Returns the line
90
+ # the hash ends on.
91
+ def attribute_hash(line)
92
+ text = line_text(line).b
93
+ open = text.index("{", @used[line])
94
+ raise Unreadable, "HAML attributes on line #{line} can't be placed in the source" unless open
95
+
96
+ start = @lines[line - 1] + open
97
+ close = matching_brace(@source, start + 1)
98
+ raise Unreadable, "HAML attributes on line #{line} aren't closed" unless close
99
+
100
+ # As an array literal: `[ a: 1 ]` and `[ attributes ]` both read as Ruby, which a `{ … }` statement doesn't.
101
+ region = @source.byteslice(start, close - start + 1)
102
+ region.setbyte(0, 91)
103
+ region.setbyte(-1, 93)
104
+ write(start, region)
105
+ last = line_of(close)
106
+ @used[last] = close - @lines[last - 1] + 1
107
+ last
108
+ end
109
+
110
+ def code(node, text)
111
+ text = text.to_s.strip
112
+ return if text.empty?
113
+
114
+ line = node.line
115
+ keyword = node.value[:keyword].to_s
116
+ return code_after_keyword(line, text, keyword) if %w[elsif case].include?(keyword)
117
+ return if MID_BLOCK.include?(keyword)
118
+
119
+ found = locate(line, text.b)
120
+ if found
121
+ write(found, text)
122
+ elsif text.start_with?('"') && text.include?("\#{")
123
+ interpolations(line) # a text line HAML turned into a quoted string
124
+ else
125
+ multiline(line, text)
126
+ end
127
+ end
128
+
129
+ # `elsif cond` and `case value` keep what follows the keyword; the keyword's bytes stay blank.
130
+ def code_after_keyword(line, text, keyword)
131
+ rest = text.sub(/\A#{keyword}\s*/, "")
132
+ return if rest.empty?
133
+
134
+ found = locate(line, rest.b)
135
+ raise Unreadable, "HAML code on line #{line} can't be placed in the source" unless found
136
+
137
+ write(found, rest)
138
+ end
139
+
140
+ # Code that spans lines is read by HAML as one line with single spaces between the parts; each part is
141
+ # put back on its own line.
142
+ def multiline(first_line, text)
143
+ word = text[/\S+/].to_s.b
144
+ first = line_text(first_line).b
145
+ at = first.index(word, @used[first_line])
146
+ raise Unreadable, "HAML code on line #{first_line} can't be placed in the source" unless at
147
+
148
+ index = first_line
149
+ loop do
150
+ raw = line_text(index).b
151
+ continues = raw.rstrip.end_with?(",") || raw.match?(/\s\|\s*\z/n)
152
+ piece = raw.strip.sub(/\s+\|\z/n, "")
153
+ if index == first_line
154
+ piece = raw.byteslice(at..).strip.sub(/\s+\|\z/n, "")
155
+ offset = @lines[index - 1] + at
156
+ else
157
+ offset = @lines[index - 1] + raw.index(piece)
158
+ end
159
+ write(offset, piece, terminate: !continues)
160
+ @used[index] = offset - @lines[index - 1] + piece.bytesize
161
+ break unless continues && index < @lines.length
162
+
163
+ index += 1
164
+ end
165
+ end
166
+
167
+ def interpolations(line_number)
168
+ line = line_text(line_number).b
169
+ position = 0
170
+ while (open = line.index("\#{", position))
171
+ close = matching_brace(line, open + 2)
172
+ break unless close
173
+
174
+ inner = line.byteslice(open + 2, close - open - 2)
175
+ absolute = @lines[line_number - 1] + open + 2
176
+ @out[absolute - 1] = ";" if absolute.positive?
177
+ @out[absolute, inner.bytesize] = inner
178
+ @last_end = absolute + inner.bytesize
179
+ position = close + 1
180
+ end
181
+ end
182
+
183
+ def matching_brace(text, from)
184
+ depth = 1
185
+ quote = nil
186
+ index = from
187
+ while index < text.bytesize
188
+ char = text.getbyte(index).chr
189
+ if quote
190
+ index += 1 if char == "\\"
191
+ quote = nil if char == quote
192
+ elsif ['"', "'"].include?(char) then quote = char
193
+ elsif char == "{" then depth += 1
194
+ elsif char == "}"
195
+ depth -= 1
196
+ return index if depth.zero?
197
+ end
198
+ index += 1
199
+ end
200
+ nil
201
+ end
202
+
203
+ def ruby_filter(node, value)
204
+ index = node.line + 1
205
+ value[:text].to_s.each_line do |text|
206
+ stripped = text.strip
207
+ next if stripped.empty?
208
+
209
+ index += 1 while index <= @lines.length && !line_text(index).include?(stripped)
210
+ raise Unreadable, ":ruby filter on line #{node.line} can't be placed in the source" if index > @lines.length
211
+
212
+ write(locate(index, stripped.b), stripped)
213
+ end
214
+ end
215
+
216
+ # The byte offset of `text` in a source line, searching from the end of what was already placed there.
217
+ def locate(line_number, text, from: nil)
218
+ return nil if line_number < 1 || line_number > @lines.length
219
+
220
+ line = line_text(line_number).b
221
+ at = line.index(text, from || @used[line_number])
222
+ return nil unless at
223
+
224
+ @used[line_number] = at + text.bytesize
225
+ @lines[line_number - 1] + at
226
+ end
227
+
228
+ CONTINUES = /(?:[,(\[{\\.]|&&|\|\||\+|-|\*|\?|:(?!:)\s*)\z/
229
+
230
+ def write(offset, text, terminate: true)
231
+ text = text.b
232
+ @out[offset - 1] = ";" if offset.positive? && @out.getbyte(offset - 1) == 32 && separate?(offset)
233
+ @out[offset, text.bytesize] = text
234
+ @last_end = offset + text.bytesize
235
+ # HAML reads each line on its own; Ruby would let `render "x", f:` swallow the next line.
236
+ open_ended = text.match?(CONTINUES) && !text.match?(/\w:\z/)
237
+ return if !terminate || open_ended
238
+
239
+ following = @out.getbyte(@last_end)
240
+ # A newline already ends a statement; it is replaced only after `f:`, which would take the next line as
241
+ # its value (and never after a piece that may hold a `#` comment, which would swallow what follows).
242
+ @out[@last_end] = ";" if following == 32 || (following == 10 && text.match?(/\w:\z/))
243
+ end
244
+
245
+ # A `;` goes before a piece when another piece already stands earlier on the same line.
246
+ def separate?(offset)
247
+ start = @out.rindex("\n", offset - 1) || -1
248
+ @out.byteslice(start + 1, offset - start - 1).match?(/\S/)
249
+ end
250
+
251
+ def line_of(offset) = (@lines.rindex { |start| start <= offset } || 0) + 1
252
+
253
+ def line_text(number)
254
+ from = @lines[number - 1]
255
+ to = @lines[number] || @source.bytesize
256
+ @source.byteslice(from, to - from).chomp
257
+ end
258
+ end
259
+ end
260
+ end