Skip to content

Commit f7534ae

Browse files
Fix hovers and conflicts in Vue <style lang="sass"> blocks (#930)
* Refactor * Handle Sass in Vue style blocks * Update changelog
1 parent e191eb5 commit f7534ae

File tree

7 files changed

+210
-11
lines changed

7 files changed

+210
-11
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
{
2+
"code": "<style lang=\"sass\">\n.foo\n @apply uppercase lowercase\n</style>",
3+
"language": "vue",
4+
"expected": [
5+
{
6+
"code": "cssConflict",
7+
"className": {
8+
"className": "uppercase",
9+
"classList": {
10+
"classList": "uppercase lowercase",
11+
"range": {
12+
"start": { "line": 2, "character": 9 },
13+
"end": { "line": 2, "character": 28 }
14+
},
15+
"important": false
16+
},
17+
"relativeRange": {
18+
"start": { "line": 0, "character": 0 },
19+
"end": { "line": 0, "character": 9 }
20+
},
21+
"range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } }
22+
},
23+
"otherClassNames": [
24+
{
25+
"className": "lowercase",
26+
"classList": {
27+
"classList": "uppercase lowercase",
28+
"range": {
29+
"start": { "line": 2, "character": 9 },
30+
"end": { "line": 2, "character": 28 }
31+
},
32+
"important": false
33+
},
34+
"relativeRange": {
35+
"start": { "line": 0, "character": 10 },
36+
"end": { "line": 0, "character": 19 }
37+
},
38+
"range": {
39+
"start": { "line": 2, "character": 19 },
40+
"end": { "line": 2, "character": 28 }
41+
}
42+
}
43+
],
44+
"range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } },
45+
"severity": 2,
46+
"message": "'uppercase' applies the same CSS properties as 'lowercase'.",
47+
"relatedInformation": [
48+
{
49+
"message": "lowercase",
50+
"location": {
51+
"uri": "{{URI}}",
52+
"range": {
53+
"start": { "line": 2, "character": 19 },
54+
"end": { "line": 2, "character": 28 }
55+
}
56+
}
57+
}
58+
]
59+
},
60+
{
61+
"code": "cssConflict",
62+
"className": {
63+
"className": "lowercase",
64+
"classList": {
65+
"classList": "uppercase lowercase",
66+
"range": {
67+
"start": { "line": 2, "character": 9 },
68+
"end": { "line": 2, "character": 28 }
69+
},
70+
"important": false
71+
},
72+
"relativeRange": {
73+
"start": { "line": 0, "character": 10 },
74+
"end": { "line": 0, "character": 19 }
75+
},
76+
"range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } }
77+
},
78+
"otherClassNames": [
79+
{
80+
"className": "uppercase",
81+
"classList": {
82+
"classList": "uppercase lowercase",
83+
"range": {
84+
"start": { "line": 2, "character": 9 },
85+
"end": { "line": 2, "character": 28 }
86+
},
87+
"important": false
88+
},
89+
"relativeRange": {
90+
"start": { "line": 0, "character": 0 },
91+
"end": { "line": 0, "character": 9 }
92+
},
93+
"range": {
94+
"start": { "line": 2, "character": 9 },
95+
"end": { "line": 2, "character": 18 }
96+
}
97+
}
98+
],
99+
"range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } },
100+
"severity": 2,
101+
"message": "'lowercase' applies the same CSS properties as 'uppercase'.",
102+
"relatedInformation": [
103+
{
104+
"message": "uppercase",
105+
"location": {
106+
"uri": "{{URI}}",
107+
"range": {
108+
"start": { "line": 2, "character": 9 },
109+
"end": { "line": 2, "character": 18 }
110+
}
111+
}
112+
}
113+
]
114+
}
115+
]
116+
}

packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ withFixture('basic', (c) => {
3232
testFixture('css-conflict/css')
3333
testFixture('css-conflict/css-multi-rule')
3434
testFixture('css-conflict/css-multi-prop')
35+
testFixture('css-conflict/vue-style-lang-sass')
3536
testFixture('invalid-screen/simple')
3637
testFixture('invalid-theme/simple')
3738
})
@@ -63,6 +64,7 @@ withFixture('v4/basic', (c) => {
6364
testFixture('css-conflict/variants-positive')
6465
testFixture('css-conflict/jsx-concat-negative')
6566
testFixture('css-conflict/jsx-concat-positive')
67+
testFixture('css-conflict/vue-style-lang-sass')
6668
// testFixture('css-conflict/css')
6769
// testFixture('css-conflict/css-multi-rule')
6870
// testFixture('css-conflict/css-multi-prop')

packages/tailwindcss-language-server/tests/hover/hover.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ withFixture('basic', (c) => {
7575
end: { line: 0, character: 31 },
7676
},
7777
})
78+
79+
testHover('vue <style lang=sass>', {
80+
lang: 'vue',
81+
text: `<style lang="sass">
82+
.foo
83+
@apply underline
84+
</style>`,
85+
position: { line: 2, character: 13 },
86+
expected: '.underline {\n' + ' text-decoration-line: underline;\n' + '}',
87+
expectedRange: {
88+
start: { line: 2, character: 9 },
89+
end: { line: 2, character: 18 },
90+
},
91+
})
7892
})
7993

