Code Architecture¶
This document is a map of the package for whoever changes it. What Snipwise does is specified on the home page and in the documents it links to. Why the configuration has the shape it has is recorded in design.md, which is the document to read before adding a scanner or a key.
A Run from Start to Finish¶
A run is a pipeline with a single decision point in the middle, and nothing is written before every file has been read:
- Find and read
snipwise.md.snipwise.configsearches the current directory and its parents for the file, parses it as CommonMark, hands the first fenced code block before the first level-2 heading tosnipwise.settings, and collects the snippet sections. The directory holding the file is the root against which every glob pattern is expanded. - Select the files.
Config.selectexpands thepatternsof every rule and returns each selected file together with the rules that selected it. This is the only place where a file enters a run, which is what makes the selection the boundary of everything Snipwise may read or write. - Read and scan everything.
snipwise.inventoryreads every selected file and asks each of its rules for the regions it owns.snipwise.rules.claimthen works out which rule owns which region, and the result is anInventory: every file with its regions, and every snippet with every copy of it. - Decide.
Snippet.resolveworks out the text of each snippet from its definition, its source, the block that--originalnames, or the agreement of its copies.snipwise.inventory.resolve_at_headis the fallback that onlysnipwise fixreaches for. A snippet that stays undecided ends the run before anything is written. - Render, compare and write.
ScannedFile.updaterenders each snippet into each region that receives it and splices the character ranges.snipwise.__main__then reports the regions that differed, and writes the new contents when the subcommand isfix.
Steps 3 and 4 are separate because a snippet is a set of copies: a copy that was not read is a copy that was missed. Naming files on the command line therefore narrows what is checked and written in step 5, and never what is read in step 3.
The Three Steps of Every Rule¶
snipwise.rules.Rule is the protocol that every scanner implements,
and it has exactly three methods,
which is the shape that design.md commits to:
scanturns the text of a file into a list ofRegionobjects. ARegionis a snippet name, a half-open character range, a label for reports and acontextdictionary holding whatever the scan found along the way.renderturns the text of a snippet into the text the region should hold, using therendertemplate of the rule and the context of the scan.recoverreads a region backwards into the text of the snippet, which is the inverse ofrenderand is only defined when thereversibleproperty of the rule is true.
The splice is shared rather than implemented per scanner:
snipwise.files.splice replaces a list of character ranges in one pass,
which is what keeps the promise that Snipwise never reserializes a file.
The four implementations are WholeRule, MarkersRule, RegexRule and JsonRule,
all in snipwise.rules.
They hold the table they were built from through BaseRule,
and the three that find their regions in the text of a file
share their compiled template and their reversible through TextRule.
MarkersRule keeps a scanner of its own, in snipwise.markers,
because a scanner can say that a begin marker is never closed
where an expression would leave the block silently unowned.
The Modules¶
In dependency order, so that a module only ever imports the ones above it:
| Module | Responsibility |
|---|---|
errors |
SnipwiseError, whose message is the complete diagnostic |
names |
The name of the configuration file and the grammar of a snippet name |
markdown |
The CommonMark parsers and the plain text rendering of a snippet |
region |
Region, the character range Snipwise owns, LineIndex and split_lines |
template |
The Jinja environment, the closed context and the domain filters |
jsonspan |
The character span that an RFC 6901 pointer addresses |
jsonvalue |
How rendered text becomes the JSON value that replaces another |
markers |
Scanning, rendering and recovering a pair of marker lines |
files |
Naming a target file, reading it, splicing character ranges, writing it back |
settings |
The TOML block: every key, every validator and every refusal |
rules |
The Rule protocol, its four implementations and region ownership |
base |
The selected files as HEAD has them, read through git |
config |
Finding snipwise.md, reading it, and expanding the patterns of its rules |
inventory |
Every copy of every snippet, and which text each snippet has |
__main__ |
Argument parsing, the reports and the exit codes |
Conventions to Keep¶
- One exception type.
Everything a user can get wrong raises
SnipwiseError, and its message is the finished diagnostic rather than a fragment to be wrapped. It starts withpath:orpath:lineno:whenever the problem sits in a file, which is why nearly every function takes alocationargument holding the name of the file as the user sees it.__main__prints it behind asnipwise:prefix and exits with code 2. - Refuse early.
Whatever can be seen in
snipwise.mdalone is refused while it is read: an expression that does not compile, a template that does not parse, an unknown key, twoinsertentries addressing the same value. The alternative is an error that only appears on the day a file is edited. - Decide before writing.
snipwise fixreads and resolves the whole selection before it writes the first file, so a project is never left half fixed. - Ranges, never documents. A change that loads a file into a structure and dumps it back is a change that invariant 1 of design.md refuses.
- Attrs everywhere.
Value objects are
@attrs.define(frozen=True)with a docstring under every field.
Where a Change Belongs¶
- A new configuration key is declared and validated in
snipwise.settings, used insnipwise.rules, and documented in snipwisefile.md. Unknown keys are errors, so a key that is added is a key that can be relied upon. - A new filter is registered in
snipwise.template, and may read the snippet and the arguments its template gives it, and nothing else. It is written there as well, unless it reads the snippet as CommonMark rather than as lines, which puts it insnipwise.markdownnext toplain. - A new scanner is a class in
snipwise.rulesimplementing the three steps, built onTextRulewhen it finds its regions in the text and onBaseRulewhen it does not, ascannervalue insnipwise.settings, and a section in scanners.md. Whether it can be read backwards decides itsreversibleproperty, and therefore whether its regions can be candidates. - A change in what Snipwise prints or exits with belongs in
snipwise.__main__and in tests/test_cli.py, which is the file that pins the command-line interface. The exit codes are part of that interface: everypre-commithook that runs Snipwise depends on them.