Skip to content

Commit a1c6dbf

Browse files
committed
Add completions for —value(…) and —modifier(…)
1 parent 7a6c19f commit a1c6dbf

File tree

5 files changed

+404
-13
lines changed

5 files changed

+404
-13
lines changed

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

Lines changed: 257 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type CompletionList,
88
type Position,
99
type CompletionContext,
10+
InsertTextFormat,
1011
} from 'vscode-languageserver'
1112
import type { TextDocument } from 'vscode-languageserver-textdocument'
1213
import dlv from 'dlv'
@@ -18,7 +19,7 @@ import { findLast, matchClassAttributes } from './util/find'
1819
import { stringifyConfigValue, stringifyCss } from './util/stringify'
1920
import { stringifyScreen, Screen } from './util/screens'
2021
import isObject from './util/isObject'
21-
import braceLevel from './util/braceLevel'
22+
import { braceLevel, parenLevel } from './util/braceLevel'
2223
import * as emmetHelper from 'vscode-emmet-helper-bundled'
2324
import { isValidLocationForEmmetAbbreviation } from './util/isValidLocationForEmmetAbbreviation'
2425
import { isJsDoc, isJsxContext } from './util/js'
@@ -41,6 +42,9 @@ import { IS_SCRIPT_SOURCE, IS_TEMPLATE_SOURCE } from './metadata/extensions'
4142
import * as postcss from 'postcss'
4243
import { findFileDirective } from './completions/file-paths'
4344
import type { ThemeEntry } from './util/v4'
45+
import { posix } from 'node:path/win32'
46+
import { segment } from './util/segment'
47+
import { resolveKnownThemeKeys, resolveKnownThemeNamespaces } from './util/v4/theme-keys'
4448

4549
let isUtil = (className) =>
4650
Array.isArray(className.__info)
@@ -1097,6 +1101,172 @@ function provideCssHelperCompletions(
10971101
)
10981102
}
10991103

