ruby/lib/syntax_suggest/code_line.rb
2026-04-16 16:48:17 +09:00

223 lines
6.3 KiB
Ruby

# frozen_string_literal: true
module SyntaxSuggest
# Represents a single line of code of a given source file
#
# This object contains metadata about the line such as
# amount of indentation, if it is empty or not, and
# lexical data, such as if it has an `end` or a keyword
# in it.
#
# Visibility of lines can be toggled off. Marking a line as invisible
# indicates that it should not be used for syntax checks.
# It's functionally the same as commenting it out.
#
# Example:
#
# line = CodeLine.from_source("def foo\n").first
# line.number => 1
# line.empty? # => false
# line.visible? # => true
# line.mark_invisible
# line.visible? # => false
#
class CodeLine
TRAILING_SLASH = ("\\" + $/).freeze
# Returns an array of CodeLine objects
# from the source string
def self.from_source(source)
source = +source
parse_result = Prism.parse_lex(source)
ast, tokens = parse_result.value
clean_comments!(source, parse_result.comments)
visitor = Visitor.new
visitor.visit(ast)
tokens.sort_by! { |token, _state| token.location.start_line }
prev_token = nil
tokens.map! do |token, _state|
prev_token = Token.new(token, prev_token, visitor)
end
tokens_for_line = tokens.each_with_object(Hash.new { |h, k| h[k] = [] }) { |token, hash| hash[token.line] << token }
source.lines.map.with_index do |line, index|
CodeLine.new(
line: line,
index: index,
tokens: tokens_for_line[index + 1],
consecutive: visitor.consecutive_lines.include?(index + 1)
)
end
end
# Remove comments that apear on their own in source. They will never be the cause
# of syntax errors and are just visual noise. Example:
#
# source = +<<~RUBY
# # Comment-only line
# foo # Inline comment
# RUBY
# CodeLine.clean_comments!(source, Prism.parse(source).comments)
# source # => "\nfoo # Inline comment\n"
def self.clean_comments!(source, comments)
# Iterate backwards since we are modifying the source in place and must preserve
# the offsets. Prism comments are sorted by their location in the source.
comments.reverse_each do |comment|
next if comment.trailing?
source.bytesplice(comment.location.start_offset, comment.location.length, "")
end
end
attr_reader :line, :index, :tokens, :line_number, :indent
def initialize(line:, index:, tokens:, consecutive:)
@tokens = tokens
@line = line
@index = index
@consecutive = consecutive
@original = line
@line_number = @index + 1
strip_line = line.dup
strip_line.lstrip!
@indent = if (@empty = strip_line.empty?)
line.length - 1 # Newline removed from strip_line is not "whitespace"
else
line.length - strip_line.length
end
set_kw_end
end
# Used for stable sort via indentation level
#
# Ruby's sort is not "stable" meaning that when
# multiple elements have the same value, they are
# not guaranteed to return in the same order they
# were put in.
#
# So when multiple code lines have the same indentation
# level, they're sorted by their index value which is unique
# and consistent.
#
# This is mostly needed for consistency of the test suite
def indent_index
@indent_index ||= [indent, index]
end
alias_method :number, :line_number
# Returns true if the code line is determined
# to contain a keyword that matches with an `end`
#
# For example: `def`, `do`, `begin`, `ensure`, etc.
def is_kw?
@is_kw
end
# Returns true if the code line is determined
# to contain an `end` keyword
def is_end?
@is_end
end
# Used to hide lines
#
# The search alorithm will group lines into blocks
# then if those blocks are determined to represent
# valid code they will be hidden
def mark_invisible
@line = ""
end
# Means the line was marked as "invisible"
# Confusingly, "empty" lines are visible...they
# just don't contain any source code other than a newline ("\n").
def visible?
!line.empty?
end
# Opposite or `visible?` (note: different than `empty?`)
def hidden?
!visible?
end
# An `empty?` line is one that was originally left
# empty in the source code, while a "hidden" line
# is one that we've since marked as "invisible"
def empty?
@empty
end
# Opposite of `empty?` (note: different than `visible?`)
def not_empty?
!empty?
end
# Renders the given line
#
# Also allows us to represent source code as
# an array of code lines.
#
# When we have an array of code line elements
# calling `join` on the array will call `to_s`
# on each element, which essentially converts
# it back into it's original source string.
def to_s
line
end
# When the code line is marked invisible
# we retain the original value of it's line
# this is useful for debugging and for
# showing extra context
#
# DisplayCodeWithLineNumbers will render
# all lines given to it, not just visible
# lines, it uses the original method to
# obtain them.
attr_reader :original
# Comparison operator, needed for equality
# and sorting
def <=>(other)
index <=> other.index
end
# Can this line be logically joined together
# with the following line? Determined by walking
# the AST
def consecutive?
@consecutive
end
# Determines if the given line has a trailing slash.
# Simply check if the line contains a backslash after
# the content of the last token.
#
# lines = CodeLine.from_source(<<~EOM)
# it "foo" \
# EOM
# expect(lines.first.trailing_slash?).to eq(true)
#
def trailing_slash?
return unless (last = @tokens.last)
@line.byteindex(TRAILING_SLASH, last.location.end_column) != nil
end
private def set_kw_end
kw_count = 0
end_count = 0
@tokens.each do |token|
kw_count += 1 if token.is_kw?
end_count += 1 if token.is_end?
end
@is_kw = (kw_count - end_count) > 0
@is_end = (end_count - kw_count) > 0
end
end
end