diff --git a/src/Atom.jl b/src/Atom.jl
index a7a4e78a..6d671bf3 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/refactor.jl b/src/refactor.jl
new file mode 100644
index 00000000..6c675290
--- /dev/null
+++ b/src/refactor.jl
@@ -0,0 +1,248 @@
+handle("renamerefactor") do data
+ @destruct [
+ old,
+ full,
+ new,
+ # local context
+ column || 1,
+ row || 1,
+ startRow || 0,
+ context || "",
+ # module context
+ mod || "Main",
+ ] = data
+ renamerefactor(old, full, new, column, row, startRow, context, mod)
+end
+
+# NOTE: invalid identifiers will be caught by frontend
+function renamerefactor(
+ old, full, new,
+ column = 1, row = 1, startrow = 0, context = "",
+ mod = "Main",
+)
+ # catch keyword renaming
+ iskeyword(old) && return Dict(:warning => "Keywords can't be renamed: `$old`")
+
+ mod = getmodule(mod)
+ hstr = first(split(full, '.'))
+ head = getfield′(mod, hstr)
+
+ # catch field renaming
+ hstr ≠ old && !isa(head, Module) && return Dict(
+ :warning => "Rename refactoring on a field isn't available: `$hstr.$old`"
+ )
+
+ expr = CSTParser.parse(context)
+ bind = let
+ if expr !== nothing
+ items = toplevelitems(expr, context)
+ ind = findfirst(item -> item isa ToplevelBinding, items)
+ ind === nothing ? nothing : items[ind].bind
+ else
+ nothing
+ end
+ end
+
+ # local rename refactor if `old` isn't a toplevel binding
+ if islocalrefactor(bind, old)
+ try
+ refactored = localrenamerefactor(old, new, 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(old, mod, context)) :
+ Dict(
+ :text => refactored,
+ :success => "_Local_ rename refactoring `$old` ⟹ `$new` succeeded"
+ )
+ catch err
+ return Dict(:error => errdescription(old, new, err))
+ end
+ end
+
+ # global rename refactor if the local rename refactor didn't happen
+ try
+ kind, desc = globalrenamerefactor(old, new, mod, expr)
+
+ # make description
+ if kind === :success
+ val = getfield′(mod, full)
+ moddesc = if (head isa Module && head ≠ mod) ||
+ (applicable(parentmodule, val) && (head = parentmodule(val)) ≠ mod)
+ moduledescription(old, head)
+ else
+ ""
+ end
+
+ desc = join(("_Global_ rename refactoring `$mod.$old` ⟹ `$mod.$new` succeeded.", moddesc, desc), "\n\n")
+ end
+
+ return Dict(kind => desc)
+ catch err
+ return Dict(:error => errdescription(old, new, err))
+ end
+end
+
+islocalrefactor(bind, name) = bind === nothing || name ≠ bind.name
+
+# local refactor
+# --------------
+
+function localrenamerefactor(old, new, column, row, startrow, context, expr)
+ bindings = localbindings(expr, context)
+ line = row - startrow
+ scope = currentscope(old, bindings, byteoffset(context, line, column))
+ scope === nothing && return ""
+
+ currentcontext = scope.bindstr
+ oldsym = Symbol(old)
+ newsym = Symbol(new)
+ newcontext = MacroTools.textwalk(currentcontext) do sym
+ sym === oldsym ? newsym : sym
+ end
+
+ replace(context, currentcontext => newcontext)
+end
+localrenamerefactor(old, new, 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(old, new, mod, expr)
+ entrypath, _ = if mod == Main
+ MAIN_MODULE_LOCATION[]
+ else
+ moduledefinition(mod)
+ end
+
+ files = modulefiles(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(old, new, mod, expr, files)
+ end
+end
+
+function _globalrenamerefactor(old, new, mod, expr, files)
+ ismacro = CSTParser.defines_macro(expr)
+ oldsym = ismacro ? Symbol("@" * old) : Symbol(old)
+ newsym = ismacro ? Symbol("@" * new) : Symbol(new)
+
+ 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
+
+ MacroTools.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(old))(args__) body_ end)
+ push!(modifiedfiles, fullpath(file))
+ Expr(:macro, :($(Symbol(new))($(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 `$old` 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))
$(errmsg(err))