From 9a7e2e27a1496948ae3fff6947b8df9e2f2dd103 Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Wed, 5 Jun 2024 14:04:42 +0200 Subject: [PATCH] Add indentation formatting This is a huge patch and adds formatting of indentation. It is slightly difficult to come up with a good set of rules but I think what is here is pretty nice. There are two types of indentation: block/hard indentation and continuation/soft indentation. Block indentation are context independent and are thus "hard" or required in the sense that they always happen. Block indentation are enabled for all `x - end` blocks where `x` is one of `begin`, `do`, `for`, `function`, `if`, `let`, `quote`, `struct`, `try`, `while`. A notable exception is `module`, but one idea is to indent nested modules (in the same file). Continuation or soft indents are context dependent in the sense that if nested within another soft indent they extra indents are skipped. Soft indentation applies to all expressions spanning multiple lines. For example, newlines in operator chains: ```julia a + b * c + d # soft indent ``` function calls ```julia foo( a, b c, d # soft indent ) ``` etc. --- .gitignore | 1 + src/Runic.jl | 236 ++++++++-------- src/chisels.jl | 108 +++++++- src/main.jl | 2 +- src/runestone.jl | 696 ++++++++++++++++++++++++++++++++++++++++++++++- test/runtests.jl | 155 ++++++++++- 6 files changed, 1070 insertions(+), 128 deletions(-) diff --git a/.gitignore b/.gitignore index b067edd..dab5446 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /Manifest.toml +*.cov diff --git a/src/Runic.jl b/src/Runic.jl index a7f8004..7a67408 100644 --- a/src/Runic.jl +++ b/src/Runic.jl @@ -115,6 +115,9 @@ mutable struct Context debug::Bool check::Bool diff::Bool + # Global state + indent_level::Int # track (hard) indentation level + call_depth::Int # track call-depth level for debug printing # Current state # node::Union{Node, Nothing} prev_sibling::Union{Node, Nothing} @@ -145,9 +148,12 @@ function Context( # Debug mode enforces verbose and assert verbose = debug ? true : verbose assert = debug ? true : assert + indent_level = 0 + call_depth = 0 + prev_sibling = next_sibling = nothing return Context( - src_str, src_tree, src_io, fmt_io, fmt_tree, - quiet, verbose, assert, debug, check, diff, nothing, nothing, + src_str, src_tree, src_io, fmt_io, fmt_tree, quiet, verbose, assert, debug, check, + diff, call_depth, indent_level, prev_sibling, next_sibling, ) end @@ -187,6 +193,8 @@ function format_node_with_kids!(ctx::Context, node::Node) return nothing end + ctx.call_depth += 1 + # Keep track of the siblings on this stack prev_sibling = ctx.prev_sibling next_sibling = ctx.next_sibling @@ -247,6 +255,7 @@ function format_node_with_kids!(ctx::Context, node::Node) # Reset the siblings ctx.prev_sibling = prev_sibling ctx.next_sibling = next_sibling + ctx.call_depth -= 1 # Return a new node if any of the kids changed if any_kid_changed return make_node(node, kids′) @@ -266,7 +275,18 @@ Format a node. Return values: function format_node!(ctx::Context, node::Node)::Union{Node, Nothing, NullNode} node_kind = kind(node) + # Not that two separate `if`s are used here because a node like `else` can be both + # dedent and indent + if has_tag(node, TAG_INDENT) + ctx.indent_level += 1 + end + if has_tag(node, TAG_DEDENT) + ctx.indent_level -= 1 + end + # Go through the runestone and apply transformations. + ctx.call_depth += 1 + @return_something insert_delete_mark_newlines(ctx, node) @return_something trim_trailing_whitespace(ctx, node) @return_something format_hex_literals(ctx, node) @return_something format_oct_literals(ctx, node) @@ -275,131 +295,128 @@ function format_node!(ctx::Context, node::Node)::Union{Node, Nothing, NullNode} @return_something spaces_around_assignments(ctx, node) @return_something no_spaces_around_colon_etc(ctx, node) @return_something for_loop_use_in(ctx, node) + @return_something four_space_indent(ctx, node) + ctx.call_depth -= 1 # If the node is unchanged at this point, just keep going. - - # Nodes that always recurse! if ( - node_kind === K"block" || - node_kind === K"braces" || - node_kind === K"bracescat" || # {a; b} - node_kind === K"call" || - node_kind === K"cartesian_iterator" || - node_kind === K"char" || - node_kind === K"cmdstring" || - node_kind === K"comparison" || - node_kind === K"comprehension" || - node_kind === K"core_@cmd" || - node_kind === K"curly" || - node_kind === K"dotcall" || - node_kind === K"filter" || - node_kind === K"generator" || - node_kind === K"hcat" || - node_kind === K"importpath" || - node_kind === K"inert" || - node_kind === K"juxtapose" || - node_kind === K"macrocall" || - node_kind === K"ncat" || - node_kind === K"nrow" || - node_kind === K"parens" || - node_kind === K"ref" || - node_kind === K"row" || - node_kind === K"string" || - node_kind === K"toplevel" || - node_kind === K"typed_comprehension" || - node_kind === K"typed_hcat" || - node_kind === K"typed_ncat" || - node_kind === K"typed_vcat" || - node_kind === K"vcat" || - node_kind === K"vect" - ) + node_kind === K"block" || + node_kind === K"braces" || + node_kind === K"bracescat" || # {a; b} + node_kind === K"call" || + node_kind === K"cartesian_iterator" || + node_kind === K"char" || + node_kind === K"cmdstring" || + node_kind === K"comparison" || + node_kind === K"comprehension" || + node_kind === K"core_@cmd" || + node_kind === K"curly" || + node_kind === K"dotcall" || + node_kind === K"filter" || + node_kind === K"generator" || + node_kind === K"hcat" || + node_kind === K"importpath" || + node_kind === K"inert" || + node_kind === K"juxtapose" || + node_kind === K"macrocall" || + node_kind === K"ncat" || + node_kind === K"nrow" || + node_kind === K"parens" || + node_kind === K"ref" || + node_kind === K"row" || + node_kind === K"string" || + node_kind === K"toplevel" || + node_kind === K"typed_comprehension" || + node_kind === K"typed_hcat" || + node_kind === K"typed_ncat" || + node_kind === K"typed_vcat" || + node_kind === K"vcat" || + node_kind === K"vect" + ) + # Nodes that always recurse! @assert !JuliaSyntax.is_trivia(node) node′ = format_node_with_kids!(ctx, node) @assert node′ !== nullnode return node′ - - # Nodes that recurse! if not trivia - elseif !JuliaSyntax.is_trivia(node) && ( - node_kind === K"abstract" || - node_kind === K"as" || - node_kind === K"break" || - node_kind === K"catch" || - node_kind === K"const" || - node_kind === K"continue" || - node_kind === K"do" || - node_kind === K"doc" || - node_kind === K"elseif" || - node_kind === K"export" || - node_kind === K"finally" || - node_kind === K"for" || - node_kind === K"function" || - node_kind === K"global" || - node_kind === K"if" || - node_kind === K"import" || - node_kind === K"let" || - node_kind === K"local" || - node_kind === K"macro" || - node_kind === K"module" || - node_kind === K"outer" || - node_kind === K"parameters" || - node_kind === K"primitive" || - node_kind === K"quote" || - node_kind === K"return" || - node_kind === K"struct" || - node_kind === K"try" || - node_kind === K"tuple" || - node_kind === K"using" || - node_kind === K"var" || - node_kind === K"where" || - node_kind === K"while" + elseif !JuliaSyntax.is_trivia(node) && ( + node_kind === K"abstract" || + node_kind === K"as" || + node_kind === K"break" || + node_kind === K"catch" || + node_kind === K"const" || + node_kind === K"continue" || + node_kind === K"do" || + node_kind === K"doc" || + node_kind === K"elseif" || + node_kind === K"export" || + node_kind === K"finally" || + node_kind === K"for" || + node_kind === K"function" || + node_kind === K"global" || + node_kind === K"if" || + node_kind === K"import" || + node_kind === K"let" || + node_kind === K"local" || + node_kind === K"macro" || + node_kind === K"module" || + node_kind === K"outer" || + node_kind === K"parameters" || + node_kind === K"primitive" || + node_kind === K"quote" || + node_kind === K"return" || + node_kind === K"struct" || + node_kind === K"try" || + node_kind === K"tuple" || + node_kind === K"using" || + node_kind === K"var" || + node_kind === K"where" || + node_kind === K"while" ) + # Nodes that recurse! if not trivia + @assert !JuliaSyntax.is_trivia(node) node′ = format_node_with_kids!(ctx, node) @assert node′ !== nullnode return node′ - - # Nodes that should recurse if they have kids (all??) elseif !is_leaf(node) && ( - JuliaSyntax.is_operator(node) || - node_kind === K"else" # try-(catch|finally)-else - ) + JuliaSyntax.is_operator(node) || + node_kind === K"else" # try-(catch|finally)-else + ) + # Nodes that should recurse if they have kids (all??) node′ = format_node_with_kids!(ctx, node) @assert node′ !== nullnode return node′ - - # Whitespace and comments emitted verbatim for now elseif node_kind === K"Whitespace" || - node_kind === K"NewlineWs" || - node_kind === K"Comment" + node_kind === K"NewlineWs" || + node_kind === K"Comment" + # Whitespace and comments emitted verbatim for now accept_node!(ctx, node) return nothing - - # Nodes that always emit like the source code elseif ( - node_kind === K"(" || - node_kind === K")" || - node_kind === K"," || - node_kind === K"::" || - node_kind === K";" || - node_kind === K"<:" || - node_kind === K"@" || - node_kind === K"BinInt" || - node_kind === K"Char" || - node_kind === K"CmdMacroName" || - node_kind === K"CmdString" || - node_kind === K"Float" || - node_kind === K"Float32" || - node_kind === K"HexInt" || - node_kind === K"Identifier" || - node_kind === K"Integer" || - node_kind === K"MacroName" || - node_kind === K"OctInt" || - node_kind === K"String" || - node_kind === K"StringMacroName" || - node_kind === K"false" || - node_kind === K"true" || - node_kind === K"type" || - JuliaSyntax.is_operator(node) || - JuliaSyntax.is_trivia(node) && ( + node_kind === K"(" || + node_kind === K")" || + node_kind === K"," || + node_kind === K"::" || + node_kind === K";" || + node_kind === K"<:" || + node_kind === K"@" || + node_kind === K"BinInt" || + node_kind === K"Char" || + node_kind === K"CmdMacroName" || + node_kind === K"CmdString" || + node_kind === K"Float" || + node_kind === K"Float32" || + node_kind === K"HexInt" || + node_kind === K"Identifier" || + node_kind === K"Integer" || + node_kind === K"MacroName" || + node_kind === K"OctInt" || + node_kind === K"String" || + node_kind === K"StringMacroName" || + node_kind === K"false" || + node_kind === K"true" || + node_kind === K"type" || + JuliaSyntax.is_operator(node) || + JuliaSyntax.is_trivia(node) && ( node_kind === K"$" || node_kind === K"=" || node_kind === K"[" || @@ -445,7 +462,8 @@ function format_node!(ctx::Context, node::Node)::Union{Node, Nothing, NullNode} node_kind === K"{" || node_kind === K"}" ) - ) + ) + # Nodes that always emit like the source code accept_node!(ctx, node) return nothing else diff --git a/src/chisels.jl b/src/chisels.jl index d8aecbd..8360b2a 100644 --- a/src/chisels.jl +++ b/src/chisels.jl @@ -4,6 +4,12 @@ # Node utilities extensions and JuliaSyntax extensions # ######################################################## +# JuliaSyntax.jl overloads == for this but seems easier to just define a new function +function nodes_equal(n1::Node, n2::Node) + return head(n1) == head(n2) && span(n1) == span(n2) && n1.tags == n2.tags && + all(((x, y),) -> nodes_equal(x, y), zip(n1.kids, n2.kids)) +end + # See JuliaSyntax/src/parse_stream.jl function stringify_flags(node::Node) io = IOBuffer() @@ -67,6 +73,87 @@ end const TAG_INDENT = TagType(1) << 0 # This node is responsible for decrementing the indentation level const TAG_DEDENT = TagType(1) << 1 +# This (NewlineWs) node is the last one before a TAG_DEDENT +const TAG_PRE_DEDENT = TagType(1) << 2 +# This (NewlineWs) node is a line continuation +const TAG_LINE_CONT = UInt32(1) << 31 + +function add_tag(node::Node, tag::TagType) + @assert is_leaf(node) + return Node(head(node), span(node), node.kids, node.tags | tag) +end + +# Tags all leading NewlineWs nodes as continuation nodes. Note that comments are skipped +# over so that cases like `\n#comment\ncode` works as expected. +function continue_newlines(node::Node; leading::Bool = true, trailing::Bool = true) + if is_leaf(node) + if kind(node) === K"NewlineWs" && !has_tag(node, TAG_LINE_CONT) + return add_tag(node, TAG_LINE_CONT) + else + return nothing + end + end + kids = verified_kids(node) + if length(kids) == 1 + return nothing + end + any_kid_changed = false + if leading + idx = firstindex(kids) - 1 + while true + # Skip over whitespace + comments which can mask the newlines + idx = findnext(x -> !(kind(x) in KSet"Whitespace Comment"), kids, idx + 1) + if idx === nothing + # No matching kid found + break + elseif kind(kids[idx]) === K"NewlineWs" + # Kid is a NewlineWs node, tag and keep looking + kid′ = continue_newlines(kids[idx]; leading = leading, trailing = trailing) + if kid′ !== nothing + kids[idx] = kid′ + any_kid_changed = false + end + else + # This kid is not Whitespace, Comment or NewlineWs. + # Recurse but break out of the loop + kid′ = continue_newlines(kids[idx]; leading = leading, trailing = trailing) + if kid′ !== nothing + kids[idx] = kid′ + any_kid_changed = false + end + break + end + end + end + if trailing + idx = lastindex(kids) + 1 + while true + # Skip over whitespace + comments which can mask the newlines + idx = findprev(x -> !(kind(x) in KSet"Whitespace Comment"), kids, idx - 1) + if idx === nothing + # No matching kid found + break + elseif kind(kids[idx]) === K"NewlineWs" + # Kid is a NewlineWs node, tag and keep looking + kid′ = continue_newlines(kids[idx]; leading = leading, trailing = trailing) + if kid′ !== nothing + kids[idx] = kid′ + any_kid_changed = false + end + else + # This kid is not Whitespace, Comment or NewlineWs. + # Recurse but break out of the loop + kid′ = continue_newlines(kids[idx]; leading = leading, trailing = trailing) + if kid′ !== nothing + kids[idx] = kid′ + any_kid_changed = false + end + break + end + end + end + return any_kid_changed ? node : nothing +end function has_tag(node::Node, tag::TagType) return node.tags & tag != 0 @@ -80,12 +167,18 @@ function stringify_tags(node::Node) if has_tag(node, TAG_DEDENT) write(io, "dedent,") end + if has_tag(node, TAG_PRE_DEDENT) + write(io, "pre-dedent,") + end + if has_tag(node, TAG_LINE_CONT) + write(io, "line-cont.,") + end truncate(io, max(0, position(io) - 1)) # Remove trailing comma return String(take!(io)) end # Create a new node with the same head but new kids -function make_node(node::Node, kids′::Vector{Node}, tags = TagType(0)) +function make_node(node::Node, kids′::Vector{Node}, tags = node.tags) span′ = mapreduce(span, +, kids′; init = 0) return Node(head(node), span′, kids′, tags) end @@ -135,7 +228,7 @@ end # Extract the operator of an infix op call node function infix_op_call_op(node::Node) - @assert is_infix_op_call(node) + @assert is_infix_op_call(node) || kind(node) === K"||" kids = verified_kids(node) first_operand_index = findfirst(!JuliaSyntax.is_whitespace, kids) op_index = findnext(JuliaSyntax.is_operator, kids, first_operand_index + 1) @@ -147,7 +240,7 @@ function is_comparison_leaf(node::Node) if is_leaf(node) && JuliaSyntax.is_prec_comparison(node) return true elseif !is_leaf(node) && kind(node) === K"." && - meta_nargs(node) == 2 && is_comparison_leaf(verified_kids(node)[2]) + meta_nargs(node) == 2 && is_comparison_leaf(verified_kids(node)[2]) return true else return false @@ -165,6 +258,15 @@ function first_non_whitespace_kid(node::Node) return kids[idx] end +function is_begin_block(node::Node) + return kind(node) === K"block" && length(verified_kids(node)) > 0 && + kind(verified_kids(node)[1]) === K"begin" +end + +function is_paren_block(node::Node) + return kind(node) === K"block" && JuliaSyntax.has_flags(node, JuliaSyntax.PARENS_FLAG) +end + ########################## # Utilities for IOBuffer # ########################## diff --git a/src/main.jl b/src/main.jl index 866f4ca..61e12f3 100644 --- a/src/main.jl +++ b/src/main.jl @@ -265,7 +265,7 @@ function main(argv) end # Output the result - changed = ctx.fmt_tree !== ctx.src_tree + changed = !nodes_equal(ctx.fmt_tree, ctx.src_tree) if check if changed print_progress && errln() diff --git a/src/runestone.jl b/src/runestone.jl index 1da505f..4a56540 100644 --- a/src/runestone.jl +++ b/src/runestone.jl @@ -168,7 +168,7 @@ function spaces_around_x(ctx::Context, node::Node, is_x::F) where F for (i, kid) in pairs(kids) if kind(kid) === K"NewlineWs" || - (i == 1 && kind(kid) === K"Whitespace") + (i == 1 && kind(kid) === K"Whitespace") # NewlineWs are accepted as is by this pass. # Whitespace is accepted as is if this is the first kid even if the span is # larger than we expect since we don't look backwards. It should be cleaned up @@ -270,10 +270,10 @@ end # <: and >: operators. function spaces_around_operators(ctx::Context, node::Node) if !( - (is_infix_op_call(node) && !(kind(infix_op_call_op(node)) in KSet": ^")) || - (kind(node) in KSet"<: >:" && meta_nargs(node) == 3) || - (kind(node) === K"comparison" && !JuliaSyntax.is_trivia(node)) - ) + (is_infix_op_call(node) && !(kind(infix_op_call_op(node)) in KSet": ^")) || + (kind(node) in KSet"<: >:" && meta_nargs(node) == 3) || + (kind(node) === K"comparison" && !JuliaSyntax.is_trivia(node)) + ) return nothing end @assert kind(node) in KSet"call comparison <: >:" @@ -348,10 +348,10 @@ end # no spaces around `:`, `^`, and `::` function no_spaces_around_colon_etc(ctx::Context, node::Node) if !( - (is_infix_op_call(node) && kind(infix_op_call_op(node)) in KSet": ^") || - (kind(node) === K"::" && !is_leaf(node)) || - (kind(node) in KSet"<: >:" && meta_nargs(node) == 2) - ) + (is_infix_op_call(node) && kind(infix_op_call_op(node)) in KSet": ^") || + (kind(node) === K"::" && !is_leaf(node)) || + (kind(node) in KSet"<: >:" && meta_nargs(node) == 2) + ) return nothing end @assert kind(node) in KSet"call :: <: >:" @@ -427,9 +427,9 @@ end # replace `=` and `∈` with `in` in for-loops function for_loop_use_in(ctx::Context, node::Node) if !( - (kind(node) === K"for" && !is_leaf(node) && meta_nargs(node) == 4) || - (kind(node) === K"generator" && meta_nargs(node) == 3) # TODO: Unsure about 3. - ) + (kind(node) === K"for" && !is_leaf(node) && meta_nargs(node) == 4) || + (kind(node) === K"generator" && meta_nargs(node) == 3) # TODO: Unsure about 3. + ) return nothing end pos = position(ctx.fmt_io) @@ -470,3 +470,675 @@ function for_loop_use_in(ctx::Context, node::Node) seek(ctx.fmt_io, pos) # reset return node′ end + +# This function materialized all indentations marked by `insert_delete_mark_newlines`. +function four_space_indent(ctx::Context, node::Node) + kind(node) === K"NewlineWs" || return nothing + next_sibling_kind(ctx) === K"NewlineWs" && return + bytes = read_bytes(ctx, node) + @assert !in(UInt8('\r'), bytes) + @assert bytes[1] == UInt8('\n') + indent_level = ctx.indent_level + # TAG_PRE_DEDENT means this is the newline just before an `end` + if has_tag(node, TAG_PRE_DEDENT) + indent_level -= 1 + end + # TAG_LINE_CONT is a "soft" indentation + if has_tag(node, TAG_LINE_CONT) + indent_level += 1 + end + spn′ = 1 + 4 * indent_level + spn = span(node) + if spn == spn′ + return nothing + end + resize!(bytes, spn′) + fill!(@view(bytes[2:end]), UInt8(' ')) + replace_bytes!(ctx, bytes, spn) + node′ = Node(head(node), spn′, (), node.tags) + return node′ +end + +# This function tags the `function`/`macro` and `end` keywords as well as the trailing +# newline of the function/macro body. +function indent_function_or_macro(ctx::Context, node::Node) + kids = verified_kids(node) + any_kid_changed = false + # First node is the function/macro keyword + func_idx = 1 + func_node = kids[func_idx] + @assert is_leaf(func_node) && kind(func_node) in KSet"function macro" + if !has_tag(func_node, TAG_INDENT) + kids[func_idx] = add_tag(func_node, TAG_INDENT) + any_kid_changed = true + end + # Second node is the space between keyword and name + # TODO: Make sure there is just a single space + space_idx = 2 + space_node = kids[space_idx] + @assert is_leaf(space_node) && kind(space_node) === K"Whitespace" + # Third node is the signature (call/where/::) + sig_idx = 3 + sig_node = kids[sig_idx] + @assert !is_leaf(sig_node) && kind(sig_node) in KSet"call where ::" + # Fourth node is the function/macro body block. + block_idx = 4 + block_node′ = indent_block(ctx, kids[block_idx]) + if block_node′ !== nothing + kids[block_idx] = block_node′ + any_kid_changed = true + end + # Fifth node is the closing end keyword + end_idx = 5 + end_node = kids[end_idx] + @assert is_leaf(end_node) && kind(end_node) === K"end" + if !has_tag(end_node, TAG_DEDENT) + kids[end_idx] = add_tag(end_node, TAG_DEDENT) + any_kid_changed = true + end + @assert verified_kids(node) === kids + return any_kid_changed ? node : nothing +end + +function indent_let(ctx::Context, node::Node) + kids = verified_kids(node) + any_kid_changed = false + # First node is the let keyword + let_idx = 1 + let_node = kids[let_idx] + @assert is_leaf(let_node) && kind(let_node) === K"let" + if !has_tag(let_node, TAG_INDENT) + kids[let_idx] = add_tag(let_node, TAG_INDENT) + any_kid_changed = true + end + # Second node is the variables block (will be soft-indented by the assignments pass) + vars_idx = 2 + vars_node = kids[vars_idx] + @assert !is_leaf(vars_node) && kind(vars_node) === K"block" + @assert kind(last_leaf(vars_node)) !== "NewlineWs" + # Third node is the NewlineWs before the block + ln_idx = 3 + ln_node = kids[ln_idx] + @assert is_leaf(ln_node) && kind(ln_node) === K"NewlineWs" + # Fourth node is the function body block. + block_idx = 4 + block_node = kids[block_idx] + @assert !is_leaf(block_node) && kind(block_node) === K"block" + block_node′ = indent_block(ctx, block_node) + if block_node′ !== nothing + kids[block_idx] = block_node′ + any_kid_changed = true + end + # Fifth node is the closing end keyword + end_idx = 5 + @assert is_leaf(kids[end_idx]) && kind(kids[end_idx]) === K"end" + if !has_tag(kids[end_idx], TAG_DEDENT) + kids[end_idx] = add_tag(kids[end_idx], TAG_DEDENT) + any_kid_changed = true + end + @assert verified_kids(node) === kids + return any_kid_changed ? node : nothing +end + +# TODO: Reuse indent_block? +function indent_begin(ctx::Context, node::Node, block_kind = K"begin") + kids = verified_kids(node) + any_kid_changed = false + # First node is the begin keyword + begin_idx = 1 + begin_node = kids[begin_idx] + @assert is_leaf(begin_node) && kind(begin_node) === block_kind + if !has_tag(begin_node, TAG_INDENT) + kids[begin_idx] = add_tag(begin_node, TAG_INDENT) + any_kid_changed = true + end + # Second node is the newline + ln_idx = 2 + ln_node = kids[ln_idx] + @assert is_leaf(ln_node) && kind(ln_node) === K"NewlineWs" + # After the NewlineWs node we skip over all kids until the end + end_idx = findlast(x -> kind(x) === K"end", kids) + @assert end_idx == lastindex(kids) # ?? + # Tag last newline as pre-dedent + ln_idx = end_idx - 1 + ln_node = kids[ln_idx] + if kind(ln_node) === K"NewlineWs" + if !has_tag(ln_node, TAG_PRE_DEDENT) + kids[ln_idx] = add_tag(ln_node, TAG_PRE_DEDENT) + any_kid_changed = true + end + end + end_node = kids[end_idx] + @assert is_leaf(end_node) && kind(end_node) === K"end" + if !has_tag(end_node, TAG_DEDENT) + kids[end_idx] = add_tag(end_node, TAG_DEDENT) + any_kid_changed = true + end + @assert verified_kids(node) === kids + return any_kid_changed ? node : nothing +end + +# TODO: This needs to be reworked to handle non-standard cases like, for example, one-liners +# of the form `if x y end`. For now we only handle the standard case and ignore the rest. +function indent_block(::Context, node::Node) + @assert kind(node) === K"block" && !is_leaf(node) + kids = verified_kids(node) + any_kid_changed = false + # Expect a NewlineWs node at the end of the block (otherwise the closing `end` is not on + # a separate line). + trailing_idx = findlast(x -> kind(x) === K"NewlineWs", kids) + if trailing_idx === nothing || trailing_idx != lastindex(kids) + return nothing + elseif !has_tag(kids[trailing_idx], TAG_PRE_DEDENT) + kids[trailing_idx] = add_tag(kids[trailing_idx], TAG_PRE_DEDENT) + any_kid_changed = true + end + # Look for a leading NewlineWs node + leading_idx = findfirst(x -> kind(x) === K"NewlineWs", kids) + if leading_idx !== nothing && leading_idx < trailing_idx + # TODO: Forgot why we check for this. I think it is only necessary if we want to + # split a one-liner into multiple lines. + # return nothing + end + @assert verified_kids(node) === kids + return any_kid_changed ? node : nothing +end + +function indent_catch(ctx::Context, node::Node) + @assert kind(node) in KSet"catch else finally" + kids = verified_kids(node) + any_kid_changed = false + catch_idx = 1 + catch_node = kids[catch_idx] + @assert is_leaf(catch_node) && kind(catch_node) in KSet"catch else finally" + if !has_tag(catch_node, TAG_INDENT) + kids[catch_idx] = add_tag(catch_node, TAG_INDENT) + any_kid_changed = true + end + if !has_tag(catch_node, TAG_DEDENT) + kids[catch_idx] = add_tag(catch_node, TAG_DEDENT) + any_kid_changed = true + end + # Skip over the catch-identifier (if any) + block_idx = findnext(x -> kind(x) === K"block", kids, catch_idx + 1)::Int + @assert kind(kids[block_idx]) === K"block" + block_node′ = indent_block(ctx, kids[block_idx]) + if block_node′ !== nothing + kids[block_idx] = block_node′ + any_kid_changed = true + end + return any_kid_changed ? node : nothing +end + +function indent_try(ctx::Context, node::Node) + @assert kind(node) in KSet"try" + @assert !is_leaf(node) + kids = verified_kids(node) + any_kid_changed = false + # First node is `try` + try_idx = 1 + try_node = kids[try_idx] + @assert is_leaf(kids[try_idx]) && kind(try_node) in KSet"try" + if !has_tag(try_node, TAG_INDENT) + kids[try_idx] = add_tag(try_node, TAG_INDENT) + any_kid_changed = true + end + # Second node the try-block + try_block_idx = findnext(!JuliaSyntax.is_whitespace, kids, try_idx + 1)::Int + try_block_node′ = indent_block(ctx, kids[try_block_idx]) + if try_block_node′ !== nothing + kids[try_block_idx] = try_block_node′ + any_kid_changed = true + end + # Check for catch/finally. They can be in any order + catch_idx = findnext(x -> kind(x) in KSet"catch finally", kids, try_block_idx + 1)::Int + @assert !is_leaf(kids[catch_idx]) && kind(kids[catch_idx]) in KSet"catch finally" + catch_node′ = indent_catch(ctx, kids[catch_idx]) + if catch_node′ !== nothing + kids[catch_idx] = catch_node′ + any_kid_changed = true + end + # There may be an else in between catch and finally (lol) + else_idx = findnext(x -> kind(x) === K"else", kids, catch_idx + 1) + if else_idx !== nothing + else_node′ = indent_catch(ctx, kids[else_idx]) + if else_node′ !== nothing + kids[else_idx] = else_node′ + any_kid_changed = true + end + end + # Check for the other one + other_kind = kind(kids[catch_idx]) === K"catch" ? K"finally" : K"catch" + finally_idx = findnext( + x -> kind(x) === other_kind, kids, something(else_idx, catch_idx) + 1, + ) + if finally_idx !== nothing + finally_node′ = indent_catch(ctx, kids[finally_idx]) + if finally_node′ !== nothing + kids[finally_idx] = finally_node′ + any_kid_changed = true + end + end + # Check for end + end_idx = findnext( + x -> kind(x) === K"end", kids, something(finally_idx, else_idx, catch_idx) + 1, + )::Int + @assert is_leaf(kids[end_idx]) && kind(kids[end_idx]) === K"end" + if !has_tag(kids[end_idx], TAG_DEDENT) + kids[end_idx] = add_tag(kids[end_idx], TAG_DEDENT) + any_kid_changed = true + end + @assert verified_kids(node) === kids + return any_kid_changed ? node : nothing +end + +function indent_if(ctx::Context, node::Node) + @assert kind(node) in KSet"if elseif" + @assert !is_leaf(node) + kids = verified_kids(node) + any_kid_changed = false + # First node is either `if` or `elseif` (when called recursively) + if_idx = 1 + if_node = kids[if_idx] + @assert is_leaf(kids[if_idx]) && kind(if_node) in KSet"if elseif" + if !has_tag(if_node, TAG_INDENT) + if_node = add_tag(if_node, TAG_INDENT) + any_kid_changed = true + end + if kind(node) === K"elseif" && !has_tag(if_node, TAG_DEDENT) + if_node = add_tag(if_node, TAG_DEDENT) + any_kid_changed = true + end + kids[if_idx] = if_node + # Look for the condition node + cond_idx = findnext(!JuliaSyntax.is_whitespace, kids, if_idx + 1)::Int + if cond_idx != if_idx + 1 + # TODO: Trim whitespace between the keyword and the condition. It may exist as a + # separate leaf, or hidden in the condition node. + end + cond_node = kids[cond_idx] + @assert kind(last_leaf(cond_node)) !== "NewlineWs" + # Fourth node is the body block. + block_idx = findnext(!JuliaSyntax.is_whitespace, kids, cond_idx + 1)::Int + @assert block_idx == cond_idx + 1 + block_node′ = indent_block(ctx, kids[block_idx]) + if block_node′ !== nothing + kids[block_idx] = block_node′ + any_kid_changed = true + end + # Check for elseif + elseif_idx = findnext(x -> kind(x) === K"elseif", kids, block_idx + 1) + if elseif_idx !== nothing + @assert !is_leaf(kids[elseif_idx]) && kind(kids[elseif_idx]) === K"elseif" + elseif_node′ = indent_if(ctx, kids[elseif_idx]) + if elseif_node′ !== nothing + kids[elseif_idx] = elseif_node′ + any_kid_changed = true + end + end + # Check for else + else_idx = findnext(x -> kind(x) === K"else", kids, something(elseif_idx, block_idx) + 1) + if else_idx !== nothing + @assert is_leaf(kids[else_idx]) && kind(kids[else_idx]) === K"else" + else_node = kids[else_idx] + if !has_tag(else_node, TAG_INDENT) + else_node = add_tag(else_node, TAG_INDENT) + any_kid_changed = true + end + if !has_tag(else_node, TAG_DEDENT) + else_node = add_tag(else_node, TAG_DEDENT) + any_kid_changed = true + end + kids[else_idx] = else_node + else_block_idx = findnext(!JuliaSyntax.is_whitespace, kids, else_idx + 1)::Int + @assert kind(kids[else_block_idx]) === K"block" + else_block′ = indent_block(ctx, kids[else_block_idx]) + if else_block′ !== nothing + kids[else_block_idx] = else_block′ + any_kid_changed = true + end + end + # Check for end + end_idx = findnext(x -> kind(x) === K"end", kids, something(else_idx, elseif_idx, block_idx) + 1) + @assert (kind(node) === K"elseif") == (end_idx === nothing) + if end_idx !== nothing + @assert is_leaf(kids[end_idx]) && kind(kids[end_idx]) === K"end" + if !has_tag(kids[end_idx], TAG_DEDENT) + kids[end_idx] = add_tag(kids[end_idx], TAG_DEDENT) + any_kid_changed = true + end + end + @assert verified_kids(node) === kids + return any_kid_changed ? node : nothing +end + +function indent_call(ctx::Context, node::Node) + @assert kind(node) === K"call" + return indent_paren(ctx, node) +end + + +function indent_newlines_between_indices( + ctx::Context, node::Node, open_idx::Int, close_idx::Int; + indent_closing_token::Bool = false, + ) + kids = verified_kids(node) + any_kid_changed = false + for i in open_idx:close_idx + kid = kids[i] + this_kid_changed = false + # Skip the newline just before the closing token for e.g. (...\n) + # (indent_closing_token = false) but not in e.g. `a+\nb` (indent_closing_token = + # true) where the closing token is part of the expression itself. + if !indent_closing_token && i == close_idx - 1 && kind(kid) === K"NewlineWs" + continue + end + # Tag all direct NewlineWs kids + if kind(kid) === K"NewlineWs" && !has_tag(kid, TAG_LINE_CONT) + kid = add_tag(kid, TAG_LINE_CONT) + this_kid_changed = true + end + # NewlineWs nodes can also hide as the first or last leaf of a node, tag'em. + # TODO: The trailing argument should maybe depend on `indent_closing_token` or + # possibly set to `false` if `i == close_idx - 1`? + kid′ = continue_newlines(kid; leading = true, trailing = true) + if kid′ !== nothing + kid = kid′ + this_kid_changed = true + end + if this_kid_changed + kids[i] = kid + end + any_kid_changed |= this_kid_changed + end + @assert verified_kids(node) === kids + return any_kid_changed ? node : nothing +end + + +# Mark opening and closing parentheses, in a call or a tuple, with indent and dedent tags. +function indent_paren(ctx::Context, node::Node) + @assert kind(node) in KSet"call tuple parens" + kids = verified_kids(node) + opening_paren_idx = findfirst(x -> kind(x) === K"(", kids)::Int + closing_paren_idx = findnext(x -> kind(x) === K")", kids, opening_paren_idx + 1)::Int + return indent_newlines_between_indices(ctx, node, opening_paren_idx, closing_paren_idx) +end + +# Insert line-continuation nodes instead of bumping the indent level. +function indent_op_call(ctx::Context, node::Node) + kids = verified_kids(node) + first_operand_idx = findfirst(!JuliaSyntax.is_whitespace, kids)::Int + last_operand_idx = findlast(!JuliaSyntax.is_whitespace, kids)::Int + return indent_newlines_between_indices( + ctx, node, first_operand_idx, last_operand_idx; indent_closing_token = true, + ) +end + +function indent_loop(ctx::Context, node::Node) + @assert kind(node) in KSet"for while" + kids = verified_kids(node) + any_kid_changed = false + for_idx = findfirst(x -> kind(x) in KSet"for while", kids)::Int + if !has_tag(kids[for_idx], TAG_INDENT) + kids[for_idx] = add_tag(kids[for_idx], TAG_INDENT) + any_kid_changed = true + end + block_idx = findnext(x -> kind(x) === K"block", kids, for_idx + 1)::Int + block_node′ = indent_block(ctx, kids[block_idx]) + if block_node′ !== nothing + kids[block_idx] = block_node′ + any_kid_changed = true + end + end_idx = findlast(x -> kind(x) === K"end", kids)::Int + if !has_tag(kids[end_idx], TAG_DEDENT) + kids[end_idx] = add_tag(kids[end_idx], TAG_DEDENT) + any_kid_changed = true + end + return any_kid_changed ? node : nothing +end + +function indent_tuple(ctx::Context, node::Node) + @assert kind(node) === K"tuple" + kids = verified_kids(node) + # Check whether this is an explicit tuple, e.g. `(a, b)`, + # or an implicit tuple, e.g. `a, b`. + opening_paren_idx = findfirst(x -> kind(x) === K"(", kids) + if opening_paren_idx === nothing + # Implicit tuple: don't indent the closing token + first_item_idx = findfirst(!JuliaSyntax.is_whitespace, kids) + if first_item_idx === nothing + # Empty implicit tuple can happen in e.g. a do-block without arguments + return nothing + end + last_item_idx = findlast(!JuliaSyntax.is_whitespace, kids)::Int + return indent_newlines_between_indices( + ctx, node, first_item_idx, last_item_idx; indent_closing_token = true, + ) + else + # Explicit tuple: indent the closing token + closing_paren_idx = findnext(x -> kind(x) === K")", kids, opening_paren_idx + 1)::Int + @assert opening_paren_idx == firstindex(kids) + @assert closing_paren_idx == lastindex(kids) + return indent_newlines_between_indices( + ctx, node, opening_paren_idx, closing_paren_idx; indent_closing_token = false, + ) + end +end + +function indent_parens(ctx::Context, node::Node) + @assert kind(node) in KSet"parens" + return indent_paren(ctx, node) +end + +function indent_parameters(ctx::Context, node::Node) + kids = verified_kids(node) + # TODO: This is always here? + semicolon_idx = findfirst(x -> kind(x) === K";", kids)::Int + last_non_ws_idx = findlast(!JuliaSyntax.is_whitespace, kids)::Int + return indent_newlines_between_indices( + ctx, node, semicolon_idx, last_non_ws_idx; indent_closing_token = true, + ) +end + +function indent_struct(ctx::Context, node::Node) + @assert kind(node) === K"struct" + kids = verified_kids(node) + any_kid_changed = false + struct_idx = findfirst(!JuliaSyntax.is_whitespace, kids)::Int + @assert kind(kids[struct_idx]) in KSet"mutable struct" + if !has_tag(kids[struct_idx], TAG_INDENT) + kids[struct_idx] = add_tag(kids[struct_idx], TAG_INDENT) + any_kid_changed = true + end + block_idx = findnext(x -> kind(x) === K"block", kids, struct_idx + 1)::Int + block_node′ = indent_block(ctx, kids[block_idx]) + if block_node′ !== nothing + kids[block_idx] = block_node′ + any_kid_changed = true + end + end_idx = findlast(x -> kind(x) === K"end", kids)::Int + if !has_tag(kids[end_idx], TAG_DEDENT) + kids[end_idx] = add_tag(kids[end_idx], TAG_DEDENT) + any_kid_changed = true + end + return any_kid_changed ? node : nothing +end + +function indent_short_circuit(ctx::Context, node::Node) + return indent_op_call(ctx, node) +end + +# TODO: This function can be used for more things than just indent_using I think. Perhaps +# with a max_depth parameter. +function continue_all_newlines( + ctx::Context, node::Node; skip_last::Bool = true, is_last::Bool = is_leaf(node), + ) + if is_leaf(node) + if kind(node) === K"NewlineWs" && !has_tag(node, TAG_LINE_CONT) && + !(skip_last && is_last) + return add_tag(node, TAG_LINE_CONT) + else + return nothing + end + else + any_kid_changed = false + kids = verified_kids(node) + for (i, kid) in pairs(kids) + kid′ = continue_all_newlines( + ctx, kid; skip_last = skip_last, is_last = i == lastindex(kids), + ) + if kid′ !== nothing + kids[i] = kid′ + any_kid_changed = true + end + end + return any_kid_changed ? node : nothing + end +end + +function indent_using_import_export(ctx::Context, node::Node) + @assert kind(node) in KSet"using import export" + return continue_all_newlines(ctx, node) +end + +function indent_ternary(ctx::Context, node::Node) + @assert kind(node) === K"?" + return continue_all_newlines(ctx, node) +end + +function indent_assignment(ctx::Context, node::Node) + kids = verified_kids(node) + # Also catches for loop specifications (but at this point we have normalized `=` and `∈` + # to `in`). + op_idx = findfirst(x -> is_assignment(x) || kind(x) === K"in", kids)::Int + last_non_ws_idx = findlast(!JuliaSyntax.is_whitespace, kids)::Int + return indent_newlines_between_indices( + ctx, node, op_idx, last_non_ws_idx; indent_closing_token = true, + ) +end + +function indent_paren_block(ctx::Context, node::Node) + @assert kind(node) === K"block" + @assert JuliaSyntax.has_flags(node, JuliaSyntax.PARENS_FLAG) + kids = verified_kids(node) + opening_paren_idx = findfirst(x -> kind(x) === K"(", kids)::Int + closing_paren_idx = findnext(x -> kind(x) === K")", kids, opening_paren_idx + 1)::Int + return indent_newlines_between_indices(ctx, node, opening_paren_idx, closing_paren_idx) +end + +function indent_do(ctx::Context, node::Node) + @assert kind(node) === K"do" + kids = verified_kids(node) + any_kid_changed = false + # Skip over the call and go directly to the do-keyword + do_idx = findfirst(x -> kind(x) === K"do", kids)::Int + if !has_tag(kids[do_idx], TAG_INDENT) + kids[do_idx] = add_tag(kids[do_idx], TAG_INDENT) + any_kid_changed = true + end + # Find the do body block + block_idx = findnext(x -> kind(x) === K"block", kids, do_idx + 1)::Int + block_node′ = indent_block(ctx, kids[block_idx]) + if block_node′ !== nothing + kids[block_idx] = block_node′ + any_kid_changed = true + end + # Closing `end` + end_idx = findnext(x -> kind(x) === K"end", kids, block_idx + 1)::Int + if !has_tag(kids[end_idx], TAG_DEDENT) + kids[end_idx] = add_tag(kids[end_idx], TAG_DEDENT) + any_kid_changed = true + end + return any_kid_changed ? node : nothing +end + +function indent_quote(ctx::Context, node::Node) + @assert kind(node) === K"quote" + kids = verified_kids(node) + any_kid_changed = false + # K"quote" can be `quote ... end` or `:(...)`. + block_form = !JuliaSyntax.has_flags(node, JuliaSyntax.COLON_QUOTE) + if block_form + block_idx = findfirst(x -> kind(x) === K"block", kids) + if block_idx === nothing + # `bar` in `foo.bar` is a quote block... + return nothing + end + block_node′ = indent_begin(ctx, kids[block_idx], K"quote") + if block_node′ !== nothing + kids[block_idx] = block_node′ + any_kid_changed = true + end + return any_kid_changed ? node : nothing + else + # The short form can be ignored since the inside (K"block", K"tuple", or + # K"Identifier") of the quote will be handled by other passes. + return nothing + end +end + +function indent_array(ctx::Context, node::Node) + @assert kind(node) in KSet"vect vcat ncat" + kids = verified_kids(node) + any_kid_changed = false + opening_bracket_idx = findfirst(x -> kind(x) === K"[", kids)::Int + closing_bracket_idx = findnext(x -> kind(x) === K"]", kids, opening_bracket_idx + 1)::Int + return indent_newlines_between_indices( + ctx, node, opening_bracket_idx, closing_bracket_idx, + ) +end + +function indent_array_row(ctx::Context, node::Node) + @assert kind(node) === K"row" + return continue_all_newlines(ctx, node) +end + +function insert_delete_mark_newlines(ctx::Context, node::Node) + if is_leaf(node) + return nothing + elseif kind(node) in KSet"function macro" + return indent_function_or_macro(ctx, node) + elseif kind(node) === K"if" + return indent_if(ctx, node) + elseif kind(node) === K"let" + return indent_let(ctx, node) + elseif is_begin_block(node) + return indent_begin(ctx, node) + elseif kind(node) === K"call" && flags(node) == 0 + return indent_call(ctx, node) + elseif is_infix_op_call(node) + return indent_op_call(ctx, node) + elseif kind(node) in KSet"for while" + return indent_loop(ctx, node) + elseif kind(node) === K"tuple" + return indent_tuple(ctx, node) + elseif kind(node) === K"struct" + return indent_struct(ctx, node) + elseif kind(node) === K"parens" + return indent_parens(ctx, node) + elseif kind(node) in KSet"|| &&" + return indent_short_circuit(ctx, node) + elseif kind(node) in KSet"using import export" + return indent_using_import_export(ctx, node) + elseif is_assignment(node) + return indent_assignment(ctx, node) + elseif kind(node) === K"parameters" + return indent_parameters(ctx, node) + elseif kind(node) === K"?" + return indent_ternary(ctx, node) + elseif kind(node) === K"try" + return indent_try(ctx, node) + elseif kind(node) === K"quote" + return indent_quote(ctx, node) + elseif kind(node) === K"do" + return indent_do(ctx, node) + elseif is_paren_block(node) + return indent_paren_block(ctx, node) + elseif kind(node) in KSet"vect vcat ncat" + return indent_array(ctx, node) + elseif kind(node) in KSet"row" + return indent_array_row(ctx, node) + end + return nothing +end diff --git a/test/runtests.jl b/test/runtests.jl index 0e9e1de..d0173d8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -168,10 +168,10 @@ end "$(sp)sin(α) $(op) cos(β) $(op) tan(γ)$(sp)" # a op \n b @test format_string("$(sp)a$(sp)$(op)$(sp)\nb$(sp)") == - "$(sp)a $(op)\nb$(sp)" + "$(sp)a $(op)\n b$(sp)" # a op # comment \n b @test format_string("$(sp)a$(sp)$(op)$(sp)# comment\nb$(sp)") == - "$(sp)a $(op) # comment\nb$(sp)" + "$(sp)a $(op) # comment\n b$(sp)" end # Exceptions to the rule: `:` and `^` # a:b @@ -194,7 +194,7 @@ end @test format_string("$(sp)z$(sp)+$(sp)2x$(sp)+$(sp)z$(sp)") == "$(sp)z + 2x + z$(sp)" # Edgecase where the NewlineWs ends up inside the second call in a chain @test format_string("$(sp)a$(sp)\\$(sp)b$(sp)≈ $(sp)\n$(sp)c$(sp)\\$(sp)d$(sp)") == - "$(sp)a \\ b ≈\n$(sp)c \\ d$(sp)" + "$(sp)a \\ b ≈\n c \\ d$(sp)" end end @@ -285,3 +285,152 @@ end end end end + +@testset "block/hard indentation" begin + for sp in ("", " ", " ", " ") + # function-end + @test format_string("function f()\n$(sp)x\n$(sp)end") == + "function f()\n x\nend" + # macro-end + @test format_string("macro f()\n$(sp)x\n$(sp)end") == + "macro f()\n x\nend" + # let-end + @test format_string("let a = 1\n$(sp)x\n$(sp)end") == + "let a = 1\n x\nend" + # begin-end + @test format_string("begin\n$(sp)x\n$(sp)end") == + "begin\n x\nend" + # quote-end + @test format_string("quote\n$(sp)x\n$(sp)end") == + "quote\n x\nend" + # if-end + @test format_string("if a\n$(sp)x\n$(sp)end") == + "if a\n x\nend" + # if-else-end + @test format_string("if a\n$(sp)x\n$(sp)else\n$(sp)y\n$(sp)end") == + "if a\n x\nelse\n y\nend" + # if-elseif-end + @test format_string("if a\n$(sp)x\n$(sp)elseif b\n$(sp)y\n$(sp)end") == + "if a\n x\nelseif b\n y\nend" + # if-elseif-elseif-end + @test format_string( + "if a\n$(sp)x\n$(sp)elseif b\n$(sp)y\n$(sp)elseif c\n$(sp)z\n$(sp)end", + ) == "if a\n x\nelseif b\n y\nelseif c\n z\nend" + # if-elseif-else-end + @test format_string( + "if a\n$(sp)x\n$(sp)elseif b\n$(sp)y\n$(sp)else\n$(sp)z\n$(sp)end", + ) == "if a\n x\nelseif b\n y\nelse\n z\nend" + # if-elseif-elseif-else-end + @test format_string( + "if a\n$(sp)x\n$(sp)elseif b\n$(sp)y\n$(sp)elseif " * + "c\n$(sp)z\n$(sp)else\n$(sp)u\n$(sp)end" + ) == + "if a\n x\nelseif b\n y\nelseif c\n z\nelse\n u\nend" + # begin-end + @test format_string("begin\n$(sp)x\n$(sp)end") == "begin\n x\nend" + # (mutable) struct + for mut in ("", "mutable ") + @test format_string("$(mut)struct A\n$(sp)x\n$(sp)end") == + "$(mut)struct A\n x\nend" + end + # for-end + @test format_string("for i in I\n$(sp)x\n$(sp)end") == "for i in I\n x\nend" + @test format_string("for i in I, j in J\n$(sp)x\n$(sp)end") == "for i in I, j in J\n x\nend" + # while-end + @test format_string("while x\n$(sp)y\n$(sp)end") == "while x\n y\nend" + # try-catch-end + @test format_string("try\n$(sp)x\n$(sp)catch\n$(sp)y\n$(sp)end") == + "try\n x\ncatch\n y\nend" + # try-catch(err)-end + @test format_string("try\n$(sp)x\n$(sp)catch err\n$(sp)y\n$(sp)end") == + "try\n x\ncatch err\n y\nend" + # try-catch-finally-end + @test format_string( + "try\n$(sp)x\n$(sp)catch\n$(sp)y\n$(sp)finally\n$(sp)z\n$(sp)end", + ) == "try\n x\ncatch\n y\nfinally\n z\nend" + # try-catch(err)-finally-end + @test format_string( + "try\n$(sp)x\n$(sp)catch err\n$(sp)y\n$(sp)finally\n$(sp)z\n$(sp)end", + ) == "try\n x\ncatch err\n y\nfinally\n z\nend" + # try-finally-catch-end (yes, this is allowed...) + @test format_string( + "try\n$(sp)x\n$(sp)finally\n$(sp)y\n$(sp)catch\n$(sp)z\n$(sp)end", + ) == "try\n x\nfinally\n y\ncatch\n z\nend" + # try-finally-catch(err)-end + @test format_string( + "try\n$(sp)x\n$(sp)finally\n$(sp)y\n$(sp)catch err\n$(sp)z\n$(sp)end", + ) == "try\n x\nfinally\n y\ncatch err\n z\nend" + if VERSION >= v"1.8" + # try-catch-else-end + @test format_string( + "try\n$(sp)x\n$(sp)catch\n$(sp)y\n$(sp)else\n$(sp)z\n$(sp)end", + ) == "try\n x\ncatch\n y\nelse\n z\nend" + # try-catch(err)-else-end + @test format_string( + "try\n$(sp)x\n$(sp)catch err\n$(sp)y\n$(sp)else\n$(sp)z\n$(sp)end", + ) == "try\n x\ncatch err\n y\nelse\n z\nend" + # try-catch-else-finally-end + @test format_string( + "try\n$(sp)x\n$(sp)catch\n$(sp)y\n$(sp)else\n$(sp)z\n$(sp)finally\n$(sp)z\n$(sp)end", + ) == "try\n x\ncatch\n y\nelse\n z\nfinally\n z\nend" + # try-catch(err)-else-finally-end + @test format_string( + "try\n$(sp)x\n$(sp)catch err\n$(sp)y\n$(sp)else\n$(sp)z\n$(sp)finally\n$(sp)z\n$(sp)end", + ) == "try\n x\ncatch err\n y\nelse\n z\nfinally\n z\nend" + end + # do-end + @test format_string("open() do\n$(sp)a\n$(sp)end") == "open() do\n a\nend" + @test format_string("open() do io\n$(sp)a\n$(sp)end") == "open() do io\n a\nend" + end +end + +@testset "continuation/soft indentation" begin + for sp in ("", " ", " ", " ") + # tuple + @test format_string("(a,\n$(sp)b)") == "(a,\n b)" + @test format_string("(a,\n$(sp)b\n$(sp))") == "(a,\n b\n)" + @test format_string("(a,\n$(sp)b,\n$(sp))") == "(a,\n b,\n)" + @test format_string("(\n$(sp)a,\n$(sp)b,\n$(sp))") == "(\n a,\n b,\n)" + # call + for sep in (",", ";") + @test format_string("f(a$(sep)\n$(sp)b)") == "f(a$(sep)\n b)" + @test format_string("f(a$(sep)\n$(sp)b\n$(sp))") == "f(a$(sep)\n b\n)" + @test format_string("f(a$(sep)\n$(sp)b,\n$(sp))") == "f(a$(sep)\n b,\n)" + @test format_string("f(\n$(sp)a$(sep)\n$(sp)b,\n$(sp))") == "f(\n a$(sep)\n b,\n)" + end + # op-call + @test format_string("a +\n$(sp)b") == "a +\n b" + @test format_string("a + b *\n$(sp)c") == "a + b *\n c" + @test format_string("a +\n$(sp)b *\n$(sp)c") == "a +\n b *\n c" + @test format_string("a ||\n$(sp)b") == "a ||\n b" + # assignment + for op in ("=", "+=") + @test format_string("a $(op)\n$(sp)b") == "a $(op)\n b" + end + # using/import + for verb in ("using", "import") + @test format_string("$(verb) A,\n$(sp)B") == "$(verb) A,\n B" + @test format_string("$(verb) A: a,\n$(sp)b") == "$(verb) A: a,\n b" + @test format_string("$(verb) A:\n$(sp)a,\n$(sp)b") == "$(verb) A:\n a,\n b" + end + # export + @test format_string("export a,\n$(sp)b") == "export a,\n b" + @test format_string("export\n$(sp)a,\n$(sp)b") == "export\n a,\n b" + # ternary + @test format_string("a ?\n$(sp)b : c") == "a ?\n b : c" + @test format_string("a ? b :\n$(sp)c") == "a ? b :\n c" + @test format_string("a ?\n$(sp)b :\n$(sp)c") == "a ?\n b :\n c" + @test format_string("a ?\n$(sp)b :\n$(sp)c ?\n$(sp)d : e") == + "a ?\n b :\n c ?\n d : e" + # paren-quote + @test format_string(":(a,\n$(sp)b)") == ":(a,\n b)" + @test format_string(":(a,\n$(sp)b)") == ":(a,\n b)" + @test format_string(":(a;\n$(sp)b)") == ":(a;\n b)" + # paren-block + @test format_string("(a;\n$(sp)b)") == "(a;\n b)" + # array literals + @test format_string("[a,\n$(sp)b]") == "[a,\n b]" + @test format_string("[\n$(sp)a,\n$(sp)b\n$(sp)]") == "[\n a,\n b\n]" + @test format_string("[a b\n$(sp)c d]") == "[a b\n c d]" + end +end