diff --git a/.changeset/smart-moons-drum.md b/.changeset/smart-moons-drum.md new file mode 100644 index 000000000..68ea00796 --- /dev/null +++ b/.changeset/smart-moons-drum.md @@ -0,0 +1,5 @@ +--- +'svelte-language-server': patch +--- + +fix: extract style/script tag followed by destructuring in the template diff --git a/packages/language-server/src/lib/documents/parseHtml.ts b/packages/language-server/src/lib/documents/parseHtml.ts index 0febdf836..afe239e2d 100644 --- a/packages/language-server/src/lib/documents/parseHtml.ts +++ b/packages/language-server/src/lib/documents/parseHtml.ts @@ -1,132 +1,227 @@ import { - getLanguageService, HTMLDocument, - TokenType, - ScannerState, - Scanner, Node, - Position + Position, + ScannerState, + TokenType, + getDefaultHTMLDataProvider, + getLanguageService } from 'vscode-html-languageservice'; import { Document } from './Document'; -import { isInsideMoustacheTag } from './utils'; +import { scanMatchingBraces } from './utils'; + +const voidElements = new Set( + getDefaultHTMLDataProvider() + .provideTags() + .filter((tag) => tag.void) + .map((tag) => tag.name) +); +const createScanner = getLanguageService() + .createScanner as typeof import('vscode-html-languageservice/lib/esm/parser/htmlScanner').createScanner; -const parser = getLanguageService(); +const braceStartCode = '{'.charCodeAt(0); +const singleQuoteCode = "'".charCodeAt(0); +const doubleQuoteCode = '"'.charCodeAt(0); /** - * Parses text as HTML + * adopted from https://github.com/microsoft/vscode-html-languageservice/blob/10daf45dc16b4f4228987cf7cddf3a7dbbdc7570/src/parser/htmlParser.ts + * differences: + * + * 1. parse expression tag in Whitespace state + * 2. parse attribute with interpolation in AttributeValue state + * 3. detect svelte blocks/tags in Content state */ export function parseHtml(text: string): HTMLDocument { - const preprocessed = preprocess(text); - - // We can safely only set getText because only this is used for parsing - const parsedDoc = parser.parseHTMLDocument({ getText: () => preprocessed }); + let scanner = createScanner(text, undefined, undefined, true); - return parsedDoc; -} - -const createScanner = parser.createScanner as ( - input: string, - initialOffset?: number, - initialState?: ScannerState -) => Scanner; - -/** - * scan the text and remove any `>` or `<` that cause the tag to end short, - */ -function preprocess(text: string) { - let scanner = createScanner(text); + const htmlDocument = new HTMLNode(0, text.length, [], undefined); + let curr = htmlDocument; + let endTagStart: number = -1; + let endTagName: string | undefined = undefined; + let pendingAttribute: string | null = null; let token = scanner.scan(); - let currentStartTagStart: number | null = null; - let moustacheCheckStart = 0; - let moustacheCheckEnd = 0; - let lastToken = token; while (token !== TokenType.EOS) { - const offset = scanner.getTokenOffset(); - let blanked = false; - switch (token) { case TokenType.StartTagOpen: - if (shouldBlankStartOrEndTagLike(offset)) { - blankStartOrEndTagLike(offset); - blanked = true; - } else { - currentStartTagStart = offset; - } + const child = new HTMLNode(scanner.getTokenOffset(), text.length, [], curr); + curr.children.push(child); + curr = child; + break; + case TokenType.StartTag: + curr.tag = scanner.getTokenText(); break; - case TokenType.StartTagClose: - if (shouldBlankStartOrEndTagLike(offset)) { - blankStartOrEndTagLike(offset); - blanked = true; - } else { - currentStartTagStart = null; + if (curr.parent) { + curr.end = scanner.getTokenEnd(); // might be later set to end tag position + if (scanner.getTokenLength()) { + curr.startTagEnd = scanner.getTokenEnd(); + if (curr.tag && voidElements.has(curr.tag)) { + curr.closed = true; + curr = curr.parent; + } + } else { + // pseudo close token from an incomplete start tag + curr = curr.parent; + } } break; - case TokenType.StartTagSelfClose: - currentStartTagStart = null; + if (curr.parent) { + curr.closed = true; + curr.end = scanner.getTokenEnd(); + curr.startTagEnd = scanner.getTokenEnd(); + curr = curr.parent; + } + break; + case TokenType.EndTagOpen: + endTagStart = scanner.getTokenOffset(); + endTagName = undefined; + break; + case TokenType.EndTag: + endTagName = scanner.getTokenText().toLowerCase(); break; + case TokenType.EndTagClose: + let node = curr; + // see if we can find a matching tag + while (!node.isSameTag(endTagName) && node.parent) { + node = node.parent; + } + if (node.parent) { + while (curr !== node) { + curr.end = endTagStart; + curr.closed = false; + curr = curr.parent!; + } + curr.closed = true; + curr.endTagStart = endTagStart; + curr.end = scanner.getTokenEnd(); + curr = curr.parent!; + } + break; + case TokenType.AttributeName: { + pendingAttribute = scanner.getTokenText(); + let attributes = curr.attributes; + if (!attributes) { + curr.attributes = attributes = {}; + } + attributes[pendingAttribute] = null; + break; + } - // - // https://github.com/microsoft/vscode-html-languageservice/blob/71806ef57be07e1068ee40900ef8b0899c80e68a/src/parser/htmlScanner.ts#L327 - case TokenType.Unknown: - if ( - scanner.getScannerState() === ScannerState.WithinTag && - scanner.getTokenText() === '<' && - shouldBlankStartOrEndTagLike(offset) - ) { - blankStartOrEndTagLike(offset); - blanked = true; + case TokenType.DelimiterAssign: { + const afterAssign = scanner.getTokenEnd(); + if (text.charCodeAt(afterAssign) === braceStartCode) { + const result = scanMatchingBraces(text, afterAssign); + restartScannerAt(result.endOffset, ScannerState.WithinTag); + finishAttribute(afterAssign, result.endOffset); } break; + } + case TokenType.Whitespace: { + const afterWhitespace = scanner.getTokenEnd(); + if (text.charCodeAt(afterWhitespace) === braceStartCode) { + //
scanner.getTokenEnd()) { + restartScannerAt(expressionEnd, ScannerState.WithinContent); } break; } } - // blanked, so the token type is invalid - if (!blanked) { - lastToken = token; - } token = scanner.scan(); } + while (curr.parent) { + curr.end = text.length; + curr.closed = false; + curr = curr.parent; + } + return { + roots: htmlDocument.children, + findNodeBefore: htmlDocument.findNodeBefore.bind(htmlDocument), + findNodeAt: htmlDocument.findNodeAt.bind(htmlDocument) + }; + + function skipExpressionInCurrentRange() { + const start = scanner.getTokenOffset(); + const end = scanner.getTokenEnd(); + let index = start; + while (index < end) { + if (text.charCodeAt(index) !== braceStartCode) { + index++; + continue; + } + const matchResult = scanMatchingBraces(text, index); + index = matchResult.endOffset; + } + + return Math.max(index, end); + } - return text; + function restartScannerAt(offset: number, scannerState: ScannerState) { + if (offset <= scanner.getTokenEnd()) { + return; + } + scanner = createScanner(text, offset, scannerState, /* emitPseudoCloseTags*/ true); + } - function shouldBlankStartOrEndTagLike(offset: number) { - if (currentStartTagStart != null) { - return isInsideMoustacheTag(text, currentStartTagStart, offset); + function finishAttribute(start: number, end: number) { + if (!pendingAttribute || !curr.attributes) { + return; } - const index = text - .substring(moustacheCheckStart, moustacheCheckEnd) - .lastIndexOf('{', offset); + curr.attributes[pendingAttribute] = text.substring(start, end); + pendingAttribute = null; + } - const lastMustacheTagStart = index === -1 ? null : moustacheCheckStart + index; - if (lastMustacheTagStart == null) { - return false; + function parseSpreadOrShorthandAttribute(startOffset: number) { + const scanResult = scanMatchingBraces(text, startOffset); + const end = scanResult.endOffset; + restartScannerAt(end, ScannerState.WithinTag); + const expressionStart = startOffset + 1; + const expressionEnd = end - 1; + const expression = text.substring(expressionStart, expressionEnd).trim(); + if (text.substring(expressionStart).startsWith('...')) { + return; } - return isInsideMoustacheTag( - text.substring(lastMustacheTagStart), - null, - offset - lastMustacheTagStart - ); + curr.attributes ??= {}; + curr.attributes[expression] = text.substring(startOffset, end); } - function blankStartOrEndTagLike(offset: number) { - text = text.substring(0, offset) + ' ' + text.substring(offset + 1); - scanner = createScanner( - text, - offset, - currentStartTagStart != null ? ScannerState.WithinTag : ScannerState.WithinContent - ); + function parseAttributeValue() { + const quote = text.charCodeAt(scanner.getTokenOffset()); + // + if (!isQuote(quote)) { + finishAttribute(scanner.getTokenOffset(), scanner.getTokenEnd()); + return; + } + const start = scanner.getTokenOffset(); + const tokenEnd = scanner.getTokenEnd(); + let expressionTagEnd = skipExpressionInCurrentRange(); + if (expressionTagEnd > tokenEnd) { + const indexOfQuote = text.indexOf(String.fromCharCode(quote), expressionTagEnd); + expressionTagEnd = indexOfQuote !== -1 ? indexOfQuote + 1 : text.length; + restartScannerAt(expressionTagEnd, ScannerState.WithinTag); + } + finishAttribute(start, expressionTagEnd); } } @@ -150,10 +245,9 @@ export function getAttributeContextAtPosition( } const text = document.getText(); - const beforeStartTagEnd = - text.substring(0, tag.start) + preprocess(text.substring(tag.start, tag.startTagEnd)); + const beforeStartTagEnd = text.substring(0, tag.startTagEnd); - const scanner = createScanner(beforeStartTagEnd, tag.start); + let scanner = createScanner(beforeStartTagEnd, tag.start); let token = scanner.scan(); let currentAttributeName: string | undefined; @@ -172,7 +266,8 @@ export function getAttributeContextAtPosition( }; } } else if (token === TokenType.DelimiterAssign) { - if (scanner.getTokenEnd() === offset && currentAttributeName) { + const afterAssign = scanner.getTokenEnd(); + if (afterAssign === offset && currentAttributeName) { const nextToken = scanner.scan(); return { @@ -185,13 +280,16 @@ export function getAttributeContextAtPosition( ] }; } + if (text.charCodeAt(afterAssign) === braceStartCode) { + const scanResult = scanMatchingBraces(text, afterAssign); + restartScannerAt(scanResult.endOffset, ScannerState.WithinTag); + } } else if (token === TokenType.AttributeValue) { if (inTokenRange() && currentAttributeName) { let start = scanner.getTokenOffset(); let end = scanner.getTokenEnd(); - const char = text[start]; - if (char === '"' || char === "'") { + if (isQuote(text.charCodeAt(start))) { start++; end--; } @@ -204,13 +302,130 @@ export function getAttributeContextAtPosition( }; } currentAttributeName = undefined; + } else if (token === TokenType.Whitespace) { + const afterWhitespace = scanner.getTokenEnd(); + if (text.charCodeAt(afterWhitespace) === braceStartCode) { + //
node.start && node.startTagEnd != undefined && offset < node.startTagEnd; } + +/** + * adopted from https://github.com/microsoft/vscode-html-languageservice/blob/10daf45dc16b4f4228987cf7cddf3a7dbbdc7570/src/parser/htmlParser.ts + */ +export class HTMLNode implements Node { + tag: string | undefined; + closed: boolean = false; + startTagEnd: number | undefined; + endTagStart: number | undefined; + attributes?: { [name: string]: string | null } | undefined; + + get attributeNames(): string[] { + return this.attributes ? Object.keys(this.attributes) : []; + } + + constructor( + public start: number, + public end: number, + public children: HTMLNode[], + public parent?: HTMLNode + ) {} + + isSameTag(tagInLowerCase: string | undefined) { + if (this.tag === undefined) { + return tagInLowerCase === undefined; + } else { + return ( + tagInLowerCase !== undefined && + this.tag.length === tagInLowerCase.length && + this.tag.toLowerCase() === tagInLowerCase + ); + } + } + + public get firstChild(): Node | undefined { + return this.children[0]; + } + public get lastChild(): Node | undefined { + return this.children.length ? this.children[this.children.length - 1] : void 0; + } + + public findNodeBefore(offset: number): Node { + const idx = HTMLNode.findFirst(this.children, (c) => offset <= c.start) - 1; + if (idx >= 0) { + const child = this.children[idx]; + if (offset > child.start) { + if (offset < child.end) { + return child.findNodeBefore(offset); + } + const lastChild = child.lastChild; + if (lastChild && lastChild.end === child.end) { + return child.findNodeBefore(offset); + } + return child; + } + } + return this; + } + + public findNodeAt(offset: number): Node { + const idx = HTMLNode.findFirst(this.children, (c) => offset <= c.start) - 1; + if (idx >= 0) { + const child = this.children[idx]; + if (offset > child.start && offset <= child.end) { + return child.findNodeAt(offset); + } + } + return this; + } + + private static findFirst(array: T[], p: (t: T) => boolean): number { + let low = 0, + high = array.length; + if (high === 0) { + return 0; // no children + } + while (low < high) { + let mid = Math.floor((low + high) / 2); + if (p(array[mid])) { + high = mid; + } else { + low = mid + 1; + } + } + return low; + } +} diff --git a/packages/language-server/src/lib/documents/utils.ts b/packages/language-server/src/lib/documents/utils.ts index 51939b0e4..8a5eb3cd0 100644 --- a/packages/language-server/src/lib/documents/utils.ts +++ b/packages/language-server/src/lib/documents/utils.ts @@ -455,31 +455,6 @@ export function getLangAttribute(...tags: Array): string return attribute.replace(/^text\//, ''); } -/** - * Checks whether given position is inside a moustache tag (which includes control flow tags) - * using a simple bracket matching heuristic which might fail under conditions like - * `{#if {a: true}.a}` - */ -export function isInsideMoustacheTag(html: string, tagStart: number | null, position: number) { - if (tagStart === null) { - // Not inside - const charactersBeforePosition = html.substring(0, position); - return ( - Math.max( - // TODO make this just check for '{'? - // Theoretically, someone could do {a < b} in a simple moustache tag - charactersBeforePosition.lastIndexOf('{#'), - charactersBeforePosition.lastIndexOf('{:'), - charactersBeforePosition.lastIndexOf('{@') - ) > charactersBeforePosition.lastIndexOf('}') - ); - } else { - // Inside - const charactersInNode = html.substring(tagStart, position); - return charactersInNode.lastIndexOf('{') > charactersInNode.lastIndexOf('}'); - } -} - export function inStyleOrScript(document: Document, position: Position) { return ( isInTag(position, document.styleInfo) || @@ -487,3 +462,163 @@ export function inStyleOrScript(document: Document, position: Position) { isInTag(position, document.moduleScriptInfo) ); } + +const backtickCode = '`'.charCodeAt(0); +const braceStartCode = '{'.charCodeAt(0); +const braceEndCode = '}'.charCodeAt(0); +const singleQuoteCode = "'".charCodeAt(0); +const doubleQuoteCode = '"'.charCodeAt(0); +const forwardSlashCode = '/'.charCodeAt(0); +const starCode = '*'.charCodeAt(0); +const crCode = '\r'.charCodeAt(0); +const lfCode = '\n'.charCodeAt(0); +const escapeCode = 92; // '\' +const dollarCode = '$'.charCodeAt(0); + +interface BraceMatchResult { + terminated: boolean; + endOffset: number; +} +/** + * Matches until braces are balanced using a simple parsing logic. + * Should only be called when positioned at an opening brace. + */ +export function scanMatchingBraces(html: string, startOffset: number): BraceMatchResult { + if (html.charCodeAt(startOffset) !== braceStartCode) { + return { terminated: true, endOffset: startOffset }; + } + + let depth = 0; + let templateStack: number[] | undefined; + let index = startOffset; + + while (index < html.length) { + const char = html.charCodeAt(index); + switch (char) { + case braceStartCode: + depth++; + break; + case braceEndCode: + if (depth > 0) { + depth--; + } + if (depth === 0 && templateStack !== undefined && templateStack.length > 0) { + depth = templateStack.pop() || 0; + scanTemplateString(); + } + break; + case singleQuoteCode: + case doubleQuoteCode: { + index++; + scanString(char); + break; + } + + case backtickCode: + index++; + scanTemplateString(); + break; + case forwardSlashCode: { + // / + const nextChar = html.charCodeAt(index + 1); + if (nextChar === forwardSlashCode) { + skipToNewLine(); + } else if (nextChar === starCode) { + index += 2; // skip /* + skipToEndOfMultiLineComment(); + } + // Theoretically it could be a regex here. But it clashes with an end block and self-close tag. + // There is also /[/]/ that makes it hard to do a simple scan. So we skip regex handling for now. + break; + } + } + + index++; + if (depth === 0 && (templateStack === undefined || templateStack.length === 0)) { + return { terminated: true, endOffset: index }; + } + } + + return { terminated: false, endOffset: index }; + + function scanString(quote: number) { + while (index < html.length) { + const char = html.charCodeAt(index); + if (char === quote || char == lfCode) { + return; + } + if (char === escapeCode) { + index += 2; + continue; + } + index++; + } + } + + function scanTemplateString() { + while (index < html.length) { + const char = html.charCodeAt(index); + switch (char) { + case backtickCode: + return; + + case dollarCode: // $ + if (html.charCodeAt(index + 1) === braceStartCode) { + templateStack = templateStack || []; + templateStack.push(depth); + depth = 0; + return; + } + break; + + case escapeCode: // \ + // skip next character + index++; + break; + } + index++; + } + return; + } + + function skipToNewLine() { + while (index < html.length) { + const char = html.charCodeAt(index); + if (char === crCode || char === lfCode) { + return; + } + index++; + } + } + + function skipToEndOfMultiLineComment() { + while (index < html.length) { + const char = html.charCodeAt(index); + if (char === starCode && html.charCodeAt(index + 1) === forwardSlashCode) { + index += 2; + return; + } + index++; + } + } +} + +export function isInsideMoustacheTag(text: string, tagStart: number, offset: number): boolean { + const firstBraceIndex = text.indexOf('{', tagStart); + if (firstBraceIndex > offset) { + return false; + } + let index = firstBraceIndex; + while (index < offset) { + if (text.charCodeAt(index) !== braceStartCode) { + index++; + continue; + } + const result = scanMatchingBraces(text, index); + if (!result.terminated) { + return true; + } + index = result.endOffset; + } + return index > offset; +} diff --git a/packages/language-server/test/lib/documents/parseHtml.test.ts b/packages/language-server/test/lib/documents/parseHtml.test.ts index 54dcc0d6c..8349a4066 100644 --- a/packages/language-server/test/lib/documents/parseHtml.test.ts +++ b/packages/language-server/test/lib/documents/parseHtml.test.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import { HTMLDocument } from 'vscode-html-languageservice'; -import { parseHtml } from '../../../src/lib/documents/parseHtml'; +import { getAttributeContextAtPosition, parseHtml } from '../../../src/lib/documents/parseHtml'; +import { Document } from '../../../src/lib/documents'; describe('parseHtml', () => { const testRootElements = (document: HTMLDocument) => { @@ -114,4 +115,115 @@ describe('parseHtml', () => { ) ); }); + + it('can parse html with destructured snippet and type annotation', () => { + testRootElements( + parseHtml( + `{#snippet foo({ props }: { props?: Record })}{/snippet} + + ` + ) + ); + }); + + it('can parse html with destructured event handler', () => { + testRootElements( + parseHtml( + ` handleClick(detail)} /> + ` + ) + ); + }); + + it('can parse html with object literal in event handler', () => { + testRootElements( + parseHtml( + ` { if ({ x }.x <= 0) {} }} /> + ` + ) + ); + }); + + it('ignore { inside string', () => { + testRootElements( + parseHtml( + ` 1} /> + ` + ) + ); + }); + + it('ignore } inside template string', () => { + testRootElements(parseHtml('\n')); + }); + + it('ignore } inside template string (nested)', () => { + testRootElements(parseHtml('\n')); + }); + + it('parse attribute short-hand', () => { + const document = parseHtml( + ` + ` + ); + const fooNode = document.roots.find((r) => r.tag === 'Foo'); + assert.ok(fooNode); + const ariaLabelValue = fooNode.attributes?.['ariaLabel']; + assert.strictEqual(ariaLabelValue, '{ariaLabel}'); + }); + + it('parse expression', () => { + const document = parseHtml( + ` + ` + ); + const fooNode = document.roots.find((r) => r.tag === 'Foo'); + assert.ok(fooNode); + const ariaLabelValue = fooNode.attributes?.['ariaLabel']; + assert.strictEqual(ariaLabelValue, '{""}'); + }); + + it('parse expression with spaces around equals', () => { + const document = parseHtml(``); + const fooNode = document.roots.find((r) => r.tag === 'Foo'); + assert.ok(fooNode); + const ariaLabelValue = fooNode.attributes?.['ariaLabel']; + assert.strictEqual(ariaLabelValue, '{""}'); + }); + + it('parse attributes with interpolation', () => { + const document = parseHtml(``); + const fooNode = document.roots.find((r) => r.tag === 'Foo'); + assert.ok(fooNode); + const ariaLabelValue = fooNode.attributes?.['ariaLabel']; + assert.strictEqual(ariaLabelValue, `"a{b > c ? "": ""} c"`); + }); +}); + +describe('getAttributeContextAtPosition', () => { + it('extract attribute name', () => { + const document = setupDocument('
'); + const result = getAttributeContextAtPosition(document, { line: 0, character: 6 }); + assert.strictEqual(result?.name, 'disabled'); + assert.strictEqual(result.inValue, false); + }); + + it('extract attribute after interpolated attribute', () => { + const document = setupDocument(' b} b= />'); + const result = getAttributeContextAtPosition(document, { line: 0, character: 17 }); + assert.strictEqual(result?.name, 'b'); + assert.strictEqual(result.inValue, true); + }); + + it('extract attribute value range', () => { + const document = setupDocument(''); + const result = getAttributeContextAtPosition(document, { line: 0, character: 8 }); + assert.strictEqual(result?.name, 'a'); + assert.strictEqual(result.inValue, true); + assert.deepStrictEqual(result?.valueRange, [8, 13]); + }); + + function setupDocument(content: string) { + return new Document('file:///test/Test.svelte', content); + } }); diff --git a/packages/language-server/test/lib/documents/utils.test.ts b/packages/language-server/test/lib/documents/utils.test.ts index 509212020..7e3bf2295 100644 --- a/packages/language-server/test/lib/documents/utils.test.ts +++ b/packages/language-server/test/lib/documents/utils.test.ts @@ -4,7 +4,8 @@ import { extractStyleTag, extractScriptTags, updateRelativeImport, - getWordAt + getWordAt, + isInsideMoustacheTag } from '../../../src/lib/documents/utils'; import { Position } from 'vscode-languageserver'; @@ -442,4 +443,30 @@ describe('document/utils', () => { assert.equal(getWordAt('a a', 2), ''); }); }); + + describe('#isInsideMoustacheTag', () => { + it('detects position inside moustache tag', () => { + const result = isInsideMoustacheTag( + '
console.log("hello")}>
', + 0, + 20 + ); + assert.strictEqual(result, true); + }); + + it('detects position after template literal', () => { + const result = isInsideMoustacheTag('
', 0, 11); + assert.strictEqual(result, true); + }); + + it('detects position after nested template literals', () => { + const result = isInsideMoustacheTag('
', 0, 15); + assert.strictEqual(result, true); + }); + + it('detects position after nested template literals with interpolation', () => { + const result = isInsideMoustacheTag('
', 0, 22); + assert.strictEqual(result, true); + }); + }); });