Architecture¶
paredit-cli is organised as four layers with a strict, one-directional
dependency rule. The layers are the top-level modules of the crate
(src/lib.rs): domain, application, infrastructure, and presentation.
Understanding this shape is the fastest way to know where a change belongs.
Layers and dependency direction¶
| Layer | Module | Responsibility |
|---|---|---|
| Domain | src/domain |
Core Lisp parsing, dialect detection, and semantic refactoring rules. Independent of CLI delivery and filesystems. |
| Application | src/application |
Orchestrates typed domain operations into agent-facing reports, plans, and refactor workflows. |
| Infrastructure | src/infrastructure |
Turns filesystems and workspace discovery into inputs the application layer can consume. |
| Presentation | src/presentation |
Maps commands, flags, and output modes onto application services; renders reports and chooses exit codes. |
Dependencies point in one direction only. The domain is the stable core; the presentation layer is the only place that knows about all the others:
The rule is enforced by the module graph, not just by convention:
domainimports no other layer.applicationimports onlydomain.infrastructureimports onlydomain.presentationcomposes all three.
If a use crate::presentation or use crate::infrastructure appears inside
domain or application, the boundary has been violated — that direction
never exists in a healthy tree.
Domain: typed values, not primitives¶
The domain closes invalid states at the type level rather than validating
primitives at call sites. Byte positions are ByteOffset/ByteSpan, tree
addresses are ExpressionPath, symbol tokens are SymbolName, and a parsed
document is a SyntaxTree aggregate that stays internally consistent. Report
and decision types keep their fields private and expose semantic getters, so a
value like a similarity ratio (0.0..=1.0, finite) or a refactor plan's
automation decision cannot be constructed in a contradictory state.
Prefer this discipline when extending the domain: a validated newtype or a
semantic enum (ReportLimit::{Complete, Limited(NonZeroUsize)},
SimilarityGateDecision) over a bag of correlated bool/usize fields.
Derive redundant presentation values (booleans, counts) at the serialization
boundary instead of storing them.
Lint rules: one trait, one registry line¶
src/domain/lint is a concrete example of the domain discipline above, worth
knowing on its own because it is the most frequently extended part of the
domain. src/domain/lint_report.rs is the stable façade the application and
CLI layers import — report types, catalogue constants (RULES, RULE_DOCS,
FIXABLE_RULES, WARNING_RULES), and the two entry points that lint one
parsed file. Underneath it:
| Submodule | Role |
|---|---|
rule |
The LintRule trait. A rule declares which nodes it wants (head_filter) and what to do with one (check); it never walks the tree itself. |
model |
Vocabulary shared by every rule and the façade — Severity, RuleCategory, Fixability, RuleMeta, LintFinding, RuleFix. |
policy |
Dialect scope, rule selection, and gate decisions — logic that needs no tree. |
engine |
The single pass: walks the document once, dispatching each node to every rule whose head_filter matches it. |
registry |
REGISTRY, the one array every rule is listed in. The catalogue constants the façade re-exports are derived from it at compile time. |
rules |
One file per rule (rules/redundant_apply.rs, rules/car_reverse.rs, …): a META: RuleMeta constant plus a LintRule impl. A rule that auto-fixes attaches a RuleFix from check — the fix lives with the rule, not in the CLI layer. |
Adding a rule touches exactly three places:
- Add
src/domain/lint/rules/your_rule.rswith aMETAconstant and aLintRuleimplementation. - Add one
RuleEntry::new(...)line toREGISTRYinsrc/domain/lint/registry/mod.rs. - Add one integration test in
tests/cli/lint_report.rs(or a fixture pair undertests/fixtures/lint_goldenfor the golden test intests/cli/lint_report_golden.rs).
No parallel arrays to keep in sync and no separate fix implementation to wire
into the CLI: --fix, --fix-plan, and --diff all read the same RuleFix
the rule itself produced.
Semantics: read-only tables beside the tree¶
src/domain/semantics lets a rule reason about what code means rather than
how it is spelled. It is why zero-divisor flags (let ((z 0)) (/ x z)) and
not just (/ x 0).
Nothing here rewrites the tree. Formatting survives a refactor because every
edit is a byte-span replacement over untouched source, and that discipline only
holds while the tree stays authoritative — so the analyses hang beside it as
side tables keyed by NodeKey, never as annotations on it.
| Context | Answers |
|---|---|
binding |
Which binding does the name at this position mean? Built once per file, from the same knowledge lexical_scope uses to answer the inverse question. |
value |
What does this expression provably evaluate to? |
typing |
What type is this, at a coarse CLHS granularity? Common Lisp only. |
project |
Which package owns this symbol, so app:run and test:run are two things? |
Each context splits into model (vocabulary), policy (dialect tables), and
service (the pass that builds a table). They stack — values need bindings,
types need values — and link downward by id, never by borrow: a
ValueTable holding a &BindingTable would make them one self-referential
struct. A rule reaches them through RuleContext, which builds each on first
use, so a run whose rules ask for none pays for none.
Two rules hold throughout, and both cost deductions on purpose:
- A fact is recorded only when it is provable. Anything uncertain is absent
rather than guessed, because a rule that trusts a wrong
Knownreports a bug in working code. - An unknown head is opaque. A macro can expand into an assignment that appears nowhere in the source, so propagation stops at any head whose semantics are not registered. Ordinary function calls and standard control forms are exempt — a function cannot reach the caller's lexical environment at all, and a control form evaluates its subforms where they are written, so any assignment inside is visible.
Application: use cases behind source ports¶
Each non-trivial CLI workflow is an application use case that owns the whole orchestration — discovery, decoding, parsing, analysis, gate precedence, and error typing — and depends on the outside world only through a source port trait it defines itself. The recurring shape is request in, plan out:
Request (input DTO)
│
▼
use case ──uses──▶ SourcePort (trait, defined in application)
│
▼
Plan (output aggregate: report + inventory + typed errors + gate decision)
Three ports carry the pattern today:
| Use case | Source port | Plan / output |
|---|---|---|
usecase::similarity_report::workflow |
SimilarityReportSourcePort |
SimilarityReportPlan |
usecase::workspace_report::workflow |
WorkspaceReportSourcePort |
WorkspaceReportPlan |
usecase::remove_definition |
DefinitionSourcePort |
edit plan + write policy |
Because the port is an interface, the use case is filesystem- and
CLI-agnostic: tests drive it with an in-memory adapter, while production wires
in the real one. A port models discover-before-load explicitly — for
example SimilarityReportSourcePort resolves each file's dialect during
discover and returns bytes from load, so dialect is never smuggled
alongside a failed read. Adapter state or ordering failures return through
Result; they never panic.
The Plan an application use case returns is the contract with presentation:
it holds the domain report, a discovery inventory, per-file typed errors, and a
single computed gate decision. Presentation reads the plan; it never
re-derives the decision.
Infrastructure: discovery adapters¶
src/infrastructure/workspace implements source discovery: it walks directory
roots, applies hidden/generated/symlink/exclude filters, and yields the file
set the application ports request. fs_identity captures file identity for the
apply-time "changed on disk" guard. Infrastructure depends on the domain (for
dialect types) and nothing above it.
Presentation: adapters, rendering, exit codes¶
src/presentation/cli is a thin edge. For each workflow it:
- Converts CLI arguments into an application
Request. - Implements the use case's source port (e.g.
CliSimilarityReportSource impl SimilarityReportSourcePort) by delegating to the infrastructurediscover_workspace_files/WorkspaceDiscoveryadapter. - Calls the use case and renders the returned
Planas text or JSON. - Maps the plan's gate decision to a process exit code (see the agent interface for the code table).
Keeping request conversion, rendering, and gate-to-exit mapping here — and
everything else in the application and domain layers — is what lets the same
report logic serve both a human --output text reader and a machine
--output json consumer without duplication.
How the layers map to the three namespaces¶
The command model — inspect, edit, refactor — is a
presentation-level grouping. Underneath, an inspect report and a refactor
plan are both application use cases over the same domain SyntaxTree; the
namespace only reflects whether the command writes. This is why a report and
the refactor that consumes it always agree on paths, spans, and symbol
identity: they share the domain, not just a serialization format.
Where a change belongs¶
| Change | Layer |
|---|---|
| New parsing rule, dialect capability, or refactor safety check | domain |
| New lint rule | domain/lint/rules plus one registry line |
| New static fact about values, types, or bindings | domain/semantics |
| New report, plan, or multi-file workflow orchestration | application |
| New way to discover or read sources | infrastructure |
| New command, flag, output format, or exit-code mapping | presentation |
When a change spans layers, add it from the inside out: model it in the domain, orchestrate it in an application use case behind a port, then expose it through a presentation adapter. The development guide covers the verification gate that keeps these boundaries — and the documentation that describes them — honest.