Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: rename refactor #203

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/Atom.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 0 additions & 1 deletion src/debugger/stepper.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
247 changes: 247 additions & 0 deletions src/refactor.jl
Original file line number Diff line number Diff line change
@@ -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:
<details><summary>Context:</summary><pre><code>$(strip(context))</code></pre></details>

If you want a global rename refactoring on `$mod.$old`, you need to run this command
from its definition. <button>[Go to `$mod.$old`]($gotouri)</button>
"""
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. <button>[Go to `$parentmod.$old`]($gotouri)</button>
"""
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(("<li>[$file]($(uriopen(file)))</li>" 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.

<details><summary>
Non writable files (all in `$mod` module):
</summary><ul>$(filelist)</ul></details>
"""
end

function filesdescription(mod, files)
filelist = join(("<li>[$file]($(uriopen(file)))</li>" for file in files), '\n')
"""
<details><summary>
Refactored files (all in `$mod` module):
</summary><ul>$(filelist)</ul></details>
"""
end

function errdescription(oldword, newword, err)
"""
Rename refactoring `$oldword` ⟹ `$newword` failed.

<details><summary>Error:</summary><pre><code>$(errmsg(err))</code></pre></details>
"""
end
63 changes: 63 additions & 0 deletions test/refactor.jl
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")