Created
June 19, 2026 08:30
-
-
Save r4mbo7/7c2a44d71bb5a356251f912dc33a0da8 to your computer and use it in GitHub Desktop.
Cyclomatic complexity analyzer for Ruby methods.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # Cyclomatic complexity analyzer for Ruby methods. | |
| # | |
| # Counts the number of linearly independent paths through a method by | |
| # starting at 1 and adding 1 for each decision point: | |
| # - conditionals : if, elsif, unless (including postfix forms), ternary ?: | |
| # - loops : while, until, for (including postfix forms) | |
| # - case/rescue : each `when` clause, each `rescue` clause | |
| # - boolean ops : &&, ||, and, or | |
| # - safe nav : &. (each call may short-circuit, so it's a branch) | |
| # - assignments : ||= and &&= | |
| # - iter blocks : .each{}, .map{}, .select{} etc. (known Enumerable methods) | |
| # | |
| # No dependencies — uses only `ripper` from the Ruby stdlib. | |
| # | |
| # Usage: | |
| # cyclomatic path/to/file.rb # score for the whole file | |
| # cyclomatic path/to/file.rb --method foo # score for a single method | |
| # echo "def foo; if x; 1; end; end" | cyclomatic | |
| # | |
| # Score interpretation: | |
| # 1-4 low simple, easy to test | |
| # 5-7 moderate manageable, consider splitting if growing | |
| # 8-10 high hard to test exhaustively, refactor recommended | |
| # 11+ very high strongly consider breaking the method up | |
| require "ripper" | |
| # AST nodes that introduce a new decision branch. | |
| DECISION_NODES = %i[ | |
| if elsif unless if_mod unless_mod | |
| while until while_mod until_mod | |
| for when rescue ifop | |
| and or | |
| ].freeze | |
| # Subset of Enumerable/Array/Hash methods that imply iteration (mirrors RuboCop). | |
| ITERATING_METHODS = %i[ | |
| all? any? collect collect_concat count cycle detect drop drop_while each | |
| each_cons each_entry each_slice each_with_index each_with_object entries | |
| filter filter_map find find_all find_index flat_map grep group_by inject | |
| lazy map max max_by min min_by minmax minmax_by none? one? partition reduce | |
| reject reverse_each select slice_after slice_before slice_when sort sort_by | |
| sum take take_while tally to_h uniq zip | |
| each_key each_pair each_value merge transform_keys transform_values | |
| each_index keep_if map! reject! select! | |
| ].freeze | |
| def iterating_block_method(sexp) | |
| call = sexp[1] | |
| call = call[1] if call.is_a?(Array) && call[0] == :method_add_arg | |
| return nil unless call.is_a?(Array) && call[0] == :call | |
| ident = call[3] | |
| ident.is_a?(Array) && ident[0] == :@ident ? ident[1].to_sym : nil | |
| end | |
| def count_decisions(sexp) | |
| return 0 unless sexp.is_a?(Array) | |
| node_type = sexp[0] | |
| count = 0 | |
| count += 1 if node_type == :binary && %i[&& ||].include?(sexp[2]) | |
| count += 1 if DECISION_NODES.include?(node_type) | |
| count += 1 if node_type == :call && sexp[2].is_a?(Array) && sexp[2][1] == "&." | |
| count += 1 if node_type == :opassign && sexp[2].is_a?(Array) && %w[||= &&=].include?(sexp[2][1]) | |
| count += 1 if node_type == :method_add_block && ITERATING_METHODS.include?(iterating_block_method(sexp)) | |
| sexp.each { |child| count += count_decisions(child) } | |
| count | |
| end | |
| # Walk the full-file sexp and return the subtree for the named method. | |
| def find_method_node(sexp, name) | |
| return nil unless sexp.is_a?(Array) | |
| if sexp[0] == :def | |
| ident = sexp[1] | |
| return sexp if ident.is_a?(Array) && ident[1].to_s == name.to_s | |
| end | |
| sexp.each do |child| | |
| found = find_method_node(child, name) | |
| return found if found | |
| end | |
| nil | |
| end | |
| def cyclomatic_complexity(sexp) | |
| 1 + count_decisions(sexp) | |
| end | |
| # --- Main --- | |
| method_flag = ARGV.index("--method") | |
| method_name = method_flag && ARGV[method_flag + 1] | |
| file_arg = ARGV.find { |a| !a.start_with?("-") } | |
| source = file_arg ? File.read(file_arg) : $stdin.read | |
| sexp = Ripper.sexp(source) | |
| abort "Parse error — invalid Ruby syntax." unless sexp | |
| if method_name | |
| node = find_method_node(sexp, method_name) | |
| abort "Method '#{method_name}' not found." unless node | |
| complexity = cyclomatic_complexity(node) | |
| label = "'#{method_name}'" | |
| else | |
| complexity = cyclomatic_complexity(sexp) | |
| label = file_arg ? File.basename(file_arg) : "input" | |
| end | |
| risk = case complexity | |
| when 1..4 then "low" | |
| when 5..7 then "moderate" | |
| when 8..10 then "high" | |
| else "very high" | |
| end | |
| puts "Cyclomatic complexity of #{label}: #{complexity} (#{risk})" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment