diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Revision history for fnotation
-
-## 0.1.0.0 -- 2026-03-30
-
-* Basics in place.
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
--- a/LICENSE
+++ /dev/null
@@ -1,373 +0,0 @@
-Mozilla Public License Version 2.0
-==================================
-
-1. Definitions
---------------
-
-1.1. "Contributor"
-    means each individual or legal entity that creates, contributes to
-    the creation of, or owns Covered Software.
-
-1.2. "Contributor Version"
-    means the combination of the Contributions of others (if any) used
-    by a Contributor and that particular Contributor's Contribution.
-
-1.3. "Contribution"
-    means Covered Software of a particular Contributor.
-
-1.4. "Covered Software"
-    means Source Code Form to which the initial Contributor has attached
-    the notice in Exhibit A, the Executable Form of such Source Code
-    Form, and Modifications of such Source Code Form, in each case
-    including portions thereof.
-
-1.5. "Incompatible With Secondary Licenses"
-    means
-
-    (a) that the initial Contributor has attached the notice described
-        in Exhibit B to the Covered Software; or
-
-    (b) that the Covered Software was made available under the terms of
-        version 1.1 or earlier of the License, but not also under the
-        terms of a Secondary License.
-
-1.6. "Executable Form"
-    means any form of the work other than Source Code Form.
-
-1.7. "Larger Work"
-    means a work that combines Covered Software with other material, in
-    a separate file or files, that is not Covered Software.
-
-1.8. "License"
-    means this document.
-
-1.9. "Licensable"
-    means having the right to grant, to the maximum extent possible,
-    whether at the time of the initial grant or subsequently, any and
-    all of the rights conveyed by this License.
-
-1.10. "Modifications"
-    means any of the following:
-
-    (a) any file in Source Code Form that results from an addition to,
-        deletion from, or modification of the contents of Covered
-        Software; or
-
-    (b) any new file in Source Code Form that contains any Covered
-        Software.
-
-1.11. "Patent Claims" of a Contributor
-    means any patent claim(s), including without limitation, method,
-    process, and apparatus claims, in any patent Licensable by such
-    Contributor that would be infringed, but for the grant of the
-    License, by the making, using, selling, offering for sale, having
-    made, import, or transfer of either its Contributions or its
-    Contributor Version.
-
-1.12. "Secondary License"
-    means either the GNU General Public License, Version 2.0, the GNU
-    Lesser General Public License, Version 2.1, the GNU Affero General
-    Public License, Version 3.0, or any later versions of those
-    licenses.
-
-1.13. "Source Code Form"
-    means the form of the work preferred for making modifications.
-
-1.14. "You" (or "Your")
-    means an individual or a legal entity exercising rights under this
-    License. For legal entities, "You" includes any entity that
-    controls, is controlled by, or is under common control with You. For
-    purposes of this definition, "control" means (a) the power, direct
-    or indirect, to cause the direction or management of such entity,
-    whether by contract or otherwise, or (b) ownership of more than
-    fifty percent (50%) of the outstanding shares or beneficial
-    ownership of such entity.
-
-2. License Grants and Conditions
---------------------------------
-
-2.1. Grants
-
-Each Contributor hereby grants You a world-wide, royalty-free,
-non-exclusive license:
-
-(a) under intellectual property rights (other than patent or trademark)
-    Licensable by such Contributor to use, reproduce, make available,
-    modify, display, perform, distribute, and otherwise exploit its
-    Contributions, either on an unmodified basis, with Modifications, or
-    as part of a Larger Work; and
-
-(b) under Patent Claims of such Contributor to make, use, sell, offer
-    for sale, have made, import, and otherwise transfer either its
-    Contributions or its Contributor Version.
-
-2.2. Effective Date
-
-The licenses granted in Section 2.1 with respect to any Contribution
-become effective for each Contribution on the date the Contributor first
-distributes such Contribution.
-
-2.3. Limitations on Grant Scope
-
-The licenses granted in this Section 2 are the only rights granted under
-this License. No additional rights or licenses will be implied from the
-distribution or licensing of Covered Software under this License.
-Notwithstanding Section 2.1(b) above, no patent license is granted by a
-Contributor:
-
-(a) for any code that a Contributor has removed from Covered Software;
-    or
-
-(b) for infringements caused by: (i) Your and any other third party's
-    modifications of Covered Software, or (ii) the combination of its
-    Contributions with other software (except as part of its Contributor
-    Version); or
-
-(c) under Patent Claims infringed by Covered Software in the absence of
-    its Contributions.
-
-This License does not grant any rights in the trademarks, service marks,
-or logos of any Contributor (except as may be necessary to comply with
-the notice requirements in Section 3.4).
-
-2.4. Subsequent Licenses
-
-No Contributor makes additional grants as a result of Your choice to
-distribute the Covered Software under a subsequent version of this
-License (see Section 10.2) or under the terms of a Secondary License (if
-permitted under the terms of Section 3.3).
-
-2.5. Representation
-
-Each Contributor represents that the Contributor believes its
-Contributions are its original creation(s) or it has sufficient rights
-to grant the rights to its Contributions conveyed by this License.
-
-2.6. Fair Use
-
-This License is not intended to limit any rights You have under
-applicable copyright doctrines of fair use, fair dealing, or other
-equivalents.
-
-2.7. Conditions
-
-Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
-in Section 2.1.
-
-3. Responsibilities
--------------------
-
-3.1. Distribution of Source Form
-
-All distribution of Covered Software in Source Code Form, including any
-Modifications that You create or to which You contribute, must be under
-the terms of this License. You must inform recipients that the Source
-Code Form of the Covered Software is governed by the terms of this
-License, and how they can obtain a copy of this License. You may not
-attempt to alter or restrict the recipients' rights in the Source Code
-Form.
-
-3.2. Distribution of Executable Form
-
-If You distribute Covered Software in Executable Form then:
-
-(a) such Covered Software must also be made available in Source Code
-    Form, as described in Section 3.1, and You must inform recipients of
-    the Executable Form how they can obtain a copy of such Source Code
-    Form by reasonable means in a timely manner, at a charge no more
-    than the cost of distribution to the recipient; and
-
-(b) You may distribute such Executable Form under the terms of this
-    License, or sublicense it under different terms, provided that the
-    license for the Executable Form does not attempt to limit or alter
-    the recipients' rights in the Source Code Form under this License.
-
-3.3. Distribution of a Larger Work
-
-You may create and distribute a Larger Work under terms of Your choice,
-provided that You also comply with the requirements of this License for
-the Covered Software. If the Larger Work is a combination of Covered
-Software with a work governed by one or more Secondary Licenses, and the
-Covered Software is not Incompatible With Secondary Licenses, this
-License permits You to additionally distribute such Covered Software
-under the terms of such Secondary License(s), so that the recipient of
-the Larger Work may, at their option, further distribute the Covered
-Software under the terms of either this License or such Secondary
-License(s).
-
-3.4. Notices
-
-You may not remove or alter the substance of any license notices
-(including copyright notices, patent notices, disclaimers of warranty,
-or limitations of liability) contained within the Source Code Form of
-the Covered Software, except that You may alter any license notices to
-the extent required to remedy known factual inaccuracies.
-
-3.5. Application of Additional Terms
-
-You may choose to offer, and to charge a fee for, warranty, support,
-indemnity or liability obligations to one or more recipients of Covered
-Software. However, You may do so only on Your own behalf, and not on
-behalf of any Contributor. You must make it absolutely clear that any
-such warranty, support, indemnity, or liability obligation is offered by
-You alone, and You hereby agree to indemnify every Contributor for any
-liability incurred by such Contributor as a result of warranty, support,
-indemnity or liability terms You offer. You may include additional
-disclaimers of warranty and limitations of liability specific to any
-jurisdiction.
-
-4. Inability to Comply Due to Statute or Regulation
----------------------------------------------------
-
-If it is impossible for You to comply with any of the terms of this
-License with respect to some or all of the Covered Software due to
-statute, judicial order, or regulation then You must: (a) comply with
-the terms of this License to the maximum extent possible; and (b)
-describe the limitations and the code they affect. Such description must
-be placed in a text file included with all distributions of the Covered
-Software under this License. Except to the extent prohibited by statute
-or regulation, such description must be sufficiently detailed for a
-recipient of ordinary skill to be able to understand it.
-
-5. Termination
---------------
-
-5.1. The rights granted under this License will terminate automatically
-if You fail to comply with any of its terms. However, if You become
-compliant, then the rights granted under this License from a particular
-Contributor are reinstated (a) provisionally, unless and until such
-Contributor explicitly and finally terminates Your grants, and (b) on an
-ongoing basis, if such Contributor fails to notify You of the
-non-compliance by some reasonable means prior to 60 days after You have
-come back into compliance. Moreover, Your grants from a particular
-Contributor are reinstated on an ongoing basis if such Contributor
-notifies You of the non-compliance by some reasonable means, this is the
-first time You have received notice of non-compliance with this License
-from such Contributor, and You become compliant prior to 30 days after
-Your receipt of the notice.
-
-5.2. If You initiate litigation against any entity by asserting a patent
-infringement claim (excluding declaratory judgment actions,
-counter-claims, and cross-claims) alleging that a Contributor Version
-directly or indirectly infringes any patent, then the rights granted to
-You by any and all Contributors for the Covered Software under Section
-2.1 of this License shall terminate.
-
-5.3. In the event of termination under Sections 5.1 or 5.2 above, all
-end user license agreements (excluding distributors and resellers) which
-have been validly granted by You or Your distributors under this License
-prior to termination shall survive termination.
-
-************************************************************************
-*                                                                      *
-*  6. Disclaimer of Warranty                                           *
-*  -------------------------                                           *
-*                                                                      *
-*  Covered Software is provided under this License on an "as is"       *
-*  basis, without warranty of any kind, either expressed, implied, or  *
-*  statutory, including, without limitation, warranties that the       *
-*  Covered Software is free of defects, merchantable, fit for a        *
-*  particular purpose or non-infringing. The entire risk as to the     *
-*  quality and performance of the Covered Software is with You.        *
-*  Should any Covered Software prove defective in any respect, You     *
-*  (not any Contributor) assume the cost of any necessary servicing,   *
-*  repair, or correction. This disclaimer of warranty constitutes an   *
-*  essential part of this License. No use of any Covered Software is   *
-*  authorized under this License except under this disclaimer.         *
-*                                                                      *
-************************************************************************
-
-************************************************************************
-*                                                                      *
-*  7. Limitation of Liability                                          *
-*  --------------------------                                          *
-*                                                                      *
-*  Under no circumstances and under no legal theory, whether tort      *
-*  (including negligence), contract, or otherwise, shall any           *
-*  Contributor, or anyone who distributes Covered Software as          *
-*  permitted above, be liable to You for any direct, indirect,         *
-*  special, incidental, or consequential damages of any character      *
-*  including, without limitation, damages for lost profits, loss of    *
-*  goodwill, work stoppage, computer failure or malfunction, or any    *
-*  and all other commercial damages or losses, even if such party      *
-*  shall have been informed of the possibility of such damages. This   *
-*  limitation of liability shall not apply to liability for death or   *
-*  personal injury resulting from such party's negligence to the       *
-*  extent applicable law prohibits such limitation. Some               *
-*  jurisdictions do not allow the exclusion or limitation of           *
-*  incidental or consequential damages, so this exclusion and          *
-*  limitation may not apply to You.                                    *
-*                                                                      *
-************************************************************************
-
-8. Litigation
--------------
-
-Any litigation relating to this License may be brought only in the
-courts of a jurisdiction where the defendant maintains its principal
-place of business and such litigation shall be governed by laws of that
-jurisdiction, without reference to its conflict-of-law provisions.
-Nothing in this Section shall prevent a party's ability to bring
-cross-claims or counter-claims.
-
-9. Miscellaneous
-----------------
-
-This License represents the complete agreement concerning the subject
-matter hereof. If any provision of this License is held to be
-unenforceable, such provision shall be reformed only to the extent
-necessary to make it enforceable. Any law or regulation which provides
-that the language of a contract shall be construed against the drafter
-shall not be used to construe this License against a Contributor.
-
-10. Versions of the License
----------------------------
-
-10.1. New Versions
-
-Mozilla Foundation is the license steward. Except as provided in Section
-10.3, no one other than the license steward has the right to modify or
-publish new versions of this License. Each version will be given a
-distinguishing version number.
-
-10.2. Effect of New Versions
-
-You may distribute the Covered Software under the terms of the version
-of the License under which You originally received the Covered Software,
-or under the terms of any subsequent version published by the license
-steward.
-
-10.3. Modified Versions
-
-If you create software not governed by this License, and you want to
-create a new license for such software, you may create and use a
-modified version of this License if you rename the license and remove
-any references to the name of the license steward (except to note that
-such modified license differs from this License).
-
-10.4. Distributing Source Code Form that is Incompatible With Secondary
-Licenses
-
-If You choose to distribute Source Code Form that is Incompatible With
-Secondary Licenses under the terms of this version of the License, the
-notice described in Exhibit B of this License must be attached.
-
-Exhibit A - Source Code Form License Notice
--------------------------------------------
-
-  This Source Code Form is subject to the terms of the Mozilla Public
-  License, v. 2.0. If a copy of the MPL was not distributed with this
-  file, You can obtain one at http://mozilla.org/MPL/2.0/.
-
-If it is not possible or desirable to put the notice in a particular
-file, then You may include the notice in a location (such as a LICENSE
-file in a relevant directory) where a recipient would be likely to look
-for such a notice.
-
-You may add additional accurate notices of copyright ownership.
-
-Exhibit B - "Incompatible With Secondary Licenses" Notice
----------------------------------------------------------
-
-  This Source Code Form is "Incompatible With Secondary Licenses", as
-  defined by the Mozilla Public License, v. 2.0.
diff --git a/fnotation.cabal b/fnotation.cabal
--- a/fnotation.cabal
+++ b/fnotation.cabal
@@ -1,17 +1,18 @@
 cabal-version: 3.4
 name: fnotation
