From 456d47e9b0b772679bfc627a48472be3208fb849 Mon Sep 17 00:00:00 2001 From: James Deikun Date: Thu, 6 Aug 2026 05:49:54 -0400 Subject: [PATCH] add yclept --- cabal.project | 1 + justfile | 4 +- packages/yclept/README.md | 4 + packages/yclept/justfile | 1 + packages/yclept/src/Yclept.hs | 27 + packages/yclept/src/Yclept/Bwd.hs | 47 ++ packages/yclept/src/Yclept/Language.hs | 125 ++++ packages/yclept/src/Yclept/Modifier.hs | 119 ++++ packages/yclept/src/Yclept/Scope.hs | 343 +++++++++++ packages/yclept/src/Yclept/Trie.hs | 599 ++++++++++++++++++++ packages/yclept/src/Yclept/Trie/Untagged.hs | 173 ++++++ packages/yclept/test/EasyExample.hs | 81 +++ packages/yclept/test/Example.hs | 150 +++++ packages/yclept/yclept.cabal | 59 ++ 14 files changed, 1731 insertions(+), 2 deletions(-) create mode 100644 packages/yclept/README.md create mode 100644 packages/yclept/justfile create mode 100644 packages/yclept/src/Yclept.hs create mode 100644 packages/yclept/src/Yclept/Bwd.hs create mode 100644 packages/yclept/src/Yclept/Language.hs create mode 100644 packages/yclept/src/Yclept/Modifier.hs create mode 100644 packages/yclept/src/Yclept/Scope.hs create mode 100644 packages/yclept/src/Yclept/Trie.hs create mode 100644 packages/yclept/src/Yclept/Trie/Untagged.hs create mode 100644 packages/yclept/test/EasyExample.hs create mode 100644 packages/yclept/test/Example.hs create mode 100644 packages/yclept/yclept.cabal diff --git a/cabal.project b/cabal.project index ed74ba6b..be4cab33 100644 --- a/cabal.project +++ b/cabal.project @@ -3,6 +3,7 @@ packages: packages/diagnostician-terminal packages/diagnostician-html packages/fnotation + packages/yclept packages/coln-compiler if !os(wasi) packages: diff --git a/justfile b/justfile index fdd31d0a..58c92e41 100644 --- a/justfile +++ b/justfile @@ -14,7 +14,7 @@ check package: fix package: just -f packages/{{package}}/justfile fix -check-haskell: (check "coln-compiler") (check "coln-cli") (check "coln-repl") (check "coln-ls") (check "fnotation") (check "diagnostician") +check-haskell: (check "coln-compiler") (check "coln-cli") (check "coln-repl") (check "coln-ls") (check "fnotation") (check "diagnostician") (check "yclept") check-rust: (check "coln-store") (check "coln-query") (check "coln-batch") (check "coln-flir-rs") @@ -25,7 +25,7 @@ check-licenses: check-all: check-haskell check-rust check-typescript -fix-haskell: (fix "coln-compiler") (fix "coln-cli") (fix "coln-repl") (fix "coln-ls") (fix "fnotation") (fix "diagnostician") +fix-haskell: (fix "coln-compiler") (fix "coln-cli") (fix "coln-repl") (fix "coln-ls") (fix "fnotation") (fix "diagnostician") (fix "yclept") fix-licenses: git ls-files -z '*.[hrt]s' {{ generated_file_pathspecs }} | xargs -0 reuse annotate -c "Coln contributors" -l "Apache-2.0 OR MIT" diff --git a/packages/yclept/README.md b/packages/yclept/README.md new file mode 100644 index 00000000..faba03ad --- /dev/null +++ b/packages/yclept/README.md @@ -0,0 +1,4 @@ +# yclept + +Based on the OCaml [`yuujinchou`](../../ocaml) library: name pattern +combinators for hierarchical names. diff --git a/packages/yclept/justfile b/packages/yclept/justfile new file mode 100644 index 00000000..c4f6a8d7 --- /dev/null +++ b/packages/yclept/justfile @@ -0,0 +1 @@ +import '../../build-support/haskell.just' diff --git a/packages/yclept/src/Yclept.hs b/packages/yclept/src/Yclept.hs new file mode 100644 index 00000000..6a959499 --- /dev/null +++ b/packages/yclept/src/Yclept.hs @@ -0,0 +1,27 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT + +{- | Name pattern combinators for hierarchical names — based on the OCaml + @yuujinchou@ library. + +This is a documentation landing module: it deliberately exports nothing, +because the submodules share many names (@empty@, @union@, @singleton@, …) +and are designed to be imported /qualified/, like "Data.Map": + +> import qualified Yclept.Bwd as Bwd +> import qualified Yclept.Trie as Trie +> import qualified Yclept.Trie.Untagged as UntaggedTrie +> import qualified Yclept.Language as Language +> import qualified Yclept.Modifier as Modifier +> import qualified Yclept.Scope as Scope + +The pieces: + +* "Yclept.Bwd": backward (snoc) lists, used for path prefixes. +* "Yclept.Trie" and "Yclept.Trie.Untagged": the trie data structure. +* "Yclept.Language": the modifier DSL. +* "Yclept.Modifier": the modifier engine and its 'Yclept.Modifier.Handlers' bundle. +* "Yclept.Scope": the lexical-scope engine. +-} +module Yclept () where diff --git a/packages/yclept/src/Yclept/Bwd.hs b/packages/yclept/src/Yclept/Bwd.hs new file mode 100644 index 00000000..52464a8d --- /dev/null +++ b/packages/yclept/src/Yclept/Bwd.hs @@ -0,0 +1,47 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT + +{- | Backward (snoc) lists, a port of the OCaml @bwd@ library. + +A backward list grows at the /right/ end: @'Emp' ':<' \"x\" ':<' \"y\"@ +represents the sequence @x, y@ and 'toList' yields @[\"x\", \"y\"]@. +-} +module Yclept.Bwd ( + Bwd (..), + (#<), + (<:), + (<@), + toList, + length, +) where + +import Data.Foldable (length, toList) +import Prelude hiding (length) + +-- | A backward list. The constructor ':<' is snoc: @xs ':<' x@. +data Bwd a + = Emp + | Bwd a :< a + deriving (Eq, Ord, Show, Functor, Foldable, Traversable) + +infixl 5 :< +infixl 5 #< +infixl 5 <: +infixl 5 <@ + +-- | Snoc a single element (OCaml @( #< )@). +(#<) :: Bwd a -> a -> Bwd a +(#<) = (:<) + +{- | Snoc a single element (OCaml @( <: )@); an alias of '#<' kept for parity +with the OCaml @bwd@ API. +-} +(<:) :: Bwd a -> a -> Bwd a +(<:) = (:<) + +{- | Append a forward list onto the right end of a backward list +(OCaml @( <\@ )@). +-} +(<@) :: Bwd a -> [a] -> Bwd a +(<@) = foldl (:<) diff --git a/packages/yclept/src/Yclept/Language.hs b/packages/yclept/src/Yclept/Language.hs new file mode 100644 index 00000000..b525a873 --- /dev/null +++ b/packages/yclept/src/Yclept/Language.hs @@ -0,0 +1,125 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT + +{- | The modifier language, a port of the OCaml @Language@ / @LanguageSigs@. + +A @'Language' h@ value is an abstract syntax tree describing how to transform +a trie; it is executed by "Yclept.Modifier". Build values with the smart +constructors ('all', 'only', 'renaming', …); the raw constructors are also +exported so the engine (and clients who wish to) can pattern-match. + +Intended to be imported qualified: + +> import qualified Yclept.Language as Language +-} +module Yclept.Language ( + Language (..), + + -- * Builders + all, + id, + only, + in_, + none, + except, + renaming, + seq, + union, + hook, + + -- * Debugging + dump, +) where + +import Data.Functor.Classes (Eq1 (..)) +import Data.List (intercalate) +import Data.Text qualified as Text +import Prelude hiding (all, id, seq) + +import Yclept.Trie (Path) + +-- | The abstract type of modifiers, parametrised by the type of hook labels. +data Language h + = MAssertNonempty + | MIn Path (Language h) + | MRenaming Path Path + | MSeq [Language h] + | MUnion [Language h] + | MHook h + deriving (Eq, Show, Functor, Foldable, Traversable) + +{- | Structural equality with a supplied hook comparison (the OCaml @equal@). +The ordinary 'Eq' instance is @'liftEq' '=='@. +-} +instance Eq1 Language where + liftEq _ MAssertNonempty MAssertNonempty = True + liftEq eqh (MIn p1 m1) (MIn p2 m2) = p1 == p2 && liftEq eqh m1 m2 + liftEq _ (MRenaming a1 b1) (MRenaming a2 b2) = a1 == a2 && b1 == b2 + liftEq eqh (MSeq xs) (MSeq ys) = liftEq (liftEq eqh) xs ys + liftEq eqh (MUnion xs) (MUnion ys) = liftEq (liftEq eqh) xs ys + liftEq eqh (MHook h1) (MHook h2) = eqh h1 h2 + liftEq _ _ _ = False + +{- | Keep the content of the current tree, performing @not_found@ if it is +empty. Equivalent to @'only' []@. +-} +all :: Language h +all = MAssertNonempty + +{- | The identity modifier (@'seq' []@); like 'all' but without the emptiness +check. +-} +id :: Language h +id = seq [] + +{- | Keep the subtree rooted at @path@, dropping everything else; performs +@not_found@ if that subtree is empty. +-} +only :: Path -> Language h +only p = MSeq [MIn p MAssertNonempty, MRenaming p [], MRenaming [] p] + +-- | Run a modifier on the subtree rooted at @path@, leaving the rest intact. +in_ :: Path -> Language h -> Language h +in_ = MIn + +-- | Drop everything, performing @not_found@ if the tree was already empty. +none :: Language h +none = MSeq [MAssertNonempty, MUnion []] + +-- | Drop the subtree rooted at @p@ (@'in_' p 'none'@). +except :: Path -> Language h +except p = in_ p none + +{- | Relocate the subtree at @path@ to @path'@, dropping any existing bindings +under @path'@; performs @not_found@ if the source subtree is empty. +-} +renaming :: Path -> Path -> Language h +renaming p p' = MSeq [MIn p MAssertNonempty, MRenaming p p'] + +-- | Run the modifiers in order (@'seq' []@ is 'id'). +seq :: [Language h] -> Language h +seq = MSeq + +-- | Union of the results of the given modifiers; collisions trigger @shadow@. +union :: [Language h] -> Language h +union = MUnion + +-- | Apply the hook labelled @h@ to the whole tree (performs the @hook@ effect). +hook :: h -> Language h +hook = MHook + +-- | Dump the internal representation for debugging, given a printer for hooks. +dump :: (h -> String) -> Language h -> String +dump dumpHook = go + where + go MAssertNonempty = "assert-nonempty" + go (MIn p m) = "in(" ++ dumpPath p ++ "; " ++ go m ++ ")" + go (MRenaming p1 p2) = "renaming(" ++ dumpPath p1 ++ "; " ++ dumpPath p2 ++ ")" + go (MSeq ms) = "seq(" ++ intercalate "; " (map go ms) ++ ")" + go (MUnion ms) = "union(" ++ intercalate "; " (map go ms) ++ ")" + go (MHook h) = "hook(" ++ dumpHook h ++ ")" + +dumpPath :: Path -> String +dumpPath [] = "root" +dumpPath segs = "path(" ++ intercalate ", " (map (show . Text.unpack) segs) ++ ")" diff --git a/packages/yclept/src/Yclept/Modifier.hs b/packages/yclept/src/Yclept/Modifier.hs new file mode 100644 index 00000000..0cf7d648 --- /dev/null +++ b/packages/yclept/src/Yclept/Modifier.hs @@ -0,0 +1,119 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT +{-# LANGUAGE OverloadedRecordDot #-} + +{- | The modifier engine, a port of the OCaml @Modifier@ / @ModifierSigs@. + +The OCaml engine performs three /overridable/ algebraic effects — +@not_found@, @shadow@, and @hook@. Here those become the fields of an +explicit __bundle of handlers__, 'Handlers', which is polymorphic over a base +monad @m@. Every engine function ('modify', 'union', …) takes a bundle and +runs in @m@. + +Because the OCaml effect handlers were installed dynamically and could be +overridden per-region (via @run@ / @try_with@), the ceremony around them +dissolves here: to \"override\" a handler you simply pass a different bundle +(e.g. @hs { shadow = … }@). The @Perform@ / @try_with@ machinery therefore +lives at the scope layer ("Yclept.Scope"), which threads the current +bundle through a reader environment. + +The four OCaml @Param@ types become the type variables @d@ (data), @t@ +(tag), @h@ (hook), and @c@ (context); the @Make(Param)@ functor is just this +parametric polymorphism. +-} +module Yclept.Modifier ( + -- * The bundle of handlers + Handlers (..), + silence, + + -- * The engine + modify, + + -- * Re-exposed union operations (using the @shadow@ handler) + union, + unionSubtree, + unionSingleton, + unionRoot, +) where + +import Control.Monad (foldM) + +import Yclept.Bwd ((<@)) +import Yclept.Language (Language (..)) +import Yclept.Trie (BwdPath, Trie) +import Yclept.Trie qualified as Trie + +{- | A bundle of handlers for the three overridable modifier effects, in a base +monad @m@. @d@\/@t@ are the data\/tag, @h@ the hook label, @c@ the context. +-} +data Handlers m d t h c = Handlers + { notFound :: Maybe c -> BwdPath -> m () + {- ^ Called when a modifier expected at least one binding under a prefix but + found none. + -} + , shadow :: Maybe c -> BwdPath -> (d, t) -> (d, t) -> m (d, t) + {- ^ Called to reconcile two bindings @x@ (earlier) and @y@ (later) at the + same path during a union. + -} + , hook :: Maybe c -> BwdPath -> h -> Trie d t -> m (Trie d t) + -- ^ Called to run a custom hook on a subtree. + } + +{- | The handlers that silence every effect: @not_found@ does nothing, @shadow@ +keeps the later binding, @hook@ returns its input unchanged. +-} +silence :: (Applicative m) => Handlers m d t h c +silence = + Handlers + { notFound = \_ _ -> pure () + , shadow = \_ _ _ y -> pure y + , hook = \_ _ _ t -> pure t + } + +{- | @'modify' hs ctx prefix m t@ runs the modifier @m@ on the trie @t@, using +the handler bundle @hs@ for effects. @ctx@ is the context passed to +handlers; @prefix@ is prepended to any path reported to them (use @'Emp'@ +for none). +-} +modify :: (Monad m) => Handlers m d t h c -> Maybe c -> BwdPath -> Language h -> Trie d t -> m (Trie d t) +modify hs ctx = go + where + go prefix m t = + case m of + MAssertNonempty -> do + if Trie.isEmpty t then hs.notFound ctx prefix else pure () + pure t + MIn p m' -> + Trie.updateSubtreeM p (go (prefix <@ p) m') t + MRenaming p1 p2 -> + let (sub, remaining) = Trie.detachSubtree p1 t + in pure (Trie.updateSubtree p2 (const sub) remaining) + MSeq ms -> + foldM (\acc m' -> go prefix m' acc) t ms + MUnion ms -> + foldM + ( \ts m' -> do + ti <- go prefix m' t + union hs ctx prefix ts ti + ) + Trie.empty + ms + MHook h -> + hs.hook ctx prefix h t + +-- | Re-exposed 'Trie.union' whose merger is the @shadow@ handler. +union :: (Monad m) => Handlers m d t h c -> Maybe c -> BwdPath -> Trie d t -> Trie d t -> m (Trie d t) +union hs ctx prefix = Trie.unionM prefix (hs.shadow ctx) + +-- | Re-exposed 'Trie.unionSubtree' using the @shadow@ handler. +unionSubtree :: (Monad m) => Handlers m d t h c -> Maybe c -> BwdPath -> Trie d t -> (Trie.Path, Trie d t) -> m (Trie d t) +unionSubtree hs ctx prefix = Trie.unionSubtreeM prefix (hs.shadow ctx) + +-- | Re-exposed 'Trie.unionSingleton' using the @shadow@ handler. +unionSingleton :: (Monad m) => Handlers m d t h c -> Maybe c -> BwdPath -> Trie d t -> (Trie.Path, (d, t)) -> m (Trie d t) +unionSingleton hs ctx prefix = Trie.unionSingletonM prefix (hs.shadow ctx) + +-- | Re-exposed 'Trie.unionRoot' using the @shadow@ handler. +unionRoot :: (Monad m) => Handlers m d t h c -> Maybe c -> BwdPath -> Trie d t -> (d, t) -> m (Trie d t) +unionRoot hs ctx prefix = Trie.unionRootM prefix (hs.shadow ctx) diff --git a/packages/yclept/src/Yclept/Scope.hs b/packages/yclept/src/Yclept/Scope.hs new file mode 100644 index 00000000..5f45594c --- /dev/null +++ b/packages/yclept/src/Yclept/Scope.hs @@ -0,0 +1,343 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE OverloadedRecordDot #-} + +{- | The lexical-scope engine, a port of the OCaml @Scope@ / @ScopeSigs@. + +A scope has two namespaces: a /visible/ one (what is in scope) and an +/export/ one (what will be exported). The OCaml module realises this with +three /standard/ algebraic effects that are never overridden — mutable state +(the two tries), a reader (the export prefix), and a mutex (a re-entrancy +guard). Here those become a monad transformer stack: + +> ScopeT d t h c m = ReaderT (Env m d t h c) (StateT (ScopeState d t) m) + +Every use of 'lift' is confined to the small set of primitive wrappers below +('getScope', 'putScope', 'askEnv', 'localEnv', 'liftBase'); the rest of the +module—and any client code—goes through those, so the transformer stack can +be changed in one place. + +The /overridable/ effects (@not_found@, @shadow@, @hook@) are handled the +"Yclept.Modifier" way: a 'Handlers' bundle, carried in the reader +environment so that 'tryWith' can override it for a sub-computation exactly as +the OCaml @try_with@ did. +-} +module Yclept.Scope ( + -- * The scope monad + ScopeT, + Handlers (..), + silence, + Locked (..), + + -- * Runners + run, + runWith, + tryWith, + + -- * Escaping to the base monad + liftBase, + + -- * Name resolution + resolve, + + -- * Inclusion (affects visible and export) + includeSingleton, + includeSubtree, + + -- * Importing (affects visible only) + importSingleton, + importSubtree, + + -- * Modifying namespaces + modifyVisible, + modifyExport, + + -- * Exporting + exportVisible, + getVisible, + getExport, + + -- * Sections + section, +) where + +import Control.Exception (Exception, throw) +import Control.Monad.IO.Class (MonadIO (..)) +import Control.Monad.Trans.Class (lift) +import Control.Monad.Trans.Reader (ReaderT, ask, local, runReaderT) +import Control.Monad.Trans.State.Strict (StateT, evalStateT, get, put) + +import Yclept.Bwd (Bwd (Emp), (<@)) +import Yclept.Language (Language) +import Yclept.Modifier (Handlers (..), silence) +import Yclept.Modifier qualified as Modifier +import Yclept.Trie (BwdPath, Path, Trie) +import Yclept.Trie qualified as Trie + +-- | The reader environment: the export prefix and the current handler bundle. +data Env m d t h c = Env + { exportPrefix :: BwdPath + , handlers :: Handlers m d t h c + } + +{- | The mutable scope state. @locked@ is the re-entrancy guard that stands in +for the OCaml @Algaeff.Mutex@. +-} +data ScopeState d t = ScopeState + { visible :: Trie d t + , export :: Trie d t + , locked :: Bool + } + +{- | The scope monad transformer. @d@\/@t@ are the data\/tag, @h@ the hook +label, @c@ the context, and @m@ the base monad (where handlers run). +-} +newtype ScopeT d t h c m a = ScopeT + {unScopeT :: ReaderT (Env m d t h c) (StateT (ScopeState d t) m) a} + deriving (Functor, Applicative, Monad) + +instance (MonadIO m) => MonadIO (ScopeT d t h c m) where + liftIO = liftBase . liftIO + +{- | Raised when a scope operation is attempted while another operation on the +same scope is still in progress (the OCaml @Locked@). This signals a serious +programming error. +-} +data Locked = Locked + deriving (Show) + +instance Exception Locked + +-- --------------------------------------------------------------------------- +-- Primitive wrappers — the ONLY place 'lift' appears. +-- --------------------------------------------------------------------------- + +{- | Run a base-monad action inside 'ScopeT'. This is also how handler-driven +code (e.g. printing in an effect handler) reaches the base monad from within +a scope. +-} +liftBase :: (Monad m) => m a -> ScopeT d t h c m a +liftBase = ScopeT . lift . lift + +getScope :: (Monad m) => ScopeT d t h c m (ScopeState d t) +getScope = ScopeT (lift get) + +putScope :: (Monad m) => ScopeState d t -> ScopeT d t h c m () +putScope = ScopeT . lift . put + +askEnv :: (Monad m) => ScopeT d t h c m (Env m d t h c) +askEnv = ScopeT ask + +localEnv :: (Monad m) => (Env m d t h c -> Env m d t h c) -> ScopeT d t h c m a -> ScopeT d t h c m a +localEnv f (ScopeT a) = ScopeT (local f a) + +-- --------------------------------------------------------------------------- +-- Derived accessors and the re-entrancy guard. +-- --------------------------------------------------------------------------- + +askHandlers :: (Monad m) => ScopeT d t h c m (Handlers m d t h c) +askHandlers = (\e -> e.handlers) <$> askEnv + +askExportPrefix :: (Monad m) => ScopeT d t h c m BwdPath +askExportPrefix = (\e -> e.exportPrefix) <$> askEnv + +{- | Run a critical section. Re-entering while locked raises 'Locked' (matching +the OCaml mutex). +-} +withLock :: (Monad m) => ScopeT d t h c m a -> ScopeT d t h c m a +withLock body = do + s <- getScope + if s.locked + then throw Locked + else do + putScope s{locked = True} + r <- body + s' <- getScope + putScope s'{locked = False} + pure r + +-- --------------------------------------------------------------------------- +-- Name resolution +-- --------------------------------------------------------------------------- + +-- | Look up a name in the visible namespace. +resolve :: (Monad m) => Path -> ScopeT d t h c m (Maybe (d, t)) +resolve p = withLock $ do + s <- getScope + pure (Trie.findSingleton p s.visible) + +-- --------------------------------------------------------------------------- +-- Inclusion (affects both namespaces) +-- --------------------------------------------------------------------------- + +{- | Add a binding to both the visible and export namespaces. @ctxVisible@ and +@ctxExport@ are the contexts for the @shadow@ effect on each merge. +-} +includeSingleton :: (Monad m) => Maybe c -> Maybe c -> (Path, (d, t)) -> ScopeT d t h c m () +includeSingleton ctxVisible ctxExport (path, x) = withLock $ do + hs <- askHandlers + pfx <- askExportPrefix + s <- getScope + vis' <- liftBase (Modifier.unionSingleton hs ctxVisible Emp s.visible (path, x)) + exp' <- liftBase (Modifier.unionSingleton hs ctxExport pfx s.export (path, x)) + putScope s{visible = vis', export = exp'} + +-- | Merge a subtree (after applying @modifier@) into both namespaces. +includeSubtree :: + (Monad m) => + -- | context for the modifier + Maybe c -> + -- | context for the visible-namespace merge + Maybe c -> + -- | context for the export-namespace merge + Maybe c -> + -- | modifier applied before merging (use @Language.id@ for none) + Language h -> + (Path, Trie d t) -> + ScopeT d t h c m () +includeSubtree ctxModifier ctxVisible ctxExport modifier pns = + withLock (unsafeIncludeSubtree ctxModifier ctxVisible ctxExport modifier pns) + +-- The unlocked core of 'includeSubtree', also used by 'section' (which already +-- holds the lock). +unsafeIncludeSubtree :: + (Monad m) => + Maybe c -> Maybe c -> Maybe c -> Language h -> (Path, Trie d t) -> ScopeT d t h c m () +unsafeIncludeSubtree ctxModifier ctxVisible ctxExport modifier (path, ns) = do + hs <- askHandlers + pfx <- askExportPrefix + s <- getScope + ns' <- liftBase (Modifier.modify hs ctxModifier Emp modifier ns) + vis' <- liftBase (Modifier.unionSubtree hs ctxVisible Emp s.visible (path, ns')) + exp' <- liftBase (Modifier.unionSubtree hs ctxExport pfx s.export (path, ns')) + putScope s{visible = vis', export = exp'} + +-- --------------------------------------------------------------------------- +-- Importing (affects the visible namespace only) +-- --------------------------------------------------------------------------- + +-- | Add a binding to the visible namespace only. +importSingleton :: (Monad m) => Maybe c -> (Path, (d, t)) -> ScopeT d t h c m () +importSingleton ctxVisible (path, x) = withLock $ do + hs <- askHandlers + s <- getScope + vis' <- liftBase (Modifier.unionSingleton hs ctxVisible Emp s.visible (path, x)) + putScope s{visible = vis'} + +-- | Merge a subtree (after applying @modifier@) into the visible namespace only. +importSubtree :: + (Monad m) => + Maybe c -> Maybe c -> Language h -> (Path, Trie d t) -> ScopeT d t h c m () +importSubtree ctxModifier ctxVisible modifier (path, ns) = withLock $ do + hs <- askHandlers + s <- getScope + ns' <- liftBase (Modifier.modify hs ctxModifier Emp modifier ns) + vis' <- liftBase (Modifier.unionSubtree hs ctxVisible Emp s.visible (path, ns')) + putScope s{visible = vis'} + +-- --------------------------------------------------------------------------- +-- Modifying namespaces +-- --------------------------------------------------------------------------- + +-- | Run a modifier on the visible namespace. +modifyVisible :: (Monad m) => Maybe c -> Language h -> ScopeT d t h c m () +modifyVisible ctxVisible m = withLock $ do + hs <- askHandlers + s <- getScope + vis' <- liftBase (Modifier.modify hs ctxVisible Emp m s.visible) + putScope s{visible = vis'} + +-- | Run a modifier on the export namespace. +modifyExport :: (Monad m) => Maybe c -> Language h -> ScopeT d t h c m () +modifyExport ctxExport m = withLock $ do + hs <- askHandlers + pfx <- askExportPrefix + s <- getScope + exp' <- liftBase (Modifier.modify hs ctxExport pfx m s.export) + putScope s{export = exp'} + +-- --------------------------------------------------------------------------- +-- Exporting +-- --------------------------------------------------------------------------- + +{- | Run a modifier on the visible namespace and merge the result into the +export namespace. +-} +exportVisible :: (Monad m) => Maybe c -> Maybe c -> Language h -> ScopeT d t h c m () +exportVisible ctxModifier ctxExport m = withLock $ do + hs <- askHandlers + pfx <- askExportPrefix + s <- getScope + modified <- liftBase (Modifier.modify hs ctxModifier Emp m s.visible) + exp' <- liftBase (Modifier.union hs ctxExport pfx s.export modified) + putScope s{export = exp'} + +-- | The current visible namespace (e.g. for auto-completion). +getVisible :: (Monad m) => ScopeT d t h c m (Trie d t) +getVisible = withLock ((\s -> s.visible) <$> getScope) + +-- | The current export namespace (e.g. when wrapping up a compilation unit). +getExport :: (Monad m) => ScopeT d t h c m (Trie d t) +getExport = withLock ((\s -> s.export) <$> getScope) + +-- --------------------------------------------------------------------------- +-- Sections +-- --------------------------------------------------------------------------- + +{- | Start a nested scope, run @body@ in it, and merge its export namespace +(prefixed with @p@, after @modifier@) into both namespaces of the parent. +-} +section :: + (Monad m) => + Maybe c -> Maybe c -> Maybe c -> Language h -> Path -> ScopeT d t h c m a -> ScopeT d t h c m a +section ctxModifier ctxVisible ctxExport modifier p body = withLock $ do + hs <- askHandlers + pfx <- askExportPrefix + s <- getScope + -- The child scope inherits the parent's visible namespace, starts with an + -- empty export namespace, its export prefix extended by @p@, and is unlocked. + (ans, childExport) <- + liftBase $ + evalStateT + ( runReaderT + (unScopeT (do r <- body; e <- getExportRaw; pure (r, e))) + (Env{exportPrefix = pfx <@ p, handlers = hs}) + ) + (ScopeState{visible = s.visible, export = Trie.empty, locked = False}) + unsafeIncludeSubtree ctxModifier ctxVisible ctxExport modifier (p, childExport) + pure ans + where + getExportRaw = (\s' -> s'.export) <$> getScope + +-- --------------------------------------------------------------------------- +-- Runners +-- --------------------------------------------------------------------------- + +{- | Run a scope computation with the given handler bundle, an empty export +prefix, and an empty initial visible namespace. +-} +run :: (Monad m) => Handlers m d t h c -> ScopeT d t h c m a -> m a +run hs = runWith hs Emp Trie.empty + +{- | Run a scope computation, also specifying the export prefix and the initial +visible namespace. +-} +runWith :: (Monad m) => Handlers m d t h c -> BwdPath -> Trie d t -> ScopeT d t h c m a -> m a +runWith hs pfx initVisible body = + evalStateT + (runReaderT (unScopeT body) (Env{exportPrefix = pfx, handlers = hs})) + (ScopeState{visible = initVisible, export = Trie.empty, locked = False}) + +{- | Run @body@ with the current handler bundle transformed by @f@ (the OCaml +@try_with@). Overriding a field replaces that handler for @body@; leaving it +alone keeps the enclosing one (\"re-perform\"). For example, to silence +@shadow@: + +> tryWith (\hs -> hs { shadow = silence.shadow }) body +-} +tryWith :: + (Monad m) => + (Handlers m d t h c -> Handlers m d t h c) -> ScopeT d t h c m a -> ScopeT d t h c m a +tryWith f = localEnv (\e -> e{handlers = f e.handlers}) diff --git a/packages/yclept/src/Yclept/Trie.hs b/packages/yclept/src/Yclept/Trie.hs new file mode 100644 index 00000000..77a7d413 --- /dev/null +++ b/packages/yclept/src/Yclept/Trie.hs @@ -0,0 +1,599 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE ScopedTypeVariables #-} + +{- | A persistent trie keyed by hierarchical names (@['Data.Text.Text']@ paths), +a port of the OCaml @Trie@ module. + +Each binding carries a @data@ payload and a @tag@. The data survives +retagging; tags can be reset in @O(1)@ (see 'retag'). Internally the trie +keeps a data tree and a sparse tag tree that mirrors it, with a +@tagDefaultChild@ standing in for \"every child not listed explicitly has +this tag\" so that 'retag' is constant time. + +The public merge/update API comes in a pure flavour and a monadic flavour +(the @*M@ functions). The monadic variants exist because the modifier +engine's @shadow@ merger runs in a base monad @m@; see "Yclept.Modifier". +-} +module Yclept.Trie ( + -- * Types + Path, + BwdPath, + Trie, + Untagged, + + -- * Basic construction + empty, + isEmpty, + root, + rootOpt, + prefix, + singleton, + equal, + + -- * Finding + findSubtree, + findSingleton, + findRoot, + + -- * Mapping and filtering + iter, + mapWithPath, + filterWithPath, + filterMapWithPath, + + -- * Updating + updateSubtree, + updateSubtreeM, + updateSingleton, + updateRoot, + + -- * Union (pure) + union, + unionSubtree, + unionSingleton, + unionRoot, + + -- * Union (monadic merger) + Merger, + unionM, + unionSubtreeM, + unionSingletonM, + unionRootM, + + -- * Separation + detachSubtree, + detachSingleton, + detachRoot, + + -- * Conversion to\/from lists + toSeq, + toSeqWithBwdPaths, + toSeqValues, + ofSeq, + ofSeqWithMerger, + ofSeqWithMergerM, + + -- * Tags + mapTag, + retag, + retagSubtree, + untag, + setOfTags, +) where + +import Control.Monad (foldM) +import Control.Monad.Trans.Writer.CPS (Writer, runWriter, writer) +import Data.Foldable (traverse_) +import Data.Functor.Identity (Identity (..)) +import Data.Map.Merge.Strict qualified as Merge +import Data.Map.Strict (Map) +import Data.Map.Strict qualified as Map +import Data.Maybe (fromMaybe, isNothing, maybeToList) +import Data.Monoid (First (..)) +import Data.Set (Set) +import Data.Set qualified as Set +import Data.Text (Text) + +import Yclept.Bwd (Bwd (Emp), (<:), (<@)) + +-- | The type of hierarchical names. The name @x.y.z@ is @[\"x\", \"y\", \"z\"]@. +type Path = [Text] + +-- | Hierarchical names as backward lists. +type BwdPath = Bwd Text + +type Seg = Text + +type SegMap a = Map Seg a + +-- The data tree: a payload at the root plus a map of named children. This +-- representation is canonical, so a derived 'Eq' is the right structural +-- equality. +data DataNode a = DataNode + { dRoot :: !(Maybe a) + , dChildren :: !(SegMap (DataNode a)) + } + deriving (Eq) + +-- The tag tree mirrors the data tree. Invariants (mirroring the OCaml): +-- 1. @tagChildren@ is a subset of the corresponding @dChildren@. +-- 2. @tagRoot@ is present exactly when @dRoot@ is. +-- 3. @tagDefaultChild@ stands for the tag of every data child not present +-- in @tagChildren@. +-- The tag tree is /not/ canonical (the same logical tagging has several +-- representations), and its equality is only meaningful relative to the data +-- tree it decorates (a child's tag may be given explicitly on one side and via +-- @tagDefaultChild@ on the other). So 'TagNode' has no 'Eq' of its own; the +-- data-aware comparison lives in the 'Eq' instance of 'Node' below. +data TagNode a = TagNode + { tagRoot :: !(Maybe a) + , tagDefaultChild :: !(Maybe a) + , tagChildren :: !(SegMap (TagNode a)) + } + +data Node d t = Node !(DataNode d) !(TagNode t) + +-- Hand-written (non-derived) equality: the data tree compares structurally, +-- and the tag tree is compared /relative to that data tree/. +instance (Eq d, Eq t) => Eq (Node d t) where + Node d1 t1 == Node d2 t2 = d1 == d2 && equalTagNode d1 t1 t2 + +{- | The abstract type of a trie. @d@ is the data (survives retagging), @t@ is +the tag. +-} +type Trie d t = Maybe (Node d t) + +-- | Untagged tries (all tags are @()@). +type Untagged d = Trie d () + +-- --------------------------------------------------------------------------- +-- Making (non-empty) trees +-- --------------------------------------------------------------------------- + +empty :: Trie d t +empty = Nothing + +isEmpty :: Trie d t -> Bool +isEmpty = isNothing + +nonEmpty :: Node d t -> Trie d t +nonEmpty = Just + +-- Normalise a tag node against its data node (invariants 2 and 3). +mkTagNode :: DataNode d -> (Maybe t, (Maybe t, SegMap (TagNode t))) -> TagNode t +mkTagNode d (tagRoot0, (tagDefaultChild0, tagChildren0)) = + TagNode + { tagRoot = case d.dRoot of + Nothing -> Nothing + Just _ -> tagRoot0 + , tagDefaultChild = + if Map.size (d.dChildren) == Map.size tagChildren0 + then Nothing + else tagDefaultChild0 + , tagChildren = tagChildren0 + } + +mkTagNode' :: DataNode d -> Maybe t -> TagNode t +mkTagNode' d t = mkTagNode d (t, (t, Map.empty)) + +mkNode' :: DataNode d -> Maybe t -> Node d t +mkNode' d t = Node d (mkTagNode' d t) + +-- Materialise @tagDefaultChild@ into explicit @tagChildren@ (invariant-safe). +dropTagDefaultChild :: Node d t -> Node d t +dropTagDefaultChild n@(Node d t) = + case t.tagDefaultChild of + Nothing -> n + def@(Just _) -> + let tagChildren' = + Merge.merge + (Merge.mapMissing (\_ dchild -> mkTagNode' dchild def)) + (Merge.mapMissing (\_ _ -> invariant)) -- tag child w/o data child + (Merge.zipWithMatched (\_ _dchild tchild -> tchild)) + (d.dChildren) + (t.tagChildren) + in Node d (t{tagDefaultChild = Nothing, tagChildren = tagChildren'}) + +invariant :: a +invariant = error "Yclept.Trie: broken invariant (tag child without data child)" + +mkTree :: (Maybe d, SegMap (DataNode d)) -> (Maybe t, (Maybe t, SegMap (TagNode t))) -> Trie d t +mkTree (r, children) tagParams + | isNothing r && Map.null children = empty + | otherwise = + let d = DataNode{dRoot = r, dChildren = children} + in nonEmpty (Node d (mkTagNode d tagParams)) + +rootNode :: (d, t) -> Node d t +rootNode (d, t) = + Node + (DataNode{dRoot = Just d, dChildren = Map.empty}) + (TagNode{tagRoot = Just t, tagDefaultChild = Nothing, tagChildren = Map.empty}) + +-- | @'root' (d, t)@ makes a trie with a single binding at the root. +root :: (d, t) -> Trie d t +root = nonEmpty . rootNode + +-- | @'rootOpt' 'Nothing'@ is 'empty'; @'rootOpt' ('Just' v)@ is @'root' v@. +rootOpt :: Maybe (d, t) -> Trie d t +rootOpt = fmap rootNode + +prefixNode :: Path -> Node d t -> Node d t +prefixNode path n = foldr f n path + where + f seg (Node d t) = + Node + (DataNode{dRoot = Nothing, dChildren = Map.singleton seg d}) + (TagNode{tagRoot = Nothing, tagDefaultChild = Nothing, tagChildren = Map.singleton seg t}) + +-- | @'prefix' p t@ makes a minimal trie with @t@ rooted at @p@. +prefix :: Path -> Trie d t -> Trie d t +prefix path = fmap (prefixNode path) + +-- | @'singleton' (p, (d, t))@ makes a trie with the single binding @p@. +singleton :: (Path, (d, t)) -> Trie d t +singleton (path, dt) = prefix path (root dt) + +-- --------------------------------------------------------------------------- +-- Small helpers +-- --------------------------------------------------------------------------- + +-- Split a binding value (a data\/tag pair). +splitMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) +splitMaybe Nothing = (Nothing, Nothing) +splitMaybe (Just (a, b)) = (Just a, Just b) + +-- Split a whole subtree into its data node and tag node. (In OCaml both this +-- and 'splitMaybe' were the same function, since a node was itself a tuple.) +splitNode :: Trie d t -> (Maybe (DataNode d), Maybe (TagNode t)) +splitNode Nothing = (Nothing, Nothing) +splitNode (Just (Node d t)) = (Just d, Just t) + +-- Materialise the children of a node as data\/tag pairs (invariant-safe). +getChildrenNode :: Node d t -> SegMap (Node d t) +getChildrenNode (Node d t) = + Merge.merge + (Merge.mapMissing (\_ dchild -> Node dchild (mkTagNode' dchild (t.tagDefaultChild)))) + (Merge.mapMissing (\_ _ -> invariant)) + (Merge.zipWithMatched (\_ dchild tchild -> Node dchild tchild)) + (d.dChildren) + (t.tagChildren) + +-- --------------------------------------------------------------------------- +-- Equality +-- --------------------------------------------------------------------------- + +-- The data tree uses its derived 'Eq'. Only the (non-canonical) tag tree +-- needs a hand-written traversal that materialises default children. + +equalTagNode :: (Eq t) => DataNode d -> TagNode t -> TagNode t -> Bool +equalTagNode d t1 t2 = + t1.tagRoot == t2.tagRoot && equalTagChildren d t1 t2 + +equalTagChildren :: (Eq t) => DataNode d -> TagNode t -> TagNode t -> Bool +equalTagChildren d t1 t2 = + ( t1.tagDefaultChild == t2.tagDefaultChild + && Map.null (t1.tagChildren) + && Map.null (t2.tagChildren) + ) + || all (\(dc, tc1, tc2) -> equalTagNode dc tc1 tc2) (Map.elems (children2 d t1 t2)) + +-- Line up the two tag trees against the shared data tree, materialising each +-- side's default children. +children2 :: DataNode d -> TagNode t -> TagNode t -> SegMap (DataNode d, TagNode t, TagNode t) +children2 d t1 t2 = + Merge.merge + (Merge.mapMissing (\_ (Node dc tc1) -> (dc, tc1, mkTagNode' dc (t2.tagDefaultChild)))) + (Merge.mapMissing (\_ _ -> invariant)) + (Merge.zipWithMatched (\_ (Node dc tc1) tc2 -> (dc, tc1, tc2))) + (getChildrenNode (Node d t1)) + (t2.tagChildren) + +{- | Structural equality on tries. This is just the 'Eq' instance of the +underlying @'Maybe' ('Node' d t)@; provided under the OCaml name. +-} +equal :: (Eq d, Eq t) => Trie d t -> Trie d t -> Bool +equal = (==) + +-- --------------------------------------------------------------------------- +-- Getting data +-- --------------------------------------------------------------------------- + +findChildNode :: Seg -> Node d t -> Maybe (Node d t) +findChildNode seg (Node d t) = + case Map.lookup seg (d.dChildren) of + Nothing -> Nothing + Just dc -> + case Map.lookup seg (t.tagChildren) of + Just tc -> Just (Node dc tc) + Nothing -> Just (mkNode' dc (t.tagDefaultChild)) + +findNodeCont :: Path -> Node d t -> (Node d t -> Maybe b) -> Maybe b +findNodeCont [] n k = k n +findNodeCont (seg : path) n k = + findChildNode seg n >>= \n' -> findNodeCont path n' k + +findRootNode :: Node d t -> Maybe (d, t) +findRootNode (Node d t) = + case d.dRoot of + Nothing -> Nothing + Just r -> Just (r, fromMaybe invariant (t.tagRoot)) + +-- | @'findSubtree' p t@ returns the subtree rooted at @p@. +findSubtree :: Path -> Trie d t -> Trie d t +findSubtree path v = v >>= \n -> findNodeCont path n nonEmpty + +-- | @'findSingleton' p t@ returns the data and tag at @p@. +findSingleton :: Path -> Trie d t -> Maybe (d, t) +findSingleton path v = v >>= \n -> findNodeCont path n findRootNode + +-- | @'findRoot' t@ returns the data and tag at the root. +findRoot :: Trie d t -> Maybe (d, t) +findRoot v = v >>= findRootNode + +-- --------------------------------------------------------------------------- +-- Updating +-- --------------------------------------------------------------------------- + +updateNodeContM :: (Monad m) => Path -> Node d t -> (Trie d t -> m (Trie d t)) -> m (Trie d t) +updateNodeContM [] n k = k (nonEmpty n) +updateNodeContM (seg : path) (Node d t) k = do + childTrie <- + case findChildNode seg (Node d t) of + Nothing -> prefix path <$> k empty + Just n -> updateNodeContM path n k + let (child, tagChild) = splitNode childTrie + children' = Map.alter (const child) seg (d.dChildren) + tagChildren' = Map.alter (const tagChild) seg (t.tagChildren) + pure (mkTree (d.dRoot, children') (t.tagRoot, (t.tagDefaultChild, tagChildren'))) + +updateContM :: (Monad m) => Path -> Trie d t -> (Trie d t -> m (Trie d t)) -> m (Trie d t) +updateContM path v k = + case v of + Nothing -> prefix path <$> k empty + Just n -> updateNodeContM path n k + +updateCont :: Path -> Trie d t -> (Trie d t -> Trie d t) -> Trie d t +updateCont path v k = runIdentity (updateContM path v (Identity . k)) + +-- | @'updateSubtree' p f t@ replaces the subtree rooted at @p@ with @f@ of it. +updateSubtree :: Path -> (Trie d t -> Trie d t) -> Trie d t -> Trie d t +updateSubtree path f v = updateCont path v f + +{- | Monadic 'updateSubtree'; the replacement runs in @m@ (used by the modifier +engine, whose @in_@ recurses into a subtree effectfully). +-} +updateSubtreeM :: (Monad m) => Path -> (Trie d t -> m (Trie d t)) -> Trie d t -> m (Trie d t) +updateSubtreeM path f v = updateContM path v f + +-- | @'updateRoot' f t@ updates the value at the root with @f@. +updateRoot :: (Maybe (d, t) -> Maybe (d, t)) -> Trie d t -> Trie d t +updateRoot f Nothing = rootOpt (f Nothing) +updateRoot f (Just (Node d t)) = + let (r, tr) = splitMaybe (f (findRootNode (Node d t))) + in mkTree (r, d.dChildren) (tr, (t.tagDefaultChild, t.tagChildren)) + +-- | @'updateSingleton' p f t@ replaces the binding at @p@ with @f@ of it. +updateSingleton :: Path -> (Maybe (d, t) -> Maybe (d, t)) -> Trie d t -> Trie d t +updateSingleton path f v = updateCont path v (updateRoot f) + +-- --------------------------------------------------------------------------- +-- Union +-- --------------------------------------------------------------------------- + +{- | A merger reconciles two bindings that collide at the same path, in a base +monad @m@. +-} +type Merger m d t = BwdPath -> (d, t) -> (d, t) -> m (d, t) + +unionMaybeM :: (Applicative m) => (a -> a -> m a) -> Maybe a -> Maybe a -> m (Maybe a) +unionMaybeM _ Nothing Nothing = pure Nothing +unionMaybeM _ (Just r) Nothing = pure (Just r) +unionMaybeM _ Nothing (Just r) = pure (Just r) +unionMaybeM g (Just a) (Just b) = Just <$> g a b + +unionNodeM :: (Monad m) => BwdPath -> Merger m d t -> Node d t -> Node d t -> m (Node d t) +unionNodeM prefixP m n1_ n2_ = do + let Node nd1 nt1 = dropTagDefaultChild n1_ + Node nd2 nt2 = dropTagDefaultChild n2_ + mergedRoot <- unionMaybeM (m prefixP) (findRootNode (Node nd1 nt1)) (findRootNode (Node nd2 nt2)) + let (r, tr) = splitMaybe mergedRoot + -- after dropTagDefaultChild, tagChildren has exactly the keys of dChildren + node1children = Map.intersectionWith Node (nd1.dChildren) (nt1.tagChildren) + node2children = Map.intersectionWith Node (nd2.dChildren) (nt2.tagChildren) + combined <- + Merge.mergeA + Merge.preserveMissing + Merge.preserveMissing + (Merge.zipWithAMatched (\seg c1 c2 -> unionNodeM (prefixP <: seg) m c1 c2)) + node1children + node2children + let children' = Map.map (\(Node dd _) -> dd) combined + tagChildren' = Map.map (\(Node _ tt) -> tt) combined + pure (Node (DataNode r children') (TagNode tr Nothing tagChildren')) + +-- | Monadic 'union'. +unionM :: (Monad m) => BwdPath -> Merger m d t -> Trie d t -> Trie d t -> m (Trie d t) +unionM prefixP m = unionMaybeM (unionNodeM prefixP m) + +unionRootM :: (Monad m) => BwdPath -> Merger m d t -> Trie d t -> (d, t) -> m (Trie d t) +unionRootM _ _ Nothing v2 = pure (root v2) +unionRootM prefixP m (Just (Node d1 t1)) v2 = do + merged <- unionMaybeM (m prefixP) (findRootNode (Node d1 t1)) (Just v2) + let (r, tr) = splitMaybe merged + pure (nonEmpty (Node (d1{dRoot = r}) (t1{tagRoot = tr}))) + +unionSingletonM :: (Monad m) => BwdPath -> Merger m d t -> Trie d t -> (Path, (d, t)) -> m (Trie d t) +unionSingletonM prefixP m v1 (path, v2) = + updateContM path v1 (\v1' -> unionRootM (prefixP <@ path) m v1' v2) + +unionSubtreeM :: (Monad m) => BwdPath -> Merger m d t -> Trie d t -> (Path, Trie d t) -> m (Trie d t) +unionSubtreeM prefixP m v1 (path, v2) = + updateContM path v1 (\v1' -> unionM (prefixP <@ path) m v1' v2) + +-- Pure wrappers, defined via the monadic ones with 'Identity'. + +type PureMerger d t = BwdPath -> (d, t) -> (d, t) -> (d, t) + +liftMerger :: PureMerger d t -> Merger Identity d t +liftMerger m p a b = Identity (m p a b) + +{- | @'union' prefix merger t1 t2@ merges two tries, calling @merger@ on +collisions. The @prefix@ is prepended to any path sent to @merger@; use +@'Emp'@ for none. +-} +union :: BwdPath -> PureMerger d t -> Trie d t -> Trie d t -> Trie d t +union prefixP m t1 t2 = runIdentity (unionM prefixP (liftMerger m) t1 t2) + +unionSubtree :: BwdPath -> PureMerger d t -> Trie d t -> (Path, Trie d t) -> Trie d t +unionSubtree prefixP m t pv = runIdentity (unionSubtreeM prefixP (liftMerger m) t pv) + +unionSingleton :: BwdPath -> PureMerger d t -> Trie d t -> (Path, (d, t)) -> Trie d t +unionSingleton prefixP m t pv = runIdentity (unionSingletonM prefixP (liftMerger m) t pv) + +unionRoot :: BwdPath -> PureMerger d t -> Trie d t -> (d, t) -> Trie d t +unionRoot prefixP m t v = runIdentity (unionRootM prefixP (liftMerger m) t v) + +-- --------------------------------------------------------------------------- +-- Detaching subtrees +-- --------------------------------------------------------------------------- + +-- The OCaml uses a mutable ref to smuggle the detached value out of the update +-- continuation, which runs exactly once. Here that is a @'Writer' ('First' a)@. +applyAndUpdateCont :: forall a d t. Path -> Trie d t -> (Trie d t -> (a, Trie d t)) -> (a, Trie d t) +applyAndUpdateCont path t k = + case t of + Nothing -> let (a, t') = k empty in (a, prefix path t') + Just n -> + let step :: Trie d t -> Writer (First a) (Trie d t) + step tr = let (a, tr') = k tr in writer (tr', First (Just a)) + (t', First mAns) = runWriter (updateNodeContM path n step) + in (fromMaybe (error "Yclept.Trie: detach continuation not run") mAns, t') + +{- | @'detachSubtree' p t@ splits off the subtree at @p@; returns +@(subtree, remainder)@. +-} +detachSubtree :: Path -> Trie d t -> (Trie d t, Trie d t) +detachSubtree path t = applyAndUpdateCont path t (\tr -> (tr, empty)) + +-- | @'detachRoot' t@ splits off the binding at the root. +detachRoot :: Trie d t -> (Maybe (d, t), Trie d t) +detachRoot Nothing = (Nothing, empty) +detachRoot (Just (Node d t)) = + ( findRootNode (Node d t) + , mkTree (Nothing, d.dChildren) (Nothing, (t.tagDefaultChild, t.tagChildren)) + ) + +-- | @'detachSingleton' p t@ splits off the binding at @p@. +detachSingleton :: Path -> Trie d t -> (Maybe (d, t), Trie d t) +detachSingleton path t = applyAndUpdateCont path t detachRoot + +-- --------------------------------------------------------------------------- +-- Mapping and filtering +-- --------------------------------------------------------------------------- + +filterMapNode :: BwdPath -> (BwdPath -> (d1, t1) -> Maybe (d2, t2)) -> Node d1 t1 -> Trie d2 t2 +filterMapNode prefixP f n = + let (r, tr) = splitMaybe (findRootNode n >>= f prefixP) + combined = + Map.mapMaybeWithKey + (\seg child -> filterMapNode (prefixP <: seg) f child) + (getChildrenNode n) + children' = Map.map (\(Node dd _) -> dd) combined + tagChildren' = Map.map (\(Node _ tt) -> tt) combined + in mkTree (r, children') (tr, (Nothing, tagChildren')) + +{- | @'filterMapWithPath' prefix f t@ applies @f@ (which sees the path) to each +binding, keeping only the 'Just' results. @prefix@ is prepended to the +paths sent to @f@; use @'Emp'@ for none. +-} +filterMapWithPath :: BwdPath -> (BwdPath -> (d1, t1) -> Maybe (d2, t2)) -> Trie d1 t1 -> Trie d2 t2 +filterMapWithPath prefixP f v = v >>= filterMapNode prefixP f + +-- | @'mapWithPath' prefix f t@ maps @f@ over every binding. +mapWithPath :: BwdPath -> (BwdPath -> (d1, t1) -> (d2, t2)) -> Trie d1 t1 -> Trie d2 t2 +mapWithPath prefixP f = filterMapWithPath prefixP (\p x -> Just (f p x)) + +-- | @'filterWithPath' prefix f t@ keeps bindings for which @f@ returns 'True'. +filterWithPath :: BwdPath -> (BwdPath -> (d, t) -> Bool) -> Trie d t -> Trie d t +filterWithPath prefixP f = + filterMapWithPath prefixP (\p x -> if f p x then Just x else Nothing) + +-- --------------------------------------------------------------------------- +-- Iteration / conversion +-- --------------------------------------------------------------------------- + +toListNode :: BwdPath -> Node d t -> [(BwdPath, (d, t))] +toListNode prefixP n = + maybe [] (\r -> [(prefixP, r)]) (findRootNode n) + ++ concatMap + (\(seg, child) -> toListNode (prefixP <: seg) child) + (Map.toAscList (getChildrenNode n)) + +{- | @'iter' prefix f t@ runs @f@ (which sees the path) on every binding, in +lexicographic order. +-} +iter :: (Applicative m) => BwdPath -> (BwdPath -> (d, t) -> m ()) -> Trie d t -> m () +iter prefixP f = traverse_ (uncurry f) . toSeqWithBwdPaths prefixP + +-- | Lexicographic traversal, backward paths. +toSeqWithBwdPaths :: BwdPath -> Trie d t -> [(BwdPath, (d, t))] +toSeqWithBwdPaths prefixP = maybe [] (toListNode prefixP) + +-- | Lexicographic traversal, forward paths. +toSeq :: BwdPath -> Trie d t -> [(Path, (d, t))] +toSeq prefixP = map (\(p, v) -> (foldr (:) [] p, v)) . toSeqWithBwdPaths prefixP + +-- | Lexicographic traversal, values only. +toSeqValues :: Trie d t -> [(d, t)] +toSeqValues = map snd . toSeqWithBwdPaths Emp + +-- | Build a trie from a list, later bindings shadowing earlier ones. +ofSeq :: [(Path, (d, t))] -> Trie d t +ofSeq = ofSeqWithMerger Emp (\_ _ y -> y) + +-- | Build a trie from a list, resolving collisions with @merger@. +ofSeqWithMerger :: BwdPath -> PureMerger d t -> [(Path, (d, t))] -> Trie d t +ofSeqWithMerger prefixP m = foldl' (unionSingleton prefixP m) empty + +-- | Monadic 'ofSeqWithMerger'. +ofSeqWithMergerM :: (Monad m) => BwdPath -> Merger m d t -> [(Path, (d, t))] -> m (Trie d t) +ofSeqWithMergerM prefixP m = foldM (unionSingletonM prefixP m) empty + +-- --------------------------------------------------------------------------- +-- Tags +-- --------------------------------------------------------------------------- + +mapTagNode :: (t1 -> t2) -> TagNode t1 -> TagNode t2 +mapTagNode f (TagNode r dc ch) = + TagNode (fmap f r) (fmap f dc) (Map.map (mapTagNode f) ch) + +-- | @'mapTag' f t@ applies @f@ to every tag, leaving data intact. +mapTag :: (t1 -> t2) -> Trie d t1 -> Trie d t2 +mapTag _ Nothing = Nothing +mapTag f (Just (Node d t)) = Just (Node d (mapTagNode f t)) + +-- | @'retag' tag t@ resets every tag to @tag@ in @O(1)@. +retag :: t -> Trie d t' -> Trie d t +retag _ Nothing = Nothing +retag tg (Just (Node d _)) = nonEmpty (mkNode' d (Just tg)) + +-- | @'untag'@ is @'retag' ()@. +untag :: Trie d t -> Untagged d +untag = retag () + +-- | @'retagSubtree' path tag t@ resets tags within the subtree at @path@. +retagSubtree :: Path -> t -> Trie d t -> Trie d t +retagSubtree path tg = updateSubtree path (retag tg) + +tagListNode :: TagNode t -> [t] +tagListNode (TagNode r dc ch) = + maybeToList r ++ maybeToList dc ++ concatMap tagListNode (Map.elems ch) + +-- | @'setOfTags' t@ returns the set of tags used in @t@. +setOfTags :: (Ord t) => Trie d t -> Set t +setOfTags Nothing = Set.empty +setOfTags (Just (Node _ t)) = Set.fromList (tagListNode t) diff --git a/packages/yclept/src/Yclept/Trie/Untagged.hs b/packages/yclept/src/Yclept/Trie/Untagged.hs new file mode 100644 index 00000000..b3ca1b47 --- /dev/null +++ b/packages/yclept/src/Yclept/Trie/Untagged.hs @@ -0,0 +1,173 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT + +{- | Untagged tries, a port of the OCaml @UntaggedTrie@ (@Trie.Untagged@). + +An untagged trie is a @'Trie.Trie' d ()@ viewed through an API that hides the +unit tag. Intended to be imported qualified, e.g. + +> import qualified Yclept.Trie.Untagged as UntaggedTrie +-} +module Yclept.Trie.Untagged ( + Untagged, + Path, + BwdPath, + + -- * Construction + empty, + isEmpty, + root, + rootOpt, + prefix, + singleton, + equal, + + -- * Finding + findSubtree, + findSingleton, + findRoot, + + -- * Mapping and filtering + iter, + mapWithPath, + filterWithPath, + filterMapWithPath, + + -- * Updating + updateSubtree, + updateSingleton, + updateRoot, + + -- * Union + union, + unionSubtree, + unionSingleton, + unionRoot, + + -- * Separation + detachSubtree, + detachSingleton, + detachRoot, + + -- * Conversion + toSeq, + toSeqWithBwdPaths, + toSeqValues, + ofSeq, + ofSeqWithMerger, + + -- * Tags + tag, + untag, +) where + +import Data.Bifunctor (first, second) + +import Yclept.Trie (BwdPath, Path, Trie, Untagged) +import Yclept.Trie qualified as Trie + +tagV :: d -> (d, ()) +tagV d = (d, ()) + +untagV :: (d, ()) -> d +untagV (d, ()) = d + +-- The merger over untagged values, lifted to operate on @(d, ())@ pairs. +liftMerger :: (BwdPath -> d -> d -> d) -> BwdPath -> (d, ()) -> (d, ()) -> (d, ()) +liftMerger m p x y = tagV (m p (untagV x) (untagV y)) + +empty :: Untagged d +empty = Trie.empty + +isEmpty :: Untagged d -> Bool +isEmpty = Trie.isEmpty + +root :: d -> Untagged d +root d = Trie.root (tagV d) + +rootOpt :: Maybe d -> Untagged d +rootOpt md = Trie.rootOpt (tagV <$> md) + +prefix :: Path -> Untagged d -> Untagged d +prefix = Trie.prefix + +singleton :: (Path, d) -> Untagged d +singleton (p, d) = Trie.singleton (p, tagV d) + +equal :: (Eq d) => Untagged d -> Untagged d -> Bool +equal = Trie.equal + +findSubtree :: Path -> Untagged d -> Untagged d +findSubtree = Trie.findSubtree + +findSingleton :: Path -> Untagged d -> Maybe d +findSingleton p t = untagV <$> Trie.findSingleton p t + +findRoot :: Untagged d -> Maybe d +findRoot t = untagV <$> Trie.findRoot t + +iter :: (Applicative m) => BwdPath -> (BwdPath -> d -> m ()) -> Untagged d -> m () +iter pfx f = Trie.iter pfx (\p x -> f p (untagV x)) + +mapWithPath :: BwdPath -> (BwdPath -> d1 -> d2) -> Untagged d1 -> Untagged d2 +mapWithPath pfx f = Trie.mapWithPath pfx (\p x -> tagV (f p (untagV x))) + +filterWithPath :: BwdPath -> (BwdPath -> d -> Bool) -> Untagged d -> Untagged d +filterWithPath pfx f = Trie.filterWithPath pfx (\p x -> f p (untagV x)) + +filterMapWithPath :: BwdPath -> (BwdPath -> d1 -> Maybe d2) -> Untagged d1 -> Untagged d2 +filterMapWithPath pfx f = Trie.filterMapWithPath pfx (\p x -> tagV <$> f p (untagV x)) + +updateSubtree :: Path -> (Untagged d -> Untagged d) -> Untagged d -> Untagged d +updateSubtree = Trie.updateSubtree + +updateSingleton :: Path -> (Maybe d -> Maybe d) -> Untagged d -> Untagged d +updateSingleton p f = Trie.updateSingleton p (\md -> tagV <$> f (untagV <$> md)) + +updateRoot :: (Maybe d -> Maybe d) -> Untagged d -> Untagged d +updateRoot f = Trie.updateRoot (\md -> tagV <$> f (untagV <$> md)) + +union :: BwdPath -> (BwdPath -> d -> d -> d) -> Untagged d -> Untagged d -> Untagged d +union pfx m = Trie.union pfx (liftMerger m) + +unionSubtree :: BwdPath -> (BwdPath -> d -> d -> d) -> Untagged d -> (Path, Untagged d) -> Untagged d +unionSubtree pfx m = Trie.unionSubtree pfx (liftMerger m) + +unionSingleton :: BwdPath -> (BwdPath -> d -> d -> d) -> Untagged d -> (Path, d) -> Untagged d +unionSingleton pfx m t (p, d) = Trie.unionSingleton pfx (liftMerger m) t (p, tagV d) + +unionRoot :: BwdPath -> (BwdPath -> d -> d -> d) -> Untagged d -> d -> Untagged d +unionRoot pfx m t d = Trie.unionRoot pfx (liftMerger m) t (tagV d) + +detachSubtree :: Path -> Untagged d -> (Untagged d, Untagged d) +detachSubtree = Trie.detachSubtree + +detachSingleton :: Path -> Untagged d -> (Maybe d, Untagged d) +detachSingleton p t = first (fmap untagV) (Trie.detachSingleton p t) + +detachRoot :: Untagged d -> (Maybe d, Untagged d) +detachRoot t = first (fmap untagV) (Trie.detachRoot t) + +toSeq :: BwdPath -> Untagged d -> [(Path, d)] +toSeq pfx = map (second untagV) . Trie.toSeq pfx + +toSeqWithBwdPaths :: BwdPath -> Untagged d -> [(BwdPath, d)] +toSeqWithBwdPaths pfx = map (second untagV) . Trie.toSeqWithBwdPaths pfx + +toSeqValues :: Untagged d -> [d] +toSeqValues = map untagV . Trie.toSeqValues + +ofSeq :: [(Path, d)] -> Untagged d +ofSeq = Trie.ofSeq . map (second tagV) + +ofSeqWithMerger :: BwdPath -> (BwdPath -> d -> d -> d) -> [(Path, d)] -> Untagged d +ofSeqWithMerger pfx m = Trie.ofSeqWithMerger pfx (liftMerger m) . map (second tagV) + +-- | Attach a tag to every binding of an untagged trie (OCaml @tag@). +tag :: t -> Untagged d -> Trie d t +tag = Trie.retag + +-- | Forget all tags (OCaml @untag@). +untag :: Trie d t -> Untagged d +untag = Trie.untag diff --git a/packages/yclept/test/EasyExample.hs b/packages/yclept/test/EasyExample.hs new file mode 100644 index 00000000..5cd0cef1 --- /dev/null +++ b/packages/yclept/test/EasyExample.hs @@ -0,0 +1,81 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT +{-# LANGUAGE OverloadedStrings #-} + +{- | A port of the OCaml @test/EasyExample.ml@: a tiny language exercising the +'Scope' engine with trivial tags\/hooks\/contexts. +-} +module Main (main) where + +import Control.Monad.IO.Class (liftIO) +import Data.List (intercalate) +import Data.Text qualified as Text +import System.IO (hPutStrLn, stderr) + +import Yclept.Language (Language) +import Yclept.Language qualified as Language +import Yclept.Scope (ScopeT) +import Yclept.Scope qualified as Scope +import Yclept.Trie (Path) +import Yclept.Trie qualified as Trie +import Yclept.Trie.Untagged qualified as UntaggedTrie + +data Decl + = Decl Path Int + | Import (Trie.Untagged Int) (Language ()) + | Export Path + | Section Path [Decl] + | PrintInt Path + +type Program = [Decl] + +-- data = Int, tag = (), hook = (), context = (). The base monad is IO. +type M = ScopeT Int () () () IO + +ppPath :: Path -> String +ppPath [] = "(root)" +ppPath p = intercalate "." (map Text.unpack p) + +interpretDecl :: Decl -> M () +interpretDecl d = + case d of + Decl p x -> + Scope.includeSingleton Nothing Nothing (p, (x, ())) + Import t m -> + Scope.importSubtree Nothing Nothing m ([], Trie.retag () t) + Export p -> + Scope.exportVisible Nothing Nothing (Language.only p) + Section p sec -> + Scope.section Nothing Nothing Nothing Language.id p (interpretSection sec) + PrintInt p -> do + r <- Scope.resolve p + case r of + Nothing -> liftIO (hPutStrLn stderr ("Unbound variable " ++ ppPath p)) + Just (i, _) -> liftIO (putStrLn (ppPath p ++ " = " ++ show i)) + +interpretSection :: [Decl] -> M () +interpretSection = mapM_ interpretDecl + +interpret :: Program -> IO () +interpret prog = Scope.run Scope.silence (interpretSection prog) + +exampleTrie :: Trie.Untagged Int +exampleTrie = UntaggedTrie.ofSeq [(["y"], 20)] + +main :: IO () +main = + interpret + [ Decl ["x"] 1 + , Decl ["x"] 2 + , Import exampleTrie (Language.renaming [] ["z"]) + , Export ["z", "y"] + , Section + ["w"] + [ Decl ["a"] 100 + , Export ["x"] + ] + , PrintInt ["z", "y"] + , PrintInt ["w", "a"] + , PrintInt ["w", "x"] + ] diff --git a/packages/yclept/test/Example.hs b/packages/yclept/test/Example.hs new file mode 100644 index 00000000..ba0c0840 --- /dev/null +++ b/packages/yclept/test/Example.hs @@ -0,0 +1,150 @@ +-- SPDX-FileCopyrightText: 2026 Coln contributors +-- +-- SPDX-License-Identifier: Apache-2.0 OR MIT +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE OverloadedStrings #-} + +{- | A port of the OCaml @test/Example.ml@: a tiny language exercising the full +'Scope' engine — the @not_found@ \/ @shadow@ \/ @hook@ handler bundle, tags, +contexts, and 'Scope.tryWith'. +-} +module Main (main) where + +import Data.List (intercalate) +import Data.Text qualified as Text + +import Yclept.Bwd (Bwd (Emp), toList) +import Yclept.Language (Language) +import Yclept.Language qualified as Language +import Yclept.Scope (Handlers (..), ScopeT) +import Yclept.Scope qualified as Scope +import Yclept.Trie (BwdPath, Path) +import Yclept.Trie qualified as Trie +import Yclept.Trie.Untagged qualified as UntaggedTrie + +-- A modifier hook command. +data ModifierCmd = Print + +-- Tags distinguishing where a binding came from. +data Tag = Imported | Local + +-- Contexts, so handlers can tell which namespace an effect came from. +data Ctx = CVisible | CExport + +data Decl + = -- | a declaration + Decl Path Int + | -- | … suppressing the shadow warning + ShadowingDecl Path Int + | -- | import a trie after a modifier + Import (Trie.Untagged Int) (Language ModifierCmd) + | -- | print all visible bindings + PrintVisible + | -- | export a binding + Export Path + | -- | a section + Section Path [Decl] + +type Program = [Decl] + +-- data = Int, tag = Tag, hook = ModifierCmd, context = Ctx. Base monad IO. +type M = ScopeT Int Tag ModifierCmd Ctx IO + +-- --------------------------------------------------------------------------- +-- Pretty-printing used by the handlers +-- --------------------------------------------------------------------------- + +ppPath :: BwdPath -> String +ppPath Emp = "(root)" +ppPath p = intercalate "." (map Text.unpack (toList p)) + +ppContext :: Maybe Ctx -> String +ppContext (Just CVisible) = " in the visible namespace" +ppContext (Just CExport) = " in the export namespace" +ppContext Nothing = "" + +ppItem :: (Int, Tag) -> String +ppItem (x, Imported) = show x ++ " (imported)" +ppItem (x, Local) = show x ++ " (local)" + +-- --------------------------------------------------------------------------- +-- The handler bundle +-- --------------------------------------------------------------------------- + +handler :: Handlers IO Int Tag ModifierCmd Ctx +handler = + Handlers + { notFound = \ctx prefix -> + putStrLn $ + "[Warning] Could not find any data within the subtree at " + ++ ppPath prefix + ++ ppContext ctx + ++ "." + , shadow = \ctx path x y -> do + putStrLn $ + "[Warning] Data " + ++ ppItem x + ++ " assigned at " + ++ ppPath path + ++ " was shadowed by data " + ++ ppItem y + ++ ppContext ctx + ++ "." + pure y + , hook = \ctx prefix Print input -> do + putStrLn $ "[Info] Got the following bindings at " ++ ppPath prefix ++ ppContext ctx ++ ":" + mapM_ + (\(pth, x) -> putStrLn (" " ++ ppPath pth ++ " => " ++ ppItem x)) + (Trie.toSeqWithBwdPaths Emp input) + pure input + } + +-- --------------------------------------------------------------------------- +-- The interpreter +-- --------------------------------------------------------------------------- + +interpretDecl :: Decl -> M () +interpretDecl d = + case d of + Decl p x -> + Scope.includeSingleton (Just CVisible) (Just CExport) (p, (x, Local)) + ShadowingDecl p x -> + -- Intercept just the shadow effect, silencing it, and re-perform the rest. + Scope.tryWith (\hs -> hs{shadow = Scope.silence.shadow}) $ + Scope.includeSingleton Nothing Nothing (p, (x, Local)) + Import t m -> + Scope.importSubtree Nothing Nothing m ([], UntaggedTrie.tag Imported t) + PrintVisible -> + Scope.modifyVisible Nothing (Language.hook Print) + Export p -> + Scope.exportVisible Nothing Nothing (Language.only p) + Section p sec -> + Scope.section Nothing Nothing Nothing Language.id p (interpretSection sec) + +interpretSection :: [Decl] -> M () +interpretSection = mapM_ interpretDecl + +interpret :: Program -> IO () +interpret prog = Scope.run handler (interpretSection prog) + +main :: IO () +main = + interpret + [ Decl ["x"] 1 + , PrintVisible + , Decl ["x"] 2 + , PrintVisible + , ShadowingDecl ["x"] 10 + , PrintVisible + , Import (UntaggedTrie.ofSeq [(["y"], 20)]) (Language.renaming [] ["z"]) + , PrintVisible + , Export ["z", "y"] + , Section + ["w"] + [ Decl ["a"] 100 + , PrintVisible + , Export ["x"] + , Export ["x"] + ] + , PrintVisible + ] diff --git a/packages/yclept/yclept.cabal b/packages/yclept/yclept.cabal new file mode 100644 index 00000000..3ca68144 --- /dev/null +++ b/packages/yclept/yclept.cabal @@ -0,0 +1,59 @@ +cabal-version: 2.4 +name: yclept +version: 5.2.0 +synopsis: Name pattern combinators for hierarchical names (Haskell port) +description: + Based on the OCaml library @yuujinchou@: name-modifier combinators + for hierarchical names, a modifier engine, and a lexical-scope engine. + +license: Apache-2.0 +author: Yuujinchou contributors and James Deikun +maintainer: jamesdeikun@gmail.com +category: Language +build-type: Simple +extra-source-files: README.md + +common warnings + ghc-options: + -Wall + -Wcompat + -Wincomplete-record-updates + +library + import: warnings + hs-source-dirs: src + default-language: GHC2021 + exposed-modules: + Yclept + Yclept.Bwd + Yclept.Language + Yclept.Modifier + Yclept.Scope + Yclept.Trie + Yclept.Trie.Untagged + + build-depends: + base >=4.14 && <5, + containers >=0.6 && <0.8, + text >=1.2 && <2.2, + transformers >=0.5 && <0.7, + +executable yclept-easy-example + import: warnings + hs-source-dirs: test + main-is: EasyExample.hs + default-language: GHC2021 + build-depends: + base, + text, + yclept, + +executable yclept-example + import: warnings + hs-source-dirs: test + main-is: Example.hs + default-language: GHC2021 + build-depends: + base, + text, + yclept,