From dd3c71bd6252d9ad20591fbf5c797349f804dd2e Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 29 Jul 2022 19:39:36 +0200 Subject: [PATCH 01/13] feat: add support for SCSS in Astro Scan and parse SCSS sections from the start for diagnostics. --- CONTRIBUTING.md | 2 +- README.md | 2 +- fixtures/e2e/completion/AppButton.astro | 32 +++++++++++++++ fixtures/e2e/definition/AppButton.astro | 23 +++++++++++ fixtures/e2e/diagnostics/AppButton.astro | 19 +++++++++ fixtures/e2e/hover/AppButton.astro | 23 +++++++++++ fixtures/e2e/signature/AppButton.astro | 25 ++++++++++++ package.json | 3 +- src/client.ts | 1 + src/unsafe/server.ts | 4 +- src/unsafe/services/scanner.ts | 26 ++++++++++++- src/unsafe/test/e2e/runTest.ts | 5 +++ .../e2e/suite/completion/completion.test.ts | 10 +++++ .../e2e/suite/definition/definitions.test.ts | 7 ++++ src/unsafe/test/e2e/suite/hover/hover.test.ts | 7 ++++ .../e2e/suite/signature/signature.test.ts | 10 +++++ .../{vue-svelte.spec.ts => embedded.spec.ts} | 39 +++++++++++-------- .../utils/{vue-svelte.ts => embedded.ts} | 20 ++++++---- 18 files changed, 226 insertions(+), 32 deletions(-) create mode 100644 fixtures/e2e/completion/AppButton.astro create mode 100644 fixtures/e2e/definition/AppButton.astro create mode 100644 fixtures/e2e/diagnostics/AppButton.astro create mode 100644 fixtures/e2e/hover/AppButton.astro create mode 100644 fixtures/e2e/signature/AppButton.astro rename src/unsafe/test/utils/{vue-svelte.spec.ts => embedded.spec.ts} (71%) rename src/unsafe/utils/{vue-svelte.ts => embedded.ts} (68%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef00d561..cdfb7ebb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ This method of debugging is **not recommended** if you want to debug the functio If you still want to debug the integration tests there are a few things to keep in mind, since tests run in this way use your main stable install of VS Code (not Insiders, like from the terminal): -- You will need to install Vetur (`octref.vetur`) and Svelte for VS Code (`svelte.svelte-vscode`) +- You will need to install Vetur (`octref.vetur`), Astro (`astro-build.astro-vscode`) and Svelte for VS Code (`svelte.svelte-vscode`). - You **must** use default settings for Some Sass. Tip: use the included Workspace Settings. - To compile changes in test code, run `npm run test:compile` diff --git a/README.md b/README.md index 05513509..423746e3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Some Sass provides code suggestions, documentation and code navigation for SCSS. - Rich documentation through [SassDoc](http://sassdoc.com). - Suggestions and hover info for built-in Sass modules, when used with `@use`. -Supports standalone SCSS, as well as style blocks inside Vue and Svelte components. +Supports standalone SCSS, as well as style blocks inside Vue, Svelte and Astro components. Based on SCSS Intellisense by [Denis Malinochkin and contributors](https://github.com/mrmlnc/vscode-scss). Uses the built-in VS Code language server for SCSS. diff --git a/fixtures/e2e/completion/AppButton.astro b/fixtures/e2e/completion/AppButton.astro new file mode 100644 index 00000000..83c93f22 --- /dev/null +++ b/fixtures/e2e/completion/AppButton.astro @@ -0,0 +1,32 @@ +--- +--- + +

+ + diff --git a/fixtures/e2e/definition/AppButton.astro b/fixtures/e2e/definition/AppButton.astro new file mode 100644 index 00000000..649e5383 --- /dev/null +++ b/fixtures/e2e/definition/AppButton.astro @@ -0,0 +1,23 @@ +--- +--- + +

+ + diff --git a/fixtures/e2e/diagnostics/AppButton.astro b/fixtures/e2e/diagnostics/AppButton.astro new file mode 100644 index 00000000..4f00efec --- /dev/null +++ b/fixtures/e2e/diagnostics/AppButton.astro @@ -0,0 +1,19 @@ +--- +--- + +

+ + diff --git a/fixtures/e2e/hover/AppButton.astro b/fixtures/e2e/hover/AppButton.astro new file mode 100644 index 00000000..649e5383 --- /dev/null +++ b/fixtures/e2e/hover/AppButton.astro @@ -0,0 +1,23 @@ +--- +--- + +

+ + diff --git a/fixtures/e2e/signature/AppButton.astro b/fixtures/e2e/signature/AppButton.astro new file mode 100644 index 00000000..0ed197ac --- /dev/null +++ b/fixtures/e2e/signature/AppButton.astro @@ -0,0 +1,25 @@ +--- +--- + +

+ + diff --git a/package.json b/package.json index a1ea5490..1ff84c8d 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "activationEvents": [ "onLanguage:scss", "onLanguage:vue", - "onLanguage:svelte" + "onLanguage:svelte", + "onLanguage:astro" ], "main": "./dist/client.js", "contributes": { diff --git a/src/client.ts b/src/client.ts index 2b3511cf..c68ce001 100644 --- a/src/client.ts +++ b/src/client.ts @@ -104,6 +104,7 @@ function buildClientOptions(workspace: URI): LanguageClientOptions { { scheme: 'file', language: 'scss', pattern }, { scheme: 'file', language: 'vue', pattern }, { scheme: 'file', language: 'svelte', pattern }, + { scheme: 'file', language: 'astro', pattern }, ], synchronize: { configurationSection: ['somesass'], diff --git a/src/unsafe/server.ts b/src/unsafe/server.ts index a26aca81..6879bda9 100644 --- a/src/unsafe/server.ts +++ b/src/unsafe/server.ts @@ -24,7 +24,7 @@ import { doSignatureHelp } from './providers/signatureHelp'; import { goDefinition } from './providers/goDefinition'; import { searchWorkspaceSymbol } from './providers/workspaceSymbol'; import { findFiles } from './utils/fs'; -import { getSCSSRegionsDocument } from './utils/vue-svelte'; +import { getSCSSRegionsDocument } from './utils/embedded'; import { provideReferences } from './providers/references'; interface InitializationOption { @@ -63,7 +63,7 @@ connection.onInitialize( storageService = new StorageService(); scannerService = new ScannerService(storageService, settings); - const files = await findFiles('**/*.scss', { + const files = await findFiles('**/*.{scss,svelte,astro,vue}', { cwd: workspaceRoot.fsPath, deep: settings.scannerDepth, ignore: settings.scannerExclude diff --git a/src/unsafe/services/scanner.ts b/src/unsafe/services/scanner.ts index 56638c79..ec76ef85 100644 --- a/src/unsafe/services/scanner.ts +++ b/src/unsafe/services/scanner.ts @@ -5,6 +5,7 @@ import { URI } from 'vscode-uri'; import type { ISettings } from '../types/settings'; import type { ScssImport } from '../types/symbols'; +import { getSCSSRegionsDocument, isFileWhereScssCanBeEmbedded } from '../utils/embedded'; import { readFile, fileExists } from '../utils/fs'; import { parseDocument } from './parser'; import type StorageService from './storage'; @@ -31,7 +32,11 @@ export default class ScannerService { } public async update(document: TextDocument, workspaceRoot: URI): Promise { - const scssDocument = await parseDocument(document, workspaceRoot); + const scssRegions = this.getScssRegionsOfDocument(document); + if (!scssRegions) { + return; + } + const scssDocument = await parseDocument(scssRegions, workspaceRoot); this._storage.set(scssDocument.uri, scssDocument); } @@ -57,7 +62,11 @@ export default class ScannerService { try { const content = await readFile(filepath); const document = TextDocument.create(uri, 'scss', 1, content); - const scssDocument = await parseDocument(document, workspaceRoot); + const scssRegions = this.getScssRegionsOfDocument(document); + if (!scssRegions) { + return; + } + const scssDocument = await parseDocument(scssRegions, workspaceRoot); // TODO: be inspired by the way the LSP sample handles document storage and cache invalidation? Documents can be renamed, deleted. this._storage.set(scssDocument.uri, scssDocument); @@ -82,4 +91,17 @@ export default class ScannerService { return; } } + + protected getScssRegionsOfDocument(document: TextDocument): TextDocument | null { + if (isFileWhereScssCanBeEmbedded(document.uri)) { + const regions = getSCSSRegionsDocument(document); + if (regions.document) { + return regions.document; + } else { + return null; + } + } else { + return document; + } + } } diff --git a/src/unsafe/test/e2e/runTest.ts b/src/unsafe/test/e2e/runTest.ts index 702f621c..6239fd13 100644 --- a/src/unsafe/test/e2e/runTest.ts +++ b/src/unsafe/test/e2e/runTest.ts @@ -34,6 +34,11 @@ async function main() { stdio: 'inherit' }); + cp.spawnSync(cli, [...args, '--install-extension', 'astro-build.astro-vscode'], { + encoding: 'utf-8', + stdio: 'inherit' + }); + await runTests({ vscodeExecutablePath, version: 'insiders', diff --git a/src/unsafe/test/e2e/suite/completion/completion.test.ts b/src/unsafe/test/e2e/suite/completion/completion.test.ts index 03b699bd..a46f523a 100644 --- a/src/unsafe/test/e2e/suite/completion/completion.test.ts +++ b/src/unsafe/test/e2e/suite/completion/completion.test.ts @@ -7,11 +7,13 @@ describe('SCSS Completion Test', () => { const docUri = getDocUri('completion/main.scss'); const vueDocUri = getDocUri('completion/AppButton.vue'); const svelteDocUri = getDocUri('completion/AppButton.svelte'); + const astroDocUri = getDocUri('completion/AppButton.astro'); before(async () => { await showFile(docUri); await showFile(vueDocUri); await showFile(svelteDocUri); + await showFile(astroDocUri); }); it('Offers completions from tilde imports', async () => { @@ -20,6 +22,7 @@ describe('SCSS Completion Test', () => { await testCompletion(docUri, position(11, 11), expectedCompletions); await testCompletion(vueDocUri, position(22, 11), expectedCompletions); await testCompletion(svelteDocUri, position(14, 11), expectedCompletions); + await testCompletion(astroDocUri, position(17, 11), expectedCompletions); }); it('Offers completions from partial file', async () => { @@ -28,6 +31,7 @@ describe('SCSS Completion Test', () => { await testCompletion(docUri, position(17, 11), expectedCompletions); await testCompletion(vueDocUri, position(28, 11), expectedCompletions); await testCompletion(svelteDocUri, position(20, 11), expectedCompletions); + await testCompletion(astroDocUri, position(23, 11), expectedCompletions); }); it('Offers namespaces completions including prefixes', async () => { @@ -39,6 +43,7 @@ describe('SCSS Completion Test', () => { await testCompletion(docUri, position(23, 13), expectedCompletions); await testCompletion(vueDocUri, position(34, 13), expectedCompletions); await testCompletion(svelteDocUri, position(26, 13), expectedCompletions); + await testCompletion(astroDocUri, position(29, 13), expectedCompletions); expectedCompletions = [ @@ -48,6 +53,7 @@ describe('SCSS Completion Test', () => { await testCompletion(docUri, position(24, 15), expectedCompletions); await testCompletion(vueDocUri, position(35, 15), expectedCompletions); await testCompletion(svelteDocUri, position(27, 15), expectedCompletions); + await testCompletion(astroDocUri, position(30, 15), expectedCompletions); }); // We can't test this until somesass.suggestOnlyFromUse: true becomes the default setting @@ -57,18 +63,21 @@ describe('SCSS Completion Test', () => { await testCompletion(docUri, position(23, 13), expectedCompletions, { expectNoMatch: true }); await testCompletion(vueDocUri, position(34, 13), expectedCompletions, { expectNoMatch: true }); await testCompletion(svelteDocUri, position(26, 13), expectedCompletions, { expectNoMatch: true }); + await testCompletion(astroDocUri, position(29, 13), expectedCompletions, { expectNoMatch: true }); expectedCompletions = ['secret', 'other-secret', 'mix-secret', 'mix-other-secret']; await testCompletion(docUri, position(24, 15), expectedCompletions, { expectNoMatch: true }); await testCompletion(vueDocUri, position(35, 15), expectedCompletions, { expectNoMatch: true }); await testCompletion(svelteDocUri, position(27, 15), expectedCompletions, { expectNoMatch: true }); + await testCompletion(astroDocUri, position(30, 15), expectedCompletions, { expectNoMatch: true }); }); it('Offers no completions on Vuelike file outside SCSS regions', async () => { await testCompletion(vueDocUri, position(2, 9), []); await testCompletion(vueDocUri, position(6, 8), []); await testCompletion(svelteDocUri, position(1, 16), []); + await testCompletion(astroDocUri, position(4, 16), []); }); it('Offers variable completions on Vuelike file', async () => { @@ -76,6 +85,7 @@ describe('SCSS Completion Test', () => { await testCompletion(vueDocUri, position(16, 11), expectedCompletions); await testCompletion(svelteDocUri, position(8, 11), expectedCompletions); + await testCompletion(astroDocUri, position(11, 11), expectedCompletions); }); }); diff --git a/src/unsafe/test/e2e/suite/definition/definitions.test.ts b/src/unsafe/test/e2e/suite/definition/definitions.test.ts index 622377ef..12cd5d55 100644 --- a/src/unsafe/test/e2e/suite/definition/definitions.test.ts +++ b/src/unsafe/test/e2e/suite/definition/definitions.test.ts @@ -5,11 +5,13 @@ describe('SCSS Definition Test', () => { const docUri = getDocUri('definition/main.scss'); const vueDocUri = getDocUri('definition/AppButton.vue'); const svelteDocUri = getDocUri('definition/AppButton.svelte'); + const astroDocUri = getDocUri('definition/AppButton.astro'); before(async () => { await showFile(docUri); await showFile(vueDocUri); await showFile(svelteDocUri); + await showFile(astroDocUri); }); it('should find definition for variables', async () => { @@ -19,6 +21,7 @@ describe('SCSS Definition Test', () => { await testDefinition(docUri, position(6, 13), expectedLocation); await testDefinition(vueDocUri, position(15, 13), expectedLocation); await testDefinition(svelteDocUri, position(9, 15), expectedLocation); + await testDefinition(astroDocUri, position(12, 15), expectedLocation); }); it('should find definition for functions', async () => { @@ -28,6 +31,7 @@ describe('SCSS Definition Test', () => { await testDefinition(docUri, position(6, 24), expectedLocation); await testDefinition(vueDocUri, position(15, 24), expectedLocation); await testDefinition(svelteDocUri, position(9, 26), expectedLocation); + await testDefinition(astroDocUri, position(12, 26), expectedLocation); }); it('should find definition for mixins', async () => { @@ -37,6 +41,7 @@ describe('SCSS Definition Test', () => { await testDefinition(docUri, position(8, 12), expectedLocation); await testDefinition(vueDocUri, position(17, 12), expectedLocation); await testDefinition(svelteDocUri, position(11, 14), expectedLocation); + await testDefinition(astroDocUri, position(14, 14), expectedLocation); }); it('should find symbol definition behind namespace', async () => { @@ -46,6 +51,7 @@ describe('SCSS Definition Test', () => { await testDefinition(docUri, position(14, 14), expectedLocation); await testDefinition(vueDocUri, position(23, 14), expectedLocation); await testDefinition(svelteDocUri, position(17, 14), expectedLocation); + await testDefinition(astroDocUri, position(20, 14), expectedLocation); }); it('should find symbol definition behind namespace and prefix', async () => { @@ -55,5 +61,6 @@ describe('SCSS Definition Test', () => { await testDefinition(docUri, position(15, 17), expectedLocation); await testDefinition(vueDocUri, position(24, 17), expectedLocation); await testDefinition(svelteDocUri, position(18, 17), expectedLocation); + await testDefinition(astroDocUri, position(21, 17), expectedLocation); }); }); diff --git a/src/unsafe/test/e2e/suite/hover/hover.test.ts b/src/unsafe/test/e2e/suite/hover/hover.test.ts index 0975972e..a1cba0cb 100644 --- a/src/unsafe/test/e2e/suite/hover/hover.test.ts +++ b/src/unsafe/test/e2e/suite/hover/hover.test.ts @@ -5,11 +5,13 @@ describe('SCSS Hover Test', () => { const docUri = getDocUri('hover/main.scss'); const vueDocUri = getDocUri('hover/AppButton.vue'); const svelteDocUri = getDocUri('hover/AppButton.svelte'); + const astroDocUri = getDocUri('hover/AppButton.astro'); before(async () => { await showFile(docUri); await showFile(vueDocUri); await showFile(svelteDocUri); + await showFile(astroDocUri); }); it('shows hover for variables', async () => { @@ -20,6 +22,7 @@ describe('SCSS Hover Test', () => { await testHover(docUri, position(6, 13), expectedContents); await testHover(vueDocUri, position(15, 13), expectedContents); await testHover(svelteDocUri, position(9, 15), expectedContents); + await testHover(astroDocUri, position(12, 15), expectedContents); }); it('shows hover for functions', async () => { @@ -30,6 +33,7 @@ describe('SCSS Hover Test', () => { await testHover(docUri, position(6, 24), expectedContents); await testHover(vueDocUri, position(15, 24), expectedContents); await testHover(svelteDocUri, position(9, 26), expectedContents); + await testHover(astroDocUri, position(12, 26), expectedContents); }); it('shows hover for mixins', async () => { @@ -40,6 +44,7 @@ describe('SCSS Hover Test', () => { await testHover(docUri, position(8, 12), expectedContents); await testHover(vueDocUri, position(17, 12), expectedContents); await testHover(svelteDocUri, position(11, 14), expectedContents); + await testHover(astroDocUri, position(14, 14), expectedContents); }); it('shows hover for symbol behind namespace', async () => { @@ -50,6 +55,7 @@ describe('SCSS Hover Test', () => { await testHover(docUri, position(14, 14), expectedContents); await testHover(vueDocUri, position(23, 14), expectedContents); await testHover(svelteDocUri, position(17, 14), expectedContents); + await testHover(astroDocUri, position(20, 14), expectedContents); }); it('shows hover for symbol behind namespace and prefix', async () => { @@ -61,6 +67,7 @@ describe('SCSS Hover Test', () => { await testHover(docUri, position(15, 17), expectedContents); await testHover(vueDocUri, position(24, 17), expectedContents); await testHover(svelteDocUri, position(18, 17), expectedContents); + await testHover(astroDocUri, position(21, 17), expectedContents); }); it('shows hover for SassDoc annotations', async () => { diff --git a/src/unsafe/test/e2e/suite/signature/signature.test.ts b/src/unsafe/test/e2e/suite/signature/signature.test.ts index 84e5cbae..2382b61d 100644 --- a/src/unsafe/test/e2e/suite/signature/signature.test.ts +++ b/src/unsafe/test/e2e/suite/signature/signature.test.ts @@ -5,11 +5,13 @@ describe('SCSS Signature Help Test', () => { const docUri = getDocUri('signature/main.scss'); const vueDocUri = getDocUri('signature/AppButton.vue'); const svelteDocUri = getDocUri('signature/AppButton.svelte'); + const astroDocUri = getDocUri('signature/AppButton.astro'); before(async () => { await showFile(docUri); await showFile(vueDocUri); await showFile(svelteDocUri); + await showFile(astroDocUri); }); describe('Mixin', () => { @@ -28,6 +30,7 @@ describe('SCSS Signature Help Test', () => { await testSignature(docUri, position(5, 19), expected); await testSignature(vueDocUri, position(14, 19), expected); await testSignature(svelteDocUri, position(8, 19), expected); + await testSignature(astroDocUri, position(11, 19), expected); }); it('should suggest all parameters of mixin behind namespace and prefix', async () => { @@ -45,6 +48,7 @@ describe('SCSS Signature Help Test', () => { await testSignature(docUri, position(14, 30), expected); await testSignature(vueDocUri, position(23, 30), expected); await testSignature(svelteDocUri, position(17, 30), expected); + await testSignature(astroDocUri, position(20, 30), expected); }); it('should suggest the second parameter of mixin', async () => { @@ -62,6 +66,7 @@ describe('SCSS Signature Help Test', () => { await testSignature(docUri, position(6, 21), expected); await testSignature(vueDocUri, position(15, 21), expected); await testSignature(svelteDocUri, position(9, 21), expected); + await testSignature(astroDocUri, position(12, 21), expected); }); it('should suggest the second parameter of mixin behind namespace and prefix', async () => { @@ -79,6 +84,7 @@ describe('SCSS Signature Help Test', () => { await testSignature(docUri, position(15, 32), expected); await testSignature(vueDocUri, position(24, 32), expected); await testSignature(svelteDocUri, position(18, 32), expected); + await testSignature(astroDocUri, position(21, 32), expected); }); }); @@ -98,6 +104,7 @@ describe('SCSS Signature Help Test', () => { await testSignature(docUri, position(8, 16), expected); await testSignature(vueDocUri, position(17, 16), expected); await testSignature(svelteDocUri, position(11, 16), expected); + await testSignature(astroDocUri, position(14, 16), expected); }); it('should suggest all parameters of function behind namespace and prefix', async () => { @@ -115,6 +122,7 @@ describe('SCSS Signature Help Test', () => { await testSignature(docUri, position(17, 27), expected); await testSignature(vueDocUri, position(26, 27), expected); await testSignature(svelteDocUri, position(20, 27), expected); + await testSignature(astroDocUri, position(23, 27), expected); }); it('should suggest the second parameter of function', async () => { @@ -132,6 +140,7 @@ describe('SCSS Signature Help Test', () => { await testSignature(docUri, position(8, 26), expected); await testSignature(vueDocUri, position(17, 26), expected); await testSignature(svelteDocUri, position(11, 26), expected); + await testSignature(astroDocUri, position(14, 26), expected); }); it('should suggest the second parameter of function behind namespace and prefix', async () => { @@ -149,6 +158,7 @@ describe('SCSS Signature Help Test', () => { await testSignature(docUri, position(17, 48), expected); await testSignature(vueDocUri, position(26, 48), expected); await testSignature(svelteDocUri, position(20, 48), expected); + await testSignature(astroDocUri, position(23, 48), expected); }); it('should suggest all parameters of function from Sass built-in', async () => { diff --git a/src/unsafe/test/utils/vue-svelte.spec.ts b/src/unsafe/test/utils/embedded.spec.ts similarity index 71% rename from src/unsafe/test/utils/vue-svelte.spec.ts rename to src/unsafe/test/utils/embedded.spec.ts index c25c9bfc..8f6830e7 100644 --- a/src/unsafe/test/utils/vue-svelte.spec.ts +++ b/src/unsafe/test/utils/embedded.spec.ts @@ -3,29 +3,34 @@ import assert from 'assert'; import { - isVueOrSvelteFile, + isFileWhereScssCanBeEmbedded, getSCSSRegions, getSCSSContent, getSCSSRegionsDocument -} from '../../utils/vue-svelte'; +} from '../../utils/embedded'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { Position } from 'vscode-languageserver'; describe('Utils/VueSvelte', () => { - it('isVueOrSvelteFile', () => { - assert.strictEqual(isVueOrSvelteFile('sdasdsa/AppButton.vue'), true); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/AppButton.scss.vue'), true); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/AppButton.vue.ts'), false); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/sdadsf.ts'), false); - assert.strictEqual(isVueOrSvelteFile('sda.vue/AppButton.scss'), false); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/AppButton.vue.scss'), false); + it('isFileWhereScssCanBeEmbedded', () => { + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.vue'), true); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.scss.vue'), true); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.vue.ts'), false); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/sdadsf.ts'), false); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sda.vue/AppButton.scss'), false); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.vue.scss'), false); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/AppButton.svelte'), true); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/AppButton.scss.svelte'), true); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/AppButton.svelte.ts'), false); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/sdadsf.ts'), false); - assert.strictEqual(isVueOrSvelteFile('sda.vue/AppButton.scss'), false); - assert.strictEqual(isVueOrSvelteFile('sdasdsa/AppButton.svelte.scss'), false); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.svelte'), true); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.scss.svelte'), true); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.svelte.ts'), false); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sda.svelte/AppButton.scss'), false); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.svelte.scss'), false); + + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.astro'), true); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.scss.astro'), true); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.astro.ts'), false); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sda.astro/AppButton.scss'), false); + assert.strictEqual(isFileWhereScssCanBeEmbedded('sdasdsa/AppButton.astro.scss'), false); }); it('getSCSSRegions', () => { @@ -43,10 +48,10 @@ describe('Utils/VueSvelte', () => { assert.deepStrictEqual(getSCSSRegions( `\n`) - , [[90, 109], [143, 162]]); + , [[90, 109], [143, 162]]); assert.deepStrictEqual(getSCSSRegions( `\n\n`) - , [[90, 109], [143, 162], [202, 221]]); + , [[90, 109], [143, 162], [202, 221]]); assert.deepStrictEqual(getSCSSRegions(''), []); assert.deepStrictEqual(getSCSSRegions(''), []); diff --git a/src/unsafe/utils/vue-svelte.ts b/src/unsafe/utils/embedded.ts similarity index 68% rename from src/unsafe/utils/vue-svelte.ts rename to src/unsafe/utils/embedded.ts index e5216289..62a0d151 100644 --- a/src/unsafe/utils/vue-svelte.ts +++ b/src/unsafe/utils/embedded.ts @@ -2,8 +2,8 @@ import { TextDocument, Position } from 'vscode-languageserver-textdocument'; type Region = [number, number]; -export function isVueOrSvelteFile(path: string) { - return path.endsWith('.vue') || path.endsWith('.svelte'); +export function isFileWhereScssCanBeEmbedded(path: string) { + return path.endsWith('.vue') || path.endsWith('.svelte') || path.endsWith('.astro'); } export function getSCSSRegions(content: string) { @@ -40,15 +40,19 @@ function convertTextDocument(document: TextDocument, regions: Region[]) { return TextDocument.create(document.uri, 'scss', document.version, getSCSSContent(document.getText(), regions)); } -export function getSCSSRegionsDocument(document: TextDocument, position: Position) { - const offset = document.offsetAt(position); - if (!isVueOrSvelteFile(document.uri)) { +export function getSCSSRegionsDocument(document: TextDocument, position?: Position) { + const offset = position ? document.offsetAt(position) : 0; + if (!isFileWhereScssCanBeEmbedded(document.uri)) { return { document, offset }; } - const vueSCSSRegions = getSCSSRegions(document.getText()); - if (vueSCSSRegions.some(region => region[0] <= offset && region[1] >= offset)) { - return { document: convertTextDocument(document, vueSCSSRegions), offset }; + const scssRegions = getSCSSRegions(document.getText()); + + if (typeof position === "undefined") { + return { document: convertTextDocument(document, scssRegions), offset }; + } else if (scssRegions.some(region => region[0] <= offset && region[1] >= offset)) { + return { document: convertTextDocument(document, scssRegions), offset }; } + return { document: null, offset }; } From 0703b8a6f57f3841792d97e396e47c3fb21cadc4 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 29 Jul 2022 20:46:56 +0200 Subject: [PATCH 02/13] fix: skip extra $/. in completions in Vue, Svelte --- .../completion/completion-context.ts | 14 +++++++-- src/unsafe/providers/completion/completion.ts | 30 ++++++++++++++----- .../e2e/suite/completion/completion.test.ts | 25 ++++++++++++---- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/unsafe/providers/completion/completion-context.ts b/src/unsafe/providers/completion/completion-context.ts index 1cc567fa..2006553c 100644 --- a/src/unsafe/providers/completion/completion-context.ts +++ b/src/unsafe/providers/completion/completion-context.ts @@ -1,7 +1,10 @@ import { getCurrentWord, getTextBeforePosition } from '../../utils/string'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; import type { ISettings } from '../../types/settings'; +type SupportedExtensions = 'scss' | 'vue' | 'svelte' | 'astro'; + export type CompletionContext = { word: string; textBeforeWord: string; @@ -12,6 +15,7 @@ export type CompletionContext = { variable: boolean; function: boolean; mixin: boolean; + originalExtension: SupportedExtensions; }; const rePropertyValue = /.*:\s*/; @@ -97,7 +101,7 @@ function checkNamespaceContext(currentWord: string): string | null { return currentWord.substring(0, currentWord.indexOf(".")); } -export function createCompletionContext(text: string, offset: number, settings: ISettings): CompletionContext { +export function createCompletionContext(document: TextDocument, text: string, offset: number, settings: ISettings): CompletionContext { const currentWord = getCurrentWord(text, offset); const textBeforeWord = getTextBeforePosition(text, offset); @@ -114,6 +118,9 @@ export function createCompletionContext(text: string, offset: number, settings: // Is namespace, e.g. `namespace.$var` or `@include namespace.mixin` or `namespace.func()` const namespace = checkNamespaceContext(currentWord) + const lastDot = document.uri.lastIndexOf('.'); + const originalExtension = document.uri.substring(lastDot + 1) as SupportedExtensions; + return { word: currentWord, textBeforeWord, @@ -131,6 +138,7 @@ export function createCompletionContext(text: string, offset: number, settings: Boolean(namespace), settings ), - mixin: checkMixinContext(textBeforeWord, isPropertyValue) + mixin: checkMixinContext(textBeforeWord, isPropertyValue), + originalExtension, }; -} \ No newline at end of file +} diff --git a/src/unsafe/providers/completion/completion.ts b/src/unsafe/providers/completion/completion.ts index 5a8ec70c..9b7f2c6e 100644 --- a/src/unsafe/providers/completion/completion.ts +++ b/src/unsafe/providers/completion/completion.ts @@ -24,7 +24,7 @@ export function doCompletion( let completions = CompletionList.create([], false); const text = document.getText(); - const context = createCompletionContext(text, offset, settings); + const context = createCompletionContext(document, text, offset, settings); if (context.sassDoc) { return doSassDocCompletion(text, offset, context); @@ -287,12 +287,21 @@ function createVariableCompletionItems( } } + const isEmbedded = context.originalExtension !== "scss"; if (context.namespace) { // Avoid ending up with namespace.prefix-$variable label = `$${prefix}${asDollarlessVariable(variable.name)}`; - // The `.` in the namespace gets replaced unless we have a $ charachter after it - insertText = context.word.endsWith(".") ? `.${label}` : label; + // The `.` in the namespace gets replaced unless we have a $ charachter after it. + // Except when we're embedded in Vue, Svelte or Astro. + // Also, in those embedded scenarios, the existing $ sign is **not** replaced, so exclude it from the completion. + insertText = isEmbedded + ? asDollarlessVariable(label) + : context.word.endsWith(".") + ? `.${label}` + : label; filterText = `${context.namespace !== "*" ? context.namespace : ""}${insertText}`; + } else if (isEmbedded) { + insertText = asDollarlessVariable(label); } completions.push({ @@ -354,14 +363,20 @@ function createMixinCompletionItems( // Client needs the namespace as part of the text that is matched, // and inserted text needs to include the `.` which will otherwise - // be replaced. + // be replaced (except when we're embedded in Vue, Svelte or Astro). const label = context.namespace ? `${prefix}${mixin.name}` : mixin.name; const filterText = context.namespace ? context.namespace !== "*" ? `${context.namespace}.${prefix}${mixin.name}` : `${prefix}${mixin.name}` : mixin.name; - let insertText = context.namespace && context.namespace !== "*" ? `.${prefix}${mixin.name}` : mixin.name; + + const isEmbedded = context.originalExtension !== "scss"; + let insertText = context.namespace + ? context.namespace !== "*" && !isEmbedded + ? `.${prefix}${mixin.name}` + : `${prefix}${mixin.name}` + : mixin.name; const sortText = isPrivate ? label.replace(/^$[_-]/, '') : undefined; // Use the SnippetString syntax to provide smart completions of parameter names @@ -443,15 +458,16 @@ function createFunctionCompletionItems( // Client needs the namespace as part of the text that is matched, // and inserted text needs to include the `.` which will otherwise - // be replaced. + // be replaced (except when we're embedded in Vue, Svelte or Astro). const label = context.namespace ? `${prefix}${func.name}` : func.name; const filterText = context.namespace ? `${context.namespace !== "*" ? context.namespace : ""}.${prefix}${func.name}` : func.name; + const isEmbedded = context.originalExtension !== "scss"; let insertText = context.namespace - ? context.namespace !== "*" + ? context.namespace !== "*" && !isEmbedded ? `.${prefix}${func.name}` : `${prefix}${func.name}` : func.name; diff --git a/src/unsafe/test/e2e/suite/completion/completion.test.ts b/src/unsafe/test/e2e/suite/completion/completion.test.ts index a46f523a..c7fb581c 100644 --- a/src/unsafe/test/e2e/suite/completion/completion.test.ts +++ b/src/unsafe/test/e2e/suite/completion/completion.test.ts @@ -17,9 +17,11 @@ describe('SCSS Completion Test', () => { }); it('Offers completions from tilde imports', async () => { - const expectedCompletions = [{ label: '$tilde', detail: 'Variable declared in bar.scss' }]; - + let expectedCompletions = [{ label: '$tilde', detail: 'Variable declared in bar.scss', insertText: '"$tilde"' }]; await testCompletion(docUri, position(11, 11), expectedCompletions); + + // For Vue, Svelte and Astro, the existing $ is not replaced by VS Code, so omit it from insertText + expectedCompletions = [{ label: '$tilde', detail: 'Variable declared in bar.scss', insertText: '"tilde"' }]; await testCompletion(vueDocUri, position(22, 11), expectedCompletions); await testCompletion(svelteDocUri, position(14, 11), expectedCompletions); await testCompletion(astroDocUri, position(17, 11), expectedCompletions); @@ -36,21 +38,34 @@ describe('SCSS Completion Test', () => { it('Offers namespaces completions including prefixes', async () => { let expectedCompletions = [ - { label: '$var-var-variable', detail: 'Variable declared in _variables.scss' }, - { label: 'fun-fun-function', detail: 'Function declared in _functions.scss' } + { label: '$var-var-variable', detail: 'Variable declared in _variables.scss', insertText: '".$var-var-variable"' }, + { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":".fun-fun-function"}' } ]; await testCompletion(docUri, position(23, 13), expectedCompletions); + + // For Vue, Svelte and Astro, the existing . from the namespace and $ from the variable is not replaced by VS Code, so omit them from insertText. + expectedCompletions = [ + { label: '$var-var-variable', detail: 'Variable declared in _variables.scss', insertText: '"var-var-variable"' }, + { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":"fun-fun-function"}' } + ] + await testCompletion(vueDocUri, position(34, 13), expectedCompletions); await testCompletion(svelteDocUri, position(26, 13), expectedCompletions); await testCompletion(astroDocUri, position(29, 13), expectedCompletions); expectedCompletions = [ - { label: 'mix-mix-mixin', detail: 'Mixin declared in _mixins.scss' }, + { label: 'mix-mix-mixin', detail: 'Mixin declared in _mixins.scss', insertText: '{"_tabstop":1,"value":".mix-mix-mixin"}' }, ]; await testCompletion(docUri, position(24, 15), expectedCompletions); + + // Same as for functions with regards to the . from the namespace. + expectedCompletions = [ + { label: 'mix-mix-mixin', detail: 'Mixin declared in _mixins.scss', insertText: '{"_tabstop":1,"value":"mix-mix-mixin"}' }, + ]; + await testCompletion(vueDocUri, position(35, 15), expectedCompletions); await testCompletion(svelteDocUri, position(27, 15), expectedCompletions); await testCompletion(astroDocUri, position(30, 15), expectedCompletions); From 9f180d2fd78e30464b92bc4740762af30c3d4770 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 29 Jul 2022 19:59:45 +0200 Subject: [PATCH 03/13] chore: add editorconfig for fixtures as reminder The test files do not like being indented with tabs for some reason --- fixtures/e2e/.editorconfig | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 fixtures/e2e/.editorconfig diff --git a/fixtures/e2e/.editorconfig b/fixtures/e2e/.editorconfig new file mode 100644 index 00000000..f11458f9 --- /dev/null +++ b/fixtures/e2e/.editorconfig @@ -0,0 +1,5 @@ +# editorconfig.org + +[*] +indent_style = space +indent_size = 2 From 2300bfaf9291abbaf6ae39df4a559cb5c1af3578 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 29 Jul 2022 21:34:23 +0200 Subject: [PATCH 04/13] docs: add config suggestion for noisy Emmet --- README.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 423746e3..25b7bfe9 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ To use this feature, right-click a variable, mixin or function and choose **Improved code suggestions for variables under namespaces** -When providing code suggestions under namespaces (`@use "~namespace"`, then typing `namespace.$`) +When providing code suggestions under namespaces (`@use "namespace"`, then typing `namespace.$`) you may see the default word-based suggestions appear again. VS Code seems to think of `$` as a new fresh start for suggestions, so it will start matching any variable in the current document. @@ -91,8 +91,19 @@ and use the provided suggestion. This way you can keep word based suggestions if ```jsonc { - "editor.wordBasedSuggestions": false, - "somesass.suggestOnlyFromUse": true + // Recommended if you don't rely on @import + "somesass.suggestOnlyFromUse": true, + + // Optional, if you get suggestions from the current document after namespace.$ (you don't need the $ for narrowing down suggestions) + "editor.wordBasedSuggestions": false, + + // Add `scss` to the list of excluded languages for Emmet to avoid suggestions in Vue, Svelte or Astro files. + // VS Code understands that diff --git a/fixtures/e2e/completion/AppButton.svelte b/fixtures/e2e/completion/AppButton.svelte index 47d29d4f..f655b836 100644 --- a/fixtures/e2e/completion/AppButton.svelte +++ b/fixtures/e2e/completion/AppButton.svelte @@ -25,6 +25,7 @@ $fonts: -apple-system; .foo { color: ns. @include ns. + --runtime-var: var(--other-var, #{ns.}) } diff --git a/fixtures/e2e/completion/AppButton.vue b/fixtures/e2e/completion/AppButton.vue index e435fe7f..ad910b98 100644 --- a/fixtures/e2e/completion/AppButton.vue +++ b/fixtures/e2e/completion/AppButton.vue @@ -33,6 +33,7 @@ $fonts: -apple-system; .foo { color: ns. @include ns. + --runtime-var: var(--other-var, #{ns.}) } diff --git a/fixtures/e2e/completion/main.scss b/fixtures/e2e/completion/main.scss index 9c657f59..ca309f72 100644 --- a/fixtures/e2e/completion/main.scss +++ b/fixtures/e2e/completion/main.scss @@ -22,4 +22,5 @@ $fonts: -apple-system; .foo { color: ns. @include ns. + --runtime-var: var(--other-var, #{ns.}) } diff --git a/src/unsafe/providers/completion/completion-context.ts b/src/unsafe/providers/completion/completion-context.ts index 2006553c..16839c94 100644 --- a/src/unsafe/providers/completion/completion-context.ts +++ b/src/unsafe/providers/completion/completion-context.ts @@ -94,11 +94,12 @@ function isInterpolationContext(text: string): boolean { return text.includes('#{'); } -function checkNamespaceContext(currentWord: string): string | null { +function checkNamespaceContext(currentWord: string, isInterpolation: boolean): string | null { if (currentWord.length === 0 || !currentWord.includes(".")) { return null; } - return currentWord.substring(0, currentWord.indexOf(".")); + // Skip #{ if this is interpolation + return currentWord.substring(isInterpolation ? 2 : 0, currentWord.indexOf(".")); } export function createCompletionContext(document: TextDocument, text: string, offset: number, settings: ISettings): CompletionContext { @@ -116,7 +117,7 @@ export function createCompletionContext(document: TextDocument, text: string, of const isQuotes = reQuotes.test(textBeforeWord.replace(reQuotedValueInString, '')); // Is namespace, e.g. `namespace.$var` or `@include namespace.mixin` or `namespace.func()` - const namespace = checkNamespaceContext(currentWord) + const namespace = checkNamespaceContext(currentWord, isInterpolation) const lastDot = document.uri.lastIndexOf('.'); const originalExtension = document.uri.substring(lastDot + 1) as SupportedExtensions; diff --git a/src/unsafe/test/e2e/suite/completion/completion.test.ts b/src/unsafe/test/e2e/suite/completion/completion.test.ts index 9b07132f..bb0849a7 100644 --- a/src/unsafe/test/e2e/suite/completion/completion.test.ts +++ b/src/unsafe/test/e2e/suite/completion/completion.test.ts @@ -108,6 +108,26 @@ describe('SCSS Completion Test', () => { await testCompletion(svelteDocUri, position(8, 11), expectedCompletions); await testCompletion(astroDocUri, position(11, 11), expectedCompletions); }); + + it('Offers namespace completion inside string interpolation', async () => { + let expectedCompletions = [ + { label: '$var-var-variable', detail: 'Variable declared in _variables.scss', insertText: '".$var-var-variable"', filterText: '"ns.$var-var-variable"' }, + { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":".fun-fun-function"}' } + ]; + + await testCompletion(docUri, position(25, 40), expectedCompletions); + + // For Vue, Svelte and Astro, the existing . from the namespace is not replaced by VS Code, so omit them from insertText. + // However, we still need them both in the filter text. + expectedCompletions = [ + { label: '$var-var-variable', detail: 'Variable declared in _variables.scss', insertText: '"$var-var-variable"', filterText: '"ns.$var-var-variable"' }, + { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":"fun-fun-function"}' } + ] + + await testCompletion(vueDocUri, position(36, 40), expectedCompletions); + await testCompletion(svelteDocUri, position(28, 40), expectedCompletions); + await testCompletion(astroDocUri, position(31, 40), expectedCompletions); + }); }); describe('SassDoc Completion Test', () => { From fe6da8dc6e2d3be08e35b288add227ccebb9c514 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 29 Jul 2022 20:06:25 +0200 Subject: [PATCH 08/13] release: 2.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1ff84c8d..0c028e31 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "some-sass", "displayName": "Some Sass", "description": "Provides code suggestions, documentation and code navigation for modern SCSS", - "version": "2.4.0", + "version": "2.5.0", "publisher": "SomewhatStationery", "license": "MIT", "engines": { From 618c03112658b11c22758f9d14d601efdcc6b4f1 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 30 Jul 2022 08:54:06 +0200 Subject: [PATCH 09/13] chore: add delay on CI for Windows The delay was here originally, but wasn't needed on localhost. Add it back for Windows on CI to see if the tests become more stable. --- .../test/e2e/suite/completion/completion.test.ts | 3 ++- .../test/e2e/suite/definition/definitions.test.ts | 3 ++- src/unsafe/test/e2e/suite/hover/hover.test.ts | 3 ++- src/unsafe/test/e2e/suite/signature/signature.test.ts | 3 ++- src/unsafe/test/e2e/suite/util.ts | 10 +++++++++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/unsafe/test/e2e/suite/completion/completion.test.ts b/src/unsafe/test/e2e/suite/completion/completion.test.ts index bb0849a7..13af52eb 100644 --- a/src/unsafe/test/e2e/suite/completion/completion.test.ts +++ b/src/unsafe/test/e2e/suite/completion/completion.test.ts @@ -1,5 +1,5 @@ import { sassDocAnnotations } from '../../../../sassdocAnnotations'; -import { getDocUri, showFile, position } from '../util'; +import { getDocUri, showFile, position, sleepWindowsCI } from '../util'; import { testCompletion } from './helper'; @@ -14,6 +14,7 @@ describe('SCSS Completion Test', () => { await showFile(vueDocUri); await showFile(svelteDocUri); await showFile(astroDocUri); + await sleepWindowsCI(); }); it('Offers completions from tilde imports', async () => { diff --git a/src/unsafe/test/e2e/suite/definition/definitions.test.ts b/src/unsafe/test/e2e/suite/definition/definitions.test.ts index 12cd5d55..bae5bb91 100644 --- a/src/unsafe/test/e2e/suite/definition/definitions.test.ts +++ b/src/unsafe/test/e2e/suite/definition/definitions.test.ts @@ -1,4 +1,4 @@ -import { getDocUri, showFile, position, sameLineLocation } from '../util'; +import { getDocUri, showFile, position, sameLineLocation, sleepWindowsCI } from '../util'; import { testDefinition } from './helper'; describe('SCSS Definition Test', () => { @@ -12,6 +12,7 @@ describe('SCSS Definition Test', () => { await showFile(vueDocUri); await showFile(svelteDocUri); await showFile(astroDocUri); + await sleepWindowsCI(); }); it('should find definition for variables', async () => { diff --git a/src/unsafe/test/e2e/suite/hover/hover.test.ts b/src/unsafe/test/e2e/suite/hover/hover.test.ts index a1cba0cb..a712e72f 100644 --- a/src/unsafe/test/e2e/suite/hover/hover.test.ts +++ b/src/unsafe/test/e2e/suite/hover/hover.test.ts @@ -1,4 +1,4 @@ -import { getDocUri, showFile, position } from '../util'; +import { getDocUri, showFile, position, sleepWindowsCI } from '../util'; import { testHover } from './helper'; describe('SCSS Hover Test', () => { @@ -12,6 +12,7 @@ describe('SCSS Hover Test', () => { await showFile(vueDocUri); await showFile(svelteDocUri); await showFile(astroDocUri); + await sleepWindowsCI(); }); it('shows hover for variables', async () => { diff --git a/src/unsafe/test/e2e/suite/signature/signature.test.ts b/src/unsafe/test/e2e/suite/signature/signature.test.ts index 2382b61d..009b726b 100644 --- a/src/unsafe/test/e2e/suite/signature/signature.test.ts +++ b/src/unsafe/test/e2e/suite/signature/signature.test.ts @@ -1,4 +1,4 @@ -import { getDocUri, showFile, position } from '../util'; +import { getDocUri, showFile, position, sleepWindowsCI } from '../util'; import { testSignature } from './helper'; describe('SCSS Signature Help Test', () => { @@ -12,6 +12,7 @@ describe('SCSS Signature Help Test', () => { await showFile(vueDocUri); await showFile(svelteDocUri); await showFile(astroDocUri); + await sleepWindowsCI(); }); describe('Mixin', () => { diff --git a/src/unsafe/test/e2e/suite/util.ts b/src/unsafe/test/e2e/suite/util.ts index d937f51a..4e6394cf 100644 --- a/src/unsafe/test/e2e/suite/util.ts +++ b/src/unsafe/test/e2e/suite/util.ts @@ -25,7 +25,7 @@ export function getDocUri(p: string) { return vscode.Uri.file(getDocPath(p)); } -export function sleep(ms: number) { +export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -33,3 +33,11 @@ export async function showFile(docUri: vscode.Uri) { const doc = await vscode.workspace.openTextDocument(docUri); return await vscode.window.showTextDocument(doc); } + +// Try to work around some instabilities on CI for Windows runner +export async function sleepWindowsCI(ms = 3000): Promise { + if (process.env['RUNNER_OS'] === 'Windows') { + return await sleep(ms); + } + return Promise.resolve(); +} From 0dc89bd6a99f484a95920907fd793bf353c1466b Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 30 Jul 2022 09:30:59 +0200 Subject: [PATCH 10/13] chore: sleep for all the OSes on CI --- src/unsafe/test/e2e/suite/completion/completion.test.ts | 4 ++-- src/unsafe/test/e2e/suite/definition/definitions.test.ts | 4 ++-- src/unsafe/test/e2e/suite/hover/hover.test.ts | 4 ++-- src/unsafe/test/e2e/suite/signature/signature.test.ts | 4 ++-- src/unsafe/test/e2e/suite/util.ts | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/unsafe/test/e2e/suite/completion/completion.test.ts b/src/unsafe/test/e2e/suite/completion/completion.test.ts index 13af52eb..cc5c47e2 100644 --- a/src/unsafe/test/e2e/suite/completion/completion.test.ts +++ b/src/unsafe/test/e2e/suite/completion/completion.test.ts @@ -1,5 +1,5 @@ import { sassDocAnnotations } from '../../../../sassdocAnnotations'; -import { getDocUri, showFile, position, sleepWindowsCI } from '../util'; +import { getDocUri, showFile, position, sleepCI } from '../util'; import { testCompletion } from './helper'; @@ -14,7 +14,7 @@ describe('SCSS Completion Test', () => { await showFile(vueDocUri); await showFile(svelteDocUri); await showFile(astroDocUri); - await sleepWindowsCI(); + await sleepCI(); }); it('Offers completions from tilde imports', async () => { diff --git a/src/unsafe/test/e2e/suite/definition/definitions.test.ts b/src/unsafe/test/e2e/suite/definition/definitions.test.ts index bae5bb91..3dfa7f83 100644 --- a/src/unsafe/test/e2e/suite/definition/definitions.test.ts +++ b/src/unsafe/test/e2e/suite/definition/definitions.test.ts @@ -1,4 +1,4 @@ -import { getDocUri, showFile, position, sameLineLocation, sleepWindowsCI } from '../util'; +import { getDocUri, showFile, position, sameLineLocation, sleepCI } from '../util'; import { testDefinition } from './helper'; describe('SCSS Definition Test', () => { @@ -12,7 +12,7 @@ describe('SCSS Definition Test', () => { await showFile(vueDocUri); await showFile(svelteDocUri); await showFile(astroDocUri); - await sleepWindowsCI(); + await sleepCI(); }); it('should find definition for variables', async () => { diff --git a/src/unsafe/test/e2e/suite/hover/hover.test.ts b/src/unsafe/test/e2e/suite/hover/hover.test.ts index a712e72f..0d8bf509 100644 --- a/src/unsafe/test/e2e/suite/hover/hover.test.ts +++ b/src/unsafe/test/e2e/suite/hover/hover.test.ts @@ -1,4 +1,4 @@ -import { getDocUri, showFile, position, sleepWindowsCI } from '../util'; +import { getDocUri, showFile, position, sleepCI } from '../util'; import { testHover } from './helper'; describe('SCSS Hover Test', () => { @@ -12,7 +12,7 @@ describe('SCSS Hover Test', () => { await showFile(vueDocUri); await showFile(svelteDocUri); await showFile(astroDocUri); - await sleepWindowsCI(); + await sleepCI(); }); it('shows hover for variables', async () => { diff --git a/src/unsafe/test/e2e/suite/signature/signature.test.ts b/src/unsafe/test/e2e/suite/signature/signature.test.ts index 009b726b..5af54a5f 100644 --- a/src/unsafe/test/e2e/suite/signature/signature.test.ts +++ b/src/unsafe/test/e2e/suite/signature/signature.test.ts @@ -1,4 +1,4 @@ -import { getDocUri, showFile, position, sleepWindowsCI } from '../util'; +import { getDocUri, showFile, position, sleepCI } from '../util'; import { testSignature } from './helper'; describe('SCSS Signature Help Test', () => { @@ -12,7 +12,7 @@ describe('SCSS Signature Help Test', () => { await showFile(vueDocUri); await showFile(svelteDocUri); await showFile(astroDocUri); - await sleepWindowsCI(); + await sleepCI(); }); describe('Mixin', () => { diff --git a/src/unsafe/test/e2e/suite/util.ts b/src/unsafe/test/e2e/suite/util.ts index 4e6394cf..5dc4851d 100644 --- a/src/unsafe/test/e2e/suite/util.ts +++ b/src/unsafe/test/e2e/suite/util.ts @@ -34,9 +34,9 @@ export async function showFile(docUri: vscode.Uri) { return await vscode.window.showTextDocument(doc); } -// Try to work around some instabilities on CI for Windows runner -export async function sleepWindowsCI(ms = 3000): Promise { - if (process.env['RUNNER_OS'] === 'Windows') { +// Try to work around some instabilities on CI +export async function sleepCI(ms = 3000): Promise { + if (process.env['CI']) { return await sleep(ms); } return Promise.resolve(); From 642f0e4db3bd024e56cbf94e951a768852988977 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 30 Jul 2022 09:43:51 +0200 Subject: [PATCH 11/13] fix: default don't suggest optional parameters Include default value in label details, and keep suggestion with all variables as an option. --- .../providers/completion/completion-utils.ts | 23 ++ src/unsafe/providers/completion/completion.ts | 293 +----------------- .../completion/function-completion.ts | 112 +++++++ .../providers/completion/mixin-completion.ts | 145 +++++++++ .../completion/variable-completion.ts | 95 ++++++ .../e2e/suite/completion/completion.test.ts | 8 +- .../test/e2e/suite/completion/helper.ts | 14 +- src/unsafe/types/symbols.ts | 4 +- 8 files changed, 400 insertions(+), 294 deletions(-) create mode 100644 src/unsafe/providers/completion/completion-utils.ts create mode 100644 src/unsafe/providers/completion/function-completion.ts create mode 100644 src/unsafe/providers/completion/mixin-completion.ts create mode 100644 src/unsafe/providers/completion/variable-completion.ts diff --git a/src/unsafe/providers/completion/completion-utils.ts b/src/unsafe/providers/completion/completion-utils.ts new file mode 100644 index 00000000..068398e8 --- /dev/null +++ b/src/unsafe/providers/completion/completion-utils.ts @@ -0,0 +1,23 @@ +import type { ScssMixin, ScssParameter } from '../../types/symbols'; +import { asDollarlessVariable } from '../../utils/string'; + +export const rePrivate = /^\$?[_-].*$/; + +/** + * Return Mixin as string. + */ +export function makeMixinDocumentation(symbol: ScssMixin): string { + const args = symbol.parameters.map(item => `${item.name}: ${item.value}`).join(', '); + return `${symbol.name}(${args})`; +} + +/** + * Use the SnippetString syntax to provide smart completions of parameter names. + */ +export function mapParameterSnippet(p: ScssParameter, index: number): string { + return "${" + (index + 1) + ":" + asDollarlessVariable(p.name) + "}"; +} + +export function mapParameterSignature(p: ScssParameter): string { + return p.value ? `${p.name}: ${p.value}` : p.name; +} diff --git a/src/unsafe/providers/completion/completion.ts b/src/unsafe/providers/completion/completion.ts index 401d2540..efa994ec 100644 --- a/src/unsafe/providers/completion/completion.ts +++ b/src/unsafe/providers/completion/completion.ts @@ -1,19 +1,20 @@ 'use strict'; -import { CompletionList, CompletionItemKind, CompletionItem, MarkupKind, InsertTextFormat, CompletionItemTag, CompletionItemLabelDetails } from 'vscode-languageserver'; +import { CompletionList, CompletionItem, MarkupKind, InsertTextFormat } from 'vscode-languageserver'; import type { TextDocument } from 'vscode-languageserver-textdocument'; import type { ISettings } from '../../types/settings'; import type StorageService from '../../services/storage'; -import type { IScssDocument, ScssForward, ScssImport, ScssMixin, ScssUse } from '../../types/symbols'; +import type { IScssDocument, ScssForward, ScssImport, ScssUse } from '../../types/symbols'; import { SassBuiltInModule, sassBuiltInModules } from '../../sassBuiltInModules'; import { createCompletionContext, CompletionContext } from './completion-context'; -import { getLimitedString, asDollarlessVariable } from '../../utils/string'; -import { getVariableColor } from '../../utils/color'; -import { applySassDoc } from '../../utils/sassdoc'; +import { asDollarlessVariable } from '../../utils/string'; import { doSassDocCompletion } from './sassdoc-completion'; import { doImportCompletion } from './import-completion'; +import { createFunctionCompletionItems } from './function-completion'; +import { createMixinCompletionItems } from './mixin-completion'; +import { createVariableCompletionItems } from './variable-completion'; export function doCompletion( document: TextDocument, @@ -235,285 +236,3 @@ function traverseTree(document: TextDocument, settings: ISettings, context: Comp traverseTree(document, settings, context, storage, accumulator, childDocument, hidden, prefix); } } - -const rePrivate = /^\$?[_-].*$/; - -function createVariableCompletionItems( - scssDocument: IScssDocument, - currentDocument: TextDocument, - context: CompletionContext, - hiddenSymbols: string[] = [], - prefix = '' -): CompletionItem[] { - const completions: CompletionItem[] = []; - - for (let variable of scssDocument.variables.values()) { - const color = variable.value ? getVariableColor(variable.value) : null; - const completionKind = color ? CompletionItemKind.Color : CompletionItemKind.Variable; - - let documentation = getLimitedString(color ? color.toString() : variable.value || ''); - let detail = `Variable declared in ${scssDocument.fileName}`; - - let label = variable.name; - let sortText = undefined; - let filterText = undefined; - let insertText = undefined; - - if (variable.mixin) { - // Add 'argument from MIXIN_NAME' suffix if Variable is Mixin argument - detail = `Argument from ${variable.mixin}, ${detail.toLowerCase()}`; - } else { - const isPrivate = variable.name.match(rePrivate); - const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; - - if (isPrivate && !isFromCurrentDocument) { - continue; - } - - if (hiddenSymbols.includes(variable.name)) { - continue; - } - - if (isPrivate) { - sortText = label.replace(/^$[_-]/, ''); - } - - const sassdoc = applySassDoc( - variable, - { displayOptions: { description: true, deprecated: true, type: true } } - ); - if (sassdoc) { - documentation += `\n\n${sassdoc}`; - } - } - - const isEmbedded = context.originalExtension !== "scss"; - if (context.namespace) { - // Avoid ending up with namespace.prefix-$variable - label = `$${prefix}${asDollarlessVariable(variable.name)}`; - // The `.` in the namespace gets replaced unless we have a $ character after it. - // Except when we're embedded in Vue, Svelte or Astro, where the . is not replace. - // Also, in embedded scenarios where we don't use a namespace, the existing $ sign is not replaced. - insertText = context.word.endsWith(".") - ? `${isEmbedded ? '' : '.'}${label}` - : isEmbedded - ? asDollarlessVariable(label) - : label; - filterText = context.word.endsWith(".") - ? `${context.namespace}.${label}` - : label; - } else if (isEmbedded) { - insertText = asDollarlessVariable(label); - } - - completions.push({ - label, - filterText, - insertText, - sortText, - commitCharacters: [';', ','], - kind: completionKind, - detail, - tags: Boolean(variable.sassdoc?.deprecated) ? [CompletionItemTag.Deprecated] : [], - documentation: { - kind: MarkupKind.Markdown, - value: documentation, - }, - }); - } - - return completions; -} - -/** - * Return Mixin as string. - */ -function makeMixinDocumentation(symbol: ScssMixin): string { - const args = symbol.parameters.map(item => `${item.name}: ${item.value}`).join(', '); - return `${symbol.name}(${args})`; -} - -function createMixinCompletionItems( - scssDocument: IScssDocument, - currentDocument: TextDocument, - context: CompletionContext, - hiddenSymbols: string[] = [], - prefix = '' -): CompletionItem[] { - const completions: CompletionItem[] = []; - - for (let mixin of scssDocument.mixins.values()) { - const isPrivate = mixin.name.match(rePrivate); - const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; - if (isPrivate && !isFromCurrentDocument) { - // Don't suggest private mixins from other files - continue; - } - - if (hiddenSymbols.includes(mixin.name)) { - continue; - } - - let documentation = makeMixinDocumentation(mixin); - const sassdoc = applySassDoc( - mixin, - { displayOptions: { content: true, description: true, deprecated: true, output: true } } - ); - if (sassdoc) { - documentation += `\n____\n${sassdoc}`; - } - - // Client needs the namespace as part of the text that is matched, - // and inserted text needs to include the `.` which will otherwise - // be replaced (except when we're embedded in Vue, Svelte or Astro). - const label = context.namespace ? `${prefix}${mixin.name}` : mixin.name; - const filterText = context.namespace - ? context.namespace !== "*" - ? `${context.namespace}.${prefix}${mixin.name}` - : `${prefix}${mixin.name}` - : mixin.name; - - const isEmbedded = context.originalExtension !== "scss"; - let insertText = context.namespace - ? context.namespace !== "*" && !isEmbedded - ? `.${prefix}${mixin.name}` - : `${prefix}${mixin.name}` - : mixin.name; - const sortText = isPrivate ? label.replace(/^$[_-]/, '') : undefined; - - // Use the SnippetString syntax to provide smart completions of parameter names - let labelDetails: CompletionItemLabelDetails | undefined = undefined; - if (mixin.parameters.length > 0) { - const parametersSnippet = mixin.parameters.map((p, index) => "${" + (index + 1) + ":" + asDollarlessVariable(p.name) + "}").join(", ") - const parameterSignature = mixin.parameters.map(p => p.name).join(", "); - insertText += `(${parametersSnippet})`; - labelDetails = { - detail: `(${parameterSignature})`, - }; - } - - const detail = `Mixin declared in ${scssDocument.fileName}`; - - completions.push({ - label, - labelDetails, - filterText, - sortText, - kind: CompletionItemKind.Snippet, - detail, - insertTextFormat: InsertTextFormat.Snippet, - insertText, - tags: Boolean(mixin.sassdoc?.deprecated) ? [CompletionItemTag.Deprecated] : [], - documentation: { - kind: MarkupKind.Markdown, - value: documentation, - } - }); - - // Not all mixins have @content, but when they do, be smart about adding brackets - // and move the cursor to be ready to add said contents. - // Include as separate suggestion since content may not always be needed or wanted. - if (mixin.sassdoc?.content) { - const details = { ...labelDetails }; - details.detail = details.detail ? details.detail + ' { }' : ' { }'; - completions.push({ - label, - labelDetails: details, - filterText, - sortText, - kind: CompletionItemKind.Snippet, - detail, - insertTextFormat: InsertTextFormat.Snippet, - insertText: insertText += " {\n\t$0\n}", - tags: Boolean(mixin.sassdoc?.deprecated) ? [CompletionItemTag.Deprecated] : [], - documentation: { - kind: MarkupKind.Markdown, - value: documentation, - } - }); - } - } - - return completions; -} - -function createFunctionCompletionItems( - scssDocument: IScssDocument, - currentDocument: TextDocument, - context: CompletionContext, - hiddenSymbols: string[] = [], - prefix = '' -): CompletionItem[] { - const completions: CompletionItem[] = []; - - for (let func of scssDocument.functions.values()) { - const isPrivate = func.name.match(rePrivate); - const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; - if (isPrivate && !isFromCurrentDocument) { - // Don't suggest private functions from other files - continue; - } - - if (hiddenSymbols.includes(func.name)) { - continue; - } - - // Client needs the namespace as part of the text that is matched, - // and inserted text needs to include the `.` which will otherwise - // be replaced (except when we're embedded in Vue, Svelte or Astro). - const label = context.namespace ? `${prefix}${func.name}` : func.name; - const filterText = context.namespace - ? `${context.namespace !== "*" - ? context.namespace - : ""}.${prefix}${func.name}` - : func.name; - const isEmbedded = context.originalExtension !== "scss"; - let insertText = context.namespace - ? context.namespace !== "*" && !isEmbedded - ? `.${prefix}${func.name}` - : `${prefix}${func.name}` - : func.name; - const sortText = isPrivate ? label.replace(/^$[_-]/, '') : undefined; - - // Use the SnippetString syntax to provide smart completions of parameter names - let labelDetails: CompletionItemLabelDetails | undefined = undefined; - if (func.parameters.length > 0) { - const parametersSnippet = func.parameters.map((p, index) => "${" + (index + 1) + ":" + asDollarlessVariable(p.name) + "}").join(", ") - const functionSignature = func.parameters.map(p => p.name).join(", "); - insertText += `(${parametersSnippet})`; - labelDetails = { - detail: `(${functionSignature})`, - }; - } - - - let documentation = makeMixinDocumentation(func); - const sassdoc = applySassDoc( - func, - { displayOptions: { description: true, deprecated: true, return: true } } - ); - if (sassdoc) { - documentation += `\n____\n${sassdoc}`; - } - - const detail = `Function declared in ${scssDocument.fileName}`; - - completions.push({ - label, - labelDetails, - filterText, - sortText, - kind: CompletionItemKind.Function, - detail, - insertTextFormat: InsertTextFormat.Snippet, - insertText, - tags: Boolean(func.sassdoc?.deprecated) ? [CompletionItemTag.Deprecated] : [], - documentation: { - kind: MarkupKind.Markdown, - value: documentation, - } - }); - }; - - return completions; -} diff --git a/src/unsafe/providers/completion/function-completion.ts b/src/unsafe/providers/completion/function-completion.ts new file mode 100644 index 00000000..10247621 --- /dev/null +++ b/src/unsafe/providers/completion/function-completion.ts @@ -0,0 +1,112 @@ +import { CompletionItemKind, CompletionItem, MarkupKind, InsertTextFormat, CompletionItemTag, CompletionItemLabelDetails } from 'vscode-languageserver'; +import { applySassDoc } from '../../utils/sassdoc'; + +import type { TextDocument } from 'vscode-languageserver-textdocument'; +import type { IScssDocument, ScssFunction } from '../../types/symbols'; +import type { CompletionContext } from './completion-context'; +import { makeMixinDocumentation, mapParameterSignature, mapParameterSnippet, rePrivate } from './completion-utils'; + +export function createFunctionCompletionItems( + scssDocument: IScssDocument, + currentDocument: TextDocument, + context: CompletionContext, + hiddenSymbols: string[] = [], + prefix = '' +): CompletionItem[] { + const completions: CompletionItem[] = []; + + for (let func of scssDocument.functions.values()) { + const isPrivate = func.name.match(rePrivate); + const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; + if (isPrivate && !isFromCurrentDocument) { + // Don't suggest private functions from other files + continue; + } + + if (hiddenSymbols.includes(func.name)) { + continue; + } + + // Client needs the namespace as part of the text that is matched, + // and inserted text needs to include the `.` which will otherwise + // be replaced (except when we're embedded in Vue, Svelte or Astro). + const label = context.namespace ? `${prefix}${func.name}` : func.name; + const filterText = context.namespace + ? `${context.namespace !== "*" + ? context.namespace + : ""}.${prefix}${func.name}` + : func.name; + const isEmbedded = context.originalExtension !== "scss"; + let insertText = context.namespace + ? context.namespace !== "*" && !isEmbedded + ? `.${prefix}${func.name}` + : `${prefix}${func.name}` + : func.name; + const sortText = isPrivate ? label.replace(/^$[_-]/, '') : undefined; + + + let documentation = makeMixinDocumentation(func); + const sassdoc = applySassDoc( + func, + { displayOptions: { description: true, deprecated: true, return: true } } + ); + if (sassdoc) { + documentation += `\n____\n${sassdoc}`; + } + + const detail = `Function declared in ${scssDocument.fileName}`; + + const requiredParameters = func.parameters.filter((p) => !p.value); + const parametersSnippet = requiredParameters.map(mapParameterSnippet).join(", "); + const functionSignature = requiredParameters.map(mapParameterSignature).join(", "); + completions.push(makeFunctionCompletion( + label, + { + detail: `(${functionSignature})`, + }, + filterText, + sortText, + detail, + insertText + `(${parametersSnippet})`, + func, + documentation + )); + + if (requiredParameters.length !== func.parameters.length) { + const parametersSnippet = func.parameters.map(mapParameterSnippet).join(", "); + const functionSignature = func.parameters.map(mapParameterSignature).join(", "); + completions.push(makeFunctionCompletion( + label, + { + detail: `(${functionSignature})`, + }, + filterText, + sortText, + detail, + insertText + `(${parametersSnippet})`, + func, + documentation + )); + } + }; + + return completions; +} + +function makeFunctionCompletion(label: string, labelDetails: CompletionItemLabelDetails | undefined, filterText: string, sortText: string | undefined, detail: string, insertText: string, func: ScssFunction, documentation: string): CompletionItem { + return { + label, + labelDetails, + filterText, + sortText, + kind: CompletionItemKind.Function, + detail, + insertTextFormat: InsertTextFormat.Snippet, + insertText, + tags: Boolean(func.sassdoc?.deprecated) ? [CompletionItemTag.Deprecated] : [], + documentation: { + kind: MarkupKind.Markdown, + value: documentation, + } + }; +} diff --git a/src/unsafe/providers/completion/mixin-completion.ts b/src/unsafe/providers/completion/mixin-completion.ts new file mode 100644 index 00000000..da3e5478 --- /dev/null +++ b/src/unsafe/providers/completion/mixin-completion.ts @@ -0,0 +1,145 @@ +import { CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionItemTag, InsertTextFormat, MarkupKind } from 'vscode-languageserver'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; +import type { IScssDocument, ScssMixin } from '../../types/symbols'; +import { applySassDoc } from '../../utils/sassdoc'; +import type { CompletionContext } from './completion-context'; +import { makeMixinDocumentation, mapParameterSignature, mapParameterSnippet, rePrivate } from './completion-utils'; + +export function createMixinCompletionItems( + scssDocument: IScssDocument, + currentDocument: TextDocument, + context: CompletionContext, + hiddenSymbols: string[] = [], + prefix = '' +): CompletionItem[] { + const completions: CompletionItem[] = []; + + for (let mixin of scssDocument.mixins.values()) { + const isPrivate = mixin.name.match(rePrivate); + const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; + if (isPrivate && !isFromCurrentDocument) { + // Don't suggest private mixins from other files + continue; + } + + if (hiddenSymbols.includes(mixin.name)) { + continue; + } + + let documentation = makeMixinDocumentation(mixin); + const sassdoc = applySassDoc( + mixin, + { displayOptions: { content: true, description: true, deprecated: true, output: true } } + ); + if (sassdoc) { + documentation += `\n____\n${sassdoc}`; + } + + // Client needs the namespace as part of the text that is matched, + // and inserted text needs to include the `.` which will otherwise + // be replaced (except when we're embedded in Vue, Svelte or Astro). + const label = context.namespace ? `${prefix}${mixin.name}` : mixin.name; + const filterText = context.namespace + ? context.namespace !== "*" + ? `${context.namespace}.${prefix}${mixin.name}` + : `${prefix}${mixin.name}` + : mixin.name; + + const isEmbedded = context.originalExtension !== "scss"; + let insertText = context.namespace + ? context.namespace !== "*" && !isEmbedded + ? `.${prefix}${mixin.name}` + : `${prefix}${mixin.name}` + : mixin.name; + const sortText = isPrivate ? label.replace(/^$[_-]/, '') : undefined; + const detail = `Mixin declared in ${scssDocument.fileName}`; + + // Use the SnippetString syntax to provide smart completions of parameter names + let labelDetails: CompletionItemLabelDetails | undefined = undefined; + + const requiredParameters = mixin.parameters.filter((p) => !p.value); + if (requiredParameters.length === 0) { + makeMixinCompletion(completions, label, labelDetails, filterText, sortText, detail, insertText, mixin, documentation); + } + + if (requiredParameters.length) { + const parametersSnippet = requiredParameters.map(mapParameterSnippet).join(", "); + const functionSignature = requiredParameters.map(mapParameterSignature).join(", "); + makeMixinCompletion( + completions, + label, + { + detail: `(${functionSignature})`, + }, + filterText, + sortText, + detail, + insertText + `(${parametersSnippet})`, + mixin, + documentation + ); + } + + if (mixin.parameters.length !== requiredParameters.length) { + const parametersSnippet = mixin.parameters.map(mapParameterSnippet).join(", "); + const functionSignature = mixin.parameters.map(mapParameterSignature).join(", "); + makeMixinCompletion( + completions, + label, + { + detail: `(${functionSignature})`, + }, + filterText, + sortText, + detail, + insertText + `(${parametersSnippet})`, + mixin, + documentation + ); + } + } + + return completions; +} + +function makeMixinCompletion(completions: CompletionItem[], label: string, labelDetails: CompletionItemLabelDetails | undefined, filterText: string, sortText: string | undefined, detail: string, insertText: string, mixin: ScssMixin, documentation: string): void { + completions.push({ + label, + labelDetails, + filterText, + sortText, + kind: CompletionItemKind.Snippet, + detail, + insertTextFormat: InsertTextFormat.Snippet, + insertText, + tags: Boolean(mixin.sassdoc?.deprecated) ? [CompletionItemTag.Deprecated] : [], + documentation: { + kind: MarkupKind.Markdown, + value: documentation, + } + }); + + // Not all mixins have @content, but when they do, be smart about adding brackets + // and move the cursor to be ready to add said contents. + // Include as separate suggestion since content may not always be needed or wanted. + if (mixin.sassdoc?.content) { + const details = { ...labelDetails }; + details.detail = details.detail ? details.detail + ' { }' : ' { }'; + completions.push({ + label, + labelDetails: details, + filterText, + sortText, + kind: CompletionItemKind.Snippet, + detail, + insertTextFormat: InsertTextFormat.Snippet, + insertText: insertText += " {\n\t$0\n}", + tags: Boolean(mixin.sassdoc?.deprecated) ? [CompletionItemTag.Deprecated] : [], + documentation: { + kind: MarkupKind.Markdown, + value: documentation, + } + }); + } +} + diff --git a/src/unsafe/providers/completion/variable-completion.ts b/src/unsafe/providers/completion/variable-completion.ts new file mode 100644 index 00000000..28135f11 --- /dev/null +++ b/src/unsafe/providers/completion/variable-completion.ts @@ -0,0 +1,95 @@ +import type { TextDocument } from 'vscode-languageserver-textdocument'; +import { CompletionItem, CompletionItemKind, CompletionItemTag, MarkupKind } from 'vscode-languageserver-types'; +import type { IScssDocument } from '../../types/symbols'; +import { getVariableColor } from '../../utils/color'; +import { applySassDoc } from '../../utils/sassdoc'; +import { asDollarlessVariable, getLimitedString } from '../../utils/string'; +import type { CompletionContext } from './completion-context'; +import { rePrivate } from './completion-utils'; + +export function createVariableCompletionItems( + scssDocument: IScssDocument, + currentDocument: TextDocument, + context: CompletionContext, + hiddenSymbols: string[] = [], + prefix = '' +): CompletionItem[] { + const completions: CompletionItem[] = []; + + for (let variable of scssDocument.variables.values()) { + const color = variable.value ? getVariableColor(variable.value) : null; + const completionKind = color ? CompletionItemKind.Color : CompletionItemKind.Variable; + + let documentation = getLimitedString(color ? color.toString() : variable.value || ''); + let detail = `Variable declared in ${scssDocument.fileName}`; + + let label = variable.name; + let sortText = undefined; + let filterText = undefined; + let insertText = undefined; + + if (variable.mixin) { + // Add 'argument from MIXIN_NAME' suffix if Variable is Mixin argument + detail = `Argument from ${variable.mixin}, ${detail.toLowerCase()}`; + } else { + const isPrivate = variable.name.match(rePrivate); + const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; + + if (isPrivate && !isFromCurrentDocument) { + continue; + } + + if (hiddenSymbols.includes(variable.name)) { + continue; + } + + if (isPrivate) { + sortText = label.replace(/^$[_-]/, ''); + } + + const sassdoc = applySassDoc( + variable, + { displayOptions: { description: true, deprecated: true, type: true } } + ); + if (sassdoc) { + documentation += `\n\n${sassdoc}`; + } + } + + const isEmbedded = context.originalExtension !== "scss"; + if (context.namespace) { + // Avoid ending up with namespace.prefix-$variable + label = `$${prefix}${asDollarlessVariable(variable.name)}`; + // The `.` in the namespace gets replaced unless we have a $ character after it. + // Except when we're embedded in Vue, Svelte or Astro, where the . is not replace. + // Also, in embedded scenarios where we don't use a namespace, the existing $ sign is not replaced. + insertText = context.word.endsWith(".") + ? `${isEmbedded ? '' : '.'}${label}` + : isEmbedded + ? asDollarlessVariable(label) + : label; + filterText = context.word.endsWith(".") + ? `${context.namespace}.${label}` + : label; + } else if (isEmbedded) { + insertText = asDollarlessVariable(label); + } + + completions.push({ + label, + filterText, + insertText, + sortText, + commitCharacters: [';', ','], + kind: completionKind, + detail, + tags: Boolean(variable.sassdoc?.deprecated) ? [CompletionItemTag.Deprecated] : [], + documentation: { + kind: MarkupKind.Markdown, + value: documentation, + }, + }); + } + + return completions; +} diff --git a/src/unsafe/test/e2e/suite/completion/completion.test.ts b/src/unsafe/test/e2e/suite/completion/completion.test.ts index cc5c47e2..771e164f 100644 --- a/src/unsafe/test/e2e/suite/completion/completion.test.ts +++ b/src/unsafe/test/e2e/suite/completion/completion.test.ts @@ -40,7 +40,7 @@ describe('SCSS Completion Test', () => { it('Offers namespaces completions including prefixes', async () => { let expectedCompletions = [ { label: '$var-var-variable', detail: 'Variable declared in _variables.scss', insertText: '".$var-var-variable"', filterText: '"ns.$var-var-variable"' }, - { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":".fun-fun-function"}' } + { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":".fun-fun-function()"}' } ]; await testCompletion(docUri, position(23, 13), expectedCompletions); @@ -49,7 +49,7 @@ describe('SCSS Completion Test', () => { // However, we still need them both in the filter text. expectedCompletions = [ { label: '$var-var-variable', detail: 'Variable declared in _variables.scss', insertText: '"$var-var-variable"', filterText: '"ns.$var-var-variable"' }, - { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":"fun-fun-function"}' } + { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":"fun-fun-function()"}' } ] await testCompletion(vueDocUri, position(34, 13), expectedCompletions); @@ -113,7 +113,7 @@ describe('SCSS Completion Test', () => { it('Offers namespace completion inside string interpolation', async () => { let expectedCompletions = [ { label: '$var-var-variable', detail: 'Variable declared in _variables.scss', insertText: '".$var-var-variable"', filterText: '"ns.$var-var-variable"' }, - { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":".fun-fun-function"}' } + { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":".fun-fun-function()"}' } ]; await testCompletion(docUri, position(25, 40), expectedCompletions); @@ -122,7 +122,7 @@ describe('SCSS Completion Test', () => { // However, we still need them both in the filter text. expectedCompletions = [ { label: '$var-var-variable', detail: 'Variable declared in _variables.scss', insertText: '"$var-var-variable"', filterText: '"ns.$var-var-variable"' }, - { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":"fun-fun-function"}' } + { label: 'fun-fun-function', detail: 'Function declared in _functions.scss', insertText: '{"_tabstop":1,"value":"fun-fun-function()"}' } ] await testCompletion(vueDocUri, position(36, 40), expectedCompletions); diff --git a/src/unsafe/test/e2e/suite/completion/helper.ts b/src/unsafe/test/e2e/suite/completion/helper.ts index 40bab29e..b286a812 100644 --- a/src/unsafe/test/e2e/suite/completion/helper.ts +++ b/src/unsafe/test/e2e/suite/completion/helper.ts @@ -44,7 +44,12 @@ export async function testCompletion( ); } } else { - const match = result.items.find(i => i.label === ei.label); + const match = result.items.find(i => { + if (typeof i.label === 'string') { + return i.label === ei.label; + } + return i.label.label === ei.label; + }); if (!match) { if (options.expectNoMatch) { assert.ok(`Found no match for ${ei.label}`); @@ -54,7 +59,12 @@ export async function testCompletion( return; } - assert.strictEqual(match.label, ei.label); + if (typeof match.label === 'string') { + assert.strictEqual(match.label, ei.label); + } else { + assert.strictEqual(match.label.label, ei.label); + } + if (ei.kind) { assert.strictEqual(match.kind, ei.kind); } diff --git a/src/unsafe/types/symbols.ts b/src/unsafe/types/symbols.ts index 116f8258..1164a619 100644 --- a/src/unsafe/types/symbols.ts +++ b/src/unsafe/types/symbols.ts @@ -18,8 +18,10 @@ export interface ScssVariable extends ScssSymbol { value: string | null; } +export type ScssParameter = Omit; + export interface ScssMixin extends ScssSymbol { - parameters: Omit[]; + parameters: ScssParameter[]; } export interface ScssFunction extends ScssMixin { } From 3930042d82d8ded0dc95c1dcb20d20b056deda8e Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 30 Jul 2022 11:01:09 +0200 Subject: [PATCH 12/13] feat: provide choices from docstring literals --- .../providers/completion/completion-utils.ts | 37 +++++++++++++++++++ src/unsafe/services/parser.ts | 14 +++++-- src/unsafe/test/providers/completion.spec.ts | 17 +++++++++ src/unsafe/types/symbols.ts | 6 ++- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/unsafe/providers/completion/completion-utils.ts b/src/unsafe/providers/completion/completion-utils.ts index 068398e8..bb46cd98 100644 --- a/src/unsafe/providers/completion/completion-utils.ts +++ b/src/unsafe/providers/completion/completion-utils.ts @@ -15,9 +15,46 @@ export function makeMixinDocumentation(symbol: ScssMixin): string { * Use the SnippetString syntax to provide smart completions of parameter names. */ export function mapParameterSnippet(p: ScssParameter, index: number): string { + if (p.sassdoc?.type?.length) { + const choices = parseStringLiteralChoices(p.sassdoc.type) + if (choices.length) { + return "${" + (index + 1) + "|" + choices.join(",") + "|}" + } + } + return "${" + (index + 1) + ":" + asDollarlessVariable(p.name) + "}"; } export function mapParameterSignature(p: ScssParameter): string { return p.value ? `${p.name}: ${p.value}` : p.name; } + +const reStringLiteral = /^["'].+["']$/; // yes, this will match 'foo", but let the parser deal with yelling about that. + +/** + * @param docstring A TypeScript-like string of accepted string literal values, for example `"standard" | "entrance" | "exit"`. + */ +export function parseStringLiteralChoices(docstring: string | string[]): string[] { + const docstrings = typeof docstring === "string" ? [docstring] : docstring; + const result: string[] = []; + + for (const doc of docstrings) { + const parts = doc.split("|"); + if (parts.length === 1) { + // This may be a docstring to indicate only a single valid string literal option. + const trimmed = doc.trim(); + if (reStringLiteral.test(trimmed)) { + result.push(trimmed); + } + } else { + for (const part of parts) { + const trimmed = part.trim(); + if (reStringLiteral.test(trimmed)) { + result.push(trimmed); + } + } + } + } + + return result; +} diff --git a/src/unsafe/services/parser.ts b/src/unsafe/services/parser.ts index af728c13..d56b7002 100644 --- a/src/unsafe/services/parser.ts +++ b/src/unsafe/services/parser.ts @@ -13,6 +13,7 @@ import { ScssDocument } from '../document'; import { parseString, ParseResult } from 'scss-sassdoc-parser'; import { fileExists } from '../utils/fs'; import { sassBuiltInModuleNames } from '../sassBuiltInModules'; +import { asDollarlessVariable } from '../utils/string'; export const reModuleAtRule = /@(?:use|forward|import)/; export const reUse = /@use ["|'](?.+)["|'](?: as (?\*|\w+))?;/; @@ -161,7 +162,7 @@ async function findDocumentSymbols(document: TextDocument, ast: INode, workspace kind: SymbolKind.Method, offset, position, - parameters: getMethodParameters(ast, offset), + parameters: getMethodParameters(ast, offset, docs), sassdoc: docs, }); } else if (symbol.kind === SymbolKind.Function) { @@ -171,7 +172,7 @@ async function findDocumentSymbols(document: TextDocument, ast: INode, workspace kind: SymbolKind.Function, offset, position, - parameters: getMethodParameters(ast, offset), + parameters: getMethodParameters(ast, offset, docs), sassdoc: docs, }); } @@ -250,7 +251,7 @@ function getVariableValue(ast: INode, offset: number): string | null { return parent?.getValue()?.getText() || null; } -function getMethodParameters(ast: INode, offset: number) { +function getMethodParameters(ast: INode, offset: number, sassDoc: ParseResult | undefined) { const node = getNodeAtOffset(ast, offset); if (node === null) { @@ -264,12 +265,17 @@ function getMethodParameters(ast: INode, offset: number) { const defaultValueNode = child.getDefaultValue(); const value = defaultValueNode === undefined ? null : defaultValueNode.getText(); + const name = child.getName(); + + const dollarlessName = asDollarlessVariable(name); + const docs = sassDoc ? sassDoc.parameter?.find(p => p.name === dollarlessName) : undefined; return { - name: child.getName(), + name, offset: child.offset, value, kind: SymbolKind.Variable, + sassdoc: docs, }; }); } diff --git a/src/unsafe/test/providers/completion.spec.ts b/src/unsafe/test/providers/completion.spec.ts index 7316da13..97ca3bfe 100644 --- a/src/unsafe/test/providers/completion.spec.ts +++ b/src/unsafe/test/providers/completion.spec.ts @@ -11,6 +11,7 @@ import * as helpers from '../helpers'; import type { ISettings } from '../../types/settings'; import { ScssDocument } from '../../document'; import { sassBuiltInModules } from '../../sassBuiltInModules'; +import { parseStringLiteralChoices } from '../../providers/completion/completion-utils'; const storage = new StorageService(); @@ -148,3 +149,19 @@ describe('Providers/Completion - Built-in', () => { ); }); }); + +describe('Providers/Completion - Utils', () => { + it('parseStringLiteralChoices returns an array of string literals from a docstring', () => { + let result = parseStringLiteralChoices('"foo"'); + assert.strictEqual(result.join(', '), '"foo"'); + + result = parseStringLiteralChoices('"foo" | "bar"'); + assert.strictEqual(result.join(', '), '"foo", "bar"'); + + result = parseStringLiteralChoices('String | Number'); + assert.strictEqual(result.join(', '), ''); + + result = parseStringLiteralChoices('"String" | "Number"'); + assert.strictEqual(result.join(', '), '"String", "Number"'); + }); +}); diff --git a/src/unsafe/types/symbols.ts b/src/unsafe/types/symbols.ts index 1164a619..dce10e90 100644 --- a/src/unsafe/types/symbols.ts +++ b/src/unsafe/types/symbols.ts @@ -1,6 +1,6 @@ 'use strict'; -import type { ParseResult } from 'scss-sassdoc-parser'; +import type { Parameter, ParseResult } from 'scss-sassdoc-parser'; import type { Position, TextDocument } from 'vscode-languageserver-textdocument'; import type { DocumentLink, Range, SymbolKind } from 'vscode-languageserver-types'; import type { INode } from './nodes'; @@ -18,7 +18,9 @@ export interface ScssVariable extends ScssSymbol { value: string | null; } -export type ScssParameter = Omit; +export interface ScssParameter extends Omit { + sassdoc?: Parameter; +} export interface ScssMixin extends ScssSymbol { parameters: ScssParameter[]; From 2b6cf24173aa66fee7ffe39f6314f113c2ec3712 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 30 Jul 2022 11:11:10 +0200 Subject: [PATCH 13/13] chore: debug macos runner on CI --- src/unsafe/test/e2e/suite/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unsafe/test/e2e/suite/util.ts b/src/unsafe/test/e2e/suite/util.ts index 5dc4851d..ddd6caa2 100644 --- a/src/unsafe/test/e2e/suite/util.ts +++ b/src/unsafe/test/e2e/suite/util.ts @@ -39,5 +39,5 @@ export async function sleepCI(ms = 3000): Promise { if (process.env['CI']) { return await sleep(ms); } - return Promise.resolve(); + return await sleep(0); }