jevstrudel.git / website / src / jev / boothHighlight.mjs

Jev in the code, in Jev's colour:

  • installJevSyntax: always, every call into Jev's API: jev(, the question builders choice(, score( and noul(, walk(, the variable a song's jev({...}) and walk(...) are bound to (djJev, form), and the answers destructured from djJev.every(...) wherever they are used.
  • highlightMove: while a song plays, the names Jev chose for the playing section (the form's section, each choice), boxed wherever the song writes them as keys: in the question's options and in the .pick({...}) that plays them, and their lines tinted (and on the minimap) in Jev's colour. JevBoothEffects calls it on every change.
12import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
13import { Decoration, EditorView, ViewPlugin } from '@codemirror/view';
14import { lineHighlights } from '@strudel/codemirror';

Jev's colour, in the code and on the minimap

17const JEV_COLOR = '#ffcc00';
19const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

── always: the API's calls ─────────────────────────

22const call = Decoration.mark({ class: 'jev-call' });

Where the code is text, not code: strings and comments, as [from, to).

25function textSpans(code) {
26  const spans = [];
27  for (let i = 0; i < code.length; i++) {
28    const c = code[i];
29    if (c === '/' && code[i + 1] === '/') {
30      const end = code.indexOf('\n', i);
31      spans.push([i, end < 0 ? code.length : end]);
32      i = end < 0 ? code.length : end;
33    } else if (c === '/' && code[i + 1] === '*') {
34      const end = code.indexOf('*/', i + 2);
35      spans.push([i, end < 0 ? code.length : end + 2]);
36      i = end < 0 ? code.length : end + 1;
37    } else if (c === "'" || c === '"' || c === '`') {
38      let j = i + 1;
39      while (j < code.length && code[j] !== c) j += code[j] === '\\' ? 2 : 1;
40      spans.push([i, j + 1]);
41      i = j;
42    }
43  }
44  return spans;
45}
46const inside = (spans, at) => spans.some(([from, to]) => at >= from && at < to);
48function jevRanges(code) {
49  const ranges = [];
50  const add = (from, to) => ranges.push([from, to]);
51  for (const m of code.matchAll(/\b(jev|choice|score|noul|walk)(?=\s*\()/g)) add(m.index, m.index + m[1].length);
52  // const djJev = jev(…), const form = walk(…)
53  const bound = [...code.matchAll(/\b(?:const|let|var)\s+(\w+)\s*=\s*(?:jev|walk)\s*\(/g)].map((m) => m[1]);
54  // const { move, energy } = djJev.every(8, …)
55  const answers = [];
56  for (const name of bound) {
57    const re = new RegExp(`\\{([^}]*)\\}\\s*=\\s*${escape(name)}\\s*\\.\\s*every\\s*\\(`, 'g');
58    for (const m of code.matchAll(re)) {
59      answers.push(
60        ...m[1]
61          .split(',')
62          .map((x) => x.split(':').pop().trim())
63          .filter(Boolean),
64      );
65    }
66  }
67  for (const name of [...bound, ...answers]) {
68    for (const m of code.matchAll(new RegExp(`\\b${escape(name)}\\b`, 'g'))) add(m.index, m.index + name.length);
69  }
70  const spans = textSpans(code);
71  return ranges.filter(([from]) => !inside(spans, from)).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
72}
73
74function build(view) {
75  const builder = new RangeSetBuilder();
76  let end = -1;
77  for (const [from, to] of jevRanges(view.state.doc.toString())) {
78    if (from < end) continue; // no overlaps
79    builder.add(from, to, call);
80    end = to;
81  }
82  return builder.finish();
83}
84
85const syntax = ViewPlugin.fromClass(
86  class {
87    constructor(view) {
88      this.decorations = build(view);
89    }
90    update(u) {
91      if (u.docChanged) this.decorations = build(u.view);
92    }
93  },
94  { decorations: (v) => v.decorations },
95);

── while playing: Jev's choices ────────────────────

98const setMove = StateEffect.define();
99const mark = Decoration.mark({ class: 'jev-move-playing' });
101const field = StateField.define({
102  create: () => Decoration.none,
103  update(decorations, tr) {
104    for (const effect of tr.effects) {
105      if (effect.is(setMove))
106        return Decoration.set(
107          effect.value.map((r) => mark.range(r.from, r.to)),
108          true,
109        );
110    }
111    return decorations.map(tr.changes);
112  },
113  provide: (f) => EditorView.decorations.from(f),
114});

The same choices' lines, tinted and on the minimap in Jev's colour (packages/codemirror/lineHighlights.mjs), after any pattern's.

118const moveLines = lineHighlights.compute([field], (state) => {
119  const ranges = [];
120  for (let it = state.field(field).iter(); it.value; it.next()) {
121    ranges.push({ from: it.from, to: it.to, color: JEV_COLOR, at: Infinity });
122  }
123  return ranges;
124});
126const theme = EditorView.baseTheme({
127  '.jev-call': { color: '#ffcc00 !important', fontWeight: 'bold' },
128  '.jev-call *': { color: '#ffcc00 !important' },
129  '.jev-move-playing': {
130    backgroundColor: 'rgba(255, 204, 0, 0.3)',
131    outline: '1px solid #ffcc00',
132    borderRadius: '2px',
133  },
134});
135
136const installed = new WeakSet();
137function install(view) {
138  // its own transaction: a field added in a transaction does not see that
139  // transaction's effects
140  if (installed.has(view)) return;
141  installed.add(view);
142  view.dispatch({ effects: StateEffect.appendConfig.of([field, moveLines, syntax, theme]) });
143}
144
145export const installJevSyntax = install;

Every place name: is written as a key, after the song's jev(.

148function find(code, name) {
149  const start = Math.max(0, code.search(/\bjev\s*\(/));
150  const key = new RegExp(`(^|[\\s{,])(${escape(name)})\\s*:`, 'gm');
151  const ranges = [];
152  const spans = textSpans(code);
153  for (const m of code.slice(start).matchAll(key)) {
154    const from = start + m.index + m[1].length;
155    if (!inside(spans, from)) ranges.push({ from, to: from + name.length });
156  }
157  return ranges;
158}

view is the CodeMirror EditorView (StrudelMirror's .editor).

161export function highlightMove(view, names) {
162  if (!view) return;
163  install(view);
164  const code = view.state.doc.toString();
165  const ranges = names
166    .filter(Boolean)
167    .flatMap((name) => find(code, name))
168    .sort((a, b) => a.from - b.from);
169  view.dispatch({ effects: setMove.of(ranges) });
170}