Project P027

CSV viewer

Paste a CSV of up to 30 rows and 8 columns - quoted fields with commas and doubled quotes included - and see it as a table; sort it by a column (numbers or text, stable, empty fields last), filter it by a condition, and get a column's statistics, with numbers kept exact.

3 modules · 2 recorded sessionstext-menu UI in the terminalupdated 2026-10-06

Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.

About

Paste a CSV and see it as a table. Sort it by a column, filter it, and get a column's statistics. Quoted fields work: a comma inside quotes is part of the field, and a doubled quote inside quotes is one quote character.

  • main.eml - the menu, loading, the questions and their checks, and what the screen shows
  • table.eml - reading a CSV line, column kinds, sorting, filtering, statistics and drawing the table
  • figures.eml - numbers read exactly, and how they are shown

How each part works:

  • A line is read one character at a time, with one piece of state: whether the reader is inside quotes (the state machine of the corpus case manual-csv-parser). A quote left open at the end of a line is an error.
  • The first line is the header. A row with a different number of fields, or with a quote never closed, is skipped with its row number and the reason; the rest are loaded. Each row keeps its number from the CSV, shown in the # column, so it can be found again after sorting.
  • A column holds numbers when every field that is not empty is a number (such as 42, -3.25 or 0.5, at most 4 decimals). Numbers are kept as whole numbers of ten-thousandths, so sums and comparisons are exact; the mean is rounded once, to 2 decimals, halves away from zero. Other columns are text, compared without regard to case.
  • Sorting is an insertion sort, so rows that compare equal keep their order; empty fields go last, whichever way the sort runs.
  • A filter on a number column takes =, !=, <, <=, > or >= and a number; on a text column it keeps the rows whose field contains the text, in any case. Empty fields never pass.
  • Statistics: for numbers, the count, the empty fields, the smallest, the largest, the sum and the mean; for text, the count, the empty fields, how many different values, the most common (all of them when several tie), and the shortest and longest (the first row wins a tie).
  • The table aligns numbers right and text left; a field longer than 20 characters is cut to 17 and ....

What is checked: a CSV is a header of 1 to 8 columns, each with a name of its own (in any case), and 1 to 30 rows that fit it; an empty line ends it, and the 31st line ends it by itself. A column is chosen by its number or its name; an order is a or d. An empty answer cancels.

Sessions: sessions/basic.in loads five people with a quoted name holding a comma, a quoted name holding doubled quotes, an empty age and an empty score, shows the table, sorts by age descending (the empty age last), filters score >= 80 and city containing tai, gives the statistics of score (mean 85.19), city (by number) and age (by name in capitals), and sorts by city - the two Taipei rows keep their order; sessions/bad-input.in uses the menu before anything is loaded, loads nothing, a header with the same name twice, rows that all fail, a mix of good rows, short rows and an unclosed quote with a note longer than 20 characters, asks for column 9 and a column that does not exist, order x, a filter that matches nothing, the statistics of an empty column, 31 lines, conditions big and > x, and != 0 over 30 rows.

Built on the verified corpus case manual-csv-parser (a CSV line split by a state machine that knows whether it is inside quotes, with doubled quotes).

Recorded sessions

What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.

bad-input

interpreter: byte-equal

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 7
Pick a number from 1 to 6.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> x
Pick a number from 1 to 6.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 2
No table yet; load a CSV first (1).

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 3
No table yet; load a CSV first (1).

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 4
No table yet; load a CSV first (1).

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 5
No table yet; load a CSV first (1).

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 1
Type or paste the CSV: the header first, then one row per line (at most 30 rows); an empty line ends it.
> 
Nothing was loaded.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 1
Type or paste the CSV: the header first, then one row per line (at most 30 rows); an empty line ends it.
> id,name,ID
> 1,a,2
> 
The header needs 1 to 8 columns, each with a name of its own; nothing was loaded.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 1
Type or paste the CSV: the header first, then one row per line (at most 30 rows); an empty line ends it.
> a,b
> 1,2,3
> "open,4
> 
Row 1 was skipped: it has 3 fields, the header has 2.
Row 2 was skipped: a quote is never closed.
The CSV has no rows that fit the header; nothing was loaded.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 1
Type or paste the CSV: the header first, then one row per line (at most 30 rows); an empty line ends it.
> code,note,blank
> A1,a very long note that goes on and on,
> B2,"short, quoted",
> C3
> D4,"never closed,
> E5,plain,
> 
Row 3 was skipped: it has 1 field, the header has 3.
Row 4 was skipped: a quote is never closed.
Loaded 3 rows and 3 columns: code, note and blank.
#  code  note                  blank
-  ----  --------------------  -----
1  A1    a very long note ...
2  B2    short, quoted
5  E5    plain

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 3
column (number or name)> 9
Type a column number from 1 to 3 or a column name.
column (number or name)> nosuch
Type a column number from 1 to 3 or a column name.
column (number or name)> 
Cancelled.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 3
column (number or name)> note
order (a = ascending, d = descending)> x
Type a or d.
order (a = ascending, d = descending)> 
Cancelled.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 3
column (number or name)> note
order (a = ascending, d = descending)> a
Sorted by note, ascending (text, any case; empty fields last).
#  code  note                  blank
-  ----  --------------------  -----
1  A1    a very long note ...
5  E5    plain
2  B2    short, quoted

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 4
column (number or name)> code
text to look for (any case)> B2
1 of 3 rows has 'B2' in code:
#  code  note           blank
-  ----  -------------  -----
2  B2    short, quoted

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 4
column (number or name)> note
text to look for (any case)> zzz
No row has 'zzz' in note.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 5
column (number or name)> blank
blank: every field is empty.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 5
column (number or name)> 
Cancelled.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 1
Type or paste the CSV: the header first, then one row per line (at most 30 rows); an empty line ends it.
> n,item,qty
> 1,item 1,7
> 2,item 2,4
> 3,item 3,1
> 4,item 4,8
> 5,item 5,5
> 6,item 6,2
> 7,item 7,9
> 8,item 8,6
> 9,item 9,3
> 10,item 10,0
> 11,item 11,7
> 12,item 12,4
> 13,item 13,1
> 14,item 14,8
> 15,item 15,5
> 16,item 16,2
> 17,item 17,9
> 18,item 18,6
> 19,item 19,3
> 20,item 20,0
> 21,item 21,7
> 22,item 22,4
> 23,item 23,1
> 24,item 24,8
> 25,item 25,5
> 26,item 26,2
> 27,item 27,9
> 28,item 28,6
> 29,item 29,3
> 30,item 30,0
That is 30 rows, the most a table can have.
Loaded 30 rows and 3 columns: n (numbers), item and qty (numbers).
 #   n  item     qty
