diff --git a/src/languages/ts/index.js b/src/languages/ts/index.js index 559ff05..c73aae7 100644 --- a/src/languages/ts/index.js +++ b/src/languages/ts/index.js @@ -1333,7 +1333,13 @@ export default (options = {}) => { context.write(' '); if (node.specifiers.length === 0) { - if (node.importKind === 'type') context.write('type {} from '); + if (node.importKind === 'type') { + // one token per write, so that each maps to its own source token + context.write('type '); + context.write('{'); + context.write('}'); + context.write(' from '); + } context.visit(node.source); write_import_attributes(context, node); context.write(';'); diff --git a/test/sourcemap-keywords.test.js b/test/sourcemap-keywords.test.js index c525f0d..e4c1ca5 100644 --- a/test/sourcemap-keywords.test.js +++ b/test/sourcemap-keywords.test.js @@ -317,6 +317,7 @@ test.each([ test.each([ ['named import', `import { /* before */ value /* after */ } from 'module';`], + ['empty type-only import', `import type /* before */ { /* inside */ } from 'module';`], ['named export', `const value = 1;\nexport { /* before */ value /* after */ };`], ['empty named export', `export { /* inside */ };`], ['import attributes', `import value from 'module' with { type: 'json' };`], @@ -474,6 +475,7 @@ test.each([ ], ['for in', `for (const k /* c */ in o) {}`, ['in']], ['import type', `import /* c */ type { T } from 'm';`, ['type']], + ['empty import type', `import /* c */ type {} from 'm';`, ['type']], ['import type specifier', `import { type /* c */ T, a as /* c */ b } from 'm';`, ['type', 'as']], ['export type', `type T = 1;\nexport /* c */ type { T };`, ['type']], ['export star as', `export * as /* c */ ns from 'm';`, ['as']], @@ -531,3 +533,54 @@ test.each([ ])('tokens map the keywords of a decorated declaration: %s', (_name, source, words) => { expectExactKeywordMappings(source, words); }); + +/** + * The source position of every bracket in `code` that is not inside a comment + * @param {string} source + * @param {string} code + * @param {[number, number, number, number][][]} mappings + */ +function bracketMappings(source, code, mappings) { + /** @type {{ char: string, generated: number, source: number[] }[]} */ + const result = []; + const stripped = code.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (m) => ' '.repeat(m.length)); + for (let i = 0; i < stripped.length; i += 1) { + if (!'()[]{}'.includes(stripped[i])) continue; + result.push({ + char: code[i], + generated: i, + source: mappingsAtIndex(code, i, mappings).map((s) => s[2] * 1e6 + s[3]) + }); + } + return result; +} + +test.each([ + // a comment is flushed once the next node's tokens are in view, so a comment + // whose text is that node's opening bracket is the case that can go wrong + ['a comment before an object', `const o = /* { */ { a: 1 };`], + ['a comment before an array', `const arr = /* [ */ [1];`], + ['a comment before an arrow function', `const f = /* ( */ (a) => a;`], + ['a comment line before an arrow function', `const f = /*\n(\n*/ (a) => a;`], + ['a comment line before a block', `if (x) /*\n{\n*/ { y(); }`], + ['a line comment', `fn(value); // )\nfn(other);`], + ['commented-out code', `function f() {\n\t/*\n\tif (x) {\n\t\ty();\n\t}\n\t*/\n\tg(1);\n}`] +])('brackets in %s are not mistaken for source tokens', (_name, source) => { + const { code, mappings } = mappedWithTokens(source, { tokens: true }); + const stripped = source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (m) => ' '.repeat(m.length)); + /** @type {number[]} */ + const expected = []; + for (let i = 0; i < stripped.length; i += 1) { + if ('()[]{}'.includes(stripped[i])) { + const { gen_line, gen_col } = generatedLineColumn(source, i); + expected.push(gen_line * 1e6 + gen_col); + } + } + const actual = bracketMappings(source, code, mappings); + expect(actual.map((b) => b.char).join('')).toBe( + [...stripped].filter((c) => '()[]{}'.includes(c)).join('') + ); + for (let i = 0; i < actual.length; i += 1) { + expect(actual[i].source, `${actual[i].char} at ${actual[i].generated}`).toContain(expected[i]); + } +});