From 1dc662aa4d7639df5e1e809f846dfeda1fc0d746 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Mon, 8 Jan 2024 18:07:09 -0700 Subject: [PATCH] feat: Better bracket matching, unit tests --- .../src/compiler/phases/1-parse/state/tag.js | 14 +- .../compiler/phases/1-parse/utils/bracket.js | 134 ++++++++++++++++-- .../phases/1-parse/utils/bracket.test.ts | 111 +++++++++++++++ vitest.config.js | 1 + 4 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 packages/svelte/src/compiler/phases/1-parse/utils/bracket.test.ts diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js index 8e11f0fae0..0b1a8fe3f2 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -267,11 +267,17 @@ function open(parser) { if (parser.match('snippet')) { const snippet_declaraion_end = find_matching_bracket( - parser, - full_char_code_at(parser.template, start) + parser.template, + parser.index, + parser.template[start] ); + + if (!snippet_declaraion_end) { + error(parser.current(), 'TODO', 'Expected a closing curly bracket'); + } + // we'll eat this later, so save it - const raw_snippet_declaration = parser.template.slice(start, snippet_declaraion_end); + const raw_snippet_declaration = parser.template.slice(start, snippet_declaraion_end + 1); const start_subtractions = '{#snippet '; const end_subtractions = '}'; @@ -288,12 +294,10 @@ function open(parser) { parser.index - 1 ); - // TODO: handle error if (snippet_expression.type !== 'FunctionExpression') { error(snippet_expression, 'TODO', 'expected a function expression'); } - // TODO: handle error if (!snippet_expression.id) { error(snippet_expression, 'TODO', 'expected a snippet name'); } diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js b/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js index 5406b001af..57ffbb886e 100644 --- a/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js +++ b/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js @@ -37,24 +37,128 @@ export function get_bracket_close(open) { } /** - * The function almost known as bracket_fight - * @param {import('..').Parser} parser - * @param {number} open + * @param {number} number + * @returns {number} + */ +function infinity_if_negative(number) { + if (number < 0) { + return Infinity; + } + return number; +} + +/** + * @param {string} string The string to search. + * @param {number} search_start_index The index to start searching at. + * @param {"'" | '"' | '`'} string_start_char The character that started this string. * @returns {number} */ -export function find_matching_bracket(parser, open) { - const close = get_bracket_close(open); +function find_string_end(string, search_start_index, string_start_char) { + let string_to_search; + if (string_start_char === '`') { + string_to_search = string; + } else { + // we could slice at the search start index, but this way the index remains valid + string_to_search = string.slice( + 0, + infinity_if_negative(string.indexOf('\n', search_start_index)) + ); + } + + return find_unescaped_char(string_to_search, search_start_index, string_start_char); +} + +/** + * @param {string} string The string to search. + * @param {number} search_start_index The index to start searching at. + * @returns {number} Infinity if not found, else the index of the character. + */ +function find_regex_end(string, search_start_index) { + return find_unescaped_char(string, search_start_index, '/'); +} + +/** + * + * @param {string} string The string to search. + * @param {number} search_start_index The index to begin the search at. + * @param {string} char The character to search for. + * @returns + */ +function find_unescaped_char(string, search_start_index, char) { + let i = search_start_index; + while (true) { + const found_index = string.indexOf(char, i); + if (found_index === -1) { + return Infinity; + } + if (count_leading_backslashes(string, found_index - 1) % 2 === 0) { + return found_index; + } + i = found_index + 1; + } +} + +/** + * @param {string} string + * @param {number} search_start_index + */ +function count_leading_backslashes(string, search_start_index) { + let i = search_start_index; + let count = 0; + while (string[i] === '\\') { + count++; + i--; + } + return count; +} + +/** + * The function almost known as bracket_fight + * @param {string} template + * @param {number} index + * @param {string} open + * @returns {number | undefined} + */ +export function find_matching_bracket(template, index, open) { + const open_code = full_char_code_at(open, 0); + const close_code = get_bracket_close(open_code); let brackets = 1; - let i = parser.index; - while (brackets > 0 && i < parser.template.length) { - const code = full_char_code_at(parser.template, i); - if (code === open) { - brackets++; - } else if (code === close) { - brackets--; + let i = index; + while (brackets > 0 && i < template.length) { + const char = template[i]; + switch (char) { + case "'": + case '"': + case '`': + i = find_string_end(template, i + 1, char) + 1; + continue; + case '/': { + const next_char = template[i + 1]; + if (!next_char) continue; + if (next_char === '/') { + i = infinity_if_negative(template.indexOf('\n', i + 1)) + '\n'.length; + continue; + } + if (next_char === '*') { + i = infinity_if_negative(template.indexOf('*/', i + 1)) + '*/'.length; + continue; + } + i = find_regex_end(template, i + 1) + '/'.length; + continue; + } + default: { + const code = full_char_code_at(template, i); + if (code === open_code) { + brackets++; + } else if (code === close_code) { + brackets--; + } + if (brackets === 0) { + return i; + } + i++; + } } - i++; } - // TODO: If not found - return i; + return undefined; } diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/bracket.test.ts b/packages/svelte/src/compiler/phases/1-parse/utils/bracket.test.ts new file mode 100644 index 0000000000..abb1145411 --- /dev/null +++ b/packages/svelte/src/compiler/phases/1-parse/utils/bracket.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { find_matching_bracket, get_bracket_close } from './bracket'; +import full_char_code_at from './full_char_code_at'; + +describe('find_matching_bracket', () => { + it.each([ + { + name: 'no-gotchas-const', + template: `{@const foo = bar}`, + start: '{', + index: 1, + expected: 17 + }, + { + name: 'no-gotchas-snippet', + template: `{#snippet foo(bar, baz)}`, + start: '{', + index: 1, + expected: 23 + }, + { + name: 'multiple-bracket-pairs-snippet', + template: `{#snippet foo(bar, { baz })}`, + start: '{', + index: 1, + expected: 27 + }, + { + name: 'unbalanced-brackets-snippet', + template: `{#snippet foo(bar, { baz })`, + start: '{', + index: 1, + expected: undefined + }, + { + name: 'singlequote-string-snippet', + template: `{#snippet foo(bar = '}')}`, + start: '{', + index: 1, + expected: 24 + }, + { + name: 'doublequote-string-snippet', + template: `{#snippet foo(bar = "}")}`, + start: '{', + index: 1, + expected: 24 + }, + { + name: 'backquote-string-snippet', + template: '{#snippet foo(bar = `}`)}', + start: '{', + index: 1, + expected: 24 + }, + { + name: 'multiline-backquote-string-snippet', + template: `{#snippet foo(bar = \`} + \`)}`, + start: '{', + index: 1, + expected: 28 + }, + { + name: 'escaped-string-delimiter-snippet', + template: `{#snippet foo(bar = '\\'')}`, + start: '{', + index: 1, + expected: 25 + }, + { + name: 'regex-snippet', + template: `{#snippet foo(bar = /foobar'"/)}`, + start: '{', + index: 1, + expected: 31 + }, + { + name: 'inline-multiline-comment-snippet', + template: `{#snippet foo(bar /*inline multiline comment*/)}`, + start: '{', + index: 1, + expected: 47 + }, + { + name: 'linebreak-multiline-comment-snippet', + template: `{#snippet foo(bar /*inline multiline comment + */) + }`, + start: '{', + index: 1, + expected: 55 + }, + { + name: 'confusing-singleline-comment-snippet', + template: `{#snippet foo(bar) // this comment removes the bracket inside of it } + }`, + start: '{', + index: 1, + expected: 72 + } + ])('finds the matching bracket ($name)', ({ template, index, start, expected }) => { + const matched_bracket_location = find_matching_bracket(template, index, start); + expect(matched_bracket_location).toBe(expected); + if (matched_bracket_location) { + expect(get_bracket_close(full_char_code_at(start, 0))).toBe( + full_char_code_at(template[matched_bracket_location], 0) + ); + } + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 30e3917caa..2af320902d 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -37,6 +37,7 @@ export default defineConfig({ dir: '.', reporters: ['dot'], include: [ + 'packages/svelte/**/*.test.ts', 'packages/svelte/tests/*/test.ts', 'packages/svelte/tests/runtime-browser/test-ssr.ts' ],