syntax.ts (8628B)
1 // Presentation only: never used for parsing, compilation, or name resolution. 2 // Offsets are UTF-16 textarea offsets, not compiler Unicode-scalar positions. 3 export interface Token { start: number; end: number; kind: string; text: string } 4 const keywords = new Set(['let', 'in', 'where', 'do', 'case', 'of', 'rec']); 5 export function tokenize(source: string): Token[] { 6 // Decoration must remain cheap even for large read-only library sources. 7 if (source.length > 32_768) return [{ start: 0, end: source.length, kind: '', text: source }]; 8 const tokens: Token[] = []; 9 const add = (start: number, end: number, kind: string): void => { tokens.push({ start, end, kind, text: source.slice(start, end) }); }; 10 let i = 0; 11 while (i < source.length) { 12 const start = i, tail = source.slice(i); 13 if (tail.startsWith('--')) { const end = source.indexOf('\n', i); i = end < 0 ? source.length : end; add(start, i, 'comment'); } 14 else if (tail.startsWith('|-')) { const end = source.indexOf('-|', i + 2); i = end < 0 ? source.length : end + 2; add(start, i, 'comment'); } 15 else if (source[i] === '"') { 16 i++; let part = start; 17 while (i < source.length) { 18 if (source[i] === '\\') { 19 add(part, i, 'string'); const escape = i; i = Math.min(i + 1 + ((source.codePointAt(i + 1) ?? 0) > 0xffff ? 2 : 1), source.length); 20 add(escape, i, /^[\\][ntrfb\\"']$/.test(source.slice(escape, i)) ? 'escape' : 'invalid'); part = i; 21 } else if (source[i++] === '"') break; 22 } 23 if (part < i) add(part, i, 'string'); 24 } else { 25 const match = /^(\s+|![A-Za-z]+|[A-Za-z_][A-Za-z0-9_?'$%.\-]*|[0-9]+|=@|<-|<\||\|>|.)/su.exec(tail)!; 26 const text = match[0]; i += text.length; 27 const kind = /^\s/.test(text) ? '' : text === 't' ? 'tree' : keywords.has(text) ? 'keyword' 28 : ['!import', '!export'].includes(text) ? 'directive' : /^[0-9]/.test(text) ? 'number' 29 : /^[A-Za-z_]/.test(text) ? 'name' : /^[()[\]{}]$/.test(text) ? 'bracket' : 'operator'; 30 add(start, i, kind); 31 } 32 } 33 // Conservative declaration-site recognition, not semantic highlighting. 34 // Layout contexts distinguish local equation heads from case-arm captures. 35 const significant = tokens.filter(t => t.kind && t.kind !== 'comment'); 36 const contexts: { kind: string; indent: number; depth: number }[] = []; 37 let depth = 0, previous: Token | undefined, directiveLine = -1, line = 0; 38 let lineEnd = source.indexOf('\n'); if (lineEnd < 0) lineEnd = source.length; 39 let indent = /^ */.exec(source)![0].length; 40 for (let n = 0; n < significant.length; n++) { 41 const token = significant[n]!; 42 while (lineEnd < token.start) { 43 line = lineEnd + 1; const next = source.indexOf('\n', line); lineEnd = next < 0 ? source.length : next; 44 indent = /^ */.exec(source.slice(line))![0].length; 45 } 46 const first = token.start === line + indent; 47 if (first) while (contexts.length && indent <= contexts.at(-1)!.indent) contexts.pop(); 48 if (/^[)\]}]$/.test(token.text)) depth--; 49 while (contexts.length && depth < contexts.at(-1)!.depth) contexts.pop(); 50 if (token.kind === 'directive') directiveLine = line; 51 if (directiveLine === line && ['as', 'from'].includes(token.text)) token.kind = 'keyword'; 52 if (token.kind === 'name' && ['@', '=@'].includes(previous?.text ?? '')) token.kind = 'contract'; 53 const localHead = previous && ['let', 'where', 'rec'].includes(previous.text); 54 if (token.kind === 'name' && directiveLine !== line && (localHead || first && (indent === 0 || ['let', 'where'].includes(contexts.at(-1)?.kind ?? '')))) { 55 let nesting = 0; 56 for (let j = n + 1; j < significant.length; j++) { 57 const next = significant[j]!; 58 if (next.start > lineEnd || keywords.has(next.text)) break; 59 if (nesting === 0 && (next.text === '=' || next.text === '=@')) { token.kind = 'definition'; break; } 60 if (/^[([{]$/.test(next.text)) nesting++; 61 if (/^[)\]}]$/.test(next.text) && --nesting < 0) break; 62 if (!nesting && [':', '<-'].includes(next.text)) break; 63 } 64 } 65 if (['let', 'where', 'of'].includes(token.text)) contexts.push({ kind: token.text, indent, depth }); 66 if (token.text === 'in') { const index = contexts.map(c => c.kind).lastIndexOf('let'); if (index >= 0) contexts.splice(index); } 67 if (/^[([{]$/.test(token.text)) depth++; 68 previous = token; 69 } 70 return tokens; 71 } 72 73 export function bracketPair(tokens: Token[], caret: number): number[] { 74 const brackets = tokens.filter(t => t.kind === 'bracket'); 75 const selected = brackets.find(t => t.start === caret) ?? brackets.find(t => t.end === caret); 76 if (!selected) return []; 77 const stack: Token[] = [], pairs = new Map<number, number>(); 78 for (const token of brackets) { 79 if ('([{'.includes(token.text)) stack.push(token); 80 else { 81 const open = stack.pop(); 82 if (open && '([{'.indexOf(open.text) === ')]}'.indexOf(token.text)) { 83 pairs.set(open.start, token.start); pairs.set(token.start, open.start); 84 } else stack.length = 0; // Never match across malformed nesting. 85 } 86 } 87 const partner = pairs.get(selected.start); 88 return partner === undefined ? [selected.start] : [selected.start, partner]; 89 } 90 91 export function paintSyntax(target: HTMLElement, tokens: Token[], pair: number[] = []): void { 92 const fragment = document.createDocumentFragment(); 93 for (const token of tokens) { 94 if (!token.kind) { fragment.append(document.createTextNode(token.text)); continue; } 95 const span = document.createElement('span'); span.className = `syntax-${token.kind}`; span.textContent = token.text; 96 if (pair.includes(token.start)) span.classList.add(pair.length === 2 ? 'bracket-match' : 'bracket-unmatched'); 97 fragment.append(span); 98 } 99 target.replaceChildren(fragment); 100 } 101 102 export function syntaxEditor(input: HTMLTextAreaElement): () => void { 103 const wrapper = document.createElement('div'); wrapper.className = 'syntax-editor'; 104 input.before(wrapper); wrapper.append(input); 105 const viewport = document.createElement('div'); viewport.className = 'syntax-viewport'; viewport.setAttribute('aria-hidden', 'true'); 106 const mirror = document.createElement('pre'); viewport.append(mirror); wrapper.prepend(viewport); 107 let value: string | undefined, tokens: Token[] = [], lastPair = '', composing = false; 108 function scroll(): void { mirror.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`; } 109 function refresh(): void { 110 wrapper.hidden = input.hidden; 111 wrapper.classList.toggle('modified', input.classList.contains('modified')); 112 wrapper.classList.toggle('disabled', input.disabled); 113 wrapper.classList.toggle('composing', composing); 114 const style = getComputedStyle(input); 115 // Use the textarea's actual content box, excluding scrollbar and border. 116 mirror.style.width = `${input.clientWidth}px`; 117 viewport.style.inset = `${style.borderTopWidth} ${style.borderRightWidth} ${style.borderBottomWidth} ${style.borderLeftWidth}`; 118 for (const property of ['font-family', 'font-size', 'line-height', 'font-weight', 'font-style', 'font-stretch', 'font-variant-ligatures', 'font-kerning', 'font-feature-settings', 'font-variation-settings', 'letter-spacing', 'padding', 'tab-size', 'text-align']) mirror.style.setProperty(property, style.getPropertyValue(property)); 119 const changed = value !== input.value; 120 if (changed) { value = input.value; tokens = tokenize(value); } 121 const pair = !composing && document.activeElement === input && input.selectionStart === input.selectionEnd ? bracketPair(tokens, input.selectionStart) : []; 122 if (changed || pair.join() !== lastPair) { 123 paintSyntax(mirror, tokens, pair); lastPair = pair.join(); 124 // Preserve a trailing empty line without introducing a new line. 125 if (!value || value.endsWith('\n')) mirror.append(document.createTextNode(' ')); 126 } 127 scroll(); 128 } 129 input.classList.add('syntax-input'); 130 for (const event of ['input', 'click', 'keyup', 'select', 'focus', 'blur']) input.addEventListener(event, refresh); 131 input.addEventListener('compositionstart', () => { composing = true; refresh(); }); 132 input.addEventListener('compositionend', () => { composing = false; refresh(); }); 133 input.addEventListener('scroll', scroll, { passive: true }); 134 document.addEventListener('selectionchange', () => { if (document.activeElement === input) refresh(); }); 135 new ResizeObserver(refresh).observe(input); 136 new MutationObserver(refresh).observe(input, { attributes: true, attributeFilter: ['hidden', 'disabled', 'class'] }); 137 refresh(); return refresh; 138 }