oberonFormat.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. /** Token-based Oberon document formatter with user-settable rules.
  2. *
  3. * Twin of the Pascal formatter: line structure is preserved
  4. * (statements are never joined or split), while indentation, keyword
  5. * case, intra-line spacing, blank lines and trailing whitespace are
  6. * normalised per `FormatOptions`. Comments are preserved verbatim
  7. * (only repositioned); continuation lines of multi-line comments are
  8. * emitted as-is. Best-effort on broken code: always terminates and
  9. * never drops tokens. `*`/`-` export marks stay attached to their
  10. * declaration; the same glyphs as operators keep their spaces.
  11. */
  12. import * as path from 'path';
  13. import { parseImports, parseOberon, ObSymbol } from './oberonSymbols';
  14. import { resolveAt } from './oberonResolve';
  15. export type KeywordCase = 'upper' | 'preserve';
  16. export interface FormatOptions {
  17. indentSize: number;
  18. useTabs: boolean;
  19. keywordCase: KeywordCase;
  20. spaceAroundOperators: boolean;
  21. spaceAfterComma: boolean;
  22. spaceBeforeColon: boolean;
  23. spaceAroundRange: boolean;
  24. emptyLineLimit: number;
  25. trimTrailingWhitespace: boolean;
  26. insertFinalNewline: boolean;
  27. }
  28. export const DEFAULT_FORMAT_OPTIONS: FormatOptions = {
  29. indentSize: 2,
  30. useTabs: false,
  31. keywordCase: 'upper',
  32. spaceAroundOperators: true,
  33. spaceAfterComma: true,
  34. spaceBeforeColon: false,
  35. spaceAroundRange: true,
  36. emptyLineLimit: 1,
  37. trimTrailingWhitespace: true,
  38. insertFinalNewline: true,
  39. };
  40. /** Sanitise arbitrary settings JSON into valid options (never throws). */
  41. export function normalizeFormatOptions(raw: unknown): FormatOptions {
  42. const r = (raw ?? {}) as Partial<FormatOptions>;
  43. const num = (v: unknown, lo: number, hi: number, dflt: number): number => {
  44. const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : dflt;
  45. return Math.min(hi, Math.max(lo, n));
  46. };
  47. const bool = (v: unknown, dflt: boolean): boolean =>
  48. typeof v === 'boolean' ? v : dflt;
  49. return {
  50. indentSize: num(r.indentSize, 0, 8, DEFAULT_FORMAT_OPTIONS.indentSize),
  51. useTabs: bool(r.useTabs, false),
  52. keywordCase: r.keywordCase === 'preserve' || r.keywordCase === 'upper'
  53. ? r.keywordCase : 'upper',
  54. spaceAroundOperators: bool(r.spaceAroundOperators, true),
  55. spaceAfterComma: bool(r.spaceAfterComma, true),
  56. spaceBeforeColon: bool(r.spaceBeforeColon, false),
  57. spaceAroundRange: bool(r.spaceAroundRange, true),
  58. emptyLineLimit: num(r.emptyLineLimit, 0, 10, DEFAULT_FORMAT_OPTIONS.emptyLineLimit),
  59. trimTrailingWhitespace: bool(r.trimTrailingWhitespace, true),
  60. insertFinalNewline: bool(r.insertFinalNewline, true),
  61. };
  62. }
  63. type TokenKind = 'keyword' | 'ident' | 'number' | 'string' | 'symbol';
  64. interface RichToken {
  65. text: string;
  66. kind: TokenKind;
  67. comment?: boolean;
  68. line: number; ch: number; endLine: number; endCh: number;
  69. offset: number; endOffset: number;
  70. }
  71. /** Reserved words matched case-insensitively by the formatter lexer. */
  72. const OBERON_KEYWORDS = new Set([
  73. 'ARRAY', 'BEGIN', 'BY', 'CASE', 'CONST', 'DEFINITION', 'DIV', 'DO',
  74. 'ELSE', 'ELSIF', 'END', 'EXIT', 'FOR', 'IF', 'IMPORT', 'IN', 'IS',
  75. 'LOOP', 'MOD', 'MODULE', 'NIL', 'OF', 'OR', 'POINTER', 'PROCEDURE',
  76. 'RECORD', 'REPEAT', 'RETURN', 'THEN', 'TO', 'TYPE', 'UNTIL', 'VAR',
  77. 'WHILE', 'WITH',
  78. ]);
  79. const isWordStart = (c: string) => /[A-Za-z_]/.test(c);
  80. const isWordChar = (c: string) => /[A-Za-z0-9_]/.test(c);
  81. const isDigit = (c: string) => /[0-9]/.test(c);
  82. const isHex = (c: string) => /[0-9A-Fa-f]/.test(c);
  83. /** Lex code tokens (comments land in `comments`, strings are skipped). */
  84. function lexCode(text: string): { tokens: RichToken[]; comments: RichToken[] } {
  85. const tokens: RichToken[] = [];
  86. const comments: RichToken[] = [];
  87. let i = 0;
  88. let line = 0;
  89. let col = 0;
  90. const push = (t: string, kind: TokenKind, sl: number, sc: number, so: number): void => {
  91. tokens.push({ text: t, kind, line: sl, ch: sc, endLine: line, endCh: col, offset: so, endOffset: i });
  92. };
  93. const pushComment = (t: string, sl: number, sc: number, so: number): void => {
  94. comments.push({ text: t, kind: 'ident', comment: true, line: sl, ch: sc, endLine: line, endCh: col, offset: so, endOffset: i });
  95. };
  96. const step = (n: number): void => {
  97. for (let k = 0; k < n; k++) {
  98. if (text[i] === '\n') { line++; col = 0; }
  99. else if (text[i] !== '\r') { col++; }
  100. else { col = 0; }
  101. i++;
  102. }
  103. };
  104. while (i < text.length) {
  105. const c = text[i];
  106. const nx = i + 1 < text.length ? text[i + 1] : '';
  107. if (c === '\n' || c === '\r' || c === ' ' || c === '\t') { step(1); continue; }
  108. // Nested (* *) comment (the only Oberon comment form).
  109. if (c === '(' && nx === '*') {
  110. const sl = line; const sc = col; const so = i;
  111. let level = 1;
  112. step(2);
  113. while (i < text.length && level > 0) {
  114. if (text[i] === '(' && text[i + 1] === '*') { level++; step(2); continue; }
  115. if (text[i] === '*' && text[i + 1] === ')') { level--; step(2); continue; }
  116. step(1);
  117. }
  118. pushComment(text.slice(so, i), sl, sc, so);
  119. continue;
  120. }
  121. // "..." / '...' strings (no escapes; unterminated runs to end of line).
  122. if (c === '"' || c === "'") {
  123. const sl = line; const sc = col; const so = i;
  124. step(1);
  125. while (i < text.length && text[i] !== c && text[i] !== '\n' && text[i] !== '\r') step(1);
  126. if (i < text.length && text[i] === c) step(1);
  127. push(text.slice(so, i), 'string', sl, sc, so);
  128. continue;
  129. }
  130. if (isWordStart(c)) {
  131. const sl = line; const sc = col; const so = i;
  132. while (i < text.length && isWordChar(text[i])) step(1);
  133. const word = text.slice(so, i);
  134. push(word, OBERON_KEYWORDS.has(word.toUpperCase()) ? 'keyword' : 'ident', sl, sc, so);
  135. continue;
  136. }
  137. // Numbers: decimal, .fraction, E exponents, H/X suffixes (0FFH, 41X).
  138. if (isDigit(c)) {
  139. const sl = line; const sc = col; const so = i;
  140. while (i < text.length && isHex(text[i])) step(1);
  141. if ((text[i] === 'H' || text[i] === 'X') && !isWordChar(text[i + 1] ?? '')) {
  142. step(1);
  143. } else {
  144. // Rewind hex letters: plain decimal (optionally real).
  145. while (i > so && /[A-Fa-f]/.test(text[i - 1] ?? '')) {
  146. i--; col--;
  147. }
  148. if (text[i] === '.' && text[i + 1] !== '.' && isDigit(text[i + 1] ?? '')) {
  149. step(1);
  150. while (i < text.length && isDigit(text[i])) step(1);
  151. }
  152. if (text[i] === 'E' || text[i] === 'e') {
  153. let j = i + 1;
  154. if (text[j] === '+' || text[j] === '-') j++;
  155. if (isDigit(text[j] ?? '')) {
  156. while (j < text.length && isDigit(text[j])) j++;
  157. step(j - i);
  158. }
  159. }
  160. }
  161. push(text.slice(so, i), 'number', sl, sc, so);
  162. continue;
  163. }
  164. // Multi-char symbols.
  165. const sl = line; const sc = col; const so = i;
  166. const two = text.slice(i, i + 2);
  167. if (two === ':=' || two === '<=' || two === '>=') {
  168. step(2);
  169. push(two, 'symbol', sl, sc, so);
  170. continue;
  171. }
  172. if (c === '.' && nx === '.') {
  173. step(2);
  174. push('..', 'symbol', sl, sc, so);
  175. continue;
  176. }
  177. step(1);
  178. push(c, 'symbol', sl, sc, so);
  179. }
  180. return { tokens, comments };
  181. }
  182. /** Structural words for indentation (matched case-insensitively). */
  183. const STRUCT = new Set([
  184. 'END', 'ELSE', 'ELSIF', 'UNTIL', 'EXCEPT', 'FINALLY',
  185. 'THEN', 'BEGIN', 'RECORD', 'REPEAT', 'DO', 'CASE', 'OF', 'LOOP', 'TRY',
  186. ]);
  187. const DISPLAY_DEDENT = new Set(['END', 'ELSE', 'ELSIF', 'UNTIL', 'EXCEPT', 'FINALLY']);
  188. const OPENERS = new Set(['THEN', 'BEGIN', 'RECORD', 'REPEAT', 'DO', 'LOOP', 'TRY']);
  189. const CLOSERS = new Set(['END', 'UNTIL']);
  190. const UNARY_AFTER = new Set([
  191. '(', '[', ',', ';', ':=', '=', '..', 'THEN', 'DO', 'OF',
  192. 'NOT', 'AND', 'OR', 'DIV', 'MOD', 'IN', '+', '-', '*', '/', '<', '<=',
  193. '>', '>=', '#', '&', '~', 'BY', 'TO',
  194. ]);
  195. function wordOf(t: RichToken): string {
  196. return t.kind === 'keyword' && STRUCT.has(t.text.toUpperCase()) ? t.text.toUpperCase() : '';
  197. }
  198. function applyCase(text: string, mode: KeywordCase): string {
  199. return mode === 'upper' ? text.toUpperCase() : text;
  200. }
  201. /** Promote keyword-shaped identifiers to keywords unless they resolve.
  202. *
  203. * Lowercase keywords lex as identifiers, but code may use words like
  204. * `to` as identifiers. An identifier that is declared here or resolves
  205. * (qualifier-aware) stays an identifier; anything else shaped like a
  206. * reserved word is a keyword. This keeps formatting token-exact.
  207. */
  208. function classifyKeywords(tokens: RichToken[], text: string, filePath: string, docDir: string, marks: Set<string>): void {
  209. let symbols: ObSymbol[];
  210. try {
  211. symbols = parseOberon(text, marks);
  212. } catch {
  213. return;
  214. }
  215. const imports = parseImports(text);
  216. const declared = new Set<string>();
  217. const flatten = (ss: ObSymbol[]): void => {
  218. for (const s of ss) {
  219. if (s.name) declared.add(`${s.line}:${s.ch}`);
  220. flatten(s.children);
  221. }
  222. };
  223. flatten(symbols);
  224. const prevCode = (i: number): { tok: RichToken; idx: number } | null => {
  225. for (let j = i - 1; j >= 0; j--) {
  226. const t = tokens[j];
  227. if (!t.comment) return { tok: t, idx: j };
  228. }
  229. return null;
  230. };
  231. tokens.forEach((t, i) => {
  232. if (t.comment || t.kind !== 'ident' || !OBERON_KEYWORDS.has(t.text.toUpperCase())) return;
  233. if (declared.has(`${t.line}:${t.ch}`)) return;
  234. let qualifier: string | null = null;
  235. const dot = prevCode(i);
  236. if (dot && dot.tok.text === '.') {
  237. const head = prevCode(dot.idx);
  238. if (!head || head.tok.kind !== 'ident') return;
  239. qualifier = head.tok.text;
  240. }
  241. const r = resolveAt(symbols, imports, filePath, docDir, t.line, t.ch, qualifier, t.text);
  242. if (!r) t.kind = 'keyword';
  243. });
  244. }
  245. export function formatDocument(
  246. text: string, rawOptions?: Partial<FormatOptions>, filePath = '', docDir = '',
  247. ): string {
  248. const opts = normalizeFormatOptions(rawOptions ?? {});
  249. const { entries, eol } = formatLineEntries(text, opts, filePath, docDir);
  250. const joined = entries.map(l => l.text).join('\n');
  251. if (!opts.insertFinalNewline) return joined.split('\n').join(eol);
  252. return (joined.length > 0 ? joined + '\n' : '').split('\n').join(eol);
  253. }
  254. /** One formatted output line and the 0-based input line it came from. */
  255. export interface FormattedLine {
  256. src: number;
  257. text: string;
  258. }
  259. /** One edit for `textDocument/rangeFormatting`, directly mappable to LSP. */
  260. export interface RangeEdit {
  261. startLine: number;
  262. startCh: number;
  263. endLine: number;
  264. endCh: number;
  265. newText: string;
  266. }
  267. /** Format a line range with full-document context. Never throws. */
  268. export function formatRangeEdits(
  269. text: string, startLine: number, endLine: number,
  270. rawOptions?: Partial<FormatOptions>, filePath = '', docDir = '',
  271. ): RangeEdit[] {
  272. if (text.length === 0) return [];
  273. const opts = normalizeFormatOptions(rawOptions ?? {});
  274. const srcLines = text.split('\n');
  275. const srcCount = srcLines.length;
  276. const from = Math.min(Math.max(0, Math.min(startLine, endLine)), srcCount - 1);
  277. const to = Math.min(Math.max(Math.max(startLine, endLine), from), srcCount - 1);
  278. const { entries } = formatLineEntries(text, rawOptions, filePath, docDir);
  279. const bySrc = new Map<number, string>();
  280. for (const e of entries) {
  281. if (e.src >= from && e.src <= to && !bySrc.has(e.src)) bySrc.set(e.src, e.text);
  282. }
  283. const edits: RangeEdit[] = [];
  284. let runFrom = -1;
  285. const parts: string[] = [];
  286. let tailNewlined = false;
  287. const flush = (endExclusive: number): void => {
  288. if (runFrom < 0) return;
  289. const atEof = endExclusive >= srcCount;
  290. if (atEof && parts.length === 0 && runFrom > 0) {
  291. const prevLen = srcLines[runFrom - 1].length;
  292. edits.push({
  293. startLine: runFrom - 1, startCh: prevLen,
  294. endLine: srcCount - 1, endCh: srcLines[srcCount - 1].length,
  295. newText: opts.insertFinalNewline && entries.length > 0 ? '\n' : '',
  296. });
  297. runFrom = -1;
  298. return;
  299. }
  300. let newText = parts.join('\n');
  301. if (parts.length > 0 && (atEof ? opts.insertFinalNewline : true)) newText += '\n';
  302. if (atEof && newText.endsWith('\n')) tailNewlined = true;
  303. const end = atEof
  304. ? { line: srcCount - 1, ch: srcLines[srcCount - 1].length }
  305. : { line: endExclusive, ch: 0 };
  306. edits.push({
  307. startLine: runFrom, startCh: 0,
  308. endLine: end.line, endCh: end.ch, newText,
  309. });
  310. runFrom = -1;
  311. parts.length = 0;
  312. };
  313. for (let s = from; s <= to; s++) {
  314. const want = bySrc.get(s);
  315. if (want !== undefined && want === srcLines[s]) {
  316. flush(s);
  317. continue;
  318. }
  319. if (runFrom < 0) runFrom = s;
  320. if (want !== undefined) parts.push(want);
  321. }
  322. flush(to + 1);
  323. if (to === srcCount - 1 && opts.insertFinalNewline && !text.endsWith('\n') &&
  324. entries.length > 0 && !tailNewlined) {
  325. const last = srcLines[srcCount - 1].length;
  326. edits.push({
  327. startLine: srcCount - 1, startCh: last,
  328. endLine: srcCount - 1, endCh: last, newText: '\n',
  329. });
  330. }
  331. return edits;
  332. }
  333. function formatLineEntries(
  334. text: string, rawOptions?: Partial<FormatOptions>, filePath = '', docDir = '',
  335. ): { entries: FormattedLine[]; eol: string } {
  336. const opts = normalizeFormatOptions(rawOptions ?? {});
  337. if (text.length === 0) return { entries: [], eol: '\n' };
  338. const eol = text.includes('\r\n') ? '\r\n' : '\n';
  339. const dir = docDir || (filePath ? path.dirname(filePath) : '');
  340. const { tokens, comments } = lexCode(text);
  341. const all: RichToken[] = [...tokens, ...comments].sort((a, b) => a.offset - b.offset);
  342. const marks = new Set<string>();
  343. classifyKeywords(all, text, filePath, dir, marks);
  344. const byLine = new Map<number, RichToken[]>();
  345. let maxLine = 0;
  346. for (const t of all) {
  347. maxLine = Math.max(maxLine, t.endLine);
  348. const list = byLine.get(t.line) ?? [];
  349. list.push(t);
  350. byLine.set(t.line, list);
  351. }
  352. const srcLines = text.split(/\r?\n/);
  353. const lastLine = Math.max(maxLine, srcLines.length - 1);
  354. const verbatim = new Set<number>();
  355. for (const t of all) {
  356. if (t.comment && t.endLine > t.line) {
  357. for (let l = t.line + 1; l <= t.endLine; l++) verbatim.add(l);
  358. }
  359. }
  360. const indentOf = (level: number): string =>
  361. opts.useTabs ? '\t'.repeat(level) : ' '.repeat(opts.indentSize * level);
  362. const entries: FormattedLine[] = [];
  363. let level = 0;
  364. let casePending = false;
  365. let blanks = 0;
  366. for (let i = 0; i <= lastLine; i++) {
  367. const lineToks = (byLine.get(i) ?? []).filter(t => !t.comment || t.line === i);
  368. const code = lineToks.filter(t => !t.comment);
  369. const startsHere = lineToks.filter(t => t.comment && t.line === i);
  370. if (verbatim.has(i)) {
  371. entries.push({ src: i, text: opts.trimTrailingWhitespace ? srcLines[i]?.replace(/[ \t]+$/, '') ?? '' : srcLines[i] ?? '' });
  372. const applied = applyDelta(code, level, { casePending });
  373. level = applied.level;
  374. casePending = applied.casePending;
  375. continue;
  376. }
  377. if (code.length === 0 && startsHere.length === 0) {
  378. blanks++;
  379. if (blanks <= opts.emptyLineLimit) entries.push({ src: i, text: '' });
  380. continue;
  381. }
  382. blanks = 0;
  383. if (code.length === 0) {
  384. const parts = startsHere.map(c => firstFragment(c));
  385. entries.push({ src: i, text: indentOf(level) + parts.join(' ') });
  386. continue;
  387. }
  388. const first = wordOf(code[0]);
  389. const emitLevel = Math.max(0, level - (DISPLAY_DEDENT.has(first) ? 1 : 0));
  390. let body = emitTokens(code, opts, marks);
  391. const trailing = startsHere.filter(c => code[0].offset < c.offset);
  392. for (const c of trailing) body += ' ' + firstFragment(c);
  393. let lineText = indentOf(emitLevel) + body;
  394. if (opts.trimTrailingWhitespace) lineText = lineText.replace(/[ \t]+$/, '');
  395. entries.push({ src: i, text: lineText });
  396. const applied = applyDelta(code, level, { casePending });
  397. level = applied.level;
  398. casePending = applied.casePending;
  399. }
  400. if (!opts.insertFinalNewline) return { entries, eol };
  401. while (entries.length > 0 && /^[ \t]*$/.test(entries[entries.length - 1].text)) {
  402. entries.pop();
  403. }
  404. if (entries.length > 0) {
  405. entries[entries.length - 1].text = entries[entries.length - 1].text.replace(/[ \t]+$/, '');
  406. }
  407. return { entries, eol };
  408. }
  409. function firstFragment(c: RichToken): string {
  410. const idx = c.text.search(/\r?\n/);
  411. return idx < 0 ? c.text : c.text.slice(0, idx);
  412. }
  413. function applyDelta(
  414. code: RichToken[], level: number, state: { casePending: boolean },
  415. ): { level: number; casePending: boolean } {
  416. let opens = 0;
  417. let closes = 0;
  418. let casePending = state.casePending;
  419. for (const t of code) {
  420. const w = wordOf(t);
  421. if (!w) continue;
  422. if (w === 'CASE') {
  423. casePending = true;
  424. continue;
  425. }
  426. if (w === 'OF') {
  427. if (casePending) {
  428. opens++;
  429. casePending = false;
  430. }
  431. continue;
  432. }
  433. if (OPENERS.has(w)) {
  434. opens++;
  435. continue;
  436. }
  437. if (CLOSERS.has(w)) {
  438. closes++;
  439. continue;
  440. }
  441. }
  442. return { level: Math.max(0, level + opens - closes), casePending };
  443. }
  444. function isWordish(t: RichToken): boolean {
  445. return t.kind === 'ident' || t.kind === 'keyword' || t.kind === 'number' || t.kind === 'string';
  446. }
  447. function emitTokens(code: RichToken[], opts: FormatOptions, marks: Set<string>): string {
  448. let out = '';
  449. const gap = (prev: RichToken | null, cur: RichToken): string => {
  450. if (!prev) return '';
  451. const pt = prev.text;
  452. const ct = cur.text;
  453. if (ct === '(') return prev.kind === 'keyword' ? ' ' : '';
  454. if (ct === '[') return prev.kind === 'keyword' ? ' ' : '';
  455. if (pt === '(' || pt === '[') return '';
  456. if (pt === '.' || pt === '^' || pt === '@') return '';
  457. if (ct === ')' || ct === ']') return '';
  458. if (ct === '.' || ct === '^') return '';
  459. if (ct === ',') return '';
  460. if (ct === ';') return '';
  461. if (pt === ')' || pt === ']' || pt === ';') {
  462. return isWordish(cur) ? ' ' : '';
  463. }
  464. // `*`/`-` export marks attach to the declared name (positions
  465. // recorded by the parser); the same glyphs as operators keep spaces.
  466. if ((ct === '*' || ct === '-') && prev.kind === 'ident') {
  467. return marks.has(`${cur.line}:${cur.ch}`) ? '' : (opts.spaceAroundOperators ? ' ' : '');
  468. }
  469. if (ct === ':=') return opts.spaceAroundOperators ? ' ' : '';
  470. if (pt === ':=') return opts.spaceAroundOperators ? ' ' : '';
  471. if (pt === ',') return opts.spaceAfterComma ? ' ' : '';
  472. if (ct === ':') return opts.spaceBeforeColon ? ' ' : '';
  473. if (pt === ':') return ' ';
  474. if (ct === '..' || pt === '..') return opts.spaceAroundRange ? ' ' : '';
  475. if (ct === '+' || ct === '-') return isUnary(prev) ? '' : (opts.spaceAroundOperators ? ' ' : '');
  476. if (cur.kind === 'symbol' || prev.kind === 'symbol') {
  477. return opts.spaceAroundOperators ? ' ' : '';
  478. }
  479. return ' ';
  480. };
  481. for (let k = 0; k < code.length; k++) {
  482. const t = code[k];
  483. const text = t.kind === 'keyword' ? applyCase(t.text, opts.keywordCase) : t.text;
  484. out += gap(k > 0 ? code[k - 1] : null, t) + text;
  485. }
  486. return out;
  487. }
  488. function isUnary(prev: RichToken | null): boolean {
  489. if (!prev) return true;
  490. if (prev.kind === 'keyword') return UNARY_AFTER.has(prev.text.toUpperCase());
  491. if (prev.kind !== 'symbol') return false;
  492. return UNARY_AFTER.has(prev.text);
  493. }