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,241 @@
1
+ require "prism"
2
+ require_relative "../version"
3
+ require_relative "../scan/catalogs"
4
+ require_relative "catalog_keys"
5
+ require_relative "ruby_visitor"
6
+ require_relative "wrappers"
7
+ require_relative "known_wrappers"
8
+ require_relative "templates"
9
+
10
+ module DialectoRails
11
+ module Usages
12
+ # The usage scan (spec-0023 §6): every place the code at one commit reads a translation key, in the shape
13
+ # spec-0022 §6 fixes (the `railsUsages` object). Reads committed blobs through `Scan::Git`, never the
14
+ # working tree.
15
+ class Scanner
16
+ VERSION = "1.0.0".freeze
17
+ MAX_BYTES = 1_000_000
18
+ MAX_NAMED_KEYS = 200
19
+ DEFAULT_EXCLUDES = %w[node_modules vendor tmp log coverage public/assets app/assets/builds].freeze
20
+ EXCLUDED_PREFIXES = %w[public/packs].freeze
21
+ # What a generator copies into a new app is text for another app: it holds ERB inside `.rb` and is never loaded.
22
+ GENERATOR_TEMPLATES = %r{(?:\A|/)lib/generators/(?:.+/)?templates/}
23
+ RUBY_EXTENSIONS = %w[.rb .rake .jbuilder .builder .arb].freeze
24
+ SCRIPT_EXTENSIONS = %w[.js .jsx .ts .tsx .mjs .cjs .coffee].freeze
25
+ I18N_JS_CONFIGS = %w[config/i18n.yml config/i18n-js.yml config/i18n.js].freeze
26
+ JS_STRING = /"((?:[^"\\\n]|\\.){1,200})"|'((?:[^'\\\n]|\\.){1,200})'|`([^`$\\\n]{1,200})`/
27
+
28
+ Source = Struct.new(:path, :lang, :bytes, keyword_init: true)
29
+
30
+ # The catalog keys read at the scanned commit, available after `call`.
31
+ attr_reader :catalog
32
+
33
+ def initialize(git:, sha:, config:)
34
+ @git = git
35
+ @sha = sha
36
+ @config = config
37
+ @unscanned = []
38
+ @catalog = CatalogKeys.new
39
+ end
40
+
41
+ def call
42
+ entries = @git.entries(@sha).reject { |entry| excluded?(entry.path) }
43
+ contents = {}
44
+ read = ->(entry) { contents[entry.path] ||= @git.blob(entry.blob) }
45
+ catalog_entries = entries.select { |entry| catalogs.match?(entry.path) }
46
+ catalog_entries.each { |entry| @catalog.add(read.call(entry)) }
47
+
48
+ sources = []
49
+ entries.each do |entry|
50
+ next if catalogs.match?(entry.path)
51
+
52
+ classify(entry, sources, read)
53
+ end
54
+ contents.clear
55
+
56
+ locked_gems = gems(entries, read)
57
+ declared = KnownWrappers.declared(@config.wrappers, locked_gems.map { |gem| gem["name"] })
58
+ wrappers = Wrappers.new(i18n_functions: @config.i18n_functions, declared: declared)
59
+ programs = {}
60
+ sources.each do |source|
61
+ program = parse(source)
62
+ next unless program
63
+
64
+ programs[source.path] = [source, program]
65
+ guarded(source) { wrappers.scout(source.path, program) unless RubyVisitor::TEST_FILE.match?(source.path) }
66
+ end
67
+ wrappers.resolve!
68
+
69
+ usages = []
70
+ conventions = []
71
+ programs.each do |path, (source, program)|
72
+ guarded(source) do
73
+ result = RubyVisitor.new(file: path, lang: source.lang.to_s, bytes: source.bytes, wrappers: wrappers,
74
+ catalog: @catalog, i18n_functions: @config.i18n_functions).call(program)
75
+ usages.concat(result.records)
76
+ conventions.concat(result.conventions)
77
+ end
78
+ end
79
+
80
+ {
81
+ "version" => VERSION,
82
+ "parsers" => parsers,
83
+ "gems" => locked_gems,
84
+ "usages" => order(resolve_overlaps(usages)),
85
+ "unscanned" => @unscanned.sort_by { |item| [item["file"], item["reason"]] },
86
+ "conventions" => conventions.sort_by { |item| [item["file"], item["line"], item["key"].to_s] }
87
+ }
88
+ end
89
+
90
+ private
91
+
92
+ def catalogs = @catalogs ||= Scan::Catalogs.new(@config.catalog_paths)
93
+
94
+ def custom_excludes = @custom_excludes ||= Scan::Catalogs.new(@config.usages_exclude)
95
+
96
+ def excluded?(path)
97
+ return true if DEFAULT_EXCLUDES.any? { |name| path == name || path.start_with?("#{name}/") || path.include?("/#{name}/") }
98
+ return true if EXCLUDED_PREFIXES.any? { |prefix| path.start_with?(prefix) }
99
+ return true if GENERATOR_TEMPLATES.match?(path)
100
+
101
+ !@config.usages_exclude.empty? && custom_excludes.match?(path)
102
+ end
103
+
104
+ def classify(entry, sources, read)
105
+ path = entry.path
106
+ extension = File.extname(path).downcase
107
+ if path.end_with?(".slim")
108
+ return unscanned(path, "Slim isn't scanned")
109
+ elsif SCRIPT_EXTENSIONS.include?(extension)
110
+ return script(entry, read)
111
+ end
112
+
113
+ lang = template_language(path, extension)
114
+ return unless lang
115
+
116
+ bytes = entry.size > MAX_BYTES ? nil : read.call(entry)
117
+ return unscanned(path, "could not be parsed: file is larger than #{MAX_BYTES} bytes") unless bytes
118
+
119
+ bytes = bytes.dup.force_encoding(Encoding::UTF_8)
120
+ return unscanned(path, "could not be parsed: the file is not valid UTF-8") unless bytes.valid_encoding?
121
+
122
+ sources << Source.new(path: path, lang: lang, bytes: bytes)
123
+ end
124
+
125
+ def template_language(path, extension)
126
+ return :ruby if RUBY_EXTENSIONS.include?(extension) || %w[Rakefile Gemfile].include?(File.basename(path))
127
+ return :erb if extension == ".erb"
128
+
129
+ :haml if extension == ".haml"
130
+ end
131
+
132
+ def script(entry, read)
133
+ return if entry.size > MAX_BYTES
134
+
135
+ if i18n_js_configured?
136
+ return unscanned(entry.path, "JavaScript that reads the locale files isn't scanned")
137
+ end
138
+ return if @catalog.empty?
139
+
140
+ text = read.call(entry).dup.force_encoding(Encoding::UTF_8).scrub("")
141
+ named = text.scan(JS_STRING).map { |groups| groups.compact.first.to_s }.select { |name| @catalog.include?(name) }.uniq.sort
142
+ return if named.empty?
143
+
144
+ # The keys the file names as strings stay locked server-side; the file itself is a place to check.
145
+ # A list past the cap would silently unlock the rest, so it is sent as "all": the file locks every key.
146
+ keys = named.size > MAX_NAMED_KEYS ? "all" : named
147
+ unscanned(entry.path, "JavaScript that names a catalog key isn't scanned", keys: keys)
148
+ end
149
+
150
+ def i18n_js_configured?
151
+ return @i18n_js unless @i18n_js.nil?
152
+
153
+ paths = @git.entries(@sha).map(&:path)
154
+ @i18n_js = I18N_JS_CONFIGS.any? { |name| paths.include?(name) }
155
+ end
156
+
157
+ def unscanned(path, reason, keys: nil)
158
+ item = {"file" => path, "reason" => reason}
159
+ item["keys"] = keys if keys
160
+ @unscanned << item
161
+ end
162
+
163
+ def parse(source)
164
+ ruby = case source.lang
165
+ when :ruby then source.bytes
166
+ when :erb then Templates.erb(source.bytes)
167
+ when :haml then Templates.haml(source.bytes)
168
+ end
169
+ return haml_unavailable(source) if ruby.nil?
170
+
171
+ result = Prism.parse(ruby, version: "latest")
172
+ errors = source.lang == :ruby ? result.errors : result.errors.reject { |error| TEMPLATE_TOLERATED.include?(error.message) }
173
+ unless errors.empty?
174
+ unscanned(source.path, parse_reason(errors.first.message))
175
+ return nil
176
+ end
177
+ result.value
178
+ rescue Templates::Unreadable => e
179
+ unscanned(source.path, parse_reason(e.message)) # our own message: a line number, never source text
180
+ nil
181
+ rescue => e
182
+ # A parser's exception message may quote the source, so only its class leaves the machine.
183
+ unscanned(source.path, parse_reason("#{e.class.name} while reading the template"))
184
+ nil
185
+ end
186
+
187
+ REASON_LIMIT = 200
188
+
189
+ # `could not be parsed: <message>`, the message cut to 200 bytes on a character boundary.
190
+ def parse_reason(message)
191
+ text = message.to_s.lines.first.to_s.strip.byteslice(0, REASON_LIMIT).to_s.dup.force_encoding(Encoding::UTF_8)
192
+ text = text.byteslice(0, text.bytesize - 1).force_encoding(Encoding::UTF_8) until text.empty? || text.valid_encoding?
193
+ "could not be parsed: #{text}"
194
+ end
195
+
196
+ # A template's Ruby isn't inside a method: a layout's `yield` is fine there.
197
+ TEMPLATE_TOLERATED = ["Invalid yield", "Invalid return in class/module body"].freeze
198
+
199
+ # A bug in the scan on one file must not take the others down; the file is reported, not hidden.
200
+ def guarded(source)
201
+ yield
202
+ rescue => e
203
+ unscanned(source.path, parse_reason("internal error (#{e.class.name})"))
204
+ end
205
+
206
+ def haml_unavailable(source)
207
+ unscanned(source.path, "HAML isn't scanned")
208
+ nil
209
+ end
210
+
211
+ def gems(entries, read)
212
+ lock = entries.find { |entry| entry.path == "Gemfile.lock" }
213
+ return [] unless lock
214
+
215
+ specs = read.call(lock).dup.force_encoding(Encoding::UTF_8).scrub("").scan(/^ {4}(\S+) \(([^)\s]+)\)\s*$/)
216
+ specs.map { |name, version| {"name" => name, "version" => version} }.uniq.sort_by { |gem| [gem["name"], gem["version"]] }
217
+ end
218
+
219
+ def parsers
220
+ {"dialecto-rails" => VERSION_STRING, "prism" => Prism::VERSION, "herb" => Templates.herb_version,
221
+ "haml" => Templates.haml_version}.compact
222
+ end
223
+
224
+ VERSION_STRING = DialectoRails::VERSION
225
+
226
+ # A `literal` that lies inside a call record's anchor is already part of that use.
227
+ def resolve_overlaps(usages)
228
+ usages.group_by { |usage| usage["file"] }.flat_map do |_file, records|
229
+ claims = records.reject { |record| record["kind"] == "literal" }.map { |record| claim(record) }
230
+ records.reject do |record|
231
+ record["kind"] == "literal" && claims.any? { |from, to| record["offset"] >= from && record["offset"] < to }
232
+ end
233
+ end
234
+ end
235
+
236
+ def claim(record) = [record["offset"], record["offset"] + record["content"].bytesize]
237
+
238
+ def order(usages) = usages.sort_by { |usage| [usage["file"].b, usage["offset"]] }
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,191 @@
1
+ require "json"
2
+
3
+ module DialectoRails
4
+ module Usages
5
+ # The developer's view of a usage scan (spec-0023 §6.7): uses by kind, every catalog key sorted by how a
6
+ # rename could treat it, and what wasn't read. It follows the server's classification (dlocal
7
+ # `Dlocal.Localize.KeyRename`, spec-0022 §5.6), which decides; a parity test holds the two together.
8
+ #
9
+ # alone read only by calls that name it
10
+ # family a shared template or a group lookup reaches it: renamed only with its group
11
+ # locked Rails or a gem reads it by name, a JavaScript file names it as a string, or a call whose
12
+ # key is partly known could reach it. A file the scan can't read and a lookup of a wholly
13
+ # unknown key lock nothing: they are places to check, listed at the end.
14
+ # unreached nothing reads it
15
+ class Summary
16
+ # What Rails and the gems read by name; the server owns this file and the gem ships a copy.
17
+ READ_BY_NAME = JSON.parse(File.read(File.join(__dir__, "read_by_name.json"))).freeze
18
+
19
+ Row = Struct.new(:key, :klass, :because, :prefix, keyword_init: true)
20
+
21
+ def initialize(envelope, catalog_keys)
22
+ @envelope = envelope
23
+ @keys = catalog_keys.sort
24
+ end
25
+
26
+ def kinds = @envelope["usages"].group_by { |usage| usage["kind"] }.transform_values(&:length).sort.to_h
27
+
28
+ def rows
29
+ @rows ||= begin
30
+ index
31
+ @keys.map { |key| classify(key) }
32
+ end
33
+ end
34
+
35
+ def counts = rows.group_by(&:klass).transform_values(&:length)
36
+
37
+ def render
38
+ lines = ["Uses: #{kinds.map { |kind, count| "#{count} #{kind}" }.join(", ").then { |text| text.empty? ? "none" : text }}"]
39
+ counts.then { |by| lines << "Catalog keys: " + %w[alone family locked unreached].map { |name| "#{by[name.to_sym] || 0} #{name}" }.join(", ") }
40
+ rows.select { |row| row.klass == :locked }.each { |row| lines << " #{row.key} can't be renamed: #{row.because}" }
41
+ unreached = rows.select { |row| row.klass == :unreached }
42
+ lines << "Nothing reads #{unreached.length} keys (first: #{unreached.first(3).map(&:key).join(", ")})." unless unreached.empty?
43
+ @envelope["unscanned"].each { |item| lines << "Not scanned: #{item["file"]} (#{item["reason"]})" }
44
+ unknown = @envelope["usages"].select { |usage| usage["kind"] == "pattern" && wholly_unknown?(usage) }
45
+ unknown.each { |usage| lines << "Unknown key: #{usage["file"]}:#{usage["start_line"]} (#{reason_text(usage["reason"])})" }
46
+ places = @envelope["unscanned"].length + unknown.length
47
+ lines << "#{places} places Dialecto can't read; you check them before a rename." if places.positive?
48
+ lines.join("\n")
49
+ end
50
+
51
+ private
52
+
53
+ def index
54
+ @index ||= begin
55
+ usages = @envelope["usages"]
56
+ static = {}
57
+ set_by = Hash.new { |hash, key| hash[key] = [] }
58
+ templates = {}
59
+ patterns = []
60
+ implicit = Hash.new { |hash, key| hash[key] = [] }
61
+ usages.each do |usage|
62
+ case usage["kind"]
63
+ when "static" then static[usage["key"]] = true
64
+ when "template" then templates[usage["id"]] = usage
65
+ when "set" then usage["keys"].each { |key| set_by[key] << usage }
66
+ when "pattern" then patterns << [usage, tokens(usage["parts"])] unless wholly_unknown?(usage)
67
+ end
68
+ Array(usage["defaults"]).each do |default|
69
+ if default.key?("span") then static[default["key"]] = true
70
+ else implicit[default["key"]] << usage
71
+ end
72
+ end
73
+ end
74
+ { static: static, set_by: set_by, templates: templates, patterns: patterns, implicit: implicit,
75
+ rules: read_by_name_rules }
76
+ end
77
+ end
78
+
79
+ # A lookup of a key nothing is known about (`t(key)`): a place to check, not a lock on every key.
80
+ def wholly_unknown?(usage) = usage["parts"].none? { |part| part.key?("lit") }
81
+
82
+ # Rails' namespaces, the prefixes of the scan's gems and the scan's own conventions.
83
+ def read_by_name_rules
84
+ namespaces = READ_BY_NAME["namespaces"].map { |entry| { prefix: entry["prefix"], reader: entry["reader"] } }
85
+ gems = Array(@envelope["gems"]).flat_map do |gem|
86
+ Array(READ_BY_NAME["gems"][gem["name"]]).map { |prefix| { prefix: prefix, reader: "the #{gem["name"]} gem reads this key by its name" } }
87
+ end
88
+ conventions = Array(@envelope["conventions"]).map do |entry|
89
+ { prefix: entry["prefix"], key: entry["key"], reader: "the app reads this key by its name" }
90
+ end
91
+ namespaces + gems.uniq + conventions
92
+ end
93
+
94
+ def classify(key)
95
+ because = lock_reason(key)
96
+ return Row.new(key: key, klass: :locked, because: because) if because
97
+
98
+ groups = group_starts(key)
99
+ if index[:set_by].key?(key) || !groups.empty?
100
+ family(key, groups)
101
+ elsif index[:static][key] then Row.new(key: key, klass: :alone)
102
+ else Row.new(key: key, klass: :unreached)
103
+ end
104
+ end
105
+
106
+ def lock_reason(key)
107
+ # A file the scan can't read is a place to check, not a lock; a key the file names as a string stays locked.
108
+ unscanned = @envelope["unscanned"].find { |item| item["keys"] == "all" || Array(item["keys"]).include?(key) }
109
+ return "#{unscanned["file"]} names it as a string (#{unscanned["reason"]})" if unscanned
110
+
111
+ rule = index[:rules].find { |entry| entry[:prefix] ? key.start_with?(entry[:prefix]) : entry[:key] == key }
112
+ return rule[:reader] if rule
113
+
114
+ implicit = index[:implicit][key].first
115
+ return "#{implicit["file"]}:#{implicit["start_line"]} reads it as a fallback Rails adds" if implicit
116
+
117
+ hit = index[:patterns].find { |_usage, tokens| matches?(tokens, key) }
118
+ return "#{hit.first["file"]}:#{hit.first["start_line"]} #{reason_text(hit.first["reason"])}" if hit
119
+ end
120
+
121
+ def family(key, groups)
122
+ sets = index[:set_by][key]
123
+ starts = sets.map { |set| start_of(set) } + groups
124
+ prefix = starts.any?(&:nil?) ? "" : shared_prefix(starts)
125
+ if prefix.empty?
126
+ because = sets.map { |set| "#{set["file"]}:#{set["start_line"]} builds it from a template with no fixed dotted start" }.first
127
+ Row.new(key: key, klass: :locked, because: because)
128
+ else
129
+ Row.new(key: key, klass: :family, because: prefix, prefix: prefix)
130
+ end
131
+ end
132
+
133
+ def start_of(set)
134
+ origin = set["via"] && index[:templates][set["via"].first]
135
+ parts = set["via"] ? origin && origin["parts"] : set["parts"]
136
+ first = parts&.first
137
+ first && first["lit"]
138
+ end
139
+
140
+ # The longest dotted prefix every start shares ("" when they share no dot).
141
+ def shared_prefix(starts)
142
+ common = starts.reduce do |left, right|
143
+ left[0, (0...[left.length, right.length].min).take_while { |at| left[at] == right[at] }.length]
144
+ end
145
+ segments = common.split(".", -1)
146
+ segments.length < 2 ? "" : segments[0...-1].join(".") + "."
147
+ end
148
+
149
+ # `users.` for `users.index.title` when a call names the group `users`, which is no catalog key.
150
+ def group_starts(key)
151
+ @catalog ||= @keys.to_h { |catalog_key| [catalog_key, true] }
152
+ positions = (0...key.length).select { |at| key[at] == "." && at.positive? }
153
+ positions.filter_map do |at|
154
+ group = key[0, at]
155
+ "#{group}." if index[:static][group] && !@catalog[group]
156
+ end
157
+ end
158
+
159
+ # A pattern is a glob: literals and `*`. A wildcard matches any text, empty included.
160
+ def tokens(parts)
161
+ parts.each_with_object([]) do |part, acc|
162
+ if part["wild"] then acc << :wild unless acc.last == :wild
163
+ elsif !part["lit"].to_s.empty?
164
+ acc.last.is_a?(String) ? acc.last << part["lit"] : acc << part["lit"].dup
165
+ end
166
+ end
167
+ end
168
+
169
+ def matches?(tokens, key)
170
+ regexp = Regexp.new("\\A" + tokens.map { |token| token == :wild ? ".*" : Regexp.escape(token) }.join + "\\z", Regexp::MULTILINE)
171
+ regexp.match?(key)
172
+ end
173
+
174
+ def reason_text(reason)
175
+ {
176
+ "dynamic_scope" => "reads a key under a scope that isn't a literal",
177
+ "lazy_action_unknown" => "reads a lazy key outside a known action",
178
+ "lazy_outside_view" => "reads a lazy key outside a view",
179
+ "custom_separator" => "uses a custom separator",
180
+ "splat" => "passes options the scan can't read",
181
+ "argument" => "passes the key through a method the scan can't follow",
182
+ "concatenated_literal" => "builds a key from adjacent strings",
183
+ "interpolation" => "builds a key by interpolation",
184
+ "variable" => "reads a key from a variable",
185
+ "constant" => "reads a key from a constant",
186
+ "method_call" => "reads a key from a method call"
187
+ }.fetch(reason, "reads a key the scan can't resolve")
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,45 @@
1
+ module DialectoRails
2
+ module Usages
3
+ # Template languages reduced to the Ruby inside them, with every byte kept where it was, so an offset in
4
+ # the reduced text is an offset in the template.
5
+ module Templates
6
+ # A template construct whose Ruby can't be put back in place; the message holds a line number, never source.
7
+ Unreadable = Class.new(StandardError)
8
+
9
+ module_function
10
+
11
+ def erb(bytes)
12
+ require "herb"
13
+ Herb.extract_ruby(bytes)
14
+ end
15
+
16
+ # HAML is read when the app's bundle has the `haml` gem; nil otherwise.
17
+ def haml(bytes)
18
+ return nil unless haml_loadable?
19
+
20
+ require_relative "haml_extractor"
21
+ HamlExtractor.extract(bytes)
22
+ end
23
+
24
+ def haml_loadable?
25
+ return @haml_loadable unless @haml_loadable.nil?
26
+
27
+ @haml_loadable = begin
28
+ require "haml"
29
+ true
30
+ rescue LoadError
31
+ false
32
+ end
33
+ end
34
+
35
+ def herb_version
36
+ require "herb"
37
+ Herb::VERSION
38
+ rescue LoadError
39
+ nil
40
+ end
41
+
42
+ def haml_version = haml_loadable? ? Haml::VERSION : nil
43
+ end
44
+ end
45
+ end