18 KiB
Code style: namespacing and structure
The operational namespacing discipline for TileKit and the wider Tiledown codebase: folder mirroring, file naming, anchor placement, one-type-per-file, reserved names, namespace-vs-module collisions.
Swift does not have true namespaces, so we simulate them with root types and extensions.
For the narrower question "should the anchor be enum, struct, or class?", see namespacing.md (the type-choice canonical rule). This file covers the broader operational discipline.
Namespacing discipline (mandatory)
Every public implementation type lives under a struct or enum namespace that
mirrors its folder on disk. The named exception is the foundation-only
SharedProtocols target: its cross-target protocols, small contract values, and
null/preview conformers stay at file scope, because the module is their namespace.
The qualified name otherwise carries module + folder + role; reading
Module.Sub.Leaf should be enough to know where the type lives and what it does.
This applies to every Swift project. The TileDown-style root + sub-namespace pattern below is one specific instance of this discipline.
Core rules
-
One named exception. Outside
SharedProtocols, everypublic struct / enum / class / actor / protocoldeclared at file scope MUST be wrapped in anextension <Namespace> { ... }block (or declared inside a nested type).SharedProtocolskeeps its contract protocols, supporting value types, and null/preview conformers top-level; concrete live implementations never use this exception. -
Mirror the folder. When a folder
Sources/Foo/Bar/holds a typeX, the type lives atFoo.Bar.X. The namespace tree is a literal reflection of the file system tree. -
No name repetition. When the parent namespace already supplies a word, drop it from the type's bare name. Names should not carry redundant context.
Before After Reason TileErrorTile.Errorparent says TileRenderProgressRender.Progressparent says RenderParserTokenParser.Tokenparent says ParserBuildCommandBuild.Command.Runboth BuildandCommandare suppliedSiteBuildCommandBuild.Command.Sitedrops BuildANDCommand -
Subnamespace anchor. Each subnamespace MUST be declared exactly once in a single anchor file (typically
<Sub>.swiftnext to its sibling subnamespaces). Empty enums are the canonical form. The root namespace file is a map, not an implementation. -
Concrete types via extensions. Types implement via
extension Namespace.Sub { public struct/enum/class/actor/protocol X { ... } }. Conformances (Codable,Sendable,Identifiable, etc.) MAY be separate extensions on the qualified path:extension Namespace.Sub.X: Codable { ... }. -
public extensionfor protocol defaults. Default-implementation extensions on a nested protocol use the qualified path:public extension Module.Sub.MyProtocol { func defaultImpl() { ... } }. Bareextension MyProtocolno longer resolves once the type is nested.
Cross-cutting namespaces
Some concepts span multiple modules, e.g. Tile touches parsing, rendering, indexing, and formatting. When the cross-cutting concept is more semantically primary than the per-module organisation, lift the related types into a cross-cutting namespace with a sub-namespace per source module:
Tile
├─ Parse (was Parse.TileParser)
│ └─ Parser
├─ Core (was Core.TileCatalog etc.)
│ ├─ Catalog
│ ├─ Entry
│ └─ Statistics
├─ Render (was Render.TileRenderService etc.)
│ ├─ Service
│ ├─ Query
│ └─ Result
├─ Index (was the entire TileIndex SPM target)
└─ Format (was Format.Tile*)
├─ Markdown
├─ JSON
└─ Text
The sub-namespace name preserves provenance (which module the type used to live in). The cross-cutting namespace lives in the lowest common ancestor that every consumer already imports, typically a Shared* foundation target.
Command-tool pattern
Subcommand types in CLI binaries (AsyncParsableCommand conformers) nest under a Command namespace:
// In the main CLI binary:
enum Command {} // declared in Commands/Command.swift
extension Command {
struct Build: AsyncParsableCommand { ... } // was BuildCommand
struct Serve: AsyncParsableCommand { ... } // was ServeCommand
struct Watch: AsyncParsableCommand { ... } // was WatchCommand
}
// In sibling CLI binaries, the outer namespace is the tool's own name:
enum Release {
enum Command {} // declared in ReleaseTool/Release.swift
}
extension Release.Command {
struct Bump: AsyncParsableCommand { ... } // was BumpCommand
struct Database: AsyncParsableCommand { ... } // was DatabaseReleaseCommand
}
The root dispatcher (the @main AsyncParsableCommand that holds subcommands: [...]) stays at file scope; it's not a subcommand. swift-argument-parser conformance stays on every struct.
Namespace-vs-module collisions
When a nested namespace shares a name with an imported SPM target (e.g. the CLI declares Command.Render but also imports the Render SPM target), Swift's name lookup checks enclosing types before imported modules. Bare Render.Type inside any extension Command { ... } scope resolves to the local subcommand struct, not the module.
Fix: declare one module-internal typealias in a small alias file at the consuming target's root, pinning the SPM target under a renamed identifier:
// Sources/CLI/RenderModuleAlias.swift
import Render
typealias RenderModule = Render
Then call sites in the affected target write RenderModule.Type for module-level references. One declaration covers every consumer in the target; no per-file typealiases.
Same pattern for any Module / Namespace name clash.
Same leaf name under different roots
Two unrelated namespaces under different roots MAY share a leaf name. Example: Parser.CSV (top-level format-parser namespace holding CSV/HTML/XML/etc.) plus Core.Parser.Encoding (parser-primitives sub-namespace under Core). Swift resolves on the fully-qualified path; the compiler never gets confused. The only friction is the cognitive cost at the callsite of remembering which root a reader means.
When this emerges, prefer renaming one of the two roots to a more specific concept (e.g. Core/Parser/ to Core/Parsing/ so the leaf names diverge) over inserting per-file disambiguating typealiases. Typealiases create a per-target rename that has to be maintained; folder renames are cheap once-only moves.
If renaming is genuinely not worth it (e.g. the two roots have stable independent meanings and most readers never see them in the same file), the collision is acceptable. The case is cognitive, not syntactic.
Reserved names
Protocolis a reserved member on every type expression (the metatype literalT.Protocol). A namespace enum cannot be namedProtocol. UseProtocols(plural),Wire, orSpec. The folder on disk MAY still be namedProtocol/; only the Swift-level namespace name diverges.Erroras a nested type name collides withSwift.Errorincatchpatterns. Choices:- Keep as a sibling:
Module.<Type>Error(e.g.Tile.RenderError) - Or nest with full Swift.Error qualification:
extension Module.<Type> { public enum Error: Swift.Error, ... }, and usecatch let error as Module.<Type>.Errorexplicitly at call sites
- Keep as a sibling:
- Self-naming sub-namespace conflict. If a sub-namespace and the type inside share a name (e.g.
Transportnamespace holding aTransportprotocol), rename the inner type (Transport.Channel,Transport.Service). Don't tryTransport.Transport.
Multi-target and mono-repo behaviour
Multiple SwiftPM targets MAY contribute additional concrete types under the same shared root namespace via extensions:
TileDown.Parser.Model.Token // from TileKitCore
TileDown.Generator.Command // from TileKitCLI
TileDown.Parser.Scanning.Scanner // from TileKitTools
Targets MUST NOT redefine conflicting namespace trees, and the root namespace meaning MUST be identical across all modules.
Root + sub-namespace pattern (TileDown style)
This is one specific instance of the namespacing discipline above. Use it as the canonical example when designing a new project's namespace tree.
A project MAY use a hierarchical namespace tree where:
- A root namespace enum defines the domain
- Sub-namespaces are nested enums inside it
- All concrete types live in extensions on the leaf namespace
Root namespace with nested sub-namespaces
The root namespace file MUST define the full tree of namespace enums, and MUST contain only namespaces (no concrete types).
Example:
/// Root namespace for all TileDown types across all modules
public enum TileDown {
/// Model types for tile metadata and structure
public enum Model {}
/// Parser types for processing tiles
public enum Parser {
/// Model types for parsing (tokens, transformations, etc.)
public enum Model {}
/// Metadata parsing types
public enum Metadata {}
/// Resolution and variable context types
public enum Resolution {}
/// Scanning and tile discovery types
public enum Scanning {}
/// Tree building and hierarchy types
public enum Tree {}
/// Content and variable parsing types
public enum Content {}
}
/// Generator types for writing output
public enum Generator {}
}
Rules:
- Root namespace = one file (e.g.
TileDown.swift) - Only namespace enums inside (no concrete logic)
- No stored properties, no methods, no initializers, no nested concrete types
- Doc comments are allowed and encouraged
This file is a map, not an implementation.
Concrete types via extensions on leaf namespaces
Concrete model, parsing, resolving, generating, or utility types MUST be implemented using extension on the leaf namespace where the type belongs.
Example:
// Token.swift
import Foundation
import Models
extension TileDown.Parser.Model {
/// Represents a parsed piece of tile content
public enum Token: Equatable, Sendable {
case text(String)
case variable(VariableToken)
}
}
extension TileDown.Parser.Model.Token: CustomStringConvertible {
public var description: String {
switch self {
case .text(let str): return "Text(\"\(str)\")"
case .variable(let token): return "Variable(\(token))"
}
}
}
Rules:
- Implementation and conformances MUST be extensions
- Type lives under the correct semantic path, e.g.:
TileDown.ModelTileDown.Parser.ModelTileDown.Parser.MetadataTileDown.Generator
- Conformances (
Codable,Sendable,CustomStringConvertible, etc.) MAY be separate extensions
File layout for TileDown-style namespacing
Files SHOULD be placed to reflect the namespace tree. Two equivalent conventions:
Folder-based approach: the folder tree mirrors the namespace tree, one file per leaf type:
TileDown/
TileDown.swift // namespace anchor: public enum TileDown {}
Parser/Model/Token.swift // extension TileDown.Parser.Model { public enum Token {} }
Parser/Scanning/Scanner.swift // extension TileDown.Parser.Scanning { public struct Scanner {} }
Generator/Writer.swift // extension TileDown.Generator { public struct Writer {} }
<Namespace>.<Type>.swift approach: flat folder, file name encodes the namespace path with dots:
TileDown.swift // namespace anchor: public enum TileDown {}
TileDown.Parser.Model.Token.swift // extension TileDown.Parser.Model { public enum Token {} }
TileDown.Parser.Metadata.Header.swift // extension TileDown.Parser.Metadata { public struct Header {} }
Within a single SPM target, pick ONE pattern and stay with it.
File-naming rule for namespace extensions (mandatory)
When a file contains extension <Namespace> { public X <Type> { ... } }, the file name MUST be <Namespace>.<Type>.swift, the dot-separated qualified path of the type. This applies regardless of whether the type would also be unique without the .<Type> suffix.
Examples:
extension Indexer { public enum TilesService { ... } }toIndexer.TilesService.swiftextension Indexer { public enum PackagesService { ... } }toIndexer.PackagesService.swiftextension Tile.Index { public actor Builder { ... } }toTile.Index.Builder.swiftextension Core.Protocols { public protocol ContentFetcher { ... } }toCore.Protocols.ContentFetcher.swift
Dots match the Swift qualified name exactly: Tile.Index.Builder lives at Tile.Index.Builder.swift. The older <Namespace>+<Type>.swift convention (with a + separator) is deprecated; new files MUST use dots, and pre-existing + files SHOULD be renamed when touched.
Why uniformity even when not strictly needed:
- The namespace path is visible from the file listing without opening any file.
- Adding a second type to the same namespace later doesn't require renaming the first.
- Search and tooling (Xcode's filter,
rg --files-with-matches, the project navigator) work as a flat index on the qualified path.
The only file in a namespace folder that does NOT carry .<Type> is the namespace anchor:
TileDown.swiftdeclaringpublic enum TileDown { /* sub-namespaces */ }Tile.swiftdeclaringpublic enum Tile { public enum Parse {} public enum Core {} ... }
Anchor files contain only namespace enums, never concrete types.
Namespace anchor file placement (mandatory)
The anchor file for a namespace MUST live at the root of the namespace's owning folder, not buried in a sub-target folder.
When <Namespace> is the umbrella for several SPM sub-targets that each live in a child folder, the anchor file <Namespace>.swift goes at the parent folder root, alongside (not inside) the sub-target folders. The sub-target whose SPM path: spans the parent folder picks up the anchor file as part of its sources.
Concrete example, a TileKit namespace umbrella:
Sources/TileKit/
├── TileKit.swift // namespace anchor: extension TileKit { ... } <-- AT ROOT, not Core/TileKit.swift
├── Client/ // TileKitClient SPM target
├── Core/ // TileKitCore SPM target (path includes ../TileKit.swift)
│ ├── Protocol/
│ ├── Server/
│ └── Transport/
├── SharedTools/ // TileKitSharedTools SPM target
└── Support/ // TileKitSupport SPM target
The TileKitCore target's Package.swift entry uses path: "Sources/TileKit" with exclude: ["Client", "SharedTools", "Support"] so it picks up the anchor file plus the Core/ subtree. Every sibling target (Client, SharedTools, Support) depends on TileKitCore in production, so the TileKit namespace anchor reaches them through that dep.
Why this matters:
- Reading the file listing immediately tells you "TileKit is the umbrella here": the anchor sits visually at the top.
- Moving an anchor into
Core/TileKit.swift(or any other sub-target folder) hides the umbrella relationship behind one extracdand one extra mental indirection. - The same parent-folder pattern applies to
Shared(anchor atSources/Shared/Shared.swift, notSources/Shared/Constants/Shared.swift), and to any future grouped target family.
For cross-cutting namespaces (e.g. a Tile namespace that touches multiple SPM targets and is not owned by any single folder), the anchor lives in the lowest-leaf foundation target that every consumer reaches, AT THAT TARGET'S FOLDER ROOT, not in a sub-folder: e.g. Sources/Shared/Tile.swift (alongside Shared.swift), not Sources/Shared/Constants/Tile.swift.
One non-private type per file (mandatory)
Each file contains exactly one public, package, or internal (default-visibility) top-level type. Private/fileprivate helper types MAY co-locate with the main type when they exist solely to support it. This is enforced by scripts/check-namespacing.sh locally (pre-commit) and in CI; see core/verification.md.
Strict reading:
- A file
Foo.Bar.X.swiftdeclares exactly one ofextension Foo.Bar { public enum X { ... } },... public struct X,... public protocol X,... public actor X,... public class X, etc. - Inner / nested types DECLARED INSIDE the main type (
extension Foo.Bar.X { public enum Error { ... } }orextension Foo.Bar.X { public struct Stats { ... } }) MAY live in the same file. Each nested type also goes in its own file once it grows beyond ~50 lines or gains independent reuse. - File-private decode-only Codable models, internal-detail helper structs, etc. (anything you'd mark
privateorfileprivateso it cannot leak), MAY share the file. Mark them with the access modifier explicitly; don't rely on default-internal access.
Anti-pattern to avoid:
// Bad: Core.Parser.HTML.swift declares two unrelated public types
extension Core.Parser {
public struct HTML: ContentTransformer { ... }
}
extension Core.Parser {
public struct XML: ContentTransformer { ... } // <-- belongs in its own file Core.Parser.XML.swift
}
Fix:
// Core.Parser.HTML.swift: one type
extension Core.Parser {
public struct HTML: ContentTransformer { ... }
}
// Core.Parser.HTML.Error.swift: nested type that grew (or extract inline if small)
extension Core.Parser.HTML {
public enum Error: Swift.Error { ... }
}
// Core.Parser.XML.swift: sibling type, own file
extension Core.Parser {
public struct XML: ContentTransformer { ... }
}
Acceptance check:
roots=()
[ -d Sources ] && roots+=(Sources)
[ -d Packages/Sources ] && roots+=(Packages/Sources)
for f in $(find "${roots[@]}" -name "*.swift"); do
count=$(grep -cE "^(public |package |internal )?(actor|struct|enum|protocol|class|final class) [A-Z]" "$f")
[ "$count" -gt 1 ] && echo "$count $f"
done
Output MUST be empty; any reported file is a violation. Add the private/fileprivate modifier to helper types so they drop out of the count, or split the file.
File renames ship in their own PR
Type renames (TilesIndexerService to Indexer.TilesService) and file renames (TilesIndexerService.swift to Indexer.TilesService.swift) are different PRs. Type-rename first; easier review surface, can revert without filesystem churn. File-rename follows once the type change is settled.