Skip to content

Commit a09dbd8

Browse files
authored
feat(format): add file-based root detection for formatter enabled checks (#76)
* feat(format): add file-based root detection for formatter enabled checks * fix(format): correct findUp boundary and custom formatter signature - Fix findUp to include stopDir in search (was skipping projectDir) - Fix custom formatter enabled signature to match Info interface * test(format): add tests for file-based root detection - Add unit tests for formatter enabled detection - Test biome detection in nested directories - Test stopDir boundary condition (finding config at projectDir) - Test custom formatter signature - Add monorepo fixture structure for testing
1 parent 716714d commit a09dbd8

8 files changed

Lines changed: 198 additions & 41 deletions

File tree

packages/format/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
],
2626
"scripts": {
2727
"typecheck": "tsc -p tsconfig.json --noEmit",
28-
"test": "echo 'No tests yet'"
28+
"test": "bun test ./test"
2929
},
3030
"dependencies": {
3131
"@pleaseai/logger": "workspace:*",

packages/format/src/formatter.ts

Lines changed: 40 additions & 29 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
/**
@@ -21,12 +21,17 @@ async function findUp(
2121
let currentDir = startDir
2222
const root = stopDir ?? path.parse(startDir).root
2323

24-
while (currentDir !== root && currentDir !== path.dirname(currentDir)) {
24+
while (true) {
2525
const filePath = path.join(currentDir, filename)
2626
const file = Bun.file(filePath)
2727
if (await file.exists()) {
2828
results.push(filePath)
2929
}
30+
31+
// Stop if we've reached the stopDir or filesystem root
32+
if (currentDir === root || currentDir === path.dirname(currentDir)) {
33+
break
34+
}
3035
currentDir = path.dirname(currentDir)
3136
}
3237

@@ -41,7 +46,7 @@ export const gofmt: Info = {
4146
name: 'gofmt',
4247
command: ['gofmt', '-w', '$FILE'],
4348
extensions: ['.go'],
44-
async enabled() {
49+
async enabled(_filePath, _projectDir) {
4550
return Bun.which('gofmt') !== null
4651
},
4752
}
@@ -50,7 +55,7 @@ export const mix: Info = {
5055
name: 'mix',
5156
command: ['mix', 'format', '$FILE'],
5257
extensions: ['.ex', '.exs', '.eex', '.heex', '.leex', '.neex', '.sface'],
53-
async enabled() {
58+
async enabled(_filePath, _projectDir) {
5459
return Bun.which('mix') !== null
5560
},
5661
}
@@ -89,8 +94,9 @@ export const prettier: Info = {
8994
'.graphql',
9095
'.gql',
9196
],
92-
async enabled(projectDir: string) {
93-
const items = await findUp('package.json', projectDir)
97+
async enabled(filePath: string, projectDir: string) {
98+
const startDir = path.dirname(filePath)
99+
const items = await findUp('package.json', startDir, projectDir)
94100
for (const item of items) {
95101
const json = await Bun.file(item).json()
96102
if (json.dependencies?.prettier)
@@ -136,10 +142,11 @@ export const biome: Info = {
136142
'.graphql',
137143
'.gql',
138144
],
139-
async enabled(projectDir: string) {
145+
async enabled(filePath: string, projectDir: string) {
146+
const startDir = path.dirname(filePath)
140147
const configs = ['biome.json', 'biome.jsonc']
141148
for (const config of configs) {
142-
const found = await findUp(config, projectDir)
149+
const found = await findUp(config, startDir, projectDir)
143150
if (found.length > 0) {
144151
return true
145152
}
@@ -152,7 +159,7 @@ export const zig: Info = {
152159
name: 'zig',
153160
command: ['zig', 'fmt', '$FILE'],
154161
extensions: ['.zig', '.zon'],
155-
async enabled() {
162+
async enabled(_filePath, _projectDir) {
156163
return Bun.which('zig') !== null
157164
},
158165
}
@@ -161,8 +168,9 @@ export const clang: Info = {
161168
name: 'clang-format',
162169
command: ['clang-format', '-i', '$FILE'],
163170
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)
171+
async enabled(filePath: string, projectDir: string) {
172+
const startDir = path.dirname(filePath)
173+
const items = await findUp('.clang-format', startDir, projectDir)
166174
return items.length > 0
167175
},
168176
}
@@ -171,7 +179,7 @@ export const ktlint: Info = {
171179
name: 'ktlint',
172180
command: ['ktlint', '-F', '$FILE'],
173181
extensions: ['.kt', '.kts'],
174-
async enabled() {
182+
async enabled(_filePath, _projectDir) {
175183
return Bun.which('ktlint') !== null
176184
},
177185
}
@@ -180,12 +188,13 @@ export const ruff: Info = {
180188
name: 'ruff',
181189
command: ['ruff', 'format', '$FILE'],
182190
extensions: ['.py', '.pyi'],
183-
async enabled(projectDir: string) {
191+
async enabled(filePath: string, projectDir: string) {
184192
if (!Bun.which('ruff'))
185193
return false
194+
const startDir = path.dirname(filePath)
186195
const configs = ['pyproject.toml', 'ruff.toml', '.ruff.toml']
187196
for (const config of configs) {
188-
const found = await findUp(config, projectDir)
197+
const found = await findUp(config, startDir, projectDir)
189198
const firstFound = found[0]
190199
if (firstFound) {
191200
if (config === 'pyproject.toml') {
@@ -200,7 +209,7 @@ export const ruff: Info = {
200209
}
201210
const deps = ['requirements.txt', 'pyproject.toml', 'Pipfile']
202211
for (const dep of deps) {
203-
const found = await findUp(dep, projectDir)
212+
const found = await findUp(dep, startDir, projectDir)
204213
const firstFound = found[0]
205214
if (firstFound) {
206215
const content = await Bun.file(firstFound).text()
@@ -216,7 +225,7 @@ export const rlang: Info = {
216225
name: 'air',
217226
command: ['air', 'format', '$FILE'],
218227
extensions: ['.R'],
219-
async enabled() {
228+
async enabled(_filePath, _projectDir) {
220229
const airPath = Bun.which('air')
221230
if (airPath == null)
222231
return false
@@ -245,8 +254,8 @@ export const uvformat: Info = {
245254
name: 'uv format',
246255
command: ['uv', 'format', '--', '$FILE'],
247256
extensions: ['.py', '.pyi'],
248-
async enabled(projectDir: string) {
249-
if (await ruff.enabled(projectDir))
257+
async enabled(filePath: string, projectDir: string) {
258+
if (await ruff.enabled(filePath, projectDir))
250259
return false
251260
if (Bun.which('uv') !== null) {
252261
const proc = Bun.spawn(['uv', 'format', '--help'], { stderr: 'pipe', stdout: 'pipe' })
@@ -261,7 +270,7 @@ export const rubocop: Info = {
261270
name: 'rubocop',
262271
command: ['rubocop', '--autocorrect', '$FILE'],
263272
extensions: ['.rb', '.rake', '.gemspec', '.ru'],
264-
async enabled() {
273+
async enabled(_filePath, _projectDir) {
265274
return Bun.which('rubocop') !== null
266275
},
267276
}
@@ -270,7 +279,7 @@ export const standardrb: Info = {
270279
name: 'standardrb',
271280
command: ['standardrb', '--fix', '$FILE'],
272281
extensions: ['.rb', '.rake', '.gemspec', '.ru'],
273-
async enabled() {
282+
async enabled(_filePath, _projectDir) {
274283
return Bun.which('standardrb') !== null
275284
},
276285
}
@@ -279,7 +288,7 @@ export const htmlbeautifier: Info = {
279288
name: 'htmlbeautifier',
280289
command: ['htmlbeautifier', '$FILE'],
281290
extensions: ['.erb', '.html.erb'],
282-
async enabled() {
291+
async enabled(_filePath, _projectDir) {
283292
return Bun.which('htmlbeautifier') !== null
284293
},
285294
}
@@ -288,7 +297,7 @@ export const dart: Info = {
288297
name: 'dart',
289298
command: ['dart', 'format', '$FILE'],
290299
extensions: ['.dart'],
291-
async enabled() {
300+
async enabled(_filePath, _projectDir) {
292301
return Bun.which('dart') !== null
293302
},
294303
}
@@ -297,10 +306,11 @@ export const ocamlformat: Info = {
297306
name: 'ocamlformat',
298307
command: ['ocamlformat', '-i', '$FILE'],
299308
extensions: ['.ml', '.mli'],
300-
async enabled(projectDir: string) {
309+
async enabled(filePath: string, projectDir: string) {
301310
if (!Bun.which('ocamlformat'))
302311
return false
303-
const items = await findUp('.ocamlformat', projectDir)
312+
const startDir = path.dirname(filePath)
313+
const items = await findUp('.ocamlformat', startDir, projectDir)
304314
return items.length > 0
305315
},
306316
}
@@ -309,7 +319,7 @@ export const terraform: Info = {
309319
name: 'terraform',
310320
command: ['terraform', 'fmt', '$FILE'],
311321
extensions: ['.tf', '.tfvars'],
312-
async enabled() {
322+
async enabled(_filePath, _projectDir) {
313323
return Bun.which('terraform') !== null
314324
},
315325
}
@@ -318,7 +328,7 @@ export const latexindent: Info = {
318328
name: 'latexindent',
319329
command: ['latexindent', '-w', '-s', '$FILE'],
320330
extensions: ['.tex'],
321-
async enabled() {
331+
async enabled(_filePath, _projectDir) {
322332
return Bun.which('latexindent') !== null
323333
},
324334
}
@@ -327,7 +337,7 @@ export const gleam: Info = {
327337
name: 'gleam',
328338
command: ['gleam', 'format', '$FILE'],
329339
extensions: ['.gleam'],
330-
async enabled() {
340+
async enabled(_filePath, _projectDir) {
331341
return Bun.which('gleam') !== null
332342
},
333343
}
@@ -339,11 +349,12 @@ export const prisma: Info = {
339349
BUN_BE_BUN: '1',
340350
},
341351
extensions: ['.prisma'],
342-
async enabled(projectDir: string) {
352+
async enabled(filePath: string, projectDir: string) {
343353
// Check for schema.prisma or prisma/schema.prisma
354+
const startDir = path.dirname(filePath)
344355
const schemaFiles = ['schema.prisma', 'prisma/schema.prisma']
345356
for (const schema of schemaFiles) {
346-
const found = await findUp(schema, projectDir)
357+
const found = await findUp(schema, startDir, projectDir)
347358
if (found.length > 0) {
348359
return true
349360
}

packages/format/src/index.ts

Lines changed: 14 additions & 11 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
}
@@ -86,7 +86,7 @@ function init(config: FormatConfig): void {
8686
name,
8787
command: item.command,
8888
extensions: item.extensions ?? [],
89-
enabled: async () => true,
89+
enabled: async (_filePath: string, _projectDir: string) => true,
9090
}
9191
if (item.environment)
9292
newFormatter.environment = item.environment
@@ -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
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json"
3+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Test file - no formatter config in backend
2+
print("hello")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json"
3+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Test file for formatter detection
2+
export const hello = 'world'

0 commit comments

Comments
 (0)