From 22a32ef569ecc26b0931c162f18caf2d63d46fa8 Mon Sep 17 00:00:00 2001 From: "Lyu, Wei-Da" <36730922+jasonlyu123@users.noreply.github.com> Date: Sat, 3 Apr 2021 05:12:26 +0800 Subject: [PATCH] (feat) html style attribute hover/completion (#924) #381 --- .../src/lib/documents/parseHtml.ts | 22 +++++- .../src/plugins/css/CSSDocument.ts | 7 +- .../src/plugins/css/CSSPlugin.ts | 70 +++++++++++++---- .../src/plugins/css/StyleAttributeDocument.ts | 76 +++++++++++++++++++ .../css/features/getIdClassCompletion.ts | 4 +- .../test/plugins/css/CSSPlugin.test.ts | 63 ++++++++++++++- 6 files changed, 223 insertions(+), 19 deletions(-) create mode 100644 packages/language-server/src/plugins/css/StyleAttributeDocument.ts diff --git a/packages/language-server/src/lib/documents/parseHtml.ts b/packages/language-server/src/lib/documents/parseHtml.ts index 6441b8fc0..7ee6b45c1 100644 --- a/packages/language-server/src/lib/documents/parseHtml.ts +++ b/packages/language-server/src/lib/documents/parseHtml.ts @@ -90,6 +90,7 @@ function preprocess(text: string) { export interface AttributeContext { name: string; inValue: boolean; + valueRange?: [number, number]; } export function getAttributeContextAtPosition( @@ -118,6 +119,7 @@ export function getAttributeContextAtPosition( // adopted from https://github.com/microsoft/vscode-html-languageservice/blob/2f7ae4df298ac2c299a40e9024d118f4a9dc0c68/src/services/htmlCompletion.ts#L402 if (token === TokenType.AttributeName) { currentAttributeName = scanner.getTokenText(); + if (inTokenRange()) { return { name: currentAttributeName, @@ -126,16 +128,32 @@ export function getAttributeContextAtPosition( } } else if (token === TokenType.DelimiterAssign) { if (scanner.getTokenEnd() === offset && currentAttributeName) { + const nextToken = scanner.scan(); + return { name: currentAttributeName, - inValue: true + inValue: true, + valueRange: [ + offset, + nextToken === TokenType.AttributeValue ? scanner.getTokenEnd() : offset + ] }; } } else if (token === TokenType.AttributeValue) { if (inTokenRange() && currentAttributeName) { + let start = scanner.getTokenOffset(); + let end = scanner.getTokenEnd(); + const char = text[start]; + + if (char === '"' || char === "'") { + start++; + end--; + } + return { name: currentAttributeName, - inValue: true + inValue: true, + valueRange: [start, end] }; } currentAttributeName = undefined; diff --git a/packages/language-server/src/plugins/css/CSSDocument.ts b/packages/language-server/src/plugins/css/CSSDocument.ts index 2302704b2..0d221ac29 100644 --- a/packages/language-server/src/plugins/css/CSSDocument.ts +++ b/packages/language-server/src/plugins/css/CSSDocument.ts @@ -1,8 +1,13 @@ -import { Stylesheet } from 'vscode-css-languageservice'; +import { Stylesheet, TextDocument } from 'vscode-css-languageservice'; import { Position } from 'vscode-languageserver'; import { getLanguageService } from './service'; import { Document, DocumentMapper, ReadableDocument, TagInformation } from '../../lib/documents'; +export interface CSSDocumentBase extends DocumentMapper, TextDocument { + languageId: string; + stylesheet: Stylesheet; +} + export class CSSDocument extends ReadableDocument implements DocumentMapper { private styleInfo: Pick; readonly version = this.parent.version; diff --git a/packages/language-server/src/plugins/css/CSSPlugin.ts b/packages/language-server/src/plugins/css/CSSPlugin.ts index b1e3709a8..e594dace3 100644 --- a/packages/language-server/src/plugins/css/CSSPlugin.ts +++ b/packages/language-server/src/plugins/css/CSSPlugin.ts @@ -37,11 +37,12 @@ import { HoverProvider, SelectionRangeProvider } from '../interfaces'; -import { CSSDocument } from './CSSDocument'; +import { CSSDocument, CSSDocumentBase } from './CSSDocument'; import { getLanguage, getLanguageService } from './service'; import { GlobalVars } from './global-vars'; import { getIdClassCompletion } from './features/getIdClassCompletion'; -import { getAttributeContextAtPosition } from '../../lib/documents/parseHtml'; +import { AttributeContext, getAttributeContextAtPosition } from '../../lib/documents/parseHtml'; +import { StyleAttributeDocument } from './StyleAttributeDocument'; export class CSSPlugin implements @@ -113,10 +114,24 @@ export class CSSPlugin } const cssDocument = this.getCSSDoc(document); - if (!cssDocument.isInGenerated(position) || shouldExcludeHover(cssDocument)) { + if (shouldExcludeHover(cssDocument)) { return null; } + if (cssDocument.isInGenerated(position)) { + return this.doHoverInternal(cssDocument, position); + } + const attributeContext = getAttributeContextAtPosition(document, position); + if ( + attributeContext && + this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText()) + ) { + const [start, end] = attributeContext.valueRange; + return this.doHoverInternal(new StyleAttributeDocument(document, start, end), position); + } + return null; + } + private doHoverInternal(cssDocument: CSSDocumentBase, position: Position) { const hoverInfo = getLanguageService(extractLanguage(cssDocument)).doHover( cssDocument, cssDocument.getGeneratedPosition(position), @@ -147,14 +162,44 @@ export class CSSPlugin } const cssDocument = this.getCSSDoc(document); - if (!cssDocument.isInGenerated(position)) { - const attributeContext = getAttributeContextAtPosition(document, position); - if (!attributeContext) { - return null; - } - return getIdClassCompletion(cssDocument, attributeContext) ?? null; + + if (cssDocument.isInGenerated(position)) { + return this.getCompletionsInternal(document, position, cssDocument); + } + + const attributeContext = getAttributeContextAtPosition(document, position); + if (!attributeContext) { + return null; } + if (this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText())) { + const [start, end] = attributeContext.valueRange; + return this.getCompletionsInternal( + document, + position, + new StyleAttributeDocument(document, start, end) + ); + } else { + return getIdClassCompletion(cssDocument, attributeContext); + } + } + + private inStyleAttributeWithoutInterpolation( + attrContext: AttributeContext, + text: string + ): attrContext is Required { + return ( + attrContext.name === 'style' && + !!attrContext.valueRange && + !text.substring(attrContext.valueRange[0], attrContext.valueRange[1]).includes('{') + ); + } + + private getCompletionsInternal( + document: Document, + position: Position, + cssDocument: CSSDocumentBase + ) { if (isSASS(cssDocument)) { // the css language service does not support sass, still we can use // the emmet helper directly to at least get emmet completions @@ -354,7 +399,7 @@ function shouldExcludeColor(document: CSSDocument) { } } -function isSASS(document: CSSDocument) { +function isSASS(document: CSSDocumentBase) { switch (extractLanguage(document)) { case 'sass': return true; @@ -363,8 +408,7 @@ function isSASS(document: CSSDocument) { } } -function extractLanguage(document: CSSDocument): string { - const attrs = document.getAttributes(); - const lang = attrs.lang || attrs.type || ''; +function extractLanguage(document: CSSDocumentBase): string { + const lang = document.languageId; return lang.replace(/^text\//, ''); } diff --git a/packages/language-server/src/plugins/css/StyleAttributeDocument.ts b/packages/language-server/src/plugins/css/StyleAttributeDocument.ts new file mode 100644 index 000000000..6bec75b8e --- /dev/null +++ b/packages/language-server/src/plugins/css/StyleAttributeDocument.ts @@ -0,0 +1,76 @@ +import { Stylesheet } from 'vscode-css-languageservice'; +import { Position } from 'vscode-languageserver'; +import { getLanguageService } from './service'; +import { Document, DocumentMapper, ReadableDocument } from '../../lib/documents'; + +const PREFIX = '__ {'; +const SUFFIX = '}'; + +export class StyleAttributeDocument extends ReadableDocument implements DocumentMapper { + readonly version = this.parent.version; + + public stylesheet: Stylesheet; + public languageId = 'css'; + + constructor( + private readonly parent: Document, + private readonly attrStart: number, + private readonly attrEnd: number + ) { + super(); + + this.stylesheet = getLanguageService(this.languageId).parseStylesheet(this); + } + + /** + * Get the fragment position relative to the parent + * @param pos Position in fragment + */ + getOriginalPosition(pos: Position): Position { + const parentOffset = this.attrStart + this.offsetAt(pos) - PREFIX.length; + return this.parent.positionAt(parentOffset); + } + + /** + * Get the position relative to the start of the fragment + * @param pos Position in parent + */ + getGeneratedPosition(pos: Position): Position { + const fragmentOffset = this.parent.offsetAt(pos) - this.attrStart + PREFIX.length; + return this.positionAt(fragmentOffset); + } + + /** + * Returns true if the given parent position is inside of this fragment + * @param pos Position in parent + */ + isInGenerated(pos: Position): boolean { + const offset = this.parent.offsetAt(pos); + return offset >= this.attrStart && offset <= this.attrEnd; + } + + /** + * Get the fragment text from the parent + */ + getText(): string { + return PREFIX + this.parent.getText().slice(this.attrStart, this.attrEnd) + SUFFIX; + } + + /** + * Returns the length of the fragment as calculated from the start and end position + */ + getTextLength(): number { + return PREFIX.length + this.attrEnd - this.attrStart + SUFFIX.length; + } + + /** + * Return the parent file path + */ + getFilePath(): string | null { + return this.parent.getFilePath(); + } + + getURL() { + return this.parent.getURL(); + } +} diff --git a/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts b/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts index c10877e67..022fd3bf7 100644 --- a/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts +++ b/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts @@ -5,11 +5,11 @@ import { CSSDocument } from '../CSSDocument'; export function getIdClassCompletion( cssDoc: CSSDocument, attributeContext: AttributeContext -): CompletionList | undefined { +): CompletionList | null { const collectingType = getCollectingType(attributeContext); if (!collectingType) { - return; + return null; } const items = collectSelectors(cssDoc.stylesheet as CSSNode, collectingType); diff --git a/packages/language-server/test/plugins/css/CSSPlugin.test.ts b/packages/language-server/test/plugins/css/CSSPlugin.test.ts index 59a67a9ed..c29cc1695 100644 --- a/packages/language-server/test/plugins/css/CSSPlugin.test.ts +++ b/packages/language-server/test/plugins/css/CSSPlugin.test.ts @@ -7,7 +7,8 @@ import { CompletionItemKind, TextEdit, CompletionContext, - SelectionRange + SelectionRange, + CompletionTriggerKind } from 'vscode-languageserver'; import { DocumentManager, Document } from '../../../src/lib/documents'; import { CSSPlugin } from '../../../src/plugins'; @@ -47,6 +48,27 @@ describe('CSS Plugin', () => { const { plugin, document } = setup(''); assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 22)), null); }); + + it('for style attribute', () => { + const { plugin, document } = setup('
'); + assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 13)), { + contents: { + kind: 'markdown', + value: + 'Specifies the height of the content area,' + + " padding area or border area \\(depending on 'box\\-sizing'\\)" + + ' of certain boxes\\.\n' + + '\nSyntax: <viewport\\-length>\\{1,2\\}\n\n' + + '[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/height)' + }, + range: Range.create(0, 12, 0, 24) + }); + }); + + it('not for style attribute with interpolation', () => { + const { plugin, document } = setup('
'); + assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 13)), null); + }); }); describe('provides completions', () => { @@ -92,6 +114,45 @@ describe('CSS Plugin', () => { } as CompletionContext); assert.deepStrictEqual(completions, null); }); + + it('for style attribute', () => { + const { plugin, document } = setup('
'); + const completions = plugin.getCompletions(document, Position.create(0, 22), { + triggerKind: CompletionTriggerKind.Invoked + } as CompletionContext); + assert.deepStrictEqual( + completions?.items.find((item) => item.label === 'none'), + { + insertTextFormat: undefined, + kind: 12, + label: 'none', + documentation: { + kind: 'markdown', + value: 'The element and its descendants generates no boxes\\.' + }, + sortText: ' ', + tags: [], + textEdit: { + newText: 'none', + range: { + start: { + line: 0, + character: 21 + }, + end: { + line: 0, + character: 22 + } + } + } + } + ); + }); + + it('not for style attribute with interpolation', () => { + const { plugin, document } = setup('
'); + assert.deepStrictEqual(plugin.getCompletions(document, Position.create(0, 21)), null); + }); }); describe('provides diagnostics', () => {