Skip to content

Commit 93593dd

Browse files
authored
Support range formatting (#120)
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.
1 parent d66286d commit 93593dd

File tree

6 files changed

+234
-4
lines changed

6 files changed

+234
-4
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
### Added
10+
- New command line option `--lines=a:b` for limiting formatting to lines `a` to `b`.
11+
`--lines` can be repeated to specify multiple ranges ([#114], [#120]).
12+
813
## [v1.1.0] - 2024-12-04
914
### Changed
1015
- Fix a bug that caused "single space after keyword" to not apply after the `function`

src/Runic.jl

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ mutable struct Context
131131
check::Bool
132132
diff::Bool
133133
filemode::Bool
134+
line_ranges::Vector{UnitRange{Int}}
134135
# Global state
135136
indent_level::Int # track (hard) indentation level
136137
call_depth::Int # track call-depth level for debug printing
@@ -144,11 +145,104 @@ mutable struct Context
144145
lineage_macros::Vector{String}
145146
end
146147

148+
const RANGE_FORMATTING_BEGIN = "#= RUNIC RANGE FORMATTING " * "BEGIN =#"
149+
const RANGE_FORMATTING_END = "#= RUNIC RANGE FORMATTING " * "END =#"
150+
151+
function add_line_range_markers(str, line_ranges)
152+
lines = collect(eachline(IOBuffer(str); keep = true))
153+
sort!(line_ranges, rev = true)
154+
for r in line_ranges
155+
a, b = extrema(r)
156+
if a < 1 || b > length(lines)
157+
throw(MainError("`--lines` range out of bounds"))
158+
end
159+
if b == length(lines) && !endswith(lines[end], "\n")
160+
lines[end] *= "\n"
161+
end
162+
insert!(lines, b + 1, RANGE_FORMATTING_END * "\n")
163+
insert!(lines, a, RANGE_FORMATTING_BEGIN * "\n")
164+
end
165+
io = IOBuffer(; maxsize = sum(sizeof, lines; init = 0))
166+
join(io, lines)
167+
src_str = String(take!(io))
168+
return src_str
169+
end
170+
171+
function remove_line_range_markers(src_io, fmt_io)
172+
src_lines = eachline(seekstart(src_io); keep = true)
173+
fmt_lines = eachline(seekstart(fmt_io); keep = true)
174+
io = IOBuffer()
175+
# These can't fail because we will at the minimum have the begin/end comments
176+
src_itr = iterate(src_lines)
177+
@assert src_itr !== nothing
178+
src_ln, src_token = src_itr
179+
itr_fmt = iterate(fmt_lines)
180+
@assert itr_fmt !== nothing
181+
fmt_ln, fmt_token = itr_fmt
182+
eof = false
183+
while true
184+
# Take source lines until range start or eof
185+
while !occursin(RANGE_FORMATTING_BEGIN, src_ln)
186+
if !occursin(RANGE_FORMATTING_END, src_ln)
187+
write(io, src_ln)
188+
end
189+
src_itr = iterate(src_lines, src_token)
190+
if src_itr === nothing
191+
eof = true
192+
break
193+
end
194+
src_ln, src_token = src_itr
195+
end
196+
eof && break
197+
@assert occursin(RANGE_FORMATTING_BEGIN, src_ln) &&
198+
strip(src_ln) == RANGE_FORMATTING_BEGIN
199+
# Skip ahead in the source lines until the range end
200+
while !occursin(RANGE_FORMATTING_END, src_ln)
201+
src_itr = iterate(src_lines, src_token)
202+
@assert src_itr !== nothing
203+
src_ln, src_token = src_itr
204+
end
205+
@assert occursin(RANGE_FORMATTING_END, src_ln) &&
206+
strip(src_ln) == RANGE_FORMATTING_END
207+
# Skip ahead in the formatted lines until range start
208+
while !occursin(RANGE_FORMATTING_BEGIN, fmt_ln)
209+
fmt_itr = iterate(fmt_lines, fmt_token)
210+
@assert fmt_itr !== nothing
211+
fmt_ln, fmt_token = fmt_itr
212+
end
213+
@assert occursin(RANGE_FORMATTING_BEGIN, fmt_ln) &&
214+
strip(fmt_ln) == RANGE_FORMATTING_BEGIN
215+
# Take formatted lines until range end
216+
while !occursin(RANGE_FORMATTING_END, fmt_ln)
217+
if !occursin(RANGE_FORMATTING_BEGIN, fmt_ln)
218+
write(io, fmt_ln)
219+
end
220+
fmt_itr = iterate(fmt_lines, fmt_token)
221+
@assert fmt_itr !== nothing
222+
fmt_ln, fmt_token = fmt_itr
223+
end
224+
@assert occursin(RANGE_FORMATTING_END, fmt_ln) &&
225+
strip(fmt_ln) == RANGE_FORMATTING_END
226+
eof && break
227+
end
228+
write(seekstart(fmt_io), take!(io))
229+
truncate(fmt_io, position(fmt_io))
230+
return
231+
end
232+
147233
function Context(
148234
src_str::String; assert::Bool = true, debug::Bool = false, verbose::Bool = debug,
149-
diff::Bool = false, check::Bool = false, quiet::Bool = false, filemode::Bool = true
235+
diff::Bool = false, check::Bool = false, quiet::Bool = false, filemode::Bool = true,
236+
line_ranges::Vector{UnitRange{Int}} = UnitRange{Int}[]
150237
)
238+
if !isempty(line_ranges)
239+
# If formatting is limited to certain line ranges we modify the source string to
240+
# include begin and end marker comments.
241+
src_str = add_line_range_markers(src_str, line_ranges)
242+
end
151243
src_io = IOBuffer(src_str)
244+
# TODO: If parsing here fails, and we have line ranges, perhaps try to parse without the
245+
# markers to check whether the markers are the cause of the failure.
152246
src_tree = Node(
153247
JuliaSyntax.parseall(JuliaSyntax.GreenNode, src_str; ignore_warnings = true, version = v"2-")
154248
)
@@ -176,7 +270,7 @@ function Context(
176270
format_on = true
177271
return Context(
178272
src_str, src_tree, src_io, fmt_io, fmt_tree, quiet, verbose, assert, debug, check,
179-
diff, filemode, indent_level, call_depth, format_on, prev_sibling, next_sibling,
273+
diff, filemode, line_ranges, indent_level, call_depth, format_on, prev_sibling, next_sibling,
180274
lineage_kinds, lineage_macros
181275
)
182276
end
@@ -501,14 +595,20 @@ function format_tree!(ctx::Context)
501595
end
502596
# Truncate the output at the root span
503597
truncate(ctx.fmt_io, span(root′))
598+
# Remove line range markers if any
599+
if !isempty(ctx.line_ranges)
600+
remove_line_range_markers(ctx.src_io, ctx.fmt_io)
601+
end
504602
# Check that the output is parseable
505603
try
506604
fmt_str = String(read(seekstart(ctx.fmt_io)))
605+
# TODO: parsing may fail here because of the removal of the range comments
507606
JuliaSyntax.parseall(JuliaSyntax.GreenNode, fmt_str; ignore_warnings = true, version = v"2-")
508607
catch
509608
throw(AssertionError("re-parsing the formatted output failed"))
510609
end
511610
# Set the final tree
611+
# TODO: When range formatting this doesn't match the content of ctx.fmt_io
512612
ctx.fmt_tree = root′
513613
return nothing
514614
end

src/debug.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ struct AssertionError <: RunicException
1010
msg::String
1111
end
1212

13+
# Thrown from internal code when invalid CLI arguments can not be validated directly in
14+
# `Runic.main`: `throw(MainError("message"))` from internal code is like calling
15+
# `panic("message")` in `Runic.main`.
16+
struct MainError <: RunicException
17+
msg::String
18+
end
19+
1320
function Base.showerror(io::IO, err::AssertionError)
1421
print(
1522
io,

src/main.jl

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,24 @@ function writeo(output::Output, iob)
196196
return
197197
end
198198

199+
function insert_line_range(line_ranges, lines)
200+
m = match(r"^(\d+):(\d+)$", lines)
201+
if m === nothing
202+
return panic("can not parse `--lines` argument as an integer range")
203+
end
204+
range_start = parse(Int, m.captures[1]::SubString)
205+
range_end = parse(Int, m.captures[2]::SubString)
206+
if range_start > range_end
207+
return panic("empty `--lines` range")
208+
end
209+
range = range_start:range_end
210+
if !all(x -> isdisjoint(x, range), line_ranges)
211+
return panic("`--lines` ranges cannot overlap")
212+
end
213+
push!(line_ranges, range)
214+
return 0
215+
end
216+
199217
function main(argv)
200218
# Reset errno
201219
global errno = 0
@@ -210,6 +228,7 @@ function main(argv)
210228
diff = false
211229
check = false
212230
fail_fast = false
231+
line_ranges = typeof(1:2)[]
213232

214233
# Parse the arguments
215234
while length(argv) > 0
@@ -234,6 +253,10 @@ function main(argv)
234253
check = true
235254
elseif x == "-vv" || x == "--debug"
236255
debug = verbose = true
256+
elseif (m = match(r"^--lines=(.*)$", x); m !== nothing)
257+
if insert_line_range(line_ranges, m.captures[1]::SubString) != 0
258+
return errno
259+
end
237260
elseif x == "-o"
238261
if length(argv) < 1
239262
return panic("expected output file argument after `-o`")
@@ -272,6 +295,9 @@ function main(argv)
272295
if outputfile != "" && length(inputfiles) > 1
273296
return panic("option `--output` can not be used together with multiple input files")
274297
end
298+
if !isempty(line_ranges) && length(inputfiles) > 1
299+
return panic("option `--lines` can not be used together with multiple input files")
300+
end
275301
if length(inputfiles) > 1 && !(inplace || check)
276302
return panic("option `--inplace` or `--check` required with multiple input files")
277303
end
@@ -363,14 +389,17 @@ function main(argv)
363389

364390
# Call the library to format the text
365391
ctx = try
366-
ctx′ = Context(sourcetext; quiet, verbose, debug, diff, check)
392+
ctx′ = Context(sourcetext; quiet, verbose, debug, diff, check, line_ranges)
367393
format_tree!(ctx′)
368394
ctx′
369395
catch err
370396
print_progress && errln()
371397
if err isa JuliaSyntax.ParseError
372398
panic("failed to parse input: ", err)
373399
continue
400+
elseif err isa MainError
401+
panic(err.msg)
402+
continue
374403
end
375404
msg = "failed to format input: "
376405
@static if juliac

test/maintests.jl

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ function maintests(f::R) where {R}
343343
end
344344

345345
# runic -o readonly.jl in.jl
346-
return cdtmp() do
346+
cdtmp() do
347347
f_in = "in.jl"
348348
write(f_in, bad)
349349
f_out = "readonly.jl"
@@ -356,6 +356,49 @@ function maintests(f::R) where {R}
356356
@test isempty(fd1)
357357
@test occursin("could not write to output file", fd2)
358358
end
359+
360+
# runic --lines
361+
cdtmp() do
362+
src = """
363+
function f(a,b)
364+
return a+b
365+
end
366+
"""
367+
rc, fd1, fd2 = runic(["--lines=1:1"], src)
368+
@test rc == 0 && isempty(fd2)
369+
@test fd1 == "function f(a, b)\n return a+b\n end\n"
370+
rc, fd1, fd2 = runic(["--lines=2:2"], src)
371+
@test rc == 0 && isempty(fd2)
372+
@test fd1 == "function f(a,b)\n return a + b\n end\n"
373+
rc, fd1, fd2 = runic(["--lines=3:3"], src)
374+
@test rc == 0 && isempty(fd2)
375+
@test fd1 == "function f(a,b)\n return a+b\nend\n"
376+
rc, fd1, fd2 = runic(["--lines=1:1", "--lines=3:3"], src)
377+
@test rc == 0 && isempty(fd2)
378+
@test fd1 == "function f(a, b)\n return a+b\nend\n"
379+
rc, fd1, fd2 = runic(["--lines=1:1", "--lines=2:2", "--lines=3:3"], src)
380+
@test rc == 0 && isempty(fd2)
381+
@test fd1 == "function f(a, b)\n return a + b\nend\n"
382+
rc, fd1, fd2 = runic(["--lines=1:2"], src)
383+
@test rc == 0 && isempty(fd2)
384+
@test fd1 == "function f(a, b)\n return a + b\n end\n"
385+
# Errors
386+
rc, fd1, fd2 = runic(["--lines=1:2", "--lines=2:3"], src)
387+
@test rc == 1
388+
@test isempty(fd1)
389+
@test occursin("`--lines` ranges cannot overlap", fd2)
390+
rc, fd1, fd2 = runic(["--lines=0:1"], src)
391+
@test rc == 1 && isempty(fd1)
392+
@test occursin("`--lines` range out of bounds", fd2)
393+
rc, fd1, fd2 = runic(["--lines=3:4"], src)
394+
@test rc == 1 && isempty(fd1)
395+
@test occursin("`--lines` range out of bounds", fd2)
396+
rc, fd1, fd2 = runic(["--lines=3:4", "foo.jl", "bar.jl"], src)
397+
@test rc == 1 && isempty(fd1)
398+
@test occursin("option `--lines` can not be used together with multiple input files", fd2)
399+
end
400+
401+
return
359402
end
360403

361404
# rc = let argv = pushfirst!(copy(argv), "runic"), argc = length(argv) % Cint

test/runtests.jl

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,52 @@ end
15411541
end
15421542
end
15431543

1544+
# TODO: Support lines in format_string and format_file
1545+
function format_lines(str, lines)
1546+
line_ranges = lines isa UnitRange ? [lines] : lines
1547+
ctx = Runic.Context(str; filemode = false, line_ranges = line_ranges)
1548+
Runic.format_tree!(ctx)
1549+
return String(take!(ctx.fmt_io))
1550+
end
1551+
1552+
@testset "--lines" begin
1553+
str = """
1554+
function f(a,b)
1555+
return a+b
1556+
end
1557+
"""
1558+
@test format_lines(str, 1:1) == """
1559+
function f(a, b)
1560+
return a+b
1561+
end
1562+
"""
1563+
@test format_lines(str, 2:2) == """
1564+
function f(a,b)
1565+
return a + b
1566+
end
1567+
"""
1568+
@test format_lines(str, 3:3) == """
1569+
function f(a,b)
1570+
return a+b
1571+
end
1572+
"""
1573+
@test format_lines(str, [1:1, 3:3]) == """
1574+
function f(a, b)
1575+
return a+b
1576+
end
1577+
"""
1578+
@test format_lines(str, [1:1, 2:2, 3:3]) == """
1579+
function f(a, b)
1580+
return a + b
1581+
end
1582+
"""
1583+
@test format_lines(str, [1:2]) == """
1584+
function f(a, b)
1585+
return a + b
1586+
end
1587+
"""
1588+
end
1589+
15441590
module RunicMain1
15451591
using Test: @testset
15461592
using Runic: main

0 commit comments

Comments
 (0)