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::{HttpClient, SchemaCache, ensure_cache_dir};
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<C: HttpClient>(
137 retriever: &SchemaCache<C>,
138 registries: &[String],
139) -> Vec<CompiledCatalog> {
140 type CatalogResult = (
141 String,
142 Result<CompiledCatalog, Box<dyn core::error::Error + Send + Sync>>,
143 );
144 let mut catalog_tasks: tokio::task::JoinSet<CatalogResult> = tokio::task::JoinSet::new();
145
146 let r = retriever.clone();
148 let label = format!("default catalog {}", registry::DEFAULT_REGISTRY);
149 catalog_tasks.spawn(async move {
150 let result = registry::fetch(&r, registry::DEFAULT_REGISTRY)
151 .await
152 .map(|cat| CompiledCatalog::compile(&cat));
153 (label, result)
154 });
155
156 let r = retriever.clone();
158 catalog_tasks.spawn(async move {
159 let result = catalog::fetch_catalog(&r)
160 .await
161 .map(|cat| CompiledCatalog::compile(&cat));
162 ("SchemaStore catalog".to_string(), result)
163 });
164
165 for registry_url in registries {
167 let r = retriever.clone();
168 let url = registry_url.clone();
169 let label = format!("registry {url}");
170 catalog_tasks.spawn(async move {
171 let result = registry::fetch(&r, &url)
172 .await
173 .map(|cat| CompiledCatalog::compile(&cat));
174 (label, result)
175 });
176 }
177
178 let mut compiled = Vec::new();
179 while let Some(result) = catalog_tasks.join_next().await {
180 match result {
181 Ok((_, Ok(catalog))) => compiled.push(catalog),
182 Ok((label, Err(e))) => eprintln!("warning: failed to fetch {label}: {e}"),
183 Err(e) => eprintln!("warning: catalog fetch task failed: {e}"),
184 }
185 }
186 compiled
187}
188
189enum FileOutcome {
194 Annotated(AnnotatedFile),
195 Updated(AnnotatedFile),
196 Skipped,
197 Error(String, String),
198}
199
200fn process_file(
201 file_path: &Path,
202 config: &config::Config,
203 catalogs: &[CompiledCatalog],
204 update: bool,
205) -> FileOutcome {
206 let path_str = file_path.display().to_string();
207 let file_name = file_path
208 .file_name()
209 .and_then(|n| n.to_str())
210 .unwrap_or(&path_str);
211
212 let content = match fs::read_to_string(file_path) {
213 Ok(c) => c,
214 Err(e) => return FileOutcome::Error(path_str, format!("failed to read: {e}")),
215 };
216
217 let Some(fmt) = parsers::detect_format(file_path) else {
218 return FileOutcome::Skipped;
219 };
220
221 let parser = parsers::parser_for(fmt);
222 let Ok(instance) = parser.parse(&content, &path_str) else {
223 return FileOutcome::Skipped;
224 };
225
226 let existing_schema = parser.extract_schema_uri(&content, &instance);
227 if existing_schema.is_some() && !update {
228 return FileOutcome::Skipped;
229 }
230
231 let schema_url = config
232 .find_schema_mapping(&path_str, file_name)
233 .map(str::to_string)
234 .or_else(|| {
235 catalogs
236 .iter()
237 .find_map(|cat| cat.find_schema(&path_str, file_name))
238 .map(str::to_string)
239 });
240
241 let Some(schema_url) = schema_url else {
242 return FileOutcome::Skipped;
243 };
244
245 let is_update = existing_schema.is_some();
246 if existing_schema.is_some_and(|existing| existing == schema_url) {
247 return FileOutcome::Skipped;
248 }
249
250 let content = if is_update {
251 parser.strip_annotation(&content)
252 } else {
253 content
254 };
255
256 let Some(new_content) = parser.annotate(&content, &schema_url) else {
257 return FileOutcome::Skipped;
258 };
259
260 match fs::write(file_path, &new_content) {
261 Ok(()) => {
262 let file = AnnotatedFile {
263 path: path_str,
264 schema_url,
265 };
266 if is_update {
267 FileOutcome::Updated(file)
268 } else {
269 FileOutcome::Annotated(file)
270 }
271 }
272 Err(e) => FileOutcome::Error(path_str, format!("failed to write: {e}")),
273 }
274}
275
276#[tracing::instrument(skip_all, name = "annotate")]
290pub async fn run<C: HttpClient>(args: &AnnotateArgs, client: C) -> Result<AnnotateResult> {
291 let config_dir = args
292 .globs
293 .iter()
294 .find(|g| Path::new(g).is_dir())
295 .map(PathBuf::from);
296
297 let schema_cache_ttl = args.schema_cache_ttl;
298
299 let cache_dir_path = args
300 .cache_dir
301 .as_ref()
302 .map_or_else(ensure_cache_dir, PathBuf::from);
303 let retriever = SchemaCache::new(
304 Some(cache_dir_path),
305 client,
306 false, schema_cache_ttl,
308 );
309
310 let (mut config, _config_dir) = load_config(config_dir.as_deref());
311 config.exclude.extend(args.exclude.clone());
312
313 let files = collect_files(&args.globs, &config.exclude)?;
314 tracing::info!(file_count = files.len(), "collected files");
315
316 let catalogs = if args.no_catalog {
317 Vec::new()
318 } else {
319 fetch_catalogs(&retriever, &config.registries).await
320 };
321
322 let mut result = AnnotateResult {
323 annotated: Vec::new(),
324 updated: Vec::new(),
325 skipped: 0,
326 errors: Vec::new(),
327 };
328
329 for file_path in &files {
330 match process_file(file_path, &config, &catalogs, args.update) {
331 FileOutcome::Annotated(f) => result.annotated.push(f),
332 FileOutcome::Updated(f) => result.updated.push(f),
333 FileOutcome::Skipped => result.skipped += 1,
334 FileOutcome::Error(path, msg) => result.errors.push((path, msg)),
335 }
336 }
337
338 Ok(result)
339}
340
341#[cfg(test)]
342mod tests {
343 use lintel_check::parsers::{
344 Json5Parser, JsonParser, JsoncParser, Parser, TomlParser, YamlParser,
345 };
346
347 #[test]
350 fn json_compact() {
351 let result = JsonParser
352 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
353 .expect("annotate failed");
354 assert_eq!(
355 result,
356 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
357 );
358 }
359
360 #[test]
361 fn json_pretty() {
362 let result = JsonParser
363 .annotate(
364 "{\n \"name\": \"hello\"\n}\n",
365 "https://example.com/schema.json",
366 )
367 .expect("annotate failed");
368 assert_eq!(
369 result,
370 "{\n \"$schema\": \"https://example.com/schema.json\",\n \"name\": \"hello\"\n}\n"
371 );
372 }
373
374 #[test]
375 fn json_pretty_4_spaces() {
376 let result = JsonParser
377 .annotate(
378 "{\n \"name\": \"hello\"\n}\n",
379 "https://example.com/schema.json",
380 )
381 .expect("annotate failed");
382 assert_eq!(
383 result,
384 "{\n \"$schema\": \"https://example.com/schema.json\",\n \"name\": \"hello\"\n}\n"
385 );
386 }
387
388 #[test]
389 fn json_pretty_tabs() {
390 let result = JsonParser
391 .annotate(
392 "{\n\t\"name\": \"hello\"\n}\n",
393 "https://example.com/schema.json",
394 )
395 .expect("annotate failed");
396 assert_eq!(
397 result,
398 "{\n\t\"$schema\": \"https://example.com/schema.json\",\n\t\"name\": \"hello\"\n}\n"
399 );
400 }
401
402 #[test]
403 fn json_empty_object() {
404 let result = JsonParser
405 .annotate("{}", "https://example.com/schema.json")
406 .expect("annotate failed");
407 assert_eq!(result, r#"{"$schema":"https://example.com/schema.json",}"#);
408 }
409
410 #[test]
411 fn json_empty_object_pretty() {
412 let result = JsonParser
413 .annotate("{\n}\n", "https://example.com/schema.json")
414 .expect("annotate failed");
415 assert!(result.contains("\"$schema\": \"https://example.com/schema.json\""));
416 }
417
418 #[test]
421 fn json5_compact() {
422 let result = Json5Parser
423 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
424 .expect("annotate failed");
425 assert_eq!(
426 result,
427 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
428 );
429 }
430
431 #[test]
434 fn jsonc_compact() {
435 let result = JsoncParser
436 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
437 .expect("annotate failed");
438 assert_eq!(
439 result,
440 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
441 );
442 }
443
444 #[test]
447 fn yaml_prepends_modeline() {
448 let result = YamlParser
449 .annotate("name: hello\n", "https://example.com/schema.json")
450 .expect("annotate failed");
451 assert_eq!(
452 result,
453 "# yaml-language-server: $schema=https://example.com/schema.json\nname: hello\n"
454 );
455 }
456
457 #[test]
458 fn yaml_preserves_existing_comments() {
459 let result = YamlParser
460 .annotate(
461 "# existing comment\nname: hello\n",
462 "https://example.com/schema.json",
463 )
464 .expect("annotate failed");
465 assert_eq!(
466 result,
467 "# yaml-language-server: $schema=https://example.com/schema.json\n# existing comment\nname: hello\n"
468 );
469 }
470
471 #[test]
474 fn toml_prepends_schema_comment() {
475 let result = TomlParser
476 .annotate("name = \"hello\"\n", "https://example.com/schema.json")
477 .expect("annotate failed");
478 assert_eq!(
479 result,
480 "# :schema https://example.com/schema.json\nname = \"hello\"\n"
481 );
482 }
483
484 #[test]
485 fn toml_preserves_existing_comments() {
486 let result = TomlParser
487 .annotate(
488 "# existing comment\nname = \"hello\"\n",
489 "https://example.com/schema.json",
490 )
491 .expect("annotate failed");
492 assert_eq!(
493 result,
494 "# :schema https://example.com/schema.json\n# existing comment\nname = \"hello\"\n"
495 );
496 }
497
498 #[test]
501 fn json_strip_compact_first_property() {
502 let input = r#"{"$schema":"https://old.com/s.json","name":"hello"}"#;
503 assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
504 }
505
506 #[test]
507 fn json_strip_pretty_first_property() {
508 let input = "{\n \"$schema\": \"https://old.com/s.json\",\n \"name\": \"hello\"\n}\n";
509 assert_eq!(
510 JsonParser.strip_annotation(input),
511 "{\n \"name\": \"hello\"\n}\n"
512 );
513 }
514
515 #[test]
516 fn json_strip_only_property() {
517 let input = r#"{"$schema":"https://old.com/s.json"}"#;
518 assert_eq!(JsonParser.strip_annotation(input), "{}");
519 }
520
521 #[test]
522 fn json_strip_last_property() {
523 let input = r#"{"name":"hello","$schema":"https://old.com/s.json"}"#;
524 assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
525 }
526
527 #[test]
528 fn json_strip_no_schema() {
529 let input = r#"{"name":"hello"}"#;
530 assert_eq!(JsonParser.strip_annotation(input), input);
531 }
532
533 #[test]
536 fn yaml_strip_modeline() {
537 let input = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
538 assert_eq!(YamlParser.strip_annotation(input), "name: hello\n");
539 }
540
541 #[test]
542 fn yaml_strip_modeline_preserves_other_comments() {
543 let input =
544 "# yaml-language-server: $schema=https://old.com/s.json\n# other\nname: hello\n";
545 assert_eq!(YamlParser.strip_annotation(input), "# other\nname: hello\n");
546 }
547
548 #[test]
549 fn yaml_strip_no_modeline() {
550 let input = "name: hello\n";
551 assert_eq!(YamlParser.strip_annotation(input), input);
552 }
553
554 #[test]
557 fn toml_strip_schema_comment() {
558 let input = "# :schema https://old.com/s.json\nname = \"hello\"\n";
559 assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
560 }
561
562 #[test]
563 fn toml_strip_legacy_schema_comment() {
564 let input = "# $schema: https://old.com/s.json\nname = \"hello\"\n";
565 assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
566 }
567
568 #[test]
569 fn toml_strip_preserves_other_comments() {
570 let input = "# :schema https://old.com/s.json\n# other\nname = \"hello\"\n";
571 assert_eq!(
572 TomlParser.strip_annotation(input),
573 "# other\nname = \"hello\"\n"
574 );
575 }
576
577 #[test]
578 fn toml_strip_no_schema() {
579 let input = "name = \"hello\"\n";
580 assert_eq!(TomlParser.strip_annotation(input), input);
581 }
582
583 #[test]
586 fn json_update_round_trip() {
587 let original = "{\n \"$schema\": \"https://old.com/s.json\",\n \"name\": \"hello\"\n}\n";
588 let stripped = JsonParser.strip_annotation(original);
589 let updated = JsonParser
590 .annotate(&stripped, "https://new.com/s.json")
591 .expect("annotate failed");
592 assert_eq!(
593 updated,
594 "{\n \"$schema\": \"https://new.com/s.json\",\n \"name\": \"hello\"\n}\n"
595 );
596 }
597
598 #[test]
599 fn yaml_update_round_trip() {
600 let original = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
601 let stripped = YamlParser.strip_annotation(original);
602 let updated = YamlParser
603 .annotate(&stripped, "https://new.com/s.json")
604 .expect("annotate failed");
605 assert_eq!(
606 updated,
607 "# yaml-language-server: $schema=https://new.com/s.json\nname: hello\n"
608 );
609 }
610
611 #[test]
612 fn toml_update_round_trip() {
613 let original = "# :schema https://old.com/s.json\nname = \"hello\"\n";
614 let stripped = TomlParser.strip_annotation(original);
615 let updated = TomlParser
616 .annotate(&stripped, "https://new.com/s.json")
617 .expect("annotate failed");
618 assert_eq!(
619 updated,
620 "# :schema https://new.com/s.json\nname = \"hello\"\n"
621 );
622 }
623}