tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

syntax.ts (6290B)


      1 // Lexical highlighting and declaration-site decoration. Editor offsets count
      2 // UTF-16 code units; compiler positions count Unicode scalars.
      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 // Unique top-level declaration heads recognized by lexical layout.
    103 export function definitionSites(source: string, tokens = tokenize(source)): { name: string; start: number }[] {
    104   const seen = new Set<string>();
    105   return tokens.filter(token => {
    106     if (token.kind !== 'definition' || token.start > 0 && source[token.start - 1] !== '\n' || seen.has(token.text)) return false;
    107     seen.add(token.text); return true;
    108   }).map(token => ({ name: token.text, start: token.start }));
    109 }