Skip to content

Commit 3dc1df4

Browse files
committed
feat: add support for cson files
1 parent 96c0285 commit 3dc1df4

4 files changed

Lines changed: 270 additions & 17 deletions

File tree

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"bun",
1313
"bun-plugin",
1414
"coffeescript",
15-
"coffee-script"
15+
"coffee-script",
16+
"cson"
1617
],
1718
"type": "module",
1819
"exports": {
@@ -42,7 +43,8 @@
4243
"test": "bun test"
4344
},
4445
"dependencies": {
45-
"coffeescript": "^2.7.0"
46+
"coffeescript": "^2.7.0",
47+
"cson-parser": "^4.0.9"
4648
},
4749
"devDependencies": {
4850
"@commitlint/cli": "^20.1.0",

src/index.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,47 @@
1-
import type { BunPlugin } from 'bun';
1+
import type { BunPlugin, OnLoadResultObject, OnLoadResultSourceCode } from 'bun';
22
import { compile, type Options } from 'coffeescript';
3+
import CSON from 'cson-parser';
34

45
function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
56
return Object.fromEntries(Object.entries(obj).filter(([key]) => !keys.includes(key as K))) as Omit<T, K>;
67
}
78

9+
async function loadCson(path: string): Promise<OnLoadResultObject> {
10+
const fileContents = await Bun.file(path).text();
11+
12+
const exports = CSON.parse(fileContents);
13+
14+
return {
15+
exports,
16+
loader: 'object',
17+
};
18+
}
19+
20+
async function loadCoffeeScript(path: string, options: Options): Promise<OnLoadResultSourceCode> {
21+
const fileContents = await Bun.file(path).text();
22+
const compilerOptions = omit(options, ['inlineMap']);
23+
24+
const contents = compile(fileContents, {
25+
filename: path,
26+
...compilerOptions,
27+
});
28+
29+
return {
30+
contents,
31+
loader: 'js',
32+
};
33+
}
34+
835
export default function Plugin(options: Options = {}): BunPlugin {
936
return {
1037
name: 'bun-plugin-coffeescript',
1138
setup(builder) {
12-
builder.onLoad({ filter: /\.(coffee|litcoffee)$/ }, async ({ path }) => {
13-
const fileContents = await Bun.file(path).text();
14-
const compilerOptions = omit(options, ['inlineMap']);
15-
16-
const contents = compile(fileContents, {
17-
filename: path,
18-
...compilerOptions,
19-
});
20-
21-
return {
22-
contents,
23-
loader: 'js',
24-
};
39+
builder.onLoad({ filter: /\.(coffee|cson|litcoffee)$/ }, async ({ path }) => {
40+
if (path.endsWith('.cson')) {
41+
return loadCson(path);
42+
}
43+
44+
return loadCoffeeScript(path, options);
2545
});
2646
},
2747
};

src/module.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ declare module '*.coffee' {
33
const content: any;
44
export default content;
55
}
6+
declare module '*.cson' {
7+
// biome-ignore lint/suspicious/noExplicitAny: Module declaration needs any type
8+
const content: any;
9+
export default content;
10+
}
611

712
declare module '*.litcoffee' {
813
// biome-ignore lint/suspicious/noExplicitAny: Module declaration needs any type

tests/index.spec.ts

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,24 @@ import Plugin from '../src/index.ts';
66

77
// Type definitions for mocking
88
type OnLoadConfig = { filter: RegExp };
9-
type OnLoadCallback = (args: { path: string }) => Promise<{ contents: string; loader: string }>;
9+
type OnLoadResultSource = { contents: string; loader: string };
10+
type OnLoadResultObject = { exports: unknown; loader: string };
11+
type OnLoadResult = OnLoadResultSource | OnLoadResultObject;
12+
type OnLoadCallback = (args: { path: string }) => Promise<OnLoadResult>;
13+
14+
/**
15+
* Type guard to check if result is a source code result
16+
*/
17+
function isSourceResult(result: OnLoadResult): result is OnLoadResultSource {
18+
return 'contents' in result;
19+
}
20+
21+
/**
22+
* Type guard to check if result is an object result
23+
*/
24+
function isObjectResult(result: OnLoadResult): result is OnLoadResultObject {
25+
return 'exports' in result;
26+
}
1027

1128
/**
1229
* Helper to test async rejections in Bun 1.0+
@@ -111,6 +128,7 @@ console.log square 5
111128
expect(result).toHaveProperty('contents');
112129
expect(result).toHaveProperty('loader');
113130
expect(result.loader).toBe('js');
131+
if (!isSourceResult(result)) throw new Error('Expected source result');
114132
expect(typeof result.contents).toBe('string');
115133
expect(result.contents).toContain('square');
116134
});
@@ -144,6 +162,7 @@ dog.speak()
144162
if (!onLoadCallback) throw new Error('onLoad was not called');
145163
const result = await onLoadCallback({ path: coffeeFile });
146164

165+
if (!isSourceResult(result)) throw new Error('Expected source result');
147166
expect(result.contents).toContain('Animal');
148167
expect(result.contents).toContain('constructor');
149168
expect(result.contents).toContain('speak');
@@ -188,6 +207,7 @@ dog.speak()
188207
const result = await onLoadCallback({ path: coffeeFile });
189208

190209
// When bare: true, the output should not be wrapped in a function
210+
if (!isSourceResult(result)) throw new Error('Expected source result');
191211
expect(result.contents).toContain('add');
192212
expect(result.contents).not.toContain('(function()');
193213
});
@@ -215,6 +235,7 @@ dog.speak()
215235
const result = await onLoadCallback({ path: coffeeFile });
216236

217237
// Result should not contain inline source map
238+
if (!isSourceResult(result)) throw new Error('Expected source result');
218239
expect(result.contents).toBeDefined();
219240
expect(typeof result.contents).toBe('string');
220241
});
@@ -239,6 +260,7 @@ dog.speak()
239260
if (!onLoadCallback) throw new Error('onLoad was not called');
240261
const result = await onLoadCallback({ path: coffeeFile });
241262

263+
if (!isSourceResult(result)) throw new Error('Expected source result');
242264
expect(result.contents).toBeDefined();
243265
expect(result.loader).toBe('js');
244266
});
@@ -363,12 +385,214 @@ More documentation here.
363385
if (!onLoadCallback) throw new Error('onLoad was not called');
364386
const result = await onLoadCallback({ path: litcoffeeFile });
365387

388+
if (!isSourceResult(result)) throw new Error('Expected source result');
366389
expect(result.contents).toBeDefined();
367390
expect(result.loader).toBe('js');
368391
expect(result.contents).toContain('square');
369392
});
370393
});
371394

395+
describe('CSON support', () => {
396+
let tempDir: string;
397+
398+
beforeEach(async () => {
399+
tempDir = await mkdtemp(join(tmpdir(), 'bun-cson-'));
400+
});
401+
402+
afterEach(async () => {
403+
await rm(tempDir, { recursive: true, force: true });
404+
});
405+
406+
test('matches .cson files in filter', () => {
407+
const plugin = Plugin();
408+
let filterRegex: RegExp | undefined;
409+
410+
const mockBuilder = {
411+
onLoad: mock((config: OnLoadConfig, _callback: OnLoadCallback) => {
412+
filterRegex = config.filter;
413+
}),
414+
};
415+
416+
// biome-ignore lint/suspicious/noExplicitAny: Mock builder for testing
417+
plugin.setup(mockBuilder as any);
418+
419+
if (!filterRegex) throw new Error('onLoad was not called');
420+
expect(filterRegex.test('config.cson')).toBe(true);
421+
expect(filterRegex.test('package.cson')).toBe(true);
422+
expect(filterRegex.test('data/settings.cson')).toBe(true);
423+
});
424+
425+
test('parses CSON files', async () => {
426+
const csonFile = join(tempDir, 'data.cson');
427+
const csonSource = `
428+
# CSON Configuration
429+
name: "test-package"
430+
version: "1.0.0"
431+
config:
432+
enabled: true
433+
count: 42
434+
items: [
435+
"one"
436+
"two"
437+
"three"
438+
]
439+
`;
440+
await writeFile(csonFile, csonSource);
441+
442+
const plugin = Plugin();
443+
let onLoadCallback: OnLoadCallback | undefined;
444+
445+
const mockBuilder = {
446+
onLoad: mock((_config: OnLoadConfig, callback: OnLoadCallback) => {
447+
onLoadCallback = callback;
448+
}),
449+
};
450+
451+
// biome-ignore lint/suspicious/noExplicitAny: Mock builder for testing
452+
plugin.setup(mockBuilder as any);
453+
454+
if (!onLoadCallback) throw new Error('onLoad was not called');
455+
const result = await onLoadCallback({ path: csonFile });
456+
457+
expect(result).toHaveProperty('exports');
458+
expect(result).toHaveProperty('loader');
459+
expect(result.loader).toBe('object');
460+
if (!isObjectResult(result)) throw new Error('Expected object result');
461+
expect(result.exports).toEqual({
462+
name: 'test-package',
463+
version: '1.0.0',
464+
config: {
465+
enabled: true,
466+
count: 42,
467+
items: ['one', 'two', 'three'],
468+
},
469+
});
470+
});
471+
472+
test('handles simple CSON objects', async () => {
473+
const csonFile = join(tempDir, 'simple.cson');
474+
const csonSource = `
475+
key: "value"
476+
number: 123
477+
boolean: true
478+
`;
479+
await writeFile(csonFile, csonSource);
480+
481+
const plugin = Plugin();
482+
let onLoadCallback: OnLoadCallback | undefined;
483+
484+
const mockBuilder = {
485+
onLoad: mock((_config: OnLoadConfig, callback: OnLoadCallback) => {
486+
onLoadCallback = callback;
487+
}),
488+
};
489+
490+
// biome-ignore lint/suspicious/noExplicitAny: Mock builder for testing
491+
plugin.setup(mockBuilder as any);
492+
493+
if (!onLoadCallback) throw new Error('onLoad was not called');
494+
const result = await onLoadCallback({ path: csonFile });
495+
496+
if (!isObjectResult(result)) throw new Error('Expected object result');
497+
expect(result.exports).toEqual({
498+
key: 'value',
499+
number: 123,
500+
boolean: true,
501+
});
502+
});
503+
504+
test('handles nested CSON structures', async () => {
505+
const csonFile = join(tempDir, 'nested.cson');
506+
const csonSource = `
507+
database:
508+
host: "localhost"
509+
port: 5432
510+
credentials:
511+
username: "admin"
512+
password: "secret"
513+
options:
514+
ssl: true
515+
poolSize: 10
516+
`;
517+
await writeFile(csonFile, csonSource);
518+
519+
const plugin = Plugin();
520+
let onLoadCallback: OnLoadCallback | undefined;
521+
522+
const mockBuilder = {
523+
onLoad: mock((_config: OnLoadConfig, callback: OnLoadCallback) => {
524+
onLoadCallback = callback;
525+
}),
526+
};
527+
528+
// biome-ignore lint/suspicious/noExplicitAny: Mock builder for testing
529+
plugin.setup(mockBuilder as any);
530+
531+
if (!onLoadCallback) throw new Error('onLoad was not called');
532+
const result = await onLoadCallback({ path: csonFile });
533+
534+
if (!isObjectResult(result)) throw new Error('Expected object result');
535+
expect(result.exports).toHaveProperty('database');
536+
// biome-ignore lint/suspicious/noExplicitAny: Testing dynamic CSON structure
537+
expect((result.exports as any).database).toHaveProperty('credentials');
538+
// biome-ignore lint/suspicious/noExplicitAny: Testing dynamic CSON structure
539+
expect((result.exports as any).database.credentials.username).toBe('admin');
540+
});
541+
542+
test('handles CSON arrays', async () => {
543+
const csonFile = join(tempDir, 'array.cson');
544+
const csonSource = `[
545+
"item1"
546+
"item2"
547+
"item3"
548+
]`;
549+
await writeFile(csonFile, csonSource);
550+
551+
const plugin = Plugin();
552+
let onLoadCallback: OnLoadCallback | undefined;
553+
554+
const mockBuilder = {
555+
onLoad: mock((_config: OnLoadConfig, callback: OnLoadCallback) => {
556+
onLoadCallback = callback;
557+
}),
558+
};
559+
560+
// biome-ignore lint/suspicious/noExplicitAny: Mock builder for testing
561+
plugin.setup(mockBuilder as any);
562+
563+
if (!onLoadCallback) throw new Error('onLoad was not called');
564+
const result = await onLoadCallback({ path: csonFile });
565+
566+
if (!isObjectResult(result)) throw new Error('Expected object result');
567+
expect(Array.isArray(result.exports)).toBe(true);
568+
expect(result.exports).toEqual(['item1', 'item2', 'item3']);
569+
});
570+
571+
test('handles invalid CSON syntax', async () => {
572+
const csonFile = join(tempDir, 'invalid.cson');
573+
const invalidSource = `
574+
key: "value
575+
missing closing quote
576+
`;
577+
await writeFile(csonFile, invalidSource);
578+
579+
const plugin = Plugin();
580+
let onLoadCallback: OnLoadCallback | undefined;
581+
582+
const mockBuilder = {
583+
onLoad: mock((_config: OnLoadConfig, callback: OnLoadCallback) => {
584+
onLoadCallback = callback;
585+
}),
586+
};
587+
588+
// biome-ignore lint/suspicious/noExplicitAny: Mock builder for testing
589+
plugin.setup(mockBuilder as any);
590+
591+
if (!onLoadCallback) throw new Error('onLoad was not called');
592+
await expectToReject(onLoadCallback({ path: csonFile }));
593+
});
594+
});
595+
372596
describe('Error handling', () => {
373597
let tempDir: string;
374598

@@ -461,6 +685,7 @@ result = add 2, 3
461685
const result = await onLoadCallback({ path: coffeeFile });
462686

463687
// Try to evaluate the compiled JavaScript (basic syntax check)
688+
if (!isSourceResult(result)) throw new Error('Expected source result');
464689
expect(() => {
465690
new Function(result.contents);
466691
}).not.toThrow();
@@ -488,6 +713,7 @@ result = add 2, 3
488713
const result = await onLoadCallback({ path: coffeeFile });
489714

490715
expect(result).toBeDefined();
716+
if (!isSourceResult(result)) throw new Error('Expected source result');
491717
expect(result.contents).toBeDefined();
492718
// The compilation succeeds, meaning filename was passed correctly
493719
});

0 commit comments

Comments
 (0)