Skip to content

Fix hovers and conflicts in Vue <style lang="sass"> blocks #930

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

Merged
merged 3 commits into from
Mar 22, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
{
"code": "<style lang=\"sass\">\n.foo\n @apply uppercase lowercase\n</style>",
"language": "vue",
"expected": [
{
"code": "cssConflict",
"className": {
"className": "uppercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 28 }
},
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } }
},
"otherClassNames": [
{
"className": "lowercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 28 }
},
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": {
"start": { "line": 2, "character": 19 },
"end": { "line": 2, "character": 28 }
}
}
],
"range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } },
"severity": 2,
"message": "'uppercase' applies the same CSS properties as 'lowercase'.",
"relatedInformation": [
{
"message": "lowercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 2, "character": 19 },
"end": { "line": 2, "character": 28 }
}
}
}
]
},
{
"code": "cssConflict",
"className": {
"className": "lowercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 28 }
},
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } }
},
"otherClassNames": [
{
"className": "uppercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 28 }
},
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": {
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 18 }
}
}
],
"range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } },
"severity": 2,
"message": "'lowercase' applies the same CSS properties as 'uppercase'.",
"relatedInformation": [
{
"message": "uppercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 18 }
}
}
}
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ withFixture('basic', (c) => {
testFixture('css-conflict/css')
testFixture('css-conflict/css-multi-rule')
testFixture('css-conflict/css-multi-prop')
testFixture('css-conflict/vue-style-lang-sass')
testFixture('invalid-screen/simple')
testFixture('invalid-theme/simple')
})
Expand Down Expand Up @@ -63,6 +64,7 @@ withFixture('v4/basic', (c) => {
testFixture('css-conflict/variants-positive')
testFixture('css-conflict/jsx-concat-negative')
testFixture('css-conflict/jsx-concat-positive')
testFixture('css-conflict/vue-style-lang-sass')
// testFixture('css-conflict/css')
// testFixture('css-conflict/css-multi-rule')
// testFixture('css-conflict/css-multi-prop')
Expand Down
28 changes: 28 additions & 0 deletions packages/tailwindcss-language-server/tests/hover/hover.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ withFixture('basic', (c) => {
end: { line: 0, character: 31 },
},
})

testHover('vue <style lang=sass>', {
lang: 'vue',
text: `<style lang="sass">
.foo
@apply underline
</style>`,
position: { line: 2, character: 13 },
expected: '.underline {\n' + ' text-decoration-line: underline;\n' + '}',
expectedRange: {
start: { line: 2, character: 9 },
end: { line: 2, character: 18 },
},
})
})

withFixture('v4/basic', (c) => {
Expand Down Expand Up @@ -148,4 +162,18 @@ withFixture('v4/basic', (c) => {
end: { line: 0, character: 31 },
},
})

testHover('vue <style lang=sass>', {
lang: 'vue',
text: `<style lang="sass">
.foo
@apply underline
</style>`,
position: { line: 2, character: 13 },
expected: '.underline {\n' + ' text-decoration-line: underline;\n' + '}',
expectedRange: {
start: { line: 2, character: 9 },
end: { line: 2, character: 18 },
},
})
})
19 changes: 14 additions & 5 deletions packages/tailwindcss-language-service/src/util/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import type { State } from './state'
import { cssLanguages } from './languages'
import { getLanguageBoundaries } from './getLanguageBoundaries'

