herb-embedded 0.10.3.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.
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "herb"
4
+ require "json"
5
+ require "prism"
6
+
7
+ module Herb
8
+ module Embedded
9
+ # Herb::ParseResult#to_json returns an inspect string, not the wire
10
+ # format @herb-tools/core's ParseResult.from() expects. This builds
11
+ # that envelope by hand from the pieces that do serialize correctly.
12
+ module ResultEnvelope
13
+ # Allowlist of Herb::ParserOptions keys safe to forward from
14
+ # caller-supplied options. Deliberately excludes prism_nodes,
15
+ # prism_nodes_deep, and prism_program: asking Herb.parse itself to
16
+ # embed prism_node populates it with a raw ASCII-8BIT String, and
17
+ # forwarding that through raises JSON::GeneratorError. Both are
18
+ # instead handled below by computing JSON-safe byte arrays
19
+ # ourselves. Also excludes timeout and max_errors (timing/error-cap
20
+ # options, not shape).
21
+ FORWARDABLE_OPTIONS = %i[
22
+ strict
23
+ track_whitespace
24
+ analyze
25
+ action_view_helpers
26
+ transform_conditionals
27
+ render_nodes
28
+ strict_locals
29
+ ].freeze
30
+
31
+ module_function
32
+
33
+ def parse(source, options_hash = {})
34
+ options_hash = (options_hash || {}).transform_keys(&:to_sym)
35
+ envelope = build_envelope(source, options_hash)
36
+
37
+ return envelope.to_json unless options_hash[:prism_nodes] || options_hash[:prism_nodes_deep]
38
+
39
+ with_injected_prism_nodes(envelope, source)
40
+ end
41
+
42
+ def build_envelope(source, options_hash)
43
+ result = Herb.parse(source, **forwardable(options_hash))
44
+
45
+ value_hash = result.value.to_hash
46
+ value_hash[:prism_node] = prism_program_bytes(source) if options_hash[:prism_program]
47
+
48
+ {
49
+ value: value_hash,
50
+ source: result.source,
51
+ warnings: result.warnings,
52
+ errors: result.errors,
53
+ options: result.options.to_h,
54
+ }
55
+ end
56
+ private_class_method :build_envelope
57
+
58
+ def lex(source)
59
+ result = Herb.lex(source)
60
+
61
+ {
62
+ tokens: result.value,
63
+ source: result.source,
64
+ warnings: result.warnings,
65
+ errors: result.errors,
66
+ }.to_json
67
+ end
68
+
69
+ # value_hash's children are still live Herb::AST::Node objects
70
+ # (#to_hash is shallow), so per-node injection needs them as plain
71
+ # Hashes first. A JSON round-trip is the simplest way to get that
72
+ # without hand-walking Node#child_nodes ourselves.
73
+ def with_injected_prism_nodes(envelope, source)
74
+ parsed_envelope = JSON.parse(envelope.to_json)
75
+ inject_prism_nodes(parsed_envelope["value"], source)
76
+ parsed_envelope.to_json
77
+ end
78
+ private_class_method :with_injected_prism_nodes
79
+
80
+ def forwardable(options_hash)
81
+ options_hash.each_with_object({}) do |(key, value), forwarded|
82
+ symbol_key = key.to_sym
83
+ forwarded[symbol_key] = value if FORWARDABLE_OPTIONS.include?(symbol_key)
84
+ end
85
+ end
86
+ private_class_method :forwardable
87
+
88
+ # @herb-tools/core's DocumentNode#prismNode getter deserializes
89
+ # prism_node bytes against the node's own (whole-file) `source`, so
90
+ # the bytes must come from parsing something byte-length-identical
91
+ # to source with Ruby content at the same offsets — exactly what
92
+ # Herb.extract_ruby produces (non-Ruby content blanked, not
93
+ # stripped). A plain Array of bytes (not the ASCII-8BIT String
94
+ # Prism.dump returns) is what keeps this JSON-safe.
95
+ def prism_program_bytes(source)
96
+ Prism.dump(Herb.extract_ruby(source)).bytes
97
+ end
98
+ private_class_method :prism_program_bytes
99
+
100
+ # Every AST_ERB_* node (ERBContentNode, ERBBlockNode, ERBIfNode,
101
+ # ...) carries its own embedded-Ruby snippet in a `content` token
102
+ # with a byte `range` into the whole file. Unlike prism_program's
103
+ # single whole-document parse, each of these needs its own Prism
104
+ # parse scoped to just that snippet — but still offset-correct
105
+ # against the whole-file `source`, since that's what every
106
+ # ERB*Node#prismNode getter deserializes against (ruby_backend.js
107
+ # unwraps the resulting single-statement ProgramNode down to the
108
+ # inner expression node the vendored rules actually expect).
109
+ def inject_prism_nodes(node, source)
110
+ case node
111
+ when Hash
112
+ inject_prism_node_for(node, source)
113
+ node.each_value { |value| inject_prism_nodes(value, source) }
114
+ when Array
115
+ node.each { |value| inject_prism_nodes(value, source) }
116
+ end
117
+ end
118
+ private_class_method :inject_prism_nodes
119
+
120
+ def inject_prism_node_for(node, source)
121
+ return unless node["type"].is_a?(String) && node["type"].start_with?("AST_ERB_")
122
+
123
+ range = node.dig("content", "range")
124
+ return unless range.is_a?(Array) && range.length == 2
125
+
126
+ node["prism_node"] = prism_nodes_bytes(source, range[0], range[1])
127
+ end
128
+ private_class_method :inject_prism_node_for
129
+
130
+ # Blanks (space, newlines preserved) every byte outside [from, to)
131
+ # so the one node's own Ruby content parses alone — at the correct
132
+ # absolute offset — rather than pulling in unrelated HTML or other
133
+ # ERB tags' Ruby.
134
+ def prism_nodes_bytes(source, from, to)
135
+ bytes = source.b.bytes
136
+ bytes.each_index do |i|
137
+ next if i >= from && i < to
138
+
139
+ bytes[i] = 0x20 unless bytes[i] == 0x0A
140
+ end
141
+
142
+ Prism.dump(bytes.pack("C*").force_encoding(source.encoding)).bytes
143
+ end
144
+ private_class_method :prism_nodes_bytes
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "report"
5
+ require_relative "lint_result"
6
+ require_relative "custom_rule_loader"
7
+
8
+ module Herb
9
+ module Embedded
10
+ # The Ruby replacement for cli.js's filesystem role: discovers files,
11
+ # iterates, and aggregates results. Formatters are pure functions of
12
+ # the Report this produces.
13
+ class Runner
14
+ def initialize(root:, config:, bridge:)
15
+ @root = root
16
+ @config = config
17
+ @bridge = bridge
18
+ @custom_rules_loaded = false
19
+ end
20
+
21
+ def run(paths = nil, rules: nil)
22
+ load_custom_rules!
23
+
24
+ Report.new.tap do |report|
25
+ files_for(paths).each do |absolute_path|
26
+ file = relative_path(absolute_path)
27
+ diagnostics = @bridge.lint(File.read(absolute_path), file: file, rules: rules)
28
+ report.add(LintResult.new(file: file, diagnostics: diagnostics))
29
+ end
30
+ end
31
+ end
32
+
33
+ def fix(paths = nil, rules: nil, unsafe: false)
34
+ load_custom_rules!
35
+
36
+ Report.new.tap do |report|
37
+ files_for(paths).each do |absolute_path|
38
+ file = relative_path(absolute_path)
39
+ original_source = File.read(absolute_path)
40
+ result = @bridge.autofix(original_source, file: file, rules: rules, unsafe: unsafe)
41
+
42
+ File.write(absolute_path, result[:source]) if result[:source] != original_source
43
+
44
+ diagnostics = @bridge.lint(result[:source], file: file, rules: rules)
45
+ report.add(LintResult.new(file: file, diagnostics: diagnostics))
46
+ end
47
+ end
48
+ end
49
+
50
+ # Idempotent and safe to call ahead of #run/#fix — e.g. so a caller
51
+ # can validate rule names (like a CLI's --only flag) against
52
+ # Bridge#rule_names with custom rules already registered.
53
+ def load_custom_rules!
54
+ return if @custom_rules_loaded
55
+
56
+ CustomRuleLoader.new(root: @root, bridge: @bridge).load_all
57
+ @custom_rules_loaded = true
58
+ end
59
+
60
+ private
61
+
62
+ def files_for(paths)
63
+ return discover_files unless paths
64
+
65
+ paths.map { |path| File.expand_path(path, @root) }
66
+ end
67
+
68
+ def discover_files
69
+ included = @config.include_globs.flat_map { |glob| Dir.glob(File.join(@root, glob)) }
70
+ excluded = @config.exclude_globs.flat_map { |glob| Dir.glob(File.join(@root, glob)) }
71
+ (included - excluded).sort
72
+ end
73
+
74
+ def relative_path(absolute_path)
75
+ Pathname.new(absolute_path).relative_path_from(Pathname.new(@root)).to_s
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herb
4
+ module Embedded
5
+ VERSION = "0.10.3.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "embedded/version"
4
+ require_relative "embedded/engine_adapter"
5
+
6
+ module Herb
7
+ module Embedded
8
+ class Error < StandardError; end
9
+ end
10
+ end