The Kalem Book GitHub

This Book describes Kalem's main branch, which may be ahead of the latest release.

Completers, modes and highlighters

A file type gets three things from Kalem beyond plain editing: a completer, which offers completions at the cursor; a highlighter, which colors its code; and a document mode, which renders it (Org as a styled document, CSV as a grid, LaTeX with formulas typeset). The design gives each one contract shared by the built-in ones and by plugins (D25). This chapter says where each contract stands today.

Contract State today Code Plugin side
Completers Implemented; the built-in completers use it, and plugins' through the completer export (API 0.2.11) kalem-core/src/completers.rs API 0.2.11 completer
Highlighters syntect with the built-in syntax definitions, and language plugins' (no code) kalem-highlight/src/lib.rs T3.1.9g
Document modes The contract with its conformance check; CSV, Markdown, LaTeX and Org on it, the editors drawing each mode through mode_view kalem-core/src/modes.rs T2.7c.10, T3.1.9g
Language packs Implemented and wired; no pack built in kalem-core/src/packs.rs T3.1.9c, T3.1.9e
Layers Implemented: a plugin's overlays over Markdown and Org, applied by mode_view; the graph plugin's kalem-core/src/layers.rs API 0.2.10 layer

1. Completers

A completer is a source of completions: Org's keywords and link targets, LaTeX's commands and citation keys, the words of the document. One contract serves all of them (design §11.12): the core runs the completers that apply at the cursor, merges and ranks their items, and shows one menu, the same in both editors. The contract is a Rust trait in kalem_core::completers, implemented inside Kalem; a plugin offers a completer through the plugin API's completer export, which Kalem wraps in the same trait (see A plugin's completer, below).

The menu opens by itself when a completer's trigger fires as you type, and Ctrl+Space or Alt+/ (edit.complete) asks for it anywhere. Down and Up choose an item, Escape closes the menu, Tab takes the chosen item and so does Enter, except for a word from the document, where Enter closes the menu and starts a new line. Taking an item is one undo step. kalem complete FILE:LINE:COL prints the items at a place, best first, one a line, as label, kind and completer separated by tabs; it completes as if asked with Ctrl+Space and waits up to two seconds for slow completers.

