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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 157b3e70e039e8e93b06852fc5772ed1838a007b18d5e7de1eadbe65adf15f74
4
+ data.tar.gz: 1fc22a475a411316c609f84398db4750a9791694bcffb04e6139bb54ca0f2650
5
+ SHA512:
6
+ metadata.gz: 6c0e5e74c9d63cd191424ab8804e712b46948f02e98eb40d30c777626c0b991a490ce792a6cb36afd41b4b322942d4e0e635f9cbcd30f9ed617d3a369602a11d
7
+ data.tar.gz: e9e08f1c2cadd8626accc273de42629b34977e4f3e3051ab1d1351c8d66b2ae39d8f97abe88d02f37e696cf0c8d95a6064b038be5a061d0f4900e66060992976
data/exe/herb-lint-rb ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "herb/embedded"
5
+ require "herb/embedded/cli"
6
+
7
+ exit(Herb::Embedded::CLI.new(ARGV, stdout: $stdout, stderr: $stderr).run)
data/js/host_shim.js ADDED
@@ -0,0 +1,137 @@
1
+ // Minimal UTF-8 TextDecoder/TextEncoder for bare V8 embeddings (e.g.
2
+ // mini_racer), which provide ECMAScript but not WHATWG host APIs.
3
+ //
4
+ // Strict on purpose: real TextDecoder throws on non-buffer input rather
5
+ // than coercing it. A shim that coerces turns loud failures into silent
6
+ // ones — decode("a string") must throw, not return empty output.
7
+ (function (global) {
8
+ "use strict";
9
+
10
+ function toUint8Array(input) {
11
+ if (input instanceof Uint8Array) {
12
+ return input;
13
+ }
14
+ if (input instanceof ArrayBuffer) {
15
+ return new Uint8Array(input);
16
+ }
17
+ if (ArrayBuffer.isView(input)) {
18
+ return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
19
+ }
20
+ throw new TypeError(
21
+ "Failed to execute 'decode': The provided value is not of type " +
22
+ "'(ArrayBuffer or ArrayBufferView)'"
23
+ );
24
+ }
25
+
26
+ class TextDecoder {
27
+ constructor(encoding) {
28
+ if (encoding !== undefined && encoding !== "utf-8" && encoding !== "utf8") {
29
+ throw new RangeError("TextDecoder only supports utf-8");
30
+ }
31
+ this.encoding = "utf-8";
32
+ }
33
+
34
+ decode(input) {
35
+ const bytes = toUint8Array(input);
36
+ let result = "";
37
+ let i = 0;
38
+
39
+ while (i < bytes.length) {
40
+ const byte1 = bytes[i];
41
+ let codePoint;
42
+ let extraBytes;
43
+
44
+ if (byte1 < 0x80) {
45
+ codePoint = byte1;
46
+ extraBytes = 0;
47
+ } else if ((byte1 & 0xe0) === 0xc0) {
48
+ codePoint = byte1 & 0x1f;
49
+ extraBytes = 1;
50
+ } else if ((byte1 & 0xf0) === 0xe0) {
51
+ codePoint = byte1 & 0x0f;
52
+ extraBytes = 2;
53
+ } else if ((byte1 & 0xf8) === 0xf0) {
54
+ codePoint = byte1 & 0x07;
55
+ extraBytes = 3;
56
+ } else {
57
+ throw new TypeError("Invalid UTF-8 byte sequence");
58
+ }
59
+
60
+ for (let j = 0; j < extraBytes; j++) {
61
+ i++;
62
+ const nextByte = bytes[i];
63
+ if (nextByte === undefined || (nextByte & 0xc0) !== 0x80) {
64
+ throw new TypeError("Invalid UTF-8 continuation byte");
65
+ }
66
+ codePoint = (codePoint << 6) | (nextByte & 0x3f);
67
+ }
68
+
69
+ if (codePoint > 0xffff) {
70
+ codePoint -= 0x10000;
71
+ result += String.fromCharCode(
72
+ 0xd800 + (codePoint >> 10),
73
+ 0xdc00 + (codePoint & 0x3ff)
74
+ );
75
+ } else {
76
+ result += String.fromCharCode(codePoint);
77
+ }
78
+
79
+ i++;
80
+ }
81
+
82
+ return result;
83
+ }
84
+ }
85
+
86
+ class TextEncoder {
87
+ constructor() {
88
+ this.encoding = "utf-8";
89
+ }
90
+
91
+ encode(input) {
92
+ if (typeof input !== "string") {
93
+ throw new TypeError(
94
+ "Failed to execute 'encode': parameter 1 is not of type 'string'."
95
+ );
96
+ }
97
+
98
+ const bytes = [];
99
+
100
+ for (let i = 0; i < input.length; i++) {
101
+ let codePoint = input.charCodeAt(i);
102
+
103
+ if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < input.length) {
104
+ const next = input.charCodeAt(i + 1);
105
+ if (next >= 0xdc00 && next <= 0xdfff) {
106
+ codePoint = (codePoint - 0xd800) * 0x400 + (next - 0xdc00) + 0x10000;
107
+ i++;
108
+ }
109
+ }
110
+
111
+ if (codePoint < 0x80) {
112
+ bytes.push(codePoint);
113
+ } else if (codePoint < 0x800) {
114
+ bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f));
115
+ } else if (codePoint < 0x10000) {
116
+ bytes.push(
117
+ 0xe0 | (codePoint >> 12),
118
+ 0x80 | ((codePoint >> 6) & 0x3f),
119
+ 0x80 | (codePoint & 0x3f)
120
+ );
121
+ } else {
122
+ bytes.push(
123
+ 0xf0 | (codePoint >> 18),
124
+ 0x80 | ((codePoint >> 12) & 0x3f),
125
+ 0x80 | ((codePoint >> 6) & 0x3f),
126
+ 0x80 | (codePoint & 0x3f)
127
+ );
128
+ }
129
+ }
130
+
131
+ return new Uint8Array(bytes);
132
+ }
133
+ }
134
+
135
+ global.TextDecoder = TextDecoder;
136
+ global.TextEncoder = TextEncoder;
137
+ })(typeof globalThis !== "undefined" ? globalThis : this);
@@ -0,0 +1,204 @@
1
+ // Wires the vendored @herb-tools/linter bundle to Ruby. Must load after
2
+ // vendor/herb-linter.js and after the six rbXxx callbacks below are
3
+ // attached (Bridge#boot handles the ordering).
4
+ //
5
+ // HerbBackend (from @herb-tools/core) must be subclassed: its `version`
6
+ // getter calls the abstract backendVersion(), which the base class
7
+ // doesn't implement. Instantiating HerbBackend directly fails.
8
+ var libHerbBackend = {
9
+ parse: function (source, options) {
10
+ return JSON.parse(rbParse(source, options));
11
+ },
12
+ lex: function (source) {
13
+ return JSON.parse(rbLex(source));
14
+ },
15
+ extractRuby: function (source, options) {
16
+ return rbExtractRuby(source, options);
17
+ },
18
+ extractHTML: function (source) {
19
+ return rbExtractHTML(source);
20
+ },
21
+ parseRuby: function (source) {
22
+ return rbParseRuby(source);
23
+ },
24
+ version: function () {
25
+ return rbVersion();
26
+ },
27
+ diff: function () {
28
+ throw new Error("diff is not implemented by the Ruby backend");
29
+ },
30
+ };
31
+
32
+ // rbParse (via ResultEnvelope#inject_prism_nodes) injects prism_node bytes
33
+ // produced by Ruby's Prism.dump, which always serializes a whole
34
+ // ProgramNode — there is no public API to dump an arbitrary sub-node
35
+ // directly. But every prism_nodes-dependent rule (see CHARTER.md) expects
36
+ // an ERB*Node's prismNode to BE the single embedded-Ruby expression node
37
+ // itself (e.g. isAssignmentNode checks prismNode.constructor.name), not a
38
+ // Program wrapping it. Unwrap here, once, for every ERB node class that
39
+ // defines the prismNode getter, rather than patching the vendored bundle.
40
+ // Only unwraps when the parse yielded exactly one top-level statement —
41
+ // the case single-tag Ruby content always produces — leaving anything
42
+ // else (multiple ';'-separated statements in one tag) as the ProgramNode,
43
+ // same as an unhandled edge case would fall back to.
44
+ Object.keys(HerbLinter).forEach(function (name) {
45
+ if (!/^ERB.*Node$/.test(name)) return;
46
+
47
+ var proto = HerbLinter[name] && HerbLinter[name].prototype;
48
+ var descriptor = proto && Object.getOwnPropertyDescriptor(proto, "prismNode");
49
+ if (!descriptor || typeof descriptor.get !== "function") return;
50
+
51
+ var originalGet = descriptor.get;
52
+
53
+ Object.defineProperty(proto, "prismNode", {
54
+ configurable: true,
55
+ enumerable: descriptor.enumerable,
56
+ get: function () {
57
+ var raw = originalGet.call(this);
58
+ var body = raw && raw.constructor && raw.constructor.name === "ProgramNode" && raw.statements && raw.statements.body;
59
+
60
+ return body && body.length === 1 ? body[0] : raw;
61
+ },
62
+ });
63
+ });
64
+
65
+ class RubyBackend extends HerbLinter.HerbBackend {
66
+ backendVersion() {
67
+ return "mini_racer";
68
+ }
69
+ }
70
+
71
+ // A Promise returned directly from a Ruby<->JS call marshals to Ruby as
72
+ // {} — mini_racer doesn't drive V8's microtask queue on its own timeline,
73
+ // only between separate eval/call invocations. So boot is a two-step
74
+ // handshake: this call kicks off the async load without awaiting it;
75
+ // Bridge#boot observes __herbEmbeddedBridge.ready in a second, separate
76
+ // call, by which point the microtask queue has drained and the promise
77
+ // has resolved.
78
+ var __herbEmbeddedBridge = {
79
+ instance: new RubyBackend(function () {
80
+ return Promise.resolve(libHerbBackend);
81
+ }),
82
+ ready: false,
83
+ error: null,
84
+ };
85
+
86
+ __herbEmbeddedBridge.instance
87
+ .load()
88
+ .then(function () {
89
+ __herbEmbeddedBridge.ready = true;
90
+ })
91
+ .catch(function (e) {
92
+ __herbEmbeddedBridge.error = String(e && e.message ? e.message : e);
93
+ });
94
+
95
+ function __herbRuleNames() {
96
+ return HerbLinter.rules.map(function (ruleClass) {
97
+ return ruleClass.ruleName;
98
+ });
99
+ }
100
+
101
+ // Rule selection happens before execution: Linter's constructor takes an
102
+ // explicit rules array, so an unselected rule is never instantiated or
103
+ // checked, not merely filtered out of the offenses it already produced.
104
+ // With no explicit selection, match upstream's own default: only rules
105
+ // enabled by their own defaultConfig (Linter.filterRulesByConfig with no
106
+ // user config still filters on that), not every available rule class.
107
+ // An explicit selection bypasses that filtering entirely — matching
108
+ // --only's real semantics — so a caller can still opt into a
109
+ // not-enabled-by-default rule by naming it.
110
+ function __herbSelectRules(ruleNames) {
111
+ var wanted = ruleNames && ruleNames.length ? ruleNames : null;
112
+
113
+ if (!wanted) {
114
+ return HerbLinter.Linter.filterRulesByConfig(HerbLinter.rules).enabled;
115
+ }
116
+
117
+ return HerbLinter.rules.filter(function (ruleClass) {
118
+ return wanted.indexOf(ruleClass.ruleName) !== -1;
119
+ });
120
+ }
121
+
122
+ // Registers a custom rule from Ruby-rewritten source (see
123
+ // CustomRuleLoader.rewrite): an IIFE expression that destructures its
124
+ // base class from HerbLinter and returns the rule class. A rule sharing
125
+ // a ruleName with an existing one (built-in or previously-registered
126
+ // custom) replaces it in place, matching upstream's override behavior —
127
+ // the caller (CustomRuleLoader#load_all) is told via `overrode` so it can
128
+ // warn on the Ruby side.
129
+ function __herbRegisterCustomRule(rewrittenSource, path) {
130
+ var RuleClass;
131
+
132
+ try {
133
+ RuleClass = eval(rewrittenSource);
134
+ } catch (e) {
135
+ throw new Error("Failed to evaluate custom rule at " + path + ": " + (e && e.message ? e.message : String(e)));
136
+ }
137
+
138
+ if (!RuleClass || typeof RuleClass !== "function" || typeof RuleClass.ruleName !== "string" || typeof RuleClass.prototype.check !== "function") {
139
+ throw new Error("No valid default export found in " + path + ". Custom rules must use default export.");
140
+ }
141
+
142
+ var existingIndex = HerbLinter.rules.findIndex(function (ruleClass) {
143
+ return ruleClass.ruleName === RuleClass.ruleName;
144
+ });
145
+ var overrode = existingIndex !== -1;
146
+
147
+ if (overrode) {
148
+ HerbLinter.rules.splice(existingIndex, 1, RuleClass);
149
+ } else {
150
+ HerbLinter.rules.push(RuleClass);
151
+ }
152
+
153
+ return { ruleName: RuleClass.ruleName, overrode: overrode };
154
+ }
155
+
156
+ // A single Linter across all selected rules, matching upstream's own
157
+ // lint-then-fix composition (Linter#autofix mutates a running source as
158
+ // it walks offenses from every selected rule together, unlike #lint's
159
+ // per-rule crash isolation). The parse-doesn't-break safety check lives
160
+ // on the Ruby side (Bridge#autofix), since it just needs Herb.parse.
161
+ function __herbAutofix(source, file, ruleNames, includeUnsafe) {
162
+ var ruleClasses = __herbSelectRules(ruleNames);
163
+ var context = { fileName: file, filename: file };
164
+ var linter = new HerbLinter.Linter(__herbEmbeddedBridge.instance, ruleClasses, undefined, HerbLinter.rules);
165
+ var result = linter.autofix(source, context, undefined, { includeUnsafe: !!includeUnsafe });
166
+
167
+ return JSON.stringify({ source: result.source, fixed: result.fixed });
168
+ }
169
+
170
+ // One Linter per selected rule, each in its own try/catch: a rule that
171
+ // throws must not abort the run (spike 2 finding) — the real Linter#lint
172
+ // has no such isolation internally, since it runs every selected rule's
173
+ // check() in one uncaught loop.
174
+ //
175
+ // The 4th constructor arg (allAvailableRules) must be the full registry,
176
+ // not just the one rule being run: herb-disable-comment-unnecessary reads
177
+ // context.validRuleNames (built from Linter#getAvailableRules, which
178
+ // falls back to allAvailableRules) to decide whether a `herb:disable
179
+ // some-other-rule` comment references a real rule — omitting this arg
180
+ // left validRuleNames scoped to whatever single rule __herbLint happened
181
+ // to be running, so the rule silently never matched anything outside
182
+ // itself. Caught by conformance fixture coverage (herb-embedded-ag7).
183
+ function __herbLint(source, file, ruleNames) {
184
+ var ruleClasses = __herbSelectRules(ruleNames);
185
+ var context = { fileName: file, filename: file };
186
+ var offenses = [];
187
+
188
+ ruleClasses.forEach(function (ruleClass) {
189
+ try {
190
+ var linter = new HerbLinter.Linter(__herbEmbeddedBridge.instance, [ruleClass], undefined, HerbLinter.rules);
191
+ var result = linter.lint(source, context);
192
+ offenses = offenses.concat(result.offenses);
193
+ } catch (e) {
194
+ offenses.push({
195
+ rule: ruleClass.ruleName,
196
+ message: "Rule '" + ruleClass.ruleName + "' crashed: " + (e && e.message ? e.message : String(e)),
197
+ severity: "error",
198
+ location: null,
199
+ });
200
+ }
201
+ });
202
+
203
+ return JSON.stringify(offenses);
204
+ }
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mini_racer"
4
+ require_relative "../engine_adapter"
5
+
6
+ module Herb
7
+ module Embedded
8
+ module Adapters
9
+ # Reference EngineAdapter backed by mini_racer (bundled V8).
10
+ class MiniRacer < EngineAdapter
11
+ def initialize
12
+ super
13
+ @context = ::MiniRacer::Context.new
14
+ end
15
+
16
+ def load(source)
17
+ @context.eval(source)
18
+ self
19
+ end
20
+
21
+ def attach(name, &block)
22
+ wrapped = proc do |*args|
23
+ result = block.call(*args)
24
+ result.is_a?(Binary) ? ::MiniRacer::Binary.new(result.raw) : result
25
+ end
26
+ @context.attach(name, wrapped)
27
+ self
28
+ end
29
+
30
+ def call(function, *args)
31
+ converted = args.map { |arg| arg.is_a?(Binary) ? arg.raw.bytes : arg }
32
+ @context.call(function, *converted)
33
+ end
34
+
35
+ def dispose
36
+ @context.dispose
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "prism"
5
+ require_relative "../embedded"
6
+ require_relative "engine_adapter"
7
+ require_relative "result_envelope"
8
+ require_relative "diagnostic"
9
+
10
+ module Herb
11
+ module Embedded
12
+ # Boots the JS engine: loads the host shim and vendored bundle,
13
+ # attaches the six Ruby callbacks HerbBackend needs, then loads the
14
+ # RubyBackend subclass and waits for its async init to resolve.
15
+ class Bridge
16
+ # Rescuing Herb::Embedded::Error catches any of these without also
17
+ # catching unrelated bugs.
18
+ class VersionMismatchError < Error; end
19
+ class NotBootedError < Error; end
20
+ class BootError < Error; end
21
+
22
+ HOST_SHIM_PATH = File.expand_path("../../../js/host_shim.js", __dir__)
23
+ RUBY_BACKEND_PATH = File.expand_path("../../../js/ruby_backend.js", __dir__)
24
+
25
+ READY_CHECK_JS = <<~JS
26
+ function __herbEmbeddedReady() { return __herbEmbeddedBridge.ready; }
27
+ function __herbEmbeddedError() { return __herbEmbeddedBridge.error; }
28
+ function __herbEmbeddedVersion() { return __herbEmbeddedBridge.instance.version; }
29
+ JS
30
+
31
+ def initialize(adapter:, bundle:)
32
+ @adapter = adapter
33
+ @bundle = bundle
34
+ @booted = false
35
+ end
36
+
37
+ def boot
38
+ # Herb is pre-1.0: the major version never moves (0.8 -> 0.9 -> 0.10
39
+ # are all "0"), so semver-range gating can't distinguish a validated
40
+ # version from an unvalidated one. Gate on the recorded set instead.
41
+ unless @bundle.herb_versions.include?(::Herb::VERSION)
42
+ raise VersionMismatchError,
43
+ "herb #{::Herb::VERSION} has not been validated against this bundle " \
44
+ "(validated versions: #{@bundle.herb_versions.join(", ")})"
45
+ end
46
+
47
+ @adapter.load(File.read(HOST_SHIM_PATH))
48
+ @adapter.load(@bundle.source)
49
+ attach_callbacks
50
+ @adapter.load(File.read(RUBY_BACKEND_PATH))
51
+ @adapter.load(READY_CHECK_JS)
52
+
53
+ unless @adapter.call("__herbEmbeddedReady")
54
+ raise BootError, "Bridge failed to boot: #{@adapter.call("__herbEmbeddedError")}"
55
+ end
56
+
57
+ @booted = true
58
+ self
59
+ end
60
+
61
+ def backend_version
62
+ ensure_booted!
63
+ @adapter.call("__herbEmbeddedVersion")
64
+ end
65
+
66
+ def rule_names
67
+ ensure_booted!
68
+ @adapter.call("__herbRuleNames")
69
+ end
70
+
71
+ def lint(source, file:, rules: nil)
72
+ ensure_booted!
73
+ offenses = JSON.parse(@adapter.call("__herbLint", source, file, rules))
74
+ offenses.map { |offense| Diagnostic.from_js(offense, file: file) }
75
+ end
76
+
77
+ def register_custom_rule(source, path)
78
+ ensure_booted!
79
+ @adapter.call("__herbRegisterCustomRule", source, path)
80
+ end
81
+
82
+ # A formatter-adjacent tool must never write source that fails to
83
+ # parse into a user's working tree. If the fixed output fails to
84
+ # parse while the original input parsed cleanly, the fix is
85
+ # discarded rather than trusted.
86
+ def autofix(source, file:, rules: nil, unsafe: false)
87
+ ensure_booted!
88
+ raw = JSON.parse(@adapter.call("__herbAutofix", source, file, rules, unsafe))
89
+
90
+ if source_parses?(source) && !source_parses?(raw["source"])
91
+ return { source: source, applied: [], discarded: true }
92
+ end
93
+
94
+ applied = raw["fixed"].map { |offense| Diagnostic.from_js(offense, file: file) }
95
+ { source: raw["source"], applied: applied, discarded: false }
96
+ end
97
+
98
+ def dispose
99
+ @adapter.dispose
100
+ end
101
+
102
+ private
103
+
104
+ def ensure_booted!
105
+ raise NotBootedError, "Bridge#boot must be called before this method" unless @booted
106
+ end
107
+
108
+ def attach_callbacks
109
+ @adapter.attach("rbParse") { |source, options| ResultEnvelope.parse(source, options || {}) }
110
+ @adapter.attach("rbLex") { |source| ResultEnvelope.lex(source) }
111
+ @adapter.attach("rbExtractRuby") { |source, options| ::Herb.extract_ruby(source, **symbolize(options)) }
112
+ @adapter.attach("rbExtractHTML") { |source| ::Herb.extract_html(source) }
113
+ @adapter.attach("rbParseRuby") { |source| EngineAdapter.binary(::Prism.dump(source)) }
114
+ @adapter.attach("rbVersion") { ::Herb.version }
115
+ end
116
+
117
+ def symbolize(hash)
118
+ (hash || {}).transform_keys(&:to_sym)
119
+ end
120
+
121
+ def source_parses?(source)
122
+ JSON.parse(ResultEnvelope.parse(source, {}))["errors"].empty?
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herb
4
+ module Embedded
5
+ # The vendored @herb-tools/linter JS bundle (see rakelib/bundle.rake).
6
+ module Bundle
7
+ LINTER_VERSION = "0.10.3"
8
+ HERB_VERSIONS = ["0.10.3"].freeze
9
+
10
+ VENDOR_PATH = File.expand_path("../../../vendor/herb-linter.js", __dir__)
11
+
12
+ module_function
13
+
14
+ def source
15
+ File.read(VENDOR_PATH)
16
+ end
17
+
18
+ def linter_version
19
+ LINTER_VERSION
20
+ end
21
+
22
+ def herb_versions
23
+ HERB_VERSIONS
24
+ end
25
+ end
26
+ end
27
+ end