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 +4 -4
- data/README.md +3 -3
- data/lib/rubylens/art_model_builder.rb +184 -94
- data/lib/rubylens/index/constant_reference_collector.rb +150 -0
- data/lib/rubylens/index/declaration_collector.rb +288 -0
- data/lib/rubylens/index/git_package_source.rb +202 -0
- data/lib/rubylens/index/location_index.rb +103 -0
- data/lib/rubylens/index/manifest.rb +54 -150
- data/lib/rubylens/index/rubydex_adapter.rb +109 -449
- data/lib/rubylens/model/dependency_aggregation.rb +3 -1
- data/lib/rubylens/version.rb +1 -1
- metadata +7 -3
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubydex"
|
|
4
|
+
require_relative "../model/dependency_aggregation"
|
|
5
|
+
|
|
6
|
+
module RubyLens
|
|
7
|
+
module Index
|
|
8
|
+
# Streams Rubydex's declarations once and splits them into the three
|
|
9
|
+
# populations the snapshot needs: workspace namespaces that become scene
|
|
10
|
+
# points, Ruby construct tallies per category, and dependency declaration
|
|
11
|
+
# rows aggregated per package.
|
|
12
|
+
#
|
|
13
|
+
# It is a single pass because Rubydex materializes a new wrapper for every
|
|
14
|
+
# accessor call, so revisiting the stream costs as much as producing it.
|
|
15
|
+
class DeclarationCollector
|
|
16
|
+
# Shapes shared with the other collectors. A site key is a definition's
|
|
17
|
+
# location flattened for comparison and grouping; a definition range is a
|
|
18
|
+
# namespace's span within one file, tagged with the namespace's ordinal.
|
|
19
|
+
#
|
|
20
|
+
# @rbs!
|
|
21
|
+
# type site_key = [String, Integer, Integer, Integer, Integer]
|
|
22
|
+
# type definition_range = [Integer, Integer, Integer, Integer, Integer]
|
|
23
|
+
# type dependency_package = { ruby_counts: Array[Integer], declarations: Array[Array[Integer]] }
|
|
24
|
+
|
|
25
|
+
# A workspace namespace that earned a scene point, with the canonical
|
|
26
|
+
# definition sites that decide where its constant references land.
|
|
27
|
+
Namespace = Data.define(
|
|
28
|
+
:declaration, #: Rubydex::Namespace
|
|
29
|
+
:name, #: String
|
|
30
|
+
:definition_sites, #: Integer
|
|
31
|
+
:scope, #: Integer
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
Result = Data.define(
|
|
35
|
+
:namespaces, #: Array[Namespace]
|
|
36
|
+
:definition_ranges, #: Hash[String, Array[definition_range]]
|
|
37
|
+
:category_stats, #: Hash[String, Array[Integer]]
|
|
38
|
+
:dependency_packages, #: Array[dependency_package]
|
|
39
|
+
:dependency_signal_maxima, #: Array[Integer]
|
|
40
|
+
:dependency_ordinal_by_name, #: Hash[String, Integer]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
CLASS = 0
|
|
44
|
+
MODULE = 1
|
|
45
|
+
METHOD = 2
|
|
46
|
+
CONSTANT = 3
|
|
47
|
+
OTHER_KIND = 2
|
|
48
|
+
MIXED_SCOPE = 2
|
|
49
|
+
|
|
50
|
+
#: (manifest: untyped, locations: LocationIndex) -> void
|
|
51
|
+
def initialize(manifest:, locations:)
|
|
52
|
+
@manifest = manifest
|
|
53
|
+
@locations = locations
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: (Enumerable[Rubydex::Declaration]) -> Result
|
|
57
|
+
def call(declarations)
|
|
58
|
+
namespaces = []
|
|
59
|
+
category_stats = { "core" => Array.new(4, 0), "tests" => Array.new(4, 0) }
|
|
60
|
+
aggregation = Model::DependencyAggregation.new(package_count: @manifest.packages.length)
|
|
61
|
+
definition_ranges = Hash.new { |ranges, uri| ranges[uri] = [] }
|
|
62
|
+
dependency_positions = {}
|
|
63
|
+
|
|
64
|
+
declarations.each do |declaration|
|
|
65
|
+
name = declaration.name
|
|
66
|
+
next unless eligible?(declaration, name)
|
|
67
|
+
|
|
68
|
+
namespace = declaration.is_a?(Rubydex::Namespace)
|
|
69
|
+
construct = construct_index(declaration)
|
|
70
|
+
# Destructured immediately rather than wrapped: this runs once per
|
|
71
|
+
# declaration in the largest loop RubyLens has, and the names below
|
|
72
|
+
# are the only place these five values are ever read.
|
|
73
|
+
workspace_count, tests_only, site_keys, canonical_scope, package_site_keys =
|
|
74
|
+
summarize(declaration, namespace)
|
|
75
|
+
|
|
76
|
+
if site_keys
|
|
77
|
+
site_keys = site_keys.uniq if site_keys.length > 1
|
|
78
|
+
ordinal = namespaces.length
|
|
79
|
+
site_keys.each do |uri, start_line, start_column, end_line, end_column|
|
|
80
|
+
definition_ranges[uri] << [start_line, start_column, end_line, end_column, ordinal]
|
|
81
|
+
end
|
|
82
|
+
namespaces << Namespace.new(
|
|
83
|
+
declaration: declaration,
|
|
84
|
+
name: name,
|
|
85
|
+
definition_sites: site_keys.length,
|
|
86
|
+
scope: canonical_scope,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
if construct && workspace_count.positive?
|
|
90
|
+
category_stats.fetch(tests_only ? "tests" : "core")[construct] += 1
|
|
91
|
+
end
|
|
92
|
+
if package_site_keys
|
|
93
|
+
collect_dependency(
|
|
94
|
+
aggregation, dependency_positions, declaration, name, namespace, construct, package_site_keys
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
Result.new(
|
|
100
|
+
namespaces: namespaces,
|
|
101
|
+
definition_ranges: definition_ranges,
|
|
102
|
+
category_stats: category_stats,
|
|
103
|
+
dependency_packages: aggregation.packages,
|
|
104
|
+
dependency_signal_maxima: aggregation.signal_maxima,
|
|
105
|
+
dependency_ordinal_by_name: global_dependency_ordinals(aggregation.packages, dependency_positions),
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Rubydex reports synthetic declarations RubyLens never draws: anonymous
|
|
110
|
+
# namespaces, Todo placeholders standing in for unresolved constants, and
|
|
111
|
+
# singleton classes attached to either.
|
|
112
|
+
#
|
|
113
|
+
#: (Rubydex::Declaration, ?String?) -> bool
|
|
114
|
+
def eligible?(declaration, name = declaration.name)
|
|
115
|
+
return false if name.nil? || name.empty? || name.include?("<anonymous>")
|
|
116
|
+
return false if declaration.is_a?(Rubydex::Todo)
|
|
117
|
+
return true unless declaration.is_a?(Rubydex::SingletonClass)
|
|
118
|
+
|
|
119
|
+
attached = declaration.attached_class
|
|
120
|
+
!attached.is_a?(Rubydex::SingletonClass) && !attached.is_a?(Rubydex::Todo)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
#: (Rubydex::Declaration) -> Integer?
|
|
124
|
+
def construct_index(declaration)
|
|
125
|
+
case declaration
|
|
126
|
+
when Rubydex::SingletonClass then nil
|
|
127
|
+
when Rubydex::Class then CLASS
|
|
128
|
+
when Rubydex::Module then MODULE
|
|
129
|
+
when Rubydex::Method then METHOD
|
|
130
|
+
when Rubydex::Constant, Rubydex::ConstantAlias then CONSTANT
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# One pass over a declaration's definitions, so each definition's location
|
|
137
|
+
# and its per-URI workspace, scope, and package answers are read once.
|
|
138
|
+
# Returns [workspace_count, tests_only, canonical_site_keys,
|
|
139
|
+
# canonical_scope, package_site_keys]; the site-key collections stay nil
|
|
140
|
+
# until a definition contributes one, since most declarations contribute
|
|
141
|
+
# neither a canonical namespace site nor a package site.
|
|
142
|
+
#
|
|
143
|
+
#: (Rubydex::Declaration, bool) -> [Integer, bool, Array[site_key]?, Integer, Hash[Integer, Array[site_key]]?]
|
|
144
|
+
def summarize(declaration, namespace)
|
|
145
|
+
workspace_count = 0
|
|
146
|
+
tests_seen = false
|
|
147
|
+
core_seen = false
|
|
148
|
+
canonical_tests_seen = false
|
|
149
|
+
canonical_core_seen = false
|
|
150
|
+
canonical_site_keys = nil
|
|
151
|
+
package_site_keys = nil
|
|
152
|
+
|
|
153
|
+
declaration.definitions.each do |definition|
|
|
154
|
+
location = definition.location
|
|
155
|
+
site_key = nil
|
|
156
|
+
if @locations.workspace?(location)
|
|
157
|
+
workspace_count += 1
|
|
158
|
+
scope = @locations.scope_for(location.uri)
|
|
159
|
+
if scope == LocationIndex::TEST
|
|
160
|
+
tests_seen = true
|
|
161
|
+
elsif scope
|
|
162
|
+
core_seen = true
|
|
163
|
+
end
|
|
164
|
+
if namespace && canonical_definition?(declaration, definition)
|
|
165
|
+
(canonical_site_keys ||= []) << (site_key = location.comparable_values)
|
|
166
|
+
if scope == LocationIndex::TEST
|
|
167
|
+
canonical_tests_seen = true
|
|
168
|
+
elsif scope
|
|
169
|
+
canonical_core_seen = true
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
package_index = @locations.package_index_for(location)
|
|
174
|
+
if package_index
|
|
175
|
+
((package_site_keys ||= {})[package_index] ||= []) << (site_key || location.comparable_values)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
[
|
|
180
|
+
workspace_count,
|
|
181
|
+
tests_seen && !core_seen,
|
|
182
|
+
canonical_site_keys,
|
|
183
|
+
canonical_scope(canonical_tests_seen, canonical_core_seen),
|
|
184
|
+
package_site_keys,
|
|
185
|
+
]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
#: (bool, bool) -> Integer
|
|
189
|
+
def canonical_scope(tests_seen, core_seen)
|
|
190
|
+
return LocationIndex::CORE unless tests_seen
|
|
191
|
+
|
|
192
|
+
core_seen ? MIXED_SCOPE : LocationIndex::TEST
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A declaration reopened across packages is attributed to the one holding
|
|
196
|
+
# the most of its definitions, ties going to the earliest package.
|
|
197
|
+
#
|
|
198
|
+
#: (Model::DependencyAggregation, Hash[String, [Integer, Integer]], Rubydex::Declaration, String, bool, Integer?, Hash[Integer, Array[site_key]]) -> void
|
|
199
|
+
def collect_dependency(aggregation, positions, declaration, name, namespace, construct, package_site_keys)
|
|
200
|
+
package_index = nil
|
|
201
|
+
site_keys = nil
|
|
202
|
+
package_site_keys.each do |index, keys|
|
|
203
|
+
if site_keys.nil? || keys.length > site_keys.length ||
|
|
204
|
+
(keys.length == site_keys.length && index < package_index)
|
|
205
|
+
package_index = index
|
|
206
|
+
site_keys = keys
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
sites = site_keys.length > 1 ? site_keys.uniq.length : site_keys.length
|
|
211
|
+
references = length_of(declaration, :references)
|
|
212
|
+
row = [
|
|
213
|
+
namespace_kind(declaration),
|
|
214
|
+
namespace ? [declaration.ancestors.count - 1, 0].max : 0,
|
|
215
|
+
sites,
|
|
216
|
+
[sites - 1, 0].max,
|
|
217
|
+
namespace ? [declaration.descendants.count - 1, 0].max : 0,
|
|
218
|
+
references,
|
|
219
|
+
namespace ? length_of(declaration, :members) : 0,
|
|
220
|
+
].freeze
|
|
221
|
+
local_index = aggregation.add(package_index: package_index, row: row, construct_index: construct)
|
|
222
|
+
if references.positive? && constant_reference_target?(declaration, namespace)
|
|
223
|
+
positions[name] = [package_index, local_index]
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Dependency stars are addressed by a single ordinal spanning every
|
|
228
|
+
# package, so per-package positions are rebased onto that flat sequence.
|
|
229
|
+
#
|
|
230
|
+
#: (Array[dependency_package], Hash[String, [Integer, Integer]]) -> Hash[String, Integer]
|
|
231
|
+
def global_dependency_ordinals(packages, positions)
|
|
232
|
+
offsets = []
|
|
233
|
+
offset = 0
|
|
234
|
+
packages.each do |package|
|
|
235
|
+
offsets << offset
|
|
236
|
+
offset += package.fetch(:declarations).length
|
|
237
|
+
end
|
|
238
|
+
positions.transform_values! do |package_index, local_index|
|
|
239
|
+
offsets.fetch(package_index) + local_index
|
|
240
|
+
end
|
|
241
|
+
positions.freeze
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
#: (Rubydex::Declaration) -> Integer
|
|
245
|
+
def namespace_kind(declaration)
|
|
246
|
+
case declaration
|
|
247
|
+
when Rubydex::Class then CLASS
|
|
248
|
+
when Rubydex::Module then MODULE
|
|
249
|
+
else OTHER_KIND
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
#: (Rubydex::Declaration, bool) -> bool
|
|
254
|
+
def constant_reference_target?(declaration, namespace)
|
|
255
|
+
namespace ||
|
|
256
|
+
declaration.is_a?(Rubydex::Constant) ||
|
|
257
|
+
declaration.is_a?(Rubydex::ConstantAlias)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
#: (Rubydex::Declaration, Rubydex::Definition) -> bool
|
|
261
|
+
def canonical_definition?(declaration, definition)
|
|
262
|
+
(declaration.is_a?(Rubydex::Class) && definition.is_a?(Rubydex::ClassDefinition)) ||
|
|
263
|
+
(declaration.is_a?(Rubydex::Module) && definition.is_a?(Rubydex::ModuleDefinition))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Rubydex collections expose size on some shapes and only count on others,
|
|
267
|
+
# and this pre-1.0 interface can raise from either. A dependency star that
|
|
268
|
+
# cannot report a signal is still a real declaration, so an unreadable
|
|
269
|
+
# count degrades to zero rather than losing the row.
|
|
270
|
+
#
|
|
271
|
+
#: (untyped, Symbol) -> Integer
|
|
272
|
+
def length_of(object, name)
|
|
273
|
+
records = object.public_send(name)
|
|
274
|
+
size = records.size
|
|
275
|
+
size.nil? ? count_of(records) : size
|
|
276
|
+
rescue StandardError
|
|
277
|
+
count_of(records)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
#: (untyped) -> Integer
|
|
281
|
+
def count_of(records)
|
|
282
|
+
records ? records.count : 0
|
|
283
|
+
rescue StandardError
|
|
284
|
+
0
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module RubyLens
|
|
8
|
+
module Index
|
|
9
|
+
# Resolves a git-sourced lockfile entry into the exact files RubyLens may index.
|
|
10
|
+
#
|
|
11
|
+
# Git checkouts carry more authority than RubyGems packages: the gemspec is
|
|
12
|
+
# code from the checkout that names its own require paths, and the tree may
|
|
13
|
+
# hold symlinks pointing anywhere on disk. Every path here is therefore
|
|
14
|
+
# resolved and re-checked against the canonical package root before it
|
|
15
|
+
# becomes indexable, and any breach ends the package rather than narrowing
|
|
16
|
+
# it, so a partially trusted tree can never contribute files.
|
|
17
|
+
class GitPackageSource
|
|
18
|
+
UnsafeRequirePath = Class.new(StandardError)
|
|
19
|
+
UnsafePackageFile = Class.new(StandardError)
|
|
20
|
+
|
|
21
|
+
# Logical roots come from the gemspec and may be symlinked; the canonical
|
|
22
|
+
# root is the resolved directory every indexable file must sit inside.
|
|
23
|
+
PackagePaths = Data.define(
|
|
24
|
+
:logical_root, #: Pathname
|
|
25
|
+
:canonical_root, #: Pathname
|
|
26
|
+
:logical_canonical_root, #: Pathname
|
|
27
|
+
)
|
|
28
|
+
Resolution = Data.define(
|
|
29
|
+
:root, #: Pathname
|
|
30
|
+
:files, #: Array[String]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Nix stores gems in immutable, content-addressed store objects, so a
|
|
34
|
+
# checkout symlinked into one is trustworthy even though it resolves
|
|
35
|
+
# outside the bundle directory.
|
|
36
|
+
class NixStoreProvider
|
|
37
|
+
STORE_ROOT = Pathname("/nix/store").freeze
|
|
38
|
+
STORE_OBJECT_PATTERN = /\A[0123456789abcdfghijklmnpqrsvwxyz]{32}-.+\z/
|
|
39
|
+
|
|
40
|
+
#: (Pathname | String) -> bool
|
|
41
|
+
def trusted?(path)
|
|
42
|
+
path = Pathname(path).expand_path
|
|
43
|
+
return false unless Paths.inside?(path, STORE_ROOT)
|
|
44
|
+
|
|
45
|
+
first_component = path.relative_path_from(STORE_ROOT).each_filename.first
|
|
46
|
+
return false unless first_component
|
|
47
|
+
|
|
48
|
+
STORE_OBJECT_PATTERN.match?(first_component)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: (lockfile: Pathname) -> void
|
|
53
|
+
def initialize(lockfile:)
|
|
54
|
+
@lockfile = lockfile
|
|
55
|
+
@nix_store_provider = NixStoreProvider.new
|
|
56
|
+
@specification_indexes = {}.compare_by_identity
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns a Resolution, or a Symbol naming why the package was skipped.
|
|
60
|
+
# Callers own how a skip is reported, so every reason surfaces through the
|
|
61
|
+
# same warning vocabulary as the other package sources.
|
|
62
|
+
#
|
|
63
|
+
#: (Bundler::LazySpecification) -> (Resolution | Symbol)
|
|
64
|
+
def resolve(locked)
|
|
65
|
+
source = locked.source
|
|
66
|
+
return :local_only_required if source.allow_git_ops?
|
|
67
|
+
|
|
68
|
+
checkout = checkout_path(source)
|
|
69
|
+
return :checkout_unavailable unless checkout.directory?
|
|
70
|
+
|
|
71
|
+
index = (@specification_indexes[source] ||= specification_index(source, checkout))
|
|
72
|
+
specification = index.search(locked).first
|
|
73
|
+
return :specification_unavailable unless specification
|
|
74
|
+
|
|
75
|
+
paths = package_paths(specification, checkout)
|
|
76
|
+
return :unsafe_specification_root unless paths
|
|
77
|
+
|
|
78
|
+
Resolution.new(
|
|
79
|
+
root: paths.canonical_root,
|
|
80
|
+
files: indexable_files(paths, specification.require_paths).freeze,
|
|
81
|
+
)
|
|
82
|
+
rescue UnsafeRequirePath
|
|
83
|
+
:unsafe_require_paths
|
|
84
|
+
rescue UnsafePackageFile
|
|
85
|
+
:unsafe_package_files
|
|
86
|
+
rescue Bundler::BundlerError, Gem::Exception
|
|
87
|
+
:specification_unreadable
|
|
88
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
89
|
+
:checkout_unavailable
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
#: (Bundler::Source::Git) -> Pathname
|
|
95
|
+
def checkout_path(source)
|
|
96
|
+
bundle_root = @lockfile.dirname
|
|
97
|
+
app_config = ENV["BUNDLE_APP_CONFIG"]
|
|
98
|
+
app_config_path = if app_config
|
|
99
|
+
Pathname(app_config).expand_path(bundle_root)
|
|
100
|
+
else
|
|
101
|
+
bundle_root.join(".bundle")
|
|
102
|
+
end
|
|
103
|
+
bundle_path = Pathname(Bundler::Settings.new(app_config_path).path.path).expand_path(bundle_root)
|
|
104
|
+
bundle_path.join("bundler/gems", source.extension_dir_name)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
#: (Bundler::Source::Git, Pathname) -> Bundler::Index
|
|
108
|
+
def specification_index(source, checkout)
|
|
109
|
+
source.__send__(:set_install_path!, checkout)
|
|
110
|
+
source.specs
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#: (Gem::Specification, Pathname) -> PackagePaths?
|
|
114
|
+
def package_paths(specification, checkout)
|
|
115
|
+
logical_root = Pathname(specification.full_gem_path).expand_path
|
|
116
|
+
logical_gemspec = Pathname(specification.loaded_from).expand_path
|
|
117
|
+
return unless Paths.inside?(logical_root, checkout)
|
|
118
|
+
return unless logical_gemspec.dirname == logical_root
|
|
119
|
+
return unless logical_gemspec.file? && logical_root.directory?
|
|
120
|
+
|
|
121
|
+
canonical_checkout = checkout.realpath
|
|
122
|
+
resolved_gemspec_root = logical_gemspec.realpath.dirname
|
|
123
|
+
logical_canonical_root = logical_root.realpath
|
|
124
|
+
return unless resolved_gemspec_root.directory?
|
|
125
|
+
|
|
126
|
+
normal_checkout = !checkout.symlink? &&
|
|
127
|
+
Paths.inside?(resolved_gemspec_root, canonical_checkout) &&
|
|
128
|
+
Paths.inside?(logical_canonical_root, canonical_checkout)
|
|
129
|
+
return unless normal_checkout || @nix_store_provider.trusted?(resolved_gemspec_root)
|
|
130
|
+
|
|
131
|
+
PackagePaths.new(
|
|
132
|
+
logical_root: logical_root,
|
|
133
|
+
canonical_root: normal_checkout ? logical_canonical_root : resolved_gemspec_root,
|
|
134
|
+
logical_canonical_root: logical_canonical_root,
|
|
135
|
+
)
|
|
136
|
+
rescue TypeError, ArgumentError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
#: (PackagePaths, Array[String]) -> Array[String]
|
|
141
|
+
def indexable_files(paths, require_paths)
|
|
142
|
+
logical_require_paths = require_paths.map do |relative_path|
|
|
143
|
+
raise UnsafeRequirePath unless relative_path.is_a?(String)
|
|
144
|
+
|
|
145
|
+
relative = Pathname(relative_path)
|
|
146
|
+
raise UnsafeRequirePath if relative.absolute? || relative.each_filename.include?("..")
|
|
147
|
+
|
|
148
|
+
candidate = paths.logical_root.join(relative).cleanpath
|
|
149
|
+
raise UnsafeRequirePath unless Paths.inside?(candidate, paths.logical_root)
|
|
150
|
+
|
|
151
|
+
candidate
|
|
152
|
+
rescue ArgumentError, EncodingError
|
|
153
|
+
raise UnsafeRequirePath
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
files = []
|
|
157
|
+
visited_directories = Set.new
|
|
158
|
+
logical_require_paths.each do |path|
|
|
159
|
+
next unless path.exist? || path.symlink?
|
|
160
|
+
|
|
161
|
+
traverse(path, paths, files, visited_directories, Set.new)
|
|
162
|
+
end
|
|
163
|
+
files.uniq.sort
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Descends one require path. `active_directories` carries the current
|
|
167
|
+
# branch so a symlink cycle raises instead of recursing forever, while
|
|
168
|
+
# `visited_directories` spans the whole walk so a diamond of symlinks is
|
|
169
|
+
# merely skipped.
|
|
170
|
+
#
|
|
171
|
+
#: (Pathname, PackagePaths, Array[String], Set[Pathname], Set[Pathname]) -> void
|
|
172
|
+
def traverse(path, paths, files, visited_directories, active_directories)
|
|
173
|
+
resolved = path.realpath
|
|
174
|
+
if resolved.file?
|
|
175
|
+
raise UnsafePackageFile unless Paths.inside?(resolved, paths.canonical_root)
|
|
176
|
+
|
|
177
|
+
files << resolved.to_s if INDEXABLE_EXTENSIONS.include?(path.extname)
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
return unless resolved.directory?
|
|
181
|
+
|
|
182
|
+
unless Paths.inside?(resolved, paths.logical_canonical_root) || Paths.inside?(resolved, paths.canonical_root)
|
|
183
|
+
raise UnsafePackageFile
|
|
184
|
+
end
|
|
185
|
+
raise UnsafePackageFile if active_directories.include?(resolved)
|
|
186
|
+
return if visited_directories.include?(resolved)
|
|
187
|
+
|
|
188
|
+
active_directories.add(resolved)
|
|
189
|
+
begin
|
|
190
|
+
path.children.sort_by(&:to_s).each do |child|
|
|
191
|
+
traverse(child, paths, files, visited_directories, active_directories)
|
|
192
|
+
end
|
|
193
|
+
visited_directories.add(resolved)
|
|
194
|
+
ensure
|
|
195
|
+
active_directories.delete(resolved)
|
|
196
|
+
end
|
|
197
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
198
|
+
raise UnsafePackageFile
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "source_path"
|
|
5
|
+
|
|
6
|
+
module RubyLens
|
|
7
|
+
module Index
|
|
8
|
+
# Answers the questions the collectors ask about a Rubydex location: which
|
|
9
|
+
# file it names, whether that file is in the workspace, whether it is test
|
|
10
|
+
# or core code, and which package owns it.
|
|
11
|
+
#
|
|
12
|
+
# Rubydex hands out a fresh wrapper and a fresh URI string on every call and
|
|
13
|
+
# a codebase has far more definitions than files, so each answer is memoized
|
|
14
|
+
# by URI. That memoization is why the collectors can afford to ask per
|
|
15
|
+
# definition instead of hoisting the questions themselves.
|
|
16
|
+
class LocationIndex
|
|
17
|
+
# 1 for a file under a test directory, 0 for any other workspace file.
|
|
18
|
+
TEST = 1
|
|
19
|
+
CORE = 0
|
|
20
|
+
TEST_SEGMENTS = %w[test tests spec specs feature features].freeze
|
|
21
|
+
|
|
22
|
+
#: Set[String]
|
|
23
|
+
attr_reader :package_document_paths
|
|
24
|
+
|
|
25
|
+
# The manifest is duck-typed: production passes a Manifest, tests pass
|
|
26
|
+
# stubs answering the same four questions.
|
|
27
|
+
#
|
|
28
|
+
#: (untyped manifest) -> void
|
|
29
|
+
def initialize(manifest)
|
|
30
|
+
@manifest = manifest
|
|
31
|
+
@path_by_uri = {}
|
|
32
|
+
@workspace_by_uri = {}
|
|
33
|
+
@scope_by_uri = {}
|
|
34
|
+
@package_index_by_uri = {}
|
|
35
|
+
@package_document_paths = Set.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# One sweep over the indexed documents resolves each URI once, recording
|
|
39
|
+
# which of them belong to audited packages and returning the
|
|
40
|
+
# [document, path] pairs so callers need not re-enumerate or re-parse.
|
|
41
|
+
#
|
|
42
|
+
#: (Rubydex::Graph) -> Array[[Rubydex::Document, String]]
|
|
43
|
+
def resolve_documents(graph)
|
|
44
|
+
audited = @manifest.packages.flat_map(&:files).to_set
|
|
45
|
+
documents_with_paths = []
|
|
46
|
+
graph.documents.each do |document|
|
|
47
|
+
path = path_for(document.uri)
|
|
48
|
+
next unless path
|
|
49
|
+
|
|
50
|
+
documents_with_paths << [document, path]
|
|
51
|
+
@package_document_paths << path if audited.include?(path)
|
|
52
|
+
end
|
|
53
|
+
documents_with_paths
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: (String) -> String?
|
|
57
|
+
def path_for(uri)
|
|
58
|
+
@path_by_uri.fetch(uri) do
|
|
59
|
+
@path_by_uri[uri] = SourcePath.from_file_uri(uri)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (Rubydex::Location) -> bool
|
|
64
|
+
def workspace?(location)
|
|
65
|
+
uri = location.uri
|
|
66
|
+
@workspace_by_uri.fetch(uri) do
|
|
67
|
+
path = path_for(uri)
|
|
68
|
+
@workspace_by_uri[uri] = path ? @manifest.workspace_path?(path) : false
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# TEST, CORE, or nil when the URI has no workspace-relative path.
|
|
75
|
+
#
|
|
76
|
+
#: (String) -> Integer?
|
|
77
|
+
def scope_for(uri)
|
|
78
|
+
@scope_by_uri.fetch(uri) do
|
|
79
|
+
relative = @manifest.relative_workspace_path(path_for(uri))
|
|
80
|
+
@scope_by_uri[uri] =
|
|
81
|
+
if relative
|
|
82
|
+
relative.split(File::SEPARATOR).any? { |segment| TEST_SEGMENTS.include?(segment) } ? TEST : CORE
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Package attribution is deliberately limited to documents Rubydex actually
|
|
88
|
+
# indexed, so a path that merely sits inside a package root cannot claim it.
|
|
89
|
+
#
|
|
90
|
+
#: (Rubydex::Location) -> Integer?
|
|
91
|
+
def package_index_for(location)
|
|
92
|
+
uri = location.uri
|
|
93
|
+
@package_index_by_uri.fetch(uri) do
|
|
94
|
+
path = path_for(uri)
|
|
95
|
+
@package_index_by_uri[uri] =
|
|
96
|
+
if path && @package_document_paths.include?(path)
|
|
97
|
+
@manifest.package_index_for(path)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|