Skip to content

Commit 4296266

Browse files
committed
Add a virtual document abstraction to the language service
Most relevant requests are document related in some manner and this will serve a few purposes: - It centralizes logic common to all providers - It can help simplify the implementation of providers - It reduces duplication by moving common actions into the document creation layer (e.g. loading settings)
1 parent 2f92433 commit 4296266

File tree

4 files changed

+231
-16
lines changed

4 files changed

+231
-16
lines changed
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import type { Position, TextDocument } from 'vscode-languageserver-textdocument'
2+
import type {
3+
DocumentClassList,
4+
DocumentClassName,
5+
DocumentHelperFunction,
6+
Settings,
7+
State,
8+
} from '../util/state'
9+
import type { ServiceOptions } from '../service'
10+
import { isWithinRange } from '../util/isWithinRange'
11+
import { getDocumentBlocks, type LanguageBlock } from '../util/language-blocks'
12+
import {
13+
findClassListsInCssRange,
14+
findClassListsInHtmlRange,
15+
findCustomClassLists,
16+
findHelperFunctionsInDocument,
17+
findHelperFunctionsInRange,
18+
getClassNamesInClassList,
19+
} from '../util/find'
20+
import { dedupeBySpan } from '../util/array'
21+
22+
export interface Document {
23+
readonly state: State
24+
readonly version: number
25+
readonly uri: string
26+
readonly settings: Settings
27+
readonly storage: TextDocument
28+
29+
/**
30+
* Find the language block that contains the cursor
31+
*/
32+
blockAt(cursor: Position): LanguageBlock | null
33+
34+
/**
35+
* Find all class lists in the document
36+
*/
37+
classLists(): Iterable<DocumentClassList>
38+
39+
/**
40+
* Find all class lists at a given cursor position
41+
*/
42+
classListsAt(cursor: Position): Iterable<DocumentClassList>
43+
44+
/**
45+
* Find all class names in the document
46+
*/
47+
classNames(): Iterable<DocumentClassName>
48+
49+
/**
50+
* Find all class names at a given cursor position
51+
*
52+
* Theoretically, this function should only ever contain one entry
53+
* but the presence of custom regexes may produce multiple entries
54+
*/
55+
classNamesAt(cursor: Position): Iterable<DocumentClassName>
56+
57+
/**
58+
* Find all helper functions in the document
59+
*
60+
* This only applies to CSS contexts. Other document types will produce
61+
* zero entries.
62+
*/
63+
helperFns(): Iterable<DocumentHelperFunction>
64+
65+
/**
66+
* Find all helper functions at a given cursor position
67+
*/
68+
helperFnsAt(cursor: Position): Iterable<DocumentHelperFunction>
69+
}
70+
71+
export async function createVirtualDocument(
72+
opts: ServiceOptions,
73+
storage: TextDocument,
74+
): Promise<Document> {
75+
/**
76+
* The state of the server at the time of creation
77+
*/
78+
let state = opts.state()
79+
80+
/**
81+
* The current settings for this document
82+
*/
83+
let settings = await state.editor.getConfiguration(storage.uri)
84+
85+
/**
86+
* Conceptual boundaries of the document where different languages are used
87+
*
88+
* This is used to determine how the document is structured and what parts
89+
* are relevant to the current operation.
90+
*/
91+
let blocks = getDocumentBlocks(state, storage)
92+
93+
/**
94+
* All class lists in the document
95+
*/
96+
let classLists: DocumentClassList[] = []
97+
98+
for (let block of blocks) {
99+
if (block.context === 'css') {
100+
classLists.push(...findClassListsInCssRange(state, storage, block.range, block.lang))
101+
} else if (block.context === 'html') {
102+
classLists.push(...(await findClassListsInHtmlRange(state, storage, 'html', block.range)))
103+
} else if (block.context === 'js') {
104+
classLists.push(...(await findClassListsInHtmlRange(state, storage, 'jsx', block.range)))
105+
}
106+
}
107+
108+
classLists.push(...(await findCustomClassLists(state, storage)))
109+
110+
classLists.sort((a, b) => a.span[0] - b.span[0] || b.span[1] - a.span[1])
111+
classLists = dedupeBySpan(classLists)
112+
113+
/**
114+
* All class names in the document
115+
*/
116+
let classNames: DocumentClassName[] = []
117+
118+
for (let classList of classLists) {
119+
classNames.push(...getClassNamesInClassList(classList, state.blocklist ?? []))
120+
}
121+
122+
classNames.sort((a, b) => a.span[0] - b.span[0] || b.span[1] - a.span[1])
123+
classNames = dedupeBySpan(classNames)
124+
125+
/**
126+
* Helper functions in CSS
127+
*/
128+
let helperFns: DocumentHelperFunction[] = []
129+
130+
for (let block of blocks) {
131+
if (block.context === 'css') {
132+
helperFns.push(...findHelperFunctionsInRange(storage, block.range))
133+
}
134+
}
135+
136+
function blockAt(cursor: Position): LanguageBlock | null {
137+
for (let block of blocks) {
138+
if (isWithinRange(cursor, block.range)) {
139+
return block
140+
}
141+
}
142+
143+
return null
144+
}
145+
146+
/**
147+
* Find all class lists at a given cursor position
148+
*/
149+
function classListsAt(cursor: Position): DocumentClassList[] {
150+
return classLists.filter((classList) => isWithinRange(cursor, classList.range))
151+
}
152+
153+
/**
154+
* Find all class names at a given cursor position
155+
*/
156+
function classNamesAt(cursor: Position): DocumentClassName[] {
157+
return classNames.filter((className) => isWithinRange(cursor, className.range))
158+
}
159+
160+
/**
161+
* Find all class names at a given cursor position
162+
*/
163+
function helperFnsAt(cursor: Position): DocumentHelperFunction[] {
164+
return helperFns.filter((fn) => isWithinRange(cursor, fn.ranges.full))
165+
}
166+
167+
return {
168+
settings,
169+
storage,
170+
uri: storage.uri,
171+
172+
get version() {
173+
return storage.version
174+
},
175+
176+
get state() {
177+
return opts.state()
178+
},
179+
180+
blockAt,
181+
182+
classLists: () => classLists.slice(),
183+
classListsAt,
184+
classNames: () => classNames.slice(),
185+
classNamesAt,
186+
187+
helperFns: () => helperFns.slice(),
188+
helperFnsAt,
189+
}
190+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { TextDocument } from 'vscode-languageserver-textdocument'
2+
import type { ServiceOptions } from '../service'
3+
import { createVirtualDocument, type Document } from './document'
4+
5+
export function createDocumentStore(opts: ServiceOptions) {
6+
let documents = new Map<string, [version: number, doc: Document]>()
7+
8+
return {
9+
clear: () => documents.clear(),
10+
11+
async parse(uri: string | TextDocument) {
12+
let textDoc = typeof uri === 'string' ? await opts.fs.document(uri) : uri
13+
14+
// Return from the cache if the document has not changed
15+
let found = documents.get(textDoc.uri)
16+
if (found && found[0] === textDoc.version) return found[1]
17+
18+
let doc = await createVirtualDocument(opts, textDoc)
19+
20+
documents.set(textDoc.uri, [textDoc.version, doc])
21+
22+
return doc
23+
},
24+
}
25+
}

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

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { doCodeActions } from './codeActions/codeActionProvider'
2828
import { provideColorPresentation } from './colorPresentationProvider'
2929
import { getColor, KeywordColor } from './util/color'
3030
import * as culori from 'culori'
31+
import { Document } from './documents/document'
32+
import { createDocumentStore } from './documents/store'
3133

3234
export interface ServiceOptions {
3335
fs: FileSystem
@@ -58,12 +60,10 @@ export interface LanguageService {
5860
}
5961

6062
export function createLanguageService(opts: ServiceOptions): LanguageService {
61-
async function open(doc: TextDocument | string) {
62-
if (typeof doc === 'string') {
63-
doc = await opts.fs.document(doc)
64-
}
63+
let store = createDocumentStore(opts)
6564

66-
return createLanguageDocument(opts, doc)
65+
async function open(doc: TextDocument | string) {
66+
return createLanguageDocument(opts, await store.parse(doc))
6767
}
6868

6969
return {
@@ -91,7 +91,7 @@ export function createLanguageService(opts: ServiceOptions): LanguageService {
9191

9292
async function createLanguageDocument(
9393
opts: ServiceOptions,
94-
doc: TextDocument,
94+
doc: Document,
9595
): Promise<LanguageDocument | null> {
9696
let state = opts.state()
9797
if (!state.enabled) return null
@@ -120,33 +120,33 @@ async function createLanguageDocument(
120120
async hover(position: Position) {
121121
if (!state.enabled || !settings.tailwindCSS.hovers) return null
122122

123-
return doHover(state, doc, position)
123+
return doHover(state, doc.storage, position)
124124
},
125125

126126
async documentLinks() {
127127
if (!state.enabled) return []
128128

129-
return getDocumentLinks(state, doc, (path) => {
130-
return opts.fs.resolve(doc, path)
129+
return getDocumentLinks(state, doc.storage, (path) => {
130+
return opts.fs.resolve(doc.storage, path)
131131
})
132132
},
133133

134134
async documentColors() {
135135
if (!state.enabled || !settings.tailwindCSS.colorDecorators) return []
136136

137-
return getDocumentColors(state, doc)
137+
return getDocumentColors(state, doc.storage)
138138
},
139139

140140
async colorPresentation(color: Color, range: Range) {
141141
if (!state.enabled || !settings.tailwindCSS.colorDecorators) return []
142142

143-
return provideColorPresentation(state, doc, color, range)
143+
return provideColorPresentation(state, doc.storage, color, range)
144144
},
145145

146146
async codeLenses() {
147147
if (!state.enabled || !settings.tailwindCSS.codeLens) return []
148148

149-
return getCodeLens(state, doc)
149+
return getCodeLens(state, doc.storage)
150150
},
151151

152152
async diagnostics(kinds?: DiagnosticKind[]) {
@@ -157,7 +157,7 @@ async function createLanguageDocument(
157157
}
158158
}
159159

160-
return doValidate(state, doc, kinds)
160+
return doValidate(state, doc.storage, kinds)
161161
},
162162

163163
async codeActions(range: Range, context: CodeActionContext) {
@@ -169,14 +169,14 @@ async function createLanguageDocument(
169169
context,
170170
}
171171

172-
return doCodeActions(state, params, doc)
172+
return doCodeActions(state, params, doc.storage)
173173
},
174174

175175
async completions(position: Position, ctx?: CompletionContext) {
176176
if (!state.enabled || !settings.tailwindCSS.suggestions) return null
177177

178178
state.completionItemData.uri = doc.uri
179-
return doComplete(state, doc, position, ctx)
179+
return doComplete(state, doc.storage, position, ctx)
180180
},
181181

182182
async resolveCompletion(item: CompletionItem) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export function findClassListsInCssRange(
138138
})
139139
}
140140

141-
async function findCustomClassLists(
141+
export async function findCustomClassLists(
142142
state: State,
143143
doc: TextDocument,
144144
range?: Range,

0 commit comments

Comments
 (0)