use std::collections::BTreeMap;
use std::ops::Range;
use rowan::{TextRange, TextSize};
use serde::Deserialize;
use toml::Spanned;
use crate::diag::{DiagCode, Diagnostic, FileId, Severity, Span};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub kind: ManifestKind,
pub imports: BTreeMap<String, String>,
pub default_timing: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestKind {
Package { name: String, version: String },
Workspace { members: Vec<String> },
}
pub fn parse_manifest(file_id: FileId, text: &str) -> (Option<Manifest>, Vec<Diagnostic>) {
let mut diags = Vec::new();
let raw: RawManifest = match toml::from_str(text) {
Ok(raw) => raw,
Err(err) => {
let range = err.span().unwrap_or(0..text.len());
let rendered = err.to_string();
let message = rendered
.lines()
.last()
.unwrap_or("invalid TOML")
.trim()
.to_string();
diags.push(error(DiagCode::MANI_001, file_id, range, message));
return (None, diags);
}
};
if raw.package.is_some() && raw.workspace.is_some() {
let range = raw
.workspace
.as_ref()
.map(Spanned::span)
.unwrap_or(0..text.len());
diags.push(error(
DiagCode::MANI_002,
file_id,
range,
"manifest declares both `[package]` and `[workspace]`; the two modes are mutually exclusive".to_string(),
));
return (None, diags);
}
if raw.package.is_none() && raw.workspace.is_none() {
diags.push(error(
DiagCode::MANI_003,
file_id,
0..text.len(),
"manifest declares neither `[package]` nor `[workspace]`".to_string(),
));
return (None, diags);
}
check_unknown_keys(file_id, text, &mut diags);
let imports = collect_imports(file_id, raw.imports, &mut diags);
let default_timing = raw
.defaults
.and_then(|defaults| defaults.into_inner().timing);
let kind = if let Some(pkg) = raw.package {
let section_span = pkg.span();
let raw_pkg = pkg.into_inner();
let (name, name_span) = match raw_pkg.name {
Some(name) => (name.get_ref().clone(), name.span()),
None => (String::new(), section_span),
};
if !is_valid_package_name(&name) {
diags.push(error(
DiagCode::MANI_006,
file_id,
name_span,
format!(
"invalid package name `{name}`; expected lowercase dot-separated segments (e.g. `veh.common`)"
),
));
}
ManifestKind::Package {
name,
version: raw_pkg.version,
}
} else if let Some(ws) = raw.workspace {
ManifestKind::Workspace {
members: ws.into_inner().members,
}
} else {
return (None, diags);
};
(
Some(Manifest {
kind,
imports,
default_timing,
}),
diags,
)
}
#[derive(Deserialize)]
struct RawManifest {
package: Option<Spanned<RawPackage>>,
workspace: Option<Spanned<RawWorkspace>>,
#[serde(default)]
imports: BTreeMap<String, Spanned<String>>,
defaults: Option<Spanned<RawDefaults>>,
}
#[derive(Deserialize)]
struct RawDefaults {
timing: Option<String>,
}
#[derive(Deserialize)]
struct RawPackage {
name: Option<Spanned<String>>,
#[serde(default)]
version: String,
}
#[derive(Deserialize)]
struct RawWorkspace {
#[serde(default)]
members: Vec<String>,
}
fn collect_imports(
file_id: FileId,
raw: BTreeMap<String, Spanned<String>>,
diags: &mut Vec<Diagnostic>,
) -> BTreeMap<String, String> {
let mut imports = BTreeMap::new();
for (key, url) in raw {
let value = url.get_ref().clone();
if !is_valid_import_url(&value) {
diags.push(error(
DiagCode::MANI_007,
file_id,
url.span(),
format!("invalid import URL `{value}` for `{key}`; expected an `http://` or `https://` URL with a host"),
));
}
imports.insert(key, value);
}
imports
}
fn check_unknown_keys(file_id: FileId, text: &str, diags: &mut Vec<Diagnostic>) {
let doc: BTreeMap<String, Spanned<toml::Value>> = match toml::from_str(text) {
Ok(doc) => doc,
Err(_) => return,
};
for (key, value) in &doc {
match key.as_str() {
"package" => check_section_keys(file_id, "package", value, &["name", "version"], diags),
"workspace" => check_section_keys(file_id, "workspace", value, &["members"], diags),
"defaults" => check_section_keys(file_id, "defaults", value, &["timing"], diags),
"imports" => {}
_ => diags.push(warning(
DiagCode::MANI_005,
file_id,
value.span(),
format!("unknown manifest key `{key}`"),
)),
}
}
}
fn check_section_keys(
file_id: FileId,
section: &str,
value: &Spanned<toml::Value>,
allowed: &[&str],
diags: &mut Vec<Diagnostic>,
) {
let Some(table) = value.get_ref().as_table() else {
return;
};
for key in table.keys() {
if !allowed.contains(&key.as_str()) {
diags.push(warning(
DiagCode::MANI_005,
file_id,
value.span(),
format!("unknown key `{key}` in `[{section}]`"),
));
}
}
}
fn is_valid_package_name(name: &str) -> bool {
!name.is_empty() && name.split('.').all(is_valid_name_segment)
}
fn is_valid_name_segment(segment: &str) -> bool {
let mut chars = segment.chars();
match chars.next() {
Some(first) if first.is_ascii_lowercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}
fn is_valid_import_url(url: &str) -> bool {
let Some(rest) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
else {
return false;
};
let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
!rest[..host_end].is_empty()
}
fn error(code: DiagCode, file: FileId, range: Range<usize>, message: String) -> Diagnostic {
diagnostic(code, Severity::Error, file, range, message)
}
fn warning(code: DiagCode, file: FileId, range: Range<usize>, message: String) -> Diagnostic {
diagnostic(code, Severity::Warning, file, range, message)
}
fn diagnostic(
code: DiagCode,
severity: Severity,
file: FileId,
range: Range<usize>,
message: String,
) -> Diagnostic {
Diagnostic {
code,
severity,
message,
primary: Span {
file,
range: to_text_range(range),
},
labels: Vec::new(),
fixits: Vec::new(),
}
}
fn to_text_range(range: Range<usize>) -> TextRange {
TextRange::new(
TextSize::from(range.start as u32),
TextSize::from(range.end as u32),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diag::{MANI_CATALOG, SourceMap};
const STANDALONE: &str = "\
[package]
name = \"veh.common\"
version = \"1.2.0\"
[imports]
\"some.dep\" = \"https://ridl.example.com/some/dep@v1.0.0\"
";
const WORKSPACE: &str = "\
[workspace]
members = [\"veh-common\", \"veh-cluster\", \"veh-adas\"]
[imports]
\"third-party.foo\" = \"https://ridl.example.com/third-party/foo@v1.0.0\"
";
fn parse(text: &str) -> (Option<Manifest>, Vec<Diagnostic>) {
let mut map = SourceMap::new();
let file_id = map.file_id("ridl.toml", text);
parse_manifest(file_id, text)
}
fn codes(diags: &[Diagnostic]) -> Vec<&str> {
diags.iter().map(|d| d.code.as_str()).collect()
}
#[test]
fn adr0002_standalone_example_parses() {
let (manifest, diags) = parse(STANDALONE);
assert!(diags.is_empty(), "clean standalone manifest, got {diags:?}");
let manifest = manifest.expect("standalone manifest parses");
assert_eq!(
manifest.kind,
ManifestKind::Package {
name: "veh.common".to_string(),
version: "1.2.0".to_string(),
},
);
assert_eq!(manifest.imports.len(), 1);
assert_eq!(
manifest.imports.get("some.dep").map(String::as_str),
Some("https://ridl.example.com/some/dep@v1.0.0"),
);
}
#[test]
fn adr0002_workspace_example_parses() {
let (manifest, diags) = parse(WORKSPACE);
assert!(diags.is_empty(), "clean workspace manifest, got {diags:?}");
let manifest = manifest.expect("workspace manifest parses");
assert_eq!(
manifest.kind,
ManifestKind::Workspace {
members: vec![
"veh-common".to_string(),
"veh-cluster".to_string(),
"veh-adas".to_string(),
],
},
);
assert_eq!(
manifest.imports.get("third-party.foo").map(String::as_str),
Some("https://ridl.example.com/third-party/foo@v1.0.0"),
);
}
#[test]
fn mani_001_invalid_toml() {
let (manifest, diags) = parse("[package]\nname\n");
assert!(manifest.is_none(), "invalid TOML yields no manifest");
assert_eq!(codes(&diags), vec!["MANI-001"]);
assert_eq!(diags[0].severity, Severity::Error);
assert!(
diags[0].message.contains("expected `=`"),
"MANI-001 message must carry the reason, got {:?}",
diags[0].message,
);
assert!(
!diags[0].message.contains("TOML parse error at line"),
"MANI-001 message must not be the location header, got {:?}",
diags[0].message,
);
}
#[test]
fn mani_001_carries_type_error_reason() {
let (manifest, diags) = parse("[package]\nname = 123\nversion = \"1.0.0\"\n");
assert!(manifest.is_none());
assert_eq!(codes(&diags), vec!["MANI-001"]);
assert!(
diags[0].message.contains("invalid type"),
"MANI-001 message must carry the type-mismatch reason, got {:?}",
diags[0].message,
);
assert!(!diags[0].message.contains("TOML parse error at line"));
}
#[test]
fn mani_002_both_sections() {
let text = "\
[package]
name = \"veh.common\"
version = \"1.0.0\"
[workspace]
members = [\"a\"]
";
let (manifest, diags) = parse(text);
assert!(manifest.is_none(), "an ambiguous mode yields no manifest");
assert_eq!(codes(&diags), vec!["MANI-002"]);
assert_eq!(diags[0].severity, Severity::Error);
}
#[test]
fn mani_003_neither_section() {
let text = "[imports]\n\"x.y\" = \"https://example.com/x\"\n";
let (manifest, diags) = parse(text);
assert!(manifest.is_none(), "a modeless manifest yields no manifest");
assert_eq!(codes(&diags), vec!["MANI-003"]);
let (empty, empty_diags) = parse("");
assert!(empty.is_none());
assert_eq!(codes(&empty_diags), vec!["MANI-003"]);
}
#[test]
fn mani_004_workspace_manifest_parses_clean_in_isolation() {
let (manifest, diags) = parse(WORKSPACE);
assert!(diags.is_empty(), "no MANI-004 from the standalone parser");
assert!(matches!(
manifest.expect("workspace parses").kind,
ManifestKind::Workspace { .. },
));
assert!(
MANI_CATALOG
.iter()
.any(|entry| entry.code == DiagCode::MANI_004),
"MANI-004 is defined for the loader",
);
}
#[test]
fn mani_005_unknown_key_warns_but_parses() {
let text = "\
[package]
name = \"veh.common\"
version = \"1.0.0\"
description = \"not a known key\"
";
let (manifest, diags) = parse(text);
let manifest = manifest.expect("unknown key still parses");
assert_eq!(
manifest.kind,
ManifestKind::Package {
name: "veh.common".to_string(),
version: "1.0.0".to_string(),
},
);
assert_eq!(codes(&diags), vec!["MANI-005"]);
assert_eq!(diags[0].severity, Severity::Warning);
let top = "\
[package]
name = \"veh.common\"
version = \"1.0.0\"
[bogus]
x = 1
";
let (top_manifest, top_diags) = parse(top);
assert!(top_manifest.is_some(), "unknown top-level key still parses");
assert_eq!(codes(&top_diags), vec!["MANI-005"]);
assert_eq!(top_diags[0].severity, Severity::Warning);
}
#[test]
fn mani_006_invalid_package_name() {
let text = "[package]\nname = \"Veh.Common\"\nversion = \"1.0.0\"\n";
let (manifest, diags) = parse(text);
assert!(manifest.is_some(), "a bad name is a diagnostic, not None");
assert_eq!(codes(&diags), vec!["MANI-006"]);
assert_eq!(diags[0].severity, Severity::Error);
let no_name = "[package]\nversion = \"1.0.0\"\n";
let (_, no_name_diags) = parse(no_name);
assert_eq!(codes(&no_name_diags), vec!["MANI-006"]);
}
#[test]
fn defaults_timing_is_recorded_unparsed() {
let text = "\
[package]
name = \"veh.common\"
version = \"1.0.0\"
[defaults]
timing = \"[50ms..2s]\"
";
let (manifest, diags) = parse(text);
assert!(
diags.is_empty(),
"a `[defaults]` section is known, got {diags:?}"
);
let manifest = manifest.expect("the manifest parses");
assert_eq!(manifest.default_timing.as_deref(), Some("[50ms..2s]"));
}
#[test]
fn no_defaults_section_leaves_default_timing_absent() {
let (manifest, diags) = parse(STANDALONE);
assert!(diags.is_empty());
assert_eq!(
manifest.expect("parses").default_timing,
None,
"no `[defaults]` means no configured default",
);
}
#[test]
fn unknown_key_inside_defaults_still_warns() {
let text = "\
[package]
name = \"veh.common\"
version = \"1.0.0\"
[defaults]
timing = \"[100ms..1000ms]\"
bogus = 1
";
let (manifest, diags) = parse(text);
assert!(manifest.is_some(), "an unknown nested key still parses");
assert_eq!(codes(&diags), vec!["MANI-005"]);
assert_eq!(diags[0].severity, Severity::Warning);
}
#[test]
fn mani_007_invalid_import_url() {
let text = "\
[package]
name = \"veh.common\"
version = \"1.0.0\"
[imports]
\"some.dep\" = \"not-a-url\"
";
let (manifest, diags) = parse(text);
let manifest = manifest.expect("a bad URL is a diagnostic, not None");
assert_eq!(
manifest.imports.get("some.dep").map(String::as_str),
Some("not-a-url"),
);
assert_eq!(codes(&diags), vec!["MANI-007"]);
assert_eq!(diags[0].severity, Severity::Error);
let http = "\
[package]
name = \"veh.common\"
version = \"1.0.0\"
[imports]
\"some.dep\" = \"http://example.com/some/dep\"
";
let (_, http_diags) = parse(http);
assert!(
http_diags.is_empty(),
"http:// with a host is a valid URL, got {http_diags:?}",
);
}
}