Changelog
Copy MarkdownAll notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.2.0] - 2026-08-04
Changed
- Upgraded
ichor(dev-only)0.2.1->0.3.0andichor_runtime(runtime)0.1.0->0.2.0; bothmix.exsconstraints now major.minor only (~> 0.3/~> 0.2), no patch pin, per this project's own dependency-version convention.ichor0.3.0is what unblocks theichor_runtimebump --0.2.1still internally pinnedichor_runtime ~> 0.1.0, capping resolution below0.2.0regardless of Logos's own requirement.lib/logos/reader/generated.exregenerated viamix ichor.genagainst the newichor-- a large textual diff (internal helper functions renumbered by the newer codegen) but no behavioral change:priv/grammar/logos.aetheritself is untouched,Grammar.VM.Token's shape andLogos.Reader.tokenize/1's output are unchanged (spot-checked directly), and the full test suite passes unchanged.
Added
- Generated stdlib, special-forms & primitives doc reference
(
guides/language/stdlib/*.md, new "Stdlib Reference" ExDoc group), built byLogos.StdlibDocs.documents/0(mix logos.gen_docs) -- an overview/table-of-contents page, a special-forms page, a primitives page (every Layer-1Logos.Primitivesentry, each now with a real docstring instead of the old generic"Layer-1 primitive"placeholder --(doc +)etc. finally return something meaningful), and one page per stdlib namespace, each a flat, per-symbol lookup pulled live from that item's own docstring, companion toguides/language/LOGOS.md's narrative reference. Every documented item (special form, primitive, function, or macro) also gets a runnable example: actually evaluated at generation time, in its own isolated process (so one example's stray process state -- a leftover mailbox message, a changed process flag -- can never leak into another's result), never a hand-typed "expected output" that could drift from what the code really does. Every stdlib-namespace entry (function/macro/var, across all eightpriv/stdlib/*.logosnamespaces) also shows its own defining form's exact source, reconstructed byte-for-byte fromLogos.Reader.tokenize/1's position-preserving token stream.mix testfails if any checked-in page drifts from a fresh regeneration, if the hand-maintained special-forms list drifts fromLogos.Eval's actual clauses, or if any documented item is missing an example (or has one that errors). importaccepts an optional trailing docstring argument, same shape asdef's own 3-arg form, attached as:docmetadata on the interned Var -- closes the gap where a raw imported name used with no Logos-level wrapper (logos.string's owntrim/reverse/capitalize/replace/split) had no docstring reachable via(doc name).- Docstrings for previously-undocumented public stdlib vars: all twelve
logos.stringfunctions (five raw-imported, seven wrapped), sevenlogos.seq-ontoaccumulator helpers, andlogos.multimethod'smultimethod-registry.
[0.1.0] - 2026-08-02
Initial release.
Added
Three new stdlib namespaces (
logos.set,logos.walk,logos.string) plusread-string/pr-strinlogos.core-- closing out the "are there more stdlib modules worth adding" follow-up to the missing-functionality audit below. All three new namespaces are loaded into everyRuntimebut deliberately not auto-referred intologos.core(requirethem explicitly), matching real Clojure exactly:clojure.set/clojure.walk/clojure.stringare all separate, explicitly-required namespaces there too, unlikeclojure.core's own multimethod/protocol machinery (whylogos.multimethodis auto-referred).logos.set--union/intersection/difference/subset?/superset?/select/map-invert/rename-keys, pure Logos over existing primitives, no interpreter changes. Noproject/rename/index/join(real Clojure's relational-algebra corner ofclojure.set) -- a real, deliberate scope trim, genuinely niche outside actual in-memory relational querying.logos.walk--walk/postwalk/prewalk, a direct port ofclojure.walk. Rebuilding a map or sorted collection always produces a plain map/set -- a real, deliberate scope trim (Logos has no genericemptyconstructor to preserve an arbitrary collection's own shape with, same reasonselect-keysalready accepts this).logos.string-- Clojure-idiomatic string manipulation (upper-case/lower-case/capitalize/triml/trimr/includes?/starts-with?/ends-with?/blank?/join/split-lines/replace/reverse), every function a thin wrapper over animported, allowlisted ElixirString.*function -- going through the same sandboxing chokepointimportitself uses, never a direct, allowlist-bypassing primitive. A deliberate, explicit decision (Jan chose this over leaving string manipulation as purely the host embedding app's job, the alternative presented):Logos.Interop.Allowlistgrew seven newString.*entries specifically to support it, and its own moduledoc was updated to say so -- it no longer frames itself as just a minimal test-fixture seed list.join/split-lines/blank?need no interop at all, pure Logos over what's already available.split/replaceonly ever take a literal string pattern, never a regex (Logos has no regex literal syntax to construct one from);replace-first/index-of/last-index-of/trim-newline/escapeskipped as genuinely niche. This namespace's ownreverse(string reversal) collides by name withlogos.seq'sreverse(list reversal) if:refer [:all]'d -- documented prominently (this file's own header comment,LOGOS.md7.12, a dedicated test) as the exact same footgun real(require '[clojure.string :refer :all])has in real Clojure too, which is why(require '[logos.string :as str])is the documented, recommended form.read-string/pr-str(logos.coreprimitives) --read-stringparses a string as Logos source and returns the first form as plain, unevaluated data (Logos.Reader.read/2, reachable from Logos code now); malformed input raises a catchable:read-error.pr-strisstr's round-trippable sibling -- every argument (strings included) goes throughLogos.Printer.print/1unchanged, unlikestr, which special-cases strings to pass through bare.
Found and fixed two real bugs while building this, both sharper versions of the
^:private-cross-namespace hazardnot-found-sentinelalready surfaced during the missing-functionality work below -- being public isn't sufficient for a namespace that isn't auto-referred intologos.core:logos.set'ssuperset?calledsubset?via a bare reference, andlogos.walk'spostwalk/prewalkcalledwalk(and themselves, recursively) the same way; most oflogos.string's own wrappers had the identical shape (calling their own raw imported name). Each broke with{:unbound_symbol, ...}the moment a caller used(require '[logos.set :as set])(no:refer) instead of:refer [:all]-- exactly the recommended form forlogos.string, specifically to dodge thereversecollision above, which would otherwise have made the two pieces of guidance directly contradict each other. Caught by actualmix runsmoke tests (not just the example-based test suite) before any of it reached a committed test file; fixed by qualifying every same-namespace internal cross-call with its fulllogos.set/.../logos.walk/.../logos.string/...prefix, which -- unlike a bare reference -- always resolves against the literal target namespace regardless of caller context (the same mechanismlogos.core/build-letalready relies on to stay reachable despite being^:private). Each of the three new files' own header comments now document this explicitly, andCONTRIBUTING.md's existing^:privateguidance grew a matching section for it.Also found and fixed a real, previously under-documented gap while writing
logos.walk's own tests: a Logos vector/map/set literal never evaluates its own elements, a genuine, deliberate divergence from Clojure ([1 (+ 1 1)]evaluates to itself verbatim in Logos, a two-element vector whose second element is the literal, unevaluated list(+ 1 1)-- not[1 2]), already correctly implemented and commented inLogos.Eval.eval/3but never stated anywhere inguides/language/LOGOS.mdat the level such an easy-to-miss semantic deserves (only a narrower, destructuring-scoped mention existed). Added a dedicated, prominent note inLOGOS.mdsection 2 and the cheatsheet's reader-syntax table -- caught only because an actualmix runsmoke test of a naively-writtenpostwalktest hit it directly.A batch of missing map/seq functionality, found by a full audit of the stdlib layer for missing functionality, optimization opportunities, and Elixir code that could move to Logos (see this file's own "Changed" entries below for the other two). Two genuine primitive extensions, the rest pure Logos over them:
get/assocnow support vectors (index-based, like real Clojure) -- previously unsupported entirely.assocallows an existing index or exactly one past the end (append, matchingconjand real Clojure); anything further raises:index_out_of_boundsrather than silently leaving a gap (the underlying:array.set/3auto-extends past its bound, whichLogos.Vectordeliberately guards against).get(and keyword-as-function,(:k coll)) now support sets (real Clojure's own membership-as-lookup: the element itself if present,default/nilotherwise).contains?,keys,vals-- the itemROADMAP.mdflagged but deliberately left untracked while closing out transients/sorted collections.contains?is a pure Logos sentinel-default wrapper overget(no primitive of its own needed, now thatgetdispatches correctly across every shape);keys/valsare thinto-listwrappers, map/sorted-map-only (throws on anything else, matching real Clojure).merge/merge-with-- seeded from the first non-nilmap itself (not always a fresh{}), thenintos the rest, so merging into a sorted-map keeps it sorted (matching real Clojure's ownconj-basedmerge).get-in/assoc-in/update/update-in-- nested map ergonomics.get-inuses its own internal-only sentinel (get-in-miss, distinct fromcontains?'snot-found-sentinel) to detect a missing intermediate step without confusing it with a caller- suppliednot-foundvalue that might otherwise collide with a real stored one.select-keys-- always returns a plain map even from a sorted source, a real, minor, deliberate divergence from Clojure (not worth a dedicated sorted-preserving path for this one function).take-while/drop-while,partition/partition-all/partition-by,flatten,zipmap/mapcat/interpose/interleave,juxt/every-pred/some-fn.partition/partition-allreject a non-positiven/step(throws:invalid-partition-size) rather than looping forever -- this layer is eager, not Clojure's lazy version, so it can't just never fully realize an infinite result the way Clojure's own(partition 0 coll)effectively doesn't. No 4-arity padding form (partition/n/step/pad) and no multi-collectionmap/mapcat(Clojure's own are variadic) -- deliberate scope trims, same spirit as this codebase's other trimmed edge variants;interleaveis the one multi-collection exception, since a single-collectioninterleaveis degenerate and its own implementation doesn't actually need a variadicmap(it mapsfirst/restover the outer list of collections, itself always one collection).
Found and fixed one real bug while building this:
not-found-sentinel/get-in-miss(priv/stdlib/seq.logos) were first written^:private-- the exact hazard this file's own header comment already documents (a private helper is only reachable via the "direct var in the current namespace" check, never throughlogos.core's refer oflogos.seq, which DOES filter bypublic?) -- breakingcontains?/merge-with/select-keys/get-infor every caller outsidelogos.seqitself, i.e. always in practice. Caught by an actualmix runsmoke test before it reached a committed test file; fixed by making both public, matchingtest-registry/multimethod-registry's own precedent for this exact situation.
Changed
logos.seq'smap/filter/take/count/range/repeatare now genuinely tail-recursive. Found during a full audit of the stdlib layer (missing functionality / optimization opportunities / Elixir code that could move to Logos): each of these six built its result by wrapping its own recursive call incons/+(e.g.(cons (f (first items)) (map f (rest items)))), which is not tail position -- unlikereverse-onto/distinct-onto/reduce/drop(already correct, accumulate-then-reverse, elsewhere in the same file), so none of these six ever actually benefited from this project's own headline TCO feature; recursion depth scaled with input size. Rewritten to the same accumulate-then-reversepattern as their already-correct neighbors (count-onto/take-onto/map-onto/filter-onto/range-onto/repeat-onto, public per this file's own established^:private-is- unsafe-for-a-cross-namespace-callee rule). Purely internal -- identical observable output confirmed by the existing test suite (stdlib_test.exs,seq_property_test.exs's property coverage,logos_scripts_test.exs) with zero changes needed to any of it.test.logos's own header comment had the same stale "try/catchonly ever catches an explicit(throw ...)" claim already found and fixed inguides/language/LOGOS.mdwhile building the item below -- missed there the first time; fixed the same way.
Added
Transients and persistent sorted collections (
sorted-map/sorted-set),ROADMAP.md's last open Tier 3 item -- closing out the roadmap's own tracked gap list entirely (only a pre-existing, never-tracked gap,keys/vals/contains?, noticed while building this but out of scope for it, remains).Sorted collections: genuine new
Logos.SortedMap/Logos.SortedSetruntime value types (mirroringLogos.Vector's own precedent), backed by a sorted association list -- the same "boring, obviously correct, O(n) per op" tradeoffsort/distinctalready made, not a balanced tree, since script-sized collections need correctness far more than asymptotics.sorted-map/sorted-setorder by a newcompareprimitive (Clojure's own general 3-way ordering -- numbers across the full tower, strings, chars, keywords/symbols by{ns name}, vectors/lists elementwise -- returning -1/0/1, distinct from</<=/ ..., which stay strictly numeric, matching real Clojure exactly);sorted-map-by/sorted-set-bytake an explicit comparator instead -- an ordinary Logos function called back through the existingLogos.Eval.apply_fn/3(the same mechanismapplyitself already uses), no new evaluator machinery.type-ofreturns:sorted-map/:sorted-set;map?/set?(core.logos) were widened to recognize both their plain and sorted variant, so a sorted collection is amap?/set?like real Clojure's is; a newsorted?predicate checks specifically.get/assoc/dissoc/keyword-as-function all work on a sorted-map;conj/disj/into(logos.seq) all keep a sorted-set sorted rather than silently demoting it to a plain set on the first mutation -- the bug this would otherwise have been is whyinto's set branch now reusesconjinstead of its own separatelist->setrebuild.=treats a sorted collection as content-equal to a plain one with the same entries (map/set equality is by content, never by concrete representation, matching real Clojure:(= (sorted-map :a 1) {:a 1})istruethere too). No dedicated reader syntax and no round-trip -- printing produces ordinary{...}/#{...}text (in sorted order), which reads back as an ordinary map/set, matching real Clojure there as well ((pr-str (sorted-map :a 1))reads back as a plain hash-map too). Added a real newdisj(logos.seq) alongside this -- it didn't exist at all before, and bothsorted-setand transient sets need a removal counterpart toconj.Also used
compareto fix a real, previously-undocumented gap:sort/sort-by(logos.seq) used to compare via<=, which is strictly numeric (matching real Clojure's own<=, which also throws on two strings) -- so(sort ["b" "a"])used to error. Both now sort viacompareinstead, closing that gap for free.Transients:
transient/conj!/assoc!/dissoc!/disj!/pop!/persistent!, scoped to vectors/maps/sets only (not lists, not sorted collections -- neither is an "editable collection" in real Clojure either, so(transient (sorted-map))/(transient '(1 2))throw here too). Planned explicitly first (the two representations on the table -- a process+message design mirroringatom, vs. a:private-ETS-backed value type -- were presented with a concrete recommendation before any code); chose the latter (Logos.Transient, a new value module) on explicit request. Deliberately not built the wayatomis: an atom exists to be a safely shared mutable reference (hence process + message-passing, atomicity free from BEAM's one-message-at-a-time guarantee), but a transient exists to be the opposite -- real Clojure transients are documented single-thread-use only, and a:privateETS table makes that a genuine BEAM-enforced guarantee (any process but the creator getsArgumentErrortouching it) rather than a documented-only discipline, while also making every mutation real O(1) in-place work (no message round-trip) -- actually delivering the performance transients exist for, which the process-based alternative would not have.persistent!deletes the backing table after extracting the final value, so a transient used afterward (persistent!a second time, or any mutator) raises the sameArgumentErrorunder the hood, surfaced as a single, uniform, catchable:transient-used-after-persistentfailure -- one mechanism naturally covering both of Clojure's transient safety rules, not two separate checks.conj!/assoc!/... always return the transient itself (not the new value), matching Clojure's own "always use the returned value" contract.get/count/to-list(and everythinglogos.seqbuilds onto-list, e.g.nth/empty?) work directly against a live transient with nopersistent!needed first, for free, sinceto_list_value/1's own dispatch (Logos.Primitives) just peeks the transient's current value -- matching real Clojure transients implementing the same read interfaces their persistent counterparts do.Found and fixed one real, adjacent doc bug while writing this:
guides/language/LOGOS.md'slogos.testsection still claimedtry/catch"only ever catches an explicit(throw ...)" -- stale since the broadertry/catchwork earlier in this file; fixed to explain the real, current reasondefteststill needs process isolation (no implicittry/catchwraps a test body at all, not "primitive failures aren't catchable").Reader tagged literals (
#tag value),ROADMAP.md's second Tier 3 item. Jan chose the full, genuinely extensible route (a per-Runtimeregistry,#inst/#uuidas two pre-installed entries in it rather than special-cased, plus a newregister-data-reader!primitive) over a narrower built-in-only version, matching real Clojure's own*data-readers*design. Reader conditionals (#?(:clj ...)) are explicitly not included --ROADMAP.md's own reasoning (Logos has exactly one target platform, itself) still holds, nothing found while researching this changed that.Resolved at read time, not eval time --
'#inst "..."still yields the resolved value underquote, matching every other literal, not sugar for a deferred function call. This is the one placepriv/grammar/logos.aether/Logos.Reader.Actionsgenuinely needsLogos.Runtimestate during the parse itself (not just at the later syntax-quote desugaring pass, which is whyruntimewas already an optional parameter):Logos.Reader.read/2/read_all/2now thread it through asIchor.Actions' owncontext, previously hardcoded tonil. A registered reader is always a plain Elixir function reached throughLogos.Interop.Allowlist-- the exact same sandboxing chokepointimportalready uses -- never an arbitrary Logos closure, sohandle_rule(:tagged_literal, ...)calls it directly viaKernel.apply/3with noLogos.Evalinvolved at all, keepingLogos.Reader.Actions's own "pure reification only" architectural rule intact (this is the same category of thingChar.parse/1already is, not a new coupling to the evaluator). A deliberate narrowing from the literal "Logos code can register its own tags" framing this item started from -- flagged and confirmed before writing any code.#inst/#uuid(Logos.DataReaders, two newLogos.Interop.Allowlistentries) validate their string argument (realDateTime.from_iso8601/1for#inst, an 8-4-4-4-12 hex-digit shape for#uuid) but return it unchanged, as a plain Logos string -- deliberately no newLogos.Instant/Logos.Uuidvalue type (a separate,Logos.Record-scale design question this item didn't ask for). Direct, documented consequence: unlike Clojure's own#inst/#uuid, these two don't round-trip throughLogos.Printer.print/1/Logos.Reader.read/1back to#inst "..."/#uuid "..."-- they print as ordinary quoted strings, since a plain string carries no record of which tag (if any) produced it. Also found and documented (not a bug, an inherent consequence ofLogos.eval_string_sequence/3's already-existing "read every top-level form up front, before evaluating any of them" behavior, the same one mid-sequence(ns ...)/(in-ns ...)already has):register-data-reader!and a use of that same new tag can't appear in the same top-level source string/script file -- only in a later, separateeval_string/eval_string_sequencecall.Found and fixed a real bug in
mix logos.format/Logos.Formatwhile building this: the new bare#prefix token needed teaching the formatter "no space before the next token" (#inst, not# inst). The first attempt did this with a blind text check ("does the accumulated output so far end in#?"), which also matched an ordinary symbol whose own text just happens to end in#(auto- gensym syntax,g#/x#) -- gluing it to whatever token followed with no space. Caught by re-running the formatter against real stdlib source (priv/stdlib/core.logos'sormacro,priv/stdlib/test.logos'sassert/assert-throws) and diffing the result before trusting it, not just a synthetic test -- corrupted output had already mergedg# g#/v# v#/a# e#into single unparseable tokens on disk (never committed; restored from git before re-fixing). Fixed by wiring upLogos.Format's already-threaded-but-unusedsuppress/@prefixmachinery (tracking the actual previous token's identity, not a guess from accumulated text) instead of extending the text heuristic further -- seetest/logos/format_test.exs's new regression tests.try/catchnow catches primitive-level failures (division by zero, an unbound symbol, a wrong-arity call, ...), not only an explicit(throw ...)--ROADMAP.md's first Tier 3 item, previously a deliberate, documented divergence from Clojure; revisited and built on explicit request after weighing the two implementation routes (a surgical widening oftryalone, vs. a full internal switch to real Elixirraise/rescue) -- went with the latter.Logos.Eval/Logos.Primitives' entire{:ok, value} | {:error, reason}return convention is now raise-based internally: every primitive-level failure raises a newLogos.EvalError(reasonunchanged from what the old tuple's second element always was), modeled directly onLogos.Macroexpand's pre-existingLogos.MacroErrorpattern.try's ownrescue(already there forLogos.Thrown) now also catchesLogos.EvalError, matching acatchclause against a tag derived fromreason(a bare atom becomes its own hyphenated keyword,:division_by_zero->:division-by-zero; a tuple's first element the same way) -- a catch clause literally tagged:erroris an additional wildcard, matching any primitive-level failure, mirroring Clojure's(catch Exception e ...); an explicit(throw ...)'s own matching stays exact-tag-only, unchanged. The caught value is the failure's own human-readable string message, not an attempt at structured Elixir- term-to-Logos-value conversion. The public API (Logos.eval_string/3/eval_string_sequence/3) is contractually unaffected -- still{:ok, value, env} | {:error, reason},reasonthe exact same shape as always; this is purely an internal propagation mechanism change. Verified TCO wasn't broken by the switch (no newtry/rescueadded anywhere on the hot recursive eval path --test/logos/tco_test.exs's 10,000,000-iteration test re-run specifically to confirm).Records and protocols (
defrecord/defprotocol/extend-type),ROADMAP.md's last open Tier 2 item, deliberately deferred from the multimethods work because it raises a question multimethods never had to answer: what does a user-defined Logos type/instance actually look like? Two routes were on the table (a plain map with a conventional type-tag key, vs. a genuine new runtime value); asked the user directly, who chose the latter for real opacity and closer parity with actual Clojure record semantics. NewLogos.Recordvalue module (lib/logos/value/record.ex), the first new core value type this session and the first item requiring real interpreter-level integration rather than only primitives/macros around the existing model:type-of(Logos.Primitives) returns a record's own namespace-qualified tag directly, never the generic:maptag a plain{...}literal gets;get/assocget record- aware clauses (assocreturns a new record of the same type, never demoting to a plain map --dissocon a record is deliberately unsupported, real Clojure's demote-to-map behavior being genuine extra complexity out of scope for this first pass); keyword-as-function (Logos.Eval.apply_fn/3) now accepts a record alongside a map/nil;Logos.Printer.print/1prints#type-kw{...}, runtime-only likeFn/Atom/Pid. Two small new primitives back it:record(Elixir-side of necessity, building an arbitrary struct isn't expressible in pure Logos, the same justificationpid->atomalready established) andcurrent-ns(exposesLogos.Runtime.current_ns/1to Lisp code, needed sodefrecordcan fix a record type's tag to the namespace it was invoked from, captured once at macro-expansion time, independent of whoever later calls the constructor -- also added asymbolprimitive,keyword's counterpart, to letdefrecordsynthesize its->Name/Name?var names).defrecord/defprotocol/extend-typethemselves are pure Logos (priv/stdlib/core.logos,priv/stdlib/multimethod.logos):defprotocol/extend-typeare thin sugar generatingdefmulti/defmethodcalls dispatching ontype-of, which is now a uniform dispatch key across both record types and every built-in type (extend-type :vector Shape ...andextend-type Point Shape ...work identically). Nosatisfies?/extends?introspection and no protocol-name registry -- deliberately minimal, the same scope trim multimethods' own missing hierarchy support already established.Found and fixed a real bug while building this:
defrecord's first draft referenced its own type-name symbol bare inside the generated constructor/predicate bodies, instead of the already- computed tag value -- a bare reference resolves against the CALLER's current namespace at the moment that code actually runs (documented incore.logos's own header comment), not the namespacedefrecordwas invoked from, so a qualified constructor called from a namespace with its own same-named record silently picked up the WRONG type. Caught by an actual cross-namespacemix runsmoke test before it ever reached a test file; fixed by splicing the literal tag value directly instead of the symbol reference. A second, narrower bug:defprotocol/extend-type's own helper functions (defprotocol-multis/extend-type-methods/protocol-dispatch) were first written^:private, which broke them for every caller namespace other thanlogos.multimethoditself -- the exact hazardcore.logos's header comment already documents for helpers living outsidelogos.core's own auto-refer exemption; fixed by making them public, matchingregister-multimethod!/register-method!'s own precedent in the same file.Dynamic vars and
binding,ROADMAP.md's third Tier 2 item. Unlike every other item shipped this session, this one genuinely couldn't be pure Logos over existing primitives: dynamically-scoped symbol resolution has to be intercepted at the pointLogos.Evalresolves a Var's value, which is Elixir-side. A Vardef'd with new^:dynamicmeta (Logos.Eval's privatemeta_from_symbol/2, alongside the existing^:private/^:macroflags) can be thread-locally (i.e. per-BEAM-process) rebound for the extent of abindingbody via two new primitives,push-thread-binding!/pop-thread-binding!(Logos.Primitives), storing each override on the calling process's own process dictionary -- deliberately not another{tag, pid}row inLogos.Runtime's shared:publicETS table (the patterncurrent_nsalready uses): dynamic bindings need to be invisible to other processes, the opposite of why that table is:public, and the process dictionary comes with a real win thecurrent_nsdesign explicitly doesn't have (its own moduledoc calls out{current_ns, pid}rows never being cleaned up on process exit as an accepted tradeoff) -- a process dictionary just vanishes with its process, no leak to accept. The read side is one new shared private helper inLogos.Eval,get_var_value_dynamic_aware/3, called from both bare- and qualified-symbol resolution in place of a directNamespace.get_var_value/3call: a Var that was neverbinding-bound (the overwhelming majority) is completely unaffected.bindingitself is a purepriv/stdlib/core.logosmacro over the existingtry/finallyspecial form -- no new special form, matching howif/case/condpare all built oncond. Nestedbindingshadows correctly (each override is a stack); a spawned child process sees the Var's root value, never its parent's active binding (a fresh process dictionary), matching real Clojure (a bare new thread doesn't inherit dynamic bindings either, onlybound-fndoes). Deliberately out of scope: rebinding the innermost active binding from within its ownbindingscope (Clojure'sset!).case/condp,ROADMAP.md's second Tier 2 item -- pure macros overcond/if, no interpreter changes, inpriv/stdlib/core.logos.case's test values are literal/unevaluated (a test may also be a list of alternatives, e.g.(1 2 3), matched via a generatedor);condp's test values ARE evaluated expressions, tried per clause as(pred test expr). Both evaluate the dispatched-on expression exactly once (regardless of how many clauses are tried), support a trailing unpaired default form, and throw:no-matching-clausewhen nothing matches and no default was given.condp's:>>result-fn form is explicitly out of scope. Hit the same gensym cross-scope bug thecond->/cond->>/some->macros already needed fixing for earlier (a manually-quoted'g#outside a syntax-quote does not refer to the same symbol asg#'s auto-gensym inside it) -- fixed the same way, by calling thegensymprimitive directly and splicing the one resulting value everywhere it's needed.Multimethods (
defmulti/defmethod),ROADMAP.md's first Tier 2 item -- ad-hoc polymorphism, dispatching on the result of an arbitrary "dispatch function" called with a multimethod's own arguments (strictly more general than single-dispatch-on-first- argument-type protocols). New file,priv/stdlib/multimethod.logos->logos.multimethod, referred intologos.corelikelogos.seq/logos.map/logos.concurrency. Real Clojure semantics::defaultfallback, methods registered after a multimethod's first call take effect immediately (one shared, mutable dispatch table, not a fixed snapshot),:no-method-for-dispatch-valuethrown when nothing matches. No hierarchy support (isa?/derive/prefer-method) orremove-method/methods/get-methodintrospection -- deliberately out of scope for a first pass. Protocols/records (defprotocol/deftype/defrecord) are explicitly not included -- they raise a separate design question (what does a user-defined Logos type even look like?) that multimethods don't, and are deferred to their ownROADMAP.mditem; the registry design here (a plain, shared,intern-var!-mutated map, not an atom) reuseslogos.test's own already-proven pattern rather than inventing a new one. Found and fixed two real bugs while building this -- one a genuine language-level gap, one specific to this feature's own first draft -- see "Fixed" below.Destructuring in
let/defn/defn-,ROADMAP.md's fifth tier-1 item and the last one: a binding/param position accepts a vector pattern ([a b]positional,[a b & more]rest,[a b :as whole]the original value too) or a map pattern ({:keys [a b]}, explicit{name :key}pairs,{:keys [a] :as m}), nested patterns included --priv/stdlib/core.logos, real Clojure semantics, one new primitive (keyword, needed to turn a{:keys [a]}pattern's binding name into the keyword:atogetit by, with no other way to ask "what string is this symbol's own name" from pure Logos).fnitself does not support this -- onlylet/defn/defn-(all macros) do, expanding each pattern into a freshgensym'd plain-symbol name before handing the realfnspecial form anything, the same layering real Clojure's ownfnmacro uses overfn*. Does not support:strs/:syms(string-/symbol-keyed map lookup) or:or(default values) -- noted as a possible follow-up inROADMAP.mdif either turns out to matter. A compound pattern's value-form is evaluated exactly once, no matter how many parts get extracted from it (bound to agensym'd temp first, verified with a side-effecting value-form, not just inferred from the expansion).The destructuring helpers themselves (and
build-let's own updated body) are written using only Layer-1 primitives pluscond/fn-- neverlet, never alogos.seqfunction (map/reduce/reverse/empty?/nth/...). This is a hard bootstrap constraint, not a style choice:defn's own macro body now calls into this machinery on everydefnit expands, including the very first one incore.logos(the type-predicates section), which runs long beforelogos.seqloads -- exactly the same class of bug already found once this session indefn's multi-arity support (vector?not existing yet). Caught here too, the same way, before it reached a test file.A real seq abstraction over every collection,
ROADMAP.md's fourth tier-1 item: everylogos.seqfunction that takes acollargument (map/filter/reduce/take/drop/reverse/count/empty?, plus everything new below) now accepts a list, vector, map, set, ornil, coercing via theto-listprimitive -- not just lists as before. Chose eager coercion over a lazy-seq abstraction (seeROADMAP.md's own note on the two designs):to-liston an already-a-list value is an O(1) pattern match (Logos.Primitives'sto_list_value/1, returns the same list reference, no rebuild), so coercing on every recursive step costs nothing real, and every function's own result stays a plain list regardless of input shape -- matching real Clojure exactly ((map f a-vector)there returns a lazy seq, never a vector, either). Purepriv/stdlib/seq.logos, no new primitives, no interpreter changes. New functions, real Clojure semantics throughout:nth(2-arity throws:index-out-of-boundswith the originally requested index -- not, as an earlier draft did, however many recursive steps happened to be left when it ran out; 3-arity returns a givennot-found),second,last,some,every?,distinct(O(n^2), documented as the simple/obviously correct choice, not the fastest possible -- same spirit asreduce's own moduledoc note),sort/sort-by(<=-based insertion sort, same O(n^2) tradeoff),frequencies,group-by(buckets into vectors, matching real Clojure's own shape exactly),range/repeat(eager and bounded -- unlike Clojure's own lazy-infinite forms, a0step throws:invalid-range-steprather than looping forever, andrepeathas no unbounded 1-arity form), andconj/peek/pop(conjisinto's one-element-at-a-time cousin, same per-target-shape dispatch;peek/poplook at the front for a list, the back for a vector, Clojure's own two different "natural end" conventions for the two shapes).Multi-arity
defn/defn-,ROADMAP.md's third tier-1 item:(defn name ([p1] b1) ([p1 p2] b2)), same shapefnitself already supported, optionally with a leading docstring ((defn name "doc" ([p1] b1) ([p1 p2] b2))). Purecore.logos, no new primitives. Found and fixed a real bootstrap-breaking bug while implementing: the natural way to tell a bare params vector apart from an arity-clause list isvector?, butvector?itself is defined viadefnlater in the very same file --defn's own macro body callingvector?meant the firstdefncall anywhere ((defn nil? ...), which comes beforevector?'s own definition) failed with{:unbound_symbol, "vector?"}, breakingLogos.Stdlib.load!/1itself. Fixed by checking(= (type-of ...) :vector)directly instead --type-ofis a Layer-1 primitive, available from the very start, with no dependency on anythingdefnitself is used to build.ROADMAP.md: a prioritized, verified list of Clojure features Logos doesn't have yet (threading macros, destructuring, a uniform seq abstraction, basic numeric/utility stdlib, multi-aritydefn, protocols/multimethods, dynamic vars, and more), distinct fromCONTRIBUTING.md's "Known gaps" (which tracks bugs/incompleteness in behavior already claimed to work, currently empty).Threading macros:
->,->>,some->,some->>,as->,cond->,cond->>-- real Clojure semantics, added as the first item offROADMAP.md. All purecore.logosmacros, no interpreter changes.->/->>need no hygiene at all (each step mentions the previous step's form, not a re-evaluation of it, so nothing is ever evaluated twice);some->/some->>use syntax-quote'sg#auto-gensym for a single per-step hygienic temp (checkingxitself fornilbefore threading it into the first form, not just each step's result afterward --(some-> nil (+ 2))never attempts(+ nil 2));cond->/cond->>call thegensymprimitive directly instead, since their per-step temp needs to appear both inside one syntax-quote's own text (theletbinding) and outside it (spliced into a conditionally-threaded step built as plain data beforehand) -- a needg#auto-gensym alone can't satisfy, since it only guarantees consistency within one syntax-quote's literal text.Basic numeric/utility functions,
ROADMAP.md's second tier-1 item:not,identity,constantly,complement,inc,dec,zero?/pos?/neg?/even?/odd?,mod,min/max,comp,partial-- all purecore.logos, plus four new Layer-1 primitives the pure-Logos layer needed and had no way to express itself:quot/rem: integer-only, thin wrappers over Erlang's owndiv/rem(already exactly Clojure'squot/remsemantics -- truncate toward zero, remainder's sign matches the dividend's).mod(floored, sign matches the divisor) is built onremin pure Logos.apply:(apply f a b ... coll)dispatches straight toLogos.Eval.apply_fn/3-- the same function every other callable-application path (ordinary calls,Logos.Process, host Elixir code) already goes through, so no interpreter/evaluator changes were needed, only this one primitive.comp/partialare both themselves built on it.str: stringifies and concatenates any number of values, deliberately different fromLogos.Printer.print/1fornil("", not"nil") and strings (passed through bare, not re-quoted) --print/1answers "what reads back to this value,"stranswers "what should a human see," matching Clojure's ownstrexactly for both cases. Everything else reusesprint/1rather than reimplementing per-type stringification.
min/max/compcalllogos.seq'sreduce/reversefrom insidecore.logos, even thoughlogos.seqhasn't loaded yet at the pointcore.logositself is being read -- confirmed safe (and worth calling out as a new pattern for this file specifically) since afnbody only ever resolves its bare symbols against whatever's reachable when it's actually called, never at definition time; by the time anything callsmin/max/comp,logos.seqhas long since loaded.Decimal literals (
10.99M,3M,-2.5M, matching Clojure's ownM-suffixedBigDecimalsyntax exactly): arbitrary-precision, exact-scale decimal arithmetic -- unlikefloat(IEEE 754, can't represent0.1exactly) and unlikeLogos.Ratio(always reduces to lowest terms, so10.10loses its original "two decimal places" the moment it becomes a ratio), a decimal preserves the scale it was written with (10.10Mprints back as"10.10M", not"10.1M"). Backed by thedecimalhex package (a new, genuine runtime dependency -- the first besidesichor_runtime; zero further dependencies of its own) --lib/logos/value/decimal.ex(Logos.Decimal) is a thin wrapper module, not a new struct: Logos decimal values are the hex package's own%Decimal{}directly.+/-/*///=/</>/<=/>=/number?/type-ofall gained decimal-awareness, andlogos.coregained adecimal?predicate (alongside the existingnil?/list?/vector?/map?/set?/number?). Required a one-line change topriv/grammar/logos.aether(NUMBER's plain int/float alternative gained an optional trailing"M") and regenerating the checked-inlib/logos/reader/generated.ex-- the grammar's first change since the project's initial commit.Numeric tower / contagion rules, closest to Clojure's own plus one deliberate, documented simplification:
Integer < Ratio < Decimal < Float, each wider type winning when mixed with a narrower one. Decimal+Ratio converts the ratio to a decimal viaDecimal.div/2at the ambient context precision (real JavaBigDecimalthrows on a non-terminating ratio like1/3instead; Logos rounds, sinceLogos.Ratio's own design already isn't a literal Java-BigDecimal port). "Float poisons everything" (mixing a float into ratio/decimal math always produces a float, matching Clojure's own well-documented double/BigDecimal-mixing gotcha) applies to both Ratio and Decimal.=stays scale-sensitive (matches realBigDecimal.equals/1exactly --(= 1.10M 1.1M)isfalse; only<=/>=treat them as equal, viaDecimal.compare/2).Standard library split into real namespaces under
priv/stdlib/:core.logos(logos.core-- unchangeddefmacro/let/if/when/unless/and/or/defn, plus newdefn-/doc/ns),seq.logos(logos.seq-- newmap/filter/reduce/take/drop/reverse/count/empty?, list-only, built purely fromfirst/rest/cons/cond),concurrency.logos(logos.concurrency--receive/atom/deref/swap!/reset!, moved as-is from the old single-filestdlib.lisp),logos.map(newget/assoc/dissocLayer-1 primitives -- no.logosfile, since map access needs real Elixir map operations Logos has no way to express itself), andtest.logos(logos.test-- see its own entry below).Logos.Stdlib.load!/1loadscore.logosfirst, then the rest, then referslogos.seq/logos.map/logos.concurrencyintologos.coreitself -- combined with a smallLogos.Eval.resolve_symbol_location/2change (below), this is what keeps every one of these functions/macros reachable with no namespace prefix from any namespace, exactly as if everything still lived in one file.logos.testis deliberately excluded from this refer-into-core step -- see its own entry.logos.test: a small unit-testing library --deftest,assert,assert=,assert-throws,run-tests. Loaded into everyLogos.Runtimelike the other stdlib files, but -- unlikelogos.seq/logos.map/logos.concurrency-- deliberately not referred intologos.core; testing macros have no business being unqualified-visible in every embedding's production namespaces, so a caller opts in explicitly:(require '[logos.test :refer [:all]])(this project's own:refer [:all]spelling for "refer everything," a vector, unlike real Clojure's bare:refer :all). Eachdeftestis isolated in its ownspawn-monitored process whenrun-testsruns it -- real process isolation, not a simulatedtry/catchsandbox, since Logos'stry/catchonly ever catches an explicit(throw ...), never an ordinary evaluation error (an unbound symbol, a wrong arity, ...) -- without it, one genuinely broken test would abort the wholerun-testscall rather than being reported as one failure among many.run-testsreturns a summary map:{:total n :passed n :failed (list of (name reason) pairs)}.normal-exit?new Layer-1 primitive: whether a:DOWN/:EXITmessage'sreasonwas a clean exit. Needed because that reason is a raw Elixir term (the bare atom:normalon a clean exit) --:normalin Logos source reads as aLogos.Keyword, a different, never-==type, so(= reason :normal)is always false even for a genuinely clean exit.logos.test'srun-testsis what surfaced the need for this (checking whether a spawned test process exited cleanly).ns,doc,defn-macros (core.logos) -- previously planned but never implemented.nsis sugar overin-ns+require/use(not real file-based namespace loading, which nothing in Logos has yet);docreads back a var's docstring;defn-isdefnplus^:private.with-meta/var-doc/string?new Layer-1 primitives, backingdefn-/doc/defn+defmacro's optional-docstring detection respectively.Keyword-as-function (
(:key m)/(:key m default),Logos.Eval.apply_fn/3), matching Clojure's own keyword-as-IFnbehavior. Scoped to a map ornilargument (not sets).Docstrings throughout the stdlib, and
^:macro/^:privatemetadata used directly wherever it fits, rather than only:private:defmacroitself now flags the macro it defines viawith-meta+{:macro true}(onedef) instead of a separateset-macro!call (its own bootstrap definition is flagged the same way, via^:macroreader sugar);defn/defmacro/defn-all accept an optional leading docstring argument ((defn name "doc" [params] body...)), matching Clojure.Logos.Eval'smeta_from_symbol/2now recognizes:macroalongside the existing:private, both read off adef'd symbol's own metadata (^-attached orwith-meta-attached).test/logos_scripts/-- hand-written.logostest scripts exercising the language end to end from Logos source itself (not just from the Elixir side): short feature tests (arithmetic/ratios, collections/seq, macros/hygiene, namespaces, concurrency) plus two larger, more realistic examples (an atom-backed key-value store; a spawn-based worker pool computing a parallel sum of squares). Each script(require '[logos.test :refer [:all]])s and registers its scenarios withdeftest;test/logos_scripts_test.exsruns each script and reads its final(run-tests)summary, flunking on any reported failure (a failingdeftestno longer makes the whole script's evaluation return{:error, _}, since each is isolated in its own process -- seelogos.test's own entry above).Reader/grammar (
priv/grammar/logos.aether,Logos.Reader,Logos.Reader.Actions): a Lisp reader built on Ichor/Aether, extending the base fixture grammar with set literals (#{...}), character literals (\a,\newline,\uHHHH), ratio literals (1/3), var-quote (#'sym), anonymous-fn sugar (#(...)), real symbol/ collection metadata (^meta), and#_formdatum comments. Pure reification only -- the reader never evaluates.Eval / special forms (
Logos.Eval): a tree-walking evaluator implementing exactly six special forms (quote,cond,do,def,fn,try), written so a tail-position call is a genuine Elixir tail call -- validated with a real 10,000,000-iteration self-recursive test.try/throw/catch/finallyinterpose on a dedicatedLogos.Thrownexception, keyword-tag matched.Macroexpand / hygiene (
Logos.Macroexpand, syntax-quote desugaring inLogos.Reader.Actions): a separate macroexpansion pass to a fixed point, with lexical-shadow tracking (a local can shadow a same-named macro), implicit&form/&envbindings, real nested-depth syntax-quote (`/~/~@), automatic symbol qualification, and automatic gensym (x#) for macro hygiene.Namespaces, Vars, Runtime (
Logos.Runtime,Logos.Namespace,Logos.Var): a per-embedding-instance,:publicETS-backed namespace registry (never a global singleton) with interned Vars,require/use/aliasing, an implicitlogos.corerefer on every namespace, private-var support (^:private), circular-require detection, and per-process current-namespace tracking so concurrentspawned processes never stomp on each other'sin-ns.Bootstrap layering: Layer 1 Elixir primitives (
Logos.Primitives) plus a self-hosted Layer 2 (lib/logos/stdlib.lisp, loaded viaLogos.Stdlib) written in Logos itself --defmacrobootstrapped from two lines, thenif/let/when/unless/and/or/defn/receive/atom/deref/swap!/reset!built on top.Logos.new_runtime/1is the one-call convenience combining both.Concurrency (
Logos.Process,Logos.Atom,Logos.Pid): real BEAM process primitives (spawn/spawn-link/spawn-monitor/link/monitor/send/self/exit), hand-rolled selective receive (receive-match!) with areceivemacro over it, and atoms as an ordinary Logos-defined stateful loop process (not anAgent).Dev tooling:
Logos.Printer(a round-tripping textual printer for every reader-producible type),Logos.Format(a comment-preserving code formatter built on the raw token stream),Logos.Repl, and four Mix tasks --mix logos.repl,mix logos.run,mix logos.format,mix logos.remsh.Full documentation pass: in-code
@moduledoc/@doc/@speccoverage and private-function/complex-logic comments across every module andstdlib.lisp; an ExDoc configuration; this README, the library and language tutorials/examples/cheatsheets, and this changelog.Real file-based namespace/
requireloading.require/use/nsnow resolve a namespace not already loaded in-memory against a newLogos.Runtime.new/1opt,:load_paths(a fixed, per-Runtimelist of directory strings, exposed viaLogos.Runtime.load_paths/1), following Clojure's ownns-to-classpath naming convention (my-app.core->my_app/core.logos, first load path with a match wins).mix logos.run/mix logos.replboth configure["lib"]. Three new, narrow error shapes distinguish the failure modes only a real disk loader has:{:cannot_read_ns_file, ns, path, reason},{:ns_file_missing_ns, ns, path}(the file evaluated but never(ns ...)'d the namespace it was required under), and the pre-existing{:namespace_not_loaded, ns}now also covers "no matching file on any load path". The requiring process's current namespace is saved and restored around a load, mirroring Clojure'sloaddynamically rebinding and popping*ns*. The circular-require guard (Logos.Runtime.start_loading!/2/finish_loading!/2, already built, previously unreachable) is exercised by real recursion for the first time --{:error, {:circular_require, ns}}for a genuine A-requires-B- requires-A cycle.Exponent number syntax (
1.5e10,1e5,1.5e-10, and1.5e10M/1e5Mfor decimals), matching Clojure's own grammar.priv/grammar/logos.aether'sNUMBERrule gained an optional exponent group (("e" | "E") ("+" | "-")? DIGIT+) on its plain int/float alternative, ahead of the optional"M"suffix; the sign is optional-and-either (not minus-only) sinceDecimal.to_string/1's own default:scientificformat prints a leading+for a positive exponent, so accepting it isn't just source ergonomics -- a decimal with a large-magnitude exponent couldn't otherwise round-trip through the printer at all.
Changed
(empty? x)/(count x)/etc. on a non-collection argument now errors instead of silently returningfalse/a no-op -- a side effect oflogos.seqnow coercing everycollargument viato-list(see this release's seq-abstraction entry above):(empty? 5)used to fall through tofalse(neither thenilnor()check matched), now it errors withto-list's own{:invalid_args, ...}. Matches real Clojure, where(empty? 5)also throws -- more correct, not a regression, but a real, observable difference for any existing caller that relied on the old silent-falsebehavior.Renamed the Logos source file extension from
.lispto.logosthroughout -- the stdlib's own files,mix logos.repl's project-file preload glob,mix logos.run/mix logos.format's usage text and doc examples, and every doc-comment cross-reference to the (now four) stdlib files.Logos.Eval.resolve_symbol_location/2's auto-refer-core fallback now also checkslogos.core's ownreferstable (one level, not a recursive namespace walk) when a name isn't directly interned there -- what makes the standard-library namespace split above possible without losing "usable everywhere with no namespace prefix."Logos.Primitives.install!/1installs primitives into more than one namespace now (logos.core's existing set, plus the newlogos.map), rather than alwayslogos.core.Upgraded to the latest Ichor (GLR/LR parsing engines, custom lexemes/rules, a token refiner, a
Backtrackunification engine, and aToolkitof extracted compiler-internals helpers). Evaluated every new subsystem against Logos's actual needs rather than adopting for its own sake:use Ichor/Ichor.Actions's public contract is unchanged, so no migration was required there. Two genuine, narrow wins were adopted:Logos.Eval's privateeval_args/3now usesIchor.Toolkit.Result.map_ok/3instead of a hand-rolled accumulate-and-reverse recursion (same behavior, less bespoke code). GLR/LR,Toolkit.Pratt,Toolkit.TypeScheme,Toolkit.Layout,Toolkit.Codegen,Toolkit.Graph, andIchor.Backtrackwere all deliberately not adopted -- Logos's grammar is a non-left-recursive, non-layout-sensitive, prefix-only S-expression reader with no infix operators, no type inference, and no unification/logic-search feature, so none of those apply.Toolkit.TermWalk/Toolkit.Fixpointwere also evaluated and rejected forLogos.Macroexpand/the syntax-quote desugarer specifically: both need context threading (locals tracking, gensym-table state),quote-suppression, and per-call-site re-expansion that those generic primitives don't have a hook for -- adopting them would have added code, not removed it.Fixed a real, previously-documented gap using the Ichor upgrade as the occasion to revisit it:
SYMBOL_CHARnow includes., so dotted namespace symbols (my-app.core/x,ns.sub/x) tokenize as ordinary symbols. Previously.fell through to no grammar rule at all, so a namespace could hold a dotted name (an ordinary Elixir string, e.g."logos.core") but the reader could never produce one from source text -- this was a plain one-line grammar fix, not something that needed any of Ichor's new machinery (.staying out ofSYMBOL_STARTwas a deliberate, separate choice: Logos has no.method/.-fieldinterop sugar today, so a leading dot is still a hard read error rather than silently becoming a one-character symbol).Ichor dependency switched from a local path dependency to Hex, then from
use Ichortomix ichor.gen, splitting the dependency intoichor(dev-only) +ichor_runtime(runtime), following the same split upstream in Ichor itself. Both are now ordinary Hex packages --{:ichor, "~> 0.2.1", only: [:dev], runtime: false}and{:ichor_runtime, "~> 0.1.0"}, the latter independently published (its own repo, not a subdirectory ofichor's).lib/logos/reader/generated.exismix ichor.gen's checked-in output forpriv/grammar/logos.aether--Logos.Readernow delegatesrun_sequence/2/tokenize/1to it instead of having Ichor's native codegen spliceparse/1/tokenize/1/run/1,2/run_sequence/2directly intoLogos.Readerat every compile. Regenerate it (command inmix.exs'sdeps/0) wheneverpriv/grammar/logos.aetherchanges. The practical win:ichor(the Aether front-end,Grammar.Analysis, the LR/GLR table builder, both codegen backends) isonly: [:dev], runtime: falseand never ships, including in amix releasebuild -- onlyichor_runtime(Ichor.Actions,Ichor.Error,Ichor.Toolkit.Result, the compiled tokenizer/parser combinators) is an ordinary runtime dependency, exactly the handful of support modules the generated reader actually calls.Removed every internal citation of
DESIGN.md(a former internal design document, never published, now deleted) and of its numberedPhase Nrollout plan from module docs and comments across the codebase. Every fact those citations pointed at is now stated directly, in place, so no doc comment depends on a document that doesn't ship with the library.
Fixed
&(fn's rest-param marker) inside a syntax-quoted macro template (found while buildingdefmulti, which needs to build exactly this shape --`(defn ~name [& args#] ...)): the same bug class as thecatch/finallyfix below, a second instance of it.Logos.Reader.Actions's@special_form_names(the list syntax-quote auto-qualification skips) hadcatch/finallybut not&. A syntax-quoted[& args#]auto-qualified the bare&to e.g.user/&(not a real Var anywhere, so resolution falls back to qualifying against the current namespace) -- and a qualified symbol never satisfiesLogos.Eval'srest_marker?/1(bare,ns: nilnames only), so the parameter silently stopped being recognized as a rest marker at all: the resulting function came out with a fixed arity of 2 (&and the following name both treated as ordinary positional params) instead of "any number of arguments." Confirmed end to end (test/logos/syntax_quote_test.exs) with a minimal macro reproducing exactly this shape, independent of multimethods themselves -- this was a real, general language bug, not something specific todefmulti.- A Logos map literal with computed elements silently didn't
compute them (found in
defmulti/defmethod's own first draft):register-multimethod!built{:dispatch-fn dispatch-fn :methods {}}as a literal map, which -- perLogos.Form.t()'s own documented behavior, map/vector/set literals are self-evaluating with unevaluated elements, unlike Clojure -- stored the literal symboldispatch-fnas the value, not the function actually bound to that parameter. Every multimethod dispatch then failed with{:not_callable, #Logos.Symbol<dispatch-fn>}. Fixed by building the map viaassocfrom{}instead, the same fix shapeinto/frequencies(priv/stdlib/seq.logos) already needed for the identical reason -- not a new class of bug, but a real instance of an already-known trap, caught by an actualmix runsmoke test before it reached a test file. Logos.ReplandMix.Tasks.Logos.Runeach had a privateformat_error/1helper that calledException.message/1on a%Ichor.Error{}value -- butIchor.Erroris a plain struct, not anException, so this would have raisedProtocol.UndefinedErrorthe first time a reader-level error actually reached either code path (a REPL/mix logos.runsyntax error in the source being read, as opposed to an evaluation error). Both now callIchor.Error.format/1, the function Ichor itself uses internally to render this same struct.Logos.Process's docs referencedProcess.spawn_linkandProcess.spawn_monitor(both arity 1) -- neither exists in Elixir/Erlang (verified directly:function_exported?(Process, :spawn_link, 1)and the:spawn_monitorequivalent are bothfalse). The real functions areKernel.spawn_link/1/Kernel.spawn_monitor/1, which is whatLogos.Process.spawn_link/2/spawn_monitor/2were already correctly calling -- only the doc text named the wrong module.- Every guide's code examples and every module's technical claims were
re-run against the real, compiled implementation rather than trusted
as written, surfacing and correcting several other stale/incorrect
claims: a contradiction in
LOGOS.mdabout whether(import 'String.upcase)actually works (it does; one code block said otherwise), a stale claim that dottedin-ns/requirenamespace names are still a reader error (fixed earlier, doc never caught up), a staleLogos.Var"doesn't exist until a later phase" claim in the reader actions'var-quotedoc, a stale "no concurrency primitives exist yet" caveat inLogos.Runtime's moduledoc, and a stale claim inLogos.Eval.resolve_symbol_location/2's doc that lexical-shadow tracking for macro calls "is not yet implemented" (it is). mix format/mix logos.format --check-formattednow both pass cleanly (previously-unformatted whitespace/indentation across most oflib/andlib/logos/stdlib.lisp, unrelated to this pass's actual doc content, brought in line with the project's own stated style).mix docspreviously emitted 9 ExDoc cross-reference warnings (doc comments linking to private/hidden functions via`Module.function/arity`backtick syntax, which ExDoc's autolinker can't resolve). Rephrased each to convey the same information without triggering autolinking;mix docsnow builds with zero warnings.#(...)anon-fn sugar (Logos.Reader.Actions'sdesugar_anon_fn/1): previously spliced the captured body forms directly into the generatedfn's body instead of nesting them as one call --#(+ %1 %2)desugared to(fn [%1 %2] + %1 %2)(three sequential body forms, returning only%2) instead of(fn [%1 %2] (+ %1 %2)). Also, a bare%placeholder synthesized the%1param but left the body referencing the never-bound literal symbol%. Both fixed: the body now nests as one call, and every bare%in it is substituted to%1.macro?now resolves through the same full chain (Logos.Eval.resolve_symbol_location/2) actual macro dispatch uses, instead of a current-namespace-only lookup --(macro? 'let)now correctly returnstrue(it only ever returnedfalsebefore, sinceletis reached via the implicitlogos.corerefer, neverdef'd directly in the caller's own namespace).- Ratio arithmetic and comparison (
Logos.Primitives):/now accepts an already-constructed%Logos.Ratio{}operand and cross-multiplies instead of erroring ((/ (/ 1 3) 2)=>1/6);+/*(and-, which had the identical bug, undocumented until this pass) accept a ratio operand instead of raisingArithmeticError;</>/<=/>=now cross-multiply to compare fraction magnitude instead of comparing%Logos.Ratio{}struct fields directly ((< 1/3 1/2)now correctly returnstrue, previouslyfalse). - Ratio mixed with a float crashed (found while designing decimal's
own numeric-tower contagion rules):
num_add/2/num_sub/2/num_mul/2had clauses for ratio-vs-ratio and ratio-vs-integer, but none for ratio-vs-float -- a bare float operand fell through to the generica + bfallback, and%Logos.Ratio{} + 1.5isn't valid Erlang arithmetic (%Logos.Ratio{}isn't a number).(+ 1/3 1.5)crashed; now produces a float (1.8333333333333333), matching the same "float poisons everything" rule decimal arithmetic uses. catch/finallyinside a syntax-quoted macro template (found while buildinglogos.test'sassert-throws, which is exactly this pattern):Logos.Reader.Actions's@special_form_names(the list syntax-quote auto-qualification skips) hadtrybut not its owncatch/finallyclause-introducers. A syntax-quoted`(try ~form (catch ~tag v# v#))auto-qualifiedcatchto e.g.some-ns/catch(not a real Var anywhere, so resolution fails and it falls back to qualifying against the macro's defining namespace) -- and a qualified symbol never satisfies the private clause-boundary checkLogos.Eval'stryspecial form uses internally (bare,ns: nilnames only), so the clause silently stopped being recognized as a catch at all and became dead body code instead: the exception it was meant to catch propagated uncaught. Every syntax-quotedtry/catch/finallywritten beforelogos.testwas either incore.logos's own bootstrap (catch/finallynever appear there) or written directly by hand (never through syntax-quote), which is why this went unnoticed until now.Logos.Printer's "very large/small floats don't round-trip" gap:Float.to_string/1falls back to scientific notation ("1.0e300") past a certain magnitude, and until this pass the grammar had no exponent syntax to read that text back with. Closed by the exponent syntax addition above -- confirmed with an actual round-trip test (1.0e300/1.0e-300throughprint/1thenLogos.Reader.read/1), not just inferred from the grammar change.- The circular-require guard could never actually fire, a bug found
while wiring up real disk loading:
load_ns!/2's existingNamespace.exists?/2fast-path was checked before theloading?check, but a namespace file's own leading(ns circ.a ...)form makesNamespace.exists?(runtime, "circ.a")true the moment itsin-nsline runs -- well before the rest of the file (and whatever it itself requires) finishes evaluating. So a genuine A-requires-B-requires-A cycle would silently "succeed" (both namespaces end up created, just incompletely) instead of tripping the guard, since by the time B re-requires A, A already "exists" as an empty, still-loading shell. Fixed by checkingloading?first -- the same distinction real Clojure draws between "namespace object created" (in-ns/create-ns) and "fully loaded" (*loaded-libs*).
See CONTRIBUTING.md for the current (empty) known-gaps list.