diff --git a/.changeset/proud-poems-try.md b/.changeset/proud-poems-try.md new file mode 100644 index 00000000000..00206037921 --- /dev/null +++ b/.changeset/proud-poems-try.md @@ -0,0 +1,5 @@ +--- +"@solana/errors": patch +--- + +The development mode error message printer no longer fatals on Safari < 16.4. diff --git a/packages/errors/src/__tests__/message-formatter-test.ts b/packages/errors/src/__tests__/message-formatter-test.ts index 66fecd341b4..fbc1222d26b 100644 --- a/packages/errors/src/__tests__/message-formatter-test.ts +++ b/packages/errors/src/__tests__/message-formatter-test.ts @@ -81,18 +81,70 @@ describe('getErrorMessage', () => { ); expect(message).toBe('static error message'); }); - it('interpolates variables into a error message format string', () => { + it.each([ + { + expected: "Something awful happened: 'bar'. How awful!", + input: "Something $severity happened: '$foo'. How $severity!", + }, + // Literal backslashes, escaped dollar signs + { + expected: 'How \\awful\\ is the $severity?', + input: 'How \\\\$severity\\\\ is the \\$severity?', + }, + // Variable at beginning of sequence + { expected: 'awful times!', input: '$severity times!' }, + // Variable at end of sequence + { expected: "Isn't it awful?", input: "Isn't it $severity?" }, + // Variable in middle of text sequence + { expected: '~awful~', input: '~$severity~' }, + // Variable interpolation with no value in the lookup + { expected: 'Is $thing a sandwich?', input: 'Is $thing a sandwich?' }, + // Variable that has, as a substring, some other value in the lookup + { expected: '$fool', input: '$fool' }, + // Trick for butting a variable up against regular text + { expected: 'barl', input: '$foo\\l' }, + // Escaped variable marker + { expected: "It's the $severity, ya hear?", input: "It's the \\$severity, ya hear?" }, + // Single dollar sign + { expected: ' $ ', input: ' $ ' }, + // Single dollar sign at start + { expected: '$ ', input: '$ ' }, + // Single dollar sign at end + { expected: ' $', input: ' $' }, + // Double dollar sign with legitimate variable name + { expected: ' $bar ', input: ' $$foo ' }, + // Double dollar sign with legitimate variable name at start + { expected: '$bar ', input: '$$foo ' }, + // Double dollar sign with legitimate variable name at end + { expected: ' $bar', input: ' $$foo' }, + // Single escape sequence + { expected: ' ', input: ' \\ ' }, + // Single escape sequence at start + { expected: ' ', input: '\\ ' }, + // Single escape sequence at end + { expected: ' ', input: ' \\' }, + // Double escape sequence + { expected: ' \\ ', input: ' \\\\ ' }, + // Double escape sequence at start + { expected: '\\ ', input: '\\\\ ' }, + // Double escape sequence at end + { expected: ' \\', input: ' \\\\' }, + // Just text + { expected: 'Some unencumbered text.', input: 'Some unencumbered text.' }, + // Empty string + { expected: '', input: '' }, + ])('interpolates variables into the error message format string `"$input"`', ({ input, expected }) => { const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); messagesSpy.mockReturnValue({ // @ts-expect-error Mock error config doesn't conform to exported config. - 123: "Something $severity happened: '$foo'. How $severity!", + 123: input, }); const message = getErrorMessage( // @ts-expect-error Mock error context doesn't conform to exported context. 123, { foo: 'bar', severity: 'awful' }, ); - expect(message).toBe("Something awful happened: 'bar'. How awful!"); + expect(message).toBe(expected); }); it('interpolates a Uint8Array variable into a error message format string', () => { const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); diff --git a/packages/errors/src/message-formatter.ts b/packages/errors/src/message-formatter.ts index 6144c88eb35..76a5ca5ff53 100644 --- a/packages/errors/src/message-formatter.ts +++ b/packages/errors/src/message-formatter.ts @@ -2,15 +2,83 @@ import { SolanaErrorCode } from './codes'; import { encodeContextObject } from './context'; import { SolanaErrorMessages } from './messages'; +const enum StateType { + EscapeSequence, + Text, + Variable, +} +type State = Readonly<{ + [START_INDEX]: number; + [TYPE]: StateType; +}>; +const START_INDEX = 'i'; +const TYPE = 't'; + export function getHumanReadableErrorMessage( code: TErrorCode, context: object = {}, ): string { const messageFormatString = SolanaErrorMessages[code]; - const message = messageFormatString.replace(/(? - variableName in context ? `${context[variableName as keyof typeof context]}` : substring, - ); - return message; + if (messageFormatString.length === 0) { + return ''; + } + let state: State; + function commitStateUpTo(endIndex?: number) { + if (state[TYPE] === StateType.Variable) { + const variableName = messageFormatString.slice(state[START_INDEX] + 1, endIndex); + fragments.push( + variableName in context ? `${context[variableName as keyof typeof context]}` : `$${variableName}`, + ); + } else if (state[TYPE] === StateType.Text) { + fragments.push(messageFormatString.slice(state[START_INDEX], endIndex)); + } + } + const fragments: string[] = []; + messageFormatString.split('').forEach((char, ii) => { + if (ii === 0) { + state = { + [START_INDEX]: 0, + [TYPE]: + messageFormatString[0] === '\\' + ? StateType.EscapeSequence + : messageFormatString[0] === '$' + ? StateType.Variable + : StateType.Text, + }; + return; + } + let nextState; + switch (state[TYPE]) { + case StateType.EscapeSequence: + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; + break; + case StateType.Text: + if (char === '\\') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; + } else if (char === '$') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; + } + break; + case StateType.Variable: + if (char === '\\') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; + } else if (char === '$') { + commitStateUpTo(ii); + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; + } else if (!char.match(/\w/)) { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; + } + break; + } + if (nextState) { + if (state[TYPE] !== nextState[TYPE]) { + commitStateUpTo(ii); + } + state = nextState; + } + }); + commitStateUpTo(); + return fragments.join(''); } export function getErrorMessage(code: TErrorCode, context: object = {}): string {