Skip to content

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:

  1. Find and read snipwise.md. snipwise.config searches 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 to snipwise.settings, and collects the snippet sections. The directory holding the file is the root against which every glob pattern is expanded.
  2. Select the files. Config.select expands the patterns of 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.
  3. Read and scan everything. snipwise.inventory reads every selected file and asks each of its rules for the regions it owns. snipwise.rules.claim then works out which rule owns which region, and the result is an Inventory: every file with its regions, and every snippet with every copy of it.
  4. Decide. Snippet.resolve works out the text of each snippet from its definition, its source, the block that --original names, or the agreement of its copies. snipwise.inventory.resolve_at_head is the fallback that only snipwise fix reaches for. A snippet that stays undecided ends the run before anything is written.
  5. Render, compare and write. ScannedFile.update renders 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 is fix.

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:

  • scan turns the text of a file into a list of Region objects. A Region is a snippet name, a half-open character range, a label for reports and a context dictionary holding whatever the scan found along the way.
  • render turns the text of a snippet into the text the region should hold, using the render template of the rule and the context of the scan.
  • recover reads a region backwards into the text of the snippet, which is the inverse of render and is only defined when the reversible property 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 with path: or path:lineno: whenever the problem sits in a file, which is why nearly every function takes a location argument holding the name of the file as the user sees it. __main__ prints it behind a snipwise: prefix and exits with code 2.
  • Refuse early. Whatever can be seen in snipwise.md alone is refused while it is read: an expression that does not compile, a template that does not parse, an unknown key, two insert entries addressing the same value. The alternative is an error that only appears on the day a file is edited.
  • Decide before writing. snipwise fix reads 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 in snipwise.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 in snipwise.markdown next to plain.
  • A new scanner is a class in snipwise.rules implementing the three steps, built on TextRule when it finds its regions in the text and on BaseRule when it does not, a scanner value in snipwise.settings, and a section in scanners.md. Whether it can be read backwards decides its reversible property, 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: every pre-commit hook that runs Snipwise depends on them.