This page looks best with JavaScript enabled

Lua table errors

 ·  ☕ 9 min read

A common pattern with MediaWiki templates and modules is to print a single row of an HTML table per instance of a template. Our page source might look something like this:

{{Data start}}
{{Data row|params|go|here}}
{{Data row|params|go|here}}
{{Data row|params|go|here}}
{{Data row|params|go|here}}
{{Data end}}

In each instance of Template:Data row, we’ll have <tr></tr> with a bunch of cells in between, and our table start and end will the in Template:Data start and Template:Data end, respectively.

There’s absolutely nothing wrong with this pattern - it’s convenient, easy to work with, and easy to use. But one inconvenience is that when you encounter Lua errors, the error text itself isn’t part of the HTML structure, and so it’ll look something like this:

A caught Lua error that&rsquo;s displaying before the HTML table output

This kind of sucks for two reasons:

  1. We totally lose context of where the error is being called from. Was that on the 1st line of input? The 10th? We have no idea.
  2. Our error handling text often totally sucks. Depending where the error was called, we might have little or no context as to what was actually going on when the problem occurred. What were the user inputs, for example? Knowing that would let us ctrl+F the page source much more effectively to fix the mistake, but it’s not like we track the input args throughout our execution. It would be SUPER nice if we could report this with a customized output.

In this post I’m going to walk you through the framework I built for improving handling of errors that addresses both of these issues at the same time.

(Note: All code in this post was originally written by me for Leaguepedia and is licensed under CC BY-SA 3.0.)

High-level overview

We’re going to make a singleton class that contains a list of every caught error (note that this will NOT handle uncaught errors) as our code evaluates. When we reach the end of evaluation, we check if we’ve caught any errors. If so, we return a rendering of our list of errors instead of the default output, plus [[Category:Pages with script errors]]. Otherwise we just return the default output and all is well.

We also have a method to check whether any errors have been found so far; we can call this method to skip crucial blocks of code like Cargo stores or to return early as needed.

And because this class is a singleton, we’ll have a setIntroText() method that we can call at some point, whenever is convenient to us, which will set the intro text for our error message with whatever seems convenient to tell the user about our template.

(Currently I don’t have an appendIntroText() method, because I haven’t needed one yet, but that would be totally legit to add, in the case that there’s different pieces of intro text known at different times.)

And here’s how it’ll look:

A caught Lua errors that&rsquo;s displaying in the middle of a table in its proper location

Lua code

First, let’s look at the part that lets us set things:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
local p = require('Module:LuaClassSystem').class()

function p:init()
	i18n.init('TableErrors')
	self.introText = i18n.print('introText')
end

function p:setIntroText(text)
	self.introText = text
end