1104+
function getCsstUtilityNameAtPosition(
1105+
state: State,
1106+
document: TextDocument,
1107+
position: Position,
1108+
): { root: string; kind: 'static' | 'functional' } | null {
1109+
if (!isCssContext(state, document, position)) return null
1110+
if (!isInsideAtRule('utility', document, position)) return null
1111+
1112+
let text = document.getText({
1113+
start: { line: 0, character: 0 },
1114+
end: position,
1115+
})
1116+
1117+
// Make sure we're in a functional utility block
1118+
let block = text.lastIndexOf(`@utility`)
1119+
if (block === -1) return null
1120+
1121+
let curly = text.indexOf('{', block)
1122+
if (curly === -1) return null
1123+
1124+
let root = text.slice(block + 8, curly).trim()
1125+
1126+
if (root.length === 0) return null
1127+
1128+
if (root.endsWith('-*')) {
1129+
root = root.slice(0, -2)
1130+
1131+
if (root.length === 0) return null
1132+
1133+
return { root, kind: 'functional' }
1134+
}
1135+
1136+
return { root: root, kind: 'static' }
1137+
}
1138+
1139+
function provideUtilityFunctionCompletions(
1140+
state: State,
1141+
document: TextDocument,
1142+
position: Position,
1143+
): CompletionList {
1144+
let utilityName = getCsstUtilityNameAtPosition(state, document, position)
1145+
if (!utilityName) return null
1146+
1147+
let text = document.getText({
1148+
start: { line: position.line, character: 0 },
1149+
end: position,
1150+
})
1151+
1152+
// Make sure we're in "value position"
1153+
// e.g. --foo: <cursor>
1154+
let pattern = /^[^:]+:[^;]*$/
1155+
if (!pattern.test(text)) return null
1156+
1157+
return withDefaults(
1158+
{
1159+
isIncomplete: false,
1160+
items: [
1161+
{
1162+
label: '--value()',
1163+
textEditText: '--value($1)',
1164+
sortText: '-00000',
1165+
insertTextFormat: InsertTextFormat.Snippet,
1166+
kind: CompletionItemKind.Function,
1167+
documentation: {
1168+
kind: 'markdown' as typeof MarkupKind.Markdown,
1169+
value: 'Reference a value based on the name of the utility. e.g. the `md` in `text-md`',
1170+
},
1171+
command: { command: 'editor.action.triggerSuggest', title: '' },
1172+
},
1173+
{
1174+
label: '--modifier()',
1175+
textEditText: '--modifier($1)',
1176+
sortText: '-00001',
1177+
insertTextFormat: InsertTextFormat.Snippet,
1178+
kind: CompletionItemKind.Function,
1179+
documentation: {
1180+
kind: 'markdown' as typeof MarkupKind.Markdown,
1181+
value: "Reference a value based on the utility's modifier. e.g. the `6` in `text-md/6`",
1182+
},
1183+
},
1184+
],
1185+
},
1186+
{
1187+
data: {
1188+
...(state.completionItemData ?? {}),
1189+
},
1190+
range: {
1191+
start: position,
1192+
end: position,
1193+
},
1194+
},
1195+
state.editor.capabilities.itemDefaults,
1196+
)
1197+
}
1198+
1199+
async function provideUtilityFunctionArgumentCompletions(
1200+
state: State,
1201+
document: TextDocument,
1202+
position: Position,
1203+
): Promise<CompletionList | null> {
1204+
let utilityName = getCsstUtilityNameAtPosition(state, document, position)
1205+
if (!utilityName) return null
1206+
1207+
let text = document.getText({
1208+
start: { line: position.line, character: 0 },
1209+
end: position,
1210+
})
1211+
1212+
// Look to see if we're inside --value() or --modifier()
1213+
let fn = null
1214+
let fnStart = 0
1215+
let valueIdx = text.lastIndexOf('--value(')
1216+
let modifierIdx = text.lastIndexOf('--modifier(')
1217+
let fnIdx = Math.max(valueIdx, modifierIdx)
1218+
if (fnIdx === -1) return null
1219+
1220+
if (fnIdx === valueIdx) {
1221+
fn = '--value'
1222+
} else if (fnIdx === modifierIdx) {
1223+
fn = '--modifier'
1224+
}
1225+
1226+
fnStart = fnIdx + fn.length + 1
1227+
1228+
// Make sure we're actaully inside the function
1229+
if (parenLevel(text.slice(fnIdx)) === 0) return null
1230+
1231+
let args = Array.from(await knownUtilityFunctionArguments(state, fn))
1232+
1233+
let parts = segment(text.slice(fnStart), ',').map((s) => s.trim())
1234+
1235+
// Only suggest at the start of the argument
1236+
if (parts.at(-1) !== '') return null
1237+
1238+
// Remove items that are already used
1239+
args = args.filter((arg) => !parts.includes(arg.name))
1240+
1241+
let items: CompletionItem[] = args.map((arg, idx) => ({
1242+
label: arg.name,
1243+
insertText: arg.name,
1244+
kind: CompletionItemKind.Constant,
1245+
sortText: naturalExpand(idx, args.length),
1246+
documentation: {
1247+
kind: 'markdown' as typeof MarkupKind.Markdown,
1248+
value: arg.description.replace(/\{utility\}-/g, `${utilityName.root}-`),
1249+
},
1250+
}))
1251+
1252+
return withDefaults(
1253+
{
1254+
isIncomplete: true,
1255+
items,
1256+
},
1257+
{
1258+
data: {
1259+
...(state.completionItemData ?? {}),
1260+
},
1261+
range: {
1262+
start: position,
1263+
end: position,
1264+
},
1265+
},
1266+
state.editor.capabilities.itemDefaults,
1267+
)
1268+
}
1269+
11001270
function provideTailwindDirectiveCompletions(
11011271
state: State,
11021272
document: TextDocument,
@@ -1871,6 +2041,8 @@ export async function doComplete(
18712041
const result =
18722042
(await provideClassNameCompletions(state, document, position, context)) ||
18732043
(await provideThemeDirectiveCompletions(state, document, position)) ||
2044+
provideUtilityFunctionArgumentCompletions(state, document, position) ||
2045+
provideUtilityFunctionCompletions(state, document, position) ||
18742046
provideCssHelperCompletions(state, document, position) ||
18752047
provideCssDirectiveCompletions(state, document, position) ||
18762048
provideScreenDirectiveCompletions(state, document, position) ||
@@ -2039,3 +2211,87 @@ async function getCssDetail(state: State, className: any): Promise<string> {
20392211
}
20402212
return null
20412213
}
2214+
2215+
type UtilityFn = '--value' | '--modifier'
2216+
2217+
interface UtilityFnArg {
2218+
name: string
2219+
description: string
2220+
}
2221+
2222+
async function knownUtilityFunctionArguments(state: State, fn: UtilityFn): Promise<UtilityFnArg[]> {
2223+
if (!state.designSystem) return []
2224+
2225+
let args: UtilityFnArg[] = []
2226+
2227+
let namespaces = resolveKnownThemeNamespaces(state.designSystem)
2228+
2229+
for (let ns of namespaces) {
2230+
args.push({
2231+
name: `${ns}-*`,
2232+
description: `Support theme values from \`${ns}-*\``,
2233+
})
2234+
}
2235+
2236+
args.push({
2237+
name: 'integer',
2238+
description: 'Support integer values, e.g. `{utility}-6`',
2239+
})
2240+
2241+
args.push({
2242+
name: 'number',
2243+
description:
2244+
'Support numeric values in increments of 0.25, e.g. `{utility}-6` and `{utility}-7.25`',
2245+
})
2246+
2247+
args.push({
2248+
name: 'percentage',
2249+
description: 'Support integer percentage values, e.g. `{utility}-50%` and `{utility}-21%`',
2250+
})
2251+
2252+
if (fn === '--value') {
2253+
args.push({
2254+
name: 'ratio',
2255+
description: 'Support fractions, e.g. `{utility}-1/5` and `{utility}-16/9`',
2256+
})
2257+
}
2258+
2259+
args.push({
2260+
name: '[integer]',
2261+
description: 'Support arbitrary integer values, e.g. `{utility}-[123]`',
2262+
})
2263+
2264+
args.push({
2265+
name: '[number]',
2266+
description: 'Support arbitrary numeric values, e.g. `{utility}-[10]` and `{utility}-[10.234]`',
2267+
})
2268+
2269+
args.push({
2270+
name: '[percentage]',
2271+
description:
2272+
'Support arbitrary percentage values, e.g. `{utility}-[10%]` and `{utility}-[10.234%]`',
2273+
})
2274+
2275+
args.push({
2276+
name: '[ratio]',
2277+
description: 'Support arbitrary fractions, e.g. `{utility}-[1/5]` and `{utility}-[16/9]`',
2278+
})
2279+
2280+
args.push({
2281+
name: '[color]',
2282+
description:
2283+
'Support arbitrary color values, e.g. `{utility}-[#639]` and `{utility}-[oklch(44.03% 0.1603 303.37)]`',
2284+
})
2285+
2286+
args.push({
2287+
name: '[angle]',
2288+
description: 'Support arbitrary angle, e.g. `{utility}-[12deg]` and `{utility}-[0.21rad]`',
2289+
})
2290+
2291+
args.push({
2292+
name: '[url]',
2293+
description: "Support arbitrary URL functions, e.g. `{utility}-['url(…)']`",
2294+
})
2295+
2296+
return args
2297+
}

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

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { stringToPath } from '../util/stringToPath'
55
import isObject from '../util/isObject'
66
import { closest, distance } from '../util/closest'
77
import { combinations } from '../util/combinations'
8+
import { resolveKnownThemeKeys } from '../util/v4/theme-keys'
89
import dlv from 'dlv'
910
import type { TextDocument } from 'vscode-languageserver-textdocument'
1011
import type { DesignSystem } from '../util/v4'
@@ -228,17 +229,6 @@ function resolveThemeValue(design: DesignSystem, path: string) {
228229
return value
229230
}
230231

231-
function resolveKnownThemeKeys(design: DesignSystem): string[] {
232-
let validThemeKeys = Array.from(design.theme.entries(), ([key]) => key)
233-
234-
let prefixLength = design.theme.prefix?.length ?? 0
235-
236-
return prefixLength > 0
237-
? // Strip the configured prefix from the list of valid theme keys
238-
validThemeKeys.map((key) => `--${key.slice(prefixLength + 3)}`)
239-
: validThemeKeys
240-
}
241-
242232
function validateV4ThemePath(state: State, path: string): ValidationResult {
243233
let prefix = state.designSystem.theme.prefix ?? null
244234

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export default function braceLevel(text: string) {
1+
export function braceLevel(text: string) {
22
let count = 0
33

44
for (let i = text.length - 1; i >= 0; i--) {
@@ -9,3 +9,15 @@ export default function braceLevel(text: string) {
99

1010
return count
1111
}
12+
13+
export function parenLevel(text: string) {
14+
let count = 0
15+
16+
for (let i = text.length - 1; i >= 0; i--) {
17+
let char = text.charCodeAt(i)
18+
19+
count += Number(char === 0x28 /* ( */) - Number(char === 0x29 /* ) */)
20+
}
21+
22+
return count
23+
}

0 commit comments

Comments
 (0)