diff --git a/.changeset/many-pens-bake.md b/.changeset/many-pens-bake.md new file mode 100644 index 000000000..d33240fa6 --- /dev/null +++ b/.changeset/many-pens-bake.md @@ -0,0 +1,5 @@ +--- +"svelte-language-server": patch +--- + +[perf/refactor]: utils improvements in language-server diff --git a/packages/language-server/src/lib/documents/utils.ts b/packages/language-server/src/lib/documents/utils.ts index b5eebe271..2bf467331 100644 --- a/packages/language-server/src/lib/documents/utils.ts +++ b/packages/language-server/src/lib/documents/utils.ts @@ -184,7 +184,7 @@ export function extractTemplateTag(source: string, html?: HTMLDocument): TagInfo /** * Get the line and character based on the offset * @param offset The index of the position - * @param text The text for which the position should be retrived + * @param text The text for which the position should be retrieved * @param lineOffsets number Array with offsets for each line. Computed if not given */ export function positionAt( @@ -222,7 +222,7 @@ export function positionAt( /** * Get the offset of the line and character position * @param position Line and character position - * @param text The text for which the offset should be retrived + * @param text The text for which the offset should be retrieved * @param lineOffsets number Array with offsets for each line. Computed if not given */ export function offsetAt( @@ -281,14 +281,21 @@ export function isRangeInTag( } export function getTextInRange(range: Range, text: string) { - return text.substring(offsetAt(range.start, text), offsetAt(range.end, text)); + const lineOffsets = getLineOffsets(text); + const start = offsetAt(range.start, text, lineOffsets); + const end = offsetAt(range.end, text, lineOffsets); + return text.substring(start, end); } export function getLineAtPosition(position: Position, text: string) { - return text.substring( - offsetAt({ line: position.line, character: 0 }, text), - offsetAt({ line: position.line, character: Number.MAX_VALUE }, text) + const lineOffsets = getLineOffsets(text); + const lineStart = offsetAt({ line: position.line, character: 0 }, text, lineOffsets); + const lineEnd = offsetAt( + { line: position.line, character: Number.MAX_VALUE }, + text, + lineOffsets ); + return text.substring(lineStart, lineEnd); } /** @@ -446,22 +453,29 @@ export function getLangAttribute(...tags: Array): string * `{#if {a: true}.a}` */ export function isInsideMoustacheTag(html: string, tagStart: number | null, position: number) { + const searchEnd = Math.max(position - 1, 0); + 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('}') - ); + // TODO make this just check for '{'? + // Theoretically, someone could do {a < b} in a simple moustache tag + const lastHash = html.lastIndexOf('{#', searchEnd); + const lastColon = html.lastIndexOf('{:', searchEnd); + const lastAt = html.lastIndexOf('{@', searchEnd); + const lastClose = html.lastIndexOf('}', searchEnd); + + const lastOpen = Math.max(lastHash, lastColon, lastAt); + return lastOpen > lastClose; } else { // Inside - const charactersInNode = html.substring(tagStart, position); - return charactersInNode.lastIndexOf('{') > charactersInNode.lastIndexOf('}'); + const lastOpen = html.lastIndexOf('{', searchEnd); + const lastClose = html.lastIndexOf('}', searchEnd); + + // Ensure we only consider braces inside the tag region + const effectiveLastOpen = lastOpen >= tagStart ? lastOpen : -1; + const effectiveLastClose = lastClose >= tagStart ? lastClose : -1; + + return effectiveLastOpen > effectiveLastClose; } } diff --git a/packages/language-server/src/utils.ts b/packages/language-server/src/utils.ts index 9a1ba01d2..6370376ff 100644 --- a/packages/language-server/src/utils.ts +++ b/packages/language-server/src/utils.ts @@ -1,8 +1,7 @@ -import { isEqual, sum, uniqWith } from 'lodash'; -import { FoldingRange, Node } from 'vscode-html-languageservice'; +import { isEqual, uniqWith } from 'lodash'; +import { Node } from 'vscode-html-languageservice'; import { Position, Range } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; -import { Document, TagInformation } from './lib/documents'; type Predicate = (x: T) => boolean; @@ -137,13 +136,13 @@ export function isNotNullOrUndefined(val: T | undefined | null): val is T { * a second function determines it should. * * @param fn The function with it's argument - * @param determineIfSame The function which determines if the previous invocation should be canceld or not - * @param miliseconds Number of miliseconds to debounce + * @param determineIfSame The function which determines if the previous invocation should be canceled or not + * @param milliseconds Number of milliseconds to debounce */ export function debounceSameArg( fn: (arg: T) => void, shouldCancelPrevious: (newArg: T, prevArg?: T) => boolean, - miliseconds: number + milliseconds: number ): (arg: T) => void { let timeout: any; let prevArg: T | undefined; @@ -157,34 +156,34 @@ export function debounceSameArg( timeout = setTimeout(() => { fn(arg); prevArg = undefined; - }, miliseconds); + }, milliseconds); }; } /** - * Debounces a function but also waits at minimum the specified number of miliseconds until + * Debounces a function but also waits at minimum the specified number of milliseconds until * the next invocation. This avoids needless calls when a synchronous call (like diagnostics) * took too long and the whole timeout of the next call was eaten up already. * * @param fn The function - * @param miliseconds Number of miliseconds to debounce/throttle + * @param milliseconds Number of milliseconds to debounce/throttle */ -export function debounceThrottle(fn: () => void, miliseconds: number): () => void { +export function debounceThrottle(fn: () => void, milliseconds: number): () => void { let timeout: any; - let lastInvocation = Date.now() - miliseconds; + let lastInvocation = Date.now() - milliseconds; function maybeCall() { clearTimeout(timeout); timeout = setTimeout(() => { - if (Date.now() - lastInvocation < miliseconds) { + if (Date.now() - lastInvocation < milliseconds) { maybeCall(); return; } fn(); lastInvocation = Date.now(); - }, miliseconds); + }, milliseconds); } return maybeCall; diff --git a/packages/language-server/test/lib/documents/utils.test.ts b/packages/language-server/test/lib/documents/utils.test.ts index 509212020..e5cb40fd1 100644 --- a/packages/language-server/test/lib/documents/utils.test.ts +++ b/packages/language-server/test/lib/documents/utils.test.ts @@ -3,10 +3,22 @@ import { getLineAtPosition, extractStyleTag, extractScriptTags, + extractTemplateTag, updateRelativeImport, - getWordAt + getWordAt, + getWordRangeAt, + positionAt, + offsetAt, + getLineOffsets, + isInTag, + isRangeInTag, + getTextInRange, + isAtEndOfLine, + toRange, + getLangAttribute, + isInsideMoustacheTag } from '../../../src/lib/documents/utils'; -import { Position } from 'vscode-languageserver'; +import { Position, Range } from 'vscode-languageserver'; describe('document/utils', () => { describe('extractTag', () => { @@ -442,4 +454,451 @@ describe('document/utils', () => { assert.equal(getWordAt('a a', 2), ''); }); }); + + describe('#getWordRangeAt', () => { + it('returns range between whitespaces', () => { + assert.deepStrictEqual(getWordRangeAt('qwd asd qwd', 5), { start: 4, end: 7 }); + }); + + it('returns range between whitespace and end of string', () => { + assert.deepStrictEqual(getWordRangeAt('qwd asd', 5), { start: 4, end: 7 }); + }); + + it('returns range between start of string and whitespace', () => { + assert.deepStrictEqual(getWordRangeAt('asd qwd', 2), { start: 0, end: 3 }); + }); + + it('returns range for entire string when no delimiters', () => { + assert.deepStrictEqual(getWordRangeAt('asd', 2), { start: 0, end: 3 }); + }); + + it('returns range with custom delimiters', () => { + assert.deepStrictEqual( + getWordRangeAt('asd on:asd-qwd="asd" ', 10, { left: /\S+$/, right: /[\s=]/ }), + { start: 4, end: 14 } + ); + }); + }); + + describe('#extractTemplateTag', () => { + it('should extract template tag', () => { + const text = ''; + const extracted = extractTemplateTag(text); + assert.deepStrictEqual(extracted?.content, 'content'); + }); + + it('should return null when no template tag', () => { + const text = '
no template
'; + assert.equal(extractTemplateTag(text), null); + }); + + it('should extract first template tag only', () => { + const text = ''; + const extracted = extractTemplateTag(text); + assert.equal(extracted?.content, 'first'); + }); + }); + + describe('#positionAt', () => { + it('should return position at offset (single line)', () => { + const pos = positionAt(3, 'abcdef'); + assert.deepStrictEqual(pos, Position.create(0, 3)); + }); + + it('should return position at offset (multiple lines)', () => { + const pos = positionAt(7, 'abc\ndefghi'); + assert.deepStrictEqual(pos, Position.create(1, 3)); + }); + + it('should handle CRLF line breaks', () => { + const pos = positionAt(8, 'abc\r\ndefghi'); + assert.deepStrictEqual(pos, Position.create(1, 3)); + }); + + it('should clamp offset to text length', () => { + const pos = positionAt(100, 'short'); + assert.deepStrictEqual(pos, Position.create(0, 5)); + }); + + it('should handle negative offset', () => { + const pos = positionAt(-5, 'text'); + assert.deepStrictEqual(pos, Position.create(0, 0)); + }); + + it('should handle empty text', () => { + const pos = positionAt(0, ''); + assert.deepStrictEqual(pos, Position.create(0, 0)); + }); + + it('should return position at start of second line', () => { + const pos = positionAt(4, 'abc\ndef'); + assert.deepStrictEqual(pos, Position.create(1, 0)); + }); + }); + + describe('#offsetAt', () => { + it('should return offset at position (single line)', () => { + const offset = offsetAt(Position.create(0, 3), 'abcdef'); + assert.equal(offset, 3); + }); + + it('should return offset at position (multiple lines)', () => { + const offset = offsetAt(Position.create(1, 3), 'abc\ndefghi'); + assert.equal(offset, 7); + }); + + it('should handle CRLF line breaks', () => { + const offset = offsetAt(Position.create(1, 3), 'abc\r\ndefghi'); + assert.equal(offset, 8); + }); + + it('should clamp to text length when line exceeds', () => { + const offset = offsetAt(Position.create(10, 0), 'abc'); + assert.equal(offset, 3); + }); + + it('should return 0 for negative line', () => { + const offset = offsetAt(Position.create(-1, 5), 'abc\ndef'); + assert.equal(offset, 0); + }); + + it('should clamp character to line length', () => { + const offset = offsetAt(Position.create(0, 100), 'abc\ndef'); + assert.equal(offset, 4); + }); + }); + + describe('#getLineOffsets', () => { + it('should return offsets for single line', () => { + const offsets = getLineOffsets('hello'); + assert.deepStrictEqual(offsets, [0]); + }); + + it('should return offsets for multiple lines with LF', () => { + const offsets = getLineOffsets('a\nb\nc'); + assert.deepStrictEqual(offsets, [0, 2, 4]); + }); + + it('should return offsets for multiple lines with CRLF', () => { + const offsets = getLineOffsets('a\r\nb\r\nc'); + assert.deepStrictEqual(offsets, [0, 3, 6]); + }); + + it('should handle mixed line breaks', () => { + const offsets = getLineOffsets('a\nb\r\nc'); + assert.deepStrictEqual(offsets, [0, 2, 5]); + }); + + it('should handle empty string', () => { + const offsets = getLineOffsets(''); + assert.deepStrictEqual(offsets, []); + }); + + it('should handle trailing newline', () => { + const offsets = getLineOffsets('a\nb\n'); + assert.deepStrictEqual(offsets, [0, 2, 4]); + }); + }); + + describe('#isInTag', () => { + it('should return true when position is in tag', () => { + const tagInfo = { + content: 'test', + attributes: {}, + start: 10, + end: 20, + startPos: Position.create(0, 10), + endPos: Position.create(0, 20), + container: { start: 5, end: 25 } + }; + assert.equal(isInTag(Position.create(0, 15), tagInfo), true); + }); + + it('should return false when position is outside tag', () => { + const tagInfo = { + content: 'test', + attributes: {}, + start: 10, + end: 20, + startPos: Position.create(0, 10), + endPos: Position.create(0, 20), + container: { start: 5, end: 25 } + }; + assert.equal(isInTag(Position.create(0, 25), tagInfo), false); + }); + + it('should return false when tagInfo is null', () => { + assert.equal(isInTag(Position.create(0, 10), null), false); + }); + + it('should return true for position at start', () => { + const tagInfo = { + content: 'test', + attributes: {}, + start: 10, + end: 20, + startPos: Position.create(0, 10), + endPos: Position.create(0, 20), + container: { start: 5, end: 25 } + }; + assert.equal(isInTag(Position.create(0, 10), tagInfo), true); + }); + + it('should return true for position at end', () => { + const tagInfo = { + content: 'test', + attributes: {}, + start: 10, + end: 20, + startPos: Position.create(0, 10), + endPos: Position.create(0, 20), + container: { start: 5, end: 25 } + }; + assert.equal(isInTag(Position.create(0, 20), tagInfo), true); + }); + }); + + describe('#isRangeInTag', () => { + const tagInfo = { + content: 'test', + attributes: {}, + start: 10, + end: 20, + startPos: Position.create(0, 10), + endPos: Position.create(0, 20), + container: { start: 5, end: 25 } + }; + + it('should return true when range is fully in tag', () => { + const range = Range.create(Position.create(0, 12), Position.create(0, 18)); + assert.equal(isRangeInTag(range, tagInfo), true); + }); + + it('should return false when range start is outside', () => { + const range = Range.create(Position.create(0, 5), Position.create(0, 15)); + assert.equal(isRangeInTag(range, tagInfo), false); + }); + + it('should return false when range end is outside', () => { + const range = Range.create(Position.create(0, 15), Position.create(0, 25)); + assert.equal(isRangeInTag(range, tagInfo), false); + }); + + it('should return false when tagInfo is null', () => { + const range = Range.create(Position.create(0, 12), Position.create(0, 18)); + assert.equal(isRangeInTag(range, null), false); + }); + }); + + describe('#getTextInRange', () => { + it('should extract text in range (single line)', () => { + const text = 'hello world'; + const range = Range.create(Position.create(0, 0), Position.create(0, 5)); + assert.equal(getTextInRange(range, text), 'hello'); + }); + + it('should extract text in range (multiple lines)', () => { + const text = 'line1\nline2\nline3'; + const range = Range.create(Position.create(0, 0), Position.create(1, 5)); + assert.equal(getTextInRange(range, text), 'line1\nline2'); + }); + + it('should extract partial text from line', () => { + const text = 'hello world'; + const range = Range.create(Position.create(0, 6), Position.create(0, 11)); + assert.equal(getTextInRange(range, text), 'world'); + }); + + it('should handle empty range', () => { + const text = 'hello'; + const range = Range.create(Position.create(0, 2), Position.create(0, 2)); + assert.equal(getTextInRange(range, text), ''); + }); + }); + + describe('#isAtEndOfLine', () => { + it('should return true at LF', () => { + assert.equal(isAtEndOfLine('hello\n', 5), true); + }); + + it('should return true at CR', () => { + assert.equal(isAtEndOfLine('hello\r', 5), true); + }); + + it('should return true at end of string', () => { + assert.equal(isAtEndOfLine('hello', 5), true); + }); + + it('should return false in middle of line', () => { + assert.equal(isAtEndOfLine('hello world', 5), false); + }); + + it('should return true at CRLF (CR position)', () => { + assert.equal(isAtEndOfLine('hello\r\n', 5), true); + }); + }); + + describe('#toRange', () => { + it('should convert offsets to range', () => { + const text = 'hello\nworld'; + const range = toRange(text, 0, 5); + assert.deepStrictEqual( + range, + Range.create(Position.create(0, 0), Position.create(0, 5)) + ); + }); + + it('should handle multi-line range', () => { + const text = 'hello\nworld'; + const range = toRange(text, 0, 7); + assert.deepStrictEqual( + range, + Range.create(Position.create(0, 0), Position.create(1, 1)) + ); + }); + + it('should handle same start and end', () => { + const text = 'hello'; + const range = toRange(text, 2, 2); + assert.deepStrictEqual( + range, + Range.create(Position.create(0, 2), Position.create(0, 2)) + ); + }); + }); + + describe('#getLangAttribute', () => { + it('should return lang attribute', () => { + const tag = { + content: '', + attributes: { lang: 'typescript' }, + start: 0, + end: 0, + startPos: Position.create(0, 0), + endPos: Position.create(0, 0), + container: { start: 0, end: 0 } + }; + assert.equal(getLangAttribute(tag), 'typescript'); + }); + + it('should return type attribute when lang is missing', () => { + const tag = { + content: '', + attributes: { type: 'text/typescript' }, + start: 0, + end: 0, + startPos: Position.create(0, 0), + endPos: Position.create(0, 0), + container: { start: 0, end: 0 } + }; + assert.equal(getLangAttribute(tag), 'typescript'); + }); + + it('should prefer lang over type', () => { + const tag = { + content: '', + attributes: { lang: 'scss', type: 'text/css' }, + start: 0, + end: 0, + startPos: Position.create(0, 0), + endPos: Position.create(0, 0), + container: { start: 0, end: 0 } + }; + assert.equal(getLangAttribute(tag), 'scss'); + }); + + it('should return null when no attributes', () => { + const tag = { + content: '', + attributes: {}, + start: 0, + end: 0, + startPos: Position.create(0, 0), + endPos: Position.create(0, 0), + container: { start: 0, end: 0 } + }; + assert.equal(getLangAttribute(tag), null); + }); + + it('should return null when tag is null', () => { + assert.equal(getLangAttribute(null), null); + }); + + it('should check multiple tags and return first with lang', () => { + const tag1 = { + content: '', + attributes: {}, + start: 0, + end: 0, + startPos: Position.create(0, 0), + endPos: Position.create(0, 0), + container: { start: 0, end: 0 } + }; + const tag2 = { + content: '', + attributes: { lang: 'scss' }, + start: 0, + end: 0, + startPos: Position.create(0, 0), + endPos: Position.create(0, 0), + container: { start: 0, end: 0 } + }; + assert.equal(getLangAttribute(tag1, tag2), 'scss'); + }); + + it('should strip text/ prefix from type', () => { + const tag = { + content: '', + attributes: { type: 'text/javascript' }, + start: 0, + end: 0, + startPos: Position.create(0, 0), + endPos: Position.create(0, 0), + container: { start: 0, end: 0 } + }; + assert.equal(getLangAttribute(tag), 'javascript'); + }); + }); + + describe('#isInsideMoustacheTag', () => { + it('should return true when inside #if tag', () => { + const html = '{#if condition}content'; + assert.equal(isInsideMoustacheTag(html, null, 10), true); + }); + + it('should return false after closing moustache', () => { + const html = '{#if condition}content{/if}after'; + assert.equal(isInsideMoustacheTag(html, null, 30), false); + }); + + it('should return true when inside {:else tag', () => { + const html = '{#if a}{:else}content'; + assert.equal(isInsideMoustacheTag(html, null, 10), true); + }); + + it('should return true when inside {@html tag', () => { + const html = 'before{@html content'; + assert.equal(isInsideMoustacheTag(html, null, 15), true); + }); + + it('should return false before any moustache tags', () => { + const html = 'before{#if condition}'; + assert.equal(isInsideMoustacheTag(html, null, 3), false); + }); + + it('should handle position inside tag attributes', () => { + const html = '
'; + assert.equal(isInsideMoustacheTag(html, 0, 22), true); + }); + + it('should return false when inside tag with no open moustache', () => { + const html = '
'; + assert.equal(isInsideMoustacheTag(html, 0, 15), false); + }); + + it('should return true when open brace without close', () => { + const html = '
{ + // String: a,'b,c',d - comma at position 1 is outside, commas inside quotes should be ignored + assert.equal(traverseTypeString("a,'b,c',d", 0, ','), 1); + }); + + it('should skip commas inside double quotes', () => { + // String: "x,y",z - first comma at position 2 is inside quotes, should find comma at position 5 + assert.equal(traverseTypeString('"x,y",z', 0, ','), 5); + }); + + it('should skip commas inside single quotes', () => { + // String: 'x,y',z - first comma at position 2 is inside quotes, should find comma at position 5 + assert.equal(traverseTypeString("'x,y',z", 0, ','), 5); + }); + + it('should handle curly braces', () => { + assert.equal(traverseTypeString('hello{a,b},end', 0, ','), 10); + }); + + it('should handle angle brackets', () => { + assert.equal(traverseTypeString('Array<{a,b}>,end', 0, ','), 12); + }); + + it('should handle nested structures', () => { + assert.equal(traverseTypeString('Map>,end', 0, ','), 24); + }); + + it('should return -1 when not found', () => { + assert.equal(traverseTypeString('hello world', 0, ','), -1); + }); + + it('should respect start position', () => { + assert.equal(traverseTypeString('a,b,c', 2, ','), 3); + }); + + it('should handle mixed quotes and brackets', () => { + assert.equal(traverseTypeString('func("test,value"),end', 0, ','), 21); + }); }); });