From 78cbef2a051b4a564a0eb1111e2960e76fc3cbf4 Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Fri, 6 Dec 2024 02:40:35 +0100 Subject: [PATCH] Support range formatting This patch implements the `--lines=a:b` command line argument for limiting the formatting to the line range `a:b`. Multiple ranges are supported. Closes #114. --- CHANGELOG.md | 5 +++ src/Runic.jl | 104 +++++++++++++++++++++++++++++++++++++++++++++- src/debug.jl | 7 ++++ src/main.jl | 31 +++++++++++++- test/maintests.jl | 45 +++++++++++++++++++- test/runtests.jl | 46 ++++++++++++++++++++ 6 files changed, 234 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7f598..bbca9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Added + - New command line option `--lines=a:b` for limiting formatting to lines `a` to `b`. + `--lines` can be repeated to specify multiple ranges ([#114], [#120]). + ## [v1.1.0] - 2024-12-04 ### Changed - Fix a bug that caused "single space after keyword" to not apply after the `function` diff --git a/src/Runic.jl b/src/Runic.jl index 74d51a8..5d7df85 100644 --- a/src/Runic.jl +++ b/src/Runic.jl @@ -131,6 +131,7 @@ mutable struct Context check::Bool diff::Bool filemode::Bool + line_ranges::Vector{UnitRange{Int}} # Global state indent_level::Int # track (hard) indentation level call_depth::Int # track call-depth level for debug printing @@ -144,11 +145,104 @@ mutable struct Context lineage_macros::Vector{String} end +const RANGE_FORMATTING_BEGIN = "#= RUNIC RANGE FORMATTING " * "BEGIN =#" +const RANGE_FORMATTING_END = "#= RUNIC RANGE FORMATTING " * "END =#" + +function add_line_range_markers(str, line_ranges) + lines = collect(eachline(IOBuffer(str); keep = true)) + sort!(line_ranges, rev = true) + for r in line_ranges + a, b = extrema(r) + if a < 1 || b > length(lines) + throw(MainError("`--lines` range out of bounds")) + end + if b == length(lines) && !endswith(lines[end], "\n") + lines[end] *= "\n" + end + insert!(lines, b + 1, RANGE_FORMATTING_END * "\n") + insert!(lines, a, RANGE_FORMATTING_BEGIN * "\n") + end + io = IOBuffer(; maxsize = sum(sizeof, lines; init = 0)) + join(io, lines) + src_str = String(take!(io)) + return src_str +end + +function remove_line_range_markers(src_io, fmt_io) + src_lines = eachline(seekstart(src_io); keep = true) + fmt_lines = eachline(seekstart(fmt_io); keep = true) + io = IOBuffer() + # These can't fail because we will at the minimum have the begin/end comments + src_itr = iterate(src_lines) + @assert src_itr !== nothing + src_ln, src_token = src_itr + itr_fmt = iterate(fmt_lines) + @assert itr_fmt !== nothing + fmt_ln, fmt_token = itr_fmt + eof = false + while true + # Take source lines until range start or eof + while !occursin(RANGE_FORMATTING_BEGIN, src_ln) + if !occursin(RANGE_FORMATTING_END, src_ln) + write(io, src_ln) + end + src_itr = iterate(src_lines, src_token) + if src_itr === nothing + eof = true + break + end + src_ln, src_token = src_itr + end + eof && break + @assert occursin(RANGE_FORMATTING_BEGIN, src_ln) && + strip(src_ln) == RANGE_FORMATTING_BEGIN + # Skip ahead in the source lines until the range end + while !occursin(RANGE_FORMATTING_END, src_ln) + src_itr = iterate(src_lines, src_token) + @assert src_itr !== nothing + src_ln, src_token = src_itr + end + @assert occursin(RANGE_FORMATTING_END, src_ln) && + strip(src_ln) == RANGE_FORMATTING_END + # Skip ahead in the formatted lines until range start + while !occursin(RANGE_FORMATTING_BEGIN, fmt_ln) + fmt_itr = iterate(fmt_lines, fmt_token) + @assert fmt_itr !== nothing + fmt_ln, fmt_token = fmt_itr + end + @assert occursin(RANGE_FORMATTING_BEGIN, fmt_ln) && + strip(fmt_ln) == RANGE_FORMATTING_BEGIN + # Take formatted lines until range end + while !occursin(RANGE_FORMATTING_END, fmt_ln) + if !occursin(RANGE_FORMATTING_BEGIN, fmt_ln) + write(io, fmt_ln) + end + fmt_itr = iterate(fmt_lines, fmt_token) + @assert fmt_itr !== nothing + fmt_ln, fmt_token = fmt_itr + end + @assert occursin(RANGE_FORMATTING_END, fmt_ln) && + strip(fmt_ln) == RANGE_FORMATTING_END + eof && break + end + write(seekstart(fmt_io), take!(io)) + truncate(fmt_io, position(fmt_io)) + return +end + function Context( src_str::String; assert::Bool = true, debug::Bool = false, verbose::Bool = debug, - diff::Bool = false, check::Bool = false, quiet::Bool = false, filemode::Bool = true + diff::Bool = false, check::Bool = false, quiet::Bool = false, filemode::Bool = true, + line_ranges::Vector{UnitRange{Int}} = UnitRange{Int}[] ) + if !isempty(line_ranges) + # If formatting is limited to certain line ranges we modify the source string to + # include begin and end marker comments. + src_str = add_line_range_markers(src_str, line_ranges) + end src_io = IOBuffer(src_str) + # TODO: If parsing here fails, and we have line ranges, perhaps try to parse without the + # markers to check whether the markers are the cause of the failure. src_tree = Node( JuliaSyntax.parseall(JuliaSyntax.GreenNode, src_str; ignore_warnings = true, version = v"2-") ) @@ -176,7 +270,7 @@ function Context( format_on = true return Context( src_str, src_tree, src_io, fmt_io, fmt_tree, quiet, verbose, assert, debug, check, - diff, filemode, indent_level, call_depth, format_on, prev_sibling, next_sibling, + diff, filemode, line_ranges, indent_level, call_depth, format_on, prev_sibling, next_sibling, lineage_kinds, lineage_macros ) end @@ -501,14 +595,20 @@ function format_tree!(ctx::Context) end # Truncate the output at the root span truncate(ctx.fmt_io, span(root′)) + # Remove line range markers if any + if !isempty(ctx.line_ranges) + remove_line_range_markers(ctx.src_io, ctx.fmt_io) + end # Check that the output is parseable try fmt_str = String(read(seekstart(ctx.fmt_io))) + # TODO: parsing may fail here because of the removal of the range comments JuliaSyntax.parseall(JuliaSyntax.GreenNode, fmt_str; ignore_warnings = true, version = v"2-") catch throw(AssertionError("re-parsing the formatted output failed")) end # Set the final tree + # TODO: When range formatting this doesn't match the content of ctx.fmt_io ctx.fmt_tree = root′ return nothing end diff --git a/src/debug.jl b/src/debug.jl index b3adf19..62b83dc 100644 --- a/src/debug.jl +++ b/src/debug.jl @@ -10,6 +10,13 @@ struct AssertionError <: RunicException msg::String end +# Thrown from internal code when invalid CLI arguments can not be validated directly in +# `Runic.main`: `throw(MainError("message"))` from internal code is like calling +# `panic("message")` in `Runic.main`. +struct MainError <: RunicException + msg::String +end + function Base.showerror(io::IO, err::AssertionError) print( io, diff --git a/src/main.jl b/src/main.jl index 0caab85..131f68d 100644 --- a/src/main.jl +++ b/src/main.jl @@ -196,6 +196,24 @@ function writeo(output::Output, iob) return end +function insert_line_range(line_ranges, lines) + m = match(r"^(\d+):(\d+)$", lines) + if m === nothing + return panic("can not parse `--lines` argument as an integer range") + end + range_start = parse(Int, m.captures[1]::SubString) + range_end = parse(Int, m.captures[2]::SubString) + if range_start > range_end + return panic("empty `--lines` range") + end + range = range_start:range_end + if !all(x -> isdisjoint(x, range), line_ranges) + return panic("`--lines` ranges cannot overlap") + end + push!(line_ranges, range) + return 0 +end + function main(argv) # Reset errno global errno = 0 @@ -210,6 +228,7 @@ function main(argv) diff = false check = false fail_fast = false + line_ranges = typeof(1:2)[] # Parse the arguments while length(argv) > 0 @@ -234,6 +253,10 @@ function main(argv) check = true elseif x == "-vv" || x == "--debug" debug = verbose = true + elseif (m = match(r"^--lines=(.*)$", x); m !== nothing) + if insert_line_range(line_ranges, m.captures[1]::SubString) != 0 + return errno + end elseif x == "-o" if length(argv) < 1 return panic("expected output file argument after `-o`") @@ -272,6 +295,9 @@ function main(argv) if outputfile != "" && length(inputfiles) > 1 return panic("option `--output` can not be used together with multiple input files") end + if !isempty(line_ranges) && length(inputfiles) > 1 + return panic("option `--lines` can not be used together with multiple input files") + end if length(inputfiles) > 1 && !(inplace || check) return panic("option `--inplace` or `--check` required with multiple input files") end @@ -363,7 +389,7 @@ function main(argv) # Call the library to format the text ctx = try - ctx′ = Context(sourcetext; quiet, verbose, debug, diff, check) + ctx′ = Context(sourcetext; quiet, verbose, debug, diff, check, line_ranges) format_tree!(ctx′) ctx′ catch err @@ -371,6 +397,9 @@ function main(argv) if err isa JuliaSyntax.ParseError panic("failed to parse input: ", err) continue + elseif err isa MainError + panic(err.msg) + continue end msg = "failed to format input: " @static if juliac diff --git a/test/maintests.jl b/test/maintests.jl index d308a84..571c11b 100644 --- a/test/maintests.jl +++ b/test/maintests.jl @@ -343,7 +343,7 @@ function maintests(f::R) where {R} end # runic -o readonly.jl in.jl - return cdtmp() do + cdtmp() do f_in = "in.jl" write(f_in, bad) f_out = "readonly.jl" @@ -356,6 +356,49 @@ function maintests(f::R) where {R} @test isempty(fd1) @test occursin("could not write to output file", fd2) end + + # runic --lines + cdtmp() do + src = """ + function f(a,b) + return a+b + end + """ + rc, fd1, fd2 = runic(["--lines=1:1"], src) + @test rc == 0 && isempty(fd2) + @test fd1 == "function f(a, b)\n return a+b\n end\n" + rc, fd1, fd2 = runic(["--lines=2:2"], src) + @test rc == 0 && isempty(fd2) + @test fd1 == "function f(a,b)\n return a + b\n end\n" + rc, fd1, fd2 = runic(["--lines=3:3"], src) + @test rc == 0 && isempty(fd2) + @test fd1 == "function f(a,b)\n return a+b\nend\n" + rc, fd1, fd2 = runic(["--lines=1:1", "--lines=3:3"], src) + @test rc == 0 && isempty(fd2) + @test fd1 == "function f(a, b)\n return a+b\nend\n" + rc, fd1, fd2 = runic(["--lines=1:1", "--lines=2:2", "--lines=3:3"], src) + @test rc == 0 && isempty(fd2) + @test fd1 == "function f(a, b)\n return a + b\nend\n" + rc, fd1, fd2 = runic(["--lines=1:2"], src) + @test rc == 0 && isempty(fd2) + @test fd1 == "function f(a, b)\n return a + b\n end\n" + # Errors + rc, fd1, fd2 = runic(["--lines=1:2", "--lines=2:3"], src) + @test rc == 1 + @test isempty(fd1) + @test occursin("`--lines` ranges cannot overlap", fd2) + rc, fd1, fd2 = runic(["--lines=0:1"], src) + @test rc == 1 && isempty(fd1) + @test occursin("`--lines` range out of bounds", fd2) + rc, fd1, fd2 = runic(["--lines=3:4"], src) + @test rc == 1 && isempty(fd1) + @test occursin("`--lines` range out of bounds", fd2) + rc, fd1, fd2 = runic(["--lines=3:4", "foo.jl", "bar.jl"], src) + @test rc == 1 && isempty(fd1) + @test occursin("option `--lines` can not be used together with multiple input files", fd2) + end + + return end # rc = let argv = pushfirst!(copy(argv), "runic"), argc = length(argv) % Cint diff --git a/test/runtests.jl b/test/runtests.jl index 20b2e2a..2607571 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1541,6 +1541,52 @@ end end end +# TODO: Support lines in format_string and format_file +function format_lines(str, lines) + line_ranges = lines isa UnitRange ? [lines] : lines + ctx = Runic.Context(str; filemode = false, line_ranges = line_ranges) + Runic.format_tree!(ctx) + return String(take!(ctx.fmt_io)) +end + +@testset "--lines" begin + str = """ + function f(a,b) + return a+b + end + """ + @test format_lines(str, 1:1) == """ + function f(a, b) + return a+b + end + """ + @test format_lines(str, 2:2) == """ + function f(a,b) + return a + b + end + """ + @test format_lines(str, 3:3) == """ + function f(a,b) + return a+b + end + """ + @test format_lines(str, [1:1, 3:3]) == """ + function f(a, b) + return a+b + end + """ + @test format_lines(str, [1:1, 2:2, 3:3]) == """ + function f(a, b) + return a + b + end + """ + @test format_lines(str, [1:2]) == """ + function f(a, b) + return a + b + end + """ +end + module RunicMain1 using Test: @testset using Runic: main