Skip to main content

lintel_annotate/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use core::time::Duration;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use bpaf::{Bpaf, Parser};
9use glob::glob;
10
11use lintel_check::catalog::{self, CompiledCatalog};
12use lintel_check::config;
13use lintel_check::discover;
14use lintel_check::parsers;
15use lintel_check::registry;
16use lintel_check::retriever::SchemaCache;
17
18// ---------------------------------------------------------------------------
19// CLI args
20// ---------------------------------------------------------------------------
21
22#[derive(Debug, Clone, Bpaf)]
23#[bpaf(generate(annotate_args_inner))]
24pub struct AnnotateArgs {
25    #[bpaf(long("exclude"), argument("PATTERN"))]
26    pub exclude: Vec<String>,
27
28    #[bpaf(long("cache-dir"), argument("DIR"))]
29    pub cache_dir: Option<String>,
30
31    #[bpaf(long("no-catalog"), switch)]
32    pub no_catalog: bool,
33
34    #[bpaf(external(schema_cache_ttl))]
35    pub schema_cache_ttl: Option<Duration>,
36
37    /// Update existing annotations with latest catalog resolutions
38    #[bpaf(long("update"), switch)]
39    pub update: bool,
40
41    #[bpaf(positional("PATH"))]
42    pub globs: Vec<String>,
43}
44
45fn schema_cache_ttl() -> impl bpaf::Parser<Option<Duration>> {
46    bpaf::long("schema-cache-ttl")
47        .help("Schema cache TTL (e.g. \"12h\", \"30m\", \"1d\"); default 12h")
48        .argument::<String>("DURATION")
49        .parse(|s: String| {
50            humantime::parse_duration(&s).map_err(|e| format!("invalid duration '{s}': {e}"))
51        })
52        .optional()
53}
54
55/// Construct the bpaf parser for `AnnotateArgs`.
56pub fn annotate_args() -> impl bpaf::Parser<AnnotateArgs> {
57    annotate_args_inner()
58}
59
60// ---------------------------------------------------------------------------
61// Result types
62// ---------------------------------------------------------------------------
63
64pub struct AnnotatedFile {
65    pub path: String,
66    pub schema_url: String,
67}
68
69pub struct AnnotateResult {
70    pub annotated: Vec<AnnotatedFile>,
71    pub updated: Vec<AnnotatedFile>,
72    pub skipped: usize,
73    pub errors: Vec<(String, String)>,
74}
75
76// ---------------------------------------------------------------------------
77// Config loading (mirrors validate.rs)
78// ---------------------------------------------------------------------------
79
80fn load_config(search_dir: Option<&Path>) -> (config::Config, PathBuf) {
81    let start_dir = match search_dir {
82        Some(d) => d.to_path_buf(),
83        None => match std::env::current_dir() {
84            Ok(d) => d,
85            Err(_) => return (config::Config::default(), PathBuf::from(".")),
86        },
87    };
88
89    let cfg = config::find_and_load(&start_dir)
90        .ok()
91        .flatten()
92        .unwrap_or_default();
93    (cfg, start_dir)
94}
95
96// ---------------------------------------------------------------------------
97// File collection (mirrors validate.rs)
98// ---------------------------------------------------------------------------
99
100fn collect_files(globs_arg: &[String], exclude: &[String]) -> Result<Vec<PathBuf>> {
101    if globs_arg.is_empty() {
102        return discover::discover_files(".", exclude);
103    }
104
105    let mut result = Vec::new();
106    for pattern in globs_arg {
107        let path = Path::new(pattern);
108        if path.is_dir() {
109            result.extend(discover::discover_files(pattern, exclude)?);
110        } else {
111            for entry in glob(pattern).with_context(|| format!("invalid glob: {pattern}"))? {
112                let path = entry?;
113                if path.is_file() && !is_excluded(&path, exclude) {
114                    result.push(path);
115                }
116            }
117        }
118    }
119    Ok(result)
120}
121
122fn is_excluded(path: &Path, excludes: &[String]) -> bool {
123    let path_str = match path.to_str() {
124        Some(s) => s.strip_prefix("./").unwrap_or(s),
125        None => return false,
126    };
127    excludes
128        .iter()
129        .any(|pattern| glob_match::glob_match(pattern, path_str))
130}
131
132// ---------------------------------------------------------------------------
133// Catalog fetching
134// ---------------------------------------------------------------------------
135
136async fn fetch_catalogs(retriever: &SchemaCache, registries: &[String]) -> Vec<CompiledCatalog> {
137    type CatalogResult = (
138        String,
139        Result<CompiledCatalog, Box<dyn core::error::Error + Send + Sync>>,
140    );
141    let mut catalog_tasks: tokio::task::JoinSet<CatalogResult> = tokio::task::JoinSet::new();
142
143    // Lintel catalog
144    let r = retriever.clone();
145    let label = format!("default catalog {}", registry::DEFAULT_REGISTRY);
146    catalog_tasks.spawn(async move {
147        let result = registry::fetch(&r, registry::DEFAULT_REGISTRY)
148            .await
149            .map(|cat| CompiledCatalog::compile(&cat));
150        (label, result)
151    });
152
153    // SchemaStore catalog
154    let r = retriever.clone();
155    catalog_tasks.spawn(async move {
156        let result = catalog::fetch_catalog(&r)
157            .await
158            .map(|cat| CompiledCatalog::compile(&cat));
159        ("SchemaStore catalog".to_string(), result)
160    });
161
162    // Additional registries
163    for registry_url in registries {
164        let r = retriever.clone();
165        let url = registry_url.clone();
166        let label = format!("registry {url}");
167        catalog_tasks.spawn(async move {
168            let result = registry::fetch(&r, &url)
169                .await
170                .map(|cat| CompiledCatalog::compile(&cat));
171            (label, result)
172        });
173    }
174
175    let mut compiled = Vec::new();
176    while let Some(result) = catalog_tasks.join_next().await {
177        match result {
178            Ok((_, Ok(catalog))) => compiled.push(catalog),
179            Ok((label, Err(e))) => eprintln!("warning: failed to fetch {label}: {e}"),
180            Err(e) => eprintln!("warning: catalog fetch task failed: {e}"),
181        }
182    }
183    compiled
184}
185
186// ---------------------------------------------------------------------------
187// Per-file processing
188// ---------------------------------------------------------------------------
189
190enum FileOutcome {
191    Annotated(AnnotatedFile),
192    Updated(AnnotatedFile),
193    Skipped,
194    Error(String, String),
195}
196
197fn process_file(
198    file_path: &Path,
199    config: &config::Config,
200    catalogs: &[CompiledCatalog],
201    update: bool,
202) -> FileOutcome {
203    let path_str = file_path.display().to_string();
204    let file_name = file_path
205        .file_name()
206        .and_then(|n| n.to_str())
207        .unwrap_or(&path_str);
208
209    let content = match fs::read_to_string(file_path) {
210        Ok(c) => c,
211        Err(e) => return FileOutcome::Error(path_str, format!("failed to read: {e}")),
212    };
213
214    let Some(fmt) = parsers::detect_format(file_path) else {
215        return FileOutcome::Skipped;
216    };
217
218    let parser = parsers::parser_for(fmt);
219    let Ok(instance) = parser.parse(&content, &path_str) else {
220        return FileOutcome::Skipped;
221    };
222
223    let existing_schema = parser.extract_schema_uri(&content, &instance);
224    if existing_schema.is_some() && !update {
225        return FileOutcome::Skipped;
226    }
227
228    let schema_url = config
229        .find_schema_mapping(&path_str, file_name)
230        .map(str::to_string)
231        .or_else(|| {
232            catalogs
233                .iter()
234                .find_map(|cat| cat.find_schema(&path_str, file_name))
235                .map(str::to_string)
236        });
237
238    let Some(schema_url) = schema_url else {
239        return FileOutcome::Skipped;
240    };
241
242    let is_update = existing_schema.is_some();
243    if existing_schema.is_some_and(|existing| existing == schema_url) {
244        return FileOutcome::Skipped;
245    }
246
247    let content = if is_update {
248        parser.strip_annotation(&content)
249    } else {
250        content
251    };
252
253    let Some(new_content) = parser.annotate(&content, &schema_url) else {
254        return FileOutcome::Skipped;
255    };
256
257    match fs::write(file_path, &new_content) {
258        Ok(()) => {
259            let file = AnnotatedFile {
260                path: path_str,
261                schema_url,
262            };
263            if is_update {
264                FileOutcome::Updated(file)
265            } else {
266                FileOutcome::Annotated(file)
267            }
268        }
269        Err(e) => FileOutcome::Error(path_str, format!("failed to write: {e}")),
270    }
271}
272
273// ---------------------------------------------------------------------------
274// Core logic
275// ---------------------------------------------------------------------------
276
277/// Run the annotate command.
278///
279/// # Errors
280///
281/// Returns an error if file collection or catalog fetching fails fatally.
282///
283/// # Panics
284///
285/// Panics if `--schema-cache-ttl` is provided with an unparseable duration.
286#[tracing::instrument(skip_all, name = "annotate")]
287pub async fn run(args: &AnnotateArgs) -> Result<AnnotateResult> {
288    let config_dir = args
289        .globs
290        .iter()
291        .find(|g| Path::new(g).is_dir())
292        .map(PathBuf::from);
293
294    let mut builder = SchemaCache::builder();
295    if let Some(dir) = &args.cache_dir {
296        builder = builder.cache_dir(PathBuf::from(dir));
297    }
298    if let Some(ttl) = args.schema_cache_ttl {
299        builder = builder.ttl(ttl);
300    }
301    let retriever = builder.build();
302
303    let (mut config, _config_dir) = load_config(config_dir.as_deref());
304    config.exclude.extend(args.exclude.clone());
305
306    let files = collect_files(&args.globs, &config.exclude)?;
307    tracing::info!(file_count = files.len(), "collected files");
308
309    let catalogs = if args.no_catalog {
310        Vec::new()
311    } else {
312        fetch_catalogs(&retriever, &config.registries).await
313    };
314
315    let mut result = AnnotateResult {
316        annotated: Vec::new(),
317        updated: Vec::new(),
318        skipped: 0,
319        errors: Vec::new(),
320    };
321
322    for file_path in &files {
323        match process_file(file_path, &config, &catalogs, args.update) {
324            FileOutcome::Annotated(f) => result.annotated.push(f),
325            FileOutcome::Updated(f) => result.updated.push(f),
326            FileOutcome::Skipped => result.skipped += 1,
327            FileOutcome::Error(path, msg) => result.errors.push((path, msg)),
328        }
329    }
330
331    Ok(result)
332}
333
334#[cfg(test)]
335mod tests {
336    use lintel_check::parsers::{
337        Json5Parser, JsonParser, JsoncParser, Parser, TomlParser, YamlParser,
338    };
339
340    // --- JSON annotation ---
341
342    #[test]
343    fn json_compact() {
344        let result = JsonParser
345            .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
346            .expect("annotate failed");
347        assert_eq!(
348            result,
349            r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
350        );
351    }
352
353    #[test]
354    fn json_pretty() {
355        let result = JsonParser
356            .annotate(
357                "{\n  \"name\": \"hello\"\n}\n",
358                "https://example.com/schema.json",
359            )
360            .expect("annotate failed");
361        assert_eq!(
362            result,
363            "{\n  \"$schema\": \"https://example.com/schema.json\",\n  \"name\": \"hello\"\n}\n"
364        );
365    }
366
367    #[test]
368    fn json_pretty_4_spaces() {
369        let result = JsonParser
370            .annotate(
371                "{\n    \"name\": \"hello\"\n}\n",
372                "https://example.com/schema.json",
373            )
374            .expect("annotate failed");
375        assert_eq!(
376            result,
377            "{\n    \"$schema\": \"https://example.com/schema.json\",\n    \"name\": \"hello\"\n}\n"
378        );
379    }
380
381    #[test]
382    fn json_pretty_tabs() {
383        let result = JsonParser
384            .annotate(
385                "{\n\t\"name\": \"hello\"\n}\n",
386                "https://example.com/schema.json",
387            )
388            .expect("annotate failed");
389        assert_eq!(
390            result,
391            "{\n\t\"$schema\": \"https://example.com/schema.json\",\n\t\"name\": \"hello\"\n}\n"
392        );
393    }
394
395    #[test]
396    fn json_empty_object() {
397        let result = JsonParser
398            .annotate("{}", "https://example.com/schema.json")
399            .expect("annotate failed");
400        assert_eq!(result, r#"{"$schema":"https://example.com/schema.json",}"#);
401    }
402
403    #[test]
404    fn json_empty_object_pretty() {
405        let result = JsonParser
406            .annotate("{\n}\n", "https://example.com/schema.json")
407            .expect("annotate failed");
408        assert!(result.contains("\"$schema\": \"https://example.com/schema.json\""));
409    }
410
411    // --- JSON5 annotation delegates to same logic ---
412
413    #[test]
414    fn json5_compact() {
415        let result = Json5Parser
416            .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
417            .expect("annotate failed");
418        assert_eq!(
419            result,
420            r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
421        );
422    }
423
424    // --- JSONC annotation delegates to same logic ---
425
426    #[test]
427    fn jsonc_compact() {
428        let result = JsoncParser
429            .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
430            .expect("annotate failed");
431        assert_eq!(
432            result,
433            r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
434        );
435    }
436
437    // --- YAML annotation ---
438
439    #[test]
440    fn yaml_prepends_modeline() {
441        let result = YamlParser
442            .annotate("name: hello\n", "https://example.com/schema.json")
443            .expect("annotate failed");
444        assert_eq!(
445            result,
446            "# yaml-language-server: $schema=https://example.com/schema.json\nname: hello\n"
447        );
448    }
449
450    #[test]
451    fn yaml_preserves_existing_comments() {
452        let result = YamlParser
453            .annotate(
454                "# existing comment\nname: hello\n",
455                "https://example.com/schema.json",
456            )
457            .expect("annotate failed");
458        assert_eq!(
459            result,
460            "# yaml-language-server: $schema=https://example.com/schema.json\n# existing comment\nname: hello\n"
461        );
462    }
463
464    // --- TOML annotation ---
465
466    #[test]
467    fn toml_prepends_schema_comment() {
468        let result = TomlParser
469            .annotate("name = \"hello\"\n", "https://example.com/schema.json")
470            .expect("annotate failed");
471        assert_eq!(
472            result,
473            "# :schema https://example.com/schema.json\nname = \"hello\"\n"
474        );
475    }
476
477    #[test]
478    fn toml_preserves_existing_comments() {
479        let result = TomlParser
480            .annotate(
481                "# existing comment\nname = \"hello\"\n",
482                "https://example.com/schema.json",
483            )
484            .expect("annotate failed");
485        assert_eq!(
486            result,
487            "# :schema https://example.com/schema.json\n# existing comment\nname = \"hello\"\n"
488        );
489    }
490
491    // --- JSON strip_annotation ---
492
493    #[test]
494    fn json_strip_compact_first_property() {
495        let input = r#"{"$schema":"https://old.com/s.json","name":"hello"}"#;
496        assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
497    }
498
499    #[test]
500    fn json_strip_pretty_first_property() {
501        let input = "{\n  \"$schema\": \"https://old.com/s.json\",\n  \"name\": \"hello\"\n}\n";
502        assert_eq!(
503            JsonParser.strip_annotation(input),
504            "{\n  \"name\": \"hello\"\n}\n"
505        );
506    }
507
508    #[test]
509    fn json_strip_only_property() {
510        let input = r#"{"$schema":"https://old.com/s.json"}"#;
511        assert_eq!(JsonParser.strip_annotation(input), "{}");
512    }
513
514    #[test]
515    fn json_strip_last_property() {
516        let input = r#"{"name":"hello","$schema":"https://old.com/s.json"}"#;
517        assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
518    }
519
520    #[test]
521    fn json_strip_no_schema() {
522        let input = r#"{"name":"hello"}"#;
523        assert_eq!(JsonParser.strip_annotation(input), input);
524    }
525
526    // --- YAML strip_annotation ---
527
528    #[test]
529    fn yaml_strip_modeline() {
530        let input = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
531        assert_eq!(YamlParser.strip_annotation(input), "name: hello\n");
532    }
533
534    #[test]
535    fn yaml_strip_modeline_preserves_other_comments() {
536        let input =
537            "# yaml-language-server: $schema=https://old.com/s.json\n# other\nname: hello\n";
538        assert_eq!(YamlParser.strip_annotation(input), "# other\nname: hello\n");
539    }
540
541    #[test]
542    fn yaml_strip_no_modeline() {
543        let input = "name: hello\n";
544        assert_eq!(YamlParser.strip_annotation(input), input);
545    }
546
547    // --- TOML strip_annotation ---
548
549    #[test]
550    fn toml_strip_schema_comment() {
551        let input = "# :schema https://old.com/s.json\nname = \"hello\"\n";
552        assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
553    }
554
555    #[test]
556    fn toml_strip_legacy_schema_comment() {
557        let input = "# $schema: https://old.com/s.json\nname = \"hello\"\n";
558        assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
559    }
560
561    #[test]
562    fn toml_strip_preserves_other_comments() {
563        let input = "# :schema https://old.com/s.json\n# other\nname = \"hello\"\n";
564        assert_eq!(
565            TomlParser.strip_annotation(input),
566            "# other\nname = \"hello\"\n"
567        );
568    }
569
570    #[test]
571    fn toml_strip_no_schema() {
572        let input = "name = \"hello\"\n";
573        assert_eq!(TomlParser.strip_annotation(input), input);
574    }
575
576    // --- Round-trip: strip then re-annotate ---
577
578    #[test]
579    fn json_update_round_trip() {
580        let original = "{\n  \"$schema\": \"https://old.com/s.json\",\n  \"name\": \"hello\"\n}\n";
581        let stripped = JsonParser.strip_annotation(original);
582        let updated = JsonParser
583            .annotate(&stripped, "https://new.com/s.json")
584            .expect("annotate failed");
585        assert_eq!(
586            updated,
587            "{\n  \"$schema\": \"https://new.com/s.json\",\n  \"name\": \"hello\"\n}\n"
588        );
589    }
590
591    #[test]
592    fn yaml_update_round_trip() {
593        let original = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
594        let stripped = YamlParser.strip_annotation(original);
595        let updated = YamlParser
596            .annotate(&stripped, "https://new.com/s.json")
597            .expect("annotate failed");
598        assert_eq!(
599            updated,
600            "# yaml-language-server: $schema=https://new.com/s.json\nname: hello\n"
601        );
602    }
603
604    #[test]
605    fn toml_update_round_trip() {
606        let original = "# :schema https://old.com/s.json\nname = \"hello\"\n";
607        let stripped = TomlParser.strip_annotation(original);
608        let updated = TomlParser
609            .annotate(&stripped, "https://new.com/s.json")
610            .expect("annotate failed");
611        assert_eq!(
612            updated,
613            "# :schema https://new.com/s.json\nname = \"hello\"\n"
614        );
615    }
616}