Skip to content

Commit 926dbaf

Browse files
committed
feat(format): add file-based root detection for formatter enabled checks
1 parent 716714d commit 926dbaf

2 files changed

Lines changed: 47 additions & 38 deletions

File tree

packages/format/src/formatter.ts

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export interface Info {
66
command: string[]
77
environment?: Record<string, string>
88
extensions: string[]
9-
enabled: (projectDir: string) => Promise<boolean>
9+
enabled: (filePath: string, projectDir: string) => Promise<boolean>
1010
}
1111

1212
/**
@@ -41,7 +41,7 @@ export const gofmt: Info = {
4141
name: 'gofmt',
4242
command: ['gofmt', '-w', '$FILE'],
4343
extensions: ['.go'],
44-
async enabled() {
44+
async enabled(_filePath, _projectDir) {
4545
return Bun.which('gofmt') !== null
4646
},
4747
}
@@ -50,7 +50,7 @@ export const mix: Info = {
5050
name: 'mix',
5151
command: ['mix', 'format', '$FILE'],
5252
extensions: ['.ex', '.exs', '.eex', '.heex', '.leex', '.neex', '.sface'],
53-
async enabled() {
53+
async enabled(_filePath, _projectDir) {
5454
return Bun.which('mix') !== null
5555
},
5656
}
@@ -89,8 +89,9 @@ export const prettier: Info = {
8989
'.graphql',
9090
'.gql',
9191
],
92-
async enabled(projectDir: string) {
93-
const items = await findUp('package.json', projectDir)
92+
async enabled(filePath: string, projectDir: string) {
93+
const startDir = path.dirname(filePath)
94+
const items = await findUp('package.json', startDir, projectDir)
9495
for (const item of items) {
9596
const json = await Bun.file(item).json()
9697
if (json.dependencies?.prettier)
@@ -136,10 +137,11 @@ export const biome: Info = {
136137
'.graphql',
137138
'.gql',
138139
],
139-
async enabled(projectDir: string) {
140+
async enabled(filePath: string, projectDir: string) {
141+
const startDir = path.dirname(filePath)
140142
const configs = ['biome.json', 'biome.jsonc']
141143
for (const config of configs) {
142-
const found = await findUp(config, projectDir)
144+
const found = await findUp(config, startDir, projectDir)
143145
if (found.length > 0) {
144146
return true
145147
}
@@ -152,7 +154,7 @@ export const zig: Info = {
152154
name: 'zig',
153155
command: ['zig', 'fmt', '$FILE'],
154156
extensions: ['.zig', '.zon'],
155-
async enabled() {
157+
async enabled(_filePath, _projectDir) {
156158
return Bun.which('zig') !== null
157159
},
158160
}
@@ -161,8 +163,9 @@ export const clang: Info = {
161163
name: 'clang-format',
162164
command: ['clang-format', '-i', '$FILE'],
163165
extensions: ['.c', '.cc', '.cpp', '.cxx', '.c++', '.h', '.hh', '.hpp', '.hxx', '.h++', '.ino', '.C', '.H'],
164-
async enabled(projectDir: string) {
165-
const items = await findUp('.clang-format', projectDir)
166+
async enabled(filePath: string, projectDir: string) {
167+
const startDir = path.dirname(filePath)
168+
const items = await findUp('.clang-format', startDir, projectDir)
166169
return items.length > 0
167170
},
168171
}
@@ -171,7 +174,7 @@ export const ktlint: Info = {
171174
name: 'ktlint',
172175
command: ['ktlint', '-F', '$FILE'],
173176
extensions: ['.kt', '.kts'],
174-
async enabled() {
177+
async enabled(_filePath, _projectDir) {
175178
return Bun.which('ktlint') !== null
176179
},
177180
}
@@ -180,12 +183,13 @@ export const ruff: Info = {
180183
name: 'ruff',
181184
command: ['ruff', 'format', '$FILE'],
182185
extensions: ['.py', '.pyi'],
183-
async enabled(projectDir: string) {
186+
async enabled(filePath: string, projectDir: string) {
184187
if (!Bun.which('ruff'))
185188
return false
189+
const startDir = path.dirname(filePath)
186190
const configs = ['pyproject.toml', 'ruff.toml', '.ruff.toml']
187191
for (const config of configs) {
188-
const found = await findUp(config, projectDir)
192+
const found = await findUp(config, startDir, projectDir)
189193
const firstFound = found[0]
190194
if (firstFound) {
191195
if (config === 'pyproject.toml') {
@@ -200,7 +204,7 @@ export const ruff: Info = {
200204
}
201205
const deps = ['requirements.txt', 'pyproject.toml', 'Pipfile']
202206
for (const dep of deps) {
203-
const found = await findUp(dep, projectDir)
207+
const found = await findUp(dep, startDir, projectDir)
204208
const firstFound = found[0]
205209
if (firstFound) {
206210
const content = await Bun.file(firstFound).text()
@@ -216,7 +220,7 @@ export const rlang: Info = {
216220
name: 'air',
217221
command: ['air', 'format', '$FILE'],
218222
extensions: ['.R'],
219-
async enabled() {
223+
async enabled(_filePath, _projectDir) {
220224
const airPath = Bun.which('air')
221225
if (airPath == null)
222226
return false
@@ -245,8 +249,8 @@ export const uvformat: Info = {
245249
name: 'uv format',
246250
command: ['uv', 'format', '--', '$FILE'],
247251
extensions: ['.py', '.pyi'],
248-
async enabled(projectDir: string) {
249-
if (await ruff.enabled(projectDir))
252+
async enabled(filePath: string, projectDir: string) {
253+
if (await ruff.enabled(filePath, projectDir))
250254
return false
251255
if (Bun.which('uv') !== null) {
252256
const proc = Bun.spawn(['uv', 'format', '--help'], { stderr: 'pipe', stdout: 'pipe' })
@@ -261,7 +265,7 @@ export const rubocop: Info = {
261265
name: 'rubocop',
262266
command: ['rubocop', '--autocorrect', '$FILE'],
263267
extensions: ['.rb', '.rake', '.gemspec', '.ru'],
264-
async enabled() {
268+
async enabled(_filePath, _projectDir) {
265269
return Bun.which('rubocop') !== null
266270
},
267271
}
@@ -270,7 +274,7 @@ export const standardrb: Info = {
270274
name: 'standardrb',
271275
command: ['standardrb', '--fix', '$FILE'],
272276
extensions: ['.rb', '.rake', '.gemspec', '.ru'],
273-
async enabled() {
277+
async enabled(_filePath, _projectDir) {
274278
return Bun.which('standardrb') !== null
275279
},
276280
}
@@ -279,7 +283,7 @@ export const htmlbeautifier: Info = {
279283
name: 'htmlbeautifier',
280284
command: ['htmlbeautifier', '$FILE'],
281285
extensions: ['.erb', '.html.erb'],
282-
async enabled() {
286+
async enabled(_filePath, _projectDir) {
283287
return Bun.which('htmlbeautifier') !== null
284288
},
285289
}
@@ -288,7 +292,7 @@ export const dart: Info = {
288292
name: 'dart',
289293
command: ['dart', 'format', '$FILE'],
290294
extensions: ['.dart'],
291-
async enabled() {
295+
async enabled(_filePath, _projectDir) {
292296
return Bun.which('dart') !== null
293297
},
294298
}
@@ -297,10 +301,11 @@ export const ocamlformat: Info = {
297301
name: 'ocamlformat',
298302
command: ['ocamlformat', '-i', '$FILE'],
299303
extensions: ['.ml', '.mli'],
300-
async enabled(projectDir: string) {
304+
async enabled(filePath: string, projectDir: string) {
301305
if (!Bun.which('ocamlformat'))
302306
return false
303-
const items = await findUp('.ocamlformat', projectDir)
307+
const startDir = path.dirname(filePath)
308+
const items = await findUp('.ocamlformat', startDir, projectDir)
304309
return items.length > 0
305310
},
306311
}
@@ -309,7 +314,7 @@ export const terraform: Info = {
309314
name: 'terraform',
310315
command: ['terraform', 'fmt', '$FILE'],
311316
extensions: ['.tf', '.tfvars'],
312-
async enabled() {
317+
async enabled(_filePath, _projectDir) {
313318
return Bun.which('terraform') !== null
314319
},
315320
}
@@ -318,7 +323,7 @@ export const latexindent: Info = {
318323
name: 'latexindent',
319324
command: ['latexindent', '-w', '-s', '$FILE'],
320325
extensions: ['.tex'],
321-
async enabled() {
326+
async enabled(_filePath, _projectDir) {
322327
return Bun.which('latexindent') !== null
323328
},
324329
}
@@ -327,7 +332,7 @@ export const gleam: Info = {
327332
name: 'gleam',
328333
command: ['gleam', 'format', '$FILE'],
329334
extensions: ['.gleam'],
330-
async enabled() {
335+
async enabled(_filePath, _projectDir) {
331336
return Bun.which('gleam') !== null
332337
},
333338
}
@@ -339,11 +344,12 @@ export const prisma: Info = {
339344
BUN_BE_BUN: '1',
340345
},
341346
extensions: ['.prisma'],
342-
async enabled(projectDir: string) {
347+
async enabled(filePath: string, projectDir: string) {
343348
// Check for schema.prisma or prisma/schema.prisma
349+
const startDir = path.dirname(filePath)
344350
const schemaFiles = ['schema.prisma', 'prisma/schema.prisma']
345351
for (const schema of schemaFiles) {
346-
const found = await findUp(schema, projectDir)
352+
const found = await findUp(schema, startDir, projectDir)
347353
if (found.length > 0) {
348354
return true
349355
}

packages/format/src/index.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface FormatConfig {
2323
}
2424

2525
interface State {
26-
enabled: Record<string, boolean>
26+
enabled: Record<string, boolean> // Cache key: `${dirPath}:${formatterName}`
2727
formatters: Record<string, Formatter.Info>
2828
projectDir: string
2929
}
@@ -105,36 +105,39 @@ function getState(): State {
105105
return cachedState
106106
}
107107

108-
async function isEnabled(item: Formatter.Info): Promise<boolean> {
108+
async function isEnabled(item: Formatter.Info, filePath: string): Promise<boolean> {
109109
const s = getState()
110-
let status = s.enabled[item.name]
110+
const dirPath = path.dirname(filePath)
111+
const cacheKey = `${dirPath}:${item.name}`
112+
let status = s.enabled[cacheKey]
111113
if (status === undefined) {
112-
status = await item.enabled(s.projectDir)
113-
s.enabled[item.name] = status
114+
status = await item.enabled(filePath, s.projectDir)
115+
s.enabled[cacheKey] = status
114116
}
115117
return status
116118
}
117119

118-
async function getFormatter(ext: string): Promise<Formatter.Info[]> {
120+
async function getFormatter(ext: string, filePath: string): Promise<Formatter.Info[]> {
119121
const s = getState()
120122
const result: Formatter.Info[] = []
121123
for (const item of Object.values(s.formatters)) {
122124
log.debug({ name: item.name, ext }, 'checking formatter')
123125
if (!item.extensions.includes(ext))
124126
continue
125-
if (!(await isEnabled(item)))
127+
if (!(await isEnabled(item, filePath)))
126128
continue
127129
log.debug({ name: item.name, ext }, 'formatter enabled')
128130
result.push(item)
129131
}
130132
return result
131133
}
132134

133-
async function status(): Promise<FormatStatus[]> {
135+
async function status(filePath?: string): Promise<FormatStatus[]> {
134136
const s = getState()
137+
const testPath = filePath ?? s.projectDir
135138
const result: FormatStatus[] = []
136139
for (const formatter of Object.values(s.formatters)) {
137-
const enabled = await isEnabled(formatter)
140+
const enabled = await isEnabled(formatter, testPath)
138141
result.push({
139142
name: formatter.name,
140143
extensions: formatter.extensions,
@@ -152,7 +155,7 @@ async function formatFile(file: string): Promise<boolean> {
152155
const ext = path.extname(file)
153156
log.debug({ file, ext }, 'formatting file')
154157

155-
const formatters = await getFormatter(ext)
158+
const formatters = await getFormatter(ext, file)
156159
if (formatters.length === 0) {
157160
log.debug({ ext }, 'no formatter found')
158161
return false

0 commit comments

Comments
 (0)