8094
withFixture('v4/basic', (c) => {
@@ -148,4 +162,18 @@ withFixture('v4/basic', (c) => {
148162
end: { line: 0, character: 31 },
149163
},
150164
})
165+
166+
testHover('vue <style lang=sass>', {
167+
lang: 'vue',
168+
text: `<style lang="sass">
169+
.foo
170+
@apply underline
171+
</style>`,
172+
position: { line: 2, character: 13 },
173+
expected: '.underline {\n' + ' text-decoration-line: underline;\n' + '}',
174+
expectedRange: {
175+
start: { line: 2, character: 9 },
176+
end: { line: 2, character: 18 },
177+
},
178+
})
151179
})

packages/tailwindcss-language-service/src/util/css.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,21 @@ import type { State } from './state'
66
import { cssLanguages } from './languages'
77
import { getLanguageBoundaries } from './getLanguageBoundaries'
88

9-
export function isCssDoc(state: State, doc: TextDocument): boolean {
10-
const userCssLanguages = Object.keys(state.editor.userLanguages).filter((lang) =>
11-
cssLanguages.includes(state.editor.userLanguages[lang]),
12-
)
9+
function getCssLanguages(state: State) {
10+
const userCssLanguages = Object
11+
.keys(state.editor.userLanguages)
12+
.filter((lang) => cssLanguages.includes(state.editor.userLanguages[lang]))
13+
14+
return [...cssLanguages, ...userCssLanguages]
15+
}
1316

14-
return [...cssLanguages, ...userCssLanguages].indexOf(doc.languageId) !== -1
17+
export function isCssLanguage(state: State, lang: string) {
18+
return getCssLanguages(state).indexOf(lang) !== -1
19+
}
20+
21+
22+
export function isCssDoc(state: State, doc: TextDocument): boolean {
23+
return isCssLanguage(state, doc.languageId)
1524
}
1625

1726
export function isCssContext(state: State, doc: TextDocument, position: Position): boolean {

packages/tailwindcss-language-service/src/util/find.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { TextDocument } from 'vscode-languageserver-textdocument'
33
import type { DocumentClassName, DocumentClassList, State, DocumentHelperFunction } from './state'
44
import lineColumn from 'line-column'
55
import { isCssContext, isCssDoc } from './css'
6-
import { isHtmlContext } from './html'
6+
import { isHtmlContext, isVueDoc } from './html'
77
import { isWithinRange } from './isWithinRange'
88
import { isJsxContext } from './js'
99
import { dedupeByRange, flatten } from './array'
@@ -97,9 +97,10 @@ export function findClassListsInCssRange(
9797
state: State,
9898
doc: TextDocument,
9999
range?: Range,
100+
lang?: string,
100101
): DocumentClassList[] {
101102
const text = getTextWithoutComments(doc, 'css', range)
102-
let regex = isSemicolonlessCssLanguage(doc.languageId, state.editor?.userLanguages)
103+
let regex = isSemicolonlessCssLanguage(lang ?? doc.languageId, state.editor?.userLanguages)
103104
? /(@apply\s+)(?<classList>[^}\r\n]+?)(?<important>\s*!important)?(?:\r|\n|}|$)/g
104105
: /(@apply\s+)(?<classList>[^;}]+?)(?<important>\s*!important)?\s*[;}]/g
105106
const matches = findAll(regex, text)
@@ -302,7 +303,7 @@ export async function findClassListsInDocument(
302303
)),
303304
...boundaries
304305
.filter((b) => b.type === 'css')
305-
.map(({ range }) => findClassListsInCssRange(state, doc, range)),
306+
.map(({ range, lang }) => findClassListsInCssRange(state, doc, range, lang)),
306307
await findCustomClassLists(state, doc),
307308
]),
308309
)
@@ -408,14 +409,36 @@ export async function findClassNameAtPosition(
408409
doc: TextDocument,
409410
position: Position,
410411
): Promise<DocumentClassName> {
411-
let classNames = []
412+
let classNames: DocumentClassName[] = []
412413
const positionOffset = doc.offsetAt(position)
413414
const searchRange: Range = {
414415
start: doc.positionAt(Math.max(0, positionOffset - 2000)),
415416
end: doc.positionAt(positionOffset + 2000),
416417
}
417418

418-
if (isCssContext(state, doc, position)) {
419+
if (isVueDoc(doc)) {
420+
let boundaries = getLanguageBoundaries(state, doc)
421+
422+
let groups = await Promise.all(boundaries.map(async ({ type, range, lang }) => {
423+
if (type === 'css') {
424+
return findClassListsInCssRange(state, doc, range, lang)
425+
}
426+
427+
if (type === 'html') {
428+
return await findClassListsInHtmlRange(state, doc, 'html', range)
429+
}
430+
431+
if (type === 'jsx') {
432+
return await findClassListsInHtmlRange(state, doc, 'jsx', range)
433+
}
434+
435+
return []
436+
}))
437+
438+
classNames = dedupeByRange(flatten(groups)).flatMap(
439+
(classList) => getClassNamesInClassList(classList, state.blocklist)
440+
)
441+
} else if (isCssContext(state, doc, position)) {
419442
classNames = await findClassNamesInRange(state, doc, searchRange, 'css')
420443
} else if (isHtmlContext(state, doc, position)) {
421444
classNames = await findClassNamesInRange(state, doc, searchRange, 'html')

packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ import { isJsDoc } from './js'
77
import moo from 'moo'
88
import Cache from 'tmp-cache'
99
import { getTextWithoutComments } from './doc'
10+
import { isCssLanguage } from './css'
1011

11-
export type LanguageBoundary = { type: 'html' | 'js' | 'css' | (string & {}); range: Range }
12+
export type LanguageBoundary = {
13+
type: 'html' | 'js' | 'css' | (string & {});
14+
range: Range
15+
lang?: string
16+
}
1217

1318
let htmlScriptTypes = [
1419
// https://v3-migration.vuejs.org/breaking-changes/inline-template-attribute.html#option-1-use-script-tag
@@ -92,6 +97,13 @@ let vueStates = {
9297
htmlBlockStart: { match: '<template', push: 'htmlBlock' },
9398
...states.main,
9499
},
100+
101+
cssBlock: {
102+
langAttrStartDouble: { match: 'lang="', push: 'langAttrDouble' },
103+
langAttrStartSingle: { match: "lang='", push: 'langAttrSingle' },
104+
...states.cssBlock,
105+
},
106+
95107
htmlBlock: {
96108
htmlStart: { match: '>', next: 'html' },
97109
htmlBlockEnd: { match: '/>', pop: 1 },
@@ -193,5 +205,13 @@ export function getLanguageBoundaries(
193205

194206
cache.set(cacheKey, boundaries)
195207

208+
for (let boundary of boundaries) {
209+
if (boundary.type === 'css') continue
210+
if (!isCssLanguage(state, boundary.type)) continue
211+
212+
boundary.lang = boundary.type
213+
boundary.type = 'css'
214+
}
215+
196216
return boundaries
197217
}

packages/vscode-tailwindcss/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Fix crash when class regex matches an empty string (#897)
66
- Support Astro's `class:list` attribute by default (#890)
7+
- Fix hovers and CSS conflict detection in Vue `<style lang="sass">` blocks (#930)
78

89
## 0.10.5
910

0 commit comments

Comments
 (0)