Suite of AT Protocol TypeScript libraries built on web standards
1import { mkdir, rm, stat, writeFile } from "node:fs/promises";
2import { dirname, join, resolve } from "node:path";
3import { IndentationText, Project } from "ts-morph";
4import type { LexiconDocument, LexiconIndexer } from "../document/mod.ts";
5import { buildFilter, type BuildFilterOptions } from "./filter.ts";
6import { FilteredIndexer } from "./filtered-indexer.ts";
7import { LexDefBuilder, type LexDefBuilderOptions } from "./def-builder.ts";
8import { formatGeneratedText } from "./formatter.ts";
9import {
10 LexiconDirectoryIndexer,
11 type LexiconDirectoryIndexerOptions,
12} from "./directory-indexer.ts";
13import { isSafeIdentifier } from "./ts-lang.ts";
14
15export type LexBuilderOptions = LexDefBuilderOptions & {
16 importExt?: string;
17 fileExt?: string;
18};
19
20export type LexBuilderLoadOptions =
21 & LexiconDirectoryIndexerOptions
22 & BuildFilterOptions;
23
24export type LexBuilderSaveOptions = {
25 out: string;
26 clear?: boolean;
27 override?: boolean;
28 format?: boolean;
29};
30
31export class LexBuilder {
32 readonly #imported = new Set<string>();
33 readonly #project = new Project({
34 useInMemoryFileSystem: true,
35 manipulationSettings: { indentationText: IndentationText.TwoSpaces },
36 });
37
38 constructor(private readonly options: LexBuilderOptions = {}) {}
39
40 get fileExt(): string {
41 return this.options.fileExt ?? ".ts";
42 }
43
44 get importExt(): string {
45 return this.options.importExt ?? ".ts";
46 }
47
48 async load(options: LexBuilderLoadOptions): Promise<void> {
49 await using indexer = new FilteredIndexer(
50 new LexiconDirectoryIndexer(options),
51 buildFilter(options),
52 );
53
54 for await (const doc of indexer) {
55 if (!this.#imported.has(doc.id)) {
56 this.#imported.add(doc.id);
57 } else {
58 throw new Error(`Duplicate lexicon document id: ${doc.id}`);
59 }
60
61 await this.createDefsFile(doc, indexer);
62 await this.createExportTree(doc);
63 }
64 }
65
66 async save(options: LexBuilderSaveOptions): Promise<void> {
67 const files = this.#project.getSourceFiles();
68 const destination = resolve(options.out);
69
70 if (options.clear) {
71 await rm(destination, { recursive: true, force: true });
72 } else if (!options.override) {
73 await Promise.all(
74 files.map((f) =>
75 assertNotFileExists(
76 resolveOutputFilePath(destination, f.getFilePath()),
77 )
78 ),
79 );
80 }
81
82 await Promise.all(
83 Array.from(files, async (file) => {
84 const filePath = resolveOutputFilePath(destination, file.getFilePath());
85 const content = options.format === false
86 ? file.getFullText()
87 : await formatGeneratedText(filePath, file.getFullText());
88 await mkdir(dirname(filePath), { recursive: true });
89 await rm(filePath, { recursive: true, force: true });
90 await writeFile(filePath, content, "utf8");
91 }),
92 );
93 }
94
95 private createFile(path: string) {
96 return this.#project.createSourceFile(path);
97 }
98
99 private getFile(path: string) {
100 return this.#project.getSourceFile(path) ?? this.createFile(path);
101 }
102
103 private createExportTree(doc: LexiconDocument): void {
104 const namespaces = doc.id.split(".");
105
106 for (let i = 0; i < namespaces.length - 1; i++) {
107 const currentNs = namespaces[i];
108 const childNs = namespaces[i + 1];
109
110 const path = join("/", ...namespaces.slice(0, i + 1));
111 const file = this.getFile(`${path}${this.fileExt}`);
112
113 const childModuleSpecifier = `./${currentNs}/${childNs}${this.importExt}`;
114 const dec = file.getExportDeclaration(childModuleSpecifier);
115 if (!dec) {
116 file.addExportDeclaration({
117 moduleSpecifier: childModuleSpecifier,
118 namespaceExport: isSafeIdentifier(childNs)
119 ? childNs
120 : JSON.stringify(childNs),
121 });
122 }
123 }
124
125 const path = join("/", ...namespaces);
126 const file = this.getFile(`${path}${this.fileExt}`);
127
128 file.addExportDeclaration({
129 moduleSpecifier: `./${namespaces.at(-1)}.defs${this.importExt}`,
130 });
131
132 file.addExportDeclaration({
133 moduleSpecifier: `./${namespaces.at(-1)}.defs${this.importExt}`,
134 namespaceExport: "$defs",
135 });
136 }
137
138 private async createDefsFile(
139 doc: LexiconDocument,
140 indexer: LexiconIndexer,
141 ): Promise<void> {
142 const path = join("/", ...doc.id.split("."));
143 const file = this.createFile(`${path}.defs${this.fileExt}`);
144
145 const fileBuilder = new LexDefBuilder(
146 { ...this.options, importExt: this.importExt },
147 file,
148 doc,
149 indexer,
150 );
151 await fileBuilder.build();
152 }
153}
154
155async function assertNotFileExists(file: string): Promise<void> {
156 try {
157 await stat(file);
158 throw new Error(`File already exists: ${file}`);
159 } catch (err) {
160 if (err instanceof Error && "code" in err && err.code === "ENOENT") return;
161 throw err;
162 }
163}
164
165function resolveOutputFilePath(destination: string, filePath: string): string {
166 const relativePath = filePath
167 .replaceAll("\\", "/")
168 .replace(/^(?:[A-Za-z]:)?\/+/, "");
169 return join(destination, ...relativePath.split("/").filter(Boolean));
170}