diff --git a/.vscode/settings.json b/.vscode/settings.json index f865d2f2c..a88eab873 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,7 @@ { - "typescript.preferences.quoteStyle": "single" + "typescript.preferences.quoteStyle": "single", + "files.exclude": { + "**/**/dist": true, + "**/**/node_modules": true + } } diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 779f16f64..873e5d92c 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -6,6 +6,7 @@ "typings": "dist/src/index", "scripts": { "test": "cross-env TS_NODE_TRANSPILE_ONLY=true mocha --require ts-node/register \"test/**/*.ts\" --exclude \"test/**/*.d.ts\"", + "TypescriptPlugin": "cross-env TS_NODE_TRANSPILE_ONLY=true mocha --require ts-node/register \"test/**/TypescriptPlugin.test.ts\" --exclude \"test/**/*.d.ts\"", "build": "tsc", "prepublishOnly": "npm run build", "watch": "tsc -w" diff --git a/packages/language-server/src/plugins/typescript/CSSClassDefinitionLocator.ts b/packages/language-server/src/plugins/typescript/CSSClassDefinitionLocator.ts new file mode 100644 index 000000000..b1a08066e --- /dev/null +++ b/packages/language-server/src/plugins/typescript/CSSClassDefinitionLocator.ts @@ -0,0 +1,203 @@ +import { Position, Range } from 'vscode-languageserver'; +import { SvelteDocumentSnapshot } from './DocumentSnapshot'; +import { Document } from '../../lib/documents'; +import { SvelteNode } from './svelte-ast-utils'; +export class CSSClassDefinitionLocator { + initialNodeAt: SvelteNode; + cssClassTerminators = ['', '{', '.', '>', '~', '>', '[', ':', '#', '+']; + constructor( + public tsDoc: SvelteDocumentSnapshot, + public position: Position, + public document: Document + ) { + this.initialNodeAt = this.tsDoc.svelteNodeAt(this.position) as SvelteNode; + } + + getCSSClassDefinition() { + if (this.isStandardClassFormat()) { + return this.getStandardFormatClassName(); + } + + if (this.isTargetHTMLElement()) { + return this.getHTMlElementName(); + } + + if (this.isDirectiveFormat() && this.initialNodeAt.name) { + return this.getDefinitionRangeWithinStyleSection(`.${this.initialNodeAt.name}`); + } + + if (this.isConditionalExpressionClassFormat() && this.initialNodeAt.value) { + return this.getDefinitionRangeWithinStyleSection(`.${this.initialNodeAt.value}`); + } + + return false; + } + + /** + * Standard format: + * class="test test1" + */ + private isStandardClassFormat() { + if (this.initialNodeAt?.type == 'Text' && this.initialNodeAt?.parent?.name == 'class') { + return true; + } + + return false; + } + + /** + * HTML Element target: + *
+ */ + private isTargetHTMLElement() { + if (this.initialNodeAt?.type == 'Element') { + return true; + } + + return false; + } + + /** + * Conditional Expression format: + * class="{current === 'baz' ? 'selected' : '' + */ + private isConditionalExpressionClassFormat() { + if ( + this.initialNodeAt?.type == 'Literal' && + this.initialNodeAt?.parent?.type == 'ConditionalExpression' && + this.initialNodeAt?.parent.parent?.parent?.name == 'class' + ) { + return true; + } + + return false; + } + + /** + * Class Directive format: + * class:active="{current === 'foo'}" + */ + private isDirectiveFormat() { + if (this.initialNodeAt?.type == 'Class' && this.initialNodeAt?.parent?.type == 'Element') { + return true; + } + + return false; + } + + private getStandardFormatClassName() { + const testEndTagRange = Range.create( + Position.create(this.position.line, 0), + Position.create(this.position.line, this.position.character) + ); + const text = this.document.getText(testEndTagRange); + + let loopLength = text.length; + let testPosition = this.position.character; + let spaceCount = 0; + + //Go backwards until hitting a " and keep track of how many spaces happened along the way + while (loopLength) { + const testEndTagRange = Range.create( + Position.create(this.position.line, testPosition - 1), + Position.create(this.position.line, testPosition) + ); + const text = this.document.getText(testEndTagRange); + if (text === ' ') { + spaceCount++; + } + + if (text === '"') { + break; + } + + testPosition--; + loopLength--; + } + + const cssClassName = this.initialNodeAt?.data.split(' ')[spaceCount]; + + return this.getDefinitionRangeWithinStyleSection(`.${cssClassName}`); + } + + private getHTMlElementName() { + const result = this.getDefinitionRangeWithinStyleSection(`${this.initialNodeAt.name}`); + if (result) { + //Shift start/end to get the highlight right + const originalStart = result.start.character; + result.start.character -= 1; + if (this.initialNodeAt.name) { + result.end.character = originalStart + this.initialNodeAt.name.length; + } + + return result; + } + } + + private getDefinitionRangeWithinStyleSection(targetClass: string) { + let indexOccurence = this.document.content.indexOf( + targetClass, + this.document.styleInfo?.start + ); + + while (indexOccurence >= 0) { + if (this.isOffsetWithinStyleSection(indexOccurence)) { + const startPosition = this.document.positionAt(indexOccurence); + const targetRange = Range.create( + startPosition, + Position.create( + startPosition.line, + startPosition.character + targetClass.length + ) + ); + indexOccurence = this.document.content.indexOf(targetClass, indexOccurence + 1); + + if (!this.isExactClassMatch(targetRange)) { + continue; + } + + return targetRange; + } else { + break; + } + } + } + + private isOffsetWithinStyleSection(offsetPosition: number) { + if (this.document.styleInfo) { + if ( + offsetPosition > this.document.styleInfo?.start && + offsetPosition < this.document.styleInfo?.end + ) { + return true; + } + } + + return false; + } + + private isExactClassMatch(testRange: Range) { + //Check nothing before the test position + if (testRange.start.character > 0) { + const beforerange = Range.create( + Position.create(testRange.start.line, testRange.start.character - 1), + Position.create(testRange.start.line, testRange.start.character) + ); + if (this.document.getText(beforerange).trim()) { + return false; + } + } + + //Check css terminator is after the test position + const afterRange = Range.create( + Position.create(testRange.end.line, testRange.end.character), + Position.create(testRange.end.line, testRange.end.character + 1) + ); + const afterRangeText = this.document.getText(afterRange).trim(); + if (this.cssClassTerminators.includes(afterRangeText)) { + return true; + } + + return false; + } +} diff --git a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts index 8191a9e81..0bab57f75 100644 --- a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -26,6 +26,7 @@ import { import { Document, getTextInRange, mapSymbolInformationToOriginal } from '../../lib/documents'; import { LSConfigManager, LSTypescriptConfig } from '../../ls-config'; import { isNotNullOrUndefined, isZeroLengthRange, pathToUrl } from '../../utils'; +import { CSSClassDefinitionLocator } from './CSSClassDefinitionLocator'; import { AppCompletionItem, AppCompletionList, @@ -334,6 +335,31 @@ export class TypeScriptPlugin async getDefinitions(document: Document, position: Position): Promise { const { lang, tsDoc } = await this.getLSAndTSDoc(document); + const cssClassHelper = new CSSClassDefinitionLocator(tsDoc, position, document); + const cssDefinitionRange = cssClassHelper.getCSSClassDefinition(); + if (cssDefinitionRange) { + const results: DefinitionLink[] = []; + cssDefinitionRange.start.character++; //Report start of name instead of start at . for easy rename (F2) possibilities + + const originRange = Range.create( + Position.create(position.line, position.character), + Position.create(position.line, position.character) + ); + + results.push( + LocationLink.create( + pathToUrl(document.getFilePath() as string), + cssDefinitionRange, + cssDefinitionRange, + originRange + ) + ); + + if (results) { + return results; + } + } + const defs = lang.getDefinitionAndBoundSpan( tsDoc.filePath, tsDoc.offsetAt(tsDoc.getGeneratedPosition(position)) diff --git a/packages/language-server/src/plugins/typescript/svelte-ast-utils.ts b/packages/language-server/src/plugins/typescript/svelte-ast-utils.ts index 86c7ee579..587c0b51b 100644 --- a/packages/language-server/src/plugins/typescript/svelte-ast-utils.ts +++ b/packages/language-server/src/plugins/typescript/svelte-ast-utils.ts @@ -3,6 +3,9 @@ export interface SvelteNode { end: number; type: string; parent?: SvelteNode; + value?: any; + data?: any; + name?: string; } type HTMLLike = 'Element' | 'InlineComponent' | 'Body' | 'Window'; diff --git a/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts b/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts index ddd984fc8..0dd116013 100644 --- a/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts +++ b/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts @@ -791,6 +791,132 @@ function test(useNewTransformation: boolean) { ]); } + it('provides standard css definition from svelte template', async () => { + const { plugin, document } = setup('css-definitions.svelte'); + + const definitions = await plugin.getDefinitions(document, Position.create(12, 19)); + + assert.deepStrictEqual(definitions, [ + { + targetRange: { + start: { + line: 22, + character: 3 + }, + end: { + line: 22, + character: 8 + } + }, + targetSelectionRange: { + start: { + line: 22, + character: 3 + }, + end: { + line: 22, + character: 8 + } + }, + originSelectionRange: { + start: { + line: 12, + character: 19 + }, + end: { + line: 12, + character: 19 + } + }, + targetUri: getUri('css-definitions.svelte') + } + ]); + }); + + it('provides conditional expression css definition from svelte template', async () => { + const { plugin, document } = setup('css-definitions.svelte'); + + const definitions = await plugin.getDefinitions(document, Position.create(16, 33)); + + assert.deepStrictEqual(definitions, [ + { + targetRange: { + start: { + line: 50, + character: 3 + }, + end: { + line: 50, + character: 11 + } + }, + targetSelectionRange: { + start: { + line: 50, + character: 3 + }, + end: { + line: 50, + character: 11 + } + }, + originSelectionRange: { + start: { + line: 16, + character: 33 + }, + end: { + line: 16, + character: 33 + } + }, + targetUri: getUri('css-definitions.svelte') + } + ]); + }); + + it('provides class directive css definition from svelte template', async () => { + const { plugin, document } = setup('css-definitions.svelte'); + + const definitions = await plugin.getDefinitions(document, Position.create(12, 31)); + + assert.deepStrictEqual(definitions, [ + { + targetRange: { + start: { + line: 45, + character: 3 + }, + end: { + line: 45, + character: 9 + } + }, + targetSelectionRange: { + start: { + line: 45, + character: 3 + }, + end: { + line: 45, + character: 9 + } + }, + originSelectionRange: { + start: { + line: 12, + character: 31 + }, + end: { + line: 12, + character: 31 + } + }, + targetUri: getUri('css-definitions.svelte') + } + ]); + }); + it('(within script simple)', async () => { await test$StoreDef( Position.create(9, 1), diff --git a/packages/language-server/test/plugins/typescript/testfiles/css-definitions.svelte b/packages/language-server/test/plugins/typescript/testfiles/css-definitions.svelte new file mode 100644 index 000000000..3e09fbbc9 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/css-definitions.svelte @@ -0,0 +1,55 @@ + + +
+

Visit the Svelte tutorial to learn how to build Svelte apps.

+
+ +
+

Visit the Svelte tutorial to learn how to build Svelte apps.

+
+ +