Sheet R4 · Reference

Text checks

How to underline problems as the writer types.

A text check runs while the writer types, in the scene editor and in focus mode. Hammer sends your plugin paragraphs of plain text and underlines the issues your plugin reports back. When the writer right-clicks or taps an underline, Hammer shows its message and fixes. Picking a fix replaces the underlined text.

Every request is a JSON object. It includes locale, the BCP 47 tag of Hammer's UI language (such as fr-CA), or null; write your messages in this language. Don't confuse it with the project's language, which is the language the story is written in. Every request also includes settings, with the current value of each setting your plugin declares. Every key is present. See the manifest.

Like every export, diagnose takes no arguments and returns an i32: 0 for success, anything else for failure. A call also fails if it traps. Either way, the writer sees the message your plugin set with error_set. See the host functions.

Manifest

[[diagnostics]]
name = "grammar"
label = "Simple grammar"
scope = "paragraph"
Key Meaning
name Must be unique within your plugin, lowercase, and directory-safe, like a plugin id. It's sent as the request's diagnostics
label The check's name, as Hammer shows it
scope Which paragraphs each call receives: paragraph, the default, or scene. Hammer won't install a plugin with any other scope

Scope. With paragraph, Hammer remembers each paragraph's issues by its text. After an edit, a call receives only the paragraphs whose text the check hasn't seen yet. With scene, every call receives every paragraph in the scene, including blank ones. If your check's issues depend on other paragraphs, such as a word repeated a paragraph later, use scene. When your plugin's settings change, Hammer checks the whole text again.

Request

{"diagnostics": "grammar", "paragraphs": ["They was late.", "It rained."],
 "language": "en", "project": "Storm", "settings": {"agreement": true}, "locale": "en"}
Key Contains
diagnostics The name of the check to run
paragraphs The text, one string per paragraph, with the markdown already rendered to plain text
language The project's language as a BCP 47 tag, or null if it has none
project The project's name, for operations such as entry.list that read the project. Your plugin needs permission for those operations, as it does for any other call
settings The value of every declared setting
locale The UI's BCP 47 tag, or null. Write your messages in this language

Reply

{"diagnostics": [
  {"paragraph": 0, "start": 5, "end": 8, "message": "Use “were” with “they”",
   "fixes": ["were"]},
  {"paragraph": 1, "start": 3, "end": 9, "message": "A weak verb",
   "fixes": [{"replacement": "", "label": "Remove “rained”"}], "severity": "suggestion"}
]}
Key Contains
paragraph The paragraph the issue is in, as an index into the request's paragraphs
start, end The issue's range, as UTF-8 byte offsets into that paragraph. end is exclusive
message The message the writer sees
fixes The replacements offered for the range, in order. It can be empty
severity error, the default, or suggestion. Any other value counts as error

Rules

  • Offsets are UTF-8 bytes, which is how C and Rust index text. Hammer converts them to the editor's ranges. In "café", é takes up bytes 3 and 4, so the word runs from 0 to 5.
  • Hammer drops an issue if its paragraph doesn't exist, if its offsets fall past the end of the paragraph or in the middle of a character, or if its range is empty. It still shows the other issues.
  • A fix is either a string or an object. A string is the replacement, and the writer sees it as it is. Use {"replacement", "label"} when the replacement alone wouldn't make clear what the fix does. For example, removing a word is an empty replacement, so it needs a label. An object without a label shows its replacement. Hammer drops a fix of any other shape.
  • Severity. An error is a mistake, and Hammer underlines it with a wavy blue line (spell check's is red). A suggestion, such as a style issue, gets a quieter dotted gold line.
  • Failure. If your plugin fails, traps, replies with nothing, or replies with something that isn't this JSON, the text stays unmarked. Hammer logs the failure but doesn't show it to the writer.
  • Calls can be cancelled. Hammer interrupts a call once the editor no longer needs its result. Keep your check fast, since it runs as the writer types.
  • If a project has turned your plugin off, none of your checks run in it.

In each kit

Here's the same check in each kit: it underlines two spaces in a row and offers one space as the fix.

import hammer.DiagnoseRequest
import hammer.Diagnostic
import hammer.Fix
import hammer.diagnose
import kotlin.wasm.WasmExport

fun doubleSpaces(request: DiagnoseRequest): List<Diagnostic> = request.paragraphs.flatMapIndexed { paragraph, text ->
    val found = mutableListOf<Diagnostic>()
    var at = text.indexOf("  ")
    while (at >= 0) {
        found += Diagnostic(paragraph, at, at + 2, "Two spaces", listOf(Fix(" ")))
        at = text.indexOf("  ", at + 2)
    }
    found
}

@WasmExport
fun diagnose(): Int = diagnose(::doubleSpaces)

A Diagnostic's start and end are indexes into the paragraph's String, and the kit converts them to UTF-8 offsets. When a replacement needs a label, pass it as Fix(replacement, label). Pass severity = Severity.Suggestion for a suggestion; the default is Severity.Error. request.project is the project's name. Kotlin/Wasm's Regex is slow in Hammer's interpreter, so search text by hand instead.

use hammer::diagnose::{Diagnostic, Fix, Reply, Request, Severity};

fn check(request: &Request) -> Reply {
    let mut reply = Reply::default();
    for (paragraph, text) in request.paragraphs.iter().enumerate() {
        for (start, _) in text.match_indices("  ") {
            reply.diagnostics.push(Diagnostic {
                paragraph,
                start,
                end: start + 2,
                message: "Two spaces".into(),
                fixes: vec![Fix::Replace(" ".into())],
                severity: Severity::Suggestion,
            });
        }
    }
    reply
}

#[unsafe(no_mangle)]
pub extern "C" fn diagnose() -> i32 {
    hammer::run(|| {
        let request: Request = hammer::input_json()?;
        Ok(serde_json::to_vec(&check(&request))?)
    })
}

Indexes into a Rust str are already UTF-8 bytes. If you count ranges in characters, hammer::char_byte_offsets(text) gives you each character's byte offset, plus one more for the end. Use Fix::Labelled { replacement, label } for a fix with a label.

#define HAMMER_IMPLEMENTATION
#include "hammer.h"
#include "hammer_diagnose.h"

int hammer_diagnose_begin(const HammerDiagnoseRequest *request) {
  (void)request;
  return 1;
}

void hammer_diagnose_paragraph(HammerText paragraph) {
  for (uint64_t i = 0; i + 1 < paragraph.length; i++) {
    if (paragraph.data[i] == ' ' && paragraph.data[i + 1] == ' ') hammer_report_fix(i, i + 2, "Two spaces", " ", 1);
  }
}

hammer_diagnose.h defines the diagnose export for you. It reads the request and calls hammer_diagnose_begin once. It then calls hammer_diagnose_paragraph for each paragraph, passing the text as UTF-8, and finally writes the reply.

Return 0 from hammer_diagnose_begin to report nothing, for example when the project's language is one your check has no lists for. hammer_language_is(request, "en") matches a tag with or without a region. Read settings with hammer_setting_bool and hammer_setting_int. hammer_report reports a range with any number of HammerFixes, each one {replacement, length, label}. hammer_suggest and hammer_suggest_fix report a suggestion the same way. The request's project is the project's name.