114. A small Python front-end
Python has no braces: a block ends where the spaces stop. The tokenizer turns those spaces into tokens, and after that Python parses like every language you have parsed before. The trick is knowing exactly which part of Python to accept.
- Turn indentation into INDENT and DEDENT tokens with a stack of widths.
- Explain why lines inside brackets produce no NEWLINE and no indentation tokens.
- Describe the Python subset Viscosity accepts, and what happens to a file outside it.
- Read the parser’s block rule and its operator table.
Where does the loop end?
Here is a Python function:
def step(ps):
for p in ps:
p.x += p.vx
return ps
print(1)
In JavaScript, a { opens the loop body and a } closes it. Here
nothing closes anything. The body of for is everything indented
further than the for, and it ends at return, because return moves
back to the left. The spaces at the start of a line are syntax.
That sounds like a problem for a parser. The parsers you wrote in Part III ignore whitespace. Python’s own answer is neat: let the tokenizer measure the spaces, and turn each change into a token. The parser never sees a space.
Picture a stack of trays. Every time a line is indented further than the one before, you put a tray on the stack with the new width written on it. When a line comes back to the left, you take trays off until the top one shows that line’s width. Each tray you take off closes one block. If no tray in the stack has that width, the line doesn’t belong anywhere.
A stack of widths
Step through the tokenizer on that function:
def step(ps):for p in ps:p.x += p.vxreturn psprint(1)
This is the core of frontend-python/src/tokens.ts, simplified (the
real one also expands tabs to the next multiple of 8 and skips blank and
comment-only lines):
const indents = [0];
// at the start of every logical line:
let col = 0;
while (src[i] === ' ') { col++; i++; }
let top = indents[indents.length - 1];
if (col > top) {
indents.push(col);
toks.push({ type: 'indent' });
} else {
while (col < top) {
indents.pop();
toks.push({ type: 'dedent' });
top = indents[indents.length - 1];
}
if (col !== top) {
err('inconsistent indentation');
}
}
At the end of the file, one dedent is pushed for every width still on
the stack above 0, so every block that was opened gets closed.
Indentation tokens are the tokens a tokenizer emits for significant
whitespace. A stack holds the widths of the open blocks, starting with 0.
A wider line pushes its width and emits INDENT. A narrower line pops
every larger width, emitting one DEDENT per pop, and must then match
the new top exactly. Each logical line ends with a NEWLINE token.
For the parser, INDENT and DEDENT are just { and }.
Here is the same algorithm in JavaScript, working on a list of lines. Change the indentation and watch the tokens:
function indents(lines) {
const stack = [0];
const out = [];
for (const line of lines) {
let col = 0;
while (line[col] === ' ') col++;
if (col === line.length) continue;
let top = stack[stack.length - 1];
if (col > top) {
stack.push(col);
out.push('INDENT');
} else {
while (col < top) {
stack.pop();
out.push('DEDENT');
top = stack[stack.length - 1];
}
if (col !== top) {
throw new Error('inconsistent');
}
}
out.push(line.trim());
}
while (stack.length > 1) {
stack.pop();
out.push('DEDENT');
}
return out;
}
const src = [
'for p in ps:',
' p.x += 1',
' if p.x > 9:',
' p.x = 0',
'print(ps)',
];
console.log(indents(src).join('\n'));Brackets switch it off
Python lets a long expression run over several lines if it is inside brackets:
ps.append(P(float(i),
1.0))
The second line’s indentation means nothing, and there is no NEWLINE
between the two lines: they are one logical line. The tokenizer keeps a
depth counter: (, [ and { add one, the closing brackets subtract
one, and while depth > 0 a line end produces no NEWLINE and the next
line’s width is not measured.
The real tokenizer had a bug here, found while writing this chapter and
since fixed. A line end inside brackets set its “at the start of a
line” flag, and the flag was still set when the bracket closed. So the
line end after 1.0)) was mistaken for a blank line, and its NEWLINE
was lost. The next statement ran into this one, the parser stopped with
“expected end of line”, and the whole file was left untouched. It
failed safely, but every file with a bracket that closed on a
continuation line was skipped. The corpus program multiline_call.py
now pins the fix, and the optional exercise below gets it right too.
The part of Python it accepts
Viscosity doesn’t need all of Python. It needs the part that numeric, data-heavy code uses, and it needs to know exactly where that part ends. The parser accepts:
deffunctions,return,pass,break,continue,global@dataclassclasses with annotated fieldsif/elif/else,while,for x in ...- assignment,
+=and friends,x: float = ... - arithmetic, comparisons,
and/or/not, calls with keyword arguments, lists like[]and[a, b],importandfrom ... import
Everything else is refused with a clear message. These are real ones:
list comprehensions: not supported by
Viscosity's Python subset at line 1
near 'for'
dict and set literals: not supported …
slices: not supported …
default parameter values: not …
chained comparisons: not supported …
'try': not supported …
When the parser refuses, the plugin catches the error, returns an empty
module, and emit returns the file exactly as it was. Nothing is
half-transformed. The cost is that the reason stays inside the plugin:
viscosity explain only says “No arrays of objects found.”
The Python subset is the part of Python that Viscosity’s parser
accepts: functions, dataclass records, lists, for/while/if,
assignment, arithmetic and calls. A file that uses anything else is a
syntax error for Viscosity (not for Python), and is left unchanged.
Failing closed like this means a construct Viscosity doesn’t understand
can never be mis-transformed.
Why refuse chained comparisons, which look harmless? Because they mean
something else in JavaScript. Python reads 1 < 3 < 2 as
1 < 3 and 3 < 2, so it is False[3].
JavaScript reads (1 < 3) < 2, which is true < 2, which is true.
A parser that treated it as an ordinary binary operator would build the
wrong tree.
Blocks and binding powers
With INDENT and DEDENT in the stream, Python’s block rule is short.
This is block() from parser.ts:
function block() {
op(':');
if (peek().type !== 'newline') {
return simpleLine(); // if x: y = 1
}
p++; // NEWLINE
if (peek().type !== 'indent') {
fail('expected an indented block');
}
p++; // INDENT
const body = [];
while (peek().type !== 'dedent' &&
peek().type !== 'eof') {
body.push(...statement());
}
p++; // DEDENT
return body;
}
It is the same recursive descent as Part III, with INDENT for { and
DEDENT for }. Statements start with a keyword (def, for, …) and
are parsed by one function each. Expressions use binding powers, as in
chapter 33, from one table:
const BIN = {
or: 1, and: 2,
'<': 4, '==': 4, in: 4, 'not in': 4,
is: 4, 'is not': 4,
'|': 5, '^': 6, '&': 7, '<<': 8,
'+': 9, '-': 9,
'*': 10, '/': 10, '//': 10, '%': 10,
};
(Trimmed.) Two operators are two words, not in and is not, so the
parser looks one token ahead to spot them. And elif is handled with a
small trick: the parser rewrites the elif token to if and parses the
rest as a nested if statement in the else branch.
This is CPython’s own design. Its language reference describes the same
stack: a single 0 pushed before the first line, a push and an INDENT for
a wider line, a DEDENT per pop for a narrower one, and an error if the
width matches nothing on the stack[1]. Running
CPython’s tokenize module[2] on the
function at the top of this chapter gives the same INDENT, DEDENT and
NEWLINE tokens, in the same places, as Viscosity’s tokenizer. The
reference also notes that an inconsistent dedent is the lexer’s
error, while an unexpected indent is found by the parser. Viscosity
splits them the same way.
Your turn
Now the parser’s side. toks is a list of tokens: each line’s text (like 'for p in ps:'), plus 'INDENT' and 'DEDENT'. Write nest(toks) that returns the block structure as nested arrays: a line that ends with : is followed by an array holding its block. For example ['if x:', 'INDENT', 'y = 1', 'DEDENT', 'z = 2'] gives ['if x:', ['y = 1'], 'z = 2']. Throw an Error with message 'expected an indented block' if a : line is not followed by INDENT, and 'unexpected indent' if an INDENT follows any other line.
function nest(toks) {
return toks;
}Write logicalLines(src). Split src into physical lines and join the ones that are inside brackets ( [ { into one logical line. A continuation line is trimmed and added after a single space. Skip blank lines. A logical line keeps the indentation of its first physical line. (Ignore strings and comments: assume brackets only appear as brackets.) Make sure the line after a bracket that closes on a continuation line still starts a new logical line.
function logicalLines(src) {
return src.split('\n');
}What you learned
- Indentation tokens turn significant whitespace into INDENT and DEDENT, using a stack of open-block widths that starts at 0.
- Inside brackets, line ends and indentation don’t count: one logical line can span several physical lines.
- The Python subset covers functions, dataclasses, lists, loops,
if, assignment, arithmetic and calls. Anything else is refused, and the file is left exactly as it was. - After tokenizing, Python parses with the same recursive descent and binding powers as any other language.
Check your understanding
0/6- 1.
The stack is
[0, 4]. The next line has width 8. What does the tokenizer do? - 2.
At the end of the file the stack is
[0, 4, 8]. What comes before EOF? - 3.
Why produce INDENT/DEDENT tokens at all, instead of letting the parser count spaces?
- 4.
A file uses
d = {}once, far from any list. What does Viscosity output? - 5.
Python prints
Falsefor1 < 3 < 2. JavaScript printstrue. Why? - 6.
Before it was fixed, the real tokenizer dropped a NEWLINE after a bracket that closed on a continuation line. What was the effect?
References
- [1] Python Software Foundation. The Python Language Reference: 2. Lexical analysis. Python 3.13 documentation. docs.python.org/3.13/reference/lexical_analysis.html (checked 2026-10-31)
- [2] Python Software Foundation. tokenize — Tokenizer for Python source. Python 3.13 documentation (The Python Standard Library). docs.python.org/3.13/library/tokenize.html (checked 2026-10-31)
- [3] Python Software Foundation. The Python Language Reference: 6. Expressions. Python 3.13 documentation. docs.python.org/3.13/reference/expressions.html (checked 2026-10-31)