Fix parser bug: string values like ")" were confused with delimiter tokens

Both Python and JS parsers used next_token() which returns plain strings
for both delimiter characters and string values, making them
indistinguishable. A string whose value is ")" or "(" would be
misinterpreted as a structural delimiter, causing parse errors.

Fix: use peek() (raw character) for all structural decisions in
parseExpr before consuming via next_token(). Also add enhanced error
logging to sx.js mount/loadComponents for easier future debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 01:18:09 +00:00
parent 39f61eddd6
commit 4668c30890
2 changed files with 77 additions and 17 deletions

View File

@@ -170,15 +170,18 @@
}
function parseExpr(tok) {
var token = tok.next();
if (token === null) throw parseErr("Unexpected end of input", tok);
if (token === "(") return parseList(tok, ")");
if (token === "[") return parseList(tok, "]");
if (token === "{") return parseMap(tok);
if (token === ")" || token === "]" || token === "}") {
throw parseErr("Unexpected " + token, tok);
// Use peek() (raw character) for structural decisions so that string
// values like ")" or "(" don't get confused with actual delimiters.
var raw = tok.peek();
if (raw === null) throw parseErr("Unexpected end of input", tok);
if (raw === ")" || raw === "]" || raw === "}") {
tok.next(); // consume the delimiter
throw parseErr("Unexpected " + raw, tok);
}
return token;
if (raw === "(") { tok.next(); return parseList(tok, ")"); }
if (raw === "[") { tok.next(); return parseList(tok, "]"); }
if (raw === "{") { tok.next(); return parseMap(tok); }
return tok.next();
}
function parseList(tok, closer) {
@@ -1297,7 +1300,26 @@
var exprs = parseAll(text);
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
} catch (err) {
console.error("sx.js loadComponents error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
// Enhanced error logging: show context around parse failure
var colMatch = err.message && err.message.match(/col (\d+)/);
var lineMatch = err.message && err.message.match(/line (\d+)/);
if (colMatch && text) {
var errLine = lineMatch ? parseInt(lineMatch[1]) : 1;
var errCol = parseInt(colMatch[1]);
var lines = text.split("\n");
var pos = 0;
for (var li = 0; li < errLine - 1 && li < lines.length; li++) pos += lines[li].length + 1;
pos += errCol;
var start = Math.max(0, pos - 120);
var end = Math.min(text.length, pos + 120);
console.error("sx.js loadComponents PARSE ERROR:", err.message,
"\n total length:", text.length, "lines:", lines.length,
"\n error line " + errLine + ":", lines[errLine - 1] ? lines[errLine - 1].substring(0, 200) : "(no such line)",
"\n around error (pos ~" + pos + "):",
"\n «" + text.substring(start, pos) + "⛔" + text.substring(pos, end) + "»");
} else {
console.error("sx.js loadComponents error:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
}
throw err;
}
},
@@ -1317,7 +1339,34 @@
mount: function (target, exprOrText, extraEnv) {
var el = typeof target === "string" ? document.querySelector(target) : target;
if (!el) return;
var node = Sx.render(exprOrText, extraEnv);
var node;
try {
node = Sx.render(exprOrText, extraEnv);
} catch (e) {
if (typeof exprOrText === "string") {
var src = exprOrText;
// Find approx position from error message
var colMatch = e.message && e.message.match(/col (\d+)/);
var lineMatch = e.message && e.message.match(/line (\d+)/);
if (colMatch) {
var errLine = lineMatch ? parseInt(lineMatch[1]) : 1;
var errCol = parseInt(colMatch[1]);
var lines = src.split("\n");
var pos = 0;
for (var li = 0; li < errLine - 1 && li < lines.length; li++) pos += lines[li].length + 1;
pos += errCol;
var start = Math.max(0, pos - 80);
var end = Math.min(src.length, pos + 80);
console.error("sx.js MOUNT PARSE ERROR:", e.message,
"\n source length:", src.length,
"\n around error (pos ~" + pos + "):",
"\n «" + src.substring(start, pos) + "⛔" + src.substring(pos, end) + "»");
} else {
console.error("sx.js MOUNT PARSE ERROR:", e.message, "\n first 500:", src.substring(0, 500));
}
}
throw e;
}
el.textContent = "";
el.appendChild(node);
// Auto-hoist head elements (meta, title, link, script[ld+json]) to <head>
@@ -1354,11 +1403,13 @@
// Server sent full source (cookie was missing/stale) — update cache
localStorage.setItem("sx-components-src", text);
Sx.loadComponents(text);
console.log("[sx.js] components: downloaded (cookie stale)");
} else {
// Server omitted source — load from cache
var cached = localStorage.getItem("sx-components-src");
if (cached) {
Sx.loadComponents(cached);
console.log("[sx.js] components: cached (" + hash + ")");
} else {
// Cache entry missing — clear cookie and reload to get full source
_clearSxCompCookie();
@@ -1373,6 +1424,7 @@
localStorage.setItem("sx-components-hash", hash);
localStorage.setItem("sx-components-src", text);
Sx.loadComponents(text);
console.log("[sx.js] components: downloaded (" + hash + ")");
} else {
// Server omitted source but our cache is stale — clear and reload
localStorage.removeItem("sx-components-hash");

View File

@@ -208,17 +208,25 @@ def parse_all(text: str) -> list[Any]:
def _parse_expr(tok: Tokenizer) -> Any:
token = tok.next_token()
if token is None:
# Use peek() (raw character) for structural decisions so that string
# values like ")" or "(" don't get confused with actual delimiters.
raw = tok.peek()
if raw is None:
raise ParseError("Unexpected end of input", tok.pos, tok.line, tok.col)
if token == "(":
if raw in ")]}":
tok.next_token() # consume the delimiter
raise ParseError(f"Unexpected {raw!r}", tok.pos, tok.line, tok.col)
if raw == "(":
tok.next_token() # consume the '('
return _parse_list(tok, ")")
if token == "[":
if raw == "[":
tok.next_token() # consume the '['
return _parse_list(tok, "]")
if token == "{":
if raw == "{":
tok.next_token() # consume the '{'
return _parse_map(tok)
if token in (")", "]", "}"):
raise ParseError(f"Unexpected {token!r}", tok.pos, tok.line, tok.col)
# Everything else: strings, keywords, symbols, numbers
token = tok.next_token()
return token