mirror of
https://github.com/SquidDev-CC/CC-Tweaked
synced 2025-05-07 09:54:13 +00:00
Suggest alternative table keys on nil errors (#2097)
We now suggest alternative table keys when code errors with "attempt to index/call 'foo' (a nil value)". For example: "redstone.getinput()", will now suggest "Did you mean: getInput". This is a bit tricky to get right! In the above example, our code reads like: 1 GETTABUP 0 0 0 ; r0 := _ENV["redstone"] 2 GETFIELD 0 0 1 ; r0 := r0["getinput"] 3 CALL 0 1 1 ; r0() Note, that when we get to the problematic line, we don't have access to the original table that we attempted to index. In order to do this, we borrow ideas from Lua's getobjname — we effectively write an evaluator that walks back over the code and tries to reconstruct the expression that resulted in nil. For example, in the above case: - We know an instruction happened at pc=3, so we try to find the expression that computed r0. - We know this was set at pc=2, so we step back one. This is a GETFIELD instruction, so we check the key (it's a constant, so worth reporting), and then try to evaluate the table. - This version of r0 was set at pc=1, so we step back again. It's a GETTABUP instruction, so we can just evaluate that directly. We then use this information (indexing _ENV.redstone with "getinput") to find alternative keys (e.g. getInput, getOutput, etc...) and then pick some likely suggestions with Damerau-Levenshtein/OSD. I'm not entirely thrilled by the implementation here. The core interpretation logic is implemented in Java. Which is *fine*, but a) feels a little cheaty and b) means we're limited to what Lua bytecode can provide (for instance, we can't inspect outer functions, or list all available names in scope). We obviously can expand the bytecode if needed, but something we'd want to be careful with. The alternative approach would be to handle all the parsing in Lua. Unfortunately, this is quite hard to get right — I think we'd need some lazy parsing strategy to avoid constructing the whole AST, while still retaining all the scope information we need. I don't know. We really could make this as complex as we like, and I don't know what the right balance is. It'd be cool to detect patterns like the following, but is it *useful*? local monitor = peripheral.wrap("left") monitor.write("Hello") -- ^ monitor is nil. Is there a peripheral to the left of the -- computer? For now, the current approach feels the easiest, and should allow us to prototype things and see what does/doesn't work.
This commit is contained in:
parent
051c70a731
commit
b185d088b3
@ -11,6 +11,7 @@ import dan200.computercraft.api.lua.ILuaFunction;
|
||||
import dan200.computercraft.core.CoreConfig;
|
||||
import dan200.computercraft.core.Logging;
|
||||
import dan200.computercraft.core.computer.TimeoutState;
|
||||
import dan200.computercraft.core.lua.errorinfo.ErrorInfoLib;
|
||||
import dan200.computercraft.core.methods.LuaMethod;
|
||||
import dan200.computercraft.core.methods.MethodSupplier;
|
||||
import dan200.computercraft.core.util.LuaUtil;
|
||||
@ -77,6 +78,7 @@ public class CobaltLuaMachine implements ILuaMachine {
|
||||
var globals = state.globals();
|
||||
CoreLibraries.debugGlobals(state);
|
||||
Bit32Lib.add(state, globals);
|
||||
ErrorInfoLib.add(state);
|
||||
globals.rawset("_HOST", ValueFactory.valueOf(environment.hostString()));
|
||||
globals.rawset("_CC_DEFAULT_SETTINGS", ValueFactory.valueOf(CoreConfig.defaultComputerSettings));
|
||||
|
||||
|
@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: 2009-2011 Luaj.org, 2015-2020 SquidDev
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package dan200.computercraft.core.lua.errorinfo;
|
||||
|
||||
import org.squiddev.cobalt.Prototype;
|
||||
|
||||
import static org.squiddev.cobalt.Lua.*;
|
||||
|
||||
/**
|
||||
* Extracted parts of Cobalt's {@link org.squiddev.cobalt.debug.DebugHelpers}.
|
||||
*/
|
||||
final class DebugHelpers {
|
||||
private DebugHelpers() {
|
||||
}
|
||||
|
||||
private static int filterPc(int pc, int jumpTarget) {
|
||||
return pc < jumpTarget ? -1 : pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the PC where a register was last set.
|
||||
* <p>
|
||||
* This makes some assumptions about the structure of the bytecode, namely that there are no back edges within the
|
||||
* CFG. As a result, this is only valid for temporary values, and not locals.
|
||||
*
|
||||
* @param pt The function prototype.
|
||||
* @param lastPc The PC to work back from.
|
||||
* @param reg The register.
|
||||
* @return The last instruction where the register was set, or {@code -1} if not defined.
|
||||
*/
|
||||
static int findSetReg(Prototype pt, int lastPc, int reg) {
|
||||
var lastInsn = -1; // Last instruction that changed "reg";
|
||||
var jumpTarget = 0; // Any code before this address is conditional
|
||||
|
||||
for (var pc = 0; pc < lastPc; pc++) {
|
||||
var i = pt.code[pc];
|
||||
var op = GET_OPCODE(i);
|
||||
var a = GETARG_A(i);
|
||||
switch (op) {
|
||||
case OP_LOADNIL -> {
|
||||
var b = GETARG_B(i);
|
||||
if (a <= reg && reg <= a + b) lastInsn = filterPc(pc, jumpTarget);
|
||||
}
|
||||
case OP_TFORCALL -> {
|
||||
if (a >= a + 2) lastInsn = filterPc(pc, jumpTarget);
|
||||
}
|
||||
case OP_CALL, OP_TAILCALL -> {
|
||||
if (reg >= a) lastInsn = filterPc(pc, jumpTarget);
|
||||
}
|
||||
case OP_JMP -> {
|
||||
var dest = pc + 1 + GETARG_sBx(i);
|
||||
// If jump is forward and doesn't skip lastPc, update jump target
|
||||
if (pc < dest && dest <= lastPc && dest > jumpTarget) jumpTarget = dest;
|
||||
}
|
||||
default -> {
|
||||
if (testAMode(op) && reg == a) lastInsn = filterPc(pc, jumpTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lastInsn;
|
||||
}
|
||||
}
|
@ -0,0 +1,222 @@
|
||||
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.lua.errorinfo;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.squiddev.cobalt.*;
|
||||
import org.squiddev.cobalt.debug.DebugFrame;
|
||||
import org.squiddev.cobalt.function.LuaFunction;
|
||||
import org.squiddev.cobalt.function.RegisteredFunction;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.squiddev.cobalt.Lua.*;
|
||||
import static org.squiddev.cobalt.debug.DebugFrame.FLAG_ANY_HOOK;
|
||||
|
||||
/**
|
||||
* Provides additional info about an error.
|
||||
* <p>
|
||||
* This is currently an internal and deeply unstable module. It's not clear if doing this via bytecode (rather than an
|
||||
* AST) is the correct approach and/or, what the correct design is.
|
||||
*/
|
||||
public class ErrorInfoLib {
|
||||
private static final int MAX_DEPTH = 8;
|
||||
|
||||
private static final RegisteredFunction[] functions = new RegisteredFunction[]{
|
||||
RegisteredFunction.ofV("info_for_nil", ErrorInfoLib::getInfoForNil),
|
||||
};
|
||||
|
||||
public static void add(LuaState state) throws LuaError {
|
||||
state.registry().getSubTable(Constants.LOADED).rawset("cc.internal.error_info", RegisteredFunction.bind(functions));
|
||||
}
|
||||
|
||||
private static Varargs getInfoForNil(LuaState state, Varargs args) throws LuaError {
|
||||
var thread = args.arg(1).checkThread();
|
||||
var level = args.arg(2).checkInteger();
|
||||
|
||||
var context = getInfoForNil(state, thread, level);
|
||||
return context == null ? Constants.NIL : ValueFactory.varargsOf(
|
||||
ValueFactory.valueOf(context.op()), ValueFactory.valueOf(context.source().isGlobal()),
|
||||
context.source().table(), context.source().key()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get some additional information about an {@code attempt to $OP (a nil value)} error. This often occurs as a
|
||||
* result of a misspelled local, global or table index, and so we attempt to detect those cases.
|
||||
*
|
||||
* @param state The current Lua state.
|
||||
* @param thread The thread which has errored.
|
||||
* @param level The level where the error occurred. We currently expect this to always be 0.
|
||||
* @return Some additional information about the error, where available.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static @Nullable NilInfo getInfoForNil(LuaState state, LuaThread thread, int level) {
|
||||
var frame = thread.getDebugState().getFrame(level);
|
||||
if (frame == null || frame.closure == null || (frame.flags & FLAG_ANY_HOOK) != 0) return null;
|
||||
|
||||
var prototype = frame.closure.getPrototype();
|
||||
var pc = frame.pc;
|
||||
var insn = prototype.code[pc];
|
||||
|
||||
// Find what operation we're doing that errored.
|
||||
return switch (GET_OPCODE(insn)) {
|
||||
case OP_CALL, OP_TAILCALL ->
|
||||
NilInfo.of("call", resolveValueSource(state, frame, prototype, pc, GETARG_A(insn), 0));
|
||||
case OP_GETTABLE, OP_SETTABLE, OP_SELF ->
|
||||
NilInfo.of("index", resolveValueSource(state, frame, prototype, pc, GETARG_A(insn), 0));
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about an {@code attempt to $OP (a nil value)} error.
|
||||
*
|
||||
* @param op The operation we tried to perform.
|
||||
* @param source The expression that resulted in a nil value.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
record NilInfo(String op, ValueSource source) {
|
||||
public static @Nullable NilInfo of(String op, @Nullable ValueSource values) {
|
||||
return values == null ? null : new NilInfo(op, values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A partially-reconstructed Lua expression. This currently only is used for table indexing ({@code table[key]}.
|
||||
*
|
||||
* @param isGlobal Whether this is a global table access. This is a best-effort guess, and does not distinguish between
|
||||
* {@code foo} and {@code _ENV.foo}.
|
||||
* @param table The table being indexed.
|
||||
* @param key The key we tried to index.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
record ValueSource(boolean isGlobal, LuaValue table, LuaString key) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to partially reconstruct a Lua expression from the current debug state.
|
||||
*
|
||||
* @param state The current Lua state.
|
||||
* @param frame The current debug frame.
|
||||
* @param prototype The current function.
|
||||
* @param pc The current program counter.
|
||||
* @param register The register where this value was stored.
|
||||
* @param depth The current depth. Starts at 0, and aborts once reaching {@link #MAX_DEPTH}.
|
||||
* @return The reconstructed expression, or {@code null} if not available.
|
||||
*/
|
||||
@SuppressWarnings("NullTernary")
|
||||
private static @Nullable ValueSource resolveValueSource(LuaState state, DebugFrame frame, Prototype prototype, int pc, int register, int depth) {
|
||||
if (depth > MAX_DEPTH) return null;
|
||||
if (prototype.getLocalName(register + 1, pc) != null) return null;
|
||||
|
||||
// Find where this register was set. If unknown, then abort.
|
||||
pc = DebugHelpers.findSetReg(prototype, pc, register);
|
||||
if (pc == -1) return null;
|
||||
|
||||
var insn = prototype.code[pc];
|
||||
return switch (GET_OPCODE(insn)) {
|
||||
case OP_MOVE -> {
|
||||
var a = GETARG_A(insn);
|
||||
var b = GETARG_B(insn); // move from `b' to `a'
|
||||
yield b < a ? resolveValueSource(state, frame, prototype, pc, register, depth + 1) : null; // Resolve 'b' .
|
||||
}
|
||||
case OP_GETTABUP, OP_GETTABLE, OP_SELF -> {
|
||||
var tableIndex = GETARG_B(insn);
|
||||
var keyIndex = GETARG_C(insn);
|
||||
// We're only interested in expressions of the form "foo.bar". Showing a "did you mean" hint for
|
||||
// "foo[i]" isn't very useful!
|
||||
if (!ISK(keyIndex)) yield null;
|
||||
|
||||
var key = prototype.constants[INDEXK(keyIndex)];
|
||||
if (key.type() != Constants.TSTRING) yield null;
|
||||
|
||||
var table = GET_OPCODE(insn) == OP_GETTABUP
|
||||
? frame.closure.getUpvalue(tableIndex).getValue()
|
||||
: evaluate(state, frame, prototype, pc, tableIndex, depth);
|
||||
if (table == null) yield null;
|
||||
|
||||
var isGlobal = GET_OPCODE(insn) == OP_GETTABUP && Objects.equals(prototype.getUpvalueName(tableIndex), Constants.ENV);
|
||||
yield new ValueSource(isGlobal, table, (LuaString) key);
|
||||
}
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconstruct the value of a register.
|
||||
*
|
||||
* @param state The current Lua state.
|
||||
* @param frame The current debug frame.
|
||||
* @param prototype The current function
|
||||
* @param pc The PC to evaluate at.
|
||||
* @param register The register to evaluate.
|
||||
* @param depth The current depth. Starts at 0, and aborts once reaching {@link #MAX_DEPTH}.
|
||||
* @return The reconstructed value, or {@code null} if unavailable.
|
||||
*/
|
||||
@SuppressWarnings("NullTernary")
|
||||
private static @Nullable LuaValue evaluate(LuaState state, DebugFrame frame, Prototype prototype, int pc, int register, int depth) {
|
||||
if (depth >= MAX_DEPTH) return null;
|
||||
|
||||
// If this is a local, then return its contents.
|
||||
if (prototype.getLocalName(register + 1, pc) != null) return frame.stack[register];
|
||||
|
||||
// Otherwise find where this register was set. If unknown, then abort.
|
||||
pc = DebugHelpers.findSetReg(prototype, pc, register);
|
||||
if (pc == -1) return null;
|
||||
|
||||
var insn = prototype.code[pc];
|
||||
var opcode = GET_OPCODE(insn);
|
||||
return switch (opcode) {
|
||||
case OP_MOVE -> {
|
||||
var a = GETARG_A(insn);
|
||||
var b = GETARG_B(insn); // move from `b' to `a'
|
||||
yield b < a ? evaluate(state, frame, prototype, pc, register, depth + 1) : null; // Resolve 'b'.
|
||||
}
|
||||
// Load constants
|
||||
case OP_LOADK -> prototype.constants[GETARG_Bx(insn)];
|
||||
case OP_LOADKX -> prototype.constants[GETARG_Ax(prototype.code[pc + 1])];
|
||||
case OP_LOADBOOL -> GETARG_B(insn) == 0 ? Constants.FALSE : Constants.TRUE;
|
||||
case OP_LOADNIL -> Constants.NIL;
|
||||
// Upvalues and tables.
|
||||
case OP_GETUPVAL -> frame.closure.getUpvalue(GETARG_B(insn)).getValue();
|
||||
case OP_GETTABLE, OP_GETTABUP -> {
|
||||
var table = opcode == OP_GETTABUP
|
||||
? frame.closure.getUpvalue(GETARG_B(insn)).getValue()
|
||||
: evaluate(state, frame, prototype, pc, GETARG_B(insn), depth + 1);
|
||||
if (table == null) yield null;
|
||||
|
||||
var key = evaluateK(state, frame, prototype, pc, GETARG_C(insn), depth + 1);
|
||||
yield key == null ? null : safeIndex(state, table, key);
|
||||
}
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static @Nullable LuaValue evaluateK(LuaState state, DebugFrame frame, Prototype prototype, int pc, int registerOrConstant, int depth) {
|
||||
return ISK(registerOrConstant) ? prototype.constants[INDEXK(registerOrConstant)] : evaluate(state, frame, prototype, pc, registerOrConstant, depth + 1);
|
||||
}
|
||||
|
||||
private static @Nullable LuaValue safeIndex(LuaState state, LuaValue table, LuaValue key) {
|
||||
var loop = 0;
|
||||
do {
|
||||
LuaValue metatable;
|
||||
if (table instanceof LuaTable tbl) {
|
||||
var res = tbl.rawget(key);
|
||||
if (!res.isNil() || (metatable = tbl.metatag(state, CachedMetamethod.INDEX)).isNil()) return res;
|
||||
} else if ((metatable = table.metatag(state, CachedMetamethod.INDEX)).isNil()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (metatable instanceof LuaFunction) return null;
|
||||
|
||||
table = metatable;
|
||||
}
|
||||
while (++loop < Constants.MAXTAGLOOP);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,167 @@
|
||||
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
|
||||
--
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
--[[- Internal tools for diagnosing errors and suggesting fixes.
|
||||
|
||||
> [!DANGER]
|
||||
> This is an internal module and SHOULD NOT be used in your own code. It may
|
||||
> be removed or changed at any time.
|
||||
|
||||
@local
|
||||
]]
|
||||
|
||||
local debug, type, rawget = debug, type, rawget
|
||||
local sub, lower, find, min, abs = string.sub, string.lower, string.find, math.min, math.abs
|
||||
|
||||
--[[- Compute the Optimal String Distance between two strings.
|
||||
|
||||
@tparam string str_a The first string.
|
||||
@tparam string str_b The second string.
|
||||
@treturn number|nil The distance between two strings, or nil if they are two far
|
||||
apart.
|
||||
]]
|
||||
local function osa_distance(str_a, str_b, threshold)
|
||||
local len_a, len_b = #str_a, #str_b
|
||||
|
||||
-- If the two strings are too different in length, then bail now.
|
||||
if abs(len_a - len_b) > threshold then return end
|
||||
|
||||
-- Zero-initialise our distance table.
|
||||
local d = {}
|
||||
for i = 1, (len_a + 1) * (len_b + 1) do d[i] = 0 end
|
||||
|
||||
-- Then fill the first row and column
|
||||
local function idx(a, b) return a * (len_a + 1) + b + 1 end
|
||||
for i = 0, len_a do d[idx(i, 0)] = i end
|
||||
for j = 0, len_b do d[idx(0, j)] = j end
|
||||
|
||||
-- Then compute our distance
|
||||
for i = 1, len_a do
|
||||
local char_a = sub(str_a, i, i)
|
||||
for j = 1, len_b do
|
||||
local char_b = sub(str_b, j, j)
|
||||
|
||||
local sub_cost
|
||||
if char_a == char_b then
|
||||
sub_cost = 0
|
||||
elseif lower(char_a) == lower(char_b) then
|
||||
sub_cost = 0.5
|
||||
else
|
||||
sub_cost = 1
|
||||
end
|
||||
|
||||
local new_cost = min(
|
||||
d[idx(i - 1, j)] + 1, -- Deletion
|
||||
d[idx(i, j - 1)] + 1, -- Insertion,
|
||||
d[idx(i - 1, j - 1)] + sub_cost -- Substitution
|
||||
)
|
||||
|
||||
-- Transposition
|
||||
if i > 1 and j > 1 and char_a == sub(str_b, j - 1, j - 1) and char_b == sub(str_a, i - 1, i - 1) then
|
||||
local trans_cost = d[idx(i - 2, j - 2)] + 1
|
||||
if trans_cost < new_cost then new_cost = trans_cost end
|
||||
end
|
||||
|
||||
d[idx(i, j)] = new_cost
|
||||
end
|
||||
end
|
||||
|
||||
local result = d[idx(len_a, len_b)]
|
||||
if result <= threshold then return result else return nil end
|
||||
end
|
||||
|
||||
--- Check whether this suggestion is useful.
|
||||
local function useful_suggestion(str)
|
||||
local len = #str
|
||||
return len > 0 and len < 32 and find(str, "^[%a_]%w*$")
|
||||
end
|
||||
|
||||
local function get_suggestions(is_global, value, key, thread, frame_offset)
|
||||
if not useful_suggestion(key) then return end
|
||||
|
||||
-- Pick a maximum number of edits. We're more lenient on longer strings, but
|
||||
-- still only allow two mistakes.
|
||||
local threshold = #key >= 5 and 2 or 1
|
||||
|
||||
-- Find all items in the table, and see if they seem similar.
|
||||
local suggestions = {}
|
||||
local function process_suggestion(k)
|
||||
if type(k) ~= "string" or not useful_suggestion(k) then return end
|
||||
|
||||
local distance = osa_distance(k, key, threshold)
|
||||
if distance then
|
||||
if distance < threshold then
|
||||
-- If this is better than any existing match, then prefer it.
|
||||
suggestions = { k }
|
||||
threshold = distance
|
||||
else
|
||||
-- Otherwise distance==threshold, and so just add it.
|
||||
suggestions[#suggestions + 1] = k
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
while type(value) == "table" do
|
||||
for k in next, value do process_suggestion(k) end
|
||||
|
||||
local mt = debug.getmetatable(value)
|
||||
if mt == nil then break end
|
||||
value = rawget(mt, "__index")
|
||||
end
|
||||
|
||||
-- If we're attempting to lookup a global, then also suggest any locals and
|
||||
-- upvalues. Our upvalues will be incomplete, but maybe a little useful?
|
||||
if is_global then
|
||||
for i = 1, 200 do
|
||||
local name = debug.getlocal(thread, frame_offset, i)
|
||||
if not name then break end
|
||||
process_suggestion(name)
|
||||
end
|
||||
|
||||
local func = debug.getinfo(thread, frame_offset, "f").func
|
||||
for i = 1, 255 do
|
||||
local name = debug.getupvalue(func, i)
|
||||
if not name then break end
|
||||
process_suggestion(name)
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(suggestions)
|
||||
|
||||
return suggestions
|
||||
end
|
||||
|
||||
--[[- Get a tip to display at the end of an error.
|
||||
|
||||
@tparam string err The error message.
|
||||
@tparam coroutine thread The current thread.
|
||||
@tparam number frame_offset The offset into the thread where the current frame exists
|
||||
@return An optional message to append to the error.
|
||||
]]
|
||||
local function get_tip(err, thread, frame_offset)
|
||||
local nil_op = err:match("^attempt to (%l+) .* %(a nil value%)")
|
||||
if not nil_op then return end
|
||||
|
||||
local has_error_info, error_info = pcall(require, "cc.internal.error_info")
|
||||
if not has_error_info then return end
|
||||
local op, is_global, table, key = error_info.info_for_nil(thread, frame_offset)
|
||||
if op == nil or op ~= nil_op then return end
|
||||
|
||||
local suggestions = get_suggestions(is_global, table, key, thread, frame_offset)
|
||||
if not suggestions or next(suggestions) == nil then return end
|
||||
|
||||
local pretty = require "cc.pretty"
|
||||
local msg = "Did you mean: "
|
||||
|
||||
local n_suggestions = min(3, #suggestions)
|
||||
for i = 1, n_suggestions do
|
||||
if i > 1 then
|
||||
if i == n_suggestions then msg = msg .. " or " else msg = msg .. ", " end
|
||||
end
|
||||
msg = msg .. pretty.text(suggestions[i], colours.lightGrey)
|
||||
end
|
||||
return msg .. "?"
|
||||
end
|
||||
|
||||
return { get_tip = get_tip }
|
@ -21,7 +21,7 @@ local function find_frame(thread, file, line)
|
||||
if not frame then break end
|
||||
|
||||
if frame.short_src == file and frame.what ~= "C" and frame.currentline == line then
|
||||
return frame
|
||||
return offset, frame
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -191,11 +191,11 @@ local function report(err, thread, source_map)
|
||||
|
||||
if type(err) ~= "string" then return end
|
||||
|
||||
local file, line = err:match("^([^:]+):(%d+):")
|
||||
local file, line, err = err:match("^([^:]+):(%d+): (.*)")
|
||||
if not file then return end
|
||||
line = tonumber(line)
|
||||
|
||||
local frame = find_frame(thread, file, line)
|
||||
local frame_offset, frame = find_frame(thread, file, line)
|
||||
if not frame or not frame.currentcolumn then return end
|
||||
|
||||
local column = frame.currentcolumn
|
||||
@ -237,6 +237,7 @@ local function report(err, thread, source_map)
|
||||
get_line = function() return line_contents end,
|
||||
}, {
|
||||
{ tag = "annotate", start_pos = column, end_pos = column, msg = "" },
|
||||
require "cc.internal.error_hints".get_tip(err, thread, frame_offset),
|
||||
})
|
||||
end
|
||||
|
||||
|
@ -31,7 +31,7 @@ setmetatable(tEnv, { __index = _ENV })
|
||||
do
|
||||
local make_package = require "cc.require".make
|
||||
local dir = shell.dir()
|
||||
_ENV.require, _ENV.package = make_package(_ENV, dir)
|
||||
tEnv.require, tEnv.package = make_package(tEnv, dir)
|
||||
end
|
||||
|
||||
if term.isColour() then
|
||||
|
@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package dan200.computercraft.core.lua.errorinfo;
|
||||
|
||||
import org.intellij.lang.annotations.Language;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.squiddev.cobalt.*;
|
||||
import org.squiddev.cobalt.compiler.CompileException;
|
||||
import org.squiddev.cobalt.compiler.LoadState;
|
||||
import org.squiddev.cobalt.lib.CoreLibraries;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
public class ErrorInfoLibTest {
|
||||
@Test
|
||||
public void testNilInfoForUnknownLibFunction() throws LuaError, CompileException {
|
||||
var state = newState();
|
||||
var thread = captureError(state, "string.forma()");
|
||||
|
||||
assertEquals(
|
||||
new ErrorInfoLib.NilInfo(
|
||||
"call",
|
||||
new ErrorInfoLib.ValueSource(false, state.globals().rawget("string"), ValueFactory.valueOf("forma"))
|
||||
),
|
||||
ErrorInfoLib.getInfoForNil(state, thread, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNilInfoForUnknownGlobal() throws LuaError, CompileException {
|
||||
var state = newState();
|
||||
var thread = captureError(state, "pront()");
|
||||
|
||||
assertEquals(
|
||||
new ErrorInfoLib.NilInfo(
|
||||
"call",
|
||||
new ErrorInfoLib.ValueSource(true, state.globals(), ValueFactory.valueOf("pront"))
|
||||
),
|
||||
ErrorInfoLib.getInfoForNil(state, thread, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNilInfoForComplexExpression() throws LuaError, CompileException {
|
||||
var state = newState();
|
||||
var thread = captureError(state, "x = { { y = 1 } }; for i = 1, #x do x[i].z() end");
|
||||
|
||||
var inner = ((LuaTable) state.globals().rawget("x")).rawget(1);
|
||||
assertEquals(
|
||||
new ErrorInfoLib.NilInfo(
|
||||
"call",
|
||||
new ErrorInfoLib.ValueSource(false, inner, ValueFactory.valueOf("z"))
|
||||
),
|
||||
ErrorInfoLib.getInfoForNil(state, thread, 0)
|
||||
);
|
||||
}
|
||||
|
||||
private static LuaState newState() throws LuaError {
|
||||
var state = new LuaState();
|
||||
CoreLibraries.standardGlobals(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private static LuaThread captureError(LuaState state, @Language("lua") String contents) throws CompileException, LuaError {
|
||||
var fn = LoadState.load(state, new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)), "=in.lua", state.globals());
|
||||
var thread = new LuaThread(state, fn);
|
||||
Assertions.assertThrows(LuaError.class, () -> LuaThread.run(thread, Constants.NIL));
|
||||
return thread;
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
|
||||
--
|
||||
-- SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
describe("cc.internal.error_hints", function()
|
||||
local error_hints = require "cc.internal.error_hints"
|
||||
|
||||
local function get_tip_for(code)
|
||||
local fn = assert(load(code, "=input.lua"))
|
||||
local co = coroutine.create(fn)
|
||||
local ok, err = coroutine.resume(co)
|
||||
expect(ok):eq(false)
|
||||
|
||||
local _, _, err = err:match("^([^:]+):(%d+): (.*)")
|
||||
|
||||
local tip = error_hints.get_tip(err, co, 0)
|
||||
return tip and tostring(tip) or nil
|
||||
end
|
||||
|
||||
describe("gives hints for 'attempt to OP (a nil value)' errors", function()
|
||||
it("suggests alternative globals", function()
|
||||
expect(get_tip_for("pront()")):eq("Did you mean: print?")
|
||||
end)
|
||||
|
||||
it("suggests alternative locals", function()
|
||||
expect(get_tip_for("local foo; fot()")):eq("Did you mean: foo?")
|
||||
end)
|
||||
|
||||
it("suggests alternative table keys", function()
|
||||
expect(get_tip_for("redstone.getinput()")):eq("Did you mean: getInput?")
|
||||
end)
|
||||
|
||||
it("suggests multiple table keys", function()
|
||||
expect(get_tip_for("redstone.getAnaloguInput()")):eq("Did you mean: getAnalogInput or getAnalogueInput?")
|
||||
end)
|
||||
end)
|
||||
end)
|
Loading…
x
Reference in New Issue
Block a user