Skip to content

The snipwise.md Configuration File

Create a snipwise.md file in the root of your project, or let snipwise init create one for you. This is mostly a free-format Markdown file, of which only two elements are interpreted by Snipwise:

  • The first fenced code block, before the first level-2 heading, holds the rules in TOML format. A rule says which files Snipwise may act on and what happens to them.
  • Every level-2 heading whose text is a single name between backticks starts a snippet section, which names a snippet and optionally defines its original contents in a code block.

The configuration block is mandatory while the snippet sections are optional. Everything else is facultative prose. A Markdown file is used as the configuration format because it is easy to read, and because fenced code blocks give you syntax highlighting of the snippet content in most editors.

TOML Configuration Block

The configuration consists of a set of rules, either [[targets]] or [[sources]].

Target Rules

A target rule says which files Snipwise acts on and what happens to them:

[[targets]]
patterns = ["README.md", "docs/*.md", "src/**/*.py"]
exclude = ["docs/examples/*.md"]

Snipwise reads and modifies no file that no rule selects, so at least one target rule is required.

Required field:

  • patterns is a list of glob patterns, relative to the directory containing snipwise.md, selecting the files this rule acts on. Every pattern must match at least one file, so that a typo or a forgotten rename is reported instead of silently checking less. Keep the patterns as narrow as is practical, because every file they select is read on every run. A pattern starting with **/ matches at any depth and therefore visits every directory, which is slow in a large project. The snipwise.md file itself is never selected, whatever the patterns of a rule happen to match.

Optional fields:

  • exclude is a list of glob patterns that are removed again from that selection. Use it for files that contain markers without corresponding snippets. It narrows the rule it belongs to and no other rule, and is allowed to match nothing because it can be used to exclude files, that are not always present, e.g. build artifacts. However, if no files remain after the exclusions, Snipwise refuses the rule as a whole, because it is a rule with no effect.
  • scanner identifies the snippet regions of a file, and is one of whole, markers, regex or json. The default is markers, which is a pair of comment lines around the copy. See Snippet Scanners.
  • render is a Jinja template that transforms a snippet on its way into a file. It defaults to copying the snippet as it is written. See Rendering.

Fields dependent on scanner:

  • regex (required with scanner = "regex", refused otherwise) is the regular expression whose matches are the regions of this rule.
  • insert (required with scanner = "json", refused otherwise) lists the values this rule writes and the snippets they hold. See Values of a JSON File.
  • snippets is a list of snippet names, which narrows the rule to those snippets instead of every snippet it finds. See Multiple Rules. Some scanners require it, and some forbid it:
    • A rule with scanner = "whole" requires snippets to hold exactly one name, because a file that is one region says nothing about which snippet it holds.
    • A regex rule without a name group requires snippets to hold exactly one name.
    • It is refused with scanner = "json", where the insert entries list the snippets already.

Unknown keys are refused.

Rendering

The same snippet does not always belong in two files in the same shape. The render key of a rule is a Jinja template that says what the files of that rule receive:

[[targets]]
patterns = ["paper/*.tex"]
snippets = ["tagline"]
render = "{{ content | unwrap }}"

The default is "{{ content }}", which copies the snippet as it is written in snipwise.md. content is the text of the snippet, and name is the name it is known by. Every rule gives its template those two, and a regex rule adds the other named groups of its expression, which An Expression of Your Own describes. That is all a template ever sees: there are no environment variables, no dates, no other snippets and no filesystem, so what snipwise check reports depends on snipwise.md and the file in front of it and on nothing else. A template that names anything else is an error rather than an empty result.

Jinja already supplies filters such as trim, replace, upper and indent. Five more are added, because copying text into another format needs them:

Filter Result
{{ content \| unwrap }} Every paragraph joined into one line
{{ content \| prefix('# ') }} The string in front of every line
{{ content \| suffix(',') }} The string at the end of every line
{{ content \| plain }} The Markdown of the snippet as text
{{ content \| codeblock('python') }} The snippet inside a fenced code block

unwrap keeps the paragraphs of a snippet separated by a blank line, and drops the final newline, because the file that receives the text decides how it is terminated.

prefix is what lets one snippet serve several comment syntaxes. On a blank line it drops the trailing whitespace of the prefix, so that a comment block stays unbroken without gaining a line of trailing whitespace, and suffix leaves a blank line alone.

render is written as an expression above, which is what almost every rule needs, but it is a full template, so a rule may also loop over the lines of a snippet:

render = "{% for line in content.splitlines() %}- {{ line }}\n{% endfor %}"

