diff --git a/Project.toml b/Project.toml index 99090cfa..66ac187d 100644 --- a/Project.toml +++ b/Project.toml @@ -28,6 +28,7 @@ Profile = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79" REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" Requires = "ae029012-a4dd-5104-9daa-d747884805df" Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" +SourceWalk = "2987fe4f-acca-4bd1-87ee-98b3d562f1a1" Traceur = "37b6cedf-1f77-55f8-9503-c64b63398394" TreeViews = "a2a6695c-b41b-5b7d-aed9-dbfdeacea5d7" WebIO = "0f1e0344-ec1d-5b48-a673-e5cf874b6c29" @@ -47,7 +48,7 @@ JuliaInterpreter = "^0.7.10" Juno = "^0.7, ^0.8" LNR = "^0.2.0" Lazy = "^0.13.2, ^0.14, ^0.15" -MacroTools = "^0.5" +MacroTools = "^0.5.1" Media = "^0.5" OrderedCollections = "^1.1" Requires = "^0.5, 1.0" diff --git a/src/Atom.jl b/src/Atom.jl index 7b4f15cb..5ef85c22 100644 --- a/src/Atom.jl +++ b/src/Atom.jl @@ -57,6 +57,7 @@ include("completions.jl") include("goto.jl") include("datatip.jl") include("formatter.jl") +include("refactor.jl") include("frontend.jl") include("debugger/debugger.jl") include("profiler/profiler.jl") diff --git a/src/debugger/stepper.jl b/src/debugger/stepper.jl index 3c596eb1..c3e423e7 100644 --- a/src/debugger/stepper.jl +++ b/src/debugger/stepper.jl @@ -3,7 +3,6 @@ using JuliaInterpreter: pc_expr, extract_args, debug_command, root, caller, import JuliaInterpreter import ..Atom: fullpath, handle, @msg, Inline, display_error, hideprompt, getmodule import Juno: Row -using MacroTools mutable struct DebuggerState frame::Union{Nothing, Frame} diff --git a/src/refactor.jl b/src/refactor.jl new file mode 100644 index 00000000..692c0ddc --- /dev/null +++ b/src/refactor.jl @@ -0,0 +1,247 @@ +using SourceWalk: textwalk, sourcewalk + +handle("renamerefactor") do data + @destruct [ + oldWord, + fullWord, + newWord, + # local context + column || 1, + row || 1, + startRow || 0, + context || "", + # module context + mod || "Main", + ] = data + renamerefactor(oldWord, fullWord, newWord, column, row, startRow, context, mod) +end + +# NOTE: invalid identifiers will be caught by frontend +function renamerefactor( + oldword, fullword, newword, + column = 1, row = 1, startrow = 0, context = "", + mod = "Main", +) + # catch keyword renaming + iskeyword(oldword) && return Dict(:warning => "Keywords can't be renamed: `$oldword`") + + mod = getmodule(mod) + hstr = first(split(fullword, '.')) + head = getfield′(mod, hstr) + + # catch field renaming + hstr ≠ oldword && !isa(head, Module) && return Dict( + :warning => "Rename refactoring on a field isn't available: `$hstr.$oldword`" + ) + + expr = CSTParser.parse(context) + + bind = let + items = toplevelitems(context, expr) + ind = findfirst(item -> item isa ToplevelBinding, items) + ind === nothing ? nothing : items[ind].bind + end + + # local rename refactor if `old` isn't a toplevel binding + if islocalrefactor(bind, oldword) + try + refactored = localrenamerefactor(oldword, newword, column, row, startrow, context, expr) + return isempty(refactored) ? + # NOTE: global refactoring not on definition, e.g.: on a call site, will be caught here + Dict(:info => contextdescription(oldword, mod, context)) : + Dict( + :text => refactored, + :success => "_Local_ rename refactoring `$oldword` ⟹ `$newword` succeeded" + ) + catch err + return Dict(:error => errdescription(oldword, newword, err)) + end + end + + # global rename refactor if the local rename refactor didn't happen + try + kind, desc = globalrenamerefactor(oldword, newword, mod, expr) + + # make description + if kind === :success + val = getfield′(mod, fullword) + moddesc = if (head isa Module && head ≠ mod) || + (applicable(parentmodule, val) && (head = parentmodule(val)) ≠ mod) + moduledescription(oldword, head) + else + "" + end + + desc = join(("_Global_ rename refactoring `$mod.$oldword` ⟹ `$mod.$newword` succeeded.", moddesc, desc), "\n\n") + end + + return Dict(kind => desc) + catch err + return Dict(:error => errdescription(oldword, newword, err)) + end +end + +islocalrefactor(bind, name) = bind === nothing || name ≠ bind.name + +# local refactor +# -------------- + +function localrenamerefactor(oldword, newword, column, row, startrow, context, expr) + bindings = localbindings(expr, context) + line = row - startrow + scope = currentscope(oldword, bindings, byteoffset(context, line, column)) + scope === nothing && return "" + + currentcontext = scope.bindstr + oldsym = Symbol(oldword) + newsym = Symbol(newword) + newcontext = textwalk(currentcontext) do sym + sym === oldsym ? newsym : sym + end + + replace(context, currentcontext => newcontext) +end +localrenamerefactor(oldword, newword, column, row, startrow, context, expr::Nothing) = "" + +function currentscope(name, bindings, byteoffset) + for binding in bindings + isa(binding, LocalScope) || continue + + # first looks for innermost scope + childscope = currentscope(name, binding.children, byteoffset) + childscope !== nothing && return childscope + + if byteoffset in binding.span && + any(bind -> bind isa LocalBinding && name == bind.name, binding.children) + return binding + end + end + + return nothing +end + +# global refactor +# --------------- + +function globalrenamerefactor(oldword, newword, mod, expr) + entrypath, _ = if mod == Main + MAIN_MODULE_LOCATION[] + else + moduledefinition(mod) + end + + files = modulefiles(string(mod), entrypath) + + # catch refactorings on an unsaved / non-existing file + isempty(files) && return :warning, unsaveddescription() + + # catch refactorings on files without write permission + nonwritables = nonwritablefiles(files) + if !isempty(nonwritables) + return :warning, nonwritablesdescription(mod, nonwritables) + end + + with_logger(JunoProgressLogger()) do + _globalrenamerefactor(oldword, newword, mod, expr, files) + end +end + +function _globalrenamerefactor(oldword, newword, mod, expr, files) + ismacro = CSTParser.defines_macro(expr) + oldsym = ismacro ? Symbol("@" * oldword) : Symbol(oldword) + newsym = ismacro ? Symbol("@" * newword) : Symbol(newword) + + total = length(files) + # TODO: enable line location information (the upstream needs to be enhanced) + modifiedfiles = Set{String}() + + id = "global_rename_refactor_progress" + @info "Start global rename refactoring" progress=0 _id=id + + for (i, file) ∈ enumerate(files) + @logmsg -1 "Refactoring: $file ($i / $total)" progress=i/total _id=id + + sourcewalk(file) do ex + if ex === oldsym + push!(modifiedfiles, fullpath(file)) + newsym + # handle dot accessor + elseif @capture(ex, m_.$oldsym) && getfield′(mod, Symbol(m)) isa Module + push!(modifiedfiles, fullpath(file)) + Expr(:., m, newsym) + # macro case + elseif ismacro && @capture(ex, macro $(Symbol(oldword))(args__) body_ end) + push!(modifiedfiles, fullpath(file)) + Expr(:macro, :($(Symbol(newword))($(args...))), :($body)) + else + ex + end + end + end + + @info "Finish global rename refactoring" progress=1 _id=id + + return if !isempty(modifiedfiles) + :success, filesdescription(mod, modifiedfiles) + else + :warning, "No rename refactoring occured on `$oldword` in `$mod` module." + end +end + +# descriptions +# ------------ + +function contextdescription(old, mod, context) + gotouri = urigoto(mod, old) + """ + `$old` isn't found in local bindings in the current context: +
Context:
$(strip(context))
+ + If you want a global rename refactoring on `$mod.$old`, you need to run this command + from its definition. + """ +end + +function moduledescription(old, parentmod) + gotouri = urigoto(parentmod, old) + """ + **NOTE**: `$old` is defined in `$parentmod` -- you may need the same rename refactorings + in that module as well. + """ +end + +function unsaveddescription() + """ + Global rename refactor failed, since the given file isn't saved on the disk yet. + Please run this command again after you save the file. + """ +end + +function nonwritablesdescription(mod, files) + filelist = join(("
  • [$file]($(uriopen(file)))
  • " for file in files), '\n') + """ + Global rename refactor failed, since there are non-writable files detected in + `$mod` module. Please make sure the files have an write access. + +
    + Non writable files (all in `$mod` module): +
    + """ +end + +function filesdescription(mod, files) + filelist = join(("
  • [$file]($(uriopen(file)))
  • " for file in files), '\n') + """ +
    + Refactored files (all in `$mod` module): +
    + """ +end + +function errdescription(oldword, newword, err) + """ + Rename refactoring `$oldword` ⟹ `$newword` failed. + +
    Error:
    $(errmsg(err))
    + """ +end diff --git a/test/refactor.jl b/test/refactor.jl new file mode 100644 index 00000000..2b8fb1b6 --- /dev/null +++ b/test/refactor.jl @@ -0,0 +1,63 @@ +@testset "rename refactor" begin + import Atom: renamerefactor + + @testset "catch invalid/unsupported refactorings" begin + # catch renaming on keywords + let result = renamerefactor("function", "function", "func") + @test haskey(result, :warning) + end + + # catch field renaming + let result = renamerefactor("field", "obj.field", "newfield") + @test haskey(result, :warning) + end + + # but when dot-accessed object is a (existing) module, continues refactoring + let result = renamerefactor("bind", "Main.bind", "newbind") + @test !haskey(result, :warning) + @test haskey(result, :info) + end + end + + @testset "local rename refactor" begin + # TODO + end + + @testset "global rename refactor" begin + @testset "catch edge cases" begin + # handle refactoring on an unsaved / non-existing file + let context = """ + toplevel() = nothing + """ + # mock MAIN_MODULE_LOCATION update in `module` handler + @eval Atom begin + MAIN_MODULE_LOCATION[] = "", 1 + end + result = renamerefactor("toplevel", "toplevel", "toplevel2", 0, 1, 1, context) + @test haskey(result, :warning) + @test result[:warning] == Atom.unsaveddescription() + end + + # handle refactoring on nonwritable files + let path = joinpath(@__DIR__, "fixtures", "Junk.jl") + originalmode = Base.filemode(path) + + try + Base.chmod(path, 0x444) # only reading + context = "module Junk2 end" + result = renamerefactor("Junk2", "Junk2", "Junk3", 0, 1, 1, context, "Main.Junk") + + @test haskey(result, :warning) + @test result[:warning] == Atom.nonwritablesdescription(Main.Junk, [path]) + catch err + @info """ + Cancelled the test for handling of refactorings on non-writable + files due to the error below: + """ err + finally + Base.chmod(path, originalmode) + end + end + end + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 8ce27151..b8c5afc0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -58,5 +58,6 @@ include("outline.jl") include("completions.jl") include("goto.jl") include("datatip.jl") +include("refactor.jl") include("workspace.jl") include("docs.jl")