archspec 1.1.0 → 1.2.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.
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Facts
5
+ # Framework-neutral facts, established by source semantics or by reflection.
6
+ # Locations retain the original evidence, even when it lives in a different
7
+ # file from the owner.
8
+ #
9
+ # Owners, sources, and targets can be graph ConstantNodes, constant names,
10
+ # or the runtime classes and modules themselves. Locations can be
11
+ # SourceLocations, <tt>[path, line]</tt> pairs such as
12
+ # <tt>Module#const_source_location</tt> returns, backtrace locations a macro
13
+ # recorded with +caller_locations+, or anything with a +source_location+:
14
+ #
15
+ # facts.reference(source: Invoice, target: Customer, location: ["app/models/invoice.rb", 3])
16
+ # facts.methods(owner: "Invoice", names: %w[customer customer=], location: declaration_site)
17
+ #
18
+ # Use #apply for an in-memory semantic pass, or #to_document and Facts.write
19
+ # for an explicit external producer. Both routes use the same validation and
20
+ # application code. Building facts never mutates the graph.
21
+ class Builder
22
+ MODULE_NAME = Module.instance_method(:name)
23
+
24
+ attr_reader :graph, :producer
25
+
26
+ def initialize(graph, producer:)
27
+ @graph = graph
28
+ @producer = producer.to_s
29
+ @entries = LIST_KEYS.to_h { |key| [key, []] }
30
+ end
31
+
32
+ def reference(source:, target:, location:)
33
+ location = location_of(location)
34
+ source = constant(source, location)
35
+ add('references', entry_for(source, location).merge('source' => source.name, 'target' => name_of(target)))
36
+ end
37
+
38
+ def methods(owner:, names:, location:, scope: :instance, visibility: :public,
39
+ signatures: [], alias_target: nil, mode: :if_missing, installation: nil)
40
+ unless alias_target.nil? || alias_target.is_a?(String) || alias_target.is_a?(Symbol)
41
+ raise Error, 'alias target must be a String or Symbol'
42
+ end
43
+ location = location_of(location)
44
+ owner = constant(owner, location)
45
+ entry = entry_for(owner, location).merge('owner' => owner.name, 'names' => Array(names).map(&:to_s),
46
+ 'scope' => scope.to_s, 'visibility' => visibility.to_s, 'mode' => mode.to_s,
47
+ 'signatures' => signatures.map do |signature|
48
+ signature.to_h.transform_keys(&:to_s).transform_values do |value|
49
+ value.is_a?(Array) ? value.map(&:to_s) : value
50
+ end
51
+ end)
52
+ entry['alias_target'] = alias_target.to_s unless alias_target.nil?
53
+ entry['installation'] = location_for(location_of(installation)) if installation
54
+ add('methods', entry)
55
+ end
56
+
57
+ # Preserve a source definition's metadata while installing it on an owner.
58
+ # An installation site gives callback definitions their Ruby precedence:
59
+ # later local definitions win, earlier definitions are replaced.
60
+ def method_definition(owner:, definition:, installation: nil)
61
+ methods(owner: owner, names: [definition.name], scope: definition.scope, location: definition.location,
62
+ visibility: definition.visibility, signatures: definition.signatures, alias_target: definition.alias_target,
63
+ mode: installation ? :define : :append, installation: installation)
64
+ end
65
+
66
+ # Emit mixins in installation order, including singleton prepends.
67
+ def mixin(owner:, target:, kind:, location:)
68
+ location = location_of(location)
69
+ owner = constant(owner, location)
70
+ add('mixins', entry_for(owner, location).merge('owner' => owner.name,
71
+ 'target' => name_of(target), 'kind' => kind.to_s))
72
+ end
73
+
74
+ # Bind an existing receiverless call without changing its lexical owner.
75
+ # Emit every consumer of a shared callback before applying the batch.
76
+ def bind_receiver(edge:, receiver:, scope:)
77
+ source = @graph.constants_named(edge.from_constant).find { |node| node.path == edge.from_path }
78
+ raise Error, 'receiver fact requires a call owned by an analyzed constant' unless source
79
+
80
+ add('receivers', entry_for(source, edge.location).merge('source' => source.name,
81
+ 'name' => edge.to, 'receiver' => name_of(receiver), 'scope' => scope.to_s))
82
+ end
83
+
84
+ def expose_methods(source:, owner:, scope:)
85
+ source = constant(source, nil)
86
+ entry = entry_for(source, source.location).merge('source' => source.name,
87
+ 'owner' => name_of(owner), 'scope' => scope.to_s)
88
+ owners = @graph.constants_named(name_of(owner)).map { |node| relative(node.path) }.uniq - [entry['source_path']]
89
+ entry['depends_on'] = owners.sort unless owners.empty?
90
+ add('exposures', entry)
91
+ end
92
+
93
+ def gap(source:, message:, location:)
94
+ location = location_of(location)
95
+ source = constant(source, location)
96
+ add('gaps', entry_for(source, location).merge('source' => source.name, 'message' => message))
97
+ end
98
+
99
+ # Settle a static analysis gap this producer observed, such as a
100
+ # conditional concern callback. The location is the gap's own location.
101
+ # Pass the +consumers+ you inspected when the gap is about a module's
102
+ # includers: their files are fingerprinted, and a check reports any
103
+ # includer added later instead of keeping the gap settled.
104
+ def resolve(source:, location:, message: nil, consumers: nil)
105
+ location = location_of(location)
106
+ source = constant(source, location)
107
+ entry = entry_for(source, location).merge('source' => source.name)
108
+ entry['message'] = message if message
109
+ if consumers
110
+ nodes = consumers.map { |consumer| constant(consumer, nil) }
111
+ entry['consumers'] = nodes.map(&:name).uniq.sort
112
+ paths = nodes.map { |node| relative(node.path) }.uniq - [entry['source_path'], entry['path']]
113
+ entry['depends_on'] = paths.sort unless paths.empty?
114
+ end
115
+ add('resolves', entry)
116
+ end
117
+
118
+ def apply
119
+ document = Document.new(@graph, facts, @producer)
120
+ Application.apply(@graph, [document.compile.with(producer: nil)])
121
+ end
122
+
123
+ # Validate before publication, so malformed evidence cannot replace a
124
+ # producer's previous snapshot. +inputs+ are global files whose change
125
+ # invalidates the whole document; +sources+ are patterns of analyzed
126
+ # files whose change invalidates the facts they cite. +env+ records
127
+ # environment variables that must match when a check sets them.
128
+ def to_document(facts_path: 'archspec_facts', inputs: DEFAULT_INPUTS, sources: [], env: {})
129
+ cited = @entries.values.flatten.flat_map do |entry|
130
+ [entry['path'], entry['source_path'], entry.dig('installation', 'path'), *entry['depends_on']]
131
+ end.compact.uniq.map { |path| File.expand_path(path, @graph.root) }
132
+ document = { 'version' => VERSION, 'producer' => @producer,
133
+ 'env' => env.to_h { |name, value| [name.to_s, value.to_s] },
134
+ 'inputs' => inputs.map(&:to_s), 'sources' => sources.map(&:to_s),
135
+ 'snapshot' => Facts.fingerprint(@graph, inputs: inputs, sources: sources, cited: cited,
136
+ excluding: facts_path) }.merge(facts)
137
+ reader = Document.new(@graph, document, @producer)
138
+ Application.validate(@graph, [reader.compile])
139
+ document
140
+ end
141
+
142
+ def count(key)
143
+ @entries.fetch(key.to_s).size
144
+ end
145
+
146
+ private
147
+
148
+ def add(key, entry)
149
+ @entries[key] << entry
150
+ entry
151
+ end
152
+
153
+ def facts
154
+ { 'version' => VERSION, 'producer' => @producer }.merge(@entries.transform_values(&:uniq))
155
+ end
156
+
157
+ def constant(value, location)
158
+ return value if value.is_a?(ConstantNode)
159
+
160
+ name = name_of(value)
161
+ nodes = @graph.constants_named(name)
162
+ raise Error, "#{name} is not an analyzed constant" if nodes.empty?
163
+
164
+ candidates = [
165
+ nodes.select { |node| location && node.path == location.path },
166
+ nodes.reject(&:namespace_only),
167
+ nodes
168
+ ]
169
+ match = candidates.find(&:one?)
170
+ raise Error, "#{name} is defined in several files; pass its graph node instead" unless match
171
+
172
+ match.first
173
+ end
174
+
175
+ def name_of(value)
176
+ name = case value
177
+ when Module then MODULE_NAME.bind_call(value)
178
+ when ConstantNode then value.name
179
+ else value.to_s
180
+ end
181
+ raise Error, 'facts cannot name an anonymous class or module' if name.nil? || name.empty?
182
+
183
+ name.delete_prefix('::')
184
+ end
185
+
186
+ def location_of(value)
187
+ value = value.source_location if value.respond_to?(:source_location)
188
+ value = [value.path, value.lineno] if value.respond_to?(:lineno) && value.respond_to?(:path)
189
+ case value
190
+ when SourceLocation
191
+ value
192
+ when Array
193
+ path, line = value
194
+ raise Error, "invalid location #{value.inspect}" unless path && line.is_a?(Integer) && line.positive?
195
+
196
+ line_location(File.expand_path(path.to_s, @graph.root), line)
197
+ else
198
+ raise Error, "invalid location #{value.inspect}"
199
+ end
200
+ end
201
+
202
+ # A line given without columns spans its code, so diagnostics underline
203
+ # the declaration rather than the indentation before it.
204
+ def line_location(path, line)
205
+ text = File.file?(path) ? File.foreach(path).drop(line - 1).first.to_s.chomp : ''
206
+ start = (text.index(/\S/) || 0) + 1
207
+ SourceLocation.new(path, line, start, line, [text.rstrip.bytesize + 1, start].max)
208
+ end
209
+
210
+ def entry_for(source, location)
211
+ location_for(location).merge('source_path' => relative(source.path))
212
+ end
213
+
214
+ def location_for(location)
215
+ location.to_h.transform_keys(&:to_s).merge('path' => relative(location.path))
216
+ end
217
+
218
+ def relative(path)
219
+ Pathname(path).relative_path_from(Pathname(@graph.root)).to_s
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Facts
5
+ # Decodes a complete batch before any graph mutation. Both the in-memory
6
+ # builder and external YAML documents must satisfy this contract.
7
+ class Document
8
+ LOCATION_KEYS = %w[path line column end_line end_column].freeze
9
+ ENTRY_LOCATION_KEYS = LOCATION_KEYS + %w[source_path depends_on]
10
+ LEGACY_LIST_KEYS = %w[references methods gaps].freeze
11
+ LEGACY_ENVELOPE_KEYS = %w[version producer environment snapshot].freeze
12
+ ENVELOPE_KEYS = %w[version producer env inputs sources snapshot].freeze
13
+ METHOD_KEYS = %w[visibility signatures alias_target mode installation].freeze
14
+ SCOPES = %w[instance class].freeze
15
+
16
+ def initialize(graph, document, label)
17
+ @graph = graph
18
+ @document = document
19
+ @label = label
20
+ @lines = {}
21
+ @stale = Set.new
22
+ @removed = Set.new
23
+ end
24
+
25
+ # Compiles an imported document, or returns nil when it no longer
26
+ # describes the source. Stale files are reported and their facts skipped.
27
+ def compile_fresh(excluding:)
28
+ validate_envelope
29
+ invalid('version 2 facts require a snapshot') if @document['version'] != 1 && @document['snapshot'].nil?
30
+ reason = @document['version'] == 1 ? legacy_staleness(excluding) : staleness(excluding)
31
+ if reason
32
+ stale_diagnostic(@label, "#{producer} facts are out of date: #{reason}; run `archspec reflect`")
33
+ return
34
+ end
35
+
36
+ @stale.each do |path|
37
+ stale_diagnostic(path, "#{producer} facts are out of date for this file; run `archspec reflect`")
38
+ end
39
+ compile
40
+ end
41
+
42
+ def compile
43
+ validate_envelope
44
+ Operations.new(*LIST_KEYS.map do |key|
45
+ Array(@document[key]).reject { |entry| stale_entry?(entry) }
46
+ .flat_map do |entry|
47
+ validate_dependencies(entry)
48
+ public_send("decode_#{key}", entry)
49
+ end
50
+ end, producer)
51
+ end
52
+
53
+ def decode_references(entry)
54
+ validate_entry(entry, %w[source target])
55
+ Reference.new(source_for(entry, 'source'), constant_name(entry['target']), location_for(entry))
56
+ end
57
+
58
+ def decode_methods(entry)
59
+ validate_entry(entry, %w[owner scope names] + (@document['version'] == 1 ? [] : METHOD_KEYS))
60
+ owner = source_for(entry, 'owner')
61
+ location = location_for(entry)
62
+ scope = enum(entry['scope'], SCOPES, 'method scope')
63
+ names = string_list(entry['names'], 'method names', nonempty: true)
64
+ visibility = enum(entry.fetch('visibility', 'public'), %w[public protected private], 'method visibility')
65
+ mode = enum(entry.fetch('mode', 'if_missing'), %w[if_missing append define], 'method mode')
66
+ installation = entry['installation']
67
+ if mode == :define
68
+ invalid('define mode requires an installation location') unless installation.is_a?(Hash)
69
+ invalid('invalid installation fields') unless (installation.keys - LOCATION_KEYS).empty?
70
+ installation = location_for(installation)
71
+ elsif !installation.nil?
72
+ invalid('installation requires define mode')
73
+ end
74
+ signatures = entry.fetch('signatures', [])
75
+ invalid('expected a list of signatures') unless signatures.is_a?(Array)
76
+ signatures = signatures.map { |signature| signature_for(signature) }
77
+ alias_target = entry['alias_target']
78
+ string(alias_target, 'alias target') unless alias_target.nil?
79
+ names.map do |name|
80
+ definition = MethodDefinition.new(owner.name, name.to_sym, scope, location, visibility, signatures, alias_target&.to_sym)
81
+ Method.new(owner, definition, mode, installation)
82
+ end
83
+ end
84
+
85
+ def decode_mixins(entry)
86
+ validate_entry(entry, %w[owner target kind])
87
+ Mixin.new(source_for(entry, 'owner'), constant_name(entry['target']),
88
+ enum(entry['kind'], %w[include prepend extend singleton_prepend], 'mixin kind'), location_for(entry))
89
+ end
90
+
91
+ def decode_receivers(entry)
92
+ validate_entry(entry, %w[source name receiver scope])
93
+ source = source_for(entry, 'source')
94
+ name = string(entry['name'], 'call name')
95
+ location = location_for(entry)
96
+ unless receiverless_calls.include?([source.name, source.path, name, location])
97
+ invalid('receiver binding must identify an existing receiverless call with its complete source range')
98
+ end
99
+ Receiver.new(source, name, location, constant_name(entry['receiver']), enum(entry['scope'], SCOPES, 'receiver scope'))
100
+ end
101
+
102
+ def decode_exposures(entry)
103
+ validate_entry(entry, %w[source owner scope])
104
+ source = source_for(entry, 'source')
105
+ location_for(entry)
106
+ invalid('method exposure source must be a module') unless source.module?
107
+ owner = constant_name(entry['owner'])
108
+ invalid("method exposure owner #{owner} is not analyzed") if @graph.constants_named(owner).empty?
109
+ Exposure.new(source, owner, enum(entry['scope'], SCOPES, 'exposure scope'))
110
+ end
111
+
112
+ def decode_gaps(entry)
113
+ validate_entry(entry, %w[source message])
114
+ Gap.new(source_for(entry, 'source'), string(entry['message'], 'gap message'), location_for(entry))
115
+ end
116
+
117
+ def decode_resolves(entry)
118
+ validate_entry(entry, %w[source message consumers])
119
+ source = source_for(entry, 'source')
120
+ location = location_for(entry)
121
+ message = entry['message'].nil? ? nil : string(entry['message'], 'gap message')
122
+ return [] if new_consumers?(source, entry['consumers'])
123
+
124
+ unless @graph.edges.any? do |edge|
125
+ edge.type == :dynamic_feature && edge.from_constant == source.name && edge.from_path == source.path &&
126
+ edge.location == location && (message.nil? || edge.to == message)
127
+ end
128
+ invalid('a resolution must identify an existing analysis gap with its complete source range')
129
+ end
130
+ Resolve.new(source, message, location)
131
+ end
132
+
133
+ private
134
+
135
+ def producer
136
+ @document['producer']
137
+ end
138
+
139
+ def legacy_staleness(excluding)
140
+ return 'source or configuration changed' unless @document['snapshot'] == Facts.snapshot(@graph, excluding: excluding)
141
+
142
+ environment = @document['environment']
143
+ return unless environment && ENV['RAILS_ENV'] && ENV['RAILS_ENV'] != environment
144
+
145
+ "captured for RAILS_ENV=#{environment}, but RAILS_ENV is #{ENV['RAILS_ENV']}"
146
+ end
147
+
148
+ # Changes to global inputs, or to the recorded environment, invalidate
149
+ # the document. A changed, added, or removed analyzed file only
150
+ # invalidates the facts that cite it.
151
+ def staleness(excluding)
152
+ (@document['env'] || {}).each do |name, value|
153
+ current = ENV.fetch(name, nil)
154
+ return "captured with #{name}=#{value}, but it is #{current}" if current && current != value
155
+ end
156
+
157
+ recorded = @document['snapshot'] || {}
158
+ inputs = Facts.input_files(@graph, @document['inputs'] || [], excluding: excluding)
159
+ sources = Facts.source_files(@graph, @document['sources'] || [])
160
+ candidates = recorded.keys.map { |path| File.expand_path(path, @graph.root) } | inputs | sources
161
+ root = Pathname(@graph.root)
162
+ candidates.sort.each do |path|
163
+ relative = Pathname(path).relative_path_from(root).to_s
164
+ next if recorded[relative] == Facts.digest(path)
165
+ return "#{relative} changed" if inputs.include?(path) || input_pattern?(relative)
166
+
167
+ # A covered file that is gone takes its facts with it; one that
168
+ # changed reports them as stale.
169
+ (@graph.files.key?(path) ? @stale : @removed) << path
170
+ end
171
+ nil
172
+ end
173
+
174
+ def stale_diagnostic(path, message)
175
+ @graph.fact_diagnostics << Diagnostic.new(rule: STALE_RULE, message: message,
176
+ location: SourceLocation.point(File.expand_path(path), 1, 1),
177
+ evidence: Pathname(File.expand_path(@label)).relative_path_from(Pathname(@graph.root)).to_s)
178
+ end
179
+
180
+ def stale_entry?(entry)
181
+ return false if (@stale.empty? && @removed.empty?) || !entry.is_a?(Hash)
182
+
183
+ installation = entry['installation'].is_a?(Hash) ? entry['installation']['path'] : nil
184
+ dependencies = entry['depends_on'].is_a?(Array) ? entry['depends_on'] : []
185
+ [entry['path'], entry['source_path'], installation, *dependencies].any? do |path|
186
+ next false unless path.is_a?(String)
187
+
188
+ expanded = File.expand_path(path, @graph.root)
189
+ @stale.include?(expanded) || @removed.include?(expanded)
190
+ end
191
+ end
192
+
193
+ def input_pattern?(relative)
194
+ (@document['inputs'] || []).any? do |pattern|
195
+ File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
196
+ end
197
+ end
198
+
199
+ # Files an entry depends on beyond its own location, such as the owner
200
+ # of an exposure. They must be fingerprinted so an edit is noticed.
201
+ def validate_dependencies(entry)
202
+ return unless entry.is_a?(Hash) && entry.key?('depends_on')
203
+
204
+ paths = string_list(entry['depends_on'], 'dependencies')
205
+ snapshot = @document['snapshot']
206
+ missing = snapshot && paths.find { |path| !snapshot.key?(path) }
207
+ invalid("the snapshot does not fingerprint #{missing}") if missing
208
+ end
209
+
210
+ # A resolution holds only for the consumers the producer inspected. A
211
+ # consumer added since then was never observed, so it is reported.
212
+ def new_consumers?(source, recorded)
213
+ return false if recorded.nil?
214
+
215
+ string_list(recorded, 'consumers')
216
+ added = @graph.consumers_of(source.name).reject { |node| recorded.include?(node.name) }
217
+ added.each do |node|
218
+ stale_diagnostic(node.path, "#{producer} facts have not observed this consumer of #{source.name}; " \
219
+ 'run `archspec reflect`')
220
+ end
221
+ added.any?
222
+ end
223
+
224
+ # Only calls owned by constants that receiver facts name are indexed.
225
+ def receiverless_calls
226
+ @receiverless_calls ||= begin
227
+ sources = Array(@document['receivers']).filter_map { |entry| entry['source'].to_s.delete_prefix('::') if entry.is_a?(Hash) }.to_set
228
+ @graph.edges.each_with_object(Set.new) do |edge, calls|
229
+ next unless sources.include?(edge.from_constant) && edge.type == :calls_named_method && edge.receiver == :none
230
+
231
+ calls << [edge.from_constant, edge.from_path, edge.to, edge.location]
232
+ end
233
+ end
234
+ end
235
+
236
+ def validate_envelope
237
+ unless @document.is_a?(Hash) && @document['version'].is_a?(Integer) && [1, VERSION].include?(@document['version'])
238
+ invalid("expected version 1 or #{VERSION}")
239
+ end
240
+ legacy = @document['version'] == 1
241
+ lists = legacy ? LEGACY_LIST_KEYS : LIST_KEYS
242
+ keys = (legacy ? LEGACY_ENVELOPE_KEYS : ENVELOPE_KEYS) + lists
243
+ invalid('unknown document fields') unless (@document.keys - keys).empty?
244
+ string(@document['producer'], 'producer')
245
+ unless @document['environment'].nil? || @document['environment'].is_a?(String)
246
+ invalid('expected an environment string')
247
+ end
248
+ invalid('expected fact lists') unless lists.all? { |key| @document[key].nil? || @document[key].is_a?(Array) }
249
+ validate_freshness_fields unless legacy
250
+ end
251
+
252
+ def validate_freshness_fields
253
+ env = @document['env']
254
+ unless env.nil? || (env.is_a?(Hash) && env.all? { |key, value| key.is_a?(String) && value.is_a?(String) })
255
+ invalid('expected env to map variable names to strings')
256
+ end
257
+ %w[inputs sources].each do |key|
258
+ string_list(@document[key], "#{key} patterns") unless @document[key].nil?
259
+ end
260
+ snapshot = @document['snapshot']
261
+ return if snapshot.nil?
262
+
263
+ unless snapshot.is_a?(Hash) && snapshot.all? { |key, value| key.is_a?(String) && value.is_a?(String) }
264
+ invalid('expected a snapshot of file digests')
265
+ end
266
+ end
267
+
268
+ def signature_for(value)
269
+ unless value.is_a?(Hash) && (value.keys - MethodSignature.members.map(&:to_s)).empty?
270
+ invalid('invalid method signature fields')
271
+ end
272
+ counts = %w[required optional].to_h do |key|
273
+ number = value.fetch(key, 0)
274
+ invalid("invalid signature #{key}") unless number.is_a?(Integer) && number >= 0
275
+ [key.to_sym, number]
276
+ end
277
+ flags = %w[rest keyword_rest block forward].to_h do |key|
278
+ flag = value.fetch(key, false)
279
+ invalid("invalid signature #{key}") unless flag == true || flag == false
280
+ [key.to_sym, flag]
281
+ end
282
+ keywords = %w[keywords optional_keywords].to_h do |key|
283
+ [key.to_sym, string_list(value.fetch(key, []), "signature #{key}").map(&:to_sym)]
284
+ end
285
+ invalid('required and optional keywords overlap') unless (keywords[:keywords] & keywords[:optional_keywords]).empty?
286
+ MethodSignature.new(
287
+ counts.fetch(:required),
288
+ counts.fetch(:optional),
289
+ flags.fetch(:rest),
290
+ keywords.fetch(:keywords),
291
+ keywords.fetch(:optional_keywords),
292
+ flags.fetch(:keyword_rest),
293
+ flags.fetch(:block),
294
+ flags.fetch(:forward)
295
+ )
296
+ end
297
+
298
+ def validate_entry(entry, keys)
299
+ invalid('invalid entry fields') unless entry.is_a?(Hash) && (entry.keys - keys - ENTRY_LOCATION_KEYS).empty?
300
+ end
301
+
302
+ def enum(value, values, label)
303
+ invalid("invalid #{label}: expected #{values.join(', ')}") unless values.include?(value)
304
+ value.to_sym
305
+ end
306
+
307
+ def string(value, label)
308
+ invalid("expected a nonempty #{label}") unless value.is_a?(String) && !value.empty?
309
+ value
310
+ end
311
+
312
+ def string_list(value, label, nonempty: false)
313
+ unless value.is_a?(Array) && (!nonempty || !value.empty?) && value.all? { |item| item.is_a?(String) && !item.empty? }
314
+ invalid("invalid #{label}")
315
+ end
316
+ value
317
+ end
318
+
319
+ def source_for(entry, key)
320
+ name = constant_name(entry[key])
321
+ source_path = project_path(entry['source_path'] || entry['path'])
322
+ source = @graph.constants_named(name).find { |node| node.path == source_path }
323
+ invalid("#{name} is not defined in #{entry['source_path'] || entry['path']}") unless source
324
+ source
325
+ end
326
+
327
+ def constant_name(value)
328
+ unless value.is_a?(String) && /\A(?:::)?[[:upper:]][[:alnum:]_]*(?:::[[:upper:]][[:alnum:]_]*)*\z/.match?(value)
329
+ invalid('invalid constant name')
330
+ end
331
+ value.delete_prefix('::')
332
+ end
333
+
334
+ # Every cited file must be analyzed and, in a version 2 snapshot,
335
+ # fingerprinted, so a later edit to it is always detected.
336
+ def project_path(value)
337
+ invalid('invalid source path') unless value.is_a?(String) && !Pathname(value).absolute?
338
+ expanded = File.expand_path(value, @graph.root)
339
+ invalid("source path #{value} is not analyzed") unless @graph.files.key?(expanded)
340
+ snapshot = @document['snapshot']
341
+ if @document['version'] != 1 && snapshot && !snapshot.key?(value)
342
+ invalid("the snapshot does not fingerprint #{value}")
343
+ end
344
+ expanded
345
+ end
346
+
347
+ def location_for(entry)
348
+ source = project_path(entry['path'])
349
+ line = entry['line']
350
+ column = entry.fetch('column', 1)
351
+ end_line = entry.fetch('end_line', line)
352
+ end_column = entry.fetch('end_column', column)
353
+ lines = @lines[source] ||= File.readlines(source)
354
+ valid = [line, column, end_line, end_column].all? { |value| value.is_a?(Integer) && value.positive? }
355
+ valid &&= line <= lines.size && end_line <= lines.size &&
356
+ ([line, column] <=> [end_line, end_column]) <= 0 &&
357
+ column <= lines[line - 1].chomp.bytesize + 1 && end_column <= lines[end_line - 1].chomp.bytesize + 1
358
+ invalid('invalid source location') unless valid
359
+ SourceLocation.new(source, line, column, end_line, end_column)
360
+ end
361
+
362
+ def invalid(message)
363
+ raise Error, "invalid facts file #{@label}: #{message}"
364
+ end
365
+ end
366
+ end
367
+ end