1// Jev in the code, in Jev's colour: 2// 3// - installJevSyntax: always, every call into Jev's API: `jev(`, the 4// question builders `choice(`, `score(` and `noul(`, `walk(`, the variable a 5// song's `jev({...})` and `walk(...)` are bound to (`djJev`, `form`), and 6// the answers destructured from `djJev.every(...)` wherever they are used. 7// - highlightMove: while a song plays, the names Jev chose for the playing 8// section (the form's section, each choice), boxed wherever the song 9// writes them as keys: in the question's options and in the `.pick({...})` 10// that plays them, and their lines tinted (and on the minimap) in Jev's 11// 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'; 15 16// Jev's colour, in the code and on the minimap 17const JEV_COLOR = '#ffcc00'; 18 19const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 20 21// ── always: the API's calls ───────────────────────── 22const call = Decoration.mark({ class: 'jev-call' }); 23 24// 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); 47 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); 96 97// ── while playing: Jev's choices ──────────────────── 98const setMove = StateEffect.define(); 99const mark = Decoration.mark({ class: 'jev-move-playing' }); 100 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}); 115 116// The same choices' lines, tinted and on the minimap in Jev's colour 117// (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}); 125 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; 146 147// 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} 159 160// `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}