1#![doc = include_str!("../README.md")]
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::Result;
7use bpaf::Bpaf;
8
9use lintel_cli_common::CliCacheOptions;
10use lintel_schema_cache::SchemaCache;
11use lintel_validate::parsers;
12use lintel_validate::validate;
13use schema_catalog::CompiledCatalog;
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"))]
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 let parser = parsers::parser_for(fmt);
90 let Ok(instance) = parser.parse(&content, &path_str) else {
91 return FileOutcome::Skipped;
92 };
93
94 let existing_schema = parser.extract_schema_uri(&content, &instance);
95 if existing_schema.is_some() && !update {
96 return FileOutcome::Skipped;
97 }
98
99 let schema_url = config
100 .find_schema_mapping(&path_str, file_name)
101 .map(str::to_string)
102 .or_else(|| {
103 catalogs
104 .iter()
105 .find_map(|cat| cat.find_schema(&path_str, file_name))
106 .map(str::to_string)
107 });
108
109 let Some(schema_url) = schema_url else {
110 return FileOutcome::Skipped;
111 };
112
113 let is_update = existing_schema.is_some();
114 if existing_schema.is_some_and(|existing| existing == schema_url) {
115 return FileOutcome::Skipped;
116 }
117
118 let content = if is_update {
119 parser.strip_annotation(&content)
120 } else {
121 content
122 };
123
124 let Some(new_content) = parser.annotate(&content, &schema_url) else {
125 return FileOutcome::Skipped;
126 };
127
128 match fs::write(file_path, &new_content) {
129 Ok(()) => {
130 let file = AnnotatedFile {
131 path: path_str,
132 schema_url,
133 };
134 if is_update {
135 FileOutcome::Updated(file)
136 } else {
137 FileOutcome::Annotated(file)
138 }
139 }
140 Err(e) => FileOutcome::Error(path_str, format!("failed to write: {e}")),
141 }
142}
143
144#[tracing::instrument(skip_all, name = "annotate")]
158pub async fn run(args: &AnnotateArgs) -> Result<AnnotateResult> {
159 let config_dir = args
160 .globs
161 .iter()
162 .find(|g| Path::new(g).is_dir())
163 .map(PathBuf::from);
164
165 let mut builder = SchemaCache::builder();
166 if let Some(dir) = &args.cache.cache_dir {
167 builder = builder.cache_dir(PathBuf::from(dir));
168 }
169 if let Some(ttl) = args.cache.schema_cache_ttl {
170 builder = builder.ttl(ttl);
171 }
172 let retriever = builder.build();
173
174 let (mut config, _, _) = validate::load_config(config_dir.as_deref());
175 config.exclude.extend(args.exclude.clone());
176
177 let files = validate::collect_files(&args.globs, &config.exclude)?;
178 tracing::info!(file_count = files.len(), "collected files");
179
180 let catalogs =
181 validate::fetch_compiled_catalogs(&retriever, &config, args.cache.no_catalog).await;
182
183 let mut result = AnnotateResult {
184 annotated: Vec::new(),
185 updated: Vec::new(),
186 skipped: 0,
187 errors: Vec::new(),
188 };
189
190 for file_path in &files {
191 match process_file(file_path, &config, &catalogs, args.update) {
192 FileOutcome::Annotated(f) => result.annotated.push(f),
193 FileOutcome::Updated(f) => result.updated.push(f),
194 FileOutcome::Skipped => result.skipped += 1,
195 FileOutcome::Error(path, msg) => result.errors.push((path, msg)),
196 }
197 }
198
199 Ok(result)
200}
201
202#[cfg(test)]
203mod tests {
204 use lintel_validate::parsers::{
205 Json5Parser, JsonParser, JsoncParser, Parser, TomlParser, YamlParser,
206 };
207
208 #[test]
211 fn json_compact() {
212 let result = JsonParser
213 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
214 .expect("annotate failed");
215 assert_eq!(
216 result,
217 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
218 );
219 }
220
221 #[test]
222 fn json_pretty() {
223 let result = JsonParser
224 .annotate(
225 "{\n \"name\": \"hello\"\n}\n",
226 "https://example.com/schema.json",
227 )
228 .expect("annotate failed");
229 assert_eq!(
230 result,
231 "{\n \"$schema\": \"https://example.com/schema.json\",\n \"name\": \"hello\"\n}\n"
232 );
233 }
234
235 #[test]
236 fn json_pretty_4_spaces() {
237 let result = JsonParser
238 .annotate(
239 "{\n \"name\": \"hello\"\n}\n",
240 "https://example.com/schema.json",
241 )
242 .expect("annotate failed");
243 assert_eq!(
244 result,
245 "{\n \"$schema\": \"https://example.com/schema.json\",\n \"name\": \"hello\"\n}\n"
246 );
247 }
248
249 #[test]
250 fn json_pretty_tabs() {
251 let result = JsonParser
252 .annotate(
253 "{\n\t\"name\": \"hello\"\n}\n",
254 "https://example.com/schema.json",
255 )
256 .expect("annotate failed");
257 assert_eq!(
258 result,
259 "{\n\t\"$schema\": \"https://example.com/schema.json\",\n\t\"name\": \"hello\"\n}\n"
260 );
261 }
262
263 #[test]
264 fn json_empty_object() {
265 let result = JsonParser
266 .annotate("{}", "https://example.com/schema.json")
267 .expect("annotate failed");
268 assert_eq!(result, r#"{"$schema":"https://example.com/schema.json",}"#);
269 }
270
271 #[test]
272 fn json_empty_object_pretty() {
273 let result = JsonParser
274 .annotate("{\n}\n", "https://example.com/schema.json")
275 .expect("annotate failed");
276 assert!(result.contains("\"$schema\": \"https://example.com/schema.json\""));
277 }
278
279 #[test]
282 fn json5_compact() {
283 let result = Json5Parser
284 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
285 .expect("annotate failed");
286 assert_eq!(
287 result,
288 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
289 );
290 }
291
292 #[test]
295 fn jsonc_compact() {
296 let result = JsoncParser
297 .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
298 .expect("annotate failed");
299 assert_eq!(
300 result,
301 r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
302 );
303 }
304
305 #[test]
308 fn yaml_prepends_modeline() {
309 let result = YamlParser
310 .annotate("name: hello\n", "https://example.com/schema.json")
311 .expect("annotate failed");
312 assert_eq!(
313 result,
314 "# yaml-language-server: $schema=https://example.com/schema.json\nname: hello\n"
315 );
316 }
317
318 #[test]
319 fn yaml_preserves_existing_comments() {
320 let result = YamlParser
321 .annotate(
322 "# existing comment\nname: hello\n",
323 "https://example.com/schema.json",
324 )
325 .expect("annotate failed");
326 assert_eq!(
327 result,
328 "# yaml-language-server: $schema=https://example.com/schema.json\n# existing comment\nname: hello\n"
329 );
330 }
331
332 #[test]
335 fn toml_prepends_schema_comment() {
336 let result = TomlParser
337 .annotate("name = \"hello\"\n", "https://example.com/schema.json")
338 .expect("annotate failed");
339 assert_eq!(
340 result,
341 "# :schema https://example.com/schema.json\nname = \"hello\"\n"
342 );
343 }
344
345 #[test]
346 fn toml_preserves_existing_comments() {
347 let result = TomlParser
348 .annotate(
349 "# existing comment\nname = \"hello\"\n",
350 "https://example.com/schema.json",
351 )
352 .expect("annotate failed");
353 assert_eq!(
354 result,
355 "# :schema https://example.com/schema.json\n# existing comment\nname = \"hello\"\n"
356 );
357 }
358
359 #[test]
362 fn json_strip_compact_first_property() {
363 let input = r#"{"$schema":"https://old.com/s.json","name":"hello"}"#;
364 assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
365 }
366
367 #[test]
368 fn json_strip_pretty_first_property() {
369 let input = "{\n \"$schema\": \"https://old.com/s.json\",\n \"name\": \"hello\"\n}\n";
370 assert_eq!(
371 JsonParser.strip_annotation(input),
372 "{\n \"name\": \"hello\"\n}\n"
373 );
374 }
375
376 #[test]
377 fn json_strip_only_property() {
378 let input = r#"{"$schema":"https://old.com/s.json"}"#;
379 assert_eq!(JsonParser.strip_annotation(input), "{}");
380 }
381
382 #[test]
383 fn json_strip_last_property() {
384 let input = r#"{"name":"hello","$schema":"https://old.com/s.json"}"#;
385 assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
386 }
387
388 #[test]
389 fn json_strip_no_schema() {
390 let input = r#"{"name":"hello"}"#;
391 assert_eq!(JsonParser.strip_annotation(input), input);
392 }
393
394 #[test]
397 fn yaml_strip_modeline() {
398 let input = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
399 assert_eq!(YamlParser.strip_annotation(input), "name: hello\n");
400 }
401
402 #[test]
403 fn yaml_strip_modeline_preserves_other_comments() {
404 let input =
405 "# yaml-language-server: $schema=https://old.com/s.json\n# other\nname: hello\n";
406 assert_eq!(YamlParser.strip_annotation(input), "# other\nname: hello\n");
407 }
408
409 #[test]
410 fn yaml_strip_no_modeline() {
411 let input = "name: hello\n";
412 assert_eq!(YamlParser.strip_annotation(input), input);
413 }
414
415 #[test]
418 fn toml_strip_schema_comment() {
419 let input = "# :schema https://old.com/s.json\nname = \"hello\"\n";
420 assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
421 }
422
423 #[test]
424 fn toml_strip_legacy_schema_comment() {
425 let input = "# $schema: https://old.com/s.json\nname = \"hello\"\n";
426 assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
427 }
428
429 #[test]
430 fn toml_strip_preserves_other_comments() {
431 let input = "# :schema https://old.com/s.json\n# other\nname = \"hello\"\n";
432 assert_eq!(
433 TomlParser.strip_annotation(input),
434 "# other\nname = \"hello\"\n"
435 );
436 }
437
438 #[test]
439 fn toml_strip_no_schema() {
440 let input = "name = \"hello\"\n";
441 assert_eq!(TomlParser.strip_annotation(input), input);
442 }
443
444 #[test]
447 fn json_update_round_trip() {
448 let original = "{\n \"$schema\": \"https://old.com/s.json\",\n \"name\": \"hello\"\n}\n";
449 let stripped = JsonParser.strip_annotation(original);
450 let updated = JsonParser
451 .annotate(&stripped, "https://new.com/s.json")
452 .expect("annotate failed");
453 assert_eq!(
454 updated,
455 "{\n \"$schema\": \"https://new.com/s.json\",\n \"name\": \"hello\"\n}\n"
456 );
457 }
458
459 #[test]
460 fn yaml_update_round_trip() {
461 let original = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
462 let stripped = YamlParser.strip_annotation(original);
463 let updated = YamlParser
464 .annotate(&stripped, "https://new.com/s.json")
465 .expect("annotate failed");
466 assert_eq!(
467 updated,
468 "# yaml-language-server: $schema=https://new.com/s.json\nname: hello\n"
469 );
470 }
471
472 #[test]
473 fn toml_update_round_trip() {
474 let original = "# :schema https://old.com/s.json\nname = \"hello\"\n";
475 let stripped = TomlParser.strip_annotation(original);
476 let updated = TomlParser
477 .annotate(&stripped, "https://new.com/s.json")
478 .expect("annotate failed");
479 assert_eq!(
480 updated,
481 "# :schema https://new.com/s.json\nname = \"hello\"\n"
482 );
483 }
484}