Use a Wadler style pretty printer in the Lua REPL (#334)

- Add a cc.pretty module, which provides a Wadler style pretty printer [1].

 - The cc.pretty.pretty function converts an arbitrary object into a
   pretty-printed document. This can then be printed to the screen with
   cc.pretty.{write, print} or converted to a string with cc.pretty.render.

 - Convert the Lua REPL to use the pretty printer rather than
   textutils.serialise.

[1]: http://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf
This commit is contained in:
Jonathan Coates 2020-01-17 22:51:36 +00:00 committed by GitHub
parent c79f643ba7
commit 798868427e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 630 additions and 13 deletions

View File

@ -0,0 +1,416 @@
--- Provides a "pretty printer", for rendering data structures in an
-- aesthetically pleasing manner.
--
-- In order to display something using @{cc.pretty}, you build up a series of
-- @{documents|Doc}. These behave a little bit like strings; you can concatenate
-- them together and then print them to the screen.
--
-- However, documents also allow you to control how they should be printed. There
-- are several functions (such as @{nest} and @{group}) which allow you to control
-- the "layout" of the document. When you come to display the document, the 'best'
-- (most compact) layout is used.
--
-- @module cc.pretty
-- @usage Print a table to the terminal
-- local pretty = require "cc.pretty"
-- pretty.write(pretty.dump({ 1, 2, 3 }))
--
-- @usage Build a custom document and display it
-- local pretty = require "cc.pretty"
-- pretty.write(pretty.group(pretty.text("hello") .. pretty.space_line .. pretty.text("world")))
local expect = require "cc.expect".expect
local type, getmetatable, setmetatable, colours, str_write = type, getmetatable, setmetatable, colours, write
--- @{table.insert} alternative, but with the length stored inline.
local function append(out, value)
local n = out.n + 1
out[n], out.n = value, n
end
--- A document, which
--
-- Documents effectively represent a sequence of strings in alternative layouts,
-- which we will try to print in the most compact form necessary.
--
-- @type Doc
local Doc = { }
--- An empty document.
local empty = setmetatable({ tag = "nil" }, Doc)
--- A document with a single space in it.
local space = setmetatable({ tag = "text", text = " " }, Doc)
--- A line break. When collapsed with @{group}, this will be replaced with @{empty}.
local line = setmetatable({ tag = "line", flat = empty }, Doc)
--- A line break. When collapsed with @{group}, this will be replaced with @{space}.
local space_line = setmetatable({ tag = "line", flat = space }, Doc)
local text_cache = { [""] = empty, [" "] = space, ["\n"] = space_line }
local function mk_text(text, colour)
return text_cache[text] or setmetatable({ tag = "text", text = text, colour = colour }, Doc)
end
--- Create a new document from a string.
--
-- If your string contains multiple lines, @{group} will flatten the string
-- into a single line, with spaces between each line.
--
-- @tparam string text The string to construct a new document with.
-- @tparam[opt] number colour The colour this text should be printed with. If not given, we default to the current
-- colour.
-- @treturn Doc The document with the provided text.
local function text(text, colour)
expect(1, text, "string")
expect(2, colour, "number", "nil")
local cached = text_cache[text]
if cached then return cached end
local new_line = text:find("\n", 1)
if not new_line then return mk_text(text, colour) end
-- Split the string by "\n". With a micro-optimisation to skip empty strings.
local doc = setmetatable({ tag = "concat", n = 0 }, Doc)
if new_line ~= 1 then append(doc, mk_text(text:sub(1, new_line - 1), colour)) end
new_line = new_line + 1
while true do
local next_line = text:find("\n", new_line)
append(doc, space_line)
if not next_line then
if new_line <= #text then append(doc, mk_text(text:sub(new_line), colour)) end
return doc
else
if new_line <= next_line - 1 then
append(doc, mk_text(text:sub(new_line, next_line - 1), colour))
end
new_line = next_line + 1
end
end
end
--- Concatenate several documents together. This behaves very similar to string concatenation.
-- @tparam Doc|string ... The documents to concatenate.
-- @treturn Doc The concatenated documents.
-- @usage pretty.concat(doc1, " - ", doc2)
-- @usage doc1 .. " - " .. doc2
local function concat(...)
local args = table.pack(...)
for i = 1, args.n do
if type(args[i]) == "string" then args[i] = text(args[i]) end
if getmetatable(args[i]) ~= Doc then expect(i, args[i], "document") end
end
if args.n == 0 then return empty end
if args.n == 1 then return args[1] end
args.tag = "concat"
return setmetatable(args, Doc)
end
Doc.__concat = concat
--- Indent later lines of the given document with the given number of spaces.
--
-- For instance, nesting the document
-- ```txt
-- foo
-- bar
-- ``
-- by two spaces will produce
-- ```txt
-- foo
-- bar
-- ```
--
-- @tparam number depth The number of spaces with which the document should be indented.
-- @tparam Doc doc The document to indent.
-- @treturn Doc The nested document.
-- @usage pretty.nest(2, pretty.text("foo\nbar"))
local function nest(depth, doc)
expect(1, depth, "number")
if getmetatable(doc) ~= Doc then expect(2, doc, "document") end
if depth <= 0 then error("depth must be a positive number", 2) end
return setmetatable({ tag = "nest", depth = depth, doc }, Doc)
end
local function flatten(doc)
if doc.flat then return doc.flat end
local kind = doc.tag
if kind == "nil" or kind == "text" then
return doc
elseif kind == "concat" then
local out = setmetatable({ tag = "concat", n = doc.n }, Doc)
for i = 1, doc.n do out[i] = flatten(doc[i]) end
doc.flat, out.flat = out, out -- cache the flattened node
return out
elseif kind == "nest" then
return flatten(doc[1])
elseif kind == "group" then
return doc[1]
else
error("Unknown doc " .. kind)
end
end
--- Builds a document which is displayed on a single line if there is enough
-- room, or as normal if not.
--
-- @tparam Doc doc The document to group.
-- @treturn Doc The grouped document.
local function group(doc)
if getmetatable(doc) ~= Doc then expect(1, doc, "document") end
if doc.tag == "group" then return doc end -- Skip if already grouped.
local flattened = flatten(doc)
if flattened == doc then return doc end -- Also skip if flattening does nothing.
return setmetatable({ tag = "group", flattened, doc }, Doc)
end
local function get_remaining(doc, width)
local kind = doc.tag
if kind == "nil" or kind == "line" then
return width
elseif kind == "text" then
return width - #doc.text
elseif kind == "concat" then
for i = 1, doc.n do
width = get_remaining(doc[i], width)
if width < 0 then break end
end
return width
elseif kind == "group" or kind == "nest" then
return get_remaining(kind[1])
else
error("Unknown doc " .. kind)
end
end
--- Display a document on the terminal.
--
-- @tparam Doc doc The document to render
-- @tparam[opt] number ribbon_frac The maximum fraction of the width that we should write in.
local function write(doc, ribbon_frac)
if getmetatable(doc) ~= Doc then expect(1, doc, "document") end
expect(2, ribbon_frac, "number", "nil")
local term = term
local width, height = term.getSize()
local ribbon_width = (ribbon_frac or 0.6) * width
if ribbon_width < 0 then ribbon_width = 0 end
if ribbon_width > width then ribbon_width = width end
local def_colour = term.getTextColour()
local current_colour = def_colour
local function go(doc, indent, col)
local kind = doc.tag
if kind == "nil" then
return col
elseif kind == "text" then
local doc_colour = doc.colour or def_colour
if doc_colour ~= current_colour then
term.setTextColour(doc_colour)
current_colour = doc_colour
end
str_write(doc.text)
return col + #doc.text
elseif kind == "line" then
local _, y = term.getCursorPos()
if y < height then
term.setCursorPos(indent + 1, y + 1)
else
term.scroll(1)
term.setCursorPos(indent + 1, height)
end
return indent
elseif kind == "concat" then
for i = 1, doc.n do col = go(doc[i], indent, col) end
return col
elseif kind == "nest" then
return go(doc[1], indent + doc.depth, col)
elseif kind == "group" then
if get_remaining(doc[1], math.min(width, ribbon_width + indent) - col) >= 0 then
return go(doc[1], indent, col)
else
return go(doc[2], indent, col)
end
else
error("Unknown doc " .. kind)
end
end
local col = math.max(term.getCursorPos() - 1, 0)
go(doc, 0, col)
if current_colour ~= def_colour then term.setTextColour(def_colour) end
end
--- Display a document on the terminal with a trailing new line.
--
-- @tparam Doc doc The document to render.
-- @tparam[opt] number ribbon_frac The maximum fraction of the width that we should write in.
local function print(doc, ribbon_frac)
if getmetatable(doc) ~= Doc then expect(1, doc, "document") end
expect(2, ribbon_frac, "number", "nil")
write(doc, ribbon_frac)
str_write("\n")
end
--- Render a document, converting it into a string.
--
-- @tparam Doc doc The document to render.
-- @tparam[opt] number width The maximum width of this document. Note that long strings will not be wrapped to
-- fit this width - it is only used for finding the best layout.
-- @tparam[opt] number ribbon_frac The maximum fraction of the width that we should write in.
local function render(doc, width, ribbon_frac)
if getmetatable(doc) ~= Doc then expect(1, doc, "document") end
expect(2, width, "number", "nil")
expect(3, ribbon_frac, "number", "nil")
local ribbon_width
if width then
ribbon_width = (ribbon_frac or 0.6) * width
if ribbon_width < 0 then ribbon_width = 0 end
if ribbon_width > width then ribbon_width = width end
end
local out = { n = 0 }
local function go(doc, indent, col)
local kind = doc.tag
if kind == "nil" then
return col
elseif kind == "text" then
append(out, doc.text)
return col + #doc.text
elseif kind == "line" then
append(out, "\n" .. (" "):rep(indent))
return indent
elseif kind == "concat" then
for i = 1, doc.n do col = go(doc[i], indent, col) end
return col
elseif kind == "nest" then
return go(doc[1], indent + doc.depth, col)
elseif kind == "group" then
if not width or get_remaining(doc[1], math.min(width, ribbon_width + indent) - col) >= 0 then
return go(doc[1], indent, col)
else
return go(doc[2], indent, col)
end
else
error("Unknown doc " .. kind)
end
end
go(doc, 0, 0)
return table.concat(out, "", 1, out.n)
end
local keywords = {
[ "and" ] = true, [ "break" ] = true, [ "do" ] = true, [ "else" ] = true,
[ "elseif" ] = true, [ "end" ] = true, [ "false" ] = true, [ "for" ] = true,
[ "function" ] = true, [ "if" ] = true, [ "in" ] = true, [ "local" ] = true,
[ "nil" ] = true, [ "not" ] = true, [ "or" ] = true, [ "repeat" ] = true, [ "return" ] = true,
[ "then" ] = true, [ "true" ] = true, [ "until" ] = true, [ "while" ] = true,
}
local comma = text(",")
local braces = text("{}")
local obrace, cbrace = text("{"), text("}")
local obracket, cbracket = text("["), text("] = ")
local function key_compare(a, b)
local ta, tb = type(a), type(b)
if ta == "string" then return tb ~= "string" or a < b
elseif tb == "string" then return false
end
if ta == "number" then return tb ~= "number" or a < b end
return false
end
local function pretty_impl(obj, tracking)
local obj_type = type(obj)
if obj_type == "string" then
local formatted = ("%q"):format(obj):gsub("\\\n", "\\n")
return text(formatted, colours.red)
elseif obj_type == "number" then
return text(tostring(obj), colours.magenta)
elseif obj_type ~= "table" or tracking[obj] then
return text(tostring(obj), colours.lightGrey)
elseif getmetatable(obj) ~= nil and getmetatable(obj).__tostring then
return text(tostring(obj))
elseif next(obj) == nil then
return braces
else
tracking[obj] = true
local doc = setmetatable({ tag = "concat", n = 1, space_line }, Doc)
local length, keys, keysn = #obj, {}, 1
for k in pairs(obj) do keys[keysn], keysn = k, keysn + 1 end
table.sort(keys, key_compare)
for i = 1, keysn - 1 do
if i > 1 then append(doc, comma) append(doc, space_line) end
local k = keys[i]
local v = obj[k]
local ty = type(k)
if ty == "number" and k % 1 == 0 and k >= 1 and k <= length then
append(doc, pretty_impl(v, tracking))
elseif ty == "string" and not keywords[k] and k:match("^[%a_][%a%d_]*$") then
append(doc, text(k .. " = "))
append(doc, pretty_impl(v, tracking))
else
append(doc, obracket)
append(doc, pretty_impl(k, tracking))
append(doc, cbracket)
append(doc, pretty_impl(v, tracking))
end
end
tracking[obj] = nil
return group(concat(obrace, nest(2, concat(table.unpack(doc, 1, n))), space_line, cbrace))
end
end
--- Pretty-print an arbitrary object, converting it into a document.
--
-- This can then be rendered with @{write} or @{print}.
--
-- @param obj The object to pretty-print.
-- @treturn Doc The object formatted as a document.
-- @usage Display a table on the screen
-- local pretty = require "cc.pretty"
-- pretty.print(pretty.pretty({ 1, 2, 3 })
local function pretty(obj)
return pretty_impl(obj, {})
end
return {
empty = empty,
space = space,
line = line,
space_line = space_line,
text = text,
concat = concat,
nest = nest,
group = group,
write = write,
print = print,
render = render,
pretty = pretty,
}

View File

@ -6,6 +6,8 @@ if #tArgs > 0 then
return
end
local pretty = require "cc.pretty"
local bRunning = true
local tCommandHistory = {}
local tEnv = {
@ -87,20 +89,11 @@ while bRunning do
local n = 1
while n < tResults.n or n <= nForcePrint do
local value = tResults[ n + 1 ]
if type( value ) == "table" then
local metatable = getmetatable( value )
if type(metatable) == "table" and type(metatable.__tostring) == "function" then
print( tostring( value ) )
else
local ok, serialised = pcall( textutils.serialise, value )
if ok then
print( serialised )
else
print( tostring( value ) )
end
end
local ok, serialised = pcall(pretty.pretty, value)
if ok then
pretty.print(serialised)
else
print( tostring( value ) )
print(tostring(value))
end
n = n + 1
end

View File

@ -0,0 +1,208 @@
local with_window = require "test_helpers".with_window
describe("cc.pretty", function()
local pp = require("cc.pretty")
describe("text", function()
it("is constant for the empty string", function()
expect(pp.text("")):eq(pp.empty)
end)
it("is constant for a space", function()
expect(pp.text(" ")):eq(pp.space)
end)
it("is constant for a newline", function()
expect(pp.text("\n")):eq(pp.space_line)
end)
it("validates arguments", function()
expect.error(pp.text, 123):eq("bad argument #1 (expected string, got number)")
expect.error(pp.text, "", ""):eq("bad argument #2 (expected number, got string)")
end)
it("produces text documents", function()
expect(pp.text("a")):same({ tag = "text", text = "a" })
expect(pp.text("a", colours.grey)):same({ tag = "text", text = "a", colour = colours.grey })
end)
it("splits lines", function()
expect(pp.text("a\nb"))
:same(pp.concat(pp.text("a"), pp.space_line, pp.text("b")))
expect(pp.text("ab\ncd\nef"))
:same(pp.concat(pp.text("ab"), pp.space_line, pp.text("cd"), pp.space_line, pp.text("ef")))
end)
it("preserves empty lines", function()
expect(pp.text("a\n\nb"))
:same(pp.concat(pp.text("a"), pp.space_line, pp.space_line, pp.text("b")))
expect(pp.text("\n\nb"))
:same(pp.concat(pp.space_line, pp.space_line, pp.text("b")))
expect(pp.text("a\n\n"))
:same(pp.concat(pp.text("a"), pp.space_line, pp.space_line))
end)
end)
describe("concat", function()
it("returns empty with 0 arguments", function()
expect(pp.concat()):eq(pp.empty)
end)
it("acts as the identity with 1 argument", function()
local x = pp.text("test")
expect(pp.concat(x)):eq(x)
end)
it("coerces strings", function()
expect(pp.concat("a", "b")):same(pp.concat(pp.text("a"), pp.text("b")))
end)
it("validates arguments", function()
expect.error(pp.concat, 123):eq("bad argument #1 (expected document, got number)")
expect.error(pp.concat, "", {}):eq("bad argument #2 (expected document, got table)")
end)
it("can be used as an operator", function()
local a, b = pp.text("a"), pp.text("b")
expect(pp.concat(a, b)):same(a .. b)
end)
end)
describe("group", function()
it("is idempotent", function()
local x = pp.group(pp.text("a\nb"))
expect(pp.group(x)):eq(x)
end)
it("does nothing for flat strings", function()
local x = pp.text("a")
expect(pp.group(x)):eq(x)
end)
end)
-- Allows us to test
local function test_output(display)
it("displays the empty document", function()
expect(display(pp.empty)):same { "" }
end)
it("displays a multiline string", function()
expect(display(pp.text("hello\nworld"))):same {
"hello",
"world",
}
end)
it("displays a nested string", function()
expect(display(pp.nest(2, pp.concat("hello", pp.line, "world")))):same {
"hello",
" world",
}
end)
it("displays a flattened group", function()
expect(display(pp.group(pp.concat("hello", pp.space_line, "world")))):same {
"hello world",
}
expect(display(pp.group(pp.concat("hello", pp.line, "world")))):same {
"helloworld",
}
end)
it("displays an expanded group", function()
expect(display(pp.group(pp.concat("hello darkness", pp.space_line, "my old friend")))):same {
"hello darkness",
"my old friend",
}
end)
it("group removes nest", function()
expect(display(pp.group(pp.nest(2, pp.concat("hello", pp.space_line, "world"))))):same {
"hello world",
}
end)
end
describe("write", function()
local function display(doc)
local w = with_window(20, 10, function() pp.write(doc) end)
local _, y = w.getCursorPos()
local out = {}
for i = 1, y do out[i] = w.getLine(i):gsub("%s+$", "") end
return out
end
test_output(display)
it("wraps a long string", function()
expect(display(pp.text("hello world this is a long string which will wrap"))):same {
"hello world this is",
"a long string which",
"will wrap",
}
end)
end)
describe("render", function()
local function display(doc)
local rendered = pp.render(doc, 20)
local n, lines = 1, {}
for line in (rendered .. "\n"):gmatch("([^\n]*)\n") do lines[n], n = line, n + 1 end
return lines
end
test_output(display)
it("does not wrap a long string", function()
expect(display(pp.text("hello world this is a long string which will wrap"))):same {
"hello world this is a long string which will wrap",
}
end)
end)
describe("pretty", function()
--- We make use of "render" here, as it's considerably easier than checking against the actual structure.
-- However, it does also mean our tests are less unit-like.
local function pretty(x, width) return pp.render(pp.pretty(x), width) end
describe("tables", function()
it("displays empty tables", function()
expect(pp.pretty({})):same(pp.text("{}"))
end)
it("displays list-like tables", function()
expect(pretty({ 1, 2, 3 })):eq("{ 1, 2, 3 }")
end)
it("displays mixed tables", function()
expect(pretty({ n = 3, 1, 2, 3 })):eq("{ n = 3, 1, 2, 3 }")
end)
it("escapes keys", function()
expect(pretty({ ["and"] = 1, ["not that"] = 2 })):eq('{ ["and"] = 1, ["not that"] = 2 }')
end)
it("sorts keys", function()
expect(pretty({ c = 1, b = 2, a = 3 })):eq('{ a = 3, b = 2, c = 1 }')
end)
it("groups tables", function()
expect(pretty({ 1, 2, 3 }, 4)):eq("{\n 1,\n 2,\n 3\n}")
end)
end)
it("shows numbers", function()
expect(pretty(123)):eq("123")
end)
it("shows strings", function()
expect(pretty("hello\nworld")):eq('"hello\\nworld"')
end)
it("shows functions", function()
expect(pretty(pretty)):eq(tostring(pretty))
end)
end)
end)