-
Notifications
You must be signed in to change notification settings - Fork 16
Implement initial complexity analysis pass and CLI command #961
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,8 @@ USAGE = <<~TEXT | |
| relationships, properties) without indexing the workspace. | ||
| console Open an interactive session with a populated graph for the current workspace | ||
| mcp [PATH] Run the MCP server for AI assistants (workspace defaults to the current dir) | ||
| help Show this help message | ||
| complexity [PATH ...] | ||
| Compute a complexity report (defaults to the current dir) | ||
|
|
||
| Run `rdx <command> --help` for command-specific options. | ||
| TEXT | ||
|
|
@@ -145,6 +146,54 @@ operation = | |
| require "rubydex/mcp_server" | ||
| Rubydex::MCPServer.run(path) | ||
| exit | ||
| when "complexity" | ||
| format = "text" | ||
| top = 25 | ||
| diff_file = nil | ||
| methods_only = false | ||
| details = false | ||
| group = false | ||
| OptionParser.new do |parser| | ||
| parser.banner = "Usage: rdx complexity [PATH ...] [options]" | ||
| parser.on("--format FORMAT", ["text", "json"], "Output format (text or json)") { |value| format = value } | ||
| parser.on("--top N", Integer, "Max entries in text output, 0 = all (default 25)") { |value| top = value } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we check if |
||
| parser.on("--diff FILE", "Diff against a baseline JSON report") { |value| diff_file = value } | ||
| parser.on("--methods-only", "Skip code outside methods") { methods_only = true } | ||
| parser.on("--details", "Show per-construct score breakdown under each method") { details = true } | ||
| parser.on("--group", "Group and sort by class with subtotals") { group = true } | ||
| parser.on("-h", "--help", "Show this help") do | ||
| puts parser | ||
| exit | ||
| end | ||
| end.parse! | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we |
||
|
|
||
| if diff_file && details | ||
| abort("`--details` does not apply to diff output; remove it or drop `--diff`") | ||
| end | ||
| if diff_file && group | ||
| abort("`--group` does not apply to diff output; remove it or drop `--diff`") | ||
| end | ||
| if group && format == "json" | ||
| abort("`--group` only affects text output; use `--format text` or drop `--group`") | ||
| end | ||
| paths = ARGV.empty? ? [Dir.pwd] : ARGV.dup | ||
| if diff_file | ||
| baseline = begin | ||
| File.read(diff_file) | ||
| rescue Errno::ENOENT | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could broaden to |
||
| abort("baseline report `#{diff_file}` does not exist") | ||
| end | ||
| # Details aren't part of the diff output; avoid the wasted collection pass. | ||
| current = Rubydex::Complexity.analyze(paths, format: :json, top: 0, methods_only: methods_only) | ||
| begin | ||
| print(Rubydex::Complexity.diff(baseline, current, format: format, top: top)) | ||
| rescue ArgumentError => e | ||
| abort(e.message) | ||
| end | ||
| else | ||
| print(Rubydex::Complexity.analyze(paths, format: format, top: top, methods_only: methods_only, details: details, group: group)) | ||
| end | ||
| exit | ||
| else | ||
| abort_with_usage("unknown command: #{command}") | ||
| end | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| #include "complexity.h" | ||
| #include "rustbindings.h" | ||
| #include "utils.h" | ||
|
|
||
| /* | ||
| * call-seq: | ||
| * Rubydex::Complexity.native_analyze(paths, format, top, methods_only, details, group) -> String | ||
| * | ||
| * Runs the complexity analysis pass over +paths+ (an array of path strings) and | ||
| * returns the formatted report. +format+ is +"text"+ or +"json"+ (String or Symbol); +top+ is the | ||
| * maximum number of entries in text output (0 prints all), ignored for JSON. +methods_only+ | ||
| * skips code outside methods; +details+ collects the per-construct breakdown; | ||
| * +group+ groups text output by class with subtotals (ignored for JSON). Raises | ||
| * ArgumentError on a fatal config or format error. | ||
| */ | ||
| static VALUE rdxr_complexity_analyze(VALUE self, VALUE paths, VALUE format, VALUE top, VALUE methods_only, VALUE details, VALUE group) { | ||
| rdxi_check_array_of_strings(paths); | ||
|
|
||
| long length = RARRAY_LEN(paths); | ||
| char **paths_array = rdxi_str_array_to_char(paths, (size_t)length); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is going to leak if |
||
|
|
||
| struct CComplexityResult result = rdx_complexity_analyze( | ||
| (const char *const *)paths_array, | ||
| (size_t)length, | ||
| rdxi_symbol_or_string_cstr(format, "text"), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we use a C enum instead so we don't have to keep a pointer to the Ruby memory during the whole analysis? |
||
| NUM2SIZET(top), | ||
| RTEST(methods_only), | ||
| RTEST(details), | ||
| RTEST(group) | ||
| ); | ||
|
|
||
| rdxi_free_str_array(paths_array, (size_t)length); | ||
|
|
||
| if (result.error != NULL) { | ||
| VALUE message = rb_utf8_str_new_cstr(result.error); | ||
| free_c_string(result.error); | ||
| rb_raise(rb_eArgError, "%s", StringValueCStr(message)); | ||
| } | ||
|
|
||
| if (result.warnings != NULL) { | ||
| VALUE stderr_io = rb_gv_get("$stderr"); | ||
| rb_io_write(stderr_io, rb_utf8_str_new_cstr(result.warnings)); | ||
| rb_io_write(stderr_io, rb_utf8_str_new_cstr("\n")); | ||
| free_c_string(result.warnings); | ||
| } | ||
|
|
||
| return rdxi_owned_c_string_to_ruby(result.output); | ||
| } | ||
|
|
||
| /* | ||
| * call-seq: | ||
| * Rubydex::Complexity.native_diff(baseline_json, current_json, format, top) -> String | ||
| * | ||
| * Diffs two complexity reports (JSON strings) and returns the formatted diff. +format+ is +"text"+ | ||
| * or +"json"+; +top+ caps each text section (0 prints all), ignored for JSON. Raises ArgumentError | ||
| * if either JSON string is malformed or the format is unknown. | ||
| */ | ||
| static VALUE rdxr_complexity_diff(VALUE self, VALUE baseline_json, VALUE current_json, VALUE format, VALUE top) { | ||
| Check_Type(baseline_json, T_STRING); | ||
| Check_Type(current_json, T_STRING); | ||
|
|
||
| struct CComplexityResult result = rdx_complexity_diff( | ||
| StringValueCStr(baseline_json), | ||
| StringValueCStr(current_json), | ||
| rdxi_symbol_or_string_cstr(format, "text"), | ||
| NUM2SIZET(top) | ||
| ); | ||
|
|
||
| if (result.error != NULL) { | ||
| VALUE message = rb_utf8_str_new_cstr(result.error); | ||
| free_c_string(result.error); | ||
| rb_raise(rb_eArgError, "%s", StringValueCStr(message)); | ||
| } | ||
|
|
||
| return rdxi_owned_c_string_to_ruby(result.output); | ||
| } | ||
|
|
||
| void rdxi_initialize_complexity(VALUE mRubydex) { | ||
| VALUE mComplexity = rb_define_module_under(mRubydex, "Complexity"); | ||
| // The `native_` prefix leaves the public kwargs API (in complexity.rb) free to own the | ||
| // `analyze` / `diff` names without colliding with these module functions. | ||
| rb_define_module_function(mComplexity, "native_analyze", rdxr_complexity_analyze, 6); | ||
| rb_define_module_function(mComplexity, "native_diff", rdxr_complexity_diff, 4); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| #ifndef RUBYDEX_COMPLEXITY_H | ||
| #define RUBYDEX_COMPLEXITY_H | ||
|
|
||
| #include "ruby.h" | ||
|
|
||
| void rdxi_initialize_complexity(VALUE mRubydex); | ||
|
|
||
| #endif // RUBYDEX_COMPLEXITY_H |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Rubydex | ||
| # ABC complexity reports. Partially defined in C (native_analyze / native_diff). | ||
| module Complexity | ||
| class << self | ||
| #: (Array[String] paths, ?format: String | Symbol, ?top: Integer, ?methods_only: bool, ?details: bool, ?group: bool) -> String | ||
| def analyze(paths, format: :text, top: 25, methods_only: false, details: false, group: false) | ||
| raise TypeError, "no implicit conversion of #{paths.class} into Array" unless paths.is_a?(Array) | ||
|
|
||
| native_analyze(paths.map(&:to_s), format, top, methods_only, details, group) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why the |
||
| end | ||
|
|
||
| #: (String baseline_json, String current_json, ?format: String | Symbol, ?top: Integer) -> String | ||
| def diff(baseline_json, current_json, format: :text, top: 25) | ||
| native_diff(baseline_json, current_json, format, top) | ||
| end | ||
| end | ||
| end | ||
| end | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we even consider
topforjsonoutput?