Skip to content

Commit 89dee31

Browse files
committed
Add custom CSS class scanning and completion support
- Implemented `scanCssFilesForCustomClasses` to extract custom CSS classes from specified CSS files. - Enhanced `createProjectService` to scan for custom classes in project dependencies and main CSS config. - Updated completion provider to include custom CSS classes in completion suggestions. - Added tests for custom class extraction functionality. - Introduced a new utility for identifying and extracting custom classes from CSS content.
1 parent 3d3f866 commit 89dee31

File tree

5 files changed

+366
-26
lines changed

5 files changed

+366
-26
lines changed

packages/tailwindcss-language-server/src/projects.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { doCodeActions } from '@tailwindcss/language-service/src/codeActions/cod
5656
import { getDocumentColors } from '@tailwindcss/language-service/src/documentColorProvider'
5757
import { getDocumentLinks } from '@tailwindcss/language-service/src/documentLinksProvider'
5858
import { debounce } from 'debounce'
59+
import { scanCssFilesForCustomClasses } from '@tailwindcss/language-service/src/util/css-class-scanner'
5960
import { getModuleDependencies } from './util/getModuleDependencies'
6061
import assert from 'node:assert'
6162
// import postcssLoadConfig from 'postcss-load-config'
@@ -1069,6 +1070,22 @@ export async function createProjectService(
10691070
}
10701071
state.variants = getVariants(state)
10711072

1073+
// Scan CSS files for custom classes (v3 projects)
1074+
const cssFiles = Array.from(state.dependencies ?? []).filter((dep) => dep.endsWith('.css'))
1075+
1076+
// Also scan the main CSS file if it's a CSS config
1077+
if (state.isCssConfig && state.configPath) {
1078+
cssFiles.push(state.configPath)
1079+
}
1080+
1081+
if (cssFiles.length > 0) {
1082+
try {
1083+
await scanCssFilesForCustomClasses(state, cssFiles)
1084+
} catch (error) {
1085+
console.error('Error scanning CSS files for custom classes:', error)
1086+
}
1087+
}
1088+
10721089
let screens = dlv(state.config, 'theme.screens', dlv(state.config, 'screens', {}))
10731090
state.screens = isObject(screens) ? Object.keys(screens) : []
10741091

