Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/typeprof/cli/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def initialize(argv)
opt.on("--[no-]show-errors", "Display possible errors found during the analysis") {|v| core_options[:output_diagnostics] = v }
opt.on("--[no-]show-parameter-names", "Display parameter names for methods") {|v| core_options[:output_parameter_names] = v }
opt.on("--[no-]show-source-locations", "Display definition source locations for methods") {|v| core_options[:output_source_locations] = v }
opt.on("--[no-]show-stats", "Display type inference statistics after analysis (for debugging purpose)") {|v| core_options[:output_stats] = v }

opt.separator ""
opt.separator "Advanced options:"
Expand Down Expand Up @@ -67,6 +68,7 @@ def initialize(argv)
output_errors: false,
output_parameter_names: false,
output_source_locations: false,
output_stats: false,
exclude_patterns: exclude_patterns,
}.merge(core_options)

Expand Down
2 changes: 1 addition & 1 deletion lib/typeprof/core/graph/box.rb
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ def initialize(node, genv, cpath, singleton, mid, f_args, ret_boxes)

attr_accessor :node

attr_reader :cpath, :singleton, :mid, :f_args, :ret
attr_reader :cpath, :singleton, :mid, :f_args, :ret, :record_block

def destroy(genv)
me = genv.resolve_method(@cpath, @singleton, @mid)
Expand Down
224 changes: 224 additions & 0 deletions lib/typeprof/core/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,230 @@ def batch(files, output)
end
output.puts dump_declarations(file)
end

if @options[:output_stats]
rb_files = show_files.reject {|f| File.extname(f) == ".rbs" }
stats = collect_stats(rb_files)
output.puts
output.puts format_stats(stats)
end
end

def collect_stats(files)
file_stats = []

files.each do |path|
methods = []
constants = []
seen_ivars = Set[]
ivars = []
seen_cvars = Set[]
cvars = []
seen_gvars = Set[]
gvars = []

@rb_text_nodes[path]&.traverse do |event, node|
next unless event == :enter

node.boxes(:mdef) do |mdef|
param_slots = []
f = mdef.f_args
[f.req_positionals, f.opt_positionals, f.post_positionals, f.req_keywords, f.opt_keywords].each do |ary|
ary.each {|vtx| param_slots << classify_vertex(vtx) }
end
[f.rest_positionals, f.rest_keywords].each do |vtx|
param_slots << classify_vertex(vtx) if vtx
end

is_initialize = mdef.mid == :initialize
ret_slots = is_initialize ? [] : [classify_vertex(mdef.ret)]

blk = mdef.record_block
block_param_slots = []
block_ret_slots = []
if blk.used
blk.f_args.each {|vtx| block_param_slots << classify_vertex(vtx) }
block_ret_slots << classify_vertex(blk.ret)
end

methods << {
mid: mdef.mid,
singleton: mdef.singleton,
param_slots: param_slots,
ret_slots: ret_slots,
block_param_slots: block_param_slots,
block_ret_slots: block_ret_slots,
}
end

if node.is_a?(AST::ConstantWriteNode) && node.static_cpath
constants << classify_vertex(node.ret)
end

if node.is_a?(AST::InstanceVariableWriteNode)
scope = node.lenv.cref.scope_level
if scope == :class || scope == :instance
key = [node.lenv.cref.cpath, scope == :class, node.var]
unless seen_ivars.include?(key)
seen_ivars << key
ve = @genv.resolve_ivar(key[0], key[1], key[2])
ivars << classify_vertex(ve.vtx)
end
end
end

if node.is_a?(AST::ClassVariableWriteNode)
key = [node.lenv.cref.cpath, node.var]
unless seen_cvars.include?(key)
seen_cvars << key
ve = @genv.resolve_cvar(key[0], key[1])
cvars << classify_vertex(ve.vtx)
end
end

if node.is_a?(AST::GlobalVariableWriteNode)
unless seen_gvars.include?(node.var)
seen_gvars << node.var
ve = @genv.resolve_gvar(node.var)
gvars << classify_vertex(ve.vtx)
end
end
end

