Skip to content

Commit

Permalink
(feat) html style attribute hover/completion (sveltejs#924)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonlyu123 authored Apr 2, 2021
1 parent c1b60de commit 22a32ef
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 19 deletions.
22 changes: 20 additions & 2 deletions packages/language-server/src/lib/documents/parseHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function preprocess(text: string) {
export interface AttributeContext {
name: string;
inValue: boolean;
valueRange?: [number, number];
}

export function getAttributeContextAtPosition(
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion packages/language-server/src/plugins/css/CSSDocument.ts
Original file line number Diff line number Diff line change
@@ -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<TagInformation, 'attributes' | 'start' | 'end'>;
readonly version = this.parent.version;
Expand Down
70 changes: 57 additions & 13 deletions packages/language-server/src/plugins/css/CSSPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<AttributeContext> {
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
Expand Down Expand Up @@ -354,7 +399,7 @@ function shouldExcludeColor(document: CSSDocument) {
}
}

function isSASS(document: CSSDocument) {
function isSASS(document: CSSDocumentBase) {
switch (extractLanguage(document)) {
case 'sass':
return true;
Expand All @@ -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\//, '');
}
76 changes: 76 additions & 0 deletions packages/language-server/src/plugins/css/StyleAttributeDocument.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
63 changes: 62 additions & 1 deletion packages/language-server/test/plugins/css/CSSPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +48,27 @@ describe('CSS Plugin', () => {
const { plugin, document } = setup('<style lang="stylus">h1 {}</style>');
assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 22)), null);
});

it('for style attribute', () => {
const { plugin, document } = setup('<div style="height: auto;"></div>');
assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 13)), <Hover>{
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: &lt;viewport\\-length&gt;\\{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('<div style="height: {}"></div>');
assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 13)), null);
});
});

describe('provides completions', () => {
Expand Down Expand Up @@ -92,6 +114,45 @@ describe('CSS Plugin', () => {
} as CompletionContext);
assert.deepStrictEqual(completions, null);
});

it('for style attribute', () => {
const { plugin, document } = setup('<div style="display: n"></div>');
const completions = plugin.getCompletions(document, Position.create(0, 22), {
triggerKind: CompletionTriggerKind.Invoked
} as CompletionContext);
assert.deepStrictEqual(
completions?.items.find((item) => item.label === 'none'),
<CompletionItem>{
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('<div style="height: {}"></div>');
assert.deepStrictEqual(plugin.getCompletions(document, Position.create(0, 21)), null);
});
});

describe('provides diagnostics', () => {
Expand Down

0 comments on commit 22a32ef

Please sign in to comment.