packages feed

tala-syntax (empty) → 0.1.0

raw patch · 19 files changed

+5566/−0 lines, 19 filesdep +basedep +bytestring

Dependencies added: base, bytestring

Files

+ LICENSE view
@@ -0,0 +1,373 @@+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 https://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.
+ LICENSE-EXCEPTION view
@@ -0,0 +1,81 @@+Progmasoft Linking Exception, Version 1.1
+
+This Exception is an additional permission to the Mozilla Public License, version 2.0 ("MPL-2.0"). It is intended to preserve MPL-2.0's file-level obligations for Covered Software while removing doubt about combining that software with independent code. Capitalized terms not defined here have the meanings given in MPL-2.0.
+
+1. Definitions
+
+"Independent Component" means software contained in one or more Source Code Form files that neither contain Covered Software nor are Modifications of Covered Software. A component does not cease to be independent merely because compilation, static or dynamic linking, loading, packaging, or deployment places its Executable Form in the same file, process, address space, archive, image, or distribution as Covered Software.
+
+"Combined Work" means a Larger Work that combines Covered Software with one or more Independent Components by any technical means, including static or dynamic linking, loading, embedding, plugins, extensions, language bindings, an API, ABI, FFI boundary, command-line interface, protocol, inter-process communication mechanism, or file format.
+
+"Output" means material produced by executing Covered Software, including generated source, object code, executable code, intermediate representations, documentation, reports, diagnostics, formatted text, and analysis results.
+
+2. Additional Permission for Combined Works
+
+Subject to compliance with MPL-2.0 for the Covered Software, each recipient has a worldwide, royalty-free, non-exclusive, irrevocable additional copyright permission to create, reproduce, use, modify, display, perform, make available, and distribute a Combined Work under terms of the recipient's choice, including proprietary or closed-source terms.
+
+Those chosen terms may govern Independent Components and other portions of the Combined Work, but must not restrict or alter any recipient's rights in the Covered Software under MPL-2.0 and this Exception. No distributor is required to relicense an Independent Component merely because it forms part of a Combined Work.
+
+3. Boundary of Covered Software
+
+Linking, loading, calling, communicating with, packaging, installing, or distributing an Independent Component with Covered Software does not, by itself:
+
+(a) make the Independent Component Covered Software or a Modification;
+(b) subject the Independent Component's Source Code Form to MPL-2.0; or
+(c) expand MPL-2.0 beyond files that are Covered Software or Modifications under MPL-2.0.
+
+If Source Code Form from Covered Software is copied into another file, or a new Source Code Form file contains Covered Software, MPL-2.0 determines whether that file is a Modification. Labels, file extensions, build boundaries, code generation, or later separation of files do not avoid that determination.
+
+4. Outputs
+
+An Output does not become Covered Software merely because Covered Software produced, transformed, linked, or processed it. The copyright license chosen for input material does not change merely because Covered Software processes that material.
+
+If an Output contains copyrightable Source Code Form or other copyrightable material copied or derived from Covered Software, this Exception does not remove MPL-2.0 obligations from that incorporated material. Facts, ideas, interfaces, names, signatures, diagnostics, and other material not protected by copyright are not converted into Covered Software by this Exception.
+
+5. Preservation of MPL-2.0 Obligations
+
+This Exception does not remove, replace, waive, or narrow any MPL-2.0 obligation applicable to Covered Software. In particular:
+
+(a) Covered Software in Source Code Form and every Modification remain subject to MPL-2.0;
+(b) distribution in Executable Form remains subject to MPL-2.0 Section 3.2, including the obligation to inform recipients how to obtain the corresponding Source Code Form when that section applies;
+(c) notices protected by MPL-2.0 Section 3.4 must be retained; and
+(d) this Exception grants no permission to withhold Source Code Form that MPL-2.0 requires to be made available.
+
+Distribution of a Combined Work under terms of choice is permission for the Combined Work as a whole and its Independent Components. It is not permission to relicense a Covered Software file.
+
+6. Packaging and Deployment
+
+Placing Covered Software and an Independent Component in the same archive, package, installer, application bundle, container image, firmware image, distribution medium, hosted deployment, or process does not change their respective copyright licenses. Network interaction or operation as separate processes is not required for independence.
+
+7. Direct Grant to Recipients
+
+The additional permission in this Exception is granted directly by the copyright holders applying it to every recipient of the applicable Covered Software. A distributor need not sublicense this Exception. Distribution through one or more intermediaries does not reduce the permission, and termination of an upstream distributor's rights does not terminate rights already received by a compliant downstream recipient.
+
+8. Termination and Reinstatement
+
+This Exception has no separate patent-litigation, field-of-use, combination, or compatibility termination condition. A recipient may exercise it only while entitled to exercise the corresponding MPL-2.0 copyright rights. If those rights terminate under MPL-2.0 Section 5.1, exercise of this Exception for the affected Covered Software is suspended to the same extent and is reinstated when, and to the extent that, the MPL-2.0 rights are reinstated.
+
+A recipient may elect to use Covered Software under MPL-2.0 without relying on this Exception.
+
+9. Other Rights
+
+This Exception grants copyright permission only. It grants no trademark, trade dress, service mark, publicity, confidential-information, or patent rights. Patent rights, if any, arise only under MPL-2.0 or a separately applicable patent grant. Nothing in this Exception limits rights arising independently under exhaustion, fair use, fair dealing, implied license, estoppel, or other applicable law.
+
+10. Relationship to MPL-2.0
+
+MPL-2.0 and this Exception are read together. If this Exception and MPL-2.0 cannot both be given effect with respect to the creation or distribution of a Combined Work, this Exception controls only to the minimum extent necessary to provide the additional permission stated in Sections 2 through 6. MPL-2.0 controls in every other respect.
+
+Nothing here changes whether Covered Software is "Incompatible With Secondary Licenses" or changes the operation of MPL-2.0 Section 3.3 for a work governed by a Secondary License.
+
+11. Applicability and Version
+
+This Exception applies only to a file that expressly identifies it with this exact SPDX license expression:
+
+MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+Reference to Version 1.1 does not incorporate any later version. A copy of this text supplied in a repository or distribution identifies the permission; the per-file expression determines which files receive it.
+
+12. Severability
+
+If any provision of this Exception is held unenforceable, it shall be reformed only to the minimum extent necessary to make it enforceable. If reformation is unavailable, that provision is severed and the remaining provisions continue to the maximum extent permitted by law.
+
+ src/Progmasoft/Tala/AST.hs view
@@ -0,0 +1,688 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | Public syntax and phase-indexed tree model shared by the Tala
+frontend. Parsed trees retain source spelling, renamed trees attach stable
+identities, resolved trees attach declarations, and typed trees attach
+semantic types without changing the syntax structure.
+-}
+module Progmasoft.Tala.AST
+    ( Identifier (..)
+    , QualifiedName (..)
+    , SourcePosition (..)
+    , SourceSpan (..)
+    , SyntaxTree (..)
+    , Declaration (..)
+    , EnumCase (..)
+    , enumType
+    , enumUnderlyingType
+    , enumMemberValues
+    , TemplateParameter (..)
+    , TemplateParameterKind (..)
+    , TemplateParameterShape (..)
+    , TemplateParameterShapeKind (..)
+    , TemplateDefault (..)
+    , Parameter (..)
+    , Block (..)
+    , Statement (..)
+    , Expression (..)
+    , Pattern (..)
+    , RelationalPatternOperator (..)
+    , MatchArm (..)
+    , MatchPattern (..)
+    , matchArmExpressions
+    , matchPatternSpan
+    , matchPatternAnnotation
+    , matchPatternBinding
+    , statementSourceSpan
+    , expressionSourceSpan
+    , traverseMatchArm
+    , traverseMatchPattern
+    , CallableBody (..)
+    , Capture (..)
+    , CaptureMode (..)
+    , Literal (..)
+    , UnaryOperator (..)
+    , BinaryOperator (..)
+    , TypeSyntax (..)
+    , TemplateValueSyntax (..)
+    , TemplateArgumentSyntax (..)
+    , BindingKind (..)
+    , Access (..)
+    , ParsedAST (..)
+    , RenamedName (..)
+    , RenamedAST (..)
+    , SymbolId (..)
+    , ResolvedName (..)
+    , ResolvedAST (..)
+    , Type (..)
+    , TemplateArgument (..)
+    , TemplateValue (..)
+    , TypedAST (..)
+    , boolType
+    , intType
+    , unitType
+    , voidType
+    , stringType
+    , namedType
+    ) where
+
+-- | One source-language identifier in its original spelling.
+newtype Identifier = Identifier {identifierText :: String}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Name split into ordered namespace, type, and member segments.
+newtype QualifiedName = QualifiedName {qualifiedNameParts :: [Identifier]}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Zero-based source coordinate used by parser and diagnostic spans.
+data SourcePosition = SourcePosition {sourceLine :: Int, sourceColumn :: Int}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Half-open source range with the identity of the source file.
+data SourceSpan = SourceSpan
+    {sourceFile :: FilePath, sourceStart :: SourcePosition, sourceEnd :: SourcePosition}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Type expression written in source before semantic resolution.
+data TypeSyntax
+    = ExplicitType Identifier
+    | QualifiedTypeSyntax QualifiedName [TemplateArgumentSyntax]
+    | BuiltinArrayTypeSyntax TypeSyntax
+    | ArrayTypeSyntax TypeSyntax
+    | FixedArrayTypeSyntax TypeSyntax TemplateValueSyntax
+    | DictionaryTypeSyntax TypeSyntax TypeSyntax
+    | CallableTypeSyntax [TypeSyntax] TypeSyntax
+    | AutoType
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Restricted compile-time expression used for generic value arguments.
+
+It deliberately excludes calls, closures, and other runtime expressions,
+preventing runtime behavior from leaking into a specialization identity.
+The type checker evaluates this tree before constructing Core.
+-}
+data TemplateValueSyntax
+    = TemplateIntegerSyntax SourceSpan Integer
+    | TemplateCharacterSyntax SourceSpan Integer
+    | TemplateBooleanSyntax SourceSpan Bool
+    | TemplateNameSyntax SourceSpan QualifiedName
+    | TemplateUnarySyntax SourceSpan UnaryOperator TemplateValueSyntax
+    | TemplateBinarySyntax SourceSpan BinaryOperator TemplateValueSyntax TemplateValueSyntax
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | One generic argument, preserving the distinction between types and values.
+data TemplateArgumentSyntax
+    = TemplateTypeSyntax TypeSyntax
+    | TemplateValueArgumentSyntax TemplateValueSyntax
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Source access modifier recorded on a declaration.
+data Access = DefaultAccess | PublicAccess | InternalAccess | ProtectedAccess | PrivateAccess
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Module-level namespace and ordered declarations, parameterized by phase.
+data SyntaxTree name annotation = SyntaxTree
+    { syntaxNamespace :: Maybe QualifiedName
+    , syntaxDeclarations :: [Declaration name annotation]
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Type, function, or generic type declaration in a syntax tree.
+data Declaration name annotation
+    = TypeDeclaration
+        { declarationSpan :: SourceSpan
+        , declarationName :: name
+        , declarationAnnotation :: annotation
+        , typeMembers :: [Declaration name annotation]
+        }
+    | FunctionDeclaration
+        { declarationSpan :: SourceSpan
+        , declarationName :: name
+        , declarationAnnotation :: annotation
+        , declarationReturnSyntax :: TypeSyntax
+        , declarationParameters :: [Parameter name annotation]
+        , declarationBody :: Block name annotation
+        , declarationIsStatic :: Bool
+        , declarationAccess :: Access
+        }
+    | TemplateTypeDeclaration
+        { declarationSpan :: SourceSpan
+        , declarationName :: name
+        , declarationAnnotation :: annotation
+        , declarationTemplateParameters :: [TemplateParameter name annotation]
+        , typeMembers :: [Declaration name annotation]
+        }
+    | {- | A classic enum: a value type whose members are named integers.
+      The underlying type is absent when the source does not write one.
+      -}
+      EnumDeclaration
+        { declarationSpan :: SourceSpan
+        , declarationName :: name
+        , declarationAnnotation :: annotation
+        , enumUnderlying :: Maybe TypeSyntax
+        , enumCases :: [EnumCase]
+        }
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | A member of a classic enum. A member without a written value takes the
+value after that of the member before it, and the first takes zero.
+
+A written value is a constant integer expression. It is kept as it was
+parsed in every stage: the names in it are earlier members of the same enum,
+which are named by their spelling and take no symbols, so no later stage has
+anything to add to it. The type checker computes its value.
+-}
+data EnumCase = EnumCase
+    { enumCaseSpan :: SourceSpan
+    , enumCaseName :: Identifier
+    , enumCaseValue :: Maybe (Expression Identifier ())
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Generic parameter with its declaration span, category, pack flag, and default.
+
+Template metadata remains present through @TypedAST@: renaming assigns each
+parameter a stable symbol and type checking uses it in bodies and array sizes.
+-}
+data TemplateParameter name annotation = TemplateParameter
+    { templateParameterSpan :: SourceSpan
+    , templateParameterName :: name
+    , templateParameterAnnotation :: annotation
+    , templateParameterKind :: TemplateParameterKind
+    , templateParameterIsPack :: Bool
+    , templateParameterDefault :: Maybe TemplateDefault
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Generic parameter category: type, value, or nested template signature.
+data TemplateParameterKind
+    = TemplateTypeParameter
+    | TemplateValueParameterKind TypeSyntax
+    | TemplateTemplateParameter [TemplateParameterShape]
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Anonymous shape of one parameter in a template-template signature.
+
+These shapes describe accepted argument categories without introducing
+artificial names into lexical scope.
+-}
+data TemplateParameterShape = TemplateParameterShape
+    { templateParameterShapeKind :: TemplateParameterShapeKind
+    , templateParameterShapeIsPack :: Bool
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Accepted argument category at one level of a template-template signature.
+data TemplateParameterShapeKind
+    = TemplateTypeParameterShape
+    | TemplateValueParameterShape TypeSyntax
+    | TemplateTemplateParameterShape [TemplateParameterShape]
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Default argument attached to a generic parameter.
+data TemplateDefault
+    = TemplateTypeDefault TypeSyntax
+    | TemplateValueDefault TemplateValueSyntax
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Callable parameter with source location and unresolved type syntax.
+data Parameter name annotation = Parameter
+    { parameterSpan :: SourceSpan
+    , parameterName :: name
+    , parameterAnnotation :: annotation
+    , parameterTypeSyntax :: TypeSyntax
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Ordered statement sequence representing a lexical block.
+newtype Block name annotation = Block {blockStatements :: [Statement name annotation]}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Mutability requested for a local or foreach binding.
+data BindingKind = ImmutableBinding | MutableBinding
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Ownership behavior requested for a callable capture.
+
+'StrongCapture' is also the default for ordinary captures; type checking
+refines storage according to the captured value category.
+-}
+data CaptureMode = StrongCapture | WeakCapture | UnownedCapture
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Captured binding with an inner name and an optional outer-scope initializer.
+
+A shorthand capture initially has no initializer. The renamer materializes
+its outer-name read so later phases do not need to recover source spelling.
+-}
+data Capture name annotation = Capture
+    { captureSpan :: SourceSpan
+    , captureMode :: CaptureMode
+    , captureName :: name
+    , captureAnnotation :: annotation
+    , captureInitializer :: Maybe (Expression name annotation)
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Expression-bodied or block-bodied callable body.
+data CallableBody name annotation
+    = CallableExpressionBody (Expression name annotation)
+    | CallableBlockBody (Block name annotation)
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Statement forms supported by the parsed and typed Tala trees.
+data Statement name annotation
+    = BindingStatement SourceSpan BindingKind TypeSyntax name annotation (Expression name annotation)
+    | AssignmentStatement SourceSpan name annotation (Expression name annotation)
+    | ReturnStatement SourceSpan (Maybe (Expression name annotation))
+    | IfStatement SourceSpan (Expression name annotation) (Block name annotation) (Maybe (Block name annotation))
+    | WhileStatement SourceSpan (Expression name annotation) (Block name annotation)
+    | DoWhileStatement SourceSpan (Block name annotation) (Expression name annotation)
+    | ForStatement
+        SourceSpan
+        (Maybe (Statement name annotation))
+        (Maybe (Expression name annotation))
+        [Statement name annotation]
+        (Block name annotation)
+    | ForEachStatement
+        SourceSpan
+        BindingKind
+        TypeSyntax
+        name
+        annotation
+        (Expression name annotation)
+        (Block name annotation)
+    | -- @target++;@ or @++target;@. The language has no decrement operator.
+      IncrementStatement SourceSpan name annotation
+    | -- @target op= value@ reads and writes one named storage location. The
+      -- annotation is the target type; the operator is the binary operation
+      -- whose result is stored back.
+      CompoundAssignmentStatement SourceSpan BinaryOperator name annotation (Expression name annotation)
+    | -- @_ = value;@ evaluates its operand and drops the result. It is a
+      -- statement of its own, not an assignment to a binding named @_@.
+      DiscardStatement SourceSpan (Expression name annotation)
+    | BreakStatement SourceSpan (Maybe (Expression name annotation))
+    | ContinueStatement SourceSpan
+    | -- @guard (condition) else { ... }@ runs its block only when the
+      -- condition is false, and the block must leave the enclosing scope, so
+      -- the statements after the guard run only when the condition held.
+      GuardStatement SourceSpan (Expression name annotation) (Block name annotation)
+    | -- A block written as a statement of its own. Its statements run in
+      -- order, and the names it declares are in scope only inside it.
+      BlockStatement SourceSpan (Block name annotation)
+    | ExpressionStatement SourceSpan (Expression name annotation) Bool
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Expression forms retained from parsing through type checking.
+data Expression name annotation
+    = NameExpression SourceSpan name annotation
+    | LiteralExpression SourceSpan Literal annotation
+    | -- A member selector preserves the receiver and the member spelling until
+      -- the checker knows whether the receiver denotes a type or a value. The
+      -- current executable subset accepts only type-qualified method calls.
+      MemberAccessExpression SourceSpan (Expression name annotation) Identifier annotation
+    | -- @Receiver::Member@: the method of that name as a callable value,
+      -- without a call of it. The checker replaces a reference it can
+      -- resolve with the name of the method, so no later stage sees one.
+      MethodReferenceExpression SourceSpan (Expression name annotation) Identifier annotation
+    | CallExpression SourceSpan (Expression name annotation) [Expression name annotation] annotation
+    | UnaryExpression SourceSpan UnaryOperator (Expression name annotation) annotation
+    | BinaryExpression SourceSpan BinaryOperator (Expression name annotation) (Expression name annotation) annotation
+    | IsPatternExpression SourceSpan (Expression name annotation) (Pattern name annotation) annotation
+    | -- @condition ? first : second@ evaluates exactly one arm.
+      ConditionalExpression
+        SourceSpan
+        (Expression name annotation)
+        (Expression name annotation)
+        (Expression name annotation)
+        annotation
+    | -- @left ?: fallback@ yields the left value when it is true in Boolean
+      -- context and evaluates the fallback only otherwise. The left operand
+      -- is evaluated once.
+      CoalesceExpression SourceSpan (Expression name annotation) (Expression name annotation) annotation
+    | -- @target = value@ or @target op= value@ used for its value. The
+      -- operator is absent for simple assignment. The expression stores into
+      -- the named location and yields the stored value; its annotation is
+      -- the target type. The statement forms keep their own nodes, so this
+      -- node appears only where an assignment is an operand.
+      AssignmentExpression SourceSpan (Maybe BinaryOperator) name (Expression name annotation) annotation
+    | -- @++target@ and @target++@ used for their value. The flag selects the
+      -- prefix form, which yields the new value; the postfix form yields the
+      -- previous one.
+      IncrementExpression SourceSpan Bool name annotation
+    | -- A loop used for its value. The statement is the loop itself, a
+      -- 'WhileStatement' or a 'ForStatement'; the value is the operand of
+      -- the @break value;@ that leaves it, and the annotation is the type of
+      -- that value.
+      LoopExpression SourceSpan (Statement name annotation) annotation
+    | -- A block used for its value: the arms of an @if@ expression and the
+      -- block bodies of @match@ arms. Its statements run in order and its
+      -- value is the final expression, which has no terminating semicolon.
+      -- The parser creates this node only in those two places; a block is
+      -- not a primary expression of its own.
+      BlockExpression SourceSpan (Block name annotation) annotation
+    | -- @match (subject), ... { patterns -> body, ... }@. The subjects are
+      -- evaluated once, left to right, and the first arm whose patterns and
+      -- guard accept them supplies the result. The same node is the
+      -- statement form; there its annotation is @void@, the arms yield no
+      -- value, and no arm needs to accept.
+      MatchExpression SourceSpan [Expression name annotation] [MatchArm name annotation] annotation
+    | CallableExpression
+        SourceSpan
+        Bool
+        [Capture name annotation]
+        [Parameter name annotation]
+        (CallableBody name annotation)
+        annotation
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | One arm of a @match@: one pattern per subject, an optional guard, and
+the body that runs when all of them accept.
+
+A block body is a 'BlockExpression'; any other body is the expression written
+after the arrow.
+-}
+data MatchArm name annotation = MatchArm
+    { matchArmSpan :: SourceSpan
+    , matchArmPatterns :: [MatchPattern name annotation]
+    , matchArmGuard :: Maybe (Expression name annotation)
+    , matchArmBody :: Expression name annotation
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Pattern of a @match@ arm for one subject.
+
+These are the forms of the @match-pattern@ grammar rule. They are separate
+from 'Pattern', the patterns of an @is@ expression, which have relational and
+combined forms that a @match@ arm does not have, and no bindings.
+-}
+data MatchPattern name annotation
+    = -- | @_@ accepts every value.
+      MatchWildcardPattern SourceSpan annotation
+    | -- | A literal accepts the value equal to it.
+      MatchLiteralPattern SourceSpan Literal annotation
+    | -- | @null@ accepts the null reference.
+      MatchNullPattern SourceSpan annotation
+    | {- | @Type name@ or @Type _@ accepts a value of the type and, with a
+      name, binds it for the guard and the body.
+      -}
+      MatchTypePattern SourceSpan TypeSyntax (Maybe name) annotation
+    | -- | @.Case@ names an enum case of the subject's type.
+      MatchCasePattern SourceSpan Identifier annotation
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Source range of a statement.
+statementSourceSpan :: Statement name annotation -> SourceSpan
+statementSourceSpan statement = case statement of
+    BindingStatement value _ _ _ _ _ -> value
+    AssignmentStatement value _ _ _ -> value
+    ReturnStatement value _ -> value
+    IfStatement value _ _ _ -> value
+    WhileStatement value _ _ -> value
+    DoWhileStatement value _ _ -> value
+    ForStatement value _ _ _ _ -> value
+    ForEachStatement value _ _ _ _ _ _ -> value
+    IncrementStatement value _ _ -> value
+    CompoundAssignmentStatement value _ _ _ _ -> value
+    DiscardStatement value _ -> value
+    BreakStatement value _ -> value
+    ContinueStatement value -> value
+    GuardStatement value _ _ -> value
+    BlockStatement value _ -> value
+    ExpressionStatement value _ _ -> value
+
+-- | Source range of an expression.
+expressionSourceSpan :: Expression name annotation -> SourceSpan
+expressionSourceSpan expression = case expression of
+    NameExpression value _ _ -> value
+    LiteralExpression value _ _ -> value
+    MemberAccessExpression value _ _ _ -> value
+    MethodReferenceExpression value _ _ _ -> value
+    CallExpression value _ _ _ -> value
+    UnaryExpression value _ _ _ -> value
+    BinaryExpression value _ _ _ _ -> value
+    IsPatternExpression value _ _ _ -> value
+    ConditionalExpression value _ _ _ _ -> value
+    CoalesceExpression value _ _ _ -> value
+    AssignmentExpression value _ _ _ _ -> value
+    IncrementExpression value _ _ _ -> value
+    LoopExpression value _ _ -> value
+    BlockExpression value _ _ -> value
+    MatchExpression value _ _ _ -> value
+    CallableExpression value _ _ _ _ _ -> value
+
+-- | The guard, when present, and the body of an arm, in evaluation order.
+matchArmExpressions :: MatchArm name annotation -> [Expression name annotation]
+matchArmExpressions arm = maybe [] (: []) (matchArmGuard arm) ++ [matchArmBody arm]
+
+-- | Source range of a match pattern.
+matchPatternSpan :: MatchPattern name annotation -> SourceSpan
+matchPatternSpan patternValue = case patternValue of
+    MatchWildcardPattern value _ -> value
+    MatchLiteralPattern value _ _ -> value
+    MatchNullPattern value _ -> value
+    MatchTypePattern value _ _ _ -> value
+    MatchCasePattern value _ _ -> value
+
+-- | Annotation of a match pattern: after type checking, the subject type.
+matchPatternAnnotation :: MatchPattern name annotation -> annotation
+matchPatternAnnotation patternValue = case patternValue of
+    MatchWildcardPattern _ value -> value
+    MatchLiteralPattern _ _ value -> value
+    MatchNullPattern _ value -> value
+    MatchTypePattern _ _ _ value -> value
+    MatchCasePattern _ _ value -> value
+
+-- | The name a match pattern binds, when it binds one.
+matchPatternBinding :: MatchPattern name annotation -> Maybe name
+matchPatternBinding patternValue = case patternValue of
+    MatchTypePattern _ _ name _ -> name
+    _ -> Nothing
+
+{- | Rebuild an arm from rewrites of its patterns, its guard, and its body,
+applied in source order.
+-}
+traverseMatchArm ::
+    (Applicative effect) =>
+    (MatchPattern name annotation -> effect (MatchPattern name' annotation')) ->
+    (Expression name annotation -> effect (Expression name' annotation')) ->
+    MatchArm name annotation ->
+    effect (MatchArm name' annotation')
+traverseMatchArm onPattern onExpression (MatchArm spanValue patterns guard body) =
+    MatchArm spanValue <$> traverse onPattern patterns <*> traverse onExpression guard <*> onExpression body
+
+-- | Rebuild a pattern from rewrites of the name it binds and its annotation.
+traverseMatchPattern ::
+    (Applicative effect) =>
+    (name -> effect name') ->
+    (annotation -> effect annotation') ->
+    MatchPattern name annotation ->
+    effect (MatchPattern name' annotation')
+traverseMatchPattern onName onAnnotation patternValue = case patternValue of
+    MatchWildcardPattern spanValue annotation -> MatchWildcardPattern spanValue <$> onAnnotation annotation
+    MatchLiteralPattern spanValue literal annotation -> MatchLiteralPattern spanValue literal <$> onAnnotation annotation
+    MatchNullPattern spanValue annotation -> MatchNullPattern spanValue <$> onAnnotation annotation
+    MatchTypePattern spanValue syntax name annotation ->
+        MatchTypePattern spanValue syntax <$> traverse onName name <*> onAnnotation annotation
+    MatchCasePattern spanValue name annotation -> MatchCasePattern spanValue name <$> onAnnotation annotation
+
+{- | Pattern tested by an @is@ expression.
+
+Pattern @and@ and @or@ combine tests beneath @is@; they are distinct from
+both eager and short-circuit Boolean operators in 'Expression'.
+-}
+data Pattern name annotation
+    = WildcardPattern SourceSpan annotation
+    | NullPattern SourceSpan annotation
+    | LiteralPattern SourceSpan Literal annotation
+    | TypePattern SourceSpan TypeSyntax annotation
+    | RelationalPattern SourceSpan RelationalPatternOperator Literal annotation
+    | NotPattern SourceSpan (Pattern name annotation) annotation
+    | AndPattern SourceSpan (Pattern name annotation) (Pattern name annotation) annotation
+    | OrPattern SourceSpan (Pattern name annotation) (Pattern name annotation) annotation
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Relational comparison used by a relational pattern.
+data RelationalPatternOperator
+    = PatternLessThan
+    | PatternLessEqual
+    | PatternGreaterThan
+    | PatternGreaterEqual
+    | PatternEqual
+    | PatternNotEqual
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Literal token value after lexical escape decoding.
+data Literal
+    = IntegerLiteral Integer
+    | FloatingLiteral String
+    | CharacterLiteral Integer
+    | BooleanLiteral Bool
+    | StringLiteral String
+    | UnitLiteral
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Unary operator recognized by the parser.
+data UnaryOperator = UnaryPlus | UnaryNegate | LogicalNot | BitwiseNot
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Binary arithmetic, comparison, bitwise, or Boolean operator.
+data BinaryOperator
+    = Add
+    | Subtract
+    | Multiply
+    | Divide
+    | FloorDivide
+    | Remainder
+    | Power
+    | ShiftLeft
+    | ShiftRight
+    | BitwiseAnd
+    | BitwiseXor
+    | BitwiseOr
+    | LessThan
+    | LessEqual
+    | GreaterThan
+    | GreaterEqual
+    | Equal
+    | NotEqual
+    | LogicalAnd
+    | LogicalOr
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Parser output with source identifiers and no semantic annotations.
+newtype ParsedAST = ParsedAST {parsedSyntaxTree :: SyntaxTree Identifier ()}
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Renamed source name carrying its preserved spelling and unique identity.
+
+Zero is reserved as the native/wire no-symbol sentinel. The renamer assigns
+positive identities; negative identities exist only to diagnose unresolved
+names during resolution.
+-}
+data RenamedName = RenamedName {renamedSpelling :: Identifier, renamedUnique :: Int}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Syntax tree after declarations and references receive unique identities.
+newtype RenamedAST = RenamedAST {renamedSyntaxTree :: SyntaxTree RenamedName ()}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Stable numeric symbol identity used by resolved and native representations.
+newtype SymbolId = SymbolId {symbolIdValue :: Int}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Reference to a resolved declaration with its original spelling.
+data ResolvedName = ResolvedName {resolvedSymbol :: SymbolId, resolvedSpelling :: Identifier}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Syntax tree whose names point to declarations but are not yet type-checked.
+newtype ResolvedAST = ResolvedAST {resolvedSyntaxTree :: SyntaxTree ResolvedName ()}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Semantic type attached to resolved expressions and declarations.
+data Type
+    = NamedType QualifiedName [TemplateArgument]
+    | FunctionType [Type] Type
+    | TypeVariable ResolvedName
+    | ErrorType
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Evaluated generic argument, retaining its original position in the list.
+
+The ordered sum keeps @Example<int, 4, String>@ distinct from
+@Example<int, String, 4>@ when forming specialization identities.
+-}
+data TemplateArgument
+    = TypeTemplateArgument Type
+    | ValueTemplateArgument TemplateValue
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Canonical value accepted as a generic argument after type checking.
+
+Integers are unbounded so specialization does not depend on host word size.
+Parameter references remain representable for generic bodies.
+-}
+data TemplateValue
+    = IntegerTemplateValue Integer
+    | BooleanTemplateValue Bool
+    | CharacterTemplateValue Integer
+    | TemplateValueParameter ResolvedName
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Resolved syntax tree with semantic types attached to its annotations.
+newtype TypedAST = TypedAST {typedSyntaxTree :: SyntaxTree ResolvedName Type}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Construct a one-segment nominal type without generic arguments.
+namedType :: String -> Type
+namedType value = NamedType (QualifiedName [Identifier value]) []
+
+-- | Canonical built-in Boolean type.
+boolType :: Type
+boolType = namedType "bool"
+
+{- | The type of a classic enum.
+
+An enum is a type of its own, and it is a value of its underlying integer
+type with a closed set of values. Both facts are needed after type checking:
+the lowering stores an enum as its underlying type, and a @match@ over an
+enum is complete when its arms name every value. The type therefore carries
+them: its name under the reserved root @enum@, which no source can spell
+because @enum@ is a keyword, then the underlying type, then the distinct
+values of its members in ascending order.
+-}
+enumType :: Identifier -> Type -> [Integer] -> Type
+enumType name underlying values =
+    NamedType
+        (QualifiedName [Identifier "enum", name])
+        (TypeTemplateArgument underlying : map (ValueTemplateArgument . IntegerTemplateValue) values)
+
+-- | The underlying integer type of an enum type, and nothing for any other type.
+enumUnderlyingType :: Type -> Maybe Type
+enumUnderlyingType valueType = case valueType of
+    NamedType (QualifiedName [Identifier "enum", _]) (TypeTemplateArgument underlying : _) -> Just underlying
+    _ -> Nothing
+
+-- | The distinct member values of an enum type, and nothing for any other type.
+enumMemberValues :: Type -> Maybe [Integer]
+enumMemberValues valueType = case valueType of
+    NamedType (QualifiedName [Identifier "enum", _]) (TypeTemplateArgument _ : values) ->
+        Just [value | ValueTemplateArgument (IntegerTemplateValue value) <- values]
+    _ -> Nothing
+
+-- | Canonical built-in signed integer type used by the current frontend.
+intType :: Type
+intType = namedType "int"
+
+-- | Canonical unit type for expressions that produce no value.
+unitType :: Type
+unitType = namedType "unit"
+
+-- | Canonical void type for procedure declarations and return checking.
+voidType :: Type
+voidType = namedType "void"
+
+-- | Canonical standard string type.
+stringType :: Type
+stringType = namedType "String"
+ src/Progmasoft/Tala/CharacterLiteral.hs view
@@ -0,0 +1,137 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+-- | Decoder and u32 packer for Tala character literals.
+module Progmasoft.Tala.CharacterLiteral
+    ( CharacterLiteralError (..)
+    , scanCharacterLiteral
+    , parseCharacterLiteral
+    , renderCharacterLiteralError
+    ) where
+
+import Data.Char (digitToInt, isHexDigit, ord)
+
+-- | Failure reason while scanning or decoding a character literal.
+data CharacterLiteralError
+    = UnterminatedCharacterLiteral
+    | EmptyCharacterLiteral
+    | PhysicalLineBreakInCharacterLiteral
+    | UnsupportedCharacterEscape Char
+    | MissingHexadecimalEscapeDigits
+    | WrongUnicodeEscapeWidth Int
+    | InvalidUnicodeEscapeDigit Char
+    | InvalidUnicodeScalar Integer
+    | CharacterLiteralOverflow
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Scan through the closing quote while respecting escaped quotes. The
+opening quote is supplied as part of the input and retained in the token.
+-}
+scanCharacterLiteral :: String -> Either CharacterLiteralError (String, String)
+scanCharacterLiteral ('\'' : remaining) = go ['\''] False remaining
+    where
+        go _ _ [] = Left UnterminatedCharacterLiteral
+        go prefix escaped (character : rest)
+            | character == '\n' || character == '\r' = Left PhysicalLineBreakInCharacterLiteral
+            | character == '\'' && not escaped = Right (reverse ('\'' : prefix), rest)
+            | character == '\\' && not escaped = go ('\\' : prefix) True rest
+            | otherwise = go (character : prefix) False rest
+scanCharacterLiteral _ = Left UnterminatedCharacterLiteral
+
+{- | Decode a complete character token and pack its Unicode scalar values.
+The result uses the compiler's stable little-endian u32 character packing.
+-}
+parseCharacterLiteral :: String -> Either CharacterLiteralError Integer
+parseCharacterLiteral spelling = case spelling of
+    '\'' : content -> case reverse content of
+        '\'' : reversedBody -> do
+            values <- decodeCharacters (reverse reversedBody)
+            if null values
+                then Left EmptyCharacterLiteral
+                else pack values
+        _ -> Left UnterminatedCharacterLiteral
+    _ -> Left UnterminatedCharacterLiteral
+
+decodeCharacters :: String -> Either CharacterLiteralError [Integer]
+decodeCharacters [] = Right []
+decodeCharacters ('\\' : remaining) = do
+    (value, rest) <- decodeEscape remaining
+    (value :) <$> decodeCharacters rest
+decodeCharacters (character : remaining) =
+    (fromIntegral (ord character) :) <$> decodeCharacters remaining
+
+decodeEscape :: String -> Either CharacterLiteralError (Integer, String)
+decodeEscape [] = Left UnterminatedCharacterLiteral
+decodeEscape (escape : remaining) = case escape of
+    '\'' -> simple '\''
+    '"' -> simple '"'
+    '\\' -> simple '\\'
+    '0' -> simple '\0'
+    'a' -> simple '\a'
+    'b' -> simple '\b'
+    'e' -> Right (0x1b, remaining)
+    'f' -> simple '\f'
+    'n' -> simple '\n'
+    'r' -> simple '\r'
+    't' -> simple '\t'
+    'v' -> simple '\v'
+    'x' -> decodeVariableHex remaining
+    'u' -> decodeFixedHex 4 remaining
+    'U' -> decodeFixedHex 8 remaining
+    _ -> Left (UnsupportedCharacterEscape escape)
+    where
+        simple character = Right (fromIntegral (ord character), remaining)
+
+decodeVariableHex :: String -> Either CharacterLiteralError (Integer, String)
+decodeVariableHex input =
+    let (digits, remaining) = span isHexDigit input
+     in if null digits
+            then Left MissingHexadecimalEscapeDigits
+            else scalarValue digits remaining
+
+decodeFixedHex :: Int -> String -> Either CharacterLiteralError (Integer, String)
+decodeFixedHex width input =
+    let (digits, remaining) = splitAt width input
+     in if length digits /= width
+            then Left (WrongUnicodeEscapeWidth width)
+            else case filter (not . isHexDigit) digits of
+                invalid : _ -> Left (InvalidUnicodeEscapeDigit invalid)
+                [] -> scalarValue digits remaining
+
+scalarValue :: String -> String -> Either CharacterLiteralError (Integer, String)
+scalarValue digits remaining =
+    let value = foldl' (\total digit -> total * 16 + fromIntegral (digitToInt digit)) 0 digits
+     in if value > 0x10ffff || value >= 0xd800 && value <= 0xdfff
+            then Left (InvalidUnicodeScalar value)
+            else Right (value, remaining)
+
+pack :: [Integer] -> Either CharacterLiteralError Integer
+pack = foldl append (Right 0)
+    where
+        append result next = do
+            value <- result
+            let width = encodedByteWidth next
+                packed = value * (256 ^ width) + next
+            if packed > 0xffffffff
+                then Left CharacterLiteralOverflow
+                else Right packed
+
+encodedByteWidth :: Integer -> Integer
+encodedByteWidth value
+    | value <= 0xff = 1
+    | value <= 0xffff = 2
+    | value <= 0xffffff = 3
+    | otherwise = 4
+
+-- | Render a character-literal failure for a source diagnostic.
+renderCharacterLiteralError :: CharacterLiteralError -> String
+renderCharacterLiteralError issue = case issue of
+    UnterminatedCharacterLiteral -> "unterminated character literal"
+    EmptyCharacterLiteral -> "character literal must contain at least one value"
+    PhysicalLineBreakInCharacterLiteral -> "physical line break is not allowed in a character literal"
+    UnsupportedCharacterEscape escape -> "unsupported character escape \\" ++ [escape]
+    MissingHexadecimalEscapeDigits -> "hexadecimal character escape requires at least one digit"
+    WrongUnicodeEscapeWidth width -> "Unicode character escape requires exactly " ++ show width ++ " digits"
+    InvalidUnicodeEscapeDigit digit -> show digit ++ " is not a hexadecimal escape digit"
+    InvalidUnicodeScalar value -> show value ++ " is not a Unicode scalar value"
+    CharacterLiteralOverflow -> "packed character literal does not fit u32"
+ src/Progmasoft/Tala/Completion.hs view
@@ -0,0 +1,276 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | Whether control can reach the end of a statement or an expression.
+
+This is a fact about control flow and is kept apart from types. A block that
+ends with @return@, an @if@ expression whose two blocks both leave, and a
+call one of whose arguments is such an expression never produce a value, but
+no type says so: the question is answered from the typed tree by the
+functions of this module. The type checker uses them to decide what must
+have a value, and the desugarer uses the same functions to lower what never
+completes as statements alone, so that nothing is stored for a value that
+does not exist.
+
+The answers err on the side of completing: a call is assumed to return, and
+a condition is assumed to be able to take either value unless it is the
+literal @true@. Whether a @break@ or @continue@ has a loop to leave is a
+separate rule of the type checker.
+-}
+module Progmasoft.Tala.Completion
+    ( blockCannotComplete
+    , statementCannotComplete
+    , doesNotComplete
+    , neverCompletingOperand
+    , matchArmsAlwaysAccept
+    , acceptsEveryValue
+    , isUnguarded
+    , blockTransfers
+    , statementTransfers
+    , expressionTransfers
+    , isBreak
+    , isContinue
+    ) where
+
+import Progmasoft.Tala.AST
+
+{- | Whether control can never reach the end of a block.
+
+A statement cannot complete normally when it is a @return@, a @break@ or a
+@continue@; an @if@ with an @else@ whose two blocks both cannot; a nested
+block that cannot; a loop whose condition is the constant true, or absent in
+a @for@, and that no @break@ leaves; a statement @match@ some arm of which
+always accepts and all of whose arms cannot; or any statement an expression
+of which is always evaluated and never completes. A block cannot complete
+when one of its statements cannot, because the statements after that one are
+never reached.
+-}
+blockCannotComplete :: Block name Type -> Bool
+blockCannotComplete (Block statements) = any statementCannotComplete statements
+
+-- | Whether control can never reach the point after a statement.
+statementCannotComplete :: Statement name Type -> Bool
+statementCannotComplete statement = case statement of
+    ReturnStatement {} -> True
+    BreakStatement {} -> True
+    ContinueStatement {} -> True
+    BindingStatement _ _ _ _ _ value -> doesNotComplete value
+    AssignmentStatement _ _ _ value -> doesNotComplete value
+    CompoundAssignmentStatement _ _ _ _ value -> doesNotComplete value
+    DiscardStatement _ value -> doesNotComplete value
+    IfStatement _ condition whenTrue whenFalse ->
+        doesNotComplete condition
+            || maybe False (\block -> blockCannotComplete whenTrue && blockCannotComplete block) whenFalse
+    GuardStatement _ condition _ -> doesNotComplete condition
+    BlockStatement _ nested -> blockCannotComplete nested
+    -- A loop ends normally when a break leaves it, from its body or from
+    -- its condition. Without one it does not end when its condition is the
+    -- constant true or never completes; a do/while also when its body
+    -- cannot complete, because its body runs first.
+    WhileStatement _ condition body ->
+        not (leftByBreak (expressionTransfers condition ++ blockTransfers body))
+            && (doesNotComplete condition || isConstantTrue condition)
+    DoWhileStatement _ body condition ->
+        not (leftByBreak (expressionTransfers condition ++ blockTransfers body))
+            && (doesNotComplete condition || isConstantTrue condition)
+    ForStatement _ initializer condition updates body ->
+        maybe False statementCannotComplete initializer
+            || ( not
+                    ( leftByBreak
+                        ( maybe [] expressionTransfers condition
+                            ++ blockTransfers body
+                            ++ concatMap statementTransfers updates
+                        )
+                    )
+                    && maybe True (\test -> doesNotComplete test || isConstantTrue test) condition
+               )
+    ExpressionStatement _ value _ -> doesNotComplete value
+    _ -> False
+    where
+        isConstantTrue expression = case expression of
+            LiteralExpression _ (BooleanLiteral True) _ -> True
+            _ -> False
+        leftByBreak = any isBreak
+
+{- | Whether evaluating an expression never yields a value.
+
+That is so when a part of it that is always evaluated never does, or when it
+is a choice every branch of which leaves: an @if@ expression whose two
+blocks do not complete, a block used as a value that cannot complete or
+whose final expression does not, or a @match@ one arm of which always
+accepts and no arm of which completes. An operand that is evaluated only
+sometimes, such as the right operand of @&&@, decides nothing.
+-}
+doesNotComplete :: Expression name Type -> Bool
+doesNotComplete expression = case expression of
+    -- The final expression of a block is its last statement, so one walk
+    -- of the statements covers it; a second look at it here would double
+    -- the work at every level of nested blocks.
+    BlockExpression _ block _ -> blockCannotComplete block
+    ConditionalExpression _ condition first second _ ->
+        doesNotComplete condition || (doesNotComplete first && doesNotComplete second)
+    MatchExpression _ subjects arms _ ->
+        any doesNotComplete subjects
+            || ( not (null arms)
+                    && matchArmsAlwaysAccept (subjectTypesOf arms) arms
+                    && all (doesNotComplete . matchArmBody) arms
+               )
+    -- A loop used as an expression yields the value of the break that
+    -- leaves it; without such a break it never yields one.
+    LoopExpression _ loop _ -> statementCannotComplete loop
+    _ -> case neverCompletingOperand expression of
+        Just _ -> True
+        Nothing -> False
+    where
+        -- Every typed pattern carries the type of the value it accepts.
+        subjectTypesOf arms = case arms of
+            first : _ -> map matchPatternAnnotation (matchArmPatterns first)
+            [] -> []
+
+{- | The operands of an expression that are evaluated before its first
+operand that never completes, and that operand; nothing when every operand
+that is always evaluated completes. Choices between branches are not
+operands in this sense and are answered by 'doesNotComplete' itself.
+-}
+neverCompletingOperand :: Expression name Type -> Maybe ([Expression name Type], Expression name Type)
+neverCompletingOperand expression = case break doesNotComplete (alwaysEvaluated expression) of
+    (before, operand : _) -> Just (before, operand)
+    (_, []) -> Nothing
+    where
+        alwaysEvaluated value = case value of
+            MemberAccessExpression _ receiver _ _ -> [receiver]
+            MethodReferenceExpression _ receiver _ _ -> [receiver]
+            CallExpression _ callee arguments _ -> callee : arguments
+            UnaryExpression _ _ operand _ -> [operand]
+            BinaryExpression _ operator left right _
+                | operator `elem` [LogicalAnd, LogicalOr] -> [left]
+                | otherwise -> [left, right]
+            IsPatternExpression _ subject _ _ -> [subject]
+            CoalesceExpression _ left _ _ -> [left]
+            AssignmentExpression _ _ _ operand _ -> [operand]
+            MatchExpression _ subjects _ _ -> subjects
+            _ -> []
+
+-- | Whether a pattern accepts every value of its subject.
+acceptsEveryValue :: MatchPattern name annotation -> Bool
+acceptsEveryValue patternValue = case patternValue of
+    MatchWildcardPattern {} -> True
+    MatchTypePattern {} -> True
+    _ -> False
+
+{- | Whether some arm is certain to accept, whatever the subjects are.
+
+That is the case when an arm without a guard has only patterns that accept
+every value. It is also the case when every subject has a closed set of
+values, a @bool@ or an enum, and the arms without guards accept each
+combination of those values between them; the combinations are enumerated,
+up to 'maximumEnumeratedCombinations' of them. Nothing else is recognized: a
+guard may be false, and the literals of a wider type are never listed in
+full.
+-}
+matchArmsAlwaysAccept :: [Type] -> [MatchArm name annotation] -> Bool
+matchArmsAlwaysAccept subjectTypes arms = any catchAll unguarded || coversClosedValues
+    where
+        unguarded = filter isUnguarded arms
+        catchAll arm = all acceptsEveryValue (matchArmPatterns arm)
+        coversClosedValues = case traverse closedValues subjectTypes of
+            Just domains
+                | not (null domains)
+                , product (map length domains) <= maximumEnumeratedCombinations ->
+                    all (\values -> any (`acceptsLiterals` values) unguarded) (sequence domains)
+            _ -> False
+
+-- | The values of a type that has a closed set of them, as the literals a pattern names.
+closedValues :: Type -> Maybe [Literal]
+closedValues valueType
+    | valueType == boolType = Just [BooleanLiteral True, BooleanLiteral False]
+    | otherwise = map IntegerLiteral <$> enumMemberValues valueType
+
+{- | The most combinations of subject values that are enumerated to decide
+whether a match accepts every value. A match with more is complete only
+through a catch-all arm; the bound keeps the check linear in practice.
+-}
+maximumEnumeratedCombinations :: Int
+maximumEnumeratedCombinations = 256
+
+-- | Whether an arm's patterns accept the given values of its subjects.
+acceptsLiterals :: MatchArm name annotation -> [Literal] -> Bool
+acceptsLiterals arm values =
+    length (matchArmPatterns arm) == length values && and (zipWith accepts (matchArmPatterns arm) values)
+    where
+        accepts patternValue value = case patternValue of
+            MatchLiteralPattern _ literal _ -> literal == value
+            _ -> acceptsEveryValue patternValue
+
+-- | Whether an arm has no guard.
+isUnguarded :: MatchArm name annotation -> Bool
+isUnguarded arm = case matchArmGuard arm of
+    Nothing -> True
+    Just _ -> False
+
+{- | The @break@ and @continue@ statements in a block that target the loop
+the block belongs to.
+
+Loops nested in the block keep their own transfers, in their bodies and in
+their conditions and update clauses. A transfer in a block used as a value
+inside an expression targets the same loop as one in a statement, so
+expressions are searched as well; closures and loops used as expressions
+are not, because a transfer in them cannot reach this loop.
+-}
+blockTransfers :: Block name annotation -> [Statement name annotation]
+blockTransfers (Block statements) = concatMap statementTransfers statements
+
+-- | The transfers of one statement that target the loop around it.
+statementTransfers :: Statement name annotation -> [Statement name annotation]
+statementTransfers statement = case statement of
+    BreakStatement _ value -> statement : maybe [] expressionTransfers value
+    ContinueStatement {} -> [statement]
+    BindingStatement _ _ _ _ _ value -> expressionTransfers value
+    AssignmentStatement _ _ _ value -> expressionTransfers value
+    ReturnStatement _ value -> maybe [] expressionTransfers value
+    IfStatement _ condition whenTrue whenFalse ->
+        expressionTransfers condition ++ blockTransfers whenTrue ++ maybe [] blockTransfers whenFalse
+    WhileStatement {} -> []
+    DoWhileStatement {} -> []
+    ForStatement _ initializer _ _ _ -> maybe [] statementTransfers initializer
+    ForEachStatement _ _ _ _ _ source _ -> expressionTransfers source
+    IncrementStatement {} -> []
+    CompoundAssignmentStatement _ _ _ _ value -> expressionTransfers value
+    DiscardStatement _ value -> expressionTransfers value
+    GuardStatement _ condition block -> expressionTransfers condition ++ blockTransfers block
+    BlockStatement _ block -> blockTransfers block
+    ExpressionStatement _ value _ -> expressionTransfers value
+
+-- | The transfers in an expression that target the loop around it.
+expressionTransfers :: Expression name annotation -> [Statement name annotation]
+expressionTransfers expression = case expression of
+    NameExpression {} -> []
+    LiteralExpression {} -> []
+    MemberAccessExpression _ receiver _ _ -> expressionTransfers receiver
+    MethodReferenceExpression _ receiver _ _ -> expressionTransfers receiver
+    CallExpression _ callee arguments _ -> concatMap expressionTransfers (callee : arguments)
+    UnaryExpression _ _ value _ -> expressionTransfers value
+    BinaryExpression _ _ left right _ -> expressionTransfers left ++ expressionTransfers right
+    IsPatternExpression _ subject _ _ -> expressionTransfers subject
+    ConditionalExpression _ condition first second _ -> concatMap expressionTransfers [condition, first, second]
+    CoalesceExpression _ left fallback _ -> expressionTransfers left ++ expressionTransfers fallback
+    AssignmentExpression _ _ _ value _ -> expressionTransfers value
+    IncrementExpression {} -> []
+    LoopExpression {} -> []
+    BlockExpression _ block _ -> blockTransfers block
+    MatchExpression _ subjects arms _ ->
+        concatMap expressionTransfers subjects
+            ++ concatMap (\arm -> maybe [] expressionTransfers (matchArmGuard arm) ++ expressionTransfers (matchArmBody arm)) arms
+    CallableExpression {} -> []
+
+-- | Whether a statement is a @break@.
+isBreak :: Statement name annotation -> Bool
+isBreak statement = case statement of
+    BreakStatement {} -> True
+    _ -> False
+
+-- | Whether a statement is a @continue@.
+isContinue :: Statement name annotation -> Bool
+isContinue statement = case statement of
+    ContinueStatement {} -> True
+    _ -> False
+ src/Progmasoft/Tala/Diagnostic.hs view
@@ -0,0 +1,44 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+-- | Structured compiler diagnostics shared by frontend stages.
+module Progmasoft.Tala.Diagnostic
+    ( DiagnosticStage (..)
+    , DiagnosticSeverity (..)
+    , Diagnostic (..)
+    ) where
+
+import Progmasoft.Tala.AST (SourceSpan)
+
+-- | Pipeline stage that produced a diagnostic.
+data DiagnosticStage
+    = SourceLoaderStage
+    | LexerStage
+    | ParserStage
+    | RenamerStage
+    | NameResolutionStage
+    | TypeCheckerStage
+    | DesugarerStage
+    | CoreStage
+    | CoreOptimizerStage
+    | CorePrepStage
+    | XppLoweringStage
+    | XppOptimizerStage
+    | XmmLoweringStage
+    | XmmOptimizerStage
+    | LlvmBackendStage
+    deriving stock (Bounded, Enum, Eq, Ord, Read, Show)
+
+-- | User-facing severity currently emitted by the Haskell frontend.
+data DiagnosticSeverity = Error | Warning
+    deriving stock (Bounded, Enum, Eq, Ord, Read, Show)
+
+-- | Stable diagnostic code, message, source location, and severity.
+data Diagnostic = Diagnostic
+    { diagnosticStage :: DiagnosticStage
+    , diagnosticSeverity :: DiagnosticSeverity
+    , diagnosticCode :: String
+    , diagnosticSpan :: Maybe SourceSpan
+    , diagnosticMessage :: String
+    }
+    deriving stock (Eq, Ord, Read, Show)
+ src/Progmasoft/Tala/Diagnostic/Protocol.hs view
@@ -0,0 +1,534 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | Versioned, bounded binary representation of compiler diagnostics.
+
+The wire model is separate from the frontend's current Error/Warning model
+so native stages can also report informational messages and editor hints.
+-}
+module Progmasoft.Tala.Diagnostic.Protocol
+    ( DiagnosticDocument (..)
+    , DiagnosticRecord (..)
+    , ProtocolSeverity (..)
+    , DiagnosticArgument (..)
+    , DiagnosticLocation (..)
+    , DiagnosticRelatedLocation (..)
+    , DiagnosticFix (..)
+    , DiagnosticTextEdit (..)
+    , DiagnosticProtocolLimits (..)
+    , DiagnosticProtocolError (..)
+    , defaultDiagnosticProtocolLimits
+    , diagnosticDocument
+    , encodeDiagnosticDocument
+    , decodeDiagnosticDocument
+    ) where
+
+import Control.Monad (replicateM, unless, when)
+import Data.Bits (shiftL, (.|.))
+import Data.ByteString qualified as ByteString
+import Data.ByteString.Builder qualified as Builder
+import Data.ByteString.Lazy qualified as LazyByteString
+import Data.Char (isAsciiUpper, isDigit, ord)
+import Data.List (nub)
+import Data.Word (Word16, Word32, Word8)
+import Progmasoft.Tala.AST (SourcePosition (..), SourceSpan (..))
+import Progmasoft.Tala.Diagnostic
+
+protocolVersion :: Word16
+protocolVersion = 1
+
+magic :: [Word8]
+magic = map (fromIntegral . ord) "TADG"
+
+-- | Named text value used to format a diagnostic message.
+data DiagnosticArgument = DiagnosticArgument
+    { argumentName :: String
+    , argumentValue :: String
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Wire-level severity shared with native compiler and editor clients.
+data ProtocolSeverity
+    = ProtocolError
+    | ProtocolWarning
+    | ProtocolInformation
+    | ProtocolHint
+    deriving stock (Bounded, Enum, Eq, Ord, Read, Show)
+
+{- | Source path and zero-based scalar coordinates for one diagnostic span.
+
+Columns count Unicode scalar values; clients must not reinterpret them as
+byte offsets or UTF-16 code-unit positions.
+-}
+data DiagnosticLocation = DiagnosticLocation
+    { locationSource :: FilePath
+    , locationStartLine :: Word32
+    , locationStartColumn :: Word32
+    , locationEndLine :: Word32
+    , locationEndColumn :: Word32
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Secondary location that explains a diagnostic's context.
+data DiagnosticRelatedLocation = DiagnosticRelatedLocation
+    { relatedLocation :: DiagnosticLocation
+    , relatedMessage :: String
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Replacement text for one source range in a code action.
+data DiagnosticTextEdit = DiagnosticTextEdit
+    { editLocation :: DiagnosticLocation
+    , editReplacement :: String
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Atomic editor action containing one or more source edits.
+data DiagnosticFix = DiagnosticFix
+    { fixTitle :: String
+    , fixEdits :: [DiagnosticTextEdit]
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | One versioned diagnostic, including related spans and suggested fixes.
+data DiagnosticRecord = DiagnosticRecord
+    { recordStage :: DiagnosticStage
+    , recordSeverity :: ProtocolSeverity
+    , recordCode :: String
+    , recordMessage :: String
+    , recordArguments :: [DiagnosticArgument]
+    , recordPrimary :: Maybe DiagnosticLocation
+    , recordRelated :: [DiagnosticRelatedLocation]
+    , recordFixes :: [DiagnosticFix]
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Ordered diagnostic records returned for a compiler operation.
+newtype DiagnosticDocument = DiagnosticDocument
+    { documentRecords :: [DiagnosticRecord]
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Resource bounds enforced when encoding and decoding untrusted documents.
+data DiagnosticProtocolLimits = DiagnosticProtocolLimits
+    { maximumWireBytes :: Int
+    , maximumRecords :: Int
+    , maximumTextScalars :: Int
+    , maximumArguments :: Int
+    , maximumRelatedLocations :: Int
+    , maximumFixes :: Int
+    , maximumEditsPerFix :: Int
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Conservative default allocation and complexity limits for the protocol.
+defaultDiagnosticProtocolLimits :: DiagnosticProtocolLimits
+defaultDiagnosticProtocolLimits =
+    DiagnosticProtocolLimits
+        { maximumWireBytes = 16 * 1024 * 1024
+        , maximumRecords = 65535
+        , maximumTextScalars = 1024 * 1024
+        , maximumArguments = 256
+        , maximumRelatedLocations = 256
+        , maximumFixes = 128
+        , maximumEditsPerFix = 4096
+        }
+
+-- | Error offset, field path, and explanation for a protocol failure.
+data DiagnosticProtocolError = DiagnosticProtocolError
+    { protocolErrorOffset :: Int
+    , protocolErrorContext :: String
+    , protocolErrorMessage :: String
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Convert compiler diagnostics to the richer wire-level document model.
+diagnosticDocument :: [Diagnostic] -> Either DiagnosticProtocolError DiagnosticDocument
+diagnosticDocument diagnostics = DiagnosticDocument <$> traverse convert diagnostics
+    where
+        convert value = do
+            primary <- traverse sourceLocation (diagnosticSpan value)
+            pure
+                DiagnosticRecord
+                    { recordStage = diagnosticStage value
+                    , recordSeverity = protocolSeverity (diagnosticSeverity value)
+                    , recordCode = diagnosticCode value
+                    , recordMessage = diagnosticMessage value
+                    , recordArguments = []
+                    , recordPrimary = primary
+                    , recordRelated = []
+                    , recordFixes = []
+                    }
+
+sourceLocation :: SourceSpan -> Either DiagnosticProtocolError DiagnosticLocation
+sourceLocation spanValue = do
+    startLine <- sourceCoordinate "source start line" (sourceLine (sourceStart spanValue))
+    startColumn <- sourceCoordinate "source start column" (sourceColumn (sourceStart spanValue))
+    endLine <- sourceCoordinate "source end line" (sourceLine (sourceEnd spanValue))
+    endColumn <- sourceCoordinate "source end column" (sourceColumn (sourceEnd spanValue))
+    pure
+        DiagnosticLocation
+            { locationSource = sourceFile spanValue
+            , locationStartLine = startLine
+            , locationStartColumn = startColumn
+            , locationEndLine = endLine
+            , locationEndColumn = endColumn
+            }
+    where
+        sourceCoordinate context value
+            | value <= 0 = Left (modelError context "compiler source positions must be one-based")
+            | toInteger value - 1 > toInteger (maxBound :: Word32) =
+                Left (modelError context "compiler source position exceeds the protocol range")
+            | otherwise = Right (fromIntegral (value - 1))
+
+-- | Validate and encode a diagnostic document using the supplied limits.
+encodeDiagnosticDocument ::
+    DiagnosticProtocolLimits ->
+    DiagnosticDocument ->
+    Either DiagnosticProtocolError ByteString.ByteString
+encodeDiagnosticDocument limits document = do
+    validateDocument limits document
+    encodedRecords <- traverse (encodeRecord limits) (documentRecords document)
+    let payload =
+            mconcat (map Builder.word8 magic)
+                <> Builder.word16LE protocolVersion
+                <> Builder.word16LE 0
+                <> countBuilder (length (documentRecords document))
+                <> mconcat encodedRecords
+        bytes = LazyByteString.toStrict (Builder.toLazyByteString payload)
+    if ByteString.length bytes > maximumWireBytes limits
+        then Left (limitError "wire byte length" "diagnostic document exceeds configured byte limit")
+        else Right bytes
+
+encodeRecord :: DiagnosticProtocolLimits -> DiagnosticRecord -> Either DiagnosticProtocolError Builder.Builder
+encodeRecord limits record = do
+    arguments <- traverse encodeArgument (recordArguments record)
+    primary <- traverse encodeLocation (recordPrimary record)
+    related <- traverse encodeRelated (recordRelated record)
+    fixes <- traverse (encodeFix limits) (recordFixes record)
+    pure
+        ( Builder.word8 (stageTag (recordStage record))
+            <> Builder.word8 (severityTag (recordSeverity record))
+            <> textBuilder (recordCode record)
+            <> textBuilder (recordMessage record)
+            <> countBuilder (length arguments)
+            <> mconcat arguments
+            <> Builder.word8 (maybe 0 (const 1) primary)
+            <> maybe mempty id primary
+            <> countBuilder (length related)
+            <> mconcat related
+            <> countBuilder (length fixes)
+            <> mconcat fixes
+        )
+
+encodeArgument :: DiagnosticArgument -> Either DiagnosticProtocolError Builder.Builder
+encodeArgument argument =
+    pure (textBuilder (argumentName argument) <> textBuilder (argumentValue argument))
+
+encodeLocation :: DiagnosticLocation -> Either DiagnosticProtocolError Builder.Builder
+encodeLocation location =
+    pure
+        ( textBuilder (locationSource location)
+            <> Builder.word32LE (locationStartLine location)
+            <> Builder.word32LE (locationStartColumn location)
+            <> Builder.word32LE (locationEndLine location)
+            <> Builder.word32LE (locationEndColumn location)
+        )
+
+encodeRelated :: DiagnosticRelatedLocation -> Either DiagnosticProtocolError Builder.Builder
+encodeRelated related = do
+    location <- encodeLocation (relatedLocation related)
+    pure (location <> textBuilder (relatedMessage related))
+
+encodeFix :: DiagnosticProtocolLimits -> DiagnosticFix -> Either DiagnosticProtocolError Builder.Builder
+encodeFix _ fix = do
+    edits <- traverse encodeEdit (fixEdits fix)
+    pure (textBuilder (fixTitle fix) <> countBuilder (length edits) <> mconcat edits)
+
+encodeEdit :: DiagnosticTextEdit -> Either DiagnosticProtocolError Builder.Builder
+encodeEdit edit = do
+    location <- encodeLocation (editLocation edit)
+    pure (location <> textBuilder (editReplacement edit))
+
+textBuilder :: String -> Builder.Builder
+textBuilder value =
+    countBuilder (length value)
+        <> mconcat [Builder.word32LE (fromIntegral (ord character)) | character <- value]
+
+countBuilder :: Int -> Builder.Builder
+countBuilder = Builder.word32LE . fromIntegral
+
+stageTag :: DiagnosticStage -> Word8
+stageTag = fromIntegral . fromEnum
+
+severityTag :: ProtocolSeverity -> Word8
+severityTag = fromIntegral . fromEnum
+
+protocolSeverity :: DiagnosticSeverity -> ProtocolSeverity
+protocolSeverity severity = case severity of
+    Error -> ProtocolError
+    Warning -> ProtocolWarning
+
+validateDocument :: DiagnosticProtocolLimits -> DiagnosticDocument -> Either DiagnosticProtocolError ()
+validateDocument limits document = do
+    validateCount limits "diagnostic count" (maximumRecords limits) (length (documentRecords document))
+    mapM_ (validateRecord limits) (documentRecords document)
+
+validateRecord :: DiagnosticProtocolLimits -> DiagnosticRecord -> Either DiagnosticProtocolError ()
+validateRecord limits record = do
+    unless
+        (validCode (recordCode record))
+        (Left (modelError "diagnostic code" "diagnostic code must be 1-64 ASCII uppercase, digit, or hyphen scalars"))
+    when
+        (null (recordMessage record))
+        (Left (modelError "diagnostic message" "diagnostic message is empty"))
+    validateText limits "diagnostic code" (recordCode record)
+    validateText limits "diagnostic message" (recordMessage record)
+    validateCount limits "diagnostic argument count" (maximumArguments limits) (length (recordArguments record))
+    let names = map argumentName (recordArguments record)
+    when
+        (any null names)
+        (Left (modelError "diagnostic argument name" "diagnostic argument name is empty"))
+    when
+        (length names /= length (nub names))
+        (Left (modelError "diagnostic argument name" "diagnostic argument names must be unique within one record"))
+    mapM_ validateArgument (recordArguments record)
+    mapM_ (validateLocation limits "primary location") (recordPrimary record)
+    validateCount limits "related location count" (maximumRelatedLocations limits) (length (recordRelated record))
+    mapM_ validateRelated (recordRelated record)
+    validateCount limits "diagnostic fix count" (maximumFixes limits) (length (recordFixes record))
+    mapM_ validateFix (recordFixes record)
+    where
+        validateArgument argument = do
+            validateText limits "diagnostic argument name" (argumentName argument)
+            validateText limits "diagnostic argument value" (argumentValue argument)
+        validateRelated related = do
+            validateLocation limits "related location" (relatedLocation related)
+            when
+                (null (relatedMessage related))
+                (Left (modelError "related location message" "related location message is empty"))
+            validateText limits "related location message" (relatedMessage related)
+        validateFix fix = do
+            when
+                (null (fixTitle fix))
+                (Left (modelError "diagnostic fix title" "diagnostic fix title is empty"))
+            when
+                (null (fixEdits fix))
+                (Left (modelError "diagnostic fix edits" "diagnostic fix must contain at least one edit"))
+            validateText limits "diagnostic fix title" (fixTitle fix)
+            validateCount limits "diagnostic edit count" (maximumEditsPerFix limits) (length (fixEdits fix))
+            mapM_ validateEdit (fixEdits fix)
+        validateEdit edit = do
+            validateLocation limits "diagnostic edit" (editLocation edit)
+            validateText limits "diagnostic edit replacement" (editReplacement edit)
+
+validateLocation :: DiagnosticProtocolLimits -> String -> DiagnosticLocation -> Either DiagnosticProtocolError ()
+validateLocation limits context location = do
+    when
+        (null (locationSource location))
+        (Left (modelError context "diagnostic source identity is empty"))
+    validateText limits "diagnostic source" (locationSource location)
+    unless
+        ( positionPrecedes
+            (locationStartLine location, locationStartColumn location)
+            (locationEndLine location, locationEndColumn location)
+        )
+        (Left (modelError context "diagnostic range end precedes its start"))
+
+positionPrecedes :: (Word32, Word32) -> (Word32, Word32) -> Bool
+positionPrecedes (leftLine, leftColumn) (rightLine, rightColumn) =
+    leftLine < rightLine || (leftLine == rightLine && leftColumn <= rightColumn)
+
+validateText :: DiagnosticProtocolLimits -> String -> String -> Either DiagnosticProtocolError ()
+validateText limits context value = do
+    validateCount limits context (maximumTextScalars limits) (length value)
+    unless
+        (all isUnicodeScalar value)
+        (Left (modelError context "text contains a non-scalar Unicode value"))
+
+validateCount :: DiagnosticProtocolLimits -> String -> Int -> Int -> Either DiagnosticProtocolError ()
+validateCount _ context maximumCount value
+    | value < 0 || value > maximumCount || toInteger value > toInteger (maxBound :: Word32) =
+        Left (limitError context "collection count exceeds configured limit")
+    | otherwise = Right ()
+
+validCode :: String -> Bool
+validCode code =
+    not (null code)
+        && length code <= 64
+        && all (\character -> isAsciiUpper character || isDigit character || character == '-') code
+
+isUnicodeScalar :: Char -> Bool
+isUnicodeScalar character =
+    let value = ord character
+     in value <= 0x10ffff && not (value >= 0xd800 && value <= 0xdfff)
+
+modelError :: String -> String -> DiagnosticProtocolError
+modelError = DiagnosticProtocolError 0
+
+limitError :: String -> String -> DiagnosticProtocolError
+limitError = DiagnosticProtocolError 0
+
+data DecodeState = DecodeState
+    { decodeBytes :: ByteString.ByteString
+    , decodeOffset :: Int
+    , decodeLimits :: DiagnosticProtocolLimits
+    }
+
+newtype Decoder value = Decoder
+    { runDecoder :: DecodeState -> Either DiagnosticProtocolError (value, DecodeState)
+    }
+
+instance Functor Decoder where
+    fmap transform parser = Decoder $ \state -> do
+        (value, next) <- runDecoder parser state
+        pure (transform value, next)
+
+instance Applicative Decoder where
+    pure value = Decoder (Right . (value,))
+    function <*> argument = Decoder $ \state -> do
+        (transform, afterFunction) <- runDecoder function state
+        (value, afterArgument) <- runDecoder argument afterFunction
+        pure (transform value, afterArgument)
+
+instance Monad Decoder where
+    parser >>= continuation = Decoder $ \state -> do
+        (value, next) <- runDecoder parser state
+        runDecoder (continuation value) next
+
+-- | Decode one complete document, rejecting unsupported versions and trailing bytes.
+decodeDiagnosticDocument ::
+    DiagnosticProtocolLimits ->
+    ByteString.ByteString ->
+    Either DiagnosticProtocolError DiagnosticDocument
+decodeDiagnosticDocument limits bytes
+    | ByteString.length bytes > maximumWireBytes limits =
+        Left (limitError "wire byte length" "diagnostic document exceeds configured byte limit")
+    | otherwise = do
+        (document, state) <- runDecoder decodeDocument (DecodeState bytes 0 limits)
+        unless
+            (decodeOffset state == ByteString.length bytes)
+            (Left (DiagnosticProtocolError (decodeOffset state) "document" "bytes remain after diagnostic document"))
+        validateDocument limits document
+        pure document
+
+decodeDocument :: Decoder DiagnosticDocument
+decodeDocument = do
+    observedMagic <- replicateM 4 (byte "magic")
+    unlessDecoder (observedMagic == magic) "magic" "input is not a Tala diagnostic document"
+    version <- word16 "version"
+    unlessDecoder (version == protocolVersion) "version" "unsupported diagnostic protocol version"
+    flags <- word16 "flags"
+    unlessDecoder (flags == 0) "flags" "reserved diagnostic flags must be zero"
+    count <- boundedCount "diagnostic count" maximumRecords
+    DiagnosticDocument <$> replicateM count decodeRecord
+
+decodeRecord :: Decoder DiagnosticRecord
+decodeRecord = do
+    stage <- decodeStage
+    severity <- decodeSeverity
+    code <- text "diagnostic code"
+    message <- text "diagnostic message"
+    argumentCount <- boundedCount "diagnostic argument count" maximumArguments
+    arguments <-
+        replicateM argumentCount (DiagnosticArgument <$> text "diagnostic argument name" <*> text "diagnostic argument value")
+    hasPrimary <- boolean "primary location presence"
+    primary <- if hasPrimary then Just <$> decodeLocation else pure Nothing
+    relatedCount <- boundedCount "related location count" maximumRelatedLocations
+    related <- replicateM relatedCount (DiagnosticRelatedLocation <$> decodeLocation <*> text "related location message")
+    fixCount <- boundedCount "diagnostic fix count" maximumFixes
+    fixes <- replicateM fixCount decodeFix
+    pure (DiagnosticRecord stage severity code message arguments primary related fixes)
+
+decodeLocation :: Decoder DiagnosticLocation
+decodeLocation =
+    DiagnosticLocation
+        <$> text "diagnostic source"
+        <*> word32 "diagnostic start line"
+        <*> word32 "diagnostic start column"
+        <*> word32 "diagnostic end line"
+        <*> word32 "diagnostic end column"
+
+decodeFix :: Decoder DiagnosticFix
+decodeFix = do
+    title <- text "diagnostic fix title"
+    editCount <- boundedCount "diagnostic edit count" maximumEditsPerFix
+    edits <- replicateM editCount (DiagnosticTextEdit <$> decodeLocation <*> text "diagnostic edit replacement")
+    pure (DiagnosticFix title edits)
+
+decodeStage :: Decoder DiagnosticStage
+decodeStage = do
+    tag <- byte "diagnostic stage"
+    if fromIntegral tag <= fromEnum (maxBound :: DiagnosticStage)
+        then pure (toEnum (fromIntegral tag))
+        else failDecoder "diagnostic stage" "unknown diagnostic stage tag"
+
+decodeSeverity :: Decoder ProtocolSeverity
+decodeSeverity = do
+    tag <- byte "diagnostic severity"
+    if fromIntegral tag <= fromEnum (maxBound :: ProtocolSeverity)
+        then pure (toEnum (fromIntegral tag))
+        else failDecoder "diagnostic severity" "unknown diagnostic severity tag"
+
+text :: String -> Decoder String
+text context = do
+    count <- boundedCount context maximumTextScalars
+    values <- replicateM count (word32 context)
+    if all validScalar values
+        then pure (map (toEnum . fromIntegral) values)
+        else failDecoder context "text contains a non-scalar Unicode value"
+    where
+        validScalar value = value <= 0x10ffff && not (value >= 0xd800 && value <= 0xdfff)
+
+boundedCount :: String -> (DiagnosticProtocolLimits -> Int) -> Decoder Int
+boundedCount context selector = do
+    value <- word32 context
+    limits <- Decoder (\state -> Right (decodeLimits state, state))
+    if toInteger value <= toInteger (selector limits)
+        then pure (fromIntegral value)
+        else failDecoder context "collection count exceeds configured limit"
+
+boolean :: String -> Decoder Bool
+boolean context = do
+    value <- byte context
+    case value of
+        0 -> pure False
+        1 -> pure True
+        _ -> failDecoder context "boolean byte must be zero or one"
+
+byte :: String -> Decoder Word8
+byte context = Decoder $ \state ->
+    if decodeOffset state >= ByteString.length (decodeBytes state)
+        then Left (DiagnosticProtocolError (decodeOffset state) context "input ended before field was complete")
+        else
+            Right
+                ( ByteString.index (decodeBytes state) (decodeOffset state)
+                , state {decodeOffset = decodeOffset state + 1}
+                )
+
+word16 :: String -> Decoder Word16
+word16 context = do
+    low <- byte context
+    high <- byte context
+    pure (fromIntegral low .|. (fromIntegral high `shiftL` 8))
+
+word32 :: String -> Decoder Word32
+word32 context = do
+    a <- byte context
+    b <- byte context
+    c <- byte context
+    d <- byte context
+    pure
+        ( fromIntegral a
+            .|. (fromIntegral b `shiftL` 8)
+            .|. (fromIntegral c `shiftL` 16)
+            .|. (fromIntegral d `shiftL` 24)
+        )
+
+unlessDecoder :: Bool -> String -> String -> Decoder ()
+unlessDecoder condition context message =
+    if condition then pure () else failDecoder context message
+
+failDecoder :: String -> String -> Decoder value
+failDecoder context message = Decoder $ \state ->
+    Left (DiagnosticProtocolError (decodeOffset state) context message)
+ src/Progmasoft/Tala/FloatingLiteral.hs view
@@ -0,0 +1,134 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+-- | Source-accurate floating-point literal scanning and validation.
+module Progmasoft.Tala.FloatingLiteral
+    ( FloatingLiteralError (..)
+    , scanNumericCandidate
+    , isFloatingCandidate
+    , normalizeFloatingSpelling
+    , validateFloatingSpelling
+    , renderFloatingLiteralError
+    ) where
+
+import Data.Char (isAlphaNum, isDigit)
+
+-- | Reason that a floating-point spelling is not valid in Tala.
+data FloatingLiteralError
+    = MissingFractionDigits
+    | MissingExponentDigits
+    | SeparatorOutsideDigitPair
+    | SeparatorInsideExponent
+    | MultipleDecimalPoints
+    | MultipleExponents
+    | InvalidFloatingCharacter Char
+    | FloatingSuffixNotSupported
+    deriving stock (Eq, Ord, Read, Show)
+
+-- The sign is consumed only immediately after e/E. This prevents 1+2 from
+-- becoming one malformed literal while retaining 2E-4 as one token.
+
+{- | Consume one source-shaped numeric candidate and return the remaining text.
+A sign belongs to the token only immediately after an exponent marker.
+-}
+scanNumericCandidate :: String -> (String, String)
+scanNumericCandidate = go [] False
+    where
+        go output _ [] = (reverse output, [])
+        go output afterExponent (character : remaining)
+            | isAlphaNum character || character `elem` ['\'', '_', '.'] =
+                go (character : output) (character `elem` ['e', 'E']) remaining
+            | afterExponent && character `elem` ['+', '-'] = go (character : output) False remaining
+            | otherwise = (reverse output, character : remaining)
+
+{- | Decide whether a numeric candidate has a decimal or exponent marker.
+Radix-prefixed integer candidates are deliberately excluded.
+-}
+isFloatingCandidate :: String -> Bool
+isFloatingCandidate spelling
+    | take 2 spelling `elem` ["0x", "0X", "0b", "0B", "0o", "0O"] = False
+    | otherwise = any (`elem` spelling) ['.', 'e', 'E']
+
+-- | Remove digit separators without otherwise changing the spelling.
+normalizeFloatingSpelling :: String -> String
+normalizeFloatingSpelling = filter (/= '\'')
+
+-- | Validate a decimal floating spelling and return its normalized form.
+validateFloatingSpelling :: String -> Either FloatingLiteralError String
+validateFloatingSpelling spelling = do
+    validateCharacters spelling
+    validateCounts spelling
+    validateSeparators spelling
+    validateStructure spelling
+    pure (normalizeFloatingSpelling spelling)
+
+validateCharacters :: String -> Either FloatingLiteralError ()
+validateCharacters = go
+    where
+        go [] = Right ()
+        go (character : remaining)
+            | isDigit character || character `elem` ['\'', '.', 'e', 'E', '+', '-'] = go remaining
+            | isAlphaNum character || character == '_' = Left FloatingSuffixNotSupported
+            | otherwise = Left (InvalidFloatingCharacter character)
+
+validateCounts :: String -> Either FloatingLiteralError ()
+validateCounts spelling
+    | count '.' spelling > 1 = Left MultipleDecimalPoints
+    | count 'e' lower > 1 = Left MultipleExponents
+    | otherwise = Right ()
+    where
+        lower = map lowercaseExponent spelling
+        lowercaseExponent 'E' = 'e'
+        lowercaseExponent value = value
+
+validateSeparators :: String -> Either FloatingLiteralError ()
+validateSeparators spelling = go False spelling
+    where
+        go _ [] = Right ()
+        go _ ('e' : remaining) = go True remaining
+        go _ ('E' : remaining) = go True remaining
+        go True ('\'' : _) = Left SeparatorInsideExponent
+        go False (left : '\'' : right : remaining)
+            | isDigit left && isDigit right = go False (right : remaining)
+            | otherwise = Left SeparatorOutsideDigitPair
+        go _ ('\'' : _) = Left SeparatorOutsideDigitPair
+        go inExponent (_ : remaining) = go inExponent remaining
+
+validateStructure :: String -> Either FloatingLiteralError ()
+validateStructure spelling =
+    let normalized = normalizeFloatingSpelling spelling
+        (mantissa, exponentPart) = break (`elem` ['e', 'E']) normalized
+     in do
+            validateMantissa mantissa
+            validateExponent exponentPart
+
+validateMantissa :: String -> Either FloatingLiteralError ()
+validateMantissa mantissa = case break (== '.') mantissa of
+    (integerPart, [])
+        | null integerPart || any (not . isDigit) integerPart -> Left MissingFractionDigits
+        | otherwise -> Right ()
+    (integerPart, _ : fractionPart)
+        | null integerPart || any (not . isDigit) integerPart -> Left MissingFractionDigits
+        | null fractionPart || any (not . isDigit) fractionPart -> Left MissingFractionDigits
+        | otherwise -> Right ()
+
+validateExponent :: String -> Either FloatingLiteralError ()
+validateExponent [] = Right ()
+validateExponent (_ : remaining) =
+    let digits = case remaining of sign : rest | sign `elem` ['+', '-'] -> rest; _ -> remaining
+     in if null digits || any (not . isDigit) digits then Left MissingExponentDigits else Right ()
+
+-- | Render a floating-literal failure for a source diagnostic.
+renderFloatingLiteralError :: FloatingLiteralError -> String
+renderFloatingLiteralError issue = case issue of
+    MissingFractionDigits -> "floating-point literal requires digits on both sides of the decimal point"
+    MissingExponentDigits -> "floating-point exponent requires decimal digits"
+    SeparatorOutsideDigitPair -> "apostrophe must appear between two digits of the floating-point mantissa"
+    SeparatorInsideExponent -> "apostrophe is not allowed in a floating-point exponent"
+    MultipleDecimalPoints -> "floating-point literal contains more than one decimal point"
+    MultipleExponents -> "floating-point literal contains more than one exponent marker"
+    InvalidFloatingCharacter character -> show character ++ " is not valid in a floating-point literal"
+    FloatingSuffixNotSupported -> "floating-point literal suffixes are not supported"
+
+count :: (Eq a) => a -> [a] -> Int
+count value = length . filter (== value)
+ src/Progmasoft/Tala/Lexer.hs view
@@ -0,0 +1,343 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+-- | Configurable lexical scanner that returns source-positioned tokens.
+module Progmasoft.Tala.Lexer (LexerInput (..), Lexer (..), defaultLexer, runLexer) where
+
+import Data.Char (chr, digitToInt, isAlpha, isAlphaNum, isDigit, isHexDigit, isSpace)
+import Data.List (stripPrefix)
+import Progmasoft.Tala.AST (SourcePosition (..), SourceSpan (..))
+import Progmasoft.Tala.CharacterLiteral
+import Progmasoft.Tala.Diagnostic
+import Progmasoft.Tala.FloatingLiteral
+import Progmasoft.Tala.NumericLiteral
+import Progmasoft.Tala.Parser (Token (..), TokenKind (..))
+
+-- | Source identity and complete text supplied to a lexer implementation.
+data LexerInput = LexerInput {lexerSourceFile :: FilePath, lexerSourceText :: String}
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Lexer strategy whose failures are returned as structured diagnostics.
+newtype Lexer = Lexer {lexSource :: LexerInput -> Either [Diagnostic] [Token]}
+
+-- | Run a lexer over a source input.
+runLexer :: Lexer -> LexerInput -> Either [Diagnostic] [Token]
+runLexer = lexSource
+
+-- | Default Tala lexer implementation.
+defaultLexer :: Lexer
+defaultLexer = Lexer lexTala
+
+-- Long comments and raw strings deliberately share one delimiter recognizer. Keeping
+-- the level matching in one place prevents the two source forms from drifting apart.
+lexTala :: LexerInput -> Either [Diagnostic] [Token]
+lexTala (LexerInput file source) = go 1 1 source []
+    where
+        go line column [] output = Right (reverse (token EndOfFileToken "" line column line column : output))
+        go line column input@(character : rest) output
+            | Just remaining <- stripPrefix "--" input =
+                lexComment line column remaining output
+            | Just (level, openerWidth, remaining) <- longBracketOpener input =
+                lexRawString line column level openerWidth remaining output
+            | Just (nextLine, nextColumn, remaining) <- consumeLineBreak line column input =
+                go nextLine nextColumn remaining output
+            | isSpace character = go line (column + 1) rest output
+            | isAlpha character || character == '_' =
+                let (tailText, remaining) = span (\c -> isAlphaNum c || c == '_') rest
+                    text = character : tailText
+                    kind = if text `elem` keywords then KeywordToken else IdentifierToken
+                 in go line (column + length text) remaining (token kind text line column line (column + length text) : output)
+            | isDigit character =
+                let (text, remaining) = scanNumericCandidate input
+                 in if isFloatingCandidate text
+                        then case validateFloatingSpelling text of
+                            Left issue -> Left [diagnostic "TAL0008" line column (renderFloatingLiteralError issue)]
+                            Right _ ->
+                                go
+                                    line
+                                    (column + length text)
+                                    remaining
+                                    (token FloatingToken text line column line (column + length text) : output)
+                        else case parseIntegerSpelling text of
+                            Left issue -> Left [diagnostic "TAL0006" line column (renderIntegerLiteralError issue)]
+                            Right _ ->
+                                go
+                                    line
+                                    (column + length text)
+                                    remaining
+                                    (token IntegerToken text line column line (column + length text) : output)
+            | character == '"' = lexString line column rest output
+            | character == '\'' = case scanCharacterLiteral input of
+                Left issue -> Left [diagnostic "TAL0007" line column (renderCharacterLiteralError issue)]
+                Right (text, remaining) ->
+                    go
+                        line
+                        (column + length text)
+                        remaining
+                        (token CharacterToken text line column line (column + length text) : output)
+            | otherwise = case longestSymbol input of
+                Just symbol ->
+                    go
+                        line
+                        (column + length symbol)
+                        (drop (length symbol) input)
+                        (token SymbolToken symbol line column line (column + length symbol) : output)
+                Nothing -> Left [diagnostic "TAL0001" line column ("unsupported character " ++ show character)]
+
+        -- Documentation markers are consumed with their comments. Their eventual
+        -- Document-attribute lowering belongs to the Attributes/AST stage, not to the
+        -- delimiter scanner. Recognizing them here still gives every form the exact
+        -- language-defined termination and source-position rules.
+        lexComment line column input output = case longBracketOpener input of
+            Just (level, openerWidth, afterOpener) ->
+                let afterMarker = dropDocumentationMarker afterOpener
+                    markerWidth = length afterOpener - length afterMarker
+                 in skipLongComment line (column + 2 + openerWidth + markerWidth) level afterMarker output
+            Nothing ->
+                let afterMarker = dropDocumentationMarker input
+                    (comment, remaining) = break isLineBreak afterMarker
+                 in go line (column + 2 + (length input - length afterMarker) + length comment) remaining output
+
+        skipLongComment line column level input output = case stripPrefix (longBracketCloser level) input of
+            Just remaining -> go line (column + level + 2) remaining output
+            Nothing -> case input of
+                [] -> Left [diagnostic "TAL0002" line column "unterminated long comment"]
+                _
+                    | Just (nextLine, nextColumn, remaining) <- consumeLineBreak line column input ->
+                        skipLongComment nextLine nextColumn level remaining output
+                (_ : remaining) -> skipLongComment line (column + 1) level remaining output
+
+        lexRawString startLine startColumn level openerWidth input output =
+            let (line, column, content) = case consumeLineBreak startLine (startColumn + openerWidth) input of
+                    Just (nextLine, nextColumn, remaining) -> (nextLine, nextColumn, remaining)
+                    Nothing -> (startLine, startColumn + openerWidth, input)
+             in collectRaw startLine startColumn line column level content [] output
+
+        collectRaw startLine startColumn line column level input value output =
+            case stripPrefix (longBracketCloser level) input of
+                Just remaining ->
+                    let text = dropFinalBoundaryNewline (reverse value)
+                        endColumn = column + level + 2
+                     in go
+                            line
+                            endColumn
+                            remaining
+                            (token StringToken text startLine startColumn line endColumn : output)
+                Nothing -> case input of
+                    [] -> Left [diagnostic "TAL0005" startLine startColumn "unterminated raw string literal"]
+                    _
+                        | Just (nextLine, nextColumn, remaining) <- consumeLineBreak line column input ->
+                            collectRaw startLine startColumn nextLine nextColumn level remaining ('\n' : value) output
+                    (next : remaining) -> collectRaw startLine startColumn line (column + 1) level remaining (next : value) output
+
+        -- Normal strings retain the core escape table. Unlike raw strings, physical
+        -- line breaks are normalized to one space and escape sequences are decoded.
+        lexString startLine startColumn input output = collect input [] startLine (startColumn + 1)
+            where
+                collect [] _ currentLine currentColumn =
+                    Left [diagnostic "TAL0003" currentLine currentColumn "unterminated string literal"]
+                collect ('"' : remaining) value currentLine currentColumn =
+                    let text = reverse value
+                     in go
+                            currentLine
+                            (currentColumn + 1)
+                            remaining
+                            (token StringToken text startLine startColumn currentLine (currentColumn + 1) : output)
+                collect current value currentLine currentColumn
+                    | Just (nextLine, nextColumn, remaining) <- consumeLineBreak currentLine currentColumn current =
+                        collect remaining (' ' : value) nextLine nextColumn
+                collect ('\\' : remaining) value currentLine currentColumn =
+                    case decodeEscape remaining of
+                        Left message -> Left [diagnostic "TAL0004" currentLine currentColumn message]
+                        Right (decoded, width, rest) -> collect rest (decoded : value) currentLine (currentColumn + width + 1)
+                collect (next : remaining) value currentLine currentColumn =
+                    collect remaining (next : value) currentLine (currentColumn + 1)
+
+        token kind text startLine startColumn endLine endColumn =
+            Token kind text (SourceSpan file (SourcePosition startLine startColumn) (SourcePosition endLine endColumn))
+        diagnostic code line column message =
+            Diagnostic
+                LexerStage
+                Error
+                code
+                (Just (SourceSpan file (SourcePosition line column) (SourcePosition line (column + 1))))
+                message
+
+dropDocumentationMarker :: String -> String
+dropDocumentationMarker ('|' : remaining) = remaining
+dropDocumentationMarker ('!' : remaining) = remaining
+dropDocumentationMarker input = input
+
+isLineBreak :: Char -> Bool
+isLineBreak character = character == '\n' || character == '\r'
+
+consumeLineBreak :: Int -> Int -> String -> Maybe (Int, Int, String)
+consumeLineBreak line _ ('\r' : '\n' : remaining) = Just (line + 1, 1, remaining)
+consumeLineBreak line _ ('\r' : remaining) = Just (line + 1, 1, remaining)
+consumeLineBreak line _ ('\n' : remaining) = Just (line + 1, 1, remaining)
+consumeLineBreak _ _ _ = Nothing
+
+-- Returns the delimiter level, opener width, and content after the opener.
+longBracketOpener :: String -> Maybe (Int, Int, String)
+longBracketOpener ('[' : remaining) =
+    let (equals, suffix) = span (== '=') remaining
+     in case suffix of
+            '[' : content -> Just (length equals, length equals + 2, content)
+            _ -> Nothing
+longBracketOpener _ = Nothing
+
+longBracketCloser :: Int -> String
+longBracketCloser level = ']' : replicate level '=' ++ "]"
+
+dropFinalBoundaryNewline :: String -> String
+dropFinalBoundaryNewline value = case reverse value of
+    '\n' : remaining -> reverse remaining
+    _ -> value
+
+decodeEscape :: String -> Either String (Char, Int, String)
+decodeEscape [] = Left "unterminated escape sequence"
+decodeEscape (escape : remaining) = case escape of
+    '\'' -> simple '\''
+    '"' -> simple '"'
+    '\\' -> simple '\\'
+    '0' -> simple '\0'
+    'a' -> simple '\a'
+    'b' -> simple '\b'
+    'e' -> simple (chr 0x1b)
+    'f' -> simple '\f'
+    'n' -> simple '\n'
+    'r' -> simple '\r'
+    't' -> simple '\t'
+    'v' -> simple '\v'
+    'x' -> hexadecimal remaining
+    'u' -> exactHexadecimal 4 remaining
+    'U' -> exactHexadecimal 8 remaining
+    _ -> Left ("unsupported escape sequence \\" ++ [escape])
+    where
+        simple value = Right (value, 1, remaining)
+        hexadecimal input =
+            let (digits, rest) = span isHexDigit input
+             in if null digits
+                    then Left "hexadecimal escape requires at least one digit"
+                    else scalar digits rest
+        exactHexadecimal width input =
+            let (digits, rest) = splitAt width input
+             in if length digits /= width || any (not . isHexDigit) digits
+                    then Left ("Unicode escape requires exactly " ++ show width ++ " hexadecimal digits")
+                    else scalar digits rest
+        scalar digits rest =
+            let value = foldl (\total digit -> total * 16 + fromIntegral (digitToInt digit)) 0 digits :: Integer
+             in if value > 0x10ffff || value >= 0xd800 && value <= 0xdfff
+                    then Left "escape does not encode a Unicode scalar value"
+                    else Right (chr (fromInteger value), length digits + 1, rest)
+
+keywords :: [String]
+keywords =
+    [ "and"
+    , "auto"
+    , "bool"
+    , "break"
+    , "byte"
+    , "char"
+    , "class"
+    , "continue"
+    , "do"
+    , "for"
+    , "else"
+    , "enum"
+    , "false"
+    , "final"
+    , "guard"
+    , "if"
+    , "int"
+    , "internal"
+    , "is"
+    , "long"
+    , "longint"
+    , "match"
+    , "namespace"
+    , "null"
+    , "not"
+    , "or"
+    , "private"
+    , "protected"
+    , "public"
+    , "return"
+    , "static"
+    , "template"
+    , "typename"
+    , "sfloat"
+    , "lfloat"
+    , "float"
+    , "double"
+    , "true"
+    , "unit"
+    , "ubyte"
+    , "ushort"
+    , "ulong"
+    , "uint"
+    , "ulongint"
+    , "void"
+    , "while"
+    ]
+
+longestSymbol :: String -> Maybe String
+longestSymbol source =
+    firstMatch
+        [ "..."
+        , "<<="
+        , ">>="
+        , "**="
+        , "//="
+        , "??="
+        , "+="
+        , "-="
+        , "*="
+        , "/="
+        , "%="
+        , "&="
+        , "|="
+        , "^="
+        , "??"
+        , "?:"
+        , "::"
+        , "=="
+        , "\\="
+        , "<="
+        , ">="
+        , "&&"
+        , "||"
+        , "//"
+        , "->"
+        , "**"
+        , "++"
+        , "{"
+        , "}"
+        , "("
+        , ")"
+        , ","
+        , ":"
+        , ";"
+        , "."
+        , "="
+        , "+"
+        , "-"
+        , "*"
+        , "/"
+        , "%"
+        , "&"
+        , "|"
+        , "^"
+        , "<"
+        , ">"
+        , "!"
+        , "?"
+        , "["
+        , "]"
+        , "\\"
+        ]
+    where
+        firstMatch [] = Nothing
+        firstMatch (candidate : remaining)
+            | take (length candidate) source == candidate = Just candidate
+            | otherwise = firstMatch remaining
+ src/Progmasoft/Tala/NestingLimits.hs view
@@ -0,0 +1,175 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | Limits on how deeply a program may nest, checked on the parsed tree.
+
+The stages after Core walk a function body recursively: one level of
+recursion for every statement nested in another and for every expression
+that is an operand of another. Their stack is finite, so a program nested
+deeply enough would end the compiler with a stack overflow instead of a
+diagnostic. This module rejects such a program first, at the place where the
+nesting becomes too deep.
+
+The limits bound real nesting only. Three shapes nest in the tree as deep as
+they are long and are walked in a loop by every stage, so they do not count:
+an @else if@ chain, whose links are all at the level of the first @if@; the
+arms of a @match@, which lower to such a chain, so that the body of an arm
+is one level below its match however many arms the match has; and a chain of
+a binary operator, whose left operand is at the level of the operator.
+
+What a level costs is measured, not estimated: the stack every native stage
+needs per level of nesting, in an ordinary and in a sanitizer build, is
+recorded in @Benchmarks/2026-10-04-Nesting-And-Chains.md@ and can be measured
+again with @//Talac/Support/Tests:stack_probe@. At both limits the whole
+native pipeline needs a few megabytes of the stack the compiler runs on
+(@Tala/Support/CompilerStack.hpp@). The native Core reader and
+writer bound both depths at 4096 for Core that does not come from this
+frontend.
+-}
+module Progmasoft.Tala.NestingLimits
+    ( maximumStatementNesting
+    , maximumExpressionNesting
+    , nestingProblems
+    ) where
+
+import Progmasoft.Tala.AST
+import Progmasoft.Tala.Diagnostic
+
+{- | The deepest a statement may be nested in other statements of one
+function body. The statements of the body itself are at level 1.
+-}
+maximumStatementNesting :: Int
+maximumStatementNesting = 256
+
+{- | The deepest an expression may be nested in other expressions of one
+function body. An expression that is not an operand is at level 1.
+-}
+maximumExpressionNesting :: Int
+maximumExpressionNesting = 1024
+
+-- | A place where one of the limits is exceeded.
+data Excess
+    = StatementExcess SourceSpan
+    | ExpressionExcess SourceSpan
+
+{- | Diagnostics for every function body that nests too deeply.
+
+Each function reports at most one statement and one expression: the first
+of each, in source order, that is one level beyond the limit. Nothing below
+a reported node is visited, so the work stays proportional to the size of
+the accepted part of the program.
+-}
+nestingProblems :: ParsedAST -> [Diagnostic]
+nestingProblems (ParsedAST (SyntaxTree _ declarations)) = concatMap declarationProblems declarations
+
+declarationProblems :: Declaration name annotation -> [Diagnostic]
+declarationProblems declaration = case declaration of
+    FunctionDeclaration {declarationBody = body} -> report (blockExcess 1 0 body)
+    TypeDeclaration {typeMembers = members} -> concatMap declarationProblems members
+    TemplateTypeDeclaration {typeMembers = members} -> concatMap declarationProblems members
+    -- The value of a member is an expression of its own, at level 1.
+    EnumDeclaration {enumCases = cases} ->
+        concat [report (expressionExcess 1 1 value) | EnumCase {enumCaseValue = Just value} <- cases]
+    where
+        report found =
+            take 1 [statementProblem spanValue | StatementExcess spanValue <- found]
+                ++ take 1 [expressionProblem spanValue | ExpressionExcess spanValue <- found]
+
+statementProblem :: SourceSpan -> Diagnostic
+statementProblem spanValue =
+    Diagnostic
+        ParserStage
+        Error
+        "TAP0039"
+        (Just spanValue)
+        ( "statements are nested more than "
+            ++ show maximumStatementNesting
+            ++ " levels deep here; move the inner statements into a method of their own"
+        )
+
+expressionProblem :: SourceSpan -> Diagnostic
+expressionProblem spanValue =
+    Diagnostic
+        ParserStage
+        Error
+        "TAP0040"
+        (Just spanValue)
+        ( "this expression is nested more than "
+            ++ show maximumExpressionNesting
+            ++ " levels deep; compute part of it in a statement of its own"
+        )
+
+{- | The excesses in a block whose statements are at the given statement
+level, inside the given number of enclosing expressions.
+-}
+blockExcess :: Int -> Int -> Block name annotation -> [Excess]
+blockExcess level enclosing (Block statements) = concatMap (statementExcess level enclosing) statements
+
+statementExcess :: Int -> Int -> Statement name annotation -> [Excess]
+statementExcess level enclosing statement
+    | level > maximumStatementNesting = [StatementExcess (statementSourceSpan statement)]
+    | otherwise = case statement of
+        BindingStatement _ _ _ _ _ value -> operand value
+        AssignmentStatement _ _ _ value -> operand value
+        ReturnStatement _ value -> maybe [] operand value
+        IfStatement _ condition whenTrue whenFalse ->
+            operand condition ++ inner whenTrue ++ case whenFalse of
+                Nothing -> []
+                -- The next link of an else-if chain stays at this level.
+                Just (Block [next@IfStatement {}]) -> statementExcess level enclosing next
+                Just block -> inner block
+        WhileStatement _ condition body -> operand condition ++ inner body
+        DoWhileStatement _ body condition -> inner body ++ operand condition
+        ForStatement _ initializer condition updates body ->
+            maybe [] (statementExcess level enclosing) initializer
+                ++ maybe [] operand condition
+                ++ concatMap (statementExcess level enclosing) updates
+                ++ inner body
+        ForEachStatement _ _ _ _ _ source body -> operand source ++ inner body
+        IncrementStatement {} -> []
+        CompoundAssignmentStatement _ _ _ _ value -> operand value
+        DiscardStatement _ value -> operand value
+        BreakStatement _ value -> maybe [] operand value
+        ContinueStatement {} -> []
+        GuardStatement _ condition block -> operand condition ++ inner block
+        BlockStatement _ block -> inner block
+        ExpressionStatement _ value _ -> operand value
+    where
+        inner = blockExcess (level + 1) enclosing
+        operand = expressionExcess level (enclosing + 1)
+
+{- | The excesses in an expression at the given expression level. Statements
+inside it, in a value block, a loop, a match arm or a closure body, are one
+statement level below the statement that holds the expression, and
+expressions inside those statements keep counting from this one, so the two
+levels together bound how deep any walk of the body can recurse.
+-}
+expressionExcess :: Int -> Int -> Expression name annotation -> [Excess]
+expressionExcess level depth expression
+    | depth > maximumExpressionNesting = [ExpressionExcess (expressionSourceSpan expression)]
+    | otherwise = case expression of
+        NameExpression {} -> []
+        LiteralExpression {} -> []
+        MemberAccessExpression _ receiver _ _ -> operand receiver
+        MethodReferenceExpression _ receiver _ _ -> operand receiver
+        CallExpression _ callee arguments _ -> concatMap operand (callee : arguments)
+        UnaryExpression _ _ value _ -> operand value
+        -- The left operand of a binary operator is at the level of the
+        -- operator: `a + b + c` nests in it as deep as the chain is long,
+        -- and every stage walks that chain in a loop.
+        BinaryExpression _ _ left right _ -> expressionExcess level depth left ++ operand right
+        IsPatternExpression _ subject _ _ -> operand subject
+        ConditionalExpression _ condition first second _ -> concatMap operand [condition, first, second]
+        CoalesceExpression _ left fallback _ -> operand left ++ operand fallback
+        AssignmentExpression _ _ _ value _ -> operand value
+        IncrementExpression {} -> []
+        LoopExpression _ loop _ -> statementExcess (level + 1) depth loop
+        BlockExpression _ block _ -> blockExcess (level + 1) depth block
+        MatchExpression _ subjects arms _ ->
+            concatMap operand subjects ++ concatMap (concatMap operand . matchArmExpressions) arms
+        CallableExpression _ _ captures _ body _ ->
+            concatMap (maybe [] operand . captureInitializer) captures ++ case body of
+                CallableExpressionBody value -> operand value
+                CallableBlockBody block -> blockExcess (level + 1) depth block
+    where
+        operand = expressionExcess level (depth + 1)
+ src/Progmasoft/Tala/NumericLiteral.hs view
@@ -0,0 +1,143 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | Lexical rules for Tala integer literals.
+
+This module deliberately owns spelling validation rather than leaving it to
+'read'.  The language has radix-sensitive apostrophe separators, rejects
+octal notation, and must report malformed literals as one source token.
+-}
+module Progmasoft.Tala.NumericLiteral
+    ( IntegerRadix (..)
+    , ParsedInteger (..)
+    , IntegerLiteralError (..)
+    , scanIntegerSpelling
+    , parseIntegerSpelling
+    , renderIntegerLiteralError
+    ) where
+
+import Data.Char (digitToInt, isAlphaNum, isDigit, isHexDigit)
+
+-- | Radix supported by a Tala integer literal.
+data IntegerRadix = BinaryRadix | DecimalRadix | HexadecimalRadix
+    deriving stock (Bounded, Enum, Eq, Ord, Read, Show)
+
+-- | Validated radix, mathematical value, and separator-free digit sequence.
+data ParsedInteger = ParsedInteger
+    { parsedIntegerRadix :: IntegerRadix
+    , parsedIntegerValue :: Integer
+    , parsedIntegerDigits :: String
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Failure reason for scanning or validating an integer spelling.
+data IntegerLiteralError
+    = MissingDigits IntegerRadix
+    | UnsupportedOctalPrefix
+    | InvalidDigit IntegerRadix Char
+    | SeparatorAtStart IntegerRadix
+    | SeparatorAtEnd IntegerRadix
+    | ConsecutiveSeparators IntegerRadix
+    | SeparatorTouchesInvalidDigit IntegerRadix
+    | IntegerLiteralContinuesWithIdentifier
+    deriving stock (Eq, Ord, Read, Show)
+
+{- | Consume the entire source-shaped candidate.  Invalid radix digits and
+identifier tails remain attached so the lexer emits one useful diagnostic
+instead of an integer followed by a misleading name token.
+-}
+scanIntegerSpelling :: String -> (String, String)
+scanIntegerSpelling = span isCandidate
+    where
+        isCandidate character = isAlphaNum character || character == '\'' || character == '_'
+
+-- | Parse a complete integer spelling, rejecting octal and malformed separators.
+parseIntegerSpelling :: String -> Either IntegerLiteralError ParsedInteger
+parseIntegerSpelling spelling
+    | hasPrefix "0o" spelling || hasPrefix "0O" spelling = Left UnsupportedOctalPrefix
+    | hasPrefix "0x" spelling || hasPrefix "0X" spelling = parseDigits HexadecimalRadix (drop 2 spelling)
+    | hasPrefix "0b" spelling || hasPrefix "0B" spelling = parseDigits BinaryRadix (drop 2 spelling)
+    | otherwise = parseDigits DecimalRadix spelling
+
+parseDigits :: IntegerRadix -> String -> Either IntegerLiteralError ParsedInteger
+parseDigits radix digits
+    | null digits = Left (MissingDigits radix)
+    | startsWithSeparator digits = Left (SeparatorAtStart radix)
+    | endsWithSeparator digits = Left (SeparatorAtEnd radix)
+    | "''" `contains` digits = Left (ConsecutiveSeparators radix)
+    | otherwise = do
+        validateSeparatorNeighbors radix digits
+        validateDigits radix digits
+        let compact = filter (/= '\'') digits
+        pure
+            ParsedInteger
+                { parsedIntegerRadix = radix
+                , parsedIntegerValue = foldl (accumulate radix) 0 compact
+                , parsedIntegerDigits = compact
+                }
+
+startsWithSeparator :: String -> Bool
+startsWithSeparator ('\'' : _) = True
+startsWithSeparator _ = False
+
+endsWithSeparator :: String -> Bool
+endsWithSeparator = startsWithSeparator . reverse
+
+validateSeparatorNeighbors :: IntegerRadix -> String -> Either IntegerLiteralError ()
+validateSeparatorNeighbors radix = go
+    where
+        go (left : '\'' : right : remaining)
+            | validDigit radix left && validDigit radix right = go (right : remaining)
+            | otherwise = Left (SeparatorTouchesInvalidDigit radix)
+        go (_ : remaining) = go remaining
+        go _ = Right ()
+
+validateDigits :: IntegerRadix -> String -> Either IntegerLiteralError ()
+validateDigits radix = go
+    where
+        go [] = Right ()
+        go ('\'' : remaining) = go remaining
+        go (character : remaining)
+            | validDigit radix character = go remaining
+            | isAlphaNum character || character == '_' = Left (InvalidDigit radix character)
+            | otherwise = Left IntegerLiteralContinuesWithIdentifier
+
+validDigit :: IntegerRadix -> Char -> Bool
+validDigit BinaryRadix character = character == '0' || character == '1'
+validDigit DecimalRadix character = isDigit character
+validDigit HexadecimalRadix character = isHexDigit character
+
+accumulate :: IntegerRadix -> Integer -> Char -> Integer
+accumulate radix value digit = value * radixValue radix + fromIntegral (digitToInt digit)
+
+radixValue :: IntegerRadix -> Integer
+radixValue BinaryRadix = 2
+radixValue DecimalRadix = 10
+radixValue HexadecimalRadix = 16
+
+-- | Render an integer-literal failure for a source diagnostic.
+renderIntegerLiteralError :: IntegerLiteralError -> String
+renderIntegerLiteralError issue = case issue of
+    MissingDigits radix -> radixName radix ++ " literal requires at least one digit"
+    UnsupportedOctalPrefix -> "octal integer literals do not exist; use decimal, hexadecimal, or binary"
+    InvalidDigit radix character -> show character ++ " is not a valid " ++ radixName radix ++ " digit"
+    SeparatorAtStart radix -> "apostrophe cannot begin the digits of a " ++ radixName radix ++ " literal"
+    SeparatorAtEnd radix -> "apostrophe cannot end a " ++ radixName radix ++ " literal"
+    ConsecutiveSeparators radix -> "consecutive apostrophes are invalid in a " ++ radixName radix ++ " literal"
+    SeparatorTouchesInvalidDigit radix ->
+        "apostrophe must appear between two valid " ++ radixName radix ++ " digits"
+    IntegerLiteralContinuesWithIdentifier -> "integer literal cannot continue with identifier characters"
+
+radixName :: IntegerRadix -> String
+radixName BinaryRadix = "binary"
+radixName DecimalRadix = "decimal"
+radixName HexadecimalRadix = "hexadecimal"
+
+hasPrefix :: String -> String -> Bool
+hasPrefix prefix value = take (length prefix) value == prefix
+
+contains :: String -> String -> Bool
+contains needle haystack
+    | length needle > length haystack = False
+    | take (length needle) haystack == needle = True
+    | otherwise = contains needle (drop 1 haystack)
+ src/Progmasoft/Tala/Parser.hs view
@@ -0,0 +1,1456 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | The syntax parser turns one lexed source unit into a positioned parsed AST.
+
+It owns grammar and precedence, but not name binding or type decisions. Parsing
+stops at the first structural error so later phases never receive a guessed or
+partially recovered declaration tree.
+-}
+module Progmasoft.Tala.Parser (TokenKind (..), Token (..), ParserInput (..), Parser (..), defaultParser, runParser) where
+
+import Progmasoft.Tala.AST
+import Progmasoft.Tala.CharacterLiteral
+import Progmasoft.Tala.Diagnostic
+import Progmasoft.Tala.FloatingLiteral
+import Progmasoft.Tala.NumericLiteral
+import Progmasoft.Tala.Parser.Cursor
+import Progmasoft.Tala.Parser.Match
+import Progmasoft.Tala.Parser.Token
+
+-- | Tokens and diagnostic source identity for one physical input file.
+data ParserInput = ParserInput
+    { parserSourceFile :: FilePath
+    -- ^ Path reported on syntax diagnostics.
+    , parserTokens :: [Token]
+    -- ^ Lexer tokens, including the end-of-file token.
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | A parser implementation that returns a complete AST or diagnostics.
+newtype Parser = Parser {parseTokens :: ParserInput -> Either [Diagnostic] ParsedAST}
+
+-- | Run the supplied parser over one lexed file.
+runParser :: Parser -> ParserInput -> Either [Diagnostic] ParsedAST
+runParser = parseTokens
+
+-- | The repository grammar parser for the currently supported declaration set.
+defaultParser :: Parser
+defaultParser = Parser parseTala
+
+parseTala :: ParserInput -> Either [Diagnostic] ParsedAST
+parseTala input = case runP (parseModule <* endOfInput) (parserTokens input) of
+    Left problem -> Left [problem]
+    Right (tree, []) -> Right (ParsedAST tree)
+    Right (_, token : _) -> Left [problemAt token "TAP0001" ("unexpected token " ++ show (tokenText token))]
+    where
+        parseModule = do
+            namespace <- optionalParser parseNamespace
+            SyntaxTree namespace <$> manyUntilEof parseDeclaration
+
+parseNamespace :: P QualifiedName
+parseNamespace = do
+    _ <- keyword "namespace"
+    (first, _) <- identifier
+    remaining <- moreParts
+    _ <- symbol ";"
+    pure (QualifiedName (first : remaining))
+    where
+        moreParts = do
+            dot <- optionalSymbol "."
+            if dot then do (part, _) <- identifier; (part :) <$> moreParts else pure []
+
+manyUntilEof :: P a -> P [a]
+manyUntilEof parser = do
+    next <- peekToken
+    case next of
+        Nothing -> pure []
+        Just token | tokenKind token == EndOfFileToken -> pure []
+        _ -> (:) <$> parser <*> manyUntilEof parser
+
+parseDeclaration :: P (Declaration Identifier ())
+parseDeclaration = do
+    isTemplate <- peekText "template"
+    isEnum <- peekText "enum"
+    if isTemplate
+        then parseTemplateDeclaration
+        else if isEnum then parseEnumDeclaration else parseOrdinaryTypeDeclaration
+
+{- | Parse a classic enum: @enum Name { A, B = 2, C }@, with an optional
+underlying type written as @enum Name = byte { ... }@. A comma separates the
+members and may follow the last one. The value of a member is read as an
+integer literal with an optional minus sign; other constant expressions are
+not implemented yet.
+-}
+parseEnumDeclaration :: P (Declaration Identifier ())
+parseEnumDeclaration = do
+    start <- keyword "enum"
+    (name, _) <- identifier
+    hasUnderlying <- optionalSymbol "="
+    underlying <- if hasUnderlying then Just <$> parseTypeSyntax else pure Nothing
+    _ <- symbol "{"
+    cases <- parseCases
+    close <- symbol "}"
+    pure (EnumDeclaration (mergeSpan (tokenSpan start) (tokenSpan close)) name () underlying cases)
+    where
+        parseCases = do
+            done <- peekText "}"
+            if done
+                then pure []
+                else do
+                    (caseName, caseSpan) <- identifier
+                    hasValue <- optionalSymbol "="
+                    -- A value is an expression without assignment; which
+                    -- expressions are constant is the type checker's rule.
+                    value <- if hasValue then Just <$> parseConditional else pure Nothing
+                    comma <- optionalSymbol ","
+                    remaining <- if comma then parseCases else pure []
+                    pure (EnumCase caseSpan caseName value : remaining)
+
+parseOrdinaryTypeDeclaration :: P (Declaration Identifier ())
+parseOrdinaryTypeDeclaration = do
+    _ <- parseAccess
+    start <- keyword "class"
+    (name, _) <- identifier
+    _ <- symbol "{"
+    members <- manyUntil "}" parseMember
+    close <- symbol "}"
+    pure (TypeDeclaration (mergeSpan (tokenSpan start) (tokenSpan close)) name () members)
+
+{- | Parse a template declaration while preserving ordered, typed parameters.
+Later phases distinguish type and value arguments, so the prefix cannot be
+discarded as decoration after recognizing the class name.
+-}
+parseTemplateDeclaration :: P (Declaration Identifier ())
+parseTemplateDeclaration = do
+    start <- keyword "template"
+    _ <- symbol "<"
+    empty <- peekText ">"
+    if empty
+        then failCurrent "TAP0019" "a template parameter list cannot be empty"
+        else pure ()
+    parameters <- separatedUntil ">" "," parseTemplateParameter
+    _ <- symbol ">"
+    _ <- parseAccess
+    _ <- keyword "class"
+    (name, _) <- identifier
+    _ <- symbol "{"
+    members <- manyUntil "}" parseMember
+    close <- symbol "}"
+    pure
+        ( TemplateTypeDeclaration
+            (mergeSpan (tokenSpan start) (tokenSpan close))
+            name
+            ()
+            parameters
+            members
+        )
+
+parseTemplateParameter :: P (TemplateParameter Identifier ())
+parseTemplateParameter = do
+    (parameter, spanValue) <- withSpan parseTemplateParameterBody
+    pure parameter {templateParameterSpan = spanValue}
+
+parseTemplateParameterBody :: P (TemplateParameter Identifier ())
+parseTemplateParameterBody = do
+    nested <- peekText "template"
+    if nested then parseTemplateTemplateParameter else parseDirectTemplateParameter
+
+parseDirectTemplateParameter :: P (TemplateParameter Identifier ())
+parseDirectTemplateParameter = do
+    typeParameter <- peekText "typename"
+    if typeParameter
+        then do
+            _ <- keyword "typename"
+            packed <- optionalSymbol "..."
+            (name, spanValue) <- identifier
+            defaultValue <- optionalTemplateTypeDefault
+            pure (TemplateParameter spanValue name () TemplateTypeParameter packed defaultValue)
+        else do
+            parameterType <- parseTypeSyntax
+            packed <- optionalSymbol "..."
+            (name, spanValue) <- identifier
+            defaultValue <- optionalTemplateValueDefault
+            pure
+                ( TemplateParameter
+                    spanValue
+                    name
+                    ()
+                    (TemplateValueParameterKind parameterType)
+                    packed
+                    defaultValue
+                )
+
+parseTemplateTemplateParameter :: P (TemplateParameter Identifier ())
+parseTemplateTemplateParameter = do
+    _ <- keyword "template"
+    _ <- symbol "<"
+    empty <- peekText ">"
+    if empty
+        then failCurrent "TAP0020" "a template-template parameter signature cannot be empty"
+        else pure ()
+    shapes <- separatedUntil ">" "," parseTemplateParameterShape
+    _ <- symbol ">"
+    _ <- keyword "class"
+    packed <- optionalSymbol "..."
+    (name, spanValue) <- identifier
+    defaultValue <- optionalTemplateTypeDefault
+    pure
+        ( TemplateParameter
+            spanValue
+            name
+            ()
+            (TemplateTemplateParameter shapes)
+            packed
+            defaultValue
+        )
+
+parseTemplateParameterShape :: P TemplateParameterShape
+parseTemplateParameterShape = do
+    nested <- peekText "template"
+    if nested
+        then do
+            _ <- keyword "template"
+            _ <- symbol "<"
+            shapes <- separatedUntil ">" "," parseTemplateParameterShape
+            _ <- symbol ">"
+            _ <- keyword "class"
+            packed <- optionalSymbol "..."
+            pure (TemplateParameterShape (TemplateTemplateParameterShape shapes) packed)
+        else do
+            isType <- peekText "typename"
+            if isType
+                then do
+                    _ <- keyword "typename"
+                    packed <- optionalSymbol "..."
+                    pure (TemplateParameterShape TemplateTypeParameterShape packed)
+                else do
+                    valueType <- parseTypeSyntax
+                    packed <- optionalSymbol "..."
+                    pure (TemplateParameterShape (TemplateValueParameterShape valueType) packed)
+
+optionalTemplateTypeDefault :: P (Maybe TemplateDefault)
+optionalTemplateTypeDefault = do
+    equals <- optionalSymbol "="
+    if equals then Just . TemplateTypeDefault <$> parseTypeSyntax else pure Nothing
+
+optionalTemplateValueDefault :: P (Maybe TemplateDefault)
+optionalTemplateValueDefault = do
+    equals <- optionalSymbol "="
+    if equals
+        then Just . TemplateValueDefault <$> (parseAdditive >>= requireTemplateValue)
+        else pure Nothing
+
+parseMember :: P (Declaration Identifier ())
+parseMember = do
+    (member, spanValue) <- withSpan parseMemberBody
+    pure member {declarationSpan = spanValue}
+
+parseMemberBody :: P (Declaration Identifier ())
+parseMemberBody = do
+    access <- parseAccess
+    isStatic <- maybe False (const True) <$> optionalParser (keyword "static")
+    returnType <- parseTypeSyntax
+    (name, nameSpan) <- identifier
+    _ <- symbol "("
+    parameters <- separated "," parseParameter
+    _ <- symbol ")"
+    -- A semicolon-free expression is a function result, not an optionally
+    -- terminated statement. Only a value-returning function's outer body may
+    -- therefore admit that form; nested control-flow blocks may not.
+    body <- parseBlock (returnType /= ExplicitType (Identifier "void"))
+    pure
+        (FunctionDeclaration (mergeSpan nameSpan (blockSpan body nameSpan)) name () returnType parameters body isStatic access)
+
+parseAccess :: P Access
+parseAccess = do
+    token <- optionalParser (keyword "public" <|?> keyword "internal" <|?> keyword "private" <|?> keyword "protected")
+    pure $ case fmap tokenText token of
+        Just "public" -> PublicAccess
+        Just "internal" -> InternalAccess
+        Just "private" -> PrivateAccess
+        Just "protected" -> ProtectedAccess
+        _ -> DefaultAccess
+
+manyUntil :: String -> P a -> P [a]
+manyUntil closing parser = do
+    done <- peekText closing
+    eof <- peekKind EndOfFileToken
+    if done
+        then pure []
+        else if eof then failCurrent "TAP0002" "unterminated declaration" else (:) <$> parser <*> manyUntil closing parser
+
+parseParameter :: P (Parameter Identifier ())
+parseParameter = do
+    _ <- optionalParameterLabel
+    parameterType <- parseTypeSyntax
+    (name, spanValue) <- identifier
+    pure (Parameter spanValue name () parameterType)
+
+-- External parameter labels are independent from the implementation name. An
+-- underscore suppresses a label, while `label:` gives one explicitly. The AST
+-- intentionally stores only the implementation name until call-site labels
+-- become part of name resolution rather than pretending both names are equal.
+optionalParameterLabel :: P (Maybe Token)
+optionalParameterLabel = do
+    tokens <- peekTokens 2
+    case tokens of
+        first : _
+            | tokenKind first == IdentifierToken
+            , tokenText first == "_" ->
+                Just <$> takeToken
+        first : colon : _
+            | tokenKind first == IdentifierToken
+            , tokenKind colon == SymbolToken
+            , tokenText colon == ":" -> do
+                label <- takeToken
+                _ <- takeToken
+                pure (Just label)
+        _ -> pure Nothing
+
+parseTypeSyntax :: P TypeSyntax
+parseTypeSyntax = parseCallableTypeSyntax <|?> parseArrayTypeSyntax <|?> parseNamedTypeSyntax
+
+-- Function types use the same arrow as callable literals, but are unambiguous
+-- in a type position because their left hand side is a parenthesized type list.
+parseCallableTypeSyntax :: P TypeSyntax
+parseCallableTypeSyntax = do
+    _ <- symbol "("
+    empty <- peekText ")"
+    parameters <- if empty then pure [] else separatedUntil ")" "," parseTypeSyntax
+    _ <- symbol ")"
+    _ <- symbol "->"
+    CallableTypeSyntax parameters <$> parseTypeSyntax
+
+-- Bracket spelling is surface sugar. `[T]` and `[K to V]` lower to the public
+-- System collection types in the type checker. The compact `[]T` built-in form
+-- stays distinct because its runtime representation is not System.Array.
+parseArrayTypeSyntax :: P TypeSyntax
+parseArrayTypeSyntax = do
+    _ <- symbol "["
+    builtin <- optionalSymbol "]"
+    if builtin
+        then BuiltinArrayTypeSyntax <$> parseTypeSyntax
+        else do
+            first <- parseTypeSyntax
+            separator <- peekToken
+            case separator of
+                Just token | tokenText token == "to" && tokenKind token `elem` [IdentifierToken, KeywordToken] -> do
+                    _ <- contextualWord "to"
+                    value <- parseTypeSyntax
+                    _ <- symbol "]"
+                    pure (DictionaryTypeSyntax first value)
+                Just token
+                    | tokenKind token == SymbolToken && tokenText token == ";" ->
+                        do
+                            _ <- takeToken
+                            value <- parseExpression >>= requireTemplateValue
+                            _ <- symbol "]"
+                            pure (FixedArrayTypeSyntax first value)
+                _ -> do
+                    _ <- symbol "]"
+                    pure (ArrayTypeSyntax first)
+
+parseNamedTypeSyntax :: P TypeSyntax
+parseNamedTypeSyntax = do
+    token <- satisfy (\candidate -> tokenKind candidate `elem` [IdentifierToken, KeywordToken]) "type"
+    case tokenText token of
+        "unit" -> failAt (tokenSpan token) "TAP0013" "Tala has no source-language unit type; use void for a no-result function"
+        "auto" -> pure AutoType
+        value -> do
+            remaining <- qualifiedTypeParts
+            arguments <- optionalTypeArguments
+            let parts = Identifier value : remaining
+            pure $ case (parts, arguments) of
+                ([name], []) -> ExplicitType name
+                _ -> QualifiedTypeSyntax (QualifiedName parts) arguments
+
+qualifiedTypeParts :: P [Identifier]
+qualifiedTypeParts = do
+    dot <- optionalSymbol "."
+    if dot
+        then do
+            (part, _) <- identifier
+            (part :) <$> qualifiedTypeParts
+        else pure []
+
+optionalTypeArguments :: P [TemplateArgumentSyntax]
+optionalTypeArguments = do
+    open <- optionalSymbol "<"
+    if not open
+        then pure []
+        else do
+            empty <- peekText ">"
+            if empty
+                then failCurrent "TAP0016" "a generic type argument list cannot be empty"
+                else do
+                    arguments <- separatedUntil ">" "," parseTemplateArgumentSyntax
+                    _ <- symbol ">"
+                    pure arguments
+
+-- A leading literal or unary operator makes a generic argument unambiguously
+-- a value.  A bare identifier remains a type until declaration-aware template
+-- lookup exists; fixed-array sugar has an explicit semicolon and therefore
+-- supports named values without that ambiguity today.
+parseTemplateArgumentSyntax :: P TemplateArgumentSyntax
+parseTemplateArgumentSyntax = do
+    next <- peekToken
+    case next of
+        Just token
+            | tokenKind token `elem` [IntegerToken, CharacterToken]
+                || (tokenKind token == KeywordToken && tokenText token `elem` ["true", "false", "not"])
+                || (tokenKind token == SymbolToken && tokenText token `elem` ["+", "-"]) ->
+                -- The outer '>' terminates this argument and is also a binary
+                -- comparison token. Parsing the unparenthesized prefix only
+                -- through additive precedence prevents the delimiter from
+                -- being consumed as an operator. Comparisons remain available
+                -- when explicitly parenthesized, for example Flag<(1 > 0)>.
+                TemplateValueArgumentSyntax <$> (parseAdditive >>= requireTemplateValue)
+        _ -> TemplateTypeSyntax <$> parseTypeSyntax
+
+requireTemplateValue :: Expression Identifier () -> P TemplateValueSyntax
+requireTemplateValue expression = case expression of
+    NameExpression spanValue name _ -> pure (TemplateNameSyntax spanValue (QualifiedName [name]))
+    MemberAccessExpression spanValue _ _ _ -> unsupported spanValue
+    MethodReferenceExpression spanValue _ _ _ -> unsupported spanValue
+    LiteralExpression spanValue literal _ -> case literal of
+        IntegerLiteral value -> pure (TemplateIntegerSyntax spanValue value)
+        CharacterLiteral value -> pure (TemplateCharacterSyntax spanValue value)
+        BooleanLiteral value -> pure (TemplateBooleanSyntax spanValue value)
+        _ -> unsupported spanValue
+    UnaryExpression spanValue operator value _ ->
+        TemplateUnarySyntax spanValue operator <$> requireTemplateValue value
+    BinaryExpression spanValue operator left right _ ->
+        TemplateBinarySyntax spanValue operator <$> requireTemplateValue left <*> requireTemplateValue right
+    CallExpression spanValue _ _ _ -> unsupported spanValue
+    IsPatternExpression spanValue _ _ _ -> unsupported spanValue
+    ConditionalExpression spanValue _ _ _ _ -> unsupported spanValue
+    CoalesceExpression spanValue _ _ _ -> unsupported spanValue
+    AssignmentExpression spanValue _ _ _ _ -> unsupported spanValue
+    IncrementExpression spanValue _ _ _ -> unsupported spanValue
+    LoopExpression spanValue _ _ -> unsupported spanValue
+    BlockExpression spanValue _ _ -> unsupported spanValue
+    MatchExpression spanValue _ _ _ -> unsupported spanValue
+    CallableExpression spanValue _ _ _ _ _ -> unsupported spanValue
+    where
+        unsupported spanValue =
+            failAt spanValue "TAP0018" "template value arguments must be compile-time scalar expressions"
+
+{- | What the last item of a block may be.
+
+Every block may end with a statement. A callable body may also end with an
+expression that has no semicolon, which is its result. A value block, the
+block of an @if@ expression or of a @match@ arm, is one whose last item is
+its value: there an @if@ with an @else@ and a @match@ in last position are
+expressions as well, as the grammar's @final-expression@ allows.
+-}
+data BlockMode = StatementBlock | CallableBlock | ValueBlock
+    deriving stock (Eq)
+
+parseBlock :: Bool -> P (Block Identifier ())
+parseBlock allowFinalExpression = parseBlockIn (if allowFinalExpression then CallableBlock else StatementBlock)
+
+parseBlockIn :: BlockMode -> P (Block Identifier ())
+parseBlockIn mode = do _ <- symbol "{"; statements <- go; _ <- symbol "}"; pure (Block statements)
+    where
+        go = do
+            done <- peekText "}"
+            eof <- peekKind EndOfFileToken
+            if done
+                then pure []
+                else
+                    if eof
+                        then failCurrent "TAP0002" "unterminated block"
+                        else (:) <$> parseStatementIn mode <*> go
+
+blockSpan :: Block name annotation -> SourceSpan -> SourceSpan
+blockSpan (Block []) fallback = fallback
+blockSpan (Block statements) fallback = foldl mergeSpan fallback (map statementSpan statements)
+
+statementSpan :: Statement name annotation -> SourceSpan
+statementSpan statement = case statement of
+    BindingStatement value _ _ _ _ _ -> value
+    AssignmentStatement value _ _ _ -> value
+    ReturnStatement value _ -> value
+    IfStatement value _ _ _ -> value
+    WhileStatement value _ _ -> value
+    DoWhileStatement value _ _ -> value
+    ForStatement value _ _ _ _ -> value
+    ForEachStatement value _ _ _ _ _ _ -> value
+    IncrementStatement value _ _ -> value
+    CompoundAssignmentStatement value _ _ _ _ -> value
+    DiscardStatement value _ -> value
+    BreakStatement value _ -> value
+    ContinueStatement value -> value
+    GuardStatement value _ _ -> value
+    BlockStatement value _ -> value
+    ExpressionStatement value _ _ -> value
+
+-- The branching forms of "Progmasoft.Tala.Parser.Match" are built from the
+-- expression, block, and type grammar of this module.
+branchGrammar :: BranchGrammar
+branchGrammar = BranchGrammar parseExpression (parseBlockIn ValueBlock) (parseBlock False) parseTypeSyntax parsePatternLiteral
+
+parseStatementIn :: BlockMode -> P (Statement Identifier ())
+parseStatementIn mode =
+    do
+        let allowFinalExpression = mode /= StatementBlock
+        tokens <- peekTokens 3
+        -- Compound types make a fixed token-count heuristic incorrect. Probe
+        -- the complete declaration prefix without consuming it, then commit to
+        -- that grammar branch so a later initializer error remains precise.
+        case tokens of
+            first : _ | tokenKind first == KeywordToken && tokenText first == "return" -> parseReturn
+            first : _
+                | tokenKind first == KeywordToken && tokenText first == "if" ->
+                    if mode == ValueBlock then parseIfInValueBlock else parseIf
+            first : _ | tokenKind first == KeywordToken && tokenText first == "while" -> parseWhile
+            first : _ | tokenKind first == KeywordToken && tokenText first == "do" -> parseDoWhile
+            first : _ | tokenKind first == KeywordToken && tokenText first == "for" -> parseFor
+            first : _ | tokenKind first == KeywordToken && tokenText first == "break" -> parseBreak
+            first : _ | tokenKind first == KeywordToken && tokenText first == "continue" -> parseContinue
+            first : _ | tokenKind first == KeywordToken && tokenText first == "guard" -> parseGuardStatement branchGrammar
+            -- No expression starts with a brace, so a brace that starts a
+            -- statement opens a nested block.
+            first : _ | tokenKind first == SymbolToken && tokenText first == "{" -> do
+                (block, spanValue) <- withSpan (parseBlock False)
+                pure (BlockStatement spanValue block)
+            -- A `match` that starts a statement is the statement form: it
+            -- needs no terminator and its arms need not cover every value.
+            -- As the last item of a value block it is the value of the block.
+            first : _ | tokenKind first == KeywordToken && tokenText first == "match" -> do
+                value <- parseMatchExpression branchGrammar
+                closesBlock <- peekText "}"
+                pure (ExpressionStatement (expressionSpan value) value (not (mode == ValueBlock && closesBlock)))
+            _ | startsIncrement tokens -> parseIncrementStatement
+            _ | startsCompoundAssignment tokens -> parseCompoundAssignmentStatement
+            _ | startsDiscard tokens -> parseDiscard
+            first : _ | tokenKind first == KeywordToken && tokenText first == "final" -> parseBinding
+            -- `unit` is forbidden specifically in type position. Commit here
+            -- so declaration lookahead cannot hide TAP0013 behind an unrelated
+            -- expression diagnostic.
+            first : _ | tokenKind first == KeywordToken && tokenText first == "unit" -> parseBinding
+            _ -> do
+                binding <- matchesAhead (parseTypeSyntax >> identifier)
+                if binding then parseBinding else parseAssignmentOrExpression allowFinalExpression
+
+parseReturn :: P (Statement Identifier ())
+parseReturn = do
+    start <- keyword "return"
+    empty <- peekText ";"
+    if empty
+        then do end <- symbol ";"; pure (ReturnStatement (mergeSpan (tokenSpan start) (tokenSpan end)) Nothing)
+        else do
+            value <- parseExpression
+            end <- symbol ";"
+            pure (ReturnStatement (mergeSpan (tokenSpan start) (tokenSpan end)) (Just value))
+
+parseIf :: P (Statement Identifier ())
+parseIf = do
+    ((condition, trueBlock, falseBlock), spanValue) <- withSpan $ do
+        _ <- keyword "if"
+        _ <- symbol "("
+        condition <- parseCondition branchGrammar "an if"
+        _ <- symbol ")"
+        trueBlock <- parseBlock False
+        falseBlock <- optionalParser $ do
+            _ <- keyword "else"
+            chained <- peekText "if"
+            if chained then Block . (: []) <$> parseIf else parseBlock False
+        pure (condition, trueBlock, falseBlock)
+    pure (IfStatement spanValue condition trueBlock falseBlock)
+
+{- | An @if@ at the start of an item of a value block.
+
+Whether it is the statement or the expression is known only after it: it is
+the value of the block when it is the last item, has an @else@ block, and
+one of its blocks ends with a value; the other block then either ends with a
+value as well or leaves the block, which the type checker decides. Its blocks are therefore parsed once,
+as value blocks, and the statement form is recovered from them when the @if@
+turns out to be a statement. Parsing it twice instead would double the work
+at every level of nested value blocks.
+-}
+parseIfInValueBlock :: P (Statement Identifier ())
+parseIfInValueBlock = do
+    ((condition, first, second, chained), spanValue) <- withSpan $ do
+        _ <- keyword "if"
+        _ <- symbol "("
+        condition <- parseCondition branchGrammar "an if"
+        _ <- symbol ")"
+        first <- withSpan (parseBlockIn ValueBlock)
+        hasElse <- peekText "else"
+        if not hasElse
+            then pure (condition, first, Nothing, Nothing)
+            else do
+                _ <- keyword "else"
+                chain <- peekText "if"
+                if chain
+                    then do
+                        next <- parseIf
+                        pure (condition, first, Nothing, Just next)
+                    else do
+                        second <- withSpan (parseBlockIn ValueBlock)
+                        pure (condition, first, Just second, Nothing)
+    closesBlock <- peekText "}"
+    case (second, chained) of
+        (Just (secondBlock, secondSpan), Nothing)
+            | closesBlock && (blockEndsWithValue (fst first) || blockEndsWithValue secondBlock) ->
+                pure
+                    ( ExpressionStatement
+                        spanValue
+                        ( ConditionalExpression
+                            spanValue
+                            condition
+                            (BlockExpression (snd first) (fst first) ())
+                            (BlockExpression secondSpan secondBlock ())
+                            ()
+                        )
+                        False
+                    )
+        _ -> do
+            whenTrue <- statementBlock (fst first)
+            whenFalse <- case (second, chained) of
+                (Just (secondBlock, _), _) -> Just <$> statementBlock secondBlock
+                (Nothing, Just next) -> pure (Just (Block [next]))
+                (Nothing, Nothing) -> pure Nothing
+            pure (IfStatement spanValue condition whenTrue whenFalse)
+
+-- | Whether the last item of a block is an expression without a semicolon.
+blockEndsWithValue :: Block name annotation -> Bool
+blockEndsWithValue (Block statements) = case reverse statements of
+    ExpressionStatement _ _ False : _ -> True
+    _ -> False
+
+{- | The statement form of a block that was parsed as a value block.
+
+An @if@ or a @match@ in last position was read as the value of the block;
+as statements they need no terminator, so they become statements again. Any
+other expression in last position is missing its semicolon.
+-}
+statementBlock :: Block Identifier () -> P (Block Identifier ())
+statementBlock (Block statements) = case reverse statements of
+    ExpressionStatement spanValue value False : before -> do
+        final <- case value of
+            MatchExpression {} -> pure (ExpressionStatement spanValue value True)
+            ConditionalExpression _ condition (BlockExpression _ first _) (BlockExpression _ second _) _ ->
+                IfStatement spanValue condition <$> statementBlock first <*> (Just <$> statementBlock second)
+            _ -> failAt (endOf spanValue) "TAP0006" "expected \";\""
+        pure (Block (reverse (final : before)))
+    _ -> pure (Block statements)
+    where
+        endOf spanValue = spanValue {sourceStart = sourceEnd spanValue}
+
+-- Loops are retained as structured syntax until the Desugarer. Keeping their
+-- delimiters and header clauses explicit lets the type checker validate loop
+-- scope before CorePrep assigns basic-block identities.
+parseWhile :: P (Statement Identifier ())
+parseWhile = do
+    ((condition, body), spanValue) <- withSpan $ do
+        _ <- keyword "while"
+        _ <- symbol "("
+        condition <- parseCondition branchGrammar "a while"
+        _ <- symbol ")"
+        body <- parseBlock False
+        pure (condition, body)
+    pure (WhileStatement spanValue condition body)
+
+parseDoWhile :: P (Statement Identifier ())
+parseDoWhile = do
+    ((body, condition), spanValue) <- withSpan $ do
+        _ <- keyword "do"
+        body <- parseBlock False
+        _ <- keyword "while"
+        _ <- symbol "("
+        condition <- parseExpression
+        _ <- symbol ")"
+        _ <- symbol ";"
+        pure (body, condition)
+    pure (DoWhileStatement spanValue body condition)
+
+parseFor :: P (Statement Identifier ())
+parseFor = do
+    (header, spanValue) <- withSpan $ do
+        _ <- keyword "for"
+        _ <- symbol "("
+        emptyInitializer <- peekText ";"
+        if emptyInitializer
+            then parseForHeaderWithoutInitializer
+            else do
+                foreach <- matchesAhead (parseForBinding >> symbol ":")
+                if foreach then parseForEachHeader else parseClassicForHeader
+    pure $ case header of
+        ClassicHeader initializer condition updates body -> ForStatement spanValue initializer condition updates body
+        ForEachHeader binding source body ->
+            ForEachStatement spanValue (forBindingKind binding) (forBindingType binding) (forBindingName binding) () source body
+    where
+        parseForHeaderWithoutInitializer = do
+            _ <- symbol ";"
+            condition <- optionalUntil ";" parseExpression
+            _ <- symbol ";"
+            updates <- separatedUntil ")" "," parseForAction
+            _ <- symbol ")"
+            body <- parseBlock False
+            pure (ClassicHeader Nothing condition updates body)
+
+        parseForEachHeader = do
+            binding <- parseForBinding
+            _ <- symbol ":"
+            source <- parseExpression
+            _ <- symbol ")"
+            body <- parseBlock False
+            pure (ForEachHeader binding source body)
+
+        parseClassicForHeader = do
+            initializer <- parseForInitializer
+            _ <- symbol ";"
+            condition <- optionalUntil ";" parseExpression
+            _ <- symbol ";"
+            updates <- separatedUntil ")" "," parseForAction
+            _ <- symbol ")"
+            body <- parseBlock False
+            pure (ClassicHeader initializer condition updates body)
+
+data ForHeader
+    = ClassicHeader
+        (Maybe (Statement Identifier ()))
+        (Maybe (Expression Identifier ()))
+        [Statement Identifier ()]
+        (Block Identifier ())
+    | ForEachHeader ForBinding (Expression Identifier ()) (Block Identifier ())
+
+parseForInitializer :: P (Maybe (Statement Identifier ()))
+parseForInitializer = do
+    binding <- matchesAhead (optionalParser (keyword "final") >> parseTypeSyntax >> identifier >> symbol "=")
+    if binding
+        then do
+            start <- peekToken
+            finalToken <- optionalParser (keyword "final")
+            syntax <- parseTypeSyntax
+            (name, nameSpan) <- identifier
+            _ <- symbol "="
+            value <- parseExpression
+            let kind = maybe MutableBinding (const ImmutableBinding) finalToken
+                spanValue = maybe nameSpan tokenSpan start
+            pure (Just (BindingStatement spanValue kind syntax name () value))
+        else Just <$> parseForAction
+
+optionalUntil :: String -> P value -> P (Maybe value)
+optionalUntil closing parser = do
+    done <- peekText closing
+    if done then pure Nothing else Just <$> parser
+
+data ForBinding = ForBinding BindingKind TypeSyntax Identifier SourceSpan
+
+forBindingKind :: ForBinding -> BindingKind
+forBindingKind (ForBinding kind _ _ _) = kind
+
+forBindingType :: ForBinding -> TypeSyntax
+forBindingType (ForBinding _ syntax _ _) = syntax
+
+forBindingName :: ForBinding -> Identifier
+forBindingName (ForBinding _ _ name _) = name
+
+parseForBinding :: P ForBinding
+parseForBinding = do
+    finalToken <- optionalParser (keyword "final")
+    syntax <- parseTypeSyntax
+    (name, nameSpan) <- identifier
+    pure (ForBinding (maybe MutableBinding (const ImmutableBinding) finalToken) syntax name nameSpan)
+
+-- Assignment headers are represented with the same statement node used in a
+-- body. Prefix and postfix `++` have the same effect as a statement; their
+-- value-producing forms are expressions. The language has no decrement
+-- operator: `--` always starts a comment.
+parseForAction :: P (Statement Identifier ())
+parseForAction = do
+    tokens <- peekTokens 2
+    case tokens of
+        first : second : _
+            | isIncrementToken first && tokenKind second == IdentifierToken -> parseIncrement True
+        first : second : _
+            | tokenKind first == IdentifierToken && isIncrementToken second -> parseIncrement False
+        _ | startsCompoundAssignment tokens -> parseCompoundAssignment
+        _ -> do
+            value <- parseConditional
+            assignment <- optionalSymbol "="
+            if assignment
+                then case value of
+                    NameExpression start name _ -> AssignmentStatement start name () <$> parseExpression
+                    _ -> failAt (expressionSpan value) "TAP0003" "assignment target must be a name"
+                else pure (ExpressionStatement (expressionSpan value) value True)
+
+startsIncrement :: [Token] -> Bool
+startsIncrement tokens = case tokens of
+    first : second : _ ->
+        (isIncrementToken first && tokenKind second == IdentifierToken)
+            || (tokenKind first == IdentifierToken && isIncrementToken second)
+    _ -> False
+
+parseIncrementStatement :: P (Statement Identifier ())
+parseIncrementStatement = do
+    increment <- parseForAction
+    end <- symbol ";"
+    pure $ case increment of
+        IncrementStatement spanValue name annotation ->
+            IncrementStatement (mergeSpan spanValue (tokenSpan end)) name annotation
+        _ -> increment
+
+parseIncrement :: Bool -> P (Statement Identifier ())
+parseIncrement prefix = do
+    tokens <- peekTokens 2
+    case (prefix, tokens) of
+        (True, first : second : _)
+            | isIncrementToken first && tokenKind second == IdentifierToken -> do
+                _ <- symbol "++"
+                (name, spanValue) <- identifier
+                pure (IncrementStatement (mergeSpan (tokenSpan first) spanValue) name ())
+        (False, first : second : _)
+            | tokenKind first == IdentifierToken && isIncrementToken second -> do
+                (name, spanValue) <- identifier
+                finalOperator <- symbol "++"
+                pure (IncrementStatement (mergeSpan spanValue (tokenSpan finalOperator)) name ())
+        _ -> failCurrent "TAP0028" incrementTargetMessage
+
+-- Every compound operator stores the result of its binary operator back into
+-- the target. Comparison and logical operators have no compound spelling.
+compoundAssignmentOperators :: [(String, BinaryOperator)]
+compoundAssignmentOperators =
+    [ ("+=", Add)
+    , ("-=", Subtract)
+    , ("*=", Multiply)
+    , ("/=", Divide)
+    , ("//=", FloorDivide)
+    , ("%=", Remainder)
+    , ("**=", Power)
+    , ("<<=", ShiftLeft)
+    , (">>=", ShiftRight)
+    , ("&=", BitwiseAnd)
+    , ("^=", BitwiseXor)
+    , ("|=", BitwiseOr)
+    ]
+
+compoundAssignmentOperator :: Token -> Maybe BinaryOperator
+compoundAssignmentOperator token
+    | tokenKind token == SymbolToken = lookup (tokenText token) compoundAssignmentOperators
+    | otherwise = Nothing
+
+startsCompoundAssignment :: [Token] -> Bool
+startsCompoundAssignment tokens = case tokens of
+    first : second : _ ->
+        tokenKind first == IdentifierToken && compoundAssignmentOperator second /= Nothing
+    _ -> False
+
+-- The header form has no terminator; a `for` update list separates its
+-- actions with commas and closes them with the parenthesis.
+parseCompoundAssignment :: P (Statement Identifier ())
+parseCompoundAssignment = do
+    (name, nameSpan) <- identifier
+    operatorTokenValue <- takeToken
+    case compoundAssignmentOperator operatorTokenValue of
+        Nothing ->
+            failAt (tokenSpan operatorTokenValue) "TAP0006" "expected a compound assignment operator"
+        Just operator -> do
+            value <- parseExpression
+            pure (CompoundAssignmentStatement (mergeSpan nameSpan (expressionSpan value)) operator name () value)
+
+parseCompoundAssignmentStatement :: P (Statement Identifier ())
+parseCompoundAssignmentStatement = do
+    assignment <- parseCompoundAssignment
+    end <- symbol ";"
+    pure $ case assignment of
+        CompoundAssignmentStatement spanValue operator name annotation value ->
+            CompoundAssignmentStatement (mergeSpan spanValue (tokenSpan end)) operator name annotation value
+        _ -> assignment
+
+-- `_` never names a binding, so `_ =` can be recognized from two tokens and
+-- cannot be confused with an assignment to a declared name.
+startsDiscard :: [Token] -> Bool
+startsDiscard tokens = case tokens of
+    first : second : _ ->
+        tokenKind first == IdentifierToken
+            && tokenText first == "_"
+            && tokenKind second == SymbolToken
+            && tokenText second == "="
+    _ -> False
+
+parseDiscard :: P (Statement Identifier ())
+parseDiscard = do
+    start <- takeToken
+    _ <- symbol "="
+    value <- parseExpression
+    end <- symbol ";"
+    pure (DiscardStatement (mergeSpan (tokenSpan start) (tokenSpan end)) value)
+
+parseBreak :: P (Statement Identifier ())
+parseBreak = do
+    start <- keyword "break"
+    hasValue <- not <$> peekText ";"
+    value <- if hasValue then Just <$> parseExpression else pure Nothing
+    end <- symbol ";"
+    pure (BreakStatement (mergeSpan (tokenSpan start) (tokenSpan end)) value)
+
+parseContinue :: P (Statement Identifier ())
+parseContinue = do
+    start <- keyword "continue"
+    end <- symbol ";"
+    pure (ContinueStatement (mergeSpan (tokenSpan start) (tokenSpan end)))
+
+parseBinding :: P (Statement Identifier ())
+parseBinding = do
+    start <- peekToken
+    finalToken <- optionalParser (keyword "final")
+    bindingType <- parseTypeSyntax
+    (name, nameSpan) <- identifier
+    _ <- symbol "="
+    value <- parseExpression
+    end <- symbol ";"
+    let startSpan = maybe nameSpan tokenSpan start
+        kind = maybe MutableBinding (const ImmutableBinding) finalToken
+    pure (BindingStatement (mergeSpan startSpan (tokenSpan end)) kind bindingType name () value)
+
+parseAssignmentOrExpression :: Bool -> P (Statement Identifier ())
+parseAssignmentOrExpression allowFinalExpression = do
+    -- The statement's own target is parsed below the assignment level so a
+    -- top-level `target = value;` keeps its statement node; the value on the
+    -- right may itself be an assignment expression.
+    expression <- parseConditional
+    assignment <- optionalSymbol "="
+    if assignment
+        then case expression of
+            NameExpression start name _ -> do
+                value <- parseExpression
+                end <- symbol ";"
+                pure (AssignmentStatement (mergeSpan start (tokenSpan end)) name () value)
+            _ -> failAt (expressionSpan expression) "TAP0003" "assignment target must be a name"
+        else do
+            -- A named target was dispatched before this parser ran, so a
+            -- compound operator here follows a non-storage expression.
+            next <- peekToken
+            case next of
+                Just token
+                    | compoundAssignmentOperator token /= Nothing ->
+                        failAt (expressionSpan expression) "TAP0003" "assignment target must be a name"
+                    | tokenKind token == SymbolToken && tokenText token == "??=" ->
+                        failAt
+                            (tokenSpan token)
+                            "TAP0031"
+                            "null-coalescing assignment requires nullable types, which are not implemented"
+                _ -> pure ()
+            terminated <- peekText ";"
+            if terminated
+                then do
+                    end <- symbol ";"
+                    pure (ExpressionStatement (mergeSpan (expressionSpan expression) (tokenSpan end)) expression True)
+                else do
+                    closesBody <- peekText "}"
+                    if allowFinalExpression && closesBody
+                        then pure (ExpressionStatement (expressionSpan expression) expression False)
+                        else do
+                            _ <- symbol ";"
+                            pure (ExpressionStatement (expressionSpan expression) expression True)
+
+parseExpression :: P (Expression Identifier ())
+parseExpression = parseAssignment
+
+-- Assignment binds more weakly than every other operator and is
+-- right-associative: `a = b = 10` groups as `a = (b = 10)` and `a = b += 2` as
+-- `a = (b += 2)`. The target is a single name, so two tokens decide whether an
+-- expression is an assignment. `_ = value` is the discard statement and is
+-- never an expression.
+parseAssignment :: P (Expression Identifier ())
+parseAssignment = do
+    tokens <- peekTokens 2
+    case tokens of
+        first : second : _
+            | startsDiscard tokens ->
+                failAt
+                    (mergeSpan (tokenSpan first) (tokenSpan second))
+                    "TAP0032"
+                    "discard is a statement and cannot be used as a value"
+            | tokenKind first == IdentifierToken && isSimpleAssignment second -> do
+                (name, nameSpan) <- identifier
+                _ <- takeToken
+                value <- parseAssignment
+                pure (AssignmentExpression (mergeSpan nameSpan (expressionSpan value)) Nothing name value ())
+            | tokenKind first == IdentifierToken
+            , Just operator <- compoundAssignmentOperator second -> do
+                (name, nameSpan) <- identifier
+                _ <- takeToken
+                value <- parseAssignment
+                pure (AssignmentExpression (mergeSpan nameSpan (expressionSpan value)) (Just operator) name value ())
+        _ -> parseConditional
+    where
+        isSimpleAssignment token = tokenKind token == SymbolToken && tokenText token == "="
+
+-- The conditional forms bind more weakly than every binary operator and are
+-- right-associative: `a ? b : c ? d : e` groups as `a ? b : (c ? d : e)` and
+-- `a ?: b ?: c` as `a ?: (b ?: c)`.
+parseConditional :: P (Expression Identifier ())
+parseConditional = do
+    condition <- parseLogicalOr
+    next <- peekToken
+    case next of
+        Just token | tokenKind token == SymbolToken && tokenText token == "?" -> do
+            _ <- takeToken
+            omittedMiddle <- peekText ":"
+            if omittedMiddle
+                then do
+                    -- `left ? : fallback` is the same omitted-middle form
+                    -- as the adjacent `?:` spelling.
+                    _ <- symbol ":"
+                    coalesce condition
+                else do
+                    first <- parseConditional
+                    separator <- peekText ":"
+                    if separator
+                        then do
+                            _ <- symbol ":"
+                            second <- parseConditional
+                            pure
+                                ( ConditionalExpression
+                                    (mergeSpan (expressionSpan condition) (expressionSpan second))
+                                    condition
+                                    first
+                                    second
+                                    ()
+                                )
+                        else failCurrent "TAP0029" "conditional expression requires ':' and a second result"
+        Just token | tokenKind token == SymbolToken && tokenText token == "?:" -> do
+            _ <- takeToken
+            coalesce condition
+        Just token
+            | tokenKind token == SymbolToken && tokenText token == "??" ->
+                failAt (tokenSpan token) "TAP0030" "null coalescing requires nullable types, which are not implemented"
+        _ -> pure condition
+    where
+        coalesce left = do
+            fallback <- parseConditional
+            pure (CoalesceExpression (mergeSpan (expressionSpan left) (expressionSpan fallback)) left fallback ())
+
+parseLogicalOr
+    , parseLogicalAnd
+    , parseEquality
+    , parseComparison
+    , parseBitwiseOr
+    , parseBitwiseXor
+    , parseBitwiseAnd
+    , parseShift
+    , parseAdditive
+    , parseMultiplicative ::
+        P (Expression Identifier ())
+parseLogicalOr = chainLeft parseLogicalAnd [("||", LogicalOr)]
+parseLogicalAnd = chainLeft parseEquality [("&&", LogicalAnd)]
+parseEquality = nonAssociative "equality" parseComparison [("==", Equal), ("\\=", NotEqual)]
+parseComparison = do
+    left <-
+        nonAssociative
+            "relational"
+            parseBitwiseOr
+            [("<", LessThan), ("<=", LessEqual), (">", GreaterThan), (">=", GreaterEqual)]
+    introducesPattern <- peekText "is"
+    if not introducesPattern
+        then pure left
+        else do
+            _ <- keyword "is"
+            patternValue <- parsePattern
+            pure (IsPatternExpression (mergeSpan (expressionSpan left) (patternSpan patternValue)) left patternValue ())
+parseBitwiseOr = chainLeft parseBitwiseXor [("|", BitwiseOr)]
+parseBitwiseXor = chainLeft parseBitwiseAnd [("^", BitwiseXor)]
+parseBitwiseAnd = chainLeft parseShift [("&", BitwiseAnd)]
+-- Shift spellings deliberately remain two lexer tokens. Treating `>>` as one
+-- context-free token would consume the adjacent closing delimiters in nested
+-- generic types such as `A<B<C>>`. The parser has enough context to combine
+-- the pair here without weakening the generic grammar.
+parseShift = parseAdditive >>= continue
+    where
+        continue left = do
+            tokens <- peekTokens 2
+            case fmap tokenText tokens of
+                ["<", "<"] -> combine ShiftLeft left
+                [">", ">"] -> combine ShiftRight left
+                _ -> pure left
+        combine operator left = do
+            _ <- takeToken
+            _ <- takeToken
+            right <- parseAdditive
+            continue (BinaryExpression (mergeSpan (expressionSpan left) (expressionSpan right)) operator left right ())
+parseAdditive = chainLeft parseMultiplicative [("+", Add), ("-", Subtract)]
+parseMultiplicative = chainLeft parseUnary [("*", Multiply), ("/", Divide), ("//", FloorDivide), ("%", Remainder)]
+
+chainLeft :: P (Expression Identifier ()) -> [(String, BinaryOperator)] -> P (Expression Identifier ())
+chainLeft operand operators = operand >>= continue
+    where
+        continue left = do
+            next <- peekToken
+            case next >>= operatorToken operators of
+                Nothing -> pure left
+                Just operator -> do
+                    _ <- takeToken
+                    right <- operand
+                    continue (BinaryExpression (mergeSpan (expressionSpan left) (expressionSpan right)) operator left right ())
+
+parseUnary :: P (Expression Identifier ())
+parseUnary = do
+    next <- peekToken
+    case next
+        >>= operatorToken [("+", UnaryPlus), ("-", UnaryNegate), ("not", LogicalNot), ("!", BitwiseNot)] of
+        Just operator -> do
+            start <- takeToken
+            value <- parseUnary
+            pure (UnaryExpression (mergeSpan (tokenSpan start) (expressionSpan value)) operator value ())
+        Nothing -> case next of
+            Just token | isIncrementToken token -> parsePrefixIncrement
+            _ -> parsePower
+
+isIncrementToken :: Token -> Bool
+isIncrementToken token = tokenKind token == SymbolToken && tokenText token == "++"
+
+-- A prefix form modifies a named storage location and yields the new value.
+-- Anything else after the operator, such as `++10` or `++(a + b)`, has no
+-- storage to modify.
+parsePrefixIncrement :: P (Expression Identifier ())
+parsePrefixIncrement = do
+    operator <- takeToken
+    next <- peekToken
+    case next of
+        Just token | tokenKind token == IdentifierToken -> do
+            (name, nameSpan) <- identifier
+            pure
+                (IncrementExpression (mergeSpan (tokenSpan operator) nameSpan) True name ())
+        _ -> failAt (tokenSpan operator) "TAP0028" incrementTargetMessage
+
+incrementTargetMessage :: String
+incrementTargetMessage = "increment requires a named storage location"
+
+-- Power binds more strongly than prefix operators and recurses through the
+-- prefix layer on its right, making `2 ** 3 ** 2` right-associative while
+-- preserving the specified `-2 ** 2 == -(2 ** 2)` grouping.
+parsePower :: P (Expression Identifier ())
+parsePower = do
+    left <- parsePostfix
+    present <- optionalSymbol "**"
+    if present
+        then do
+            right <- parseUnary
+            pure (BinaryExpression (mergeSpan (expressionSpan left) (expressionSpan right)) Power left right ())
+        else pure left
+
+-- Equality and relational groups are non-associative. A parenthesized operand
+-- starts a fresh level; a second operator at this level is a syntax error.
+nonAssociative :: String -> P (Expression Identifier ()) -> [(String, BinaryOperator)] -> P (Expression Identifier ())
+nonAssociative group operand operators = do
+    left <- operand
+    next <- peekToken
+    case next >>= operatorToken operators of
+        Nothing -> pure left
+        Just operator -> do
+            _ <- takeToken
+            right <- operand
+            following <- peekToken
+            case following >>= operatorToken operators of
+                Just _ -> failCurrent "TAP0014" (group ++ " operators cannot be chained; use parentheses or an explicit logical conjunction")
+                Nothing -> pure (BinaryExpression (mergeSpan (expressionSpan left) (expressionSpan right)) operator left right ())
+
+operatorToken :: [(String, a)] -> Token -> Maybe a
+operatorToken operators token
+    | tokenKind token `elem` [SymbolToken, KeywordToken] = lookup (tokenText token) operators
+    | otherwise = Nothing
+
+parsePostfix :: P (Expression Identifier ())
+parsePostfix = parsePrimary >>= calls
+    where
+        calls callee = do
+            next <- peekToken
+            case next of
+                Just token | tokenText token == "." -> do
+                    _ <- symbol "."
+                    (member, memberSpan) <- identifier
+                    let selected = MemberAccessExpression (mergeSpan (expressionSpan callee) memberSpan) callee member ()
+                    calls selected
+                -- `::` names a method without calling it.
+                Just token | tokenText token == "::" -> do
+                    _ <- symbol "::"
+                    (member, memberSpan) <- identifier
+                    calls (MethodReferenceExpression (mergeSpan (expressionSpan callee) memberSpan) callee member ())
+                Just token | tokenText token == "(" -> do
+                    _ <- symbol "("
+                    arguments <- separated "," parseExpression
+                    close <- symbol ")"
+                    calls (CallExpression (mergeSpan (expressionSpan callee) (tokenSpan close)) callee arguments ())
+                -- A postfix form yields the previous value of a named storage
+                -- location. It is not a storage location itself, so nothing
+                -- may be selected from, called on, or incremented after it.
+                Just token | isIncrementToken token -> case callee of
+                    NameExpression nameSpan name _ -> do
+                        _ <- takeToken
+                        pure (IncrementExpression (mergeSpan nameSpan (tokenSpan token)) False name ())
+                    _ -> failAt (expressionSpan callee) "TAP0028" incrementTargetMessage
+                _ -> pure callee
+
+parsePrimary :: P (Expression Identifier ())
+parsePrimary = do
+    next <- peekToken
+    case next of
+        Just token | tokenKind token == SymbolToken && tokenText token `elem` ["\\", "["] -> parseCallable
+        -- `.Member` names a member of the enum that the context expects.
+        -- It is a member selection without a receiver; the place of the
+        -- receiver holds the unit literal, which no source can write.
+        Just token | tokenKind token == SymbolToken && tokenText token == "." -> do
+            _ <- takeToken
+            (member, memberSpan) <- identifier
+            pure
+                ( MemberAccessExpression
+                    (mergeSpan (tokenSpan token) memberSpan)
+                    (LiteralExpression (tokenSpan token) UnitLiteral ())
+                    member
+                    ()
+                )
+        -- A loop in operand position is a loop expression: its value is
+        -- supplied by `break value;`. The statement parsers are reused, so
+        -- both forms have exactly one grammar.
+        Just token | tokenKind token == KeywordToken && tokenText token `elem` ["while", "for"] -> do
+            loop <- if tokenText token == "while" then parseWhile else parseFor
+            pure (LoopExpression (statementSpan loop) loop ())
+        -- `if` and `match` in operand position are expressions. At the start
+        -- of a statement the statement parsers have already taken them.
+        Just token | tokenKind token == KeywordToken && tokenText token == "if" -> parseIfExpression branchGrammar
+        Just token | tokenKind token == KeywordToken && tokenText token == "match" -> parseMatchExpression branchGrammar
+        Just token | tokenKind token == IntegerToken -> do
+            _ <- takeToken
+            case parseIntegerSpelling (tokenText token) of
+                Right parsed -> pure (LiteralExpression (tokenSpan token) (IntegerLiteral (parsedIntegerValue parsed)) ())
+                Left issue -> failAt (tokenSpan token) "TAP0010" (renderIntegerLiteralError issue)
+        Just token | tokenKind token == FloatingToken -> do
+            _ <- takeToken
+            case validateFloatingSpelling (tokenText token) of
+                Right normalized -> pure (LiteralExpression (tokenSpan token) (FloatingLiteral normalized) ())
+                Left issue -> failAt (tokenSpan token) "TAP0012" (renderFloatingLiteralError issue)
+        Just token | tokenKind token == CharacterToken -> do
+            _ <- takeToken
+            case parseCharacterLiteral (tokenText token) of
+                Right value -> pure (LiteralExpression (tokenSpan token) (CharacterLiteral value) ())
+                Left issue -> failAt (tokenSpan token) "TAP0011" (renderCharacterLiteralError issue)
+        Just token | tokenKind token == StringToken -> do _ <- takeToken; pure (LiteralExpression (tokenSpan token) (StringLiteral (tokenText token)) ())
+        Just token | tokenKind token == KeywordToken && tokenText token `elem` ["true", "false"] -> do _ <- takeToken; pure (LiteralExpression (tokenSpan token) (BooleanLiteral (tokenText token == "true")) ())
+        Just token | tokenKind token == IdentifierToken -> do _ <- takeToken; pure (NameExpression (tokenSpan token) (Identifier (tokenText token)) ())
+        Just token | tokenText token == "(" -> do
+            _ <- takeToken
+            value <- parseExpression
+            _ <- symbol ")"
+            pure value
+        Just token -> failAt (tokenSpan token) "TAP0004" ("expected expression, found " ++ show (tokenText token))
+        Nothing -> failCurrent "TAP0005" "expected expression at end of input"
+
+-- Pattern combinators intentionally live in their own parser. Letting them
+-- fall through the ordinary expression precedence table would make `and` and
+-- `or` accidental aliases for && and || and would evaluate the subject more
+-- than once during lowering.
+parsePattern :: P (Pattern Identifier ())
+parsePattern = parseOrPattern
+
+parseOrPattern :: P (Pattern Identifier ())
+parseOrPattern = parseAndPattern >>= continue
+    where
+        continue left = do
+            present <- peekText "or"
+            if not present
+                then pure left
+                else do
+                    _ <- keyword "or"
+                    right <- parseAndPattern
+                    continue (OrPattern (mergeSpan (patternSpan left) (patternSpan right)) left right ())
+
+parseAndPattern :: P (Pattern Identifier ())
+parseAndPattern = parsePrimaryPattern >>= continue
+    where
+        continue left = do
+            present <- peekText "and"
+            if not present
+                then pure left
+                else do
+                    _ <- keyword "and"
+                    right <- parsePrimaryPattern
+                    continue (AndPattern (mergeSpan (patternSpan left) (patternSpan right)) left right ())
+
+parsePrimaryPattern :: P (Pattern Identifier ())
+parsePrimaryPattern = do
+    next <- peekToken
+    case next of
+        Just token | tokenText token == "not" -> do
+            start <- keyword "not"
+            nested <- parsePrimaryPattern
+            pure (NotPattern (mergeSpan (tokenSpan start) (patternSpan nested)) nested ())
+        Just token | tokenText token `elem` ["<", "<=", ">", ">=", "==", "\\="] -> do
+            operatorTokenValue <- takeToken
+            (literal, literalSpan) <- parsePatternLiteral
+            let operator = case tokenText operatorTokenValue of
+                    "<" -> PatternLessThan
+                    "<=" -> PatternLessEqual
+                    ">" -> PatternGreaterThan
+                    ">=" -> PatternGreaterEqual
+                    "==" -> PatternEqual
+                    _ -> PatternNotEqual
+            pure (RelationalPattern (mergeSpan (tokenSpan operatorTokenValue) literalSpan) operator literal ())
+        Just token | tokenText token == "_" -> do
+            _ <- takeToken
+            pure (WildcardPattern (tokenSpan token) ())
+        Just token | tokenText token == "null" -> do
+            _ <- keyword "null"
+            pure (NullPattern (tokenSpan token) ())
+        Just token | tokenText token == "(" -> do
+            _ <- symbol "("
+            nested <- parsePattern
+            _ <- symbol ")"
+            pure nested
+        Just token
+            | tokenKind token `elem` [IntegerToken, FloatingToken, CharacterToken, StringToken]
+                || tokenText token `elem` ["true", "false"] -> do
+                (literal, spanValue) <- parsePatternLiteral
+                pure (LiteralPattern spanValue literal ())
+        Just _ -> do
+            (typeSyntax, spanValue) <- withSpan parseTypeSyntax
+            pure (TypePattern spanValue typeSyntax ())
+        Nothing -> failCurrent "TAP0021" "expected pattern after is"
+
+parsePatternLiteral :: P (Literal, SourceSpan)
+parsePatternLiteral = do
+    token <- takeToken
+    let spanValue = tokenSpan token
+    case tokenKind token of
+        IntegerToken -> case parseIntegerSpelling (tokenText token) of
+            Right parsed -> pure (IntegerLiteral (parsedIntegerValue parsed), spanValue)
+            Left issue -> failAt spanValue "TAP0010" (renderIntegerLiteralError issue)
+        FloatingToken -> case validateFloatingSpelling (tokenText token) of
+            Right normalized -> pure (FloatingLiteral normalized, spanValue)
+            Left issue -> failAt spanValue "TAP0012" (renderFloatingLiteralError issue)
+        CharacterToken -> case parseCharacterLiteral (tokenText token) of
+            Right value -> pure (CharacterLiteral value, spanValue)
+            Left issue -> failAt spanValue "TAP0011" (renderCharacterLiteralError issue)
+        StringToken -> pure (StringLiteral (tokenText token), spanValue)
+        KeywordToken | tokenText token == "true" -> pure (BooleanLiteral True, spanValue)
+        KeywordToken | tokenText token == "false" -> pure (BooleanLiteral False, spanValue)
+        _ -> failAt spanValue "TAP0022" "relational and constant patterns require a literal"
+
+-- A capture list belongs to the callable which follows it.  Keeping this at
+-- primary-expression precedence allows immediately invoking a literal while
+-- preventing binary operators from becoming part of capture initializers.
+parseCallable :: P (Expression Identifier ())
+parseCallable = do
+    ((explicitCaptures, captures, parameters, body), spanValue) <- withSpan $ do
+        (explicitCaptures, captures) <- optionalCaptureList
+        _ <- symbol "\\"
+        parameters <- parseCallableParameters
+        _ <- symbol "->"
+        body <- parseCallableBody
+        pure (explicitCaptures, captures, parameters, body)
+    pure (CallableExpression spanValue explicitCaptures captures parameters body ())
+
+optionalCaptureList :: P (Bool, [Capture Identifier ()])
+optionalCaptureList = do
+    present <- peekText "["
+    if not present
+        then pure (False, [])
+        else do
+            _ <- symbol "["
+            empty <- peekText "]"
+            captures <- if empty then pure [] else separatedUntil "]" "," parseCapture
+            _ <- symbol "]"
+            pure (True, captures)
+
+parseCapture :: P (Capture Identifier ())
+parseCapture = do
+    modeToken <- optionalParser (keyword "weak" <|?> keyword "unowned")
+    (name, nameSpan) <- identifier
+    hasInitializer <- optionalSymbol "="
+    initializer <- if hasInitializer then Just <$> parseExpression else pure Nothing
+    let mode = case fmap tokenText modeToken of
+            Just "weak" -> WeakCapture
+            Just "unowned" -> UnownedCapture
+            _ -> StrongCapture
+        spanValue = maybe nameSpan (mergeSpan nameSpan . expressionSpan) initializer
+    pure (Capture spanValue mode name () initializer)
+
+parseCallableParameters :: P [Parameter Identifier ()]
+parseCallableParameters = do
+    parenthesized <- optionalSymbol "("
+    if parenthesized
+        then do
+            empty <- peekText ")"
+            parameters <- if empty then pure [] else separatedUntil ")" "," parseCallableParameter
+            _ <- symbol ")"
+            pure parameters
+        else do
+            arrow <- peekText "->"
+            if arrow then pure [] else separatedUntil "->" "," parseInferredCallableParameter
+
+parseCallableParameter :: P (Parameter Identifier ())
+parseCallableParameter = do
+    typed <- matchesAhead (optionalParameterLabel >> parseTypeSyntax >> identifier)
+    if typed then parseParameter else parseInferredCallableParameter
+
+parseInferredCallableParameter :: P (Parameter Identifier ())
+parseInferredCallableParameter = do
+    (name, spanValue) <- identifier
+    pure (Parameter spanValue name () AutoType)
+
+parseCallableBody :: P (CallableBody Identifier ())
+parseCallableBody = do
+    block <- peekText "{"
+    if block
+        then CallableBlockBody <$> parseBlock True
+        else CallableExpressionBody <$> parseExpression
+
+separatedUntil :: String -> String -> P a -> P [a]
+separatedUntil closing separator parser = do
+    first <- parser
+    more <- optionalSymbol separator
+    if more
+        then (first :) <$> separatedUntil closing separator parser
+        else do
+            done <- peekText closing
+            if done then pure [first] else failCurrent "TAP0009" ("expected " ++ show separator ++ " or " ++ show closing)
+
+expressionSpan :: Expression name annotation -> SourceSpan
+expressionSpan expression = case expression of
+    NameExpression value _ _ -> value
+    LiteralExpression value _ _ -> value
+    MemberAccessExpression value _ _ _ -> value
+    MethodReferenceExpression value _ _ _ -> value
+    CallExpression value _ _ _ -> value
+    UnaryExpression value _ _ _ -> value
+    BinaryExpression value _ _ _ _ -> value
+    IsPatternExpression value _ _ _ -> value
+    ConditionalExpression value _ _ _ _ -> value
+    CoalesceExpression value _ _ _ -> value
+    AssignmentExpression value _ _ _ _ -> value
+    IncrementExpression value _ _ _ -> value
+    LoopExpression value _ _ -> value
+    BlockExpression value _ _ -> value
+    MatchExpression value _ _ _ -> value
+    CallableExpression value _ _ _ _ _ -> value
+
+patternSpan :: Pattern name annotation -> SourceSpan
+patternSpan patternValue = case patternValue of
+    WildcardPattern value _ -> value
+    NullPattern value _ -> value
+    LiteralPattern value _ _ -> value
+    TypePattern value _ _ -> value
+    RelationalPattern value _ _ _ -> value
+    NotPattern value _ _ -> value
+    AndPattern value _ _ _ -> value
+    OrPattern value _ _ _ -> value
+
+separated :: String -> P a -> P [a]
+separated separator parser = do
+    done <- peekText ")"
+    if done
+        then pure []
+        else do
+            first <- parser
+            more <- optionalSymbol separator
+            if more then (first :) <$> separated separator parser else pure [first]
+identifier :: P (Identifier, SourceSpan)
+identifier = do
+    token <- satisfy ((== IdentifierToken) . tokenKind) "identifier"; pure (Identifier (tokenText token), tokenSpan token)
+mergeSpan :: SourceSpan -> SourceSpan -> SourceSpan
+mergeSpan left right = SourceSpan (sourceFile left) (sourceStart left) (sourceEnd right)
+ src/Progmasoft/Tala/Parser/Cursor.hs view
@@ -0,0 +1,200 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+module Progmasoft.Tala.Parser.Cursor
+    ( P
+    , runP
+    , (<|?>)
+    , optionalParser
+    , matchesAhead
+    , peekToken
+    , peekTokens
+    , peekText
+    , peekKind
+    , takeToken
+    , satisfy
+    , keyword
+    , contextualWord
+    , symbol
+    , optionalSymbol
+    , failCurrent
+    , failAt
+    , problemAt
+    , withSpan
+    , endOfInput
+    ) where
+
+import Progmasoft.Tala.AST (SourceSpan (..))
+import Progmasoft.Tala.Diagnostic
+import Progmasoft.Tala.Parser.Token
+
+-- Consumption is measured in tokens, independently of source spans. Synthetic
+-- input may contain identical spans, and decoded String payload lengths need
+-- not equal the source width. Neither is a safe backtracking checkpoint.
+data Cursor = Cursor
+    { remainingTokens :: [Token]
+    , consumedTokens :: !Int
+    , previousSpan :: Maybe SourceSpan
+    }
+
+data Reply a
+    = Accepted a Cursor
+    | Rejected Diagnostic !Int
+
+newtype P a = P {step :: Cursor -> Reply a}
+
+instance Functor P where
+    fmap function parser = P $ \cursor -> case step parser cursor of
+        Accepted value next -> Accepted (function value) next
+        Rejected problem offset -> Rejected problem offset
+
+instance Applicative P where
+    pure value = P (Accepted value)
+    functions <*> values = do
+        function <- functions
+        value <- values
+        pure (function value)
+
+instance Monad P where
+    parser >>= continuation = P $ \cursor -> case step parser cursor of
+        Accepted value next -> step (continuation value) next
+        Rejected problem offset -> Rejected problem offset
+
+runP :: P a -> [Token] -> Either Diagnostic (a, [Token])
+runP parser tokens = case step parser (Cursor tokens 0 Nothing) of
+    Accepted value cursor -> Right (value, remainingTokens cursor)
+    Rejected problem _ -> Left problem
+
+-- Alternatives may retry only when the first parser consumed nothing. Once a
+-- declaration keyword or delimiter has been read, its failure belongs to that
+-- construct. Falling back would replace the useful diagnostic with a guess.
+(<|?>) :: P a -> P a -> P a
+left <|?> right = P $ \cursor -> case step left cursor of
+    Rejected _ offset | offset == consumedTokens cursor -> step right cursor
+    result -> result
+
+optionalParser :: P a -> P (Maybe a)
+optionalParser parser = (Just <$> parser) <|?> pure Nothing
+
+-- Lookahead is deliberately explicit. It is used only to choose an ambiguous
+-- grammar branch and never exposes a parsed value whose cursor was discarded.
+matchesAhead :: P a -> P Bool
+matchesAhead parser = P $ \cursor -> case step parser cursor of
+    Accepted _ _ -> Accepted True cursor
+    Rejected _ _ -> Accepted False cursor
+
+peekToken :: P (Maybe Token)
+peekToken = P $ \cursor ->
+    Accepted
+        ( case remainingTokens cursor of
+            [] -> Nothing
+            token : _ -> Just token
+        )
+        cursor
+
+-- Bounded lookahead chooses ambiguous identifier-led statements without
+-- executing and rolling back a full expression or declaration parser.
+peekTokens :: Int -> P [Token]
+peekTokens count = P $ \cursor -> Accepted (take count (remainingTokens cursor)) cursor
+
+peekText :: String -> P Bool
+peekText text = maybe False matches <$> peekToken
+    where
+        matches token = tokenKind token `elem` [KeywordToken, SymbolToken] && tokenText token == text
+
+peekKind :: TokenKind -> P Bool
+peekKind kind = maybe False ((== kind) . tokenKind) <$> peekToken
+
+takeToken :: P Token
+takeToken = P $ \cursor -> case remainingTokens cursor of
+    token : rest ->
+        Accepted token (Cursor rest (consumedTokens cursor + 1) (Just (tokenSpan token)))
+    [] -> reject cursor "TAP0008" "unexpected end of input"
+
+satisfy :: (Token -> Bool) -> String -> P Token
+satisfy predicate expectation = do
+    next <- peekToken
+    case next of
+        Just token | predicate token -> takeToken
+        Just token -> failCurrent "TAP0006" ("expected " ++ expectation ++ ", found " ++ show (tokenText token))
+        Nothing -> failCurrent "TAP0007" ("expected " ++ expectation ++ " at end of input")
+
+-- Checking the kind as well as the spelling prevents decoded literals whose
+-- payload happens to be a keyword or delimiter from changing the grammar.
+keyword :: String -> P Token
+keyword text = satisfy matches (show text)
+    where
+        matches token =
+            tokenText token == text
+                && ( tokenKind token == KeywordToken
+                        || (text `elem` ["weak", "unowned"] && tokenKind token == IdentifierToken)
+                   )
+
+-- Contextual separators remain ordinary identifiers outside their owning
+-- syntax. This avoids reserving words such as `to` throughout the language.
+contextualWord :: String -> P Token
+contextualWord text =
+    satisfy
+        (\token -> tokenKind token `elem` [IdentifierToken, KeywordToken] && tokenText token == text)
+        (show text)
+
+symbol :: String -> P Token
+symbol text = satisfy (\token -> tokenKind token == SymbolToken && tokenText token == text) (show text)
+
+optionalSymbol :: String -> P Bool
+optionalSymbol text = do
+    next <- peekToken
+    case next of
+        Just token | tokenKind token == SymbolToken && tokenText token == text -> takeToken >> pure True
+        _ -> pure False
+
+failCurrent :: String -> String -> P a
+failCurrent code message = P $ \cursor -> reject cursor code message
+
+failAt :: SourceSpan -> String -> String -> P a
+failAt spanValue code message = P $ \cursor ->
+    Rejected (Diagnostic ParserStage Error code (Just spanValue) message) (consumedTokens cursor)
+
+reject :: Cursor -> String -> String -> Reply a
+reject cursor code message =
+    Rejected
+        (Diagnostic ParserStage Error code (currentSpan cursor) message)
+        (consumedTokens cursor)
+
+currentSpan :: Cursor -> Maybe SourceSpan
+currentSpan cursor = case remainingTokens cursor of
+    token : _ -> Just (tokenSpan token)
+    [] -> endSpan <$> previousSpan cursor
+
+endSpan :: SourceSpan -> SourceSpan
+endSpan value = value {sourceStart = sourceEnd value}
+
+problemAt :: Token -> String -> String -> Diagnostic
+problemAt token code message = Diagnostic ParserStage Error code (Just (tokenSpan token)) message
+
+-- Delimiters belong to the construct's span even when its body is empty.
+-- Capturing cursor positions also preserves the filename of empty closures.
+withSpan :: P a -> P (a, SourceSpan)
+withSpan parser = P $ \cursor -> case currentSpan cursor of
+    Nothing -> reject cursor "TAP0008" "expected construct at end of input"
+    Just start -> case step parser cursor of
+        Rejected problem offset -> Rejected problem offset
+        Accepted value next ->
+            let finish = maybe start id (previousSpan next)
+                spanValue = start {sourceEnd = sourceEnd finish}
+             in Accepted (value, spanValue) next
+
+-- The public Parser accepts token streams as well as lexer output. An EOF in
+-- the middle must never hide the suffix of a malformed supplied stream.
+endOfInput :: P ()
+endOfInput = do
+    next <- peekToken
+    case next of
+        Nothing -> pure ()
+        Just token | tokenKind token == EndOfFileToken -> do
+            _ <- takeToken
+            trailing <- peekToken
+            case trailing of
+                Nothing -> pure ()
+                Just extra -> failAt (tokenSpan extra) "TAP0015" "tokens follow the end-of-file marker"
+        Just token -> failAt (tokenSpan token) "TAP0001" ("unexpected token " ++ show (tokenText token))
+ src/Progmasoft/Tala/Parser/Match.hs view
@@ -0,0 +1,257 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | Grammar of @match@, of @if@ used as an expression, and of @guard@.
+
+These forms contain expressions, blocks, and types, which the main parser
+owns. They receive those parsers through 'BranchGrammar' instead of importing
+them, so the main parser can depend on this module without a cycle and each
+nested construct still has exactly one grammar.
+-}
+module Progmasoft.Tala.Parser.Match
+    ( BranchGrammar (..)
+    , parseCondition
+    , parseIfExpression
+    , parseMatchExpression
+    , parseGuardStatement
+    ) where
+
+import Progmasoft.Tala.AST
+import Progmasoft.Tala.Parser.Cursor
+import Progmasoft.Tala.Parser.Token
+
+-- | The parsers of the main grammar that the branching forms are built from.
+data BranchGrammar = BranchGrammar
+    { grammarExpression :: P (Expression Identifier ())
+    -- ^ A complete expression.
+    , grammarValueBlock :: P (Block Identifier ())
+    -- ^ A block that may end with an expression without a semicolon.
+    , grammarStatementBlock :: P (Block Identifier ())
+    -- ^ A block whose statements are all terminated.
+    , grammarType :: P TypeSyntax
+    -- ^ A type as written in a declaration.
+    , grammarLiteral :: P (Literal, SourceSpan)
+    -- ^ One literal token, decoded.
+    }
+
+{- | The condition of an @if@, @guard@, or @while@.
+
+The grammar also allows a binding, @auto user = FindUser()@, whose test is
+whether the bound value is present. That needs optional values, which the
+compiler does not have, so the binding form is recognized and rejected by
+name instead of failing later as a malformed expression.
+-}
+parseCondition :: BranchGrammar -> String -> P (Expression Identifier ())
+parseCondition grammar construct = do
+    binding <- matchesAhead (grammarType grammar >> identifierToken >> symbol "=")
+    if binding
+        then
+            failCurrent
+                "TAP0035"
+                ( "a binding in "
+                    ++ construct
+                    ++ " condition requires optional values, which are not implemented"
+                )
+        else grammarExpression grammar
+
+{- | @if (condition) { ... } else { ... }@ in operand position.
+
+Both branches are blocks and both are required: the expression must have a
+value whichever way the condition falls. The result is the conditional
+expression node with block operands, so the two spellings of a two-way choice
+share one typing rule and one lowering.
+-}
+parseIfExpression :: BranchGrammar -> P (Expression Identifier ())
+parseIfExpression grammar = do
+    ((condition, first, second), spanValue) <- withSpan $ do
+        _ <- keyword "if"
+        _ <- symbol "("
+        condition <- parseCondition grammar "an if"
+        _ <- symbol ")"
+        first <- valueBlock grammar
+        hasElse <- peekText "else"
+        if hasElse
+            then pure ()
+            else failCurrent "TAP0033" "an if used as an expression requires an else branch"
+        _ <- keyword "else"
+        chained <- peekText "if"
+        if chained
+            then failCurrent "TAP0034" "the else branch of an if used as an expression must be a block"
+            else pure ()
+        second <- valueBlock grammar
+        pure (condition, first, second)
+    pure (ConditionalExpression spanValue condition first second ())
+
+valueBlock :: BranchGrammar -> P (Expression Identifier ())
+valueBlock grammar = do
+    (block, spanValue) <- withSpan (grammarValueBlock grammar)
+    pure (BlockExpression spanValue block ())
+
+{- | @match (subject), ... { arm, ... }@.
+
+The statement and the expression have the same grammar; the caller decides
+which one it is from the position of the keyword.
+-}
+parseMatchExpression :: BranchGrammar -> P (Expression Identifier ())
+parseMatchExpression grammar = do
+    ((subjects, arms), spanValue) <- withSpan $ do
+        _ <- keyword "match"
+        subjects <- parseSubjects grammar
+        _ <- symbol "{"
+        arms <- parseArms grammar
+        _ <- symbol "}"
+        pure (subjects, arms)
+    pure (MatchExpression spanValue subjects arms ())
+
+-- Every subject has its own parentheses: @match (a), (b)@.
+parseSubjects :: BranchGrammar -> P [Expression Identifier ()]
+parseSubjects grammar = do
+    _ <- symbol "("
+    subject <- grammarExpression grammar
+    _ <- symbol ")"
+    more <- optionalSymbol ","
+    if more then (subject :) <$> parseSubjects grammar else pure [subject]
+
+parseArms :: BranchGrammar -> P [MatchArm Identifier ()]
+parseArms grammar = do
+    done <- peekText "}"
+    eof <- peekKind EndOfFileToken
+    if done
+        then pure []
+        else
+            if eof
+                then failCurrent "TAP0002" "unterminated match"
+                else (:) <$> parseArm grammar <*> parseArms grammar
+
+{- | One arm: @patterns [if guard] -> body [,]@.
+
+The comma is optional after every body, as in the grammar. An expression
+body is parsed like any expression, so a parenthesized pattern that follows
+it without a comma is read as the argument list of a call of the body. That
+reading is the grammar's own: the arrow that then follows cannot continue an
+expression, and the diagnostic for it says what happened instead of naming
+the arrow as unexpected.
+-}
+parseArm :: BranchGrammar -> P (MatchArm Identifier ())
+parseArm grammar = do
+    ((patterns, guard, body), spanValue) <- withSpan $ do
+        patterns <- parsePatterns grammar
+        guarded <- peekText "if"
+        guard <-
+            if guarded
+                then do
+                    _ <- keyword "if"
+                    Just <$> grammarExpression grammar
+                else pure Nothing
+        _ <- symbol "->"
+        blockBody <- peekText "{"
+        body <- if blockBody then valueBlock grammar else grammarExpression grammar
+        separated <- optionalSymbol ","
+        swallowed <- peekText "->"
+        if not blockBody && not separated && swallowed
+            then
+                failCurrent
+                    "TAP0038"
+                    "the pattern of this arm was read as part of the previous arm's body; write ',' after that body"
+            else pure ()
+        pure (patterns, guard, body)
+    pure (MatchArm spanValue patterns guard body)
+
+parsePatterns :: BranchGrammar -> P [MatchPattern Identifier ()]
+parsePatterns grammar = do
+    first <- parseMatchPattern grammar
+    more <- optionalSymbol ","
+    if more then (first :) <$> parsePatterns grammar else pure [first]
+
+{- | One pattern, optionally in its own parentheses.
+
+A bare name is not a pattern: a binding always states its type, as in
+@int value@, so a name can never be mistaken for a constant to compare with.
+A numeric literal may be preceded by a minus sign, which makes the constant
+negative; the range of the constant is checked against the subject's type by
+the type checker, so the most negative value of a signed type is a pattern
+although its magnitude alone is not a value of that type.
+-}
+parseMatchPattern :: BranchGrammar -> P (MatchPattern Identifier ())
+parseMatchPattern grammar = do
+    (build, spanValue) <- withSpan $ do
+        parenthesized <- optionalSymbol "("
+        build <- parseBarePattern grammar
+        if parenthesized then () <$ symbol ")" else pure ()
+        pure build
+    pure (build spanValue)
+
+parseBarePattern :: BranchGrammar -> P (SourceSpan -> MatchPattern Identifier ())
+parseBarePattern grammar = do
+    next <- peekToken
+    case next of
+        Just token
+            | isWildcard token -> do
+                _ <- takeToken
+                pure (\spanValue -> MatchWildcardPattern spanValue ())
+            | tokenKind token == KeywordToken && tokenText token == "null" -> do
+                _ <- takeToken
+                pure (\spanValue -> MatchNullPattern spanValue ())
+            | tokenKind token == SymbolToken && tokenText token == "." -> do
+                _ <- takeToken
+                name <- identifierToken
+                pure (\spanValue -> MatchCasePattern spanValue (Identifier (tokenText name)) ())
+            | isLiteralStart token -> do
+                (literal, _) <- grammarLiteral grammar
+                pure (\spanValue -> MatchLiteralPattern spanValue literal ())
+            -- A minus sign belongs to the numeric literal that follows it.
+            -- It is part of the constant, not an operator: no other
+            -- expression is a pattern.
+            | tokenKind token == SymbolToken && tokenText token == "-" -> do
+                _ <- takeToken
+                numeric <- peekToken
+                case numeric of
+                    Just following | tokenKind following `elem` [IntegerToken, FloatingToken] -> do
+                        (literal, _) <- grammarLiteral grammar
+                        pure (\spanValue -> MatchLiteralPattern spanValue (negated literal) ())
+                    _ -> failCurrent "TAP0036" "a '-' in a match pattern must be followed by a numeric literal"
+        _ -> do
+            typed <- matchesAhead (grammarType grammar >> identifierToken)
+            if not typed
+                then
+                    failCurrent
+                        "TAP0036"
+                        "expected a match pattern: a literal, '_', 'null', '.Case', or a type followed by a name or '_'"
+                else do
+                    syntax <- grammarType grammar
+                    name <- identifierToken
+                    let binding = if isWildcard name then Nothing else Just (Identifier (tokenText name))
+                    pure (\spanValue -> MatchTypePattern spanValue syntax binding ())
+    where
+        negated literal = case literal of
+            IntegerLiteral value -> IntegerLiteral (negate value)
+            FloatingLiteral spelling -> FloatingLiteral ('-' : spelling)
+            other -> other
+        isWildcard token = tokenKind token == IdentifierToken && tokenText token == "_"
+        isLiteralStart token =
+            tokenKind token `elem` [IntegerToken, FloatingToken, CharacterToken, StringToken]
+                || (tokenKind token == KeywordToken && tokenText token `elem` ["true", "false"])
+
+identifierToken :: P Token
+identifierToken = satisfy ((== IdentifierToken) . tokenKind) "identifier"
+
+{- | @guard (condition) else { ... }@.
+
+The block is an ordinary statement block. That it must leave the enclosing
+scope is a rule about control flow, which the type checker enforces.
+-}
+parseGuardStatement :: BranchGrammar -> P (Statement Identifier ())
+parseGuardStatement grammar = do
+    ((condition, block), spanValue) <- withSpan $ do
+        _ <- keyword "guard"
+        _ <- symbol "("
+        condition <- parseCondition grammar "a guard"
+        _ <- symbol ")"
+        hasElse <- peekText "else"
+        if hasElse
+            then pure ()
+            else failCurrent "TAP0037" "guard requires an else block"
+        _ <- keyword "else"
+        block <- grammarStatementBlock grammar
+        pure (condition, block)
+    pure (GuardStatement spanValue condition block)
+ src/Progmasoft/Tala/Parser/Token.hs view
@@ -0,0 +1,27 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+-- | Token types shared between the lexer, parser, and parser cursor.
+module Progmasoft.Tala.Parser.Token (TokenKind (..), Token (..)) where
+
+import Progmasoft.Tala.AST (SourceSpan)
+
+-- | Lexical category of one token emitted by the Tala scanner.
+data TokenKind
+    = IdentifierToken
+    | KeywordToken
+    | SymbolToken
+    | IntegerToken
+    | FloatingToken
+    | CharacterToken
+    | StringToken
+    | EndOfFileToken
+    deriving stock (Bounded, Enum, Eq, Ord, Read, Show)
+
+-- | Token spelling, category, and half-open source range.
+data Token = Token
+    { tokenKind :: TokenKind
+    , tokenText :: String
+    , tokenSpan :: SourceSpan
+    }
+    deriving stock (Eq, Ord, Read, Show)
+ src/Progmasoft/Tala/RuntimeCall.hs view
@@ -0,0 +1,310 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | The catalog of runtime calls.
+
+A runtime call is an operation the compiler does not lower to instructions
+but to a call of a function of the Tala runtime: joining two strings,
+converting a number for output, writing to the console. Core and the stages
+after it carry it as one operation whose first operand is an integer
+literal, the identity of the function, and whose remaining operands are the
+arguments.
+
+This module is what the frontend knows about each function: its identity,
+the types it takes and returns, and whether it does something that can be
+observed. The native stages have the same table in
+@Tala\/Core\/RuntimeCall.hpp@. The identities are part of the
+artifact formats: a function is never renumbered, and a new one takes the
+next free number. The tests of both sides pin every row.
+
+The catalog stands in the syntax package because the type checker, which
+chooses the function for a source construct, and Core, which verifies the
+call, both need it and neither depends on the other.
+-}
+module Progmasoft.Tala.RuntimeCall
+    ( RuntimeFunction (..)
+    , RuntimeParameter (..)
+    , RuntimeResult (..)
+    , runtimeFunctions
+    , runtimeFunctionIdentity
+    , runtimeFunctionOf
+    , runtimeFunctionSymbol
+    , runtimeParameters
+    , runtimeResult
+    , runtimeResultType
+    , runtimeObservable
+    , runtimeAccepts
+    , runtimeName
+    , runtimeFunctionOfName
+    , builtinSystemSymbol
+    , builtinConsoleSymbol
+    , RuntimeDefect (..)
+    , runtimeCallDefect
+    , runtimeDefectText
+    -- * Conversion flags
+    , flagLeft
+    , flagZero
+    , flagPlus
+    , flagSpace
+    , flagAlternate
+    , flagGroup
+    , flagHexadecimal
+    , absent
+    -- * Console targets
+    , consoleOutput
+    , consoleOutputLine
+    , consoleError
+    , consoleErrorLine
+    ) where
+
+import Progmasoft.Tala.AST
+
+-- | A function of the runtime that generated code may call.
+data RuntimeFunction
+    = -- | Two strings one after the other.
+      TextConcat
+    | -- | A signed integer in decimal.
+      TextFromSigned
+    | -- | An unsigned integer in decimal.
+      TextFromUnsigned
+    | -- | @true@ or @false@.
+      TextFromBool
+    | -- | The one character.
+      TextFromChar
+    | -- | @%d@ and @%x@ of a signed integer.
+      TextFormatSigned
+    | -- | @%u@ and @%x@ of an unsigned integer.
+      TextFormatUnsigned
+    | -- | @%f@.
+      TextFormatFloating
+    | -- | @%s@.
+      TextFormatString
+    | -- | @%c@.
+      TextFormatChar
+    | -- | @%n@: the line terminator of the platform.
+      TextNewline
+    | -- | Write a string to standard output or standard error.
+      ConsoleWrite
+    | -- | Whether two strings hold the same characters.
+      TextEquals
+    deriving (Bounded, Enum, Eq, Ord, Read, Show)
+
+{- | What an argument of a runtime function may be. A function takes a family
+of types where the language gives the operation to all of them; the backend
+widens the argument to the representation the runtime function is written
+for, which changes no value.
+-}
+data RuntimeParameter
+    = -- | A signed integer of at most 64 bits.
+      SignedParameter
+    | -- | An unsigned integer of at most 64 bits.
+      UnsignedParameter
+    | -- | A floating-point number of at most 64 bits.
+      FloatingParameter
+    | -- | @bool@.
+      BoolParameter
+    | -- | @char@.
+      CharParameter
+    | -- | @String@.
+      TextParameter
+    | -- | @int@: flags, a width or a precision.
+      CountParameter
+    deriving (Eq, Ord, Read, Show)
+
+-- | What a runtime function returns.
+data RuntimeResult
+    = -- | No value.
+      NoResult
+    | -- | A @String@ the caller owns.
+      TextResult
+    | -- | A @bool@.
+      TruthResult
+    deriving (Eq, Ord, Read, Show)
+
+-- | Every runtime function, in order of identity.
+runtimeFunctions :: [RuntimeFunction]
+runtimeFunctions = [minBound .. maxBound]
+
+-- | The identity the first operand of a call holds.
+runtimeFunctionIdentity :: RuntimeFunction -> Integer
+runtimeFunctionIdentity function = case function of
+    TextConcat -> 1
+    TextFromSigned -> 2
+    TextFromUnsigned -> 3
+    TextFromBool -> 4
+    TextFromChar -> 5
+    TextFormatSigned -> 6
+    TextFormatUnsigned -> 7
+    TextFormatFloating -> 8
+    TextFormatString -> 9
+    TextFormatChar -> 10
+    TextNewline -> 11
+    ConsoleWrite -> 12
+    TextEquals -> 13
+
+-- | The function with the given identity, when there is one.
+runtimeFunctionOf :: Integer -> Maybe RuntimeFunction
+runtimeFunctionOf identity = lookup identity [(runtimeFunctionIdentity function, function) | function <- runtimeFunctions]
+
+-- | The symbol of the runtime library that implements the function.
+runtimeFunctionSymbol :: RuntimeFunction -> String
+runtimeFunctionSymbol function = case function of
+    TextConcat -> "tala_text_concat"
+    TextFromSigned -> "tala_text_from_signed"
+    TextFromUnsigned -> "tala_text_from_unsigned"
+    TextFromBool -> "tala_text_from_bool"
+    TextFromChar -> "tala_text_from_char"
+    TextFormatSigned -> "tala_text_format_signed"
+    TextFormatUnsigned -> "tala_text_format_unsigned"
+    TextFormatFloating -> "tala_text_format_floating"
+    TextFormatString -> "tala_text_format_string"
+    TextFormatChar -> "tala_text_format_char"
+    TextNewline -> "tala_text_newline"
+    ConsoleWrite -> "tala_console_write"
+    TextEquals -> "tala_text_equals"
+
+-- | The arguments of the function, in order.
+runtimeParameters :: RuntimeFunction -> [RuntimeParameter]
+runtimeParameters function = case function of
+    TextConcat -> [TextParameter, TextParameter]
+    TextFromSigned -> [SignedParameter]
+    TextFromUnsigned -> [UnsignedParameter]
+    TextFromBool -> [BoolParameter]
+    TextFromChar -> [CharParameter]
+    TextFormatSigned -> conversion SignedParameter
+    TextFormatUnsigned -> conversion UnsignedParameter
+    TextFormatFloating -> conversion FloatingParameter
+    TextFormatString -> conversion TextParameter
+    TextFormatChar -> conversion CharParameter
+    TextNewline -> []
+    ConsoleWrite -> [TextParameter, CountParameter]
+    TextEquals -> [TextParameter, TextParameter]
+    where
+        -- Flags, width and precision, and then the value. The value stands
+        -- last because that is the order of a format's arguments: a width
+        -- or a precision written as @*@ is the argument before the value,
+        -- and the operands of a call are evaluated in order.
+        conversion value = [CountParameter, CountParameter, CountParameter, value]
+
+-- | What the function returns.
+runtimeResult :: RuntimeFunction -> RuntimeResult
+runtimeResult function = case function of
+    ConsoleWrite -> NoResult
+    TextEquals -> TruthResult
+    _ -> TextResult
+
+-- | The type a call of the function has.
+runtimeResultType :: RuntimeFunction -> Type
+runtimeResultType function = case runtimeResult function of
+    NoResult -> unitType
+    TextResult -> stringType
+    TruthResult -> boolType
+
+{- | Whether a call does something that can be observed apart from its
+result. Such a call is an effect: it happens where it is written, and it is
+never removed, repeated or moved.
+-}
+runtimeObservable :: RuntimeFunction -> Bool
+runtimeObservable function = function == ConsoleWrite
+
+-- | Whether an argument of the given type may stand where the parameter is.
+runtimeAccepts :: RuntimeParameter -> Type -> Bool
+runtimeAccepts parameter valueType = case parameter of
+    SignedParameter -> spelling `elem` ["byte", "short", "long", "int"]
+    UnsignedParameter -> spelling `elem` ["ubyte", "ushort", "ulong", "uint"]
+    FloatingParameter -> spelling `elem` ["sfloat", "lfloat", "float"]
+    BoolParameter -> spelling == "bool"
+    CharParameter -> spelling == "char"
+    TextParameter -> spelling == "String"
+    CountParameter -> spelling == "int"
+    where
+        spelling = case valueType of
+            NamedType (QualifiedName [Identifier name]) [] -> name
+            _ -> ""
+
+{- | The name a call of a runtime function has in the typed tree.
+
+The type checker rewrites a source construct that needs the runtime into a
+call of this name, and the lowering to Core turns a call of it into a
+runtime call. Its symbol is reserved: the renamer gives every declaration of
+a program a positive symbol, so no source name is ever one of these. The
+spelling begins with a character no identifier may contain.
+-}
+runtimeName :: RuntimeFunction -> ResolvedName
+runtimeName function =
+    ResolvedName (SymbolId (negate (runtimeNameBase + fromInteger (runtimeFunctionIdentity function)))) (Identifier ('$' : runtimeFunctionSymbol function))
+
+-- | The runtime function a name of the typed tree stands for, if it is one.
+runtimeFunctionOfName :: ResolvedName -> Maybe RuntimeFunction
+runtimeFunctionOfName name =
+    let value = negate (symbolIdValue (resolvedSymbol name)) - runtimeNameBase
+     in if value > 0 then runtimeFunctionOf (toInteger value) else Nothing
+
+runtimeNameBase :: Int
+runtimeNameBase = 1000
+
+{- | The symbols of the two names the language declares for every program:
+@System@ and, because @System@ is imported implicitly, @Console@. A
+declaration of the program with one of these names shadows it.
+-}
+builtinSystemSymbol, builtinConsoleSymbol :: SymbolId
+builtinSystemSymbol = SymbolId (-2)
+builtinConsoleSymbol = SymbolId (-3)
+
+-- | Why a runtime call is malformed.
+data RuntimeDefect
+    = -- | No first operand, or one that names no function of the catalog.
+      DefectiveIdentity
+    | -- | The number of arguments is not the number the function takes.
+      DefectiveArity
+    | -- | An argument has a type its parameter does not accept.
+      DefectiveArgument
+    | -- | The call does not have the type the function returns.
+      DefectiveResult
+    deriving (Eq, Ord, Read, Show)
+
+{- | Check a runtime call: the function its first operand names, if it names
+one, the types of the arguments after it, and the type of the call.
+-}
+runtimeCallDefect :: Maybe RuntimeFunction -> [Type] -> Type -> Maybe RuntimeDefect
+runtimeCallDefect named arguments resultType = case named of
+    Nothing -> Just DefectiveIdentity
+    Just function
+        | length arguments /= length (runtimeParameters function) -> Just DefectiveArity
+        | not (and (zipWith runtimeAccepts (runtimeParameters function) arguments)) -> Just DefectiveArgument
+        | resultType /= runtimeResultType function -> Just DefectiveResult
+        | otherwise -> Nothing
+
+-- | What is wrong with a call, in the words every stage reports.
+runtimeDefectText :: RuntimeDefect -> String
+runtimeDefectText defect = case defect of
+    DefectiveIdentity -> "runtime call must begin with an integer literal that names a function of the runtime catalog"
+    DefectiveArity -> "runtime call has the wrong number of arguments for its function"
+    DefectiveArgument -> "runtime call argument has a type its function does not take"
+    DefectiveResult -> "runtime call does not have the type its function returns"
+
+{- | The flags of a conversion, combined by addition in the @flags@ argument
+of the formatting functions. The values are those of @TALA_TEXT_FLAG_*@ in
+the runtime header.
+-}
+flagLeft, flagZero, flagPlus, flagSpace, flagAlternate, flagGroup, flagHexadecimal :: Integer
+flagLeft = 1
+flagZero = 2
+flagPlus = 4
+flagSpace = 8
+flagAlternate = 16
+flagGroup = 32
+flagHexadecimal = 64
+
+-- | A width or a precision that a conversion does not have.
+absent :: Integer
+absent = -1
+
+{- | Where a console write goes and whether a line ends after it: the second
+argument of 'ConsoleWrite'. The values are those of @TALA_CONSOLE_*@.
+-}
+consoleOutput, consoleOutputLine, consoleError, consoleErrorLine :: Integer
+consoleOutput = 0
+consoleOutputLine = 1
+consoleError = 2
+consoleErrorLine = 3
+ src/Progmasoft/Tala/SourceText.hs view
@@ -0,0 +1,230 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | Lossless lexical structure for source-oriented tools.
+
+The semantic lexer intentionally decodes literals and discards trivia. That is
+the correct representation for parsing, but it is unsafe for formatters and
+editor tools: rebuilding source from semantic tokens can change escape spelling,
+comment delimiters, or raw-string contents. This module provides the parallel
+lossless boundary. Every fragment retains its exact source text and half-open
+source span, and concatenating the fragments always reconstructs the input.
+-}
+module Progmasoft.Tala.SourceText
+    ( SourceFragmentKind (..)
+    , SourceFragment (..)
+    , scanSourceFragments
+    , reconstructSource
+    , maskedSource
+    , protectedLineNumbers
+    ) where
+
+import Data.List (stripPrefix)
+import Progmasoft.Tala.AST (SourcePosition (..), SourceSpan (..))
+import Progmasoft.Tala.Diagnostic
+
+-- | Kind of fragment retained by the lossless source scanner.
+data SourceFragmentKind
+    = CodeFragment
+    | LineCommentFragment
+    | LongCommentFragment
+    | StringLiteralFragment
+    | CharacterLiteralFragment
+    | RawStringLiteralFragment
+    deriving stock (Bounded, Enum, Eq, Ord, Read, Show)
+
+-- | Exact fragment text and its original half-open source span.
+data SourceFragment = SourceFragment
+    { sourceFragmentKind :: SourceFragmentKind
+    , sourceFragmentText :: String
+    , sourceFragmentSpan :: SourceSpan
+    }
+    deriving stock (Eq, Ord, Read, Show)
+
+-- | Split source without normalizing or decoding any character.
+scanSourceFragments :: FilePath -> String -> Either [Diagnostic] [SourceFragment]
+scanSourceFragments file = go (SourcePosition 1 1) Nothing []
+    where
+        go _ pending output [] = Right (reverse (flushPending file pending output))
+        go position pending output input
+            | Just remaining <- stripPrefix "--" input =
+                let flushed = flushPending file pending output
+                 in case longBracketOpener remaining of
+                        Just (level, opener, content) -> do
+                            let prefix = "--" ++ opener
+                                afterPrefix = advanceText position prefix
+                            (text, end, rest) <- consumeLong file LongCommentFragment position afterPrefix prefix level content
+                            go end Nothing (fragment file LongCommentFragment position end text : flushed) rest
+                        Nothing ->
+                            let (body, rest) = break isLineBreak remaining
+                                text = "--" ++ body
+                                end = advanceText position text
+                             in go end Nothing (fragment file LineCommentFragment position end text : flushed) rest
+            | Just (level, opener, content) <- longBracketOpener input = do
+                let flushed = flushPending file pending output
+                    afterPrefix = advanceText position opener
+                (text, end, rest) <- consumeLong file RawStringLiteralFragment position afterPrefix opener level content
+                go end Nothing (fragment file RawStringLiteralFragment position end text : flushed) rest
+            | '"' : remaining <- input = do
+                let flushed = flushPending file pending output
+                    afterQuote = advanceText position "\""
+                (text, end, rest) <- consumeQuoted file StringLiteralFragment '"' position afterQuote "\"" remaining
+                go end Nothing (fragment file StringLiteralFragment position end text : flushed) rest
+            | '\'' : remaining <- input = do
+                let flushed = flushPending file pending output
+                    afterQuote = advanceText position "'"
+                (text, end, rest) <- consumeQuoted file CharacterLiteralFragment '\'' position afterQuote "'" remaining
+                go end Nothing (fragment file CharacterLiteralFragment position end text : flushed) rest
+            | otherwise =
+                let (unit, rest, end) = consumeSourceUnit position input
+                    nextPending = appendPending position unit pending
+                 in go end nextPending output rest
+
+appendPending :: SourcePosition -> String -> Maybe (SourcePosition, String) -> Maybe (SourcePosition, String)
+appendPending position text Nothing = Just (position, reverse text)
+appendPending _ text (Just (start, reversed)) = Just (start, reverse text ++ reversed)
+
+flushPending :: FilePath -> Maybe (SourcePosition, String) -> [SourceFragment] -> [SourceFragment]
+flushPending _ Nothing output = output
+flushPending file (Just (start, reversed)) output =
+    let text = reverse reversed
+        end = advanceText start text
+     in fragment file CodeFragment start end text : output
+
+fragment :: FilePath -> SourceFragmentKind -> SourcePosition -> SourcePosition -> String -> SourceFragment
+fragment file kind start end text = SourceFragment kind text (SourceSpan file start end)
+
+consumeQuoted ::
+    FilePath ->
+    SourceFragmentKind ->
+    Char ->
+    SourcePosition ->
+    SourcePosition ->
+    String ->
+    String ->
+    Either [Diagnostic] (String, SourcePosition, String)
+consumeQuoted file kind delimiter start = loop
+    where
+        loop position _ [] = Left [unterminated file kind start position]
+        loop position reversed input@(character : remaining)
+            | character == delimiter =
+                let end = advanceText position [delimiter]
+                 in Right (reverse (delimiter : reversed), end, remaining)
+            | character == '\\' = case remaining of
+                [] -> Left [unterminated file kind start position]
+                _ ->
+                    let slashEnd = advanceText position "\\"
+                        (escaped, rest, end) = consumeSourceUnit slashEnd remaining
+                     in loop end (reverse escaped ++ '\\' : reversed) rest
+            | otherwise =
+                let (unit, rest, end) = consumeSourceUnit position input
+                 in loop end (reverse unit ++ reversed) rest
+
+consumeLong ::
+    FilePath ->
+    SourceFragmentKind ->
+    SourcePosition ->
+    SourcePosition ->
+    String ->
+    Int ->
+    String ->
+    Either [Diagnostic] (String, SourcePosition, String)
+consumeLong file kind start initialPosition prefix initialLevel initialInput =
+    loop initialPosition (reverse prefix) initialLevel initialInput
+    where
+        closer currentLevel = ']' : replicate currentLevel '=' ++ "]"
+        loop currentPosition reversedPrefix currentLevel currentInput
+            | Just remaining <- stripPrefix (closer currentLevel) currentInput =
+                let closing = closer currentLevel
+                    end = advanceText currentPosition closing
+                 in Right (reverse reversedPrefix ++ closing, end, remaining)
+            | null currentInput = Left [unterminated file kind start currentPosition]
+            | otherwise =
+                let (unit, rest, end) = consumeSourceUnit currentPosition currentInput
+                 in loop end (reverse unit ++ reversedPrefix) currentLevel rest
+
+unterminated :: FilePath -> SourceFragmentKind -> SourcePosition -> SourcePosition -> Diagnostic
+unterminated file kind start end =
+    Diagnostic
+        LexerStage
+        Error
+        (diagnosticCodeFor kind)
+        (Just (SourceSpan file start end))
+        ("unterminated " ++ description kind)
+    where
+        diagnosticCodeFor StringLiteralFragment = "TAL0101"
+        diagnosticCodeFor CharacterLiteralFragment = "TAL0102"
+        diagnosticCodeFor RawStringLiteralFragment = "TAL0103"
+        diagnosticCodeFor LongCommentFragment = "TAL0104"
+        diagnosticCodeFor _ = "TAL0105"
+        description StringLiteralFragment = "string literal"
+        description CharacterLiteralFragment = "character literal"
+        description RawStringLiteralFragment = "raw string literal"
+        description LongCommentFragment = "long comment"
+        description _ = "source fragment"
+
+longBracketOpener :: String -> Maybe (Int, String, String)
+longBracketOpener ('[' : remaining) =
+    let (equals, suffix) = span (== '=') remaining
+     in case suffix of
+            '[' : content ->
+                let opener = '[' : equals ++ "["
+                 in Just (length equals, opener, content)
+            _ -> Nothing
+longBracketOpener _ = Nothing
+
+consumeSourceUnit :: SourcePosition -> String -> (String, String, SourcePosition)
+consumeSourceUnit (SourcePosition line _) ('\r' : '\n' : remaining) =
+    ("\r\n", remaining, SourcePosition (line + 1) 1)
+consumeSourceUnit (SourcePosition line _) ('\r' : remaining) =
+    ("\r", remaining, SourcePosition (line + 1) 1)
+consumeSourceUnit (SourcePosition line _) ('\n' : remaining) =
+    ("\n", remaining, SourcePosition (line + 1) 1)
+consumeSourceUnit (SourcePosition line column) (character : remaining) =
+    ([character], remaining, SourcePosition line (column + 1))
+consumeSourceUnit position [] = ("", [], position)
+
+advanceText :: SourcePosition -> String -> SourcePosition
+advanceText position [] = position
+advanceText position input =
+    let (_, remaining, next) = consumeSourceUnit position input
+     in advanceText next remaining
+
+isLineBreak :: Char -> Bool
+isLineBreak character = character == '\r' || character == '\n'
+
+-- | Reassemble the original source by concatenating fragment text.
+reconstructSource :: [SourceFragment] -> String
+reconstructSource = concatMap sourceFragmentText
+
+{- | Retain code and physical line endings while hiding protected payloads.
+The result has the same character count and line structure as the source.
+-}
+maskedSource :: [SourceFragment] -> String
+maskedSource = concatMap mask
+    where
+        mask value
+            | sourceFragmentKind value == CodeFragment = sourceFragmentText value
+            | otherwise = map hide (sourceFragmentText value)
+        hide character
+            | isLineBreak character = character
+            | otherwise = ' '
+
+{- | Lines crossed by a multi-line protected fragment cannot be reindented or
+trimmed without potentially changing literal/comment payload text.
+-}
+protectedLineNumbers :: [SourceFragment] -> [Int]
+protectedLineNumbers = deduplicate . concatMap coveredLines
+    where
+        coveredLines value
+            | sourceFragmentKind value == CodeFragment = []
+            | startLine == endLine = []
+            | otherwise = [startLine .. endLine]
+            where
+                SourcePosition startLine _ = sourceStart (sourceFragmentSpan value)
+                SourcePosition endLine _ = sourceEnd (sourceFragmentSpan value)
+        -- Fragments are emitted in source order, so covered line ranges are also
+        -- ordered and can overlap only at boundaries. Adjacent deduplication keeps
+        -- this operation linear even for very large raw literals.
+        deduplicate [] = []
+        deduplicate (value : remaining) = value : deduplicate (dropWhile (== value) remaining)
+ src/Progmasoft/Tala/SourceWarnings.hs view
@@ -0,0 +1,101 @@+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+{- | Warnings about source text that is valid but probably not what its
+author meant.
+
+A warning never changes what a program means and never rejects it. The only
+warning today concerns the spelling of the removed decrement operator.
+Tala has no decrement: @--@ starts a comment wherever it stands outside
+a string. Text written for an earlier compiler, or by habit from another
+language, may still say @count--;@ or @--count;@. Both are now a comment,
+and the statement terminator after them is part of that comment, so the
+program can keep compiling with a different meaning. This module points at
+those two spellings.
+-}
+module Progmasoft.Tala.SourceWarnings
+    ( sourceWarnings
+    , decrementSpellingCode
+    ) where
+
+import Data.Char (isAlpha, isAlphaNum, isSpace)
+import Progmasoft.Tala.AST (SourcePosition (..), SourceSpan (..))
+import Progmasoft.Tala.Diagnostic
+import Progmasoft.Tala.SourceText
+
+-- | Code of the warning for a comment that looks like a decrement.
+decrementSpellingCode :: String
+decrementSpellingCode = "TAL0009"
+
+{- | Warnings for one source text, in source order.
+
+A text the lossless scanner rejects has no warnings: its lexical error is
+reported by the lexer, and a warning about the same text would only repeat
+it.
+-}
+sourceWarnings :: FilePath -> String -> [Diagnostic]
+sourceWarnings file text = case scanSourceFragments file text of
+    Left _ -> []
+    Right fragments -> concat (zipWith decrementSpelling (Nothing : map Just fragments) fragments)
+
+{- | Warn when a line comment has one of the two decrement spellings.
+
+* Attached: the comment starts directly after a name, a closing parenthesis
+  or a closing bracket, as in @count--;@.
+* Leading: the comment text is a name directly after @--@, followed only by
+  a statement terminator, as in @--count;@.
+
+Documentation comments and long comments are never decrement spellings, and
+a comment separated from the code by a space is an ordinary comment.
+-}
+decrementSpelling :: Maybe SourceFragment -> SourceFragment -> [Diagnostic]
+decrementSpelling previous fragment
+    | sourceFragmentKind fragment /= LineCommentFragment = []
+    | otherwise = case sourceFragmentText fragment of
+        '-' : '-' : body
+            | isDocumentation body -> []
+            | attached -> [warning attachedMessage]
+            | isLeadingName body -> [warning leadingMessage]
+        _ -> []
+    where
+        attached = case previous of
+            Just code
+                | sourceFragmentKind code == CodeFragment ->
+                    case reverse (sourceFragmentText code) of
+                        final : _ -> isAlphaNum final || final `elem` "_)]"
+                        [] -> False
+            _ -> False
+        warning = Diagnostic LexerStage Warning decrementSpellingCode (Just signSpan)
+        start = sourceStart (sourceFragmentSpan fragment)
+        signSpan =
+            SourceSpan
+                (sourceFile (sourceFragmentSpan fragment))
+                start
+                (SourcePosition (sourceLine start) (sourceColumn start + 2))
+
+isDocumentation :: String -> Bool
+isDocumentation body = case body of
+    '|' : _ -> True
+    '!' : _ -> True
+    _ -> False
+
+-- | Whether a comment body is exactly a name and a statement terminator.
+isLeadingName :: String -> Bool
+isLeadingName body = case body of
+    first : remaining
+        | isAlpha first || first == '_' ->
+            let afterName = dropWhile (\character -> isAlphaNum character || character == '_') remaining
+             in case dropWhile isSpace afterName of
+                    ';' : rest -> all isSpace rest
+                    _ -> False
+    _ -> False
+
+attachedMessage :: String
+attachedMessage =
+    "'--' starts a comment here and does not decrement: Tala has no decrement operator. "
+        ++ "Write 'name -= 1', or put a space before '--' if a comment is intended"
+
+leadingMessage :: String
+leadingMessage =
+    "'--' starts a comment here and does not decrement: Tala has no decrement operator. "
+        ++ "Write 'name -= 1', or put a space after '--' if a comment is intended"
+ tala-syntax.cabal view
@@ -0,0 +1,57 @@+cabal-version: 3.12
+-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+name: tala-syntax
+version: 0.1.0
+synopsis: Tala source model, lexer, parser, and diagnostics
+description:
+    The source model of Tala: source text and positions, the lexer, the
+    parser, the parsed syntax tree and diagnostics.
+    .
+    This is an implementation package of the Tala compiler. Its modules may
+    change in any release; tools should depend on tala-tooling instead.
+    .
+    The source is also offered under the Progmasoft Linking Exception 1.1
+    (LICENSE-EXCEPTION), an additional permission to MPL-2.0 for works that
+    combine this package with independent code.
+license: MPL-2.0
+license-files:
+    LICENSE
+    LICENSE-EXCEPTION
+author: Progmasoft
+maintainer: support@progmasoft.com
+homepage: https://tala.run/
+bug-reports: https://github.com/Progmasoft/tala/issues
+category: Language, Compiler
+tested-with: GHC ==9.10.3
+build-type: Simple
+
+library
+    exposed-modules:
+        Progmasoft.Tala.AST
+        Progmasoft.Tala.CharacterLiteral
+        Progmasoft.Tala.Diagnostic
+        Progmasoft.Tala.Diagnostic.Protocol
+        Progmasoft.Tala.FloatingLiteral
+        Progmasoft.Tala.Lexer
+        Progmasoft.Tala.Completion
+        Progmasoft.Tala.NestingLimits
+        Progmasoft.Tala.NumericLiteral
+        Progmasoft.Tala.Parser
+        Progmasoft.Tala.RuntimeCall
+        Progmasoft.Tala.SourceText
+        Progmasoft.Tala.SourceWarnings
+    other-modules:
+        Progmasoft.Tala.Parser.Cursor
+        Progmasoft.Tala.Parser.Match
+        Progmasoft.Tala.Parser.Token
+    hs-source-dirs: src
+    build-depends:
+        base >=4.20 && <4.23,
+        bytestring >=0.12 && <0.14
+    default-language: GHC2024
+
+source-repository head
+    type: git
+    location: https://github.com/Progmasoft/tala
+    subdir: Talac/Haskell/Syntax