require_relative '../../ruler' require_relative '../../logger' require_relative '../../lexer/lexer_constants' class Tailor module Rulers class IndentationSpacesRuler < Tailor::Ruler # Used for managing the state of indentation for some file/text. An # object of this type has no knowledge of the file/text itself, but rather # just manages indentation expectations based on the object's user's # input, somewhat like a state machine. # # For the sake of talking about indentation expectations, the docs here # make mention of 'levels' of indentation. A _level_ here is simply # 1 * (number of spaces to indent); so if you've set (number of spaces to # indent) to 2, saying something should be indented 1 level, is simply # saying that it should be indented 2 spaces. class IndentationManager include Tailor::LexerConstants include Tailor::Logger::Mixin # These are event names generated by the {Lexer} that signify # indentation level should/could increase by 1. ENCLOSERS = Set.new [:on_lbrace, :on_lbracket, :on_lparen] # Look-up table that allows for OPEN_EVENT_FOR[:on_rbrace]. OPEN_EVENT_FOR = { on_kw: :on_kw, on_rbrace: :on_lbrace, on_rbracket: :on_lbracket, on_rparen: :on_lparen } # @return [Fixnum] The actual number of characters the current line is # indented. attr_reader :actual_indentation # @return [Array] Each element represents a reason why code should # be indented. Indent levels are not necessarily 1:1 relationship # to these reasons (hence the need for this class). attr_reader :indent_reasons # @param [Fixnum] spaces The number of spaces each level of indentation # should move in & out. def initialize(spaces) @spaces = spaces @proper = { this_line: 0, next_line: 0 } @actual_indentation = 0 @indent_reasons = [] start end # @return [Fixnum] The indent level the file should currently be at. def should_be_at @proper[:this_line] end # Decreases the indentation expectation for the current line by # 1 level. def decrease_this_line if started? @proper[:this_line] -= @spaces if @proper[:this_line] < 0 @proper[:this_line] = 0 end log "@proper[:this_line] = #{@proper[:this_line]}" log "@proper[:next_line] = #{@proper[:next_line]}" else log '#decrease_this_line called, but checking is stopped.' end end # Should be called just before moving to the next line. This sets the # expectation set in +@proper[:next_line]+ to # +@proper[:this_line]+. def transition_lines if started? log 'Resetting change_this to 0.' log 'Setting @proper[:this_line] = that of :next_line' @proper[:this_line] = @proper[:next_line] log "Transitioning @proper[:this_line] to #{@proper[:this_line]}" else log 'Skipping #transition_lines; checking is stopped.' end end # Starts the process of increasing/decreasing line indentation # expectations. def start log 'Starting indentation ruling.' log "Next check should be at #{should_be_at}" @do_measurement = true end # Tells if the indentation checking process is on. # # @return [Boolean] +true+ if it's started; +false+ if not. def started? @do_measurement end # Stops the process of increasing/decreasing line indentation # expectations. def stop if started? msg = "Stopping indentation ruling. Should be: #{should_be_at}; " msg << "actual: #{@actual_indentation}" log msg end @do_measurement = false end # Updates +@actual_indentation+ based on the given lexed_line_output. # # @param [Array] lexed_line_output The lexed output for the current line. def update_actual_indentation(lexed_line_output) if lexed_line_output.end_of_multi_line_string? log 'Found end of multi-line string.' return end first_non_space_element = lexed_line_output.first_non_space_element @actual_indentation = first_non_space_element.first.last log "Actual indentation: #{@actual_indentation}" end # Checks if the current line ends with an operator, comma, or period. # # @param [LexedLine] lexed_line # @return [Boolean] def line_ends_with_single_token_indenter?(lexed_line) lexed_line.ends_with_op? || lexed_line.ends_with_comma? || lexed_line.ends_with_period? || lexed_line.ends_with_label? || lexed_line.ends_with_modifier_kw? end # Checks to see if the last token in @single_tokens is the same as the # one in +token_event+. # # @param [Array] token_event A single event (probably extracted from a # {LexedLine}). # @return [Boolean] def line_ends_with_same_as_last(token_event) return false if @indent_reasons.empty? @indent_reasons.last[:event_type] == token_event[1] end # Determines if the current spot in the file is enclosed in braces, # brackets, or parens. # # @return [Boolean] def in_an_enclosure? return false if @indent_reasons.empty? i_reasons = @indent_reasons.dup log "i reasons: #{i_reasons}" until ENCLOSERS.include? i_reasons.last[:event_type] i_reasons.pop break if i_reasons.empty? end return false if i_reasons.empty? i_reasons.last[:event_type] == :on_lbrace || i_reasons.last[:event_type] == :on_lbracket || i_reasons.last[:event_type] == :on_lparen end # Adds to the list of reasons to indent the next line, then increases # the expectation for the next line by +@spaces+. # # @param [Symbol] event_type The event type that caused the reason for # indenting. # @param [Tailor::Token,String] token The token that caused the reason # for indenting. # @param [Fixnum] lineno The line number the reason for indenting was # discovered on. def add_indent_reason(event_type, token, lineno) @indent_reasons << { event_type: event_type, token: token, lineno: lineno, should_be_at: @proper[:this_line] } @proper[:next_line] = @indent_reasons.last[:should_be_at] + @spaces log "Added indent reason; it's now:" @indent_reasons.each { |r| log r.to_s } end # An "opening reason" is a reason for indenting that also has a "closing # reason", such as a +def+, +{+, +[+, +(+. # # @param [Symbol] event_type The event type that is the opening reason. # @param [Tailor::Token,String] token The token that is the opening # reasons. # @param [Fixnum] lineno The line number the opening reason was found # on. def update_for_opening_reason(event_type, token, lineno) if token.modifier_keyword? log "Found modifier in line: '#{token}'" return end log "Token '#{token}' not used as a modifier." if token.do_is_for_a_loop? log "Found keyword loop using optional 'do'" return end add_indent_reason(event_type, token, lineno) end # A "continuation reason" is a reason for indenting & outdenting that's # not an opening or closing reason, such as +elsif+, +rescue+, +when+ # (in a +case+ statement), etc. # # @param [Symbol] token # @param [Tailor::LexedLine] lexed_line # @param [Fixnum] lineno The line number the opening reason was found # on. def update_for_continuation_reason(token, lexed_line, lineno) d_tokens = @indent_reasons.dup d_tokens.pop on_line_token = d_tokens.find { |t| t[:lineno] == lineno } log "online token: #{on_line_token}" if on_line_token.nil? && lexed_line.to_s =~ /^\s*#{token}/ @proper[:this_line] -= @spaces unless @proper[:this_line].zero? msg = "Continuation keyword: '#{token}'. " msg << "change_this -= 1 -> #{@proper[:this_line]}" log msg end last_reason_line = @indent_reasons.find { |r| r[:lineno] == lineno } @proper[:next_line] = if last_reason_line.nil? if @indent_reasons.empty? @spaces else @indent_reasons.last[:should_be_at] + @spaces end else @indent_reasons.last[:should_be_at] - @spaces end end # A "closing reason" is a reason for indenting that also has an "opening # reason", such as a +end+, +}+, +]+, +)+. # # @param [Symbol] event_type The event type that is the closing reason. # @param [Tailor::LexedLine] lexed_line The line that contains the # closing reason. def update_for_closing_reason(event_type, lexed_line) remove_continuation_keywords remove_appropriate_reason(event_type) @proper[:next_line] = if @indent_reasons.empty? 0 else @indent_reasons.last[:should_be_at] + @spaces end log "Updated :next after closing; it's now #{@proper[:next_line]}" meth = "only_#{event_type.to_s.sub(/^on_/, '')}?" if lexed_line.send(meth.to_sym) || lexed_line.to_s =~ /^\s*end\n?$/ @proper[:this_line] = [0, @proper[:this_line] - @spaces].max msg = 'End multi-line statement. ' msg < "change_this -= 1 -> #{@proper[:this_line]}." log msg end end # Removes the last matching opening reason reason of +event_type+ from # the list of indent reasons. # # @param [Symbol] closing_event_type The closing event for which to find # the matching opening event to remove from the list of indent # reasons. def remove_appropriate_reason(closing_event_type) if last_opening_event = last_opening_event(closing_event_type) r_index = @indent_reasons.reverse.index(last_opening_event) index = @indent_reasons.size - r_index - 1 tmp_reasons = [] @indent_reasons.each_with_index do |r, i| tmp_reasons << r unless i == index end @indent_reasons.replace(tmp_reasons) elsif last_single_token_event log "Just popped off reason: #{@indent_reasons.pop}" else log "Couldn't find a matching opening reason to pop off...'" return end log "Removed indent reason; it's now:" @indent_reasons.each { |r| log r.to_s } end # A "single-token" event is one that that causes indentation # expectations to increase. They don't have have a paired closing # reason like opening reasons. Instead, they're determined to be done # with their indenting when an :on_ignored_nl occurs. Single-token # events are operators and commas (commas that aren't used as # separators in {, [, ( events). def last_single_token_event return nil if @indent_reasons.empty? @indent_reasons.reverse.find do |r| !ENCLOSERS.include?(r[:event_type]) && r[:event_type] != :on_kw end end # Returns the last matching opening event that corresponds to the # +closing_event_type+. # # @param [Symbol] closing_event_type The closing event for which to # find its associated opening event. def last_opening_event(closing_event_type) return nil if @indent_reasons.empty? @indent_reasons.reverse.find do |r| r[:event_type] == OPEN_EVENT_FOR[closing_event_type] end end def last_indent_reason_type return if @indent_reasons.empty? @indent_reasons.last[:event_type] end # Removes all continuation keywords from the list of # indentation reasons. def remove_continuation_keywords return if @indent_reasons.empty? while CONTINUATION_KEYWORDS.include?(@indent_reasons.last[:token]) log "Just popped off continuation reason: #{@indent_reasons.pop}" end end # Overriding to be able to call +#multi_line_brackets?+, # +#multi_line_braces?+, and +#multi_line_parens?+, where each takes a # single parameter, which is the lineno. # # @return [Boolean] def method_missing(meth, *args, &blk) if meth.to_s =~ /^multi_line_(.+)\?$/ token = case $1 when 'brackets' then '[' when 'braces' then '{' when 'parens' then '(' else super(meth, *args, &blk) end lineno = args.first tokens = @indent_reasons.find_all do |t| t[:token] == token end log "#{meth} called, but no #{$1} were found." if tokens.empty? return false if tokens.empty? token_on_this_line = tokens.find { |t| t[:lineno] == lineno } return true if token_on_this_line.nil? false else super(meth, *args, &blk) end end end end end end