Download
265 lines 11.5 KiBMoonScriptModified 2019-03-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
-- This file contains the logic for making nicer error messages
debug_getinfo = debug.getinfo
Files = require "files"
C = require "colors"
pretty_error = require("pretty_errors")
export SOURCE_MAP

ok, to_lua = pcall -> require('moonscript.base').to_lua
if not ok then to_lua = -> nil
MOON_SOURCE_MAP = setmetatable {},
    __index: (file)=>
        _, line_table = to_lua(file)
        self[file] = line_table or false
        return line_table or false

-- Make a better version of debug.getinfo that provides info about the original source
-- where the error came from, even if that's in another language.
debug.getinfo = (thread,f,what)->
    if what == nil
        f,what,thread = thread,f,nil
    if type(f) == 'number' then f += 1 -- Account for this wrapper function
    info = if thread == nil
        debug_getinfo(f,what)
    else debug_getinfo(thread,f,what)
    if not info or not info.func then return info
    if info.short_src or info.source or info.linedefine or info.currentline
        -- TODO: reduce duplicate code
        if map = SOURCE_MAP[info.source]
            if info.currentline
                info.currentline = assert(map[info.currentline])
            if info.linedefined
                info.linedefined = assert(map[info.linedefined])
            if info.lastlinedefined
                info.lastlinedefined = assert(map[info.lastlinedefined])
            info.short_src = info.source\match('@([^[]*)') or info.short_src
            info.name = if info.name
                "action '#{info.name\from_lua_id!}'"
            else "main chunk"
    return info

-- This uses a slightly modified Damerau-Levenshtein distance:
strdist = (a,b,cache={})->
    if a == b then return 0
    if #a < #b then a,b = b,a
    if b == "" then return #a
    k = a..'\003'..b
    unless cache[k]
        -- Insert, delete, substitute (given weight 1.1 as a heuristic)
        cache[k] = math.min(
            strdist(a\sub(1,-2),b,cache) + 1,
            strdist(a,b\sub(1,-2),cache) + 1,
            strdist(a\sub(1,-2),b\sub(1,-2),cache) + (a\sub(-1) ~= b\sub(-1) and 1.1 or 0)
        )
        -- Transposition:
        if #a >= 2 and #b >= 2 and a\sub(-1,-1) == b\sub(-2,-2) and a\sub(-2,-2) == b\sub(-1,-1)
            cache[k] = math.min(cache[k], strdist(a\sub(1,-3),b\sub(1,-3),cache) + 1)
    return cache[k]

