diff --git a/src/Atom.jl b/src/Atom.jl index 6cf4c10b..cca3c671 100644 --- a/src/Atom.jl +++ b/src/Atom.jl @@ -1,5 +1,6 @@ __precompile__() +@doc read(joinpath(dirname(@__DIR__), "README.md"), String) module Atom using Juno, Lazy, JSON, MacroTools, Media, Base.StackTraces @@ -51,6 +52,7 @@ include("outline.jl") include("completions.jl") include("goto.jl") include("datatip.jl") +include("refactor.jl") include("misc.jl") include("formatter.jl") include("frontend.jl") diff --git a/src/completions.jl b/src/completions.jl index 80dbb01d..6682ec21 100644 --- a/src/completions.jl +++ b/src/completions.jl @@ -145,19 +145,14 @@ completionurl(c::REPLCompletions.ModuleCompletion) = begin mod, name = c.parent, c.mod val = getfield′(mod, name) if val isa Module # module info - parentmodule(val) == val || val ∈ (Main, Base, Core) ? - "atom://julia-client/?moduleinfo=true&mod=$(name)" : - "atom://julia-client/?moduleinfo=true&mod=$(mod).$(name)" + urimoduleinfo(parentmodule(val) == val || val ∈ (Base, Core) ? name : "$mod.$name") else - "atom://julia-client/?docs=true&mod=$(mod)&word=$(name)" + uridocs(mod, name) end end -completionurl(c::REPLCompletions.MethodCompletion) = - "atom://julia-client/?docs=true&mod=$(c.method.module)&word=$(c.method.name)" -completionurl(c::REPLCompletions.PackageCompletion) = - "atom://julia-client/?moduleinfo=true&mod=$(c.package)" -completionurl(c::REPLCompletions.KeywordCompletion) = - "atom://julia-client/?docs=true&mod=Main&word=$(c.keyword)" +completionurl(c::REPLCompletions.MethodCompletion) = uridocs(c.method.module, c.method.name) +completionurl(c::REPLCompletions.PackageCompletion) = urimoduleinfo(c.package) +completionurl(c::REPLCompletions.KeywordCompletion) = uridocs("Main", c.keyword) completionmodule(mod, c) = shortstr(mod) completionmodule(mod, c::REPLCompletions.ModuleCompletion) = shortstr(c.parent) diff --git a/src/docs.jl b/src/docs.jl index d4909835..473a42f3 100644 --- a/src/docs.jl +++ b/src/docs.jl @@ -32,7 +32,7 @@ function renderitem(x) mod = getmodule(x.mod) name = Symbol(x.name) - r[:typ], r[:icon], r[:nativetype] = if (name !== :ans || mod === Base) && name ∈ keys(Docs.keywords) + r[:typ], r[:icon], r[:nativetype] = if (name !== :ans || mod === Base) && iskeyword(name) "keyword", "k", x.typ else val = getfield′(mod, name) diff --git a/src/refactor.jl b/src/refactor.jl new file mode 100644 index 00000000..07dccdd0 --- /dev/null +++ b/src/refactor.jl @@ -0,0 +1,230 @@ +handle("renamerefactor") do data + @destruct [ + old, + full, + new, + path, + # local context + column || 1, + row || 1, + startRow || 0, + context || "", + # module context + mod || "Main", + ] = data + renamerefactor(old, full, new, path, column, row, startRow, context, mod) +end + +function renamerefactor( + old, full, new, path, + 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) + head = first(split(full, '.')) + headval = getfield′(mod, head) + + # catch field renaming + head ≠ old && !isa(headval, Module) && return Dict( + :warning => "Rename refactoring on a field isn't available: `$obj.$old`" + ) + + expr = CSTParser.parse(context) + items = toplevelitems(expr, context) + ind = findfirst(item -> item isa ToplevelBinding, items) + bind = ind === nothing ? nothing : items[ind].bind + + # local rename refactor if `old` isn't a toplevel binding + if islocalrefactor(bind, old) + try + refactored = localrefactor(old, new, path, 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 = globalrefactor(old, new, mod, expr) + + # make description + if kind === :success + val = getfield′(mod, full) + moddesc = if (headval isa Module && headval ≠ mod) || + (applicable(parentmodule, val) && (headval = parentmodule(val)) ≠ mod) + moduledescription(old, headval) + 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 localrefactor(old, new, path, column, row, startrow, context, expr) + bindings = local_bindings(expr, context) + line = row - startrow + scope = current_scope(old, bindings, byteoffset(context, line, column)) + scope === nothing && return "" + + current_context = scope.bindstr + oldsym = Symbol(old) + newsym = Symbol(new) + new_context = MacroTools.textwalk(current_context) do sym + sym === oldsym ? newsym : sym + end + + replace(context, current_context => new_context) +end + +function current_scope(name, bindings, byteoffset) + for binding in bindings + isa(binding, LocalScope) || continue + + scope = binding + if byteoffset in scope.span && + any(bind -> bind isa LocalBinding && name == bind.name, scope.children) + return scope + else + let scope = current_scope(name, scope.children, byteoffset) + scope !== nothing && return scope + end + end + end + + return nothing +end + +# global refactor +# --------------- + +function globalrefactor(old, new, mod, expr) + entrypath, line = if mod == Main + MAIN_MODULE_LOCATION[] + else + moduledefinition(mod) + end + files = modulefiles(entrypath) + + nonwritablefiles = filter(f -> Int(Base.uperm(f)) ≠ 6, files) + if !isempty(nonwritablefiles) + return :warning, nonwritabledescription(mod, nonwritablefiles) + end + + with_logger(JunoProgressLogger()) do + refactorfiles(old, new, mod, files, expr) + end +end + +function refactorfiles(old, new, mod, files, expr) + 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) + refactoredfiles = Set{String}() + + id = "global_rename_refactor_progress" + @info "Start global rename refactoring" progress=0 _id=id + + for (i, file) ∈ enumerate(files) + @info "Refactoring: $file ($i / $total)" progress=i/total _id=id + + MacroTools.sourcewalk(file) do ex + if ex === oldsym + push!(refactoredfiles, fullpath(file)) + newsym + # handle dot (module) accessor + elseif @capture(ex, m_.$oldsym) && getfield′(mod, Symbol(m)) isa Module + push!(refactoredfiles, fullpath(file)) + Expr(:., m, newsym) + # macro case + elseif ismacro && @capture(ex, macro $(Symbol(old))(args__) body_ end) + push!(refactoredfiles, 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(refactoredfiles) + :success, filedescription(mod, refactoredfiles) + 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))

