use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::wire::DiagnosticSeverity;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Schema {
pub schema_version: u32,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, ParamSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attaches_to: Vec<String>,
#[serde(default)]
pub body: BodyShape,
#[serde(default)]
pub verbatim_label: bool,
#[serde(default)]
pub capabilities: Capabilities,
#[serde(default)]
pub hooks: HookSet,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handler: Option<HandlerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<DiagnosticDecl>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DiagnosticDecl {
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(
default = "default_decl_severity",
deserialize_with = "deserialize_strict_severity"
)]
pub default_severity: DiagnosticSeverity,
}
fn default_decl_severity() -> DiagnosticSeverity {
DiagnosticSeverity::Warning
}
fn deserialize_strict_severity<'de, D>(deserializer: D) -> Result<DiagnosticSeverity, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{Error, Unexpected};
let s = String::deserialize(deserializer)?;
match s.as_str() {
"error" => Ok(DiagnosticSeverity::Error),
"warning" => Ok(DiagnosticSeverity::Warning),
"info" => Ok(DiagnosticSeverity::Info),
"hint" => Ok(DiagnosticSeverity::Hint),
_ => Err(D::Error::invalid_value(
Unexpected::Str(&s),
&"one of: error, warning, info, hint",
)),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ParamSpec {
#[serde(rename = "type")]
pub ty: ParamType,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub values: Vec<EnumValue>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ParamType {
String,
Bool,
Int,
Float,
Enum,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnumValue {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BodyShape {
#[serde(default = "BodyKind::default_kind")]
pub kind: BodyKind,
#[serde(default)]
pub presence: BodyPresence,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl Default for BodyShape {
fn default() -> Self {
Self {
kind: BodyKind::None,
presence: BodyPresence::Optional,
description: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum BodyKind {
None,
Text,
Lex,
}
impl BodyKind {
fn default_kind() -> Self {
Self::None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum BodyPresence {
Optional,
Required,
}
impl Default for BodyPresence {
fn default() -> Self {
Self::Optional
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Capabilities {
#[serde(default)]
pub fs: bool,
#[serde(default)]
pub net: bool,
}
impl Capabilities {
pub fn is_pure(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HookSet {
#[serde(default)]
pub label: bool,
#[serde(default)]
pub validate: bool,
#[serde(default)]
pub resolve: bool,
#[serde(default)]
pub ir_build: bool,
#[serde(default)]
pub hover: bool,
#[serde(default)]
pub completion: bool,
#[serde(default)]
pub code_action: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub render: Vec<RenderHook>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RenderHook(pub String);
impl RenderHook {
pub fn new(format: impl Into<String>) -> Self {
Self(format.into())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HandlerSpec {
pub transport: HandlerTransport,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum HandlerTransport {
Native,
Subprocess,
Wasm,
}
#[cfg(test)]
mod tests {
use super::*;
fn comment_schema() -> Schema {
let mut params = BTreeMap::new();
params.insert(
"role".into(),
ParamSpec {
ty: ParamType::Enum,
required: true,
default: None,
description: None,
pattern: None,
values: vec![
EnumValue {
name: "author".into(),
description: None,
},
EnumValue {
name: "editor".into(),
description: None,
},
],
},
);
Schema {
schema_version: 1,
label: "acme.commenting".into(),
description: Some("A comment thread.".into()),
params,
attaches_to: vec!["paragraph".into(), "session".into()],
body: BodyShape {
kind: BodyKind::Lex,
presence: BodyPresence::Required,
description: None,
},
verbatim_label: false,
capabilities: Capabilities {
fs: false,
net: false,
},
hooks: HookSet {
validate: true,
hover: true,
render: vec![RenderHook::new("html"), RenderHook::new("markdown")],
..HookSet::default()
},
handler: Some(HandlerSpec {
transport: HandlerTransport::Subprocess,
command: vec!["acme-comment-handler".into()],
timeout_ms: Some(2000),
}),
diagnostics: vec![
DiagnosticDecl {
code: "unresolved-thread".into(),
description: Some("A comment thread has no resolution.".into()),
default_severity: DiagnosticSeverity::Warning,
},
DiagnosticDecl {
code: "missing-author".into(),
description: None,
default_severity: DiagnosticSeverity::Error,
},
],
}
}
#[test]
fn schema_round_trips_through_json() {
let s = comment_schema();
let serialised = serde_json::to_string(&s).unwrap();
let back: Schema = serde_json::from_str(&serialised).unwrap();
assert_eq!(back, s);
}
#[test]
fn capabilities_is_pure_for_zero_fs_zero_net() {
assert!(Capabilities::default().is_pure());
assert!(!Capabilities {
fs: true,
net: false
}
.is_pure());
assert!(!Capabilities {
fs: false,
net: true
}
.is_pure());
}
#[test]
fn hookset_default_is_all_off() {
let hs = HookSet::default();
assert!(!hs.validate);
assert!(!hs.resolve);
assert!(!hs.ir_build);
assert!(hs.render.is_empty());
}
#[test]
fn hookset_ir_build_round_trips_through_json() {
let hs = HookSet {
ir_build: true,
..HookSet::default()
};
let serialised = serde_json::to_string(&hs).unwrap();
assert!(
serialised.contains("\"ir_build\":true"),
"ir_build must serialise: {serialised}"
);
let back: HookSet = serde_json::from_str(&serialised).unwrap();
assert!(back.ir_build);
let legacy = r#"{"label":false,"validate":false,"resolve":false,"hover":false,"completion":false,"code_action":false}"#;
let parsed: HookSet = serde_json::from_str(legacy).unwrap();
assert!(
!parsed.ir_build,
"legacy JSON must default ir_build to false"
);
}
#[test]
fn body_shape_default_is_none_optional() {
let bs = BodyShape::default();
assert_eq!(bs.kind, BodyKind::None);
assert_eq!(bs.presence, BodyPresence::Optional);
}
#[test]
fn schema_without_diagnostics_field_loads_empty() {
let s: Schema =
serde_json::from_str(r#"{"schema_version": 1, "label": "acme.task"}"#).unwrap();
assert!(s.diagnostics.is_empty());
}
#[test]
fn diagnostic_decl_default_severity_is_warning() {
let s: Schema = serde_json::from_str(
r#"{"schema_version": 1, "label": "acme.task",
"diagnostics": [{"code": "due-date-missing"}]}"#,
)
.unwrap();
assert_eq!(s.diagnostics.len(), 1);
assert_eq!(s.diagnostics[0].code, "due-date-missing");
assert_eq!(s.diagnostics[0].description, None);
assert_eq!(
s.diagnostics[0].default_severity,
DiagnosticSeverity::Warning
);
}
#[test]
fn diagnostic_decl_explicit_severity_parses() {
let s: Schema = serde_json::from_str(
r#"{"schema_version": 1, "label": "acme.task",
"diagnostics": [{"code": "due-date-missing",
"description": "Task lacks a due date.",
"default_severity": "error"}]}"#,
)
.unwrap();
assert_eq!(s.diagnostics[0].default_severity, DiagnosticSeverity::Error);
assert_eq!(
s.diagnostics[0].description.as_deref(),
Some("Task lacks a due date.")
);
}
#[test]
fn diagnostic_decl_rejects_unknown_field() {
assert!(serde_json::from_str::<Schema>(
r#"{"schema_version": 1, "label": "acme.task",
"diagnostics": [{"code": "due-date-missing", "severty": "warn"}]}"#,
)
.is_err());
}
#[test]
fn diagnostic_decl_rejects_unknown_severity_value() {
for bad in [r#""warn""#, r#""erorr""#, r#""fatal""#] {
let src = format!(
r#"{{"schema_version": 1, "label": "acme.task",
"diagnostics": [{{"code": "x", "default_severity": {bad}}}]}}"#
);
assert!(
serde_json::from_str::<Schema>(&src).is_err(),
"expected `{bad}` to be rejected"
);
}
}
}