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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +5 -0
  3. data/exe/thunder +11 -0
  4. data/lib/thunder/application.rb +35 -0
  5. data/lib/thunder/cli/command.rb +19 -0
  6. data/lib/thunder/cli/commands/console.rb +24 -0
  7. data/lib/thunder/cli/commands/db/create.rb +20 -0
  8. data/lib/thunder/cli/commands/db/drop.rb +20 -0
  9. data/lib/thunder/cli/commands/db/migrate.rb +20 -0
  10. data/lib/thunder/cli/commands/db/reset.rb +22 -0
  11. data/lib/thunder/cli/commands/db/setup.rb +21 -0
  12. data/lib/thunder/cli/commands/generate/chunk.rb +23 -0
  13. data/lib/thunder/cli/commands/generate/command.rb +64 -0
  14. data/lib/thunder/cli/commands/generate/event.rb +28 -0
  15. data/lib/thunder/cli/commands/generate/model.rb +39 -0
  16. data/lib/thunder/cli/commands/generate/operation.rb +39 -0
  17. data/lib/thunder/cli/commands/generate/outbox.rb +33 -0
  18. data/lib/thunder/cli/commands/generate/subscriber.rb +33 -0
  19. data/lib/thunder/cli/commands/messaging/consumer.rb +52 -0
  20. data/lib/thunder/cli/commands/messaging/outbox.rb +36 -0
  21. data/lib/thunder/cli/commands/new.rb +44 -0
  22. data/lib/thunder/cli/commands/serve.rb +38 -0
  23. data/lib/thunder/cli/commands/spec.rb +77 -0
  24. data/lib/thunder/cli/commands/version.rb +16 -0
  25. data/lib/thunder/cli.rb +78 -0
  26. data/lib/thunder/console/backtrace.rb +36 -0
  27. data/lib/thunder/console/banner.rb +93 -0
  28. data/lib/thunder/console/completion.rb +68 -0
  29. data/lib/thunder/console/constants.rb +57 -0
  30. data/lib/thunder/console/evaluation.rb +72 -0
  31. data/lib/thunder/console/help.rb +30 -0
  32. data/lib/thunder/console/helpers.rb +16 -0
  33. data/lib/thunder/console/inspector.rb +114 -0
  34. data/lib/thunder/console/pretty.rb +70 -0
  35. data/lib/thunder/console/prompt.rb +52 -0
  36. data/lib/thunder/console/theme.rb +85 -0
  37. data/lib/thunder/console/views/collection.rb +172 -0
  38. data/lib/thunder/console/views/format.rb +114 -0
  39. data/lib/thunder/console/views/layout.rb +72 -0
  40. data/lib/thunder/console/views/model.rb +92 -0
  41. data/lib/thunder/console/views/operation.rb +112 -0
  42. data/lib/thunder/console/views/record.rb +95 -0
  43. data/lib/thunder/console/views.rb +45 -0
  44. data/lib/thunder/console.rb +167 -0
  45. data/lib/thunder/generator.rb +73 -0
  46. data/lib/thunder/templates/chunk/app/common/models/application_model.rb.erb +4 -0
  47. data/lib/thunder/templates/chunk/app/common/operations/external_operation.rb.erb +3 -0
  48. data/lib/thunder/templates/chunk/app/common/operations/internal_operation.rb.erb +3 -0
  49. data/lib/thunder/templates/chunk/app/external/operations/_keep.erb +0 -0
  50. data/lib/thunder/templates/chunk/app/internal/models/_keep.erb +0 -0
  51. data/lib/thunder/templates/chunk/app/internal/operations/_keep.erb +0 -0
  52. data/lib/thunder/templates/chunk/config/chunk.rb.erb +9 -0
  53. data/lib/thunder/templates/chunk/database/migrate/_keep.erb +0 -0
  54. data/lib/thunder/templates/event/event.rb.erb +6 -0
  55. data/lib/thunder/templates/model/migration.rb.erb +17 -0
  56. data/lib/thunder/templates/model/model.rb.erb +6 -0
  57. data/lib/thunder/templates/new/Gemfile.erb +21 -0
  58. data/lib/thunder/templates/new/README.md.erb +8 -0
  59. data/lib/thunder/templates/new/_gitignore.erb +4 -0
  60. data/lib/thunder/templates/new/_ruby-version.erb +2 -0
  61. data/lib/thunder/templates/new/bin/thunder.erb +6 -0
  62. data/lib/thunder/templates/new/chunks/_keep.erb +0 -0
  63. data/lib/thunder/templates/operation/external.rb.erb +24 -0
  64. data/lib/thunder/templates/operation/internal.rb.erb +26 -0
  65. data/lib/thunder/templates/outbox/migration.rb.erb +21 -0
  66. data/lib/thunder/templates/subscriber/subscriber.rb.erb +18 -0
  67. data/lib/thunder/version.rb +5 -0
  68. data/lib/thunder.rb +32 -0
  69. 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
@@ -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