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::{HttpClient, SchemaCache, ensure_cache_dir};
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<C: HttpClient>(
137    retriever: &SchemaCache<C>,
138    registries: &[String],
139) -> Vec<CompiledCatalog> {
140    type CatalogResult = (
141        String,
142        Result<CompiledCatalog, Box<dyn core::error::Error + Send + Sync>>,
143    );
144    let mut catalog_tasks: tokio::task::JoinSet<CatalogResult> = tokio::task::JoinSet::new();
145
146    // Lintel catalog
147    let r = retriever.clone();
148    let label = format!("default catalog {}", registry::DEFAULT_REGISTRY);
149    catalog_tasks.spawn(async move {
150        let result = registry::fetch(&r, registry::DEFAULT_REGISTRY)
151            .await
152            .map(|cat| CompiledCatalog::compile(&cat));
153        (label, result)
154    });
155
156    // SchemaStore catalog
157    let r = retriever.clone();
158    catalog_tasks.spawn(async move {
159        let result = catalog::fetch_catalog(&r)
160            .await
161            .map(|cat| CompiledCatalog::compile(&cat));
162        ("SchemaStore catalog".to_string(), result)
163    });
164
165    // Additional registries
166    for registry_url in registries {
167        let r = retriever.clone();
168        let url = registry_url.clone();
169        let label = format!("registry {url}");
170        catalog_tasks.spawn(async move {
171            let result = registry::fetch(&r, &url)
172                .await
173                .map(|cat| CompiledCatalog::compile(&cat));
174            (label, result)
175        });
176    }
177
178    let mut compiled = Vec::new();
179    while let Some(result) = catalog_tasks.join_next().await {
180        match result {
181            Ok((_, Ok(catalog))) => compiled.push(catalog),
182            Ok((label, Err(e))) => eprintln!("warning: failed to fetch {label}: {e}"),
183            Err(e) => eprintln!("warning: catalog fetch task failed: {e}"),
184        }
185    }
186    compiled
187}
188
189// ---------------------------------------------------------------------------
190// Per-file processing
191// ---------------------------------------------------------------------------
192
193enum FileOutcome {
194    Annotated(AnnotatedFile),
195    Updated(AnnotatedFile),
196    Skipped,
197    Error(String, String),
198}
199
200fn process_file(
201    file_path: &Path,
202    config: &config::Config,
203    catalogs: &[CompiledCatalog],
204    update: bool,
205) -> FileOutcome {
206    let path_str = file_path.display().to_string();
207    let file_name = file_path
208        .file_name()
209        .and_then(|n| n.to_str())
210        .unwrap_or(&path_str);
211
212    let content = match fs::read_to_string(file_path) {
213        Ok(c) => c,
214        Err(e) => return FileOutcome::Error(path_str, format!("failed to read: {e}")),
215    };
216
217    let Some(fmt) = parsers::detect_format(file_path) else {
218        return FileOutcome::Skipped;
219    };
220
221    let parser = parsers::parser_for(fmt);
222    let Ok(instance) = parser.parse(&content, &path_str) else {
223        return FileOutcome::Skipped;
224    };
225
226    let existing_schema = parser.extract_schema_uri(&content, &instance);
227    if existing_schema.is_some() && !update {
228        return FileOutcome::Skipped;
229    }
230
231    let schema_url = config
232        .find_schema_mapping(&path_str, file_name)
233        .map(str::to_string)
234        .or_else(|| {
235            catalogs
236                .iter()
237                .find_map(|cat| cat.find_schema(&path_str, file_name))
238                .map(str::to_string)
239        });
240
241    let Some(schema_url) = schema_url else {
242        return FileOutcome::Skipped;
243    };
244
245    let is_update = existing_schema.is_some();
246    if existing_schema.is_some_and(|existing| existing == schema_url) {
247        return FileOutcome::Skipped;
248    }
249
250    let content = if is_update {
251        parser.strip_annotation(&content)
252    } else {
253        content
254    };
255
256    let Some(new_content) = parser.annotate(&content, &schema_url) else {
257        return FileOutcome::Skipped;
258    };
259
260    match fs::write(file_path, &new_content) {
261        Ok(()) => {
262            let file = AnnotatedFile {
263                path: path_str,
264                schema_url,
265            };
266            if is_update {
267                FileOutcome::Updated(file)
268            } else {
269                FileOutcome::Annotated(file)
270            }
271        }
272        Err(e) => FileOutcome::Error(path_str, format!("failed to write: {e}")),
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Core logic
278// ---------------------------------------------------------------------------
279
280/// Run the annotate command.
281///
282/// # Errors
283///
284/// Returns an error if file collection or catalog fetching fails fatally.
285///
286/// # Panics
287///
288/// Panics if `--schema-cache-ttl` is provided with an unparseable duration.
289#[tracing::instrument(skip_all, name = "annotate")]
290pub async fn run<C: HttpClient>(args: &AnnotateArgs, client: C) -> Result<AnnotateResult> {
291    let config_dir = args
292        .globs
293        .iter()
294        .find(|g| Path::new(g).is_dir())
295        .map(PathBuf::from);
296
297    let schema_cache_ttl = args.schema_cache_ttl;
298
299    let cache_dir_path = args
300        .cache_dir
301        .as_ref()
302        .map_or_else(ensure_cache_dir, PathBuf::from);
303    let retriever = SchemaCache::new(
304        Some(cache_dir_path),
305        client,
306        false, // don't force schema fetch
307        schema_cache_ttl,
308    );
309
310    let (mut config, _config_dir) = load_config(config_dir.as_deref());
311    config.exclude.extend(args.exclude.clone());
312
313    let files = collect_files(&args.globs, &config.exclude)?;
314    tracing::info!(file_count = files.len(), "collected files");
315
316    let catalogs = if args.no_catalog {
317        Vec::new()
318    } else {
319        fetch_catalogs(&retriever, &config.registries).await
320    };
321
322    let mut result = AnnotateResult {
323        annotated: Vec::new(),
324        updated: Vec::new(),
325        skipped: 0,
326        errors: Vec::new(),
327    };
328
329    for file_path in &files {
330        match process_file(file_path, &config, &catalogs, args.update) {
331            FileOutcome::Annotated(f) => result.annotated.push(f),
332            FileOutcome::Updated(f) => result.updated.push(f),
333            FileOutcome::Skipped => result.skipped += 1,
334            FileOutcome::Error(path, msg) => result.errors.push((path, msg)),
335        }
336    }
337
338    Ok(result)
339}
340
341#[cfg(test)]
342mod tests {
343    use lintel_check::parsers::{
344        Json5Parser, JsonParser, JsoncParser, Parser, TomlParser, YamlParser,
345    };
346
347    // --- JSON annotation ---
348
349    #[test]
350    fn json_compact() {
351        let result = JsonParser
352            .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
353            .expect("annotate failed");
354        assert_eq!(
355            result,
356            r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
357        );
358    }
359
360    #[test]
361    fn json_pretty() {
362        let result = JsonParser
363            .annotate(
364                "{\n  \"name\": \"hello\"\n}\n",
365                "https://example.com/schema.json",
366            )
367            .expect("annotate failed");
368        assert_eq!(
369            result,
370            "{\n  \"$schema\": \"https://example.com/schema.json\",\n  \"name\": \"hello\"\n}\n"
371        );
372    }
373
374    #[test]
375    fn json_pretty_4_spaces() {
376        let result = JsonParser
377            .annotate(
378                "{\n    \"name\": \"hello\"\n}\n",
379                "https://example.com/schema.json",
380            )
381            .expect("annotate failed");
382        assert_eq!(
383            result,
384            "{\n    \"$schema\": \"https://example.com/schema.json\",\n    \"name\": \"hello\"\n}\n"
385        );
386    }
387
388    #[test]
389    fn json_pretty_tabs() {
390        let result = JsonParser
391            .annotate(
392                "{\n\t\"name\": \"hello\"\n}\n",
393                "https://example.com/schema.json",
394            )
395            .expect("annotate failed");
396        assert_eq!(
397            result,
398            "{\n\t\"$schema\": \"https://example.com/schema.json\",\n\t\"name\": \"hello\"\n}\n"
399        );
400    }
401
402    #[test]
403    fn json_empty_object() {
404        let result = JsonParser
405            .annotate("{}", "https://example.com/schema.json")
406            .expect("annotate failed");
407        assert_eq!(result, r#"{"$schema":"https://example.com/schema.json",}"#);
408    }
409
410    #[test]
411    fn json_empty_object_pretty() {
412        let result = JsonParser
413            .annotate("{\n}\n", "https://example.com/schema.json")
414            .expect("annotate failed");
415        assert!(result.contains("\"$schema\": \"https://example.com/schema.json\""));
416    }
417
418    // --- JSON5 annotation delegates to same logic ---
419
420    #[test]
421    fn json5_compact() {
422        let result = Json5Parser
423            .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
424            .expect("annotate failed");
425        assert_eq!(
426            result,
427            r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
428        );
429    }
430
431    // --- JSONC annotation delegates to same logic ---
432
433    #[test]
434    fn jsonc_compact() {
435        let result = JsoncParser
436            .annotate(r#"{"name":"hello"}"#, "https://example.com/schema.json")
437            .expect("annotate failed");
438        assert_eq!(
439            result,
440            r#"{"$schema":"https://example.com/schema.json","name":"hello"}"#
441        );
442    }
443
444    // --- YAML annotation ---
445
446    #[test]
447    fn yaml_prepends_modeline() {
448        let result = YamlParser
449            .annotate("name: hello\n", "https://example.com/schema.json")
450            .expect("annotate failed");
451        assert_eq!(
452            result,
453            "# yaml-language-server: $schema=https://example.com/schema.json\nname: hello\n"
454        );
455    }
456
457    #[test]
458    fn yaml_preserves_existing_comments() {
459        let result = YamlParser
460            .annotate(
461                "# existing comment\nname: hello\n",
462                "https://example.com/schema.json",
463            )
464            .expect("annotate failed");
465        assert_eq!(
466            result,
467            "# yaml-language-server: $schema=https://example.com/schema.json\n# existing comment\nname: hello\n"
468        );
469    }
470
471    // --- TOML annotation ---
472
473    #[test]
474    fn toml_prepends_schema_comment() {
475        let result = TomlParser
476            .annotate("name = \"hello\"\n", "https://example.com/schema.json")
477            .expect("annotate failed");
478        assert_eq!(
479            result,
480            "# :schema https://example.com/schema.json\nname = \"hello\"\n"
481        );
482    }
483
484    #[test]
485    fn toml_preserves_existing_comments() {
486        let result = TomlParser
487            .annotate(
488                "# existing comment\nname = \"hello\"\n",
489                "https://example.com/schema.json",
490            )
491            .expect("annotate failed");
492        assert_eq!(
493            result,
494            "# :schema https://example.com/schema.json\n# existing comment\nname = \"hello\"\n"
495        );
496    }
497
498    // --- JSON strip_annotation ---
499
500    #[test]
501    fn json_strip_compact_first_property() {
502        let input = r#"{"$schema":"https://old.com/s.json","name":"hello"}"#;
503        assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
504    }
505
506    #[test]
507    fn json_strip_pretty_first_property() {
508        let input = "{\n  \"$schema\": \"https://old.com/s.json\",\n  \"name\": \"hello\"\n}\n";
509        assert_eq!(
510            JsonParser.strip_annotation(input),
511            "{\n  \"name\": \"hello\"\n}\n"
512        );
513    }
514
515    #[test]
516    fn json_strip_only_property() {
517        let input = r#"{"$schema":"https://old.com/s.json"}"#;
518        assert_eq!(JsonParser.strip_annotation(input), "{}");
519    }
520
521    #[test]
522    fn json_strip_last_property() {
523        let input = r#"{"name":"hello","$schema":"https://old.com/s.json"}"#;
524        assert_eq!(JsonParser.strip_annotation(input), r#"{"name":"hello"}"#);
525    }
526
527    #[test]
528    fn json_strip_no_schema() {
529        let input = r#"{"name":"hello"}"#;
530        assert_eq!(JsonParser.strip_annotation(input), input);
531    }
532
533    // --- YAML strip_annotation ---
534
535    #[test]
536    fn yaml_strip_modeline() {
537        let input = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
538        assert_eq!(YamlParser.strip_annotation(input), "name: hello\n");
539    }
540
541    #[test]
542    fn yaml_strip_modeline_preserves_other_comments() {
543        let input =
544            "# yaml-language-server: $schema=https://old.com/s.json\n# other\nname: hello\n";
545        assert_eq!(YamlParser.strip_annotation(input), "# other\nname: hello\n");
546    }
547
548    #[test]
549    fn yaml_strip_no_modeline() {
550        let input = "name: hello\n";
551        assert_eq!(YamlParser.strip_annotation(input), input);
552    }
553
554    // --- TOML strip_annotation ---
555
556    #[test]
557    fn toml_strip_schema_comment() {
558        let input = "# :schema https://old.com/s.json\nname = \"hello\"\n";
559        assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
560    }
561
562    #[test]
563    fn toml_strip_legacy_schema_comment() {
564        let input = "# $schema: https://old.com/s.json\nname = \"hello\"\n";
565        assert_eq!(TomlParser.strip_annotation(input), "name = \"hello\"\n");
566    }
567
568    #[test]
569    fn toml_strip_preserves_other_comments() {
570        let input = "# :schema https://old.com/s.json\n# other\nname = \"hello\"\n";
571        assert_eq!(
572            TomlParser.strip_annotation(input),
573            "# other\nname = \"hello\"\n"
574        );
575    }
576
577    #[test]
578    fn toml_strip_no_schema() {
579        let input = "name = \"hello\"\n";
580        assert_eq!(TomlParser.strip_annotation(input), input);
581    }
582
583    // --- Round-trip: strip then re-annotate ---
584
585    #[test]
586    fn json_update_round_trip() {
587        let original = "{\n  \"$schema\": \"https://old.com/s.json\",\n  \"name\": \"hello\"\n}\n";
588        let stripped = JsonParser.strip_annotation(original);
589        let updated = JsonParser
590            .annotate(&stripped, "https://new.com/s.json")
591            .expect("annotate failed");
592        assert_eq!(
593            updated,
594            "{\n  \"$schema\": \"https://new.com/s.json\",\n  \"name\": \"hello\"\n}\n"
595        );
596    }
597
598    #[test]
599    fn yaml_update_round_trip() {
600        let original = "# yaml-language-server: $schema=https://old.com/s.json\nname: hello\n";
601        let stripped = YamlParser.strip_annotation(original);
602        let updated = YamlParser
603            .annotate(&stripped, "https://new.com/s.json")
604            .expect("annotate failed");
605        assert_eq!(
606            updated,
607            "# yaml-language-server: $schema=https://new.com/s.json\nname: hello\n"
608        );
609    }
610
611    #[test]
612    fn toml_update_round_trip() {
613        let original = "# :schema https://old.com/s.json\nname = \"hello\"\n";
614        let stripped = TomlParser.strip_annotation(original);
615        let updated = TomlParser
616            .annotate(&stripped, "https://new.com/s.json")
617            .expect("annotate failed");
618        assert_eq!(
619            updated,
620            "# :schema https://new.com/s.json\nname = \"hello\"\n"
621        );
622    }
623}