diff --git a/CHANGELOG.md b/CHANGELOG.md index 46cf3e0..288e184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to EigenScript are documented here. ## [Unreleased] +### Fixed +- **Dot access accepts keyword-named fields (#542).** `d.loop`, `d.in`, + `d.when` — any word keyword now parses as a dot key (read, write, + compound assign, any chain depth, all three parser postfix sites). + The position after `.` admits nothing but a field name, so the old + `TOK_IDENT`-only check rejected programs with no ambiguity to protect + against, leaving keys creatable by literal, `dict_set`, and + `json_decode` reachable only by bracket — and contradicted SYNTAX.md's + soft-keyword promise for `d.prev`/`d.at`. Found porting a DAW whose + data model has `clip.loop` / event `when` fields. Suite [118] + (49 checks); SPEC/SYNTAX/GRAMMAR/COMPARISON updated. The ouroboros + `frontend.eigs` mirror lands with the next `EIGS_REF` bump + (deferred-mirror pattern, #326/#328). + ### Added - **`--bundle`: single-file distribution (#413).** `eigenscript --bundle app.eigs out [--with-tape tape]` copies the runtime binary diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 951141d..9ab5767 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -173,8 +173,9 @@ user.field = "computing"; console.log(user.name, Object.keys(user).length); ``` -EigenScript — dot access and bracket access are interchangeable; -assignment creates keys: +EigenScript — dot access and bracket access are interchangeable +(keyword-named fields too: `d.loop`, `d.when` — like JavaScript member +access, the position disambiguates); assignment creates keys: ```eigenscript user is {"name": "Ada", "year": 1815} diff --git a/docs/GRAMMAR.md b/docs/GRAMMAR.md index d09a1ca..d415bd0 100644 --- a/docs/GRAMMAR.md +++ b/docs/GRAMMAR.md @@ -249,11 +249,17 @@ lambda = '(' [ param_list ] ')' '=>' expression After any primary expression, zero or more postfix operations: ``` -postfix = primary { subscript | '.' IDENT } +postfix = primary { subscript | '.' word } +word = IDENT | any keyword ; #542 subscript = '[' expression ']' | '[' [ expression ] ':' [ expression ] ']' ; slice ``` +The dot-key position accepts any word, keywords included: nothing but a +field name can appear after `.`, so there is no ambiguity to protect +against, and keys creatable by literal, `dict_set`, and `json_decode` +(`"loop"`, `"in"`, `"when"`, …) stay reachable by dot. + Note: which postfix forms a primary accepts depends on the primary. IDENT, dict literals, parenthesized expressions, f-strings (which desugar to parenthesized expressions), and the soft-keyword identifier diff --git a/docs/SPEC.md b/docs/SPEC.md index dd3a176..48ac66b 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -576,6 +576,22 @@ print of app.users[1].id 2 ``` +Key names are not restricted by the keyword table: after `.` nothing but +a field name can appear, so any word — including keywords like `loop`, +`in`, or `when` (common in `json_decode` output) — works as a dot key, +read or write, at any chain depth. + +```eigenscript +ev is {"when": 3, "loop": 1} +ev.loop is ev.loop + 1 +print of ev.when +print of ev.loop +``` +```output +3 +2 +``` + ## Functions `define name(params) as:` introduces a function. `return` exits with a diff --git a/docs/SYNTAX.md b/docs/SYNTAX.md index f6634e4..4efec15 100644 --- a/docs/SYNTAX.md +++ b/docs/SYNTAX.md @@ -305,6 +305,11 @@ print of config.host # "localhost" print of config.port # 8080 ``` +Any word can follow the dot, including keywords — `d.loop`, `d.in`, +`d.when` all work: the position after `.` is unambiguous, so +keyword-named keys (e.g. from `json_decode` of external data) are +reachable by dot as well as by bracket. + **Bracket access:** ```eigenscript key is "host" diff --git a/src/eigenscript.h b/src/eigenscript.h index 7bcfdf8..be48c4a 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -108,6 +108,8 @@ typedef enum { TOK_NUM, TOK_STR, TOK_IDENT, + /* Word keywords: TOK_IS..TOK_LOCAL must stay contiguous — the parser's + * tok_is_dot_key accepts the whole run as dict field names after `.`. */ TOK_IS, TOK_OF, TOK_DEFINE, TOK_AS, TOK_IF, TOK_ELSE, TOK_ELIF, TOK_LOOP, TOK_WHILE, TOK_RETURN, TOK_AND, TOK_OR, TOK_NOT, diff --git a/src/parser.c b/src/parser.c index 3b330b8..76061ab 100644 --- a/src/parser.c +++ b/src/parser.c @@ -153,6 +153,24 @@ static void p_expect_ident_like(Parser *p) { p_advance(p); } +/* Dot keys: after `.` nothing but a field name can appear, so the position is + * unambiguous and ANY word may serve as a key — TOK_IDENT plus every word + * keyword (the contiguous TOK_IS..TOK_LOCAL run; all are lexed through the + * identifier path, so str_val always carries the source word). Keys like + * "loop"/"in"/"when" are creatable via literals, dict_set, and json_decode; + * rejecting them here made those fields reachable only by bracket (#542). */ +static int tok_is_dot_key(TokType type) { + return type == TOK_IDENT || (type >= TOK_IS && type <= TOK_LOCAL); +} + +static void p_expect_dot_key(Parser *p) { + if (!tok_is_dot_key(p_cur(p)->type)) { + p_expect(p, TOK_IDENT); /* raises + advances */ + return; + } + p_advance(p); +} + static void p_skip_newlines(Parser *p) { while (p_cur(p)->type == TOK_NEWLINE) p_advance(p); } @@ -666,7 +684,7 @@ static ASTNode* parse_postfix_chain(Parser *p, ASTNode *n) { if (p_cur(p)->type == TOK_DOT) { p_advance(p); Token *key_tok = p_cur(p); - p_expect(p, TOK_IDENT); + p_expect_dot_key(p); ASTNode *dot = make_node_col(AST_DOT, p_cur(p)->line, key_tok->col); dot->data.dot.target = n; dot->data.dot.key = xstrdup(key_tok->str_val); @@ -849,7 +867,7 @@ static ASTNode* parse_primary(Parser *p) { if (p_cur(p)->type == TOK_DOT) { p_advance(p); Token *key_tok = p_cur(p); - p_expect(p, TOK_IDENT); + p_expect_dot_key(p); ASTNode *dot = make_node_col(AST_DOT, key_tok->line, key_tok->col); dot->data.dot.target = expr; dot->data.dot.key = xstrdup(key_tok->str_val); @@ -957,7 +975,7 @@ static ASTNode* parse_primary(Parser *p) { if (p_cur(p)->type == TOK_DOT) { p_advance(p); Token *key_tok = p_cur(p); - p_expect(p, TOK_IDENT); + p_expect_dot_key(p); ASTNode *dot = make_node_col(AST_DOT, p_cur(p)->line, key_tok->col); dot->data.dot.target = n; dot->data.dot.key = xstrdup(key_tok->str_val); diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 5a1a782..3379220 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1057,6 +1057,13 @@ echo "[117] for-in Length Snapshot (#491, 9 checks)" check_eigs_suite "for-in snapshots length; body mutation is bounded + safe" \ test_for_in_mutation.eigs "FOR_IN_MUTATION_DONE" 9 +# [118] Any keyword works as a dot key (#542): keys creatable by literal/ +# dict_set/json_decode were unreachable by `.` — read, write, chains, and +# all three parser postfix sites (IDENT chain, paren, dict literal). +echo "[118] Keyword Dot Keys (#542, 49 checks)" +check_eigs_suite "all 39 keywords + chains/json/paren/literal as dot keys" \ + test_dict_keyword_keys.eigs "All tests passed" 49 + # [23] Named parameters echo "[23/27] Named Parameters (9 checks)" NP_OUTPUT=$(./eigenscript ../tests/test_named_params.eigs 2>&1); NP_OUTPUT_RC=$? diff --git a/tests/test_dict_keyword_keys.eigs b/tests/test_dict_keyword_keys.eigs new file mode 100644 index 0000000..dbf59b9 --- /dev/null +++ b/tests/test_dict_keyword_keys.eigs @@ -0,0 +1,162 @@ +# Regression #542: any keyword works as a dot key — the position after `.` +# is unambiguous, so keys creatable by literal/dict_set/json_decode must be +# reachable (read AND write, any chain depth) by dot, not just bracket. +load_file of "lib/test.eigs" + +# Every word keyword (TOK_IS..TOK_LOCAL) as a dot key: literal create, +# dot-write (read-modify), dot-read. +d is {"loop": 1} +d.loop is d.loop + 1 +assert_eq of [d.loop, 2, "dot key: loop"] +d is {"if": 1} +d.if is d.if + 1 +assert_eq of [d.if, 2, "dot key: if"] +d is {"return": 1} +d.return is d.return + 1 +assert_eq of [d.return, 2, "dot key: return"] +d is {"of": 1} +d.of is d.of + 1 +assert_eq of [d.of, 2, "dot key: of"] +d is {"is": 1} +d.is is d.is + 1 +assert_eq of [d.is, 2, "dot key: is"] +d is {"and": 1} +d.and is d.and + 1 +assert_eq of [d.and, 2, "dot key: and"] +d is {"or": 1} +d.or is d.or + 1 +assert_eq of [d.or, 2, "dot key: or"] +d is {"not": 1} +d.not is d.not + 1 +assert_eq of [d.not, 2, "dot key: not"] +d is {"while": 1} +d.while is d.while + 1 +assert_eq of [d.while, 2, "dot key: while"] +d is {"for": 1} +d.for is d.for + 1 +assert_eq of [d.for, 2, "dot key: for"] +d is {"in": 1} +d.in is d.in + 1 +assert_eq of [d.in, 2, "dot key: in"] +d is {"try": 1} +d.try is d.try + 1 +assert_eq of [d.try, 2, "dot key: try"] +d is {"catch": 1} +d.catch is d.catch + 1 +assert_eq of [d.catch, 2, "dot key: catch"] +d is {"import": 1} +d.import is d.import + 1 +assert_eq of [d.import, 2, "dot key: import"] +d is {"local": 1} +d.local is d.local + 1 +assert_eq of [d.local, 2, "dot key: local"] +d is {"define": 1} +d.define is d.define + 1 +assert_eq of [d.define, 2, "dot key: define"] +d is {"elif": 1} +d.elif is d.elif + 1 +assert_eq of [d.elif, 2, "dot key: elif"] +d is {"else": 1} +d.else is d.else + 1 +assert_eq of [d.else, 2, "dot key: else"] +d is {"as": 1} +d.as is d.as + 1 +assert_eq of [d.as, 2, "dot key: as"] +d is {"null": 1} +d.null is d.null + 1 +assert_eq of [d.null, 2, "dot key: null"] +d is {"break": 1} +d.break is d.break + 1 +assert_eq of [d.break, 2, "dot key: break"] +d is {"continue": 1} +d.continue is d.continue + 1 +assert_eq of [d.continue, 2, "dot key: continue"] +d is {"match": 1} +d.match is d.match + 1 +assert_eq of [d.match, 2, "dot key: match"] +d is {"case": 1} +d.case is d.case + 1 +assert_eq of [d.case, 2, "dot key: case"] +d is {"unobserved": 1} +d.unobserved is d.unobserved + 1 +assert_eq of [d.unobserved, 2, "dot key: unobserved"] +d is {"converged": 1} +d.converged is d.converged + 1 +assert_eq of [d.converged, 2, "dot key: converged"] +d is {"stable": 1} +d.stable is d.stable + 1 +assert_eq of [d.stable, 2, "dot key: stable"] +d is {"improving": 1} +d.improving is d.improving + 1 +assert_eq of [d.improving, 2, "dot key: improving"] +d is {"oscillating": 1} +d.oscillating is d.oscillating + 1 +assert_eq of [d.oscillating, 2, "dot key: oscillating"] +d is {"diverging": 1} +d.diverging is d.diverging + 1 +assert_eq of [d.diverging, 2, "dot key: diverging"] +d is {"equilibrium": 1} +d.equilibrium is d.equilibrium + 1 +assert_eq of [d.equilibrium, 2, "dot key: equilibrium"] + +# The documented soft keywords (SYNTAX.md promised these worked already). +d is {"prev": 1} +d.prev is d.prev + 1 +assert_eq of [d.prev, 2, "dot key: prev"] +d is {"at": 1} +d.at is d.at + 1 +assert_eq of [d.at, 2, "dot key: at"] +d is {"what": 1} +d.what is d.what + 1 +assert_eq of [d.what, 2, "dot key: what"] +d is {"who": 1} +d.who is d.who + 1 +assert_eq of [d.who, 2, "dot key: who"] +d is {"when": 1} +d.when is d.when + 1 +assert_eq of [d.when, 2, "dot key: when"] +d is {"where": 1} +d.where is d.where + 1 +assert_eq of [d.where, 2, "dot key: where"] +d is {"why": 1} +d.why is d.why + 1 +assert_eq of [d.why, 2, "dot key: why"] +d is {"how": 1} +d.how is d.how + 1 +assert_eq of [d.how, 2, "dot key: how"] + +# Chained access through a keyword key, read/write/compound. +d2 is {"x": {"when": 7}} +assert_eq of [d2.x.when, 7, "chained keyword key read"] +d2.x.when is 8 +assert_eq of [d2.x.when, 8, "chained keyword key write"] +d2.x.when += 2 +assert_eq of [d2.x.when, 10, "chained keyword key compound assign"] + +# json_decode of external data — the original DeslanStudios report. +j is json_decode of ("{\"in\": 4, \"if\": {\"for\": 5}}") +assert_eq of [j.in, 4, "json_decode keyword key"] +assert_eq of [j.if.for, 5, "json_decode nested keyword chain"] + +# The parenthesized-expression and dict-literal postfix chains (separate +# parser sites from the IDENT chain). +p is ({"at": 9}) +assert_eq of [p.at, 9, "paren postfix keyword key"] +lit is {"try": 10}.try +assert_eq of [lit, 10, "dict-literal postfix keyword key"] + +# dict_set-created key, dot-read; dot-created key, bracket-read (agreement). +e is {} +dict_set of [e, "loop", 5] +assert_eq of [e.loop, 5, "dict_set key dot-read"] +e.while is 6 +assert_eq of [e["while"], 6, "dot-created key bracket-read"] + +# Keyword still functions as a keyword right next to its key use. +total is 0 +src is {"in": [1, 2]} +for x in src.in: + total is total + x +assert_eq of [total, 3, "for-in iterates over d.in"] + +test_summary of null