1035139A plucked dulcimer string and its box, as two coupled wave equations on WebGPUJeremy Magland 1/**
2 * A small MATLAB tokenizer, for syntax highlighting the model editor.
3 *
4 * Only what highlighting needs — comments, literals, numbers, keywords — and
5 * deliberately not a parser: numbl does the real parsing, and reports errors
6 * with positions. Tokens preserve the source text exactly, character for
7 * character, because the highlighted output is overlaid on a textarea and any
8 * dropped or added character would shift the two out of alignment.
9 */
11export type TokenClass = 'com' | 'str' | 'num' | 'kw' | 'ext';
13export interface Token {
14 text: string;
15 cls: TokenClass | null;
16}
18const KEYWORDS = new Set([
19 'break', 'case', 'catch', 'classdef', 'continue', 'else', 'elseif', 'end',
20 'for', 'function', 'global', 'if', 'otherwise', 'parfor', 'persistent',
21 'return', 'spmd', 'switch', 'try', 'while',
22]);
24const isIdentStart = (c: string): boolean => /[A-Za-z_]/.test(c);
25const isIdent = (c: string): boolean => /[A-Za-z0-9_]/.test(c);
26const isDigit = (c: string): boolean => c >= '0' && c <= '9';
28/**
29 * In MATLAB `'` is both the transpose operator and the char-literal delimiter.
30 * It opens a literal unless it directly follows something that can be
31 * transposed — a value, a closing bracket, or another transpose.
32 */
33function quoteIsTranspose(src: string, at: number): boolean {
34 for (let i = at - 1; i >= 0; i--) {
35 const c = src[i];
36 if (c === ' ' || c === '\t') continue;
37 return isIdent(c) || c === ')' || c === ']' || c === '}' || c === '.' || c === "'";
38 }
39 return false;
40}
42/**
43 * Tokenize `src`. `external` names (the operations the host provides, e.g.
44 * `synth` / `analys`) get their own class so the boundary between the model and
45 * what it is given is visible in the editor.
46 */
47export function tokenizeMatlab(
48 src: string,
49 external: ReadonlySet<string> = new Set(),
50): Token[] {
51 const out: Token[] = [];
52 const push = (text: string, cls: TokenClass | null): void => {
53 if (!text) return;
54 const last = out[out.length - 1];
55 if (last && last.cls === cls) last.text += text;
56 else out.push({ text, cls });
57 };
59 let i = 0;
60 let atLineStart = true;
61 let inBlockComment = false;
63 while (i < src.length) {
64 const c = src[i];
66 // Block comments: `%{` and `%}` each alone on their line.
67 if (atLineStart) {
68 const eol = src.indexOf('\n', i);
69 const lineEnd = eol === -1 ? src.length : eol;
70 const line = src.slice(i, lineEnd);
71 const trimmed = line.trim();
72 if (!inBlockComment && trimmed === '%{') inBlockComment = true;
73 else if (inBlockComment && trimmed === '%}') {
74 push(line, 'com');
75 i = lineEnd;
76 inBlockComment = false;
77 atLineStart = false;
78 continue;
79 }
80 if (inBlockComment) {
81 push(line, 'com');
82 i = lineEnd;
83 atLineStart = false;
84 continue;
85 }
86 }
88 if (c === '\n') {
89 push(c, null);
90 i++;
91 atLineStart = true;
92 continue;
93 }
94 if (c === ' ' || c === '\t') {
95 push(c, null);
96 i++;
97 continue;
98 }
99 atLineStart = false;
101 // Line comment, including MATLAB's `%%` section markers.
102 if (c === '%') {
103 const eol = src.indexOf('\n', i);
104 const end = eol === -1 ? src.length : eol;
105 push(src.slice(i, end), 'com');
106 i = end;
107 continue;
108 }
110 // Line continuation is an operator, but any trailing text is a comment.
111 if (c === '.' && src.startsWith('...', i)) {
112 const eol = src.indexOf('\n', i);
113 const end = eol === -1 ? src.length : eol;
114 push('...', null);
115 push(src.slice(i + 3, end), 'com');
116 i = end;
117 continue;
118 }
120 // Char literal (or transpose).
121 if (c === "'") {
122 if (quoteIsTranspose(src, i)) {
123 push("'", null);
124 i++;
125 continue;
126 }
127 let j = i + 1;
128 while (j < src.length && src[j] !== '\n') {
129 if (src[j] === "'") {
130 if (src[j + 1] === "'") j += 2; // escaped quote
131 else {
132 j++;
133 break;
134 }
135 } else j++;
136 }
137 push(src.slice(i, j), 'str');
138 i = j;
139 continue;
140 }
142 // Double-quoted string.
143 if (c === '"') {
144 let j = i + 1;
145 while (j < src.length && src[j] !== '\n') {
146 if (src[j] === '"') {
147 if (src[j + 1] === '"') j += 2;
148 else {
149 j++;
150 break;
151 }
152 } else j++;
153 }
154 push(src.slice(i, j), 'str');
155 i = j;
156 continue;
157 }
159 // Number: 12, 1.5, .5, 1e-3, 2i
160 if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
161 let j = i;
162 while (j < src.length && isDigit(src[j])) j++;
163 if (src[j] === '.') {
164 j++;
165 while (j < src.length && isDigit(src[j])) j++;
166 }
167 if (src[j] === 'e' || src[j] === 'E') {
168 let k = j + 1;
169 if (src[k] === '+' || src[k] === '-') k++;
170 if (isDigit(src[k])) {
171 k++;
172 while (k < src.length && isDigit(src[k])) k++;
173 j = k;
174 }
175 }
176 if (src[j] === 'i' || src[j] === 'j') j++;
177 push(src.slice(i, j), 'num');
178 i = j;
179 continue;
180 }
182 // Identifier / keyword / external operation.
183 if (isIdentStart(c)) {
184 let j = i;
185 while (j < src.length && isIdent(src[j])) j++;
186 const word = src.slice(i, j);
187 push(word, KEYWORDS.has(word) ? 'kw' : external.has(word) ? 'ext' : null);
188 i = j;
189 continue;
190 }
192 push(c, null);
193 i++;
194 }
196 return out;
197}
199const escapeHtml = (s: string): string =>
200 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
202/** Highlighted HTML for `src`, safe to assign to innerHTML. */
203export function highlightMatlab(
204 src: string,
205 external: ReadonlySet<string> = new Set(),
206): string {
207 const html = tokenizeMatlab(src, external)
208 .map((t) => (t.cls ? `<span class="tok-${t.cls}">${escapeHtml(t.text)}</span>` : escapeHtml(t.text)))
209 .join('');
210 // A trailing newline keeps the last line's box height stable, so the overlay
211 // and the textarea scroll to the same extent.
212 return `${html}\n`;
213}