Class: Herb::Dev::Runner

Inherits:
Object
  • Object
show all
Includes:
Colors
Defined in:
lib/herb/dev/runner.rb,
sig/herb/dev/runner.rbs

Constant Summary collapse

PATCHABLE_TYPES =

: Array

Returns:

  • (Array[String])
["text_changed", "attribute_value_changed", "attribute_added", "attribute_removed"].freeze
CLEAR_SCREEN =

Returns:

  • (::String)
"\e[2J\e[H"
HIDE_CURSOR =

Returns:

  • (::String)
"\e[?25l"
SHOW_CURSOR =

Returns:

  • (::String)
"\e[?25h"

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Colors

bold, bright_magenta, cyan, dimmed, enabled?, fg, fg_bg, green, magenta, red, #self?.bold, #self?.bright_magenta, #self?.cyan, #self?.dimmed, #self?.enabled?, #self?.fg, #self?.fg_bg, #self?.green, #self?.magenta, #self?.red, #self?.white, #self?.yellow, white, yellow

Constructor Details

#initialize(path: ".", cli: nil) ⇒ Runner

Returns a new instance of Runner.

Parameters:

  • path: (Object) (defaults to: ".")
  • cli: (Object) (defaults to: nil)


27
28
29
30
# File 'lib/herb/dev/runner.rb', line 27

def initialize(path: ".", cli: nil)
  @path = path
  @cli = cli
end

Class Method Details

.can_patch?(operations) ⇒ Boolean

Parameters:

  • operations (Object)

Returns:

  • (Boolean)


13
14
15
16
17
18
19
20
21
# File 'lib/herb/dev/runner.rb', line 13

def self.can_patch?(operations)
  operations.all? { |operation|
    next false unless PATCHABLE_TYPES.include?(operation.type.to_s)
    next false if operation.new_node&.type&.to_s&.include?("ERB")
    next false if operation.old_node&.type&.to_s&.include?("ERB")

    true
  }
end

Instance Method Details

#broadcast_errors(file_path, relative_path, current_parse, previous_content, errored_files, websocket, timestamp, display_path) ⇒ Object

Parameters:

  • file_path (Object)
  • relative_path (Object)
  • current_parse (Object)
  • previous_content (Object)
  • errored_files (Object)
  • websocket (Object)
  • timestamp (Object)
  • display_path (Object)

Returns:

  • (Object)


267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/herb/dev/runner.rb', line 267