Custom rendering is write-only. When Snipwise renders a snippet with anything other than the default {{ content }}, the result can never act as the original that other copies are compared with, which is what makes a transformation safe to apply. A rule with a render therefore gives up being read backwards, which only matters for a snippet that snipwise.md does not define. See Which Blocks Can Be Read.

Plain Text Filter

A snippet written for a README.md is Markdown, and a destination such as a docstring, a help text or a .desktop file may want the words without the markup that carried them. That is what plain does:

[[targets]]
patterns = ["snipwise/__init__.py"]
snippets = ["tagline"]
render = "{{ content | plain | prefix('# ') }}"

Emphasis, strong emphasis and code span give up their syntax but keep their text, a link retains only its text, an image gives the text of its description, and a backslash escape or a character entity gives the character it stands for. A bullet becomes a dash whatever the source wrote, an ordered list is renumbered from its own first number onwards, and a nested list is indented under the text of the item that holds it.

The line structure of the snippet is kept. A line break inside a paragraph stays a line break, so a snippet written with semantic line breaks arrives as it was written, and {{ content | plain | unwrap }} is how a destination asks for one line per paragraph. Two blocks are separated by a single blank line, a list is laid out tight whether or not its source separated the items with blank lines, and the result ends without a newline, as unwrap does.

What a snippet may hold is paragraphs, inline markup and lists, and nothing else. A heading, a code block, a block quote, a thematic break, a table, raw HTML or a list item holding more than one paragraph has no plain text form, and plain refuses it by name rather than approximating it:

snipwise: README.md:12: cannot apply the 'plain' filter: a table has no plain text form

Note that the plain filter assumes its input adheres to CommonMark. A reference link whose definition is missing is literal text there, so it is neither rendered nor refused and arrives as it was written, while a link reference definition that is used disappears with the rest of the link syntax. A footnote is not part of CommonMark at all, and what becomes of one depends on whether its definition reads as a link reference definition. [^1]: note reads as one, so Text[^1] arrives as Text^1 and the note is gone, while [^note]: The note text. does not, and the whole footnote arrives as literal text. A snippet that a destination renders with plain is therefore better off without footnotes.

Code Block Filter

A snippet that is a command line, a configuration fragment or a piece of code belongs in a fenced code block once it lands in a Markdown file, and codeblock writes the two fences around it:

[[targets]]
patterns = ["docs/*.md"]
snippets = ["install"]
render = "{{ content | codeblock('console') }}"

The first argument is the info string of the opening fence, which is text when it is left out. The final newline of the snippet is dropped, so that the closing fence does not gain a blank line in front of it, and the result ends with that fence and without a newline, as plain and unwrap do.

