Merge pull request #630 from tailwindlabs/next

Theme helper improvements, support `@config`, adopt `getVariants`, bump bundled modules
master
Brad Cornes 2022-10-18 20:24:24 +01:00 committed by GitHub
commit 68750d859b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1214 additions and 874 deletions

1153
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,10 +32,10 @@
}, },
"devDependencies": { "devDependencies": {
"@parcel/watcher": "2.0.3", "@parcel/watcher": "2.0.3",
"@tailwindcss/aspect-ratio": "0.4.0", "@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.4.0", "@tailwindcss/forms": "0.5.3",
"@tailwindcss/line-clamp": "0.3.0", "@tailwindcss/line-clamp": "0.4.2",
"@tailwindcss/typography": "0.5.0", "@tailwindcss/typography": "0.5.7",
"@types/debounce": "1.2.0", "@types/debounce": "1.2.0",
"@types/node": "14.14.34", "@types/node": "14.14.34",
"@types/vscode": "1.65.0", "@types/vscode": "1.65.0",
@ -63,7 +63,7 @@
"resolve": "1.20.0", "resolve": "1.20.0",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"stack-trace": "0.0.10", "stack-trace": "0.0.10",
"tailwindcss": "3.0.11", "tailwindcss": "3.1.8",
"typescript": "4.6.4", "typescript": "4.6.4",
"vscode-css-languageservice": "5.4.1", "vscode-css-languageservice": "5.4.1",
"vscode-languageserver": "8.0.2", "vscode-languageserver": "8.0.2",

View File

@ -162,11 +162,16 @@ connection.onCompletion(async ({ textDocument, position }, _token) =>
{ {
...item, ...item,
label: 'theme()', label: 'theme()',
filterText: 'theme',
documentation: { documentation: {
kind: 'markdown', kind: 'markdown',
value: value:
'Use the `theme()` function to access your Tailwind config values using dot notation.', 'Use the `theme()` function to access your Tailwind config values using dot notation.',
}, },
command: {
title: '',
command: 'editor.action.triggerSuggest',
},
textEdit: { textEdit: {
...item.textEdit, ...item.textEdit,
newText: item.textEdit.newText.replace(/^calc\(/, 'theme('), newText: item.textEdit.newText.replace(/^calc\(/, 'theme('),
@ -357,6 +362,7 @@ function createVirtualCssDocument(textDocument: TextDocument): TextDocument {
/@media(\s+screen\s*\([^)]+\))/g, /@media(\s+screen\s*\([^)]+\))/g,
(_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}` (_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`
) )
.replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_')
) )
} }
@ -387,7 +393,7 @@ async function validateTextDocument(textDocument: TextDocument): Promise<void> {
.filter((diagnostic) => { .filter((diagnostic) => {
if ( if (
diagnostic.code === 'unknownAtRules' && diagnostic.code === 'unknownAtRules' &&
/Unknown at rule @(tailwind|apply)/.test(diagnostic.message) /Unknown at rule @(tailwind|apply|config)/.test(diagnostic.message)
) { ) {
return false return false
} }

View File

@ -27,6 +27,9 @@ import {
FileChangeType, FileChangeType,
Disposable, Disposable,
TextDocumentIdentifier, TextDocumentIdentifier,
DocumentLinkRequest,
DocumentLinkParams,
DocumentLink,
} from 'vscode-languageserver/node' } from 'vscode-languageserver/node'
import { TextDocument } from 'vscode-languageserver-textdocument' import { TextDocument } from 'vscode-languageserver-textdocument'
import { URI } from 'vscode-uri' import { URI } from 'vscode-uri'
@ -60,6 +63,7 @@ import {
FeatureFlags, FeatureFlags,
Settings, Settings,
ClassNames, ClassNames,
Variant,
} from 'tailwindcss-language-service/src/util/state' } from 'tailwindcss-language-service/src/util/state'
import { import {
provideDiagnostics, provideDiagnostics,
@ -68,6 +72,7 @@ import {
} from './lsp/diagnosticsProvider' } from './lsp/diagnosticsProvider'
import { doCodeActions } from 'tailwindcss-language-service/src/codeActions/codeActionProvider' import { doCodeActions } from 'tailwindcss-language-service/src/codeActions/codeActionProvider'
import { getDocumentColors } from 'tailwindcss-language-service/src/documentColorProvider' import { getDocumentColors } from 'tailwindcss-language-service/src/documentColorProvider'
import { getDocumentLinks } from 'tailwindcss-language-service/src/documentLinksProvider'
import { debounce } from 'debounce' import { debounce } from 'debounce'
import { getModuleDependencies } from './util/getModuleDependencies' import { getModuleDependencies } from './util/getModuleDependencies'
import assert from 'assert' import assert from 'assert'
@ -112,6 +117,7 @@ const TRIGGER_CHARACTERS = [
// @apply and emmet-style // @apply and emmet-style
'.', '.',
// config/theme helper // config/theme helper
'(',
'[', '[',
// JIT "important" prefix // JIT "important" prefix
'!', '!',
@ -187,6 +193,7 @@ interface ProjectService {
onDocumentColor(params: DocumentColorParams): Promise<ColorInformation[]> onDocumentColor(params: DocumentColorParams): Promise<ColorInformation[]>
onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]> onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]>
onCodeAction(params: CodeActionParams): Promise<CodeAction[]> onCodeAction(params: CodeActionParams): Promise<CodeAction[]>
onDocumentLinks(params: DocumentLinkParams): DocumentLink[]
} }
type ProjectConfig = { folder: string; configPath?: string; documentSelector?: string[] } type ProjectConfig = { folder: string; configPath?: string; documentSelector?: string[] }
@ -298,6 +305,27 @@ async function createProjectService(
getDocumentSymbols: (uri: string) => { getDocumentSymbols: (uri: string) => {
return connection.sendRequest('@/tailwindCSS/getDocumentSymbols', { uri }) return connection.sendRequest('@/tailwindCSS/getDocumentSymbols', { uri })
}, },
async readDirectory(document, directory) {
try {
directory = path.resolve(path.dirname(getFileFsPath(document.uri)), directory)
let dirents = await fs.promises.readdir(directory, { withFileTypes: true })
let result: Array<[string, { isDirectory: boolean }] | null> = await Promise.all(
dirents.map(async (dirent) => {
let isDirectory = dirent.isDirectory()
return (await isExcluded(
state,
document,
path.join(directory, dirent.name, isDirectory ? '/' : '')
))
? null
: [dirent.name, { isDirectory }]
})
)
return result.filter((item) => item !== null)
} catch {
return []
}
},
}, },
} }
@ -1027,6 +1055,14 @@ async function createProjectService(
if (!settings.tailwindCSS.codeActions) return null if (!settings.tailwindCSS.codeActions) return null
return doCodeActions(state, params) return doCodeActions(state, params)
}, },
onDocumentLinks(params: DocumentLinkParams): DocumentLink[] {
if (!state.enabled) return null
let document = documentService.getDocument(params.textDocument.uri)
if (!document) return null
return getDocumentLinks(state, document, (linkPath) =>
URI.file(path.resolve(path.dirname(URI.parse(document.uri).fsPath), linkPath)).toString()
)
},
provideDiagnostics: debounce((document: TextDocument) => { provideDiagnostics: debounce((document: TextDocument) => {
if (!state.enabled) return if (!state.enabled) return
provideDiagnostics(state, document) provideDiagnostics(state, document)
@ -1146,105 +1182,117 @@ function isAtRule(node: Node): node is AtRule {
return node.type === 'atrule' return node.type === 'atrule'
} }
function getVariants(state: State): Record<string, string> { function getVariants(state: State): Array<Variant> {
if (state.jit) { if (state.jitContext?.getVariants) {
function escape(className: string): string { return state.jitContext.getVariants()
let node = state.modules.postcssSelectorParser.module.className() }
node.value = className
return dlv(node, 'raws.value', node.value)
}
let result = {} if (state.jit) {
let result: Array<Variant> = []
// [name, [sort, fn]] // [name, [sort, fn]]
// [name, [[sort, fn]]] // [name, [[sort, fn]]]
Array.from(state.jitContext.variantMap as Map<string, [any, any]>).forEach( Array.from(state.jitContext.variantMap as Map<string, [any, any]>).forEach(
([variantName, variantFnOrFns]) => { ([variantName, variantFnOrFns]) => {
let fns = (Array.isArray(variantFnOrFns[0]) ? variantFnOrFns : [variantFnOrFns]).map( result.push({
([_sort, fn]) => fn name: variantName,
) values: [],
isArbitrary: false,
let placeholder = '__variant_placeholder__' hasDash: true,
selectors: () => {
let root = state.modules.postcss.module.root({ function escape(className: string): string {
nodes: [ let node = state.modules.postcssSelectorParser.module.className()
state.modules.postcss.module.rule({ node.value = className
selector: `.${escape(placeholder)}`, return dlv(node, 'raws.value', node.value)
nodes: [],
}),
],
})
let classNameParser = state.modules.postcssSelectorParser.module((selectors) => {
return selectors.first.filter(({ type }) => type === 'class').pop().value
})
function getClassNameFromSelector(selector) {
return classNameParser.transformSync(selector)
}
function modifySelectors(modifierFunction) {
root.each((rule) => {
if (rule.type !== 'rule') {
return
} }
rule.selectors = rule.selectors.map((selector) => { let fns = (Array.isArray(variantFnOrFns[0]) ? variantFnOrFns : [variantFnOrFns]).map(
return modifierFunction({ ([_sort, fn]) => fn
get className() { )
return getClassNameFromSelector(selector)
}, let placeholder = '__variant_placeholder__'
selector,
}) let root = state.modules.postcss.module.root({
nodes: [
state.modules.postcss.module.rule({
selector: `.${escape(placeholder)}`,
nodes: [],
}),
],
}) })
})
return root
}
let definitions = [] let classNameParser = state.modules.postcssSelectorParser.module((selectors) => {
return selectors.first.filter(({ type }) => type === 'class').pop().value
})
for (let fn of fns) { function getClassNameFromSelector(selector) {
let definition: string return classNameParser.transformSync(selector)
let container = root.clone() }
let returnValue = fn({
container, function modifySelectors(modifierFunction) {
separator: state.separator, root.each((rule) => {
modifySelectors, if (rule.type !== 'rule') {
format: (def: string) => { return
definition = def.replace(/:merge\(([^)]+)\)/g, '$1') }
},
wrap: (rule: Container) => { rule.selectors = rule.selectors.map((selector) => {
if (isAtRule(rule)) { return modifierFunction({
definition = `@${rule.name} ${rule.params}` get className() {
return getClassNameFromSelector(selector)
},
selector,
})
})
})
return root
}
let definitions = []
for (let fn of fns) {
let definition: string
let container = root.clone()
let returnValue = fn({
container,
separator: state.separator,
modifySelectors,
format: (def: string) => {
definition = def.replace(/:merge\(([^)]+)\)/g, '$1')
},
wrap: (rule: Container) => {
if (isAtRule(rule)) {
definition = `@${rule.name} ${rule.params}`
}
},
})
if (!definition) {
definition = returnValue
} }
},
})
if (!definition) { if (definition) {
definition = returnValue definitions.push(definition)
} continue
}
if (definition) { container.walkDecls((decl) => {
definitions.push(definition) decl.remove()
continue })
}
container.walkDecls((decl) => { definition = container
decl.remove() .toString()
}) .replace(`.${escape(`${variantName}:${placeholder}`)}`, '&')
.replace(/(?<!\\)[{}]/g, '')
.replace(/\s*\n\s*/g, ' ')
.trim()
definition = container if (!definition.includes(placeholder)) {
.toString() definitions.push(definition)
.replace(`.${escape(`${variantName}:${placeholder}`)}`, '&') }
.replace(/(?<!\\)[{}]/g, '') }
.replace(/\s*\n\s*/g, ' ')
.trim()
if (!definition.includes(placeholder)) { return definitions
definitions.push(definition) },
} })
}
result[variantName] = definitions.join(', ') || null
} }
) )
@ -1276,7 +1324,13 @@ function getVariants(state: State): Record<string, string> {
}) })
}) })
return variants.reduce((obj, variant) => ({ ...obj, [variant]: null }), {}) return variants.map((variant) => ({
name: variant,
values: [],
isArbitrary: false,
hasDash: true,
selectors: () => [],
}))
} }
async function getPlugins(config: any) { async function getPlugins(config: any) {
@ -1484,6 +1538,7 @@ class TW {
this.connection.onDocumentColor(this.onDocumentColor.bind(this)) this.connection.onDocumentColor(this.onDocumentColor.bind(this))
this.connection.onColorPresentation(this.onColorPresentation.bind(this)) this.connection.onColorPresentation(this.onColorPresentation.bind(this))
this.connection.onCodeAction(this.onCodeAction.bind(this)) this.connection.onCodeAction(this.onCodeAction.bind(this))
this.connection.onDocumentLinks(this.onDocumentLinks.bind(this))
} }
private updateCapabilities() { private updateCapabilities() {
@ -1498,6 +1553,7 @@ class TW {
capabilities.add(HoverRequest.type, { documentSelector: null }) capabilities.add(HoverRequest.type, { documentSelector: null })
capabilities.add(DocumentColorRequest.type, { documentSelector: null }) capabilities.add(DocumentColorRequest.type, { documentSelector: null })
capabilities.add(CodeActionRequest.type, { documentSelector: null }) capabilities.add(CodeActionRequest.type, { documentSelector: null })
capabilities.add(DocumentLinkRequest.type, { documentSelector: null })
capabilities.add(CompletionRequest.type, { capabilities.add(CompletionRequest.type, {
documentSelector: null, documentSelector: null,
@ -1563,6 +1619,10 @@ class TW {
return this.getProject(params.textDocument)?.onCodeAction(params) ?? null return this.getProject(params.textDocument)?.onCodeAction(params) ?? null
} }
onDocumentLinks(params: DocumentLinkParams): DocumentLink[] {
return this.getProject(params.textDocument)?.onDocumentLinks(params) ?? null
}
listen() { listen() {
this.connection.listen() this.connection.listen()
} }
@ -1604,7 +1664,8 @@ function supportsDynamicRegistration(connection: Connection, params: InitializeP
params.capabilities.textDocument.hover?.dynamicRegistration && params.capabilities.textDocument.hover?.dynamicRegistration &&
params.capabilities.textDocument.colorProvider?.dynamicRegistration && params.capabilities.textDocument.colorProvider?.dynamicRegistration &&
params.capabilities.textDocument.codeAction?.dynamicRegistration && params.capabilities.textDocument.codeAction?.dynamicRegistration &&
params.capabilities.textDocument.completion?.dynamicRegistration params.capabilities.textDocument.completion?.dynamicRegistration &&
params.capabilities.textDocument.documentLink?.dynamicRegistration
) )
} }
@ -1629,6 +1690,7 @@ connection.onInitialize(async (params: InitializeParams): Promise<InitializeResu
hoverProvider: true, hoverProvider: true,
colorProvider: true, colorProvider: true,
codeActionProvider: true, codeActionProvider: true,
documentLinkProvider: {},
completionProvider: { completionProvider: {
resolveProvider: true, resolveProvider: true,
triggerCharacters: [...TRIGGER_CHARACTERS, ':'], triggerCharacters: [...TRIGGER_CHARACTERS, ':'],

View File

@ -4,9 +4,12 @@ import { State } from 'tailwindcss-language-service/src/util/state'
import { TextDocument } from 'vscode-languageserver-textdocument' import { TextDocument } from 'vscode-languageserver-textdocument'
import { getFileFsPath } from './uri' import { getFileFsPath } from './uri'
export default async function isExcluded(state: State, document: TextDocument): Promise<boolean> { export default async function isExcluded(
state: State,
document: TextDocument,
file: string = getFileFsPath(document.uri)
): Promise<boolean> {
let settings = await state.editor.getConfiguration(document.uri) let settings = await state.editor.getConfiguration(document.uri)
let file = getFileFsPath(document.uri)
for (let pattern of settings.tailwindCSS.files.exclude) { for (let pattern of settings.tailwindCSS.files.exclude) {
if (minimatch(file, path.join(state.editor.folder, pattern))) { if (minimatch(file, path.join(state.editor.folder, pattern))) {

View File

@ -110,7 +110,6 @@ export function completionsFromClassList(
} }
} }
let allVariants = Object.keys(state.variants)
let { variants: existingVariants, offset } = getVariantsFromClassName(state, partialClassName) let { variants: existingVariants, offset } = getVariantsFromClassName(state, partialClassName)
replacementRange.start.character += offset replacementRange.start.character += offset
@ -123,55 +122,109 @@ export function completionsFromClassList(
let items: CompletionItem[] = [] let items: CompletionItem[] = []
if (!important) { if (!important) {
let shouldSortVariants = !semver.gte(state.version, '2.99.0') let variantOrder = 0
function variantItem(
item: Omit<CompletionItem, 'kind' | 'data' | 'sortText' | 'textEdit'> & {
textEdit?: { newText: string; range?: Range }
}
): CompletionItem {
return {
kind: 9,
data: 'variant',
command:
item.insertTextFormat === 2 // Snippet
? undefined
: {
title: '',
command: 'editor.action.triggerSuggest',
},
sortText: '-' + naturalExpand(variantOrder++),
...item,
textEdit: {
newText: item.label,
range: replacementRange,
...item.textEdit,
},
}
}
items.push( items.push(
...Object.entries(state.variants) ...state.variants.flatMap((variant) => {
.filter(([variant]) => !existingVariants.includes(variant)) let items: CompletionItem[] = []
.map(([variant, definition], index) => {
let resultingVariants = [...existingVariants, variant] if (variant.isArbitrary) {
items.push(
variantItem({
label: `${variant.name}${variant.hasDash ? '-' : ''}[]${sep}`,
insertTextFormat: 2,
textEdit: {
newText: `${variant.name}${variant.hasDash ? '-' : ''}[\${1}]${sep}\${0}`,
},
// command: {
// title: '',
// command: 'tailwindCSS.onInsertArbitraryVariantSnippet',
// arguments: [variant.name, replacementRange],
// },
})
)
} else if (!existingVariants.includes(variant.name)) {
let shouldSortVariants = !semver.gte(state.version, '2.99.0')
let resultingVariants = [...existingVariants, variant.name]
if (shouldSortVariants) { if (shouldSortVariants) {
let allVariants = state.variants.map(({ name }) => name)
resultingVariants = resultingVariants.sort( resultingVariants = resultingVariants.sort(
(a, b) => allVariants.indexOf(b) - allVariants.indexOf(a) (a, b) => allVariants.indexOf(b) - allVariants.indexOf(a)
) )
} }
return { items.push(
label: variant + sep, variantItem({
kind: 9, label: `${variant.name}${sep}`,
detail: definition, detail: variant.selectors().join(', '),
data: 'variant', textEdit: {
command: { newText: resultingVariants[resultingVariants.length - 1] + sep,
title: '', },
command: 'editor.action.triggerSuggest', additionalTextEdits:
}, shouldSortVariants && resultingVariants.length > 1
sortText: '-' + naturalExpand(index), ? [
textEdit: { {
newText: resultingVariants[resultingVariants.length - 1] + sep, newText:
range: replacementRange, resultingVariants.slice(0, resultingVariants.length - 1).join(sep) +
}, sep,
additionalTextEdits: range: {
shouldSortVariants && resultingVariants.length > 1 start: {
? [ ...classListRange.start,
{ character: classListRange.end.character - partialClassName.length,
newText: },
resultingVariants.slice(0, resultingVariants.length - 1).join(sep) + sep, end: {
range: { ...replacementRange.start,
start: { character: replacementRange.start.character,
...classListRange.start, },
character: classListRange.end.character - partialClassName.length,
},
end: {
...replacementRange.start,
character: replacementRange.start.character,
}, },
}, },
}, ]
] : [],
: [], })
} as CompletionItem )
}) }
if (variant.values.length) {
items.push(
...variant.values
.filter((value) => !existingVariants.includes(`${variant.name}-${value}`))
.map((value) =>
variantItem({
label: `${variant.name}${variant.hasDash ? '-' : ''}${value}${sep}`,
detail: variant.selectors({ value }).join(', '),
})
)
)
}
return items
})
) )
} }
@ -503,6 +556,11 @@ function provideAtApplyCompletions(
) )
} }
const NUMBER_REGEX = /^(\d+\.?|\d*\.\d+)$/
function isNumber(str: string): boolean {
return NUMBER_REGEX.test(str)
}
async function provideClassNameCompletions( async function provideClassNameCompletions(
state: State, state: State,
document: TextDocument, document: TextDocument,
@ -537,14 +595,26 @@ function provideCssHelperCompletions(
const match = text const match = text
.substr(0, text.length - 1) // don't include that extra character from earlier .substr(0, text.length - 1) // don't include that extra character from earlier
.match(/\b(?<helper>config|theme)\(['"](?<keys>[^'"]*)$/) .match(/\b(?<helper>config|theme)\(\s*['"]?(?<path>[^)'"]*)$/)
if (match === null) { if (match === null) {
return null return null
} }
let alpha: string
let path = match.groups.path.replace(/^['"]+/g, '')
let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]*))$/)
if (matches) {
path = matches[1]
alpha = matches[2]
}
if (alpha !== undefined) {
return null
}
let base = match.groups.helper === 'config' ? state.config : dlv(state.config, 'theme', {}) let base = match.groups.helper === 'config' ? state.config : dlv(state.config, 'theme', {})
let parts = match.groups.keys.split(/([\[\].]+)/) let parts = path.split(/([\[\].]+)/)
let keys = parts.filter((_, i) => i % 2 === 0) let keys = parts.filter((_, i) => i % 2 === 0)
let separators = parts.filter((_, i) => i % 2 !== 0) let separators = parts.filter((_, i) => i % 2 !== 0)
// let obj = // let obj =
@ -557,7 +627,7 @@ function provideCssHelperCompletions(
} }
let obj: any let obj: any
let offset: number = 0 let offset: number = keys[keys.length - 1].length
let separator: string = separators.length ? separators[separators.length - 1] : null let separator: string = separators.length ? separators[separators.length - 1] : null
if (keys.length === 1) { if (keys.length === 1) {
@ -576,41 +646,73 @@ function provideCssHelperCompletions(
if (!obj) return null if (!obj) return null
let editRange = {
start: {
line: position.line,
character: position.character - offset,
},
end: position,
}
return { return {
isIncomplete: false, isIncomplete: false,
items: Object.keys(obj).map((item, index) => { items: Object.keys(obj)
let color = getColorFromValue(obj[item]) .sort((a, z) => {
const replaceDot: boolean = item.indexOf('.') !== -1 && separator && separator.endsWith('.') let aIsNumber = isNumber(a)
const insertClosingBrace: boolean = let zIsNumber = isNumber(z)
text.charAt(text.length - 1) !== ']' && if (aIsNumber && !zIsNumber) {
(replaceDot || (separator && separator.endsWith('['))) return -1
const detail = stringifyConfigValue(obj[item]) }
if (!aIsNumber && zIsNumber) {
return 1
}
if (aIsNumber && zIsNumber) {
return parseFloat(a) - parseFloat(z)
}
return 0
})
.map((item, index) => {
let color = getColorFromValue(obj[item])
const replaceDot: boolean = item.indexOf('.') !== -1 && separator && separator.endsWith('.')
const insertClosingBrace: boolean =
text.charAt(text.length - 1) !== ']' &&
(replaceDot || (separator && separator.endsWith('[')))
const detail = stringifyConfigValue(obj[item])
return { return {
label: item, label: item,
filterText: `${replaceDot ? '.' : ''}${item}`, sortText: naturalExpand(index),
sortText: naturalExpand(index), commitCharacters: [!item.includes('.') && '.', !item.includes('[') && '['].filter(
kind: color ? 16 : isObject(obj[item]) ? 9 : 10, Boolean
// VS Code bug causes some values to not display in some cases ),
detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail, kind: color ? 16 : isObject(obj[item]) ? 9 : 10,
documentation: // VS Code bug causes some values to not display in some cases
color && typeof color !== 'string' && (color.alpha ?? 1) !== 0 detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail,
? culori.formatRgb(color) documentation:
: null, color && typeof color !== 'string' && (color.alpha ?? 1) !== 0
textEdit: { ? culori.formatRgb(color)
newText: `${replaceDot ? '[' : ''}${item}${insertClosingBrace ? ']' : ''}`, : null,
range: { textEdit: {
start: { newText: `${item}${insertClosingBrace ? ']' : ''}`,
line: position.line, range: editRange,
character:
position.character - keys[keys.length - 1].length - (replaceDot ? 1 : 0) - offset,
},
end: position,
}, },
}, additionalTextEdits: replaceDot
data: 'helper', ? [
} {
}), newText: '[',
range: {
start: {
...editRange.start,
character: editRange.start.character - 1,
},
end: editRange.start,
},
},
]
: [],
data: 'helper',
}
}),
} }
} }
@ -741,7 +843,12 @@ function provideVariantsDirectiveCompletions(
if (/\s+/.test(parts[parts.length - 1])) return null if (/\s+/.test(parts[parts.length - 1])) return null
let possibleVariants = Object.keys(state.variants) let possibleVariants = state.variants.flatMap((variant) => {
if (variant.values.length) {
return variant.values.map((value) => `${variant.name}${variant.hasDash ? '-' : ''}${value}`)
}
return [variant.name]
})
const existingVariants = parts.slice(0, parts.length - 1) const existingVariants = parts.slice(0, parts.length - 1)
if (state.jit) { if (state.jit) {
@ -945,6 +1052,20 @@ function provideCssDirectiveCompletions(
}, },
}, },
]), ]),
...(semver.gte(state.version, '3.2.0')
? [
{
label: '@config',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
value: `[Tailwind CSS Documentation](${docsUrl(
state.version,
'functions-and-directives/#config'
)})`,
},
},
]
: []),
] ]
return { return {
@ -967,6 +1088,52 @@ function provideCssDirectiveCompletions(
} }
} }
async function provideConfigDirectiveCompletions(
state: State,
document: TextDocument,
position: Position
): Promise<CompletionList> {
if (!isCssContext(state, document, position)) {
return null
}
if (!semver.gte(state.version, '3.2.0')) {
return null
}
let text = document.getText({ start: { line: position.line, character: 0 }, end: position })
let match = text.match(/@config\s*(?<partial>'[^']*|"[^"]*)$/)
if (!match) {
return null
}
let partial = match.groups.partial.slice(1) // remove quote
let valueBeforeLastSlash = partial.substring(0, partial.lastIndexOf('/'))
let valueAfterLastSlash = partial.substring(partial.lastIndexOf('/') + 1)
return {
isIncomplete: false,
items: (await state.editor.readDirectory(document, valueBeforeLastSlash || '.'))
.filter(([name, type]) => type.isDirectory || /\.c?js$/.test(name))
.map(([name, type]) => ({
label: type.isDirectory ? name + '/' : name,
kind: type.isDirectory ? 19 : 17,
textEdit: {
newText: type.isDirectory ? name + '/' : name,
range: {
start: {
line: position.line,
character: position.character - valueAfterLastSlash.length,
},
end: position,
},
},
command: type.isDirectory
? { command: 'editor.action.triggerSuggest', title: '' }
: undefined,
})),
}
}
async function provideEmmetCompletions( async function provideEmmetCompletions(
state: State, state: State,
document: TextDocument, document: TextDocument,
@ -1055,6 +1222,7 @@ export async function doComplete(
provideVariantsDirectiveCompletions(state, document, position) || provideVariantsDirectiveCompletions(state, document, position) ||
provideTailwindDirectiveCompletions(state, document, position) || provideTailwindDirectiveCompletions(state, document, position) ||
provideLayerDirectiveCompletions(state, document, position) || provideLayerDirectiveCompletions(state, document, position) ||
(await provideConfigDirectiveCompletions(state, document, position)) ||
(await provideCustomClassNameCompletions(state, document, position)) (await provideCustomClassNameCompletions(state, document, position))
if (result) return result if (result) return result

View File

@ -1,16 +1,12 @@
import { State, Settings } from '../util/state' import { State, Settings } from '../util/state'
import type { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' import type { TextDocument } from 'vscode-languageserver'
import { InvalidConfigPathDiagnostic, DiagnosticKind } from './types' import { InvalidConfigPathDiagnostic, DiagnosticKind } from './types'
import { isCssDoc } from '../util/css' import { findHelperFunctionsInDocument } from '../util/find'
import { getLanguageBoundaries } from '../util/getLanguageBoundaries'
import { findAll, indexToPosition } from '../util/find'
import { stringToPath } from '../util/stringToPath' import { stringToPath } from '../util/stringToPath'
import isObject from '../util/isObject' import isObject from '../util/isObject'
import { closest } from '../util/closest' import { closest } from '../util/closest'
import { absoluteRange } from '../util/absoluteRange'
import { combinations } from '../util/combinations' import { combinations } from '../util/combinations'
import dlv from 'dlv' import dlv from 'dlv'
import { getTextWithoutComments } from '../util/doc'
function pathToString(path: string | string[]): string { function pathToString(path: string | string[]): string {
if (typeof path === 'string') return path if (typeof path === 'string') return path
@ -167,54 +163,24 @@ export function getInvalidConfigPathDiagnostics(
if (severity === 'ignore') return [] if (severity === 'ignore') return []
let diagnostics: InvalidConfigPathDiagnostic[] = [] let diagnostics: InvalidConfigPathDiagnostic[] = []
let ranges: Range[] = []
if (isCssDoc(state, document)) { findHelperFunctionsInDocument(state, document).forEach((helperFn) => {
ranges.push(undefined) let base = helperFn.helper === 'theme' ? ['theme'] : []
} else { let result = validateConfigPath(state, helperFn.path, base)
let boundaries = getLanguageBoundaries(state, document)
if (!boundaries) return []
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
}
ranges.forEach((range) => { if (result.isValid === true) {
let text = getTextWithoutComments(document, 'css', range) return
let matches = findAll( }
/(?<prefix>\s|^)(?<helper>config|theme)\((?<quote>['"])(?<key>[^)]+)\k<quote>[^)]*\)/g,
text
)
matches.forEach((match) => { diagnostics.push({
let base = match.groups.helper === 'theme' ? ['theme'] : [] code: DiagnosticKind.InvalidConfigPath,
let result = validateConfigPath(state, match.groups.key, base) range: helperFn.ranges.path,
severity:
if (result.isValid === true) { severity === 'error'
return null ? 1 /* DiagnosticSeverity.Error */
} : 2 /* DiagnosticSeverity.Warning */,
message: result.reason,
let startIndex = suggestions: result.suggestions,
match.index +
match.groups.prefix.length +
match.groups.helper.length +
1 + // open paren
match.groups.quote.length
diagnostics.push({
code: DiagnosticKind.InvalidConfigPath,
range: absoluteRange(
{
start: indexToPosition(text, startIndex),
end: indexToPosition(text, startIndex + match.groups.key.length),
},
range
),
severity:
severity === 'error'
? 1 /* DiagnosticSeverity.Error */
: 2 /* DiagnosticSeverity.Warning */,
message: result.reason,
suggestions: result.suggestions,
})
}) })
}) })

View File

@ -32,7 +32,12 @@ export function getInvalidVariantDiagnostics(
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range)) ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
} }
let possibleVariants = Object.keys(state.variants) let possibleVariants = state.variants.flatMap((variant) => {
if (variant.values.length) {
return variant.values.map((value) => `${variant.name}${variant.hasDash ? '-' : ''}${value}`)
}
return [variant.name]
})
if (state.jit) { if (state.jit) {
possibleVariants.unshift('responsive') possibleVariants.unshift('responsive')
possibleVariants = possibleVariants.filter((v) => !state.screens.includes(v)) possibleVariants = possibleVariants.filter((v) => !state.screens.includes(v))

View File

@ -36,12 +36,12 @@ export async function getDocumentColors(
let helperFns = findHelperFunctionsInDocument(state, document) let helperFns = findHelperFunctionsInDocument(state, document)
helperFns.forEach((fn) => { helperFns.forEach((fn) => {
let keys = stringToPath(fn.value) let keys = stringToPath(fn.path)
let base = fn.helper === 'theme' ? ['theme'] : [] let base = fn.helper === 'theme' ? ['theme'] : []
let value = dlv(state.config, [...base, ...keys]) let value = dlv(state.config, [...base, ...keys])
let color = getColorFromValue(value) let color = getColorFromValue(value)
if (color && typeof color !== 'string' && (color.alpha ?? 1) !== 0) { if (color && typeof color !== 'string' && (color.alpha ?? 1) !== 0) {
colors.push({ range: fn.valueRange, color: culoriColorToVscodeColor(color) }) colors.push({ range: fn.ranges.path, color: culoriColorToVscodeColor(color) })
} }
}) })

View File

@ -0,0 +1,57 @@
import { State } from './util/state'
import type { DocumentLink, Range, TextDocument } from 'vscode-languageserver'
import { isCssDoc } from './util/css'
import { getLanguageBoundaries } from './util/getLanguageBoundaries'
import { findAll, indexToPosition } from './util/find'
import { getTextWithoutComments } from './util/doc'
import { absoluteRange } from './util/absoluteRange'
import * as semver from './util/semver'
export function getDocumentLinks(
state: State,
document: TextDocument,
resolveTarget: (linkPath: string) => string
): DocumentLink[] {
return getConfigDirectiveLinks(state, document, resolveTarget)
}
function getConfigDirectiveLinks(
state: State,
document: TextDocument,
resolveTarget: (linkPath: string) => string
): DocumentLink[] {
if (!semver.gte(state.version, '3.2.0')) {
return []
}
let links: DocumentLink[] = []
let ranges: Range[] = []
if (isCssDoc(state, document)) {
ranges.push(undefined)
} else {
let boundaries = getLanguageBoundaries(state, document)
if (!boundaries) return []
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
}
for (let range of ranges) {
let text = getTextWithoutComments(document, 'css', range)
let matches = findAll(/@config\s*(?<path>'[^']+'|"[^"]+")/g, text)
for (let match of matches) {
links.push({
target: resolveTarget(match.groups.path.slice(1, -1)),
range: absoluteRange(
{
start: indexToPosition(text, match.index + match[0].length - match.groups.path.length),
end: indexToPosition(text, match.index + match[0].length),
},
range
),
})
}
}
return links
}

View File

@ -3,12 +3,12 @@ import type { Hover, TextDocument, Position } from 'vscode-languageserver'
import { stringifyCss, stringifyConfigValue } from './util/stringify' import { stringifyCss, stringifyConfigValue } from './util/stringify'
import dlv from 'dlv' import dlv from 'dlv'
import { isCssContext } from './util/css' import { isCssContext } from './util/css'
import { findClassNameAtPosition } from './util/find' import { findClassNameAtPosition, findHelperFunctionsInRange } from './util/find'
import { validateApply } from './util/validateApply' import { validateApply } from './util/validateApply'
import { getClassNameParts } from './util/getClassNameAtPosition' import { getClassNameParts } from './util/getClassNameAtPosition'
import * as jit from './util/jit' import * as jit from './util/jit'
import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostics' import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostics'
import { getTextWithoutComments } from './util/doc' import { isWithinRange } from './util/isWithinRange'
export async function doHover( export async function doHover(
state: State, state: State,
@ -22,49 +22,34 @@ export async function doHover(
} }
function provideCssHelperHover(state: State, document: TextDocument, position: Position): Hover { function provideCssHelperHover(state: State, document: TextDocument, position: Position): Hover {
if (!isCssContext(state, document, position)) return null if (!isCssContext(state, document, position)) {
const line = getTextWithoutComments(document, 'css').split('\n')[position.line]
const match = line.match(/(?<helper>theme|config)\((?<quote>['"])(?<key>[^)]+)\k<quote>[^)]*\)/)
if (match === null) return null
const startChar = match.index + match.groups.helper.length + 2
const endChar = startChar + match.groups.key.length
if (position.character < startChar || position.character >= endChar) {
return null return null
} }
let key = match.groups.key let helperFns = findHelperFunctionsInRange(document, {
.split(/(\[[^\]]+\]|\.)/) start: { line: position.line, character: 0 },
.filter(Boolean) end: { line: position.line + 1, character: 0 },
.filter((x) => x !== '.') })
.map((x) => x.replace(/^\[([^\]]+)\]$/, '$1'))
if (key.length === 0) return null for (let helperFn of helperFns) {
if (isWithinRange(position, helperFn.ranges.path)) {
if (match.groups.helper === 'theme') { let validated = validateConfigPath(
key = ['theme', ...key] state,
helperFn.path,
helperFn.helper === 'theme' ? ['theme'] : []
)
let value = validated.isValid ? stringifyConfigValue(validated.value) : null
if (value === null) {
return null
}
return {
contents: { kind: 'markdown', value: ['```plaintext', value, '```'].join('\n') },
range: helperFn.ranges.path,
}
}
} }
const value = validateConfigPath(state, key).isValid return null
? stringifyConfigValue(dlv(state.config, key))
: null
if (value === null) return null
return {
contents: { kind: 'markdown', value: ['```plaintext', value, '```'].join('\n') },
range: {
start: { line: position.line, character: startChar },
end: {
line: position.line,
character: endChar,
},
},
}
} }
async function provideClassNameHover( async function provideClassNameHover(

View File

@ -359,36 +359,48 @@ export function findHelperFunctionsInRange(
range?: Range range?: Range
): DocumentHelperFunction[] { ): DocumentHelperFunction[] {
const text = getTextWithoutComments(doc, 'css', range) const text = getTextWithoutComments(doc, 'css', range)
const matches = findAll( let matches = findAll(
/(?<before>^|\s)(?<helper>theme|config)\((?:(?<single>')([^']+)'|(?<double>")([^"]+)")[^)]*\)/gm, /(?<prefix>\s|^)(?<helper>config|theme)(?<innerPrefix>\(\s*)(?<path>[^)]*?)\s*\)/g,
text text
) )
return matches.map((match) => { return matches.map((match) => {
let value = match[4] || match[6] let quotesBefore = ''
let startIndex = match.index + match.groups.before.length let path = match.groups.path.replace(/['"]+$/, '').replace(/^['"]+/, (m) => {
quotesBefore = m
return ''
})
let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]+))$/)
if (matches) {
path = matches[1]
}
path = path.replace(/['"]*\s*$/, '')
let startIndex =
match.index +
match.groups.prefix.length +
match.groups.helper.length +
match.groups.innerPrefix.length
return { return {
full: match[0].substr(match.groups.before.length),
value,
helper: match.groups.helper === 'theme' ? 'theme' : 'config', helper: match.groups.helper === 'theme' ? 'theme' : 'config',
quotes: match.groups.single ? "'" : '"', path,
range: resolveRange( ranges: {
{ full: resolveRange(
start: indexToPosition(text, startIndex), {
end: indexToPosition(text, match.index + match[0].length), start: indexToPosition(text, startIndex),
}, end: indexToPosition(text, startIndex + match.groups.path.length),
range },
), range
valueRange: resolveRange( ),
{ path: resolveRange(
start: indexToPosition(text, startIndex + match.groups.helper.length + 1), {
end: indexToPosition( start: indexToPosition(text, startIndex + quotesBefore.length),
text, end: indexToPosition(text, startIndex + quotesBefore.length + path.length),
startIndex + match.groups.helper.length + 1 + 1 + value.length + 1 },
), range
}, ),
range },
),
} }
}) })
} }

View File

@ -5,17 +5,25 @@ export function getVariantsFromClassName(
state: State, state: State,
className: string className: string
): { variants: string[]; offset: number } { ): { variants: string[]; offset: number } {
let allVariants = Object.keys(state.variants) let allVariants = state.variants.flatMap((variant) => {
let parts = splitAtTopLevelOnly(className, state.separator).filter(Boolean) if (variant.values.length) {
return variant.values.map((value) => `${variant.name}${variant.hasDash ? '-' : ''}${value}`)
}
return [variant.name]
})
let variants = new Set<string>() let variants = new Set<string>()
let offset = 0 let offset = 0
let parts = splitAtTopLevelOnly(className, state.separator)
if (parts.length < 2) {
return { variants: Array.from(variants), offset }
}
parts = parts.filter(Boolean)
for (let part of parts) { for (let part of parts) {
if ( if (
allVariants.includes(part) || allVariants.includes(part) ||
(state.jit && (state.jit &&
((part.includes('[') && part.endsWith(']')) || ((part.includes('[') && part.endsWith(']')) || part.includes('/')) &&
(part.includes('<') && part.includes('>'))) &&
jit.generateRules(state, [`${part}${state.separator}[color:red]`]).rules.length > 0) jit.generateRules(state, [`${part}${state.separator}[color:red]`]).rules.length > 0)
) { ) {
variants.add(part) variants.add(part)

View File

@ -29,6 +29,10 @@ export type EditorState = {
} }
getConfiguration: (uri?: string) => Promise<Settings> getConfiguration: (uri?: string) => Promise<Settings>
getDocumentSymbols: (uri: string) => Promise<SymbolInformation[]> getDocumentSymbols: (uri: string) => Promise<SymbolInformation[]>
readDirectory: (
document: TextDocument,
directory: string
) => Promise<Array<[string, { isDirectory: boolean }]>>
} }
type DiagnosticSeveritySetting = 'ignore' | 'warning' | 'error' type DiagnosticSeveritySetting = 'ignore' | 'warning' | 'error'
@ -76,6 +80,14 @@ export interface FeatureFlags {
experimental: string[] experimental: string[]
} }
export interface Variant {
name: string
values: string[]
isArbitrary: boolean
hasDash: boolean
selectors: (params?: { value?: string; label?: string }) => string[]
}
export interface State { export interface State {
enabled: boolean enabled: boolean
configPath?: string configPath?: string
@ -86,7 +98,7 @@ export interface State {
dependencies?: string[] dependencies?: string[]
plugins?: any plugins?: any
screens?: string[] screens?: string[]
variants?: Record<string, string | null> variants?: Variant[]
corePlugins?: string[] corePlugins?: string[]
modules?: { modules?: {
tailwindcss?: { version: string; module: any } tailwindcss?: { version: string; module: any }
@ -124,12 +136,12 @@ export type DocumentClassName = {
} }
export type DocumentHelperFunction = { export type DocumentHelperFunction = {
full: string
helper: 'theme' | 'config' helper: 'theme' | 'config'
value: string path: string
quotes: '"' | "'" ranges: {
range: Range full: Range
valueRange: Range path: Range
}
} }
export type ClassNameMeta = { export type ClassNameMeta = {

View File

@ -26,6 +26,7 @@ import {
ProviderResult, ProviderResult,
SnippetString, SnippetString,
TextEdit, TextEdit,
TextEditorSelectionChangeKind,
} from 'vscode' } from 'vscode'
import { import {
LanguageClient, LanguageClient,
@ -149,6 +150,62 @@ export async function activate(context: ExtensionContext) {
}) })
) )
// context.subscriptions.push(
// commands.registerCommand(
// 'tailwindCSS.onInsertArbitraryVariantSnippet',
// (
// variantName: string,
// range: {
// start: { line: number; character: number }
// end: { line: number; character: number }
// }
// ) => {
// let listener = Window.onDidChangeTextEditorSelection((event) => {
// if (event.selections.length !== 1) {
// listener.dispose()
// return
// }
// let document = event.textEditor.document
// let selection = event.selections[0]
// let line = document.lineAt(range.start.line)
// let lineRangeFromCompletion = new Range(
// range.start.line,
// range.start.character,
// line.range.end.line,
// line.range.end.character
// )
// let lineText = document.getText(lineRangeFromCompletion)
// let match = lineText.match(/^(\S+)]:/)
// if (!match) {
// listener.dispose()
// return
// }
// let arbitraryValueRange = new Range(
// lineRangeFromCompletion.start.translate(0, variantName.length + 2),
// lineRangeFromCompletion.start.translate(0, match[1].length)
// )
// if (!arbitraryValueRange.contains(selection)) {
// listener.dispose()
// }
// if (
// event.kind === TextEditorSelectionChangeKind.Command &&
// selection.isEmpty &&
// selection.start.isEqual(arbitraryValueRange.end.translate(0, 2))
// ) {
// commands.executeCommand('editor.action.triggerSuggest')
// }
// })
// context.subscriptions.push(listener)
// }
// )
// )
let watcher = Workspace.createFileSystemWatcher(`**/${CONFIG_FILE_GLOB}`, false, true, true) let watcher = Workspace.createFileSystemWatcher(`**/${CONFIG_FILE_GLOB}`, false, true, true)
watcher.onDidCreate((uri) => { watcher.onDidCreate((uri) => {
@ -378,7 +435,11 @@ export async function activate(context: ExtensionContext) {
async resolveCompletionItem(item, token, next) { async resolveCompletionItem(item, token, next) {
let result = await next(item, token) let result = await next(item, token)
let selections = Window.activeTextEditor.selections let selections = Window.activeTextEditor.selections
if (selections.length > 1 && result.additionalTextEdits?.length > 0) { if (
result['data'] === 'variant' &&
selections.length > 1 &&
result.additionalTextEdits?.length > 0
) {
let length = let length =
selections[0].start.character - result.additionalTextEdits[0].range.start.character selections[0].start.character - result.additionalTextEdits[0].range.start.character
let prefixLength = let prefixLength =