enhance_error = (error_message)->
    -- Hacky: detect the line numbering
    unless error_message and error_message\match("%d|")
        error_message or= ""
        -- When calling 'nil' actions, make a better error message
        if fn_name = error_message\match("attempt to call a nil value %(method '(.*)'%)")
            action_name = fn_name\from_lua_id!
            error_message = "This object does not have the method '#{action_name}'."
        elseif fn_name = (error_message\match("attempt to call a nil value %(global '(.*)'%)") or
            error_message\match("attempt to call global '(.*)' %(a nil value%)"))

            action_name = fn_name\from_lua_id!
            error_message = "The action '#{action_name}' is not defined."

            -- Look for simple misspellings:

            -- This check is necessary for handling both top-level code and code inside a fn
            func = debug.getinfo(2,'f').func
            local env
            if _VERSION == "Lua 5.1"
                env = getfenv(func)
            else
                ename,env = debug.getupvalue(func, 1)
                unless ename == "_ENV" or ename == "_G"
                    func = debug.getinfo(3,'f').func
                    ename,env = debug.getupvalue(func, 1)

            THRESHOLD = math.min(4.5, .9*#action_name) -- Ignore matches with strdist > THRESHOLD
            candidates = {}
            cache = {}

            -- Locals:
            for i=1,99
                k, v = debug.getlocal(2, i)
                break if k == nil
                unless k\sub(1,1) == "(" or type(v) != 'function'
                    k = k\from_lua_id!
                    if strdist(k, action_name, cache) <= THRESHOLD and k != ""
                        table.insert candidates, k

            -- Upvalues:
            for i=1,debug.getinfo(func, 'u').nups
                k, v = debug.getupvalue(func, i)
                unless k\sub(1,1) == "(" or type(v) != 'function'
                    k = k\from_lua_id!
                    if strdist(k, action_name, cache) <= THRESHOLD and k != ""
                        table.insert candidates, k

            -- Globals and global compile rules:
            scan = (t, is_lua_id)->
                return unless t
                for k,v in pairs(t)
                    if type(k) == 'string' and type(v) == 'function'
                        k = k\from_lua_id! unless is_lua_id
                        if strdist(k, action_name, cache) <= THRESHOLD and k != ""
                            table.insert candidates, k
            scan env.COMPILE_RULES, true
            scan env.COMPILE_RULES._IMPORTS, true
            scan env
            scan env._IMPORTS

            if #candidates > 0
                for c in *candidates do THRESHOLD = math.min(THRESHOLD, strdist(c, action_name, cache))
                candidates = [c for c in *candidates when strdist(c, action_name, cache) <= THRESHOLD]
                --candidates = ["#{c}[#{strdist(c,action_name,cache)}/#{THRESHOLD}]" for c in *candidates]
                if #candidates == 1
                    error_message ..= "\n\x1b[3mSuggestion: Maybe you meant '#{candidates[1]}'? "
                elseif #candidates > 0
                    last = table.remove(candidates)
                    error_message ..= "\n"..C('italic', "Suggestion: Maybe you meant '#{table.concat candidates, "', '"}'#{#candidates > 1 and ',' or ''} or '#{last}'? ")

        level = 2
        while true
            -- TODO: reduce duplicate code
            calling_fn = debug_getinfo(level)
            if not calling_fn then break
            level += 1
            local filename, file, line_num
            if map = SOURCE_MAP and SOURCE_MAP[calling_fn.source]
                if calling_fn.currentline
                    line_num = assert(map[calling_fn.currentline])
                filename,start,stop = calling_fn.source\match('@([^[]*)%[([0-9]+):([0-9]+)]')
                if not filename
                    filename,start = calling_fn.source\match('@([^[]*)%[([0-9]+)]')
                assert(filename)
                file = Files.read(filename)
            else
                filename = calling_fn.short_src
                file = Files.read(filename)
                if calling_fn.short_src\match("%.moon$") and type(MOON_SOURCE_MAP[file]) == 'table'
                    char = MOON_SOURCE_MAP[file][calling_fn.currentline]
                    line_num = file\line_number_at(char)
                else
                    line_num = calling_fn.currentline

            if file and filename and line_num
                start = 1
                lines = file\lines!
                for i=1,line_num-1 do start += #lines[i] + 1
                stop = start + #lines[line_num]
                start += #lines[line_num]\match("^ *")
                error_message = pretty_error{
                    title:"Error"
                    error:error_message, source:file
                    start:start, stop:stop, filename:filename
                }
                break
            if calling_fn.func == xpcall then break


    ret = {
        C('bold red', error_message or "Error")
        "stack traceback:"
    }

    level = 2
    while true
        -- TODO: reduce duplicate code
        calling_fn = debug_getinfo(level)
        if not calling_fn then break
        if calling_fn.func == xpcall then break
        level += 1
        name = calling_fn.name and "function '#{calling_fn.name}'" or nil
        if calling_fn.linedefined == 0 then name = "main chunk"
        if name == "function 'run_lua_fn'" then continue
        line = nil
        if map = SOURCE_MAP and SOURCE_MAP[calling_fn.source]
            if calling_fn.currentline
                calling_fn.currentline = assert(map[calling_fn.currentline])
            if calling_fn.linedefined
                calling_fn.linedefined = assert(map[calling_fn.linedefined])
            if calling_fn.lastlinedefined
                calling_fn.lastlinedefined = assert(map[calling_fn.lastlinedefined])
            --calling_fn.short_src = calling_fn.source\match('"([^[]*)')
            filename,start,stop = calling_fn.source\match('@([^[]*)%[([0-9]+):([0-9]+)]')
            if not filename
                filename,start = calling_fn.source\match('@([^[]*)%[([0-9]+)]')
            assert(filename)
            name = if calling_fn.name
                "action '#{calling_fn.name\from_lua_id!}'"
            else "main chunk"

            file = Files.read(filename)
            lines = file and file\lines! or {}
            if err_line = lines[calling_fn.currentline]
                offending_statement = C('bright red', err_line\match("^[ ]*(.*)"))
                line = C('yellow', "#{filename}:#{calling_fn.currentline} in #{name}\n        #{offending_statement}")
            else
                line = C('yellow', "#{filename}:#{calling_fn.currentline} in #{name}")
        else
            local line_num
            if name == nil
                search_level = level
                _info = debug.getinfo(search_level)
                while true
                    search_level += 1
                    _info = debug.getinfo(search_level)
                    break unless _info
                    for i=1,999
                        varname, val = debug.getlocal(search_level, i)
                        if not varname then break
                        if val == calling_fn.func
                            name = "local '#{varname}'"
                            if not varname\match("%(")
                                break
                    unless name
                        for i=1,_info.nups
                            varname, val = debug.getupvalue(_info.func, i)
                            if not varname then break
                            if val == calling_fn.func
                                name = "upvalue '#{varname}'"
                                if not varname\match("%(")
                                    break

            local file, lines
            if file = Files.read(calling_fn.short_src)
                lines = file\lines!
            
            if file and (calling_fn.short_src\match("%.moon$") or file\match("^#![^\n]*moon\n")) and type(MOON_SOURCE_MAP[file]) == 'table'
                char = MOON_SOURCE_MAP[file][calling_fn.currentline]
                line_num = file\line_number_at(char)
                line = C('cyan', "#{calling_fn.short_src}:#{line_num} in #{name or '?'}")
            else
                line_num = calling_fn.currentline
                if calling_fn.short_src == '[C]'
                    line = C('green', "#{calling_fn.short_src} in #{name or '?'}")
                else
                    line = C('blue', "#{calling_fn.short_src}:#{calling_fn.currentline} in #{name or '?'}")

            if file
                if err_line = lines[line_num]
                    offending_statement = C('bright red', "#{err_line\match("^[ ]*(.*)$")}")
                    line ..= "\n        "..offending_statement
        table.insert ret, line
        if calling_fn.istailcall
            table.insert ret, C('dim', "      (...tail calls...)")

    return table.concat(ret, "\n")

guard = (fn)->
    ok, err = xpcall(fn, enhance_error)
    if not ok
        io.stderr\write err
        io.stderr\flush!
        os.exit 1

return {:guard, :enhance_error, :print_error}