-version: 0.2.0.0
-license: MPL-2.0
-license-file: LICENSE
+version: 0.2.0.1
+license: (Apache-2.0 OR MIT)
 maintainer: root@owenlynch.org
-author: Owen Lynch
+author: Coln contributors
 description:
   A lower house syntax for programming language experimentation.
-
 category: Language
 build-type: Simple
-extra-doc-files: CHANGELOG.md
 
+source-repository head
+  type: git
+  location: https://github.com/coln-project/Coln
+
 library
   exposed-modules:
     FNotation
@@ -19,8 +20,8 @@
     FNotation.Kinds
     FNotation.Lexer
     FNotation.Names
-    FNotation.Parser
     FNotation.Pretty
+    FNotation.Reader
     FNotation.Tokens
     FNotation.Trees
 
@@ -50,7 +51,7 @@
 
   ghc-options: -Wall
   build-depends:
-    base ^>=4.21.0.0,
+    base ^>=4.21.0.0 || ^>=4.22,
     containers,
     diagnostician,
     hashable,
@@ -70,6 +71,7 @@
     Test.FNotation.Property.Gen.Token
     Test.FNotation.Property.Lexing
     Test.FNotation.Property.Parsing
+    Test.FNotation.Spot
 
   default-language: GHC2024
   default-extensions:
