Tables of text for sysl, laid out so the columns line up.
A cell is a value, not a string — anything that renders itself can go in one, so a row of a name, a count and a flag is ordinary code and nothing has to be converted on the way in. What comes out is whichever shape the reader wants: a plain aligned block, a box-drawn grid in five weights, a Markdown table, tab-separated fields, or a matrix in brackets.
sh/sysl/table/
table.sysl the library
tests.sysl its tests, run by `sysl test .`
sysl.hocon who this package is, and what it needs of the machine
The module is sh.sysl.table, and the three directories are that name: a dotted module name
mirrors its path from the library root. The prefix is the reverse-DNS of sysl.sh, so that a package
claims a name nobody else will mint rather than the top-level word table.
Name it in your project's sysl.hocon and sysl build fetches it:
dependencies {
table { git = "github.com/sysl-lang/table", version = "0.1.4" }
}The coordinate is an identity rather than a URL, so it carries no https://, and version is the
tag v0.1.4 here.
Or build it into an artifact and compile against that, which needs no fetching:
sysl build-lib . -o /tmp/table.syslib
sysl run yourprogram.sysl --lib /tmp/table.syslib
import sh.sysl.table.*
main()
var t = table()
t.header(["language", "released", "typed"])
t.add(["sysl", 2026, true])
t.add(["C", 1972, true])
t.add(["Python", 1991, false])
print(t.text())
language released typed
sysl 2026 true
C 1972 true
Python 1991 false
The header is bold and underlined on a terminal; set_ansi(false) turns that off, and the output
above is what you get with it off.
text hands back a string. render(out) writes through a *Writer instead, which is the one that
costs nothing — a table going to the terminal never becomes a string at all.
A style says what the table is made of, and dividers says whether the columns are separated inside
it. Everything follows from the pair:
g.set_style(Light)
g.set_dividers(true)
g.set_header_line(true)
g.right_align(1)
g.right_align(2)
┌─────────┬─────┬───────┐
│ item │ qty │ price │
├─────────┼─────┼───────┤
│ apples │ 12 │ 1.5 │
│ oranges │ 3 │ 2.25 │
└─────────┴─────┴───────┘
| style | what it draws |
|---|---|
Plain |
columns separated by spaces, no frame — the default |
Ascii |
+---+, for a terminal that cannot be trusted with anything else |
Light |
┌─┬┐ │ ├┼┤ └┴┘ |
Heavy |
┏━┯┓ ┃ ┣┿┫ ┗┷┛ — heavy frame, light dividers |
Double |
╔═╤╗ ║ ╠╪╣ ╚╧╝ — double frame, light dividers |
Rounded |
╭─┬╮ │ ├┼┤ ╰┴╯ |
Markdown |
a Markdown table, alignments carried in the separator row |
Tabbed |
fields separated by tabs, for something else to read |
Matrix |
bracketed as a matrix, ⎡ ⎤ |
MatrixRounded |
the same in parentheses, ⎛ ⎞ |
The frame is heavy or double and the dividers inside it are not, deliberately: the frame says where the table ends and a divider says where a column does, and drawing both at one weight makes the second as loud as the first.
The last four ignore dividers, because for them the separator is not a choice a caller has.
m.set_style(Markdown)
m.right_align(1)
| column | meaning |
| ------ | ----------------: |
| width | how wide it looks |
| bytes | how much it takes |
x.set_style(Matrix)
⎡1 0 0⎤
⎢0 1 0⎥
⎣0 0 1⎦
A bracket is three characters tall whatever the matrix is, so a one-row matrix takes the middle piece
on both sides: ⎢1 2 3⎥ is a row vector, and ⎡1 2 3⎦ is nothing.
Two levels. A column has one, and a cell may override it:
t.set_column_align(0, Center) -- this column
t.right_align(1) -- the one a table is usually asked for by name
t.cell_align(2, Right) -- this cell, in the row most recently added
A header carries an alignment of its own, so a column alignment set later reaches the numbers without
moving the names over them. set_header_centered(false) lays the names out like any other row.
Columns are indexed from 0, unlike the Scala library this was ported from, which indexed from 1.
line() draws a rule above the next row added, so a rule asked for after the last row has nothing to
sit above and is dropped — which is what makes it safe to call inside a loop.
t.set_header_line(true) -- a rule between the header and the data
t.set_header_bold(true) -- ANSI, and dropped for Markdown and tabbed output
t.set_header_underlined(true)
t.set_header_centered(true)
A rule does not need a frame. A Plain table with one line under its header is one of the
commonest shapes there is, and it is what set_header_line(true) gives with no style set at all:
package version pure sysl
────────────────────────────
table 0.1.3 true
harness 0.1.1 true
The rule is exactly as wide as the row it sits under. Tabbed and Matrix are the two styles with
nowhere to put one, and there a rule is dropped.
The header's emphasis is on by default — bold and underlined, in ANSI, and dropped for Markdown
and tabbed output where a terminal is not what reads the result. set_ansi(false) turns off every
escape the table would emit.
cell_style(col, esc) puts an escape sequence around one cell's text, and sequences accumulate, so a
cell may be given a colour and a weight by two calls. underline(col) is separate from it because an
underline covers the cell's padding as well as its text, which is what makes a row of underlined
headers one continuous line rather than a line per word.
The whole of the layout rests on this. café is five bytes, four characters and four columns;
日本 is six bytes, two characters and four columns. Only the last of the three says where the
next border falls.
╭──────────┬──────────╮
│ 言語 │ city │
├──────────┼──────────┤
│ 日本語 │ 京都 │
│ Français │ Montréal │
╰──────────┴──────────╯
That number comes from sysl.text.columns, which is the East Asian Width and combining-mark data of
the Unicode Character Database. A format specifier's width would have been the obvious thing to reach
for and is the wrong one: it counts bytes, deliberately, so that it means what snprintf means. Every
cell here is rendered with a neutral specifier and padded by this library against a count it measured
itself.
render and text take self rather than *self, so a table may be rendered, added to, and
rendered again, and the second answer is the table as it then stands. Nothing is cached and no
phantom row is appended to make the arithmetic line up: the widths are measured for the rendering
that asked for them and thrown away with it.
A cell is one line. Wrapping a cell too wide for its column, spanning a cell across columns, and laying a table out to a total width are all more of the same arithmetic and none of them is here.
A row must have one cell per column, the first row settles how many, and the header must be the first row. Those are contract clauses, so a program that breaks one stops with the line that did it — they are mistakes in the calling code rather than conditions to be handled.
It was ported from edadma/table, a Scala library, and holds that
library's output byte for byte wherever the two can both express the case — the Scala tests were 445
lines of exact expected output, and they are the specification this was written against. Five
differences are deliberate:
-
Doubledraws a double border. There it was a name in an enumeration and nothing else: every branch tested for light and fell through to heavy, so a double border silently drew a heavy one. -
Roundedis a style. The four arc glyphs were declared there and never referenced, with three comments marking where they would have gone. -
A collection is a cell like any other. The stringifier there tested each value for being an array or a sequence and joined it by hand, because everything arrived as
Any. Here a slice renders itself, so the special case does not exist. -
Ten constructor flags became two axes.
border,columnDividers,markdown,tabbed,matrixandmatrixRoundedencoded one choice between them and admitted combinations that meant nothing — Markdown and matrix, tabbed with a heavy border. A style and adividersflag say the same things and no others, and the drawing code reads one glyph table instead of re-deriving the same four-way test at twelve separate sites. -
A rule with no frame is light, and stops where its row does. There the borderless rule fell through the same
if LIGHT else HEAVYchain that madeDOUBLErender heavy, so a plain table's header rule came out in heavy box-drawing — and one character wider than the row it sat under, because a trailing space was appended for a last column that an unframed row does not have.
Two defects went with them: the width was measured in UTF-16 code units, which misaligns any column holding a character outside the Basic Multilingual Plane's narrow range; and rendering mutated the table it was rendering, so a table rendered, added to, and rendered again was quietly wrong.
A sysl 0.0.18 toolchain or newer. The escapes it emits for a header's emphasis are
sysl.term's, which is where they belong and which arrived in that
release; before it, this package carried its own copy of the three it uses.
sysl test .
Forty-five cases, each asserting the exact bytes a caller gets. A table is one of the few things whose whole contract is its output — a column one space narrower than it should be is not a degraded table, it is a wrong one — so nothing here counts lines or looks for a substring.
ISC