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_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// ---------------------------------------------------------------------------
17// CLI args
18// ---------------------------------------------------------------------------
19
20#[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    /// Update existing annotations with latest catalog resolutions
30    #[bpaf(long("update"), switch)]
31    pub update: bool,
32
33    #[bpaf(positional("PATH"))]
34    pub globs: Vec<String>,
35}
36
37/// Construct the bpaf parser for `AnnotateArgs`.
38pub fn annotate_args() -> impl bpaf::Parser<AnnotateArgs> {
39    annotate_args_inner()
40}
41
42// ---------------------------------------------------------------------------
43// Result types
44// ---------------------------------------------------------------------------
45
46pub 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
58// ---------------------------------------------------------------------------
59// Per-file processing
60// ---------------------------------------------------------------------------
61
62enum 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// ---------------------------------------------------------------------------
146// Core logic
147// ---------------------------------------------------------------------------
148
149/// Run the annotate command.
150///
151/// # Errors
152///
153/// Returns an error if file collection or catalog fetching fails fatally.
154///
155/// # Panics
156///
157/// Panics if `--schema-cache-ttl` is provided with an unparseable duration.
158#[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    // --- JSON annotation ---
210
211    #[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    // --- JSON5 annotation delegates to same logic ---
281
282    #[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    // --- JSONC annotation delegates to same logic ---
294
295    #[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    // --- YAML annotation ---
307
308    #[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    // --- TOML annotation ---
334
335    #[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    // --- JSON strip_annotation ---
361
362    #[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    // --- YAML strip_annotation ---
396
397    #[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    // --- TOML strip_annotation ---
417
418    #[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    // --- Round-trip: strip then re-annotate ---
446
447    #[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}