Skip to content

Commit 86497bb

Browse files
authored
Rework language boundary detection (tailwindlabs#502)
* Fix `classRegex` error * Rework language boundary detection
1 parent a082bb3 commit 86497bb

13 files changed

+24067
-8306
lines changed

package-lock.json

Lines changed: 23893 additions & 8186 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/tailwindcss-language-service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"semver": "7.3.2",
3030
"sift-string": "0.0.2",
3131
"stringify-object": "3.3.0",
32+
"tmp-cache": "1.1.0",
3233
"vscode-emmet-helper-bundled": "0.0.1",
3334
"vscode-languageclient": "7.0.0",
3435
"vscode-languageserver": "7.0.0",

packages/tailwindcss-language-service/src/codeActions/provideInvalidApplyCodeActions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ export async function provideInvalidApplyCodeActions(
4343
if (!isCssDoc(state, document)) {
4444
let languageBoundaries = getLanguageBoundaries(state, document)
4545
if (!languageBoundaries) return []
46-
cssRange = languageBoundaries.css.find((range) => isWithinRange(diagnostic.range.start, range))
46+
cssRange = languageBoundaries
47+
.filter((b) => b.type === 'css')
48+
.find(({ range }) => isWithinRange(diagnostic.range.start, range))?.range
4749
if (!cssRange) return []
4850
cssText = document.getText(cssRange)
4951
}

packages/tailwindcss-language-service/src/completionProvider.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { stringifyScreen, Screen } from './util/screens'
2020
import isObject from './util/isObject'
2121
import * as emmetHelper from 'vscode-emmet-helper-bundled'
2222
import { isValidLocationForEmmetAbbreviation } from './util/isValidLocationForEmmetAbbreviation'
23-
import { isJsContext } from './util/js'
23+
import { isJsDoc, isJsxContext } from './util/js'
2424
import { naturalExpand } from './util/naturalExpand'
2525
import semver from 'semver'
2626
import { docsUrl } from './util/docsUrl'
@@ -511,7 +511,7 @@ async function provideClassNameCompletions(
511511
return provideAtApplyCompletions(state, document, position)
512512
}
513513

514-
if (isHtmlContext(state, document, position) || isJsContext(state, document, position)) {
514+
if (isHtmlContext(state, document, position) || isJsxContext(state, document, position)) {
515515
return provideClassAttributeCompletions(state, document, position, context)
516516
}
517517

@@ -973,8 +973,8 @@ async function provideEmmetCompletions(
973973
let settings = await state.editor.getConfiguration(document.uri)
974974
if (settings.tailwindCSS.emmetCompletions !== true) return null
975975

976-
const isHtml = isHtmlContext(state, document, position)
977-
const isJs = !isHtml && isJsContext(state, document, position)
976+
const isHtml = !isJsDoc(state, document) && isHtmlContext(state, document, position)
977+
const isJs = isJsDoc(state, document) || isJsxContext(state, document, position)
978978

979979
const syntax = isHtml ? 'html' : isJs ? 'jsx' : null
980980

packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export function getInvalidConfigPathDiagnostics(
173173
} else {
174174
let boundaries = getLanguageBoundaries(state, document)
175175
if (!boundaries) return []
176-
ranges.push(...boundaries.css)
176+
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
177177
}
178178

179179
ranges.forEach((range) => {

packages/tailwindcss-language-service/src/diagnostics/getInvalidScreenDiagnostics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function getInvalidScreenDiagnostics(
2424
} else {
2525
let boundaries = getLanguageBoundaries(state, document)
2626
if (!boundaries) return []
27-
ranges.push(...boundaries.css)
27+
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
2828
}
2929

3030
ranges.forEach((range) => {

packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function getInvalidTailwindDirectiveDiagnostics(
2424
} else {
2525
let boundaries = getLanguageBoundaries(state, document)
2626
if (!boundaries) return []
27-
ranges.push(...boundaries.css)
27+
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
2828
}
2929

3030
let notSemicolonLanguages = ['sass', 'sugarss', 'stylus']

packages/tailwindcss-language-service/src/diagnostics/getInvalidVariantDiagnostics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function getInvalidVariantDiagnostics(
2828
} else {
2929
let boundaries = getLanguageBoundaries(state, document)
3030
if (!boundaries) return []
31-
ranges.push(...boundaries.css)
31+
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
3232
}
3333

3434
let possibleVariants = Object.keys(state.variants)

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { TextDocument, Position } from 'vscode-languageserver'
2-
import { isInsideTag, isVueDoc, isSvelteDoc, isHtmlDoc } from './html'
2+
import { isVueDoc, isSvelteDoc, isHtmlDoc } from './html'
33
import { isJsDoc } from './js'
44
import { State } from './state'
55
import { cssLanguages } from './languages'
6+
import { getLanguageBoundaries } from './getLanguageBoundaries'
67

78
export function isCssDoc(state: State, doc: TextDocument): boolean {
89
const userCssLanguages = Object.keys(state.editor.userLanguages).filter((lang) =>
@@ -23,7 +24,9 @@ export function isCssContext(state: State, doc: TextDocument, position: Position
2324
end: position,
2425
})
2526

26-
return isInsideTag(str, ['style'])
27+
let boundaries = getLanguageBoundaries(state, doc, str)
28+
29+
return boundaries ? boundaries[boundaries.length - 1].type === 'css' : false
2730
}
2831

2932
return false

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import lineColumn from 'line-column'
44
import { isCssContext, isCssDoc } from './css'
55
import { isHtmlContext } from './html'
66
import { isWithinRange } from './isWithinRange'
7-
import { isJsContext } from './js'
7+
import { isJsxContext } from './js'
88
import { flatten } from './array'
99
import { getClassAttributeLexer, getComputedClassAttributeLexer } from './lexers'
1010
import { getLanguageBoundaries } from './getLanguageBoundaries'
@@ -306,9 +306,13 @@ export async function findClassListsInDocument(
306306

307307
return flatten([
308308
...(await Promise.all(
309-
boundaries.html.map((range) => findClassListsInHtmlRange(state, doc, range))
309+
boundaries
310+
.filter((b) => b.type === 'html' || b.type === 'jsx')
311+
.map(({ range }) => findClassListsInHtmlRange(state, doc, range))
310312
)),
311-
...boundaries.css.map((range) => findClassListsInCssRange(doc, range)),
313+
...boundaries
314+
.filter((b) => b.type === 'css')
315+
.map(({ range }) => findClassListsInCssRange(doc, range)),
312316
await findCustomClassLists(state, doc),
313317
])
314318
}
@@ -324,7 +328,11 @@ export function findHelperFunctionsInDocument(
324328
let boundaries = getLanguageBoundaries(state, doc)
325329
if (!boundaries) return []
326330

327-
return flatten(boundaries.css.map((range) => findHelperFunctionsInRange(doc, range)))
331+
return flatten(
332+
boundaries
333+
.filter((b) => b.type === 'css')
334+
.map(({ range }) => findHelperFunctionsInRange(doc, range))
335+
)
328336
}
329337

330338
export function findHelperFunctionsInRange(
@@ -385,7 +393,7 @@ export async function findClassNameAtPosition(
385393

386394
if (isCssContext(state, doc, position)) {
387395
classNames = await findClassNamesInRange(state, doc, searchRange, 'css')
388-
} else if (isHtmlContext(state, doc, position) || isJsContext(state, doc, position)) {
396+
} else if (isHtmlContext(state, doc, position) || isJsxContext(state, doc, position)) {
389397
classNames = await findClassNamesInRange(state, doc, searchRange, 'html')
390398
}
391399

Lines changed: 137 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,153 @@
11
import type { TextDocument, Range } from 'vscode-languageserver'
22
import { isVueDoc, isHtmlDoc, isSvelteDoc } from './html'
33
import { State } from './state'
4-
import { findAll, indexToPosition } from './find'
4+
import { indexToPosition } from './find'
55
import { isJsDoc } from './js'
6+
import moo from 'moo'
7+
import Cache from 'tmp-cache'
68

7-
export interface LanguageBoundaries {
8-
html: Range[]
9-
css: Range[]
9+
export type LanguageBoundary = { type: 'html' | 'js' | 'css' | string; range: Range }
10+
11+
let text = { text: { match: /[^]/, lineBreaks: true } }
12+
13+
let states = {
14+
main: {
15+
cssBlockStart: { match: '<style', push: 'cssBlock' },
16+
jsBlockStart: { match: '<script', push: 'jsBlock' },
17+
...text,
18+
},
19+
cssBlock: {
20+
styleStart: { match: '>', next: 'style' },
21+
cssBlockEnd: { match: '/>', pop: 1 },
22+
attrStartDouble: { match: '"', push: 'attrDouble' },
23+
attrStartSingle: { match: "'", push: 'attrSingle' },
24+
interp: { match: '{', push: 'interp' },
25+
...text,
26+
},
27+
jsBlock: {
28+
scriptStart: { match: '>', next: 'script' },
29+
jsBlockEnd: { match: '/>', pop: 1 },
30+
langAttrStartDouble: { match: 'lang="', push: 'langAttrDouble' },
31+
langAttrStartSingle: { match: "lang='", push: 'langAttrSingle' },
32+
attrStartDouble: { match: '"', push: 'attrDouble' },
33+
attrStartSingle: { match: "'", push: 'attrSingle' },
34+
interp: { match: '{', push: 'interp' },
35+
...text,
36+
},
37+
interp: {
38+
interp: { match: '{', push: 'interp' },
39+
end: { match: '}', pop: 1 },
40+
...text,
41+
},
42+
langAttrDouble: {
43+
langAttrEnd: { match: '"', pop: 1 },
44+
lang: { match: /[^"]+/, lineBreaks: true },
45+
},
46+
langAttrSingle: {
47+
langAttrEnd: { match: "'", pop: 1 },
48+
lang: { match: /[^']+/, lineBreaks: true },
49+
},
50+
attrDouble: {
51+
attrEnd: { match: '"', pop: 1 },
52+
...text,
53+
},
54+
attrSingle: {
55+
attrEnd: { match: "'", pop: 1 },
56+
...text,
57+
},
58+
style: {
59+
cssBlockEnd: { match: '</style>', pop: 1 },
60+
...text,
61+
},
62+
script: {
63+
jsBlockEnd: { match: '</script>', pop: 1 },
64+
...text,
65+
},
1066
}
1167

12-
export function getLanguageBoundaries(state: State, doc: TextDocument): LanguageBoundaries | null {
13-
if (isVueDoc(doc)) {
14-
let text = doc.getText()
15-
let blocks = findAll(
16-
/(?<open><(?<type>template|style|script)\b[^>]*>).*?(?<close><\/\k<type>>|$)/gis,
17-
text
18-
)
19-
let htmlRanges: Range[] = []
20-
let cssRanges: Range[] = []
21-
for (let i = 0; i < blocks.length; i++) {
22-
let range = {
23-
start: indexToPosition(text, blocks[i].index + blocks[i].groups.open.length),
24-
end: indexToPosition(
25-
text,
26-
blocks[i].index + blocks[i][0].length - blocks[i].groups.close.length
27-
),
28-
}
29-
if (blocks[i].groups.type === 'style') {
30-
cssRanges.push(range)
31-
} else {
32-
htmlRanges.push(range)
33-
}
34-
}
68+
let vueStates = {
69+
...states,
70+
main: {
71+
htmlBlockStart: { match: '<template', push: 'htmlBlock' },
72+
...states.main,
73+
},
74+
htmlBlock: {
75+
htmlStart: { match: '>', next: 'html' },
76+
htmlBlockEnd: { match: '/>', pop: 1 },
77+
attrStartDouble: { match: '"', push: 'attrDouble' },
78+
attrStartSingle: { match: "'", push: 'attrSingle' },
79+
interp: { match: '{', push: 'interp' },
80+
...text,
81+
},
82+
html: {
83+
htmlBlockEnd: { match: '</template>', pop: 1 },
84+
...text,
85+
},
86+
}
3587

36-
return {
37-
html: htmlRanges,
38-
css: cssRanges,
39-
}
88+
let defaultLexer = moo.states(states)
89+
let vueLexer = moo.states(vueStates)
90+
91+
let cache = new Cache<string, LanguageBoundary[] | null>({ max: 25, maxAge: 1000 })
92+
93+
export function getLanguageBoundaries(
94+
state: State,
95+
doc: TextDocument,
96+
text: string = doc.getText()
97+
): LanguageBoundary[] | null {
98+
let cacheKey = `${doc.languageId}:${text}`
99+
if (cache.has(cacheKey)) {
100+
return cache.get(cacheKey)
40101
}
41102

42-
if (isHtmlDoc(state, doc) || isJsDoc(state, doc) || isSvelteDoc(doc)) {
43-
let text = doc.getText()
44-
let styleBlocks = findAll(
45-
/(?<open><style(?:\s[^>]*[^\/]>|\s*>)).*?(?<close><\/style>|$)/gis,
46-
text
47-
)
48-
let htmlRanges: Range[] = []
49-
let cssRanges: Range[] = []
50-
let currentIndex = 0
103+
let defaultType = isVueDoc(doc)
104+
? 'none'
105+
: isHtmlDoc(state, doc) || isJsDoc(state, doc) || isSvelteDoc(doc)
106+
? 'html'
107+
: null
51108

52-
for (let i = 0; i < styleBlocks.length; i++) {
53-
htmlRanges.push({
54-
start: indexToPosition(text, currentIndex),
55-
end: indexToPosition(text, styleBlocks[i].index),
56-
})
57-
cssRanges.push({
58-
start: indexToPosition(text, styleBlocks[i].index + styleBlocks[i].groups.open.length),
59-
end: indexToPosition(
60-
text,
61-
styleBlocks[i].index + styleBlocks[i][0].length - styleBlocks[i].groups.close.length
62-
),
63-
})
64-
currentIndex = styleBlocks[i].index + styleBlocks[i][0].length
65-
}
66-
htmlRanges.push({
67-
start: indexToPosition(text, currentIndex),
68-
end: indexToPosition(text, text.length),
69-
})
109+
if (defaultType === null) {
110+
cache.set(cacheKey, null)
111+
return null
112+
}
113+
114+
let lexer = defaultType === 'none' ? vueLexer : defaultLexer
115+
lexer.reset(text)
116+
117+
let type = defaultType
118+
let boundaries: LanguageBoundary[] = [
119+
{ type: defaultType, range: { start: { line: 0, character: 0 }, end: undefined } },
120+
]
121+
let offset = 0
70122

71-
return {
72-
html: htmlRanges,
73-
css: cssRanges,
123+
try {
124+
for (let token of lexer) {
125+
if (token.type.endsWith('BlockStart')) {
126+
let position = indexToPosition(text, offset)
127+
if (!boundaries[boundaries.length - 1].range.end) {
128+
boundaries[boundaries.length - 1].range.end = position
129+
}
130+
type = token.type.replace(/BlockStart$/, '')
131+
boundaries.push({ type, range: { start: position, end: undefined } })
132+
} else if (token.type.endsWith('BlockEnd')) {
133+
let position = indexToPosition(text, offset)
134+
boundaries[boundaries.length - 1].range.end = position
135+
boundaries.push({ type: defaultType, range: { start: position, end: undefined } })
136+
} else if (token.type === 'lang') {
137+
boundaries[boundaries.length - 1].type = token.text
138+
}
139+
offset += token.text.length
74140
}
141+
} catch {
142+
cache.set(cacheKey, null)
143+
return null
144+
}
145+
146+
if (!boundaries[boundaries.length - 1].range.end) {
147+
boundaries[boundaries.length - 1].range.end = indexToPosition(text, offset)
75148
}
76149

77-
return null
150+
cache.set(cacheKey, boundaries)
151+
152+
return boundaries
78153
}

0 commit comments

Comments
 (0)