Repository: alangpierce/sucrase (ts)

Issue:
Add support for the import assertions proposal
For example, this import statement should be passed through in ESM mode and the assert declaration should be removed in CJS mode:
```js
import foo from "foo.json" assert {type: "json"};
...
export {val} from './foo.js' assert {type: "javascript"};
```

Proposal is here: https://github.com/tc39/proposal-import-assertions

Babel implements the parsing for this at https://github.com/babel/babel/pull/12139
Support for transpiling imports with `assert {type: "json"}` to use the old native way of node:fs ?
Consider looking at this code: https://deno.land/x/emoji@0.2.1/emoji.ts?source
It has `assert`, which crashes sucrase:
![image](https://user-images.githubusercontent.com/92968534/189516084-ce28a5a3-5fea-491b-a5ef-fc53f593b602.png)

The 4th line has `assert`.

Pull request:
Add support for import assertions
Fixes #744
Fixes #566

In ESM mode, we parse import assertions and usually just pass them through to the output code, though TS import elision needs to know how to remove them as well.

In CJS mode, we always remove import assertions since `require` can handle JSON files.

Patch:
diff --git a/generator/generateReadWordTree.ts b/generator/generateReadWordTree.ts
index 80e8959..a5453ab 100644
--- a/generator/generateReadWordTree.ts
+++ b/generator/generateReadWordTree.ts
@@ -44,6 +44,7 @@ const CONTEXTUAL_KEYWORDS = [
   "abstract",
   "accessor",
   "as",
+  "assert",
   "asserts",
   "async",
   "await",
diff --git a/src/TokenProcessor.ts b/src/TokenProcessor.ts
index 04c4be2..7588a66 100644
--- a/src/TokenProcessor.ts
+++ b/src/TokenProcessor.ts
@@ -189,6 +189,24 @@ export default class TokenProcessor {
     this.replaceTokenTrimmingLeftWhitespace("");
   }
 
+  /**
+   * Remove all code until the next }, accounting for balanced braces.
+   */
+  removeBalancedCode(): void {
+    let braceDepth = 0;
+    while (!this.isAtEnd()) {
+      if (this.matches1(tt.braceL)) {
+        braceDepth++;
+      } else if (this.matches1(tt.braceR)) {
+        if (braceDepth === 0) {
+          return;
+        }
+        braceDepth--;
+      }
+      this.removeToken();
+    }
+  }
+
   copyExpectedToken(tokenType: TokenType): void {
     if (this.tokens[this.tokenIndex].type !== tokenType) {
       throw new Error(`Expected token ${tokenType}`);
diff --git a/src/parser/tokenizer/keywords.ts b/src/parser/tokenizer/keywords.ts
index b886bf7..9964f40 100644
--- a/src/parser/tokenizer/keywords.ts
+++ b/src/parser/tokenizer/keywords.ts
@@ -3,6 +3,7 @@ export enum ContextualKeyword {
   _abstract,
   _accessor,
   _as,
+  _assert,
   _asserts,
   _async,
   _await,
diff --git a/src/parser/tokenizer/readWordTree.ts b/src/parser/tokenizer/readWordTree.ts
index 3b947c0..1468b81 100644
--- a/src/parser/tokenizer/readWordTree.ts
+++ b/src/parser/tokenizer/readWordTree.ts
@@ -45,7 +45,7 @@ export const READ_WORD_TREE = new Int32Array([
   // "asser"
   -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 540, -1, -1, -1, -1, -1, -1,
   // "assert"
-  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 567, -1, -1, -1, -1, -1, -1, -1,
+  ContextualKeyword._assert << 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 567, -1, -1, -1, -1, -1, -1, -1,
   // "asserts"
   ContextualKeyword._asserts << 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
   // "asy"
diff --git a/src/parser/traverser/statement.ts b/src/parser/traverser/statement.ts
index 24ccfc9..cc8891f 100644
--- a/src/parser/traverser/statement.ts
+++ b/src/parser/traverser/statement.ts
@@ -64,6 +64,7 @@ import {
   parseIdentifier,
   parseMaybeAssign,
   parseMethod,
+  parseObj,
   parseParenExpression,
   parsePropertyName,
 } from "./expression";
@@ -78,6 +79,7 @@ import {
   eatContextual,
   expect,
   expectContextual,
+  hasPrecedingLineBreak,
   isContextual,
   isLineTerminator,
   semicolon,
@@ -996,6 +998,7 @@ function parseExportSpecifiersMaybe(): void {
 export function parseExportFrom(): void {
   if (eatContextual(ContextualKeyword._from)) {
     parseExprAtom();
+    maybeParseImportAssertions();
   }
   semicolon();
 }
@@ -1118,6 +1121,7 @@ export function parseImport(): void {
     expectContextual(ContextualKeyword._from);
     parseExprAtom();
   }
+  maybeParseImportAssertions();
   semicolon();
 }
 
@@ -1191,3 +1195,16 @@ function parseImportSpecifier(): void {
     parseImportedIdentifier();
   }
 }
+
+/**
+ * Parse import assertions like `assert {type: "json"}`.
+ *
+ * Import assertions technically have their own syntax, but are always parseable
+ * as a plain JS object, so just do that for simplicity.
+ */
+function maybeParseImportAssertions(): void {
+  if (isContextual(ContextualKeyword._assert) && !hasPrecedingLineBreak()) {
+    next();
+    parseObj(false, false);
+  }
+}
diff --git a/src/transformers/CJSImportTransformer.ts b/src/transformers/CJSImportTransformer.ts
index 7b29047..204b99b 100644
--- a/src/transformers/CJSImportTransformer.ts
+++ b/src/transformers/CJSImportTransformer.ts
@@ -10,6 +10,7 @@ import getDeclarationInfo, {
   EMPTY_DECLARATION_INFO,
 } from "../util/getDeclarationInfo";
 import getImportExportSpecifierInfo from "../util/getImportExportSpecifierInfo";
+import {removeMaybeImportAssertion} from "../util/removeMaybeImportAssertion";
 import shouldElideDefaultExport from "../util/shouldElideDefaultExport";
 import type ReactHotLoaderTransformer from "./ReactHotLoaderTransformer";
 import type RootTransformer from "./RootTransformer";
@@ -143,6 +144,7 @@ export default class CJSImportTransformer extends Transformer {
       this.tokens.replaceTokenTrimmingLeftWhitespace(this.importProcessor.claimImportCode(path));
       this.tokens.appendCode(this.importProcessor.claimImportCode(path));
     }
+    removeMaybeImportAssertion(this.tokens);
     if (this.tokens.matches1(tt.semi)) {
       this.tokens.removeToken();
     }
@@ -330,6 +332,7 @@ export default class CJSImportTransformer extends Transformer {
       ) {
         this.tokens.removeToken();
         this.tokens.removeToken();
+        removeMaybeImportAssertion(this.tokens);
       }
       return true;
     } else {
@@ -781,6 +784,7 @@ export default class CJSImportTransformer extends Transformer {
       this.tokens.removeToken();
       const path = this.tokens.stringValue();
       this.tokens.replaceTokenTrimmingLeftWhitespace(this.importProcessor.claimImportCode(path));
+      removeMaybeImportAssertion(this.tokens);
     } else {
       // This is a normal named export, so use that.
       this.tokens.appendCode(exportStatements.join(" "));
@@ -798,6 +802,7 @@ export default class CJSImportTransformer extends Transformer {
     }
     const path = this.tokens.stringValue();
     this.tokens.replaceTokenTrimmingLeftWhitespace(this.importProcessor.claimImportCode(path));
+    removeMaybeImportAssertion(this.tokens);
     if (this.tokens.matches1(tt.semi)) {
       this.tokens.removeToken();
     }
diff --git a/src/transformers/ESMImportTransformer.ts b/src/transformers/ESMImportTransformer.ts
index 84e64c4..9a4db8a 100644
--- a/src/transformers/ESMImportTransformer.ts
+++ b/src/transformers/ESMImportTransformer.ts
@@ -11,6 +11,7 @@ import getDeclarationInfo, {
 } from "../util/getDeclarationInfo";
 import getImportExportSpecifierInfo from "../util/getImportExportSpecifierInfo";
 import {getNonTypeIdentifiers} from "../util/getNonTypeIdentifiers";
+import {removeMaybeImportAssertion} from "../util/removeMaybeImportAssertion";
 import shouldElideDefaultExport from "../util/shouldElideDefaultExport";
 import type ReactHotLoaderTransformer from "./ReactHotLoaderTransformer";
 import Transformer from "./Transformer";
@@ -102,6 +103,7 @@ export default class ESMImportTransformer extends Transformer {
       ) {
         this.tokens.removeToken();
         this.tokens.removeToken();
+        removeMaybeImportAssertion(this.tokens);
       }
       return true;
     }
@@ -145,6 +147,7 @@ export default class ESMImportTransformer extends Transformer {
         this.tokens.removeToken();
       }
       this.tokens.removeToken();
+      removeMaybeImportAssertion(this.tokens);
       if (this.tokens.matches1(tt.semi)) {
         this.tokens.removeToken();
       }
diff --git a/src/util/removeMaybeImportAssertion.ts b/src/util/removeMaybeImportAssertion.ts
new file mode 100644
index 0000000..dce8c18
--- /dev/null
+++ b/src/util/removeMaybeImportAssertion.ts
@@ -0,0 +1,19 @@
+import {ContextualKeyword} from "../parser/tokenizer/keywords";
+import {TokenType as tt} from "../parser/tokenizer/types";
+import type TokenProcessor from "../TokenProcessor";
+
+/**
+ * Starting at a potential `assert` token remove the import assertion if there
+ * is one.
+ */
+export function removeMaybeImportAssertion(tokens: TokenProcessor): void {
+  if (tokens.matches2(tt.name, tt.braceL) && tokens.matchesContextual(ContextualKeyword._assert)) {
+    // assert
+    tokens.removeToken();
+    // {
+    tokens.removeToken();
+    tokens.removeBalancedCode();
+    // }
+    tokens.removeToken();
+  }
+}
diff --git a/website/src/Worker.worker.ts b/website/src/Worker.worker.ts
index ffa8513..50c468a 100644
--- a/website/src/Worker.worker.ts
+++ b/website/src/Worker.worker.ts
@@ -154,6 +154,7 @@ function runBabel(): {code: string; time: number | null} {
       "proposal-optional-catch-binding",
       "proposal-nullish-coalescing-operator",
       "proposal-optional-chaining",
+      "syntax-import-assertions",
     );
   }
 


Test patch:
diff --git a/test/imports-test.ts b/test/imports-test.ts
index 4841e13..5139c36 100644
--- a/test/imports-test.ts
+++ b/test/imports-test.ts
@@ -327,6 +327,29 @@ return obj && obj.__esModule ? obj : { default: obj }; }
     );
   });
 
+  it("removes import assertions", () => {
+    assertResult(
+      `
+      import DefaultName from 'module1' assert {type: "json"};
+      import {namedName} from 'module2' assert {type: "json"};
+      import "module3" assert {type: "json"};
+      export * from "module4" assert {type: "json"};
+      // Arbitrary expressions like these aren't actually allowed right now, but
+      // exercise the ability to detect matching braces.
+      import test from "module5" assert {type: {foo: "test"}};
+    `,
+      `"use strict";${ESMODULE_PREFIX}${IMPORT_DEFAULT_PREFIX}${CREATE_STAR_EXPORT_PREFIX}
+      var _module1 = require('module1'); var _module12 = _interopRequireDefault(_module1);
+      var _module2 = require('module2');
+      require('module3');
+      var _module4 = require('module4'); _createStarExport(_module4);
+      // Arbitrary expressions like these aren't actually allowed right now, but
+      // exercise the ability to detect matching braces.
+      var _module5 = require('module5'); var _module52 = _interopRequireDefault(_module5);
+    `,
+    );
+  });
+
   it("allows an import statement with no import bindings", () => {
     assertResult(
       `
diff --git a/test/tokens-test.ts b/test/tokens-test.ts
index 9f852bc..0de511d 100644
--- a/test/tokens-test.ts
+++ b/test/tokens-test.ts
@@ -15,7 +15,17 @@ function assertTokens(
 ): void {
   const tokens: Array<SimpleToken> = parse(code, true, !isFlow, isFlow).tokens;
   const helpMessage = `Tokens did not match. Starting point with just token types: [${tokens
-    .map((t) => `{type: tt.${tt[t.type]}}`)
+    .map((t) => {
+      let tokenCode = "{";
+      tokenCode += `type: tt.${tt[t.type]}`;
+      if (t.contextualKeyword) {
+        tokenCode += `, contextualKeyword: ContextualKeyword.${
+          ContextualKeyword[t.contextualKeyword]
+        }`;
+      }
+      tokenCode += "}";
+      return tokenCode;
+    })
     .join(", ")}]`;
   assert.strictEqual(tokens.length, expectedTokens.length, helpMessage);
   const projectedTokens = tokens.map((token, i) => {
@@ -506,6 +516,42 @@ describe("tokens", () => {
     );
   });
 
+  it("parses import assertions", () => {
+    assertTokens(
+      `
+      import foo from "./foo.json" assert {type: "json"};
+      export {val} from './foo.js' assert {type: "javascript"};
+    `,
+      [
+        {type: tt._import},
+        {type: tt.name},
+        {type: tt.name, contextualKeyword: ContextualKeyword._from},
+        {type: tt.string},
+        {type: tt.name, contextualKeyword: ContextualKeyword._assert},
+        {type: tt.braceL},
+        {type: tt.name, contextualKeyword: ContextualKeyword._type},
+        {type: tt.colon},
+        {type: tt.string},
+        {type: tt.braceR},
+        {type: tt.semi},
+        {type: tt._export},
+        {type: tt.braceL},
+        {type: tt.name},
+        {type: tt.braceR},
+        {type: tt.name, contextualKeyword: ContextualKeyword._from},
+        {type: tt.string},
+        {type: tt.name, contextualKeyword: ContextualKeyword._assert},
+        {type: tt.braceL},
+        {type: tt.name, contextualKeyword: ContextualKeyword._type},
+        {type: tt.colon},
+        {type: tt.string},
+        {type: tt.braceR},
+        {type: tt.semi},
+        {type: tt.eof},
+      ],
+    );
+  });
+
   it("treats single-child JSX as non-static", () => {
     assertFirstJSXRole("const elem = <div>Hello</div>;", JSXRole.OneChild);
   });
diff --git a/test/typescript-test.ts b/test/typescript-test.ts
index 1f70928..e4ef163 100644
--- a/test/typescript-test.ts
+++ b/test/typescript-test.ts
@@ -1,5 +1,6 @@
 import type {Options} from "../src/Options";
 import {
+  CREATE_NAMED_EXPORT_FROM_PREFIX,
   CREATE_REQUIRE_PREFIX,
   CREATE_STAR_EXPORT_PREFIX,
   ESMODULE_PREFIX,
@@ -3413,4 +3414,52 @@ describe("typescript transform", () => {
       {transforms: ["typescript", "jsx"]},
     );
   });
+
+  it("allows and preserves import assertions when targeting ESM", () => {
+    assertResult(
+      `
+      import jsonValue from "./file1.json" assert {type: "json"};
+      import implicitlyElidedImport from "./file2.json" assert {type: "json"};
+      import type explicitlyElidedImport from "./file3.json" assert {type: "json"};
+      import "./file4.json" assert {type: "json"};
+      export {val} from './file5.json' assert {type: "json"};
+      export type {val} from './file6.json' assert {type: "json"};
+      console.log(jsonValue);
+    `,
+      `
+      import jsonValue from "./file1.json" assert {type: "json"};
+
+
+      import "./file4.json" assert {type: "json"};
+      export {val} from './file5.json' assert {type: "json"};
+      ;
+      console.log(jsonValue);
+    `,
+      {transforms: ["typescript"]},
+    );
+  });
+
+  it("removes import assertions when targeting CJS", () => {
+    assertResult(
+      `
+      import jsonValue from "./file1.json" assert {type: "json"};
+      import implicitlyElidedImport from "./file2.json" assert {type: "json"};
+      import type explicitlyElidedImport from "./file3.json" assert {type: "json"};
+      import "./file4.json" assert {type: "json"};
+      export {val} from './file5.json' assert {type: "json"};
+      export type {val} from './file6.json' assert {type: "json"};
+      console.log(jsonValue);
+    `,
+      `"use strict";${ESMODULE_PREFIX}${IMPORT_DEFAULT_PREFIX}${CREATE_NAMED_EXPORT_FROM_PREFIX}
+      var _file1json = require('./file1.json'); var _file1json2 = _interopRequireDefault(_file1json);
+      
+      
+      require('./file4.json');
+      var _file5json = require('./file5.json'); _createNamedExportFrom(_file5json, 'val', 'val');
+      ;
+      console.log(_file1json2.default);
+    `,
+      {transforms: ["typescript", "imports"]},
+    );
+  });
 });