JSON Pointer at Cursor [Seeking implementation critique]

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?)

Thanks!

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.

Great, okay – here is v2:

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?

No, also check for syntaxTree(tr.startStart) == syntaxTree(tr.state).

Thank you! That makes sense.

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.

1 Like