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 }