--  --  -------  ---
 1   1  item 1     7
 2   2  item 2     4
 3   3  item 3     1
 4   4  item 4     8
 5   5  item 5     5
 6   6  item 6     2
 7   7  item 7     9
 8   8  item 8     6
 9   9  item 9     3
10  10  item 10    0
11  11  item 11    7
12  12  item 12    4
13  13  item 13    1
14  14  item 14    8
15  15  item 15    5
16  16  item 16    2
17  17  item 17    9
18  18  item 18    6
19  19  item 19    3
20  20  item 20    0
21  21  item 21    7
22  22  item 22    4
23  23  item 23    1
24  24  item 24    8
25  25  item 25    5
26  26  item 26    2
27  27  item 27    9
28  28  item 28    6
29  29  item 29    3
30  30  item 30    0

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 4
column (number or name)> qty
condition (such as > 30, = 42 or != 0)> big
Type =, !=, <, <=, > or >= and a number, such as >= 30.
condition (such as > 30, = 42 or != 0)> > x
Type =, !=, <, <=, > or >= and a number, such as >= 30.
condition (such as > 30, = 42 or != 0)> 
Cancelled.

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 4
column (number or name)> qty
condition (such as > 30, = 42 or != 0)> != 0
27 of 30 rows have qty != 0:
 #   n  item     qty
--  --  -------  ---
 1   1  item 1     7
 2   2  item 2     4
 3   3  item 3     1
 4   4  item 4     8
 5   5  item 5     5
 6   6  item 6     2
 7   7  item 7     9
 8   8  item 8     6
 9   9  item 9     3
11  11  item 11    7
12  12  item 12    4
13  13  item 13    1
14  14  item 14    8
15  15  item 15    5
16  16  item 16    2
17  17  item 17    9
18  18  item 18    6
19  19  item 19    3
21  21  item 21    7
22  22  item 22    4
23  23  item 23    1
24  24  item 24    8
25  25  item 25    5
26  26  item 26    2
27  27  item 27    9
28  28  item 28    6
29  29  item 29    3

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 5
column (number or name)> qty
qty: numbers - 30 values, 0 empty
  smallest 0, largest 9, sum 135, mean 4.50

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 6
Bye.
What was typed (89 lines)
7
x
2
3
4
5
1

1
id,name,ID
1,a,2

1
a,b
1,2,3
"open,4

1
code,note,blank
A1,a very long note that goes on and on,
B2,"short, quoted",
C3
D4,"never closed,
E5,plain,

3
9
nosuch

3
note
x

3
note
a
4
code
B2
4
note
zzz
5
blank
5

1
n,item,qty
1,item 1,7
2,item 2,4
3,item 3,1
4,item 4,8
5,item 5,5
6,item 6,2
7,item 7,9
8,item 8,6
9,item 9,3
10,item 10,0
11,item 11,7
12,item 12,4
13,item 13,1
14,item 14,8
15,item 15,5
16,item 16,2
17,item 17,9
18,item 18,6
19,item 19,3
20,item 20,0
21,item 21,7
22,item 22,4
23,item 23,1
24,item 24,8
25,item 25,5
26,item 26,2
27,item 27,9
28,item 28,6
29,item 29,3
30,item 30,0
4
qty
big
> x

4
qty
!= 0
5
qty
6

basic

