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,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
|