@@ -1150,7 +1167,20 @@ export async function createProjectService(
11501167
state.variants = getVariants(state)
11511168
state.blocklist = Array.from(designSystem.invalidCandidates ?? [])
11521169

1170+
// Scan CSS files for custom classes
11531171
let deps = designSystem.dependencies()
1172+
const cssFiles = Array.from(deps).filter((dep) => dep.endsWith('.css'))
1173+
1174+
// Also scan the main CSS file
1175+
cssFiles.push(state.configPath)
1176+
1177+
if (cssFiles.length > 0) {
1178+
try {
1179+
await scanCssFilesForCustomClasses(state, cssFiles)
1180+
} catch (error) {
1181+
console.error('Error scanning CSS files for custom classes:', error)
1182+
}
1183+
}
11541184

11551185
for (let dep of deps) {
11561186
dependencies.add(dep)

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

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import removeMeta from './util/removeMeta'
1515
import { formatColor, getColor, getColorFromValue } from './util/color'
1616
import { isHtmlContext, isHtmlDoc } from './util/html'
1717
import { isCssContext } from './util/css'
18-
import { findLast, matchClassAttributes, matchClassFunctions } from './util/find'
18+
import {
19+
findClassNamesInRange,
20+
findLast,
21+
matchClassAttributes,
22+
matchClassFunctions,
23+
} from './util/find'
1924
import { stringifyConfigValue, stringifyCss } from './util/stringify'
2025
import { stringifyScreen, Screen } from './util/screens'
2126
import isObject from './util/isObject'
@@ -282,36 +287,44 @@ export function completionsFromClassList(
282287
}
283288
}
284289

285-
return withDefaults(
286-
{
287-
isIncomplete: false,
288-
items: items.concat(
289-
state.classList.reduce<CompletionItem[]>((items, [className, { color }], index) => {
290-
if (state.blocklist?.includes([...existingVariants, className].join(state.separator))) {
291-
return items
292-
}
290+
// Add custom CSS classes to completions
291+
let allItems = items.concat(
292+
state.classList.reduce<CompletionItem[]>((items, [className, { color }], index) => {
293+
if (state.blocklist?.includes([...existingVariants, className].join(state.separator))) {
294+
return items
295+
}
293296

294-
let kind = color ? CompletionItemKind.Color : CompletionItemKind.Constant
295-
let documentation: string | undefined
297+
let kind = color ? CompletionItemKind.Color : CompletionItemKind.Constant
298+
let documentation: string | undefined
296299

297-
if (color && typeof color !== 'string') {
298-
documentation = formatColor(color)
299-
}
300+
if (color && typeof color !== 'string') {
301+
documentation = formatColor(color)
302+
}
300303

301-
if (prefix.length > 0 && existingVariants.length === 0) {
302-
className = `${prefix}:${className}`
303-
}
304+
if (prefix.length > 0 && existingVariants.length === 0) {
305+
className = `${prefix}:${className}`
306+
}
307+
308+
items.push({
309+
label: className,
310+
kind,
311+
...(documentation ? { documentation } : {}),
312+
sortText: naturalExpand(index, state.classList.length),
313+
})
314+
315+
return items
316+
}, [] as CompletionItem[]),
317+
)
304318

305-
items.push({
306-
label: className,
307-
kind,
308-
...(documentation ? { documentation } : {}),
309-
sortText: naturalExpand(index, state.classList.length),
310-
})
319+
// Add custom CSS classes if they exist
320+
if (state.customCssClasses && state.customCssClasses.length > 0) {
321+
allItems = allItems.concat(state.customCssClasses)
322+
}
311323

312-
return items
313-
}, [] as CompletionItem[]),
314-
),
324+
return withDefaults(
325+
{
326+
isIncomplete: false,
327+
items: allItems,
315328
},
316329
{
317330
data: {
@@ -2231,6 +2244,13 @@ export async function doComplete(
22312244
): Promise<CompletionList | null> {
22322245
if (state === null) return { items: [], isIncomplete: false }
22332246

2247+
const customClassNames = await provideCustomClassNameCompletions(
2248+
state,
2249+
document,
2250+
position,
2251+
context,
2252+
)
2253+
22342254
const result =
22352255
(await provideClassNameCompletions(state, document, position, context)) ||
22362256
(await provideThemeDirectiveCompletions(state, document, position)) ||
@@ -2264,6 +2284,22 @@ export async function resolveCompletionItem(
22642284
return item
22652285
}
22662286

2287+
// Handle custom CSS classes
2288+
if (item.data?._type === 'custom-css-class') {
2289+
const declarations = item.data.declarations as Record<string, string>
2290+
if (declarations) {
2291+
const cssRules = Object.entries(declarations)
2292+
.map(([prop, value]) => ` ${prop}: ${value};`)
2293+
.join('\n')
2294+
2295+
item.documentation = {
2296+
kind: 'markdown' as typeof MarkupKind.Markdown,
2297+
value: `\`\`\`css\n.${item.label} {\n${cssRules}\n}\n\`\`\``,
2298+
}
2299+
}
2300+
return item
2301+
}
2302+
22672303
if (item.data?._type === 'screen') {
22682304
let screens = dlv(state.config, ['theme', 'screens'], dlv(state.config, ['screens'], {}))
22692305
if (!isObject(screens)) screens = {}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { expect, test } from 'vitest'
2+
import { extractCustomClassesFromCss } from './css-class-scanner'
3+
4+
test('extract custom CSS classes from CSS content', async ({ expect }) => {
5+
const css = `
6+
.typography-h3 {
7+
font-family: Montserrat;
8+
font-size: 48px;
9+
font-style: normal;
10+
font-weight: 700;
11+
line-height: 116.7%;
12+
}
13+
14+
.custom-button {
15+
background-color: #1a9dd9;
16+
color: white;
17+
padding: 0.5rem 1rem;
18+
border-radius: 0.25rem;
19+
}
20+
21+
/* This should be ignored as it's a Tailwind utility */
22+
.text-blue-500 {
23+
color: #3b82f6;
24+
}
25+
26+
/* This should be ignored as it has pseudo-selectors */
27+
.hover\:bg-blue-500:hover {
28+
background-color: #3b82f6;
29+
}
30+
`
31+
32+
const classes = extractCustomClassesFromCss(css, 'test.css')
33+
34+
expect(classes).toHaveLength(2)
35+
expect(classes[0]).toEqual({
36+
className: 'typography-h3',
37+
source: 'test.css',
38+
declarations: {
39+
'font-family': 'Montserrat',
40+
'font-size': '48px',
41+
'font-style': 'normal',
42+
'font-weight': '700',
43+
'line-height': '116.7%',
44+
},
45+
})
46+
expect(classes[1]).toEqual({
47+
className: 'custom-button',
48+
source: 'test.css',
49+
declarations: {
50+
'background-color': '#1a9dd9',
51+
color: 'white',
52+
padding: '0.5rem 1rem',
53+
'border-radius': '0.25rem',
54+
},
55+
})
56+
})
57+
58+
test('ignore Tailwind utility classes', async ({ expect }) => {
59+
const css = `
60+
.text-blue-500 {
61+
color: #3b82f6;
62+
}
63+
64+
.bg-red-500 {
65+
background-color: #ef4444;
66+
}
67+
68+
.p-4 {
69+
padding: 1rem;
70+
}
71+
`
72+
73+
const classes = extractCustomClassesFromCss(css, 'test.css')
74+
75+
expect(classes).toHaveLength(0)
76+
})
77+
78+
test('ignore complex selectors', async ({ expect }) => {
79+
const css = `
80+
.button:hover {
81+
background-color: #1a9dd9;
82+
}
83+
84+
.input[type="text"] {
85+
border: 1px solid #ccc;
86+
}
87+
88+
.nav > li {
89+
display: inline-block;
90+
}
91+
`
92+
93+
const classes = extractCustomClassesFromCss(css, 'test.css')
94+
95+
expect(classes).toHaveLength(0)
96+
})

0 commit comments

Comments
 (0)