Skip to content

Commit 2271780

Browse files
committed
wip tsconfig support
1 parent c7c6ab6 commit 2271780

File tree

14 files changed

+364
-1
lines changed

14 files changed

+364
-1
lines changed

packages/tailwindcss-language-server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@
8181
"rimraf": "3.0.2",
8282
"stack-trace": "0.0.10",
8383
"tailwindcss": "3.4.4",
84+
"tsconfck": "^3.1.4",
85+
"tsconfig-paths": "^4.2.0",
8486
"typescript": "5.3.3",
8587
"vite-tsconfig-paths": "^4.3.1",
8688
"vitest": "^1.4.0",

packages/tailwindcss-language-server/src/project-locator.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ function testFixture(fixture: string, details: any[]) {
1818
let fixturePath = `${fixtures}/${fixture}`
1919

2020
test.concurrent(fixture, async ({ expect }) => {
21-
let resolver = await createResolver({ root: fixturePath })
21+
let resolver = await createResolver({ root: fixturePath, tsconfig: true })
2222
let locator = new ProjectLocator(fixturePath, settings, resolver)
2323
let projects = await locator.search()
2424

@@ -205,3 +205,18 @@ testFixture('v4/missing-files', [
205205
content: ['{URL}/package.json'],
206206
},
207207
])
208+
209+
testFixture('v4/path-mappings', [
210+
//
211+
{
212+
config: 'app.css',
213+
content: [
214+
'{URL}/package.json',
215+
'{URL}/src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}',
216+
'{URL}/src/a/my-config.ts',
217+
'{URL}/src/a/my-plugin.ts',
218+
'{URL}/src/b/file.ts',
219+
'{URL}/tsconfig.json',
220+
],
221+
},
222+
])

packages/tailwindcss-language-server/src/resolver/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
FileSystem,
88
} from 'enhanced-resolve'
99
import { loadPnPApi, type PnpApi } from './pnp'
10+
import { loadTsConfig, type TSConfigApi } from './tsconfig'
1011

1112
export interface ResolverOptions {
1213
/**
@@ -23,6 +24,15 @@ export interface ResolverOptions {
2324
*/
2425
pnp?: boolean | PnpApi
2526

27+
/**
28+
* Whether or not the resolver should load tsconfig path mappings.
29+
*
30+
* If `true`, the resolver will look for all `tsconfig` files in the project
31+
* and use them to resolve module paths where possible. However, if an API is
32+
* provided, the resolver will use that API to resolve module paths.
33+
*/
34+
tsconfig?: boolean | TSConfigApi
35+
2636
/**
2737
* A filesystem to use for resolution. If not provided, the resolver will
2838
* create one and use it internally for itself and any child resolvers that
@@ -61,6 +71,15 @@ export interface Resolver {
6171
*/
6272
resolveCssId(id: string, base: string): Promise<string>
6373

74+
/**
75+
* Return a list of path resolution aliases for the given base directory
76+
*
77+
* This isn't a direct mapping of the `paths` property in a `tsconfig.json`
78+
* because tsconfig paths support more than one pathspec per alias whereas
79+
* this function returns a single pathspec per alias.
80+
*/
81+
aliases(base: string): Promise<Record<string, string>>
82+
6483
/**
6584
* Create a child resolver with the given options.
6685
*
@@ -83,6 +102,15 @@ export async function createResolver(opts: ResolverOptions): Promise<Resolver> {
83102
pnpApi = await loadPnPApi(opts.root)
84103
}
85104

105+
let tsconfig: TSConfigApi | null = null
106+
107+
// Load TSConfig path mappings
108+
if (typeof opts.tsconfig === 'object') {
109+
tsconfig = opts.tsconfig
110+
} else if (opts.tsconfig) {
111+
tsconfig = await loadTsConfig(opts.root)
112+
}
113+
86114
let esmResolver = ResolverFactory.createResolver({
87115
fileSystem,
88116
extensions: ['.mjs', '.js'],
@@ -128,6 +156,11 @@ export async function createResolver(opts: ResolverOptions): Promise<Resolver> {
128156
if (base.startsWith('//')) base = `\\\\${base.slice(2)}`
129157
}
130158

159+
if (tsconfig) {
160+
let match = await tsconfig.resolveId(id, base)
161+
if (match) id = match
162+
}
163+
131164
return new Promise((resolve, reject) => {
132165
resolver.resolve({}, base, id, {}, (err, res) => {
133166
if (err) {
@@ -155,18 +188,34 @@ export async function createResolver(opts: ResolverOptions): Promise<Resolver> {
155188
pnpApi?.setup()
156189
}
157190

191+
async function aliases(base: string) {
192+
if (!tsconfig) return {}
193+
194+
let paths = await tsconfig.paths(base)
195+
let aliases = {}
196+
197+
for (let [key, value] of Object.entries(paths)) {
198+
aliases[key] = value[0]
199+
}
200+
201+
return aliases
202+
}
203+
158204
return {
159205
setupPnP,
160206
resolveJsId,
161207
resolveCssId,
162208

209+
aliases,
210+
163211
child(childOpts: Partial<ResolverOptions>) {
164212
return createResolver({
165213
...opts,
166214
...childOpts,
167215

168216
// Inherit defaults from parent
169217
pnp: childOpts.pnp ?? pnpApi,
218+
tsconfig: childOpts.tsconfig ?? tsconfig,
170219
fileSystem: childOpts.fileSystem ?? fileSystem,
171220
})
172221
},
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as path from 'node:path'
2+
import * as tsconfig from 'tsconfig-paths'
3+
import * as tsconfck from 'tsconfck'
4+
import { normalizePath } from '../utils'
5+
import { DefaultMap } from '../util/default-map'
6+
7+
export interface TSConfigApi {
8+
/**
9+
* Get the tsconfig paths used in the given directory
10+
*
11+
* @param base
12+
*/
13+
paths(base: string): Promise<Record<string, string[]>>
14+
15+
/**
16+
* Resolve a module to a file path based on the tsconfig paths
17+
*
18+
* @param base
19+
*/
20+
resolveId(id: string, base: string): Promise<string | undefined>
21+
}
22+
23+
export async function loadTsConfig(root: string): Promise<TSConfigApi> {
24+
// 1. Find all tsconfig files in the project
25+
let files = await tsconfck.findAll(root, {
26+
configNames: ['tsconfig.json', 'jsconfig.json'],
27+
skip(dir) {
28+
if (dir === 'node_modules') return true
29+
if (dir === '.git') return true
30+
31+
return false
32+
},
33+
})
34+
35+
// 2. Load them all
36+
let options: tsconfck.TSConfckParseOptions = {
37+
root,
38+
cache: new tsconfck.TSConfckCache(),
39+
}
40+
41+
let parsed = new Set<tsconfck.TSConfckParseResult>()
42+
43+
for (let file of files) {
44+
try {
45+
let result = await tsconfck.parse(file, options)
46+
parsed.add(result)
47+
} catch (err) {
48+
console.error(err)
49+
}
50+
}
51+
52+
// 3. Extract referenced projects
53+
for (let result of parsed) {
54+
if (!result.referenced) continue
55+
56+
// Mach against referenced projects rather than the project itself
57+
for (let ref of result.referenced) {
58+
parsed.add(ref)
59+
}
60+
61+
// And use the project itself as a fallback since project references can
62+
// be used to override the parent project.
63+
parsed.delete(result)
64+
parsed.add(result)
65+
66+
result.referenced = undefined
67+
}
68+
69+
// 4. Create matchers for each project
70+
interface Matcher {
71+
(id: string): Promise<string | undefined>
72+
}
73+
74+
let resolvers = new DefaultMap<string, Matcher[]>(() => [])
75+
let pathMap = new Map<string, Record<string, string[]>>()
76+
77+
for (let result of parsed) {
78+
let parent = normalizePath(path.dirname(result.tsconfigFile))
79+
80+
let opts = result.tsconfig.compilerOptions ?? {}
81+
let baseUrl = opts.baseUrl
82+
let absoluteBaseUrl = path.resolve(parent, baseUrl || '')
83+
84+
let match!: tsconfig.MatchPathAsync
85+
86+
function resolve(id: string) {
87+
match ??= tsconfig.createMatchPathAsync(
88+
absoluteBaseUrl,
89+
opts.paths ?? {},
90+
undefined,
91+
baseUrl !== undefined,
92+
)
93+
94+
return new Promise<string | undefined>((resolve, reject) => {
95+
match(id, undefined, undefined, undefined, (err, path) => {
96+
if (err) {
97+
reject(err)
98+
} else {
99+
resolve(path)
100+
}
101+
})
102+
})
103+
}
104+
105+
resolvers.get(parent).push(resolve)
106+
pathMap.set(parent, opts.paths ?? {})
107+
}
108+
109+
function* walkPaths(base: string) {
110+
let projectDir = normalizePath(base)
111+
112+
let prevProjectDir: string | undefined
113+
while (projectDir !== prevProjectDir) {
114+
yield projectDir
115+
116+
prevProjectDir = projectDir
117+
projectDir = path.dirname(projectDir)
118+
}
119+
120+
return null
121+
}
122+
123+
// 5. Create matchers for each project
124+
async function resolveId(id: string, base: string) {
125+
for (let projectDir of walkPaths(base)) {
126+
for (let resolve of resolvers.get(projectDir)) {
127+
try {
128+
return await resolve(id)
129+
} catch (err) {
130+
// If we got here we found a valid resolver for this path but it
131+
// failed to resolve the path then we should stop looking. If we
132+
// didn't we might end up using a resolver that would give us a
133+
// valid path but not the one we want.
134+
return null
135+
}
136+
}
137+
}
138+
139+
return null
140+
}
141+
142+
async function paths(base: string) {
143+
for (let projectDir of walkPaths(base)) {
144+
let paths = pathMap.get(projectDir)
145+
if (paths) return paths
146+
}
147+
148+
return {}
149+
}
150+
151+
return {
152+
resolveId,
153+
paths,
154+
}
155+
}

packages/tailwindcss-language-server/src/tw.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export class TW {
247247
let resolver = await createResolver({
248248
root: base,
249249
pnp: true,
250+
tsconfig: true,
250251
})
251252

252253
let locator = new ProjectLocator(base, globalSettings, resolver)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* A Map that can generate default values for keys that don't exist.
3+
* Generated default values are added to the map to avoid recomputation.
4+
*/
5+
export class DefaultMap<T = string, V = any> extends Map<T, V> {
6+
constructor(private factory: (key: T, self: DefaultMap<T, V>) => V) {
7+
super()
8+
}
9+
10+
get(key: T): V {
11+
let value = super.get(key)
12+
13+
if (value === undefined) {
14+
value = this.factory(key, this)
15+
this.set(key, value)
16+
}
17+
18+
return value
19+
}
20+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@import 'tailwindcss';
2+
3+
@import '#a/file.css';
4+
@config '#a/my-config.ts';
5+
@plugin '#a/my-plugin.ts';

packages/tailwindcss-language-server/tests/fixtures/v4/path-mappings/package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"dependencies": {
3+
"tailwindcss": "^4.0.0-beta.6"
4+
}
5+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@theme {
2+
--color-map-a-css: black;
3+
}

0 commit comments

Comments
 (0)