export function isCssDoc(state: State, doc: TextDocument): boolean {
const userCssLanguages = Object.keys(state.editor.userLanguages).filter((lang) =>
cssLanguages.includes(state.editor.userLanguages[lang]),
)
function getCssLanguages(state: State) {
const userCssLanguages = Object
.keys(state.editor.userLanguages)
.filter((lang) => cssLanguages.includes(state.editor.userLanguages[lang]))

return [...cssLanguages, ...userCssLanguages]
}

return [...cssLanguages, ...userCssLanguages].indexOf(doc.languageId) !== -1
export function isCssLanguage(state: State, lang: string) {
return getCssLanguages(state).indexOf(lang) !== -1
}


export function isCssDoc(state: State, doc: TextDocument): boolean {
return isCssLanguage(state, doc.languageId)
}

export function isCssContext(state: State, doc: TextDocument, position: Position): boolean {
Expand Down
33 changes: 28 additions & 5 deletions packages/tailwindcss-language-service/src/util/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { TextDocument } from 'vscode-languageserver-textdocument'
import type { DocumentClassName, DocumentClassList, State, DocumentHelperFunction } from './state'
import lineColumn from 'line-column'
import { isCssContext, isCssDoc } from './css'
import { isHtmlContext } from './html'
import { isHtmlContext, isVueDoc } from './html'
import { isWithinRange } from './isWithinRange'
import { isJsxContext } from './js'
import { dedupeByRange, flatten } from './array'
Expand Down Expand Up @@ -97,9 +97,10 @@ export function findClassListsInCssRange(
state: State,
doc: TextDocument,
range?: Range,
lang?: string,
): DocumentClassList[] {
const text = getTextWithoutComments(doc, 'css', range)
let regex = isSemicolonlessCssLanguage(doc.languageId, state.editor?.userLanguages)
let regex = isSemicolonlessCssLanguage(lang ?? doc.languageId, state.editor?.userLanguages)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a question, in what scenario would lang and doc.languageId be different?

is that when you have <style lang="sass"> in an html or vue file where lang will be sass and doc.languageId would be html?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vue file with a <style> block or a <script> block. Also HTML file with a <script> block. doc always refers to the whole file/document rather than just a portion of the file.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha yep that's what I meant and thought. Thanks for verifying!

? /(@apply\s+)(?<classList>[^}\r\n]+?)(?<important>\s*!important)?(?:\r|\n|}|$)/g
: /(@apply\s+)(?<classList>[^;}]+?)(?<important>\s*!important)?\s*[;}]/g
const matches = findAll(regex, text)
Expand Down Expand Up @@ -302,7 +303,7 @@ export async function findClassListsInDocument(
)),
...boundaries
.filter((b) => b.type === 'css')
.map(({ range }) => findClassListsInCssRange(state, doc, range)),
.map(({ range, lang }) => findClassListsInCssRange(state, doc, range, lang)),
await findCustomClassLists(state, doc),
]),
)
Expand Down Expand Up @@ -408,14 +409,36 @@ export async function findClassNameAtPosition(
doc: TextDocument,
position: Position,
): Promise<DocumentClassName> {
let classNames = []
let classNames: DocumentClassName[] = []
const positionOffset = doc.offsetAt(position)
const searchRange: Range = {
start: doc.positionAt(Math.max(0, positionOffset - 2000)),
end: doc.positionAt(positionOffset + 2000),
}

if (isCssContext(state, doc, position)) {
if (isVueDoc(doc)) {
let boundaries = getLanguageBoundaries(state, doc)

let groups = await Promise.all(boundaries.map(async ({ type, range, lang }) => {
if (type === 'css') {
return findClassListsInCssRange(state, doc, range, lang)
}

if (type === 'html') {
return await findClassListsInHtmlRange(state, doc, 'html', range)
}

if (type === 'jsx') {
return await findClassListsInHtmlRange(state, doc, 'jsx', range)
}

return []
}))

classNames = dedupeByRange(flatten(groups)).flatMap(
(classList) => getClassNamesInClassList(classList, state.blocklist)
)
} else if (isCssContext(state, doc, position)) {
classNames = await findClassNamesInRange(state, doc, searchRange, 'css')
} else if (isHtmlContext(state, doc, position)) {
classNames = await findClassNamesInRange(state, doc, searchRange, 'html')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import { isJsDoc } from './js'
import moo from 'moo'
import Cache from 'tmp-cache'
import { getTextWithoutComments } from './doc'
import { isCssLanguage } from './css'

export type LanguageBoundary = { type: 'html' | 'js' | 'css' | (string & {}); range: Range }
export type LanguageBoundary = {
type: 'html' | 'js' | 'css' | (string & {});
range: Range
lang?: string
}

let htmlScriptTypes = [
// https://v3-migration.vuejs.org/breaking-changes/inline-template-attribute.html#option-1-use-script-tag
Expand Down Expand Up @@ -92,6 +97,13 @@ let vueStates = {
htmlBlockStart: { match: '<template', push: 'htmlBlock' },
...states.main,
},

cssBlock: {
langAttrStartDouble: { match: 'lang="', push: 'langAttrDouble' },
langAttrStartSingle: { match: "lang='", push: 'langAttrSingle' },
...states.cssBlock,
},

htmlBlock: {
htmlStart: { match: '>', next: 'html' },
htmlBlockEnd: { match: '/>', pop: 1 },
Expand Down Expand Up @@ -193,5 +205,13 @@ export function getLanguageBoundaries(

cache.set(cacheKey, boundaries)

for (let boundary of boundaries) {
if (boundary.type === 'css') continue
if (!isCssLanguage(state, boundary.type)) continue

boundary.lang = boundary.type
boundary.type = 'css'
}

return boundaries
}
1 change: 1 addition & 0 deletions packages/vscode-tailwindcss/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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

## 0.10.5

Expand Down