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