file_stats << {
path: path,
methods: methods,
constants: constants,
ivars: ivars,
cvars: cvars,
gvars: gvars,
}
end

file_stats
end

def classify_vertex(vtx)
vtx.types.empty? ? :untyped : :typed
end

def format_stats(stats)
total_methods = 0
fully_typed = 0
partially_typed = 0
fully_untyped = 0

slot_categories = %i[param ret blk_param blk_ret const ivar cvar gvar]
typed = Hash.new(0)
untyped = Hash.new(0)

file_summaries = []

stats.each do |file|
f_typed = 0
f_total = 0

file[:methods].each do |m|
total_methods += 1

method_slot_keys = %i[param_slots ret_slots block_param_slots block_ret_slots]
category_keys = %i[param ret blk_param blk_ret]

all_slots = method_slot_keys.flat_map {|k| m[k] }

method_slot_keys.zip(category_keys) do |slot_key, cat|
m[slot_key].each do |s|
if s == :typed
typed[cat] += 1
else
untyped[cat] += 1
end
end
end

if all_slots.empty? || all_slots.all? {|s| s == :typed }
fully_typed += 1
elsif all_slots.none? {|s| s == :typed }
fully_untyped += 1
else
partially_typed += 1
end

f_typed += all_slots.count(:typed)
f_total += all_slots.size
end

%i[constants ivars cvars gvars].zip(%i[const ivar cvar gvar]) do |data_key, cat|
file[data_key].each do |s|
f_total += 1
if s == :typed
typed[cat] += 1
f_typed += 1
else
untyped[cat] += 1
end
end
end

if f_total > 0
file_summaries << {
path: file[:path],
methods: file[:methods].size,
typed: f_typed,
total: f_total,
}
end
end

overall_typed = slot_categories.sum {|c| typed[c] }
overall_untyped = slot_categories.sum {|c| untyped[c] }
overall_total = overall_typed + overall_untyped

labels = {
param: "Parameter slots",
ret: "Return slots",
blk_param: "Block parameter slots",
blk_ret: "Block return slots",
const: "Constants",
ivar: "Instance variables",
cvar: "Class variables",
gvar: "Global variables",
}

lines = []
lines << "# TypeProf Evaluation Statistics"
lines << "#"
lines << "# Total methods: #{ total_methods }"
lines << "# Fully typed: #{ fully_typed }"
lines << "# Partially typed: #{ partially_typed }"
lines << "# Fully untyped: #{ fully_untyped }"

slot_categories.each do |cat|
total = typed[cat] + untyped[cat]
lines << "#"
lines << "# #{ labels[cat] }: #{ total }"
lines << "# Typed: #{ typed[cat] } (#{ pct(typed[cat], total) })"
lines << "# Untyped: #{ untyped[cat] } (#{ pct(untyped[cat], total) })"
end

lines << "#"
lines << "# Overall: #{ overall_typed }/#{ overall_total } typed (#{ pct(overall_typed, overall_total) })"
lines << "# #{ overall_untyped }/#{ overall_total } untyped (#{ pct(overall_untyped, overall_total) })"

if file_summaries.size > 1
lines << "#"
lines << "# Per-file breakdown:"
file_summaries.each do |fs|
lines << "# #{ fs[:path] }: #{ fs[:methods] } methods, #{ fs[:typed] }/#{ fs[:total] } typed (#{ pct(fs[:typed], fs[:total]) })"
end
end

lines.join("\n")
end

def pct(n, total)
return "0.0%" if total == 0
"#{ (n * 100.0 / total).round(1) }%"
end

private
Expand Down
52 changes: 52 additions & 0 deletions test/cli_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,58 @@ def foo: (String) -> String
END
end

