Skip to main content

ridl_core/
manifest.rs

1//! The `ridl.toml` manifest parser (ADR-0002 §4).
2//!
3//! A manifest has one file shape and two mutually exclusive modes: a standalone
4//! [`ManifestKind::Package`] or a [`ManifestKind::Workspace`] (ADR-0002 §4).
5//! Both modes may carry an `[imports]` table that aliases logical package names
6//! to URLs. [`parse_manifest`] reads one manifest's text and returns the parsed
7//! [`Manifest`] together with any [`Diagnostic`]s — content problems are
8//! accumulated diagnostics, never an error return (ADR-0004 §5).
9//!
10//! # Diagnostics (the `MANI-…` namespace, ADR-0007 decision 2)
11//!
12//! `MANI-001` invalid TOML, `MANI-002` both sections, `MANI-003` neither
13//! section, `MANI-005` unknown key (warning), `MANI-006` invalid package name,
14//! `MANI-007` invalid import URL, `MANI-010` a `[lints]` entry that names no
15//! lint or whose value is not a level (warning, ADR-0024 decision 11).
16//! `MANI-004` (nested workspace) is defined in
17//! the catalogue but emitted by the package loader, not here: a
18//! manifest read in isolation cannot know it is a workspace member, so a valid
19//! `[workspace]` manifest parses clean.
20//!
21//! # Spans and the [`FileId`]
22//!
23//! [`parse_manifest`] takes the [`FileId`] the caller interned for the manifest
24//! path (via [`SourceMap::file_id`](crate::diag::SourceMap::file_id)) and stamps
25//! it into every diagnostic [`Span`]. Byte ranges come from the `toml` crate:
26//! [`toml::Spanned`] for the offending value (name, import URL) and the parse
27//! error's own span for `MANI-001`; structural problems with no single
28//! offending value (`MANI-002`, `MANI-003`) point at the relevant section or the
29//! whole document.
30//!
31//! # Parsing strategy
32//!
33//! The text is deserialized three times: once into a typed shape with
34//! [`toml::Spanned`] leaves (for the values and their spans), once into a
35//! key-to-spanned-value map (to enumerate the keys the typed shape silently
36//! drops, so unknown keys can warn), and once into the `[lints]` table with
37//! spanned keys (so each MANI-010 can point at its key). All three read the
38//! same valid TOML; the second cannot fail once the first has, and the third
39//! fails only when `lints` is not a table, which is MANI-010.
40
41use std::collections::BTreeMap;
42use std::ops::Range;
43
44use rowan::{TextRange, TextSize};
45use serde::Deserialize;
46use toml::Spanned;
47
48use crate::diag::{DiagCode, Diagnostic, FileId, Severity, Span};
49use crate::lint::{LintLevel, LintTable, lint_by_name};
50
51/// A parsed `ridl.toml` manifest: its mode-specific [`ManifestKind`], its
52/// `[imports]` table (logical package name to URL), its `[defaults]` as a
53/// [`TimingDefaults`] — the three optional keys `timing`, `command_timing` and
54/// `query_timing` — and its `[lints]` table, all shared by both modes.
55///
56/// `defaults` holds the raw `[defaults]` timing strings (e.g.
57/// `"[100ms..1000ms]"` or `"[..1s]"`), stored **unparsed**: `ridl-core`
58/// cannot depend on `ridl-sem`, so the checker parses and validates them
59/// (MANI-009) — the manifest layer only records the strings (ridl §9.1,
60/// §9.3).
61///
62/// `lints` holds only the valid `[lints]` entries: a registered lint name
63/// mapped to a level. Every other entry is MANI-010 and is dropped
64/// (ADR-0002 §4, ADR-0024 decision 11).
65///
66/// `codegen_header_file` is the raw `[codegen] header-file` value with the byte
67/// range of that value in the manifest text. The loader resolves the path
68/// against the manifest's directory and reads the file.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Manifest {
71    pub kind: ManifestKind,
72    pub imports: BTreeMap<String, String>,
73    pub defaults: TimingDefaults,
74    pub lints: LintTable,
75    pub codegen_header_file: Option<(String, Range<usize>)>,
76}
77
78/// The raw `[defaults]` timing strings of one manifest, or the merge of
79/// several. Each key is stored unparsed and is `None` when no manifest sets it.
80#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
81pub struct TimingDefaults {
82    /// `[defaults].timing`, the default for signals and events.
83    pub timing: Option<String>,
84    /// `[defaults].command_timing`, the default for commands.
85    pub command_timing: Option<String>,
86    /// `[defaults].query_timing`, the default for queries.
87    pub query_timing: Option<String>,
88}
89
90impl TimingDefaults {
91    /// Per key: `self`'s value, else `fallback`'s.
92    pub fn or(self, fallback: &TimingDefaults) -> TimingDefaults {
93        TimingDefaults {
94            timing: self.timing.or_else(|| fallback.timing.clone()),
95            command_timing: self
96                .command_timing
97                .or_else(|| fallback.command_timing.clone()),
98            query_timing: self.query_timing.or_else(|| fallback.query_timing.clone()),
99        }
100    }
101}
102
103/// The two mutually exclusive manifest modes (ADR-0002 §4).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum ManifestKind {
106    /// A standalone package distributed as a unit.
107    Package { name: String, version: String },
108    /// A coordinated set of packages developed together.
109    Workspace { members: Vec<String> },
110}
111
112/// Parses and validates one `ridl.toml`. Content problems (both sections
113/// present, neither present, unknown keys, an invalid package name, an invalid
114/// import URL) are returned as accumulated [`Diagnostic`]s rather than an error;
115/// only a structurally unusable manifest (invalid TOML, ambiguous or missing
116/// mode) yields `None`. `file_id` is the id the caller interned for the manifest
117/// path; every diagnostic span carries it.
118pub fn parse_manifest(file_id: FileId, text: &str) -> (Option<Manifest>, Vec<Diagnostic>) {
119    let mut diags = Vec::new();
120
121    // A syntax (or schema) error means there is no usable manifest: MANI-001.
122    let raw: RawManifest = match toml::from_str(text) {
123        Ok(raw) => raw,
124        Err(err) => {
125            let range = err.span().unwrap_or(0..text.len());
126            // The span already draws the caret at the location, so the message
127            // carries the reason, not the location. A `toml` error renders as a
128            // multi-line block whose first line is the location header
129            // ("TOML parse error at line 2, column 5") and whose last line is
130            // the description ("key with no value, expected `=`"); the last line
131            // is the description-first text the house style wants (T6).
132            let rendered = err.to_string();
133            let message = rendered
134                .lines()
135                .last()
136                .unwrap_or("invalid TOML")
137                .trim()
138                .to_string();
139            diags.push(error(DiagCode::MANI_001, file_id, range, message));
140            return (None, diags);
141        }
142    };
143
144    // Exactly one mode section must be present (ADR-0002 §4).
145    if raw.package.is_some() && raw.workspace.is_some() {
146        let range = raw
147            .workspace
148            .as_ref()
149            .map(Spanned::span)
150            .unwrap_or(0..text.len());
151        diags.push(error(
152            DiagCode::MANI_002,
153            file_id,
154            range,
155            "manifest declares both `[package]` and `[workspace]`; the two modes are mutually exclusive".to_string(),
156        ));
157        return (None, diags);
158    }
159    if raw.package.is_none() && raw.workspace.is_none() {
160        diags.push(error(
161            DiagCode::MANI_003,
162            file_id,
163            0..text.len(),
164            "manifest declares neither `[package]` nor `[workspace]`".to_string(),
165        ));
166        return (None, diags);
167    }
168
169    check_unknown_keys(file_id, text, &mut diags);
170
171    let imports = collect_imports(file_id, raw.imports, &mut diags);
172    // The raw `[defaults]` strings, recorded verbatim; the checker parses
173    // them and reports MANI-009 (ridl §9.1).
174    let defaults = raw
175        .defaults
176        .map(|defaults| {
177            let raw = defaults.into_inner();
178            TimingDefaults {
179                timing: raw.timing,
180                command_timing: raw.command_timing,
181                query_timing: raw.query_timing,
182            }
183        })
184        .unwrap_or_default();
185    let lints = collect_lints(file_id, text, &mut diags);
186    let codegen_header_file = raw
187        .codegen
188        .and_then(|codegen| codegen.into_inner().header_file)
189        .map(|value| (value.get_ref().clone(), value.span()));
190
191    let kind = if let Some(pkg) = raw.package {
192        let section_span = pkg.span();
193        let raw_pkg = pkg.into_inner();
194        let (name, name_span) = match raw_pkg.name {
195            Some(name) => (name.get_ref().clone(), name.span()),
196            None => (String::new(), section_span),
197        };
198        if !is_valid_package_name(&name) {
199            diags.push(error(
200                DiagCode::MANI_006,
201                file_id,
202                name_span,
203                format!(
204                    "invalid package name `{name}`; expected lowercase dot-separated segments (e.g. `veh.common`)"
205                ),
206            ));
207        }
208        ManifestKind::Package {
209            name,
210            version: raw_pkg.version,
211        }
212    } else if let Some(ws) = raw.workspace {
213        ManifestKind::Workspace {
214            members: ws.into_inner().members,
215        }
216    } else {
217        // Unreachable: exactly one section is present (checked above).
218        return (None, diags);
219    };
220
221    (
222        Some(Manifest {
223            kind,
224            imports,
225            defaults,
226            lints,
227            codegen_header_file,
228        }),
229        diags,
230    )
231}
232
233/// The raw manifest shape `toml` deserializes into. Every leaf a diagnostic may
234/// point at is a [`toml::Spanned`] so its byte range is available; the mode
235/// sections are spanned so a missing package name or a mode conflict can fall
236/// back to the section's range.
237#[derive(Deserialize)]
238struct RawManifest {
239    package: Option<Spanned<RawPackage>>,
240    workspace: Option<Spanned<RawWorkspace>>,
241    #[serde(default)]
242    imports: BTreeMap<String, Spanned<String>>,
243    defaults: Option<Spanned<RawDefaults>>,
244    codegen: Option<Spanned<RawCodegen>>,
245}
246
247#[derive(Deserialize)]
248struct RawDefaults {
249    timing: Option<String>,
250    command_timing: Option<String>,
251    query_timing: Option<String>,
252}
253
254#[derive(Deserialize)]
255struct RawCodegen {
256    #[serde(rename = "header-file")]
257    header_file: Option<Spanned<String>>,
258}
259
260#[derive(Deserialize)]
261struct RawPackage {
262    name: Option<Spanned<String>>,
263    #[serde(default)]
264    version: String,
265}
266
267#[derive(Deserialize)]
268struct RawWorkspace {
269    #[serde(default)]
270    members: Vec<String>,
271}
272
273/// The `[lints]` table alone, read in a parse of its own so that each key
274/// carries its span. `lints` is not a field of [`RawManifest`]: a typed field
275/// there would make `lints = 1` fail the whole typed parse, which is MANI-001
276/// with no manifest, where ADR-0024 decision 11 wants one MANI-010 and the rest
277/// of the manifest.
278#[derive(Deserialize)]
279struct RawLints {
280    #[serde(default)]
281    lints: Option<BTreeMap<Spanned<String>, Spanned<toml::Value>>>,
282}
283
284/// Collects the `[lints]` table into a [`LintTable`] of registered lint names
285/// and levels (ADR-0002 §4). Every entry whose key is not a lint name, or whose
286/// value is not one of the four level strings, is MANI-010 on the key and is
287/// dropped (ADR-0024 decision 11). A `lints` key that is not a table is one
288/// MANI-010 on the value, and the table is empty.
289fn collect_lints(file_id: FileId, text: &str, diags: &mut Vec<Diagnostic>) -> LintTable {
290    let mut lints = LintTable::new();
291    let raw: RawLints = match toml::from_str(text) {
292        Ok(raw) => raw,
293        Err(_) => {
294            // The typed manifest parse accepted this text, and every key and
295            // value of a table fits `RawLints`, so the only failure left is a
296            // `lints` value that is not a table. Its span comes from the
297            // untyped map (the one `check_unknown_keys` reads).
298            let doc: BTreeMap<String, Spanned<toml::Value>> =
299                toml::from_str(text).unwrap_or_default();
300            let range = doc.get("lints").map(Spanned::span).unwrap_or(0..text.len());
301            diags.push(warning(
302                DiagCode::MANI_010,
303                file_id,
304                range,
305                "`[lints]` must be a table".to_string(),
306            ));
307            return lints;
308        }
309    };
310    for (key, value) in raw.lints.unwrap_or_default() {
311        let name = key.get_ref();
312        let Some(registered) = lint_by_name(name).and_then(|entry| entry.lint) else {
313            diags.push(warning(
314                DiagCode::MANI_010,
315                file_id,
316                key.span(),
317                format!("unknown lint `{name}` in `[lints]`"),
318            ));
319            continue;
320        };
321        let Some(level) = value.get_ref().as_str().and_then(LintLevel::parse) else {
322            diags.push(warning(
323                DiagCode::MANI_010,
324                file_id,
325                key.span(),
326                format!("`[lints].{name}` must be one of \"allow\", \"info\", \"warn\", \"deny\""),
327            ));
328            continue;
329        };
330        lints.insert(registered, level);
331    }
332    lints
333}
334
335/// Collects the `[imports]` table into the public `name -> URL` map, flagging
336/// any value that is not a valid import URL (MANI-007). A flagged import is
337/// still recorded verbatim so downstream resolution can report against it.
338fn collect_imports(
339    file_id: FileId,
340    raw: BTreeMap<String, Spanned<String>>,
341    diags: &mut Vec<Diagnostic>,
342) -> BTreeMap<String, String> {
343    let mut imports = BTreeMap::new();
344    for (key, url) in raw {
345        let value = url.get_ref().clone();
346        if !is_valid_import_url(&value) {
347            diags.push(error(
348                DiagCode::MANI_007,
349                file_id,
350                url.span(),
351                format!("invalid import URL `{value}` for `{key}`; expected an `http://` or `https://` URL with a host"),
352            ));
353        }
354        imports.insert(key, value);
355    }
356    imports
357}
358
359/// Warns (MANI-005) on any key the manifest schema does not define. The typed
360/// [`RawManifest`] silently drops unknown keys, so the text is re-read as a map
361/// of spanned values to enumerate every key. Keys under `[imports]` are the
362/// user's logical package names and are never "unknown".
363fn check_unknown_keys(file_id: FileId, text: &str, diags: &mut Vec<Diagnostic>) {
364    let doc: BTreeMap<String, Spanned<toml::Value>> = match toml::from_str(text) {
365        Ok(doc) => doc,
366        // Unreachable: the typed parse already accepted this text.
367        Err(_) => return,
368    };
369    // The allowed-key lists below must stay in sync with the fields of
370    // `RawPackage`, `RawWorkspace`, `RawDefaults`, and `RawCodegen`: a field added there
371    // without a matching entry here would wrongly warn as an unknown key.
372    // Keys under `[lints]` are lint names; `collect_lints` checks them against
373    // the registry (MANI-010), so they are never "unknown" here.
374    for (key, value) in &doc {
375        match key.as_str() {
376            "package" => check_section_keys(file_id, "package", value, &["name", "version"], diags),
377            "workspace" => check_section_keys(file_id, "workspace", value, &["members"], diags),
378            "defaults" => check_section_keys(
379                file_id,
380                "defaults",
381                value,
382                &["timing", "command_timing", "query_timing"],
383                diags,
384            ),
385            "codegen" => check_section_keys(file_id, "codegen", value, &["header-file"], diags),
386            "imports" => {}
387            "lints" => {}
388            _ => diags.push(warning(
389                DiagCode::MANI_005,
390                file_id,
391                value.span(),
392                format!("unknown manifest key `{key}`"),
393            )),
394        }
395    }
396}
397
398/// Warns (MANI-005) on any key in a mode section that is not in `allowed`. The
399/// section value carries the only span available, so an unknown nested key
400/// points at its section.
401fn check_section_keys(
402    file_id: FileId,
403    section: &str,
404    value: &Spanned<toml::Value>,
405    allowed: &[&str],
406    diags: &mut Vec<Diagnostic>,
407) {
408    let Some(table) = value.get_ref().as_table() else {
409        return;
410    };
411    for key in table.keys() {
412        if !allowed.contains(&key.as_str()) {
413            diags.push(warning(
414                DiagCode::MANI_005,
415                file_id,
416                value.span(),
417                format!("unknown key `{key}` in `[{section}]`"),
418            ));
419        }
420    }
421}
422
423/// A package name is one or more lowercase dot-separated segments, each an ASCII
424/// lowercase letter followed by ASCII lowercase letters or digits (ADR-0002 §1).
425/// The empty string (a missing name) is invalid.
426fn is_valid_package_name(name: &str) -> bool {
427    !name.is_empty() && name.split('.').all(is_valid_name_segment)
428}
429
430fn is_valid_name_segment(segment: &str) -> bool {
431    let mut chars = segment.chars();
432    match chars.next() {
433        Some(first) if first.is_ascii_lowercase() => {}
434        _ => return false,
435    }
436    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
437}
438
439/// A deliberately minimal import-URL check: the value must use the `http` or
440/// `https` scheme and name a non-empty host (the run up to the first `/`, `?`,
441/// or `#`). Full URL, version-suffix, and registry validation is the fetch
442/// layer's job; this only rejects values that are plainly not URLs.
443fn is_valid_import_url(url: &str) -> bool {
444    let Some(rest) = url
445        .strip_prefix("https://")
446        .or_else(|| url.strip_prefix("http://"))
447    else {
448        return false;
449    };
450    let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
451    !rest[..host_end].is_empty()
452}
453
454/// Builds an error [`Diagnostic`] with the given code, file, byte range, and
455/// message. Manifest diagnostics carry no secondary labels or fix-its.
456fn error(code: DiagCode, file: FileId, range: Range<usize>, message: String) -> Diagnostic {
457    diagnostic(code, Severity::Error, file, range, message)
458}
459
460/// Builds a warning [`Diagnostic`] (used only for MANI-005 and MANI-010).
461fn warning(code: DiagCode, file: FileId, range: Range<usize>, message: String) -> Diagnostic {
462    diagnostic(code, Severity::Warning, file, range, message)
463}
464
465fn diagnostic(
466    code: DiagCode,
467    severity: Severity,
468    file: FileId,
469    range: Range<usize>,
470    message: String,
471) -> Diagnostic {
472    Diagnostic {
473        code,
474        severity,
475        message,
476        primary: Span {
477            file,
478            range: to_text_range(range),
479        },
480        labels: Vec::new(),
481        fixits: Vec::new(),
482    }
483}
484
485/// Converts a `toml` byte range into a `rowan::TextRange`, the coordinate space
486/// the diagnostic model works in.
487fn to_text_range(range: Range<usize>) -> TextRange {
488    TextRange::new(
489        TextSize::from(range.start as u32),
490        TextSize::from(range.end as u32),
491    )
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::diag::{MANI_CATALOG, SourceMap};
498
499    const STANDALONE: &str = "\
500[package]
501name = \"veh.common\"
502version = \"1.2.0\"
503
504[imports]
505\"some.dep\" = \"https://ridl.example.com/some/dep@v1.0.0\"
506";
507
508    const WORKSPACE: &str = "\
509[workspace]
510members = [\"veh-common\", \"veh-cluster\", \"veh-adas\"]
511
512[imports]
513\"third-party.foo\" = \"https://ridl.example.com/third-party/foo@v1.0.0\"
514";
515
516    fn parse(text: &str) -> (Option<Manifest>, Vec<Diagnostic>) {
517        let mut map = SourceMap::new();
518        let file_id = map.file_id("ridl.toml", text);
519        parse_manifest(file_id, text)
520    }
521
522    fn codes(diags: &[Diagnostic]) -> Vec<&str> {
523        diags.iter().map(|d| d.code.as_str()).collect()
524    }
525
526    #[test]
527    fn adr0002_standalone_example_parses() {
528        let (manifest, diags) = parse(STANDALONE);
529        assert!(diags.is_empty(), "clean standalone manifest, got {diags:?}");
530        let manifest = manifest.expect("standalone manifest parses");
531        assert_eq!(
532            manifest.kind,
533            ManifestKind::Package {
534                name: "veh.common".to_string(),
535                version: "1.2.0".to_string(),
536            },
537        );
538        assert_eq!(manifest.imports.len(), 1);
539        assert_eq!(
540            manifest.imports.get("some.dep").map(String::as_str),
541            Some("https://ridl.example.com/some/dep@v1.0.0"),
542        );
543    }
544
545    #[test]
546    fn adr0002_workspace_example_parses() {
547        let (manifest, diags) = parse(WORKSPACE);
548        assert!(diags.is_empty(), "clean workspace manifest, got {diags:?}");
549        let manifest = manifest.expect("workspace manifest parses");
550        assert_eq!(
551            manifest.kind,
552            ManifestKind::Workspace {
553                members: vec![
554                    "veh-common".to_string(),
555                    "veh-cluster".to_string(),
556                    "veh-adas".to_string(),
557                ],
558            },
559        );
560        assert_eq!(
561            manifest.imports.get("third-party.foo").map(String::as_str),
562            Some("https://ridl.example.com/third-party/foo@v1.0.0"),
563        );
564    }
565
566    #[test]
567    fn mani_001_invalid_toml() {
568        // `name` with no `=` value is a TOML syntax error.
569        let (manifest, diags) = parse("[package]\nname\n");
570        assert!(manifest.is_none(), "invalid TOML yields no manifest");
571        assert_eq!(codes(&diags), vec!["MANI-001"]);
572        assert_eq!(diags[0].severity, Severity::Error);
573        // The message carries the reason (description-first, T6 house style),
574        // not the "TOML parse error at line …" location header.
575        assert!(
576            diags[0].message.contains("expected `=`"),
577            "MANI-001 message must carry the reason, got {:?}",
578            diags[0].message,
579        );
580        assert!(
581            !diags[0].message.contains("TOML parse error at line"),
582            "MANI-001 message must not be the location header, got {:?}",
583            diags[0].message,
584        );
585    }
586
587    #[test]
588    fn mani_001_carries_type_error_reason() {
589        // A wrong-typed field value is a schema error; its reason must survive.
590        let (manifest, diags) = parse("[package]\nname = 123\nversion = \"1.0.0\"\n");
591        assert!(manifest.is_none());
592        assert_eq!(codes(&diags), vec!["MANI-001"]);
593        assert!(
594            diags[0].message.contains("invalid type"),
595            "MANI-001 message must carry the type-mismatch reason, got {:?}",
596            diags[0].message,
597        );
598        assert!(!diags[0].message.contains("TOML parse error at line"));
599    }
600
601    #[test]
602    fn mani_002_both_sections() {
603        let text = "\
604[package]
605name = \"veh.common\"
606version = \"1.0.0\"
607
608[workspace]
609members = [\"a\"]
610";
611        let (manifest, diags) = parse(text);
612        assert!(manifest.is_none(), "an ambiguous mode yields no manifest");
613        assert_eq!(codes(&diags), vec!["MANI-002"]);
614        assert_eq!(diags[0].severity, Severity::Error);
615    }
616
617    #[test]
618    fn mani_003_neither_section() {
619        // Only `[imports]`, no mode section.
620        let text = "[imports]\n\"x.y\" = \"https://example.com/x\"\n";
621        let (manifest, diags) = parse(text);
622        assert!(manifest.is_none(), "a modeless manifest yields no manifest");
623        assert_eq!(codes(&diags), vec!["MANI-003"]);
624
625        // An empty manifest is the same failure.
626        let (empty, empty_diags) = parse("");
627        assert!(empty.is_none());
628        assert_eq!(codes(&empty_diags), vec!["MANI-003"]);
629    }
630
631    #[test]
632    fn mani_004_workspace_manifest_parses_clean_in_isolation() {
633        // Nested-workspace detection is the loader's job. Parsed
634        // in isolation a `[workspace]` manifest is a valid workspace, never
635        // MANI-004.
636        let (manifest, diags) = parse(WORKSPACE);
637        assert!(diags.is_empty(), "no MANI-004 from the standalone parser");
638        assert!(matches!(
639            manifest.expect("workspace parses").kind,
640            ManifestKind::Workspace { .. },
641        ));
642        // The code exists in the catalogue for the loader to emit.
643        assert!(
644            MANI_CATALOG
645                .iter()
646                .any(|entry| entry.code == DiagCode::MANI_004),
647            "MANI-004 is defined for the loader",
648        );
649    }
650
651    #[test]
652    fn mani_005_unknown_key_warns_but_parses() {
653        // Unknown key inside `[package]`.
654        let text = "\
655[package]
656name = \"veh.common\"
657version = \"1.0.0\"
658description = \"not a known key\"
659";
660        let (manifest, diags) = parse(text);
661        let manifest = manifest.expect("unknown key still parses");
662        assert_eq!(
663            manifest.kind,
664            ManifestKind::Package {
665                name: "veh.common".to_string(),
666                version: "1.0.0".to_string(),
667            },
668        );
669        assert_eq!(codes(&diags), vec!["MANI-005"]);
670        assert_eq!(diags[0].severity, Severity::Warning);
671
672        // Unknown top-level section is also warned.
673        let top = "\
674[package]
675name = \"veh.common\"
676version = \"1.0.0\"
677
678[bogus]
679x = 1
680";
681        let (top_manifest, top_diags) = parse(top);
682        assert!(top_manifest.is_some(), "unknown top-level key still parses");
683        assert_eq!(codes(&top_diags), vec!["MANI-005"]);
684        assert_eq!(top_diags[0].severity, Severity::Warning);
685    }
686
687    #[test]
688    fn mani_006_invalid_package_name() {
689        // Uppercase is not a lowercase dot-segment name.
690        let text = "[package]\nname = \"Veh.Common\"\nversion = \"1.0.0\"\n";
691        let (manifest, diags) = parse(text);
692        assert!(manifest.is_some(), "a bad name is a diagnostic, not None");
693        assert_eq!(codes(&diags), vec!["MANI-006"]);
694        assert_eq!(diags[0].severity, Severity::Error);
695
696        // A missing name is an empty name, equally invalid.
697        let no_name = "[package]\nversion = \"1.0.0\"\n";
698        let (_, no_name_diags) = parse(no_name);
699        assert_eq!(codes(&no_name_diags), vec!["MANI-006"]);
700    }
701
702    #[test]
703    fn defaults_timing_is_recorded_unparsed() {
704        // The `[defaults].timing` string rides through verbatim — no MANI-005,
705        // and the checker (not the manifest) validates it (ridl §9.1).
706        let text = "\
707[package]
708name = \"veh.common\"
709version = \"1.0.0\"
710
711[defaults]
712timing = \"[50ms..2s]\"
713";
714        let (manifest, diags) = parse(text);
715        assert!(
716            diags.is_empty(),
717            "a `[defaults]` section is known, got {diags:?}"
718        );
719        let manifest = manifest.expect("the manifest parses");
720        assert_eq!(manifest.defaults.timing.as_deref(), Some("[50ms..2s]"));
721    }
722
723    #[test]
724    fn defaults_command_and_query_timing_parse() {
725        let text = "\
726[package]
727name = \"veh.common\"
728version = \"1.0.0\"
729
730[defaults]
731timing = \"[100ms..1s]\"
732command_timing = \"[..1s]\"
733query_timing = \"[..3s]\"
734";
735        let (manifest, diags) = parse(text);
736        assert!(diags.is_empty(), "known keys draw nothing, got {diags:?}");
737        assert_eq!(
738            manifest.expect("parses").defaults,
739            TimingDefaults {
740                timing: Some("[100ms..1s]".to_string()),
741                command_timing: Some("[..1s]".to_string()),
742                query_timing: Some("[..3s]".to_string()),
743            }
744        );
745    }
746
747    /// `or` resolves each key on its own, in both directions: a key `self`
748    /// sets wins over the fallback's value, and a key `self` leaves unset takes
749    /// the fallback's value.
750    #[test]
751    fn timing_defaults_or_resolves_each_key_on_its_own() {
752        let all = |prefix: &str| TimingDefaults {
753            timing: Some(format!("{prefix}-timing")),
754            command_timing: Some(format!("{prefix}-command")),
755            query_timing: Some(format!("{prefix}-query")),
756        };
757        // Every key set on both sides: `self` wins on every key.
758        assert_eq!(all("own").or(&all("fallback")), all("own"));
759        // No key set on `self`: every key comes from the fallback.
760        assert_eq!(
761            TimingDefaults::default().or(&all("fallback")),
762            all("fallback")
763        );
764        // One key set on `self`: that key is its own, the other two are the
765        // fallback's.
766        let fallback = all("fallback");
767        let own = || Some("own".to_string());
768        let cases = [
769            (
770                TimingDefaults {
771                    timing: own(),
772                    ..TimingDefaults::default()
773                },
774                TimingDefaults {
775                    timing: own(),
776                    ..fallback.clone()
777                },
778            ),
779            (
780                TimingDefaults {
781                    command_timing: own(),
782                    ..TimingDefaults::default()
783                },
784                TimingDefaults {
785                    command_timing: own(),
786                    ..fallback.clone()
787                },
788            ),
789            (
790                TimingDefaults {
791                    query_timing: own(),
792                    ..TimingDefaults::default()
793                },
794                TimingDefaults {
795                    query_timing: own(),
796                    ..fallback.clone()
797                },
798            ),
799        ];
800        for (own, expected) in cases {
801            assert_eq!(own.clone().or(&fallback), expected, "{own:?}");
802        }
803        // A key unset on both sides stays unset.
804        assert_eq!(
805            TimingDefaults::default().or(&TimingDefaults::default()),
806            TimingDefaults::default()
807        );
808    }
809
810    #[test]
811    fn no_defaults_section_leaves_the_defaults_absent() {
812        let (manifest, diags) = parse(STANDALONE);
813        assert!(diags.is_empty());
814        assert_eq!(
815            manifest.expect("parses").defaults,
816            TimingDefaults::default(),
817            "no `[defaults]` means no configured default",
818        );
819    }
820
821    #[test]
822    fn unknown_key_inside_defaults_still_warns() {
823        let text = "\
824[package]
825name = \"veh.common\"
826version = \"1.0.0\"
827
828[defaults]
829timing = \"[100ms..1000ms]\"
830command_timing = \"[..1s]\"
831query_timing = \"[..3s]\"
832bogus = 1
833";
834        let (manifest, diags) = parse(text);
835        assert!(manifest.is_some(), "an unknown nested key still parses");
836        assert_eq!(codes(&diags), vec!["MANI-005"]);
837        assert_eq!(diags[0].severity, Severity::Warning);
838    }
839
840    #[test]
841    fn mani_007_invalid_import_url() {
842        let text = "\
843[package]
844name = \"veh.common\"
845version = \"1.0.0\"
846
847[imports]
848\"some.dep\" = \"not-a-url\"
849";
850        let (manifest, diags) = parse(text);
851        let manifest = manifest.expect("a bad URL is a diagnostic, not None");
852        // The import is still recorded, verbatim.
853        assert_eq!(
854            manifest.imports.get("some.dep").map(String::as_str),
855            Some("not-a-url"),
856        );
857        assert_eq!(codes(&diags), vec!["MANI-007"]);
858        assert_eq!(diags[0].severity, Severity::Error);
859
860        // A plain `http://` host is accepted (no MANI-007).
861        let http = "\
862[package]
863name = \"veh.common\"
864version = \"1.0.0\"
865
866[imports]
867\"some.dep\" = \"http://example.com/some/dep\"
868";
869        let (_, http_diags) = parse(http);
870        assert!(
871            http_diags.is_empty(),
872            "http:// with a host is a valid URL, got {http_diags:?}",
873        );
874    }
875
876    const PACKAGE_HEAD: &str = "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n";
877    const WORKSPACE_HEAD: &str = "[workspace]\nmembers = [\"a\"]\n\n";
878
879    /// The text of `text` under the primary span of `diag`.
880    fn spanned_text<'a>(text: &'a str, diag: &Diagnostic) -> &'a str {
881        let range = diag.primary.range;
882        &text[usize::from(range.start())..usize::from(range.end())]
883    }
884
885    #[test]
886    fn lints_table_is_read() {
887        for head in [PACKAGE_HEAD, WORKSPACE_HEAD] {
888            let text = format!("{head}[lints]\nmissing-timing = \"deny\"\n");
889            let (manifest, diags) = parse(&text);
890            assert!(diags.is_empty(), "a valid `[lints]` table, got {diags:?}");
891            let manifest = manifest.expect("the manifest parses");
892            assert_eq!(manifest.lints.get("missing-timing"), Some(&LintLevel::Deny));
893            assert_eq!(manifest.lints.len(), 1);
894        }
895    }
896
897    #[test]
898    fn lints_key_is_known() {
899        let text = format!("{PACKAGE_HEAD}[lints]\n");
900        let (manifest, diags) = parse(&text);
901        assert!(diags.is_empty(), "no MANI-005 for `lints`, got {diags:?}");
902        assert!(manifest.expect("the manifest parses").lints.is_empty());
903    }
904
905    #[test]
906    fn no_lints_table_leaves_lints_empty() {
907        let (manifest, diags) = parse(STANDALONE);
908        assert!(diags.is_empty());
909        assert!(manifest.expect("parses").lints.is_empty());
910    }
911
912    #[test]
913    fn unknown_lint_name() {
914        let text = format!("{PACKAGE_HEAD}[lints]\nnope = \"deny\"\n");
915        let (manifest, diags) = parse(&text);
916        assert_eq!(codes(&diags), vec!["MANI-010"]);
917        assert_eq!(diags[0].severity, Severity::Warning);
918        assert_eq!(diags[0].message, "unknown lint `nope` in `[lints]`");
919        assert!(
920            manifest.expect("the manifest parses").lints.is_empty(),
921            "an unknown lint is not recorded",
922        );
923    }
924
925    #[test]
926    fn code_as_key() {
927        let text = format!("{PACKAGE_HEAD}[lints]\n\"RIDL-100\" = \"deny\"\n");
928        let (manifest, diags) = parse(&text);
929        assert_eq!(codes(&diags), vec!["MANI-010"]);
930        assert_eq!(diags[0].message, "unknown lint `RIDL-100` in `[lints]`");
931        assert!(manifest.expect("the manifest parses").lints.is_empty());
932    }
933
934    #[test]
935    fn error_code_as_key() {
936        // An Error code has no lint name, so neither its code nor any name is
937        // accepted as a key.
938        let text = format!("{PACKAGE_HEAD}[lints]\n\"RIDL-101\" = \"allow\"\n");
939        let (manifest, diags) = parse(&text);
940        assert_eq!(codes(&diags), vec!["MANI-010"]);
941        assert!(manifest.expect("the manifest parses").lints.is_empty());
942    }
943
944    #[test]
945    fn bad_level() {
946        for value in ["\"Deny\"", "3"] {
947            let text = format!("{PACKAGE_HEAD}[lints]\nmissing-timing = {value}\n");
948            let (manifest, diags) = parse(&text);
949            assert_eq!(codes(&diags), vec!["MANI-010"], "value {value}");
950            assert_eq!(diags[0].severity, Severity::Warning);
951            assert_eq!(
952                diags[0].message,
953                "`[lints].missing-timing` must be one of \"allow\", \"info\", \"warn\", \"deny\"",
954            );
955            assert_eq!(spanned_text(&text, &diags[0]), "missing-timing");
956            assert!(
957                manifest.expect("the manifest parses").lints.is_empty(),
958                "an entry with a bad level is not recorded",
959            );
960        }
961    }
962
963    #[test]
964    fn valid_entries_survive_a_bad_sibling() {
965        let text = format!("{PACKAGE_HEAD}[lints]\nmissing-timing = \"deny\"\nnope = \"warn\"\n");
966        let (manifest, diags) = parse(&text);
967        assert_eq!(codes(&diags), vec!["MANI-010"]);
968        let manifest = manifest.expect("the manifest parses");
969        assert_eq!(manifest.lints.get("missing-timing"), Some(&LintLevel::Deny));
970        assert_eq!(manifest.lints.len(), 1);
971    }
972
973    #[test]
974    fn lints_not_a_table() {
975        // A top-level key must come before the first section header.
976        let text = format!("lints = 1\n\n{PACKAGE_HEAD}");
977        let (manifest, diags) = parse(&text);
978        assert_eq!(codes(&diags), vec!["MANI-010"], "no MANI-001, no MANI-005");
979        assert_eq!(diags[0].message, "`[lints]` must be a table");
980        assert_eq!(spanned_text(&text, &diags[0]), "1");
981        let manifest = manifest.expect("the rest of the manifest is still read");
982        assert_eq!(
983            manifest.kind,
984            ManifestKind::Package {
985                name: "veh.common".to_string(),
986                version: "1.0.0".to_string(),
987            },
988        );
989        assert!(manifest.lints.is_empty());
990    }
991
992    #[test]
993    fn mani_010_span_is_the_key() {
994        let text = format!("{PACKAGE_HEAD}[lints]\nnope = \"deny\"\n");
995        let (_, diags) = parse(&text);
996        assert_eq!(codes(&diags), vec!["MANI-010"]);
997        assert_eq!(spanned_text(&text, &diags[0]), "nope");
998    }
999
1000    #[test]
1001    fn codegen_header_file_is_parsed() {
1002        let text = format!("{PACKAGE_HEAD}[codegen]\nheader-file = \"H.txt\"\n");
1003        let (manifest, diags) = parse(&text);
1004        assert!(diags.is_empty(), "no diagnostic, got {diags:?}");
1005        let (path, range) = manifest
1006            .expect("the manifest parses")
1007            .codegen_header_file
1008            .expect("header-file is read");
1009        assert_eq!(path, "H.txt");
1010        assert_eq!(&text[range], "\"H.txt\"");
1011    }
1012
1013    #[test]
1014    fn unknown_codegen_key_is_mani_005() {
1015        let text = format!("{PACKAGE_HEAD}[codegen]\nheader = \"x\"\n");
1016        let (_, diags) = parse(&text);
1017        assert_eq!(codes(&diags), vec!["MANI-005"]);
1018    }
1019}