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;
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// ---------------------------------------------------------------------------
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"))]
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    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// ---------------------------------------------------------------------------
145// Core logic
146// ---------------------------------------------------------------------------
147
148/// Run the annotate command.
149///
150/// # Errors
151///
152/// Returns an error if file collection or catalog fetching fails fatally.
153///
154/// # Panics
155///
156/// Panics if `--schema-cache-ttl` is provided with an unparseable duration.
157#[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    // --- JSON annotation ---
209
210    #[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    // --- JSON5 annotation delegates to same logic ---
280
281    #[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    // --- JSONC annotation delegates to same logic ---
293
294    #[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    // --- YAML annotation ---
306
307    #[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    // --- TOML annotation ---
333
334    #[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    // --- JSON strip_annotation ---
360
361    #[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    // --- YAML strip_annotation ---
395
396    #[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    // --- TOML strip_annotation ---
416
417    #[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    // --- Round-trip: strip then re-annotate ---
445
446    #[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}