# SPDX-License-Identifier: MIT # Return code of main errno::Cint = 0 # Check whether we are compiling with juliac using Preferences: @load_preference const juliac = @load_preference("juliac", false) @static if juliac include("juliac.jl") const stdin = RawIO(RawFD(0)) const stdout = RawIO(RawFD(1)) const stderr = RawIO(RawFD(2)) const run_cmd = run_juliac const printstyled = printstyled_juliac const mktempdir = mktempdir_juliac const sprint_showerror = sprint_showerror_juliac const print_vnum = print_vnum_juliac else # const stdin = Base.stdin # const stdout = Base.stdout # const stderr = Base.stderr const run_cmd = Base.run const printstyled = Base.printstyled const mktempdir = Base.mktempdir sprint_showerror(err::Exception) = sprint(showerror, err) const print_vnum = Base.print end supports_color(io) = get(io, :color, false) # juliac-compatible `Base.walkdir` but since we are collecting the files eagerly anyway we # might as well use the same method even when not compiling with juliac. function tryf(f::F, arg, default) where {F} try return f(arg) catch return default end end function scandir!(files, root) # Don't recurse into `.git`. If e.g. a branch name ends with `.jl` there are files # inside of `.git` which has the `.jl` extension, but they are not Julia source files. if occursin(".git", root) && ".git" in splitpath(root) @assert endswith(root, ".git") return end tryf(isdir, root, false) || return dirs = Vector{String}() for f in tryf(readdir, root, String[]) jf = joinpath(root, f) if tryf(isdir, jf, false) push!(dirs, f) elseif (tryf(isfile, jf, false) || tryf(islink, jf, false)) && endswith(jf, ".jl") push!(files, jf) else # Ignore it I guess... end end for dir in dirs scandir!(files, joinpath(root, dir)) end return end function panic( msg::String, err::Union{Exception, Nothing} = nothing, bt::Union{Vector{Base.StackFrame}, Nothing} = nothing ) printstyled(stderr, "ERROR: "; color = :red, bold = true) print(stderr, msg) if err !== nothing print(stderr, sprint_showerror(err)) end @static if juliac @assert bt === nothing else if bt !== nothing Base.show_backtrace(stderr, bt) end end println(stderr) global errno = 1 return errno end function okln() printstyled(stderr, "✔"; color = :green, bold = true) println(stderr) return end function errln() printstyled(stderr, "✖"; color = :red, bold = true) println(stderr) return end # Print a typical cli program help message function print_help() io = stdout printstyled(io, "NAME", bold = true) println(io) println(io, " Runic.main - format Julia source code") println(io) printstyled(io, "SYNOPSIS", bold = true) println(io) println(io, " julia -m Runic [] ...") println(io) printstyled(io, "DESCRIPTION", bold = true) println(io) println( io, """ `Runic.main` (typically invoked as `julia -m Runic`) formats Julia source code using the Runic.jl formatter. """ ) printstyled(io, "OPTIONS", bold = true) println(io) 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 no path is given, or if path is `-`, input is read from stdin. -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` to be installed. --help Print this message. -i, --inplace Format files in place. --lines=: Limit formatting to the line range to . Multiple ranges can be formatted by specifying multiple --lines arguments. -o , --output= File to write formatted output to. If no output is given, or if the file is `-`, output is written to stdout. --stdin-filename= Assumed filename when formatting from stdin. Used for error messages. -v, --verbose Enable verbose output. --version Print Runic and julia version information. """ ) return end function print_version() print(stdout, "runic version ") print_vnum(stdout, RUNIC_VERSION) print(stdout, ", julia version ") print_vnum(stdout, VERSION) println(stdout) return end # juliac: type-stable output struct (required for juliac but useful in general too) struct Output{IO} which::Symbol file::String stream::IO output_is_file::Bool output_is_samefile::Bool end function writeo(output::Output, iob) @assert output.which !== :devnull if output.which === :file # juliac: `open(...) do` uses dynamic dispatch # write(output.file, iob) let io = open(output.file, "w") try write(io, iob) finally close(io) end end elseif output.which == :stdout write(output.stream, iob) end 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 # Default values inputfiles = String[] outputfile = "" stdin_filename = "stdin" quiet = false verbose = false debug = false inplace = false diff = false check = false fail_fast = false line_ranges = typeof(1:2)[] input_is_stdin = true multiple_inputs = false # Parse the arguments while length(argv) > 0 x = popfirst!(argv) if x == "-i" || x == "--inplace" inplace = true elseif x == "--help" print_help() return errno elseif x == "--version" print_version() return errno elseif x == "-q" || x == "--quiet" quiet = true elseif x == "-v" || x == "--verbose" verbose = true elseif x == "-v" || x == "--fail-fast" fail_fast = true elseif x == "-d" || x == "--diff" diff = true elseif x == "-c" || x == "--check" 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 (m = match(r"^--stdin-filename=(.+)$", x); m !== nothing) stdin_filename = String(m.captures[1]::SubString) elseif x == "-o" if length(argv) < 1 return panic("expected output file argument after `-o`") end outputfile = popfirst!(argv) elseif (m = match(r"^--output=(.+)$", x); m !== nothing) outputfile = String(m.captures[1]::SubString) else # Remaining arguments must be `-`, files, or directories first = true while true if x == "-" # `-` is only allowed once and only in first position if length(argv) > 0 || !first return panic("input `-` can not be combined with other input") end push!(inputfiles, x) input_is_stdin = true else input_is_stdin = false if isdir(x) scandir!(inputfiles, x) # Directories are considered to be multiple (potential) inputs even # if they end up being empty multiple_inputs = true else # isfile(x) push!(inputfiles, x) # Assume it is a file for now end end length(argv) == 0 && break x = popfirst!(argv) first = false multiple_inputs = true end break end end # Insert `-` as the input if there were no input files/directories on the command line if input_is_stdin && length(inputfiles) == 0 @assert !multiple_inputs push!(inputfiles, "-") end # Check the arguments if inplace && check return panic("options `--inplace` and `--check` are mutually exclusive") end if inplace && outputfile != "" return panic("options `--inplace` and `--output` are mutually exclusive") end if check && outputfile != "" return panic("options `--check` and `--output` are mutually exclusive") end if inplace && input_is_stdin return panic("option `--inplace` can not be used together with stdin input") end if outputfile != "" && multiple_inputs # TODO: Why not? return panic("option `--output` can not be used together with multiple input files") end if !isempty(line_ranges) && multiple_inputs return panic("option `--lines` can not be used together with multiple input files") end if multiple_inputs && !(inplace || check) return panic("option `--inplace` or `--check` required with multiple input files") end if diff if Sys.which("git") === nothing return panic("option `--diff` requires `git` to be installed") end end # Disable verbose if piping from/to stdin/stdout output_is_stdout = !inplace && !check && (outputfile == "" || outputfile == "-") print_progress = verbose && !(input_is_stdin || output_is_stdout) # Loop over the input files nfiles_str = string(length(inputfiles)) for (file_counter, inputfile) in enumerate(inputfiles) if print_progress @assert inputfile != "-" input_pretty = relpath(inputfile) if Sys.iswindows() input_pretty = replace(input_pretty, "\\" => "/") end prefix = string( "[", lpad(string(file_counter), textwidth(nfiles_str), " "), "/", nfiles_str, "] " ) verb = check ? "Checking" : "Formatting" str = string(prefix, verb, " `", input_pretty, "` ") ndots = 80 - textwidth(str) - 1 - 1 dots = ndots > 0 ? "."^ndots : "" printstyled(stderr, string(str, dots, " "); color = :blue) end # Read the input if inputfile == "-" @assert input_is_stdin @assert length(inputfiles) == 1 @assert !multiple_inputs sourcetext = try read(stdin, String) catch err print_progress && errln() panic("could not read input from stdin: ", err) continue end elseif isfile(inputfile) @assert !input_is_stdin sourcetext = try read(inputfile, String) catch err print_progress && errln() panic("could not read input from file `$(inputfile)`: ", err) continue end else print_progress && errln() panic("input path is not a file or directory: `$(inputfile)`") continue end # Figure out output if inplace @assert outputfile == "" @assert isfile(inputfile) @assert !input_is_stdin output = Output(:file, inputfile, stdout, true, true) elseif check @assert outputfile == "" output = Output(:devnull, "", stdout, false, false) else @assert length(inputfiles) == 1 @assert !multiple_inputs if outputfile == "" || outputfile == "-" output = Output(:stdout, "", stdout, false, false) elseif isfile(outputfile) && !input_is_stdin && samefile(outputfile, inputfile) print_progress && errln() panic("can not use same file for input and output, use `-i` to modify a file in place") continue else output = Output(:file, outputfile, stdout, true, false) end end # Call the library to format the text inputfile_pretty = inputfile == "-" ? stdin_filename : inputfile ctx = try ctx′ = Context( sourcetext; quiet, verbose, debug, diff, check, line_ranges, filename = inputfile_pretty, ) format_tree!(ctx′) ctx′ catch err print_progress && errln() if err isa JuliaSyntax.ParseError panic(string("failed to parse input from ", inputfile_pretty, ": "), err) continue elseif err isa MainError panic(err.msg) continue end msg = string("failed to format input from ", inputfile_pretty, ": ") @static if juliac rc = panic(msg, err) else # Limit stacktrace to 5 frames because Runic uses recursion a lot and 5 # should be enough to see where the error occurred. bt = stacktrace(catch_backtrace()) bt = bt[1:min(5, length(bt))] rc = panic(msg, err, bt) end if fail_fast return rc end continue end # Output the result changed = !nodes_equal(ctx.fmt_tree, ctx.src_tree) if check if changed print_progress && errln() global errno = 1 else print_progress && okln() end elseif changed || !inplace @assert output.which !== :devnull try writeo(output, seekstart(ctx.fmt_io)) catch err print_progress && errln() panic("could not write to output file `$(output.file)`: ", err) continue end print_progress && okln() else print_progress && okln() end if changed && diff mktempdir() do dir a = mkdir(joinpath(dir, "a")) b = mkdir(joinpath(dir, "b")) file = basename(inputfile) A = joinpath(a, file) B = joinpath(b, file) src_str = ctx.src_str # If we have ranges we need to remove the comment markers # TODO: It isn't great that the source string has been modified to begin # with, and to support --lines in the API functions this filtering # needs to be moved to the `format_tree` function. if !isempty(line_ranges) io = IOBuffer(; sizehint = sizeof(src_str)) for line in eachline(IOBuffer(src_str); keep = true) if !( occursin(RANGE_FORMATTING_BEGIN, line) || occursin(RANGE_FORMATTING_END, line) ) write(io, line) end end src_str = String(take!(io)) end # juliac: `open(...) do` uses dynamic dispatch otherwise the following # blocks could be written as # ``` # write(A, src_str) # write(B, seekstart(ctx.fmt_io)) # ``` let io = open(A, "w") try write(io, src_str) finally close(io) end end let io = open(B, "w") try write(io, seekstart(ctx.fmt_io)) finally close(io) end end color = supports_color(stderr) ? "always" : "never" # juliac: Cmd string parsing uses dynamic dispatch # cmd = ``` # $(git) --no-pager diff --color=$(color) --no-index --no-prefix # $(relpath(A, dir)) $(relpath(B, dir)) # ``` git_argv = String[ Sys.which("git"), "--no-pager", "diff", "--color=$(color)", "--no-index", "--no-prefix", relpath(A, dir), relpath(B, dir), ] cmd = Cmd(git_argv) # `ignorestatus` because --no-index implies --exit-code cmd = setenv(ignorestatus(cmd); dir = dir) cmd = pipeline(cmd, stdout = stderr, stderr = stderr) run_cmd(cmd) return end end end # inputfile loop return errno end @static if isdefined(Base, Symbol("@main")) @main end