Vale CMS Need more than a rule playground? Explore
Why Vale

Prose inside files that aren’t prose

Views

A markup file is a document, and Vale parses it into the blocks its rules see. A data file, a source file, or a plain-text file with a convention has prose in it too—in the descriptions of an API specification, the docstrings of a module, the body of a commit message—but nothing marks where. A View says where: a list of queries that pull named pieces out of the file, so Vale lints those and passes over the rest.

Views reference

Three engines, one idea

A View is a YAML file in your styles directory. It names an engine and a list of scopes, each a query the engine evaluates. What a query finds becomes a block with the scope's name, placed at the line and column it came from.

JSON, YAML, and TOML, by extension.

A selector over the parsed document. Every string it lands on is one value.

  • OpenAPI
  • Jupyter
  • Helm values
  • Actions workflows

Any plain-text file with a convention.

A template of named values and a state machine of regular expressions.

  • Commit messages
  • Transcripts
  • Changelogs
  • Logs

The nineteen languages Vale has a grammar for.

A query against the syntax tree, replacing the built-in comment query.

  • Docstrings
  • String literals
  • Comments

The View key names a View under a section of your config, so the files it applies to are whichever that section matches. A name that isn't there is an error when the configuration loads, and a query that fails is an error that points at the section.

An API spec is documentation

Every description in an OpenAPI document is published prose—it becomes your API reference. Two selectors reach all of them, and a description written in Markdown is parsed as Markdown, so the link target inside it is skipped as it would be on a page.

openapi.yaml
openapi: 3.1.0
info:
title: Pet Store API title
version: 1.0.0
description: |-
Manage pets, orders, and users. description
See [the guide](/guide) to get started. description
paths:
/pet:
post:
description: Add a new pet to the store description
operationId: addPet
config/views/OpenAPI.yml
# <StylesPath>/config/views/OpenAPI.yml
engine: dasel
scopes:
  - name: title
    expr: info.title

  - name: description
    expr: search(has("description")).map(description)
    type: md
.vale.ini
[*.{json,yml,yaml}]
BasedOnStyles = Vale, House
View = OpenAPI

Tinted lines are linted. Version numbers, operation IDs, and path keys are structure, and a rule never sees them. A selector may land on one string or many; every string it selects is a value, and anything that isn't a string is dropped.

A notebook is a document

A Jupyter notebook keeps each Markdown cell's source as a list of lines. A scope's join puts a list back together, so the cell is one value, placed where its first line is.

config/views/Notebook.yml
# <StylesPath>/config/views/Notebook.yml
engine: dasel
scopes:
  - name: cell
    expr: cells.all().filter(equal(cell_type,markdown)).source
    join: ""
    type: md
.vale.ini
[*.ipynb]
BasedOnStyles = Vale
View = Notebook

A commit message has three parts

The subject, the body, and the trailers each deserve rules of their own. A TextFSM template declares the values to capture and a state machine that fills them, line by line, in the same regular-expression dialect every rule uses.

COMMIT_EDITMSG
fix: report the shortfall at the scope that fell short. subject
Zero matches leave no occurence to point at, but the scope has a body
position of its own. body
Signed-off-by: Joseph Kato <j@example.com> trailer
# Please enter the commit message for your changes.
.vale.ini
[COMMIT_EDITMSG]
BasedOnStyles = Vale, House
View = Commit
config/views/Commit.yml
# <StylesPath>/config/views/Commit.yml
engine: textfsm
template: |
  Value Subject (.+)
  Value List Body (.*)
  Value List Trailer ((?:BREAKING CHANGE|[A-Z][\w-]+): .+)

  Start
    ^${Subject} -> Body

  Body
    ^# -> Next
    ^${Trailer}
    ^${Body}
scopes:
  - name: subject
    expr: Subject

  - name: body
    expr: Body
    type: md

  - name: trailer
    expr: Trailer

Reading begins in Start: the first line is the subject, and reading moves to Body. There, each line is tried against the rules in order. A line starting with # is Git's own commentary and is read past; a line shaped like Word: text is a trailer; anything else is body. Point a commit-msg hook at the file and the message is checked before the commit lands.

A rule can aim at one part

A scope's name is how a rule reaches what the query found. It's appended to the scope of every block the value produces, so a rule that names it runs there and nowhere else.

House/Subject.yml
# <StylesPath>/House/Subject.yml
extends: occurrence
message: "Keep the subject line to twelve words."
level: error
scope: subject
max: 12
token: \b\w+\b

scope: subject runs on the first line of a commit message and nowhere else. A rule with the usual scope: text runs on everything the View extracted, and a query without a name is linted but reachable only by the scopes its format gives it.

A scope's type is the format the extracted text is parsed as: md, rst, html, org, or adoc. Without one, the text is read as plain lines, which is right for a subject and wrong for a body written in Markdown.

One side of a conversation

A transcript alternates between a user and a model, and only one side is yours to lint. A turn runs until the next label, so each state needs to know when the turn is over before it knows whose turn comes next.

transcript.txt
user: Summarize the change in one line.
assistant: The linter now reports where a scope fell short. assistant
It's worth noting that this only affects occurence rules. assistant
user: Thanks, that's clear enuf.
vale transcript.txt
 transcript.txt
 3:6   error  'worth noting' hedges, and this is the model's turn.  House.Assistant
 3:42  error  Did you really mean 'occurence'?                      Vale.Spelling
config/views/Transcript.yml
engine: textfsm
template: |
  Value List Assistant (.*)
  Value List User (.*)

  Start
    ^assistant: ${Assistant} -> Assistant
    ^user: ${User} -> User

  Assistant
    ^(?:user|assistant): -> Continue.Record
    ^assistant: ${Assistant}
    ^user: ${User} -> User
    ^${Assistant}

  User
    ^(?:user|assistant): -> Continue.Record
    ^user: ${User}
    ^assistant: ${Assistant} -> Assistant
    ^${User}
scopes:
  - name: assistant
    expr: Assistant
    type: md

The first rule in each state is the trick: it matches any label, captures nothing, emits the turn in hand as a record, and continues, so the rules below it capture the new turn into a fresh record. The user's lines are captured so they have somewhere to go, but no scope names them, so the misspelling on the last line goes unreported. The column is the column of the capture, so an alert on the first line of a turn points past the label.

Your own query, in source

Vale reads source code by its comments. A tree-sitter View replaces that with queries of your own, run against the file's syntax tree, so you decide what counts as prose in a language.

greet.py
def hello(name: str) -> str:
"""Greet someone by name.""" docstring
return f"Hello, {name}!"
config/views/Docstrings.yml
engine: tree-sitter
scopes:
  - expr: (comment)+ @comment

  - name: docstring
    expr: |
      ((function_definition
        body: (block . (expression_statement (string) @docstring)))
      (#offset! @docstring 0 3 0 -3))
    type: md

Each capture is one value. Its scope is text.comment, then the query's name, so the docstrings answer to scope: text.comment.docstring and a plain scope: comment reaches both queries. Delimiters and per-line decoration are stripped before linting, and #offset! trims a capture by rows and columns from each end, which is how the docstring loses its quotes. The same shape lints string literals, a UI's user-facing text, or anything else a grammar can name.