I wanted to set up a feature where the editor for a JSON document would keep track of a JSON Pointer (RFC 6901) string that describes the location where the cursor is at. I have implemented a facet which attempts to efficiently walk the syntax tree and build up this string value:
import { syntaxTree } from "@codemirror/language";
import { Facet } from "@codemirror/state";
export const jsonPointerFacet = Facet.define();
export const jsonPointerExtension = () => jsonPointerFacet.compute(['doc', 'selection'], state => {
const pos = state.selection.main.from;
const path = [''];
const tree = syntaxTree(state);
let nextIndex = NaN;
let nextIndexStack = new Array<number>();
tree.iterate({
to: pos,
enter(node) {
switch (node.name) {
case 'Array': {
if (node.to < pos) {
nextIndex++;
return false;
}
if (!Number.isNaN(nextIndex)) {
path.push(''+nextIndex);
}
nextIndexStack.push(++nextIndex);
nextIndex = 0;
break;
}
case 'Object': {
if (node.to < pos) {
nextIndex++;
return false;
}
if (!Number.isNaN(nextIndex)) {
path.push(''+nextIndex);
}
nextIndexStack.push(nextIndex);
nextIndex = NaN;
break;
}
case 'String': case 'Number': case 'Null': case 'True': case 'False': {
if (node.to >= pos && !Number.isNaN(nextIndex)) {
path.push(''+nextIndex);
}
else {
nextIndex++;
}
return false;
}
case 'Property': {
if (node.to < pos) return false;
break;
}
case 'PropertyName': {
path.push(JSON.parse(state.doc.sliceString(node.from, node.to)).replace(/[\/~]/g, (v: string) => v === '~' ? '~0' : '~1'));
break;
}
}
},
});
return path.join('/');
});
The value of this facet could then be used for a “breadcrumb” like display, or some other feature.
I’m still very new to CM6 and keen to learn all I can about the best way to do things. I’d welcome any feedback about this. Like:
Can this be done more efficiently? At the moment the entire string is recalculated every time the cursor moves – is there a caching strategy that would be recommended?
Is using a Facet even the right feature for this kind of thing? If not, what would you recommend instead?
Are there cases you can see where this implementation would fail on a valid JSON document? (Or get itself into a particularly bad state on an invalid one?)
I don’t think this needs caching, no. But I think a faster/simpler implementation would just resolve the cursor position in the syntax tree, and build up the path by walking up parent pointers from the resulting node.
A state field seems a more reasonable place to store this then a facet. You can make sure to only recompute it when the selection, document, or syntax tree changes.
import { syntaxTree } from "@codemirror/language";
import { StateField, Text } from "@codemirror/state";
import { SyntaxNode } from "@lezer/common";
const VAL_NODE_NAME = /^(?:Null|True|False|Object|Array|String|Number)$/;
function getJsonPointerAt(docText: Text, node: SyntaxNode): string {
const path: string[] = [];
for (let n: SyntaxNode | null = node; n && n.parent; n = n.parent) {
switch (n.parent.name) {
case 'Property': {
const name = n.parent.getChild('PropertyName');
if (name) {
path.unshift(JSON.parse(docText.sliceString(name.from, name.to)).replace(/[\/~]/g, (v: string) => v === '~' ? '~0' : '~1'));
}
break;
}
case 'Array': {
if (VAL_NODE_NAME.test(n.name)) {
let index = 0;
for (let s = n.prevSibling; s; s = s.prevSibling) {
if (VAL_NODE_NAME.test(s.name)) {
index++;
}
}
path.unshift(''+index);
}
break;
}
}
}
path.unshift('');
return path.join('/');
}
export const jsonPointerField = StateField.define<string>({
create(state) {
return getJsonPointerAt(state.doc, syntaxTree(state).resolve(state.selection.main.from, 1));
},
update(value, transaction) {
const { state, startState } = transaction;
if (startState.selection.eq(state.selection) && !transaction.docChanged) {
return value;
}
return getJsonPointerAt(state.doc, syntaxTree(state).resolve(state.selection.main.from, 1));
},
});
How does this look? For one thing I’m wondering if the condition I have in the update method is sufficient – you mentioned changes to both the document and the syntax tree, is transaction.docChanged enough to cover that?
I’ve also started work on a sort of inverse feature: taking a JSON Pointer in like a search term, and selecting the part of the document that matches it, if any.
The core parts are:
A StateEffect called setJsonPointerQuery that is dispatched with a new search term as the effect value when the user types one in.
A StateField called jsonPointerQuery that starts as null and checks for effects of type setJsonPointerQuery to update itself.
A Command called selectJsonPointerMatch that navigates the syntax tree and dispatches a transaction to update the selection, if a match is found.
import { syntaxTree } from "@codemirror/language";
import { EditorSelection, StateEffect, StateField } from "@codemirror/state";
import { Command } from "@codemirror/view";
import { SyntaxNode } from "@lezer/common";
const VAL_NODE_NAME = /^(?:Null|True|False|Object|Array|String|Number)$/;
export const setJsonPointerQuery = StateEffect.define<string | null>();
export const jsonPointerQuery = StateField.define<string | null>({
create() {
return null;
},
update(value, transaction) {
for (let effect of transaction.effects) {
if (effect.is(setJsonPointerQuery)) {
return effect.value;
}
}
return value;
},
})
function toValueNode(node: SyntaxNode): SyntaxNode | null {
if (VAL_NODE_NAME.test(node.name)) return node;
for (let child = node.firstChild; child; child = child.nextSibling) {
const n = toValueNode(child);
if (n != null) return n;
}
return null;
}
export const selectJsonPointerMatch: Command = view => {
let jptr = view.state.field(jsonPointerQuery, false);
if (jptr == null) return false;
if (jptr == '') {
const node = toValueNode(syntaxTree(view.state).topNode);
if (node) {
view.dispatch({
selection: EditorSelection.create([EditorSelection.range(node.from, node.to)]),
})
return true;
}
else {
return false;
}
}
if (jptr[0] !== '/') return false;
const parts = jptr.slice(1).split(/\//g).map(v => v.replace(/~[01]/g, m => m === '~0' ? '~' : '/'));
const tree = syntaxTree(view.state);
let node = toValueNode(tree.topNode);
if (!node) return false;
let part_i = 0;
while (part_i < parts.length) {
switch (node.name) {
case 'Object': {
const props = node.getChildren('Property');
const propIndex = props.findIndex(prop => {
const name = prop.getChild('PropertyName');
if (!name) return false;
return JSON.parse(view.state.doc.sliceString(name.from, name.to)) === parts[part_i];
});
if (propIndex === -1) {
return false;
}
node = toValueNode(props[propIndex]);
if (node == null) return false;
part_i++;
break;
}
case 'Array': {
if (!/^(?:0|[1-9][0-9]*)$/.test(parts[part_i])) {
return false;
}
let index = +parts[part_i];
node = node.firstChild;
while (node && !VAL_NODE_NAME.test(node.name)) {
node = node.nextSibling;
}
if (!node) return false;
while (index > 0) {
node = node && node.nextSibling;
while (node && !VAL_NODE_NAME.test(node.name)) {
node = node.nextSibling;
}
if (!node) return false;
index--;
}
part_i++;
break;
}
default: {
return false;
}
}
}
view.dispatch({
selection: EditorSelection.create([EditorSelection.range(node.from, node.to)]),
})
return true;
};
As before, I’d appreciate any feedback about this approach.