rubylens 0.2.0 → 0.2.1

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: d79e23df4b2738a967167958385f2ef8b6c8dd6bd333f70a96690ed3c6f35823
4
- data.tar.gz: '09f0c64309ee0427d3b3cf326187d9aaf9e6cf3d3b6766fd7951c3ba4fccd5e0'
3
+ metadata.gz: abf1b3d5b3080d2f7a6715fe84c770cdd184ab1c164da45e9d9b4f0c3c1c44f6
4
+ data.tar.gz: 966f347550c546b49bfa70def65d375f3cb5675c804ed52cd2328b182c45dd82
5
5
  SHA512:
6
- metadata.gz: e1edf50d830b80dd72f9d033f6d1f9a74a51a90a559c6cc37329701b3762891145fe2e86a4b9f412224af2092a3c4b5c128473b0d453a19d83a689ff9c0570b0
7
- data.tar.gz: 22e667b3baec097b8bb30c48ba9aa1c8fcd4817993479c0680b45f1605880ff8351f8ee0355e9ac3e06a3a3cc588471a0fd8953ea1561628e3fa411880d0961b
6
+ metadata.gz: fdb436f8272c226cbb020851269c04de5c3b713d0eb640b05580ef8a4f7930c5bee8c0df9da0d3185877da452895a2e94e47683f9cb8bf33d884ec80d21d7990
7
+ data.tar.gz: 27f66613b7207f462d180333f8da247574c7b46064bc44715e3d3914c6b1b25c38f46cda2f5434b1940c64280b7f54c67d4fcc61a9514aec90ead2fa326cd337
data/README.md CHANGED
@@ -28,11 +28,11 @@ Three levels of disclosure:
28
28
  > [!IMPORTANT]
