# frozen_string_literal: true
require 'yard'
module Solargraph
# A Ruby file that has been parsed into an AST.
#
class Source
# autoload :FlawedBuilder, 'solargraph/source/flawed_builder'
autoload :Updater, 'solargraph/source/updater'
autoload :Change, 'solargraph/source/change'
autoload :Mapper, 'solargraph/source/mapper'
# autoload :NodeMethods, 'solargraph/source/node_methods'
autoload :EncodingFixes, 'solargraph/source/encoding_fixes'
autoload :Cursor, 'solargraph/source/cursor'
autoload :Chain, 'solargraph/source/chain'
autoload :SourceChainer, 'solargraph/source/source_chainer'
# autoload :NodeChainer, 'solargraph/source/node_chainer'
include EncodingFixes
# include NodeMethods
# @return [String]
attr_reader :filename
# @return [String]
attr_reader :code
# @return [Parser::AST::Node]
attr_reader :node
# @return [Hash{Integer => Array<String>}]
attr_reader :comments
# @todo Deprecate?
# @return [Integer]
attr_reader :version
# @param code [String]
# @param filename [String]
# @param version [Integer]
def initialize code, filename = nil, version = 0
@code = normalize(code)
@repaired = code
@filename = filename
@version = version
@domains = []
begin
# @node, @comments = Source.parse_with_comments(@code, filename)
@node, @comments = Solargraph::Parser.parse_with_comments(@code, filename)
@parsed = true
rescue Parser::SyntaxError, EncodingError => e
# @todo 100% whitespace results in a nil node, so there's no reason to parse it.
# We still need to determine whether the resulting node should be nil or a dummy
# node with a location that encompasses the range.
# @node, @comments = Source.parse_with_comments(@code.gsub(/[^\s]/, ' '), filename)
@node = nil
@comments = {}
@parsed = false
# rescue Exception => e
# Solargraph.logger.warn "[#{e.class}] #{e.message}"
# Solargraph.logger.warn e.backtrace.join("\n")
# raise "Error parsing #{filename || '(source)'}: [#{e.class}] #{e.message}"
ensure
@code.freeze
end
end
# @param range [Solargraph::Range]
# @return [String]
def at range
from_to range.start.line, range.start.character, range.ending.line, range.ending.character
end
# @param l1 [Integer]
# @param c1 [Integer]
# @param l2 [Integer]
# @param c2 [Integer]
# @return [String]
def from_to l1, c1, l2, c2
b = Solargraph::Position.line_char_to_offset(@code, l1, c1)
e = Solargraph::Position.line_char_to_offset(@code, l2, c2)
@code[b..e-1]
end
# Get the nearest node that contains the specified index.
#
# @param line [Integer]
# @param column [Integer]
# @return [AST::Node]
def node_at(line, column)
tree_at(line, column).first
end
# Get an array of nodes containing the specified index, starting with the
# nearest node and ending with the root.
#
# @param line [Integer]
# @param column [Integer]
# @return [Array<AST::Node>]
def tree_at(line, column)
# offset = Position.line_char_to_offset(@code, line, column)
position = Position.new(line, column)
stack = []
inner_tree_at @node, position, stack
stack
end
# Start synchronizing the source. This method updates the code without
# parsing a new AST. The resulting Source object will be marked not
# synchronized (#synchronized? == false).
#
# @param updater [Source::Updater]
# @return [Source]
def start_synchronize updater
raise 'Invalid synchronization' unless updater.filename == filename
real_code = updater.write(@code)
src = Source.allocate
src.filename = filename
src.code = real_code
src.version = updater.version
src.parsed = parsed?
src.repaired = updater.repair(@repaired)
src.synchronized = false
src.node = @node
src.comments = @comments
src.error_ranges = error_ranges
src.last_updater = updater
return src.finish_synchronize unless real_code.lines.length == @code.lines.length
src
end
# Finish synchronizing a source that was updated via #start_synchronize.
# This method returns self if the source is already synchronized. Otherwise
# it parses the AST and returns a new synchronized Source.
#
# @return [Source]
def finish_synchronize
return self if synchronized?
synced = Source.new(@code, filename)
if synced.parsed?
synced.version = version
return synced
end
synced = Source.new(@repaired, filename)
synced.error_ranges.concat (error_ranges + last_updater.changes.map(&:range))
synced.code = @code
synced.synchronized = true
synced.version = version
synced
end
# Synchronize the Source with an update. This method applies changes to the
# code, parses the new code's AST, and returns the resulting Source object.
#
# @param updater [Source::Updater]
# @return [Source]
def synchronize updater
raise 'Invalid synchronization' unless updater.filename == filename
real_code = updater.write(@code)
if real_code == @code
@version = updater.version
return self
end
synced = Source.new(real_code, filename)
if synced.parsed?
synced.version = updater.version
return synced
end
incr_code = updater.repair(@repaired)
synced = Source.new(incr_code, filename)
synced.error_ranges.concat (error_ranges + updater.changes.map(&:range))
synced.code = real_code
synced.version = updater.version
synced
end
# @param position [Position]
# @return [Source::Cursor]
def cursor_at position
Cursor.new(self, position)
end
# @return [Boolean]
def parsed?
@parsed
end
def repaired?
@is_repaired ||= (@code != @repaired)
end
# @param position [Position]
# @return [Boolean]
def string_at? position
if Parser.rubyvm?
string_ranges.each do |range|
if synchronized?
return true if range.include?(position) || range.ending == position
else
return true if last_updater && last_updater.changes.one? && range.contain?(last_updater.changes.first.range.start)
end
end
false
else
return false if Position.to_offset(code, position) >= code.length
string_nodes.each do |node|
range = Range.from_node(node)
next if range.ending.line < position.line
break if range.ending.line > position.line
return true if node.type == :str && range.include?(position) && range.start != position
return true if [:STR, :str].include?(node.type) && range.include?(position) && range.start != position
if node.type == :dstr
inner = node_at(position.line, position.column)
next if inner.nil?
inner_range = Range.from_node(inner)
next unless range.include?(inner_range.ending)
return true if inner.type == :str
inner_code = at(Solargraph::Range.new(inner_range.start, position))
return true if (inner.type == :dstr && inner_range.ending.character <= position.character) && !inner_code.end_with?('}') ||
(inner.type != :dstr && inner_range.ending.line == position.line && position.character <= inner_range.ending.character && inner_code.end_with?('}'))
end
break if range.ending.line > position.line
end
false
end
end
def string_ranges
@string_ranges ||= Parser.string_ranges(node)
end
# @param position [Position]
# @return [Boolean]
def comment_at? position
comment_ranges.each do |range|
return true if range.include?(position) ||
(range.ending.line == position.line && range.ending.column < position.column)
break if range.ending.line > position.line
end
false
end
# @param name [String]
# @return [Array<Location>]
def references name
Parser.references self, name
end
# @return [Array<Range>]
def error_ranges
@error_ranges ||= []
end
# @param node [Parser::AST::Node]
# @return [String]
def code_for(node)
rng = Range.from_node(node)
b = Position.line_char_to_offset(@code, rng.start.line, rng.start.column)
e = Position.line_char_to_offset(@code, rng.ending.line, rng.ending.column)
frag = code[b..e-1].to_s
frag.strip.gsub(/,$/, '')
end
# @param node [Parser::AST::Node]
# @return [String]
def comments_for node
rng = Range.from_node(node)
stringified_comments[rng.start.line] ||= begin
buff = associated_comments[rng.start.line]
buff ? stringify_comment_array(buff) : nil
end
end
# A location representing the file in its entirety.
#
# @return [Location]
def location
st = Position.new(0, 0)
en = Position.from_offset(code, code.length)
range = Range.new(st, en)
Location.new(filename, range)
end
FOLDING_NODE_TYPES = if Parser.rubyvm?
%i[
CLASS SCLASS MODULE DEFN DEFS IF WHILE UNLESS ITER STR
].freeze
else
%i[
class sclass module def defs if str dstr array while unless kwbegin hash block
].freeze
end
# Get an array of ranges that can be folded, e.g., the range of a class
# definition or an if condition.
#
# See FOLDING_NODE_TYPES for the list of node types that can be folded.
#
# @return [Array<Range>]
def folding_ranges
@folding_ranges ||= begin
result = []
inner_folding_ranges node, result
result.concat foldable_comment_block_ranges
result
end
end
def synchronized?
@synchronized = true if @synchronized.nil?
@synchronized
end
# Get a hash of comments grouped by the line numbers of the associated code.
#
# @return [Hash{Integer => Array<Parser::Source::Comment>}]
def associated_comments
@associated_comments ||= begin
result = {}
buffer = String.new('')
last = nil
@comments.each_pair do |num, snip|
if !last || num == last + 1
buffer.concat "#{snip.text}\n"
else
result[first_not_empty_from(last + 1)] = buffer.clone
buffer.replace "#{snip.text}\n"
end
last = num
end
result[first_not_empty_from(last + 1)] = buffer unless buffer.empty? || last.nil?
result
end
end
private
def first_not_empty_from line
cursor = line
cursor += 1 while cursor < code_lines.length && code_lines[cursor].strip.empty?
cursor = line if cursor > code_lines.length - 1
cursor
end
# @param top [Parser::AST::Node]
# @param result [Array<Range>]
# @return [void]
def inner_folding_ranges top, result = []
# return unless top.is_a?(::Parser::AST::Node)
return unless Parser.is_ast_node?(top)
if FOLDING_NODE_TYPES.include?(top.type)
range = Range.from_node(top)
if result.empty? || range.start.line > result.last.start.line
result.push range unless range.ending.line - range.start.line < 2
end
end
top.children.each do |child|
inner_folding_ranges(child, result)
end
end
# Get a string representation of an array of comments.
#
# @param comments [String]
# @return [String]
def stringify_comment_array comments
ctxt = String.new('')
started = false
skip = nil
comments.lines.each { |l|
# Trim the comment and minimum leading whitespace
p = l.gsub(/^#+/, '')
if p.strip.empty?
next unless started
ctxt.concat p
else
here = p.index(/[^ \t]/)
skip = here if skip.nil? || here < skip
ctxt.concat p[skip..-1]
end
started = true
}
ctxt
end
# A hash of line numbers and their associated comments.
#
# @return [Hash{Integer => Array<String>}]
def stringified_comments
@stringified_comments ||= {}
end
# @return [Array<Parser::AST::Node>]
def string_nodes
@string_nodes ||= string_nodes_in(@node)
end
# @return [Array<Range>]
def comment_ranges
@comment_ranges ||= @comments.values.map(&:range)
end
# Get an array of foldable comment block ranges. Blocks are excluded if
# they are less than 3 lines long.
#
# @return [Array<Range>]
def foldable_comment_block_ranges
return [] unless synchronized?
result = []
grouped = []
comments.keys.each do |l|
if grouped.empty? || l == grouped.last + 1
grouped.push l
else
result.push Range.from_to(grouped.first, 0, grouped.last, 0) unless grouped.length < 3
grouped = [l]
end
end
result.push Range.from_to(grouped.first, 0, grouped.last, 0) unless grouped.length < 3
result
end
# @param n [Parser::AST::Node]
# @return [Array<Parser::AST::Node>]
def string_nodes_in n
result = []
if Parser.is_ast_node?(n)
if n.type == :str || n.type == :dstr || n.type == :STR || n.type == :DSTR
result.push n
else
n.children.each{ |c| result.concat string_nodes_in(c) }
end
end
result
end
# @param node [Parser::AST::Node]
# @param position [Position]
# @param stack [Array<Parser::AST::Node>]
# @return [void]
def inner_tree_at node, position, stack
return if node.nil?
# here = Range.from_to(node.loc.expression.line, node.loc.expression.column, node.loc.expression.last_line, node.loc.expression.last_column)
here = Range.from_node(node)
if here.contain?(position) || colonized(here, position, node)
stack.unshift node
node.children.each do |c|
next unless Parser.is_ast_node?(c)
next if !Parser.rubyvm? && c.loc.expression.nil?
inner_tree_at(c, position, stack)
end
end
end
def colonized range, position, node
node.type == :COLON2 &&
range.ending.line == position.line &&
range.ending.character == position.character - 2 &&
code[Position.to_offset(code, Position.new(position.line, position.character - 2)), 2] == '::'
end
protected
# @return [String]
attr_writer :filename
# @return [Integer]
attr_writer :version
# @param val [String]
# @return [String]
def code=(val)
@code_lines= nil
@code = val
end
# @return [Parser::AST::Node]
attr_writer :node
# @return [Array<Range>]
attr_writer :error_ranges
# @return [String]
attr_accessor :repaired
# @return [Boolean]
attr_writer :parsed
# @return [Array<Parser::Source::Comment>]
attr_writer :comments
# @return [Boolean]
attr_writer :synchronized
# @return [Source::Updater]
attr_accessor :last_updater
private
# @return [Array<String>]
def code_lines
@code_lines ||= code.lines
end
class << self
# @param filename [String]
# @return [Solargraph::Source]
def load filename
file = File.open(filename)
code = file.read
file.close
Source.load_string(code, filename)
end
# @param code [String]
# @param filename [String]
# @param version [Integer]
# @return [Solargraph::Source]
def load_string code, filename = nil, version = 0
Source.new code, filename, version
end
# @param comments [String]
# @return [YARD::DocstringParser]
def parse_docstring comments
# HACK: Pass a dummy code object to the parser for plugins that
# expect it not to be nil
YARD::Docstring.parser.parse(comments, YARD::CodeObjects::Base.new(:root, 'stub'))
end
end
end
end