+ + 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 nonwritabledescription(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. + +
    + Non writable files (all in `$mod` module): +
    + """ +end + +function filedescription(mod, files) + filelist = join(("
  • [$file]($(uriopen(file)))
  • " for file in files), '\n') + """ +
    + Refactored files (all in `$mod` module): +
    + """ +end + +function errdescription(old, new, err) + """ + Rename refactoring `$old` ⟹ `$new` failed. + +
    Error:
    $(errmsg(err))

    + """ +end diff --git a/src/utils.jl b/src/utils.jl index f4e6613e..d4d5838c 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,3 +1,6 @@ +# path utilities +# -------------- + include("path_matching.jl") isuntitled(p) = occursin(r"^(\.\\|\./)?untitled-[\d\w]+(:\d+)?$", p) @@ -99,6 +102,9 @@ function md_hlines(md) return MD(v) end +# string utilties +# --------------- + function strlimit(str::AbstractString, limit::Int = 30, ellipsis::AbstractString = "…") will_append = length(str) > limit @@ -122,34 +128,56 @@ shortstr(val) = strlimit(string(val), 20) struct Undefined end # get utilities +# ------------- + using CodeTools """ - getfield′(mod::Module, name::String, default = Undefined()) + getfield′(mod::Module, name::AbstractString, default = Undefined()) getfield′(mod::Module, name::Symbol, default = Undefined()) + getfield′(mod::AbstractString, name::Symbol, default = Undefined()) getfield′(object, name::Symbol, default = Undefined()) + getfield′(object, name::AbstractString, default = Undefined()) Returns the specified field of a given `Module` or some arbitrary `object`, or `default` if no such a field is found. """ -getfield′(mod::Module, name::String, default = Undefined()) = CodeTools.getthing(mod, name, default) +getfield′(mod::Module, name::AbstractString, default = Undefined()) = CodeTools.getthing(mod, name, default) getfield′(mod::Module, name::Symbol, default = Undefined()) = getfield′(mod, string(name), default) +getfield′(mod::AbstractString, name::Symbol, default = Undefined()) = getfield′(getmodule(mod), string(name), default) getfield′(@nospecialize(object), name::Symbol, default = Undefined()) = isdefined(object, name) ? getfield(object, name) : default +getfield′(@nospecialize(object), name::AbstractString, default = Undefined()) = isdefined(object, name) ? getfield(object, Symbol(name)) : default """ - getmodule(mod::String) - getmodule(parent::Union{Nothing, Module}, mod::String) + getmodule(mod::AbstractString) + getmodule(parent::Union{Nothing, Module}, mod::AbstractString) getmodule(code::AbstractString, pos; filemod) Calls `CodeTools.getmodule(args...)`, but returns `Main` instead of `nothing` in a fallback case. """ getmodule(args...) = (m = CodeTools.getmodule(args...)) === nothing ? Main : m -getmethods(mod::Module, word::String) = methods(CodeTools.getthing(mod, word)) -getmethods(mod::String, word::String) = getmethods(getmodule(mod), word) +""" + getmethods(mod::Module, word::AbstractString) + getmethods(mod::AbstractString, word::AbstractString) -getdocs(mod::Module, word::String, fallbackmod::Module = Main) = begin - md = if Symbol(word) in keys(Docs.keywords) +Returns the [`MethodList`](@ref) for `word`, which is bound within `mod` module. +""" +getmethods(mod::Module, word::AbstractString) = methods(CodeTools.getthing(mod, word)) +getmethods(mod::AbstractString, word::AbstractString) = getmethods(getmodule(mod), word) + +""" + getdocs(mod::Module, word::AbstractString, fallbackmod::Module = Main) + getdocs(mod::AbstractString, word::AbstractString, fallbackmod::Module = Main) + +Retrieves docs for `mod.word` with [`@doc`](@ref) macro. If `@doc` is not available + within `mod` module, `@doc` will be evaluated in `fallbackmod` module if possible. + +!!! note + You may want to run [`cangetdocs`](@ref) in advance. +""" +getdocs(mod::Module, word::AbstractString, fallbackmod::Module = Main) = begin + md = if iskeyword(word) Core.eval(Main, :(@doc($(Symbol(word))))) else docsym = Symbol("@doc") @@ -164,13 +192,36 @@ getdocs(mod::Module, word::String, fallbackmod::Module = Main) = begin end md_hlines(md) end -getdocs(mod::String, word::String, fallbackmod::Module = Main) = +getdocs(mod::AbstractString, word::AbstractString, fallbackmod::Module = Main) = getdocs(getmodule(mod), word, fallbackmod) +""" + cangetdocs(mod::Module, word::Symbol) + cangetdocs(mod::Module, word::AbstractString) + cangetdocs(mod::AbstractString, word::Union{Symbol, AbstractString}) + +Checks if the documentation bindings for `mod.word` is resolved and `mod.word` + is not deprecated. +""" cangetdocs(mod::Module, word::Symbol) = Base.isbindingresolved(mod, word) && !Base.isdeprecated(mod, word) -cangetdocs(mod::Module, word::String) = cangetdocs(mod, Symbol(word)) +cangetdocs(mod::Module, word::AbstractString) = cangetdocs(mod, Symbol(word)) +cangetdocs(mod::AbstractString, word::Union{Symbol, AbstractString}) = cangetdocs(getmodule(mod), word) + +# is utilities +# ------------ + +iskeyword(word::Symbol) = word in keys(Docs.keywords) +iskeyword(word::AbstractString) = iskeyword(Symbol(word)) + +# uri utilties +# ------------ + +uriopen(file, line = 0) = "atom://julia-client/?open=true&file=$(file)&line=$(line)" +uridocs(mod, word) = "atom://julia-client/?docs=true&mod=$(mod)&word=$(word)" +urigoto(mod, word) = "atom://julia-client/?goto=true&mod=$(mod)&word=$(word)" +urimoduleinfo(mod) = "atom://julia-client/?moduleinfo=true&mod=$(mod)" #= module file detections @@ -204,6 +255,34 @@ function modulefiles(mod::Module) return fixpath(parentfile), [fixpath(mf[2]) for mf in included_files] end +""" + included_files = modulefiles(entrypath::String)::Vector{String} + +Return all the file paths that can be reached via [`include`](@ref) calls. +Note this function currently only looks for static _toplevel_ calls. +""" +function modulefiles(entrypath::String, files = []) + push!(files, entrypath) + + text = read(entrypath, String) + parsed = CSTParser.parse(text, true) + items = toplevelitems(parsed, text) + + for item in items + if item isa ToplevelCall + expr = item.expr + if isinclude(expr) + nextfile = expr.args[3].val + nextentrypath = joinpath(dirname(entrypath), nextfile) + isfile(nextentrypath) || continue + modulefiles(nextentrypath, files) + end + end + end + + return files +end + function moduledefinition(mod::Module) # NOTE: added when adapted evalmethod = first(methods(getfield(mod, :eval))) parentfile = String(evalmethod.file) diff --git a/test/goto.jl b/test/goto.jl index 246a21e0..3ebf158d 100644 --- a/test/goto.jl +++ b/test/goto.jl @@ -52,7 +52,7 @@ @testset "module goto" begin let item = modulegotoitems("Atom", Main)[1] @test item.file == realpath′(joinpath(@__DIR__, "..", "src", "Atom.jl")) - @test item.line == 2 + @test item.line == 3 end let item = modulegotoitems("Junk2", Main.Junk)[1] @test item.file == joinpath(@__DIR__, "fixtures", "Junk.jl")