Highly ambitious ATProtocol AppView service and sdks
1import { walk } from "@std/fs/walk";
2import { extname } from "@std/path";
3import { green, red, dim, cyan } from "@std/fmt/colors";
4import { type LexiconDoc, validate } from "@slices/lexicon";
5import { logger } from "./logger.ts";
6
7// Type for raw lexicon content that may include unknown fields
8type RawLexicon = LexiconDoc & Record<string, unknown>;
9
10export interface LexiconFile {
11 path: string;
12 content: unknown;
13 valid: boolean;
14 errors?: string[];
15}
16
17export interface LexiconValidationResult {
18 files: LexiconFile[];
19 totalFiles: number;
20 validFiles: number;
21 invalidFiles: number;
22}
23
24export async function findLexiconFiles(directory: string): Promise<string[]> {
25 const lexiconFiles: string[] = [];
26
27 try {
28 for await (const entry of walk(directory)) {
29 if (entry.isFile && extname(entry.path) === ".json") {
30 lexiconFiles.push(entry.path);
31 }
32 }
33 } catch (error) {
34 if (error instanceof Deno.errors.NotFound) {
35 throw new Error(`Directory not found: ${directory}`);
36 }
37 throw error;
38 }
39
40 return lexiconFiles;
41}
42
43export async function readAndParseLexicon(filePath: string): Promise<unknown> {
44 try {
45 const content = await Deno.readTextFile(filePath);
46 return JSON.parse(content);
47 } catch (error) {
48 if (error instanceof Deno.errors.NotFound) {
49 throw new Error(`File not found: ${filePath}`);
50 }
51 if (error instanceof SyntaxError) {
52 throw new Error(`Invalid JSON in file: ${filePath} - ${error.message}`);
53 }
54 throw error;
55 }
56}
57
58export async function validateLexicon(lexicon: unknown): Promise<{
59 valid: boolean;
60 errors?: string[];
61}> {
62 try {
63 // Basic structure validation
64 if (!lexicon || typeof lexicon !== "object") {
65 return { valid: false, errors: ["Lexicon must be an object"] };
66 }
67
68 const lex = lexicon as Record<string, unknown>;
69
70 // Check required fields
71 if (!lex.id || typeof lex.id !== "string") {
72 return { valid: false, errors: ["Lexicon must have a valid 'id' field"] };
73 }
74
75 // Check for either 'definitions' or 'defs' (convert if needed)
76 const defs = lex.defs || lex.definitions;
77 if (!defs || typeof defs !== "object") {
78 return {
79 valid: false,
80 errors: ["Lexicon must have a 'defs' or 'definitions' object"],
81 };
82 }
83
84 // Use the new validate function
85 const validationResult = await validate([lexicon as RawLexicon]);
86
87 // validate returns null if validation succeeds, or error map if validation fails
88 if (validationResult === null) {
89 return { valid: true };
90 } else {
91 // Extract errors for this specific lexicon
92 const lexiconId = lex.id as string;
93 const errors = validationResult[lexiconId] || [`Unknown validation error for lexicon: ${lexiconId}`];
94 return { valid: false, errors };
95 }
96 } catch (error) {
97 const err = error as Error;
98 return { valid: false, errors: [`Validation error: ${err.message}`] };
99 }
100}
101
102export async function validateLexiconFiles(
103 filePaths: string[],
104 showProgress = true
105): Promise<LexiconValidationResult> {
106 const files: LexiconFile[] = [];
107 const lexicons: RawLexicon[] = [];
108
109 // Read and parse all files
110 for (let i = 0; i < filePaths.length; i++) {
111 const filePath = filePaths[i];
112
113 if (showProgress) {
114 logger.progress("Reading lexicons", i + 1, filePaths.length);
115 }
116
117 try {
118 const content = await readAndParseLexicon(filePath);
119
120 // Basic structure validation
121 if (!content || typeof content !== "object") {
122 files.push({
123 path: filePath,
124 content: null,
125 valid: false,
126 errors: ["Lexicon must be an object"],
127 });
128 continue;
129 }
130
131 const lex = content as Record<string, unknown>;
132
133 // Check required fields
134 if (!lex.id || typeof lex.id !== "string") {
135 files.push({
136 path: filePath,
137 content,
138 valid: false,
139 errors: ["Lexicon must have a valid 'id' field"],
140 });
141 continue;
142 }
143
144 // Check for either 'definitions' or 'defs'
145 const defs = lex.defs || lex.definitions;
146 if (!defs || typeof defs !== "object") {
147 files.push({
148 path: filePath,
149 content,
150 valid: false,
151 errors: ["Lexicon must have a 'defs' or 'definitions' object"],
152 });
153 continue;
154 }
155
156 // Store for validation
157 lexicons.push(content as RawLexicon);
158 files.push({
159 path: filePath,
160 content,
161 valid: true,
162 errors: [],
163 });
164 } catch (error) {
165 const err = error as Error;
166 files.push({
167 path: filePath,
168 content: null,
169 valid: false,
170 errors: [err.message],
171 });
172 }
173 }
174
175 // Validate all lexicons together using the new validation system
176 if (lexicons.length > 0) {
177 try {
178 const validationErrors = await validate(lexicons);
179
180 // If validation errors exist, map them back to files
181 if (validationErrors) {
182 for (const file of files) {
183 if (file.valid && file.content) {
184 const lexicon = file.content as RawLexicon;
185 const errors = validationErrors[lexicon.id];
186 if (errors && errors.length > 0) {
187 file.valid = false;
188 file.errors = errors;
189 }
190 }
191 }
192 }
193 } catch (error) {
194 const errorMessage =
195 error instanceof Error ? error.message : String(error);
196 // Mark all valid files as invalid due to validation failure
197 for (const file of files) {
198 if (file.valid) {
199 file.valid = false;
200 file.errors = [`Validation failed: ${errorMessage}`];
201 }
202 }
203 }
204 }
205
206 const validFiles = files.filter((f) => f.valid).length;
207 const invalidFiles = files.length - validFiles;
208
209 return {
210 files,
211 totalFiles: filePaths.length,
212 validFiles,
213 invalidFiles,
214 };
215}
216
217function colorizeErrorPaths(errorMessage: string): string {
218 // Highlight field paths in quotes with cyan color
219 return errorMessage.replace(
220 /'([^']+)'/g,
221 (_match, p1) => cyan(`'${p1}'`)
222 );
223}
224
225function formatError(error: string, index: number): string {
226 return ` ${red(`${index + 1}.`)} ${colorizeErrorPaths(error)}`;
227}
228
229export function printValidationSummary(result: LexiconValidationResult): void {
230 logger.section("Validation Summary");
231 logger.result(`Total files: ${result.totalFiles}`);
232 logger.result(`Valid: ${green(result.validFiles.toString())}`);
233 logger.result(`Invalid: ${red(result.invalidFiles.toString())}`);
234
235 if (result.invalidFiles > 0) {
236 logger.section("Invalid Files");
237 for (const file of result.files) {
238 if (!file.valid) {
239 console.log(` ${dim(file.path)}`);
240 if (file.errors) {
241 file.errors.forEach((error, index) => {
242 console.log(formatError(error, index));
243 });
244 }
245 }
246 }
247 }
248}