Repository: decaffeinate/decaffeinate (ts)

Issue:
Option to wrap decaffeinate output in IIFE
I have a feature request.

There should be an option `--iife` to wrap decaffeinate's code output (below the decaffeinate suggestions comment, if any) in an IIFE. The CoffeeScript compiler wraps its JS output in an IIFE, and decaffeinate should too, or at least there should be an option to do so automatically.


Pull request description:
feat: add `bare` option to allow IIFE wrapper
Adds `bare` option, which is `true` by default, but when `false` will wrap the output in an IIFE similar to the official CoffeeScript compiler's default behavior. This can be triggered on the CLI with `--no-bare`, which mirrors `coffee`'s `--bare` flag.

**Example:**
```sh
❯ echo 'a = 1' | decaffeinate --no-bare
(function() {
const a = 1;

}).call(this);                                                                                                           
```

Closes #2412

Patch:
diff --git a/src/cli.ts b/src/cli.ts
index 4e8d5b5e..8ac22786 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,7 +2,7 @@ import assert from 'assert';
 import { readdir, readFile, stat, writeFile } from 'mz/fs';
 import { basename, dirname, extname, join } from 'path';
 import { convert, modernizeJS } from './index';
-import { Options } from './options';
+import { DEFAULT_OPTIONS, Options } from './options';
 import PatchError from './utils/PatchError';
 
 export interface IO {
@@ -18,7 +18,14 @@ export default async function run(
   args: ReadonlyArray<string>,
   io: IO = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }
 ): Promise<number> {
-  const options = parseArguments(args, io);
+  const parseResult = parseArguments(args, io);
+
+  if (parseResult.kind === 'error') {
+    io.stderr.write(`${parseResult.message}\n`);
+    return 1;
+  }
+
+  const options = parseResult;
 
   if (options.help) {
     usage(args[0], io.stdout);
@@ -39,17 +46,25 @@ export default async function run(
   return 0;
 }
 
+type ParseOptionsResult = CLIOptions | ParseOptionsError;
+
 interface CLIOptions {
-  paths: Array<string>;
-  baseOptions: Options;
-  modernizeJS: boolean;
-  version: boolean;
-  help: boolean;
+  readonly kind: 'success';
+  readonly paths: Array<string>;
+  readonly baseOptions: Options;
+  readonly modernizeJS: boolean;
+  readonly version: boolean;
+  readonly help: boolean;
+}
+
+interface ParseOptionsError {
+  readonly kind: 'error';
+  readonly message: string;
 }
 
-function parseArguments(args: ReadonlyArray<string>, io: IO): CLIOptions {
+function parseArguments(args: ReadonlyArray<string>, io: IO): ParseOptionsResult {
   const paths = [];
-  const baseOptions: Options = {};
+  const baseOptions: Options = { ...DEFAULT_OPTIONS };
   let modernizeJS = false;
   let help = false;
   let version = false;
@@ -75,6 +90,11 @@ function parseArguments(args: ReadonlyArray<string>, io: IO): CLIOptions {
         modernizeJS = true;
         break;
 
+      case '--bare':
+      case '--no-bare':
+        baseOptions.bare = arg === '--bare';
+        break;
+
       case '--literate':
         baseOptions.literate = true;
         break;
@@ -166,15 +186,22 @@ function parseArguments(args: ReadonlyArray<string>, io: IO): CLIOptions {
 
       default:
         if (arg.startsWith('-')) {
-          io.stderr.write(`Error: unrecognized option '${arg}'\n`);
-          process.exit(1);
+          return { kind: 'error', message: `unrecognized option: ${arg}` };
         }
         paths.push(arg);
         break;
     }
   }
 
-  return { paths, baseOptions, modernizeJS, version, help };
+  if (!baseOptions.bare && modernizeJS) {
+    return { kind: 'error', message: 'cannot use --modernize-js with --no-bare' };
+  }
+
+  if (!baseOptions.bare && baseOptions.useJSModules) {
+    return { kind: 'error', message: 'cannot use --use-js-modules with --no-bare' };
+  }
+
+  return { kind: 'success', paths, baseOptions, modernizeJS, version, help };
 }
 
 /**
diff --git a/src/index.ts b/src/index.ts
index 31b132c2..02118d0f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -45,6 +45,10 @@ interface Stage {
  * and formatting.
  */
 export function convert(source: string, options: Options = {}): ConversionResult {
+  if (!options.bare && options.useJSModules) {
+    throw new Error('useJSModules requires bare output');
+  }
+
   source = removeUnicodeBOMIfNecessary(source);
   options = resolveOptions(options);
   const originalNewlineStr = detectNewlineStr(source);
@@ -77,7 +81,7 @@ export function convert(source: string, options: Options = {}): ConversionResult
   }
   result.code = convertNewlines(result.code, originalNewlineStr);
   return {
-    code: result.code,
+    code: options.bare ? result.code : `(function() {\n${result.code}\n}).call(this);`,
   };
 }
 
diff --git a/src/options.ts b/src/options.ts
index 45d913b2..6aee8a91 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -19,6 +19,7 @@ export interface Options {
   optionalChaining?: boolean;
   logicalAssignment?: boolean;
   nullishCoalescing?: boolean;
+  bare?: boolean;
 }
 
 export const DEFAULT_OPTIONS: Options = {
@@ -39,6 +40,10 @@ export const DEFAULT_OPTIONS: Options = {
   looseIncludes: false,
   looseComparisonNegation: false,
   disallowInvalidConstructors: false,
+  optionalChaining: false,
+  logicalAssignment: false,
+  nullishCoalescing: false,
+  bare: true,
 };
 
 export function resolveOptions(options: Options): Options {


Test patch:
diff --git a/test/cli_test.ts b/test/cli_test.ts
index fcd78cf7..4e3ee0ae 100644
--- a/test/cli_test.ts
+++ b/test/cli_test.ts
@@ -30,7 +30,8 @@ async function runCli(
   args: ReadonlyArray<string>,
   stdin: string,
   expectedStdout: string,
-  expectedStderr = ''
+  expectedStderr = '',
+  expectedExitCode = 0
 ): Promise<void> {
   if (stdin[0] === '\n') {
     stdin = stripSharedIndent(stdin);
@@ -67,7 +68,7 @@ async function runCli(
 
   // Check the exit code and output streams.
   expect({ exitCode, stdout: stdout.trim(), stderr: stderr.trim() }).toEqual({
-    exitCode: 0,
+    exitCode: expectedExitCode,
     stdout: expectedStdout.trim(),
     stderr: expectedStderr.trim(),
   });
@@ -561,4 +562,32 @@ describe('decaffeinate CLI', () => {
     `)
     );
   });
+
+  it('can wrap output in an IIFE', async () => {
+    await runCli(['--no-bare'], '', `(function() {\n\n}).call(this);`);
+  });
+
+  it('cannot use --modernize-js with --no-bare', async () => {
+    await runCli(
+      ['--no-bare', '--modernize-js'],
+      '',
+      '',
+      `
+      cannot use --modernize-js with --no-bare
+    `,
+      1
+    );
+  });
+
+  it('cannot use --use-js-modules with --no-bare', async () => {
+    await runCli(
+      ['--no-bare', '--use-js-modules'],
+      '',
+      '',
+      `
+      cannot use --use-js-modules with --no-bare
+    `,
+      1
+    );
+  });
 });
diff --git a/test/iife_test.ts b/test/iife_test.ts
new file mode 100644
index 00000000..e4a80c7d
--- /dev/null
+++ b/test/iife_test.ts
@@ -0,0 +1,15 @@
+import check from './support/check';
+
+test('does not wrap in an IIFE by default', () => {
+  check(`a = 1`, `const a = 1;`);
+});
+
+test('wraps in an IIFE if requested', () => {
+  check(`a = 1`, `(function() {\nconst a = 1;\n}).call(this);`, { options: { bare: false } });
+});
+
+test('cannot be used with useJSModules', () => {
+  expect(() => check(`a = 1`, `const a = 1;`, { options: { useJSModules: true, bare: false } })).toThrow(
+    /useJSModules requires bare output/
+  );
+});
diff --git a/test/support/check.ts b/test/support/check.ts
index c19f90c8..61af315e 100644
--- a/test/support/check.ts
+++ b/test/support/check.ts
@@ -1,6 +1,6 @@
 import assert from 'assert';
 import { convert } from '../../src/index';
-import { Options } from '../../src/options';
+import { DEFAULT_OPTIONS, Options } from '../../src/options';
 import PatchError from '../../src/utils/PatchError';
 import stripSharedIndent from '../../src/utils/stripSharedIndent';
 
@@ -56,12 +56,13 @@ function maybeStripIndent(
 function checkOutput(source: string, expected: string, options: Options): void {
   try {
     const converted = convert(source, {
+      ...DEFAULT_OPTIONS,
       disableSuggestionComment: true,
       ...options,
     });
     let actual = converted.code;
     if (actual.endsWith('\n') && !expected.endsWith('\n')) {
-      actual = actual.substr(0, actual.length - 1);
+      actual = actual.slice(0, -1);
     }
     expect(actual).toBe(expected);
   } catch (err: unknown) {