Skip to content

Commit 90f75ef

Browse files
authored
feat(language-server): add @unocss/language-server (#5102)
1 parent 037ba01 commit 90f75ef

36 files changed

+1854
-1444
lines changed

.github/workflows/pkg.pr.new.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,4 @@ jobs:
3434
run: pnpm build
3535

3636
- name: Release
37-
run: pnpm dlx pkg-pr-new publish --compact --pnpm './packages-*/*' --only-templates --commentWithDev
37+
run: pnpm dlx pkg-pr-new publish --pnpm './packages-*/*' --only-templates --commentWithDev

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const aliasIntegrations: Record<string, string> = {
1919
'@unocss/eslint-config': r('./packages-integrations/eslint-config/src/'),
2020
'@unocss/eslint-plugin': r('./packages-integrations/eslint-plugin/src/'),
2121
'@unocss/inspector': r('./packages-integrations/inspector/src/'),
22+
'@unocss/language-server': r('./packages-integrations/language-server/src/'),
2223
'@unocss/nuxt': r('./packages-integrations/nuxt/src/'),
2324
'@unocss/postcss': r('./packages-integrations/postcss/src/'),
2425
'@unocss/postcss/esm': r('./packages-integrations/postcss/src/esm.ts'),
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
import '../dist/server.cjs'
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "@unocss/language-server",
3+
"type": "module",
4+
"version": "66.6.0",
5+
"description": "UnoCSS Language Server",
6+
"author": "Anthony Fu <anthonyfu117@hotmail.com>",
7+
"license": "MIT",
8+
"funding": "https://github.com/sponsors/antfu",
9+
"homepage": "https://unocss.dev",
10+
"repository": {
11+
"type": "git",
12+
"url": "git+https://github.com/unocss/unocss.git",
13+
"directory": "packages-integrations/language-server"
14+
},
15+
"bugs": {
16+
"url": "https://github.com/unocss/unocss/issues"
17+
},
18+
"keywords": [
19+
"unocss",
20+
"language-server",
21+
"lsp",
22+
"autocomplete"
23+
],
24+
"sideEffects": false,
25+
"exports": {
26+
".": "./dist/index.mjs",
27+
"./package.json": "./package.json"
28+
},
29+
"types": "./dist/index.d.mts",
30+
"bin": {
31+
"unocss-language-server": "./bin/unocss-language-server.js"
32+
},
33+
"files": [
34+
"bin",
35+
"dist"
36+
],
37+
"scripts": {
38+
"build": "tsdown --config-loader unrun",
39+
"dev": "tsdown --config-loader unrun --watch"
40+
},
41+
"dependencies": {
42+
"@unocss/autocomplete": "workspace:*",
43+
"@unocss/config": "workspace:*",
44+
"@unocss/core": "workspace:*",
45+
"@unocss/preset-wind3": "workspace:*",
46+
"prettier": "catalog:utils",
47+
"unconfig": "catalog:utils",
48+
"vscode-languageserver": "catalog:vscode",
49+
"vscode-languageserver-textdocument": "catalog:vscode"
50+
},
51+
"devDependencies": {
52+
"tsdown": "catalog:build"
53+
}
54+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { ColorInformation, ColorPresentation, Connection, TextDocuments } from 'vscode-languageserver'
2+
import type { TextDocument } from 'vscode-languageserver-textdocument'
3+
import type { ContextManager } from '../core/context'
4+
import type { ServerSettings } from '../types'
5+
import { fileURLToPath } from 'node:url'
6+
import { INCLUDE_COMMENT_IDE } from '#integration/constants'
7+
import { isCssId } from '#integration/utils'
8+
import { getMatchedPositionsFromDoc } from '../core/cache'
9+
import { getColorString, parseColorToRGBA } from '../utils/color'
10+
import { getCSS } from '../utils/css'
11+
12+
export function registerColorProvider(
13+
connection: Connection,
14+
documents: TextDocuments<TextDocument>,
15+
getContextManager: () => ContextManager | undefined,
16+
getSettings: () => ServerSettings,
17+
) {
18+
connection.onDocumentColor(async (params): Promise<ColorInformation[]> => {
19+
const settings = getSettings()
20+
if (!settings.colorPreview)
21+
return []
22+
23+
const contextManager = getContextManager()
24+
if (!contextManager)
25+
return []
26+
27+
const doc = documents.get(params.textDocument.uri)
28+
if (!doc)
29+
return []
30+
31+
const id = fileURLToPath(params.textDocument.uri)
32+
if (!contextManager.isTarget(id))
33+
return []
34+
35+
const code = doc.getText()
36+
if (!code)
37+
return []
38+
39+
const ctx = await contextManager.resolveClosestContext(code, id)
40+
if (!ctx)
41+
return []
42+
43+
const isTarget = ctx.filter(code, id)
44+
|| code.includes(INCLUDE_COMMENT_IDE)
45+
|| contextManager.configSources.includes(id)
46+
|| isCssId(id)
47+
48+
if (!isTarget)
49+
return []
50+
51+
const positions = await getMatchedPositionsFromDoc(
52+
ctx.uno,
53+
code,
54+
id,
55+
settings.strictAnnotationMatch,
56+
)
57+
58+
const isAttributify = ctx.uno.config.presets.some(i => i.name === '@unocss/preset-attributify')
59+
const colors: ColorInformation[] = []
60+
61+
for (const [start, end, className] of positions) {
62+
try {
63+
const css = await getCSS(ctx.uno, isAttributify ? [className, `[${className}=""]`] : className)
64+
const colorString = getColorString(css)
65+
if (!colorString)
66+
continue
67+
68+
const rgba = parseColorToRGBA(colorString)
69+
if (!rgba)
70+
continue
71+
72+
colors.push({
73+
range: {
74+
start: doc.positionAt(start),
75+
end: doc.positionAt(end),
76+
},
77+
color: rgba,
78+
})
79+
}
80+
catch {}
81+
}
82+
83+
return colors
84+
})
85+
86+
connection.onColorPresentation((params): ColorPresentation[] => {
87+
const { color } = params
88+
const r = Math.round(color.red * 255)
89+
const g = Math.round(color.green * 255)
90+
const b = Math.round(color.blue * 255)
91+
92+
return [
93+
{ label: `rgb(${r} ${g} ${b})` },
94+
]
95+
})
96+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type { UnocssAutocomplete } from '@unocss/autocomplete'
2+
import type { SuggestResult, UnocssPluginContext, UserConfig } from '@unocss/core'
3+
import type { CompletionItem, CompletionParams, Connection, TextDocuments } from 'vscode-languageserver'
4+
import type { TextDocument } from 'vscode-languageserver-textdocument'
5+
import type { ContextManager } from '../core/context'
6+
import type { ServerSettings } from '../types'
7+
import { fileURLToPath } from 'node:url'
8+
import { isCssId } from '#integration/utils'
9+
import { createAutocomplete } from '@unocss/autocomplete'
10+
import { CompletionItemKind } from 'vscode-languageserver'
11+
import { getColorString } from '../utils/color'
12+
import { getCSS, getPrettiedCSS, getPrettiedMarkdown } from '../utils/css'
13+
import { isVueWithPug, shouldProvideAutocomplete } from '../utils/position'
14+
15+
const autoCompletes = new Map<UnocssPluginContext, UnocssAutocomplete>()
16+
17+
function getAutocomplete(ctx: UnocssPluginContext, matchType: 'prefix' | 'fuzzy') {
18+
const cached = autoCompletes.get(ctx)
19+
if (cached)
20+
return cached
21+
22+
const autocomplete = createAutocomplete(ctx.uno, {
23+
matchType,
24+
throwErrors: false,
25+
})
26+
27+
autoCompletes.set(ctx, autocomplete)
28+
return autocomplete
29+
}
30+
31+
export function resetAutoCompleteCache(ctx?: UnocssPluginContext<UserConfig<any>>) {
32+
if (ctx)
33+
autoCompletes.delete(ctx)
34+
else
35+
autoCompletes.clear()
36+
}
37+
38+
async function getSuggestionResult({
39+
ctx,
40+
code,
41+
id,
42+
offset,
43+
matchType,
44+
}: {
45+
ctx: UnocssPluginContext
46+
code: string
47+
id: string
48+
offset: number
49+
matchType: 'prefix' | 'fuzzy'
50+
}) {
51+
const isPug = isVueWithPug(code, id)
52+
if (!ctx.filter(code, id) && !isCssId(id) && !isPug)
53+
return null
54+
55+
const autoComplete = getAutocomplete(ctx, matchType)
56+
57+
let result: SuggestResult | undefined
58+
59+
if (isPug) {
60+
const textBeforeCursor = code.substring(0, offset)
61+
const dotMatch = textBeforeCursor.match(/\.[-\w]*$/)
62+
63+
if (dotMatch) {
64+
const matched = dotMatch[0].substring(1)
65+
const suggestions = await autoComplete.suggest(matched || '')
66+
67+
if (suggestions.length) {
68+
result = {
69+
suggestions: suggestions.map(v => [v, v] as [string, string]),
70+
resolveReplacement: (suggestion: string) => ({
71+
start: offset - (matched?.length || 0),
72+
end: offset,
73+
replacement: suggestion,
74+
}),
75+
}
76+
}
77+
}
78+
else {
79+
result = await autoComplete.suggestInFile(code, offset)
80+
}
81+
}
82+
else {
83+
result = await autoComplete.suggestInFile(code, offset)
84+
}
85+
86+
return result
87+
}
88+
89+
export function registerCompletion(
90+
connection: Connection,
91+
documents: TextDocuments<TextDocument>,
92+
getContextManager: () => ContextManager | undefined,
93+
getSettings: () => ServerSettings,
94+
) {
95+
connection.onCompletion(async (params: CompletionParams) => {
96+
const settings = getSettings()
97+
const contextManager = getContextManager()
98+
if (!contextManager)
99+
return null
100+
101+
const doc = documents.get(params.textDocument.uri)
102+
if (!doc)
103+
return null
104+
105+
const id = fileURLToPath(params.textDocument.uri)
106+
if (!contextManager.isTarget(id))
107+
return null
108+
109+
const code = doc.getText()
110+
if (!code)
111+
return null
112+
113+
const ctx = await contextManager.resolveClosestContext(code, id)
114+
if (!ctx)
115+
return null
116+
117+
const offset = doc.offsetAt(params.position)
118+
119+
if (settings.autocompleteStrict && !shouldProvideAutocomplete(code, id, offset))
120+
return null
121+
122+
try {
123+
const result = await getSuggestionResult({
124+
ctx,
125+
code,
126+
id,
127+
offset,
128+
matchType: settings.autocompleteMatchType,
129+
})
130+
131+
if (!result || !result.suggestions.length)
132+
return null
133+
134+
const isAttributify = ctx.uno.config.presets.some(p => p.name === '@unocss/preset-attributify')
135+
const suggestions = result.suggestions.slice(0, settings.autocompleteMaxItems)
136+
137+
const items: CompletionItem[] = []
138+
for (let i = 0; i < suggestions.length; i++) {
139+
const [value, label] = suggestions[i]
140+
const css = await getCSS(ctx.uno, isAttributify ? [value, `[${value}=""]`] : value)
141+
const colorString = getColorString(css)
142+
const resolved = result.resolveReplacement(value)
143+
144+
const item: CompletionItem = {
145+
label,
146+
kind: colorString ? CompletionItemKind.Color : CompletionItemKind.EnumMember,
147+
sortText: colorString ? (/-\d$/.test(label) ? '1' : '2') : undefined,
148+
data: {
149+
value,
150+
uri: params.textDocument.uri,
151+
},
152+
textEdit: {
153+
range: {
154+
start: doc.positionAt(resolved.start),
155+
end: doc.positionAt(resolved.end),
156+
},
157+
newText: resolved.replacement,
158+
},
159+
}
160+
161+
if (colorString) {
162+
item.documentation = colorString
163+
}
164+
165+
items.push(item)
166+
}
167+
168+
return {
169+
isIncomplete: true,
170+
items,
171+
}
172+
}
173+
catch {
174+
return null
175+
}
176+
})
177+
178+
connection.onCompletionResolve(async (item: CompletionItem) => {
179+
if (!item.data?.uri || !item.data?.value)
180+
return item
181+
182+
const settings = getSettings()
183+
const contextManager = getContextManager()
184+
if (!contextManager)
185+
return item
186+
187+
const id = fileURLToPath(item.data.uri)
188+
const doc = documents.get(item.data.uri)
189+
if (!doc)
190+
return item
191+
192+
const code = doc.getText()
193+
const ctx = await contextManager.resolveClosestContext(code, id)
194+
if (!ctx)
195+
return item
196+
197+
const remToPxRatio = settings.remToPxPreview ? settings.remToPxRatio : -1
198+
199+
if (item.kind === CompletionItemKind.Color) {
200+
item.detail = await (await getPrettiedCSS(ctx.uno, item.data.value, remToPxRatio)).prettified
201+
}
202+
else {
203+
item.documentation = {
204+
kind: 'markdown',
205+
value: await getPrettiedMarkdown(ctx.uno, item.data.value, remToPxRatio),
206+
}
207+
}
208+
209+
return item
210+
})
211+
}

0 commit comments

Comments
 (0)