grammar-callbacks
Direction: guest implements this interface · Capability: none (pure data / dispatch) · Worlds: auto-pair-plugin (exports), comment-plugin (exports), grammar-plugin (exports), project-plugin (exports), treesitter-context-plugin (exports)
The behavior callbacks a grammar plugin exports; the host calls one by callback id on dispatch (the PH7.3d callback-id trampoline). Synchronous — a grammar apply resolves on the keystroke path (the PH7.7 fork: a motion must return inline to compose with its operator; async would break operator∘motion atomicity + dot-repeat/macros). Each maps its native evaluator's GrammarResult<...>: ok is the produced value; an err string is logged and the contribution is a no-op (graceful degradation, §8). A trap (fuel/epoch) is the runaway guard — the host catches it, logs, and the contribution no-ops, never a hang (a Reflex-class budget bounds it, PH7.7c).
An operator/ex-command/action returns list<effect> — the boundary form of the closed Effect enum (Effect::Many flattens to the list; §4.4). A text object returns the range it resolved; a motion its motion-result.
Uses
motion-contextfromtypesmotion-resultfromtypesoperator-contextfromtypestext-object-contextfromtypesex-command-contextfromtypesaction-contextfromtypesrangefromtypeseffectfromtypesargsfromtypesdocumentfrombuffertree-snapshotfromtree-sitter
Functions (6)
apply-action
apply-action: func(callback: u32, ctx: action-context, doc: borrow<document>, tree: option<borrow<tree-snapshot>>) -> result<list<effect>, string>
Example — Dispatch actions by callback id, reading an option and the document, declining to fall through · plugins/auto-pair/src/lib.rs
fn apply_action(
callback: u32,
ctx: ActionContext,
doc: &Document,
tree: Option<&TreeSnapshot>,
) -> Result<Vec<Effect>, String> {
// AP.3: in `manual` style the pair keys (1..=9) self-insert — the action
// DECLINES so the typed char lands via the builtin, and only the close key
// + backspace act. In `auto` style the close key declines instead.
let manual = is_manual();
if manual && (CB_OPEN_ROUND..=CB_QUOTE_BACKTICK).contains(&callback) {
return Ok(vec![Effect::Declined]);
}
Ok(match callback {
CB_OPEN_ROUND => insert_pair(&ctx, "(", ")"),
CB_OPEN_SQUARE => insert_pair(&ctx, "[", "]"),
CB_OPEN_CURLY => insert_pair(&ctx, "{", "}"),
CB_CLOSE_ROUND => close(&ctx, doc, ")"),
CB_CLOSE_SQUARE => close(&ctx, doc, "]"),
CB_CLOSE_CURLY => close(&ctx, doc, "}"),
CB_QUOTE_DOUBLE => quote(&ctx, doc, "\""),
CB_QUOTE_SINGLE => quote(&ctx, doc, "'"),
CB_QUOTE_BACKTICK => quote(&ctx, doc, "`"),
// The manual close key acts only in `manual` style; in `auto` it
// declines so `<C-j>` does whatever else it's bound to.
CB_CLOSE_MANUAL if manual => manual_close(&ctx, doc, tree),
CB_CLOSE_MANUAL => vec![Effect::Declined],
CB_BACKSPACE => backspace(&ctx, doc),
other => return Err(format!("auto-pair: unknown action callback {other}")),
})
}
apply-ex-command
apply-ex-command: func(callback: u32, ctx: ex-command-context, doc: borrow<document>, tree: option<borrow<tree-snapshot>>) -> result<list<effect>, string>
OC.10 gave this doc and tree, so a plugin ex-command can read the buffer it was invoked from — the same pair apply-action receives, minted at the same instant so their versions agree (§7). tree is none for a plain-text buffer, a parse still in flight, or a plugin without the tree-sitter grant.
Example — An ex-command that replaces the cursor's line, targeting the buffer the context names · crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs
fn apply_ex_command(
c: u32,
ctx: ExCommandContext,
doc: &Document,
tree: Option<&TreeSnapshot>,
) -> Result<Vec<Effect>, String> {
if c == 31 {
// Everything here was unreachable before OC.10: `ctx.cursor` says
// which line, `ctx.buffer_id` names the target, `doc` proves the
// buffer is readable, and `tree` proves the parse crossed too. The
// echo reports the last two so a regression to the old context fails
// loudly rather than editing the right line for the wrong reason.
let had_line = doc.line(ctx.cursor.line).is_some();
let kind = tree.map(|t| t.root().kind()).unwrap_or_else(|| "none".into());
let old = doc.line(ctx.cursor.line).unwrap_or_default();
return Ok(vec![
Effect::ApplyEdit(ApplyEditPayload {
target: ctx.buffer_id,
edit: Edit {
range: Range {
start: Position { line: ctx.cursor.line, byte: 0 },
end: Position {
line: ctx.cursor.line,
byte: old.len() as u32,
},
},
kind: EditKind::Replace(format!("EX:{had_line}:{kind}")),
},
cursor: None,
}),
]);
}
Err("multiseam: no ex-commands".into())
}
apply-motion
apply-motion: func(callback: u32, ctx: motion-context, doc: borrow<document>, tree: option<borrow<tree-snapshot>>) -> result<motion-result, string>
OM.4: a motion receives borrow<document> too. The apply-action doc-comment below anticipated this — "text-reading motions (structural / word motions) can reuse the same handle when a motion signature needs it" — and org's headline motions (]] / [[ / g{) are the first that do: finding the next headline means reading lines.
OT.1: a motion receives the tree too, on the same terms as an action — acquired the same instant as doc, none when the buffer has no parse.
This doc-comment used to say a motion gets the document but NOT the tree, because "the native MotionContext carries a ScopeResolver rather than a SyntaxSnapshot, so there is no tree handle to mint here without changing the native context — and no motion has yet needed one. When one does, that is the slice that adds it." Org's headline motions are that motion: they resolve (section) / (headline) structure, and hand-rolled star-counting is what OT.x exists to end.
The native change was smaller than that paragraph predicted. GrammarEnv::syntax already carried the type-erased snapshot on every dispatch — execute_action cloned it into ActionContext and the motion and text-object contexts simply never read it. So this cost two borrowed fields, not new plumbing. Borrowed rather than cloned because motions fire on every j: a native motion pays nothing, and only a plugin motion that actually mints the resource pays the Arc bump.
Example — A motion answered from the parse tree: jump to where the tree's span ends · crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs
fn apply_motion(
c: u32,
_ctx: MotionContext,
_doc: &Document,
tree: Option<&TreeSnapshot>,
) -> Result<MotionResult, String> {
match c {
// OT.1: target the end of the parse tree's own span. Unanswerable
// without the tree, so `none` surfaces as a guest err rather than a
// wrong-but-believable line.
20 => {
let tree = tree.ok_or_else(|| "multiseam: motion got no tree".to_string())?;
Ok(MotionResult {
target: tree.root().byte_range().end,
linewise: true,
})
}
other => Err(format!("multiseam: unknown motion callback {other}")),
}
}
apply-operator
apply-operator: func(callback: u32, ctx: operator-context, doc: borrow<document>) -> result<list<effect>, string>
CM.1: an operator receives borrow<document>, the pair the motion, text-object, action and ex-command callbacks already had (AP.0.1, OM.4b, OT.1, OC.10). It was the last one without, because no plugin had contributed an operator — and a comment operator cannot work without the text: comment-vs-uncomment, the indent column, and stripping an existing leader are all reads.
No tree, deliberately. OperatorContext carries document and comment_syntax but, unlike TextObjectContext, no path or syntax — minting a tree resource would mean widening the native context and every operator call site for a capability no operator has asked for. Add it when one does; the asymmetry is a decision, not an oversight.
Example — A linewise operator that reads the range and returns edits for the host to apply · plugins/comment/src/lib.rs
fn apply_operator(
callback: u32,
ctx: OperatorContext,
doc: &Document,
) -> Result<Vec<Effect>, String> {
if callback != CB_TOGGLE {
return Err(format!("comment: unknown operator callback {callback}"));
}
// The grammar hands over an expanded range; the operator is linewise
// regardless of how the motion arrived, which is what `gc$` doing the
// whole line means.
let first = ctx.range.start.line;
let last = ctx.range.end.line;
// Graceful and specific: the echo names the reason. A silent no-op
// here is the failure mode the plugin-host rules keep legislating
// against — the user presses `gc`, nothing happens, and nothing says
// why.
let path = doc.path();
let Some(leader) = path.as_deref().and_then(toggle::leader_for_path) else {
return Ok(vec![Effect::Echo(lattice::plugin_host::types::EchoPayload {
level: lattice::plugin_host::types::EchoLevel::Warn,
text: match path.as_deref() {
None => "comment: this buffer has no file, so no comment syntax".to_string(),
Some(p) => format!("comment: no comment syntax known for `{p}`"),
},
})]);
};
let mut nums = Vec::new();
let mut texts = Vec::new();
for n in first..=last {
if let Some(text) = doc.line(n) {
nums.push(n);
texts.push(text);
}
}
// `leader-space` is read per invocation rather than cached: a plugin
// that snapshots an option at load answers from the value the user had
// when the editor started, forever.
// Read per invocation, not cached: a plugin that snapshots an option
// at load answers from the value the user had when the editor started,
// forever. `auto-pair::is_manual` reads its own option the same way.
// Absent or unparseable ⇒ the registered default, `true`.
let leader_space = config::get_option("leader-space")
.map(|v| v != "false")
.unwrap_or(true);
let mut edits = Vec::new();
for (i, next) in toggle::toggle(&texts, leader, leader_space)
.into_iter()
.enumerate()
{
// `None` means the line is unchanged — no edit, so a no-op `gc`
// stays off the undo stack.
let Some(next) = next else { continue };
edits.push(Effect::ApplyEdit(ApplyEditPayload {
// CM.3: the buffer the operator ran over. A guest holds a
// read-only handle, so it asks the host to apply rather than
// mutating — which is why `operator-context` had to carry an
// id at all.
target: ctx.buffer_id,
edit: Edit {
range: Range {
start: Position {
line: nums[i],
byte: 0,
},
end: Position {
line: nums[i],
byte: texts[i].len() as u32,
},
},
kind: EditKind::Replace(next),
},
// Leave the caret where the user put it; vim's `gc` does not
// move it.
cursor: None,
}));
}
Ok(edits)
}
apply-text-object
apply-text-object: func(callback: u32, ctx: text-object-context, doc: borrow<document>, tree: option<borrow<tree-snapshot>>) -> result<range, string>
OM.4b: a text object receives borrow<document> too — text-object-context has always said "buffer text + the scope/comment env ride the document handle", and AP.0.1 simply wired the action path first. Org's headline and subtree objects are the first plugin ones, and resolving a subtree's bounds means reading lines.
OT.1: and the tree, for the apply-motion reason above — org's ir / ar resolve a subtree, which IS the (section) node. A text object gets the tree rather than only the scope-resolver the native structural objects use, because the resolver answers "what encloses this point" while a plugin object needs to query the tree itself.
Example — Resolve a text object to the range from the start of the cursor's line to the cursor · crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs
fn apply_text_object(
callback: u32,
ctx: TextObjectContext,
_doc: &Document,
_tree: Option<&TreeSnapshot>,
) -> Result<Range, String> {
match callback {
2 => Ok(Range {
start: Position {
line: ctx.at.line,
byte: 0,
},
end: ctx.at,
}),
other => Err(format!("fixture: unknown text-object callback {other}")),
}
}
parse-ex-args
parse-ex-args: func(callback: u32, rest: string, bang: bool) -> result<args, string>
Example — Turn an ex-command's raw argument text into typed args · plugins/project/src/lib.rs
fn parse_ex_args(_c: u32, rest: String, _bang: bool) -> Result<Args, String> {
let rest = rest.trim();
Ok(if rest.is_empty() {
Args::None
} else {
Args::String(rest.to_string())
})
}