From e112bffe5c7e3882c55bf3f375bcac121dcd560d Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 6 Mar 2026 09:48:44 +0000 Subject: [PATCH] Add index-of string primitive: spec, Python, JS, rebootstrap (index-of s needle from?) returns first index of needle in s, or -1. Optional start offset. Specced in primitives.sx, implemented in both hand-written primitives.py and bootstrapper templates, rebootstrapped. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-ref.js | 84 +-------------------------------- shared/sx/primitives.py | 4 ++ shared/sx/ref/bootstrap_js.py | 1 + shared/sx/ref/bootstrap_py.py | 1 + shared/sx/ref/primitives.sx | 5 ++ shared/sx/ref/sx_ref.py | 60 +---------------------- sx/content/pages.py | 2 +- 7 files changed, 16 insertions(+), 141 deletions(-) diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 5678c72..fe97aea 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -283,6 +283,7 @@ PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); }; PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); }; PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); }; + PRIMITIVES["index-of"] = function(s, needle, from) { return String(s).indexOf(needle, from || 0); }; PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; }; PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; }; PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); }; @@ -3316,87 +3317,6 @@ callExpr.push(dictGet(kwargs, k)); } } if (typeof aser === "function") PRIMITIVES["aser"] = aser; if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom; - // ========================================================================= - // Extension: Delimited continuations (shift/reset) - // ========================================================================= - - function Continuation(fn) { this.fn = fn; } - Continuation.prototype._continuation = true; - Continuation.prototype.call = function(value) { return this.fn(value !== undefined ? value : NIL); }; - - function ShiftSignal(kName, body, env) { - this.kName = kName; - this.body = body; - this.env = env; - } - - PRIMITIVES["continuation?"] = function(x) { return x != null && x._continuation === true; }; - - var _resetResume = []; - - function sfReset(args, env) { - var body = args[0]; - try { - return trampoline(evalExpr(body, env)); - } catch (e) { - if (e instanceof ShiftSignal) { - var sig = e; - var cont = new Continuation(function(value) { - if (value === undefined) value = NIL; - _resetResume.push(value); - try { - return trampoline(evalExpr(body, env)); - } finally { - _resetResume.pop(); - } - }); - var sigEnv = merge(sig.env); - sigEnv[sig.kName] = cont; - return trampoline(evalExpr(sig.body, sigEnv)); - } - throw e; - } - } - - function sfShift(args, env) { - if (_resetResume.length > 0) { - return _resetResume[_resetResume.length - 1]; - } - var kName = symbolName(args[0]); - var body = args[1]; - throw new ShiftSignal(kName, body, env); - } - - // Wrap evalList to intercept reset/shift - var _baseEvalList = evalList; - evalList = function(expr, env) { - var head = expr[0]; - if (isSym(head)) { - var name = head.name; - if (name === "reset") return sfReset(expr.slice(1), env); - if (name === "shift") return sfShift(expr.slice(1), env); - } - return _baseEvalList(expr, env); - }; - - // Wrap aserSpecial to handle reset/shift in SX wire mode - if (typeof aserSpecial === "function") { - var _baseAserSpecial = aserSpecial; - aserSpecial = function(name, expr, env) { - if (name === "reset") return sfReset(expr.slice(1), env); - if (name === "shift") return sfShift(expr.slice(1), env); - return _baseAserSpecial(name, expr, env); - }; - } - - // Wrap typeOf to recognize continuations - var _baseTypeOf = typeOf; - typeOf = function(x) { - if (x != null && x._continuation) return "continuation"; - return _baseTypeOf(x); - }; - - // Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes) var parse = sxParse; @@ -3488,4 +3408,4 @@ callExpr.push(dictGet(kwargs, k)); } } if (typeof module !== "undefined" && module.exports) module.exports = Sx; else global.Sx = Sx; -})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); \ No newline at end of file diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index bb8d27c..cfc6d3d 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -299,6 +299,10 @@ def prim_slice(coll: Any, start: int, end: Any = None) -> Any: return coll[start:] return coll[start:int(end)] +@register_primitive("index-of") +def prim_index_of(s: str, needle: str, start: int = 0) -> int: + return str(s).find(needle, int(start)) + @register_primitive("starts-with?") def prim_starts_with(s, prefix: str) -> bool: if not isinstance(s, str): diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 9c45bbc..9111338 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -1386,6 +1386,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = { PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); }; PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); }; PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); }; + PRIMITIVES["index-of"] = function(s, needle, from) { return String(s).indexOf(needle, from || 0); }; PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; }; PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; }; PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); }; diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index a6bc521..4dae337 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -1705,6 +1705,7 @@ PRIMITIVES["trim"] = lambda s: str(s).strip() PRIMITIVES["split"] = lambda s, sep=" ": str(s).split(sep) PRIMITIVES["join"] = lambda sep, coll: sep.join(coll) PRIMITIVES["replace"] = lambda s, old, new: s.replace(old, new) +PRIMITIVES["index-of"] = lambda s, needle, start=0: str(s).find(needle, start) PRIMITIVES["starts-with?"] = lambda s, p: str(s).startswith(p) PRIMITIVES["ends-with?"] = lambda s, p: str(s).endswith(p) PRIMITIVES["slice"] = lambda c, a, b=None: c[a:b] if b is not None else c[a:] diff --git a/shared/sx/ref/primitives.sx b/shared/sx/ref/primitives.sx index 6283cd5..91e2904 100644 --- a/shared/sx/ref/primitives.sx +++ b/shared/sx/ref/primitives.sx @@ -307,6 +307,11 @@ :returns "any" :doc "Slice a string or list from start to end (exclusive). End is optional.") +(define-primitive "index-of" + :params (s needle &rest from) + :returns "number" + :doc "Index of first occurrence of needle in s, or -1 if not found. Optional start index.") + (define-primitive "starts-with?" :params (s prefix) :returns "boolean" diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index c34763c..77ac737 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -1,3 +1,4 @@ +# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift """ sx_ref.py -- Generated from reference SX evaluator specification. @@ -725,6 +726,7 @@ PRIMITIVES["trim"] = lambda s: str(s).strip() PRIMITIVES["split"] = lambda s, sep=" ": str(s).split(sep) PRIMITIVES["join"] = lambda sep, coll: sep.join(coll) PRIMITIVES["replace"] = lambda s, old, new: s.replace(old, new) +PRIMITIVES["index-of"] = lambda s, needle, start=0: str(s).find(needle, start) PRIMITIVES["starts-with?"] = lambda s, p: str(s).startswith(p) PRIMITIVES["ends-with?"] = lambda s, p: str(s).endswith(p) PRIMITIVES["slice"] = lambda c, a, b=None: c[a:b] if b is not None else c[a:] @@ -1169,64 +1171,6 @@ def _wrap_aser_outputs(): aser_fragment = _aser_fragment_wrapped -# ========================================================================= -# Extension: delimited continuations (shift/reset) -# ========================================================================= - -_RESET_RESUME = [] # stack of resume values; empty = not resuming - -_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"]) - -def sf_reset(args, env): - """(reset body) -- establish a continuation delimiter.""" - body = first(args) - try: - return trampoline(eval_expr(body, env)) - except _ShiftSignal as sig: - def cont_fn(value=NIL): - _RESET_RESUME.append(value) - try: - return trampoline(eval_expr(body, env)) - finally: - _RESET_RESUME.pop() - k = Continuation(cont_fn) - sig_env = dict(sig.env) - sig_env[sig.k_name] = k - return trampoline(eval_expr(sig.body, sig_env)) - -def sf_shift(args, env): - """(shift k body) -- capture continuation to nearest reset.""" - if _RESET_RESUME: - return _RESET_RESUME[-1] - k_name = symbol_name(first(args)) - body = nth(args, 1) - raise _ShiftSignal(k_name, body, env) - -# Wrap eval_list to inject shift/reset dispatch -_base_eval_list = eval_list -def _eval_list_with_continuations(expr, env): - head = first(expr) - if type_of(head) == "symbol": - name = symbol_name(head) - args = rest(expr) - if name == "reset": - return sf_reset(args, env) - if name == "shift": - return sf_shift(args, env) - return _base_eval_list(expr, env) -eval_list = _eval_list_with_continuations - -# Inject into aser_special -_base_aser_special = aser_special -def _aser_special_with_continuations(name, expr, env): - if name == "reset": - return sf_reset(expr[1:], env) - if name == "shift": - return sf_shift(expr[1:], env) - return _base_aser_special(name, expr, env) -aser_special = _aser_special_with_continuations - - # ========================================================================= # Public API # ========================================================================= diff --git a/sx/content/pages.py b/sx/content/pages.py index 4816308..5785283 100644 --- a/sx/content/pages.py +++ b/sx/content/pages.py @@ -209,7 +209,7 @@ PRIMITIVES = { "Arithmetic": ["+", "-", "*", "/", "mod", "sqrt", "pow", "abs", "floor", "ceil", "round", "min", "max"], "Comparison": ["=", "!=", "<", ">", "<=", ">="], "Logic": ["not", "and", "or"], - "String": ["str", "upper", "lower", "trim", "split", "join", "starts-with?", "ends-with?", "replace", "substring"], + "String": ["str", "upper", "lower", "trim", "split", "join", "index-of", "starts-with?", "ends-with?", "replace", "substring"], "Collections": ["list", "dict", "len", "first", "last", "rest", "nth", "cons", "append", "keys", "vals", "merge", "assoc", "range", "concat", "reverse", "sort", "flatten", "zip"], "Higher-Order": ["map", "map-indexed", "filter", "reduce", "some", "every?", "for-each"], "Predicates": ["nil?", "number?", "string?", "list?", "dict?", "empty?", "contains?", "odd?", "even?", "zero?"],