attractor 2.7.2 → 2.8.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f25163f3110bce204d78b6bbffb42973582b2950f7a3905949c255ad2392588
4
- data.tar.gz: 1bf00d54220e67400a7eadcb6f9b33ab34e6a60100df5e3997861151d2ad2410
3
+ metadata.gz: 48389ab1e67aacc626f598cbcaf4b899b95dc8bcacd1ca4403802981ec52cd72
4
+ data.tar.gz: f6366aa8f6b10cb75e235f2ce6216dea5597601da575eb7dcc16ab8311347757
5
5
  SHA512:
6
- metadata.gz: 29d2e731e695406b7e247806c7a1e3cab15f555df0fd0dd90d455d14513db6722d77f8747b2f64426ecb611599eef91bcf39824066d60536503d52e3fd1b4292
7
- data.tar.gz: f71bc7dd89144521716bd9a4671c5e065113c6bb727b9da19001f3a73d5cdf4c942c7d829242c911a90811cf4404ba84094cd094b8033b92600763de21e28963
6
+ metadata.gz: 699db99bb212c7a1aab465dacb7a2e9ce1796b18e5a44005fc0bd619a5baeb55cae9dd2c0a4750c5bb2b5f93232efc1b4d503df2cc92da6ee14a9a21e294ce6c
7
+ data.tar.gz: f7485127a0e46fcaae2bf809b1ad71d28cad9caebeaa70978d117d19a0d6a6d728121f96dfe1139a193a8a0921a493e5e65c67bab355ecb7b526a7192f50e3fe
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "attractor/lizard"
4
+
5
+ module Attractor
6
+ # Base calculator for languages scored with lizard. A plugin subclasses it and fixes the
7
+ # language and extension:
8
+ #
9
+ # class SwiftCalculator < LizardCalculator
10
+ # def initialize(**options)
11
+ # super(language: "swift", file_extension: "swift", **options)
12
+ # @type = "Swift"
13
+ # end
14
+ # end
15
+ #
16
+ # Complexity is the sum of the per-function cyclomatic complexity (McCabe, lizard's CCN),
17
+ # which is a different scale from flog's Ruby score; attractor reports per language, so
18
+ # the two are never summed. Details carry the same shape as attractor-ruby >= 0.4:
19
+ # `{ "name" => { "score" => ccn, "line" => start, "end_line" => end } }`.
20
+ class LizardCalculator < BaseCalculator
21
+ attr_reader :language
22
+
23
+ def initialize(language:, file_extension:, **options)
24
+ super(file_extension: file_extension, **options)
25
+ @language = language
26
+ @type = language.capitalize
27
+ end
28
+
29
+ def calculate
30
+ super do |change|
31
+ functions = Lizard.analyze(change[:file_path], language: language)
32
+ [functions.sum(&:ccn), details_for(functions)]
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # lizard reports bare function names without their type, so overloads and same-named
39
+ # methods in different types collide. A key must not depend on what else is in the file
40
+ # or on line numbers, or `diff` cannot pair a function with itself across refs; the
41
+ # signature (lizard's long name) is what actually differs between overloads, so it is
42
+ # the key on collision, and the start line only when even the signatures match.
43
+ def details_for(functions)
44
+ by_name = functions.group_by(&:name)
45
+ by_long_name = functions.group_by(&:long_name)
46
+
47
+ functions.each_with_object({}) do |fn, details|
48
+ key = if by_name[fn.name].size == 1
49
+ fn.name
50
+ elsif by_long_name[fn.long_name].size == 1
51
+ fn.long_name
52
+ else
53
+ "#{fn.long_name}@#{fn.start_line}"
54
+ end
55
+ details[key] = {"score" => fn.ccn, "line" => fn.start_line, "end_line" => fn.end_line}
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "open3"
5
+
6
+ module Attractor
7
+ # Runs lizard (https://github.com/terryyin/lizard), a multi-language cyclomatic complexity
8
+ # analyzer, and parses its CSV. Language plugins that have no native metric tool subclass
9
+ # LizardCalculator and pass their lizard language name; this module is the only place that
10
+ # knows how lizard is invoked.
11
+ module Lizard
12
+ Function = Struct.new(:name, :long_name, :ccn, :nloc, :start_line, :end_line)
13
+
14
+ INSTALL_HINT = "lizard is not installed. Install uv (https://docs.astral.sh/uv/) so `uvx lizard` works, " \
15
+ "or `pip install lizard`."
16
+
17
+ # nloc, ccn, tokens, params, length, location, file, name, long name, start, end
18
+ CSV_COLUMNS = 11
19
+
20
+ module_function
21
+
22
+ # Resolution order: a lizard on PATH (pip/pipx/brew), then uvx (fetches lizard on first run),
23
+ # then a clear error. Memoized per process; set ATTRACTOR_LIZARD to force a command.
24
+ # Windows note: this looks for the bare names, not PATHEXT variants; attractor is not
25
+ # tested there, and ATTRACTOR_LIZARD covers it.
26
+ def command
27
+ @command ||= begin
28
+ forced = ENV["ATTRACTOR_LIZARD"].to_s.split
29
+ if forced.any?
30
+ forced
31
+ elsif executable?("lizard")
32
+ ["lizard"]
33
+ elsif executable?("uvx")
34
+ ["uvx", "lizard"]
35
+ else
36
+ raise Attractor::Error, INSTALL_HINT
37
+ end
38
+ end
39
+ end
40
+
41
+ def reset!
42
+ @command = nil
43
+ end
44
+
45
+ def analyze(file_path, language:)
46
+ stdout, stderr, status = Open3.capture3(*command, "-l", language, "--csv", file_path)
47
+ raise Attractor::Error, "lizard failed on #{file_path}: #{stderr.strip}" unless status.success?
48
+
49
+ parse(stdout, file_path: file_path)
50
+ end
51
+
52
+ # `--csv` prints no header. Anything that is not a full row (a warning lizard writes to
53
+ # stdout, a blank line) is dropped rather than turned into a phantom function; a source
54
+ # file with bytes lizard echoes verbatim must not take the whole run down either.
55
+ def parse(csv, file_path: nil)
56
+ clean = csv.dup.force_encoding(Encoding::UTF_8).scrub("?")
57
+
58
+ CSV.parse(clean).filter_map do |row|
59
+ next unless row.size >= CSV_COLUMNS && integer?(row[1]) && integer?(row[9]) && integer?(row[10])
60
+
61
+ Function.new(row[7].to_s, row[8].to_s, row[1].to_i, row[0].to_i, row[9].to_i, row[10].to_i)
62
+ end
63
+ rescue CSV::MalformedCSVError => e
64
+ where = file_path ? " for #{file_path}" : nil
65
+ raise Attractor::Error, "lizard produced unreadable CSV#{where}: #{e.message}"
66
+ end
67
+
68
+ def integer?(value)
69
+ value.to_s.match?(/\A-?\d+\z/)
70
+ end
71
+
72
+ def executable?(name)
73
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
74
+ candidate = File.join(dir, name)
75
+ File.file?(candidate) && File.executable?(candidate)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,3 +1,3 @@
1
1
  module Attractor
