thundercli 0.2.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/README.md +5 -0
- data/exe/thunder +11 -0
- data/lib/thunder/application.rb +35 -0
- data/lib/thunder/cli/command.rb +19 -0
- data/lib/thunder/cli/commands/console.rb +24 -0
- data/lib/thunder/cli/commands/db/create.rb +20 -0
- data/lib/thunder/cli/commands/db/drop.rb +20 -0
- data/lib/thunder/cli/commands/db/migrate.rb +20 -0
- data/lib/thunder/cli/commands/db/reset.rb +22 -0
- data/lib/thunder/cli/commands/db/setup.rb +21 -0
- data/lib/thunder/cli/commands/generate/chunk.rb +23 -0
- data/lib/thunder/cli/commands/generate/command.rb +64 -0
- data/lib/thunder/cli/commands/generate/event.rb +28 -0
- data/lib/thunder/cli/commands/generate/model.rb +39 -0
- data/lib/thunder/cli/commands/generate/operation.rb +39 -0
- data/lib/thunder/cli/commands/generate/outbox.rb +33 -0
- data/lib/thunder/cli/commands/generate/subscriber.rb +33 -0
- data/lib/thunder/cli/commands/messaging/consumer.rb +52 -0
- data/lib/thunder/cli/commands/messaging/outbox.rb +36 -0
- data/lib/thunder/cli/commands/new.rb +44 -0
- data/lib/thunder/cli/commands/serve.rb +38 -0
- data/lib/thunder/cli/commands/spec.rb +77 -0
- data/lib/thunder/cli/commands/version.rb +16 -0
- data/lib/thunder/cli.rb +78 -0
- data/lib/thunder/console/backtrace.rb +36 -0
- data/lib/thunder/console/banner.rb +93 -0
- data/lib/thunder/console/completion.rb +68 -0
- data/lib/thunder/console/constants.rb +57 -0
- data/lib/thunder/console/evaluation.rb +72 -0
- data/lib/thunder/console/help.rb +30 -0
- data/lib/thunder/console/helpers.rb +16 -0
- data/lib/thunder/console/inspector.rb +114 -0
- data/lib/thunder/console/pretty.rb +70 -0
- data/lib/thunder/console/prompt.rb +52 -0
- data/lib/thunder/console/theme.rb +85 -0
- data/lib/thunder/console/views/collection.rb +172 -0
- data/lib/thunder/console/views/format.rb +114 -0
- data/lib/thunder/console/views/layout.rb +72 -0
- data/lib/thunder/console/views/model.rb +92 -0
- data/lib/thunder/console/views/operation.rb +112 -0
- data/lib/thunder/console/views/record.rb +95 -0
- data/lib/thunder/console/views.rb +45 -0
- data/lib/thunder/console.rb +167 -0
- data/lib/thunder/generator.rb +73 -0
- data/lib/thunder/templates/chunk/app/common/models/application_model.rb.erb +4 -0
- data/lib/thunder/templates/chunk/app/common/operations/external_operation.rb.erb +3 -0
- data/lib/thunder/templates/chunk/app/common/operations/internal_operation.rb.erb +3 -0
- data/lib/thunder/templates/chunk/app/external/operations/_keep.erb +0 -0
- data/lib/thunder/templates/chunk/app/internal/models/_keep.erb +0 -0
- data/lib/thunder/templates/chunk/app/internal/operations/_keep.erb +0 -0
- data/lib/thunder/templates/chunk/config/chunk.rb.erb +9 -0
- data/lib/thunder/templates/chunk/database/migrate/_keep.erb +0 -0
- data/lib/thunder/templates/event/event.rb.erb +6 -0
- data/lib/thunder/templates/model/migration.rb.erb +17 -0
- data/lib/thunder/templates/model/model.rb.erb +6 -0
- data/lib/thunder/templates/new/Gemfile.erb +21 -0
- data/lib/thunder/templates/new/README.md.erb +8 -0
- data/lib/thunder/templates/new/_gitignore.erb +4 -0
- data/lib/thunder/templates/new/_ruby-version.erb +2 -0
- data/lib/thunder/templates/new/bin/thunder.erb +6 -0
- data/lib/thunder/templates/new/chunks/_keep.erb +0 -0
- data/lib/thunder/templates/operation/external.rb.erb +24 -0
- data/lib/thunder/templates/operation/internal.rb.erb +26 -0
- data/lib/thunder/templates/outbox/migration.rb.erb +21 -0
- data/lib/thunder/templates/subscriber/subscriber.rb.erb +18 -0
- data/lib/thunder/version.rb +5 -0
- data/lib/thunder.rb +32 -0
- metadata +147 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'rbconfig'
|
|
6
|
+
|
|
7
|
+
module Thunder
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# A process runs one chunk, so each chunk's specs run in a process of their own, all at the same time; each
|
|
11
|
+
# one's output is printed whole when it ends. With one chunk (-c, or paths all inside one), it runs here.
|
|
12
|
+
class Spec < Command
|
|
13
|
+
EXECUTABLE = File.expand_path('../../../../exe/thunder', __dir__)
|
|
14
|
+
|
|
15
|
+
desc 'Run the specs of the chunks, each chunk in its own process, in parallel'
|
|
16
|
+
|
|
17
|
+
argument :paths, type: :array, required: false, desc: 'Spec files or folders; their chunks run'
|
|
18
|
+
option :chunk, aliases: ['-c'], desc: 'Only this chunk'
|
|
19
|
+
|
|
20
|
+
def call(paths: [], chunk: nil, **)
|
|
21
|
+
runs = runs_for(paths, chunk)
|
|
22
|
+
return out.puts('no chunk has a spec/ folder') if runs.empty?
|
|
23
|
+
return exit(run_here(*runs.first)) if runs.one?
|
|
24
|
+
|
|
25
|
+
exit(run_apart(runs))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# { chunk => paths }; no paths means the chunk's spec/, and a chunk without one is left out.
|
|
31
|
+
def runs_for(paths, chunk_name)
|
|
32
|
+
return { Thunder.application.chunk(chunk_name) => paths } if chunk_name
|
|
33
|
+
if paths.empty?
|
|
34
|
+
return Thunder.application.chunks.select { |chunk| chunk.root_path.join('spec').directory? }.to_h { |chunk| [chunk, []] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
paths.group_by { |path| chunk_of(path) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def chunk_of(path)
|
|
41
|
+
full = Pathname(path).expand_path
|
|
42
|
+
Thunder.application.chunks.find { |chunk| full.ascend.include?(chunk.root_path.expand_path) } ||
|
|
43
|
+
raise(Error, "#{path} is not inside a chunk")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run_here(chunk, paths)
|
|
47
|
+
begin
|
|
48
|
+
require 'thunderspec'
|
|
49
|
+
rescue LoadError
|
|
50
|
+
raise Error, "thunderspec is not in the bundle; add `gem 'thunderspec'` to the Gemfile's test group"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
ThunderSpec::Runner.new(chunk, paths).run
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run_apart(runs)
|
|
57
|
+
threads = runs.map do |chunk, paths|
|
|
58
|
+
Thread.new do
|
|
59
|
+
output, status = Open3.capture2e(RbConfig.ruby, EXECUTABLE, 'spec', '-c', chunk.identifier.to_s, *paths)
|
|
60
|
+
[chunk, output, status]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
results = threads.map(&:value)
|
|
65
|
+
results.each { |chunk, output, status| print_run(chunk, output, status) }
|
|
66
|
+
results.all? { |_, _, status| status.success? } ? 0 : 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def print_run(chunk, output, status)
|
|
70
|
+
out.puts "== #{chunk.identifier} (#{status.success? ? 'passed' : 'failed'})"
|
|
71
|
+
out.puts output
|
|
72
|
+
out.puts
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thunder
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Not a Thunder::CLI::Command: it needs no application, env or chunk.
|
|
7
|
+
class Version < Dry::CLI::Command
|
|
8
|
+
desc 'Print the version'
|
|
9
|
+
|
|
10
|
+
def call(**)
|
|
11
|
+
out.puts "thunder #{Thunder::VERSION}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/thunder/cli.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/cli'
|
|
4
|
+
|
|
5
|
+
require_relative 'cli/commands/version'
|
|
6
|
+
require_relative 'cli/commands/console'
|
|
7
|
+
require_relative 'cli/commands/serve'
|
|
8
|
+
require_relative 'cli/commands/messaging/outbox'
|
|
9
|
+
require_relative 'cli/commands/messaging/consumer'
|
|
10
|
+
require_relative 'cli/commands/db/create'
|
|
11
|
+
require_relative 'cli/commands/db/migrate'
|
|
12
|
+
require_relative 'cli/commands/db/drop'
|
|
13
|
+
require_relative 'cli/commands/db/reset'
|
|
14
|
+
require_relative 'cli/commands/db/setup'
|
|
15
|
+
# WARN: AI MADE
|
|
16
|
+
require_relative 'cli/commands/new'
|
|
17
|
+
require_relative 'cli/commands/generate/command'
|
|
18
|
+
require_relative 'cli/commands/generate/chunk'
|
|
19
|
+
require_relative 'cli/commands/generate/operation'
|
|
20
|
+
require_relative 'cli/commands/generate/model'
|
|
21
|
+
require_relative 'cli/commands/generate/event'
|
|
22
|
+
require_relative 'cli/commands/generate/subscriber'
|
|
23
|
+
require_relative 'cli/commands/generate/outbox'
|
|
24
|
+
require_relative 'cli/commands/spec'
|
|
25
|
+
|
|
26
|
+
module Thunder
|
|
27
|
+
module CLI
|
|
28
|
+
extend Dry::CLI::Registry
|
|
29
|
+
|
|
30
|
+
register 'version', Commands::Version, aliases: ['-v', '--version']
|
|
31
|
+
register 'console', Commands::Console, aliases: ['c']
|
|
32
|
+
register 'serve', Commands::Serve
|
|
33
|
+
# WARN: AI MADE
|
|
34
|
+
register 'new', Commands::New
|
|
35
|
+
register 'spec', Commands::Spec
|
|
36
|
+
register 'generate', aliases: ['g'] do |generate|
|
|
37
|
+
generate.register 'chunk', Commands::Generate::Chunk
|
|
38
|
+
generate.register 'operation', Commands::Generate::Operation
|
|
39
|
+
generate.register 'model', Commands::Generate::Model
|
|
40
|
+
generate.register 'event', Commands::Generate::Event
|
|
41
|
+
generate.register 'subscriber', Commands::Generate::Subscriber
|
|
42
|
+
generate.register 'outbox', Commands::Generate::Outbox
|
|
43
|
+
end
|
|
44
|
+
register 'messaging' do |messaging|
|
|
45
|
+
messaging.register 'outbox', Commands::Messaging::Outbox
|
|
46
|
+
messaging.register 'consumer', Commands::Messaging::Consumer
|
|
47
|
+
end
|
|
48
|
+
register 'database', aliases: ['db'] do |db|
|
|
49
|
+
db.register 'create', Commands::Db::Create
|
|
50
|
+
db.register 'migrate', Commands::Db::Migrate
|
|
51
|
+
db.register 'drop', Commands::Db::Drop
|
|
52
|
+
db.register 'reset', Commands::Db::Reset
|
|
53
|
+
db.register 'setup', Commands::Db::Setup
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# WARN: AI MADE
|
|
57
|
+
# Completes the command names typed in part: `g op` and `gen ope` become `generate operation`. Stops at the
|
|
58
|
+
# command; its arguments and options are left as typed. A prefix of more than one name raises Error.
|
|
59
|
+
def self.expand(arguments)
|
|
60
|
+
expanded = []
|
|
61
|
+
|
|
62
|
+
arguments.each_with_index do |word, index|
|
|
63
|
+
result = get(expanded)
|
|
64
|
+
return expanded + arguments[index..] if result.found?
|
|
65
|
+
|
|
66
|
+
next expanded << word if get(expanded + [word]).names.size > expanded.size
|
|
67
|
+
|
|
68
|
+
matches = result.children.keys.select { |name| name.start_with?(word) }
|
|
69
|
+
return expanded + arguments[index..] if matches.empty?
|
|
70
|
+
raise Error, "'#{word}' is ambiguous: #{matches.join(', ')}" if matches.size > 1
|
|
71
|
+
|
|
72
|
+
expanded << matches.first
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
expanded
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# IRB's backtrace filter: keeps the lines of the application and of the framework, relative paths, and drops gems
|
|
7
|
+
# and the console and CLI that run the evaluation. When nothing is left, the first lines are kept as they were.
|
|
8
|
+
class Backtrace
|
|
9
|
+
FRAMEWORK = %r{/(thunder|thunderchunk|thunderoperation|thunderframe|thunderfoundation)(-[\d.]+)?/(lib/.*)\z}
|
|
10
|
+
RUNNER = %r{\Alib/thunder/(console|cli)}
|
|
11
|
+
|
|
12
|
+
def initialize(root)
|
|
13
|
+
@root = "#{root}/"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(lines)
|
|
17
|
+
kept = lines.filter_map { |line| shorten(line) }
|
|
18
|
+
hidden = lines.size - kept.size
|
|
19
|
+
return lines.first(5) if kept.empty?
|
|
20
|
+
|
|
21
|
+
hidden.positive? ? [*kept, "(#{hidden} lines hidden)"] : kept
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def shorten(line)
|
|
27
|
+
return line if line.start_with?('(irb)')
|
|
28
|
+
return line.delete_prefix(@root) if line.start_with?(@root)
|
|
29
|
+
|
|
30
|
+
path, rest = line.split(':', 2)
|
|
31
|
+
match = path.match(FRAMEWORK)
|
|
32
|
+
"#{match[1]}/#{match[3]}:#{rest}" if match && !match[3].match?(RUNNER)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# Printed when the console opens: versions, then one row per fact (chunk, env, database, what the chunk
|
|
7
|
+
# registers) and the hints, each block separated by a blank line.
|
|
8
|
+
class Banner
|
|
9
|
+
include Theme
|
|
10
|
+
|
|
11
|
+
KIND_LABELS = { model: 'models', operation: 'operations', external_operation: 'external' }.freeze
|
|
12
|
+
# Any other env is a warning.
|
|
13
|
+
ENV_ROLES = { 'production' => %i[fail bold], 'development' => [:ok] }.freeze
|
|
14
|
+
|
|
15
|
+
# One row per kind the chunk registers; also how the inspector prints a chunk.
|
|
16
|
+
def self.registry_rows(chunk)
|
|
17
|
+
chunk.registry.hash.filter_map do |kind, items|
|
|
18
|
+
next if items.empty?
|
|
19
|
+
|
|
20
|
+
[KIND_LABELS.fetch(kind, kind.to_s), items.keys.sort.join(', ')]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(console)
|
|
25
|
+
@console = console
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lines(boot_error: nil)
|
|
29
|
+
rows = @console.chunk ? chunk_rows(boot_error) : no_chunk_rows
|
|
30
|
+
['', title, '', *rows(rows), '', hints, '']
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def title
|
|
36
|
+
root = @console.application.root
|
|
37
|
+
"#{paint('⚡ thunderbolt', :accent, :bold)} " +
|
|
38
|
+
paint("#{Thunder::VERSION} · ruby #{RUBY_VERSION} · #{root.basename}", :faint)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Without a boot the chunk has no configuration, so the database is not asked about.
|
|
42
|
+
def chunk_rows(boot_error)
|
|
43
|
+
rows = [['chunk', paint(@console.chunk.identifier, :bold)], ['env', env]]
|
|
44
|
+
return [*rows, ['boot', failure(boot_error)]] if boot_error
|
|
45
|
+
|
|
46
|
+
[*rows, ['db', database], *registry]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def no_chunk_rows
|
|
50
|
+
[
|
|
51
|
+
['chunks', @console.application.chunks.map(&:identifier).join(', ')],
|
|
52
|
+
['env', env],
|
|
53
|
+
['chunk', paint('none selected', :warn)]
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def env = paint(@console.env, *ENV_ROLES.fetch(@console.env, [:warn]))
|
|
58
|
+
|
|
59
|
+
def database
|
|
60
|
+
@console.chunk.database.connection_pool.with_connection { |connection| migration_state(connection) }
|
|
61
|
+
rescue ActiveRecord::NoDatabaseError
|
|
62
|
+
"#{paint("● #{@console.chunk.database.configuration[:database]} not created", :fail)} " +
|
|
63
|
+
paint('thunder db create', :faint)
|
|
64
|
+
rescue ActiveRecord::ConnectionNotEstablished => e
|
|
65
|
+
"#{paint('● unreachable', :fail)} #{paint(e.message.lines.first.to_s.strip, :faint)}"
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
"#{paint("● #{e.class}", :fail)} #{paint(e.message.lines.first.to_s.strip, :faint)}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The database name, how many migrations the chunk has and whether any is pending.
|
|
71
|
+
def migration_state(connection)
|
|
72
|
+
migrations = ActiveRecord::MigrationContext.new(@console.chunk.root_path.join('database/migrate').to_s)
|
|
73
|
+
state = migrations.needs_migration? ? paint('pending migrations', :warn) : paint('up to date', :faint)
|
|
74
|
+
count = migrations.migrations.size
|
|
75
|
+
count = paint("#{count} #{count == 1 ? 'migration' : 'migrations'} · ", :faint)
|
|
76
|
+
|
|
77
|
+
"#{paint("● #{connection.current_database}", :ok)} #{count}#{state}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def registry = self.class.registry_rows(@console.chunk)
|
|
81
|
+
|
|
82
|
+
def failure(error)
|
|
83
|
+
"#{paint("✗ #{error.class}", :fail, :bold)} #{error.message.lines.first.to_s.strip}\n" +
|
|
84
|
+
paint('fix it and reload!', :faint)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def hints
|
|
88
|
+
others = @console.application.chunks.map(&:identifier) - [@console.chunk&.identifier]
|
|
89
|
+
" #{paint(['help', *others.first(2).map { |name| "chunk :#{name}" }].join(' · '), :faint)}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# Prepended to IRB's completor. Inside the arguments of chunk, use and use! it offers only the chunk names, the
|
|
7
|
+
# kinds and the keys of the chunk's registry (none when nothing matches or no chunk is selected); everywhere else
|
|
8
|
+
# IRB completes as usual.
|
|
9
|
+
# chunk :li<TAB> -> chunk :library
|
|
10
|
+
# use :mo<TAB> -> use :model (also with parentheses)
|
|
11
|
+
# use :operation, 'bo<TAB> -> use :operation, 'books.add'
|
|
12
|
+
# use :operation, :bo<TAB> -> use :operation, 'books.add' (a dotted key is not a valid symbol)
|
|
13
|
+
module Completion
|
|
14
|
+
ARGUMENTS = '(?:\(\s*|\s+)'
|
|
15
|
+
CHUNK = /(?<![\w.])chunk#{ARGUMENTS}(['":]?)(\w*)\z/
|
|
16
|
+
KIND = /(?<![\w.])use!?#{ARGUMENTS}(['":]?)(\w*)\z/
|
|
17
|
+
KEY = /(?<![\w.])use!?#{ARGUMENTS}['":]?(\w+)['"]?\s*,\s*(['":]?)([\w.]*)\z/
|
|
18
|
+
|
|
19
|
+
def completion_candidates(preposing, target, postposing, bind:)
|
|
20
|
+
text = preposing + target
|
|
21
|
+
|
|
22
|
+
if (match = text.match(CHUNK))
|
|
23
|
+
chunk_candidates(target, match)
|
|
24
|
+
elsif (match = text.match(KEY))
|
|
25
|
+
key_candidates(target, match)
|
|
26
|
+
elsif (match = text.match(KIND))
|
|
27
|
+
kind_candidates(target, match)
|
|
28
|
+
else
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def chunk_candidates(target, match)
|
|
36
|
+
names = Console.current.application.chunks.map { |chunk| chunk.identifier.to_s }
|
|
37
|
+
replace(target, names, match[1], match[2], symbol_by_default: true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def kind_candidates(target, match)
|
|
41
|
+
replace(target, registry.keys.map(&:to_s), match[1], match[2], symbol_by_default: true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def key_candidates(target, match)
|
|
45
|
+
keys = (registry[match[1].to_sym] || {}).keys
|
|
46
|
+
replace(target, keys, match[2], match[3], symbol_by_default: false)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The registry of the console's chunk; empty without a chunk, so there is nothing to offer.
|
|
50
|
+
def registry
|
|
51
|
+
chunk = Console.current&.chunk
|
|
52
|
+
chunk ? chunk.registry.hash : {}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Replaces the argument being typed with each name that starts with it, keeping the quote the user opened. A name
|
|
56
|
+
# that is not a valid symbol (books.add) becomes a string.
|
|
57
|
+
def replace(target, names, quote, prefix, symbol_by_default:)
|
|
58
|
+
default_mark = symbol_by_default ? ':' : "'"
|
|
59
|
+
names.sort.select { |name| name.start_with?(prefix) }.map do |name|
|
|
60
|
+
mark = quote.empty? ? default_mark : quote
|
|
61
|
+
mark = "'" if mark == ':' && !name.match?(/\A\w+\z/)
|
|
62
|
+
closing = mark == ':' ? '' : mark
|
|
63
|
+
target.sub(/['":]?[\w.]*\z/, "#{mark}#{name}#{closing}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# The constants use defines on Object. Only the ones defined here are replaced or reused: a path that hits an
|
|
7
|
+
# existing constant (process -> Process, kernel.parser -> Kernel) is refused.
|
|
8
|
+
class Constants
|
|
9
|
+
def initialize
|
|
10
|
+
@owned = Set.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# 'books.add' -> Books::Add. The first existing constant of the path not defined here, as 'A::B', when refused;
|
|
14
|
+
# nil once defined.
|
|
15
|
+
def define(key, klass)
|
|
16
|
+
*namespaces, name = key.to_s.split('.').map { |part| part.camelize.to_sym }
|
|
17
|
+
taken = taken([*namespaces, name])
|
|
18
|
+
return taken if taken
|
|
19
|
+
|
|
20
|
+
owner = define_namespaces(namespaces)
|
|
21
|
+
owner.send(:remove_const, name) if owner.const_defined?(name, false)
|
|
22
|
+
owner.const_set(name, klass)
|
|
23
|
+
@owned << [owner, name]
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Removes every constant defined here: a namespace as Reviews would collide with the chunk module on boot.
|
|
28
|
+
def clear
|
|
29
|
+
@owned.reverse_each { |owner, name| owner.send(:remove_const, name) if owner.const_defined?(name, false) }
|
|
30
|
+
@owned.clear
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def taken(path)
|
|
36
|
+
owner = Object
|
|
37
|
+
path.each_with_index do |name, index|
|
|
38
|
+
return nil unless owner.const_defined?(name, false)
|
|
39
|
+
return path[0..index].join('::') unless @owned.include?([owner, name])
|
|
40
|
+
|
|
41
|
+
owner = owner.const_get(name, false)
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Books::Add needs Books: reuses the one defined here before, creates it otherwise.
|
|
47
|
+
def define_namespaces(names)
|
|
48
|
+
names.reduce(Object) do |owner, name|
|
|
49
|
+
next owner.const_get(name, false) if owner.const_defined?(name, false)
|
|
50
|
+
|
|
51
|
+
@owned << [owner, name]
|
|
52
|
+
owner.const_set(name, Module.new)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# Wraps each evaluation (IRB's measure callbacks): time, queries run, whether it failed. Repaints the prompt so the
|
|
7
|
+
# arrow turns red after a failure, and prints the queries inline while the SQL log is on.
|
|
8
|
+
class Evaluation
|
|
9
|
+
include Theme
|
|
10
|
+
|
|
11
|
+
SQL_KEYWORDS = /\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|INTO|VALUES|SET|JOIN|LEFT|INNER|ON|AND|OR|ORDER BY|
|
|
12
|
+
GROUP BY|LIMIT|OFFSET|RETURNING|BEGIN|COMMIT|ROLLBACK|COUNT|AS|IN|IS|NULL|NOT)\b/x
|
|
13
|
+
IGNORED_QUERIES = %w[SCHEMA TRANSACTION].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :elapsed, :queries
|
|
16
|
+
attr_accessor :sql_log
|
|
17
|
+
|
|
18
|
+
def initialize(prompt)
|
|
19
|
+
@prompt = prompt
|
|
20
|
+
@elapsed = 0
|
|
21
|
+
@queries = 0
|
|
22
|
+
@sql_log = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def install
|
|
26
|
+
IRB.conf[:MEASURE] = true
|
|
27
|
+
IRB.conf[:MEASURE_CALLBACKS] = [[:THUNDER, method(:around).to_proc, nil]]
|
|
28
|
+
ActiveSupport::Notifications.subscribe('sql.active_record') { |event| on_sql(event) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def around(context, _code, _line_no, _arg)
|
|
32
|
+
@queries = 0
|
|
33
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
|
+
failed = true
|
|
35
|
+
result = yield
|
|
36
|
+
failed = failure?(result)
|
|
37
|
+
result
|
|
38
|
+
ensure
|
|
39
|
+
@elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
40
|
+
@prompt.apply(context, failed:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Empty when the evaluation took under 1ms and ran no query.
|
|
44
|
+
def summary
|
|
45
|
+
parts = []
|
|
46
|
+
parts << duration(elapsed) if elapsed >= 0.001
|
|
47
|
+
parts << "#{queries} #{queries == 1 ? 'query' : 'queries'}" if queries.positive?
|
|
48
|
+
parts.join(' · ')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# case/when rather than is_a?: the result can be a BasicObject (ThunderChunk::Configuration).
|
|
54
|
+
def failure?(result)
|
|
55
|
+
case result
|
|
56
|
+
when ThunderOperation::Response then result.failure?
|
|
57
|
+
else false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def on_sql(event)
|
|
62
|
+
return if IGNORED_QUERIES.include?(event.payload[:name]) || event.payload[:cached]
|
|
63
|
+
|
|
64
|
+
@queries += 1
|
|
65
|
+
return unless sql_log
|
|
66
|
+
|
|
67
|
+
sql = event.payload[:sql].gsub(SQL_KEYWORDS) { paint(Regexp.last_match(0), :warn) }
|
|
68
|
+
puts " #{paint('↳', :faint)} #{sql} #{paint(duration(event.duration / 1000.0), :muted)}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# The console's `help`: its helpers, in place of IRB's command list.
|
|
7
|
+
class Help < IRB::Command::Base
|
|
8
|
+
include Theme
|
|
9
|
+
|
|
10
|
+
category 'Thunder'
|
|
11
|
+
description 'List the console helpers'
|
|
12
|
+
|
|
13
|
+
LINES = [
|
|
14
|
+
['chunk', 'the chunk of this console'],
|
|
15
|
+
['chunk :name', 'restart the console on another chunk'],
|
|
16
|
+
['chunks', 'chunks of the application'],
|
|
17
|
+
['use(kind, key)', "define the key's constant (use(:model, :book) -> Book); false when not registered"],
|
|
18
|
+
['use!(kind, key)', 'the registered class; raises when not registered'],
|
|
19
|
+
['reload!', 'boot the chunk again and repoint the constants of use'],
|
|
20
|
+
['sql!', 'turn the inline SQL log on or off'],
|
|
21
|
+
['exit', 'leave the console']
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def execute(_arg)
|
|
25
|
+
width = LINES.map { |name, _| name.size }.max
|
|
26
|
+
puts('', *LINES.map { |name, text| " #{paint(name.ljust(width), :accent)} #{paint(text, :faint)}" }, '')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# Methods of the IRB main object. Without a chunk selected they print how to select one instead of raising.
|
|
7
|
+
module Helpers
|
|
8
|
+
def chunk(name = nil) = name ? Console.current.change_chunk(name) : Console.current.chunk
|
|
9
|
+
def chunks = Console.current.application.chunks.map(&:identifier)
|
|
10
|
+
def use(kind, key) = Console.current.within_chunk { |console| console.use(kind, key) } || false
|
|
11
|
+
def use!(kind, key) = Console.current.within_chunk { |console| console.use!(kind, key) }
|
|
12
|
+
def reload! = Console.current.within_chunk(&:reload!)
|
|
13
|
+
def sql! = Console.current.toggle_sql_log
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'irb/color_printer'
|
|
4
|
+
|
|
5
|
+
# WARN: AI MADE
|
|
6
|
+
module Thunder
|
|
7
|
+
class Console
|
|
8
|
+
# How values are printed. A Response becomes a block: a ✓ SUCCESS / ✗ FAILURE badge, the operation key, the failed
|
|
9
|
+
# step (params for a parameter error), time and queries, then │ rows in the state color: the payload, or the
|
|
10
|
+
# message, field, error, extras and the line of the chunk where it failed. Other values print through Pretty,
|
|
11
|
+
# with the time when the evaluation was slow. A block of several lines ends in a blank line, apart from the
|
|
12
|
+
# next prompt.
|
|
13
|
+
class Inspector
|
|
14
|
+
include Theme
|
|
15
|
+
|
|
16
|
+
SLOW = 0.1
|
|
17
|
+
# Columns a view printed as a Response's payload gives up: the rows' guide and label column, with room to spare.
|
|
18
|
+
NESTED_INSET = 'payload'.size + 10
|
|
19
|
+
|
|
20
|
+
def initialize(console)
|
|
21
|
+
@console = console
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(value, colorize:)
|
|
25
|
+
# case/when rather than is_a?: a value can be a BasicObject (ThunderChunk::Configuration).
|
|
26
|
+
case value
|
|
27
|
+
when ThunderOperation::Response then return spaced(response(value, colorize:))
|
|
28
|
+
when ThunderChunk::Chunk then return spaced(chunk(value))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
view = Views.for(value)
|
|
32
|
+
return spaced(view.call) if view
|
|
33
|
+
|
|
34
|
+
# Continuation lines start under the value, past IRB's "=> ".
|
|
35
|
+
text = pretty(value, colorize:).gsub("\n", "\n ")
|
|
36
|
+
evaluation = @console.evaluation
|
|
37
|
+
slow = evaluation.elapsed >= SLOW || evaluation.queries.positive?
|
|
38
|
+
text = "#{text}\n#{paint("⏱ #{evaluation.summary}", :faint)}" if slow
|
|
39
|
+
text.include?("\n") ? spaced(text) : text
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# IRB chomps one newline off the value; the second one is the blank line.
|
|
45
|
+
def spaced(text) = "#{text}\n\n"
|
|
46
|
+
|
|
47
|
+
def response(response, colorize:)
|
|
48
|
+
role = response.success? ? :ok : :fail
|
|
49
|
+
badge = paint(response.success? ? ' ✓ SUCCESS ' : ' ✗ FAILURE ', role, :bold, :reverse)
|
|
50
|
+
meta = [step(response), *@console.evaluation.summary.presence].compact.join(' · ')
|
|
51
|
+
header = "#{badge} #{paint(operation_key(response.operation_name), :bold)}"
|
|
52
|
+
header += " #{paint(meta, :faint)}" unless meta.empty?
|
|
53
|
+
fields =
|
|
54
|
+
response.success? ? [['payload', pretty(response.payload, colorize:)]] : failure_fields(response, colorize:)
|
|
55
|
+
|
|
56
|
+
[header, *rows(fields, indent: 3, guide: role)].join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def step(response)
|
|
60
|
+
return if response.success?
|
|
61
|
+
return 'params' if response.error.is_a?(ThunderOperation::Exceptions::ParameterError)
|
|
62
|
+
|
|
63
|
+
"step #{response.failed_step}" if response.failed_step
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def failure_fields(response, colorize:)
|
|
67
|
+
error = response.error
|
|
68
|
+
[
|
|
69
|
+
['message', paint(response.failure_message.to_s.strip, :fail)],
|
|
70
|
+
(['field', paint(error.field, :bold)] if error.respond_to?(:field) && error.field),
|
|
71
|
+
['error', paint(error.class.name.demodulize, :faint)],
|
|
72
|
+
(['extras', pretty(response.failure_extras, colorize:)] if response.failure_extras.present?),
|
|
73
|
+
(['at', paint(location(error), :faint)] if location(error))
|
|
74
|
+
]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The chunk as the banner shows it, in place of the dump of its configuration, loader and registries.
|
|
78
|
+
def chunk(chunk)
|
|
79
|
+
root = chunk.root_path.relative_path_from(@console.application.root)
|
|
80
|
+
header = "#{paint(" ⚡ #{chunk.identifier} ", :accent, :bold, :reverse)} " +
|
|
81
|
+
paint("#{chunk.booted? ? 'booted' : 'not booted'} · #{root}", :faint)
|
|
82
|
+
[header, *rows(Banner.registry_rows(chunk), indent: 3, guide: :accent)].join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def operation_key(name)
|
|
86
|
+
registry = @console.chunk&.registry
|
|
87
|
+
key = registry && registry.hash.each_value.lazy.filter_map do |items|
|
|
88
|
+
items.find { |_key, klass| klass.name == name }&.first
|
|
89
|
+
end.first
|
|
90
|
+
key || name
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The first line of the backtrace inside the chunk, relative to it.
|
|
94
|
+
def location(error)
|
|
95
|
+
backtrace = error&.backtrace
|
|
96
|
+
return unless @console.chunk && backtrace
|
|
97
|
+
|
|
98
|
+
root = "#{@console.chunk.root_path}/"
|
|
99
|
+
line = backtrace.find { |entry| entry.start_with?(root) }
|
|
100
|
+
line&.delete_prefix(root)&.sub(/:in .*\z/, '')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A value inside a Response's rows. A view there loses the room the rows take (guide and label column) and the
|
|
104
|
+
# indent it has at the top level.
|
|
105
|
+
def pretty(value, colorize:)
|
|
106
|
+
view = Views.for(value)
|
|
107
|
+
return Pretty.new(colorize:).call(value) unless view
|
|
108
|
+
|
|
109
|
+
view.inset = NESTED_INSET
|
|
110
|
+
view.call.gsub(/^ {3}/, '')
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|