diff --git a/src/Runic.jl b/src/Runic.jl index dc79ed3..d6bcb3c 100644 --- a/src/Runic.jl +++ b/src/Runic.jl @@ -318,6 +318,7 @@ function format_node!(ctx::Context, node::Node)::Union{Node, Nothing, NullNode} @return_something parens_around_op_calls_in_colon(ctx, node) @return_something for_loop_use_in(ctx, node) @return_something braces_around_where_rhs(ctx, node) + @return_something indent_multiline_strings(ctx, node) @return_something four_space_indent(ctx, node) @return_something spaces_in_listlike(ctx, node) ctx.call_depth -= 1 diff --git a/src/main.jl b/src/main.jl index 8d4d032..eabceaf 100644 --- a/src/main.jl +++ b/src/main.jl @@ -37,42 +37,42 @@ function print_help() printstyled(io, "DESCRIPTION\n", bold = true) println( io, """ - `Runic.main` (typically invoked as `julia -m Runic`) formats Julia source - code using the Runic.jl formatter. - """, + `Runic.main` (typically invoked as `julia -m Runic`) formats Julia source + code using the Runic.jl formatter. + """, ) printstyled(io, "OPTIONS\n", bold = true) println( io, """ - ... - Input path(s) (files and/or directories) to process. For directories, - all files (recursively) with the '*.jl' suffix are used as input files. - If path is `-` input is read from stdin and output written to stdout. + ... + Input path(s) (files and/or directories) to process. For directories, + all files (recursively) with the '*.jl' suffix are used as input files. + If path is `-` input is read from stdin and output written to stdout. - -c, --check - Do not write output and exit with a non-zero code if the input is not - formatted correctly. + -c, --check + Do not write output and exit with a non-zero code if the input is not + formatted correctly. - -d, --diff - Print the diff between the input and formatted output to stderr. - Requires `git` or `diff` to be installed. + -d, --diff + Print the diff between the input and formatted output to stderr. + Requires `git` or `diff` to be installed. - --fail-fast - Exit immediately after the first error. Only applicable when formatting - multiple files in the same invocation. + --fail-fast + Exit immediately after the first error. Only applicable when formatting + multiple files in the same invocation. - --help - Print this message. + --help + Print this message. - -i, --inplace - Edit files in place. This option is required when passing multiple input - paths. + -i, --inplace + Edit files in place. This option is required when passing multiple input + paths. - -o, --output - Output file to write formatted code to. If the specified file is `-` - output is written to stdout. This option can not be used together with - multiple input paths. - """, + -o, --output + Output file to write formatted code to. If the specified file is `-` + output is written to stdout. This option can not be used together with + multiple input paths. + """, ) return end diff --git a/src/runestone.jl b/src/runestone.jl index 0015c5a..8452ac9 100644 --- a/src/runestone.jl +++ b/src/runestone.jl @@ -2757,3 +2757,126 @@ function insert_delete_mark_newlines(ctx::Context, node::Node) end return nothing end + +function indent_multiline_strings(ctx::Context, node::Node) + if !(kind(node) in KSet"string cmdstring" && JuliaSyntax.has_flags(node, JuliaSyntax.TRIPLE_STRING_FLAG)) + return nothing + end + triplekind = kind(node) === K"string" ? K"\"\"\"" : K"```" + itemkind = kind(node) === K"string" ? K"String" : K"CmdString" + indent_span = 4 * ctx.indent_level + indented = indent_span > 0 + + pos = position(ctx.fmt_io) + kids = verified_kids(node) + kids′ = kids + any_changes = false + + # Fastpath for the common case of top level multiline strings like e.g. docstrings + if !indented && findfirst(x -> kind(x) === K"Whitespace", kids) === nothing + return nothing + end + + # Opening triple quote + open_idx = findfirst(x -> kind(x) === triplekind, kids) + close_idx = findlast(x -> kind(x) === triplekind, kids) + @assert close_idx == length(kids) # ? + open_kid = kids[open_idx] + @assert kind(open_kid) === triplekind + accept_node!(ctx, open_kid) + + # Loop over the lines/expressions + idx = open_idx + 1 + state = :expect_something + while idx < close_idx + kid = kids[idx] + if state === :expect_something + if kind(kid) === itemkind + if indented && read_bytes(ctx, kid)[end] == UInt8('\n') + state = :expect_indent_ws + end + accept_node!(ctx, kid) + any_changes && push!(kids′, kid) + elseif kind(kid) === K"Whitespace" + # Delete this one + replace_bytes!(ctx, "", span(kid)) + if kids′ === kids + kids′ = kids[1:(idx - 1)] + end + any_changes = true + else + accept_node!(ctx, kid) + any_changes && push!(kids′, kid) + end + else + @assert state === :expect_indent_ws + state = :expect_something + if kind(kid) === itemkind && span(kid) == 1 && peek(ctx.fmt_io) == UInt8('\n') + # If this line is empty there shouldn't be a whitespace node. Switch the + # state and loop around with the same idx. + state = :expect_something + continue # Skip the index increment + elseif begin + cond = false + if kind(kid) === K"Whitespace" && idx + 1 < close_idx && + kind(kids[idx + 1]) === itemkind && span(kids[idx + 1]) == 1 + peekpos = position(ctx.fmt_io) + accept_node!(ctx, kid) + accept_node!(ctx, kids[idx + 1]) + seek(ctx.fmt_io, position(ctx.fmt_io) - 1) + cond = peek(ctx.fmt_io) == UInt8('\n') + seek(ctx.fmt_io, peekpos) + end + cond + end + # If this whitespace is followed by an empty string it should be deleted + state = :expect_something + continue # Skip the index increment + elseif kind(kid) === K"Whitespace" && span(kid) == indent_span + @assert all(x -> x === UInt8(' '), read_bytes(ctx, kid)) + accept_node!(ctx, kid) + any_changes && push!(kids′, kid) + elseif kind(kid) === K"Whitespace" + replace_bytes!(ctx, " "^indent_span, span(kid)) + if kids′ === kids + kids′ = kids[1:(idx - 1)] + end + kid′ = Node(head(kid), indent_span, tags(kid)) + any_changes = true + push!(kids′, kid′) + accept_node!(ctx, kid′) + else + replace_bytes!(ctx, " "^indent_span, 0) + if kids′ === kids + kids′ = kids[1:(idx - 1)] + end + kid′ = Node(JuliaSyntax.SyntaxHead(K"Whitespace", JuliaSyntax.TRIVIA_FLAG), indent_span) + any_changes = true + push!(kids′, kid′) + accept_node!(ctx, kid′) + continue # Skip the index increment + end + end + idx += 1 + end + # Make sure to add indent before the closing triple quote + if state === :expect_indent_ws + replace_bytes!(ctx, " "^indent_span, 0) + if kids′ === kids + kids′ = kids[1:(idx - 1)] + end + kid′ = Node(JuliaSyntax.SyntaxHead(K"Whitespace", JuliaSyntax.TRIVIA_FLAG), indent_span) + any_changes = true + push!(kids′, kid′) + accept_node!(ctx, kid′) + end + @assert idx == close_idx + # Closing triple quote + close_kid = kids[close_idx] + @assert kind(close_kid) === triplekind + accept_node!(ctx, close_kid) + any_changes && push!(kids′, close_kid) + # Reset stream + seek(ctx.fmt_io, pos) + return any_changes ? make_node(node, kids′) : nothing +end diff --git a/test/runtests.jl b/test/runtests.jl index d827ffe..fcf2e35 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -893,3 +893,33 @@ end @testset "parsing new syntax" begin @test format_string("public a, b") == "public a, b" # Julia 1.11 end + +@testset "indent of multiline strings" begin + for triple in ("\"\"\"", "```"), sp in ("", " ", " "), + (pre, post) in (("", ""), ("pre", ""), ("pre", "post")) + otriple = pre * triple + ctriple = triple * post + # Level 0 + @test format_string("$(sp)$(otriple)\n$(sp)a\n$(sp)b\n$(sp)$(ctriple)") === + "$(sp)$(otriple)\na\nb\n$(ctriple)" + @test format_string("$(sp)$(otriple)\n$(sp)a\n\n$(sp)b\n$(sp)$(ctriple)") === + "$(sp)$(otriple)\na\n\nb\n$(ctriple)" + @test format_string("x = $(otriple)\n$(sp)a\n$(sp)b\n$(sp)$(ctriple)") === + "x = $(otriple)\na\nb\n$(ctriple)" + @test format_string("$(sp)$(otriple)a\n$(sp)a\n$(sp)b\n$(sp)$(ctriple)") === + "$(sp)$(otriple)a\na\nb\n$(ctriple)" + @test format_string("$(sp)$(otriple)\n$(sp)a\$(b)c\n$(sp)$(ctriple)") === + "$(sp)$(otriple)\na\$(b)c\n$(ctriple)" + # Level 1 + @test format_string("begin\n$(sp)$(otriple)\n$(sp)a\n$(sp)b\n$(sp)$(ctriple)\nend") === + "begin\n $(otriple)\n a\n b\n $(ctriple)\nend" + @test format_string("begin\n$(sp)$(otriple)\n$(sp)a\n$(sp)\n$(sp)b\n$(sp)$(ctriple)\nend") === + "begin\n $(otriple)\n a\n\n b\n $(ctriple)\nend" + @test format_string("begin\n$(sp)x = $(otriple)\n$(sp)a\n$(sp)b\n$(sp)$(ctriple)\nend") === + "begin\n x = $(otriple)\n a\n b\n $(ctriple)\nend" + @test format_string("begin\n$(sp)$(otriple)a\n$(sp)a\n$(sp)b\n$(sp)$(ctriple)\nend") === + "begin\n $(otriple)a\n a\n b\n $(ctriple)\nend" + @test format_string("begin\n$(sp)$(otriple)\n$(sp)a\$(b)c\n$(sp)$(ctriple)\nend") === + "begin\n $(otriple)\n a\$(b)c\n $(ctriple)\nend" + end +end