2
- VERSION = "2.7.2"
2
+ VERSION = "2.8.0"
3
3
  end
data/lib/attractor.rb CHANGED
@@ -5,6 +5,7 @@ require "attractor/gem_names"
5
5
  require "attractor/duration_parser"
6
6
  require "attractor/config"
7
7
  require "attractor/calculators/base_calculator"
8
+ require "attractor/calculators/lizard_calculator"
8
9
  require "attractor/detectors/base_detector"
9
10
  require "attractor/reporters/base_reporter"
10
11
  require "attractor/suggester"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: attractor
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.2
4
+ version: 2.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Rubisch
@@ -429,6 +429,7 @@ files:
429
429
  - lib/attractor/cache.rb
430
430
  - lib/attractor/calculators/base_calculator.rb
431
431
  - lib/attractor/calculators/base_calculator.rb~
432
+ - lib/attractor/calculators/lizard_calculator.rb
432
433
  - lib/attractor/cli.rb
433
434
  - lib/attractor/cli.rb~
434
435
  - lib/attractor/config.rb
@@ -446,6 +447,7 @@ files:
446
447
  - lib/attractor/formatters/targets/diff.rb
447
448
  - lib/attractor/gem_names.rb
448
449
  - lib/attractor/git.rb
450
+ - lib/attractor/lizard.rb
449
451
  - lib/attractor/registry_entry.rb
450
452
  - lib/attractor/reporters/base_reporter.rb
451
453
  - lib/attractor/reporters/base_reporter.rb~