Skip to content

Commit 4cdd7c0

Browse files
authored
Merge pull request #46 from DouglasNeuroInformatics/parser
add ast parser
2 parents e90d97c + 0a0cc1d commit 4cdd7c0

File tree

4 files changed

+149
-0
lines changed

4 files changed

+149
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
"passport-jwt": "^4.0.1",
112112
"serialize-error": "^12.0.0",
113113
"supertest": "^7.0.0",
114+
"ts-morph": "^25.0.1",
114115
"type-fest": "^4.37.0",
115116
"unplugin-swc": "^1.5.1"
116117
},

pnpm-lock.yaml

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/meta/__tests__/parser.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { Parser } from '../parser.js';
4+
5+
const fs = vi.hoisted(() => ({
6+
existsSync: vi.fn(),
7+
readFileSync: vi.fn()
8+
}));
9+
10+
vi.mock('node:fs', () => fs);
11+
12+
describe('Parser', () => {
13+
let parser: Parser;
14+
15+
beforeEach(() => {
16+
parser = new Parser();
17+
fs.existsSync.mockReturnValue(true);
18+
});
19+
20+
describe('parseUserConfig', () => {
21+
it('should return an error if the provided file does not exist', () => {
22+
fs.existsSync.mockReturnValueOnce(false);
23+
expect(parser.parseUserConfig('config.ts')).toMatchObject({
24+
error: {
25+
message: `Config file does not exist: config.ts`
26+
}
27+
});
28+
});
29+
it('should return an error if the source code does not contain a default export', () => {
30+
const sourceCode = "const config = { value: 'foo' };";
31+
fs.readFileSync.mockReturnValueOnce(sourceCode);
32+
expect(parser.parseUserConfig('config.ts')).toMatchObject({
33+
error: {
34+
message: "Source file 'config.ts' does not include a default export symbol"
35+
}
36+
});
37+
});
38+
it('should return an error if the default export does not reference anything', () => {
39+
const sourceCode = 'export default config;';
40+
fs.readFileSync.mockReturnValueOnce(sourceCode);
41+
expect(parser.parseUserConfig('config.ts')).toMatchObject({
42+
error: {
43+
message: "Default export symbol in 'config.ts' has no declarations"
44+
}
45+
});
46+
});
47+
it('should return an error if the default export has multiple references', () => {
48+
const sourceCode = 'var config = {}; var config = {}; export default config;';
49+
fs.readFileSync.mockReturnValueOnce(sourceCode);
50+
expect(parser.parseUserConfig('config.ts')).toMatchObject({
51+
error: {
52+
message: "Default export symbol in 'config.ts' has multiple declarations (2)"
53+
}
54+
});
55+
});
56+
it('should return ok if the config can be parsed', () => {
57+
const sourceCode = 'const config = {}; export default config;';
58+
fs.readFileSync.mockReturnValueOnce(sourceCode);
59+
const result = parser.parseUserConfig('config.ts');
60+
expect(result.isOk()).toBe(true);
61+
});
62+
});
63+
});

src/meta/parser.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as fs from 'node:fs';
2+
3+
import { ExceptionBuilder, RuntimeException } from '@douglasneuroinformatics/libjs';
4+
import { ok, Result } from 'neverthrow';
5+
import { Node, Project, SourceFile, Symbol } from 'ts-morph';
6+
7+
const { SyntaxParsingException } = new ExceptionBuilder().setParams({ name: 'SyntaxParsingException' }).build();
8+
9+
export class Parser {
10+
private readonly project: Project;
11+
12+
constructor() {
13+
this.project = new Project();
14+
}
15+
16+
parseUserConfig(configFile: string): Result<Node, Error> {
17+
if (!fs.existsSync(configFile)) {
18+
return RuntimeException.asErr(`Config file does not exist: ${configFile}`);
19+
}
20+
const sourceCode = fs.readFileSync(configFile, 'utf-8');
21+
const sourceFile = this.project.createSourceFile(configFile, sourceCode);
22+
return this.parseDefaultExportNode(sourceFile);
23+
}
24+
25+
private parseDefaultExportNode(sourceFile: SourceFile): Result<Node, typeof SyntaxParsingException.Instance> {
26+
const filename = sourceFile.getBaseName();
27+
const defaultExportSymbol = sourceFile.getDefaultExportSymbol();
28+
if (!defaultExportSymbol) {
29+
return SyntaxParsingException.asErr(`Source file '${filename}' does not include a default export symbol`);
30+
}
31+
const resolvedSymbol = this.resolveAliasedSymbol(defaultExportSymbol);
32+
const declarations = resolvedSymbol.getDeclarations();
33+
if (declarations.length === 0) {
34+
return SyntaxParsingException.asErr(`Default export symbol in '${filename}' has no declarations`);
35+
}
36+
if (declarations.length > 1) {
37+
return SyntaxParsingException.asErr(
38+
`Default export symbol in '${filename}' has multiple declarations (${declarations.length})`
39+
);
40+
}
41+
return ok(declarations[0]!);
42+
}
43+
44+
private resolveAliasedSymbol(symbol: Symbol): Symbol {
45+
let current = symbol;
46+
while (current.getAliasedSymbol()) {
47+
current = current.getAliasedSymbol()!;
48+
}
49+
return current;
50+
}
51+
}

0 commit comments

Comments
 (0)