jevstrudel.git / packages / codemirror / autocomplete.mjs
1import jsdoc from '../../doc.json';
2import { autocompletion } from '@codemirror/autocomplete';
3import { h } from './html';
4//TODO: fix tonal scale import
5// import { Scale } from '@tonaljs/tonal';
6// import { soundMap } from '@strudel/webaudio';
7let soundMap = undefined;
8import { complex } from '@strudel/tonal';
9
10const escapeHtml = (str) => {
11  const div = document.createElement('div');
12  div.innerText = str;
13  return div.innerHTML;
14};
15
16const stripHtml = (html) => {
17  const div = document.createElement('div');
18  div.innerHTML = html;
19  return div.textContent || div.innerText || '';
20};
21
22const getDocLabel = (doc) => doc.name || doc.longname;
23
24const buildParamsList = (params) =>
25  params?.length
26    ? `
27    <div class="autocomplete-info-params-section">
28      <h4 class="autocomplete-info-section-title">Parameters</h4>
29      <ul class="autocomplete-info-params-list">
30        ${params
31          .map(
32            ({ name, type, description }) => `
33          <li class="autocomplete-info-param-item">
34            <span class="autocomplete-info-param-name">${name}</span>
35            <span class="autocomplete-info-param-type">${type.names?.join(' | ')}</span>
36            ${description ? `<div class="autocomplete-info-param-desc">${stripHtml(description)}</div>` : ''}
37          </li>
38        `,
39          )
40          .join('')}
41      </ul>
42    </div>
43  `
44    : '';
45
46const buildExamples = (examples) =>
47  examples?.length
48    ? `
49    <div class="autocomplete-info-examples-section">
50      <h4 class="autocomplete-info-section-title">Examples</h4>
51      ${examples
52        .map(
53          (example) => `
54        <pre class="autocomplete-info-example-code">${escapeHtml(example)}</pre>
55      `,
56        )
57        .join('')}
58    </div>
59  `
60    : '';
61
62export const Autocomplete = (doc) =>
63  h`
64  <div class="autocomplete-info-container">
65    <div class="autocomplete-info-tooltip">
66      <h3 class="autocomplete-info-function-name">${getDocLabel(doc)}</h3>
67      ${doc.synonyms_text ? `<div class="autocomplete-info-function-synonyms">Synonyms: ${doc.synonyms_text}</div>` : ''}
68      ${doc.description ? `<div class="autocomplete-info-function-description">${doc.description}</div>` : ''}
69      ${buildParamsList(doc.params)}
70      ${buildExamples(doc.examples)}
71    </div>
72  </div>
73`[0];
74
75const isValidDoc = (doc) => {
76  const label = getDocLabel(doc);
77  return label && !label.startsWith('_') && !['package'].includes(doc.kind);
78};
79
80const hasExcludedTags = (doc) =>
81  ['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
82
83export function bankCompletions() {
84  // TODO: FIX IMPORT
85  const soundDict = soundMap?.get() ?? {};
86
87  const banks = new Set();
88  for (const key of Object.keys(soundDict)) {
89    const [bank, suffix] = key.split('_');
90    if (suffix && bank) banks.add(bank);
91  }
92  return Array.from(banks)
93    .sort()
94    .map((name) => ({ label: name, type: 'bank' }));
95}
96
97// Attempt to get all scale names from Tonal TODO: FIX IMPORT
98let scaleCompletions = [];
99// try {
100//   scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
101// } catch (e) {
102//   console.warn('[autocomplete] Could not load scale names from Tonal:', e);
103// }
104
105// Valid mode values for voicing
106const modeCompletions = [
107  { label: 'below', type: 'mode' },
108  { label: 'above', type: 'mode' },
109  { label: 'duck', type: 'mode' },
110  { label: 'root', type: 'mode' },
111];
112
113// Valid chord symbols from ireal dictionary plus empty string for major triads
114const chordSymbols = ['', ...Object.keys(complex)].sort();
115const chordSymbolCompletions = chordSymbols.map((symbol) => {
116  if (symbol === '') {
117    return {
118      label: 'major',
119      apply: '',
120      type: 'chord-symbol',
121    };
122  }
123  return {
124    label: symbol,
125    apply: symbol,
126    type: 'chord-symbol',
127  };
128});
129
130export const getSynonymDoc = (doc, synonym) => {
131  const synonyms = doc.synonyms || [];
132  const docLabel = getDocLabel(doc);
133  // Swap `doc.name` in for `s` in the list of synonyms
134  const synonymsWithDoc = [docLabel, ...synonyms].filter((x) => x && x !== synonym);
135  return {
136    ...doc,
137    name: synonym,
138    longname: synonym,
139    synonyms: synonymsWithDoc,
140    synonyms_text: synonymsWithDoc.join(', '),
141  };
142};
143
144const jsdocCompletions = (() => {
145  const seen = new Set(); // avoid repetition
146  const completions = [];
147  for (const doc of jsdoc.docs) {
148    if (!isValidDoc(doc) || hasExcludedTags(doc)) continue;
149    const docLabel = getDocLabel(doc);
150    // Remove duplicates
151    const synonyms = doc.synonyms || [];
152    let labels = [docLabel, ...synonyms];
153    for (const label of labels) {
154      // https://codemirror.net/docs/ref/#autocomplete.Completion
155      if (label && !seen.has(label)) {
156        seen.add(label);
157        completions.push({
158          label,
159          info: () => Autocomplete(getSynonymDoc(doc, label)),
160          type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type
161        });
162      }
163    }
164  }
165  return completions;
166})();
167
168// --- Handler functions for each context ---
169const pitchNames = [
170  'C',
171  'C#',
172  'Db',
173  'D',
174  'D#',
175  'Eb',
176  'E',
177  'E#',
178  'Fb',
179  'F',
180  'F#',
181  'Gb',
182  'G',
183  'G#',
184  'Ab',
185  'A',
186  'A#',
187  'Bb',
188  'B',
189  'B#',
190  'Cb',
191];
192
193// Cached regex patterns for scaleHandler
194const SCALE_NO_QUOTES_REGEX = /scale\(\s*$/;
195const SCALE_AFTER_COLON_REGEX = /scale\(\s*['"][^'"]*:[^'"]*$/;
196const SCALE_PRE_COLON_REGEX = /scale\(\s*['"][^'"]*$/;
197const SCALE_PITCH_MATCH_REGEX = /([A-Ga-g][#b]*)?$/;
198const SCALE_SPACES_TO_COLON_REGEX = /\s+/g;
199
200function scaleHandler(context) {
201  // First check for scale context without quotes - block with empty completions
202  let scaleNoQuotesContext = context.matchBefore(SCALE_NO_QUOTES_REGEX);
203  if (scaleNoQuotesContext) {
204    return {
205      from: scaleNoQuotesContext.to,
206      options: [],
207    };
208  }
209
210  // Check for after-colon context first (more specific)
211  let scaleAfterColonContext = context.matchBefore(SCALE_AFTER_COLON_REGEX);
212  if (scaleAfterColonContext) {
213    const text = scaleAfterColonContext.text;
214    const colonIdx = text.lastIndexOf(':');
215    if (colonIdx !== -1) {
216      const fragment = text.slice(colonIdx + 1);
217      const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment));
218      const options = filteredScales.map((s) => ({
219        ...s,
220        apply: s.label.replace(SCALE_SPACES_TO_COLON_REGEX, ':'),
221      }));
222      const from = scaleAfterColonContext.from + colonIdx + 1;
223      return {
224        from,
225        options,
226      };
227    }
228  }
229
230  // Then check for pre-colon context
231  let scalePreColonContext = context.matchBefore(SCALE_PRE_COLON_REGEX);
232  if (scalePreColonContext) {
233    if (!scalePreColonContext.text.includes(':')) {
234      if (context.explicit) {
235        const text = scalePreColonContext.text;
236        const match = text.match(SCALE_PITCH_MATCH_REGEX);
237        const fragment = match ? match[0] : '';
238        const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
239        const from = scalePreColonContext.to - fragment.length;
240        const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
241        return { from, options };
242      } else {
243        return { from: scalePreColonContext.to, options: [] };
244      }
245    }
246  }
247  return null;
248}
249
250// Cached regex patterns for soundHandler
251const SOUND_NO_QUOTES_REGEX = /(s|sound)\(\s*$/;
252const SOUND_WITH_QUOTES_REGEX = /(s|sound)\(\s*['"][^'"]*$/;
253const SOUND_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w]*)$/;
254
255function soundHandler(context) {
256  // First check for sound context without quotes - block with empty completions
257  let soundNoQuotesContext = context.matchBefore(SOUND_NO_QUOTES_REGEX);
258  if (soundNoQuotesContext) {
259    return {
260      from: soundNoQuotesContext.to,
261      options: [],
262    };
263  }
264
265  // Then check for sound context with quotes - provide completions
266  let soundContext = context.matchBefore(SOUND_WITH_QUOTES_REGEX);
267  if (!soundContext) return null;
268
269  const text = soundContext.text;
270  const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
271  if (quoteIdx === -1) return null;
272  const inside = text.slice(quoteIdx + 1);
273  const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
274  const fragment = fragMatch ? fragMatch[1] : inside;
275  const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
276  const filteredSounds = soundNames.filter((name) => name.includes(fragment));
277  let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
278  const from = soundContext.to - fragment.length;
279  return {
280    from,
281    options,
282  };
283}
284
285// Cached regex patterns for bankHandler
286const BANK_NO_QUOTES_REGEX = /bank\(\s*$/;
287const BANK_WITH_QUOTES_REGEX = /bank\(\s*['"][^'"]*$/;
288
289function bankHandler(context) {
290  // First check for bank context without quotes - block with empty completions
291  let bankNoQuotesContext = context.matchBefore(BANK_NO_QUOTES_REGEX);
292  if (bankNoQuotesContext) {
293    return {
294      from: bankNoQuotesContext.to,
295      options: [],
296    };
297  }
298
299  // Then check for bank context with quotes - provide completions
300  let bankMatch = context.matchBefore(BANK_WITH_QUOTES_REGEX);
301  if (!bankMatch) return null;
302
303  const text = bankMatch.text;
304  const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
305  if (quoteIdx === -1) return null;
306  const inside = text.slice(quoteIdx + 1);
307  const fragment = inside;
308  let banks = bankCompletions();
309  const filteredBanks = banks.filter((b) => b.label.startsWith(fragment));
310  const from = bankMatch.to - fragment.length;
311  return {
312    from,
313    options: filteredBanks,
314  };
315}
316
317// Cached regex patterns for modeHandler
318const MODE_NO_QUOTES_REGEX = /mode\(\s*$/;
319const MODE_AFTER_COLON_REGEX = /mode\(\s*['"][^'"]*:[^'"]*$/;
320const MODE_PRE_COLON_REGEX = /mode\(\s*['"][^'"]*$/;
321const MODE_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w:]*)$/;
322
323function modeHandler(context) {
324  // First check for mode context without quotes - block with empty completions
325  let modeNoQuotesContext = context.matchBefore(MODE_NO_QUOTES_REGEX);
326  if (modeNoQuotesContext) {
327    return {
328      from: modeNoQuotesContext.to,
329      options: [],
330    };
331  }
332
333  // Check for after-colon context first (more specific)
334  let modeAfterColonContext = context.matchBefore(MODE_AFTER_COLON_REGEX);
335  if (modeAfterColonContext) {
336    const text = modeAfterColonContext.text;
337    const colonIdx = text.lastIndexOf(':');
338    if (colonIdx !== -1) {
339      const fragment = text.slice(colonIdx + 1);
340      // For anchor after colon, we can suggest pitch names
341      const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
342      const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
343      const from = modeAfterColonContext.from + colonIdx + 1;
344      return {
345        from,
346        options,
347      };
348    }
349  }
350
351  // Then check for pre-colon context
352  let modeContext = context.matchBefore(MODE_PRE_COLON_REGEX);
353  if (!modeContext) return null;
354
355  const text = modeContext.text;
356  const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
357  if (quoteIdx === -1) return null;
358  const inside = text.slice(quoteIdx + 1);
359  const fragMatch = inside.match(MODE_FRAGMENT_MATCH_REGEX);
360  const fragment = fragMatch ? fragMatch[1] : inside;
361  const filteredModes = modeCompletions.filter((m) => m.label.startsWith(fragment));
362  const from = modeContext.to - fragment.length;
363  return {
364    from,
365    options: filteredModes,
366  };
367}
368
369// Cached regex patterns for chordHandler
370const CHORD_NO_QUOTES_REGEX = /chord\(\s*$/;
371const CHORD_WITH_QUOTES_REGEX = /chord\(\s*['"][^'"]*$/;
372const CHORD_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w#b+^:-]*)$/;
373
374function chordHandler(context) {
375  // First check for chord context without quotes - block with empty completions
376  let chordNoQuotesContext = context.matchBefore(CHORD_NO_QUOTES_REGEX);
377  if (chordNoQuotesContext) {
378    return {
379      from: chordNoQuotesContext.to,
380      options: [],
381    };
382  }
383
384  // Then check for chord context with quotes - provide completions
385  let chordContext = context.matchBefore(CHORD_WITH_QUOTES_REGEX);
386  if (!chordContext) return null;
387
388  const text = chordContext.text;
389  const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
390  if (quoteIdx === -1) return null;
391  const inside = text.slice(quoteIdx + 1);
392
393  // Use same fragment matching as sound/mode for expressions like "<G Am>"
394  const fragMatch = inside.match(CHORD_FRAGMENT_MATCH_REGEX);
395  const fragment = fragMatch ? fragMatch[1] : inside;
396
397  // Check if fragment contains any pitch name at start (for root + symbol)
398  let rootMatch = null;
399  let symbolFragment = fragment;
400  for (const pitch of pitchNames) {
401    if (fragment.toLowerCase().startsWith(pitch.toLowerCase())) {
402      rootMatch = pitch;
403      symbolFragment = fragment.slice(pitch.length);
404      break;
405    }
406  }
407
408  if (rootMatch) {
409    // We have a root, now complete chord symbols
410    const filteredSymbols = chordSymbolCompletions.filter((s) =>
411      s.label.toLowerCase().startsWith(symbolFragment.toLowerCase()),
412    );
413
414    // Create completions that replace the entire chord, not just the symbol part
415    const options = filteredSymbols;
416
417    const from = chordContext.to - symbolFragment.length;
418    return { from, options };
419  } else {
420    // No root yet, complete with pitch names
421    const filteredPitches = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
422    const options = filteredPitches.map((p) => ({ label: p, type: 'pitch' }));
423    const from = chordContext.to - fragment.length;
424    return { from, options };
425  }
426}
427
428// Cached regex patterns for fallbackHandler
429const FALLBACK_WORD_REGEX = /\w*/;
430
431function fallbackHandler(context) {
432  const word = context.matchBefore(FALLBACK_WORD_REGEX);
433  if (word && word.from === word.to && !context.explicit) return null;
434  if (word) {
435    return {
436      from: word.from,
437      options: jsdocCompletions,
438    };
439  }
440  return null;
441}
442
443const handlers = [
444  soundHandler,
445  bankHandler,
446  chordHandler,
447  scaleHandler,
448  modeHandler,
449  // this handler *must* be last
450  fallbackHandler,
451];
452
453export const strudelAutocomplete = (context) => {
454  for (const handler of handlers) {
455    const result = handler(context);
456    if (result) {
457      return result;
458    }
459  }
460  return null;
461};
462
463export const isAutoCompletionEnabled = (enabled) =>
464  enabled ? [autocompletion({ override: [strudelAutocomplete], closeOnBlur: false })] : [];