# frozen_string_literal: true
require 'pathname'
require 'observer'
module Solargraph
# A Library handles coordination between a Workspace and an ApiMap.
#
class Library
include Logging
include Observable
# @return [Solargraph::Workspace]
attr_reader :workspace
# @return [String, nil]
attr_reader :name
# @return [Source, nil]
attr_reader :current
# @return [LanguageServer::Progress, nil]
attr_reader :cache_progress
# @param workspace [Solargraph::Workspace]
# @param name [String, nil]
def initialize workspace = Solargraph::Workspace.new, name = nil
@workspace = workspace
@name = name
# @type [Integer, nil]
@total = nil
# @type [Source, nil]
@current = nil
@sync_count = 0
end
def inspect
# Let's not deal with insane data dumps in spec failures
to_s
end
# True if the ApiMap is up to date with the library's workspace and open
# files.
#
# @return [Boolean]
def synchronized?
@sync_count < 2
end
# Attach a source to the library.
#
# The attached source does not need to be a part of the workspace. The
# library will include it in the ApiMap while it's attached. Only one
# source can be attached to the library at a time.
#
# @param source [Source, nil]
# @return [void]
def attach source
if @current && (!source || @current.filename != source.filename) && source_map_hash.key?(@current.filename) && !workspace.has_file?(@current.filename)
source_map_hash.delete @current.filename
source_map_external_require_hash.delete @current.filename
@external_requires = nil
end
changed = source && @current != source
@current = source
maybe_map @current
catalog if changed
end
# True if the specified file is currently attached.
#
# @param filename [String]
# @return [Boolean]
def attached? filename
!@current.nil? && @current.filename == filename
end
alias open? attached?
# Detach the specified file if it is currently attached to the library.
#
# @param filename [String]
# @return [Boolean] True if the specified file was detached
def detach filename
return false if @current.nil? || @current.filename != filename
attach nil
true
end
# True if the specified file is included in the workspace (but not
# necessarily open).
#
# @param filename [String]
# @return [Boolean]
def contain? filename
workspace.has_file?(filename)
end
# Create a source to be added to the workspace. The file is ignored if it is
# neither open in the library nor included in the workspace.
#
# @param filename [String]
# @param text [String] The contents of the file
# @return [Boolean] True if the file was added to the workspace.
def create filename, text
return false unless contain?(filename) || open?(filename)
source = Solargraph::Source.load_string(text, filename)
workspace.merge(source)
true
end
# Create file sources from files on disk. A file is ignored if it is
# neither open in the library nor included in the workspace.
#
# @param filenames [Array<String>]
# @return [Boolean] True if at least one file was added to the workspace.
def create_from_disk *filenames
sources = filenames
.reject { |filename| File.directory?(filename) || !File.exist?(filename) }
.map { |filename| Solargraph::Source.load_string(File.read(filename), filename) }
result = workspace.merge(*sources)
sources.each { |source| maybe_map source }
result
end
# Delete files from the library. Deleting a file will make it unavailable
# for checkout and optionally remove it from the workspace unless the
# workspace configuration determines that it should still exist.
#
# @param filenames [Array<String>]
# @return [Boolean] True if any file was deleted
def delete *filenames
result = false
filenames.each do |filename|
detach filename
result ||= workspace.remove(filename)
end
result
end
# Close a file in the library. Closing a file will make it unavailable for
# checkout although it may still exist in the workspace.
#
# @param filename [String]
# @return [void]
def close filename
return unless @current&.filename == filename
@current = nil
catalog unless workspace.has_file?(filename)
end
# Get completion suggestions at the specified file and location.
#
# @param filename [String] The file to analyze
# @param line [Integer] The zero-based line number
# @param column [Integer] The zero-based column number
# @return [SourceMap::Completion, nil]
# @todo Take a Location instead of filename/line/column
def completions_at filename, line, column
sync_catalog
position = Position.new(line, column)
cursor = Source::Cursor.new(read(filename), position)
mutex.synchronize { api_map.clip(cursor).complete }
rescue FileNotFoundError => e
handle_file_not_found filename, e
end
# Get definition suggestions for the expression at the specified file and
# location.
#
# @param filename [String] The file to analyze
# @param line [Integer] The zero-based line number
# @param column [Integer] The zero-based column number
# @return [Array<Solargraph::Pin::Base>, nil]
# @todo Take filename/position instead of filename/line/column
def definitions_at filename, line, column
sync_catalog
position = Position.new(line, column)
cursor = Source::Cursor.new(read(filename), position)
if cursor.comment?
source = read(filename)
offset = Solargraph::Position.to_offset(source.code, Solargraph::Position.new(line, column))
lft = source.code[0..offset-1].match(/\[[a-z0-9_:<, ]*?([a-z0-9_:]*)\z/i)
rgt = source.code[offset..-1].match(/^([a-z0-9_]*)(:[a-z0-9_:]*)?[\]>, ]/i)
if lft && rgt
tag = (lft[1] + rgt[1]).sub(/:+$/, '')
clip = mutex.synchronize { api_map.clip(cursor) }
clip.translate tag
else
[]
end
else
mutex.synchronize do
clip = api_map.clip(cursor)
if cursor.assign?
[Pin::ProxyType.new(name: cursor.word, return_type: clip.infer)]
else
clip.define.map { |pin| pin.realize(api_map) }
end
end
end
rescue FileNotFoundError => e
handle_file_not_found(filename, e)
end
# Get type definition suggestions for the expression at the specified file and
# location.
#
# @param filename [String] The file to analyze
# @param line [Integer] The zero-based line number
# @param column [Integer] The zero-based column number
# @return [Array<Solargraph::Pin::Base>, nil]
# @todo Take filename/position instead of filename/line/column
def type_definitions_at filename, line, column
sync_catalog
position = Position.new(line, column)
cursor = Source::Cursor.new(read(filename), position)
mutex.synchronize { api_map.clip(cursor).types }
rescue FileNotFoundError => e
handle_file_not_found filename, e
end
# Get signature suggestions for the method at the specified file and
# location.
#
# @param filename [String] The file to analyze
# @param line [Integer] The zero-based line number
# @param column [Integer] The zero-based column number
# @return [Array<Solargraph::Pin::Base>]
# @todo Take filename/position instead of filename/line/column
def signatures_at filename, line, column
sync_catalog
position = Position.new(line, column)
cursor = Source::Cursor.new(read(filename), position)
mutex.synchronize { api_map.clip(cursor).signify }
end
# @param filename [String]
# @param line [Integer]
# @param column [Integer]
# @param strip [Boolean] Strip special characters from variable names
# @param only [Boolean] Search for references in the current file only
# @return [Array<Solargraph::Range>]
# @todo Take a Location instead of filename/line/column
def references_from filename, line, column, strip: false, only: false
sync_catalog
cursor = Source::Cursor.new(read(filename), [line, column])
clip = mutex.synchronize { api_map.clip(cursor) }
pin = clip.define.first
return [] unless pin
result = []
files = if only
[api_map.source_map(filename)]
else
(workspace.sources + (@current ? [@current] : []))
end
files.uniq(&:filename).each do |source|
found = source.references(pin.name)
found.select! do |loc|
referenced = definitions_at(loc.filename, loc.range.ending.line, loc.range.ending.character).first
referenced&.path == pin.path
end
if pin.path == 'Class#new'
caller = cursor.chain.base.infer(api_map, clip.send(:block), clip.locals).first
if caller.defined?
found.select! do |loc|
clip = api_map.clip_at(loc.filename, loc.range.start)
other = clip.send(:cursor).chain.base.infer(api_map, clip.send(:block), clip.locals).first
caller == other
end
else
found.clear
end
end
# HACK: for language clients that exclude special characters from the start of variable names
if strip && match = cursor.word.match(/^[^a-z0-9_]+/i)
found.map! do |loc|
Solargraph::Location.new(loc.filename, Solargraph::Range.from_to(loc.range.start.line, loc.range.start.column + match[0].length, loc.range.ending.line, loc.range.ending.column))
end
end
result.concat(found.sort do |a, b|
a.range.start.line <=> b.range.start.line
end)
end
result.uniq
end
# Get the pins at the specified location or nil if the pin does not exist.
#
# @param location [Location]
# @return [Array<Solargraph::Pin::Base>]
def locate_pins location
sync_catalog
mutex.synchronize { api_map.locate_pins(location).map { |pin| pin.realize(api_map) } }
end
# Match a require reference to a file.
#
# @param location [Location]
# @return [Location, nil]
def locate_ref location
map = source_map_hash[location.filename]
return if map.nil?
pin = map.requires.select { |p| p.location.range.contain?(location.range.start) }.first
return nil if pin.nil?
# @param full [String]
return_if_match = proc do |full|
if source_map_hash.key?(full)
return Location.new(full, Solargraph::Range.from_to(0, 0, 0, 0))
end
end
workspace.require_paths.each do |path|
full = File.join path, pin.name
return_if_match.(full)
return_if_match.(full << ".rb")
end
nil
rescue FileNotFoundError
nil
end
# Get an array of pins that match a path.
#
# @param path [String]
# @return [Enumerable<Solargraph::Pin::Base>]
def get_path_pins path
sync_catalog
mutex.synchronize { api_map.get_path_suggestions(path) }
end
# @param query [String]
# @return [Enumerable<YARD::CodeObjects::Base>]
def document query
sync_catalog
mutex.synchronize { api_map.document query }
end
# @param query [String]
# @return [Array<String>]
def search query
sync_catalog
mutex.synchronize { api_map.search query }
end
# Get an array of all symbols in the workspace that match the query.
#
# @param query [String]
# @return [Array<Pin::Base>]
def query_symbols query
sync_catalog
mutex.synchronize { api_map.query_symbols query }
end
# Get an array of document symbols.
#
# Document symbols are composed of namespace, method, and constant pins.
# The results of this query are appropriate for building the response to a
# textDocument/documentSymbol message in the language server protocol.
#
# @param filename [String]
# @return [Array<Solargraph::Pin::Base>]
def document_symbols filename
sync_catalog
mutex.synchronize { api_map.document_symbols(filename) }
end
# @param path [String]
# @return [Enumerable<Solargraph::Pin::Base>]
def path_pins path
sync_catalog
mutex.synchronize { api_map.get_path_suggestions(path) }
end
# @return [Array<SourceMap>]
def source_maps
source_map_hash.values
end
# Get the current text of a file in the library.
#
# @param filename [String]
# @return [String]
def read_text filename
source = read(filename)
source.code
end
# Get diagnostics about a file.
#
# @param filename [String]
# @return [Array<Hash>]
def diagnose filename
# @todo Only open files get diagnosed. Determine whether anything or
# everything in the workspace should get diagnosed, or if there should
# be an option to do so.
#
sync_catalog
return [] unless open?(filename)
result = []
source = read(filename)
repargs = {}
workspace.config.reporters.each do |line|
if line == 'all!'
Diagnostics.reporters.each do |reporter|
repargs[reporter] ||= []
end
else
args = line.split(':').map(&:strip)
name = args.shift
reporter = Diagnostics.reporter(name)
raise DiagnosticsError, "Diagnostics reporter #{name} does not exist" if reporter.nil?
repargs[reporter] ||= []
repargs[reporter].concat args
end
end
repargs.each_pair do |reporter, args|
result.concat reporter.new(*args.uniq).diagnose(source, api_map)
end
result
end
# Update the ApiMap from the library's workspace and open files.
#
# @return [void]
def catalog
@sync_count += 1
end
# @return [Bench]
def bench
Bench.new(
source_maps: source_map_hash.values,
workspace: workspace,
external_requires: external_requires
)
end
# Get an array of foldable ranges for the specified file.
#
# @deprecated The library should not need to handle folding ranges. The
# source itself has all the information it needs.
#
# @param filename [String]
# @return [Array<Range>]
def folding_ranges filename
read(filename).folding_ranges
end
# Create a library from a directory.
#
# @param directory [String] The path to be used for the workspace
# @param name [String, nil]
# @return [Solargraph::Library]
def self.load directory = '', name = nil
Solargraph::Library.new(Solargraph::Workspace.new(directory), name)
end
# Try to merge a source into the library's workspace. If the workspace is
# not configured to include the source, it gets ignored.
#
# @param source [Source]
# @return [Boolean] True if the source was merged into the workspace.
def merge source
result = workspace.merge(source)
maybe_map source
result
end
# @return [Hash{String => SourceMap}]
def source_map_hash
@source_map_hash ||= {}
end
def mapped?
(workspace.filenames - source_map_hash.keys).empty?
end
# @return [SourceMap, Boolean]
def next_map
return false if mapped?
src = workspace.sources.find { |s| !source_map_hash.key?(s.filename) }
if src
Logging.logger.debug "Mapping #{src.filename}"
source_map_hash[src.filename] = Solargraph::SourceMap.map(src)
source_map_hash[src.filename]
else
false
end
end
# @return [self]
def map!
workspace.sources.each do |src|
source_map_hash[src.filename] = Solargraph::SourceMap.map(src)
find_external_requires source_map_hash[src.filename]
end
self
end
# @return [Array<Solargraph::Pin::Base>]
def pins
@pins ||= []
end
# @return [Set<String>]
def external_requires
@external_requires ||= source_map_external_require_hash.values.flatten.to_set
end
private
# @return [Hash{String => Set<String>}]
def source_map_external_require_hash
@source_map_external_require_hash ||= {}
end
# @param source_map [SourceMap]
# @return [void]
def find_external_requires source_map
new_set = source_map.requires.map(&:name).to_set
# return if new_set == source_map_external_require_hash[source_map.filename]
_filenames = nil
filenames = ->{ _filenames ||= workspace.filenames.to_set }
source_map_external_require_hash[source_map.filename] = new_set.reject do |path|
workspace.require_paths.any? do |base|
full = File.join(base, path)
filenames[].include?(full) or filenames[].include?(full << ".rb")
end
end
@external_requires = nil
end
# @return [Mutex]
def mutex
@mutex ||= Mutex.new
end
# @return [ApiMap]
def api_map
@api_map ||= Solargraph::ApiMap.new
end
# Get the source for an open file or create a new source if the file
# exists on disk. Sources created from disk are not added to the open
# workspace files, i.e., the version on disk remains the authoritative
# version.
#
# @raise [FileNotFoundError] if the file does not exist
# @param filename [String]
# @return [Solargraph::Source]
def read filename
return @current if @current && @current.filename == filename
raise FileNotFoundError, "File not found: #{filename}" unless workspace.has_file?(filename)
workspace.source(filename)
end
# @param filename [String]
# @param error [FileNotFoundError]
# @return [nil]
def handle_file_not_found filename, error
if workspace.source(filename)
Solargraph.logger.debug "#{filename} is not cataloged in the ApiMap"
nil
else
raise error
end
end
# @param source [Source, nil]
# @return [void]
def maybe_map source
return unless source
return unless @current == source || workspace.has_file?(source.filename)
if source_map_hash.key?(source.filename)
new_map = Solargraph::SourceMap.map(source)
source_map_hash[source.filename] = new_map
else
source_map_hash[source.filename] = Solargraph::SourceMap.map(source)
end
end
# @return [Set<Gem::Specification>]
def cache_errors
@cache_errors ||= Set.new
end
# @return [void]
def cache_next_gemspec
return if @cache_progress
spec = api_map.uncached_gemspecs.find { |spec| !cache_errors.include?(spec) }
return end_cache_progress unless spec
pending = api_map.uncached_gemspecs.length - cache_errors.length - 1
logger.info "Caching #{spec.name} #{spec.version}"
Thread.new do
cache_pid = Process.spawn(workspace.command_path, 'cache', spec.name, spec.version.to_s)
report_cache_progress spec.name, pending
Process.wait(cache_pid)
logger.info "Cached #{spec.name} #{spec.version}"
rescue Errno::EINVAL => _e
logger.info "Cached #{spec.name} #{spec.version} with EINVAL"
rescue StandardError => e
cache_errors.add spec
Solargraph.logger.warn "Error caching gemspec #{spec.name} #{spec.version}: [#{e.class}] #{e.message}"
ensure
end_cache_progress
catalog
sync_catalog
end
end
# @param gem_name [String]
# @param pending [Integer]
# @return [void]
def report_cache_progress gem_name, pending
@total ||= pending
@total = pending if pending > @total
finished = @total - pending
pct = if @total.zero?
0
else
((finished.to_f / @total.to_f) * 100).to_i
end
message = "#{gem_name}#{pending > 0 ? " (+#{pending})" : ''}"
# "
if @cache_progress
@cache_progress.report(message, pct)
else
@cache_progress = LanguageServer::Progress.new('Caching gem')
# If we don't send both a begin and a report, the progress notification
# might get stuck in the status bar forever
@cache_progress.begin(message, pct)
changed
notify_observers @cache_progress
@cache_progress.report(message, pct)
end
changed
notify_observers @cache_progress
end
# @return [void]
def end_cache_progress
changed if @cache_progress&.finish('done')
notify_observers @cache_progress
@cache_progress = nil
@total = nil
end
def sync_catalog
return if @sync_count == 0
mutex.synchronize do
logger.info "Cataloging #{workspace.directory.empty? ? 'generic workspace' : workspace.directory}"
api_map.catalog bench
source_map_hash.values.each { |map| find_external_requires(map) }
logger.info "Catalog complete (#{api_map.source_maps.length} files, #{api_map.pins.length} pins)"
logger.info "#{api_map.uncached_gemspecs.length} uncached gemspecs"
cache_next_gemspec
@sync_count = 0
end
end
end
end