1#![doc = include_str!("../README.md")]
2
3use core::time::Duration;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use bpaf::{Bpaf, Parser};
9use glob::glob;
10
11use lintel_check::catalog::{self, CompiledCatalog};
12use lintel_check::config;
13use lintel_check::discover;
14use lintel_check::parsers;
15use lintel_check::registry;
16use lintel_check::retriever::SchemaCache;
17
18#[derive(Debug, Clone, Bpaf)]
23#[bpaf(generate(annotate_args_inner))]
24pub struct AnnotateArgs {
25 #[bpaf(long("exclude"), argument("PATTERN"))]
26 pub exclude: Vec<String>,
27
28 #[bpaf(long("cache-dir"), argument("DIR"))]
29 pub cache_dir: Option<String>,
30
31 #[bpaf(long("no-catalog"), switch)]
32 pub no_catalog: bool,
33
34 #[bpaf(external(schema_cache_ttl))]
35 pub schema_cache_ttl: Option<Duration>,
36
37 #[bpaf(long("update"), switch)]
39 pub update: bool,
40
41 #[bpaf(positional("PATH"))]
42 pub globs: Vec<String>,
43}
44
45fn schema_cache_ttl() -> impl bpaf::Parser<Option<Duration>> {
46 bpaf::long("schema-cache-ttl")
47 .help("Schema cache TTL (e.g. \"12h\", \"30m\", \"1d\"); default 12h")
48 .argument::<String>("DURATION")
49 .parse(|s: String| {
50 humantime::parse_duration(&s).map_err(|e| format!("invalid duration '{s}': {e}"))
51 })
52 .optional()
53}
54
55pub fn annotate_args() -> impl bpaf::Parser<AnnotateArgs> {
57 annotate_args_inner()
58}
59
60pub struct AnnotatedFile {
65 pub path: String,
66 pub schema_url: String,
67}
68
69pub struct AnnotateResult {
70 pub annotated: Vec<AnnotatedFile>,
71 pub updated: Vec<AnnotatedFile>,
72 pub skipped: usize,
73 pub errors: Vec<(String, String)>,
74}
75
76fn load_config(search_dir: Option<&Path>) -> (config::Config, PathBuf) {
81 let start_dir = match search_dir {
82 Some(d) => d.to_path_buf(),
83 None => match std::env::current_dir() {
84 Ok(d) => d,
85 Err(_) => return (config::Config::default(), PathBuf::from(".")),
86 },
87 };
88
89 let cfg = config::find_and_load(&start_dir)
90 .ok()
91 .flatten()
92 .unwrap_or_default();
93 (cfg, start_dir)
94}
95
96fn collect_files(globs_arg: &[String], exclude: &[String]) -> Result<Vec<PathBuf>> {
101 if globs_arg.is_empty() {
102 return discover::discover_files(".", exclude);
103 }
104
105 let mut result = Vec::new();
106 for pattern in globs_arg {
107 let path = Path::new(pattern);
108 if path.is_dir() {
109 result.extend(discover::discover_files(pattern, exclude)?);
110 } else {
111 for entry in glob(pattern).with_context(|| format!("invalid glob: {pattern}"))? {
112 let path = entry?;
113 if path.is_file() && !is_excluded(&path, exclude) {
114 result.push(path);
115 }
116 }
117 }
118 }
119 Ok(result)
120}
121
122fn is_excluded(path: &Path, excludes: &[String]) -> bool {
123 let path_str = match path.to_str() {
124 Some(s) => s.strip_prefix("./").unwrap_or(s),
125 None => return false,
126 };
127 excludes
128 .iter()
129 .any(|pattern| glob_match::glob_match(pattern, path_str))
130}
131
132async fn fetch_catalogs(retriever: &SchemaCache, registries: &[String]) -> Vec<CompiledCatalog> {
137 type CatalogResult = (
138 String,
139 Result<CompiledCatalog, Box<dyn core::error::Error + Send + Sync>>,
140 );
141 let mut catalog_tasks: tokio::task::JoinSet<CatalogResult> = tokio::task::JoinSet::new();
142
143 let r = retriever.clone();
145 let label = format!("default catalog {}", registry::DEFAULT_REGISTRY);
146 catalog_tasks.spawn(async move {
147 let result = registry::fetch(&r, registry::DEFAULT_REGISTRY)
148 .await
149 .map(|cat| CompiledCatalog::compile(&cat));
150 (label, result)
151 });
152
153 let r = retriever.clone();
155 catalog_tasks.spawn(async move {
156 let result = catalog::fetch_catalog(&r)
157 .await
158 .map(|cat| CompiledCatalog::compile(&cat));
159 ("SchemaStore catalog".to_string(), result)
160 });
161
162 for registry_url in registries {
164 let r = retriever.clone();
165 let url = registry_url.clone();
166 let label = format!("registry {url}");
167 catalog_tasks.spawn(async move {
168 let result = registry::fetch(&r, &url)
169 .await
170 .map(|cat| CompiledCatalog::compile(&cat));
171 (label, result)
172 });
173 }
174
175 let mut compiled = Vec::new();
176 while let Some(result) = catalog_tasks.join_next().await {
177 match result {
178 Ok((_, Ok(catalog))) => compiled.push(catalog),
179 Ok((label, Err(e))) => eprintln!("warning: failed to fetch {label}: {e}"),
180 Err(e) => eprintln!("warning: catalog fetch task failed: {e}"),
181 }
182 }
183 compiled
184}
185
186enum FileOutcome {
191 Annotated(AnnotatedFile),
192 Updated(AnnotatedFile),
193 Skipped,
194 Error(String, String),
195}
196
197fn process_file(
198 file_path: &Path,
199 config: &config::Config,
200 catalogs: &[CompiledCatalog],
201 update: bool,
202) -> FileOutcome {
203 let path_str = file_path.display().to_string();
204 let file_name = file_path
205 .file_name()
206 .and_then(|n| n.to_str())
207 .unwrap_or(&path_str);
208
209 let content = match fs::read_to_string(file_path) {
210 Ok(c) => c,
211 Err(e) => return FileOutcome::Error(path_str, format!("failed to read: {e}")),
212 };
213
214 let Some(fmt) = parsers::detect_format(file_path) else {
215 return FileOutcome::Skipped;
216 };
217
218 let parser = parsers::parser_for(fmt);
219 let Ok(instance) = parser.parse(&content, &path_str) else {
220 return FileOutcome::Skipped;
221 };
222
223 let existing_schema = parser.extract_schema_uri(&content, &instance);
224 if existing_schema.is_some() && !update {
225 return FileOutcome::Skipped;
226 }
227
228 let schema_url = config
229 .find_schema_mapping(&path_str, file_name)
230 .map(str::to_string)
231 .or_else(|| {
232 catalogs
233 .iter()
234 .find_map(|cat| cat.find_schema(&path_str, file_name))
235 .map(str::to_string)
236 });
237
238 let Some(schema_url) = schema_url else {
239 return FileOutcome::Skipped;
240 };
241
242 let is_update = existing_schema.is_some();
243 if existing_schema.is_some_and(|existing| existing == schema_url) {
244 return FileOutcome::Skipped;
245 }
246
247 let content = if is_update {
248 parser.strip_annotation(&content)
249 } else {
250 content
251 };
252
253 let Some(new_content) = parser.annotate(&content, &schema_url) else {
254 return FileOutcome::Skipped;
255 };
256
257 match fs::write(file_path, &new_content) {
258 Ok(()) => {
259 let file = AnnotatedFile {
260 path: path_str,
261 schema_url,
262 };
263 if is_update {
264 FileOutcome::Updated(file)
265 } else {
266 FileOutcome::Annotated(file)
267 }
268 }
269 Err(e) => FileOutcome::Error(path_str, format!("failed to write: {e}")),
270 }
271}
272
273#[tracing::instrument(skip_all, name = "annotate")]
287pub async fn run(args: &AnnotateArgs) -> Result<AnnotateResult> {
288 let config_dir = args
289 .globs
290 .iter()
291 .find(|g| Path::new(g).is_dir())
292 .map(PathBuf::from);
293
294 let mut builder = SchemaCache::builder();
295 if let Some(dir) = &args.cache_dir {
296 builder = builder.cache_dir(PathBuf::from(dir));
297 }
298 if let Some(ttl) = args.schema_cache_ttl {
299 builder = builder.ttl(ttl);
300 }
301 let retriever = builder.build();
302
303 let (mut config, _config_dir) = load_config(config_dir.as_deref());
304 config.exclude.extend(args.exclude.clone());
305
306 let files = collect_files(&args.globs, &config.exclude)?;
307 tracing::info!(file_count = files.len(), "collected files");
308
309 let catalogs = if args.no_catalog {
310 Vec::new()
311 } else {
312 fetch_catalogs(&retriever, &config.registries).await
313 };
314
315 let mut result = AnnotateResult {
316 annotated: Vec::new(),
317 updated: Vec::new(),
318 skipped: 0,
319 errors: Vec::new(),
320 };
321
322 for file_path in &files {
323 match process_file(file_path, &config, &catalogs, args.update) {
324 FileOutcome::Annotated(f) => result.annotated.push(f),
325 FileOutcome::Updated(f) => result.updated.push(f),
326 FileOutcome::Skipped => result.skipped += 1,
327 FileOutcome::Error(path, msg) => result.errors.push((path, msg)),
328 }
329 }
330
331 Ok(result)
332}
333
334#[cfg(test)]
335mod tests {
336 use lintel_check::parsers::{
337 Json5Parser, JsonParser, JsoncParser, Parser, TomlParser, YamlParser,
338 };
339
340 #[test]
343 fn json_compact() {
344 let result = JsonParser
345 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
346 .expect("annotate failed");
347 assert_eq!(
348 result,
349 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
350 );
351 }
352
353 #[test]
354 fn json_pretty() {
355 let result = JsonParser
356 .annotate(
357 "{\n \"name\": \"hello\"\n}\n",
358 "https://example.com/schema.json",
359 )
360 .expect("annotate failed");
361 assert_eq!(
362 result,
363 "{\n \"$schema\": \"https://example.com/schema.json\",\n \"name\": \"hello\"\n}\n"
364 );
365 }
366
367 #[test]
368 fn json_pretty_4_spaces() {
369 let result = JsonParser
370 .annotate(
371 "{\n \"name\": \"hello\"\n}\n",
372 "https://example.com/schema.json",
373 )
374 .expect("annotate failed");
375 assert_eq!(
376 result,
377 "{\n \"$schema\": \"https://example.com/schema.json\",\n \"name\": \"hello\"\n}\n"
378 );
379 }
380
381 #[test]
382 fn json_pretty_tabs() {
383 let result = JsonParser
384 .annotate(
385 "{\n\t\"name\": \"hello\"\n}\n",
386 "https://example.com/schema.json",
387 )
388 .expect("annotate failed");
389 assert_eq!(
390 result,
391 "{\n\t\"$schema\": \"https://example.com/schema.json\",\n\t\"name\": \"hello\"\n}\n"
392 );
393 }
394
395 #[test]
396 fn json_empty_object() {
397 let result = JsonParser
398 .annotate("{}", "https://example.com/schema.json")
399 .expect("annotate failed");
400 assert_eq!(result, r#"{"$schema":"https://example.com/schema.json",}"#);
401 }
402
403 #[test]
404 fn json_empty_object_pretty() {
405 let result = JsonParser
406 .annotate("{\n}\n", "https://example.com/schema.json")
407 .expect("annotate failed");
408 assert!(result.contains("\"$schema\": \"https://example.com/schema.json\""));
409 }
410
411 #[test]
414 fn json5_compact() {
415 let result = Json5Parser
416 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
417 .expect("annotate failed");
418 assert_eq!(
419 result,
420 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
421 );
422 }
423
424 #[test]
427 fn jsonc_compact() {
428 let result = JsoncParser
429 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
430 .expect("annotate failed");
431 assert_eq!(
432 result,
433 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
434 );
435 }
436
437 #[test]
440 fn yaml_prepends_modeline() {
441 let result = YamlParser
442 .annotate("name: hello\n", "https://example.com/schema.json")
443 .expect("annotate failed");
444 assert_eq!(
445 result,
446 "# yaml-language-server: $schema=https://example.com/schema.json\nname: hello\n"
447 );
448 }
449
450 #[test]
451 fn yaml_preserves_existing_comments() {
452 let result = YamlParser
453 .annotate(
454 "# existing comment\nname: hello\n",
455 "https://example.com/schema.json",
456 )
457 .expect("annotate failed");
458 assert_eq!(
459 result,
460 "# yaml-language-server: $schema=https://example.com/schema.json\n# existing comment\nname: hello\n"
461 );
462 }
463
464 #[test]
467 fn toml_prepends_schema_comment() {
468 let result = TomlParser
469 .annotate("name = \"hello\"\n", "https://example.com/schema.json")
470 .expect("annotate failed");
471 assert_eq!(
472 result,
473 "# :schema https://example.com/schema.json\nname = \"hello\"\n"
474 );
475 }
476
477 #[test]
478 fn toml_preserves_existing_comments() {
479 let result = TomlParser
480 .annotate(
481 "# existing comment\nname = \"hello\"\n",
482 "https://example.com/schema.json",
483 )
484 .expect("annotate failed");
485 assert_eq!(
486 result,
487 "# :schema https://example.com/schema.json\n# existing comment\nname = \"hello\"\n"
488 );
489 }
490
491 #[test]
494 fn json_strip_compact_first_property() {
495 let input = r#"{"$schema":"https://old.com/s.json","name":"hello"}"#;
496 assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
497 }
498
499 #[test]
500 fn json_strip_pretty_first_property() {
501 let input = "{\n \"$schema\": \"https://old.com/s.json\",\n \"name\": \"hello\"\n}\n";
502 assert_eq!(
503 JsonParser.strip_annotation(input),
504 "{\n \"name\": \"hello\"\n}\n"
505 );
506 }
507
508 #[test]
509 fn json_strip_only_property() {
510 let input = r#"{"$schema":"https://old.com/s.json"}"#;
511 assert_eq!(JsonParser.strip_annotation(input), "{}");
512 }
513
514 #[test]
515 fn json_strip_last_property() {
516 let input = r#"{"name":"hello","$schema":"https://old.com/s.json"}"#;
517 assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
518 }
519
520 #[test]
521 fn json_strip_no_schema() {
522 let input = r#"{"name":"hello"}"#;
523 assert_eq!(JsonParser.strip_annotation(input), input);
524 }
525
526 #[test]
529 fn yaml_strip_modeline() {
530 let input = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
531 assert_eq!(YamlParser.strip_annotation(input), "name: hello\n");
532 }
533
534 #[test]
535 fn yaml_strip_modeline_preserves_other_comments() {
536 let input =
537 "# yaml-language-server: $schema=https://old.com/s.json\n# other\nname: hello\n";
538 assert_eq!(YamlParser.strip_annotation(input), "# other\nname: hello\n");
539 }
540
541 #[test]
542 fn yaml_strip_no_modeline() {
543 let input = "name: hello\n";
544 assert_eq!(YamlParser.strip_annotation(input), input);
545 }
546
547 #[test]
550 fn toml_strip_schema_comment() {
551 let input = "# :schema https://old.com/s.json\nname = \"hello\"\n";
552 assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
553 }
554
555 #[test]
556 fn toml_strip_legacy_schema_comment() {
557 let input = "# $schema: https://old.com/s.json\nname = \"hello\"\n";
558 assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
559 }
560
561 #[test]
562 fn toml_strip_preserves_other_comments() {
563 let input = "# :schema https://old.com/s.json\n# other\nname = \"hello\"\n";
564 assert_eq!(
565 TomlParser.strip_annotation(input),
566 "# other\nname = \"hello\"\n"
567 );
568 }
569
570 #[test]
571 fn toml_strip_no_schema() {
572 let input = "name = \"hello\"\n";
573 assert_eq!(TomlParser.strip_annotation(input), input);
574 }
575
576 #[test]
579 fn json_update_round_trip() {
580 let original = "{\n \"$schema\": \"https://old.com/s.json\",\n \"name\": \"hello\"\n}\n";
581 let stripped = JsonParser.strip_annotation(original);
582 let updated = JsonParser
583 .annotate(&stripped, "https://new.com/s.json")
584 .expect("annotate failed");
585 assert_eq!(
586 updated,
587 "{\n \"$schema\": \"https://new.com/s.json\",\n \"name\": \"hello\"\n}\n"
588 );
589 }
590
591 #[test]
592 fn yaml_update_round_trip() {
593 let original = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
594 let stripped = YamlParser.strip_annotation(original);
595 let updated = YamlParser
596 .annotate(&stripped, "https://new.com/s.json")
597 .expect("annotate failed");
598 assert_eq!(
599 updated,
600 "# yaml-language-server: $schema=https://new.com/s.json\nname: hello\n"
601 );
602 }
603
604 #[test]
605 fn toml_update_round_trip() {
606 let original = "# :schema https://old.com/s.json\nname = \"hello\"\n";
607 let stripped = TomlParser.strip_annotation(original);
608 let updated = TomlParser
609 .annotate(&stripped, "https://new.com/s.json")
610 .expect("annotate failed");
611 assert_eq!(
612 updated,
613 "# :schema https://new.com/s.json\nname = \"hello\"\n"
614 );
615 }
616}