| 
 | 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