herb 0.9.7-aarch64-linux-gnu → 0.10.0-aarch64-linux-gnu
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 +4 -4
- data/README.md +1 -0
- data/ext/herb/extconf.rb +1 -0
- data/ext/herb/extension.c +108 -0
- data/herb.gemspec +1 -1
- data/lib/herb/3.2/herb.so +0 -0
- data/lib/herb/3.3/herb.so +0 -0
- data/lib/herb/3.4/herb.so +0 -0
- data/lib/herb/4.0/herb.so +0 -0
- data/lib/herb/action_view/render_analyzer.rb +1057 -0
- data/lib/herb/ast/erb_render_node.rb +155 -0
- data/lib/herb/bootstrap.rb +0 -1
- data/lib/herb/cli.rb +253 -19
- data/lib/herb/colors.rb +18 -0
- data/lib/herb/configuration.rb +49 -13
- data/lib/herb/defaults.yml +3 -0
- data/lib/herb/dev/runner.rb +445 -0
- data/lib/herb/dev/server.rb +207 -0
- data/lib/herb/dev/server_entry.rb +128 -0
- data/lib/herb/diff_operation.rb +34 -0
- data/lib/herb/diff_result.rb +59 -0
- data/lib/herb/engine/compiler.rb +56 -3
- data/lib/herb/engine/validators/render_validator.rb +92 -0
- data/lib/herb/engine.rb +58 -4
- data/lib/herb/html/util.rb +16 -0
- data/lib/herb/project.rb +1 -6
- data/lib/herb/version.rb +1 -1
- data/lib/herb.rb +41 -5
- data/sig/herb/action_view/render_analyzer.rbs +122 -0
- data/sig/herb/ast/erb_render_node.rbs +29 -0
- data/sig/herb/colors.rbs +12 -0
- data/sig/herb/configuration.rbs +20 -1
- data/sig/herb/dev/runner.rbs +59 -0
- data/sig/herb/dev/server.rbs +50 -0
- data/sig/herb/dev/server_entry.rbs +51 -0
- data/sig/herb/diff_operation.rbs +34 -0
- data/sig/herb/diff_result.rbs +34 -0
- data/sig/herb/engine/compiler.rbs +6 -0
- data/sig/herb/engine/validators/render_validator.rbs +21 -0
- data/sig/herb/engine.rbs +15 -0
- data/sig/herb/html/util.rbs +13 -0
- data/sig/herb.rbs +12 -2
- data/sig/herb_c_extension.rbs +1 -1
- data/sig/vendor/did_you_mean.rbs +6 -0
- data/sig/vendor/parallel.rbs +4 -0
- data/src/analyze/action_view/attribute_extraction_helpers.c +3 -2
- data/src/diff/herb_diff.c +137 -0
- data/src/diff/herb_diff_attributes.c +207 -0
- data/src/diff/herb_diff_children.c +518 -0
- data/src/diff/herb_diff_helpers.c +114 -0
- data/src/diff/herb_diff_nodes.c +707 -0
- data/src/diff/herb_hash.c +42 -0
- data/src/diff/herb_hash_index_map.c +47 -0
- data/src/diff/herb_hash_map.c +104 -0
- data/src/diff/herb_hash_tree.c +680 -0
- data/src/include/diff/herb_diff.h +118 -0
- data/src/include/diff/herb_hash.h +25 -0
- data/src/include/diff/herb_hash_index_map.h +32 -0
- data/src/include/diff/herb_hash_map.h +30 -0
- data/src/include/herb.h +1 -0
- data/src/include/version.h +1 -1
- data/templates/javascript/packages/core/src/config.ts.erb +43 -0
- data/templates/rust/src/ast/nodes.rs.erb +1 -1
- data/templates/rust/src/config.rs.erb +50 -0
- data/templates/src/diff/herb_diff_helpers.c.erb +38 -0
- data/templates/src/diff/herb_diff_nodes.c.erb +224 -0
- data/templates/src/diff/herb_hash_tree.c.erb +147 -0
- data/templates/template.rb +4 -4
- metadata +40 -4
- data/lib/herb/3.0/herb.so +0 -0
- data/lib/herb/3.1/herb.so +0 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: ignore
|
|
3
|
+
|
|
4
|
+
require_relative "../colors"
|
|
5
|
+
|
|
6
|
+
module Herb
|
|
7
|
+
module Dev
|
|
8
|
+
class Runner
|
|
9
|
+
include Herb::Colors
|
|
10
|
+
|
|
11
|
+
PATCHABLE_TYPES = ["text_changed", "attribute_value_changed", "attribute_added", "attribute_removed"].freeze #: Array[String]
|
|
12
|
+
|
|
13
|
+
def self.can_patch?(operations)
|
|
14
|
+
operations.all? { |operation|
|
|
15
|
+
next false unless PATCHABLE_TYPES.include?(operation.type.to_s)
|
|
16
|
+
next false if operation.new_node&.type&.to_s&.include?("ERB")
|
|
17
|
+
next false if operation.old_node&.type&.to_s&.include?("ERB")
|
|
18
|
+
|
|
19
|
+
true
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
CLEAR_SCREEN = "\e[2J\e[H"
|
|
24
|
+
HIDE_CURSOR = "\e[?25l"
|
|
25
|
+
SHOW_CURSOR = "\e[?25h"
|
|
26
|
+
|
|
27
|
+
def initialize(path: ".", cli: nil)
|
|
28
|
+
@path = path
|
|
29
|
+
@cli = cli
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run
|
|
33
|
+
require_cruise
|
|
34
|
+
require_relative "server"
|
|
35
|
+
|
|
36
|
+
unless File.directory?(@path)
|
|
37
|
+
puts "Not a directory: '#{@path}'."
|
|
38
|
+
exit(1)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
config = Herb::Configuration.load(@path)
|
|
42
|
+
expanded_path = File.realpath(File.expand_path(config.project_root || @path))
|
|
43
|
+
|
|
44
|
+
check_existing_server(expanded_path)
|
|
45
|
+
port = find_port
|
|
46
|
+
|
|
47
|
+
print CLEAR_SCREEN
|
|
48
|
+
print HIDE_CURSOR
|
|
49
|
+
print_header(config, expanded_path)
|
|
50
|
+
|
|
51
|
+
file_states = index_files(config, @path)
|
|
52
|
+
|
|
53
|
+
websocket = Herb::Dev::Server.new(port: port, project_path: expanded_path)
|
|
54
|
+
websocket.start
|
|
55
|
+
|
|
56
|
+
puts " #{fg("WebSocket:".ljust(11), 245)}#{fg("ws://localhost:#{websocket.port}", 250)}"
|
|
57
|
+
puts
|
|
58
|
+
puts " #{fg("Ready!", 42)} #{fg("Watching for changes...", 241)}"
|
|
59
|
+
puts
|
|
60
|
+
|
|
61
|
+
watch_files(config, expanded_path, websocket, file_states)
|
|
62
|
+
rescue Interrupt
|
|
63
|
+
websocket&.stop
|
|
64
|
+
print SHOW_CURSOR
|
|
65
|
+
puts
|
|
66
|
+
puts "Stopped."
|
|
67
|
+
exit(0)
|
|
68
|
+
ensure
|
|
69
|
+
websocket&.stop
|
|
70
|
+
print SHOW_CURSOR
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stop
|
|
74
|
+
require_relative "server"
|
|
75
|
+
|
|
76
|
+
entries = Herb::Dev::ServerEntry.all
|
|
77
|
+
|
|
78
|
+
if entries.empty?
|
|
79
|
+
puts "No herb dev servers running."
|
|
80
|
+
exit(0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
entries.each do |entry|
|
|
84
|
+
entry.stop!
|
|
85
|
+
puts "Stopped herb dev server for #{entry.project_name} (PID: #{entry.pid}, port: #{entry.port})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
exit(0)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def restart
|
|
92
|
+
require_relative "server"
|
|
93
|
+
|
|
94
|
+
Herb::Dev::ServerEntry.all.each(&:stop!)
|
|
95
|
+
sleep 0.5
|
|
96
|
+
run
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def status
|
|
100
|
+
require_relative "server"
|
|
101
|
+
|
|
102
|
+
entries = Herb::Dev::ServerEntry.all
|
|
103
|
+
|
|
104
|
+
if entries.empty?
|
|
105
|
+
puts "No herb dev servers running."
|
|
106
|
+
else
|
|
107
|
+
entries.each do |entry|
|
|
108
|
+
puts "#{entry.project_name} — PID: #{entry.pid}, port: #{entry.port}, started: #{entry.started_at}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
exit(0)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def pluralize(count, word)
|
|
118
|
+
"#{count} #{word}#{"s" unless count == 1}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def require_cruise
|
|
122
|
+
require "cruise"
|
|
123
|
+
rescue LoadError
|
|
124
|
+
abort <<~MESSAGE
|
|
125
|
+
The 'cruise' gem is required for the Herb Dev Server.
|
|
126
|
+
|
|
127
|
+
Install it:
|
|
128
|
+
gem install cruise
|
|
129
|
+
|
|
130
|
+
or add to your Gemfile:
|
|
131
|
+
bundle add cruise
|
|
132
|
+
MESSAGE
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def check_existing_server(expanded_path)
|
|
136
|
+
existing = Herb::Dev::ServerEntry.find_by_project(expanded_path)
|
|
137
|
+
|
|
138
|
+
return unless existing
|
|
139
|
+
|
|
140
|
+
puts "Herb dev server is already running for this project (PID: #{existing.pid}, port: #{existing.port})."
|
|
141
|
+
puts
|
|
142
|
+
puts " herb dev stop Stop the running server"
|
|
143
|
+
puts " herb dev restart Restart the server"
|
|
144
|
+
exit(1)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def find_port
|
|
148
|
+
port = Herb::Dev::Server::DEFAULT_PORT
|
|
149
|
+
port_owner = Herb::Dev::ServerEntry.find_by_port(port)
|
|
150
|
+
|
|
151
|
+
if port_owner
|
|
152
|
+
port = Herb::Dev::Server.find_available_port(port + 1)
|
|
153
|
+
abort "No available ports found" unless port
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
port
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def print_header(config, expanded_path)
|
|
160
|
+
puts
|
|
161
|
+
puts fg_bg(" \u{1F33F} Herb Dev Server ", 255, 28)
|
|
162
|
+
puts
|
|
163
|
+
puts " #{fg("\u26A0\uFE0F Experimental:", 214)} #{fg("The dev server is experimental and may not work correctly in all cases.", 241)}"
|
|
164
|
+
puts
|
|
165
|
+
|
|
166
|
+
puts " #{fg("Herb:".ljust(11), 245)}#{fg(Herb::VERSION, 250)}"
|
|
167
|
+
puts " #{fg("Project:".ljust(11), 245)}#{fg(expanded_path, 250)}"
|
|
168
|
+
puts " #{fg("PID:".ljust(11), 245)}#{fg(Process.pid, 250)} #{fg("(#{File.join(Herb::Dev::ServerEntry::SERVERS_DIR, "#{Process.pid}.json")})", 241)}"
|
|
169
|
+
|
|
170
|
+
if config.config_path
|
|
171
|
+
relative_config = config.config_path.to_s.delete_prefix("#{expanded_path}/")
|
|
172
|
+
puts " #{fg("Config:".ljust(11), 245)}#{fg(relative_config, 250)}"
|
|
173
|
+
else
|
|
174
|
+
puts " #{fg("Config:".ljust(11), 245)}#{fg("(defaults)", 241)}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def index_files(config, path)
|
|
179
|
+
puts " #{fg("Indexing files...", 241)}"
|
|
180
|
+
|
|
181
|
+
file_states = {}
|
|
182
|
+
initial_files = config.find_files(path)
|
|
183
|
+
|
|
184
|
+
initial_files.each do |file_path|
|
|
185
|
+
file_states[file_path] = File.read(file_path)
|
|
186
|
+
rescue StandardError
|
|
187
|
+
# skip files that can't be read
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
print "\e[1A\e[2K"
|
|
191
|
+
puts " #{fg("Files:".ljust(11), 245)}#{fg("#{file_states.size} templates indexed", 250)}"
|
|
192
|
+
|
|
193
|
+
file_states
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def watch_files(config, expanded_path, websocket, file_states)
|
|
197
|
+
include_patterns = config.file_include_patterns
|
|
198
|
+
exclude_patterns = config.file_exclude_patterns
|
|
199
|
+
first_change = true
|
|
200
|
+
errored_files = Set.new
|
|
201
|
+
|
|
202
|
+
Thread.new do
|
|
203
|
+
$stdin.gets(nil)
|
|
204
|
+
Thread.main.raise(Interrupt)
|
|
205
|
+
rescue IOError, Errno::EBADF
|
|
206
|
+
Thread.main.raise(Interrupt)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
Cruise.watch(expanded_path, only: ["created", "modified", "removed"]) do |event|
|
|
210
|
+
file_path = event.path
|
|
211
|
+
relative_path = file_path.delete_prefix("#{expanded_path}/")
|
|
212
|
+
|
|
213
|
+
next if config.path_excluded?(relative_path, exclude_patterns)
|
|
214
|
+
next unless config.path_included?(relative_path, include_patterns)
|
|
215
|
+
|
|
216
|
+
if first_change
|
|
217
|
+
print "\e[2A\e[J"
|
|
218
|
+
puts " #{fg("Recent changes:", 245)}"
|
|
219
|
+
puts
|
|
220
|
+
first_change = false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
timestamp = fg(Time.now.strftime("%H:%M:%S.%L"), 241)
|
|
224
|
+
display_path = fg(relative_path, 250)
|
|
225
|
+
|
|
226
|
+
case event.kind
|
|
227
|
+
when "created", "modified"
|
|
228
|
+
handle_file_change(file_path, relative_path, file_states, errored_files, websocket, timestamp, display_path)
|
|
229
|
+
when "removed"
|
|
230
|
+
file_states.delete(file_path)
|
|
231
|
+
badge = bold(fg("- removed", 196))
|
|
232
|
+
puts " #{timestamp} #{badge} #{display_path}"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def handle_file_change(file_path, relative_path, file_states, errored_files, websocket, timestamp, display_path)
|
|
238
|
+
return unless File.exist?(file_path)
|
|
239
|
+
|
|
240
|
+
current_content = File.read(file_path)
|
|
241
|
+
previous_content = file_states[file_path]
|
|
242
|
+
|
|
243
|
+
if previous_content.nil?
|
|
244
|
+
file_states[file_path] = current_content
|
|
245
|
+
badge = bold(fg("+ added ", 42))
|
|
246
|
+
puts " #{timestamp} #{badge} #{display_path}"
|
|
247
|
+
return
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
return if previous_content == current_content && !errored_files.include?(file_path)
|
|
251
|
+
|
|
252
|
+
current_parse = Herb.parse(current_content, strict: true, analyze: true)
|
|
253
|
+
|
|
254
|
+
if current_parse.errors.any?
|
|
255
|
+
broadcast_errors(file_path, relative_path, current_parse, previous_content, errored_files, websocket, timestamp, display_path)
|
|
256
|
+
return
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
if errored_files.delete?(file_path)
|
|
260
|
+
broadcast_fixed(file_path, relative_path, current_content, file_states, websocket, timestamp, display_path)
|
|
261
|
+
return
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
handle_diff(file_path, relative_path, current_content, previous_content, file_states, websocket, timestamp, display_path)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def broadcast_errors(file_path, relative_path, current_parse, previous_content, errored_files, websocket, timestamp, display_path)
|
|
268
|
+
current_errors = current_parse.errors
|
|
269
|
+
|
|
270
|
+
previous_parse = Herb.parse(previous_content, strict: true, analyze: true)
|
|
271
|
+
previous_errors = previous_parse.errors
|
|
272
|
+
|
|
273
|
+
new_errors = current_errors.select { |error|
|
|
274
|
+
previous_errors.none? { |previous_error|
|
|
275
|
+
previous_error.error_name == error.error_name && previous_error.location.start.line == error.location.start.line
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
badge = bold(fg("\u{2717} error ", 196))
|
|
280
|
+
puts " #{timestamp} #{badge} #{display_path} #{fg("(#{pluralize(current_errors.size, "error")})", 241)}"
|
|
281
|
+
|
|
282
|
+
new_errors.each do |error|
|
|
283
|
+
location = fg("#{relative_path}:#{error.location.start.line}:#{error.location.start.column}", 241)
|
|
284
|
+
puts " #{fg(error.error_name, 196)} #{location}"
|
|
285
|
+
puts " #{fg(error.message, 250)}" if error.message && !error.message.empty?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
errored_files.add(file_path)
|
|
289
|
+
|
|
290
|
+
if websocket.client_count.positive?
|
|
291
|
+
websocket.broadcast({
|
|
292
|
+
type: "error",
|
|
293
|
+
file: relative_path,
|
|
294
|
+
errors: current_errors.map { |error|
|
|
295
|
+
{
|
|
296
|
+
name: error.error_name,
|
|
297
|
+
message: error.message,
|
|
298
|
+
line: error.location.start.line,
|
|
299
|
+
column: error.location.start.column,
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
puts
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def broadcast_fixed(file_path, relative_path, current_content, file_states, websocket, timestamp, display_path)
|
|
309
|
+
file_states[file_path] = current_content
|
|
310
|
+
badge = bold(fg("\u{2713} fixed ", 42))
|
|
311
|
+
puts " #{timestamp} #{badge} #{display_path}"
|
|
312
|
+
|
|
313
|
+
if websocket.client_count.positive?
|
|
314
|
+
websocket.broadcast({ type: "fixed", file: relative_path })
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
puts
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def handle_diff(file_path, relative_path, current_content, previous_content, file_states, websocket, timestamp, display_path)
|
|
321
|
+
diff_result = Herb.diff(previous_content, current_content)
|
|
322
|
+
file_states[file_path] = current_content
|
|
323
|
+
|
|
324
|
+
return if diff_result.identical?
|
|
325
|
+
|
|
326
|
+
operations = diff_result.operations
|
|
327
|
+
can_patch = self.class.can_patch?(operations)
|
|
328
|
+
|
|
329
|
+
if can_patch && websocket.client_count.positive?
|
|
330
|
+
patch_operations = operations.map do |operation|
|
|
331
|
+
{
|
|
332
|
+
type: operation.type.to_s,
|
|
333
|
+
path: operation.path,
|
|
334
|
+
old_value: extract_node_value(operation.old_node),
|
|
335
|
+
new_value: extract_node_value(operation.new_node),
|
|
336
|
+
old_node_type: operation.old_node&.type,
|
|
337
|
+
new_node_type: operation.new_node&.type,
|
|
338
|
+
}
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
websocket.broadcast({
|
|
342
|
+
type: "patch",
|
|
343
|
+
file: relative_path,
|
|
344
|
+
operations: patch_operations,
|
|
345
|
+
})
|
|
346
|
+
elsif !can_patch && websocket.client_count.positive?
|
|
347
|
+
websocket.broadcast({
|
|
348
|
+
type: "reload",
|
|
349
|
+
file: relative_path,
|
|
350
|
+
})
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
print_diff_summary(operations, can_patch, websocket, timestamp, display_path)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def print_diff_summary(operations, can_patch, websocket, timestamp, display_path)
|
|
357
|
+
badge = if can_patch
|
|
358
|
+
bold(fg("\u{2713} patch ", 42))
|
|
359
|
+
else
|
|
360
|
+
bold(fg("\u{21BB} reload ", 214))
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
clients_label = websocket.client_count.positive? ? " #{fg("[#{pluralize(websocket.client_count, "client")}]", 241)}" : ""
|
|
364
|
+
puts " #{timestamp} #{badge} #{display_path} #{fg("(#{pluralize(operations.size, "operation")})", 241)}#{clients_label}"
|
|
365
|
+
|
|
366
|
+
operations.each_with_index do |operation, index|
|
|
367
|
+
type = operation.type.to_s
|
|
368
|
+
|
|
369
|
+
type_color = case type
|
|
370
|
+
when "node_inserted" then 114
|
|
371
|
+
when "node_removed", "attribute_removed" then 168
|
|
372
|
+
when "node_replaced", "tag_name_changed" then 173
|
|
373
|
+
when "node_wrapped", "node_unwrapped", "attribute_added", "attribute_value_changed" then 75
|
|
374
|
+
when "node_moved" then 73
|
|
375
|
+
when "text_changed" then 186
|
|
376
|
+
when "erb_content_changed" then 176
|
|
377
|
+
else 241
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
type_label = type.tr("_", " ")
|
|
381
|
+
index_label = fg("##{index + 1}", 241)
|
|
382
|
+
path_label = fg("[#{operation.path.join(", ")}]", 241)
|
|
383
|
+
indent = " "
|
|
384
|
+
|
|
385
|
+
puts "#{indent}#{index_label} #{bold(fg(type_label, type_color))} #{path_label}"
|
|
386
|
+
|
|
387
|
+
print_diff_node(indent, "-", 168, operation.old_node, type) if operation.old_node
|
|
388
|
+
print_diff_node(indent, "+", 114, operation.new_node, type) if operation.new_node
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
puts
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def print_diff_node(indent, sign, color, node, _type)
|
|
395
|
+
value = extract_node_value(node)
|
|
396
|
+
|
|
397
|
+
if value
|
|
398
|
+
value.split("\n").each do |line|
|
|
399
|
+
puts "#{indent} #{fg(sign, color)} #{fg(line, color)}"
|
|
400
|
+
end
|
|
401
|
+
else
|
|
402
|
+
label = node.type.to_s.sub("AST_", "").sub("_NODE", "")
|
|
403
|
+
location = node.location ? " (#{node.location.start.line}:#{node.location.start.column})" : ""
|
|
404
|
+
puts "#{indent} #{fg(sign, color)} #{fg("#{label}#{location}", color)}"
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def extract_node_value(node)
|
|
409
|
+
return nil unless node
|
|
410
|
+
|
|
411
|
+
if node.is_a?(Herb::AST::HTMLTextNode)
|
|
412
|
+
return node.content&.to_s
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
if node.is_a?(Herb::AST::ERBContentNode)
|
|
416
|
+
return node.content&.value&.to_s
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
if node.is_a?(Herb::AST::HTMLElementNode)
|
|
420
|
+
name = node.tag_name
|
|
421
|
+
name = name.value if name.respond_to?(:value)
|
|
422
|
+
return "<#{name}>"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
if node.is_a?(Herb::AST::HTMLAttributeNode)
|
|
426
|
+
parts = []
|
|
427
|
+
|
|
428
|
+
if node.name.respond_to?(:children)
|
|
429
|
+
parts << node.name.children.map { |child| child.respond_to?(:content) ? child.content.to_s : "" }.join
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
if node.value.respond_to?(:children)
|
|
433
|
+
value = node.value.children.map { |child| child.respond_to?(:content) ? child.content.to_s : "" }.join
|
|
434
|
+
parts << "=\"#{value}\""
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
result = parts.join
|
|
438
|
+
return result.empty? ? nil : result
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
nil
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
require "websocket"
|
|
9
|
+
rescue LoadError
|
|
10
|
+
require "bundler/inline"
|
|
11
|
+
|
|
12
|
+
gemfile do
|
|
13
|
+
source "https://rubygems.org"
|
|
14
|
+
gem "websocket"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require "websocket"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
require_relative "server_entry"
|
|
21
|
+
|
|
22
|
+
module Herb
|
|
23
|
+
module Dev
|
|
24
|
+
class Server
|
|
25
|
+
DEFAULT_PORT = 8592
|
|
26
|
+
HANDSHAKE_TIMEOUT = 5
|
|
27
|
+
|
|
28
|
+
attr_reader :port, :project_path
|
|
29
|
+
|
|
30
|
+
Client = Data.define(:socket, :version, :mutex)
|
|
31
|
+
|
|
32
|
+
def initialize(port: DEFAULT_PORT, project_path: nil)
|
|
33
|
+
@port = port
|
|
34
|
+
@project_path = project_path
|
|
35
|
+
@clients = [] #: Array[Client]
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@server = nil
|
|
38
|
+
@accept_thread = nil
|
|
39
|
+
@entry = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start
|
|
43
|
+
@entry = ServerEntry.new(pid: Process.pid, port: @port, project: @project_path)
|
|
44
|
+
@entry.save
|
|
45
|
+
@server = TCPServer.new("0.0.0.0", @port)
|
|
46
|
+
|
|
47
|
+
@accept_thread = Thread.new do
|
|
48
|
+
loop do
|
|
49
|
+
socket = @server.accept
|
|
50
|
+
Thread.new(socket) { |s| handle_connection(s) }
|
|
51
|
+
rescue IOError
|
|
52
|
+
break
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stop
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@clients.each { |client| safely_close(client.socket) }
|
|
60
|
+
@clients.clear
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
safely_close(@server)
|
|
64
|
+
|
|
65
|
+
@accept_thread&.kill
|
|
66
|
+
@entry&.remove
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def broadcast(message)
|
|
70
|
+
data = message.is_a?(String) ? message : JSON.generate(message)
|
|
71
|
+
|
|
72
|
+
failed_clients = []
|
|
73
|
+
clients_snapshot = @mutex.synchronize { @clients.dup }
|
|
74
|
+
|
|
75
|
+
clients_snapshot.each do |client|
|
|
76
|
+
frame = WebSocket::Frame::Outgoing::Server.new(version: client.version, data: data, type: :text)
|
|
77
|
+
|
|
78
|
+
client.mutex.synchronize do
|
|
79
|
+
client.socket.write(frame.to_s)
|
|
80
|
+
client.socket.flush
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError
|
|
83
|
+
safely_close(client.socket)
|
|
84
|
+
|
|
85
|
+
failed_clients << client
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return unless failed_clients.any?
|
|
89
|
+
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
failed_clients.each { |client| @clients.delete(client) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def client_count
|
|
96
|
+
@mutex.synchronize { @clients.size }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.port_available?(port)
|
|
100
|
+
server = TCPServer.new("0.0.0.0", port)
|
|
101
|
+
server.close
|
|
102
|
+
|
|
103
|
+
true
|
|
104
|
+
rescue Errno::EADDRINUSE
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.find_available_port(starting_port = DEFAULT_PORT)
|
|
109
|
+
port = starting_port
|
|
110
|
+
|
|
111
|
+
loop do
|
|
112
|
+
return port if port_available?(port)
|
|
113
|
+
|
|
114
|
+
port += 1
|
|
115
|
+
break if port > starting_port + 100
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def safely_close(resource)
|
|
124
|
+
resource&.close
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handle_connection(socket)
|
|
130
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
131
|
+
|
|
132
|
+
handshake = WebSocket::Handshake::Server.new
|
|
133
|
+
|
|
134
|
+
until handshake.finished?
|
|
135
|
+
readable = socket.wait_readable(HANDSHAKE_TIMEOUT)
|
|
136
|
+
|
|
137
|
+
unless readable
|
|
138
|
+
socket.close
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
data = socket.read_nonblock(4096, exception: false)
|
|
143
|
+
break if data.nil? || data == :wait_readable
|
|
144
|
+
|
|
145
|
+
data.each_byte { |byte| handshake << byte.chr }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
unless handshake.valid?
|
|
149
|
+
socket.close
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
socket.write(handshake.to_s)
|
|
154
|
+
socket.flush
|
|
155
|
+
|
|
156
|
+
welcome = WebSocket::Frame::Outgoing::Server.new(
|
|
157
|
+
version: handshake.version,
|
|
158
|
+
data: JSON.generate({ type: "welcome", project: @project_path }),
|
|
159
|
+
type: :text
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
socket.write(welcome.to_s)
|
|
163
|
+
socket.flush
|
|
164
|
+
|
|
165
|
+
client = Client.new(socket: socket, version: handshake.version, mutex: Mutex.new)
|
|
166
|
+
@mutex.synchronize { @clients << client }
|
|
167
|
+
|
|
168
|
+
frame_parser = WebSocket::Frame::Incoming::Server.new(version: handshake.version)
|
|
169
|
+
|
|
170
|
+
loop do
|
|
171
|
+
chunk = socket.readpartial(4096)
|
|
172
|
+
|
|
173
|
+
frame_parser << chunk
|
|
174
|
+
|
|
175
|
+
while (frame = frame_parser.next)
|
|
176
|
+
case frame.type
|
|
177
|
+
when :close
|
|
178
|
+
close_frame = WebSocket::Frame::Outgoing::Server.new(version: handshake.version, data: "", type: :close)
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
client.mutex.synchronize { socket.write(close_frame.to_s) }
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
return
|
|
187
|
+
when :ping
|
|
188
|
+
pong = WebSocket::Frame::Outgoing::Server.new(version: handshake.version, data: frame.data, type: :pong)
|
|
189
|
+
|
|
190
|
+
client.mutex.synchronize { socket.write(pong.to_s) }
|
|
191
|
+
when :text
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
197
|
+
# client disconnected
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
warn "[herb-dev-server] connection error: #{e.class}: #{e.message}"
|
|
200
|
+
ensure
|
|
201
|
+
@mutex.synchronize { @clients.delete_if { |client| client.socket == socket } }
|
|
202
|
+
|
|
203
|
+
safely_close(socket)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|