The fence is as long as the snippet requires. A snippet holding a line of three backticks would end a block of three backticks halfway, so the fence grows to one backtick more than the longest line of backticks the snippet holds, and a snippet that holds none gets the three that CommonMark asks for. The filter therefore never writes a block that stops where it should not, and a rule that renders a snippet of Markdown needs to say nothing about it. Only a line that is nothing but backticks counts: a run inside a line, or one followed by a word such as ```python, does not close a block and does not lengthen the fence.

The second argument raises that minimum, for a destination that wants every block written the same way:

render = "{{ content | codeblock('markdown', 4) }}"

It is a floor and not a ceiling, so a snippet holding a line of five backticks still gets a fence of six. A minimum below three is refused, because CommonMark accepts no shorter fence, and so is an info string holding a backtick or a line break, because a fence cannot carry either however long it is:

snipwise: README.md:9: cannot apply the 'codeblock' filter: a fence of 2 backticks is too short

Source Rules

Snippets do not have to be defined in snipwise.md. A version string, or the description in pyproject.toml, already lives somewhere that a developer reaches for to edit it, and copying it into snipwise.md would add in a third place, while there is already a clearly authoritative original.

A [[sources]] table is the mirror of a [[targets]] table. It describes files and regions in the same words and marks them read-only:

[[sources]]
patterns = ["yourpackage/__init__.py"]
scanner = "regex"
regex = '__version__ = "(?P<content>[^"]*)"'
snippets = ["version"]

The version string stays where it belongs, and every copy of it in the files your rules select follows. Such a rule serves two purposes that look like one:

  • A text that Snipwise does not own becomes a snippet without being copied into snipwise.md, as in the example above.
  • The original of an ordinary snippet is settled once and for all. A rule with scanner = "markers" is how you say that the block in one file is the one to edit, which is the most common reason to write one of these rules at all. A project that never wants to be asked which copy wins writes these rules and gets the determinism that a definition in snipwise.md gives.

A source rule takes the same keys as a target rule, with two exceptions. render is refused, because a transformation has no inverse and reading a region backwards through one would be a guess rather than a copy. scanner = "json" is refused, and insert with it, because such a rule addresses a value instead of finding it, so nothing in the document says that the value is a text somebody wrote there.

Four constraints keep this sound:

  • A snippet has at most one source. A rule whose patterns select three files, or whose regex matches twice in one file, declares three originals for one snippet, which is refused with every region named. Requiring them to agree instead would only fail on the day the files drift apart, which is the moment you are least able to tell which one was meant.
  • Having a source rules out a definition in snipwise.md. A heading may still carry prose about the snippet, but not a code block.
  • A region is a source or a destination and never both, which is what keeps a check a single pass and a cycle impossible to write down. See Multiple Rules.
  • Propagation is one-directional. Nothing Snipwise writes ever flows back. A copy edited by mistake is put back the way the source has it, and the source itself is never touched.

Keep the Original Where It Belongs is the advice on deciding which snippets want such a rule.

A source region is never rewritten. A marked block used as a source therefore keeps whatever layout you left there, and snipwise check never reports it as out of date, which would be a complaint that nothing could act upon. Reading it still tidies the text on its way to the copies, because the indentation and the line endings of a block belong to the file that holds it. See Which Blocks Can Be Read.

Finally, snipwise.md is never a source. Its snippet sections say what they have to say already, and a pattern that selects nothing else is refused.

Multiple Rules

A project that treats all its files alike needs a single rule. For more complex cases, multiple rules are allowed, and they are resolved in a single pass.

Rules are not consulted in order and none of them takes precedence by position. Their patterns may overlap, and the files of one rule may be the files of another, as long as it stays clear which rule acts on which snippet. What a rule really selects is therefore a set of file-and-snippet pairs, and every marker Snipwise meets has to belong to exactly one rule:

  • A snippet claimed by two rules is an error naming the file and the snippet. Rules are few and written by hand, so a silent first-match-wins would hide a mistake more often than it would help.
  • A marker that no rule claims is also an error, because a narrowed rule must not silently switch off the check of the other blocks of a file.
  • A narrowed rule (with snippets) outranks a rule that claims everything. This is the one exception, and it is what lets one snippet of a file be treated differently from the others while the rest of the file keeps working:

    [[targets]]
    patterns = ["docs/*.md"]
    
    [[targets]]
    patterns = ["docs/index.md"]
    snippets = ["tagline"]
    

    Here the second rule takes tagline in docs/index.md, and the first rule keeps every other snippet of every file in docs. A rule is narrowed by its snippets key, or, with scanner = "json", by the snippets its insert entries name, which is what lets two json rules address different values of the same file.

A [[sources]] rule takes part in the same resolution as a [[targets]] rule, because a region is a source or a destination and never both. A source rule narrowed with snippets therefore takes that snippet away from a target rule that receives everything, which is how one block of a file becomes the original while the rest of the file keeps working. Two rules of different kinds that claim a snippet on equal terms is an error like any other.

Snippet Sections

Below the TOML configuration block, you may define level-2 sections to declare or define snippets. A snippet is a name and a piece of text, and nothing else. It does not know which files hold it or in what shape, because that is the business of the rules.

## `python-versions`

The versions that the test suite covers.
Edit them here.

```text
Snipwise requires Python 3.11 or later.
It is tested on Python 3.11, 3.12, 3.13 and 3.14.
```
  • The name identifies the snippet in the marker lines of your files, so keep it short and stable. It is one token that starts with a letter, a digit or an underscore, followed by more of those, dots or dashes.
  • The first fenced code block after the heading is the content of the snippet. Anything else in the section is facultative prose.
  • The info string of that code block, such as toml or python, only serves to highlight the snippet in your editor and is ignored by Snipwise.
  • If the content itself contains a fenced code block, wrap it in a fence with more backticks.
  • Any other heading is ignored, which leaves you free to add a title, an introduction, or grouping headings.
  • A snippet heading with no fenced code block below it declares the snippet instead of defining it, which says that its text lives in the files that hold it. See Snippets without a Definition.

Snippets without a Definition

A snippet defined in snipwise.md and used in two files is stored three times, and the copy in snipwise.md is the one you edit. That is worth it for a fragment that several files share on equal terms. It is not worth it for a sentence that plainly belongs to one of them, where you would rather edit the sentence where it reads best and let the copies follow. A [[sources]] rule is one way to say which copy that is. This section is about the other way, which is to say nothing at all.

A snippet heading with no fenced code block below it declares the name without saying what the text is:

## `python-versions`

Maintained in `README.md`, from where Snipwise copies it into the documentation.

The heading is not required either. A name that snipwise.md never mentions comes into being by being used, so the set of snippets is whatever the markers of your files say it is. Snipwise then compares the copies with each other instead of with an original. Writing the heading is a way of saying which of them to edit, and nothing more: it does not excuse a snippet from having two copies. (See The Lone-Copy Error.)

Which Blocks Can Be Read

A block can be the original only if the text of the snippet can be read back out of it, which makes it a candidate. That is what a rule without a render of its own gives you: a markers block gives its text back once the indentation of its begin marker is removed, and a regex match gives back its content group.

  • A block of a rule with a render is a receiver and never a candidate, because a transformation such as unwrap throws information away and reading it backwards would be a guess rather than a copy.
  • A JSON value is never a candidate either, because the rule addresses it rather than finding it, so nothing in the document says that it is a value written by hand.

A snippet that snipwise.md does not define, that no source rule names and that no candidate holds is an error, reported while the files are scanned rather than when a difference eventually appears. Such a snippet asks Snipwise to keep copies of a text that is written nowhere it can read.

Reading a block also tidies it up. The indentation of the BEGIN marker must be repeated exactly on every line of the block, including mixtures of tabs and spaces. When this is not the case, the offending lines are re-indented to match the BEGIN marker, and the block is rewritten along with the others. (More indentation is allowed.) Empty lines are never indented and trailing whitespace on a blank line is also dropped. snipwise fix rewrites the tidied winning block along with all its copies. snipwise check reports the winning block as out of date in that case, so that both commands keep agreeing about what has to change.

Which Copy Wins

Only snipwise fix has to know which copy wins, and it has four answers:

  1. A snippet defined in snipwise.md takes its text from there, and nothing is inferred.
  2. A snippet with a [[sources]] rule takes its text from the region that rule names.
  3. When every candidate holds the same text, that text wins, and the blocks that do not hold it are rewritten.
  4. Otherwise, the candidates are compared with the last commit, and the copy that changed since then wins when exactly one of them did.

Anything else is an ambiguity that Snipwise refuses to resolve on its own. It then exits with code 2 and rewrites nothing at all, because that is the code for input it cannot act upon, and it is --original that gets such a project moving again.

The Comparison with the Last Commit

A snippet that snipwise.md does not define and that no source rule names is settled by its copies as long as they agree. When they disagree, snipwise fix asks git what those copies held at the last commit, and takes the one that changed since then, when exactly one of them did. That is what makes a snippet without a definition pleasant to live with: you edit the sentence in the file where it reads best, run snipwise fix, and the copies follow.

Both halves of that condition carry weight:

  • The copies have to have agreed at the last commit. What they held there is the text the edit started from. Two copies that were already out of step at that commit say nothing about which of the two was meant, and letting whichever one you touched win would overwrite an older difference that nobody has looked at.
  • At most one distinct text may differ from it, counted by text and not by block. Editing the same sentence in three files by hand is the answer and not a conflict, and Snipwise fills in the copies that were missed. Two copies that moved in two directions is a conflict and nothing else. When none of them differs, the text they all still hold is the answer, which is what settles a snippet whose only disagreement is a block added since.

A block that did not exist at that commit is neither a change nor evidence, and it takes no part in the comparison at all. It receives what the copies that were already there hold: the text they changed to when one of them changed, and the text they never stopped holding when none of them did. Such a block is usually an empty pair of markers waiting to be filled, and counting it as an edit would let an empty block win. It is filled in the same way when it is not empty, because a block in a file that the last commit does not know is not a copy that changed, however carefully it was written. Use --original to say otherwise. When none of the copies existed at that commit, git has nothing to say about them.

This is the only place where Snipwise looks outside your working tree, and it looks only at the files that the patterns of your rules already select. Every way the lookup can fail is an absence of evidence rather than an error: no repository, a branch without a commit, a file that git does not know, a committed file that Snipwise cannot scan, a git that is not installed and a git that takes too long to answer. The snippet then stays ambiguous, which lands you back at --original or at a [[sources]] rule.

snipwise fix does this and snipwise check does not. A check compares the files your rules select with each other and with nothing else, so it reports a disagreement whether or not the last commit would settle it.

Snipwise reads the committed text as it was committed and applies no filter of its own to it. A repository that rewrites files on checkout, with core.autocrlf or with a filter driver, has a working tree that differs from the commit for reasons that have nothing to do with Snipwise, and a block in such a file looks edited on every run. A project in that position is better off naming its originals in [[sources]] rules.