HS parser: fix number+comparison keyword collision, eval-hs uses hs-compile

Parser: skip unit suffix when next ident is a comparison keyword
(starts, ends, contains, matches, is, does, in, precedes, follows).
Fixes "123 starts with '12'" returning "123starts" instead of true.

eval-hs: use hs-compile directly instead of hs-to-sx-from-source with
"return " prefix, which was causing the parser to consume the comparison
as a string suffix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 11:29:01 +00:00
parent a93e5924df
commit 4f02f82f4e
377 changed files with 9517 additions and 8694 deletions

View File

@@ -0,0 +1,57 @@
;; Live GraphQL parse island — parses input and shows AST + serialized output
(defisland
()
(let
((input (signal "{\n user(id: 1) {\n name\n email\n }\n}"))
(ast-output (signal ""))
(ser-output (signal "")))
(define
do-parse!
(fn
(e)
(let
((src (deref input)))
(when
(not (empty? src))
(let
((doc (gql-parse src)))
(do
(set-signal! ast-output (sx-serialize doc))
(set-signal! ser-output (gql-serialize doc))))))))
(div
(~tw :tokens "flex flex-col gap-3")
(textarea
:rows "5"
(~tw
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none")
:placeholder "{ user(id: 1) { name email } }"
:value (deref input)
:on-input (fn (e) (set-signal! input (get-prop e "target.value"))))
(button
(~tw
:tokens "self-start px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors")
:on-click do-parse!
"Parse")
(when
(not (empty? (deref ast-output)))
(div
(~tw :tokens "space-y-4")
(div
(h4
(~tw
:tokens "text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2")
"AST")
(pre
(~tw
:tokens "bg-gray-900 text-green-400 p-4 rounded-lg text-sm overflow-x-auto")
(deref ast-output)))
(div
(h4
(~tw
:tokens "text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2")
"Serialized")
(pre
(~tw
:tokens "bg-gray-900 text-amber-400 p-4 rounded-lg text-sm overflow-x-auto")
(deref ser-output))))))))

View File

@@ -0,0 +1,274 @@
;; GraphQL demo — parser, executor, and hyperscript integration
(defcomp
()
(~docs/page
:title "GraphQL"
(p
(~tw :tokens "text-lg text-gray-600 mb-2")
"A pure SX implementation of the "
(a
:href "https://spec.graphql.org/"
:target "_blank"
(~tw :tokens "text-violet-600 underline")
"GraphQL query language")
". The parser, executor, and serializer are all s-expressions "
"compiled to bytecode by the same kernel that runs the rest of the site.")
(p
(~tw :tokens "text-gray-600 mb-6")
"GraphQL operations map directly to the existing "
(code "defquery")
"/"
(code "defaction")
" system. Queries become IO suspension calls, mutations become actions, "
"and field selection is projection over result dicts. The hyperscript "
"integration adds "
(code "fetch gql { ... }")
" as a native command.")
(~docs/section
:title "Parser"
:id "parser"
(p
(~tw :tokens "text-gray-600 mb-3")
"The tokenizer and recursive-descent parser live in "
(code "lib/graphql.sx")
". Feed it any GraphQL source and get an SX AST back.")
(~docs/code :src "(gql-parse \"{ user(id: 1) { name email } }\")")
(p (~tw :tokens "text-gray-500 text-sm mb-3") "Result:")
(~docs/code
:src (str
"(gql-doc\n"
" (gql-query nil () ()\n"
" ((gql-field \"user\" ((\"id\" 1)) ()\n"
" ((gql-field \"name\" () () ())\n"
" (gql-field \"email\" () () ()))))))"))
(p
(~tw :tokens "text-gray-600 mt-3")
"Every GraphQL concept has an SX node type: "
(code "gql-query")
", "
(code "gql-mutation")
", "
(code "gql-field")
", "
(code "gql-fragment")
", "
(code "gql-var")
", "
(code "gql-directive")
". "
"Accessors like "
(code "gql-field-name")
" and "
(code "gql-op-selections")
" make the AST easy to walk."))
(~docs/section
:title "Variables and Operations"
:id "variables"
(p
(~tw :tokens "text-gray-600 mb-3")
"Named operations, variable definitions with types and defaults, "
"and variable references all parse to structured AST nodes.")
(~docs/code
:src "query GetUser($id: ID!, $limit: Int = 10) {\n user(id: $id) {\n name\n posts(limit: $limit) { title }\n }\n}")
(p
(~tw :tokens "text-gray-500 text-sm mt-3 mb-3")
"Variable definitions become "
(code "(gql-var-def name type default)")
" nodes. "
"References become "
(code "(gql-var name)")
" nodes that the executor substitutes "
"from a provided bindings dict."))
(~docs/section
:title "Fragments"
:id "fragments"
(p
(~tw :tokens "text-gray-600 mb-3")
"Fragment definitions, fragment spreads, and inline fragments:")
(~docs/code
:src "query {\n user {\n ...UserFields\n ... on Admin { role }\n }\n}\n\nfragment UserFields on User {\n name\n email\n}")
(p
(~tw :tokens "text-gray-600 mt-3")
"The executor collects fragment definitions from the document "
"and resolves spreads during projection. Inline fragments apply "
"unconditionally (type checking is available via "
(code "spec/types.sx")
")."))
(~docs/section
:title "Executor"
:id "executor"
(p
(~tw :tokens "text-gray-600 mb-3")
"The executor in "
(code "lib/graphql-exec.sx")
" walks the parsed AST and dispatches each root field through a resolver. "
"The default resolver uses "
(code "perform io-gql-resolve")
" to dispatch via IO suspension, exactly like "
(code "defquery")
".")
(~docs/code
:src "(let ((doc (gql-parse \"{ user(id: 1) { name email } }\"))\n (resolver (fn (field-name args op-type)\n (if (= field-name \"user\")\n {:name \"Alice\" :email \"alice@test.com\" :age 30}\n nil))))\n (gql-execute doc {} resolver))")
(p (~tw :tokens "text-gray-500 text-sm mb-3") "Result:")
(~docs/code
:src "{:data {:user {:email \"alice@test.com\" :name \"Alice\"}}}")
(p
(~tw :tokens "text-gray-600 mt-3")
"Notice "
(code ":age")
" is absent from the result. "
"The executor projects each resolver result down to only the fields "
"the query requested. Nested selections, list results, and aliased "
"fields all work."))
(~docs/section
:title "Aliases"
:id "aliases"
(p
(~tw :tokens "text-gray-600 mb-3")
"Query the same field multiple times with different arguments:")
(~docs/code
:src "{\n me: user(id: 1) { name }\n them: user(id: 2) { name }\n}")
(p
(~tw :tokens "text-gray-600 mt-3")
"Each aliased field calls the resolver independently. "
"Results are keyed by the alias name: "
(code "{:data {:me {:name ...} :them {:name ...}}}")
"."))
(~docs/section
:title "Serializer"
:id "serializer"
(p
(~tw :tokens "text-gray-600 mb-3")
"The serializer converts an AST back to GraphQL source. "
"Useful for logging, debugging, and wire format.")
(~docs/code
:src "(gql-serialize (gql-parse \"query GetUser($id: ID!) { user(id: $id) { name } }\"))")
(p
(~tw :tokens "text-gray-500 text-sm mt-3")
"Round-trips cleanly. Parse, transform the AST, serialize back."))
(~docs/section
:title "Hyperscript: fetch gql"
:id "hyperscript"
(p
(~tw :tokens "text-gray-600 mb-3")
"The "
(code "fetch gql")
" command embeds GraphQL queries directly in hyperscript. "
"Write queries inline "
(em "in the HTML attribute")
", compiled to bytecode "
"alongside the rest of the hyperscript.")
(~docs/code
:src "<!-- shorthand query -->\n<button _=\"on click fetch gql { user(id: 1) { name } } then put it.data.user.name into #result\">\n Load User\n</button>\n\n<!-- with explicit endpoint -->\n<button _=\"on click fetch gql { posts { title } } from '/api/graphql' then ...\">\n Load Posts\n</button>\n\n<!-- mutation -->\n<button _=\"on click fetch gql mutation { deletePost(id: 42) { success } } then ...\">\n Delete\n</button>")
(p
(~tw :tokens "text-gray-600 mt-3 mb-3")
"The parser collects the GraphQL body between braces, preserving nested "
"structure. At runtime, "
(code "hs-fetch-gql")
" sends a standard "
(code "{ \"query\": \"...\", \"variables\": {} }")
" JSON POST to the endpoint (defaults to "
(code "/graphql")
").")
(p
(~tw :tokens "text-gray-600")
"The compilation pipeline: hyperscript source "
(span (~tw :tokens "text-gray-400") " → ")
"HS tokenizer "
(span (~tw :tokens "text-gray-400") " → ")
"HS parser ("
(code "fetch-gql")
" AST) "
(span (~tw :tokens "text-gray-400") " → ")
"HS compiler ("
(code "hs-fetch-gql")
" call) "
(span (~tw :tokens "text-gray-400") " → ")
"bytecode. The GraphQL source string is a constant in the compiled output."))
(~docs/section
:title "GraphQL → SX Mapping"
:id "mapping"
(p
(~tw :tokens "text-gray-600 mb-3")
"Every GraphQL concept has a direct SX equivalent:")
(table
(~tw :tokens "w-full text-sm border-collapse mb-4")
(thead
(tr
(~tw :tokens "border-b border-gray-200")
(th
(~tw :tokens "text-left py-2 pr-4 text-gray-500 font-medium")
"GraphQL")
(th
(~tw :tokens "text-left py-2 pr-4 text-gray-500 font-medium")
"SX")
(th
(~tw :tokens "text-left py-2 text-gray-500 font-medium")
"Mechanism")))
(tbody
(tr
(~tw :tokens "border-b border-gray-100")
(td (~tw :tokens "py-2 pr-4") "Query")
(td (~tw :tokens "py-2 pr-4") (code "defquery"))
(td (~tw :tokens "py-2") "IO suspension"))
(tr
(~tw :tokens "border-b border-gray-100")
(td (~tw :tokens "py-2 pr-4") "Mutation")
(td (~tw :tokens "py-2 pr-4") (code "defaction"))
(td (~tw :tokens "py-2") "IO suspension"))
(tr
(~tw :tokens "border-b border-gray-100")
(td (~tw :tokens "py-2 pr-4") "Subscription")
(td (~tw :tokens "py-2 pr-4") "SSE + signals")
(td (~tw :tokens "py-2") "Reactive islands"))
(tr
(~tw :tokens "border-b border-gray-100")
(td (~tw :tokens "py-2 pr-4") "Fragment")
(td (~tw :tokens "py-2 pr-4") (code "defcomp"))
(td (~tw :tokens "py-2") "Component composition"))
(tr
(~tw :tokens "border-b border-gray-100")
(td (~tw :tokens "py-2 pr-4") "Schema")
(td (~tw :tokens "py-2 pr-4") (code "spec/types.sx"))
(td (~tw :tokens "py-2") "Gradual type system"))
(tr
(~tw :tokens "border-b border-gray-100")
(td (~tw :tokens "py-2 pr-4") "Resolver")
(td (~tw :tokens "py-2 pr-4") (code "perform"))
(td (~tw :tokens "py-2") "CEK IO suspension"))
(tr
(td (~tw :tokens "py-2 pr-4") "Field selection")
(td (~tw :tokens "py-2 pr-4") (code "gql-project"))
(td (~tw :tokens "py-2") "Dict projection")))))
(~docs/section
:title "Live Parse"
:id "live-parse"
(p
(~tw :tokens "text-gray-600 mb-3")
"Type a GraphQL query and see the SX AST. "
"Parsed by the same "
(code "lib/graphql.sx")
" that compiles to bytecode.")
(div
(~tw :tokens "flex flex-col gap-3")
(textarea
:id "gql-input"
:name "source"
:rows "5"
(~tw
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none")
:placeholder "{ user(id: 1) { name email } }"
"{\n user(id: 1) {\n name\n email\n }\n}")
(button
(~tw
:tokens "self-start px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors cursor-pointer")
:id "gql-parse-btn"
"Parse")
(div
:id "gql-output"
(~tw
:tokens "min-h-24 p-3 bg-gray-50 rounded-lg border border-gray-200 font-mono text-sm text-gray-700 whitespace-pre-wrap")
(span (~tw :tokens "text-gray-400") "AST will appear here")))
(script
"document.getElementById('gql-parse-btn').addEventListener('click', function() { var src = document.getElementById('gql-input').value; fetch('/sx/(applications.(graphql.(api.parse-demo)))?source=' + encodeURIComponent(src)).then(function(r) { return r.text(); }).then(function(html) { document.getElementById('gql-output').innerHTML = html; }); });"))))