@@ -96,7 +98,7 @@
   ghc-options: -Wall
   build-depends:
     QuickCheck,
-    base ^>=4.21.0.0,
+    base ^>=4.21.0.0 || ^>=4.22,
     bytestring,
     containers,
     diagnostician,
@@ -105,6 +107,7 @@
     prettyprinter,
     tasty,
     tasty-golden,
+    tasty-hunit,
     tasty-quickcheck,
     temporary,
     text,
diff --git a/src/FNotation.hs b/src/FNotation.hs
--- a/src/FNotation.hs
+++ b/src/FNotation.hs
@@ -1,8 +1,12 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module FNotation (
   module FNotation.Config,
   module FNotation.Lexer,
   module FNotation.Names,
-  module FNotation.Parser,
+  module FNotation.Reader,
   module FNotation.Pretty,
   module FNotation.Kinds,
   module FNotation.Trees,
@@ -13,6 +17,6 @@
 import FNotation.Kinds (Kind)
 import FNotation.Lexer (LexerCode (..), lex, lexerCodeTable)
 import FNotation.Names
-import FNotation.Parser (ParserCode (..), parse, parserCodeTable)
 import FNotation.Pretty (dprettyWithConfigs)
+import FNotation.Reader (ReaderCode (..), read, readerCodeTable)
 import FNotation.Trees (Ntn, Ntn0, NtnGeneric (..), head, span, pattern Decl, pattern Group)
diff --git a/src/FNotation/Config.hs b/src/FNotation/Config.hs
--- a/src/FNotation/Config.hs
+++ b/src/FNotation/Config.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module FNotation.Config where
 
 import Data.Text
diff --git a/src/FNotation/Kinds.hs b/src/FNotation/Kinds.hs
--- a/src/FNotation/Kinds.hs
+++ b/src/FNotation/Kinds.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module FNotation.Kinds where
 
 import Diagnostician (DPretty (..))
@@ -18,6 +22,8 @@
   | End
   | Tag
   | Field
+  | -- | No-space field, like the `x` in `f.x`
+    FieldImmediate
   | Int
   | String
   | LParen
diff --git a/src/FNotation/Lexer.hs b/src/FNotation/Lexer.hs
--- a/src/FNotation/Lexer.hs
+++ b/src/FNotation/Lexer.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module FNotation.Lexer where
 
 import Data.Char (isDigit, isLetter, isPrint, ord)
@@ -156,6 +160,7 @@
   '~' -> True
   ':' -> True
   '=' -> True
+  '@' -> True
   _ -> False
 
 report :: LexState -> LexerCode -> DDoc -> IO ()
@@ -297,29 +302,29 @@
 --------------------------------------------------------------------------------
 
 -- | Lex all the tokens
-run :: LexState -> IO ()
-run st =
+run :: LexState -> Bool -> IO ()
+run st fresh =
   peek st >>= \case
-    '\t' -> skip st >> run st
-    ' ' -> skip st >> run st
-    '(' -> classify st LParen >> run st
-    ')' -> classify st RParen >> run st
-    '[' -> classify st LBrack >> run st
-    ']' -> classify st RBrack >> run st
-    '{' -> classify st LCurly >> run st
-    '}' -> classify st RCurly >> run st
-    ',' -> classify st Comma >> run st
-    ';' -> classify st Semicolon >> run st
-    '\n' -> classify st Nl >> run st
-    '#' -> comment st >> run st
-    '\"' -> string st >> run st
-    '.' -> advance st >> tryName st Field "period" >> run st
-    '\'' -> advance st >> tryName st Tag "single quote" >> run st
+    '\t' -> skip st >> run st True
+    ' ' -> skip st >> run st True
+    '(' -> classify st LParen >> run st True
+    ')' -> classify st RParen >> run st False
+    '[' -> classify st LBrack >> run st True
+    ']' -> classify st RBrack >> run st False
+    '{' -> classify st LCurly >> run st True
+    '}' -> classify st RCurly >> run st False
+    ',' -> classify st Comma >> run st True
+    ';' -> classify st Semicolon >> run st True
+    '\n' -> classify st Nl >> run st True
+    '#' -> comment st >> run st True
+    '\"' -> string st >> run st False
+    '.' -> advance st >> tryName st (if fresh then Field else FieldImmediate) "period" >> run st False
+    '\'' -> advance st >> tryName st Tag "single quote" >> run st False
     '\0' -> emit0 st Eof
-    '`' -> ident st >> run st
-    c | isDigit c -> (int st >>= emit st Int . VInt) >> run st
-    c | isLetter c || c == '_' || isSymbol c -> ident st >> run st
-    c -> unexpectedChar st c >> run st
+    '`' -> ident st >> run st False
+    c | isDigit c -> (int st >>= emit st Int . VInt) >> run st False
+    c | isLetter c || c == '_' || isSymbol c -> ident st >> run st False
+    c -> unexpectedChar st c >> run st False
 
 -- | Run the lexer, return a vector of tokens ready for parsing.
 lex :: ConfTable Kind -> Reporter LexerCode -> File -> IO (V.Vector Token)
@@ -329,5 +334,5 @@
   iter <- newIORef $ TU.iter file.contents 0
   out <- bufferWithCapacity (TU.lengthWord8 file.contents + 1)
   let st = LexState pos prev iter out file reporter config
-  run st
+  run st True
   bufferUnsafeFreeze st.out
diff --git a/src/FNotation/Names.hs b/src/FNotation/Names.hs
--- a/src/FNotation/Names.hs
+++ b/src/FNotation/Names.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module FNotation.Names (
   Name (..),
   dprettyWithKinds,
diff --git a/src/FNotation/Parser.hs b/src/FNotation/Parser.hs
deleted file mode 100644
--- a/src/FNotation/Parser.hs
+++ /dev/null
@@ -1,346 +0,0 @@
-module FNotation.Parser where
-
-import Data.IORef
-import Data.Map (Map)
-import Data.Map qualified as Map
-import Data.Text (Text)
-import Data.Vector qualified as V
-import Diagnostician
-import FNotation.Config
-import FNotation.Kinds qualified as T
-import FNotation.Names
-import FNotation.Tokens qualified as T
-import FNotation.Trees
-import Prettyprinter
-import Prelude hiding (lookup)
-
--- Parser diagnostics
---------------------------------------------------------------------------------
-
-data ParserCode
-  = UnexpectedToken
-  | DefaultedPrec
-  | IncompatiblePrecedences
-  | ModifierWithoutModifyee
-  deriving (Eq, Ord)
-
-parserCodeTable :: Map ParserCode CodeMeta
-parserCodeTable =
-  Map.fromList
-    [ (UnexpectedToken, CodeMeta 0 SError Nothing)
-    , (DefaultedPrec, CodeMeta 1 SWarning Nothing)
-    , (IncompatiblePrecedences, CodeMeta 2 SError Nothing)
-    , (ModifierWithoutModifyee, CodeMeta 3 SError Nothing)
-    ]
-
--- Parser monad
---------------------------------------------------------------------------------
-
-data ParseState = ParseState
-  { pos :: IORef Int
-  , gas :: IORef Int
-  , skipNewlines :: IORef Bool
-  , tokens :: V.Vector T.Token
-  , file :: File
-  , reporter :: Reporter ParserCode
-  , config :: ConfTable Prec
-  }
-
--- Parsing utilities
---------------------------------------------------------------------------------
-
-report :: ParseState -> Span -> ParserCode -> DDoc -> IO ()
-report st s c m = do
-  let n = Note (Just (SourceLoc st.file s)) Nothing
-  let d = Diagnostic c m [n]
-  st.reporter.reportIO d
-
-cur :: ParseState -> IO T.Kind
-cur st = do
-  gas <- readIORef st.gas
-  if gas <= 0
-    then do
-      pos <- readIORef st.pos
-      let token = st.tokens V.! pos
-      error $ "out of gas at token " ++ show (dpretty token)
-    else writeIORef st.gas (gas - 1)
-  pos <- readIORef st.pos
-  pure (st.tokens V.! pos).kind
-
-locally :: IORef a -> a -> IO b -> IO b
-locally ref v action = do
-  old <- readIORef ref
-  writeIORef ref v
-  res <- action
-  writeIORef ref old
-  pure res
-
-ignoreNewlines :: ParseState -> IO a -> IO a
-ignoreNewlines st = locally st.skipNewlines True
-
-withNewlines :: ParseState -> IO a -> IO a
-withNewlines st = locally st.skipNewlines False
-
-curSpan :: ParseState -> IO Span
-curSpan st = do
-  pos <- readIORef st.pos
-  pure (V.unsafeIndex st.tokens pos).span
-
-curValue :: ParseState -> IO T.TokenValue
-curValue st = do
-  pos <- readIORef st.pos
-  pure (V.unsafeIndex st.tokens pos).value
-
-curName :: ParseState -> IO Name
-curName st =
-  curValue st >>= \case
-    T.VName x -> pure x
-    _ -> error "expected token to be associated with a name"
-
-curInt :: ParseState -> IO Int
-curInt st =
-  curValue st >>= \case
-    T.VInt x -> pure x
-    _ -> error "expected token to be associated with an int"
-
-curString :: ParseState -> IO Text
-curString st =
-  curValue st >>= \case
-    T.VString x -> pure x
-    _ -> error "expected token to be associated with a string"
-
-at :: ParseState -> T.Kind -> IO Bool
-at st k = (k ==) <$> cur st
-
-advance :: ParseState -> IO ()
-advance st = do
-  pos <- readIORef st.pos
-  let n = V.length st.tokens
-  if pos < n - 1
-    then do
-      let next j
-            | j < n = case (V.unsafeIndex st.tokens j).kind of
-                T.Nl -> next (j + 1)
-                _ -> j
-            | otherwise = j
-      readIORef st.skipNewlines >>= \case
-        True -> writeIORef st.pos $ next (pos + 1)
-        False -> writeIORef st.pos $ pos + 1
-      writeIORef st.gas 256
-    else pure ()
-
-eat :: ParseState -> T.Kind -> IO Bool
-eat st k =
-  at st k >>= \case
-    True -> advance st >> pure True
-    False -> pure False
-
-reportUnexpected :: ParseState -> T.Kind -> T.Class -> IO ()
-reportUnexpected st k c = do
-  s <- curSpan st
-  report st s UnexpectedToken $
-    "Unexpected token kind" <+> dpretty k <> ", expected" <+> dpretty c
-
-expect :: ParseState -> T.Kind -> IO ()
-expect st k = do
-  k' <- cur st
-  if k == k'
-    then advance st
-    else
-      reportUnexpected st k' (T.CSpecific k) >> pure ()
-
-openingPos :: ParseState -> IO Pos
-openingPos st = (.start) <$> curSpan st
-
-close :: ParseState -> Pos -> (Span -> Ntn) -> IO Ntn
-close st s f = do
-  (Span _ e) <- curSpan st
-  pure $ f (Span s e)
-
-advanceClose :: ParseState -> Pos -> (Span -> Ntn) -> IO Ntn
-advanceClose st s f = do
-  n <- close st s f
-  advance st
-  pure n
-
--- The fnotation grammar
---------------------------------------------------------------------------------
-
-argStarts :: V.Vector T.Kind
-argStarts =
-  V.fromList
-    [ T.LParen
-    , T.LBrack
-    , T.AIdent
-    , T.AKeyword
-    , T.Field
-    , T.Tag
-    , T.Int
-    , T.Block
-    ]
-
-argStart :: T.Kind -> Bool
-argStart k = V.elem k argStarts
-
-tupleElems :: ParseState -> IO [Ntn]
-tupleElems st =
-  cur st >>= \case
-    T.RBrack -> pure []
-    k | argStart k -> do
-      n <- expr st
-      cur st >>= \case
-        T.RBrack -> pure [n]
-        T.Comma -> do
-          advance st
-          ns <- tupleElems st
-          pure $ n : ns
-        k' -> do
-          reportUnexpected st k' T.CTupleMark
-          pure [n]
-    k -> do
-      reportUnexpected st k T.CExprStart
-      pure []
-
-arg :: ParseState -> IO Ntn
-arg st = do
-  m <- openingPos st
-  cur st >>= \case
-    T.LParen -> do
-      e <- ignoreNewlines st $ do
-        advance st
-        expr st
-      expect st T.RParen
-      pure e
-    T.LBrack -> do
-      ns <- ignoreNewlines st $ do
-        advance st
-        tupleElems st
-      expect st T.RBrack
-      close st m $ Tuple ns
-    T.AIdent -> do
-      x <- curName st
-      advanceClose st m $ Ident x
-    T.AKeyword -> do
-      x <- curName st
-      advanceClose st m $ Keyword x
-    T.Field -> do
-      x <- curName st
-      advanceClose st m $ Field x
-    T.Tag -> do
-      x <- curName st
-      advanceClose st m $ Tag x
-    T.Int -> do
-      i <- curInt st
-      advanceClose st m $ Int i
-    T.String -> do
-      x <- curString st
-      advanceClose st m $ String x
-    T.Block -> block st
-    k -> do
-      reportUnexpected st k T.CExprStart
-      advanceClose st m Error
-
-args :: ParseState -> IO [Ntn]
-args st = do
-  k <- cur st
-  if argStart k
-    then (:) <$> arg st <*> args st
-    else pure []
-
-expr :: ParseState -> IO Ntn
-expr st = arg st >>= go (Prec 0 AssocNon)
- where
-  go p lhs = do
-    cur st >>= \case
-      k@(T.SIdent; T.SKeyword) -> do
-        s <- curSpan st
-        x <- curName st
-        let n = case k of
-              T.SIdent -> Ident x s
-              T.SKeyword -> Keyword x s
-        p' <- case confTableLookup st.config x.last of
-          Just p' -> pure p'
-          Nothing -> do
-            report st s DefaultedPrec $
-              "Defaulted precedence of" <+> dpretty x <+> "to the same as +"
-            pure $ Prec 50 AssocL
-        case precLe p p' of
-          Nothing -> do
-            report st s IncompatiblePrecedences "Incompatible precedences"
-            pure lhs
-          Just False -> pure lhs
-          Just True -> do
-            advance st
-            rhs <- arg st >>= go p'
-            go p (Infix lhs n rhs)
-      k | argStart k -> do
-        rhs <- arg st
-        go p (Juxt lhs rhs)
-      _ -> pure lhs
-
-decl :: ParseState -> IO Ntn
-decl st = do
-  p <- openingPos st
-  go p []
- where
-  go p mods =
-    cur st >>= \case
-      T.Modifier -> do
-        m <- curName st
-        advance st
-        go p (m : mods)
-      T.Decl -> do
-        x <- curName st
-        advance st
-        n <- expr st
-        expect st T.Nl
-        pure $ MDecl (reverse mods) x n (Span p (endPos n))
-      _ -> do
-        s <- curSpan st
-        report st s ModifierWithoutModifyee "expected a declaration after a declaration modifier"
-        advanceClose st p Error
-
-stmt :: ParseState -> IO Ntn
-stmt st =
-  cur st >>= \case
-    T.Modifier; T.Decl -> decl st
-    _ -> expr st
-
-stmts :: ParseState -> IO [Ntn]
-stmts st = go []
- where
-  go ns =
-    cur st >>= \case
-      T.Nl -> do
-        advance st
-        go ns
-      k | k == T.End || k == T.Eof -> pure $ reverse ns
-      _ -> do
-        n <- stmt st
-        go $ n : ns
-
-block :: ParseState -> IO Ntn
-block st =
-  cur st >>= \case
-    T.Block -> do
-      m <- openingPos st
-      x <- curName st
-      advance st
-      h <-
-        cur st >>= \case
-          k | argStart k -> Just <$> arg st
-          _ -> pure Nothing
-      ns <- stmts st
-      advanceClose st m $ Block x h ns
-    _ -> error "expected a block"
-
--- Toplevel parsing interface
---------------------------------------------------------------------------------
-
-parse :: ConfTable Prec -> Reporter ParserCode -> File -> V.Vector T.Token -> IO [Ntn]
-parse config reporter file tokens = do
-  pos <- newIORef 0
-  gas <- newIORef 256
-  skipNewlines <- newIORef False
-  let st = ParseState pos gas skipNewlines tokens file reporter config
-  stmts st
diff --git a/src/FNotation/Pretty.hs b/src/FNotation/Pretty.hs
--- a/src/FNotation/Pretty.hs
+++ b/src/FNotation/Pretty.hs
@@ -1,5 +1,11 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module FNotation.Pretty where
 
+import Data.Maybe (fromMaybe)
+
 import Diagnostician
 import FNotation.Config
 import FNotation.Kinds (Kind)
@@ -32,9 +38,27 @@
 
 type ConfigArg = (?config :: ConfTable Prec, ?lconfig :: ConfTable Kind)
 
+tryImmediate :: (ConfigArg) => NtnGeneric a -> Maybe DDoc
+tryImmediate (Ident x _) = return $ dprettyWithKinds ?lconfig x
+tryImmediate (Field x _) = return $ "." <> dprettyWithKinds ?lconfig x
+tryImmediate t@Tuple{} = return $ prtTop t
+tryImmediate (Juxt n (Field x _)) = do
+  i <- tryImmediate n
+  return $ i <> "." <> dprettyWithKinds ?lconfig x
+tryImmediate _ = Nothing
+
 prtTop :: (ConfigArg) => NtnGeneric a -> DDoc
 prtTop = prt Top
 
+bracketedTuple :: [DDoc] -> DDoc
+bracketedTuple [] = "[]"
+bracketedTuple ds =
+  group
+    . enclose ("[" <> line') (line' <> "]")
+    . vsep
+    . punctuate ","
+    $ (\d -> flatAlt (indent 2 d) d) <$> ds
+
 precApp :: Prec
 precApp = Prec 100 AssocL
 
@@ -45,14 +69,17 @@
 prt :: (ConfigArg) => PrevPrec -> NtnGeneric a -> DDoc
 prt p = \case
   Juxt n n' ->
-    par (looser precApp p) $
-      prt (LeftOf precApp) n <+> prt (RightOf precApp) n'
+    fromMaybe
+      ( par (looser precApp p) $
+          prt (LeftOf precApp) n <+> prt (RightOf precApp) n'
+      )
+      (tryImmediate (Juxt n n'))
   Infix l n r ->
     let mp' = case n of
           Ident x _ -> confTableLookup ?config x.last
           Keyword x _ -> confTableLookup ?config x.last
           _ -> Nothing
-        p' = maybe (Prec 50 AssocL) id mp'
+        p' = fromMaybe (Prec 50 AssocL) mp'
      in par (looser p' p) (prt (LeftOf p') l <+> prt Bot n <+> prt (RightOf p') r)
   Block x hd stmts _ ->
     vsep $
@@ -67,7 +94,7 @@
   Tag x _ -> "'" <> dprettyWithKinds ?lconfig x
   Int i _ -> pretty i
   String x _ -> "\"" <> pretty x <> "\""
-  Tuple ns _ -> list $ prtTop <$> ns
+  Tuple ns _ -> bracketedTuple $ prtTop <$> ns
   Error _ -> "<error>"
 
 dprettyWithConfigs :: ConfTable Prec -> ConfTable Kind -> NtnGeneric a -> DDoc
diff --git a/src/FNotation/Reader.hs b/src/FNotation/Reader.hs
new file mode 100644
--- /dev/null
+++ b/src/FNotation/Reader.hs
@@ -0,0 +1,373 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
+module FNotation.Reader where
+
+import Data.IORef
+import Data.Map (Map)
+import Data.Map qualified as Map
+import Data.Text (Text)
+import Data.Vector qualified as V
+import Diagnostician
+import FNotation.Config
+import FNotation.Kinds qualified as T
+import FNotation.Names
+import FNotation.Tokens qualified as T
+import FNotation.Trees hiding (head)
+import Prettyprinter
+import Prelude hiding (head, lookup)
+
+-- Reader diagnostics
+--------------------------------------------------------------------------------
+
+data ReaderCode
+  = UnexpectedToken
+  | DefaultedPrec
+  | IncompatiblePrecedences
+  | ModifierWithoutModifyee
+  | StatementWithoutNewline
+  deriving (Eq, Ord)
+
+readerCodeTable :: Map ReaderCode CodeMeta
+readerCodeTable =
+  Map.fromList
+    [ (UnexpectedToken, CodeMeta 0 SError Nothing)
+    , (DefaultedPrec, CodeMeta 1 SWarning Nothing)
+    , (IncompatiblePrecedences, CodeMeta 2 SError Nothing)
+    , (ModifierWithoutModifyee, CodeMeta 3 SError Nothing)
+    , (StatementWithoutNewline, CodeMeta 4 SError Nothing)
+    ]
+
+-- Reader monad
+--------------------------------------------------------------------------------
+
+data ReaderState = ReaderState
+  { pos :: IORef Int
+  , gas :: IORef Int
+  , skipNewlines :: IORef Bool
+  , tokens :: V.Vector T.Token
+  , file :: File
+  , reporter :: Reporter ReaderCode
+  , config :: ConfTable Prec
+  }
+
+-- Parsing utilities
+--------------------------------------------------------------------------------
+
+report :: ReaderState -> Span -> ReaderCode -> DDoc -> IO ()
+report st s c m = do
+  let n = Note (Just (SourceLoc st.file s)) Nothing
+  let d = Diagnostic c m [n]
+  st.reporter.reportIO d
+
+cur :: ReaderState -> IO T.Kind
+cur st = do
+  gas <- readIORef st.gas
+  if gas <= 0
+    then do
+      pos <- readIORef st.pos
+      let token = st.tokens V.! pos
+      error $ "out of gas at token " ++ show (dpretty token)
+    else writeIORef st.gas (gas - 1)
+  pos <- readIORef st.pos
+  pure (st.tokens V.! pos).kind
+
+locally :: IORef a -> a -> IO b -> IO b
+locally ref v action = do
+  old <- readIORef ref
+  writeIORef ref v
+  res <- action
+  writeIORef ref old
+  pure res
+
+ignoreNewlines :: ReaderState -> IO a -> IO a
+ignoreNewlines st = locally st.skipNewlines True
+
+withNewlines :: ReaderState -> IO a -> IO a
+withNewlines st = locally st.skipNewlines False
+
+curSpan :: ReaderState -> IO Span
+curSpan st = do
+  pos <- readIORef st.pos
+  pure (V.unsafeIndex st.tokens pos).span
+
+curValue :: ReaderState -> IO T.TokenValue
+curValue st = do
+  pos <- readIORef st.pos
+  pure (V.unsafeIndex st.tokens pos).value
+
+curName :: ReaderState -> IO Name
+curName st =
+  curValue st >>= \case
+    T.VName x -> pure x
+    _ -> error "expected token to be associated with a name"
+
+curInt :: ReaderState -> IO Int
+curInt st =
+  curValue st >>= \case
+    T.VInt x -> pure x
+    _ -> error "expected token to be associated with an int"
+
+curString :: ReaderState -> IO Text
+curString st =
+  curValue st >>= \case
+    T.VString x -> pure x
+    _ -> error "expected token to be associated with a string"
+
+at :: ReaderState -> T.Kind -> IO Bool
+at st k = (k ==) <$> cur st
+
+advance :: ReaderState -> IO ()
+advance st = do
+  pos <- readIORef st.pos
+  let n = V.length st.tokens
+  if pos < n - 1
+    then do
+      let next j
+            | j < n = case (V.unsafeIndex st.tokens j).kind of
+                T.Nl -> next (j + 1)
+                _ -> j
+            | otherwise = j
+      readIORef st.skipNewlines >>= \case
+        True -> writeIORef st.pos $ next (pos + 1)
+        False -> writeIORef st.pos $ pos + 1
+      writeIORef st.gas 256
+    else pure ()
+
+eat :: ReaderState -> T.Kind -> IO Bool
+eat st k =
+  at st k >>= \case
+    True -> advance st >> pure True
+    False -> pure False
+
+reportUnexpected :: ReaderState -> T.Kind -> T.Class -> IO ()
+reportUnexpected st k c = do
+  s <- curSpan st
+  report st s UnexpectedToken $
+    "Unexpected token kind" <+> dpretty k <> ", expected" <+> dpretty c
+
+expect :: ReaderState -> T.Kind -> IO ()
+expect st k = do
+  k' <- cur st
+  if k == k'
+    then advance st
+    else
+      reportUnexpected st k' (T.CSpecific k) >> pure ()
+
+openingPos :: ReaderState -> IO Pos
+openingPos st = (.start) <$> curSpan st
+
+close :: ReaderState -> Pos -> (Span -> Ntn) -> IO Ntn
+close st s f = do
+  (Span _ e) <- curSpan st
+  pure $ f (Span s e)
+
+advanceClose :: ReaderState -> Pos -> (Span -> Ntn) -> IO Ntn
+advanceClose st s f = do
+  n <- close st s f
+  advance st
+  pure n
+
+-- The fnotation grammar
+--------------------------------------------------------------------------------
+
+argStarts :: V.Vector T.Kind
+argStarts =
+  V.fromList
+    [ T.LParen
+    , T.LBrack
+    , T.AIdent
+    , T.AKeyword
+    , T.Field
+    , T.Tag
+    , T.Int
+    , T.Block
+    ]
+
+argStart :: T.Kind -> Bool
+argStart k = V.elem k argStarts
+
+tupleElems :: ReaderState -> IO [Ntn]
+tupleElems st =
+  cur st >>= \case
+    T.RBrack -> pure []
+    k | argStart k -> do
+      n <- expr st
+      cur st >>= \case
+        T.RBrack -> pure [n]
+        T.Comma -> do
+          advance st
+          ns <- tupleElems st
+          pure $ n : ns
+        k' -> do
+          reportUnexpected st k' T.CTupleMark
+          pure [n]
+    k -> do
+      reportUnexpected st k T.CExprStart
+      pure []
+
+argBase :: ReaderState -> IO Ntn
+argBase st = do
+  m <- openingPos st
+  cur st >>= \case
+    T.LParen -> do
+      e <- ignoreNewlines st $ do
+        advance st
+        expr st
+      expect st T.RParen
+      pure e
+    T.LBrack -> do
+      ns <- ignoreNewlines st $ do
+        advance st
+        tupleElems st
+      expect st T.RBrack
+      close st m $ Tuple ns
+    T.AIdent -> do
+      x <- curName st
+      advanceClose st m $ Ident x
+    T.AKeyword -> do
+      x <- curName st
+      advanceClose st m $ Keyword x
+    T.Field; T.FieldImmediate -> do
+      x <- curName st
+      advanceClose st m $ Field x
+    T.Tag -> do
+      x <- curName st
+      advanceClose st m $ Tag x
+    T.Int -> do
+      i <- curInt st
+      advanceClose st m $ Int i
+    T.String -> do
+      x <- curString st
+      advanceClose st m $ String x
+    T.Block -> block st
+    k -> do
+      reportUnexpected st k T.CExprStart
+      advanceClose st m Error
+
+-- `.x.y.z -> [x, y, z]`
+argProjs :: ReaderState -> IO [Ntn]
+argProjs st =
+  cur st >>= \case
+    T.FieldImmediate -> do
+      field <- argBase st
+      rest <- argProjs st
+      return (field : rest)
+    _ -> pure []
+
+arg :: ReaderState -> IO Ntn
+arg st = do
+  head <- argBase st
+  spine <- argProjs st
+  pure $ foldr (flip Juxt) head (reverse spine)
+
+args :: ReaderState -> IO [Ntn]
+args st = do
+  k <- cur st
+  if argStart k
+    then (:) <$> arg st <*> args st
+    else pure []
+
+expr :: ReaderState -> IO Ntn
+expr st = arg st >>= go (Prec 0 AssocNon)
+ where
+  go p lhs = do
+    cur st >>= \case
+      k@(T.SIdent; T.SKeyword) -> do
+        s <- curSpan st
+        x <- curName st
+        let n = case k of
+              T.SIdent -> Ident x s
+              T.SKeyword -> Keyword x s
+        p' <- case confTableLookup st.config x.last of
+          Just p' -> pure p'
+          Nothing -> do
+            report st s DefaultedPrec $
+              "Defaulted precedence of" <+> dpretty x <+> "to the same as +"
+            pure $ Prec 50 AssocL
+        case precLe p p' of
+          Nothing -> do
+            report st s IncompatiblePrecedences "Incompatible precedences"
+            pure lhs
+          Just False -> pure lhs
+          Just True -> do
+            advance st
+            rhs <- arg st >>= go p'
+            go p (Infix lhs n rhs)
+      k | argStart k -> do
+        rhs <- arg st
+        go p (Juxt lhs rhs)
+      _ -> pure lhs
+
+decl :: ReaderState -> IO Ntn
+decl st = do
+  p <- openingPos st
+  go p []
+ where
+  go p mods =
+    cur st >>= \case
+      T.Modifier -> do
+        m <- curName st
+        advance st
+        go p (m : mods)
+      T.Decl -> do
+        x <- curName st
+        advance st
+        n <- expr st
+        pure $ MDecl (reverse mods) x n (Span p (endPos n))
+      _ -> do
+        s <- curSpan st
+        report st s ModifierWithoutModifyee "expected a declaration after a declaration modifier"
+        advanceClose st p Error
+
+stmt :: ReaderState -> IO Ntn
+stmt st =
+  cur st >>= \case
+    T.Modifier; T.Decl -> decl st
+    _ -> expr st
+
+stmts :: ReaderState -> IO [Ntn]
+stmts st = go True []
+ where
+  -- following = we have seen at least one newline
+  go following ns =
+    cur st >>= \case
+      T.Nl -> do
+        advance st
+        go True ns
+      k | k == T.End || k == T.Eof -> pure $ reverse ns
+      _ -> case following of
+        False -> do
+          s <- curSpan st
+          report st s StatementWithoutNewline "expected a newline, end, or eof after a statement"
+          pure $ reverse ns
+        True -> do
+          n <- stmt st
+          go False $ n : ns
+
+block :: ReaderState -> IO Ntn
+block st =
+  cur st >>= \case
+    T.Block -> do
+      m <- openingPos st
+      x <- curName st
+      advance st
+      h <-
+        cur st >>= \case
+          k | argStart k -> Just <$> expr st
+          _ -> pure Nothing
+      ns <- stmts st
+      advanceClose st m $ Block x h ns
+    _ -> error "expected a block"
+
+-- Toplevel parsing interface
+--------------------------------------------------------------------------------
+
+read :: ConfTable Prec -> Reporter ReaderCode -> File -> V.Vector T.Token -> IO [Ntn]
+read config reporter file tokens = do
+  pos <- newIORef 0
+  gas <- newIORef 256
+  skipNewlines <- newIORef False
+  let st = ReaderState pos gas skipNewlines tokens file reporter config
+  stmts st
diff --git a/src/FNotation/Tokens.hs b/src/FNotation/Tokens.hs
--- a/src/FNotation/Tokens.hs
+++ b/src/FNotation/Tokens.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module FNotation.Tokens where
 
 import Data.Text (Text)
diff --git a/src/FNotation/Trees.hs b/src/FNotation/Trees.hs
--- a/src/FNotation/Trees.hs
+++ b/src/FNotation/Trees.hs
@@ -1,3 +1,6 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
 {-# LANGUAGE ViewPatterns #-}
 
 module FNotation.Trees where
@@ -44,7 +47,7 @@
 pattern Group :: NonEmpty (NtnGeneric a) -> NtnGeneric a
 pattern Group xs <- (getGroup [] -> xs)
   where
-    Group (x :| xs) = foldl' (flip Juxt) x xs
+    Group (x :| xs) = foldl' Juxt x xs
 
 {-# COMPLETE Group #-}
 
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -1,8 +1,13 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module Main where
 
 import Test.FNotation.Golden (goldenTests)
 import Test.FNotation.Property.Lexing (lexerProperties)
-import Test.FNotation.Property.Parsing (parserProperties)
+import Test.FNotation.Property.Parsing (readerProperties)
+import Test.FNotation.Spot (spotTests)
 import Test.Tasty
 
 main :: IO ()
@@ -15,6 +20,7 @@
       , testGroup
           "Property Tests"
           [ lexerProperties
-          , parserProperties
+          , readerProperties
           ]
+      , spotTests
       ]
diff --git a/test/Test/FNotation/Common.hs b/test/Test/FNotation/Common.hs
--- a/test/Test/FNotation/Common.hs
+++ b/test/Test/FNotation/Common.hs
@@ -1,5 +1,12 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module Test.FNotation.Common where
 
+import Data.Map (Map)
+import Data.Map qualified as Map
+import Diagnostician
 import FNotation
 import FNotation.Kinds qualified as K
 import Prelude hiding (lex)
@@ -30,8 +37,8 @@
     , ("=>", K.SKeyword)
     ]
 
-parseConfig :: ConfTable Prec
-parseConfig =
+readConfig :: ConfTable Prec
+readConfig =
   confTableFromList
     [ (":=", Prec 10 AssocNon)
     , (":", Prec 20 AssocNon)
@@ -43,3 +50,18 @@
     , ("*", Prec 60 AssocL)
     , ("/", Prec 60 AssocL)
     ]
+
+data TestCode = LexerCode LexerCode | ReaderCode ReaderCode
+  deriving (Eq, Ord)
+
+codeTable :: Map TestCode CodeMeta
+codeTable =
+  mconcat
+    [ promoteCodeTable lexerCodeTable LexerCode 0
+    , promoteCodeTable readerCodeTable ReaderCode 100
+    ]
+
+instance Code TestCode where
+  codeMeta c = case Map.lookup c codeTable of
+    Just m -> m
+    Nothing -> error "unregistered code"
diff --git a/test/Test/FNotation/Golden.hs b/test/Test/FNotation/Golden.hs
--- a/test/Test/FNotation/Golden.hs
+++ b/test/Test/FNotation/Golden.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module Test.FNotation.Golden (goldenTests) where
 
 import Control.Exception
@@ -19,28 +23,13 @@
 import Test.FNotation.Common
 import Test.Tasty (TestTree, testGroup)
 import Test.Tasty.Golden (findByExtension, goldenVsString)
-import Prelude hiding (lex)
+import Prelude hiding (lex, read)
 
 render :: DDoc -> LBS.ByteString
 render = TLE.encodeUtf8 . renderLazy . layoutPretty defaultLayoutOptions
 
-data TestCode = LexerCode LexerCode | ParserCode ParserCode
-  deriving (Eq, Ord)
-
-codeTable :: Map TestCode CodeMeta
-codeTable =
-  mconcat
-    [ promoteCodeTable lexerCodeTable LexerCode 0
-    , promoteCodeTable parserCodeTable ParserCode 100
-    ]
-
-instance Code TestCode where
-  codeMeta c = case Map.lookup c codeTable of
-    Just m -> m
-    Nothing -> error "unregistered code"
-
-parseToPretty :: FilePath -> IO LBS.ByteString
-parseToPretty fp = do
+readToPretty :: FilePath -> IO LBS.ByteString
+readToPretty fp = do
   src <- T.readFile fp
   let f = newFile fp src
   withSystemTempFile "reporter-output" $ \path h -> do
@@ -48,8 +37,8 @@
     try @SomeException (lex lexConfig (contramap LexerCode r) f) >>= \case
       Left err -> pure $ TLE.encodeUtf8 $ "lex error:\n" <> TL.show err
       Right tokens -> do
-        try @SomeException (parse parseConfig (contramap ParserCode r) f tokens) >>= \case
-          Left err -> pure $ TLE.encodeUtf8 $ "parse error:\n" <> TL.show err
+        try @SomeException (read readConfig (contramap ReaderCode r) f tokens) >>= \case
+          Left err -> pure $ TLE.encodeUtf8 $ "read error:\n" <> TL.show err
           Right ns -> do
             hFlush h
             hClose h
@@ -64,7 +53,7 @@
                   , vsep $ dpretty <$> ns
                   , ""
                   , "-- pretty"
-                  , vsep $ dprettyWithConfigs parseConfig lexConfig <$> ns
+                  , vsep $ dprettyWithConfigs readConfig lexConfig <$> ns
                   , ""
                   , "-- messages"
                   , pretty $ msgs
@@ -76,7 +65,7 @@
   return $
     testGroup
       "Golden tests"
-      [ goldenVsString (takeBaseName ntnFile) outputFile (parseToPretty ntnFile)
+      [ goldenVsString (takeBaseName ntnFile) outputFile (readToPretty ntnFile)
       | ntnFile <- ntnFiles
       , let outputFile = replaceExtension ntnFile ".output"
       ]
diff --git a/test/Test/FNotation/Property/Gen/Source.hs b/test/Test/FNotation/Property/Gen/Source.hs
--- a/test/Test/FNotation/Property/Gen/Source.hs
+++ b/test/Test/FNotation/Property/Gen/Source.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 -- | QuickCheck generators for FNotation source text.
 module Test.FNotation.Property.Gen.Source (
   -- * Source text generator
diff --git a/test/Test/FNotation/Property/Gen/Token.hs b/test/Test/FNotation/Property/Gen/Token.hs
--- a/test/Test/FNotation/Property/Gen/Token.hs
+++ b/test/Test/FNotation/Property/Gen/Token.hs
@@ -1,7 +1,11 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 {- | QuickCheck generators for FNotation token streams.
 
 These generators produce 'V.Vector Token' values that satisfy the invariants
-expected by the parser ('FNotation.Parser.parse'):
+expected by the reader ('FNotation.Reader.read'):
 
  1. Every token has the correct 'Kind'/'TokenValue' pairing:
 
@@ -13,7 +17,7 @@
         'RBrack', 'LCurly', 'RCurly', 'Comma', 'Semicolon', 'Nl', 'Eof',
         'Error') carry 'VEmpty'.
 
- 2. The stream is non-empty and always ends with an 'Eof' token (the parser's
+ 2. The stream is non-empty and always ends with an 'Eof' token (the reader's
     @stmts@ loop terminates only on 'End' or 'Eof').
 
  3. Spans are synthetic but well-formed (non-negative, start <= end), assigned
@@ -46,7 +50,7 @@
 
 {- | A newtype wrapper around a non-empty 'V.Vector' of 'Token's that is
 always terminated by 'Eof'. Suitable for feeding directly to
-'FNotation.Parser.parse'.
+'FNotation.Reader.read'.
 -}
 newtype FNTokens = FNTokens {getTokens :: V.Vector Token}
 
@@ -101,13 +105,13 @@
 {- | Generate a single token with a well-formed kind/value pairing.
 
 The frequency distribution covers every 'Kind' constructor. Kinds that the
-parser actively dispatches on ('AIdent', 'SIdent', 'LParen', 'LBrack', 'Nl',
-etc.) are weighted more heavily to produce interesting parse trees.
+reader actively dispatches on ('AIdent', 'SIdent', 'LParen', 'LBrack', 'Nl',
+etc.) are weighted more heavily to produce interesting read trees.
 -}
 genToken :: Gen Token
 genToken =
   frequency
-    [ -- Name-bearing tokens (parser dispatches on these in arg/expr/stmt)
+    [ -- Name-bearing tokens (reader dispatches on these in arg/expr/stmt)
       (10, genNameToken AIdent)
     , (3, genNameToken AKeyword)
     , (6, genNameToken SIdent)
@@ -131,7 +135,7 @@
     , (3, genPunctToken Comma)
     , (2, genPunctToken Semicolon)
     , (6, genPunctToken Nl)
-    , -- Error token (parser may encounter these from lexer errors)
+    , -- Error token (reader may encounter these from lexer errors)
       (1, genPunctToken Error)
     ]
 
diff --git a/test/Test/FNotation/Property/Lexing.hs b/test/Test/FNotation/Property/Lexing.hs
--- a/test/Test/FNotation/Property/Lexing.hs
+++ b/test/Test/FNotation/Property/Lexing.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module Test.FNotation.Property.Lexing where
 
 import Data.Vector qualified as V
diff --git a/test/Test/FNotation/Property/Parsing.hs b/test/Test/FNotation/Property/Parsing.hs
--- a/test/Test/FNotation/Property/Parsing.hs
+++ b/test/Test/FNotation/Property/Parsing.hs
@@ -1,3 +1,7 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
 module Test.FNotation.Property.Parsing where
 
 import Diagnostician
@@ -6,19 +10,20 @@
 import Test.FNotation.Property.Gen.Token
 import Test.Tasty
 import Test.Tasty.QuickCheck
+import Prelude hiding (read)
 
 -- | A reporter that silently discards all diagnostics.
-nullReporter :: Reporter ParserCode
+nullReporter :: Reporter ReaderCode
 nullReporter = Reporter{reportIO = \_ -> pure ()}
 
--- | Property: the parser should not crash on any generated token stream.
-parserProperties :: TestTree
-parserProperties =
+-- | Property: the reader should not crash on any generated token stream.
+readerProperties :: TestTree
+readerProperties =
   testGroup
-    "Parser properties"
-    [ testProperty "parser does not crash on arbitrary tokens" \(FNTokens tokens) ->
+    "Reader properties"
+    [ testProperty "reader does not crash on arbitrary tokens" \(FNTokens tokens) ->
         ioProperty do
           let f = newFile "<quickcheck>" ""
-          ns <- parse parseConfig nullReporter f tokens
+          ns <- read readConfig nullReporter f tokens
           length ns `seq` pure True
     ]
diff --git a/test/Test/FNotation/Spot.hs b/test/Test/FNotation/Spot.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/FNotation/Spot.hs
@@ -0,0 +1,33 @@
+-- SPDX-FileCopyrightText: 2026 Coln contributors
+--
+-- SPDX-License-Identifier: Apache-2.0 OR MIT
+
+module Test.FNotation.Spot where
+
+import Data.Functor.Contravariant
+import Data.IORef
+import Data.Text (Text)
+import Diagnostician
+import FNotation
+import Test.FNotation.Common
+import Test.Tasty
+import Test.Tasty.HUnit
+import Prelude hiding (lex, read)
+
+readErrorFree :: Text -> IO Bool
+readErrorFree src = do
+  ref <- newIORef ([] :: [Diagnostic TestCode])
+  let r = pureReporter ref
+  let f = newFile "<input>" src
+  tokens <- lex lexConfig (contramap LexerCode r) f
+  _ <- read readConfig (contramap ReaderCode r) f tokens
+  errs <- readIORef ref
+  pure $ null errs
+
+spotTests :: TestTree
+spotTests =
+  testGroup
+    "Spot checks"
+    [ testCase "Statement without newline" $ do
+        readErrorFree "def x := 2" @? "failed to read statement without newline"
+    ]