29
29
  > Nothing is uploaded and no source code is embedded. Outputs still describe your project: they name it, can name classes and gems, and reveal some relationship topology. See [Privacy and sharing](#privacy-and-sharing).
30
30
 
31
- https://github.com/user-attachments/assets/43570623-6d98-46c9-9303-7faa4035b2a7
31
+ https://github.com/user-attachments/assets/ec8d9357-c726-463b-bbde-0fd6eca25d2c
32
32
 
33
- *The Explorer on Rails: search, fly to a class, expand a gem cloud.*
33
+ *The Explorer on Discourse: search, fly to a class, expand a gem cloud.*
34
34
 
35
- https://github.com/user-attachments/assets/bb266de5-bbd7-4ccd-814b-15961b45bd39
35
+ https://github.com/user-attachments/assets/972a15b9-d863-4a52-85e1-af7bc92c0459
36
36
 
37
37
  *A `--details` clip: what your team sees.*
38
38
 
@@ -6,113 +6,202 @@ require_relative "morphology_classifier"
6
6
 
7
7
  module RubyLens
8
8
  class ArtModelBuilder
9
+ SCHEMA = "rubylens.art.v13"
9
10
  SIGNAL_FIELDS = %w[ancestorDepth definitionSites reopenings descendants references members].freeze
11
+ # Namespace row columns carrying the six signals, aligned with SIGNAL_FIELDS.
12
+ NAMESPACE_SIGNAL_COLUMNS = (3..8).freeze
10
13
 
14
+ # `order` lists snapshot ordinals in draw order and is the single authority
15
+ # for each shuffle; the snapshot-ordinal-to-draw-position inverse is derived
16
+ # where it is needed rather than stored beside it.
17
+ Namespaces = Data.define(
18
+ :order, #: Array[Integer]
19
+ :rows, #: Array[Array[Integer]]
20
+ :names, #: Array[String]
21
+ :reference_rows, #: Array[[Integer, Integer]]
22
+ ) do
23
+ # Link endpoints past the namespace block address dependency stars, which
24
+ # the snapshot numbers from zero after the namespaces.
25
+ #
26
+ #: () -> Array[Integer]
27
+ def referenced_dependency_ordinals
28
+ reference_rows.map { |_referring_index, referenced_index| referenced_index - order.length }
29
+ end
30
+
31
+ #: () -> Array[Integer]
32
+ def draw_positions
33
+ positions = Array.new(order.length)
34
+ order.each_with_index { |old_index, new_index| positions[old_index] = new_index }
35
+ positions
36
+ end
37
+ end
38
+
39
+ Packages = Data.define(
40
+ :order, #: Array[Integer]
41
+ :rows, #: Array[Array[Integer]]
42
+ :names, #: Array[String]
43
+ :morphologies, #: Array[Array[Integer]]
44
+ :systems, #: Array[Array[Integer]]
45
+ )
46
+
47
+ # Unlike the other two, this index is not an inverse of any stored order:
48
+ # a dependency star's snapshot ordinal is only known for rows travel
49
+ # targets, and is captured as the shuffled rows are emitted.
50
+ Dependencies = Data.define(
51
+ :rows, #: Array[Array[Integer]]
52
+ :index, #: Hash[Integer, Integer]
53
+ )
54
+
55
+ #: (?seed: Integer) -> void
11
56
  def initialize(seed: 0x51A7_E11A)
12
57
  @seed = seed
13
58
  end
14
59
 
60
+ # Draw order is shuffled so neighbouring stars come from unrelated parts of
61
+ # the codebase: adjacent points would otherwise share a package or a name
62
+ # prefix and read as structure the galaxy does not mean to show. Every
63
+ # ordinal the snapshot used is therefore remapped onto the shuffled order.
64
+ #: (Hash[String, untyped]) -> Hash[String, untyped]
15
65
  def build(snapshot)
16
66
  random = Random.new(@seed)
17
- morphology = MorphologyClassifier.new(snapshot).call
18
- namespace_order = (0...snapshot.fetch("namespaces").length).to_a.shuffle(random: random)
19
- namespace_index = Array.new(namespace_order.length)
20
- namespace_order.each_with_index { |old_index, new_index| namespace_index[old_index] = new_index }
21
- constant_reference_rows = valid_constant_reference_rows(snapshot)
22
- namespaces = namespace_order.map do |index|
23
- row = snapshot.fetch("namespaces").fetch(index)
24
- [random.rand(0..0xffff_ffff), *row]
25
- end
26
- namespace_names = namespace_order.map { |index| snapshot.fetch("namespace_names").fetch(index) }
27
- package_order = (0...snapshot.fetch("packages").length).to_a.shuffle(random: random)
28
- package_index = package_order.each_with_index.to_h
29
- dependency_systems, dependency_system_index = build_dependency_systems(snapshot, package_index)
30
- package_morphologies = []
31
- packages = package_order.map do |old_index|
32
- package = snapshot.fetch("packages").fetch(old_index)
33
- package_seed = random.rand(0..0xffff_ffff)
34
- package_morphology = MorphologyClassifier.new(package:, phase_seed: package_seed).call
35
- package_morphologies << [package_morphology.fetch("family"), *package_morphology.fetch("knobs")]
67
+ namespaces = build_namespaces(snapshot, random)
68
+ packages = build_packages(snapshot, random)
69
+ dependencies = build_dependencies(snapshot, packages, namespaces, random)
70
+
71
+ {
72
+ "schema" => SCHEMA,
73
+ "projectName" => snapshot.fetch("project_name"),
74
+ "morphology" => morphology_row(MorphologyClassifier.new(snapshot).call),
75
+ "totals" => {
76
+ "namespaces" => namespaces.rows.length,
77
+ "packages" => packages.rows.length,
78
+ "dependencyStars" => dependencies.rows.length,
79
+ },
80
+ "domains" => signal_domains(namespaces.rows, snapshot.fetch("dependency_signal_maxima")),
81
+ "categoryStats" => snapshot.fetch("category_stats"),
82
+ "namespaceNames" => namespaces.names,
83
+ "namespaces" => namespaces.rows,
84
+ "constantReferenceLinks" => build_constant_reference_links(
85
+ namespaces.reference_rows,
86
+ namespaces.draw_positions,
87
+ dependencies.index,
88
+ ),
89
+ "packageNames" => packages.names,
90
+ "packages" => packages.rows,
91
+ "packageMorphologies" => packages.morphologies,
92
+ "dependencySystems" => packages.systems,
93
+ "dependencyStars" => dependencies.rows,
94
+ "dependencyWarnings" => allowed_dependency_warnings(snapshot),
95
+ "warningCounts" => snapshot.fetch("warning_counts"),
96
+ }
97
+ end
98
+
99
+ private
100
+
101
+ #: (Hash[String, untyped], Random) -> Namespaces
102
+ def build_namespaces(snapshot, random)
103
+ rows = snapshot.fetch("namespaces")
104
+ names = snapshot.fetch("namespace_names")
105
+ order = (0...rows.length).to_a.shuffle(random: random)
106
+ Namespaces.new(
107
+ order: order,
108
+ rows: order.map { |old_index| [random.rand(0..0xffff_ffff), *rows.fetch(old_index)] },
109
+ names: order.map { |old_index| names.fetch(old_index) },
110
+ reference_rows: valid_constant_reference_rows(snapshot),
111
+ )
112
+ end
113
+
114
+ #: (Hash[String, untyped], Random) -> Packages
115
+ def build_packages(snapshot, random)
116
+ snapshot_packages = snapshot.fetch("packages")
117
+ order = (0...snapshot_packages.length).to_a.shuffle(random: random)
118
+ systems, system_index = build_dependency_systems(snapshot, order.each_with_index.to_h)
119
+ morphologies = []
120
+
121
+ rows = order.map do |old_index|
122
+ package = snapshot_packages.fetch(old_index)
123
+ seed = random.rand(0..0xffff_ffff)
124
+ morphologies << morphology_row(MorphologyClassifier.new(package: package, phase_seed: seed).call)
36
125
  [
37
- package_seed,
126
+ seed,
38
127
  package.fetch("role"),
39
128
  package.fetch("location"),
40
129
  package.fetch("declarations").length,
41
130
  *package.fetch("ruby_counts"),
42
- dependency_system_index.fetch(old_index, -1),
131
+ system_index.fetch(old_index, -1),
43
132
  ]
44
133
  end
45
- package_names = package_order.map { |old_index| snapshot.fetch("packages").fetch(old_index).fetch("name") }
46
- dependency_offsets = []
47
- dependency_count = 0
48
- snapshot.fetch("packages").each do |package|
49
- dependency_offsets << dependency_count
50
- dependency_count += package.fetch("declarations").length
51
- end
52
- dependency_target_rows = {}.compare_by_identity
53
- constant_reference_rows.each do |_referring_index, referenced_index|
54
- dependency_ordinal = referenced_index - namespace_order.length
55
- next if dependency_ordinal.negative? || dependency_ordinal >= dependency_count
56
-
57
- next_package_index = dependency_offsets.bsearch_index { |offset| offset > dependency_ordinal }
58
- old_package_index = (next_package_index || dependency_offsets.length) - 1
59
- package = snapshot.fetch("packages").fetch(old_package_index)
60
- local_index = dependency_ordinal - dependency_offsets.fetch(old_package_index)
61
- dependency_target_rows[package.fetch("declarations").fetch(local_index)] = dependency_ordinal
62
- end
63
- dependencies = []
64
- dependency_index = {}
65
- package_order.each do |old_index|
134
+
135
+ Packages.new(
136
+ order: order,
137
+ rows: rows,
138
+ names: order.map { |old_index| snapshot_packages.fetch(old_index).fetch("name") },
139
+ morphologies: morphologies,
140
+ systems: systems,
141
+ )
142
+ end
143
+
144
+ # Dependency stars are emitted package by package in shuffled order, so a
145
+ # snapshot ordinal is only recoverable for the rows travel actually targets.
146
+ # Those rows are identified before the shuffle and tracked by object
147
+ # identity, since equal declaration rows are common and interchangeable.
148
+ #: (Hash[String, untyped], Packages, Namespaces, Random) -> Dependencies
149
+ def build_dependencies(snapshot, packages, namespaces, random)
150
+ targeted = travel_target_rows(snapshot, namespaces.referenced_dependency_ordinals)
151
+ rows = []
152
+ index = {}
153
+ packages.order.each_with_index do |old_index, new_index|
66
154
  package = snapshot.fetch("packages").fetch(old_index)
67
- declarations = package.fetch("declarations").sort.shuffle(random: random)
68
- declarations.each do |declaration|
69
- original_ordinal = dependency_target_rows[declaration]
70
- dependency_index[original_ordinal] = dependencies.length if original_ordinal
71
- dependencies << [
72
- random.rand(0..0xffff_ffff),
73
- package_index.fetch(old_index),
74
- *declaration.drop(1),
75
- ]
155
+ package.fetch("declarations").sort.shuffle(random: random).each do |declaration|
156
+ original_ordinal = targeted[declaration]
157
+ index[original_ordinal] = rows.length if original_ordinal
158
+ rows << [random.rand(0..0xffff_ffff), new_index, *declaration.drop(1)]
76
159
  end
77
160
  end
78
- constant_reference_links = build_constant_reference_links(
79
- constant_reference_rows,
80
- namespace_index,
81
- dependency_index,
82
- )
83
- {
84
- "schema" => "rubylens.art.v13",
85
- "projectName" => snapshot.fetch("project_name"),
86
- "morphology" => [morphology.fetch("family"), *morphology.fetch("knobs")],
87
- "totals" => {
88
- "namespaces" => namespaces.length,
89
- "packages" => packages.length,
90
- "dependencyStars" => dependencies.length,
91
- },
92
- "domains" => signal_domains(namespaces, snapshot.fetch("dependency_signal_maxima")),
93
- "categoryStats" => snapshot.fetch("category_stats"),
94
- "namespaceNames" => namespace_names,
95
- "namespaces" => namespaces,
96
- "constantReferenceLinks" => constant_reference_links,
97
- "packageNames" => package_names,
98
- "packages" => packages,
99
- "packageMorphologies" => package_morphologies,
100
- "dependencySystems" => dependency_systems,
101
- "dependencyStars" => dependencies,
102
- "dependencyWarnings" => snapshot.fetch("dependency_warnings", []).filter_map do |warning|
103
- name = warning.fetch("name")
104
- reason = warning.fetch("reason")
105
- next unless name.is_a?(String) && DependencyWarning::NAME_PATTERN.match?(name)
106
- next unless reason.is_a?(String) && DependencyWarning::ALLOWED_REASONS.include?(reason)
107
-
108
- { "name" => name, "reason" => reason }
109
- end,
110
- "warningCounts" => snapshot.fetch("warning_counts"),
111
- }
161
+ Dependencies.new(rows: rows, index: index)
112
162
  end
113
163
 
114
- private
164
+ #: (Hash[String, untyped], Array[Integer]) -> Hash[Array[Integer], Integer]
165
+ def travel_target_rows(snapshot, dependency_ordinals)
166
+ offsets = []
167
+ total = 0
168
+ snapshot.fetch("packages").each do |package|
169
+ offsets << total
170
+ total += package.fetch("declarations").length
171
+ end
115
172
 
173
+ targeted = {}.compare_by_identity
174
+ dependency_ordinals.each do |ordinal|
175
+ next if ordinal.negative? || ordinal >= total
176
+
177
+ next_package = offsets.bsearch_index { |offset| offset > ordinal }
178
+ package_index = (next_package || offsets.length) - 1
179
+ declarations = snapshot.fetch("packages").fetch(package_index).fetch("declarations")
180
+ targeted[declarations.fetch(ordinal - offsets.fetch(package_index))] = ordinal
181
+ end
182
+ targeted
183
+ end
184
+
185
+ #: (Hash[String, untyped]) -> Array[Integer]
186
+ def morphology_row(morphology)
187
+ [morphology.fetch("family"), *morphology.fetch("knobs")]
188
+ end
189
+
190
+ # Warnings reach the artifact only if they match the closed vocabulary, so a
191
+ # tampered snapshot cannot smuggle arbitrary text into a shareable page.
192
+ #: (Hash[String, untyped]) -> Array[Hash[String, String]]
193
+ def allowed_dependency_warnings(snapshot)
194
+ snapshot.fetch("dependency_warnings", []).filter_map do |warning|
195
+ name = warning.fetch("name")
196
+ reason = warning.fetch("reason")
197
+ next unless name.is_a?(String) && DependencyWarning::NAME_PATTERN.match?(name)
198
+ next unless reason.is_a?(String) && DependencyWarning::ALLOWED_REASONS.include?(reason)
199
+
200
+ { "name" => name, "reason" => reason }
201
+ end
202
+ end
203
+
204
+ #: (Hash[String, untyped]) -> Array[[Integer, Integer]]
116
205
  def valid_constant_reference_rows(snapshot)
117
206
  rows = snapshot.fetch("constant_reference_links", [])
118
207
  return [] unless rows.is_a?(Array)
@@ -122,6 +211,7 @@ module RubyLens
122
211
  end
123
212
  end
124
213
 
214
+ #: (Array[[Integer, Integer]], Array[Integer], Hash[Integer, Integer]) -> Array[[Integer, Integer]]
125
215
  def build_constant_reference_links(rows, namespace_index, dependency_index)
126
216
  snapshot_namespace_count = namespace_index.length
127
217
  candidates = rows.filter_map do |row|
@@ -141,6 +231,7 @@ module RubyLens
141
231
  candidates.shuffle(random: Random.new(@seed ^ 0xC057_A17E))
142
232
  end
143
233
 
234
+ #: (Hash[String, untyped], Hash[Integer, Integer]) -> [Array[Array[Integer]], Hash[Integer, Integer]]
144
235
  def build_dependency_systems(snapshot, package_index)
145
236
  system_random = Random.new(@seed ^ 0xD3E5_157E)
146
237
  package_system_index = {}
@@ -156,17 +247,16 @@ module RubyLens
156
247
  [systems, package_system_index]
157
248
  end
158
249
 
159
- def signal_domains(namespaces, dependency_maxima)
160
- namespace_columns = [3, 4, 5, 6, 7, 8]
161
- namespace_domains = namespace_columns.map { |column| maximum(namespaces, column) }
250
+ # Each signal is scaled against the larger of its workspace and dependency
251
+ # maxima, so the two populations stay comparable in the same scene.
252
+ #: (Array[Array[Integer]], Array[Integer]) -> Hash[String, Integer]
253
+ def signal_domains(namespace_rows, dependency_maxima)
254
+ namespace_domains = NAMESPACE_SIGNAL_COLUMNS.map do |column|
255
+ namespace_rows.map { |row| row[column].to_i }.max || 0
256
+ end
162
257
  SIGNAL_FIELDS.each_with_index.to_h do |field, index|
163
258
  [field, [namespace_domains[index], dependency_maxima[index]].max]
164
259
  end
165
260
  end
166
-
167
- def maximum(rows, column)
168
- rows.map { |row| row[column].to_i }.max || 0
169
- end
170
-
171
261
  end
172
262
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubydex"
4
+ require "set"
5
+
6
+ module RubyLens
7
+ module Index
8
+ # Turns Rubydex's resolved constant references into two different things:
9
+ # the complete inbound reference count for every workspace namespace, and a
10
+ # bounded sample of directed links the renderer animates as travel.
11
+ #
12
+ # The two have deliberately different contracts. Counts are exact. Links
13
+ # stop at the limits below and are never ranked, so they are a visual
14
+ # sample and must not be read as a complete relationship or call graph.
15
+ class ConstantReferenceCollector
16
+ LINK_LIMIT = 1_024
17
+ ATTRIBUTION_LIMIT = LINK_LIMIT * 2
18
+
19
+ # `inbound_counts` is keyed by namespace ordinal, absent meaning zero;
20
+ # each link is [referring namespace ordinal, referenced endpoint ordinal].
21
+ Summary = Data.define(
22
+ :inbound_counts, #: Hash[Integer, Integer]
23
+ :links, #: Array[[Integer, Integer]]
24
+ )
25
+
26
+ #: (locations: LocationIndex) -> void
27
+ def initialize(locations:)
28
+ @locations = locations
29
+ end
30
+
31
+ # `namespaces` are the collector's workspace namespaces in ordinal order;
32
+ # `ordinal_by_name` and `dependency_ordinal_by_name` address link
33
+ # endpoints, with dependency ordinals offset past the namespace block.
34
+ #
35
+ #: (graph: Rubydex::Graph, namespaces: Array[DeclarationCollector::Namespace], definition_ranges: Hash[String, Array[DeclarationCollector::definition_range]], ordinal_by_name: Hash[String, Integer], dependency_ordinal_by_name: Hash[String, Integer], namespace_count: Integer) -> Summary
36
+ def call(graph:, namespaces:, definition_ranges:, ordinal_by_name:, dependency_ordinal_by_name:, namespace_count:)
37
+ Summary.new(
38
+ inbound_counts: inbound_counts(namespaces),
39
+ links: links(graph, definition_ranges, ordinal_by_name, dependency_ordinal_by_name, namespace_count),
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ # Asking each namespace for its own references is bounded by the
46
+ # namespaces actually drawn, unlike scanning the global reference stream.
47
+ #
48
+ #: (Array[DeclarationCollector::Namespace]) -> Hash[Integer, Integer]
49
+ def inbound_counts(namespaces)
50
+ counts = Hash.new(0)
51
+ namespaces.each_with_index do |namespace, index|
52
+ namespace.declaration.references.each do |reference|
53
+ location = begin
54
+ reference.location
55
+ rescue StandardError
56
+ next
57
+ end
58
+ counts[index] += 1 if @locations.workspace?(location)
59
+ end
60
+ end
61
+ counts.freeze
62
+ end
63
+
64
+ #: (Rubydex::Graph, Hash[String, Array[DeclarationCollector::definition_range]], Hash[String, Integer], Hash[String, Integer], Integer) -> Array[[Integer, Integer]]
65
+ def links(graph, definition_ranges, ordinal_by_name, dependency_ordinal_by_name, namespace_count)
66
+ links = []
67
+ retained = Set.new
68
+ attributions = 0
69
+
70
+ graph.constant_references.each do |reference|
71
+ break if links.length >= LINK_LIMIT || attributions >= ATTRIBUTION_LIMIT
72
+ next unless reference.is_a?(Rubydex::ResolvedConstantReference)
73
+
74
+ name = referenced_name(reference)
75
+ next unless name
76
+
77
+ namespace_ordinal = ordinal_by_name[name]
78
+ dependency_ordinal = dependency_ordinal_by_name[name] unless namespace_ordinal
79
+ next unless namespace_ordinal || dependency_ordinal
80
+
81
+ location = begin
82
+ reference.location
83
+ rescue StandardError
84
+ next
85
+ end
86
+ next unless @locations.workspace?(location)
87
+
88
+ attributions += 1
89
+ endpoint = namespace_ordinal || namespace_count + dependency_ordinal
90
+
91
+ values = begin
92
+ location.comparable_values
93
+ rescue StandardError
94
+ next
95
+ end
96
+ origin = enclosing_namespace_ordinal(values, definition_ranges)
97
+ next unless origin
98
+ next if endpoint == origin
99
+
100
+ link = [origin, endpoint]
101
+ next unless retained.add?(link)
102
+
103
+ links << link
104
+ end
105
+
106
+ links.freeze
107
+ end
108
+
109
+ #: (Rubydex::ResolvedConstantReference) -> String?
110
+ def referenced_name(reference)
111
+ reference.declaration.name
112
+ rescue StandardError
113
+ nil
114
+ end
115
+
116
+ # A reference belongs to the innermost namespace definition containing it.
117
+ # Two definitions sharing the exact same span cannot be told apart, so the
118
+ # reference is dropped rather than attributed to an arbitrary one.
119
+ #
120
+ #: (DeclarationCollector::site_key, Hash[String, Array[DeclarationCollector::definition_range]]) -> Integer?
121
+ def enclosing_namespace_ordinal(location_values, ranges_by_uri)
122
+ uri, start_line, start_column, end_line, end_column = location_values
123
+ best = nil
124
+ ambiguous = false
125
+ ranges_by_uri.fetch(uri, []).each do |range|
126
+ range_start_line, range_start_column, range_end_line, range_end_column, ordinal = range
127
+ next if compare(range_start_line, range_start_column, start_line, start_column).positive?
128
+ next if compare(range_end_line, range_end_column, end_line, end_column).negative?
129
+
130
+ start_comparison = best ? compare(range_start_line, range_start_column, best[0], best[1]) : 1
131
+ end_comparison = best && start_comparison.zero? ? compare(range_end_line, range_end_column, best[2], best[3]) : 0
132
+ if start_comparison.positive? || (start_comparison.zero? && end_comparison.negative?)
133
+ best = range
134
+ ambiguous = false
135
+ elsif start_comparison.zero? && end_comparison.zero? && ordinal != best[4]
136
+ ambiguous = true
137
+ end
138
+ end
139
+
140
+ ambiguous ? nil : best&.fetch(4)
141
+ end
142
+
143
+ #: (Integer, Integer, Integer, Integer) -> Integer
144
+ def compare(left_line, left_column, right_line, right_column)
145
+ line_comparison = left_line <=> right_line
146
+ line_comparison.zero? ? left_column <=> right_column : line_comparison
147
+ end
148
+ end
149
+ end
150
+ end