From 73d103252925a8ea93341691cc572bf648855c04 Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Thu, 23 May 2024 20:05:05 +0200 Subject: [PATCH] Add initial files --- LICENSE | 22 ++++ Project.toml | 9 ++ README.md | 3 + src/Runic.jl | 305 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.jl | 131 ++++++++++++++++++++++ 5 files changed, 470 insertions(+) create mode 100644 LICENSE create mode 100644 Project.toml create mode 100644 README.md create mode 100644 src/Runic.jl create mode 100644 src/main.jl diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf6f90a --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Fredrik Ekre and Runic.jl contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..1094442 --- /dev/null +++ b/Project.toml @@ -0,0 +1,9 @@ +name = "Runic" +uuid = "62bfec6d-59d7-401d-8490-b29ee721c001" +version = "1.0.0" + +[deps] +JuliaSyntax = "70703baa-626e-46a2-a12c-08ffd08c73b4" + +[compat] +JuliaSyntax = "0.4.8" diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2a5959 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Runic.jl + +*A code formatter with rules set in stone.* diff --git a/src/Runic.jl b/src/Runic.jl new file mode 100644 index 0000000..e8848e3 --- /dev/null +++ b/src/Runic.jl @@ -0,0 +1,305 @@ +module Runic + +using JuliaSyntax: + JuliaSyntax, @K_str, @KSet_str + +mutable struct Context + const io::IO + const src::String + indent_level::Int + offset::Int + node::JuliaSyntax.GreenNode + parent::Union{JuliaSyntax.GreenNode, Nothing} +end + +function Context(src)::Union{Tuple{Nothing, Exception}, Tuple{Context, Nothing}} + root = try + JuliaSyntax.parseall(JuliaSyntax.GreenNode, src; ignore_warnings=true) + catch e + return nothing, e + end + return Context(IOBuffer(), src, 0, 0, root, nothing), nothing +end + +# Emit the node like in the original source code. +function emit!(ctx::Context)::Union{Nothing, Exception} + node = ctx.node + # Should never emit nodes with children + @assert !JuliaSyntax.haschildren(node) + # First index of the current node, assumed to be valid + i = ctx.offset + 1 + @assert isvalid(ctx.src, i) + # Last index of the current node computed as the previous valid index from the first index of the next node + j = prevind(ctx.src, ctx.offset + JuliaSyntax.span(node) + 1) + @assert isvalid(ctx.src, j) + # String representation of this node + str = @view ctx.src[i:j] + @debug "Emitting ..." JuliaSyntax.kind(node) str + return emit!(ctx, str) +end + +# Emit the node with a replacement string. +function emit!(ctx::Context, str::AbstractString) + write(ctx.io, str) + ctx.offset += JuliaSyntax.span(ctx.node) + return +end + +function recurse!(ctx)::Union{Nothing, Exception} + # Stash the family + grandparent = ctx.parent + ctx.parent = ctx.node + for child in JuliaSyntax.children(ctx.node) + ctx.node = child + if (err = format_context!(ctx); err !== nothing) + return err + end + end + # Restore the family + ctx.node = ctx.parent + ctx.parent = grandparent + return nothing +end + +function format_context!(ctx::Context)::Union{Nothing, Exception} + node = ctx.node + node_kind = JuliaSyntax.kind(node) + + # 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" + ) + @debug "Recursing always" node_kind + @assert !JuliaSyntax.is_trivia(node) + if (err = recurse!(ctx); err !== nothing) + return err + end + + # 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" + ) + @debug "Recursing if not trivia" node_kind + if (err = recurse!(ctx); err !== nothing) + return err + end + + # Nodes that should recurse if they have children (all??) + elseif JuliaSyntax.haschildren(node) && ( + JuliaSyntax.is_operator(node) || + node_kind === K"else" # try-(catch|finally)-else + ) + @debug "Recursing because children" node_kind + if (err = recurse!(ctx); err !== nothing) + return err + end + + # Whitespace and comments emitted verbatim for now + elseif node_kind === K"Whitespace" || + node_kind === K"NewlineWs" || + node_kind === K"Comment" + @debug "emit ws" node_kind + if (err = emit!(ctx); err !== nothing) + return err + end + + # 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"```" || + node_kind === K"abstract" || + node_kind === K"as" || + node_kind === K"baremodule" || + node_kind === K"begin" || + node_kind === K"break" || + node_kind === K"catch" || + node_kind === K"const" || + node_kind === K"continue" || + node_kind === K"do" || + node_kind === K"else" || + node_kind === K"elseif" || + node_kind === K"end" || + 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"in" || + node_kind === K"let" || + node_kind === K"local" || + node_kind === K"macro" || + node_kind === K"module" || + node_kind === K"mutable" || + node_kind === K"outer" || + node_kind === K"primitive" || + node_kind === K"quote" || + node_kind === K"return" || + node_kind === K"struct" || + node_kind === K"try" || + node_kind === K"using" || + node_kind === K"var" || + node_kind === K"while" || + node_kind === K"{" || + node_kind === K"}" + ) + ) + @debug "Emitting raw" node_kind + if (err = emit!(ctx); err !== nothing) + return err + end + else + return ErrorException("unhandled node of type $(JuliaSyntax.kind(ctx.node)), current text:\n" * String(take!(ctx.io))) + end + return nothing +end + +function format_context(sourcetext)::Tuple{Any,Union{Nothing, Exception}} + # Build the context + ctx, err = Context(sourcetext) + if err !== nothing + return ctx, err + end + # Run the formatter + err = format_context!(ctx) + return ctx, err +end + +""" + format_string(sourcetext::AbstractString) -> String + +Format a string. +""" +function format_string(sourcetext::AbstractString) + # Format it! + ctx, err = format_context(sourcetext) + if err !== nothing + throw(err) + end + # Return the string + return String(take!(ctx.io)) +end + +""" + format_file(inputfile::AbstractString, outputfile::AbstractString; inplace::Bool=false) + +Format a file. +""" +function format_file(inputfile::AbstractString, outputfile::Union{AbstractString, Nothing} = nothing; inplace::Bool=false) + # Argument handling + sourcetext = read(inputfile, String) + if outputfile === nothing && !inplace + error("output file required when `inplace = false`") + end + if isfile(outputfile) && samefile(inputfile, outputfile) && !inplace + error("must not use same input and output file when `inplace = false`") + end + # Format it + ctx, err = format_context(sourcetext) + if err !== nothing + throw(err) + end + # Write the output on success + write(outputfile, take!(ctx.io)) + return +end + +if isdefined(Base, Symbol("@main")) + include("main.jl") +end + +end # module diff --git a/src/main.jl b/src/main.jl new file mode 100644 index 0000000..f4ba70e --- /dev/null +++ b/src/main.jl @@ -0,0 +1,131 @@ +errno::Cint = 0 + +function panic(msg...) + printstyled(stderr, "ERROR: "; color = :red, bold = true) + for m in msg + if m isa Exception + showerror(stderr, m) + else + print(stderr, msg...) + end + end + println(stderr) + global errno = 1 + return errno +end + +function (@main)(argv) + # Reset errno + global errno = 0 + + # Default values + inputfiles = String[] + outputfile = nothing + inplace::Bool = false + + # Parse the arguments + while length(argv) > 0 + x = popfirst!(argv) + if x == "-i" + inplace = true + elseif x == "-o" + if length(argv) < 1 + return panic("expected output file as argument after `-o`") + end + outputfile = popfirst!(argv) + else + # Remaining arguments must be inputfile(s) + push!(inputfiles, x) + for x in argv + if x == "-" + return panic("input \"-\" can not be used with multiple files") + end + push!(inputfiles, x) + end + break + end + end + + # stdin is the default input + if isempty(inputfiles) + push!(inputfiles, "-") + end + + # multiple files require -i and no -o + if length(inputfiles) > 1 + if !inplace + return panic("option `-i` is required for multiple input files") + elseif outputfile !== nothing + return panic("option `-o` is incompatible with multiple input files") + end + end + + # inplace = true is incompatible with given output + if inplace && outputfile !== nothing + @assert length(inputfiles) == 1 + return panic("option `-i` is incompatible with option `-o $(outputfile)`") + end + + # inplace = true is incompatible with stdin as input + if inplace && first(inputfiles) == "-" + return panic("option `-i` is incompatible with stdin as input") + end + + # Loop over the input files + for inputfile in inputfiles + # Read the input + input_is_file = true + if inputfile == "-" + input_is_file = false + sourcetext = try + read(stdin, String) + catch err + return panic("could not read input from stdin: ", err) + end + elseif isfile(inputfile) + sourcetext = try + read(inputfile, String) + catch err + panic("could not read input from file `$(inputfile)`: ", err) + continue + end + else + panic("input file does not exist: `$(inputfile)`") + continue + end + + # Check output + if inplace + @assert outputfile === nothing + @assert isfile(inputfile) + @assert input_is_file + # @assert length(inputfiles) == 1 # checked above + output = inputfile + else + @assert length(inputfiles) == 1 + if outputfile === nothing || outputfile == "-" + output = stdout + elseif isfile(outputfile) && input_is_file && samefile(outputfile, inputfile) + return panic("can not use same file for input and output, use `-i` to modify a file in place") + else + output = outputfile + end + end + + # Call the library to format the text + ctx, err = format_context(sourcetext) + if err !== nothing + panic(err) + continue + end + + # Write the output + try + write(output, take!(ctx.io)) + catch err + panic("could not write to output: ", err) + end + end + + return errno +end