$ kalem complete notes.org:4:5      # line 4 is "#+ti"
#+title:	keyword	org
ID Applies in Trigger Priority Items
org Org documents #+, [[, [fn: or : before the cursor 10 #+ keywords and blocks, link targets, footnote labels, tags
latex LaTeX files \, {, , or [ before the cursor 10 Commands, environments, citation keys, labels, files, packages, options
words Every mode but the file manager A word of three letters 0 Words of the document, nearest to the cursor first

The words completer takes words of at least three letters that start with the word being typed, ignoring case, from the megabyte of text around the cursor, nearest first, at most fifty; in Org documents it stays quiet where Org's own completion applies. A dictionary with spell checking and a model are not implemented; a language server completes the files of a language plugin that names one.

1.1. The contract

pub trait Completer: Send + Sync {
    fn id(&self) -> &'static str;                 // "org", "words"
    fn priority(&self) -> i32 { 0 }               // higher first among equals
    fn scope(&self) -> Scope { Scope::all() }     // the text types it serves
    fn applies(&self, ctx: &Context) -> bool;     // where else it applies
    fn trigger(&self) -> Trigger;                 // what opens its menu
    fn slow(&self) -> bool { false }              // run on another thread
    fn budget(&self) -> Duration { DEFAULT_BUDGET } // a slow one's time: 100 ms
    fn complete(&self, ctx: &Context, doc: Option<&DocumentState>,
                cancel: &Cancel) -> Vec<Item>;
    fn resolve(&self, _item: &Item) -> Option<String> { None }
    fn hover(&self, _ctx: &Context, _doc: Option<&DocumentState>) -> Option<String> { None }
}

scope gives the text types the completer serves, as a command's scope does, by the type at the cursor, so a completer for python also runs in a Python source block; applies decides the rest from the context. A fast completer gets the document (doc is Some) and runs on the editor's thread, so it must return quickly; a completer whose slow is true runs on another thread with the context only, and long work checks cancel.cancelled(). resolve gives documentation for an item when it is chosen; hover gives text about the thing under the cursor (the editors' hover card shows a language server's, not yet a completer's).

Trigger Fires when
Strings(&[...]) One of the strings occurs in the line before the cursor, or on request
WordPrefix(n) The word before the cursor has at least n characters; on request, one
Request Only on request (Ctrl+Space, Alt+/)

The Context holds text (the document's text up to a megabyte, else the megabyte around the cursor, starting at base), point, mode, text_type, path and requested; its methods slice(range), line_before(), paragraph_before() and word_prefix() give the text around the cursor. An Item has label, insert, range (what it replaces, ending at the cursor), cursor (where the cursor goes in insert), kind (word, keyword, link, tag, footnote, symbol, snippet), detail and source; Item::new(label, insert, range, kind) makes one.

At each keystroke that may complete, the Registry keeps the completers that apply and whose trigger fires, runs the fast ones at once and the slow ones on their own threads, adding their items as they arrive; the next keystroke drops the old completion and sets its cancel flag. Items with the same insertion over the same range count once, and the list is ordered by items whose label starts with the word being typed, then items accepted recently, then the completer's priority. A slow completer that misses its budget (budget, 100 ms) shows nothing for that keystroke and the menu keeps the others' items.

1.2. Writing a completer today

Inside Kalem, a new completer is a type that implements Completer, added to Registry::with_builtins in completers.rs (the LaTeX completer in latex_complete.rs is a complete example). A sketch that completes a fixed list of words in Markdown after two letters:

use kalem_core::command::Scope;
use kalem_core::completers::{Cancel, Completer, Context, Item, Kind, Trigger};
use kalem_core::DocumentState;

struct Glossary;

impl Completer for Glossary {
    fn id(&self) -> &'static str { "glossary" }
    fn scope(&self) -> Scope { Scope::only(&["markdown"]) }
    fn applies(&self, _ctx: &Context) -> bool { true }
    fn trigger(&self) -> Trigger { Trigger::WordPrefix(2) }
    fn complete(&self, ctx: &Context, _doc: Option<&DocumentState>, _c: &Cancel) -> Vec<Item> {
        let (start, prefix) = ctx.word_prefix();
        ["adjunction", "category", "functor", "morphism"]
            .iter()
            .filter(|w| w.starts_with(prefix))
            .map(|w| {
                let mut item = Item::new(*w, *w, start..ctx.point, Kind::Word);
                item.detail = "glossary".into();
                item
            })
            .collect()
    }
}

1.3. A plugin's completer

A plugin offers completions through the completer interface it exports (plugin API 0.2.11, kalem-plugin's feature completer). Its manifest declares its completers, which Kalem runs with its own:

"completers": [{
  "id": "refs", "types": ["markdown", "org"], "layers": ["logseq"],
  "triggers": ["[[", "#", "(("], "replaces": ["wiki", "words"], "priority": 12
}]
Field What it says
types The text types it serves, as a completer's scope; all when missing
layers The plugin's own layers: when given, it applies only in a document one of them serves
triggers Strings before the cursor on its line that open its menu, as Trigger::Strings
word A word of this many letters opens it too; with neither, only Ctrl+Space asks it
replaces Completers it stands in for: it is asked first, and where it gives items they are not asked
priority Its rank among the others when items rank the same: Kalem's own are 10, the words 0; 5 when missing

Kalem asks the plugin's complete(completer, request) on the editor's thread as the user types: the request holds the document's file, its text (all of it up to a megabyte, else the megabyte around the cursor, from base), the cursor and the text type at it; each item names the byte where the text it replaces starts (it ends at the cursor), what it inserts and where the cursor goes in it, its kind, a detail and documentation. An item that does not fit the text is left out. While another call of the plugin runs, the completer offers nothing for that keystroke. The graph plugin of getkalem/plugins completes pages, tags and blocks with it in a Logseq graph or an Obsidian vault.

2. Document modes

The modes today are a fixed set in the core (DocumentMode in mode.rs):

Mode Name in settings and editorMode Files
Org org .org, .org_archive
Markdown markdown .md, .markdown, .mdown, .mkd, .gfm
CSV csv .csv, .tsv, .tab
LaTeX latex .tex, .latex, .ltx
BibTeX text, language bib .bib, shown as a grid of entries
Plain text text Everything else that is text, with a language for highlighting
Binary binary Not opened for editing
File manager directory Folders

A mode is chosen in this order: the mode chosen with Set Document Mode (view.setMode), which the workspace settings remember by path in files.modes; a mode line on the first line (-*- mode: org -*- or -*- org -*-; a name that is not a mode opens plain text with that language); the extension; a #! line (#!/usr/bin/env python3 opens plain text in python); plain text without a language. A file whose start has a NUL byte, or bytes that are not UTF-8 with many control characters, is binary.

One contract serves every mode, the one built-in modes use and plugins will use (§11.11): the trait ModeSpec in kalem_core::modes, with id and detect (extensions and a sniff function over the first bytes), parse(text, edit?, previous?) (a flat tree of nodes, each with a kind from a fixed vocabulary, a byte range and its parent), grid(text) (rows and cells with ranges, for table-like formats), edit (Enter, Tab and toggles, each returning edits with ranges), the language pack hooks outline, format and diagnostics, and an optional to_html. A mode returns ranges into the text, never text, so it cannot break the byte-exact round trip; modes::check is the conformance check every mode runs: every range inside the text and inside its parent, two parses equal, an incremental parse equal to a full one. CSV, Markdown, LaTeX and Org (org_mode::OrgMode) are written against it (T2.7c.10). LaTeX maps its syntax tree to the kinds: sectioning commands as headings, levels counted from the document's highest (a book's chapters, an article's sections are level 1); itemize, enumerate and description as lists, each item from its \item to the next with what it holds; tables with rows ended by \\ and cells split by &; code environments with their language (minted's argument, lstlisting's language=); displayed and inline mathematics; \emph, \textbf, \texttt, \verb, \href and \url with their targets, \includegraphics with its file, \footnote; any other command or environment by its name. Its outline is the headings' titles, its formatter kalem fmt's, its diagnostics kalem check's, Enter and Tab those of the editor; every file of the arXiv sample passes modes::check. Both editors draw each mode through kalem_core::mode_view, one trait for a mode's lines, blocks and outline. Still to come: the plugin side, kalem::modes::register (T3.1.9g).

3. Layers over a mode

A layer changes how a mode draws a document without parsing it again: a plugin that knows a dialect of Markdown or Org (Logseq's blocks and properties, Obsidian's callouts, block ids and comments) says what its constructs become, and the mode draws the rest as it always does, its tables, links, formulas and pictures included. It is the way every "Markdown plus" is a plugin without a second Markdown parser.

A plugin declares its layers in its manifest:

"layers": [
  { "id": "logseq", "markers": ["logseq/config.edn"], "modes": ["markdown", "org"] },
  { "id": "obsidian", "markers": [".obsidian"], "modes": ["markdown"] }
]

A layer serves the documents of its modes whose folder, or a folder above it, holds one of its markers. For such a document Kalem asks the plugin's layer export (API 0.2.10) for overlays, by byte range of the text, once for each version of the text, and draws each line as its mode draws it with them applied:

Overlay Away from the cursor With the cursor in it
A span hidden Not shown, as a mode's markers The source
A span replaced Shown as the layer's text, in its style The source
A span styled Drawn in the style (bold, a tag, a keyword…) The same
Lines hidden Not shown at all Shown
Lines folded Folded to their first line, as a drawer Shown

The setting editor.show_source_markers rules them as it rules a mode's markers (always shows hidden and replaced spans as source, never never does). The source view (Show Source) shows the text as it is. A span that would cut a run the mode already replaced (a wiki link showing its title) is left out; overlapping spans and lines are left out after the first. A layer asked while a plugin's call runs answers at the next drawing; one that fails leaves the document as its mode draws it. A plugin calls layers.refresh when what its layers show depends on other files that changed (a block reference showing its block's text): every document a layer serves asks again.

The folder holding a layer's marker, once it is a project, is watched whole: a file another program writes there (Logseq or Obsidian open on the same graph, a sync) reaches the plugins as workspace-file-changed, as an open document's does, so that a plugin's index of the folder follows it; a file in a .git folder does not. An open document of it is read again, or the user asked, as always.

A layer may draw lines under a line of the text that are not part of it (API 0.2.13): a plugin built with kalem-plugin's feature layer-2 answers overlays_2, whose overlays add below, for the line holding a byte, lines of styled pieces, each so many columns in from where that line's text starts. The graph plugin shows an embedded block's children, an embedded page's blocks and a query's results under their line, as Logseq shows them in place. They are drawn under their line wherever it shows, the cursor on it or not, and not in the source view or when the line is folded or hidden away; the cursor steps over them, a search does not find them, a copy leaves them out, and a click on one puts the cursor at the end of their line.

Such a line may show the text of a file, to be typed into where it is drawn (API 0.2.15): a plugin built with the feature layer-3 answers overlays_3, whose lines under lines may each name a source, the file, its line and the byte of it the text starts at, and the text it held, to the line's end. The graph plugin's embedded blocks name the block's line in its page. A click on such a line, or Edit Line Under Line (view.editRow) on its line, makes it a one-line input showing that text as the file holds it, the cursor in it: Enter writes what was typed in place of that text, Escape drops it, Up, Down and Tab write it and go to the line before or after under the same line. It is written in the file's open document when that has unsaved changes, a step to undo there, else in the file, which an open document of it then reads again and the layer is asked about anew. Only files in Kalem's projects are written (the plugins' workspace, as fs:write:workspace reaches), and only where they still hold the text the layer saw; else Kalem says why and writes nothing. The text is that of one line: a line feed typed is a space.

A layer may name the plugin's command that opens the link at the cursor in the documents it serves ("open": "graph.openLink", API 0.2.12): a Ctrl-click runs it in place of the mode's own Open Link, so that a link is followed by the plugin's rules (a Logseq page's aliases, an Obsidian note's shortest path); the command leaves to the mode what is not its.

The plugin's keys in the documents a layer serves are bound with a when-clause naming it, editorLayer = graph.logseq=, and such keys come before the profile's and Vim's there, as a plugin's keys in its own documents do: an outliner's Enter or Tab in a list is the plugin's, not Markdown's.

The contract is kalem_core::layers; the plugin's side is kalem_plugin::layer with the feature layer, whose Plugin trait answers overlays(layer, path, text), with the feature layer-2 overlays_2(layer, path, text), or with the feature layer-3 overlays_3(layer, path, text).

4. Highlighters

Code is highlighted by kalem-highlight, one engine for plain text files and for Org source blocks (D16). It uses syntect, which reads Sublime Text syntax definitions, with pure Rust regular expressions; the definitions are syntect's default set with those of bat added (the two-face crate), built into Kalem: about two hundred languages, listed in Plain text and code. A language name is looked up, ignoring case, first in a table of Org's source block names that differ from syntect's (sh, shell, bash and zsh are shell, elisp is Lisp, js JavaScript, py Python, tex LaTeX, dot Graphviz), then as a syntax's name token, then as a file extension; a name that finds nothing is not highlighted.

The highlighter gives each span a kind from the syntax's scope, not a color: comment, string, number, constant, keyword, operator, function, type, variable, tag, macro, invalid. The editors color the kinds with the theme, whose six code colors (keyword, string, comment, number, function, type) you can change in themes/light.toml and themes/dark.toml. In plain text files the highlighter keeps its state at each line, so after an edit only the changed lines, and those after them until the state is what it was, are highlighted again. A language plugin adds Sublime syntax definitions from its folder, with no code: they come before the built-in ones for their extensions, and extends (which syntect does not know, and which Sublime Text's own syntaxes use) is resolved by Kalem. The set with the plugins' syntaxes takes most of a second to build, so it is built once and kept in Kalem's state folder until a plugin changes.

5. Language packs

A format whose view is its source (JSON, YAML, TOML, XML, a diff, a ledger journal, a gettext catalogue) gets no document mode but a language pack: four hooks on top of its highlighter, the same four a plugin gives (kalem_core::packs, T2.7a.7).

Hook What the pack gives Where Kalem uses it
outline Headings with their level, title and start The outline sidebar of both editors
format The formatted text, or a refusal with its place Format Document (hasFormatter), kalem fmt, kalem check
diagnostics Problems with a range, a code and a message The status bar, kalem check
completer A completer on the contract above The completion menu

A pack serves text types, the highlighter's language names (json, diff), and a document in plain text mode with one of them gets the pack's hooks. The status bar shows the problem on the cursor's line or how many there are; Format Document refuses a text the formatter refuses and puts the cursor at the place; kalem fmt lists it as "not formatted" and fails, and kalem check prints each problem as FILE:LINE:COL: error[CODE]: MESSAGE. The packs themselves are to be plugins in getkalem/plugins (D29). The plugin API gives three of the hooks so far, completer (0.2.11), problems for the diagnostics (0.2.14) and formatter (0.2.16), and not the outline; the contract is in the core with its wiring and tests, and no pack is built in.