aboutsummaryrefslogtreecommitdiff
path: root/error_handling.moon
blob: d6a08811b5ef359b689691cf0f4a643fa257dac1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
-- 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)->
                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}