From 450b100ed37c8eafbc1b356919594ddeb341f25d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:08:08 +0000 Subject: [PATCH 1/4] Track jsonic and jsonic/go from GitHub main - package.json: peerDep jsonic -> github:jsonicjs/jsonic#main - go/go.mod: jsonic/go -> v0.1.19-0.20260418194431-54100be22847 (main) TS 64/64 and Go 727/727 subtests pass. https://claude.ai/code/session_017W6amvYxE2ZmaTp3obEVS2 --- go/go.mod | 2 +- go/go.sum | 2 ++ package.json | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go/go.mod b/go/go.mod index d23be34..33f757f 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,4 +2,4 @@ module github.com/jsonicjs/expr/go go 1.24.7 -require github.com/jsonicjs/jsonic/go v0.1.18 +require github.com/jsonicjs/jsonic/go v0.1.19-0.20260418194431-54100be22847 diff --git a/go/go.sum b/go/go.sum index be07d2e..8592797 100644 --- a/go/go.sum +++ b/go/go.sum @@ -20,3 +20,5 @@ github.com/jsonicjs/jsonic/go v0.1.15 h1:gky2G3ZYM12+7R4TgQNKaM42C33DDdtRs4xdde/ github.com/jsonicjs/jsonic/go v0.1.15/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= github.com/jsonicjs/jsonic/go v0.1.18 h1:OW15hjFisrw2n7HE6zDuQAikW8A5NUW8OyP4SCG2oFg= github.com/jsonicjs/jsonic/go v0.1.18/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= +github.com/jsonicjs/jsonic/go v0.1.19-0.20260418194431-54100be22847 h1:+utFlbRO7upKu+DLO9tjUzWHacbxwsyiuFcCkylV3IA= +github.com/jsonicjs/jsonic/go v0.1.19-0.20260418194431-54100be22847/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= diff --git a/package.json b/package.json index 59cf1ad..c0b4865 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,6 @@ "typescript": "^5.7.3" }, "peerDependencies": { - "jsonic": "^2.24.0" + "jsonic": "github:jsonicjs/jsonic#main" } } From 4785c254a860352ef208835f97d7f9098e32fafc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:18:12 +0000 Subject: [PATCH 2/4] Tag plugin-added alts with g=expr via jsonic rule.alt.g option Jsonic appends this tag to every alt registered through jsonic.rule() during plugin setup, giving callers a single group to filter or exclude expr alts by. https://claude.ai/code/session_017W6amvYxE2ZmaTp3obEVS2 --- src/expr.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/expr.ts b/src/expr.ts index 966ccc0..ca1b63b 100644 --- a/src/expr.ts +++ b/src/expr.ts @@ -180,6 +180,12 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { comment: { order: 1e5 }, }, }, + + rule: { + alt: { + g: 'expr', + }, + }, }) const PREFIX = values(prefixTM).map((op: any) => op.tin) From 2b3d9f3298d4deabe74b0df6efef6405923a84da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:15:06 +0000 Subject: [PATCH 3/4] Tag plugin-added alts with g=expr (TS and Go) Replace the earlier no-op TS options.rule.alt.g with an actual tagging pass. jsonic only applies rule.alt.g when it comes from the setting arg to jsonic.grammar(); jsonic.options() stores the value but nothing reads it. Since the plugin uses jsonic.rule() (not grammar()), tag the alts manually: - TS: wrap jsonic.rule() in a local rule() helper that overrides rs.open/rs.close to append "expr" to each passed alt's g. - Go: wrap j.Rule() in modifyRule() that snapshots pre-existing alt pointers on rs.Open/rs.Close and tags only the newly-added ones. For plugin-created rules (expr, paren, ternary) assigned via RSM(), tagAllAlts tags every alt in the spec before install. Pre-existing alts on val/list/map/pair/elem are untouched. Verification (Go): expr Open 3/3 Close 11/11 paren Open 2/2 Close 1/1 val Open 2/11 Close 4/10 list Open 0/5 Close 1/3 map Open 0/4 Close 1/5 pair Open 0/2 Close 1/11 elem Open 0/4 Close 2/8 TS 64/64 and Go 727/727 tests pass. https://claude.ai/code/session_017W6amvYxE2ZmaTp3obEVS2 --- go/expr.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++---- src/expr.ts | 48 +++++++++++++++++++++++++++----------- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/go/expr.go b/go/expr.go index e4aefac..ad3ded7 100644 --- a/go/expr.go +++ b/go/expr.go @@ -207,8 +207,62 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { mkS := func(tins []int) [][]int { return [][]int{tins} } + // appendExprTag appends "expr" to the alt's G (group tag), mirroring + // the TS plugin's tagExpr helper — which in turn mirrors the jsonic + // grammar(...) setting {rule:{alt:{g:'expr'}}}. Applied manually + // because the plugin uses j.Rule() (not j.Grammar()). + appendExprTag := func(a *jsonic.AltSpec) { + if a == nil { + return + } + if a.G == "" { + a.G = "expr" + } else { + a.G = a.G + ",expr" + } + } + + // modifyRule wraps j.Rule(): snapshot the existing alt pointers on + // rs.Open/rs.Close, run the modifier, then tag only the alts the + // modifier added (by identity) with "expr". + modifyRule := func(name string, fn func(rs *jsonic.RuleSpec)) { + j.Rule(name, func(rs *jsonic.RuleSpec) { + preOpen := make(map[*jsonic.AltSpec]bool, len(rs.Open)) + for _, a := range rs.Open { + preOpen[a] = true + } + preClose := make(map[*jsonic.AltSpec]bool, len(rs.Close)) + for _, a := range rs.Close { + preClose[a] = true + } + fn(rs) + for _, a := range rs.Open { + if !preOpen[a] { + appendExprTag(a) + } + } + for _, a := range rs.Close { + if !preClose[a] { + appendExprTag(a) + } + } + }) + } + + // tagAllAlts tags every alt on the given rule spec with "expr". + // Used for plugin-created rules (expr, paren, ternary) where every + // alt is plugin-added. + tagAllAlts := func(rs *jsonic.RuleSpec) { + for _, a := range rs.Open { + appendExprTag(a) + } + for _, a := range rs.Close { + appendExprTag(a) + } + } + // === VAL rule modifications === - j.Rule("val", func(rs *jsonic.RuleSpec) { + modifyRule("val", func(rs *jsonic.RuleSpec) { // Prefix operator: backtrack and push to 'expr'. if hasPrefix { rs.Open = append([]*jsonic.AltSpec{{ @@ -371,7 +425,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { }) // === LIST rule modifications === - j.Rule("list", func(rs *jsonic.RuleSpec) { + modifyRule("list", func(rs *jsonic.RuleSpec) { rs.BO = append(rs.BO, func(r *jsonic.Rule, ctx *jsonic.Context) { if r.Prev == nil || r.Prev == jsonic.NoRule || r.Prev.U["implist"] == nil { r.N["expr"] = 0 @@ -404,7 +458,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { }) // === MAP rule modifications === - j.Rule("map", func(rs *jsonic.RuleSpec) { + modifyRule("map", func(rs *jsonic.RuleSpec) { rs.BO = append(rs.BO, func(r *jsonic.Rule, ctx *jsonic.Context) { r.N["expr"] = 0 r.N["expr_prefix"] = 0 @@ -427,7 +481,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { }) // === PAIR rule modifications === - j.Rule("pair", func(rs *jsonic.RuleSpec) { + modifyRule("pair", func(rs *jsonic.RuleSpec) { if hasParen { rs.Close = append([]*jsonic.AltSpec{{ S: mkS(CP), @@ -441,7 +495,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { }) // === ELEM rule modifications === - j.Rule("elem", func(rs *jsonic.RuleSpec) { + modifyRule("elem", func(rs *jsonic.RuleSpec) { if hasParen { // Close implicit list within parens when ')' is seen. rs.Close = append([]*jsonic.AltSpec{ @@ -833,6 +887,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { }, } + tagAllAlts(exprSpec) j.RSM()["expr"] = exprSpec // === PAREN rule === @@ -976,6 +1031,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { }, } + tagAllAlts(parenSpec) j.RSM()["paren"] = parenSpec } @@ -1157,6 +1213,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { }, } + tagAllAlts(ternarySpec) j.RSM()["ternary"] = ternarySpec } diff --git a/src/expr.ts b/src/expr.ts index ca1b63b..4140632 100644 --- a/src/expr.ts +++ b/src/expr.ts @@ -180,14 +180,34 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { comment: { order: 1e5 }, }, }, - - rule: { - alt: { - g: 'expr', - }, - }, }) + // Append 'expr' to the g (group tag) of every alt added below. Mirrors + // the jsonic grammar(...) setting {rule:{alt:{g:'expr'}}}, applied + // manually because the plugin uses jsonic.rule() (not jsonic.grammar()). + const tagExpr = (alts: any): any => { + if (!Array.isArray(alts)) return alts + return alts.map((a) => { + if (null == a || 'object' !== typeof a) return a + const existing = null == a.g + ? [] + : Array.isArray(a.g) + ? [...a.g] + : String(a.g).split(/\s*,\s*/).filter((s: string) => s.length > 0) + return { ...a, g: [...existing, 'expr'] } + }) + } + + const rule = (name: string, fn: (rs: RuleSpec) => void) => { + jsonic.rule(name, (rs: RuleSpec) => { + const origOpen = rs.open.bind(rs) + const origClose = rs.close.bind(rs) + ;(rs as any).open = (alts: any, flags?: any) => origOpen(tagExpr(alts), flags) + ;(rs as any).close = (alts: any, flags?: any) => origClose(tagExpr(alts), flags) + fn(rs) + }) + } + const PREFIX = values(prefixTM).map((op: any) => op.tin) const INFIX = values(infixTM).map((op: any) => op.tin) const SUFFIX = values(suffixTM).map((op: any) => op.tin) @@ -221,7 +241,7 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { const NONE = null as unknown as AltSpec - jsonic.rule('val', (rs: RuleSpec) => { + rule('val', (rs: RuleSpec) => { // TODO: jsonic - make it easier to handle this case // Implicit pair not allowed inside ternary if (hasTernary && TERN1.includes(jsonic.token.CL)) { @@ -400,7 +420,7 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { }) - jsonic.rule('list', (rs: RuleSpec) => { + rule('list', (rs: RuleSpec) => { // rs.bo(false, (...rest: any) => { rs.bo(false, (r: Rule) => { // List elements are new expressions. @@ -422,7 +442,7 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { ]) }) - jsonic.rule('map', (rs: RuleSpec) => { + rule('map', (rs: RuleSpec) => { rs.bo(false, (...rest: any) => { // Map values are new expressions. rest[0].n.expr = 0 @@ -439,7 +459,7 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { ]) }) - jsonic.rule('elem', (rs: RuleSpec) => { + rule('elem', (rs: RuleSpec) => { rs.close([ // Close implicit list within parens. hasParen @@ -463,7 +483,7 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { ]) }) - jsonic.rule('pair', (rs: RuleSpec) => { + rule('pair', (rs: RuleSpec) => { rs.close([ // Close implicit map within parens. hasParen @@ -477,7 +497,7 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { ]) }) - jsonic.rule('expr', (rs: RuleSpec) => { + rule('expr', (rs: RuleSpec) => { rs.open([ // An opening parenthesis of an expression. @@ -727,7 +747,7 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { }) }) - jsonic.rule('paren', (rs: RuleSpec) => { + rule('paren', (rs: RuleSpec) => { rs.bo((r: Rule) => { // Allow implicits inside parens r.n.dmap = 0 @@ -795,7 +815,7 @@ let Expr: Plugin = function Expr(jsonic: Jsonic, options: ExprOptions) { // Ternary operators are like fancy parens. if (hasTernary) { - jsonic.rule('ternary', (rs: RuleSpec) => { + rule('ternary', (rs: RuleSpec) => { rs.open([ { s: [TERN0], From ba3b86880ca88c1099afaea0933bf1fa91122f7b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 19:38:26 +0000 Subject: [PATCH 4/4] Update jsonic to latest; add Makefile; restructure docs (Diataxis) Versions: - TS peerDep jsonic: github main -> ^2.25.1 (npm latest) - Go jsonic/go: v0.1.19 (released, no longer a pseudo-version) Go port: jsonic/go v0.1.19 changed two signatures. - RuleDefiner now takes (rs *RuleSpec, p *Parser); update the modifyRule wrapper to match. - Token.ResolveVal now takes (rule, ctx); pass them through. Makefile modeled on jsonicjs/ini: build/test/clean targets split by language, plus tidy-go, tags-go, publish-go, and a full reset. Added a Version const to go/expr.go so the publish-go sed target mirrors ini's. Docs restructured per diataxis.fr: - README.md is now a landing page with install + pointers. - docs/tutorial.md: first-expression walkthrough (TS and Go). - docs/how-to.md: seven task-oriented recipes (custom ops, preval, ternary, disable-default, group filter). - docs/reference.md: exports table per language, OpDef/ExprOptions/ Evaluate types, default op table, AST shape, group tags. - docs/explanation.md: Pratt parsing overview, why S-expressions, paren/preval semantics, ternary as two-token op, how the plugin plugs into Jsonic, the g=expr tagging convention. TS 64/64 and Go 727/727 tests pass via `make test`. https://claude.ai/code/session_017W6amvYxE2ZmaTp3obEVS2 --- Makefile | 51 ++++++++++++++++ README.md | 42 ++++++++++--- docs/explanation.md | 96 ++++++++++++++++++++++++++++++ docs/how-to.md | 130 ++++++++++++++++++++++++++++++++++++++++ docs/reference.md | 136 ++++++++++++++++++++++++++++++++++++++++++ docs/tutorial.md | 142 ++++++++++++++++++++++++++++++++++++++++++++ go/expr.go | 9 ++- go/go.mod | 2 +- go/go.sum | 2 + package.json | 2 +- 10 files changed, 598 insertions(+), 14 deletions(-) create mode 100644 Makefile create mode 100644 docs/explanation.md create mode 100644 docs/how-to.md create mode 100644 docs/reference.md create mode 100644 docs/tutorial.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c3fb1b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +.PHONY: all build test clean build-ts build-go test-ts test-go clean-ts clean-go publish-go tags-go tidy-go reset + +all: build test + +build: build-ts build-go + +test: test-ts test-go + +clean: clean-ts clean-go + +# TypeScript +build-ts: + npm run build + +test-ts: + npm test + +clean-ts: + rm -rf dist dist-test + +# Go +build-go: + cd go && go build ./... + +test-go: + cd go && go test ./... + +clean-go: + cd go && go clean -cache + +# Publish Go module: make publish-go V=0.1.7 +publish-go: test-go + @test -n "$(V)" || (echo "Usage: make publish-go V=x.y.z" && exit 1) + sed -i '' 's/^const Version = ".*"/const Version = "$(V)"/' go/expr.go + git add go/expr.go + git commit -m "go: v$(V)" + git tag go/v$(V) + git push origin main go/v$(V) + if command -v gh >/dev/null 2>&1; then gh release create go/v$(V) --title "go/v$(V)" --notes "Go module release v$(V)"; fi + +tidy-go: + cd go && go mod tidy + +tags-go: + git tag -l 'go/v*' --sort=-version:refname + +reset: + npm run reset + cd go && go clean -cache + cd go && go build ./... + cd go && go test -v ./... diff --git a/README.md b/README.md index 3377742..9f42e53 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,44 @@ -# @jsonic/expr (JSONIC syntax plugin) +# @jsonic/expr -This plugin allows the [Jsonic](https://jsonic.senecajs.org) JSON -parser to support expression syntax. +An expression-syntax plugin for the [Jsonic](https://jsonic.senecajs.org) +parser, available in both TypeScript and Go. +Adds Pratt-parser expressions to Jsonic: infix, prefix, suffix, ternary, +and paren operators with configurable precedence. Expressions parse into +LISP-style S-expressions (arrays whose first element is the operator src), +which a user-supplied evaluator can reduce to values. [![npm version](https://img.shields.io/npm/v/@jsonic/expr.svg)](https://npmjs.com/package/@jsonic/expr) [![build](https://github.com/jsonicjs/expr/actions/workflows/build.yml/badge.svg)](https://github.com/jsonicjs/expr/actions/workflows/build.yml) -[![Coverage Status](https://coveralls.io/repos/github/jsonicjs/expr/badge.svg?branch=main)](https://coveralls.io/github/jsonicjs/expr?branch=main) -[![Known Vulnerabilities](https://snyk.io/test/github/jsonicjs/expr/badge.svg)](https://snyk.io/test/github/jsonicjs/expr) -[![DeepScan grade](https://deepscan.io/api/teams/5016/projects/22469/branches/663909/badge/grade.svg)](https://deepscan.io/dashboard#view=project&tid=5016&pid=22469&bid=663909) -[![Maintainability](https://api.codeclimate.com/v1/badges/d44aad76c5a355d01f30/maintainability)](https://codeclimate.com/github/jsonicjs/expr/maintainability) -| ![Voxgig](https://www.voxgig.com/res/img/vgt01r.png) | This open source module is sponsored and supported by [Voxgig](https://www.voxgig.com). | -| ---------------------------------------------------- | --------------------------------------------------------------------------------------- | +## Install +TypeScript: +```sh +npm install @jsonic/expr jsonic +``` +Go: +```sh +go get github.com/jsonicjs/expr/go +``` +## Documentation + +Docs are organised following the [Diátaxis](https://diataxis.fr) framework: + +- **[Tutorial](docs/tutorial.md)** — start here. Parse your first expression in + TS and Go. +- **[How-to guides](docs/how-to.md)** — focused recipes: add an operator, + plug in an evaluator, use paren-preval for function calls, restrict to + strict math. +- **[Reference](docs/reference.md)** — exported types and functions, + `OpDef` schema, default operator set, grammar group tags. +- **[Explanation](docs/explanation.md)** — design notes: Pratt algorithm, + S-expression AST, paren/ternary/preval semantics, why `g=expr` tagging. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/docs/explanation.md b/docs/explanation.md new file mode 100644 index 0000000..c77e11a --- /dev/null +++ b/docs/explanation.md @@ -0,0 +1,96 @@ +# Explanation + +Background reading on how the plugin works. Useful before you customise +precedence tables or diagnose a surprising parse. + +- [Pratt parsing in one page](#pratt-parsing-in-one-page) +- [Why S-expressions](#why-s-expressions) +- [Paren semantics and preval](#paren-semantics-and-preval) +- [Ternary as a two-token operator](#ternary-as-a-two-token-operator) +- [How the plugin plugs into Jsonic](#how-the-plugin-plugs-into-jsonic) +- [The `g=expr` tagging convention](#the-gexpr-tagging-convention) + +--- + +## Pratt parsing in one page + +Pratt parsing is a top-down operator-precedence algorithm where every +operator carries two numbers: a _left binding power_ (how strongly it +pulls in the term to its left) and a _right binding power_ (how strongly +it pulls to the right). + +When the parser sits between two operators, it compares the left-side +operator's _right_ BP to the right-side operator's _left_ BP. Whichever +is higher wins the shared term. + +That's enough to handle precedence (`*` beats `+`), associativity +(left-associative when `left < right`, right-associative when +`left > right`), and mixed pre/in/post-fix operators without separate +grammar productions per operator. + +## Why S-expressions + +The parse result is an array starting with the operator: +`['+', 1, ['*', 2, 3]]`. This has three properties we want: + +- **Uniform shape**: infix, prefix, suffix, ternary, and paren all fit + the same `[op, ...terms]` template. +- **Easy to walk**: a single recursive function (your `evaluate`) can + fold the tree bottom-up. +- **Round-trippable**: `Simplify` converts to pure strings/arrays/maps, + which survives `JSON.stringify` and deep-equality comparisons. + +## Paren semantics and preval + +Parens are modelled as unary operators whose single term is whatever is +inside. So `(1+2)` parses as `['(', ['+', 1, 2]]` rather than collapsing +the parens away. Keeping the paren op in the tree lets evaluators +distinguish grouping from implicit structure (important when you add +paren kinds like `[...]` that mean "list" rather than "group"). + +`preval` extends this: a paren op can consume the token preceding `(` +as an extra operand, turning `foo(1,2)` into `['(', 'foo', 1, 2]`. +Combined with `preval.allow`, this gives you named function-call syntax +without needing a full function grammar. + +## Ternary as a two-token operator + +`a?b:c` is a single operator with two syntactic tokens (`?` and `:`) and +three operands. The plugin exposes this as a normal `OpDef` with +`src: ['?', ':']` and `ternary: true`. Nesting is governed by the same +BP comparison as binary operators — `1?2:3?4:5` chains right because the +ternary op is right-associative by convention. + +## How the plugin plugs into Jsonic + +On `use`, the plugin: + +1. Registers each operator token via `jsonic.options({ fixed: ... })` + so the lexer recognises them. +2. Extends a few existing Jsonic rules (`val`, `list`, `map`, `pair`, + `elem`) with alts that backtrack when an operator token appears and + hand control to the plugin's own `expr` rule. +3. Defines three new rules — `expr`, `paren`, and (optional) `ternary` + — that implement the Pratt algorithm as ordered alternates. + +The result is that expression syntax is additive: it enters via +backtracking from the existing value/list/map rules, and returns the +same kind of node (a value) back to them when it finishes. + +## The `g=expr` tagging convention + +Every alt the plugin adds carries `expr` in its grammar group (`g`) +field. Users of a shared Jsonic instance can then strip the expression +grammar cleanly: + +```ts +jsonic.options({ rule: { exclude: 'expr' } }) +``` + +Internally the plugin tags alts by snapshotting `rs.Open`/`rs.Close` +before each rule modifier and appending `expr` only to newly-added +alts. Pre-existing alts (the base Jsonic grammar) are left untouched. + +This mirrors jsonic's own `GrammarSetting.Rule.Alt.G` mechanism, which +applies when rules are installed via `jsonic.grammar()` (the plugin +uses `jsonic.rule()` instead, so the tag is applied manually). diff --git a/docs/how-to.md b/docs/how-to.md new file mode 100644 index 0000000..6f966eb --- /dev/null +++ b/docs/how-to.md @@ -0,0 +1,130 @@ +# How-to guides + +Focused recipes. Each guide assumes you've read the [Tutorial](tutorial.md). + +- [Add a custom infix operator](#add-a-custom-infix-operator) +- [Add a prefix or suffix operator](#add-a-prefix-or-suffix-operator) +- [Group with parentheses](#group-with-parentheses) +- [Build function-call syntax with paren-preval](#build-function-call-syntax-with-paren-preval) +- [Add a ternary operator](#add-a-ternary-operator) +- [Disable a default operator](#disable-a-default-operator) +- [Strip expr alts with a group filter](#strip-expr-alts-with-a-group-filter) + +--- + +## Add a custom infix operator + +Define an operator by name under `op`. Give it an `src` token and a +`left`/`right` precedence pair (lower number = lower priority). +Left-associative operators have `left < right`; right-associative have +`left > right`. + +TypeScript: + +```ts +Jsonic.make().use(Expr, { + op: { + power: { infix: true, src: '^', left: 260, right: 250 }, // right-assoc + }, +})('2^3^2') // ['^', 2, ['^', 3, 2]] +``` + +Go: + +```go +expr.Parse("2^3^2", map[string]interface{}{ + "op": map[string]interface{}{ + "power": map[string]interface{}{ + "infix": true, "src": "^", "left": 260, "right": 250, + }, + }, +}) +``` + +## Add a prefix or suffix operator + +Prefix operators use `right` only; suffix operators use `left` only. + +```ts +Expr, { + op: { + bang: { prefix: true, src: '!', right: 200 }, // !x + factor: { suffix: true, src: '!', left: 210 }, // x! + }, +} +``` + +Because both overloads share the `!` src, Jsonic disambiguates by +position: `!` appearing before a term is prefix, after a term is suffix. + +## Group with parentheses + +Parens are defined as operators with `paren: true`, `osrc`, `csrc`: + +```ts +Expr, { + op: { + brace: { paren: true, osrc: '[', csrc: ']' }, + }, +} +``` + +The default operator set already includes `plain: { paren:true, osrc:'(', +csrc:')' }`. Multiple paren kinds can coexist. + +## Build function-call syntax with paren-preval + +`preval` turns `foo(1,2)` into a paren expression whose `preval` value +is `foo`. Useful for call-like syntax. + +```ts +Expr, { + op: { + call: { + paren: true, osrc: '(', csrc: ')', + preval: { active: true, required: true }, + }, + }, +} +``` + +With `preval.required`, a paren only matches when preceded by a value. +Use `preval.allow: ['foo','bar']` to restrict which preceding names are +valid. + +## Add a ternary operator + +Ternary is defined by two tokens in `src`: + +```ts +Expr, { + op: { + cond: { ternary: true, src: ['?', ':'], left: 80, right: 90 }, + }, +} +``` + +Parses `a ? b : c` as `['?', a, b, c]`. + +## Disable a default operator + +Set the named op to `null` in your options: + +```ts +Jsonic.make().use(Expr, { op: { remainder: null } }) +``` + +`1%2` now fails to parse — no `%` operator is registered. + +## Strip expr alts with a group filter + +Every grammar alternate added by the plugin is tagged with `expr` in +its `g` (group) field. Use Jsonic's `rule.exclude` option to remove +the expression grammar entirely: + +```ts +Jsonic.make().use(Expr).options({ rule: { exclude: 'expr' } }) +``` + +This is how you temporarily reuse a shared Jsonic instance without +expression parsing. diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..641fcb3 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,136 @@ +# Reference + +Exported API for `@jsonic/expr` (TypeScript) and +`github.com/jsonicjs/expr/go`. + +- [Exports](#exports) +- [Types](#types) +- [Plugin options](#plugin-options) +- [Default operators](#default-operators) +- [AST shape](#ast-shape) +- [Group tags](#group-tags) + +--- + +## Exports + +### TypeScript (`@jsonic/expr`) + +| Symbol | Kind | Description | +| ------------ | ----- | --------------------------------------------------------- | +| `Expr` | plugin| Jsonic plugin. Pass to `jsonic.use(Expr, opts?)`. | +| `evaluation` | fn | Internal evaluator entry used by the plugin. | +| `testing` | obj | Internal helpers exposed for the test suite. | +| `ExprOptions`| type | Shape of the plugin options argument. | +| `OpDef` | type | Shape of a single operator definition. | +| `Op` | type | Full operator descriptor passed to evaluators. | +| `Evaluate` | type | Signature of a user evaluator. | + +### Go (`github.com/jsonicjs/expr/go`) + +| Symbol | Kind | Description | +| -------------- | ------ | ------------------------------------------------------------ | +| `Expr` | func | Jsonic plugin. `j.Use(Expr, opts...)`. | +| `Parse` | func | Convenience: parse a string, returning `(any, error)`. | +| `MakeJsonic` | func | Build a `*jsonic.Jsonic` pre-configured with `Expr`. | +| `Simplify` | func | Render an AST with operator `src` strings for inspection. | +| `Version` | const | Module version string. | +| `OpDef` | type | Operator definition (user-supplied). | +| `Op` | type | Full operator descriptor (plugin-resolved). | +| `ExprOptions` | type | Typed options struct. | +| `PrevalDef` | type | Paren-preval settings. | + +## Types + +### `OpDef` + +Fields are optional; the combination of flags decides the operator kind. + +| Field | Type | Notes | +| --------- | --------------------------- | ----------------------------------------------------- | +| `src` | `string \| string[]` | Token source. `string[]` for ternary. | +| `osrc` | `string` | Paren open src (when `paren:true`). | +| `csrc` | `string` | Paren close src (when `paren:true`). | +| `left` | `number` | Left binding power (infix/suffix). | +| `right` | `number` | Right binding power (infix/prefix). | +| `prefix` | `boolean` | Prefix operator, e.g. `-x`. | +| `suffix` | `boolean` | Suffix operator, e.g. `x!`. | +| `infix` | `boolean` | Infix operator, e.g. `a+b`. | +| `ternary` | `boolean` | Ternary operator; requires `src: [openTok, closeTok]`.| +| `paren` | `boolean` | Parenthesis operator. | +| `preval` | `{active, required, allow}` | See [paren-preval recipe](how-to.md). | +| `use` | `any` | Arbitrary user data forwarded to evaluators on `Op`. | + +### `ExprOptions` + +```ts +type ExprOptions = { + op?: { [name: string]: OpDef | null } // null disables a default op + evaluate?: Evaluate +} +``` + +### `Evaluate` + +```ts +type Evaluate = (rule: Rule, ctx: Context, op: Op, ...terms: any[]) => any +``` + +Called bottom-up: by the time your evaluator runs, all term args are +already evaluated. Return the op's value. + +## Plugin options + +```ts +jsonic.use(Expr, { op: {...}, evaluate: fn }) +``` + +```go +j.Use(expr.Expr, map[string]any{ + "op": map[string]any{...}, + "evaluate": fn, +}) +``` + +Named ops in the `op` map merge with the defaults. Set a name to `null` +(TS) or omit / override it to disable a default op. + +## Default operators + +| Name | Kind | `src` | Precedence (left, right) | +| --------------- | ------ | ----- | ------------------------ | +| `positive` | prefix | `+` | _, 14000 | +| `negative` | prefix | `-` | _, 14000 | +| `addition` | infix | `+` | 140, 150 | +| `subtraction` | infix | `-` | 140, 150 | +| `multiplication`| infix | `*` | 160, 170 | +| `division` | infix | `/` | 160, 170 | +| `remainder` | infix | `%` | 160, 170 | +| `plain` | paren | `(` `)` | — | + +Higher numeric precedence binds tighter. `left < right` gives +left-associativity (`a+b+c` → `(a+b)+c`). + +## AST shape + +Expressions are arrays whose first element is the operator descriptor. +Remaining elements are the operands (terms), in source order. Terms can +be literals, maps, arrays, or nested expressions. + +```text +1+2 → [Op('+'), 1, 2] +-3 → [Op('-'), 3] // prefix +(1+2) → [Op('('), [Op('+'), 1, 2]] +foo(1,2) → [Op('(','foo'), 1, 2] // with paren-preval +a?b:c → [Op('?'), a, b, c] +``` + +`Simplify` (Go) or `testing.simplify` (TS) substitutes each `Op` with its +`src` string to produce a compact shape suitable for comparison. + +## Group tags + +Every alt the plugin adds is tagged with `expr` in its grammar group +(`g`) field, in addition to alt-specific tags like `expr,prefix`, +`expr,paren,open`, etc. Use Jsonic's `rule.include` / `rule.exclude` to +filter by these tags. diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..2e3380f --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,142 @@ +# Tutorial + +A walk-through of parsing and evaluating your first expression with +`@jsonic/expr`. By the end you will have: + +1. Parsed `1+2*3` into an S-expression AST +2. Plugged in an evaluator to compute the numeric result +3. Seen how expressions compose with the rest of Jsonic JSON + +The TypeScript and Go variants are kept side-by-side. Pick one language +and follow the column; the inputs and outputs match. + +--- + +## 1. Set up + +```sh +# TypeScript +npm install @jsonic/expr jsonic + +# Go +go get github.com/jsonicjs/expr/go +``` + +## 2. Parse an expression + +TypeScript: + +```ts +import { Jsonic } from 'jsonic' +import { Expr } from '@jsonic/expr' + +const j = Jsonic.make().use(Expr) + +console.log(j('1+2*3')) +// [ { src: '+', ... }, 1, [ { src: '*', ... }, 2, 3 ] ] +``` + +Go: + +```go +package main + +import ( + "fmt" + expr "github.com/jsonicjs/expr/go" +) + +func main() { + result, _ := expr.Parse("1+2*3") + fmt.Println(expr.Simplify(result)) + // ["+" 1 ["*" 2 3]] +} +``` + +The AST is an array whose first element is the operator. Operator +precedence (`*` binds tighter than `+`) shapes the tree without +parentheses. + +## 3. Add an evaluator + +An evaluator is a function that receives the op and its evaluated terms +and returns a value. Supply it via the plugin's `evaluate` option. + +TypeScript: + +```ts +import { Jsonic } from 'jsonic' +import { Expr } from '@jsonic/expr' + +const math = (rule: any, ctx: any, op: any, ...terms: any[]) => { + switch (op.src) { + case '+': return terms[0] + terms[1] + case '-': return op.prefix ? -terms[0] : terms[0] - terms[1] + case '*': return terms[0] * terms[1] + case '/': return terms[0] / terms[1] + } +} + +const j = Jsonic.make().use(Expr, { evaluate: math }) + +console.log(j('1+2*3')) // 7 +console.log(j('-4+10')) // 6 +``` + +Go: + +```go +package main + +import ( + "fmt" + jsonic "github.com/jsonicjs/jsonic/go" + expr "github.com/jsonicjs/expr/go" +) + +func math(r *jsonic.Rule, ctx *jsonic.Context, op *expr.Op, terms []interface{}) interface{} { + a, _ := terms[0].(float64) + switch op.Src { + case "+": + b, _ := terms[1].(float64) + return a + b + case "-": + if op.Prefix { + return -a + } + b, _ := terms[1].(float64) + return a - b + case "*": + b, _ := terms[1].(float64) + return a * b + case "/": + b, _ := terms[1].(float64) + return a / b + } + return nil +} + +func main() { + result, _ := expr.Parse("1+2*3", map[string]interface{}{"evaluate": math}) + fmt.Println(result) // 7 +} +``` + +## 4. Compose with JSON + +Expressions live inside any Jsonic value slot, so you can drop them +into objects and arrays: + +```ts +j('{ total: 1+2*3, flags: [!true, -4] }') +// { total: 7, flags: [false, -4] } (after an evaluator for ! and - is added) +``` + +## Next steps + +- [How-to guides](how-to.md) for recipes: adding a custom operator, + using parens for grouping, building function-call syntax via + `paren-preval`. +- [Reference](reference.md) for the full `OpDef` schema and the default + operator table. +- [Explanation](explanation.md) for how the Pratt parser and AST work. diff --git a/go/expr.go b/go/expr.go index ad3ded7..b07f75a 100644 --- a/go/expr.go +++ b/go/expr.go @@ -12,6 +12,9 @@ import ( jsonic "github.com/jsonicjs/jsonic/go" ) +// Version is the Go module version of this plugin. +const Version = "0.1.0" + // OpDef defines an operator for the expression parser. type OpDef struct { Src interface{} // string or []string (for ternary) @@ -226,7 +229,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { // rs.Open/rs.Close, run the modifier, then tag only the alts the // modifier added (by identity) with "expr". modifyRule := func(name string, fn func(rs *jsonic.RuleSpec)) { - j.Rule(name, func(rs *jsonic.RuleSpec) { + j.Rule(name, func(rs *jsonic.RuleSpec, _ *jsonic.Parser) { preOpen := make(map[*jsonic.AltSpec]bool, len(rs.Open)) for _, a := range rs.Open { preOpen[a] = true @@ -287,7 +290,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { return false } if len(pdef.Preval.Allow) > 0 { - val, _ := r.O0.ResolveVal().(string) + val, _ := r.O0.ResolveVal(r, ctx).(string) for _, a := range pdef.Preval.Allow { if a == val { return true @@ -299,7 +302,7 @@ func Expr(j *jsonic.Jsonic, opts map[string]interface{}) error { }, U: map[string]interface{}{"paren_preval": true}, A: func(r *jsonic.Rule, ctx *jsonic.Context) { - r.Node = r.O0.ResolveVal() + r.Node = r.O0.ResolveVal(r, ctx) }, G: "expr,paren,preval", }}, rs.Open...) diff --git a/go/go.mod b/go/go.mod index 33f757f..9b95251 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,4 +2,4 @@ module github.com/jsonicjs/expr/go go 1.24.7 -require github.com/jsonicjs/jsonic/go v0.1.19-0.20260418194431-54100be22847 +require github.com/jsonicjs/jsonic/go v0.1.19 diff --git a/go/go.sum b/go/go.sum index 8592797..aab0b2a 100644 --- a/go/go.sum +++ b/go/go.sum @@ -22,3 +22,5 @@ github.com/jsonicjs/jsonic/go v0.1.18 h1:OW15hjFisrw2n7HE6zDuQAikW8A5NUW8OyP4SCG github.com/jsonicjs/jsonic/go v0.1.18/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= github.com/jsonicjs/jsonic/go v0.1.19-0.20260418194431-54100be22847 h1:+utFlbRO7upKu+DLO9tjUzWHacbxwsyiuFcCkylV3IA= github.com/jsonicjs/jsonic/go v0.1.19-0.20260418194431-54100be22847/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= +github.com/jsonicjs/jsonic/go v0.1.19 h1:jEP+GSxMGKV+eTJEjuU0qRMUQ8GAIl1SRigc+mbZzVo= +github.com/jsonicjs/jsonic/go v0.1.19/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= diff --git a/package.json b/package.json index c0b4865..3f24dcf 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,6 @@ "typescript": "^5.7.3" }, "peerDependencies": { - "jsonic": "github:jsonicjs/jsonic#main" + "jsonic": "^2.25.1" } }