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 })] : [];