CodeMirror
CodeMirror 6 is a common choice for an expression editor in a web app. This page gives a complete extension that connects it to the language service: completions as you type, errors and warnings underlined with a quick-fix for "did you mean", and types on hover. Copy it into your project; it is about 120 lines and has no dependencies beyond CodeMirror and Bonsai.
The editor above runs exactly the code on this page, against an environment that declares user and orders. Type user. or orders.filter(. to see completions, misspell a field to see the quick-fix, and hover a name to see its type.
Install
npm install bonsai-js @codemirror/state @codemirror/view @codemirror/autocomplete @codemirror/lintThe extension
Save this as bonsai-language.ts. Positions map directly: the language service and CodeMirror both count UTF-16 code units.
import {
autocompletion,
type Completion,
type CompletionContext,
type CompletionResult,
} from '@codemirror/autocomplete'
import { linter, type Diagnostic } from '@codemirror/lint'
import type { EditorState, Extension } from '@codemirror/state'
import { EditorView, hoverTooltip, type Tooltip } from '@codemirror/view'
import type { LanguageService } from 'bonsai-js/service'
// CodeMirror's icon for each kind of completion item.
const TYPES: Record<string, string> = {
value: 'constant',
variable: 'variable',
local: 'variable',
property: 'property',
function: 'function',
method: 'method',
keyword: 'keyword',
}
/** Completions at the cursor, from the language service. */
export function bonsaiCompletions(service: LanguageService) {
return (context: CompletionContext): CompletionResult | null => {
const source = context.state.doc.toString()
const result = service.complete(source, context.pos)
const typed = source.slice(result.start, context.pos)
const before = source.slice(0, result.start)
const afterDot = /\??\.\s*$/u.test(before) && !/\.\.\.\s*$/u.test(before)
// While typing, open after `.`, once a name is started, or where only values fit
// (inside `order.country == "`); Ctrl-Space always opens.
const onlyValues = result.items.length > 0 && result.items.every((item) => item.kind === 'value')
if (!context.explicit && typed === '' && !afterDot && !onlyValues) return null
const options = result.items
.filter((item) => item.label !== '.')
.map((item): Completion => ({
label: item.label,
type: TYPES[item.kind],
detail: item.detail,
...(item.documentation === undefined ? {} : { info: item.documentation }),
// An item with its own range (a field inserted as ["first-name"]) replaces that range.
apply: (view, _completion, from, to) => {
const start = item.range?.start ?? from
const end = item.range?.end ?? to
// A value picked inside quotes (`== "N`) closes the string if needed and puts the cursor after it.
const doc = view.state.doc
const before = doc.sliceString(start - 1, start)
const quote = item.kind === 'value' && (before === '"' || before === "'") ? before : ''
const closed = quote !== '' && doc.sliceString(end, end + 1) === quote
const insert = item.insertText + (quote && !closed ? quote : '')
view.dispatch({
changes: { from: start, to: end, insert },
selection: { anchor: start + insert.length + (closed ? 1 : 0) },
})
},
}))
if (options.length === 0) return null
return { from: result.start, to: result.end, options, validFor: /^[\w$]*$/u }
}
}
/** Every syntax and check finding, as CodeMirror diagnostics with a quick-fix for a suggestion. */
export function bonsaiDiagnostics(service: LanguageService, state: EditorState): Diagnostic[] {
const length = state.doc.length
return service.diagnostics(state.doc.toString()).map((d) => {
const from = Math.min(d.start, length)
const suggestion = d.suggestion
return {
from,
to: Math.min(Math.max(d.end, from), length),
severity: d.severity,
message: d.message,
actions:
suggestion === undefined
? []
: [
{
name: `Use "${suggestion}"`,
apply: (view, start, end) => view.dispatch({ changes: { from: start, to: end, insert: suggestion } }),
},
],
}
})
}
/** The type or signature under the pointer. Text is set with textContent, never as HTML. */
export function bonsaiHover(service: LanguageService, state: EditorState, pos: number): Tooltip | null {
const info = service.hover(state.doc.toString(), pos)
if (info === undefined) return null
return {
pos: info.start,
end: info.end,
above: true,
create: () => {
const dom = document.createElement('div')
dom.className = 'cm-bonsai-hover'
const detail = document.createElement('code')
detail.textContent = info.detail
dom.append(detail)
if (info.documentation !== undefined) {
const doc = document.createElement('div')
doc.className = 'cm-bonsai-doc'
doc.textContent = info.documentation
dom.append(doc)
}
return { dom }
},
}
}
/** Completions, diagnostics, and hover types for a Bonsai expression editor. */
export function bonsaiLanguage(service: LanguageService): Extension {
return [
autocompletion({ override: [bonsaiCompletions(service)] }),
linter((view) => bonsaiDiagnostics(service, view.state), { delay: 200 }),
hoverTooltip((view, pos) => bonsaiHover(service, view.state, pos)),
EditorView.baseTheme({
'.cm-bonsai-hover': { padding: '4px 8px', maxWidth: '40em' },
'.cm-bonsai-doc': { marginTop: '4px', opacity: '0.8' },
}),
]
}Diagnostic messages and completion details quote the expression, which the user controls, so the hover is built with textContent. CodeMirror renders completion labels, details, and diagnostic messages as text.
Create an editor
Create the language service once per environment and pass it to the extension. Check the text again when the rule is saved: the editor helps the person writing it, and check() is the gate.
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { bonsai, t } from 'bonsai-js'
import { createLanguageService } from 'bonsai-js/service'
import { bonsaiLanguage } from './bonsai-language'
const env = bonsai({
variables: {
user: t.object({ name: t.string(), age: t.number(), plan: t.enum('free', 'pro') }),
orders: t.list(t.object({ total: t.number(), paid: t.boolean() })),
},
})
// expect: every rule must be a yes or no, so a non-boolean rule is underlined too.
const service = createLanguageService(env, { expect: t.boolean() })
function createEditor(parent: HTMLElement, doc: string): EditorView {
return new EditorView({ parent, state: EditorState.create({ doc, extensions: [bonsaiLanguage(service)] }) })
}
function save(view: EditorView): string | undefined {
const source = view.state.doc.toString()
return env.check(source, { expect: t.boolean() }).ok ? source : undefined
}bonsaiLanguage adds only what Bonsai provides. For undo history, key bindings, line numbers, and the rest of a full editor, add CodeMirror's basicSetup from the codemirror package, or your own selection of extensions, to extensions.
What it produces
The pieces work on an EditorState, so they can be tested without a browser:
import { CompletionContext } from '@codemirror/autocomplete'
import { bonsaiCompletions, bonsaiDiagnostics, bonsaiHover } from './bonsai-language'
const typing = EditorState.create({ doc: 'user.' })
const completions = bonsaiCompletions(service)(new CompletionContext(typing, 5, false))
completions?.options.map((option) => option.label).slice(0, 3) // => ["age", "name", "plan"]
completions?.options[0].type // => "property"
const misspelled = EditorState.create({ doc: 'user.agee >= 18' })
const [problem] = bonsaiDiagnostics(service, misspelled)
problem.from // => 5
problem.to // => 9
problem.severity // => "error"
problem.message // => "Property \"agee\" does not exist on user; did you mean \"age\"?"
problem.actions?.map((action) => action.name) // => ["Use \"age\""]
bonsaiDiagnostics(service, EditorState.create({ doc: 'user.age' }))[0].message // => "Expected the expression to produce boolean but it produces number"
bonsaiHover(service, misspelled, 1)?.pos // => 0When the environment changes
When the variables or functions change (a tenant's schema loads, a user adds a custom field), create a new language service and swap it in with a Compartment, which keeps the text, selection, and history:
import { Compartment } from '@codemirror/state'
const language = new Compartment()
function createReconfigurableEditor(parent: HTMLElement, doc: string): EditorView {
return new EditorView({
parent,
state: EditorState.create({ doc, extensions: [language.of(bonsaiLanguage(service))] }),
})
}
function useEnvironment(view: EditorView, next: typeof env): void {
view.dispatch({ effects: language.reconfigure(bonsaiLanguage(createLanguageService(next))) })
}Notes
- Completions open after
.and once a name is started, as in the Playground; Ctrl-Space opens them anywhere.validForlets CodeMirror filter the list as the user keeps typing a name, without asking the service again. - Diagnostics run 200 ms after the user stops typing. The service never evaluates an expression or calls a host function, so it is safe on every keystroke.
- For a filter or a rule that must have one type, create the service with
expect(as above), soEXPECTED_TYPEis underlined while typing. - Everything here also works with
bonsai-js/liteenvironments.
See Editor Support for the language service itself and the Language Service API for its types.