use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const GRAMMAR_SYMBOL: &[u8] = b"lc_grammar_language\0";
pub type GrammarAsset = super::artifact_install::ArtifactAsset;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GrammarManifest {
pub name: String,
pub display_name: String,
pub version: String,
pub homepage: String,
pub license: String,
pub extensions: Vec<String>,
pub abi_version: u32,
pub assets: BTreeMap<String, GrammarAsset>,
}
impl GrammarManifest {
pub fn from_json(text: &str) -> Result<Self, String> {
serde_json::from_str(text).map_err(|e| format!("invalid grammar manifest: {e}"))
}
pub fn asset_for(&self, target_triple: &str) -> Option<&GrammarAsset> {
self.assets.get(target_triple)
}
pub fn display_name(&self) -> &str {
if self.display_name.trim().is_empty() {
&self.name
} else {
&self.display_name
}
}
pub fn validate(&self) -> Result<(), String> {
let name = self.name.trim();
if name.is_empty() {
return Err("grammar manifest is missing `name`".into());
}
if !is_slug(name) {
return Err(format!(
"grammar name `{name}` must be a slug (lowercase letters, digits and dashes, \
no leading/trailing dash)"
));
}
if self.extensions.is_empty() {
return Err(format!("grammar `{name}` declares no `extensions`"));
}
if self.abi_version == 0 {
return Err(format!("grammar `{name}` is missing `abi_version`"));
}
for (triple, asset) in &self.assets {
if asset.sha256.trim().is_empty() {
return Err(format!(
"grammar `{name}` asset for `{triple}` is missing `sha256` — a dylib \
dlopen'd in-process must be pinned"
));
}
if asset.url.trim().is_empty() {
return Err(format!(
"grammar `{name}` asset for `{triple}` is missing `url`"
));
}
}
Ok(())
}
}
fn is_slug(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('-')
&& !s.ends_with('-')
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
fn asset() -> GrammarAsset {
GrammarAsset {
filename: "lua-x86_64-pc-windows-msvc.dll".into(),
url: "https://example.com/lua.dll".into(),
sha256: "a".repeat(64),
}
}
fn valid() -> GrammarManifest {
GrammarManifest {
name: "lua".into(),
extensions: vec!["lua".into()],
abi_version: 15,
assets: BTreeMap::from([("x86_64-pc-windows-msvc".into(), asset())]),
..Default::default()
}
}
#[test]
fn valid_manifest_passes() {
assert!(valid().validate().is_ok());
}
#[test]
fn rejects_bad_slug() {
let mut m = valid();
m.name = "Lua Grammar".into();
assert!(m.validate().is_err());
}
#[test]
fn rejects_missing_extensions() {
let mut m = valid();
m.extensions.clear();
assert!(m.validate().is_err());
}
#[test]
fn rejects_missing_abi_version() {
let mut m = valid();
m.abi_version = 0;
assert!(m.validate().is_err());
}
#[test]
fn rejects_unpinned_asset() {
let mut m = valid();
m.assets.get_mut("x86_64-pc-windows-msvc").unwrap().sha256 = String::new();
assert!(m.validate().is_err());
}
#[test]
fn json_round_trip() {
let m = valid();
let json = serde_json::to_string(&m).unwrap();
let back = GrammarManifest::from_json(&json).unwrap();
assert_eq!(m, back);
}
}