Skip to main content

lintel_annotate/
lib.rs

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// ---------------------------------------------------------------------------
16// CLI args
17// ---------------------------------------------------------------------------
18
19#[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    /// Update existing annotations with latest catalog resolutions
29    #[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
36/// Construct the bpaf parser for `AnnotateArgs`.
37pub fn annotate_args() -> impl bpaf::Parser<AnnotateArgs> {
38    annotate_args_inner()
39}
40
41// ---------------------------------------------------------------------------
42// Result types
43// ---------------------------------------------------------------------------
44
45pub 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
57// ---------------------------------------------------------------------------
58// Per-file processing
59// ---------------------------------------------------------------------------
60
61enum 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    // JSONL files don't support inline annotations; use lintel.toml mappings instead.
90    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// ---------------------------------------------------------------------------
150// Core logic
151// ---------------------------------------------------------------------------
152
153/// Run the annotate command.
154///
155/// # Errors
156///
157/// Returns an error if file collection or catalog fetching fails fatally.
158///
159/// # Panics
160///
161/// Panics if `--schema-cache-ttl` is provided with an unparseable duration.
162#[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    // --- JSON annotation ---
214
215    #[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    // --- JSON5 annotation delegates to same logic ---
285
286    #[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    // --- JSONC annotation delegates to same logic ---
298
299    #[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    // --- YAML annotation ---
311
312    #[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    // --- TOML annotation ---
338
339    #[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    // --- JSON strip_annotation ---
365
366    #[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    // --- YAML strip_annotation ---
400
401    #[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    // --- TOML strip_annotation ---
421
422    #[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    // --- Round-trip: strip then re-annotate ---
450
451    #[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}