Skip to content
Open
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
107 changes: 107 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,113 @@ puts query.render(graph, "json")
puts Rubydex::Query.schema("table")
```

## Code Complexity

Rubydex computes ABC complexity scores (assignments, branches, calls) over Ruby
source, with Ruby-aware weights and compounding nesting penalties.

Thanks to [Ryan Davis](https://github.com/zenspider) for [flog](https://github.com/seattlerb/flog),
which the scoring rules and report shape here are modeled on.

A score is reported per method as `sqrt(a² + b² + c²)`, where `a`, `b`, and `c`
accumulate assignment, branch, and call weight respectively (each scaled by the
current nesting multiplier). The report totals all method scores and reports the
per-method average. `.rbs` files are excluded; everything else the indexer treats
as Ruby (`.rb`, `.rake`, `.ru`, …) is scored.

Exclusions are configured in `rubydex.toml` and are **decoupled from indexing**: the
top-level `exclude` key affects indexing only, while a separate `[complexity]` table
controls what `rdx complexity` skips. This lets you keep a file in the graph but out of
the complexity report. Both share the default skipped directories (`.git`, `node_modules`,
`tmp`, …).

```toml
# rubydex.toml
exclude = ["vendor/**"] # indexing only; complexity still scores these

[complexity]
exclude = ["app/assets/**", "**/*_spec.rb"] # complexity only; still indexed
```

From the command line:

```bash
# Compute a report for the current directory (top 25 methods by default)
bundle exec rdx complexity

# Scope to specific paths and show more entries
bundle exec rdx complexity app/models lib/services --top 50

# Render the full report as JSON (use --top 0 for every method)
bundle exec rdx complexity app/models --format json --top 0 > report.json

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even consider top for json output?


# Diff a fresh report against a baseline JSON report to track drift
bundle exec rdx complexity app/models --diff baseline.json

# Show what drives each method's score: per-construct contributions
bundle exec rdx complexity app/models --details --top 10

# Skip code outside methods: drops top-level `#none` noise
bundle exec rdx complexity app/models --methods-only

# Group entries by class with per-class subtotals
bundle exec rdx complexity app/models --group
```

This is the default text report for a small codebase:

```
17.2: total complexity
5.7: average complexity

10.3: Rubydex::Complexity.analyze /path/to/repo/lib/rubydex/complexity.rb:8-12
5.0: Rubydex::Complexity#none /path/to/repo/lib/rubydex/complexity.rb:5-19
1.9: Rubydex::Complexity.diff /path/to/repo/lib/rubydex/complexity.rb:15-17
```

The JSON report (`schema_version: 1`) carries `total`, `average`,
`methods_count`, and a `methods` array of entries with per-bucket breakdowns and
`start_line`/`end_line` locations. With `--details`, each entry also includes a
`details` array of per-construct contributions (`assignment`,
`branch`, `block_pass`, `magic_number`, or the called method's name) so you can see
what drives a score; the field is omitted when detail collection is off. Diff output
splits changes into regressions, improvements, added, and removed methods, each capped
at `--top` rows. `--details` works for both text and JSON reports; `--group` is
text-only (rejected with `--format json`). Neither `--details` nor `--group` applies
to `--diff` (both are rejected with it), and `--diff` requires the baseline to have
been generated with the same `--methods-only` setting.

`--details` adds a per-method breakdown:

```
10.3: Rubydex::Complexity.analyze /path/to/repo/lib/rubydex/complexity.rb:8-12
1.8: class
1.7: block_pass
1.7: map
1.6: raise
1.5: branch
0.4: magic_number
```

From Ruby:

```ruby
# Compute a report: returns the text or JSON string ready to print
puts Rubydex::Complexity.analyze(["app/models"], format: :text, top: 25)
json = Rubydex::Complexity.analyze(["app/models"], format: :json, top: 0)

# Per-construct breakdown, methods-only, and grouping
puts Rubydex::Complexity.analyze(["app/models"], details: true, top: 10)
puts Rubydex::Complexity.analyze(["app/models"], methods_only: true)
puts Rubydex::Complexity.analyze(["app/models"], group: true)

# Diff two JSON reports (e.g. a committed baseline against a fresh run).
# Both reports must share the same `methods_only` setting or diff raises ArgumentError.
puts Rubydex::Complexity.diff(baseline_json, json, format: :text, top: 25)
```

Run `rdx complexity --help` for the full set of options.

## MCP Server (Experimental)

Rubydex can run as an MCP (Model Context Protocol) server, enabling AI assistants
Expand Down
51 changes: 50 additions & 1 deletion exe/rdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check if top is negative?

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!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we rescue OptionParser::ParseError?


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could broaden to SystemCallError and say can't read baseline report so you also cover EACCESS, EISDIR and friends.

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
Expand Down
84 changes: 84 additions & 0 deletions ext/rubydex/complexity.c
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to leak if rdxi_symbol_or_string_cstr or NUM2SIZET raise


struct CComplexityResult result = rdx_complexity_analyze(
(const char *const *)paths_array,
(size_t)length,
rdxi_symbol_or_string_cstr(format, "text"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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);
}
8 changes: 8 additions & 0 deletions ext/rubydex/complexity.h
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
2 changes: 2 additions & 0 deletions ext/rubydex/rubydex.c
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#include "complexity.h"
#include "declaration.h"
#include "definition.h"
#include "diagnostic.h"
Expand Down Expand Up @@ -27,5 +28,6 @@ void Init_rubydex(void) {
rdxi_initialize_location(mRubydex);
rdxi_initialize_diagnostic(mRubydex);
rdxi_initialize_reference(mRubydex);
rdxi_initialize_complexity(mRubydex);
rdxi_initialize_signature(mRubydex);
}
1 change: 1 addition & 0 deletions lib/rubydex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@
require "rubydex/keyword_parameter"
require "rubydex/graph"
require "rubydex/declaration"
require "rubydex/complexity"
require "rubydex/signature"
require "rubydex/reference"
20 changes: 20 additions & 0 deletions lib/rubydex/complexity.rb
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the to_s, isn't paths an Array[String] from the signature?

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
20 changes: 20 additions & 0 deletions rbi/rubydex.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -623,4 +623,24 @@ class Rubydex::Reference
end
end

module Rubydex::Complexity
sig do
params(
paths: T::Array[String],
format: T.any(String, Symbol),
top: Integer,
methods_only: T::Boolean,
details: T::Boolean,
group: T::Boolean,
).returns(String)
end
def self.analyze(paths, format: :text, top: 25, methods_only: false, details: false, group: false); end

sig do
params(baseline_json: String, current_json: String, format: T.any(String, Symbol), top: Integer)
.returns(String)
end
def self.diff(baseline_json, current_json, format: :text, top: 25); end
end

Rubydex::VERSION = T.let(T.unsafe(nil), String)
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading