Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Style attribute hover/completion #924

Merged
merged 8 commits into from
Apr 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
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