Skip to content

Commit cdda0cb

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

File tree

3 files changed

+334
-2
lines changed

3 files changed

+334
-2
lines changed

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

Lines changed: 219 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,8 @@ 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'
4447

4548
let isUtil = (className) =>
4649
Array.isArray(className.__info)
@@ -1097,6 +1100,219 @@ function provideCssHelperCompletions(
10971100
)
10981101
}
10991102

1103+
function getCsstUtilityNameAtPosition(
1104+
state: State,
1105+
document: TextDocument,
1106+
position: Position,
1107+
): { root: string; kind: 'static' | 'functional' } | null {
1108+
if (!isCssContext(state, document, position)) return null
1109+
if (!isInsideAtRule('utility', document, position)) return null
1110+
1111+
let text = document.getText({
1112+
start: { line: 0, character: 0 },
1113+
end: position,
1114+
})
1115+
1116+
// Make sure we're in a functional utility block
1117+
let block = text.lastIndexOf(`@utility`)
1118+
if (block === -1) return null
1119+
1120+
let curly = text.indexOf('{', block)
1121+
if (curly === -1) return null
1122+
1123+
let root = text.slice(block + 8, curly).trim()
1124+
1125+
if (root.length === 0) return null
1126+
1127+
if (root.endsWith('-*')) {
1128+
root = root.slice(0, -2)
1129+
1130+
if (root.length === 0) return null
1131+
1132+
return { root, kind: 'functional' }
1133+
}
1134+
1135+
return { root: root, kind: 'static' }
1136+
}
1137+
1138+
function provideUtilityFunctionCompletions(
1139+
state: State,
1140+
document: TextDocument,
1141+
position: Position,
1142+
): CompletionList {
1143+
let utilityName = getCsstUtilityNameAtPosition(state, document, position)
1144+
if (!utilityName) return null
1145+
1146+
let text = document.getText({
1147+
start: { line: position.line, character: 0 },
1148+
end: position,
1149+
})
1150+
1151+
// Make sure we're in "value position"
1152+
// e.g. --foo: <cursor>
1153+
let pattern = /^[^:]+:[^;]*$/
1154+
if (!pattern.test(text)) return null
1155+
1156+
return withDefaults(
1157+
{
1158+
isIncomplete: false,
1159+
items: [
1160+
{
1161+
label: '--value()',
1162+
textEditText: '--value($1)',
1163+
sortText: '-00000',
1164+
insertTextFormat: InsertTextFormat.Snippet,
1165+
kind: CompletionItemKind.Function,
1166+
documentation: {
1167+
kind: 'markdown' as typeof MarkupKind.Markdown,
1168+
value: 'Reference a value based on the name of the utility. e.g. the `md` in `text-md`',
1169+
},
1170+
command: { command: 'editor.action.triggerSuggest', title: '' },
1171+
},
1172+
{
1173+
label: '--modifier()',
1174+
textEditText: '--modifier($1)',
1175+
sortText: '-00001',
1176+
insertTextFormat: InsertTextFormat.Snippet,
1177+
kind: CompletionItemKind.Function,
1178+
documentation: {
1179+
kind: 'markdown' as typeof MarkupKind.Markdown,
1180+
value: "Reference a value based on the utility's modifier. e.g. the `6` in `text-md/6`",
1181+
},
1182+
},
1183+
],
1184+
},
1185+
{
1186+
data: {
1187+
...(state.completionItemData ?? {}),
1188+
},
1189+
range: {
1190+
start: position,
1191+
end: position,
1192+
},
1193+
},
1194+
state.editor.capabilities.itemDefaults,
1195+
)
1196+
}
1197+
1198+
function provideUtilityFunctionArgumentCompletions(
1199+
state: State,
1200+
document: TextDocument,
1201+
position: Position,
1202+
): CompletionList {
1203+
let utilityName = getCsstUtilityNameAtPosition(state, document, position)
1204+
if (!utilityName) return null
1205+
1206+
let text = document.getText({
1207+
start: { line: position.line, character: 0 },
1208+
end: position,
1209+
})
1210+
1211+
// Look to see if we're inside --value() or --modifier()
1212+
let fn = null
1213+
let fnStart = 0
1214+
let valueIdx = text.lastIndexOf('--value(')
1215+
let modifierIdx = text.lastIndexOf('--modifier(')
1216+
let fnIdx = Math.max(valueIdx, modifierIdx)
1217+
if (fnIdx === -1) return null
1218+
1219+
if (fnIdx === valueIdx) {
1220+
fn = '--value'
1221+
} else if (fnIdx === modifierIdx) {
1222+
fn = '--modifier'
1223+
}
1224+
1225+
fnStart = fnIdx + fn.length + 1
1226+
1227+
// Make sure we're actaully inside the function
1228+
if (parenLevel(text.slice(fnIdx)) === 0) return null
1229+
1230+
let items: CompletionItem[] = [
1231+
{
1232+
label: 'integer',
1233+
insertText: `integer`,
1234+
kind: CompletionItemKind.Constant,
1235+
documentation: {
1236+
kind: 'markdown' as typeof MarkupKind.Markdown,
1237+
value: 'Support integer values, e.g. `placeholder-6`',
1238+
},
1239+
},
1240+
{
1241+
label: 'number',
1242+
insertText: `number`,
1243+
kind: CompletionItemKind.Constant,
1244+
documentation: {
1245+
kind: 'markdown' as typeof MarkupKind.Markdown,
1246+
value:
1247+
'Support numeric values in increments of 0.25, e.g. `placeholder-6` and `placeholder-7.25`',
1248+
},
1249+
},
1250+
{
1251+
label: 'percentage',
1252+
insertText: `percentage`,
1253+
kind: CompletionItemKind.Constant,
1254+
documentation: {
1255+
kind: 'markdown' as typeof MarkupKind.Markdown,
1256+
value: 'Support integer percentage values, e.g. `placeholder-50%` and `placeholder-21%`',
1257+
},
1258+
},
1259+
]
1260+
1261+
if (fn === '--value') {
1262+
items.push({
1263+
label: 'ratio',
1264+
insertText: `ratio`,
1265+
kind: CompletionItemKind.Constant,
1266+
documentation: {
1267+
kind: 'markdown' as typeof MarkupKind.Markdown,
1268+
value: 'Support fractions, e.g. `placeholder-1/5` and `placeholder-16/9`',
1269+
},
1270+
})
1271+
}
1272+
1273+
let parts = segment(text.slice(fnStart), ',').map((s) => s.trim())
1274+
1275+
// Only suggest at the start of the argument
1276+
if (parts.at(-1) !== '') return null
1277+
1278+
// Remove items that are already used
1279+
items = items.filter((item) => !parts.includes(item.label))
1280+
1281+
for (let [idx, item] of items.entries()) {
1282+
item.sortText = naturalExpand(idx, items.length)
1283+
1284+
if (typeof item.documentation === 'string') {
1285+
item.documentation = item.documentation.replace(/placeholder-/g, `${utilityName.root}-`)
1286+
} else {
1287+
item.documentation.value = item.documentation.value.replace(
1288+
/placeholder-/g,
1289+
`${utilityName.root}-`,
1290+
)
1291+
}
1292+
1293+
// TODO: Add a `, ` prefix to additional arguments automatically
1294+
// Doing so requires using `textEditText` + bookkeeping to make sure the
1295+
// output isn't mangled when the user has typed part of the argument
1296+
}
1297+
1298+
return withDefaults(
1299+
{
1300+
isIncomplete: true,
1301+
items,
1302+
},
1303+
{
1304+
data: {
1305+
...(state.completionItemData ?? {}),
1306+
},
1307+
range: {
1308+
start: position,
1309+
end: position,
1310+
},
1311+
},
1312+
state.editor.capabilities.itemDefaults,
1313+
)
1314+
}
1315+
11001316
function provideTailwindDirectiveCompletions(
11011317
state: State,
11021318
document: TextDocument,
@@ -1871,6 +2087,8 @@ export async function doComplete(
18712087
const result =
18722088
(await provideClassNameCompletions(state, document, position, context)) ||
18732089
(await provideThemeDirectiveCompletions(state, document, position)) ||
2090+
provideUtilityFunctionArgumentCompletions(state, document, position) ||
2091+
provideUtilityFunctionCompletions(state, document, position) ||
18742092
provideCssHelperCompletions(state, document, position) ||
18752093
provideCssDirectiveCompletions(state, document, position) ||
18762094
provideScreenDirectiveCompletions(state, document, position) ||

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+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
const BACKSLASH = 0x5c
2+
const OPEN_CURLY = 0x7b
3+
const CLOSE_CURLY = 0x7d
4+
const OPEN_PAREN = 0x28
5+
const CLOSE_PAREN = 0x29
6+
const OPEN_BRACKET = 0x5b
7+
const CLOSE_BRACKET = 0x5d
8+
const DOUBLE_QUOTE = 0x22
9+
const SINGLE_QUOTE = 0x27
10+
11+
// This is a shared buffer that is used to keep track of the current nesting level
12+
// of parens, brackets, and braces. It is used to determine if a character is at
13+
// the top-level of a string. This is a performance optimization to avoid memory
14+
// allocations on every call to `segment`.
15+
const closingBracketStack = new Uint8Array(256)
16+
17+
/**
18+
* This splits a string on a top-level character.
19+
*
20+
* Regex doesn't support recursion (at least not the JS-flavored version),
21+
* so we have to use a tiny state machine to keep track of paren placement.
22+
*
23+
* Expected behavior using commas:
24+
* var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
25+
* ┬ ┬ ┬ ┬
26+
* x x x ╰──────── Split because top-level
27+
* ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens
28+
*/
29+
export function segment(input: string, separator: string) {
30+
// SAFETY: We can use an index into a shared buffer because this function is
31+
// synchronous, non-recursive, and runs in a single-threaded environment.
32+
let stackPos = 0
33+
let parts: string[] = []
34+
let lastPos = 0
35+
let len = input.length
36+
37+
let separatorCode = separator.charCodeAt(0)
38+
39+
for (let idx = 0; idx < len; idx++) {
40+
let char = input.charCodeAt(idx)
41+
42+
if (stackPos === 0 && char === separatorCode) {
43+
parts.push(input.slice(lastPos, idx))
44+
lastPos = idx + 1
45+
continue
46+
}
47+
48+
switch (char) {
49+
case BACKSLASH:
50+
// The next character is escaped, so we skip it.
51+
idx += 1
52+
break
53+
// Strings should be handled as-is until the end of the string. No need to
54+
// worry about balancing parens, brackets, or curlies inside a string.
55+
case SINGLE_QUOTE:
56+
case DOUBLE_QUOTE:
57+
// Ensure we don't go out of bounds.
58+
while (++idx < len) {
59+
let nextChar = input.charCodeAt(idx)
60+
61+
// The next character is escaped, so we skip it.
62+
if (nextChar === BACKSLASH) {
63+
idx += 1
64+
continue
65+
}
66+
67+
if (nextChar === char) {
68+
break
69+
}
70+
}
71+
break
72+
case OPEN_PAREN:
73+
closingBracketStack[stackPos] = CLOSE_PAREN
74+
stackPos++
75+
break
76+
case OPEN_BRACKET:
77+
closingBracketStack[stackPos] = CLOSE_BRACKET
78+
stackPos++
79+
break
80+
case OPEN_CURLY:
81+
closingBracketStack[stackPos] = CLOSE_CURLY
82+
stackPos++
83+
break
84+
case CLOSE_BRACKET:
85+
case CLOSE_CURLY:
86+
case CLOSE_PAREN:
87+
if (stackPos > 0 && char === closingBracketStack[stackPos - 1]) {
88+
// SAFETY: The buffer does not need to be mutated because the stack is
89+
// only ever read from or written to its current position. Its current
90+
// position is only ever incremented after writing to it. Meaning that
91+
// the buffer can be dirty for the next use and still be correct since
92+
// reading/writing always starts at position `0`.
93+
stackPos--
94+
}
95+
break
96+
}
97+
}
98+
99+
parts.push(input.slice(lastPos))
100+
101+
return parts
102+
}

0 commit comments

Comments
 (0)