interpreter: byte-equal

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 1
Type or paste the CSV: the header first, then one row per line (at most 30 rows); an empty line ends it.
> name,age,city,score
> "Smith, John",42,Taipei,88.5
> Ada,36,London,91
> "Chen ""CJ"" Jie",29,Taipei,77.25
> Bo,,Kaohsiung,84
> Eve,51,Rome,
> 
Loaded 5 rows and 4 columns: name, age (numbers), city and score (numbers).
#  name           age  city       score
-  -------------  ---  ---------  -----
1  Smith, John     42  Taipei      88.5
2  Ada             36  London        91
3  Chen "CJ" Jie   29  Taipei     77.25
4  Bo                  Kaohsiung     84
5  Eve             51  Rome

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 2
#  name           age  city       score
-  -------------  ---  ---------  -----
1  Smith, John     42  Taipei      88.5
2  Ada             36  London        91
3  Chen "CJ" Jie   29  Taipei     77.25
4  Bo                  Kaohsiung     84
5  Eve             51  Rome

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 3
column (number or name)> age
order (a = ascending, d = descending)> d
Sorted by age, descending (numbers; empty fields last).
#  name           age  city       score
-  -------------  ---  ---------  -----
5  Eve             51  Rome
1  Smith, John     42  Taipei      88.5
2  Ada             36  London        91
3  Chen "CJ" Jie   29  Taipei     77.25
4  Bo                  Kaohsiung     84

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 4
column (number or name)> score
condition (such as > 30, = 42 or != 0)> >= 80
3 of 5 rows have score >= 80:
#  name         age  city       score
-  -----------  ---  ---------  -----
1  Smith, John   42  Taipei      88.5
2  Ada           36  London        91
4  Bo                Kaohsiung     84

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 4
column (number or name)> city
text to look for (any case)> tai
2 of 5 rows have 'tai' in city:
#  name           age  city    score
-  -------------  ---  ------  -----
1  Smith, John     42  Taipei   88.5
3  Chen "CJ" Jie   29  Taipei  77.25

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 5
column (number or name)> score
score: numbers - 4 values, 1 empty
  smallest 77.25, largest 91.00, sum 340.75, mean 85.19

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 5
column (number or name)> 3
city: text - 5 values, 0 empty, 4 different
  most common: Taipei (2 times)
  shortest: Rome (4 characters), longest: Kaohsiung (9 characters)

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 5
column (number or name)> AGE
age: numbers - 4 values, 1 empty
  smallest 29, largest 51, sum 158, mean 39.50

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 3
column (number or name)> city
order (a = ascending, d = descending)> a
Sorted by city, ascending (text, any case; empty fields last).
#  name           age  city       score
-  -------------  ---  ---------  -----
4  Bo                  Kaohsiung     84
2  Ada             36  London        91
5  Eve             51  Rome
1  Smith, John     42  Taipei      88.5
3  Chen "CJ" Jie   29  Taipei     77.25

== CSV viewer ==
1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit
choice> 6
Bye.
What was typed (28 lines)
1
name,age,city,score
"Smith, John",42,Taipei,88.5
Ada,36,London,91
"Chen ""CJ"" Jie",29,Taipei,77.25
Bo,,Kaohsiung,84
Eve,51,Rome,

2
3
age
d
4
score
>= 80
4
city
tai
5
score
5
3
5
AGE
3
city
a
6

Modules

The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.

main.eml(entry)

eml
# P027 CSV viewer: paste a CSV - quoted fields included - and see it as a
# table, sort it by a column, filter it, and get a column's statistics.
import table
import figures

30 => most_rows
8 => most_columns

