Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
- run: npm run build:embed
# Builds the library again and checks the embed protocol against dist/.
- run: npm run test:protocol
# The static tree pictures (renderTreeSvg, glpython-tree) against their
# snapshots, treeLayout's positions and CPython's repr, with that build.
- run: npm run test:svg
# The hints' wording in German and English, against that build.
- run: npm run test:hints
# The runtime under CPython: the visualizer's columns, docstrings,
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ npm run test:hints # the error hints' wording (de/en) against the built dist
npm run test:console # the console in Chrome with Pyodide, against dist-embed/ (run build:embed first)
npm run test:layout # the trees' layout (src/treeLayout.ts) against d3-flextree (a devDependency, only for this check)
npm run test:tree # the visualizer's mode «Baum» (src/treeMode.ts) on traces of the runtime under CPython
npm run test:svg # the static tree pictures (src/treeSvg.ts, bin/glpython-tree.mjs) against scripts/tree-svg/ (-- --update after a deliberate change)
npm run build:embed # builds the iframe app + demo page into dist-embed/ (plus embed/public/: coi-serviceworker.js)
npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.html
```
Expand All @@ -26,6 +27,8 @@ npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.h

There is no unit test framework. The automated checks are `test:protocol` (plain `node:assert` against the built `dist/index.js`; add protocol validation cases there) and `test:runtime` (plain Python scripts that exec `src/runtime/` under CPython 3.12, Pyodide's version; add runtime regression cases there). `.github/workflows/ci.yml` runs lint, both builds and the checks on every pull request and on pushes to `main`. The one browser check is `test:console` (`scripts/check-console-browser.mjs`): `playwright-core` drives the installed Chrome (GitHub's runner has one) against `vite preview` of `dist-embed/`, through the embed protocol, with Pyodide from its CDN; add browser regression cases there. Everything else that needs Pyodide in a browser (the worker, most of the UI) is not checked automatically. `npm run lint` has one known warning in `GlPythonWorkspace.tsx` (`react(immutability)` on the `cursor` variable in `Frame`).

**Static tree pictures.** `src/treeSvg.ts` (`renderTreeSvg`) draws a list that reads as a tree as an SVG for a compile step in Node, built as its own entry `dist/tree-svg.js` (export `glpython-editor/tree-svg`, no React or Monaco) and run by `bin/glpython-tree.mjs`. It parses Python's literal syntax and writes leaves with Python's repr (`test:svg` compares with CPython), uses `layoutTree` and copies `TreeView`'s look with the light tokens as literals (a static file can't read `--glp-*`); keep the two in step. Output must stay byte-identical for the same input: fixed precision, no ids, no dates. The peer dependencies are optional so that a repo installing it only for this doesn't get React.

**Licence.** MIT (`LICENSE`). `THIRD_PARTY_NOTICES.md` lists the third-party code in the repo and in the embed's bundle, with license texts (the build strips license comments, so `build:embed` copies `LICENSE` and the notices into `dist-embed/`). When a bundled dependency is added or changed, or code is copied in, update it: the embed's bundled packages are the `node_modules` paths in `dist-embed/assets/*.js.map`.

**Releases.**
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,48 @@ tidy-tree algorithm for nodes of different widths (`src/treeLayout.ts`,
checked against [d3-flextree](https://github.com/Klortho/d3-flextree)), in
characters of the code font, so a tree scales with the text around it.

### Static tree pictures (`glpython-editor/tree-svg`, `glpython-tree`)

For slides, handouts and quiz options, a list that reads as a tree can also be
drawn as a static SVG file, without a browser: the same tree rule and layout as
`listTree`, the same look (solid node boxes, dashed leaf boxes, the light
theme's colours) on a light card with rounded corners, so the picture reads the
same on a light and a dark page.

```js
import { renderTreeSvg } from 'glpython-editor/tree-svg'

const svg = renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' })
```

```sh
npx glpython-tree "['+', 2, ['*', 3, 4]]" --mark "['*', 3, 4]" -o baum.svg
```

- **Input:** the list as Python writes it: numbers (also negative and
decimal), strings in single or double quotes with Python's escapes, `True`,
`False`, `None`, lists and tuples. Leaves read as Python's repr writes them
(`2.50` → `2.5`, `"it's"`). A single value is a tree of one leaf.
- **`mark`:** subtrees to highlight, written the same way (the accent colour on
their boxes and the edges between them; the whole tree marks all of it). A
mark that isn't a subtree, or occurs more than once, is an error.
- **`markRoot`:** the root node's box alone (not its edges or children), e.g.
for the operator computed last; it combines with `mark`, and marks the leaf
of a single value. In the description: «– markiert: Wurzel» / «– marked:
root» (first, before the marked subtrees). `glpython-tree --mark-root`.
- **`fontSize`** in pixels (default 16), **`lang`** (`en`, `de`) for the
description. The picture is `role="img"` with a `<title>`: the list, and
«– markiert: …» / «– marked: …» for the marks.
- **Text and errors:** widths are in characters of the code font, as on screen;
each text is fitted to its characters' width (`textLength`), so a monospace
font with other proportions doesn't overflow its box. Invalid input throws a
`TreeSvgError` naming the position; `glpython-tree` prints it and exits
with 1 (2 for wrong usage). Without a list it reads one from stdin.
- **Deterministic:** the same input and options always give the same bytes.

Long labels aren't shortened, unlike the visualizer's trees.
`npm run test:svg` checks the pictures against `scripts/tree-svg/`.

### Copying the evaluation as a → chain

The **Copy → chain** item in the toolbar's **⋯ Options** menu copies the evaluation of the statement
Expand Down
62 changes: 62 additions & 0 deletions bin/glpython-tree.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env node
// Draws a list that reads as a tree as an SVG picture (see src/treeSvg.ts):
//
// glpython-tree "['+', 2, ['*', 3, 4]]" --mark "['*', 3, 4]" -o baum.svg
// glpython-tree "['+', 2, ['*', 3, 4]]" --mark-root -o wurzel.svg
//
// Without -o, the SVG goes to stdout; without a list, it is read from stdin.
// Options: --mark <subtree> (repeatable), --mark-root (the root node's box
// alone), --lang en|de (the picture's
// description), --font-size <px>. Exit code 1 on invalid input or a mark
// that isn't exactly one subtree, 2 on wrong usage.
import { readFileSync, writeFileSync } from 'node:fs'
import { renderTreeSvg, TreeSvgError } from '../dist/tree-svg.js'

const usage = 'Usage: glpython-tree "<list as Python writes it>" [--mark "<subtree>"]... [--mark-root] [--lang en|de] [--font-size <px>] [-o <file.svg>]'
const args = process.argv.slice(2)
const marks = []
let text = null
let output = null
let lang = 'en'
let fontSize
let markRoot = false
for (let index = 0; index < args.length; index++) {
const arg = args[index]
const next = () => {
if (index + 1 >= args.length) {
console.error(`${arg} needs a value.\n${usage}`)
process.exit(2)
}
return args[++index]
}
if (arg === '--mark' || arg === '-m') marks.push(next())
else if (arg === '--mark-root') markRoot = true
else if (arg === '-o' || arg === '--output') output = next()
else if (arg === '--lang') lang = next()
else if (arg === '--font-size') fontSize = Number(next())
else if (arg === '-h' || arg === '--help') {
console.log(usage)
process.exit(0)
} else if (text === null) text = arg
else {
console.error(`Unexpected argument ${JSON.stringify(arg)}.\n${usage}`)
process.exit(2)
}
}
if (lang !== 'en' && lang !== 'de') {
console.error(`--lang must be en or de.\n${usage}`)
process.exit(2)
}
if (fontSize !== undefined && !(fontSize > 0)) {
console.error(`--font-size must be a positive number.\n${usage}`)
process.exit(2)
}
try {
const svg = renderTreeSvg(text ?? readFileSync(0, 'utf8').trim(), { mark: marks, markRoot, lang, fontSize })
if (output) writeFileSync(output, svg)
else process.stdout.write(svg)
} catch (error) {
if (!(error instanceof TreeSvgError)) throw error
console.error(error.message)
process.exit(1)
}
17 changes: 16 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,22 @@
"types": "./dist/src/index.d.ts",
"files": [
"dist",
"bin",
"THIRD_PARTY_NOTICES.md"
],
"bin": {
"glpython-tree": "./bin/glpython-tree.mjs"
},
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/index.js"
},
"./style.css": "./dist/style.css"
"./style.css": "./dist/style.css",
"./tree-svg": {
"types": "./dist/src/treeSvg.d.ts",
"import": "./dist/tree-svg.js"
}
},
"scripts": {
"dev": "vite build --watch",
Expand All @@ -31,6 +39,7 @@
"test:console": "node scripts/check-console-browser.mjs",
"test:layout": "node --experimental-strip-types --no-warnings scripts/check-tree-layout.mjs",
"test:tree": "node --experimental-strip-types --no-warnings scripts/check-tree-mode.mjs",
"test:svg": "node --experimental-strip-types --no-warnings scripts/check-tree-svg.mjs",
"test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py && python3 -B scripts/check-runtime-input.py && python3 -B scripts/check-runtime-lists.py && python3 -B scripts/check-runtime-main.py && python3 -B scripts/check-runtime-trees.py && python3 -B scripts/check-runtime-box-reasons.py",
"prepare": "npm run build"
},
Expand All @@ -40,6 +49,12 @@
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"peerDependenciesMeta": {
"@monaco-editor/react": { "optional": true },
"lucide-react": { "optional": true },
"react": { "optional": true },
"react-dom": { "optional": true }
},
"devDependencies": {
"@monaco-editor/react": "^4.7.0",
"@types/node": "^24.13.3",
Expand Down
137 changes: 137 additions & 0 deletions scripts/check-tree-svg.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Check of the static tree pictures (src/treeSvg.ts, P43): renderTreeSvg
// draws the trees of the layout check (the course's parser output and two
// uneven trees), a mark on a subtree, the whole tree marked, a single value,
// and the root node alone marked (also with a subtree, and on a single
// value) exactly as the snapshots in scripts/tree-svg/ - byte for byte, and
// the same twice - with every box where treeLayout.ts places its node (the
// positions TreeView uses). It reads lists as Python writes them, with
// Python's repr for the leaves (compared with CPython's), and reports
// invalid input, a mark that occurs twice and one that doesn't occur. The
// glpython-tree command (bin/) is checked against the build, when there is
// one (CI builds first).
//
// npm run test:svg (--update rewrites the snapshots)
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { layoutTree } from '../src/treeLayout.ts'
import { parsePythonValue, pythonRepr, renderTree, renderTreeSvg } from '../src/treeSvg.ts'

const update = process.argv.includes('--update')
const snapshot = (name) => new URL(`tree-svg/${name}.svg`, import.meta.url)

let failed = 0
const check = (label, test) => {
try {
test()
console.log(`${label}: ok`)
} catch (error) {
failed += 1
console.error(`${label}: ${error.message}`)
}
}

const cases = [
['breit', "['f', ['g', 'erstes', 'zweites'], ['h', 'drittes', 'viertes'], ['k', 'fünftes']]"],
['2-plus-3-mal-4', "['+', 2, ['*', 3, 4]]"],
['2-plus-3-in-klammern-mal-4', "['*', ['+', 2, 3], 4]"],
['minus-1-plus-2', "['-', ['+', 1, 2]]"],
['x-plus-1', "['+', ['var', 'x'], 1]"],
['f-1-2', "['f', 1, 2]"],
['uneven-both-sides', "['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]"],
['uneven-to-the-left', "['+', ['+', ['+', 1, 2], 3], ['f', ['var', 'x']]]"],
['marked-subtree', "['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' }],
['marked-root', "['+', 2, ['*', 3, 4]]", { mark: ["['+', 2, ['*', 3, 4]]"] }],
['single-value', '42'],
['marked-root-node', "['+', 2, ['*', 3, 4]]", { markRoot: true, lang: 'de' }],
['marked-root-node-and-subtree', "['+', 2, ['*', 3, 4]]", { markRoot: true, mark: ["['*', 3, 4]"], lang: 'de' }],
['marked-single-value', '42', { markRoot: true }],
]
for (const [name, text, options] of cases) {
check(`${name}: as the snapshot, the same twice`, () => {
const svg = renderTreeSvg(text, options)
assert.equal(renderTreeSvg(text, options), svg, 'two runs differ')
if (update || !existsSync(snapshot(name))) writeFileSync(snapshot(name), svg)
assert.equal(svg, readFileSync(snapshot(name), 'utf8'), `differs from scripts/tree-svg/${name}.svg (npm run test:svg -- --update after a deliberate change)`)
})
check(`${name}: the boxes where treeLayout places them`, () => {
const { svg, nodes } = renderTree(text, options)
const expected = layoutTree(nodes[0].node)
assert.deepEqual(nodes.map(({ key, x, depth, width }) => [key, x, depth, width]), expected.nodes.map(({ key, x, depth, width }) => [key, x, depth, width]))
// At 16px with characters 0.6em wide, a box starts at 12 + (x - width / 2) * 9.6 (plus half its stroke).
const boxes = [...svg.matchAll(/<rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="[\d.]+" rx="4"[^>]*stroke-width="(\d)"/g)]
assert.equal(boxes.length, nodes.length, 'one box per node')
boxes.forEach((box, index) => {
const node = nodes[index]
const inset = Number(box[4]) / 2
assert.ok(Math.abs(Number(box[1]) - (12 + (node.x - node.width / 2) * 9.6 + inset)) < 0.01, `box ${node.key} at x ${box[1]}`)
assert.ok(Math.abs(Number(box[2]) - (12 + node.depth * 2.8 * 16 + inset)) < 0.01, `box ${node.key} at y ${box[2]}`)
assert.ok(Math.abs(Number(box[3]) - (node.width * 9.6 - 2 * inset)) < 0.01, `box ${node.key} ${box[3]} wide`)
})
})
}

check('the picture is described: role="img", <title> with the list and its marks', () => {
const svg = renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' })
assert.match(svg, /^<svg [^>]*role="img"/)
assert.match(svg, /<title>\['\+', 2, \['\*', 3, 4\]\] – markiert: \['\*', 3, 4\]<\/title>/)
assert.match(renderTreeSvg("['f', 'a<b&c']"), /<title>\['f', 'a&lt;b&amp;c'\]<\/title>/)
})
check('markRoot: the root box alone, described as the root', () => {
const svg = renderTreeSvg("['+', 2, ['*', 3, 4]]", { markRoot: true, lang: 'de' })
assert.equal(svg.match(/fill="#DCE6F2"/g)?.length, 1, 'one marked box')
assert.doesNotMatch(svg, /<line [^>]*stroke="#1F3A5F"/, 'no marked edge')
assert.match(svg, /<title>\['\+', 2, \['\*', 3, 4\]\] – markiert: Wurzel<\/title>/)
const both = renderTreeSvg("['+', 2, ['*', 3, 4]]", { markRoot: true, mark: ["['*', 3, 4]"], lang: 'de' })
assert.equal(both.match(/fill="#DCE6F2"/g)?.length, 4, 'the root and the subtree')
assert.equal(both.match(/<line [^>]*stroke="#1F3A5F"/g)?.length, 2, "only the subtree's edges")
assert.match(both, /– markiert: Wurzel; \['\*', 3, 4\]<\/title>/)
assert.match(renderTreeSvg('42', { markRoot: true }), /<title>42 – marked: root<\/title>/)
})
check('the tree rule: labels without quotes, strings with, other lists as leaves', () => {
const { nodes } = renderTree(`["+", "a", [1, 2], ['g'], ["Anna", 'Ben'], True, -2.5, None, (1, 'x')]`)
assert.deepEqual(nodes.map((placed) => placed.node.kind === 'node' ? `(${placed.node.label})` : placed.node.text), ['(+)', "'a'", '[1, 2]', '(g)', '(Anna)', "'Ben'", 'True', '-2.5', 'None', "(1, 'x')"])
})
check("leaves as Python's repr writes them", () => {
const values = String.raw`[1, -3, 2.50, 2., -0, 0.1, 1e16, 1.5e-7, 0.0001, 123456789012345678, 1_000, True, False, None, 'a', "it's", 'a"b', "both ' and \"", 'tab\t', 'nl\n', '\\', '\x07', 'é', 'é', (1,), (2), (), [[], [1, [2]]]]`
const ours = pythonRepr(parsePythonValue(values))
const python = spawnSync('python3', ['-c', `import sys; print(repr(${values}), end="")`], { encoding: 'utf8' })
assert.equal(python.status, 0, python.stderr)
assert.equal(ours, python.stdout)
})
check('errors: invalid input, where it fails', () => {
for (const [text, message] of [
["['+', 2", /Missing "\]" at position 8/],
["['+', 2 3]", /Expected "," or "\]" at position 9/],
["['+', x]", /Unknown name "x" .* at position 7/],
["['a', 'b]", /Unterminated string at position 7/],
['', /Expected a value at position 1/],
["['+', 1] 2", /Unexpected text after the value at position 10/],
]) assert.throws(() => renderTreeSvg(text), message, text)
})
check('errors: a mark that occurs twice, one that is missing, an invalid one', () => {
assert.throws(() => renderTreeSvg("['+', 3, ['*', 3, 4]]", { mark: ['3'] }), /The mark 3 occurs 2 times/)
assert.throws(() => renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 4, 3]"] }), /The mark \['\*', 4, 3\] is not a subtree/)
assert.throws(() => renderTreeSvg("['+', 2, 3]", { mark: ['[2'] }), /Invalid mark \[2/)
})

// The command, against the build.
if (existsSync(new URL('../dist/tree-svg.js', import.meta.url))) {
check('glpython-tree: stdout, -o, and exit code 1 on an error', () => {
const command = (args, input) => spawnSync(process.execPath, ['bin/glpython-tree.mjs', ...args], { encoding: 'utf8', input })
const plain = command(["['+', 2, ['*', 3, 4]]", '--mark', "['*', 3, 4]", '--lang', 'de'])
assert.equal(plain.status, 0, plain.stderr)
assert.equal(plain.stdout, readFileSync(snapshot('marked-subtree'), 'utf8'))
assert.equal(command([], "['f', 1, 2]\n").stdout, readFileSync(snapshot('f-1-2'), 'utf8'), 'from stdin')
const wrong = command(["['+', 3, ['*', 3, 4]]", '--mark', '3'])
assert.equal(wrong.status, 1)
assert.match(wrong.stderr, /occurs 2 times/)
assert.equal(command(['--lang', 'fr', '1']).status, 2)
assert.equal(command(["['+', 2, ['*', 3, 4]]", '--mark-root', '--lang', 'de']).stdout, readFileSync(snapshot('marked-root-node'), 'utf8'), '--mark-root')
})
} else {
console.log('glpython-tree: skipped (no build; npm run build first)')
}

if (failed > 0) process.exit(1)
console.log('Tree SVG checks passed.')
Loading
Loading