function p:report(text)
	if not self._tableErrors then self._tableErrors = {} end
	self._tableErrors[#self._tableErrors+1] = text
end

Note that if you don’t want to depend on LuaClassSystem, that’s okay; you could refactor this to something like (note, this is untested):

1
2
3
4
5
6
7
8
function p.setIntroText(text)
	_INTERNAL_TABLE_ERRORS_INTRO_TEXT = text
end

function p.report(text)
	if not _INTERNAL_TABLE_ERRORS_ERROR_LIST then _INTERNAL_TABLE_ERRORS_ERROR_LIST = {} end
	_INTERNAL_TABLE_ERRORS_ERROR_LIST[#_INTERNAL_TABLE_ERRORS_ERROR_LIST+1] = text
end

Note that these are GLOBAL variables. The downsides of this version are:

  1. If, for some reason, you wanted two separate instances of error handling in the same module (e.g. “user-input errors” and “data-integrity errors” are caught and reported separately), this is not possible
  2. The zealous need to use extremely unique variable names because they’re globals leads to harder-to-read code
  3. It’s impossible to subclass if we want custom behavior for a particular module at some point in the future

However, none of these downsides is prohibitive, and if you aren’t using LuaClassSystem for anything on your wiki yet, you may prefer not to introduce that much complexity JUST for this one piece of functionality.

Now the hasErrors() function:

1
2
3
function p:hasErrors()
	return self._tableErrors
end

Literally just check if it exists; if it does, it’s truthy, and if not it’s nil.

Arguably I should have put the initialization of self._tableErrors in the init method, and then check the length of the table here. The reason I didn’t is that I originally was going to architect this module in a very different way, where the list of errors was actually stored in a piece of data belonging to the calling module, and when I changed my mind I left it this way. Now I’m leaving it alone to keep it more similar to a potential non-LCS implementation.

Finally, our output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function p:output(len)
	local tr = mw.html.create('tr')
	local td = tr:tag('td')
		:attr('colspan', len)
	td:wikitext('[[Category:Pages with script errors]]')
	td:wikitext(self.introText)
		:addClass('lua-table-error')
	local ul = td:tag('ul')
	util_map.inPlace(self._tableErrors, h.printOneError, ul)
	return tr
end

function h.printOneError(errorText, ul)
	ul:tag('li')
		:wikitext(errorText)
end

A couple notes:

  • The presence of the raw text [[Category:Pages with script errors]] necessitates commented --<nowiki></nowiki> wrappers around the entire module, as you’ll see when I post the full code at the very end of this module
  • h.printOneError is a private method just for convenience of being able to use a map function without a slightly worse syntax, since self would have to be passed as an argument otherwise
  • len is the colspan of the cell. As brought up in my Toggleable columns post, I always know this value

Finally, the default i18n text I provided in the init at Module:TableErrors/i18n is just this:

1
2
3
4
5
return {
	['en'] = {
		introText = 'The following errors were detected:'
	},
}

It should probably never be used.

CSS

The css is pretty simple:

1
2
3
4
5
6
table.wikitable > * > tr > td.lua-table-error,
table.wikitable > tr > td.lua-table-error {
	color: red;
	font-weight: bold;
	font-size: 16px;
}

BUT WAIT!

One issue with this new setup is that it’s possible to not notice that you’ve left an error, because they no longer “float” towards the top of the page. That’s pretty unfortunate, I want to make absolutely certain that users will notice when they’ve made an error. So, how can we rectify this?

With position:sticky; of course!

So here’s the full style:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
table.wikitable > * > tr > td.lua-table-error,
table.wikitable > tr > td.lua-table-error {
	color: red;
	font-weight: bold;
	font-size: 16px;
	position: sticky;
	bottom: 0;
	background-clip: padding-box;
	background-color: var(--table-background);
}

And here’s how it looks when you’re scrolled up:

A caught Lua error when you&rsquo;re scrolled up to the top of the page

Scroll down, and the error slides back into place as shown above, but for good measure, here it is again:

A caught Lua error that&rsquo;s displaying in the middle of a table in its proper location

So cool!

Using the framework

Finally, let’s show an example of using the framework, in Module:RosterChangeData. First, at the top of the file, we’ll import the module and instantiate the class as a singleton:

1
local tableErrors = require('Module:TableErrors')()

Sometime probably early on we’ll want to set our intro text:

1
2
3
4
5
6
7
function p.line(frame)
	local args = util_args.merge()
	tableErrors:setIntroText(('Error in entry for team %s (input %s), region %s'):format(
		m_team.teamlinkname(args.team or ''),
		args.team or '',
		args.region or ''
	))

Instead of calling error when we catch an error, we call tableErrors:report(), so for example error(i18n.print('error_missingTeam')) becomes tableErrors:report(i18n.print('error_missingTeam')).

And then we store Cargo only if there are no errors, and return the appropriate output:

1
2
3
4
5
6
7
8
9
	if not tableErrors:hasErrors() then
		h.storeRosterChangeData(data)
	end
	local newsData = h.getNewsData(args, data)
	if tableErrors:hasErrors() then
		return tableErrors:output(#COLUMNS)
	end
	util_cargo.store(newsData)
	return util_news.makeSentenceOutput(args, newsData), h.makeRowOutput(data)

Here you can see my diff of migrating to using the new framework.

Downsides

There are a couple disadvantages of this framework.

  1. Error handling is now inconsistent - uncaught errors are displayed to users differently from caught errors. Of course, the answer to this is to have better error handling 🙂
  2. Because we’re not actually raising any errors, the tracebacks are lost. If you want to keep the tracebacks for certain errors, you can keep using error for those and bypass the handler.
  3. As with everything you customize, it adds another layer of complexity. This is another thing that can break, another thing you have to maintain, etc. If you don’t totally understand what’s going on, it might not be worth it.

What should be included in intro texts?

  1. Computed values that make it clear which row it’s in, contextually
  2. Raw inputs that make it easy to ctrl+F
  3. Anything your users ask for. Give them a long list of options, though, especially at first - they might not realize how many things are available!

Conclusion

With really just a couple lines of Lua and CSS, we have an easy-to-use wrapper for Lua error handling that lets us provide users with a huge improvement to the default output experience. You can set an intro text that includes a lot of values for the benefit of users debugging and fixing problematic input and then list errors with just a bit of context at the time of raising them.

And when they’re displayed, they’ll have a position:sticky; keeping them on screen at all times, but eventually fit into the table at their proper position. Yay!

Full Lua code

Here’s the entire contents of Module:TableErrors:

 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
-- <nowiki>
local util_args = require('Module:ArgsUtil')
local util_cargo = require("Module:CargoUtil")
local util_html = require("Module:HtmlUtil")
local util_map = require('Module:MapUtil')
local util_table = require("Module:TableUtil")
local util_text = require("Module:TextUtil")
local util_vars = require("Module:VarsUtil")
local i18n = require("Module:I18nUtil")
local lang = mw.getLanguage('en')
local h = {}
local p = require('Module:LuaClassSystem').class()

function p:init()
	i18n.init('TableErrors')
	self.introText = i18n.print('introText')
end

function p:setIntroText(text)
	self.introText = text
end

function p:report(text)
	if not self._tableErrors then self._tableErrors = {} end
	self._tableErrors[#self._tableErrors+1] = text
end

function p:hasErrors()
	return self._tableErrors
end

function p:output(len)
	local tr = mw.html.create('tr')
	local td = tr:tag('td')
		:attr('colspan', len)
	td:wikitext('[[Category:Pages with script errors]]')
	td:wikitext(self.introText)
		:addClass('lua-table-error')
	local ul = td:tag('ul')
	util_map.inPlace(self._tableErrors, h.printOneError, ul)
	return tr
end

function h.printOneError(errorText, ul)
	ul:tag('li')
		:wikitext(errorText)
end

return p
-- </nowiki>
Share on

river
WRITTEN BY
River
River is a developer most at home in MediaWiki and known for building Leaguepedia. She likes cats.

What's on this Page