def broadcast_errors(file_path, relative_path, current_parse, previous_content, errored_files, websocket, timestamp, display_path)
  current_errors = current_parse.errors

  previous_parse = Herb.parse(previous_content, strict: true, analyze: true)
  previous_errors = previous_parse.errors

  new_errors = current_errors.select { |error|
    previous_errors.none? { |previous_error|
      previous_error.error_name == error.error_name && previous_error.location.start.line == error.location.start.line
    }
  }

  badge = bold(fg("\u{2717} error  ", 196))
  puts "    #{timestamp} #{badge} #{display_path} #{fg("(#{pluralize(current_errors.size, "error")})", 241)}"

  new_errors.each do |error|
    location = fg("#{relative_path}:#{error.location.start.line}:#{error.location.start.column}", 241)
    puts "                        #{fg(error.error_name, 196)} #{location}"
    puts "                        #{fg(error.message, 250)}" if error.message && !error.message.empty?
  end

  errored_files.add(file_path)

  if websocket.client_count.positive?
    websocket.broadcast({
      type: "error",
      file: relative_path,
      errors: current_errors.map { |error|
        {
          name: error.error_name,
          message: error.message,
          line: error.location.start.line,
          column: error.location.start.column,
        }
      },
    })
  end

  puts
end

#broadcast_fixed(file_path, relative_path, current_content, file_states, websocket, timestamp, display_path) ⇒ Object

Parameters:

  • file_path (Object)
  • relative_path (Object)
  • current_content (Object)
  • file_states (Object)
  • websocket (Object)
  • timestamp (Object)
  • display_path (Object)

Returns:

  • (Object)


308
309
310
311
312
313
314
315
316
317
318
# File 'lib/herb/dev/runner.rb', line 308

def broadcast_fixed(file_path, relative_path, current_content, file_states, websocket, timestamp, display_path)
  file_states[file_path] = current_content
  badge = bold(fg("\u{2713} fixed  ", 42))
  puts "    #{timestamp} #{badge} #{display_path}"

  if websocket.client_count.positive?
    websocket.broadcast({ type: "fixed", file: relative_path })
  end

  puts
end

#check_existing_server(expanded_path) ⇒ Object

Parameters:

  • expanded_path (Object)

Returns:

  • (Object)


135
136
137
138
139
140
141
142
143
144
145
# File 'lib/herb/dev/runner.rb', line 135

def check_existing_server(expanded_path)
  existing = Herb::Dev::ServerEntry.find_by_project(expanded_path)

  return unless existing

  puts "Herb dev server is already running for this project (PID: #{existing.pid}, port: #{existing.port})."
  puts
  puts "  herb dev stop       Stop the running server"
  puts "  herb dev restart    Restart the server"
  exit(1)
end

#extract_node_value(node) ⇒ Object

Parameters:

  • node (Object)

Returns:

  • (Object)


408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/herb/dev/runner.rb', line 408

def extract_node_value(node)
  return nil unless node

  if node.is_a?(Herb::AST::HTMLTextNode)
    return node.content&.to_s
  end

  if node.is_a?(Herb::AST::ERBContentNode)
    return node.content&.value&.to_s
  end

  if node.is_a?(Herb::AST::HTMLElementNode)
    name = node.tag_name
    name = name.value if name.respond_to?(:value)
    return "<#{name}>"
  end

  if node.is_a?(Herb::AST::HTMLAttributeNode)
    parts = []

    if node.name.respond_to?(:children)
      parts << node.name.children.map { |child| child.respond_to?(:content) ? child.content.to_s : "" }.join
    end

    if node.value.respond_to?(:children)
      value = node.value.children.map { |child| child.respond_to?(:content) ? child.content.to_s : "" }.join
      parts << "=\"#{value}\""
    end

    result = parts.join
    return result.empty? ? nil : result
  end

  nil
end

#find_portObject

Returns:

  • (Object)


147
148
149
150
151
152
153
154
155
156
157
# File 'lib/herb/dev/runner.rb', line 147

def find_port
  port = Herb::Dev::Server::DEFAULT_PORT
  port_owner = Herb::Dev::ServerEntry.find_by_port(port)

  if port_owner
    port = Herb::Dev::Server.find_available_port(port + 1)
    abort "No available ports found" unless port
  end

  port
end

#handle_diff(file_path, relative_path, current_content, previous_content, file_states, websocket, timestamp, display_path) ⇒ Object

Parameters:

  • file_path (Object)
  • relative_path (Object)
  • current_content (Object)
  • previous_content (Object)
  • file_states (Object)
  • websocket (Object)
  • timestamp (Object)
  • display_path (Object)

Returns:

  • (Object)


320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/herb/dev/runner.rb', line 320

def handle_diff(file_path, relative_path, current_content, previous_content, file_states, websocket, timestamp, display_path)
  diff_result = Herb.diff(previous_content, current_content)
  file_states[file_path] = current_content

  return if diff_result.identical?

  operations = diff_result.operations
  can_patch = self.class.can_patch?(operations)

  if can_patch && websocket.client_count.positive?
    patch_operations = operations.map do |operation|
      {
        type: operation.type.to_s,
        path: operation.path,
        old_value: extract_node_value(operation.old_node),
        new_value: extract_node_value(operation.new_node),
        old_node_type: operation.old_node&.type,
        new_node_type: operation.new_node&.type,
      }
    end

    websocket.broadcast({
      type: "patch",
      file: relative_path,
      operations: patch_operations,
    })
  elsif !can_patch && websocket.client_count.positive?
    websocket.broadcast({
      type: "reload",
      file: relative_path,
    })
  end

  print_diff_summary(operations, can_patch, websocket, timestamp, display_path)
end

#handle_file_change(file_path, relative_path, file_states, errored_files, websocket, timestamp, display_path) ⇒ Object

Parameters:

  • file_path (Object)
  • relative_path (Object)
  • file_states (Object)
  • errored_files (Object)
  • websocket (Object)
  • timestamp (Object)
  • display_path (Object)

Returns:

  • (Object)


237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/herb/dev/runner.rb', line 237

def handle_file_change(file_path, relative_path, file_states, errored_files, websocket, timestamp, display_path)
  return unless File.exist?(file_path)

  current_content = File.read(file_path)
  previous_content = file_states[file_path]

  if previous_content.nil?
    file_states[file_path] = current_content
    badge = bold(fg("+ added  ", 42))
    puts "    #{timestamp} #{badge} #{display_path}"
    return
  end

  return if previous_content == current_content && !errored_files.include?(file_path)

  current_parse = Herb.parse(current_content, strict: true, analyze: true)

  if current_parse.errors.any?
    broadcast_errors(file_path, relative_path, current_parse, previous_content, errored_files, websocket, timestamp, display_path)
    return
  end

  if errored_files.delete?(file_path)
    broadcast_fixed(file_path, relative_path, current_content, file_states, websocket, timestamp, display_path)
    return
  end

  handle_diff(file_path, relative_path, current_content, previous_content, file_states, websocket, timestamp, display_path)
end

#index_files(config, path) ⇒ Object

Parameters:

  • config (Object)
  • path (Object)

Returns:

  • (Object)


178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/herb/dev/runner.rb', line 178

def index_files(config, path)
  puts "  #{fg("Indexing files...", 241)}"

  file_states = {}
  initial_files = config.find_files(path)

  initial_files.each do |file_path|
    file_states[file_path] = File.read(file_path)
  rescue StandardError
    # skip files that can't be read
  end

  print "\e[1A\e[2K"
  puts "  #{fg("Files:".ljust(11), 245)}#{fg("#{file_states.size} templates indexed", 250)}"

  file_states
end

#pluralize(count, word) ⇒ Object

Parameters:

  • count (Object)
  • word (Object)

Returns:

  • (Object)


117
118
119
# File 'lib/herb/dev/runner.rb', line 117

def pluralize(count, word)
  "#{count} #{word}#{"s" unless count == 1}"
end

Parameters:

  • indent (Object)
  • sign (Object)
  • color (Object)
  • node (Object)
  • _type (Object)

Returns:

  • (Object)


394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/herb/dev/runner.rb', line 394

def print_diff_node(indent, sign, color, node, _type)
  value = extract_node_value(node)

  if value
    value.split("\n").each do |line|
      puts "#{indent}  #{fg(sign, color)} #{fg(line, color)}"
    end
  else
    label = node.type.to_s.sub("AST_", "").sub("_NODE", "")
    location = node.location ? " (#{node.location.start.line}:#{node.location.start.column})" : ""
    puts "#{indent}  #{fg(sign, color)} #{fg("#{label}#{location}", color)}"
  end
end

Parameters:

  • operations (Object)
  • can_patch (Object)
  • websocket (Object)
  • timestamp (Object)
  • display_path (Object)

Returns:

  • (Object)


356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
# File 'lib/herb/dev/runner.rb', line 356

def print_diff_summary(operations, can_patch, websocket, timestamp, display_path)
  badge = if can_patch
            bold(fg("\u{2713} patch ", 42))
          else
            bold(fg("\u{21BB} reload ", 214))
          end

  clients_label = websocket.client_count.positive? ? " #{fg("[#{pluralize(websocket.client_count, "client")}]", 241)}" : ""
  puts "    #{timestamp} #{badge} #{display_path} #{fg("(#{pluralize(operations.size, "operation")})", 241)}#{clients_label}"

  operations.each_with_index do |operation, index|
    type = operation.type.to_s

    type_color = case type
                 when "node_inserted" then 114
                 when "node_removed", "attribute_removed" then 168
                 when "node_replaced", "tag_name_changed" then 173
                 when "node_wrapped", "node_unwrapped", "attribute_added", "attribute_value_changed" then 75
                 when "node_moved" then 73
                 when "text_changed" then 186
                 when "erb_content_changed" then 176
                 else 241
                 end

    type_label = type.tr("_", " ")
    index_label = fg("##{index + 1}", 241)
    path_label = fg("[#{operation.path.join(", ")}]", 241)
    indent = "                        "

    puts "#{indent}#{index_label} #{bold(fg(type_label, type_color))} #{path_label}"

    print_diff_node(indent, "-", 168, operation.old_node, type) if operation.old_node
    print_diff_node(indent, "+", 114, operation.new_node, type) if operation.new_node
  end

  puts
end

Parameters:

  • config (Object)
  • expanded_path (Object)

Returns:

  • (Object)


159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/herb/dev/runner.rb', line 159

def print_header(config, expanded_path)
  puts
  puts fg_bg(" \u{1F33F} Herb Dev Server ", 255, 28)
  puts
  puts "  #{fg("\u26A0\uFE0F Experimental:", 214)} #{fg("The dev server is experimental and may not work correctly in all cases.", 241)}"
  puts

  puts "  #{fg("Herb:".ljust(11), 245)}#{fg(Herb::VERSION, 250)}"
  puts "  #{fg("Project:".ljust(11), 245)}#{fg(expanded_path, 250)}"
  puts "  #{fg("PID:".ljust(11), 245)}#{fg(Process.pid, 250)} #{fg("(#{File.join(Herb::Dev::ServerEntry::SERVERS_DIR, "#{Process.pid}.json")})", 241)}"

  if config.config_path
    relative_config = config.config_path.to_s.delete_prefix("#{expanded_path}/")
    puts "  #{fg("Config:".ljust(11), 245)}#{fg(relative_config, 250)}"
  else
    puts "  #{fg("Config:".ljust(11), 245)}#{fg("(defaults)", 241)}"
  end
end

#require_cruiseObject

Returns:

  • (Object)


121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/herb/dev/runner.rb', line 121

def require_cruise
  require "cruise"
rescue LoadError
  abort <<~MESSAGE
    The 'cruise' gem is required for the Herb Dev Server.

    Install it:
      gem install cruise

    or add to your Gemfile:
      bundle add cruise
  MESSAGE
end

#restartObject

Returns:

  • (Object)


91
92
93
94
95
96
97
# File 'lib/herb/dev/runner.rb', line 91

def restart
  require_relative "server"

  Herb::Dev::ServerEntry.all.each(&:stop!)
  sleep 0.5
  run
end

#runObject

Returns:

  • (Object)


32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/herb/dev/runner.rb', line 32

def run
  require_cruise
  require_relative "server"

  unless File.directory?(@path)
    puts "Not a directory: '#{@path}'."
    exit(1)
  end

  config = Herb::Configuration.load(@path)
  expanded_path = File.realpath(File.expand_path(config.project_root || @path))

  check_existing_server(expanded_path)
  port = find_port

  print CLEAR_SCREEN
  print HIDE_CURSOR
  print_header(config, expanded_path)

  file_states = index_files(config, @path)

  websocket = Herb::Dev::Server.new(port: port, project_path: expanded_path)
  websocket.start

  puts "  #{fg("WebSocket:".ljust(11), 245)}#{fg("ws://localhost:#{websocket.port}", 250)}"
  puts
  puts "  #{fg("Ready!", 42)} #{fg("Watching for changes...", 241)}"
  puts

  watch_files(config, expanded_path, websocket, file_states)
rescue Interrupt
  websocket&.stop
  print SHOW_CURSOR
  puts
  puts "Stopped."
  exit(0)
ensure
  websocket&.stop
  print SHOW_CURSOR
end

#statusObject

Returns:

  • (Object)


99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/herb/dev/runner.rb', line 99

def status
  require_relative "server"

  entries = Herb::Dev::ServerEntry.all

  if entries.empty?
    puts "No herb dev servers running."
  else
    entries.each do |entry|
      puts "#{entry.project_name} — PID: #{entry.pid}, port: #{entry.port}, started: #{entry.started_at}"
    end
  end

  exit(0)
end

#stopObject

Returns:

  • (Object)


73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/herb/dev/runner.rb', line 73

def stop
  require_relative "server"

  entries = Herb::Dev::ServerEntry.all

  if entries.empty?
    puts "No herb dev servers running."
    exit(0)
  end

  entries.each do |entry|
    entry.stop!
    puts "Stopped herb dev server for #{entry.project_name} (PID: #{entry.pid}, port: #{entry.port})"
  end

  exit(0)
end

#watch_files(config, expanded_path, websocket, file_states) ⇒ Object

Parameters:

  • config (Object)
  • expanded_path (Object)
  • websocket (Object)
  • file_states (Object)

Returns:

  • (Object)


196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/herb/dev/runner.rb', line 196

def watch_files(config, expanded_path, websocket, file_states)
  include_patterns = config.file_include_patterns
  exclude_patterns = config.file_exclude_patterns
  first_change = true
  errored_files = Set.new

  Thread.new do
    $stdin.gets(nil)
    Thread.main.raise(Interrupt)
  rescue IOError, Errno::EBADF
    Thread.main.raise(Interrupt)
  end

  Cruise.watch(expanded_path, only: ["created", "modified", "removed"]) do |event|
    file_path = event.path
    relative_path = file_path.delete_prefix("#{expanded_path}/")

    next if config.path_excluded?(relative_path, exclude_patterns)
    next unless config.path_included?(relative_path, include_patterns)

    if first_change
      print "\e[2A\e[J"
      puts "  #{fg("Recent changes:", 245)}"
      puts
      first_change = false
    end

    timestamp = fg(Time.now.strftime("%H:%M:%S.%L"), 241)
    display_path = fg(relative_path, 250)

    case event.kind
    when "created", "modified"
      handle_file_change(file_path, relative_path, file_states, errored_files, websocket, timestamp, display_path)
    when "removed"
      file_states.delete(file_path)
      badge = bold(fg("- removed", 196))
      puts "    #{timestamp} #{badge} #{display_path}"
    end
  end
end