Skip to content

Commit f0af633

Browse files
feat: loose parser mode (#14691)
* add loose option * handle unclosed tags * handle unclosed blocks * handle unclosed opening tags * handle invalid expressions * handle invalid blocks * oops * more robust expression parsing * changeset * more unclosed tag handling * types * Update packages/svelte/src/compiler/phases/1-parse/state/element.js --------- Co-authored-by: Rich Harris <rich.harris@vercel.com>
1 parent af9b071 commit f0af633

File tree

28 files changed

+2948
-42
lines changed

28 files changed

+2948
-42
lines changed

.changeset/twelve-hairs-marry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: provide loose parser mode

packages/svelte/src/compiler/index.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export function compileModule(source, options) {
7676
*
7777
* @overload
7878
* @param {string} source
79-
* @param {{ filename?: string; modern: true }} options
79+
* @param {{ filename?: string; modern: true; loose?: boolean }} options
8080
* @returns {AST.Root}
8181
*/
8282

@@ -88,7 +88,7 @@ export function compileModule(source, options) {
8888
*
8989
* @overload
9090
* @param {string} source
91-
* @param {{ filename?: string; modern?: false }} [options]
91+
* @param {{ filename?: string; modern?: false; loose?: boolean }} [options]
9292
* @returns {Record<string, any>}
9393
*/
9494

@@ -99,15 +99,15 @@ export function compileModule(source, options) {
9999
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
100100
*
101101
* @param {string} source
102-
* @param {{ filename?: string; rootDir?: string; modern?: boolean }} [options]
102+
* @param {{ filename?: string; rootDir?: string; modern?: boolean; loose?: boolean }} [options]
103103
* @returns {AST.Root | LegacyRoot}
104104
*/
105-
export function parse(source, { filename, rootDir, modern } = {}) {
105+
export function parse(source, { filename, rootDir, modern, loose } = {}) {
106106
source = remove_bom(source);
107107
state.reset_warning_filter(() => false);
108108
state.reset(source, { filename: filename ?? '(unknown)', rootDir });
109109

110-
const ast = _parse(source);
110+
const ast = _parse(source, loose);
111111
return to_public_ast(source, ast, modern);
112112
}
113113

packages/svelte/src/compiler/phases/1-parse/index.js

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ export class Parser {
2222
*/
2323
template;
2424

25+
/**
26+
* @readonly
27+
* @type {string}
28+
*/
29+
template_untrimmed;
30+
31+
/**
32+
* Whether or not we're in loose parsing mode, in which
33+
* case we try to continue parsing as much as possible
34+
* @type {boolean}
35+
*/
36+
loose;
37+
2538
/** */
2639
index = 0;
2740

@@ -43,12 +56,17 @@ export class Parser {
4356
/** @type {LastAutoClosedTag | undefined} */
4457
last_auto_closed_tag;
4558

46-
/** @param {string} template */
47-
constructor(template) {
59+
/**
60+
* @param {string} template
61+
* @param {boolean} loose
62+
*/
63+
constructor(template, loose) {
4864
if (typeof template !== 'string') {
4965
throw new TypeError('Template must be a string');
5066
}
5167

68+
this.loose = loose;
69+
this.template_untrimmed = template;
5270
this.template = template.trimEnd();
5371

5472
let match_lang;
@@ -88,7 +106,9 @@ export class Parser {
88106
if (this.stack.length > 1) {
89107
const current = this.current();
90108

91-
if (current.type === 'RegularElement') {
109+
if (this.loose) {
110+
current.end = this.template.length;
111+
} else if (current.type === 'RegularElement') {
92112
current.end = current.start + 1;
93113
e.element_unclosed(current, current.name);
94114
} else {
@@ -151,14 +171,15 @@ export class Parser {
151171
/**
152172
* @param {string} str
153173
* @param {boolean} required
174+
* @param {boolean} required_in_loose
154175
*/
155-
eat(str, required = false) {
176+
eat(str, required = false, required_in_loose = true) {
156177
if (this.match(str)) {
157178
this.index += str.length;
158179
return true;
159180
}
160181

161-
if (required) {
182+
if (required && (!this.loose || required_in_loose)) {
162183
e.expected_token(this.index, str);
163184
}
164185

@@ -233,6 +254,7 @@ export class Parser {
233254
/** @param {RegExp} pattern */
234255
read_until(pattern) {
235256
if (this.index >= this.template.length) {
257+
if (this.loose) return '';
236258
e.unexpected_eof(this.template.length);
237259
}
238260

@@ -274,10 +296,11 @@ export class Parser {
274296

275297
/**
276298
* @param {string} template
299+
* @param {boolean} [loose]
277300
* @returns {AST.Root}
278301
*/
279-
export function parse(template) {
280-
const parser = new Parser(template);
302+
export function parse(template, loose = false) {
303+
const parser = new Parser(template, loose);
281304
return parser.root;
282305
}
283306

packages/svelte/src/compiler/phases/1-parse/read/expression.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { parse_expression_at } from '../acorn.js';
44
import { regex_whitespace } from '../../patterns.js';
55
import * as e from '../../../errors.js';
6+
import { find_matching_bracket } from '../utils/bracket.js';
67

78
/**
89
* @param {Parser} parser
@@ -39,6 +40,22 @@ export default function read_expression(parser) {
3940

4041
return /** @type {Expression} */ (node);
4142
} catch (err) {
43+
if (parser.loose) {
44+
// Find the next } and treat it as the end of the expression
45+
const end = find_matching_bracket(parser.template, parser.index, '{');
46+
if (end) {
47+
const start = parser.index;
48+
parser.index = end;
49+
// We don't know what the expression is and signal this by returning an empty identifier
50+
return {
51+
type: 'Identifier',
52+
start,
53+
end,
54+
name: ''
55+
};
56+
}
57+
}
58+
4259
parser.acorn_error(err);
4360
}
4461
}

packages/svelte/src/compiler/phases/1-parse/state/element.js

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import { decode_character_references } from '../utils/html.js';
99
import * as e from '../../../errors.js';
1010
import * as w from '../../../warnings.js';
1111
import { create_fragment } from '../utils/create.js';
12-
import { create_attribute, create_expression_metadata } from '../../nodes.js';
12+
import { create_attribute, create_expression_metadata, is_element_node } from '../../nodes.js';
1313
import { get_attribute_expression, is_expression_attribute } from '../../../utils/ast.js';
1414
import { closing_tag_omitted } from '../../../../html-tree-validation.js';
1515
import { list } from '../../../utils/string.js';
16+
import { regex_whitespace } from '../../patterns.js';
1617

1718
const regex_invalid_unquoted_attribute_value = /^(\/>|[\s"'=<>`])/;
1819
const regex_closing_textarea_tag = /^<\/textarea(\s[^>]*)?>/i;
@@ -80,7 +81,19 @@ export default function element(parser) {
8081

8182
// close any elements that don't have their own closing tags, e.g. <div><p></div>
8283
while (/** @type {AST.RegularElement} */ (parent).name !== name) {
83-
if (parent.type !== 'RegularElement') {
84+
if (parser.loose) {
85+
// If the previous element did interpret the next opening tag as an attribute, backtrack
86+
if (is_element_node(parent)) {
87+
const last = parent.attributes.at(-1);
88+
if (last?.type === 'Attribute' && last.name === `<${name}`) {
89+
parser.index = last.start;
90+
parent.attributes.pop();
91+
break;
92+
}
93+
}
94+
}
95+
96+
if (parent.type !== 'RegularElement' && !parser.loose) {
8497
if (parser.last_auto_closed_tag && parser.last_auto_closed_tag.tag === name) {
8598
e.element_invalid_closing_tag_autoclosed(start, name, parser.last_auto_closed_tag.reason);
8699
} else {
@@ -319,10 +332,41 @@ export default function element(parser) {
319332
parser.append(element);
320333

321334
const self_closing = parser.eat('/') || is_void(name);
335+
const closed = parser.eat('>', true, false);
336+
337+
// Loose parsing mode
338+
if (!closed) {
339+
// We may have eaten an opening `<` of the next element and treated it as an attribute...
340+
const last = element.attributes.at(-1);
341+
if (last?.type === 'Attribute' && last.name === '<') {
342+
parser.index = last.start;
343+
element.attributes.pop();
344+
} else {
345+
// ... or we may have eaten part of a following block ...
346+
const prev_1 = parser.template[parser.index - 1];
347+
const prev_2 = parser.template[parser.index - 2];
348+
const current = parser.template[parser.index];
349+
if (prev_2 === '{' && prev_1 === '/') {
350+
parser.index -= 2;
351+
} else if (prev_1 === '{' && (current === '#' || current === '@' || current === ':')) {
352+
parser.index -= 1;
353+
} else {
354+
// ... or we're followed by whitespace, for example near the end of the template,
355+
// which we want to take in so that language tools has more room to work with
356+
parser.allow_whitespace();
357+
if (parser.index === parser.template.length) {
358+
while (
359+
parser.index < parser.template_untrimmed.length &&
360+
regex_whitespace.test(parser.template_untrimmed[parser.index])
361+
) {
362+
parser.index++;
363+
}
364+
}
365+
}
366+
}
367+
}
322368

323-
parser.eat('>', true);
324-
325-
if (self_closing) {
369+
if (self_closing || !closed) {
326370
// don't push self-closing elements onto the stack
327371
element.end = parser.index;
328372
} else if (name === 'textarea') {
@@ -461,10 +505,22 @@ function read_attribute(parser) {
461505
return spread;
462506
} else {
463507
const value_start = parser.index;
464-
const name = parser.read_identifier();
508+
let name = parser.read_identifier();
465509

466510
if (name === null) {
467-
e.attribute_empty_shorthand(start);
511+
if (
512+
parser.loose &&
513+
(parser.match('#') || parser.match('/') || parser.match('@') || parser.match(':'))
514+
) {
515+
// We're likely in an unclosed opening tag and did read part of a block.
516+
// Return null to not crash the parser so it can continue with closing the tag.
517+
return null;
518+
} else if (parser.loose && parser.match('}')) {
519+
// Likely in the middle of typing, just created the shorthand
520+
name = '';
521+
} else {
522+
e.attribute_empty_shorthand(start);
523+
}
468524
}
469525

470526
parser.allow_whitespace();
@@ -756,5 +812,9 @@ function read_sequence(parser, done, location) {
756812
}
757813
}
758814

759-
e.unexpected_eof(parser.template.length);
815+
if (parser.loose) {
816+
return chunks;
817+
} else {
818+
e.unexpected_eof(parser.template.length);
819+
}
760820
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy