Skip to content

Commit 1b730cb

Browse files
committed
Theme helper improvements
1 parent c9acd0d commit 1b730cb

File tree

9 files changed

+180
-157
lines changed

9 files changed

+180
-157
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,16 @@ connection.onCompletion(async ({ textDocument, position }, _token) =>
162162
{
163163
...item,
164164
label: 'theme()',
165+
filterText: 'theme',
165166
documentation: {
166167
kind: 'markdown',
167168
value:
168169
'Use the `theme()` function to access your Tailwind config values using dot notation.',
169170
},
171+
command: {
172+
title: '',
173+
command: 'editor.action.triggerSuggest',
174+
},
170175
textEdit: {
171176
...item.textEdit,
172177
newText: item.textEdit.newText.replace(/^calc\(/, 'theme('),
@@ -357,6 +362,7 @@ function createVirtualCssDocument(textDocument: TextDocument): TextDocument {
357362
/@media(\s+screen\s*\([^)]+\))/g,
358363
(_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`
359364
)
365+
.replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_')
360366
)
361367
}
362368

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ const TRIGGER_CHARACTERS = [
112112
// @apply and emmet-style
113113
'.',
114114
// config/theme helper
115+
'(',
115116
'[',
116117
// JIT "important" prefix
117118
'!',

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

Lines changed: 83 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,11 @@ function provideAtApplyCompletions(
503503
)
504504
}
505505

506+
const NUMBER_REGEX = /^(\d+\.?|\d*\.\d+)$/
507+
function isNumber(str: string): boolean {
508+
return NUMBER_REGEX.test(str)
509+
}
510+
506511
async function provideClassNameCompletions(
507512
state: State,
508513
document: TextDocument,
@@ -537,14 +542,26 @@ function provideCssHelperCompletions(
537542

538543
const match = text
539544
.substr(0, text.length - 1) // don't include that extra character from earlier
540-
.match(/\b(?<helper>config|theme)\(['"](?<keys>[^'"]*)$/)
545+
.match(/\b(?<helper>config|theme)\(\s*['"]?(?<path>[^)'"]*)$/)
541546

542547
if (match === null) {
543548
return null
544549
}
545550

551+
let alpha: string
552+
let path = match.groups.path.replace(/^['"]+/g, '')
553+
let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]*))$/)
554+
if (matches) {
555+
path = matches[1]
556+
alpha = matches[2]
557+
}
558+
559+
if (alpha !== undefined) {
560+
return null
561+
}
562+
546563
let base = match.groups.helper === 'config' ? state.config : dlv(state.config, 'theme', {})
547-
let parts = match.groups.keys.split(/([\[\].]+)/)
564+
let parts = path.split(/([\[\].]+)/)
548565
let keys = parts.filter((_, i) => i % 2 === 0)
549566
let separators = parts.filter((_, i) => i % 2 !== 0)
550567
// let obj =
@@ -557,7 +574,7 @@ function provideCssHelperCompletions(
557574
}
558575

559576
let obj: any
560-
let offset: number = 0
577+
let offset: number = keys[keys.length - 1].length
561578
let separator: string = separators.length ? separators[separators.length - 1] : null
562579

563580
if (keys.length === 1) {
@@ -576,41 +593,73 @@ function provideCssHelperCompletions(
576593

577594
if (!obj) return null
578595

596+
let editRange = {
597+
start: {
598+
line: position.line,
599+
character: position.character - offset,
600+
},
601+
end: position,
602+
}
603+
579604
return {
580605
isIncomplete: false,
581-
items: Object.keys(obj).map((item, index) => {
582-
let color = getColorFromValue(obj[item])
583-
const replaceDot: boolean = item.indexOf('.') !== -1 && separator && separator.endsWith('.')
584-
const insertClosingBrace: boolean =
585-
text.charAt(text.length - 1) !== ']' &&
586-
(replaceDot || (separator && separator.endsWith('[')))
587-
const detail = stringifyConfigValue(obj[item])
606+
items: Object.keys(obj)
607+
.sort((a, z) => {
608+
let aIsNumber = isNumber(a)
609+
let zIsNumber = isNumber(z)
610+
if (aIsNumber && !zIsNumber) {
611+
return -1
612+
}
613+
if (!aIsNumber && zIsNumber) {
614+
return 1
615+
}
616+
if (aIsNumber && zIsNumber) {
617+
return parseFloat(a) - parseFloat(z)
618+
}
619+
return 0
620+
})
621+
.map((item, index) => {
622+
let color = getColorFromValue(obj[item])
623+
const replaceDot: boolean = item.indexOf('.') !== -1 && separator && separator.endsWith('.')
624+
const insertClosingBrace: boolean =
625+
text.charAt(text.length - 1) !== ']' &&
626+
(replaceDot || (separator && separator.endsWith('[')))
627+
const detail = stringifyConfigValue(obj[item])
588628

589-
return {
590-
label: item,
591-
filterText: `${replaceDot ? '.' : ''}${item}`,
592-
sortText: naturalExpand(index),
593-
kind: color ? 16 : isObject(obj[item]) ? 9 : 10,
594-
// VS Code bug causes some values to not display in some cases
595-
detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail,
596-
documentation:
597-
color && typeof color !== 'string' && (color.alpha ?? 1) !== 0
598-
? culori.formatRgb(color)
599-
: null,
600-
textEdit: {
601-
newText: `${replaceDot ? '[' : ''}${item}${insertClosingBrace ? ']' : ''}`,
602-
range: {
603-
start: {
604-
line: position.line,
605-
character:
606-
position.character - keys[keys.length - 1].length - (replaceDot ? 1 : 0) - offset,
607-
},
608-
end: position,
629+
return {
630+
label: item,
631+
sortText: naturalExpand(index),
632+
commitCharacters: [!item.includes('.') && '.', !item.includes('[') && '['].filter(
633+
Boolean
634+
),
635+
kind: color ? 16 : isObject(obj[item]) ? 9 : 10,
636+
// VS Code bug causes some values to not display in some cases
637+
detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail,
638+
documentation:
639+
color && typeof color !== 'string' && (color.alpha ?? 1) !== 0
640+
? culori.formatRgb(color)
641+
: null,
642+
textEdit: {
643+
newText: `${item}${insertClosingBrace ? ']' : ''}`,
644+
range: editRange,
609645
},
610-
},
611-
data: 'helper',
612-
}
613-
}),
646+
additionalTextEdits: replaceDot
647+
? [
648+
{
649+
newText: '[',
650+
range: {
651+
start: {
652+
...editRange.start,
653+
character: editRange.start.character - 1,
654+
},
655+
end: editRange.start,
656+
},
657+
},
658+
]
659+
: [],
660+
data: 'helper',
661+
}
662+
}),
614663
}
615664
}
616665

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

Lines changed: 17 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import { State, Settings } from '../util/state'
2-
import type { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver'
2+
import type { TextDocument } from 'vscode-languageserver'
33
import { InvalidConfigPathDiagnostic, DiagnosticKind } from './types'
4-
import { isCssDoc } from '../util/css'
5-
import { getLanguageBoundaries } from '../util/getLanguageBoundaries'
6-
import { findAll, indexToPosition } from '../util/find'
4+
import { findHelperFunctionsInDocument } from '../util/find'
75
import { stringToPath } from '../util/stringToPath'
86
import isObject from '../util/isObject'
97
import { closest } from '../util/closest'
10-
import { absoluteRange } from '../util/absoluteRange'
118
import { combinations } from '../util/combinations'
129
import dlv from 'dlv'
13-
import { getTextWithoutComments } from '../util/doc'
1410

1511
function pathToString(path: string | string[]): string {
1612
if (typeof path === 'string') return path
@@ -167,54 +163,24 @@ export function getInvalidConfigPathDiagnostics(
167163
if (severity === 'ignore') return []
168164

169165
let diagnostics: InvalidConfigPathDiagnostic[] = []
170-
let ranges: Range[] = []
171-
172-
if (isCssDoc(state, document)) {
173-
ranges.push(undefined)
174-
} else {
175-
let boundaries = getLanguageBoundaries(state, document)
176-
if (!boundaries) return []
177-
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
178-
}
179-
180-
ranges.forEach((range) => {
181-
let text = getTextWithoutComments(document, 'css', range)
182-
let matches = findAll(
183-
/(?<prefix>\s|^)(?<helper>config|theme)\((?<quote>['"])(?<key>[^)]+)\k<quote>[^)]*\)/g,
184-
text
185-
)
186166

187-
matches.forEach((match) => {
188-
let base = match.groups.helper === 'theme' ? ['theme'] : []
189-
let result = validateConfigPath(state, match.groups.key, base)
167+
findHelperFunctionsInDocument(state, document).forEach((helperFn) => {
168+
let base = helperFn.helper === 'theme' ? ['theme'] : []
169+
let result = validateConfigPath(state, helperFn.path, base)
190170

191-
if (result.isValid === true) {
192-
return null
193-
}
171+
if (result.isValid === true) {
172+
return
173+
}
194174

195-
let startIndex =
196-
match.index +
197-
match.groups.prefix.length +
198-
match.groups.helper.length +
199-
1 + // open paren
200-
match.groups.quote.length
201-
202-
diagnostics.push({
203-
code: DiagnosticKind.InvalidConfigPath,
204-
range: absoluteRange(
205-
{
206-
start: indexToPosition(text, startIndex),
207-
end: indexToPosition(text, startIndex + match.groups.key.length),
208-
},
209-
range
210-
),
211-
severity:
212-
severity === 'error'
213-
? 1 /* DiagnosticSeverity.Error */
214-
: 2 /* DiagnosticSeverity.Warning */,
215-
message: result.reason,
216-
suggestions: result.suggestions,
217-
})
175+
diagnostics.push({
176+
code: DiagnosticKind.InvalidConfigPath,
177+
range: helperFn.ranges.path,
178+
severity:
179+
severity === 'error'
180+
? 1 /* DiagnosticSeverity.Error */
181+
: 2 /* DiagnosticSeverity.Warning */,
182+
message: result.reason,
183+
suggestions: result.suggestions,
218184
})
219185
})
220186

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ export async function getDocumentColors(
3636

3737
let helperFns = findHelperFunctionsInDocument(state, document)
3838
helperFns.forEach((fn) => {
39-
let keys = stringToPath(fn.value)
39+
let keys = stringToPath(fn.path)
4040
let base = fn.helper === 'theme' ? ['theme'] : []
4141
let value = dlv(state.config, [...base, ...keys])
4242
let color = getColorFromValue(value)
4343
if (color && typeof color !== 'string' && (color.alpha ?? 1) !== 0) {
44-
colors.push({ range: fn.valueRange, color: culoriColorToVscodeColor(color) })
44+
colors.push({ range: fn.ranges.path, color: culoriColorToVscodeColor(color) })
4545
}
4646
})
4747

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

Lines changed: 25 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import type { Hover, TextDocument, Position } from 'vscode-languageserver'
33
import { stringifyCss, stringifyConfigValue } from './util/stringify'
44
import dlv from 'dlv'
55
import { isCssContext } from './util/css'
6-
import { findClassNameAtPosition } from './util/find'
6+
import { findClassNameAtPosition, findHelperFunctionsInRange } from './util/find'
77
import { validateApply } from './util/validateApply'
88
import { getClassNameParts } from './util/getClassNameAtPosition'
99
import * as jit from './util/jit'
1010
import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostics'
11-
import { getTextWithoutComments } from './util/doc'
11+
import { isWithinRange } from './util/isWithinRange'
1212

1313
export async function doHover(
1414
state: State,
@@ -22,49 +22,34 @@ export async function doHover(
2222
}
2323

2424
function provideCssHelperHover(state: State, document: TextDocument, position: Position): Hover {
25-
if (!isCssContext(state, document, position)) return null
26-
27-
const line = getTextWithoutComments(document, 'css').split('\n')[position.line]
28-
29-
const match = line.match(/(?<helper>theme|config)\((?<quote>['"])(?<key>[^)]+)\k<quote>[^)]*\)/)
30-
31-
if (match === null) return null
32-
33-
const startChar = match.index + match.groups.helper.length + 2
34-
const endChar = startChar + match.groups.key.length
35-
36-
if (position.character < startChar || position.character >= endChar) {
25+
if (!isCssContext(state, document, position)) {
3726
return null
3827
}
3928

40-
let key = match.groups.key
41-
.split(/(\[[^\]]+\]|\.)/)
42-
.filter(Boolean)
43-
.filter((x) => x !== '.')
44-
.map((x) => x.replace(/^\[([^\]]+)\]$/, '$1'))
45-
46-
if (key.length === 0) return null
47-
48-
if (match.groups.helper === 'theme') {
49-
key = ['theme', ...key]
29+
let helperFns = findHelperFunctionsInRange(document, {
30+
start: { line: position.line, character: 0 },
31+
end: { line: position.line + 1, character: 0 },
32+
})
33+
34+
for (let helperFn of helperFns) {
35+
if (isWithinRange(position, helperFn.ranges.path)) {
36+
let validated = validateConfigPath(
37+
state,
38+
helperFn.path,
39+
helperFn.helper === 'theme' ? ['theme'] : []
40+
)
41+
let value = validated.isValid ? stringifyConfigValue(validated.value) : null
42+
if (value === null) {
43+
return null
44+
}
45+
return {
46+
contents: { kind: 'markdown', value: ['```plaintext', value, '```'].join('\n') },
47+
range: helperFn.ranges.path,
48+
}
49+
}
5050
}
5151

52-
const value = validateConfigPath(state, key).isValid
53-
? stringifyConfigValue(dlv(state.config, key))
54-
: null
55-
56-
if (value === null) return null
57-
58-
return {
59-
contents: { kind: 'markdown', value: ['```plaintext', value, '```'].join('\n') },
60-
range: {
61-
start: { line: position.line, character: startChar },
62-
end: {
63-
line: position.line,
64-
character: endChar,
65-
},
66-
},
67-
}
52+
return null
6853
}
6954

7055
async function provideClassNameHover(

0 commit comments

Comments
 (0)