require "ripper"
class Rufo::Formatter
def self.format(code, **options)
formatter = new(code, **options)
formatter.format
formatter.result
end
def initialize(code, **options)
@code = code
@tokens = Ripper.lex(code).reverse!
@sexp = Ripper.sexp(code)
unless @sexp
raise ::Rufo::SyntaxError.new
end
@indent = 0
@line = 0
@column = 0
@last_was_newline = false
@output = ""
# The column of a `obj.method` call, so we can align
# calls to that dot
@dot_column = nil
# Did this line already set the `@dot_column` variable?
@line_has_dot_column = nil
# The column of a `obj.method` call, but only the name part,
# so we can also align arguments accordingly
@name_dot_column = nil
# Heredocs list, associated with calls ([heredoc, tilde])
@heredocs = []
# Current node, to be able to associate it to heredocs
@current_node = nil
# The current heredoc being printed
@current_heredoc = nil
# The current hash or call or method that has hash-like parameters
@current_hash = nil
# Map lines to commands that start at the begining of a line with the following info:
# - line indent
# - first param indent
# - first line ends with '(', '[' or '{'?
# - line of matching pair of the previous item
# - last line of that call
#
# This is needed to dedent some calls that look like this:
#
# foo bar(
# 2,
# )
#
# Without the dedent it would normally look like this:
#
# foo bar(
# 2,
# )
#
# Because the formatter aligns this to the first parameter in the call.
# However, for these cases it's better to not align it like that.
@line_to_call_info = {}
# Each line that belongs to a heredoc content is put here
@heredoc_lines = {}
# Position of comments that occur at the end of a line
@comments_positions = []
# Associate lines to alignments
# Associate a line to an index inside @comments_position
# becuase when aligning something to the left of a comment
# we need to adjust the relative comment
@line_to_alignments_positions = Hash.new { |h, k| h[k] = [] }
# Position of assignments
@assignments_positions = []
# Range of assignment (line => end_line)
#
# We need this because when we have to format:
#
# ```
# abc = 1
# a = foo bar: 2
# baz: #
# ```
#
# Because we'll insert two spaces after `a`, this will
# result in a mis-alignment for baz (and possibly other lines
# below it). So, we remember the line ranges of an assignment,
# and once we align the first one we fix the other ones.
@assignments_ranges = {}
# Hash keys positions
@hash_keys_positions = []
# Case when positions
@case_when_positions = []
# Settings
indent_size options.fetch(:indent_size, 2)
space_after_hash_brace options.fetch(:space_after_hash_brace, :dynamic)
space_after_array_bracket options.fetch(:space_after_array_bracket, :never)
align_comments options.fetch(:align_comments, true)
align_assignments options.fetch(:align_assignments, false)
align_hash_keys options.fetch(:align_hash_keys, true)
align_case_when options.fetch(:align_case_when, true)
align_chained_calls options.fetch(:align_chained_calls, true)
preserve_whitespace options.fetch(:preserve_whitespace, true)
trailing_commas options.fetch(:trailing_commas, :always)
end
# The indent size (default: 2)
def indent_size(value)
@indent_size = value
end
# Whether to put a space after a hash brace. Valid values are:
#
# * :dynamic: if there's a space, keep it. If not, don't keep it (default)
# * :always: always put a space after a hash brace
# * :never: never put a space after a hash brace
def space_after_hash_brace(value)
case value
when :dynamic, :always, :never
@space_after_hash_brace = value
else
raise ArgumentError.new("invalid value for space_after_hash_brace: #{value}. Valid values are: :dynamic, :always, :never")
end
end
# Whether to put a space after an array bracket. Valid values are:
#
# * :dynamic: if there's a space, keep it. If not, don't keep it
# * :always: always put a space after an array bracket
# * :never: never put a space after an array bracket (default)
def space_after_array_bracket(value)
case value
when :dynamic, :always, :never
@space_after_array_bracket = value
else
raise ArgumentError.new("invalid value for space_after_array_bracket: #{value}. Valid values are: :dynamic, :always, :never")
end
end
# Whether to align successive comments (default: true)
def align_comments(value)
@align_comments = value
end
# Whether to align successive assignments (default: false)
def align_assignments(value)
@align_assignments = value
end
# Whether to align successive hash keys (default: true)
def align_hash_keys(value)
@align_hash_keys = value
end
# Whether to align successive case when (default: true)
def align_case_when(value)
@align_case_when = value
end
# Whether to align chained calls to the dot (default: true)
def align_chained_calls(value)
@align_chained_calls = value
end
# Preserve whitespace after assignments target and values,
# after calls that start with a space, hash arrows and commas.
#
# This allows for manual alignment of some code that would otherwise
# be impossible to automatically format or preserve "beautiful".
#
# If `align_assignments` is true, this doesn't apply to assignments.
# If `align_hash_keys` is true, this doesn't apply to hash keys.
def preserve_whitespace(value)
@preserve_whitespace = value
end
# Whether to place commas at the end of a multi-line list
#
# * :dynamic: if there's a comma, keep it. If not, don't add it
# * :always: always put a comma (default)
# * :never: never put a comma
def trailing_commas(value)
case value
when :dynamic, :always, :never
@trailing_commas = value
else
raise ArgumentError.new("invalid value for trailing_commas: #{value}. Valid values are: :dynamic, :always, :never")
end
end
def format
visit @sexp
consume_end
write_line unless @last_was_newline
dedent_calls
do_align_assignments if @align_assignments
do_align_hash_keys if @align_hash_keys
do_align_case_when if @align_case_when
do_align_comments if @align_comments
end
def visit(node)
unless node.is_a?(Array)
bug "unexpected node: #{node} at #{current_token}"
end
case node.first
when :program
# Topmost node
#
# [:program, exps]
visit_exps node[1]
when :void_stmt
# Empty statement
#
# [:void_stmt]
skip_space_or_newline
when :@int
# Integer literal
#
# [:@int, "123", [1, 0]]
consume_token :on_int
when :@float
# Float literal
#
# [:@int, "123.45", [1, 0]]
consume_token :on_float
when :@rational
# Rational literal
#
# [:@rational, "123r", [1, 0]]
consume_token :on_rational
when :@imaginary
# Imaginary literal
#
# [:@imaginary, "123i", [1, 0]]
consume_token :on_imaginary
when :@CHAR
# [:@CHAR, "?a", [1, 0]]
consume_token :on_CHAR
when :@gvar
# [:@gvar, "$abc", [1, 0]]
write node[1]
next_token
when :@backref
# [:@backref, "$1", [1, 0]]
write node[1]
next_token
when :@backtick
# [:@backtick, "`", [1, 4]]
consume_token :on_backtick
when :string_literal, :xstring_literal
visit_string_literal node
when :string_concat
visit_string_concat node
when :@tstring_content
# [:@tstring_content, "hello ", [1, 1]]
heredoc, tilde = @current_heredoc
column = node[2][0]
# For heredocs with tilde we sometimes need to align the contents
if heredoc && tilde && @last_was_newline
write_indent(next_indent)
check :on_tstring_content
consume_token_value(current_token_value)
next_token
else
consume_token :on_tstring_content
end
when :string_content
# [:string_content, exp]
visit_exps node[1..-1], with_lines: false
when :string_embexpr
# String interpolation piece ( #{exp} )
visit_string_interpolation node
when :string_dvar
visit_string_dvar(node)
when :symbol_literal
visit_symbol_literal(node)
when :symbol
visit_symbol(node)
when :dyna_symbol
visit_quoted_symbol_literal(node)
when :@ident
consume_token :on_ident
when :var_ref
# [:var_ref, exp]
visit node[1]
when :var_field
# [:var_field, exp]
visit node[1]
when :@kw
# [:@kw, "nil", [1, 0]]
consume_token :on_kw
when :@ivar
# [:@ivar, "@foo", [1, 0]]
consume_token :on_ivar
when :@cvar
# [:@cvar, "@@foo", [1, 0]]
consume_token :on_cvar
when :@const
# [:@const, "FOO", [1, 0]]
consume_token :on_const
when :const_ref
# [:const_ref, [:@const, "Foo", [1, 8]]]
visit node[1]
when :top_const_ref
# [:top_const_ref, [:@const, "Foo", [1, 2]]]
consume_op "::"
skip_space_or_newline
visit node[1]
when :top_const_field
# [:top_const_field, [:@const, "Foo", [1, 2]]]
consume_op "::"
visit node[1]
when :const_path_ref
visit_path(node)
when :const_path_field
visit_path(node)
when :assign
visit_assign(node)
when :opassign
visit_op_assign(node)
when :massign
visit_multiple_assign(node)
when :ifop
visit_ternary_if(node)
when :if_mod
visit_suffix(node, "if")
when :unless_mod
visit_suffix(node, "unless")
when :while_mod
visit_suffix(node, "while")
when :until_mod
visit_suffix(node, "until")
when :rescue_mod
visit_suffix(node, "rescue")
when :vcall
# [:vcall, exp]
visit node[1]
when :fcall
# [:fcall, [:@ident, "foo", [1, 0]]]
visit node[1]
when :command
visit_command(node)
when :command_call
visit_command_call(node)
when :args_add_block
visit_call_args(node)
when :args_add_star
visit_args_add_star(node)
when :bare_assoc_hash
# [:bare_assoc_hash, exps]
# Align hash elements to the first key
indent(@column) do
visit_comma_separated_list node[1]
end
when :method_add_arg
visit_call_without_receiver(node)
when :method_add_block
visit_call_with_block(node)
when :call
visit_call_with_receiver(node)
when :brace_block
visit_brace_block(node)
when :do_block
visit_do_block(node)
when :block_var
visit_block_arguments(node)
when :begin
visit_begin(node)
when :bodystmt
visit_bodystmt(node)
when :if
visit_if(node)
when :unless
visit_unless(node)
when :while
visit_while(node)
when :until
visit_until(node)
when :case
visit_case(node)
when :when
visit_when(node)
when :unary
visit_unary(node)
when :binary
visit_binary(node)
when :class
visit_class(node)
when :module
visit_module(node)
when :mrhs_new_from_args
visit_mrhs_new_from_args(node)
when :mlhs_paren
visit_mlhs_paren(node)
when :mrhs_add_star
visit_mrhs_add_star(node)
when :def
visit_def(node)
when :defs
visit_def_with_receiver(node)
when :paren
visit_paren(node)
when :params
visit_params(node)
when :array
visit_array(node)
when :hash
visit_hash(node)
when :assoc_new
visit_hash_key_value(node)
when :assoc_splat
visit_splat_inside_hash(node)
when :@label
# [:@label, "foo:", [1, 3]]
write node[1]
next_token
when :dot2
visit_range(node, true)
when :dot3
visit_range(node, false)
when :regexp_literal
visit_regexp_literal(node)
when :aref
visit_array_access(node)
when :aref_field
visit_array_setter(node)
when :sclass
visit_sclass(node)
when :field
visit_setter(node)
when :return0
consume_keyword "return"
when :return
visit_return(node)
when :break
visit_break(node)
when :next
visit_next(node)
when :yield0
consume_keyword "yield"
when :yield
visit_yield(node)
when :@op
# [:@op, "*", [1, 1]]
write node[1]
next_token
when :lambda
visit_lambda(node)
when :zsuper
# [:zsuper]
consume_keyword "super"
when :super
visit_super(node)
when :defined
visit_defined(node)
when :alias, :var_alias
visit_alias(node)
when :undef
visit_undef(node)
when :mlhs_add_star
visit_mlhs_add_star(node)
when :retry
# [:retry]
consume_keyword "retry"
when :redo
# [:redo]
consume_keyword "redo"
when :for
visit_for(node)
when :BEGIN
visit_BEGIN(node)
when :END
visit_END(node)
else
bug "Unhandled node: #{node.first}"
end
end
def visit_exps(exps, with_indent: false, with_lines: true)
consume_end_of_line(at_prefix: true)
line_before_endline = nil
exps.each_with_index do |exp, i|
exp_kind = exp[0]
# Skip voids to avoid extra indentation
if exp_kind == :void_stmt
next
end
if with_indent
# Don't indent if this exp is in the same line as the previous
# one (this happens when there's a semicolon between the exps)
unless line_before_endline && line_before_endline == @line
write_indent
end
end
push_node(exp) do
visit exp
end
is_last = last?(i, exps)
skip_space unless is_last
line_before_endline = @line
if with_lines
exp_needs_two_lines = needs_two_lines?(exp)
consume_end_of_line(want_semicolon: !is_last, want_multiline: !is_last, needs_two_lines_on_comment: exp_needs_two_lines)
# Make sure to put two lines before defs, class and others
if !is_last && (exp_needs_two_lines || needs_two_lines?(exps[i + 1])) && @line <= line_before_endline + 1
write_line
end
elsif !is_last
skip_space
has_semicolon = semicolon?
skip_semicolons
if newline?
write_line
write_indent(next_indent)
elsif has_semicolon
write "; "
end
skip_space_or_newline
end
end
end
def needs_two_lines?(exp)
kind = exp[0]
case kind
when :def, :class, :module
return true
when :vcall
# Check if it's private/protected/public
nested = exp[1]
if nested[0] == :@ident
case nested[1]
when "private", "protected", "public"
return true
end
end
end
false
end
def visit_string_literal(node)
# [:string_literal, [:string_content, exps]]
heredoc = current_token_kind == :on_heredoc_beg
tilde = current_token_value.include?("~")
if heredoc
write current_token_value.rstrip
# Accumulate heredoc: we'll write it once
# we find a newline.
@heredocs << [node, tilde]
next_token
return
elsif current_token_kind == :on_backtick
consume_token :on_backtick
else
consume_token :on_tstring_beg
end
visit_string_literal_end(node)
end
def visit_string_literal_end(node)
line = @line
inner = node[1]
inner = inner[1..-1] unless node[0] == :xstring_literal
visit_exps(inner, with_lines: false)
case current_token_kind
when :on_heredoc_end
(line + 1..@line).each do |i|
@heredoc_lines[i] = true
end
heredoc, tilde = @current_heredoc
if heredoc && tilde
write_indent
write current_token_value.strip
else
write current_token_value.rstrip
end
next_token
skip_space
# Simulate a newline after the heredoc
@tokens << [[0, 0], :on_ignored_nl, "\n"]
when :on_backtick
consume_token :on_backtick
else
consume_token :on_tstring_end
end
end
def visit_string_concat(node)
# string1 string2
# [:string_concat, string1, string2]
_, string1, string2 = node
visit string1
has_backslash, first_space = skip_space_backslash
if has_backslash
write " \\"
write_line
write_indent
else
consume_space
end
visit string2
end
def visit_string_interpolation(node)
# [:string_embexpr, exps]
consume_token :on_embexpr_beg
skip_space_or_newline
visit_exps(node[1], with_lines: false)
skip_space_or_newline
consume_token :on_embexpr_end
end
def visit_string_dvar(node)
# [:string_dvar, [:var_ref, [:@ivar, "@foo", [1, 2]]]]
consume_token :on_embvar
visit node[1]
end
def visit_symbol_literal(node)
# :foo
#
# [:symbol_literal, [:symbol, [:@ident, "foo", [1, 1]]]]
#
# A symbol literal not necessarily begins with `:`.
# For example, an `alias foo bar` will treat `foo`
# a as symbol_literal but without a `:symbol` child.
visit node[1]
end
def visit_symbol(node)
# :foo
#
# [:symbol, [:@ident, "foo", [1, 1]]]
consume_token :on_symbeg
visit_exps node[1..-1], with_lines: false
end
def visit_quoted_symbol_literal(node)
# :"foo"
#
# [:dyna_symbol, exps]
_, exps = node
# This is `"...":` as a hash key
if current_token_kind == :on_tstring_beg
consume_token :on_tstring_beg
visit exps
consume_token :on_label_end
else
consume_token :on_symbeg
visit_exps exps, with_lines: false
consume_token :on_tstring_end
end
end
def visit_path(node)
# Foo::Bar
#
# [:const_path_ref,
# [:var_ref, [:@const, "Foo", [1, 0]]],
# [:@const, "Bar", [1, 5]]]
pieces = node[1..-1]
pieces.each_with_index do |piece, i|
visit piece
unless last?(i, pieces)
consume_op "::"
skip_space_or_newline
end
end
end
def visit_assign(node)
# target = value
#
# [:assign, target, value]
_, target, value = node
line = @line
visit target
consume_space(want_preserve_whitespace: !@align_assignments)
track_assignment
consume_op "="
visit_assign_value value
@assignments_ranges[line] = @line if @line != line
end
def visit_op_assign(node)
# target += value
#
# [:opassign, target, op, value]
_, target, op, value = node
line = @line
visit target
consume_space(want_preserve_whitespace: !@align_assignments)
# [:@op, "+=", [1, 2]],
check :on_op
before = op[1][0...-1]
after = op[1][-1]
write before
track_assignment before.size
write after
next_token
visit_assign_value value
@assignments_ranges[line] = @line if @line != line
end
def visit_multiple_assign(node)
# [:massign, lefts, right]
_, lefts, right = node
visit_comma_separated_list lefts
skip_space
# A trailing comma can come after the left hand side
consume_token :on_comma if comma?
consume_space(want_preserve_whitespace: !@align_assignments)
track_assignment
consume_op "="
visit_assign_value right
end
def visit_assign_value(value)
first_space = current_token if space?
skip_space
indent_after_space value, sticky: indentable_value?(value), want_space: true, first_space: first_space
end
def indentable_value?(value)
return unless current_token_kind == :on_kw
case current_token_value
when "if", "unless", "case"
true
when "begin"
# Only indent if it's begin/rescue
return false unless value[0] == :begin
body = value[1]
return false unless body[0] == :bodystmt
_, body, rescue_body, else_body, ensure_body = body
rescue_body || else_body || ensure_body
else
false
end
end
def track_comment
@line_to_alignments_positions[@line] << [:comment, @column, @comments_positions, @comments_positions.size]
@comments_positions << [@line, @column, 0, nil, 0]
end
def track_assignment(offset = 0)
track_alignment :assign, @assignments_positions, offset
end
def track_hash_key
return unless @current_hash
track_alignment :hash_key, @hash_keys_positions, 0, @current_hash.object_id
end
def track_case_when
track_alignment :case_whem, @case_when_positions
end
def track_alignment(key, target, offset = 0, id = nil)
last = target.last
if last && last[0] == @line
# Track only the first alignment in a line
return
end
@line_to_alignments_positions[@line] << [key, @column, target, target.size]
target << [@line, @column, @indent, id, offset]
end
def visit_ternary_if(node)
# cond ? then : else
#
# [:ifop, cond, then_body, else_body]
_, cond, then_body, else_body = node
visit cond
consume_space(want_preserve_whitespace: true)
consume_op "?"
first_space = current_token if space?
skip_space
if newline? || comment?
consume_end_of_line
write_indent(next_indent)
elsif first_space && @preserve_whitespace
write_space first_space[2]
else
consume_space
end
visit then_body
consume_space(want_preserve_whitespace: true)
consume_op ":"
first_space = current_token if space?
skip_space
if newline? || comment?
consume_end_of_line
write_indent(next_indent)
elsif first_space && @preserve_whitespace
write_space first_space[2]
else
consume_space
end
visit else_body
end
def visit_suffix(node, suffix)
# then if cond
# then unless cond
# exp rescue handler
#
# [:if_mod, cond, body]
_, body, cond = node
if suffix != "rescue"
body, cond = cond, body
end
visit body
consume_space(want_preserve_whitespace: true)
consume_keyword(suffix)
consume_space(want_preserve_whitespace: true)
visit cond
end
def visit_call_with_receiver(node)
# [:call, obj, :".", name]
_, obj, text, name = node
@dot_column = nil
visit obj
skip_space
if newline? || comment?
consume_end_of_line
if @align_chained_calls
@name_dot_column = @dot_column || next_indent
write_indent(@dot_column || next_indent)
else
@name_dot_column = next_indent
write_indent(next_indent)
end
end
# Remember dot column, but only if there isn't one already set
dot_column = @column unless @dot_column
consume_call_dot
skip_space
if newline? || comment?
consume_end_of_line
write_indent(next_indent)
else
skip_space_or_newline
end
if name == :call
# :call means it's .()
else
visit name
end
# Only set it after we visit the call after the dot,
# so we remember the outmost dot position
@dot_column = dot_column if dot_column
end
def consume_call_dot
if current_token_kind == :on_op
consume_token :on_op
else
consume_token :on_period
end
end
def visit_call_without_receiver(node)
# foo(arg1, ..., argN)
#
# [:method_add_arg,
# [:fcall, [:@ident, "foo", [1, 0]]],
# [:arg_paren, [:args_add_block, [[:@int, "1", [1, 6]]], false]]]
_, name, args = node
@name_dot_column = nil
visit name
# Some times a call comes without parens (should probably come as command, but well...)
return if args.empty?
# Remember dot column so it's not affected by args
dot_column = @dot_column
want_indent = @name_dot_column && @name_dot_column > @indent
maybe_indent(want_indent, @name_dot_column) do
visit_call_at_paren(node, args)
end
# Restore dot column so it's not affected by args
@dot_column = dot_column
end
def visit_call_at_paren(node, args)
consume_token :on_lparen
# If there's a trailing comma then comes [:arg_paren, args],
# which is a bit unexpected, so we fix it
if args[1].is_a?(Array) && args[1][0].is_a?(Array)
args_node = [:args_add_block, args[1], false]
else
args_node = args[1]
end
if args_node
skip_space
needs_trailing_newline = newline? || comment?
if needs_trailing_newline && (call_info = @line_to_call_info[@line])
call_info << true
end
want_trailing_comma = true
# Check if there's a block arg and if the call ends with hash key/values
if args_node[0] == :args_add_block
_, args, block_arg = args_node
want_trailing_comma = !block_arg
if args.is_a?(Array) && (last_arg = args.last) && last_arg.is_a?(Array) &&
last_arg[0].is_a?(Symbol) && last_arg[0] != :bare_assoc_hash
want_trailing_comma = false
end
end
push_call(node) do
visit args_node
skip_space
end
found_comma = comma?
if found_comma
if needs_trailing_newline
write "," if @trailing_commas != :never && !block_arg
next_token
indent(next_indent) do
consume_end_of_line
end
write_indent
else
next_token
skip_space
end
end
if newline? || comment?
if needs_trailing_newline
write "," if @trailing_commas == :always && want_trailing_comma
indent(next_indent) do
consume_end_of_line
end
write_indent
else
skip_space_or_newline
end
else
if needs_trailing_newline && !found_comma
write "," if @trailing_commas == :always && want_trailing_comma
consume_end_of_line
write_indent
end
end
else
skip_space_or_newline
end
call_info << @line if call_info
consume_token :on_rparen
end
def visit_command(node)
# foo arg1, ..., argN
#
# [:command, name, args]
_, name, args = node
push_call(node) do
visit name
has_backslash, first_space = skip_space_backslash
if has_backslash
write " \\"
write_line
write_indent(next_indent)
elsif first_space && @preserve_whitespace
write_space first_space[2]
skip_space_or_newline
else
consume_space
end
end
visit_command_end(node, args)
end
def visit_command_end(node, args)
push_call(node) do
visit_command_args(args)
end
end
def flush_heredocs
if comment?
write_space unless @output[-1] == " "
write current_token_value.rstrip
next_token
write_line
if @heredocs.last[1]
write_indent(next_indent)
end
end
printed = false
until @heredocs.empty?
heredoc, tilde = @heredocs.first
@heredocs.shift
@current_heredoc = [heredoc, tilde]
visit_string_literal_end(heredoc)
@current_heredoc = nil
printed = true
end
end
def visit_command_call(node)
# [:command_call,
# receiver
# :".",
# name
# [:args_add_block, [[:@int, "1", [1, 8]]], block]]
_, receiver, dot, name, args = node
visit receiver
skip_space_or_newline
# Remember dot column
dot_column = @column
consume_call_dot
skip_space
if newline? || comment?
consume_end_of_line
write_indent(next_indent)
else
skip_space_or_newline
end
visit name
consume_space
visit_command_args(args)
# Only set it after we visit the call after the dot,
# so we remember the outmost dot position
@dot_column = dot_column
end
def visit_command_args(args)
needed_indent = @column
# Check if there's a single argument and it's
# a def, class or module. In that case we don't
# want to align the content to the position of
# that keyword.
if args[0] == :args_add_block
nested_args = args[1]
if nested_args.is_a?(Array) && nested_args.size == 1
first = nested_args[0]
if first.is_a?(Array)
case first[0]
when :def, :class, :module
needed_indent = @indent
end
end
end
end
call_info = @line_to_call_info[@line]
if call_info
call_info = nil
else
call_info = [@indent, @column]
@line_to_call_info[@line] = call_info
end
indent(needed_indent) do
if args[0].is_a?(Symbol)
visit args
else
visit_exps args, with_lines: false
end
end
if call_info && call_info.size > 2
call_info << @line
end
end
def visit_call_with_block(node)
# [:method_add_block, call, block]
_, call, block = node
visit call
consume_space
visit block
end
def visit_brace_block(node)
# [:brace_block, args, body]
_, args, body = node
# This is for the empty `{ }` block
if void_exps?(body)
consume_token :on_lbrace
consume_block_args args
if space?
consume_space
else
skip_space_or_newline
end
consume_token :on_rbrace
return
end
closing_brace_token, index = find_closing_brace_token
# If the whole block fits into a single line, use braces
if current_token[0][0] == closing_brace_token[0][0]
consume_token :on_lbrace
consume_block_args args
consume_space
visit_exps body, with_lines: false
consume_space
consume_token :on_rbrace
return
end
# Otherwise it's multiline
consume_token :on_lbrace
consume_block_args args
if call_info = @line_to_call_info[@line]
call_info << true
end
indent_body body, force_multiline: true
write_indent
call_info << @line if call_info
consume_token :on_rbrace
end
def visit_do_block(node)
# [:brace_block, args, body]
_, args, body = node
line = @line
consume_keyword "do"
consume_block_args args
indent_body body
write_indent if @line != line
consume_keyword "end"
end
def consume_block_args(args)
if args
consume_space
# + 1 because of |...|
# ^
indent(@column + 1) do
visit args
end
end
end
def visit_block_arguments(node)
# [:block_var, params, local_params]
_, params, local_params = node
empty_params = empty_params?(params)
check :on_op
# check for ||
if empty_params && !local_params
# Don't write || as it's meaningless
if current_token_value == "|"
next_token
skip_space_or_newline
check :on_op
next_token
else
next_token
end
return
end
consume_token :on_op
found_semicolon = skip_space_or_newline(_want_semicolon: true, write_first_semicolon: true)
if found_semicolon
# Nothing
elsif empty_params && local_params
consume_token :on_semicolon
found_semicolon = true
end
skip_space_or_newline
unless empty_params
visit params
skip_space
end
if local_params
if semicolon?
consume_token :on_semicolon
consume_space
end
visit_comma_separated_list local_params
else
skip_space_or_newline
end
consume_op "|"
end
def visit_call_args(node)
# [:args_add_block, args, block]
_, args, block_arg = node
if !args.empty? && args[0] == :args_add_star
# arg1, ..., *star
visit args
else
visit_comma_separated_list args
end
if block_arg
skip_space_or_newline
if comma?
indent(next_indent) do
write_params_comma
end
end
consume_op "&"
skip_space_or_newline
visit block_arg
end
end
def visit_args_add_star(node)
# [:args_add_star, args, star, post_args]
_, args, star, *post_args = node
if !args.empty? && args[0] == :args_add_star
# arg1, ..., *star
visit args
else
visit_comma_separated_list args
end
skip_space
write_params_comma if comma?
consume_op "*"
skip_space_or_newline
visit star
if post_args && !post_args.empty?
write_params_comma
visit_comma_separated_list post_args
end
end
def visit_begin(node)
# begin
# body
# end
#
# [:begin, [:bodystmt, body, rescue_body, else_body, ensure_body]]
consume_keyword "begin"
visit node[1]
end
def visit_bodystmt(node)
# [:bodystmt, body, rescue_body, else_body, ensure_body]
_, body, rescue_body, else_body, ensure_body = node
line = @line
indent_body body
while rescue_body
# [:rescue, type, name, body, more_rescue]
_, type, name, body, more_rescue = rescue_body
write_indent
consume_keyword "rescue"
if type
skip_space
write_space
indent(@column) do
visit_rescue_types(type)
end
end
if name
skip_space
write_space
consume_op "=>"
skip_space
write_space
visit name
end
indent_body body
rescue_body = more_rescue
end
if else_body
# [:else, body]
write_indent
consume_keyword "else"
indent_body else_body[1]
end
if ensure_body
# [:ensure, body]
write_indent
consume_keyword "ensure"
indent_body ensure_body[1]
end
write_indent if @line != line
consume_keyword "end"
end
def visit_rescue_types(node)
if node[0].is_a?(Symbol)
visit node
else
visit_exps node, with_lines: false
end
end
def visit_mrhs_new_from_args(node)
# Multiple exception types
# [:mrhs_new_from_args, exps, final_exp]
_, exps, final_exp = node
if final_exp
visit_comma_separated_list exps
write_params_comma
visit final_exp
elsif exps[0].is_a?(Symbol)
visit exps
else
visit_comma_separated_list exps
end
end
def visit_mlhs_paren(node)
# [:mlhs_paren,
# [[:mlhs_paren, [:@ident, "x", [1, 12]]]]
# ]
_, args = node
# For :mlsh_paren, sometimes a paren comes,
# some times not, so act accordingly.
has_paren = current_token_kind == :on_lparen
if has_paren
consume_token :on_lparen
skip_space_or_newline
end
# For some reason there's nested :mlhs_paren for
# a single parentheses. It seems when there's
# a nested array we need parens, otherwise we
# just output whatever's inside `args`.
if args.is_a?(Array) && args[0].is_a?(Array)
indent(@column) do
visit_comma_separated_list args
skip_space_or_newline
end
else
visit args
end
consume_token :on_rparen if has_paren
end
def visit_mrhs_add_star(node)
# [:mrhs_add_star, [], [:vcall, [:@ident, "x", [3, 8]]]]
_, x, y = node
if x.empty?
consume_op "*"
visit y
else
visit x
write_params_comma
consume_space
consume_op "*"
visit y
end
end
def visit_for(node)
#[:for, var, collection, body]
_, var, collection, body = node
line = @line
consume_keyword "for"
consume_space
if var[0].is_a?(Symbol)
visit var
else
visit_comma_separated_list var
end
consume_space
consume_keyword "in"
consume_space
visit collection
skip_space
indent_body body
write_indent if @line != line
consume_keyword "end"
end
def visit_BEGIN(node)
visit_BEGIN_or_END node, "BEGIN"
end
def visit_END(node)
visit_BEGIN_or_END node, "END"
end
def visit_BEGIN_or_END(node, keyword)
# [:BEGIN, body]
_, body = node
consume_keyword(keyword)
consume_space
closing_brace_token, _index = find_closing_brace_token
# If the whole block fits into a single line, format
# in a single line
if current_token[0][0] == closing_brace_token[0][0]
consume_token :on_lbrace
consume_space
visit_exps body, with_lines: false
consume_space
consume_token :on_rbrace
else
consume_token :on_lbrace
indent_body body
write_indent
consume_token :on_rbrace
end
end
def visit_comma_separated_list(nodes)
# When there's *x inside a left hand side assignment
# or a case when, it comes as [:op, ...]
if nodes[0].is_a?(Symbol)
visit nodes
return
end
needs_indent = false
if newline? || comment?
needs_indent = true
base_column = next_indent
consume_end_of_line
write_indent(base_column)
else
base_column = @column
end
nodes.each_with_index do |exp, i|
maybe_indent(needs_indent, base_column) do
if block_given?
yield exp
else
visit exp
end
end
next if last?(i, nodes)
skip_space
check :on_comma
write ","
next_token
first_space = current_token if space?
skip_space
if newline? || comment?
indent(base_column || @indent) do
consume_end_of_line(want_multiline: false)
write_indent
end
elsif first_space && @preserve_whitespace
write_space first_space[2]
skip_space_or_newline
else
write_space
skip_space_or_newline
end
end
end
def visit_mlhs_add_star(node)
# [:mlhs_add_star, before, star, after]
_, before, star, after = node
if before && !before.empty?
visit_comma_separated_list before
write_params_comma
end
consume_op "*"
skip_space_or_newline
visit star if star
if after && !after.empty?
write_params_comma
visit_comma_separated_list after
end
end
def visit_unary(node)
# [:unary, :-@, [:vcall, [:@ident, "x", [1, 2]]]]
_, op, exp = node
consume_op_or_keyword op
if op == :not
consume_space(want_preserve_whitespace: true)
else
skip_space_or_newline
end
visit exp
end
def visit_binary(node)
# [:binary, left, op, right]
_, left, op, right = node
# If this binary is not at the beginning of a line, if there's
# a newline following the op we want to align it with the left
# value. So for example:
#
# var = left_exp ||
# right_exp
#
# But:
#
# def foo
# left_exp ||
# right_exp
# end
needed_indent = @column == @indent ? next_indent : @column
visit left
if space?
needs_space = true
else
needs_space = op != :* && op != :/ && op != :**
end
has_backslash, first_space = skip_space_backslash
if has_backslash
needs_space = true
write " \\"
write_line
write_indent(next_indent)
elsif @preserve_whitespace && first_space
write_space first_space[2]
else
write_space if needs_space
end
consume_op_or_keyword op
indent_after_space right, want_space: needs_space, needed_indent: needed_indent
end
def consume_op_or_keyword(op)
case current_token_kind
when :on_op, :on_kw
write current_token_value
next_token
else
bug "Expected op or kw, not #{current_token_kind}"
end
end
def visit_class(node)
# [:class,
# name
# superclass
# [:bodystmt, body, nil, nil, nil]]
_, name, superclass, body = node
consume_keyword "class"
skip_space_or_newline
write_space
visit name
if superclass
skip_space_or_newline
write_space
consume_op "<"
skip_space_or_newline
write_space
visit superclass
end
visit body
end
def visit_module(node)
# [:module,
# name
# [:bodystmt, body, nil, nil, nil]]
_, name, body = node
consume_keyword "module"
skip_space_or_newline
write_space
visit name
visit body
end
def visit_def(node)
# [:def,
# [:@ident, "foo", [1, 6]],
# [:params, nil, nil, nil, nil, nil, nil, nil],
# [:bodystmt, [[:void_stmt]], nil, nil, nil]]
_, name, params, body = node
consume_keyword "def"
consume_space
push_hash(node) do
visit_def_from_name name, params, body
end
end
def visit_def_with_receiver(node)
# [:defs,
# [:vcall, [:@ident, "foo", [1, 5]]],
# [:@period, ".", [1, 8]],
# [:@ident, "bar", [1, 9]],
# [:params, nil, nil, nil, nil, nil, nil, nil],
# [:bodystmt, [[:void_stmt]], nil, nil, nil]]
_, receiver, period, name, params, body = node
consume_keyword "def"
consume_space
visit receiver
skip_space_or_newline
check :on_period
write "."
next_token
skip_space_or_newline
push_hash(node) do
visit_def_from_name name, params, body
end
end
def visit_def_from_name(name, params, body)
visit name
if params[0] == :paren
params = params[1]
end
skip_space
if current_token_kind == :on_lparen
next_token
skip_space
skip_semicolons
if empty_params?(params)
skip_space_or_newline
check :on_rparen
next_token
skip_space
# () needs to be preserved if some content follows
unless newline? || comment?
write "()"
end
else
write "("
if newline? || comment?
column = @column
indent(column) do
consume_end_of_line
write_indent
visit params
end
else
indent(@column) do
visit params
end
end
skip_space_or_newline
check :on_rparen
write ")"
next_token
end
elsif !empty_params?(params)
write "("
visit params
write ")"
skip_space
end
visit body
end
def empty_params?(node)
_, a, b, c, d, e, f, g = node
!a && !b && !c && !d && !e && !f && !g
end
def visit_paren(node)
# ( exps )
#
# [:paren, exps]
_, exps = node
consume_token :on_lparen
skip_space_or_newline
if exps
if exps[0].is_a?(Symbol)
visit exps
else
visit_exps exps, with_lines: false
end
end
skip_space_or_newline
consume_token :on_rparen
end
def visit_params(node)
# (def params)
#
# [:params, pre_rest_params, args_with_default, rest_param, post_rest_params, label_params, double_star_param, blockarg]
_, pre_rest_params, args_with_default, rest_param, post_rest_params, label_params, double_star_param, blockarg = node
needs_comma = false
if pre_rest_params
visit_comma_separated_list pre_rest_params
needs_comma = true
end
if args_with_default
write_params_comma if needs_comma
visit_comma_separated_list(args_with_default) do |arg, default|
visit arg
skip_space
write_space
consume_op "="
skip_space_or_newline
write_space
visit default
end
needs_comma = true
end
if rest_param
# check for trailing , |x, |
if rest_param == 0
write_params_comma
else
# [:rest_param, [:@ident, "x", [1, 15]]]
_, rest = rest_param
write_params_comma if needs_comma
consume_op "*"
skip_space_or_newline
visit rest if rest
needs_comma = true
end
end
if post_rest_params
write_params_comma if needs_comma
visit_comma_separated_list post_rest_params
needs_comma = true
end
if label_params
# [[label, value], ...]
write_params_comma if needs_comma
visit_comma_separated_list(label_params) do |label, value|
# [:@label, "b:", [1, 20]]
write label[1]
next_token
skip_space_or_newline
if value
consume_space
track_hash_key
visit value
end
end
needs_comma = true
end
if double_star_param
write_params_comma if needs_comma
consume_op "**"
skip_space_or_newline
# A nameless double star comes as an... Integer? :-S
visit double_star_param if double_star_param.is_a?(Array)
skip_space_or_newline
needs_comma = true
end
if blockarg
# [:blockarg, [:@ident, "block", [1, 16]]]
write_params_comma if needs_comma
skip_space_or_newline
consume_op "&"
skip_space_or_newline
visit blockarg[1]
end
end
def write_params_comma
skip_space
check :on_comma
write ","
next_token
first_space = current_token if space?
skip_space
if newline? || comment?
consume_end_of_line
write_indent
elsif first_space && @preserve_whitespace
write_space first_space[2]
skip_space_or_newline
else
write_space
skip_space_or_newline
end
end
def visit_array(node)
# [:array, elements]
# Check if it's `%w(...)` or `%i(...)`
case current_token_kind
when :on_qwords_beg, :on_qsymbols_beg, :on_words_beg, :on_symbols_beg
visit_q_or_i_array(node)
return
end
_, elements = node
check :on_lbracket
write "["
next_token
if elements
if elements[0].is_a?(Symbol)
skip_space_or_newline
visit elements
skip_space_or_newline
else
visit_literal_elements elements, inside_array: true
end
else
skip_space_or_newline
end
check :on_rbracket
write "]"
next_token
end
def visit_q_or_i_array(node)
_, elements = node
# For %W it seems elements appear inside other arrays
# for some reason, so we flatten them
if elements[0].is_a?(Array) && elements[0][0].is_a?(Array)
elements = elements.flat_map { |x| x }
end
has_space = current_token_value.end_with?(" ")
write current_token_value.strip
# If there's a newline after `%w(`, write line and indent
if current_token_value.include?("\n") && elements
write_line
write_indent(next_indent)
end
next_token
if elements && !elements.empty?
write_space if has_space
column = @column
elements.each_with_index do |elem, i|
if elem[0] == :@tstring_content
# elem is [:@tstring_content, string, [1, 5]
write elem[1].strip
next_token
else
visit elem
end
if !last?(i, elements) && current_token_kind == :on_words_sep
# On a newline, write line and indent
if current_token_value.include?("\n")
next_token
write_line
write_indent(column)
else
next_token
write_space
end
end
end
end
has_newline = false
last_token = nil
while current_token_kind == :on_words_sep
has_newline ||= current_token_value.include?("\n")
unless current_token[2].strip.empty?
last_token = current_token
end
next_token
end
if has_newline
write_line
write_indent
elsif has_space && elements && !elements.empty?
write_space
end
if last_token
write last_token[2].strip
else
write current_token_value.strip
next_token
end
end
def visit_hash(node)
# [:hash, elements]
_, elements = node
check :on_lbrace
write "{"
next_token
if elements
# [:assoclist_from_args, elements]
push_hash(node) do
visit_literal_elements(elements[1], inside_hash: true)
end
else
skip_space_or_newline
end
check :on_rbrace
write "}"
next_token
end
def visit_hash_key_value(node)
# key => value
#
# [:assoc_new, key, value]
_, key, value = node
# If a symbol comes it means it's something like
# `:foo => 1` or `:"foo" => 1` and a `=>`
# always follows
symbol = current_token_kind == :on_symbeg
arrow = symbol || !(key[0] == :@label || key[0] == :dyna_symbol)
visit key
consume_space(want_preserve_whitespace: @preserve_whitespace)
track_hash_key
# Don't output `=>` for keys that are `label: value`
# or `"label": value`
if arrow
consume_op "=>"
consume_space(want_preserve_whitespace: !@align_hash_keys)
end
visit value
end
def visit_splat_inside_hash(node)
# **exp
#
# [:assoc_splat, exp]
consume_op "**"
skip_space_or_newline
visit node[1]
end
def visit_range(node, inclusive)
# [:dot2, left, right]
_, left, right = node
visit left
skip_space_or_newline
consume_op(inclusive ? ".." : "...")
skip_space_or_newline
visit right
end
def visit_regexp_literal(node)
# [:regexp_literal, pieces, [:@regexp_end, "/", [1, 1]]]
_, pieces = node
check :on_regexp_beg
write current_token_value
next_token
visit_exps pieces, with_lines: false
check :on_regexp_end
write current_token_value
next_token
end
def visit_array_access(node)
# exp[arg1, ..., argN]
#
# [:aref, name, args]
_, name, args = node
visit_array_getter_or_setter name, args
end
def visit_array_setter(node)
# exp[arg1, ..., argN]
# (followed by `=`, though not included in this node)
#
# [:aref_field, name, args]
_, name, args = node
visit_array_getter_or_setter name, args
end
def visit_array_getter_or_setter(name, args)
visit name
check :on_lbracket
write "["
next_token
column = @column
skip_space
# Sometimes args comes with an array...
if args && args[0].is_a?(Array)
visit_literal_elements args
else
if newline? || comment?
needed_indent = next_indent
if args
consume_end_of_line
write_indent(needed_indent)
else
skip_space_or_newline
end
else
needed_indent = column
end
if args
indent(needed_indent) do
visit args
end
end
end
skip_space_or_newline
check :on_rbracket
write "]"
next_token
end
def visit_sclass(node)
# class << self
#
# [:sclass, target, body]
_, target, body = node
consume_keyword "class"
consume_space
consume_op "<<"
consume_space
visit target
visit body
end
def visit_setter(node)
# foo.bar
# (followed by `=`, though not included in this node)
#
# [:field, receiver, :".", name]
_, receiver, dot, name = node
@dot_column = nil
visit receiver
skip_space
if newline? || comment?
consume_end_of_line
write_indent(@dot_column || next_indent)
end
# Remember dot column
dot_column = @column
consume_call_dot
skip_space
if newline? || comment?
consume_end_of_line
write_indent(next_indent)
else
skip_space_or_newline
end
visit name
# Only set it after we visit the call after the dot,
# so we remember the outmost dot position
@dot_column = dot_column
end
def visit_return(node)
# [:return, exp]
visit_control_keyword node, "return"
end
def visit_break(node)
# [:break, exp]
visit_control_keyword node, "break"
end
def visit_next(node)
# [:next, exp]
visit_control_keyword node, "next"
end
def visit_yield(node)
# [:yield, exp]
visit_control_keyword node, "yield"
end
def visit_control_keyword(node, keyword)
_, exp = node
consume_keyword keyword
if exp && !exp.empty?
consume_space if space?
indent(@column) do
# For `return a b` there comes many nodes, not just one... (see #8)
if node[1][0].is_a?(Symbol)
visit node[1]
else
visit_exps node[1], with_lines: false
end
end
end
end
def visit_lambda(node)
# [:lambda, [:params, nil, nil, nil, nil, nil, nil, nil], [[:void_stmt]]]
_, params, body = node
check :on_tlambda
write "->"
next_token
skip_space_or_newline
if empty_params?(params)
if current_token_kind == :on_lparen
next_token
skip_space_or_newline
check :on_rparen
next_token
skip_space_or_newline
end
else
visit params
consume_space
end
if void_exps?(body)
consume_token :on_tlambeg
consume_space
consume_token :on_rbrace
return
end
brace = current_token_value == "{"
if brace
closing_brace_token, index = find_closing_brace_token
# Check if the whole block fits into a single line
if current_token[0][0] == closing_brace_token[0][0]
consume_token :on_tlambeg
consume_space
visit_exps body, with_lines: false
consume_space
consume_token :on_rbrace
return
end
consume_token :on_tlambeg
else
consume_keyword "do"
end
indent_body body, force_multiline: true
write_indent
if brace
consume_token :on_rbrace
else
consume_keyword "end"
end
end
def visit_super(node)
# [:super, args]
_, args = node
consume_keyword "super"
if space?
consume_space
visit_command_end node, args
else
visit_call_at_paren node, args
end
end
def visit_defined(node)
# [:defined, exp]
_, exp = node
consume_keyword "defined?"
skip_space_or_newline
has_paren = current_token_kind == :on_lparen
if has_paren
write "("
next_token
skip_space_or_newline
else
consume_space
end
# exp can be [:paren, exp] if there's a parentheses,
# though not always (only if there's a space after `defined?`)
if exp[0] == :paren
exp = exp[1]
end
visit exp
if has_paren
skip_space_or_newline
check :on_rparen
write ")"
next_token
end
end
def visit_alias(node)
# [:alias, from, to]
_, from, to = node
consume_keyword "alias"
consume_space
visit from
consume_space
visit to
end
def visit_undef(node)
# [:undef, exps]
_, exps = node
consume_keyword "undef"
consume_space
visit_comma_separated_list exps
end
def visit_literal_elements(elements, inside_hash: false, inside_array: false)
base_column = @column
needs_final_space = (inside_hash || inside_array) && space?
skip_space
if inside_hash
case @space_after_hash_brace
when :never
needs_final_space = false
when :always
needs_final_space = true
end
end
if inside_array
case @space_after_array_bracket
when :never
needs_final_space = false
when :always
needs_final_space = true
end
end
if newline? || comment?
needs_final_space = false
elsif needs_final_space
consume_space
base_column = @column
end
# If there's a newline right at the beginning,
# write it, and we'll indent element and always
# add a trailing comma to the last element
needs_trailing_comma = newline? || comment?
if needs_trailing_comma
if (call_info = @line_to_call_info[@line])
call_info << true
end
needed_indent = next_indent
indent { consume_end_of_line }
write_indent(needed_indent)
else
needed_indent = base_column
end
wrote_comma = false
last_has_comma = false
elements.each_with_index do |elem, i|
is_last = last?(i, elements)
wrote_comma = false
last_has_comma = false
if needs_trailing_comma
indent(needed_indent) { visit elem }
else
visit elem
end
# We have to be careful not to aumatically write a heredoc on next_token,
# because we miss the chance to write a comma to separate elements
skip_space_no_heredoc_check
wrote_comma = check_heredocs_in_literal_elements(is_last, needs_trailing_comma, wrote_comma)
next unless comma?
last_has_comma = true
unless is_last
write ","
wrote_comma = true
end
# We have to be careful not to aumatically write a heredoc on next_token,
# because we miss the chance to write a comma to separate elements
next_token_no_heredoc_check
first_space = current_token if space?
skip_space_no_heredoc_check
wrote_comma = check_heredocs_in_literal_elements(is_last, needs_trailing_comma, wrote_comma)
if newline? || comment?
if is_last
# Nothing
else
consume_end_of_line
write_indent(needed_indent)
end
elsif !is_last && first_space && @preserve_whitespace
write_space first_space[2]
else
write_space unless is_last
end
end
if needs_trailing_comma
case @trailing_commas
when :always
write "," unless wrote_comma
when :never
# Nothing
when :dynamic
write "," if last_has_comma && !wrote_comma
end
consume_end_of_line
write_indent
elsif comment?
consume_end_of_line
else
if needs_final_space
consume_space
else
skip_space_or_newline
end
end
call_info << @line if call_info
end
def check_heredocs_in_literal_elements(is_last, needs_trailing_comma, wrote_comma)
if (newline? || comment?) && !@heredocs.empty?
if !is_last || needs_trailing_comma
write "," unless wrote_comma
wrote_comma = true
end
flush_heredocs
end
wrote_comma
end
def visit_if(node)
visit_if_or_unless node, "if"
end
def visit_unless(node)
visit_if_or_unless node, "unless"
end
def visit_if_or_unless(node, keyword, check_end: true)
# if cond
# then_body
# else
# else_body
# end
#
# [:if, cond, then, else]
line = @line
consume_keyword(keyword)
consume_space
visit node[1]
skip_space
indent_body node[2]
if else_body = node[3]
# [:else, else_contents]
# [:elsif, cond, then, else]
write_indent
case else_body[0]
when :else
consume_keyword "else"
indent_body else_body[1]
when :elsif
visit_if_or_unless else_body, "elsif", check_end: false
else
bug "expected else or elsif, not #{else_body[0]}"
end
end
if check_end
write_indent if @line != line
consume_keyword "end"
end
end
def visit_while(node)
# [:while, cond, body]
visit_while_or_until node, "while"
end
def visit_until(node)
# [:until, cond, body]
visit_while_or_until node, "until"
end
def visit_while_or_until(node, keyword)
_, cond, body = node
line = @line
consume_keyword keyword
consume_space
visit cond
skip_space
indent_body body
write_indent if @line != line
consume_keyword "end"
end
def visit_case(node)
# [:case, cond, case_when]
_, cond, case_when = node
consume_keyword "case"
if cond
consume_space
visit cond
end
consume_end_of_line
write_indent
visit case_when
write_indent
consume_keyword "end"
end
def visit_when(node)
# [:when, conds, body, next_exp]
_, conds, body, next_exp = node
consume_keyword "when"
consume_space
indent(@column) do
visit_comma_separated_list conds
skip_space
end
then_keyword = keyword?("then")
inline = then_keyword || semicolon?
if then_keyword
next_token
skip_space
track_case_when
skip_semicolons
if newline? || comment?
inline = false
# Cancel tracking of `case when ... then` on a nelwine.
@case_when_positions.pop
else
write " then "
end
elsif semicolon?
skip_semicolons
if newline? || comment?
inline = false
else
write ";"
track_case_when
write " "
end
end
if inline
indent do
visit_exps body
end
else
indent_body body
end
if next_exp
write_indent
if next_exp[0] == :else
# [:else, body]
consume_keyword "else"
skip_space
if newline? || semicolon? || comment?
indent_body next_exp[1]
else
write_space
visit_exps next_exp[1]
end
else
visit next_exp
end
end
end
def consume_space(want_preserve_whitespace: false)
first_space = current_token if space?
skip_space
if want_preserve_whitespace && @preserve_whitespace && !newline? && !comment? && first_space
write_space first_space[2] unless @output[-1] == " "
skip_space_or_newline
else
skip_space_or_newline
write_space unless @output[-1] == " "
end
end
def skip_space
next_token while space?
end
def skip_space_no_heredoc_check
while space?
next_token_no_heredoc_check
end
end
def skip_space_backslash
return [false, false] unless space?
first_space = current_token
has_slash_newline = false
while space?
has_slash_newline ||= current_token_value == "\\\n"
next_token
end
[has_slash_newline, first_space]
end
def skip_space_or_newline(_want_semicolon: false, write_first_semicolon: false)
found_newline = false
found_comment = false
found_semicolon = false
last = nil
loop do
case current_token_kind
when :on_sp
next_token
when :on_nl, :on_ignored_nl
next_token
last = :newline
found_newline = true
when :on_semicolon
if (!found_newline && !found_comment) || (!found_semicolon && write_first_semicolon)
write "; "
end
next_token
last = :semicolon
found_semicolon = true
when :on_comment
write_line if last == :newline
write_indent if found_comment
if current_token_value.end_with?("\n")
write current_token_value.rstrip
write_line
else
write current_token_value
end
next_token
found_comment = true
last = :comment
else
break
end
end
found_semicolon
end
def skip_semicolons
while semicolon? || space?
next_token
end
end
def empty_body?(body)
body[0] == :bodystmt &&
body[1].size == 1 &&
body[1][0][0] == :void_stmt
end
def consume_token(kind)
check kind
consume_token_value(current_token_value)
next_token
end
def consume_token_value(value)
write value
# If the value has newlines, we need to adjust line and column
number_of_lines = value.count("\n")
if number_of_lines > 0
@line += number_of_lines
last_line_index = value.rindex("\n")
@column = value.size - (last_line_index + 1)
@last_was_newline = @column == 0
end
end
def consume_keyword(value)
check :on_kw
if current_token_value != value
bug "Expected keyword #{value}, not #{current_token_value}"
end
write value
next_token
end
def consume_op(value)
check :on_op
if current_token_value != value
bug "Expected op #{value}, not #{current_token_value}"
end
write value
next_token
end
# Consume and print an end of line, handling semicolons and comments
#
# - at_prefix: are we at a point before an expression? (if so, we don't need a space before the first comment)
# - want_semicolon: do we want do print a semicolon to separate expressions?
# - want_multiline: do we want multiple lines to appear, or at most one?
def consume_end_of_line(at_prefix: false, want_semicolon: false, want_multiline: true, needs_two_lines_on_comment: false)
found_newline = false # Did we find any newline during this method?
last = nil # Last token kind found
multilple_lines = false # Did we pass through more than one newline?
last_comment_has_newline = false # Does the last comment has a newline?
newline_count = 0 # Number of newlines we passed
last_comment = nil # Token for the last comment found
last_comment_column = nil # Actual column of the last comment written
loop do
case current_token_kind
when :on_sp
# Ignore spaces
next_token
when :on_nl, :on_ignored_nl
# I don't know why but sometimes a on_ignored_nl
# can appear with nil as the "text", and that's wrong
if current_token[2].nil?
next_token
next
end
if last == :newline
# If we pass through consecutive newlines, don't print them
# yet, but remember this fact
multilple_lines = true unless last_comment_has_newline
else
# If we just printed a comment that had a newline,
# we must print two newlines because we remove newlines from comments (rstrip call)
if last == :comment && last_comment_has_newline
write_line
multilple_lines = true
else
write_line
multilple_lines = false
end
end
found_newline = true
next_token
last = :newline
newline_count += 1
when :on_semicolon
next_token
# If we want to print semicolons and we didn't find a newline yet,
# print it, but only if it's not followed by a newline
if !found_newline && want_semicolon && last != :semicolon
skip_space
case current_token_kind
when :on_ignored_nl, :on_eof
else
write "; "
last = :semicolon
end
end
multilple_lines = false
when :on_comment
if last == :comment
# Since we remove newlines from comments, we must add the last
# one if it was a comment
write_line
# If the last comment is in the previous line and it was already
# aligned to this comment, keep it aligned. This is useful for
# this:
#
# ```
# a = 1 # some comment
# # that continues here
# ```
#
# We want to preserve it like that and not change it to:
#
# ```
# a = 1 # some comment
# # that continues here
# ```
if last_comment &&
last_comment[0][0] + 1 == current_token[0][0] &&
last_comment[0][1] == current_token[0][1]
write_indent(last_comment_column)
track_comment
else
write_indent
end
else
if found_newline
if newline_count == 1 && needs_two_lines_on_comment
if multilple_lines
write_line
multilple_lines = false
else
multilple_lines = true
end
needs_two_lines_on_comment = false
end
# Write line or second line if needed
write_line if last != :newline || multilple_lines
write_indent
else
# If we didn't find any newline yet, this is the first comment,
# so append a space if needed (for example after an expression)
write_space unless at_prefix
track_comment
end
end
last_comment = current_token
last_comment_column = @column
last_comment_has_newline = current_token_value.end_with?("\n")
last = :comment
multilple_lines = false
write current_token_value.rstrip
next_token
when :on_embdoc_beg
write_line if multilple_lines
consume_embedded_comment
last = :comment
last_comment_has_newline = true
else
break
end
end
# Output a newline if we didn't do so yet:
# either we didn't find a newline and we are at the end of a line (and we didn't just pass a semicolon),
# or the last thing was a comment (from which we removed the newline)
# or we just passed multiple lines (but printed only one)
if (!found_newline && !at_prefix && !(want_semicolon && last == :semicolon)) ||
last == :comment ||
(multilple_lines && want_multiline)
write_line
end
end
def consume_embedded_comment
write current_token_value
next_token
while current_token_kind != :on_embdoc_end
write current_token_value
next_token
end
write current_token_value.rstrip
next_token
end
def consume_end
return unless current_token_kind == :on___end__
line = current_token[0][0]
write_line
consume_token :on___end__
lines = @code.lines[line..-1]
lines.each do |line|
write line.chomp
write_line
end
end
def indent(value = nil)
if value
old_indent = @indent
@indent = value
yield
@indent = old_indent
else
@indent += @indent_size
yield
@indent -= @indent_size
end
end
def indent_body(exps, force_multiline: false)
skip_space
has_semicolon = semicolon?
if has_semicolon
next_token
skip_semicolons
end
# If an end follows there's nothing to do
if keyword?("end")
if has_semicolon
write "; "
else
write " "
end
return
end
# A then keyword can appear after a newline after an `if`, `unless`, etc.
# Since that's a super weird formatting for if, probably way too obsolete
# by now, we just remove it.
has_then = keyword?("then")
if has_then
next_token
skip_space
end
has_do = keyword?("do")
if has_do
next_token
skip_space
end
# If no newline or comment follows, we format it inline.
if !force_multiline && !(newline? || comment?)
if has_then
write " then "
elsif has_do
write " do "
elsif has_semicolon
write "; "
else
write " "
end
visit_exps exps, with_indent: false, with_lines: false
consume_space
return
end
indent do
consume_end_of_line(want_multiline: false)
end
if keyword?("then")
next_token
skip_space_or_newline
end
# If the body is [[:void_stmt]] it's an empty body
# so there's nothing to write
if exps.size == 1 && exps[0][0] == :void_stmt
skip_space_or_newline
else
indent do
visit_exps exps, with_indent: true
end
write_line unless @last_was_newline
end
end
def maybe_indent(toggle, indent_size)
if toggle
indent(indent_size) do
yield
end
else
yield
end
end
def write(value)
@output << value
@last_was_newline = false
@column += value.size
end
def write_space(value = " ")
@output << value
@column += value.size
end
def write_line
@output << "\n"
@last_was_newline = true
@column = 0
@line += 1
end
def write_indent(indent = @indent)
@output << " " * indent
@column += indent
@last_was_newline = false
end
def indent_after_space(node, sticky: false, want_space: true, first_space: nil, needed_indent: next_indent)
first_space = current_token if space?
skip_space
case current_token_kind
when :on_ignored_nl, :on_comment
indent(needed_indent) do
consume_end_of_line
write_indent
visit node
end
else
if want_space
if first_space && @preserve_whitespace
write_space first_space[2]
else
write_space
end
end
if sticky
indent(@column) do
visit node
end
else
visit node
end
end
end
def next_indent
@indent + @indent_size
end
def check(kind)
if current_token_kind != kind
bug "Expected token #{kind}, not #{current_token_kind}"
end
end
def bug(msg)
raise Rufo::Bug.new("#{msg} at #{current_token}")
end
# [[1, 0], :on_int, "1"]
def current_token
@tokens.last
end
def current_token_kind
tok = current_token
tok ? tok[1] : :on_eof
end
def current_token_value
tok = current_token
tok ? tok[2] : ""
end
def keyword?(kw)
current_token_kind == :on_kw && current_token_value == kw
end
def newline?
current_token_kind == :on_nl || current_token_kind == :on_ignored_nl
end
def comment?
current_token_kind == :on_comment
end
def semicolon?
current_token_kind == :on_semicolon
end
def comma?
current_token_kind == :on_comma
end
def space?
current_token_kind == :on_sp
end
def void_exps?(node)
node.size == 1 && node[0].size == 1 && node[0][0] == :void_stmt
end
def find_closing_brace_token
count = 0
i = @tokens.size - 1
while i >= 0
token = @tokens[i]
(line, column), kind = token
case kind
when :on_lbrace, :on_tlambeg
count += 1
when :on_rbrace
count -= 1
return [token, i] if count == 0
end
i -= 1
end
nil
end
def newline_follows_token(index)
index -= 1
while index >= 0
token = @tokens[index]
case current_token_kind
when :on_sp
# OK
when :on_nl, :on_ignored_nl
return true
else
return false
end
index -= 1
end
true
end
def next_token
@tokens.pop
if (newline? || comment?) && !@heredocs.empty?
flush_heredocs
end
end
def next_token_no_heredoc_check
@tokens.pop
end
def last?(i, array)
i == array.size - 1
end
def push_call(node)
push_node(node) do
# A call can specify hash arguments so it acts as a
# hash for key alignment purposes
push_hash(node) do
yield
end
end
end
def push_node(node)
old_node = @current_node
@current_node = node
yield
@current_node = old_node
end
def push_hash(node)
old_hash = @current_hash
@current_hash = node
yield
@current_hash = old_hash
end
def dedent_calls
return if @line_to_call_info.empty?
lines = @output.lines
while line_to_call_info = @line_to_call_info.shift
first_line, call_info = line_to_call_info
indent, first_param_indent, needs_dedent, first_paren_end_line, last_line = call_info
next unless needs_dedent
next unless first_paren_end_line == last_line
diff = first_param_indent - indent
(first_line + 1..last_line).each do |line|
@line_to_call_info.delete(line)
next if @heredoc_lines[line]
current_line = lines[line]
current_line = current_line[diff..-1]
# It can happen that this line didn't need an indent because
# it simply had a newline
if current_line
lines[line] = current_line
adjust_other_alignments nil, line, 0, -diff
end
end
end
@output = lines.join
end
def do_align_comments
do_align @comments_positions, :comment
end
def do_align_assignments
do_align @assignments_positions, :assign
end
def do_align_hash_keys
do_align @hash_keys_positions, :hash_key
end
def do_align_case_when
do_align @case_when_positions, :case
end
def do_align(elements, scope)
lines = @output.lines
# Chunk elements that are in consecutive lines
chunks = chunk_while(elements) do |(l1, c1, i1, id1), (l2, c2, i2, id2)|
l1 + 1 == l2 && i1 == i2 && id1 == id2
end
chunks.each do |elements|
next if elements.size == 1
if scope == :hash_key
# Don't indent successive hash keys if any of them is in turn a hash
# or array literal that is formatted in separate lines.
has_brace_newline = elements.any? do |(l, c)|
line_end = lines[l][c..-1]
line_end.start_with?("=> {\n", "=> [\n", "=> [ #", "=> { #", "[\n", "{\n", "[ #", "{ #")
end
next if has_brace_newline
end
max_column = elements.map { |l, c| c }.max
elements.each do |(line, column, _, _, offset)|
next if column == max_column
split_index = column
split_index -= offset if offset
target_line = lines[line]
before = target_line[0...split_index]
after = target_line[split_index..-1]
filler_size = max_column - column
filler = " " * filler_size
# Move all lines affected by the assignment shift
if scope == :assign && (range = @assignments_ranges[line])
(line + 1..range).each do |line_number|
lines[line_number] = "#{filler}#{lines[line_number]}"
# And move other elements too if applicable
adjust_other_alignments scope, line_number, column, filler_size
end
end
# Move comments to the right if a change happened
if scope != :comment
adjust_other_alignments scope, line, column, filler_size
end
lines[line] = "#{before}#{filler}#{after}"
end
end
@output = lines.join
end
def adjust_other_alignments(scope, line, column, offset)
adjustments = @line_to_alignments_positions[line]
return unless adjustments
adjustments.each do |key, adjustment_column, target, index|
next if adjustment_column <= column
next if scope == key
target[index][1] += offset
end
end
def chunk_while(array, &block)
if array.respond_to?(:chunk_while)
array.chunk_while(&block)
else
Rufo::Backport.chunk_while(array, &block)
end
end
def result
@output
end
end