diff --git a/.prettierignore b/.prettierignore index 48d37dc02f..b24b12550b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ packages/**/dist/*.js packages/**/build/*.js packages/**/npm/**/* packages/**/config/*.js +packages/svelte/src/compiler/errors.js packages/svelte/tests/**/*.svelte packages/svelte/tests/**/_expected* packages/svelte/tests/**/_actual* diff --git a/packages/svelte/messages/compile-errors/parse.md b/packages/svelte/messages/compile-errors/parse.md new file mode 100644 index 0000000000..d2fbe371df --- /dev/null +++ b/packages/svelte/messages/compile-errors/parse.md @@ -0,0 +1,7 @@ +## unclosed_element + +%element% was left open + +## unclosed_block + +Block was left open diff --git a/packages/svelte/scripts/process-messages/index.js b/packages/svelte/scripts/process-messages/index.js new file mode 100644 index 0000000000..fb825c18de --- /dev/null +++ b/packages/svelte/scripts/process-messages/index.js @@ -0,0 +1,215 @@ +// @ts-check +import fs from 'node:fs'; +import * as acorn from 'acorn'; +import { walk } from 'zimmerframe'; +import * as esrap from 'esrap'; + +const messages = {}; +const seen = new Set(); + +for (const category of fs.readdirSync('messages')) { + messages[category] = {}; + + for (const file of fs.readdirSync(`messages/${category}`)) { + if (!file.endsWith('.md')) continue; + + const markdown = fs.readFileSync(`messages/${category}/${file}`, 'utf-8'); + + for (const match of markdown.matchAll(/## ([\w]+)\n\n([^]+?)(?=$|\n\n## )/g)) { + const [_, code, text] = match; + + if (seen.has(code)) { + throw new Error(`Duplicate message code ${category}/${code}`); + } + + seen.add(code); + messages[category][code] = text.trim(); + } + } +} + +function transform(name, dest) { + const source = fs.readFileSync(new URL(`./templates/${name}.js`, import.meta.url), 'utf-8'); + + const comments = []; + + const ast = acorn.parse(source, { + ecmaVersion: 'latest', + sourceType: 'module', + onComment: (block, value, start, end) => { + if (block && /\n/.test(value)) { + let a = start; + while (a > 0 && source[a - 1] !== '\n') a -= 1; + + let b = a; + while (/[ \t]/.test(source[b])) b += 1; + + const indentation = source.slice(a, b); + value = value.replace(new RegExp(`^${indentation}`, 'gm'), ''); + } + + comments.push({ type: block ? 'Block' : 'Line', value, start, end }); + } + }); + + walk(ast, null, { + _(node, { next }) { + let comment; + + while (comments[0] && comments[0].start < node.start) { + comment = comments.shift(); + // @ts-expect-error + (node.leadingComments ||= []).push(comment); + } + + next(); + + if (comments[0]) { + const slice = source.slice(node.end, comments[0].start); + + if (/^[,) \t]*$/.test(slice)) { + // @ts-expect-error + node.trailingComments = [comments.shift()]; + } + } + } + }); + + const category = messages[name]; + + // find the `export function CODE` node + const index = ast.body.findIndex((node) => { + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration && + node.declaration.type === 'FunctionDeclaration' + ) { + return node.declaration.id.name === 'CODE'; + } + }); + + if (index === -1) throw new Error(`missing export function CODE in ${name}.js`); + + const template_node = ast.body[index]; + ast.body.splice(index, 1); + + for (const code in category) { + const message = category[code]; + const vars = []; + for (const match of message.matchAll(/%(\w+)%/g)) { + const name = match[1]; + if (!vars.includes(name)) { + vars.push(match[1]); + } + } + + const clone = walk(/** @type {import('estree').Node} */ (template_node), null, { + // @ts-expect-error Block is a block comment, which is not recognised + Block(node, context) { + if (!node.value.includes('PARAMETER')) return; + + const value = node.value + .split('\n') + .map((line) => { + if (line.includes('PARAMETER')) { + return vars.map((name) => ` * @param {string} ${name}`).join('\n'); + } + + return line; + }) + .join('\n'); + + if (value !== node.value) { + return { ...node, value }; + } + }, + FunctionDeclaration(node, context) { + if (node.id.name !== 'CODE') return; + + const params = []; + + for (const param of node.params) { + if (param.type === 'Identifier' && param.name === 'PARAMETER') { + params.push(...vars.map((name) => ({ type: 'Identifier', name }))); + } else { + params.push(param); + } + } + + return /** @type {import('estree').FunctionDeclaration} */ ({ + .../** @type {import('estree').FunctionDeclaration} */ (context.next()), + params, + id: { + ...node.id, + name: code + } + }); + }, + Literal(node) { + if (node.value === 'CODE') { + return { + type: 'Literal', + value: code + }; + } + }, + Identifier(node) { + if (node.name !== 'MESSAGE') return; + + if (/%\w+%/.test(message)) { + const parts = message.split(/(%\w+%)/); + + /** @type {import('estree').Expression[]} */ + const expressions = []; + + /** @type {import('estree').TemplateElement[]} */ + const quasis = []; + + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]; + if (i % 2 === 0) { + quasis.push({ + type: 'TemplateElement', + value: { + raw: part, + cooked: part + }, + tail: i === parts.length - 1 + }); + } else { + expressions.push({ + type: 'Identifier', + name: part.slice(1, -1) + }); + } + } + + return { + type: 'TemplateLiteral', + expressions, + quasis + }; + } + + return { + type: 'Literal', + value: message + }; + } + }); + + // @ts-expect-error + ast.body.push(clone); + } + + // @ts-expect-error + const module = esrap.print(ast); + + fs.writeFileSync( + dest, + `/* This file is generated by scripts/process-messages.js. Do not edit! */\n\n` + module.code, + 'utf-8' + ); +} + +transform('compile-errors', 'src/compiler/errors.js'); diff --git a/packages/svelte/scripts/process-messages/templates/compile-errors.js b/packages/svelte/scripts/process-messages/templates/compile-errors.js new file mode 100644 index 0000000000..2ee86f449c --- /dev/null +++ b/packages/svelte/scripts/process-messages/templates/compile-errors.js @@ -0,0 +1,74 @@ +/** @typedef {{ start?: number, end?: number }} NodeLike */ + +// interface is duplicated between here (used internally) and ./interfaces.js +// (exposed publicly), and I'm not sure how to avoid that +export class CompileError extends Error { + name = 'CompileError'; + + /** @type {import('#compiler').CompileError['filename']} */ + filename = undefined; + + /** @type {import('#compiler').CompileError['position']} */ + position = undefined; + + /** @type {import('#compiler').CompileError['start']} */ + start = undefined; + + /** @type {import('#compiler').CompileError['end']} */ + end = undefined; + + /** + * + * @param {string} code + * @param {string} message + * @param {[number, number] | undefined} position + */ + constructor(code, message, position) { + super(message); + this.code = code; + this.position = position; + } + + toString() { + let out = `${this.name}: ${this.message}`; + + out += `\n(${this.code})`; + + if (this.filename) { + out += `\n${this.filename}`; + + if (this.start) { + out += `${this.start.line}:${this.start.column}`; + } + } + + return out; + } +} + +/** + * + * @param {number | NodeLike} node + * @param {string} code + * @param {string} message + * @returns {never} + */ +function __error(node, code, message) { + const start = typeof node === 'number' ? node : node?.start; + const end = typeof node === 'number' ? node : node?.end; + + throw new CompileError( + code, + message, + start !== undefined && end !== undefined ? [start, end] : undefined + ); +} + +/** + * @param {number | NodeLike} node + * @param {string} PARAMETER + * @returns {never} + */ +export function CODE(node, PARAMETER) { + __error(node, 'CODE', MESSAGE); +} diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js new file mode 100644 index 0000000000..082acb24cf --- /dev/null +++ b/packages/svelte/src/compiler/errors.js @@ -0,0 +1,76 @@ +/* This file is generated by scripts/process-messages.js. Do not edit! */ + +/** @typedef {{ start?: number, end?: number }} NodeLike */ +// interface is duplicated between here (used internally) and ./interfaces.js +// (exposed publicly), and I'm not sure how to avoid that +export class CompileError extends Error { + name = 'CompileError'; + /** @type {import('#compiler').CompileError['filename']} */ + filename = undefined; + /** @type {import('#compiler').CompileError['position']} */ + position = undefined; + /** @type {import('#compiler').CompileError['start']} */ + start = undefined; + /** @type {import('#compiler').CompileError['end']} */ + end = undefined; + + /** + * + * @param {string} code + * @param {string} message + * @param {[number, number] | undefined} position + */ + constructor(code, message, position) { + super(message); + this.code = code; + this.position = position; + } + + toString() { + let out = `${this.name}: ${this.message}`; + + out += `\n(${this.code})`; + + if (this.filename) { + out += `\n${this.filename}`; + + if (this.start) { + out += `${this.start.line}:${this.start.column}`; + } + } + + return out; + } +} + +/** + * + * @param {number | NodeLike} node + * @param {string} code + * @param {string} message + * @returns {never} + */ +function __error(node, code, message) { + const start = typeof node === 'number' ? node : node?.start; + const end = typeof node === 'number' ? node : node?.end; + + throw new CompileError(code, message, start !== undefined && end !== undefined ? [start, end] : undefined); +} + +/** + * @param {number | NodeLike} node + * @param {string} element + * @returns {never} + */ +export function unclosed_element(node, element) { + __error(node, "unclosed_element", `${element} was left open`); +} + +/** + * @param {number | NodeLike} node + + * @returns {never} + */ +export function unclosed_block(node) { + __error(node, "unclosed_block", "Block was left open"); +} \ No newline at end of file diff --git a/packages/svelte/src/compiler/phases/1-parse/index.js b/packages/svelte/src/compiler/phases/1-parse/index.js index 5c06c0a5ce..871e40a603 100644 --- a/packages/svelte/src/compiler/phases/1-parse/index.js +++ b/packages/svelte/src/compiler/phases/1-parse/index.js @@ -5,6 +5,7 @@ import { regex_whitespace } from '../patterns.js'; import { reserved } from './utils/names.js'; import full_char_code_at from './utils/full_char_code_at.js'; import { error } from '../../errors-tmp.js'; +import * as errors from '../../errors.js'; import { create_fragment } from './utils/create.js'; import read_options from './read/options.js'; import { getLocator } from 'locate-character'; @@ -92,10 +93,10 @@ export class Parser { if (current.type === 'RegularElement') { current.end = current.start + 1; - error(current, 'unclosed-element', current.name); + errors.unclosed_element(current, `<${current.name}>`); } else { current.end = current.start + 1; - error(current, 'unclosed-block'); + errors.unclosed_block(current); } } diff --git a/packages/svelte/tests/compiler-errors/samples/unexpected-end-of-input-d/_config.js b/packages/svelte/tests/compiler-errors/samples/unexpected-end-of-input-d/_config.js index b10de772d3..5099013e16 100644 --- a/packages/svelte/tests/compiler-errors/samples/unexpected-end-of-input-d/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/unexpected-end-of-input-d/_config.js @@ -2,7 +2,7 @@ import { test } from '../../test'; export default test({ error: { - code: 'unclosed-block', + code: 'unclosed_block', message: 'Block was left open', position: [0, 1] } diff --git a/packages/svelte/tests/compiler-errors/samples/unexpected-end-of-input/_config.js b/packages/svelte/tests/compiler-errors/samples/unexpected-end-of-input/_config.js index 8ff85b7243..d141ce161f 100644 --- a/packages/svelte/tests/compiler-errors/samples/unexpected-end-of-input/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/unexpected-end-of-input/_config.js @@ -2,7 +2,7 @@ import { test } from '../../test'; export default test({ error: { - code: 'unclosed-element', + code: 'unclosed_element', message: '