Graduately/tools/lib/cparse.mjs
FBukovina f7470d333c Add the Next.js web app and shared lesson content.
Move lessons out of the C sources into JSON so the web client can use the same material as the desktop app, and point release builds at desktop/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 16:06:15 +02:00

354 lines
9.2 KiB
JavaScript

/**
* Minimal reader for the C initializer syntax used by the desktop sources.
*
* It understands only what the lesson data actually uses: string literals with
* adjacent concatenation, char literals, integers, `NULL`, identifiers,
* designated initializers and nested braces. Anything else throws so a bad
* extraction fails loudly instead of silently producing empty lessons.
*/
const IDENT_START = /[A-Za-z_]/;
const IDENT_PART = /[A-Za-z0-9_]/;
/** Strip comments while preserving string/char literals verbatim. */
export function stripComments(src) {
let out = '';
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === '"' || c === "'") {
const quote = c;
out += c;
i++;
while (i < src.length) {
if (src[i] === '\\') {
out += src[i] + (src[i + 1] ?? '');
i += 2;
continue;
}
out += src[i];
if (src[i] === quote) {
i++;
break;
}
i++;
}
continue;
}
if (c === '/' && src[i + 1] === '*') {
const end = src.indexOf('*/', i + 2);
i = end === -1 ? src.length : end + 2;
out += ' ';
continue;
}
if (c === '/' && src[i + 1] === '/') {
const end = src.indexOf('\n', i);
i = end === -1 ? src.length : end;
out += ' ';
continue;
}
out += c;
i++;
}
return out;
}
const ESCAPES = {
n: '\n',
t: '\t',
r: '\r',
'0': '\0',
'\\': '\\',
'"': '"',
"'": "'",
a: '\x07',
b: '\b',
f: '\f',
v: '\v',
};
class Reader {
constructor(src, label) {
this.src = src;
this.label = label;
this.i = 0;
}
fail(msg) {
const at = this.src.slice(Math.max(0, this.i - 60), this.i + 60);
throw new Error(`${this.label}: ${msg} near\n...${at}...`);
}
ws() {
while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
}
peek() {
this.ws();
return this.src[this.i];
}
eat(ch) {
this.ws();
if (this.src[this.i] !== ch) this.fail(`expected '${ch}', got '${this.src[this.i]}'`);
this.i++;
}
tryEat(ch) {
this.ws();
if (this.src[this.i] === ch) {
this.i++;
return true;
}
return false;
}
/** One string literal, honouring C's adjacent-literal concatenation. */
readString() {
let value = '';
let first = true;
for (;;) {
this.ws();
if (this.src[this.i] !== '"') {
if (first) this.fail('expected string literal');
return value;
}
first = false;
this.i++;
while (this.i < this.src.length && this.src[this.i] !== '"') {
if (this.src[this.i] === '\\') {
const esc = this.src[this.i + 1];
if (esc in ESCAPES) {
value += ESCAPES[esc];
this.i += 2;
continue;
}
if (esc === 'u' || esc === 'x') {
const m = /^\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]+)/.exec(this.src.slice(this.i));
if (!m) this.fail('bad escape');
const hex = m[1].slice(1);
value += String.fromCodePoint(parseInt(hex, 16));
this.i += m[0].length;
continue;
}
value += esc;
this.i += 2;
continue;
}
value += this.src[this.i];
this.i++;
}
if (this.src[this.i] !== '"') this.fail('unterminated string');
this.i++;
}
}
readIdent() {
this.ws();
if (!IDENT_START.test(this.src[this.i] ?? '')) this.fail('expected identifier');
let start = this.i;
while (this.i < this.src.length && IDENT_PART.test(this.src[this.i])) this.i++;
return this.src.slice(start, this.i);
}
/**
* A single initializer value. Braces become arrays, or plain objects when the
* body uses designated initializers.
*/
readValue() {
this.ws();
const c = this.src[this.i];
if (c === undefined) this.fail('unexpected end of input');
if (c === '"') return this.readString();
if (c === '{') return this.readBraced();
// Cast prefix such as `(int)` or `(const char *)`: skip it and read on.
if (c === '(') {
const cast = /^\(\s*(?:const\s+|unsigned\s+|signed\s+)*[A-Za-z_]\w*\s*\**\s*\)/.exec(
this.src.slice(this.i)
);
if (cast) {
this.i += cast[0].length;
return this.readValue();
}
}
if (c === "'") {
this.i++;
let ch;
if (this.src[this.i] === '\\') {
const esc = this.src[this.i + 1];
ch = esc in ESCAPES ? ESCAPES[esc] : esc;
this.i += 2;
} else {
ch = this.src[this.i];
this.i++;
}
this.eat("'");
return ch;
}
// Numeric or simple arithmetic/negation, e.g. `-1`, `4`, `G_N_ELEMENTS(x)`.
const numMatch = /^[+-]?(?:0[xX][0-9a-fA-F]+|\d+)(?![\w.])/.exec(this.src.slice(this.i));
if (numMatch) {
this.i += numMatch[0].length;
return Number(numMatch[0]);
}
if (IDENT_START.test(c)) {
const name = this.readIdent();
if (name === 'NULL') return null;
if (name === 'TRUE') return true;
if (name === 'FALSE') return false;
// Macro call or bare constant: keep the source text for the caller.
if (this.peek() === '(') {
let depth = 0;
const start = this.i;
while (this.i < this.src.length) {
if (this.src[this.i] === '(') depth++;
else if (this.src[this.i] === ')') {
depth--;
if (depth === 0) {
this.i++;
break;
}
}
this.i++;
}
return { __c: name + this.src.slice(start, this.i) };
}
return { __c: name };
}
this.fail(`unexpected character '${c}'`);
}
readBraced() {
this.eat('{');
const list = [];
const obj = {};
let designated = false;
for (;;) {
if (this.tryEat('}')) break;
if (this.peek() === '.') {
this.i++;
const field = this.readIdent();
this.eat('=');
obj[field] = this.readValue();
designated = true;
} else {
list.push(this.readValue());
}
if (this.tryEat(',')) continue;
this.eat('}');
break;
}
return designated ? obj : list;
}
}
/**
* Find an array/struct definition by name and parse its initializer.
* Matches e.g. `static const ChoiceQ net2_qs[] = { ... };`
*/
export function parseArray(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(
`\\b${name}\\s*(?:\\[[^\\]]*\\]\\s*)+=\\s*(?=\\{)`,
'g'
);
const m = re.exec(clean);
if (!m) throw new Error(`${label}: array '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
const value = reader.readValue();
if (!Array.isArray(value)) throw new Error(`${label}: '${name}' is not a braced list`);
return value;
}
/** Parse a scalar struct definition, e.g. `static const ThemePalette nord_dark = {...};` */
export function parseStruct(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(`\\b${name}\\s*=\\s*(?=\\{)`, 'g');
const m = re.exec(clean);
if (!m) throw new Error(`${label}: struct '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
return reader.readValue();
}
/**
* Same as parseArray but scoped to the body of a single function, so the many
* identically-named `static const NetSlide slides[]` locals stay distinct.
*/
export function parseArrayInFunction(src, fnName, arrayName) {
const clean = stripComments(src);
const fnRe = new RegExp(`\\b${fnName}\\s*\\([^)]*\\)\\s*\\{`, 'g');
const m = fnRe.exec(clean);
if (!m) throw new Error(`function '${fnName}' not found`);
// Walk to the matching closing brace so we only search this body.
let depth = 0;
let i = m.index + m[0].length - 1;
const start = i;
for (; i < clean.length; i++) {
if (clean[i] === '"' || clean[i] === "'") {
const q = clean[i++];
while (i < clean.length && clean[i] !== q) i += clean[i] === '\\' ? 2 : 1;
continue;
}
if (clean[i] === '{') depth++;
else if (clean[i] === '}') {
depth--;
if (depth === 0) break;
}
}
const body = clean.slice(start, i + 1);
return parseArray(body, arrayName, { label: `${fnName}/${arrayName}` });
}
/** Parse a scalar string definition, e.g. `const char *g05_bank = "a, b, c";` */
export function parseString(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(`\\b${name}\\s*=\\s*(?=")`, 'g');
const m = re.exec(clean);
if (!m) throw new Error(`${label}: string '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
return reader.readString();
}
/** Resolve `#define NAME value` integer macros. */
export function parseDefines(src) {
const out = {};
const re = /^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)[ \t]+([^\n/]+)/gm;
let m;
while ((m = re.exec(src))) {
const raw = m[2].trim();
if (/^-?\d+$/.test(raw)) out[m[1]] = Number(raw);
}
return out;
}
/** Trailing NULLs are array terminators in the C data, not content. */
export function trimNulls(list) {
const out = [...list];
while (out.length && (out.at(-1) === null || out.at(-1) === undefined)) out.pop();
return out;
}