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 alabelshows its replacement. Hammer drops a fix of any other shape. - Severity. An
erroris a mistake, and Hammer underlines it with a wavy blue line (spell check's is red). Asuggestion, 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.