def trim(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def all_digits(s):
    if s == "" or len(s) > 3:
        return False
    for c in s:
        if not (c in "0123456789"):
            return False
    return True

def listed(items):
    "" => out
    0 => i
    while i < len(items):
        if i > 0 and i == len(items) - 1:
            out + " and " => out
        elif i > 0:
            out + ", " => out
        out + items[i] => out
        i + 1 => i
    return out

def plural(n, word):
    if n == 1:
        return "1 " + word
    return str(n) + " " + word + "s"

def load():
    # [header, rows, kinds] from typed lines, or [] when nothing was loaded.
    ("Type or paste the CSV: the header first, then one row per line (at most " + str(most_rows) + " rows); an empty line ends it.") ^0
    [] => lines
    True => reading
    while reading and len(lines) < most_rows + 1:
        input("> ") => line
        if trim(line) == "":
            False => reading
        else:
            lines + [line] => lines
    if reading:
        ("That is " + str(most_rows) + " rows, the most a table can have.") ^0
    if len(lines) == 0:
        "Nothing was loaded." ^0
        return []
    table.parse_line(lines[0]) => h
    [] => header
    True => good
    if not h[0] or len(h[1]) > most_columns:
        False => good
    else:
        for name in h[1]:
            trim(name) => name
            for other in header:
                if table.lower(other) == table.lower(name):
                    False => good
            if name == "":
                False => good
            header + [name] => header
    if not good:
        ("The header needs 1 to " + str(most_columns) + " columns, each with a name of its own; nothing was loaded.") ^0
        return []
    [] => rows
    for i in [1:len(lines) - 1]:
        table.parse_line(lines[i]) => p
        if not p[0]:
            ("Row " + str(i) + " was skipped: " + p[1] + ".") ^0
        elif len(p[1]) != len(header):
            ("Row " + str(i) + " was skipped: it has " + plural(len(p[1]), "field") + ", the header has " + str(len(header)) + ".") ^0
        else:
            rows + [[i, p[1]]] => rows
    if len(rows) == 0:
        "The CSV has no rows that fit the header; nothing was loaded." ^0
        return []
    [] => kinds
    [] => names
    for k in [0:len(header) - 1]:
        table.kind_of(rows, k) => kind
        kinds + [kind] => kinds
        if kind[0] == "number":
            names + [header[k] + " (numbers)"] => names
        else:
            names + [header[k]] => names
    ("Loaded " + plural(len(rows), "row") + " and " + plural(len(header), "column") + ": " + listed(names) + ".") ^0
    return [header, rows, kinds]

def ask_column(header):
    # A column's index, or -1 when the answer is empty, which cancels.
    while True:
        trim(input("column (number or name)> ")) => answer
        if answer == "":
            return 0 - 1
        if all_digits(answer) and int(answer) >= 1 and int(answer) <= len(header):
            return int(answer) - 1
        for k in [0:len(header) - 1]:
            if table.lower(header[k]) == table.lower(answer):
                return k
        ("Type a column number from 1 to " + str(len(header)) + " or a column name.") ^0

def show(data, rows):
    for line in table.draw(data[0], rows, data[2]):
        line ^0

def sort(data):
    # Returns the data with its rows in the new order, or unchanged.
    ask_column(data[0]) => k
    if k == 0 - 1:
        "Cancelled." ^0
        return data
    "" => order
    while order == "":
        table.lower(trim(input("order (a = ascending, d = descending)> "))) => answer
        if answer == "":
            "Cancelled." ^0
            return data
        if answer == "a" or answer == "d":
            answer => order
        else:
            "Type a or d." ^0
    data[2][k] => kind
    table.sorted_rows(data[1], k, kind[0], order == "d") => rows
    "ascending" => way
    if order == "d":
        "descending" => way
    "text, any case" => how
    if kind[0] == "number":
        "numbers" => how
    ("Sorted by " + data[0][k] + ", " + way + " (" + how + "; empty fields last).") ^0
    [data[0], rows, data[2]] => sorted_data
    show(sorted_data, rows)
    return sorted_data

def filter_rows(data):
    ask_column(data[0]) => k
    if k == 0 - 1:
        "Cancelled." ^0
        return
    data[2][k] => kind
    "" => op
    "" => target
    "" => wording
    if kind[0] == "number":
        while op == "":
            trim(input("condition (such as > 30, = 42 or != 0)> ")) => answer
            if answer == "":
                "Cancelled." ^0
                return
            table.condition(answer) => c
            if c[0]:
                c[1] => op
                c[2] => target
                (data[0][k] + " " + op + " " + c[3]) => wording
            else:
                "Type =, !=, <, <=, > or >= and a number, such as >= 30." ^0
    else:
        while op == "":
            trim(input("text to look for (any case)> ")) => answer
            if answer == "":
                "Cancelled." ^0
                return
            "has" => op
            table.lower(answer) => target
            ("'" + answer + "' in " + data[0][k]) => wording
    [] => kept
    for r in data[1]:
        if table.keeps(r[1][k], kind[0], op, target):
            kept + [r] => kept
    if len(kept) == 0:
        ("No row has " + wording + ".") ^0
    else:
        "have" => verb
        if len(kept) == 1:
            "has" => verb
        (str(len(kept)) + " of " + plural(len(data[1]), "row") + " " + verb + " " + wording + ":") ^0
        show(data, kept)

def stats(data):
    ask_column(data[0]) => k
    if k == 0 - 1:
        "Cancelled." ^0
        return
    data[0][k] => name
    data[2][k] => kind
    if kind[0] == "number":
        table.number_stats(data[1], k) => s
        (name + ": numbers - " + plural(s[0], "value") + ", " + str(s[1]) + " empty") ^0
        ("  smallest " + figures.shown(s[2], kind[1]) + ", largest " + figures.shown(s[4], kind[1]) + ", sum " + figures.shown(s[6], kind[1]) + ", mean " + figures.mean_text(s[6], s[0])) ^0
        return
    table.text_stats(data[1], k) => s
    if s[0] == 0:
        (name + ": every field is empty.") ^0
        return
    (name + ": text - " + plural(s[0], "value") + ", " + str(s[1]) + " empty, " + str(len(s[2])) + " different") ^0
    0 => most
    for c in s[3]:
        if c > most:
            c => most
    if most == 1:
        "  every value appears once" ^0
    else:
        [] => top
        for i in [0:len(s[2]) - 1]:
            if s[3][i] == most:
                top + [s[2][i]] => top
        if len(top) == 1:
            ("  most common: " + top[0] + " (" + str(most) + " times)") ^0
        else:
            ("  most common: " + listed(top) + " (" + str(most) + " times each)") ^0
    ("  shortest: " + s[4] + " (" + plural(len(s[4]), "character") + "), longest: " + s[5] + " (" + plural(len(s[5]), "character") + ")") ^0

[] => data
True => running
while running:
    "" ^0
    "== CSV viewer ==" ^0
    "1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit" ^0
    trim(input("choice> ")) => choice
    if choice == "1":
        load() => loaded
        if len(loaded) > 0:
            loaded => data
            show(data, data[1])
    elif choice == "6":
        False => running
    elif choice == "2" or choice == "3" or choice == "4" or choice == "5":
        if len(data) == 0:
            "No table yet; load a CSV first (1)." ^0
        elif choice == "2":
            show(data, data[1])
        elif choice == "3":
            sort(data) => data
        elif choice == "4":
            filter_rows(data)
        else:
            stats(data)
    else:
        "Pick a number from 1 to 6." ^0
"Bye." ^0
Python projection (main.py)
import table
import figures
most_rows = 30
most_columns = 8

def trim(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def all_digits(s):
    if s == "" or len(s) > 3:
        return False
    for c in s:
        if not c in "0123456789":
            return False
    return True

def listed(items):
    out = ""
    i = 0
    while i < len(items):
        if i > 0 and i == len(items) - 1:
            out = out + " and "
        elif i > 0:
            out = out + ", "
        out = out + items[i]
        i = i + 1
    return out

def plural(n, word):
    if n == 1:
        return "1 " + word
    return str(n) + " " + word + "s"

def load():
    print("Type or paste the CSV: the header first, then one row per line (at most " + str(most_rows) + " rows); an empty line ends it.")
    lines = []
    reading = True
    while reading and len(lines) < most_rows + 1:
        line = input("> ")
        if trim(line) == "":
            reading = False
        else:
            lines = lines + [line]
    if reading:
        print("That is " + str(most_rows) + " rows, the most a table can have.")
    if len(lines) == 0:
        print("Nothing was loaded.")
        return []
    h = table.parse_line(lines[0])
    header = []
    good = True
    if not h[0] or len(h[1]) > most_columns:
        good = False
    else:
        for name in h[1]:
            name = trim(name)
            for other in header:
                if table.lower(other) == table.lower(name):
                    good = False
            if name == "":
                good = False
            header = header + [name]
    if not good:
        print("The header needs 1 to " + str(most_columns) + " columns, each with a name of its own; nothing was loaded.")
        return []
    rows = []
    for i in range(1, len(lines)):
        p = table.parse_line(lines[i])
        if not p[0]:
            print("Row " + str(i) + " was skipped: " + p[1] + ".")
        elif len(p[1]) != len(header):
            print("Row " + str(i) + " was skipped: it has " + plural(len(p[1]), "field") + ", the header has " + str(len(header)) + ".")
        else:
            rows = rows + [[i, p[1]]]
    if len(rows) == 0:
        print("The CSV has no rows that fit the header; nothing was loaded.")
        return []
    kinds = []
    names = []
    for k in range(0, len(header)):
        kind = table.kind_of(rows, k)
        kinds = kinds + [kind]
        if kind[0] == "number":
            names = names + [header[k] + " (numbers)"]
        else:
            names = names + [header[k]]
    print("Loaded " + plural(len(rows), "row") + " and " + plural(len(header), "column") + ": " + listed(names) + ".")
    return [header, rows, kinds]

def ask_column(header):
    while True:
        answer = trim(input("column (number or name)> "))
        if answer == "":
            return 0 - 1
        if all_digits(answer) and int(answer) >= 1 and int(answer) <= len(header):
            return int(answer) - 1
        for k in range(0, len(header)):
            if table.lower(header[k]) == table.lower(answer):
                return k
        print("Type a column number from 1 to " + str(len(header)) + " or a column name.")

def show(data, rows):
    for line in table.draw(data[0], rows, data[2]):
        print(line)

def sort(data):
    k = ask_column(data[0])
    if k == 0 - 1:
        print("Cancelled.")
        return data
    order = ""
    while order == "":
        answer = table.lower(trim(input("order (a = ascending, d = descending)> ")))
        if answer == "":
            print("Cancelled.")
            return data
        if answer == "a" or answer == "d":
            order = answer
        else:
            print("Type a or d.")
    kind = data[2][k]
    rows = table.sorted_rows(data[1], k, kind[0], order == "d")
    way = "ascending"
    if order == "d":
        way = "descending"
    how = "text, any case"
    if kind[0] == "number":
        how = "numbers"
    print("Sorted by " + data[0][k] + ", " + way + " (" + how + "; empty fields last).")
    sorted_data = [data[0], rows, data[2]]
    show(sorted_data, rows)
    return sorted_data

def filter_rows(data):
    k = ask_column(data[0])
    if k == 0 - 1:
        print("Cancelled.")
        return
    kind = data[2][k]
    op = ""
    target = ""
    wording = ""
    if kind[0] == "number":
        while op == "":
            answer = trim(input("condition (such as > 30, = 42 or != 0)> "))
            if answer == "":
                print("Cancelled.")
                return
            c = table.condition(answer)
            if c[0]:
                op = c[1]
                target = c[2]
                wording = data[0][k] + " " + op + " " + c[3]
            else:
                print("Type =, !=, <, <=, > or >= and a number, such as >= 30.")
    else:
        while op == "":
            answer = trim(input("text to look for (any case)> "))
            if answer == "":
                print("Cancelled.")
                return
            op = "has"
            target = table.lower(answer)
            wording = "'" + answer + "' in " + data[0][k]
    kept = []
    for r in data[1]:
        if table.keeps(r[1][k], kind[0], op, target):
            kept = kept + [r]
    if len(kept) == 0:
        print("No row has " + wording + ".")
    else:
        verb = "have"
        if len(kept) == 1:
            verb = "has"
        print(str(len(kept)) + " of " + plural(len(data[1]), "row") + " " + verb + " " + wording + ":")
        show(data, kept)

def stats(data):
    k = ask_column(data[0])
    if k == 0 - 1:
        print("Cancelled.")
        return
    name = data[0][k]
    kind = data[2][k]
    if kind[0] == "number":
        s = table.number_stats(data[1], k)
        print(name + ": numbers - " + plural(s[0], "value") + ", " + str(s[1]) + " empty")
        print("  smallest " + figures.shown(s[2], kind[1]) + ", largest " + figures.shown(s[4], kind[1]) + ", sum " + figures.shown(s[6], kind[1]) + ", mean " + figures.mean_text(s[6], s[0]))
        return
    s = table.text_stats(data[1], k)
    if s[0] == 0:
        print(name + ": every field is empty.")
        return
    print(name + ": text - " + plural(s[0], "value") + ", " + str(s[1]) + " empty, " + str(len(s[2])) + " different")
    most = 0
    for c in s[3]:
        if c > most:
            most = c
    if most == 1:
        print("  every value appears once")
    else:
        top = []
        for i in range(0, len(s[2])):
            if s[3][i] == most:
                top = top + [s[2][i]]
        if len(top) == 1:
            print("  most common: " + top[0] + " (" + str(most) + " times)")
        else:
            print("  most common: " + listed(top) + " (" + str(most) + " times each)")
    print("  shortest: " + s[4] + " (" + plural(len(s[4]), "character") + "), longest: " + s[5] + " (" + plural(len(s[5]), "character") + ")")

data = []
running = True
while running:
    print("")
    print("== CSV viewer ==")
    print("1) load CSV  2) show table  3) sort  4) filter  5) column statistics  6) quit")
    choice = trim(input("choice> "))
    if choice == "1":
        loaded = load()
        if len(loaded) > 0:
            data = loaded
            show(data, data[1])
    elif choice == "6":
        running = False
    elif choice == "2" or choice == "3" or choice == "4" or choice == "5":
        if len(data) == 0:
            print("No table yet; load a CSV first (1).")
        elif choice == "2":
            show(data, data[1])
        elif choice == "3":
            data = sort(data)
        elif choice == "4":
            filter_rows(data)
        else:
            stats(data)
    else:
        print("Pick a number from 1 to 6.")
print("Bye.")

table.eml

eml
# P027 CSV viewer - reading CSV rows and what the table can do: column
# kinds, sorting, filtering, statistics and drawing. A row is
# [row number, fields]; the row number is its place in the CSV as loaded.
import figures

def parse_line(line):
    # [True, fields] or [False, reason]. A field in double quotes may hold
    # commas, and "" inside quotes is one quote character - the state
    # machine of the corpus case manual-csv-parser.
    [] => fields
    "" => current
    False => quoted
    0 => i
    while i < len(line):
        line[i] => c
        if c == "\"":
            if quoted and i + 1 < len(line) and line[i + 1] == "\"":
                current + "\"" => current
                i + 1 => i
            else:
                not quoted => quoted
        elif c == "," and not quoted:
            fields + [current] => fields
            "" => current
        else:
            current + c => current
        i + 1 => i
    if quoted:
        return [False, "a quote is never closed"]
    fields + [current] => fields
    return [True, fields]

def lower(s):
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" => upper
    "abcdefghijklmnopqrstuvwxyz" => small
    "" => out
    for c in s:
        0 => k
        while k < 26 and upper[k] != c:
            k + 1 => k
        if k < 26:
            out + small[k] => out
        else:
            out + c => out
    return out

def contains(s, part):
    # True when part occurs in s.
    0 => i
    while i + len(part) <= len(s):
        if s[i:i + len(part)] == part:
            return True
        i + 1 => i
    return False

def kind_of(rows, k):
    # ["number", decimals] when every non-empty field of column k is a number
    # and at least one is; ["text", 0] otherwise.
    0 => places
    0 => seen
    for r in rows:
        r[1][k] => f
        if f != "":
            figures.value_of(f) => v
            if not v[0]:
                return ["text", 0]
            if v[2] > places:
                v[2] => places
            seen + 1 => seen
    if seen == 0:
        return ["text", 0]
    return ["number", places]

def goes_before(a, b, k, kind, descending):
    # True when row a belongs strictly before row b. Empty fields always go
    # last; equal fields keep their order.
    a[1][k] => x
    b[1][k] => y
    if x == "" or y == "":
        return x != "" and y == ""
    if kind == "number":
        figures.value_of(x)[1] => vx
        figures.value_of(y)[1] => vy
    else:
        lower(x) => vx
        lower(y) => vy
    if descending:
        return vx > vy
    return vx < vy

def sorted_rows(rows, k, kind, descending):
    # Insertion sort: each row goes in after every row it does not belong
    # before, so the sort is stable.
    [] => out
    for r in rows:
        len(out) => j
        while j > 0 and goes_before(r, out[j - 1], k, kind, descending):
            j - 1 => j
        out[0:j] + [r] + out[j:len(out)] => out
    return out

def condition(s):
    # [True, operator, ten-thousandths, the number as typed] for "> 30",
    # "<=2.5", "!= 0" ...; [False, "", 0, ""] otherwise.
    0 => i
    while i < len(s) and s[i] == " ":
        i + 1 => i
    s[i:len(s)] => s
    ""  => op
    for o in [">=", "<=", "!=", ">", "<", "="]:
        if op == "" and s[0:len(o)] == o:
            o => op
    if op == "":
        return [False, "", 0, ""]
    s[len(op):len(s)] => rest
    0 => j
    while j < len(rest) and rest[j] == " ":
        j + 1 => j
    len(rest) => e
    while e > j and rest[e - 1] == " ":
        e - 1 => e
    figures.value_of(rest[j:e]) => v
    if not v[0]:
        return [False, "", 0, ""]
    return [True, op, v[1], rest[j:e]]

def keeps(field, kind, op, target):
    # Whether a field passes the filter. Empty fields never pass.
    if field == "":
        return False
    if kind == "text":
        return contains(lower(field), target)
    figures.value_of(field)[1] => v
    if op == "=":
        return v == target
    if op == "!=":
        return v != target
    if op == "<":
        return v < target
    if op == "<=":
        return v <= target
    if op == ">":
        return v > target
    return v >= target

def number_stats(rows, k):
    # [count, empty, smallest, its text, largest, its text, total]; the
    # first row wins a tie for smallest or largest.
    0 => count
    0 => empty
    0 => total
    0 => low
    "" => low_text
    0 => high
    "" => high_text
    for r in rows:
        r[1][k] => f
        if f == "":
            empty + 1 => empty
        else:
            figures.value_of(f)[1] => v
            if count == 0 or v < low:
                v => low
                f => low_text
            if count == 0 or v > high:
                v => high
                f => high_text
            total + v => total
            count + 1 => count
    return [count, empty, low, low_text, high, high_text, total]

def text_stats(rows, k):
    # [count, empty, distinct values in first-seen order, their counts,
    # shortest, longest]; the first row wins a tie for shortest or longest.
    0 => count
    0 => empty
    [] => seen
    {} => times
    "" => shortest
    "" => longest
    for r in rows:
        r[1][k] => f
        if f == "":
            empty + 1 => empty
        else:
            if f in times:
                times[f] + 1 => times[f]
            else:
                1 => times[f]
                seen + [f] => seen
            if count == 0 or len(f) < len(shortest):
                f => shortest
            if count == 0 or len(f) > len(longest):
                f => longest
            count + 1 => count
    [] => counts
    for v in seen:
        counts + [times[v]] => counts
    return [count, empty, seen, counts, shortest, longest]

def cell(s):
    # A field as shown: at most 20 characters, longer ones cut with "...".
    if len(s) > 20:
        return s[0:17] + "..."
    return s

def pad(s, width, right):
    while len(s) < width:
        if right:
            " " + s => s
        else:
            s + " " => s
    return s

def without_trailing(s):
    len(s) => e
    while e > 0 and s[e - 1] == " ":
        e - 1 => e
    return s[0:e]

def draw(header, rows, kinds):
    # The rows as lines of text: a # column with each row's number, then
    # the columns, numbers aligned right and text left.
    1 => num_width
    for r in rows:
        if len(str(r[0])) > num_width:
            len(str(r[0])) => num_width
    [] => widths
    for k in [0:len(header) - 1]:
        len(header[k]) => w
        for r in rows:
            if len(cell(r[1][k])) > w:
                len(cell(r[1][k])) => w
        widths + [w] => widths
    pad("#", num_width, True) => top
    "-" * num_width => rule
    for k in [0:len(header) - 1]:
        top + "  " + pad(header[k], widths[k], kinds[k][0] == "number") => top
        rule + "  " + "-" * widths[k] => rule
    [without_trailing(top), rule] => lines
    for r in rows:
        pad(str(r[0]), num_width, True) => line
        for k in [0:len(header) - 1]:
            line + "  " + pad(cell(r[1][k]), widths[k], kinds[k][0] == "number") => line
        lines + [without_trailing(line)] => lines
    return lines
Python projection (table.py)
import figures

def parse_line(line):
    fields = []
    current = ""
    quoted = False
    i = 0
    while i < len(line):
        c = line[i]
        if c == "\"":
            if quoted and i + 1 < len(line) and line[i + 1] == "\"":
                current = current + "\""
                i = i + 1
            else:
                quoted = not quoted
        elif c == "," and not quoted:
            fields = fields + [current]
            current = ""
        else:
            current = current + c
        i = i + 1
    if quoted:
        return [False, "a quote is never closed"]
    fields = fields + [current]
    return [True, fields]

def lower(s):
    upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    small = "abcdefghijklmnopqrstuvwxyz"
    out = ""
    for c in s:
        k = 0
        while k < 26 and upper[k] != c:
            k = k + 1
        if k < 26:
            out = out + small[k]
        else:
            out = out + c
    return out

def contains(s, part):
    i = 0
    while i + len(part) <= len(s):
        if s[i:i + len(part)] == part:
            return True
        i = i + 1
    return False

def kind_of(rows, k):
    places = 0
    seen = 0
    for r in rows:
        f = r[1][k]
        if f != "":
            v = figures.value_of(f)
            if not v[0]:
                return ["text", 0]
            if v[2] > places:
                places = v[2]
            seen = seen + 1
    if seen == 0:
        return ["text", 0]
    return ["number", places]

def goes_before(a, b, k, kind, descending):
    x = a[1][k]
    y = b[1][k]
    if x == "" or y == "":
        return x != "" and y == ""
    if kind == "number":
        vx = figures.value_of(x)[1]
        vy = figures.value_of(y)[1]
    else:
        vx = lower(x)
        vy = lower(y)
    if descending:
        return vx > vy
    return vx < vy

def sorted_rows(rows, k, kind, descending):
    out = []
    for r in rows:
        j = len(out)
        while j > 0 and goes_before(r, out[j - 1], k, kind, descending):
            j = j - 1
        out = out[0:j] + [r] + out[j:len(out)]
    return out

def condition(s):
    i = 0
    while i < len(s) and s[i] == " ":
        i = i + 1
    s = s[i:len(s)]
    op = ""
    for o in [">=", "<=", "!=", ">", "<", "="]:
        if op == "" and s[0:len(o)] == o:
            op = o
    if op == "":
        return [False, "", 0, ""]
    rest = s[len(op):len(s)]
    j = 0
    while j < len(rest) and rest[j] == " ":
        j = j + 1
    e = len(rest)
    while e > j and rest[e - 1] == " ":
        e = e - 1
    v = figures.value_of(rest[j:e])
    if not v[0]:
        return [False, "", 0, ""]
    return [True, op, v[1], rest[j:e]]

def keeps(field, kind, op, target):
    if field == "":
        return False
    if kind == "text":
        return contains(lower(field), target)
    v = figures.value_of(field)[1]
    if op == "=":
        return v == target
    if op == "!=":
        return v != target
    if op == "<":
        return v < target
    if op == "<=":
        return v <= target
    if op == ">":
        return v > target
    return v >= target

def number_stats(rows, k):
    count = 0
    empty = 0
    total = 0
    low = 0
    low_text = ""
    high = 0
    high_text = ""
    for r in rows:
        f = r[1][k]
        if f == "":
            empty = empty + 1
        else:
            v = figures.value_of(f)[1]
            if count == 0 or v < low:
                low = v
                low_text = f
            if count == 0 or v > high:
                high = v
                high_text = f
            total = total + v
            count = count + 1
    return [count, empty, low, low_text, high, high_text, total]

def text_stats(rows, k):
    count = 0
    empty = 0
    seen = []
    times = {}
    shortest = ""
    longest = ""
    for r in rows:
        f = r[1][k]
        if f == "":
            empty = empty + 1
        else:
            if f in times:
                times[f] = times[f] + 1
            else:
                times[f] = 1
                seen = seen + [f]
            if count == 0 or len(f) < len(shortest):
                shortest = f
            if count == 0 or len(f) > len(longest):
                longest = f
            count = count + 1
    counts = []
    for v in seen:
        counts = counts + [times[v]]
    return [count, empty, seen, counts, shortest, longest]

def cell(s):
    if len(s) > 20:
        return s[0:17] + "..."
    return s

def pad(s, width, right):
    while len(s) < width:
        if right:
            s = " " + s
        else:
            s = s + " "
    return s

def without_trailing(s):
    e = len(s)
    while e > 0 and s[e - 1] == " ":
        e = e - 1
    return s[0:e]

def draw(header, rows, kinds):
    num_width = 1
    for r in rows:
        if len(str(r[0])) > num_width:
            num_width = len(str(r[0]))
    widths = []
    for k in range(0, len(header)):
        w = len(header[k])
        for r in rows:
            if len(cell(r[1][k])) > w:
                w = len(cell(r[1][k]))
        widths = widths + [w]
    top = pad("#", num_width, True)
    rule = "-" * num_width
    for k in range(0, len(header)):
        top = top + "  " + pad(header[k], widths[k], kinds[k][0] == "number")
        rule = rule + "  " + "-" * widths[k]
    lines = [without_trailing(top), rule]
    for r in rows:
        line = pad(str(r[0]), num_width, True)
        for k in range(0, len(header)):
            line = line + "  " + pad(cell(r[1][k]), widths[k], kinds[k][0] == "number")
        lines = lines + [without_trailing(line)]
    return lines

figures.eml

eml
# P027 CSV viewer - numbers in a column, read exactly. A number such as
# -12.5 is kept as a whole number of ten-thousandths (-125000), so sums and
# comparisons are exact, and a mean is rounded once, when it is shown.

"0123456789" => digits
10000 => scale

def quotient(a, b):
    # a divided by b, rounded down, for a >= 0 and b > 0.
    return int((a - a % b) / b)

def rounded(a, b):
    # a / b rounded to a whole number, halves away from zero; b > 0.
    if a < 0:
        return 0 - quotient(0 - 2 * a + b, 2 * b)
    return quotient(2 * a + b, 2 * b)

def value_of(s):
    # [True, ten-thousandths, decimals] for "42", "-3.25" or "0.5" (at most
    # 4 decimals and 12 digits before the point); [False, 0, 0] otherwise.
    1 => sign
    0 => i
    if len(s) > 0 and s[0] == "-":
        0 - 1 => sign
        1 => i
    0 => whole
    i => start
    while i < len(s) and s[i] in digits:
        whole * 10 + int(s[i]) => whole
        i + 1 => i
    if i == start or i - start > 12:
        return [False, 0, 0]
    0 => frac
    0 => places
    if i < len(s):
        if s[i] != ".":
            return [False, 0, 0]
        i + 1 => i
        while i < len(s) and s[i] in digits:
            frac * 10 + int(s[i]) => frac
            places + 1 => places
            i + 1 => i
        if i < len(s) or places == 0 or places > 4:
            return [False, 0, 0]
    places => p
    while p < 4:
        frac * 10 => frac
        p + 1 => p
    return [True, sign * (whole * scale + frac), places]

def with_places(n, places):
    # A whole number of units of 10^-places as a decimal: 1250, 2 -> "12.50".
    "" => sign
    n => a
    if a < 0:
        "-" => sign
        0 - a => a
    1 => one
    0 => p
    while p < places:
        one * 10 => one
        p + 1 => p
    str(quotient(a, one)) => out
    if places > 0:
        str(a % one) => f
        while len(f) < places:
            "0" + f => f
        out + "." + f => out
    if a == 0:
        return out
    return sign + out

def shown(v, places):
    # Ten-thousandths shown with `places` decimals (0 to 4); the values a
    # column holds always fit its own number of places exactly.
    1 => unit
    places => p
    while p < 4:
        unit * 10 => unit
        p + 1 => p
    return with_places(rounded(v, unit), places)

def mean_text(total, count):
    # The mean of values adding up to `total` ten-thousandths, to 2 decimals,
    # rounded once, halves away from zero.
    return with_places(rounded(total, 100 * count), 2)
Python projection (figures.py)
digits = "0123456789"
scale = 10000

def quotient(a, b):
    return int((a - a % b) / b)

def rounded(a, b):
    if a < 0:
        return 0 - quotient(0 - 2 * a + b, 2 * b)
    return quotient(2 * a + b, 2 * b)

def value_of(s):
    sign = 1
    i = 0
    if len(s) > 0 and s[0] == "-":
        sign = 0 - 1
        i = 1
    whole = 0
    start = i
    while i < len(s) and s[i] in digits:
        whole = whole * 10 + int(s[i])
        i = i + 1
    if i == start or i - start > 12:
        return [False, 0, 0]
    frac = 0
    places = 0
    if i < len(s):
        if s[i] != ".":
            return [False, 0, 0]
        i = i + 1
        while i < len(s) and s[i] in digits:
            frac = frac * 10 + int(s[i])
            places = places + 1
            i = i + 1
        if i < len(s) or places == 0 or places > 4:
            return [False, 0, 0]
    p = places
    while p < 4:
        frac = frac * 10
        p = p + 1
    return [True, sign * (whole * scale + frac), places]

def with_places(n, places):
    sign = ""
    a = n
    if a < 0:
        sign = "-"
        a = 0 - a
    one = 1
    p = 0
    while p < places:
        one = one * 10
        p = p + 1
    out = str(quotient(a, one))
    if places > 0:
        f = str(a % one)
        while len(f) < places:
            f = "0" + f
        out = out + "." + f
    if a == 0:
        return out
    return sign + out

def shown(v, places):
    unit = 1
    p = places
    while p < 4:
        unit = unit * 10
        p = p + 1
    return with_places(rounded(v, unit), places)

def mean_text(total, count):
    return with_places(rounded(total, 100 * count), 2)

Built on these corpus cases

ΣEML 2026

Efficient New Language — a high-density semantic overlay for humans and AI agents.

This site’s demos run on the real EML toolchain, in your browser.

© 2026 EveMissLab (一言諾科技有限公司) / Neo.K. All rights reserved.