tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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 }