Skip to content

Commit 22a32ef

Browse files
authored
(feat) html style attribute hover/completion (#924)
#381
1 parent c1b60de commit 22a32ef

File tree

6 files changed

+223
-19
lines changed

6 files changed

+223
-19
lines changed

packages/language-server/src/lib/documents/parseHtml.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ function preprocess(text: string) {
9090
export interface AttributeContext {
9191
name: string;
9292
inValue: boolean;
93+
valueRange?: [number, number];
9394
}
9495

9596
export function getAttributeContextAtPosition(
@@ -118,6 +119,7 @@ export function getAttributeContextAtPosition(
118119
// adopted from https://github.com/microsoft/vscode-html-languageservice/blob/2f7ae4df298ac2c299a40e9024d118f4a9dc0c68/src/services/htmlCompletion.ts#L402
119120
if (token === TokenType.AttributeName) {
120121
currentAttributeName = scanner.getTokenText();
122+
121123
if (inTokenRange()) {
122124
return {
123125
name: currentAttributeName,
@@ -126,16 +128,32 @@ export function getAttributeContextAtPosition(
126128
}
127129
} else if (token === TokenType.DelimiterAssign) {
128130
if (scanner.getTokenEnd() === offset && currentAttributeName) {
131+
const nextToken = scanner.scan();
132+
129133
return {
130134
name: currentAttributeName,
131-
inValue: true
135+
inValue: true,
136+
valueRange: [
137+
offset,
138+
nextToken === TokenType.AttributeValue ? scanner.getTokenEnd() : offset
139+
]
132140
};
133141
}
134142
} else if (token === TokenType.AttributeValue) {
135143
if (inTokenRange() && currentAttributeName) {
144+
let start = scanner.getTokenOffset();
145+
let end = scanner.getTokenEnd();
146+
const char = text[start];
147+
148+
if (char === '"' || char === "'") {
149+
start++;
150+
end--;
151+
}
152+
136153
return {
137154
name: currentAttributeName,
138-
inValue: true
155+
inValue: true,
156+
valueRange: [start, end]
139157
};
140158
}
141159
currentAttributeName = undefined;

packages/language-server/src/plugins/css/CSSDocument.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { Stylesheet } from 'vscode-css-languageservice';
1+
import { Stylesheet, TextDocument } from 'vscode-css-languageservice';
22
import { Position } from 'vscode-languageserver';
33
import { getLanguageService } from './service';
44
import { Document, DocumentMapper, ReadableDocument, TagInformation } from '../../lib/documents';
55

6+
export interface CSSDocumentBase extends DocumentMapper, TextDocument {
7+
languageId: string;
8+
stylesheet: Stylesheet;
9+
}
10+
611
export class CSSDocument extends ReadableDocument implements DocumentMapper {
712
private styleInfo: Pick<TagInformation, 'attributes' | 'start' | 'end'>;
813
readonly version = this.parent.version;

packages/language-server/src/plugins/css/CSSPlugin.ts

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,12 @@ import {
3737
HoverProvider,
3838
SelectionRangeProvider
3939
} from '../interfaces';
40-
import { CSSDocument } from './CSSDocument';
40+
import { CSSDocument, CSSDocumentBase } from './CSSDocument';
4141
import { getLanguage, getLanguageService } from './service';
4242
import { GlobalVars } from './global-vars';
4343
import { getIdClassCompletion } from './features/getIdClassCompletion';
44-
import { getAttributeContextAtPosition } from '../../lib/documents/parseHtml';
44+
import { AttributeContext, getAttributeContextAtPosition } from '../../lib/documents/parseHtml';
45+
import { StyleAttributeDocument } from './StyleAttributeDocument';
4546

4647
export class CSSPlugin
4748
implements
@@ -113,10 +114,24 @@ export class CSSPlugin
113114
}
114115

115116
const cssDocument = this.getCSSDoc(document);
116-
if (!cssDocument.isInGenerated(position) || shouldExcludeHover(cssDocument)) {
117+
if (shouldExcludeHover(cssDocument)) {
117118
return null;
118119
}
120+
if (cssDocument.isInGenerated(position)) {
121+
return this.doHoverInternal(cssDocument, position);
122+
}
123+
const attributeContext = getAttributeContextAtPosition(document, position);
124+
if (
125+
attributeContext &&
126+
this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText())
127+
) {
128+
const [start, end] = attributeContext.valueRange;
129+
return this.doHoverInternal(new StyleAttributeDocument(document, start, end), position);
130+
}
119131

132+
return null;
133+
}
134+
private doHoverInternal(cssDocument: CSSDocumentBase, position: Position) {
120135
const hoverInfo = getLanguageService(extractLanguage(cssDocument)).doHover(
121136
cssDocument,
122137
cssDocument.getGeneratedPosition(position),
@@ -147,14 +162,44 @@ export class CSSPlugin
147162
}
148163

149164
const cssDocument = this.getCSSDoc(document);
150-
if (!cssDocument.isInGenerated(position)) {
151-
const attributeContext = getAttributeContextAtPosition(document, position);
152-
if (!attributeContext) {
153-
return null;
154-
}
155-
return getIdClassCompletion(cssDocument, attributeContext) ?? null;
165+
166+
if (cssDocument.isInGenerated(position)) {
167+
return this.getCompletionsInternal(document, position, cssDocument);
168+
}
169+
170+
const attributeContext = getAttributeContextAtPosition(document, position);
171+
if (!attributeContext) {
172+
return null;
156173
}
157174

175+
if (this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText())) {
176+
const [start, end] = attributeContext.valueRange;
177+
return this.getCompletionsInternal(
178+
document,
179+
position,
180+
new StyleAttributeDocument(document, start, end)
181+
);
182+
} else {
183+
return getIdClassCompletion(cssDocument, attributeContext);
184+
}
185+
}
186+
187+
private inStyleAttributeWithoutInterpolation(
188+
attrContext: AttributeContext,
189+
text: string
190+
): attrContext is Required<AttributeContext> {
191+
return (
192+
attrContext.name === 'style' &&
193+
!!attrContext.valueRange &&
194+
!text.substring(attrContext.valueRange[0], attrContext.valueRange[1]).includes('{')
195+
);
196+
}
197+
198+
private getCompletionsInternal(
199+
document: Document,
200+
position: Position,
201+
cssDocument: CSSDocumentBase
202+
) {
158203
if (isSASS(cssDocument)) {
159204
// the css language service does not support sass, still we can use
160205
// the emmet helper directly to at least get emmet completions
@@ -354,7 +399,7 @@ function shouldExcludeColor(document: CSSDocument) {
354399
}
355400
}
356401

357-
function isSASS(document: CSSDocument) {
402+
function isSASS(document: CSSDocumentBase) {
358403
switch (extractLanguage(document)) {
359404
case 'sass':
360405
return true;
@@ -363,8 +408,7 @@ function isSASS(document: CSSDocument) {
363408
}
364409
}
365410

366-
function extractLanguage(document: CSSDocument): string {
367-
const attrs = document.getAttributes();
368-
const lang = attrs.lang || attrs.type || '';
411+
function extractLanguage(document: CSSDocumentBase): string {
412+
const lang = document.languageId;
369413
return lang.replace(/^text\//, '');
370414
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Stylesheet } from 'vscode-css-languageservice';
2+
import { Position } from 'vscode-languageserver';
3+
import { getLanguageService } from './service';
4+
import { Document, DocumentMapper, ReadableDocument } from '../../lib/documents';
5+
6+
const PREFIX = '__ {';
7+
const SUFFIX = '}';
8+
9+
export class StyleAttributeDocument extends ReadableDocument implements DocumentMapper {
10+
readonly version = this.parent.version;
11+
12+
public stylesheet: Stylesheet;
13+
public languageId = 'css';
14+
15+
constructor(
16+
private readonly parent: Document,
17+
private readonly attrStart: number,
18+
private readonly attrEnd: number
19+
) {
20+
super();
21+
22+
this.stylesheet = getLanguageService(this.languageId).parseStylesheet(this);
23+
}
24+
25+
/**
26+
* Get the fragment position relative to the parent
27+
* @param pos Position in fragment
28+
*/
29+
getOriginalPosition(pos: Position): Position {
30+
const parentOffset = this.attrStart + this.offsetAt(pos) - PREFIX.length;
31+
return this.parent.positionAt(parentOffset);
32+
}
33+
34+
/**
35+
* Get the position relative to the start of the fragment
36+
* @param pos Position in parent
37+
*/
38+
getGeneratedPosition(pos: Position): Position {
39+
const fragmentOffset = this.parent.offsetAt(pos) - this.attrStart + PREFIX.length;
40+
return this.positionAt(fragmentOffset);
41+
}
42+
43+
/**
44+
* Returns true if the given parent position is inside of this fragment
45+
* @param pos Position in parent
46+
*/
47+
isInGenerated(pos: Position): boolean {
48+
const offset = this.parent.offsetAt(pos);
49+
return offset >= this.attrStart && offset <= this.attrEnd;
50+
}
51+
52+
/**
53+
* Get the fragment text from the parent
54+
*/
55+
getText(): string {
56+
return PREFIX + this.parent.getText().slice(this.attrStart, this.attrEnd) + SUFFIX;
57+
}
58+
59+
/**
60+
* Returns the length of the fragment as calculated from the start and end position
61+
*/
62+
getTextLength(): number {
63+
return PREFIX.length + this.attrEnd - this.attrStart + SUFFIX.length;
64+
}
65+
66+
/**
67+
* Return the parent file path
68+
*/
69+
getFilePath(): string | null {
70+
return this.parent.getFilePath();
71+
}
72+
73+
getURL() {
74+
return this.parent.getURL();
75+
}
76+
}

packages/language-server/src/plugins/css/features/getIdClassCompletion.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { CSSDocument } from '../CSSDocument';
55
export function getIdClassCompletion(
66
cssDoc: CSSDocument,
77
attributeContext: AttributeContext
8-
): CompletionList | undefined {
8+
): CompletionList | null {
99
const collectingType = getCollectingType(attributeContext);
1010

1111
if (!collectingType) {
12-
return;
12+
return null;
1313
}
1414
const items = collectSelectors(cssDoc.stylesheet as CSSNode, collectingType);
1515

packages/language-server/test/plugins/css/CSSPlugin.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
CompletionItemKind,
88
TextEdit,
99
CompletionContext,
10-
SelectionRange
10+
SelectionRange,
11+
CompletionTriggerKind
1112
} from 'vscode-languageserver';
1213
import { DocumentManager, Document } from '../../../src/lib/documents';
1314
import { CSSPlugin } from '../../../src/plugins';
@@ -47,6 +48,27 @@ describe('CSS Plugin', () => {
4748
const { plugin, document } = setup('<style lang="stylus">h1 {}</style>');
4849
assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 22)), null);
4950
});
51+
52+
it('for style attribute', () => {
53+
const { plugin, document } = setup('<div style="height: auto;"></div>');
54+
assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 13)), <Hover>{
55+
contents: {
56+
kind: 'markdown',
57+
value:
58+
'Specifies the height of the content area,' +
59+
" padding area or border area \\(depending on 'box\\-sizing'\\)" +
60+
' of certain boxes\\.\n' +
61+
'\nSyntax: &lt;viewport\\-length&gt;\\{1,2\\}\n\n' +
62+
'[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/height)'
63+
},
64+
range: Range.create(0, 12, 0, 24)
65+
});
66+
});
67+
68+
it('not for style attribute with interpolation', () => {
69+
const { plugin, document } = setup('<div style="height: {}"></div>');
70+
assert.deepStrictEqual(plugin.doHover(document, Position.create(0, 13)), null);
71+
});
5072
});
5173

5274
describe('provides completions', () => {
@@ -92,6 +114,45 @@ describe('CSS Plugin', () => {
92114
} as CompletionContext);
93115
assert.deepStrictEqual(completions, null);
94116
});
117+
118+
it('for style attribute', () => {
119+
const { plugin, document } = setup('<div style="display: n"></div>');
120+
const completions = plugin.getCompletions(document, Position.create(0, 22), {
121+
triggerKind: CompletionTriggerKind.Invoked
122+
} as CompletionContext);
123+
assert.deepStrictEqual(
124+
completions?.items.find((item) => item.label === 'none'),
125+
<CompletionItem>{
126+
insertTextFormat: undefined,
127+
kind: 12,
128+
label: 'none',
129+
documentation: {
130+
kind: 'markdown',
131+
value: 'The element and its descendants generates no boxes\\.'
132+
},
133+
sortText: ' ',
134+
tags: [],
135+
textEdit: {
136+
newText: 'none',
137+
range: {
138+
start: {
139+
line: 0,
140+
character: 21
141+
},
142+
end: {
143+
line: 0,
144+
character: 22
145+
}
146+
}
147+
}
148+
}
149+
);
150+
});
151+
152+
it('not for style attribute with interpolation', () => {
153+
const { plugin, document } = setup('<div style="height: {}"></div>');
154+
assert.deepStrictEqual(plugin.getCompletions(document, Position.create(0, 21)), null);
155+
});
95156
});
96157

97158
describe('provides diagnostics', () => {

0 commit comments

Comments
 (0)