rigortype 0.1.16 → 0.1.18
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 +4 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
- data/lib/rigor/analysis/check_rules.rb +180 -73
- data/lib/rigor/analysis/dependency_recorder.rb +122 -0
- data/lib/rigor/analysis/diagnostic.rb +18 -0
- data/lib/rigor/analysis/incremental.rb +162 -0
- data/lib/rigor/analysis/incremental_session.rb +337 -0
- data/lib/rigor/analysis/rule_catalog.rb +48 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +477 -1110
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/builtins/static_return_refinements.rb +7 -1
- data/lib/rigor/cache/descriptor.rb +50 -49
- data/lib/rigor/cache/incremental_snapshot.rb +153 -0
- data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
- data/lib/rigor/cache/rbs_constant_table.rb +2 -8
- data/lib/rigor/cache/rbs_environment.rb +2 -8
- data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
- data/lib/rigor/cache/store.rb +145 -14
- data/lib/rigor/cli/annotate_command.rb +2 -7
- data/lib/rigor/cli/baseline_command.rb +2 -7
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/command.rb +47 -0
- data/lib/rigor/cli/coverage_command.rb +3 -23
- data/lib/rigor/cli/coverage_renderer.rb +3 -8
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/diff_command.rb +3 -7
- data/lib/rigor/cli/explain_command.rb +2 -7
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mcp_command.rb +3 -7
- data/lib/rigor/cli/options.rb +57 -0
- data/lib/rigor/cli/plugin_command.rb +3 -7
- data/lib/rigor/cli/plugins_command.rb +2 -7
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/renderable.rb +26 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -7
- data/lib/rigor/cli/skill_command.rb +3 -7
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +2 -7
- data/lib/rigor/cli/type_of_command.rb +5 -38
- data/lib/rigor/cli/type_of_renderer.rb +4 -9
- data/lib/rigor/cli/type_scan_command.rb +3 -23
- data/lib/rigor/cli/type_scan_renderer.rb +4 -9
- data/lib/rigor/cli.rb +15 -532
- data/lib/rigor/configuration/dependencies.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +22 -3
- data/lib/rigor/configuration.rb +16 -3
- data/lib/rigor/environment/rbs_loader.rb +129 -71
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/block_parameter_binder.rb +1 -2
- data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
- data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
- data/lib/rigor/inference/expression_typer.rb +149 -63
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
- data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher.rb +185 -84
- data/lib/rigor/inference/narrowing.rb +262 -5
- data/lib/rigor/inference/scope_indexer.rb +208 -21
- data/lib/rigor/inference/statement_evaluator.rb +110 -48
- data/lib/rigor/language_server/buffer_resolution.rb +33 -0
- data/lib/rigor/language_server/completion_provider.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
- data/lib/rigor/language_server/folding_range_provider.rb +4 -4
- data/lib/rigor/language_server/hover_provider.rb +4 -4
- data/lib/rigor/language_server/selection_range_provider.rb +4 -4
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +302 -45
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +281 -15
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
- data/lib/rigor/rbs_extended.rb +39 -0
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +150 -167
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/acceptance_router.rb +19 -0
- data/lib/rigor/type/accepts_result.rb +3 -10
- data/lib/rigor/type/app.rb +3 -7
- data/lib/rigor/type/bot.rb +2 -3
- data/lib/rigor/type/bound_method.rb +5 -12
- data/lib/rigor/type/combinator.rb +22 -0
- data/lib/rigor/type/constant.rb +2 -3
- data/lib/rigor/type/data_class.rb +80 -0
- data/lib/rigor/type/data_instance.rb +100 -0
- data/lib/rigor/type/difference.rb +5 -10
- data/lib/rigor/type/dynamic.rb +5 -10
- data/lib/rigor/type/hash_shape.rb +5 -15
- data/lib/rigor/type/integer_range.rb +5 -10
- data/lib/rigor/type/intersection.rb +5 -10
- data/lib/rigor/type/nominal.rb +5 -10
- data/lib/rigor/type/refined.rb +5 -10
- data/lib/rigor/type/singleton.rb +5 -10
- data/lib/rigor/type/top.rb +2 -3
- data/lib/rigor/type/tuple.rb +5 -10
- data/lib/rigor/type/union.rb +5 -10
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/value_semantics.rb +77 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
- data/sig/rigor/cache.rbs +19 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +27 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +42 -25
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +58 -1
- data/sig/rigor.rbs +6 -1
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +36 -2
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../analysis/buffer_binding"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Shared option plumbing for the subcommands.
|
|
8
|
+
#
|
|
9
|
+
# Today it owns the editor-mode surface that `rigor check` and
|
|
10
|
+
# `rigor type-of` both expose: the `--tmp-file` / `--instead-of` flag
|
|
11
|
+
# pair and the buffer-binding resolution that validates it. That
|
|
12
|
+
# resolution used to be copied verbatim into both `Rigor::CLI` and
|
|
13
|
+
# `TypeOfCommand`, so a fix to one (the paired-flag check, the
|
|
14
|
+
# readability check, the error wording) could silently miss the
|
|
15
|
+
# other. Centralising it keeps the two editor-mode entry points in
|
|
16
|
+
# lockstep.
|
|
17
|
+
module Options
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Defines the `--tmp-file` / `--instead-of` editor-mode flag pair
|
|
21
|
+
# on `parser`, writing into `options`.
|
|
22
|
+
def add_editor_mode(parser, options)
|
|
23
|
+
parser.on("--tmp-file=PATH",
|
|
24
|
+
"Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
|
|
25
|
+
options[:tmp_file] = value
|
|
26
|
+
end
|
|
27
|
+
parser.on("--instead-of=PATH",
|
|
28
|
+
"Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
|
|
29
|
+
options[:instead_of] = value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resolves the editor-mode buffer binding from parsed `options`:
|
|
34
|
+
# returns nil when neither editor-mode flag is set, a
|
|
35
|
+
# `Analysis::BufferBinding` when the pair is valid, or
|
|
36
|
+
# `:usage_error` (after writing the reason to `err`) when the flags
|
|
37
|
+
# are unpaired or the temp file is unreadable.
|
|
38
|
+
def resolve_buffer_binding(options, err:)
|
|
39
|
+
tmp = options[:tmp_file]
|
|
40
|
+
instead = options[:instead_of]
|
|
41
|
+
return nil if tmp.nil? && instead.nil?
|
|
42
|
+
|
|
43
|
+
if tmp.nil? || instead.nil?
|
|
44
|
+
err.puts("--tmp-file and --instead-of must appear together")
|
|
45
|
+
return :usage_error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
unless File.file?(tmp)
|
|
49
|
+
err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
|
|
50
|
+
return :usage_error
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
class CLI
|
|
5
7
|
# `rigor plugin` (singular) — discover and read the plugin source
|
|
@@ -44,7 +46,7 @@ module Rigor
|
|
|
44
46
|
# will not resolve — read them from the same environment that ran
|
|
45
47
|
# the command (`rigor plugin print` inlines the body for exactly
|
|
46
48
|
# this case: it works with no file-reading tool at all).
|
|
47
|
-
class PluginCommand
|
|
49
|
+
class PluginCommand < Command
|
|
48
50
|
USAGE = <<~USAGE
|
|
49
51
|
Usage: rigor plugin <subcommand> [args]
|
|
50
52
|
|
|
@@ -72,12 +74,6 @@ module Rigor
|
|
|
72
74
|
PLUGINS_ROOT = File.join(GEM_ROOT, "plugins")
|
|
73
75
|
EXAMPLES_ROOT = File.join(GEM_ROOT, "examples")
|
|
74
76
|
|
|
75
|
-
def initialize(argv:, out: $stdout, err: $stderr)
|
|
76
|
-
@argv = argv
|
|
77
|
-
@out = out
|
|
78
|
-
@err = err
|
|
79
|
-
end
|
|
80
|
-
|
|
81
77
|
# @return [Integer] CLI exit status.
|
|
82
78
|
def run
|
|
83
79
|
subcommand = @argv.shift || "list"
|
|
@@ -9,6 +9,7 @@ require_relative "../plugin/services"
|
|
|
9
9
|
require_relative "../reflection"
|
|
10
10
|
require_relative "../type/combinator"
|
|
11
11
|
require_relative "plugins_renderer"
|
|
12
|
+
require_relative "command"
|
|
12
13
|
|
|
13
14
|
module Rigor
|
|
14
15
|
class CLI
|
|
@@ -60,15 +61,9 @@ module Rigor
|
|
|
60
61
|
# the RBS environment without conflict (requires constructing
|
|
61
62
|
# the Environment, which is heavier than the loader-only
|
|
62
63
|
# pass this slice does).
|
|
63
|
-
class PluginsCommand # rubocop:disable Metrics/ClassLength
|
|
64
|
+
class PluginsCommand < Command # rubocop:disable Metrics/ClassLength
|
|
64
65
|
USAGE = "Usage: rigor plugins [options]"
|
|
65
66
|
|
|
66
|
-
def initialize(argv:, out: $stdout, err: $stderr)
|
|
67
|
-
@argv = argv
|
|
68
|
-
@out = out
|
|
69
|
-
@err = err
|
|
70
|
-
end
|
|
71
|
-
|
|
72
67
|
# @return [Integer] CLI exit status.
|
|
73
68
|
def run
|
|
74
69
|
options = parse_options
|
|
@@ -41,12 +41,19 @@ module Rigor
|
|
|
41
41
|
# @return [String] the source with ANSI colour escapes, or
|
|
42
42
|
# the input unchanged when lexing surfaces an error.
|
|
43
43
|
def colorize(source)
|
|
44
|
+
# Sources read under a POSIX locale arrive tagged US-ASCII even
|
|
45
|
+
# when they carry UTF-8 bytes; retag so the token regexes below
|
|
46
|
+
# do not raise on multibyte comments.
|
|
47
|
+
source = source.dup.force_encoding(Encoding::UTF_8) unless source.encoding == Encoding::UTF_8
|
|
44
48
|
result = Prism.lex(source)
|
|
45
49
|
return source unless result.errors.empty?
|
|
46
50
|
|
|
47
51
|
render(source, result.value)
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# Prism token offsets are BYTE offsets — slice with byteslice, or
|
|
55
|
+
# any multibyte character earlier in the source shifts every
|
|
56
|
+
# subsequent token boundary.
|
|
50
57
|
def render(source, lexed)
|
|
51
58
|
out = +""
|
|
52
59
|
offset = 0
|
|
@@ -54,15 +61,15 @@ module Rigor
|
|
|
54
61
|
lexed.each do |entry|
|
|
55
62
|
token = entry.first
|
|
56
63
|
location = token.location
|
|
57
|
-
out << source[
|
|
64
|
+
out << (source.byteslice(offset, [location.start_offset - offset, 0].max) || "")
|
|
58
65
|
break if token.type == :EOF
|
|
59
66
|
|
|
60
|
-
text = source
|
|
67
|
+
text = source.byteslice(location.start_offset, location.end_offset - location.start_offset) || ""
|
|
61
68
|
out << paint(text, effective_category(token.type, previous_type))
|
|
62
69
|
offset = location.end_offset
|
|
63
70
|
previous_type = token.type
|
|
64
71
|
end
|
|
65
|
-
out << (source
|
|
72
|
+
out << (source.byteslice(offset, source.bytesize - offset) || "")
|
|
66
73
|
out
|
|
67
74
|
end
|
|
68
75
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Output-format dispatch shared by the `--format text|json` renderers.
|
|
8
|
+
#
|
|
9
|
+
# Each renderer included this and then implemented `render_text` /
|
|
10
|
+
# `render_json`; the `render(data, format:)` entry point — route by
|
|
11
|
+
# the format string, raise one consistent `OptionParser::InvalidArgument`
|
|
12
|
+
# on anything else — was copied verbatim into every one. Centralising
|
|
13
|
+
# it keeps the unsupported-format wording and the text/json contract
|
|
14
|
+
# in a single place as new renderers and formats are added.
|
|
15
|
+
module Renderable
|
|
16
|
+
def render(data, format:)
|
|
17
|
+
case format
|
|
18
|
+
when "text" then render_text(data)
|
|
19
|
+
when "json" then render_json(data)
|
|
20
|
+
else
|
|
21
|
+
raise OptionParser::InvalidArgument, "unsupported format: #{format}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -4,6 +4,7 @@ require "optionparser"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../configuration"
|
|
6
6
|
require_relative "../sig_gen"
|
|
7
|
+
require_relative "command"
|
|
7
8
|
|
|
8
9
|
module Rigor
|
|
9
10
|
class CLI
|
|
@@ -34,19 +35,13 @@ module Rigor
|
|
|
34
35
|
# `--params=observed-strict` stays reserved-but-inert until
|
|
35
36
|
# the capability-role catalog ships (rejected with a usage
|
|
36
37
|
# error so the surface stays stable).
|
|
37
|
-
class SigGenCommand
|
|
38
|
+
class SigGenCommand < Command
|
|
38
39
|
USAGE = "Usage: rigor sig-gen [options] [paths]"
|
|
39
40
|
|
|
40
41
|
VALID_MODES = %w[print diff write].freeze
|
|
41
42
|
VALID_PARAM_POLICIES = %w[untyped observed observed-strict].freeze
|
|
42
43
|
VALID_FORMATS = %w[text json].freeze
|
|
43
44
|
|
|
44
|
-
def initialize(argv:, out:, err:)
|
|
45
|
-
@argv = argv
|
|
46
|
-
@out = out
|
|
47
|
-
@err = err
|
|
48
|
-
end
|
|
49
|
-
|
|
50
45
|
# @return [Integer] CLI exit status.
|
|
51
46
|
def run
|
|
52
47
|
options = parse_options
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
3
5
|
require "optparse"
|
|
4
6
|
|
|
5
7
|
module Rigor
|
|
@@ -29,7 +31,7 @@ module Rigor
|
|
|
29
31
|
# as input to a Read tool.
|
|
30
32
|
#
|
|
31
33
|
# `rigor skill` with no subcommand is an alias for `list`.
|
|
32
|
-
class SkillCommand
|
|
34
|
+
class SkillCommand < Command
|
|
33
35
|
USAGE = <<~USAGE
|
|
34
36
|
Usage: rigor skill <subcommand> [args]
|
|
35
37
|
|
|
@@ -48,12 +50,6 @@ module Rigor
|
|
|
48
50
|
# `lib/rigor/cli/skill_command.rb` that is three directories up.
|
|
49
51
|
SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
|
|
50
52
|
|
|
51
|
-
def initialize(argv:, out: $stdout, err: $stderr)
|
|
52
|
-
@argv = argv
|
|
53
|
-
@out = out
|
|
54
|
-
@err = err
|
|
55
|
-
end
|
|
56
|
-
|
|
57
53
|
# @return [Integer] CLI exit status.
|
|
58
54
|
def run
|
|
59
55
|
subcommand = @argv.shift || "list"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optionparser"
|
|
5
|
+
require "prism"
|
|
6
|
+
|
|
7
|
+
require_relative "../configuration"
|
|
8
|
+
require_relative "../environment"
|
|
9
|
+
require_relative "../scope"
|
|
10
|
+
require_relative "../inference/flow_tracer"
|
|
11
|
+
require_relative "../inference/scope_indexer"
|
|
12
|
+
require_relative "command"
|
|
13
|
+
require_relative "trace_renderer"
|
|
14
|
+
|
|
15
|
+
module Rigor
|
|
16
|
+
class CLI
|
|
17
|
+
# Executes the `rigor trace` command: re-runs the inference engine
|
|
18
|
+
# over one file under {Rigor::Inference::FlowTracer} and replays the
|
|
19
|
+
# recorded event stream — scope binds, union formation, method
|
|
20
|
+
# dispatch, and (with `--verbose`) every expression enter/result —
|
|
21
|
+
# as a step-through terminal animation. A teaching probe: it shows
|
|
22
|
+
# HOW Rigor arrives at a type, where `rigor type-of` shows only the
|
|
23
|
+
# answer.
|
|
24
|
+
#
|
|
25
|
+
# The tracer is observational; this command never changes what
|
|
26
|
+
# `rigor check` would infer for the same file.
|
|
27
|
+
class TraceCommand < Command
|
|
28
|
+
USAGE = "Usage: rigor trace [options] FILE"
|
|
29
|
+
|
|
30
|
+
# Default frame kinds: the three teachable moments. :enter/:result
|
|
31
|
+
# add one frame per literal and drown the signal; `--verbose`
|
|
32
|
+
# opts into them.
|
|
33
|
+
DEFAULT_KINDS = %i[bind union dispatch].freeze
|
|
34
|
+
VERBOSE_KINDS = (%i[enter result] + DEFAULT_KINDS).freeze
|
|
35
|
+
|
|
36
|
+
# @return [Integer] CLI exit status.
|
|
37
|
+
def run
|
|
38
|
+
options = parse_options
|
|
39
|
+
file = @argv.first
|
|
40
|
+
if file.nil? || @argv.size != 1
|
|
41
|
+
@err.puts(USAGE)
|
|
42
|
+
return CLI::EXIT_USAGE
|
|
43
|
+
end
|
|
44
|
+
return 1 unless file_exists?(file)
|
|
45
|
+
|
|
46
|
+
execute(file: file, options: options)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_options
|
|
52
|
+
options = { format: "text", delay: nil, verbose: false, line: nil, config: nil }
|
|
53
|
+
parser = OptionParser.new do |opts|
|
|
54
|
+
opts.banner = USAGE
|
|
55
|
+
opts.on("--format=FORMAT", "Output format: text (animation) or json (raw event stream)") do |value|
|
|
56
|
+
options[:format] = value
|
|
57
|
+
end
|
|
58
|
+
opts.on("--delay=SECONDS", Float,
|
|
59
|
+
"Autoplay with SECONDS between frames (default: step on key press)") do |value|
|
|
60
|
+
options[:delay] = value
|
|
61
|
+
end
|
|
62
|
+
opts.on("--line=N", Integer, "Only replay events whose source range starts on line N") do |value|
|
|
63
|
+
options[:line] = value
|
|
64
|
+
end
|
|
65
|
+
opts.on("--verbose", "Include every expression enter/result frame") { options[:verbose] = true }
|
|
66
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
67
|
+
end
|
|
68
|
+
parser.parse!(@argv)
|
|
69
|
+
options
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def execute(file:, options:)
|
|
73
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
74
|
+
source = File.read(file, encoding: Encoding::UTF_8)
|
|
75
|
+
parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
|
|
76
|
+
return 1 if parse_errors?(parse_result, file)
|
|
77
|
+
|
|
78
|
+
events = record_events(parse_result.value, file, configuration)
|
|
79
|
+
frames = filter(events, options)
|
|
80
|
+
|
|
81
|
+
if options.fetch(:format) == "json"
|
|
82
|
+
@out.puts(JSON.pretty_generate(frames.map { |event| event_to_h(event) }))
|
|
83
|
+
return 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if frames.empty?
|
|
87
|
+
@out.puts("trace: no events to replay (try --verbose)")
|
|
88
|
+
return 0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
interactive = @out.respond_to?(:tty?) && @out.tty?
|
|
92
|
+
TraceRenderer.new(out: @out, source: source, file: file)
|
|
93
|
+
.play(frames, delay: options.fetch(:delay), interactive: interactive)
|
|
94
|
+
0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Mirrors the single-file path `rigor type-of` takes: a
|
|
98
|
+
# project-aware environment, an empty seed scope, one
|
|
99
|
+
# statement-level evaluation of the whole program — but recorded
|
|
100
|
+
# under the FlowTracer.
|
|
101
|
+
def record_events(root, file, configuration)
|
|
102
|
+
environment = Environment.for_project(
|
|
103
|
+
libraries: configuration.libraries,
|
|
104
|
+
signature_paths: configuration.signature_paths
|
|
105
|
+
)
|
|
106
|
+
scope = Scope.empty(environment: environment, source_path: file)
|
|
107
|
+
Inference::FlowTracer.record { scope.evaluate(root) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def filter(events, options)
|
|
111
|
+
kinds = options.fetch(:verbose) ? VERBOSE_KINDS : DEFAULT_KINDS
|
|
112
|
+
frames = events.select { |event| kinds.include?(event.kind) }
|
|
113
|
+
line = options.fetch(:line)
|
|
114
|
+
frames = frames.select { |event| event.location && event.location[:start_line] == line } if line
|
|
115
|
+
frames
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def event_to_h(event)
|
|
119
|
+
{
|
|
120
|
+
kind: event.kind,
|
|
121
|
+
depth: event.depth,
|
|
122
|
+
location: event.location,
|
|
123
|
+
stack: event.stack,
|
|
124
|
+
data: event.data
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def file_exists?(file)
|
|
129
|
+
return true if File.file?(file)
|
|
130
|
+
|
|
131
|
+
@err.puts("trace: file not found: #{file}")
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def parse_errors?(result, file)
|
|
136
|
+
return false if result.errors.empty?
|
|
137
|
+
|
|
138
|
+
result.errors.each { |error| @err.puts("#{file}:#{error.location.start_line}: #{error.message}") }
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "prism_colorizer"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Replays a `Rigor::Inference::FlowTracer` event stream as a terminal
|
|
8
|
+
# animation for `rigor trace`. Each frame draws a box-drawn screen:
|
|
9
|
+
# the source panel (syntax-coloured, current node range underlined)
|
|
10
|
+
# side-by-side with the scope panel (locals accumulated from :bind
|
|
11
|
+
# events), and an event panel describing the current step and the
|
|
12
|
+
# expression stack.
|
|
13
|
+
#
|
|
14
|
+
# The frame is fitted to the measured terminal: the source panel
|
|
15
|
+
# scrolls vertically (a window centred on the line under evaluation)
|
|
16
|
+
# instead of overflowing the screen height, over-long rows are
|
|
17
|
+
# clipped with an ellipsis instead of wrapping, and the event panel
|
|
18
|
+
# keeps a minimum height of {EVENT_PANE_MIN} rows so the narration
|
|
19
|
+
# never collapses to a sliver.
|
|
20
|
+
#
|
|
21
|
+
# Pure ANSI + `io/console` (both stdlib) per ADR-0's zero-runtime-
|
|
22
|
+
# dependency policy. When the output is not a TTY the frames are
|
|
23
|
+
# printed sequentially without cursor control against a default
|
|
24
|
+
# 80×24 layout, which keeps the renderer deterministic under test.
|
|
25
|
+
class TraceRenderer
|
|
26
|
+
RESET = "\e[0m"
|
|
27
|
+
DIM = "\e[90m"
|
|
28
|
+
BOLD = "\e[1m"
|
|
29
|
+
HIGHLIGHT = "\e[7m" # reverse video for the frame's source range
|
|
30
|
+
|
|
31
|
+
EVENT_PANE_MIN = 2 # minimum event-panel rows
|
|
32
|
+
SCOPE_WIDTH_MIN = 19 # minimum scope-column width
|
|
33
|
+
BODY_HEIGHT_MIN = 3 # never shrink the source window below this
|
|
34
|
+
DEFAULT_SIZE = [24, 80].freeze
|
|
35
|
+
|
|
36
|
+
# @param out [IO]
|
|
37
|
+
# @param source [String] the traced file's source.
|
|
38
|
+
# @param file [String] display path.
|
|
39
|
+
def initialize(out:, source:, file:)
|
|
40
|
+
@out = out
|
|
41
|
+
@source = source
|
|
42
|
+
@file = file
|
|
43
|
+
@lines = source.lines.map(&:chomp)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param events [Array<Rigor::Inference::FlowTracer::Event>] the
|
|
47
|
+
# pre-filtered frame list (the command owns kind filtering).
|
|
48
|
+
# @param delay [Float, nil] seconds between frames (autoplay);
|
|
49
|
+
# nil = step on key press when interactive.
|
|
50
|
+
# @param interactive [Boolean] whether to clear/redraw and wait.
|
|
51
|
+
def play(events, delay: nil, interactive: false)
|
|
52
|
+
@rows, @cols = interactive ? terminal_size : DEFAULT_SIZE
|
|
53
|
+
@interactive = interactive
|
|
54
|
+
locals_per_frame = accumulate_locals(events)
|
|
55
|
+
events.each_with_index do |event, index|
|
|
56
|
+
@out.print("\e[H\e[2J") if interactive
|
|
57
|
+
render_frame(event, index: index, total: events.size, locals: locals_per_frame[index])
|
|
58
|
+
@out.puts unless interactive
|
|
59
|
+
next if index == events.size - 1
|
|
60
|
+
|
|
61
|
+
if delay
|
|
62
|
+
sleep(delay)
|
|
63
|
+
elsif interactive
|
|
64
|
+
break unless next_frame?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# `IO.console` reflects the controlling terminal even when stdout
|
|
72
|
+
# is, say, a StringIO under test — which is why the measured size
|
|
73
|
+
# is only used for interactive replays.
|
|
74
|
+
def terminal_size
|
|
75
|
+
require "io/console"
|
|
76
|
+
size = IO.console&.winsize
|
|
77
|
+
return size if size && size[0].positive? && size[1].positive?
|
|
78
|
+
|
|
79
|
+
DEFAULT_SIZE
|
|
80
|
+
rescue LoadError, SystemCallError
|
|
81
|
+
DEFAULT_SIZE
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Replays :bind events into a per-frame snapshot of the locals
|
|
85
|
+
# panel, so frame N shows exactly the bindings visible after the
|
|
86
|
+
# first N events.
|
|
87
|
+
def accumulate_locals(events)
|
|
88
|
+
locals = {}
|
|
89
|
+
events.map do |event|
|
|
90
|
+
locals[event.data[:name]] = event.data[:type] if event.kind == :bind
|
|
91
|
+
locals.dup
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_frame(event, index:, total:, locals:)
|
|
96
|
+
inner = @cols - 3 # three vertical borders
|
|
97
|
+
lines = event_pane_lines(event, inner - 1)
|
|
98
|
+
body_height = body_height_for(lines.size)
|
|
99
|
+
|
|
100
|
+
source_rows = source_panel_rows(event)
|
|
101
|
+
left_width, right_width = column_widths(source_rows, inner)
|
|
102
|
+
source_rows = clip_rows(scroll_window(source_rows, body_height), left_width)
|
|
103
|
+
scope_rows = fit_rows(scope_panel_rows(locals), body_height, right_width)
|
|
104
|
+
|
|
105
|
+
@out.puts(top_border(left_width, right_width))
|
|
106
|
+
body_rows(source_rows, scope_rows, left_width, right_width)
|
|
107
|
+
@out.puts(divider(left_width, right_width, label: " step #{index + 1}/#{total} · #{event.kind} "))
|
|
108
|
+
lines.each { |line| @out.puts(boxed_line(line, left_width + right_width + 1)) }
|
|
109
|
+
@out.puts(bottom_border(left_width + right_width + 1))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Screen budget: borders (3 rows) + event pane + the key-hint row
|
|
113
|
+
# when stepping interactively; whatever remains belongs to the
|
|
114
|
+
# source/scope body, floored at {BODY_HEIGHT_MIN}.
|
|
115
|
+
def body_height_for(event_rows)
|
|
116
|
+
chrome = 3 + event_rows + (@interactive ? 1 : 0)
|
|
117
|
+
[@rows - chrome, BODY_HEIGHT_MIN].max
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The event pane keeps at least {EVENT_PANE_MIN} rows (padding with
|
|
121
|
+
# blanks) so the bottom of the frame is visually stable across
|
|
122
|
+
# event kinds; over-long lines are clipped to the frame width.
|
|
123
|
+
def event_pane_lines(event, max_width)
|
|
124
|
+
lines = [describe_event(event), stack_line(event)].compact
|
|
125
|
+
lines << "" while lines.size < EVENT_PANE_MIN
|
|
126
|
+
lines.map { |line| clip(line, max_width) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Source rows win the width contest; the scope column keeps its
|
|
130
|
+
# minimum and absorbs whatever the source does not need.
|
|
131
|
+
def column_widths(source_rows, inner)
|
|
132
|
+
left_need = (source_rows.map { |raw, _| raw.length }.max || 0) + 1
|
|
133
|
+
left_width = left_need.clamp(24, [inner - SCOPE_WIDTH_MIN, 24].max)
|
|
134
|
+
[left_width, [inner - left_width, SCOPE_WIDTH_MIN].max]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Vertical scroll: when the panel is taller than the window, keep
|
|
138
|
+
# a slice centred on the row under evaluation (the `▶` row, which
|
|
139
|
+
# the marker row directly follows).
|
|
140
|
+
def scroll_window(rows, height)
|
|
141
|
+
return rows if rows.size <= height
|
|
142
|
+
|
|
143
|
+
focus = rows.index { |raw, _| raw.start_with?("▶") } || 0
|
|
144
|
+
start = (focus - (height / 2)).clamp(0, rows.size - height)
|
|
145
|
+
rows[start, height]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def clip_rows(rows, width)
|
|
149
|
+
rows.map do |raw, painted|
|
|
150
|
+
next [raw, painted] if raw.length <= width
|
|
151
|
+
|
|
152
|
+
# Re-paint from the clipped raw text — clipping the painted
|
|
153
|
+
# string directly would cut ANSI escapes in half.
|
|
154
|
+
clipped = clip(raw, width)
|
|
155
|
+
[clipped, repaint_clipped(clipped)]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A clipped row loses its highlight/marker alignment guarantees, so
|
|
160
|
+
# it is repainted with plain syntax colouring (gutter dimmed).
|
|
161
|
+
def repaint_clipped(clipped)
|
|
162
|
+
gutter = clipped[0, 6]
|
|
163
|
+
rest = clipped[6..] || ""
|
|
164
|
+
DIM + gutter + RESET + PrismColorizer.colorize(rest).chomp
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def fit_rows(rows, height, width)
|
|
168
|
+
rows = rows[0, height - 1] + [" … (+#{rows.size - height + 1} more)"] if rows.size > height
|
|
169
|
+
rows.map { |row| clip(row, width) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def clip(text, width)
|
|
173
|
+
return text if text.length <= width
|
|
174
|
+
|
|
175
|
+
"#{text[0, [width - 1, 0].max]}…"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Each row is `[raw_text, painted_text]` so width math runs on the
|
|
179
|
+
# escape-free string. The row under the event's source range gets a
|
|
180
|
+
# `▔▔▔` marker row injected after it.
|
|
181
|
+
def source_panel_rows(event)
|
|
182
|
+
rows = []
|
|
183
|
+
location = event.location
|
|
184
|
+
@lines.each_with_index do |line, i|
|
|
185
|
+
number = i + 1
|
|
186
|
+
current = location && number == location[:start_line]
|
|
187
|
+
gutter = "#{current ? '▶' : ' '}#{number.to_s.rjust(3)} "
|
|
188
|
+
raw = gutter + line
|
|
189
|
+
painted = (current ? BOLD + gutter + RESET : DIM + gutter + RESET) + paint_line(line, location, number)
|
|
190
|
+
rows << [raw, painted]
|
|
191
|
+
rows << marker_row(gutter.length, line, location) if current
|
|
192
|
+
end
|
|
193
|
+
rows
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def marker_row(gutter_width, line, location)
|
|
197
|
+
before, slice, = split_at_range(line, location)
|
|
198
|
+
indent = " " * (gutter_width + before.length)
|
|
199
|
+
width = [slice.length, 1].max
|
|
200
|
+
[indent + ("▔" * width), indent + BOLD + ("▔" * width) + RESET]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Highlights the in-range slice with reverse video; everything else
|
|
204
|
+
# gets Prism syntax colouring. The highlight is applied on the raw
|
|
205
|
+
# slice (not the colorized string) so byte offsets stay honest.
|
|
206
|
+
def paint_line(line, location, number)
|
|
207
|
+
return PrismColorizer.colorize(line) unless location && number == location[:start_line]
|
|
208
|
+
|
|
209
|
+
before, slice, after = split_at_range(line, location)
|
|
210
|
+
return PrismColorizer.colorize(line) if slice.empty?
|
|
211
|
+
|
|
212
|
+
PrismColorizer.colorize(before).chomp +
|
|
213
|
+
HIGHLIGHT + slice + RESET +
|
|
214
|
+
PrismColorizer.colorize(after).chomp
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Prism columns are BYTE columns — split the line with byteslice
|
|
218
|
+
# so a multibyte character earlier on the line cannot shift (or
|
|
219
|
+
# overrun) the highlight range. Returns `[before, slice, after]`.
|
|
220
|
+
def split_at_range(line, location)
|
|
221
|
+
from = [location[:start_column], line.bytesize].min
|
|
222
|
+
to = location[:end_line] == location[:start_line] ? [location[:end_column], line.bytesize].min : line.bytesize
|
|
223
|
+
to = from if to < from
|
|
224
|
+
[line.byteslice(0, from), line.byteslice(from, to - from), line.byteslice(to, line.bytesize - to) || ""]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def scope_panel_rows(locals)
|
|
228
|
+
return [" (no locals yet)"] if locals.empty?
|
|
229
|
+
|
|
230
|
+
width = locals.keys.map(&:length).max
|
|
231
|
+
locals.map { |name, type| format(" %-#{width}s : %s", name, type) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def describe_event(event)
|
|
235
|
+
data = event.data
|
|
236
|
+
case event.kind
|
|
237
|
+
when :bind then "bind #{data[:name]} ← #{data[:type]}"
|
|
238
|
+
when :union then "union #{data[:members].join(' | ')} → #{data[:type]}"
|
|
239
|
+
when :dispatch then describe_dispatch(data)
|
|
240
|
+
when :enter then "eval #{data[:node]}"
|
|
241
|
+
when :result then "result #{data[:node]} → #{data[:type]}"
|
|
242
|
+
else "#{event.kind} #{data.inspect}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def describe_dispatch(data)
|
|
247
|
+
call = "#{data[:receiver]} ##{data[:method]}(#{data[:args].join(', ')})"
|
|
248
|
+
return "dispatch #{call} → #{data[:type]}" if data[:resolved]
|
|
249
|
+
|
|
250
|
+
"dispatch #{call} → no rule matched (fail-soft → Dynamic[top])"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def stack_line(event)
|
|
254
|
+
return nil if event.stack.empty?
|
|
255
|
+
|
|
256
|
+
"stack #{event.stack.join(' › ')}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# -- box drawing ---------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def top_border(left, right)
|
|
262
|
+
"┌#{pad_label("─ #{@file} ", left)}┬#{pad_label('─ scope ', right)}┐"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Pads `label` with `─` to exactly `width` cells (truncating an
|
|
266
|
+
# over-long label so the frame never breaks).
|
|
267
|
+
def pad_label(label, width)
|
|
268
|
+
label = "#{label[0, width - 2]} " if label.length > width
|
|
269
|
+
label + ("─" * [width - label.length, 0].max)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def body_rows(source_rows, scope_rows, left, right)
|
|
273
|
+
[source_rows.size, scope_rows.size].max.times do |i|
|
|
274
|
+
raw, painted = source_rows[i] || ["", ""]
|
|
275
|
+
scope = scope_rows[i] || ""
|
|
276
|
+
@out.puts("│#{painted}#{' ' * [left - raw.length, 0].max}│#{scope}#{' ' * [right - scope.length, 0].max}│")
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def divider(left, right, label:)
|
|
281
|
+
"├#{pad_label(label, left)}┴#{'─' * right}┤"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def boxed_line(text, width)
|
|
285
|
+
"│ #{text}#{' ' * [width - text.length - 1, 0].max}│"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def bottom_border(width)
|
|
289
|
+
"└#{'─' * width}┘"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Single-keystroke stepping via stdlib io/console; falls back to
|
|
293
|
+
# line-buffered Enter when raw mode is unavailable (e.g. pipes that
|
|
294
|
+
# still claim to be TTYs). Returns false when the user quits.
|
|
295
|
+
def next_frame?
|
|
296
|
+
@out.print("#{DIM} [any key: next · q: quit]#{RESET}")
|
|
297
|
+
key = read_key
|
|
298
|
+
@out.print("\r\e[K")
|
|
299
|
+
key != "q"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def read_key
|
|
303
|
+
require "io/console"
|
|
304
|
+
$stdin.getch
|
|
305
|
+
rescue LoadError, Errno::ENOTTY, Errno::ENODEV
|
|
306
|
+
$stdin.gets&.strip
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|