1#![doc = include_str!("../README.md")]
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::Result;
7use bpaf::{Bpaf, ShellComp};
8
9use lintel_cli_common::CliCacheOptions;
10use lintel_schema_cache::SchemaCache;
11use lintel_validate::parsers;
12use lintel_validate::validate;
13use schema_catalog::{CompiledCatalog, FileFormat};
14
15#[derive(Debug, Clone, Bpaf)]
20#[bpaf(generate(annotate_args_inner))]
21pub struct AnnotateArgs {
22 #[bpaf(long("exclude"), argument("PATTERN"))]
23 pub exclude: Vec<String>,
24
25 #[bpaf(external(lintel_cli_common::cli_cache_options))]
26 pub cache: CliCacheOptions,
27
28 #[bpaf(long("update"), switch)]
30 pub update: bool,
31
32 #[bpaf(positional("PATH"), complete_shell(ShellComp::File { mask: None }))]
33 pub globs: Vec<String>,
34}
35
36pub fn annotate_args() -> impl bpaf::Parser<AnnotateArgs> {
38 annotate_args_inner()
39}
40
41pub struct AnnotatedFile {
46 pub path: String,
47 pub schema_url: String,
48}
49
50pub struct AnnotateResult {
51 pub annotated: Vec<AnnotatedFile>,
52 pub updated: Vec<AnnotatedFile>,
53 pub skipped: usize,
54 pub errors: Vec<(String, String)>,
55}
56
57enum FileOutcome {
62 Annotated(AnnotatedFile),
63 Updated(AnnotatedFile),
64 Skipped,
65 Error(String, String),
66}
67
68fn process_file(
69 file_path: &Path,
70 config: &lintel_config::Config,
71 catalogs: &[CompiledCatalog],
72 update: bool,
73) -> FileOutcome {
74 let path_str = file_path.display().to_string();
75 let file_name = file_path
76 .file_name()
77 .and_then(|n| n.to_str())
78 .unwrap_or(&path_str);
79
80 let content = match fs::read_to_string(file_path) {
81 Ok(c) => c,
82 Err(e) => return FileOutcome::Error(path_str, format!("failed to read: {e}")),
83 };
84
85 let Some(fmt) = parsers::detect_format(file_path) else {
86 return FileOutcome::Skipped;
87 };
88
89 if fmt == FileFormat::Jsonl {
91 return FileOutcome::Skipped;
92 }
93
94 let parser = parsers::parser_for(fmt);
95 let Ok(instance) = parser.parse(&content, &path_str) else {
96 return FileOutcome::Skipped;
97 };
98
99 let existing_schema = parser.extract_schema_uri(&content, &instance);
100 if existing_schema.is_some() && !update {
101 return FileOutcome::Skipped;
102 }
103
104 let schema_url = config
105 .find_schema_mapping(&path_str, file_name)
106 .map(str::to_string)
107 .or_else(|| {
108 catalogs
109 .iter()
110 .find_map(|cat| cat.find_schema(&path_str, file_name))
111 .map(str::to_string)
112 });
113
114 let Some(schema_url) = schema_url else {
115 return FileOutcome::Skipped;
116 };
117
118 let is_update = existing_schema.is_some();
119 if existing_schema.is_some_and(|existing| existing == schema_url) {
120 return FileOutcome::Skipped;
121 }
122
123 let content = if is_update {
124 parser.strip_annotation(&content)
125 } else {
126 content
127 };
128
129 let Some(new_content) = parser.annotate(&content, &schema_url) else {
130 return FileOutcome::Skipped;
131 };
132
133 match fs::write(file_path, &new_content) {
134 Ok(()) => {
135 let file = AnnotatedFile {
136 path: path_str,
137 schema_url,
138 };
139 if is_update {
140 FileOutcome::Updated(file)
141 } else {
142 FileOutcome::Annotated(file)
143 }
144 }
145 Err(e) => FileOutcome::Error(path_str, format!("failed to write: {e}")),
146 }
147}
148
149#[tracing::instrument(skip_all, name = "annotate")]
163pub async fn run(args: &AnnotateArgs) -> Result<AnnotateResult> {
164 let config_dir = args
165 .globs
166 .iter()
167 .find(|g| Path::new(g).is_dir())
168 .map(PathBuf::from);
169
170 let mut builder = SchemaCache::builder();
171 if let Some(dir) = &args.cache.cache_dir {
172 builder = builder.cache_dir(PathBuf::from(dir));
173 }
174 if let Some(ttl) = args.cache.schema_cache_ttl {
175 builder = builder.ttl(ttl);
176 }
177 let retriever = builder.build();
178
179 let (mut config, _, _) = validate::load_config(config_dir.as_deref());
180 config.exclude.extend(args.exclude.clone());
181
182 let files = validate::collect_files(&args.globs, &config.exclude)?;
183 tracing::info!(file_count = files.len(), "collected files");
184
185 let catalogs =
186 validate::fetch_compiled_catalogs(&retriever, &config, args.cache.no_catalog).await;
187
188 let mut result = AnnotateResult {
189 annotated: Vec::new(),
190 updated: Vec::new(),
191 skipped: 0,
192 errors: Vec::new(),
193 };
194
195 for file_path in &files {
196 match process_file(file_path, &config, &catalogs, args.update) {
197 FileOutcome::Annotated(f) => result.annotated.push(f),
198 FileOutcome::Updated(f) => result.updated.push(f),
199 FileOutcome::Skipped => result.skipped += 1,
200 FileOutcome::Error(path, msg) => result.errors.push((path, msg)),
201 }
202 }
203
204 Ok(result)
205}
206
207#[cfg(test)]
208mod tests {
209 use lintel_validate::parsers::{
210 Json5Parser, JsonParser, JsoncParser, Parser, TomlParser, YamlParser,
211 };
212
213 #[test]
216 fn json_compact() {
217 let result = JsonParser
218 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
219 .expect("annotate failed");
220 assert_eq!(
221 result,
222 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
223 );
224 }
225
226 #[test]
227 fn json_pretty() {
228 let result = JsonParser
229 .annotate(
230 "{\n \"name\": \"hello\"\n}\n",
231 "https://example.com/schema.json",
232 )
233 .expect("annotate failed");
234 assert_eq!(
235 result,
236 "{\n \"$schema\": \"https://example.com/schema.json\",\n \"name\": \"hello\"\n}\n"
237 );
238 }
239
240 #[test]
241 fn json_pretty_4_spaces() {
242 let result = JsonParser
243 .annotate(
244 "{\n \"name\": \"hello\"\n}\n",
245 "https://example.com/schema.json",
246 )
247 .expect("annotate failed");
248 assert_eq!(
249 result,
250 "{\n \"$schema\": \"https://example.com/schema.json\",\n \"name\": \"hello\"\n}\n"
251 );
252 }
253
254 #[test]
255 fn json_pretty_tabs() {
256 let result = JsonParser
257 .annotate(
258 "{\n\t\"name\": \"hello\"\n}\n",
259 "https://example.com/schema.json",
260 )
261 .expect("annotate failed");
262 assert_eq!(
263 result,
264 "{\n\t\"$schema\": \"https://example.com/schema.json\",\n\t\"name\": \"hello\"\n}\n"
265 );
266 }
267
268 #[test]
269 fn json_empty_object() {
270 let result = JsonParser
271 .annotate("{}", "https://example.com/schema.json")
272 .expect("annotate failed");
273 assert_eq!(result, r#"{"$schema":"https://example.com/schema.json",}"#);
274 }
275
276 #[test]
277 fn json_empty_object_pretty() {
278 let result = JsonParser
279 .annotate("{\n}\n", "https://example.com/schema.json")
280 .expect("annotate failed");
281 assert!(result.contains("\"$schema\": \"https://example.com/schema.json\""));
282 }
283
284 #[test]
287 fn json5_compact() {
288 let result = Json5Parser
289 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
290 .expect("annotate failed");
291 assert_eq!(
292 result,
293 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
294 );
295 }
296
297 #[test]
300 fn jsonc_compact() {
301 let result = JsoncParser
302 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
303 .expect("annotate failed");
304 assert_eq!(
305 result,
306 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
307 );
308 }
309
310 #[test]
313 fn yaml_prepends_modeline() {
314 let result = YamlParser
315 .annotate("name: hello\n", "https://example.com/schema.json")
316 .expect("annotate failed");
317 assert_eq!(
318 result,
319 "# yaml-language-server: $schema=https://example.com/schema.json\nname: hello\n"
320 );
321 }
322
323 #[test]
324 fn yaml_preserves_existing_comments() {
325 let result = YamlParser
326 .annotate(
327 "# existing comment\nname: hello\n",
328 "https://example.com/schema.json",
329 )
330 .expect("annotate failed");
331 assert_eq!(
332 result,
333 "# yaml-language-server: $schema=https://example.com/schema.json\n# existing comment\nname: hello\n"
334 );
335 }
336
337 #[test]
340 fn toml_prepends_schema_comment() {
341 let result = TomlParser
342 .annotate("name = \"hello\"\n", "https://example.com/schema.json")
343 .expect("annotate failed");
344 assert_eq!(
345 result,
346 "# :schema https://example.com/schema.json\nname = \"hello\"\n"
347 );
348 }
349
350 #[test]
351 fn toml_preserves_existing_comments() {
352 let result = TomlParser
353 .annotate(
354 "# existing comment\nname = \"hello\"\n",
355 "https://example.com/schema.json",
356 )
357 .expect("annotate failed");
358 assert_eq!(
359 result,
360 "# :schema https://example.com/schema.json\n# existing comment\nname = \"hello\"\n"
361 );
362 }
363
364 #[test]
367 fn json_strip_compact_first_property() {
368 let input = r#"{"$schema":"https://old.com/s.json","name":"hello"}"#;
369 assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
370 }
371
372 #[test]
373 fn json_strip_pretty_first_property() {
374 let input = "{\n \"$schema\": \"https://old.com/s.json\",\n \"name\": \"hello\"\n}\n";
375 assert_eq!(
376 JsonParser.strip_annotation(input),
377 "{\n \"name\": \"hello\"\n}\n"
378 );
379 }
380
381 #[test]
382 fn json_strip_only_property() {
383 let input = r#"{"$schema":"https://old.com/s.json"}"#;
384 assert_eq!(JsonParser.strip_annotation(input), "{}");
385 }
386
387 #[test]
388 fn json_strip_last_property() {
389 let input = r#"{"name":"hello","$schema":"https://old.com/s.json"}"#;
390 assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
391 }
392
393 #[test]
394 fn json_strip_no_schema() {
395 let input = r#"{"name":"hello"}"#;
396 assert_eq!(JsonParser.strip_annotation(input), input);
397 }
398
399 #[test]
402 fn yaml_strip_modeline() {
403 let input = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
404 assert_eq!(YamlParser.strip_annotation(input), "name: hello\n");
405 }
406
407 #[test]
408 fn yaml_strip_modeline_preserves_other_comments() {
409 let input =
410 "# yaml-language-server: $schema=https://old.com/s.json\n# other\nname: hello\n";
411 assert_eq!(YamlParser.strip_annotation(input), "# other\nname: hello\n");
412 }
413
414 #[test]
415 fn yaml_strip_no_modeline() {
416 let input = "name: hello\n";
417 assert_eq!(YamlParser.strip_annotation(input), input);
418 }
419
420 #[test]
423 fn toml_strip_schema_comment() {
424 let input = "# :schema https://old.com/s.json\nname = \"hello\"\n";
425 assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
426 }
427
428 #[test]
429 fn toml_strip_legacy_schema_comment() {
430 let input = "# $schema: https://old.com/s.json\nname = \"hello\"\n";
431 assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
432 }
433
434 #[test]
435 fn toml_strip_preserves_other_comments() {
436 let input = "# :schema https://old.com/s.json\n# other\nname = \"hello\"\n";
437 assert_eq!(
438 TomlParser.strip_annotation(input),
439 "# other\nname = \"hello\"\n"
440 );
441 }
442
443 #[test]
444 fn toml_strip_no_schema() {
445 let input = "name = \"hello\"\n";
446 assert_eq!(TomlParser.strip_annotation(input), input);
447 }
448
449 #[test]
452 fn json_update_round_trip() {
453 let original = "{\n \"$schema\": \"https://old.com/s.json\",\n \"name\": \"hello\"\n}\n";
454 let stripped = JsonParser.strip_annotation(original);
455 let updated = JsonParser
456 .annotate(&stripped, "https://new.com/s.json")
457 .expect("annotate failed");
458 assert_eq!(
459 updated,
460 "{\n \"$schema\": \"https://new.com/s.json\",\n \"name\": \"hello\"\n}\n"
461 );
462 }
463
464 #[test]
465 fn yaml_update_round_trip() {
466 let original = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
467 let stripped = YamlParser.strip_annotation(original);
468 let updated = YamlParser
469 .annotate(&stripped, "https://new.com/s.json")
470 .expect("annotate failed");
471 assert_eq!(
472 updated,
473 "# yaml-language-server: $schema=https://new.com/s.json\nname: hello\n"
474 );
475 }
476
477 #[test]
478 fn toml_update_round_trip() {
479 let original = "# :schema https://old.com/s.json\nname = \"hello\"\n";
480 let stripped = TomlParser.strip_annotation(original);
481 let updated = TomlParser
482 .annotate(&stripped, "https://new.com/s.json")
483 .expect("annotate failed");
484 assert_eq!(
485 updated,
486 "# :schema https://new.com/s.json\nname = \"hello\"\n"
487 );
488 }
489}