diff --git a/src/Bundle.ts b/src/Bundle.ts index 0ef4344..44e7b8e 100644 --- a/src/Bundle.ts +++ b/src/Bundle.ts @@ -1,6 +1,7 @@ import type { ExclusionRange } from './MagicString.ts' import type { DecodedSourceMap, SourceMapOptions } from './SourceMap.ts' import MagicString from './MagicString.ts' +import MagicStringError from './MagicStringError.ts' import SourceMap from './SourceMap.ts' import getLocator from './utils/getLocator.ts' import getRelativePath from './utils/getRelativePath.ts' @@ -72,9 +73,7 @@ export default class Bundle { } if (!isObject(source) || !source.content) { - throw new Error( - 'bundle.addSource() takes an object with a `content` property, which should be an instance of MagicString, and an optional `filename`', - ) + throw new MagicStringError('addSource() requires a `content` property that is a MagicString') } ['filename', 'ignoreList', 'indentExclusionRanges', 'separator'].forEach((option) => { @@ -95,7 +94,9 @@ export default class Bundle { else { const uniqueSource = this.uniqueSources[this.uniqueSourceIndexByFilename[source.filename]] if (source.content.original !== uniqueSource.content) { - throw new Error(`Illegal source: same filename (${source.filename}), different contents`) + throw new MagicStringError( + `duplicate filename "${source.filename}" with different content, use unique filenames`, + ) } } } diff --git a/src/MagicString.ts b/src/MagicString.ts index 618c694..2d66a6a 100644 --- a/src/MagicString.ts +++ b/src/MagicString.ts @@ -1,6 +1,7 @@ import type { DecodedSourceMap, SourceMapOptions } from './SourceMap.ts' import BitSet from './BitSet.ts' import Chunk from './Chunk.ts' +import MagicStringError from './MagicStringError.ts' import SourceMap from './SourceMap.ts' import getLocator from './utils/getLocator.ts' import getRelativePath from './utils/getRelativePath.ts' @@ -115,8 +116,9 @@ export default class MagicString { * Appends the specified content to the end of the string. */ append(content: string): this { - if (typeof content !== 'string') - throw new TypeError('outro content must be a string') + if (typeof content !== 'string') { + throw new MagicStringError(`content must be a string, got ${typeof content}`) + } this.outro += content return this @@ -130,8 +132,9 @@ export default class MagicString { appendLeft(index: number, content: string): this { index = index + this.offset - if (typeof content !== 'string') - throw new TypeError('inserted content must be a string') + if (typeof content !== 'string') { + throw new MagicStringError(`content must be a string, got ${typeof content}`) + } if (DEBUG) this.stats.time('appendLeft') @@ -160,8 +163,9 @@ export default class MagicString { appendRight(index: number, content: string): this { index = index + this.offset - if (typeof content !== 'string') - throw new TypeError('inserted content must be a string') + if (typeof content !== 'string') { + throw new MagicStringError(`content must be a string, got ${typeof content}`) + } if (DEBUG) this.stats.time('appendRight') @@ -444,9 +448,7 @@ export default class MagicString { /** @internal */ insert(): never { - throw new Error( - 'magicString.insert(...) is deprecated. Use prependRight(...) or appendLeft(...)', - ) + throw new MagicStringError('insert() is deprecated, use appendLeft() or prependRight()') } /** @internal */ @@ -484,8 +486,9 @@ export default class MagicString { if (start === end) return this - if (index >= start && index <= end) - throw new Error('Cannot move a selection inside itself') + if (index >= start && index <= end) { + throw new MagicStringError('cannot move a selection inside itself') + } if (DEBUG) this.stats.time('move') @@ -569,25 +572,29 @@ export default class MagicString { start = start + this.offset end = end + this.offset - if (typeof content !== 'string') - throw new TypeError('replacement content must be a string') + if (typeof content !== 'string') { + throw new MagicStringError(`content must be a string, got ${typeof content}`) + } if (this.original.length !== 0) { while (start < 0) start += this.original.length while (end < 0) end += this.original.length } - if (start < 0) - throw new Error('Character is out of bounds') - if (end > this.original.length) - throw new Error('end is out of bounds') + if (start < 0) { + throw new MagicStringError(`start ${start} is out of bounds`) + } + if (end > this.original.length) { + throw new MagicStringError(`end ${end} is out of bounds`) + } if (start === end) { - throw new Error( - 'Cannot overwrite a zero-length range – use appendLeft or prependRight instead', + throw new MagicStringError( + `cannot overwrite a zero-length range at ${start}, use appendLeft() or prependRight()`, ) } - if (start > end) - throw new Error(`end must be greater than start (start: ${start}, end: ${end})`) + if (start > end) { + throw new MagicStringError(`end must be greater than start (start: ${start}, end: ${end})`) + } if (DEBUG) this.stats.time('overwrite') @@ -625,7 +632,7 @@ export default class MagicString { let chunk = first while (chunk !== last) { if (chunk.next !== this.byStart.get(chunk.end)) { - throw new Error('Cannot overwrite across a split point') + throw new MagicStringError('cannot overwrite across a split point') } chunk = chunk.next chunk.edit('', false) @@ -651,8 +658,9 @@ export default class MagicString { * Prepends the string with the specified content. */ prepend(content: string): this { - if (typeof content !== 'string') - throw new TypeError('outro content must be a string') + if (typeof content !== 'string') { + throw new MagicStringError(`content must be a string, got ${typeof content}`) + } this.intro = content + this.intro return this @@ -664,8 +672,9 @@ export default class MagicString { prependLeft(index: number, content: string): this { index = index + this.offset - if (typeof content !== 'string') - throw new TypeError('inserted content must be a string') + if (typeof content !== 'string') { + throw new MagicStringError(`content must be a string, got ${typeof content}`) + } if (DEBUG) this.stats.time('insertRight') @@ -692,8 +701,9 @@ export default class MagicString { prependRight(index: number, content: string): this { index = index + this.offset - if (typeof content !== 'string') - throw new TypeError('inserted content must be a string') + if (typeof content !== 'string') { + throw new MagicStringError(`content must be a string, got ${typeof content}`) + } if (DEBUG) this.stats.time('insertRight') @@ -730,10 +740,12 @@ export default class MagicString { if (start === end) return this - if (start < 0 || end > this.original.length) - throw new Error('Character is out of bounds') - if (start > end) - throw new Error(`end must be greater than start (start: ${start}, end: ${end})`) + if (start < 0 || end > this.original.length) { + throw new MagicStringError(`range ${start}–${end} is out of bounds`) + } + if (start > end) { + throw new MagicStringError(`end must be greater than start (start: ${start}, end: ${end})`) + } if (DEBUG) this.stats.time('remove') @@ -771,10 +783,12 @@ export default class MagicString { if (start === end) return this - if (start < 0 || end > this.original.length) - throw new Error('Character is out of bounds') - if (start > end) - throw new Error(`end must be greater than start (start: ${start}, end: ${end})`) + if (start < 0 || end > this.original.length) { + throw new MagicStringError(`range ${start}–${end} is out of bounds`) + } + if (start > end) { + throw new MagicStringError(`end must be greater than start (start: ${start}, end: ${end})`) + } if (DEBUG) this.stats.time('reset') @@ -874,8 +888,9 @@ export default class MagicString { chunk = chunk.next } - if (chunk && chunk.edited && chunk.start !== start) - throw new Error(`Cannot use replaced character ${start} as slice start anchor.`) + if (chunk && chunk.edited && chunk.start !== start) { + throw new MagicStringError(`cannot use edited character ${start} as slice start anchor`) + } const startChunk = chunk while (chunk) { @@ -884,8 +899,9 @@ export default class MagicString { } const containsEnd = chunk.start < end && chunk.end >= end - if (containsEnd && chunk.edited && chunk.end !== end) - throw new Error(`Cannot use replaced character ${end} as slice end anchor.`) + if (containsEnd && chunk.edited && chunk.end !== end) { + throw new MagicStringError(`cannot use edited character ${end} as slice end anchor`) + } const sliceStart = startChunk === chunk ? start - chunk.start : 0 const sliceEnd = containsEnd ? chunk.content.length + end - chunk.end : chunk.content.length @@ -949,8 +965,8 @@ export default class MagicString { if (chunk.edited && chunk.content.length) { // zero-length edited chunks are a special case (overlapping replacements) const loc = getLocator(this.original)(index) - throw new Error( - `Cannot split a chunk that has already been edited (${loc.line}:${loc.column} – "${chunk.original}")`, + throw new MagicStringError( + `cannot split a chunk that has already been edited (${loc.line}:${loc.column} – "${chunk.original}")`, ) } @@ -1224,9 +1240,7 @@ export default class MagicString { } if (!searchValue.global) { - throw new TypeError( - 'MagicString.prototype.replaceAll called with a non-global RegExp argument', - ) + throw new MagicStringError('replaceAll() requires a global RegExp') } return this._replaceRegexp(searchValue, replacement) diff --git a/src/MagicStringError.ts b/src/MagicStringError.ts new file mode 100644 index 0000000..d19f213 --- /dev/null +++ b/src/MagicStringError.ts @@ -0,0 +1,13 @@ +/** + * The single error type thrown by MagicString. + * + * Every message is prefixed with `[MagicString]` so its source is obvious at a + * glance, and is kept short and consistent in tone. + */ +export default class MagicStringError extends Error { + override name = 'MagicStringError' + + constructor(message: string, options?: ErrorOptions) { + super(`[MagicString] ${message}`, options) + } +} diff --git a/src/SourceMap.ts b/src/SourceMap.ts index c41b72b..50ba996 100644 --- a/src/SourceMap.ts +++ b/src/SourceMap.ts @@ -1,4 +1,5 @@ import { encode } from '@jridgewell/sourcemap-codec' +import MagicStringError from './MagicStringError.ts' type Btoa = (str: string) => string @@ -61,7 +62,7 @@ function getBtoa(): Btoa { } return () => { - throw new Error('Unsupported environment: `window.btoa` or `Buffer` should be supported.') + throw new MagicStringError('unsupported environment: `btoa` or `Buffer` is required') } } diff --git a/src/index.ts b/src/index.ts index 74ba59e..7f35617 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ import Bundle from './Bundle.ts' import MagicString from './MagicString.ts' +import MagicStringError from './MagicStringError.ts' import SourceMap from './SourceMap.ts' -export { Bundle, MagicString as default, MagicString, SourceMap } +export { Bundle, MagicString as default, MagicString, MagicStringError, SourceMap } export type { BundleOptions } from './Bundle.ts' export type { ExclusionRange, diff --git a/test/MagicString.test.ts b/test/MagicString.test.ts index aa12bbb..cf1b479 100644 --- a/test/MagicString.test.ts +++ b/test/MagicString.test.ts @@ -2,6 +2,7 @@ import type { RawSourceMap } from 'source-map-js' import type { ExclusionRange } from '../src/index.ts' import { SourceMapConsumer } from 'source-map-js' import { assert, describe, it } from 'vitest' +import { MagicStringError } from '../src/index.ts' import MagicString from './utils/IntegrityCheckingMagicString.ts' describe('magicString', () => { @@ -40,7 +41,7 @@ describe('magicString', () => { it('should throw when given non-string content', () => { const s = new MagicString('') // @ts-expect-error runtime validation is the subject of this test - assert.throws(() => s.append([]), TypeError) + assert.throws(() => s.append([]), MagicStringError) }) }) @@ -868,11 +869,11 @@ describe('magicString', () => { it('refuses to move a selection to inside itself', () => { const s = new MagicString('abcdefghijkl') - assert.throws(() => s.move(3, 6, 3), /Cannot move a selection inside itself/) + assert.throws(() => s.move(3, 6, 3), /cannot move a selection inside itself/) - assert.throws(() => s.move(3, 6, 4), /Cannot move a selection inside itself/) + assert.throws(() => s.move(3, 6, 4), /cannot move a selection inside itself/) - assert.throws(() => s.move(3, 6, 6), /Cannot move a selection inside itself/) + assert.throws(() => s.move(3, 6, 6), /cannot move a selection inside itself/) }) it('does nothing when moving a zero-length range', () => { @@ -949,7 +950,7 @@ describe('magicString', () => { assert.throws( () => s.overwrite(8, 12, 'yy'), - /Cannot split a chunk that has already been edited/, + /cannot split a chunk that has already been edited/, ) assert.equal(s.toString(), 'abcdefgxxl') @@ -1012,14 +1013,14 @@ describe('magicString', () => { const s = new MagicString('x') assert.throws( () => s.overwrite(0, 0, 'anything'), - /Cannot overwrite a zero-length range – use appendLeft or prependRight instead/, + /cannot overwrite a zero-length range/, ) }) it('should throw when given non-string content', () => { const s = new MagicString('') // @ts-expect-error runtime validation is the subject of this test - assert.throws(() => s.overwrite(0, 1, []), TypeError) + assert.throws(() => s.overwrite(0, 1, []), MagicStringError) }) it('replaces interior inserts', () => { @@ -1048,14 +1049,14 @@ describe('magicString', () => { const s = new MagicString('abcdefghijkl') s.move(6, 9, 3) - assert.throws(() => s.overwrite(5, 7, 'XX'), /Cannot overwrite across a split point/) + assert.throws(() => s.overwrite(5, 7, 'XX'), /cannot overwrite across a split point/) }) it('disallows overwriting fully surrounding content moved away', () => { const s = new MagicString('abcdefghijkl') s.move(6, 9, 3) - assert.throws(() => s.overwrite(4, 11, 'XX'), /Cannot overwrite across a split point/) + assert.throws(() => s.overwrite(4, 11, 'XX'), /cannot overwrite across a split point/) }) it('disallows overwriting fully surrounding content moved away even if there is another split', () => { @@ -1063,7 +1064,7 @@ describe('magicString', () => { s.move(6, 9, 3) s.appendLeft(5, 'foo') - assert.throws(() => s.overwrite(4, 11, 'XX'), /Cannot overwrite across a split point/) + assert.throws(() => s.overwrite(4, 11, 'XX'), /cannot overwrite across a split point/) }) it('allows later insertions at the end', () => { @@ -1091,7 +1092,7 @@ describe('magicString', () => { assert.throws( () => s.update(8, 12, 'yy'), - /Cannot split a chunk that has already been edited/, + /cannot split a chunk that has already been edited/, ) assert.equal(s.toString(), 'abcdefgxxl') @@ -1154,14 +1155,14 @@ describe('magicString', () => { const s = new MagicString('x') assert.throws( () => s.update(0, 0, 'anything'), - /Cannot overwrite a zero-length range – use appendLeft or prependRight instead/, + /cannot overwrite a zero-length range/, ) }) it('should throw when given non-string content', () => { const s = new MagicString('') // @ts-expect-error runtime validation is the subject of this test - assert.throws(() => s.update(0, 1, []), TypeError) + assert.throws(() => s.update(0, 1, []), MagicStringError) }) it('should throw when start is greater than end', () => { @@ -1177,7 +1178,7 @@ describe('magicString', () => { it('should throw error when using negative indices with empty string', () => { const s = new MagicString('') - assert.throws(() => s.update(-2, -1, 'x'), /Character is out of bounds/) + assert.throws(() => s.update(-2, -1, 'x'), /out of bounds/) }) it('replaces interior inserts with overwrite option', () => { @@ -1207,14 +1208,14 @@ describe('magicString', () => { const s = new MagicString('abcdefghijkl') s.move(6, 9, 3) - assert.throws(() => s.update(5, 7, 'XX'), /Cannot overwrite across a split point/) + assert.throws(() => s.update(5, 7, 'XX'), /cannot overwrite across a split point/) }) it('disallows overwriting fully surrounding content moved away', () => { const s = new MagicString('abcdefghijkl') s.move(6, 9, 3) - assert.throws(() => s.update(4, 11, 'XX'), /Cannot overwrite across a split point/) + assert.throws(() => s.update(4, 11, 'XX'), /cannot overwrite across a split point/) }) it('disallows overwriting fully surrounding content moved away even if there is another split', () => { @@ -1222,7 +1223,7 @@ describe('magicString', () => { s.move(6, 9, 3) s.appendLeft(5, 'foo') - assert.throws(() => s.update(4, 11, 'XX'), /Cannot overwrite across a split point/) + assert.throws(() => s.update(4, 11, 'XX'), /cannot overwrite across a split point/) }) it('allows later insertions at the end with overwrite option', () => { @@ -1352,7 +1353,7 @@ describe('magicString', () => { s.overwrite(5, 7, 'XX') - assert.throws(() => s.remove(4, 6), /Cannot split a chunk that has already been edited/) + assert.throws(() => s.remove(4, 6), /cannot split a chunk that has already been edited/) }) it('should return this', () => { @@ -1379,7 +1380,7 @@ describe('magicString', () => { it('should throw error when using negative indices with empty string', () => { const s = new MagicString('') - assert.throws(() => s.remove(-2, -1), /Character is out of bounds/) + assert.throws(() => s.remove(-2, -1), /out of bounds/) }) it('should report the resolved indices when a negative start lands past end', () => { @@ -1494,7 +1495,7 @@ describe('magicString', () => { s.overwrite(5, 7, 'XX') - assert.throws(() => s.reset(4, 6), /Cannot split a chunk that has already been edited/) + assert.throws(() => s.reset(4, 6), /cannot split a chunk that has already been edited/) }) it('should return this', () => { @@ -2032,8 +2033,8 @@ describe('magicString', () => { it('rejects with non-global regexp', () => { assert.throws( () => new MagicString('123').replaceAll(/./, ''), - TypeError, - 'MagicString.prototype.replaceAll called with a non-global RegExp argument', + MagicStringError, + 'replaceAll() requires a global RegExp', ) }) diff --git a/test/index.test.ts b/test/index.test.ts index 17f0e08..10d52be 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3,7 +3,7 @@ import { assert, describe, it } from 'vitest' describe('module exports', () => { it('only exposes ESM exports', () => { - assert.deepEqual(Object.keys(magicStringModule), ['Bundle', 'MagicString', 'default', 'SourceMap']) + assert.deepEqual(Object.keys(magicStringModule), ['Bundle', 'MagicString', 'default', 'MagicStringError', 'SourceMap']) assert.equal(Object.hasOwn(MagicString, 'Bundle'), false) assert.equal(Object.hasOwn(MagicString, 'SourceMap'), false) assert.equal(Object.hasOwn(MagicString, 'default'), false)