|  | 
|  | 1 | +import {Token, tokenizer as Tokenizer, tokTypes} from "acorn"; | 
|  | 2 | +import type {SourceFile} from "typescript"; | 
| 1 | 3 | import {ModuleKind, ScriptTarget, transpile} from "typescript"; | 
|  | 4 | +import {createProgram, createSourceFile} from "typescript"; | 
|  | 5 | +import {isClassExpression, isFunctionExpression, isParenthesizedExpression} from "typescript"; | 
|  | 6 | +import {isExpressionStatement} from "typescript"; | 
|  | 7 | + | 
|  | 8 | +const tokenizerOptions = { | 
|  | 9 | +  ecmaVersion: "latest" | 
|  | 10 | +} as const; | 
|  | 11 | + | 
|  | 12 | +const compilerOptions = { | 
|  | 13 | +  target: ScriptTarget.ESNext, | 
|  | 14 | +  module: ModuleKind.Preserve, | 
|  | 15 | +  verbatimModuleSyntax: true | 
|  | 16 | +} as const; | 
| 2 | 17 | 
 | 
| 3 | 18 | export function transpileTypeScript(input: string): string { | 
| 4 |  | -  return transpile(input, { | 
| 5 |  | -    target: ScriptTarget.ESNext, | 
| 6 |  | -    module: ModuleKind.Preserve, | 
| 7 |  | -    verbatimModuleSyntax: true | 
|  | 19 | +  const expr = asExpression(input); | 
|  | 20 | +  if (expr) return trimTrailingSemicolon(transpile(expr, compilerOptions)); | 
|  | 21 | +  parseTypeScript(input); // enforce valid syntax | 
|  | 22 | +  return transpile(input, compilerOptions); | 
|  | 23 | +} | 
|  | 24 | + | 
|  | 25 | +/** If the given is an expression (not a statement), returns it with parens. */ | 
|  | 26 | +function asExpression(input: string): string | undefined { | 
|  | 27 | +  if (hasUnmatchedParens(input)) return; // disallow funny business | 
|  | 28 | +  const expr = `(${trim(input)})`; | 
|  | 29 | +  if (!isSolitaryExpression(expr)) return; | 
|  | 30 | +  return expr; | 
|  | 31 | +} | 
|  | 32 | + | 
|  | 33 | +/** Parses the specified TypeScript input, returning the AST or throwing a SyntaxError. */ | 
|  | 34 | +function parseTypeScript(input: string): SourceFile { | 
|  | 35 | +  const file = createSourceFile("input.ts", input, compilerOptions.target); | 
|  | 36 | +  const program = createProgram(["input.ts"], compilerOptions, { | 
|  | 37 | +    getSourceFile: (path) => (path === "input.ts" ? file : undefined), | 
|  | 38 | +    getDefaultLibFileName: () => "lib.d.ts", | 
|  | 39 | +    writeFile: () => {}, | 
|  | 40 | +    getCurrentDirectory: () => "/", | 
|  | 41 | +    getDirectories: () => [], | 
|  | 42 | +    getCanonicalFileName: (path) => path, | 
|  | 43 | +    useCaseSensitiveFileNames: () => true, | 
|  | 44 | +    getNewLine: () => "\n", | 
|  | 45 | +    fileExists: (path) => path === "input.ts", | 
|  | 46 | +    readFile: (path) => (path === "input.ts" ? input : undefined) | 
| 8 | 47 |   }); | 
|  | 48 | +  const diagnostics = program.getSyntacticDiagnostics(file); | 
|  | 49 | +  if (diagnostics.length > 0) { | 
|  | 50 | +    const [diagnostic] = diagnostics; | 
|  | 51 | +    throw new SyntaxError(String(diagnostic.messageText)); | 
|  | 52 | +  } | 
|  | 53 | +  return file; | 
|  | 54 | +} | 
|  | 55 | + | 
|  | 56 | +/** Returns true if the specified input is exactly one parenthesized expression statement. */ | 
|  | 57 | +function isSolitaryExpression(input: string): boolean { | 
|  | 58 | +  let file; | 
|  | 59 | +  try { | 
|  | 60 | +    file = parseTypeScript(input); | 
|  | 61 | +  } catch { | 
|  | 62 | +    return false; | 
|  | 63 | +  } | 
|  | 64 | +  if (file.statements.length !== 1) return false; | 
|  | 65 | +  const statement = file.statements[0]; | 
|  | 66 | +  if (!isExpressionStatement(statement)) return false; | 
|  | 67 | +  const expression = statement.expression; | 
|  | 68 | +  if (!isParenthesizedExpression(expression)) return false; | 
|  | 69 | +  const subexpression = expression.expression; | 
|  | 70 | +  if (isClassExpression(subexpression) && subexpression.name) return false; | 
|  | 71 | +  if (isFunctionExpression(subexpression) && subexpression.name) return false; | 
|  | 72 | +  return true; | 
|  | 73 | +} | 
|  | 74 | + | 
|  | 75 | +function* tokenize(input: string): Generator<Token> { | 
|  | 76 | +  const tokenizer = Tokenizer(input, tokenizerOptions); | 
|  | 77 | +  while (true) { | 
|  | 78 | +    const t = tokenizer.getToken(); | 
|  | 79 | +    if (t.type === tokTypes.eof) break; | 
|  | 80 | +    yield t; | 
|  | 81 | +  } | 
|  | 82 | +} | 
|  | 83 | + | 
|  | 84 | +/** Returns true if the specified input has mismatched parens. */ | 
|  | 85 | +function hasUnmatchedParens(input: string): boolean { | 
|  | 86 | +  let depth = 0; | 
|  | 87 | +  for (const t of tokenize(input)) { | 
|  | 88 | +    if (t.type === tokTypes.parenL) ++depth; | 
|  | 89 | +    else if (t.type === tokTypes.parenR && --depth < 0) return true; | 
|  | 90 | +  } | 
|  | 91 | +  return false; | 
|  | 92 | +} | 
|  | 93 | + | 
|  | 94 | +/** Removes leading and trailing whitespace around the specified input. */ | 
|  | 95 | +function trim(input: string): string { | 
|  | 96 | +  let start; | 
|  | 97 | +  let end; | 
|  | 98 | +  for (const t of tokenize(input)) { | 
|  | 99 | +    start ??= t; | 
|  | 100 | +    end = t; | 
|  | 101 | +  } | 
|  | 102 | +  return input.slice(start?.start, end?.end); | 
|  | 103 | +} | 
|  | 104 | + | 
|  | 105 | +/** Removes a trailing semicolon, if present. */ | 
|  | 106 | +function trimTrailingSemicolon(input: string): string { | 
|  | 107 | +  let end; | 
|  | 108 | +  for (const t of tokenize(input)) end = t; | 
|  | 109 | +  return end?.type === tokTypes.semi ? input.slice(0, end.start) : input; | 
| 9 | 110 | } | 
0 commit comments