mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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>
354 lines
9.2 KiB
JavaScript
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;
|
|
}
|