def test_e2e_show_stats
result = test_run("show_stats", ["--no-show-typeprof-version", "--show-stats", "."])
stats = result[/# TypeProf Evaluation Statistics.*/m]
assert(stats, "--show-stats should output statistics section")

# Method summary: 6 methods total
# initialize (no slots) → fully typed
# typed_method (param typed, ret typed) → fully typed
# with_typed_block (ret typed, block_param typed, block_ret typed) → fully typed
# untyped_params (2 params untyped, ret typed) → partially typed
# with_untyped_block (ret untyped, block_param untyped, block_ret untyped) → fully untyped
# uncalled_writer (param untyped, ret untyped) → fully untyped
assert_include(stats, "# Total methods: 6")
assert_include(stats, "# Fully typed: 3")
assert_include(stats, "# Partially typed: 1")
assert_include(stats, "# Fully untyped: 2")

# Parameter slots: typed_method(1 typed) + untyped_params(2 untyped) + uncalled_writer(1 untyped)
assert_include(stats, "# Parameter slots: 4\n# Typed: 1 (25.0%)\n# Untyped: 3 (75.0%)")

# Return slots: typed_method(typed) + untyped_params(typed nil) + with_typed_block(typed)
# + with_untyped_block(untyped) + uncalled_writer(untyped)
assert_include(stats, "# Return slots: 5\n# Typed: 3 (60.0%)\n# Untyped: 2 (40.0%)")

# Block parameter slots: with_typed_block(1 typed) + with_untyped_block(1 untyped)
assert_include(stats, "# Block parameter slots: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)")

# Block return slots: with_typed_block(1 typed) + with_untyped_block(1 untyped)
assert_include(stats, "# Block return slots: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)")

# Constants: TYPED_CONST(typed) + Foo::UNTYPED_CONST(untyped)
assert_include(stats, "# Constants: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)")

# Instance variables: @typed_ivar(typed) + @untyped_ivar(untyped)
assert_include(stats, "# Instance variables: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)")

# Class variables: @@typed_cvar(typed) + @@untyped_cvar(untyped)
assert_include(stats, "# Class variables: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)")

# Global variables: $typed_gvar(typed) + $untyped_gvar(untyped)
assert_include(stats, "# Global variables: 2\n# Typed: 1 (50.0%)\n# Untyped: 1 (50.0%)")

# Overall: 10 typed out of 21
assert_include(stats, "# Overall: 10/21 typed (47.6%)")
assert_include(stats, "# 11/21 untyped (52.4%)")
end

def test_e2e_no_show_stats
result = test_run("basic", ["--no-show-typeprof-version", "."])
assert_not_include(result, "TypeProf Evaluation Statistics")
end

def test_lsp_options_with_lsp_mode
assert_nothing_raised { TypeProf::CLI::CLI.new(["--lsp", "--stdio"]) }
end
Expand Down
48 changes: 48 additions & 0 deletions test/fixtures/show_stats/show_stats.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Typed constant
TYPED_CONST = 1

# Typed global variable
$typed_gvar = 99

class Foo
# Untyped constant: assigned from unwritten class-level ivar
UNTYPED_CONST = @unset

# Typed class variable
@@typed_cvar = "hello"

def initialize
# Typed instance variable
@typed_ivar = 42
end

# Fully typed method: param and return both typed
def typed_method(n)
n
end

# Partially typed method: params untyped (never called), return typed (nil)
def untyped_params(a, b)
end

# Method with typed block: yields a typed value
def with_typed_block
yield 1
end

# Method with untyped block: yields an untyped value (unwritten ivar)
def with_untyped_block
yield @nonexistent
end

# Never called: param 'a' has no type, so ivar/cvar/gvar assigned from it are untyped
def uncalled_writer(a)
@untyped_ivar = a
@@untyped_cvar = a
$untyped_gvar = a
end
end

Foo.new.typed_method("str")
Foo.new.with_typed_block {|x| x.to_s }
Foo.new.with_untyped_block {|x| x }
5 changes: 5 additions & 0 deletions test/fixtures/show_stats/typeprof.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"typeprof_version": "experimental",
"rbs_dir": "sig/",
"analysis_unit_dirs": ["."]
}