use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use decodal::{ Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader, LoadedImport, Span, SyntaxToken, SyntaxTokenKind, Value, tokenize_source, }; use decodal_language_service::{CompletionResult, LanguageService}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 2; pub const DECODAL_VERSION: &str = "0.4.0"; pub const DEFAULT_SCHEMA_VERSION: u32 = 1; pub const DEFAULT_IMPORT_POLICY_VERSION: u32 = 1; pub const WORKSPACE_CONFIG_SCHEMA_GLOBAL: &str = "WorkspaceConfigSchema"; pub const WORKSPACE_CONFIG_SCHEMA_SOURCE: &str = "workspace-config-schema.dcdl"; pub const WORKSPACE_CONFIG_EVALUATION_SOURCE: &str = "import \"__MAIN_ENTRYPOINT__\" as WorkspaceConfigSchema"; pub const MAX_ENTRY_COUNT: usize = 256; pub const MAX_CHANGE_COUNT: usize = 256; pub const MAX_ENTRY_BYTES: usize = 256 * 1024; pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024; pub const MAX_PATH_BYTES: usize = 512; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)] pub struct VirtualPath(String); impl VirtualPath { pub fn parse(value: impl AsRef) -> Result { let value = value.as_ref(); if value.is_empty() { return Err(ConfigTreeError::InvalidPath( "path must not be empty".into(), )); } if value.len() > MAX_PATH_BYTES { return Err(ConfigTreeError::LimitExceeded("path bytes")); } if value.starts_with('/') || value.contains('\\') || value.contains('\0') || value.contains("://") { return Err(ConfigTreeError::InvalidPath(value.into())); } let mut normalized = Vec::new(); for component in value.split('/') { if component.is_empty() || component == "." || component == ".." { return Err(ConfigTreeError::InvalidPath(value.into())); } if component.chars().any(char::is_control) { return Err(ConfigTreeError::InvalidPath(value.into())); } normalized.push(component); } Ok(Self(normalized.join("/"))) } pub fn as_str(&self) -> &str { &self.0 } fn parent_components(&self) -> Vec<&str> { let mut components = self.0.split('/').collect::>(); components.pop(); components } } impl fmt::Display for VirtualPath { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.0) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[serde(rename_all = "snake_case")] pub enum ConfigContentType { Decodal, Text, } impl ConfigContentType { pub fn media_type(self) -> &'static str { match self { Self::Decodal => "text/x-decodal", Self::Text => "text/plain", } } } /// Stable value projection used when a virtual config source imports Markdown. /// /// Frontmatter delimiters are transport syntax and are intentionally absent from /// `content`; unknown frontmatter keys stay in `frontmatter` without a /// domain-specific parser interpreting them. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct MarkdownDocumentProjection { pub frontmatter: serde_json::Map, pub content: String, } /// Parse one Markdown source into the common virtual-config import shape. /// /// Files without a leading YAML frontmatter delimiter produce an empty /// frontmatter object and preserve the complete file as `content`. pub fn project_markdown_document(source: &str) -> Result { let Some(after_opening) = source .strip_prefix("---\n") .or_else(|| source.strip_prefix("---\r\n")) else { return Ok(MarkdownDocumentProjection { frontmatter: serde_json::Map::new(), content: source.to_string(), }); }; let mut frontmatter_end = None; let mut offset = 0usize; for line_with_ending in after_opening.split_inclusive('\n') { let line = line_with_ending.trim_end_matches(['\r', '\n']); if line == "---" { frontmatter_end = Some((offset, offset + line_with_ending.len())); break; } offset += line_with_ending.len(); } if frontmatter_end.is_none() && after_opening.ends_with("---") { let start = after_opening.len() - 3; if start == 0 || after_opening[..start].ends_with('\n') { frontmatter_end = Some((start, after_opening.len())); } } let Some((frontmatter_end, content_start)) = frontmatter_end else { return Err("opening YAML frontmatter delimiter has no closing delimiter".to_string()); }; let frontmatter_source = &after_opening[..frontmatter_end]; let frontmatter = if frontmatter_source.trim().is_empty() { serde_json::Map::new() } else { let yaml = serde_yaml::from_str::(frontmatter_source) .map_err(|error| format!("invalid YAML frontmatter: {error}"))?; let value = yaml_to_json(yaml)?; value .as_object() .cloned() .ok_or_else(|| "YAML frontmatter must be a mapping".to_string())? }; Ok(MarkdownDocumentProjection { frontmatter, content: after_opening[content_start..].to_string(), }) } fn yaml_to_json(value: serde_yaml::Value) -> Result { match value { serde_yaml::Value::Null => Ok(serde_json::Value::Null), serde_yaml::Value::Bool(value) => Ok(serde_json::Value::Bool(value)), serde_yaml::Value::Number(value) => { if let Some(value) = value.as_i64() { Ok(serde_json::Value::Number(value.into())) } else if let Some(value) = value.as_u64() { Ok(serde_json::Value::Number(value.into())) } else if let Some(value) = value.as_f64() { serde_json::Number::from_f64(value) .map(serde_json::Value::Number) .ok_or_else(|| "YAML frontmatter contains a non-finite number".to_string()) } else { Err("YAML frontmatter contains an unsupported number".to_string()) } } serde_yaml::Value::String(value) => Ok(serde_json::Value::String(value)), serde_yaml::Value::Sequence(values) => values .into_iter() .map(yaml_to_json) .collect::, _>>() .map(serde_json::Value::Array), serde_yaml::Value::Mapping(values) => { let mut object = serde_json::Map::new(); for (key, value) in values { let serde_yaml::Value::String(key) = key else { return Err("YAML frontmatter mapping keys must be strings".to_string()); }; object.insert(key, yaml_to_json(value)?); } Ok(serde_json::Value::Object(object)) } serde_yaml::Value::Tagged(_) => Err("YAML frontmatter tags are not supported".to_string()), } } fn markdown_projection_to_value(projection: MarkdownDocumentProjection) -> Result { Ok(Value::object([ ( "frontmatter", json_to_decodal_value(serde_json::Value::Object(projection.frontmatter))?, ), ("content", Value::string(projection.content)), ])) } fn json_to_decodal_value(value: serde_json::Value) -> Result { match value { serde_json::Value::Null => Err( "YAML null values cannot be represented as concrete Decodal import values".to_string(), ), serde_json::Value::Bool(value) => Ok(Value::bool(value)), serde_json::Value::Number(value) => { if let Some(value) = value.as_i64() { Ok(Value::int(value)) } else if let Some(value) = value.as_u64() { i64::try_from(value) .map(Value::int) .map_err(|_| "YAML integer exceeds the Decodal i64 range".to_string()) } else if let Some(value) = value.as_f64() { Ok(Value::float(value)) } else { Err("JSON number cannot be represented as a Decodal value".to_string()) } } serde_json::Value::String(value) => Ok(Value::string(value)), serde_json::Value::Array(values) => values .into_iter() .map(json_to_decodal_value) .collect::, _>>() .map(Value::array), serde_json::Value::Object(values) => values .into_iter() .map(|(name, value)| Ok((name, json_to_decodal_value(value)?))) .collect::, _>>() .map(Value::object), } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] pub struct ConfigEntry { pub path: VirtualPath, pub content_type: ConfigContentType, pub content: String, pub content_digest: String, } impl ConfigEntry { pub fn new( path: VirtualPath, content_type: ConfigContentType, content: impl Into, ) -> Result { let content = content.into(); if content.len() > MAX_ENTRY_BYTES { return Err(ConfigTreeError::LimitExceeded("entry bytes")); } Ok(Self { path, content_type, content_digest: digest_bytes(content.as_bytes()), content, }) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] pub struct ConfigTreeSnapshot { #[ts(type = "number")] pub revision: u64, pub digest: String, pub entries: BTreeMap, } impl ConfigTreeSnapshot { pub fn empty() -> Self { Self::from_entries(0, Vec::new()).expect("empty config snapshot is valid") } pub fn from_entries( revision: u64, entries: impl IntoIterator, ) -> Result { let mut ordered = BTreeMap::new(); let mut total = 0usize; for entry in entries { total = total .checked_add(entry.content.len()) .ok_or(ConfigTreeError::LimitExceeded("total bytes"))?; if total > MAX_TOTAL_BYTES { return Err(ConfigTreeError::LimitExceeded("total bytes")); } if ordered.insert(entry.path.clone(), entry).is_some() { return Err(ConfigTreeError::DuplicatePath); } } if ordered.len() > MAX_ENTRY_COUNT { return Err(ConfigTreeError::LimitExceeded("entry count")); } let digest = snapshot_digest(&ordered); Ok(Self { revision, digest, entries: ordered, }) } pub fn list_prefix(&self, prefix: Option<&VirtualPath>) -> Vec<&ConfigEntry> { self.entries .values() .filter(|entry| { prefix.is_none_or(|prefix| { entry.path == *prefix || entry .path .as_str() .strip_prefix(prefix.as_str()) .is_some_and(|rest| rest.starts_with('/')) }) }) .collect() } pub fn get(&self, path: &VirtualPath) -> Option<&ConfigEntry> { self.entries.get(path) } pub fn changes_to(&self, candidate: &Self) -> Vec { let mut changes = Vec::new(); for (path, base_entry) in &self.entries { match candidate.entries.get(path) { None => changes.push(ConfigTreeChange::Delete { path: path.clone(), expected_digest: base_entry.content_digest.clone(), }), Some(candidate_entry) if candidate_entry.content_digest != base_entry.content_digest => { changes.push(ConfigTreeChange::Update { path: path.clone(), expected_digest: base_entry.content_digest.clone(), content: candidate_entry.content.clone(), }); } Some(_) => {} } } for (path, entry) in &candidate.entries { if !self.entries.contains_key(path) { changes.push(ConfigTreeChange::Create { path: path.clone(), content_type: entry.content_type, content: entry.content.clone(), }); } } changes } pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result { if changes.len() > MAX_CHANGE_COUNT { return Err(ConfigTreeError::LimitExceeded("change count")); } let mut entries = self.entries.clone(); let mut touched = BTreeSet::new(); for change in changes { for path in change.paths() { if !touched.insert(path.clone()) { return Err(ConfigTreeError::PathChangedMoreThanOnce(path.clone())); } } match change { ConfigTreeChange::Create { path, content_type, content, } => { if entries.contains_key(path) { return Err(ConfigTreeError::AlreadyExists(path.clone())); } entries.insert( path.clone(), ConfigEntry::new(path.clone(), *content_type, content.clone())?, ); } ConfigTreeChange::Update { path, expected_digest, content, } => { let current = entries .get(path) .ok_or_else(|| ConfigTreeError::NotFound(path.clone()))?; if ¤t.content_digest != expected_digest { return Err(ConfigTreeError::EntryConflict(path.clone())); } entries.insert( path.clone(), ConfigEntry::new(path.clone(), current.content_type, content.clone())?, ); } ConfigTreeChange::Rename { from, to, expected_digest, } => { if entries.contains_key(to) { return Err(ConfigTreeError::AlreadyExists(to.clone())); } let current = entries .remove(from) .ok_or_else(|| ConfigTreeError::NotFound(from.clone()))?; if ¤t.content_digest != expected_digest { return Err(ConfigTreeError::EntryConflict(from.clone())); } entries.insert( to.clone(), ConfigEntry::new(to.clone(), current.content_type, current.content)?, ); } ConfigTreeChange::Delete { path, expected_digest, } => { let current = entries .get(path) .ok_or_else(|| ConfigTreeError::NotFound(path.clone()))?; if ¤t.content_digest != expected_digest { return Err(ConfigTreeError::EntryConflict(path.clone())); } entries.remove(path); } } } Self::from_entries(self.revision, entries.into_values()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ConfigTreeChange { Create { path: VirtualPath, content_type: ConfigContentType, content: String, }, Update { path: VirtualPath, expected_digest: String, content: String, }, Rename { from: VirtualPath, to: VirtualPath, expected_digest: String, }, Delete { path: VirtualPath, expected_digest: String, }, } impl ConfigTreeChange { fn paths(&self) -> Vec<&VirtualPath> { match self { Self::Create { path, .. } | Self::Update { path, .. } | Self::Delete { path, .. } => { vec![path] } Self::Rename { from, to, .. } => vec![from, to], } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[serde(tag = "kind", rename_all = "snake_case")] #[ts(export)] pub enum ConfigProjectionValidator { StaticTemplateCatalog { namespace: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] key_aliases: BTreeMap, }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[ts(export)] pub struct ConfigSchemaContribution { pub provider_id: String, pub namespace: String, pub version: String, pub source: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub projection_validator: Option, pub source_digest: String, } impl ConfigSchemaContribution { pub fn new( provider_id: impl Into, namespace: impl Into, version: impl Into, source: impl Into, ) -> Result { let provider_id = provider_id.into(); let namespace = namespace.into(); let version = version.into(); let source = source.into(); if provider_id.trim().is_empty() { return Err(ConfigTreeError::InvalidSchemaContribution( "provider_id must not be empty".to_string(), )); } if namespace.trim().is_empty() { return Err(ConfigTreeError::InvalidSchemaContribution( "namespace must not be empty".to_string(), )); } if version.trim().is_empty() { return Err(ConfigTreeError::InvalidSchemaContribution( "version must not be empty".to_string(), )); } if source.trim().is_empty() { return Err(ConfigTreeError::InvalidSchemaContribution( "schema source must not be empty".to_string(), )); } Ok(Self { provider_id, namespace, version, source_digest: digest_bytes(source.as_bytes()), source, projection_validator: None, }) } pub fn with_projection_validator(mut self, validator: ConfigProjectionValidator) -> Self { self.projection_validator = Some(validator); self } fn validate(&self) -> Result<(), ConfigTreeError> { let expected = digest_bytes(self.source.as_bytes()); if self.source_digest != expected { return Err(ConfigTreeError::InvalidSchemaContribution(format!( "schema contribution {} digest mismatch", self.provider_id ))); } Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[ts(export)] pub struct WorkspaceConfigSchemaBundle { pub contributions: Vec, pub source: String, pub fingerprint: String, } impl WorkspaceConfigSchemaBundle { pub fn compose( contributions: impl IntoIterator, ) -> Result { let mut contributions = contributions.into_iter().collect::>(); contributions.sort_by(|left, right| left.provider_id.cmp(&right.provider_id)); for contribution in &contributions { contribution.validate()?; } for pair in contributions.windows(2) { if pair[0].provider_id == pair[1].provider_id { return Err(ConfigTreeError::DuplicateSchemaProvider( pair[0].provider_id.clone(), )); } } let mut namespaces = std::collections::BTreeSet::new(); for contribution in &contributions { if !namespaces.insert(contribution.namespace.clone()) { return Err(ConfigTreeError::DuplicateSchemaNamespace( contribution.namespace.clone(), )); } } let source = if contributions.is_empty() { "{ ...Unknown }".to_string() } else { contributions .iter() .map(|contribution| format!("({})", contribution.source)) .collect::>() .join(" & ") }; let fingerprint = digest_bytes( serde_json::to_vec(&( CONFIG_SOURCE_CONTRACT_VERSION, DECODAL_VERSION, contributions .iter() .map(|contribution| { ( contribution.provider_id.as_str(), contribution.namespace.as_str(), contribution.version.as_str(), contribution.source_digest.as_str(), contribution.projection_validator.as_ref(), ) }) .collect::>(), digest_bytes(source.as_bytes()), )) .expect("schema bundle fingerprint input serializes") .as_slice(), ); Ok(Self { contributions, source, fingerprint, }) } pub fn empty() -> Self { Self::compose(Vec::new()).expect("empty schema bundle is valid") } pub fn validate(&self) -> Result<(), ConfigTreeError> { let recomposed = Self::compose(self.contributions.clone())?; if recomposed.source != self.source || recomposed.fingerprint != self.fingerprint { return Err(ConfigTreeError::InvalidSchemaContribution( "workspace config schema bundle fingerprint mismatch".to_string(), )); } Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] pub struct ToolchainContract { pub contract_version: u32, pub decodal_version: String, pub schema_version: u32, pub entrypoints: Vec, pub import_policy_version: u32, pub schema_bundle: WorkspaceConfigSchemaBundle, pub fingerprint: String, } impl ToolchainContract { pub fn new( schema_version: u32, entrypoints: Vec, import_policy_version: u32, ) -> Self { Self::with_schema_bundle( schema_version, entrypoints, import_policy_version, WorkspaceConfigSchemaBundle::empty(), ) } pub fn with_schema_bundle( schema_version: u32, mut entrypoints: Vec, import_policy_version: u32, schema_bundle: WorkspaceConfigSchemaBundle, ) -> Self { entrypoints.sort(); entrypoints.dedup(); let mut contract = Self { contract_version: CONFIG_SOURCE_CONTRACT_VERSION, decodal_version: DECODAL_VERSION.to_string(), schema_version, entrypoints, import_policy_version, schema_bundle, fingerprint: String::new(), }; contract.fingerprint = digest_bytes( serde_json::to_vec(&( contract.contract_version, &contract.decodal_version, contract.schema_version, &contract.entrypoints, contract.import_policy_version, &contract.schema_bundle.fingerprint, )) .expect("toolchain contract serializes") .as_slice(), ); contract } pub fn validate(&self) -> Result<(), ConfigTreeError> { self.schema_bundle.validate()?; let expected = Self::with_schema_bundle( self.schema_version, self.entrypoints.clone(), self.import_policy_version, self.schema_bundle.clone(), ); if self.contract_version != expected.contract_version || self.decodal_version != expected.decodal_version || self.fingerprint != expected.fingerprint { return Err(ConfigTreeError::InvalidSchemaContribution( "toolchain contract fingerprint mismatch".to_string(), )); } Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] pub struct ConfigSpan { pub start_byte: u32, pub end_byte: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] pub struct ConfigDiagnosticLabel { pub span: ConfigSpan, pub message: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] pub struct ConfigDiagnostic { pub path: VirtualPath, #[ts(type = "number")] pub revision: u64, pub tree_digest: String, pub kind: String, pub span: ConfigSpan, pub message: String, pub labels: Vec, pub notes: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] #[ts(export)] pub struct EvaluatedProjection { pub entrypoint: VirtualPath, #[ts(type = "unknown")] pub data_json: serde_json::Value, pub projection_digest: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] pub struct EvaluationResult { pub projections: Vec, pub projection_digest: String, } #[derive(Debug, Clone, PartialEq, Eq)] struct ConfigFieldCompletionContext { schema_path: Vec, from: usize, } #[derive(Debug)] enum ConfigCompletionContainer { Object { schema_path: Vec, pending_path: Vec, last_identifier_from: Option, trailing_dot: bool, reading_value: bool, }, Array { schema_path: Vec, }, Other { schema_path: Vec, }, } fn workspace_schema_assertion_object_start( tokens: &[SyntaxToken], utf8_byte_offset: usize, ) -> Option { let mut object_stack = Vec::::new(); let mut matching_assertions = Vec::::new(); for (index, token) in tokens.iter().enumerate() { match &token.kind { SyntaxTokenKind::LBrace => object_stack.push(index), SyntaxTokenKind::RBrace => { let Some(object_index) = object_stack.pop() else { continue; }; let object_start = tokens[object_index].span.start as usize; let object_end = token.span.start as usize; if !(object_start < utf8_byte_offset && utf8_byte_offset <= object_end) { continue; } let mut suffix = tokens[index + 1..] .iter() .filter(|token| !matches!(token.kind, SyntaxTokenKind::Comment)); if !matches!( suffix.next().map(|token| &token.kind), Some(SyntaxTokenKind::As) ) { continue; } let Some(SyntaxTokenKind::Ident(global)) = suffix.next().map(|token| &token.kind) else { continue; }; if global == WORKSPACE_CONFIG_SCHEMA_GLOBAL { matching_assertions.push(object_start); } } _ => {} } } matching_assertions.into_iter().max() } fn config_field_completion_context( source: &str, utf8_byte_offset: usize, ) -> Option { if utf8_byte_offset > source.len() || !source.is_char_boundary(utf8_byte_offset) { return None; } let tokens = tokenize_source(source).ok()?; let asserted_object_start = workspace_schema_assertion_object_start(&tokens, utf8_byte_offset)?; let mut containers = Vec::::new(); for token in tokens { let token_start = token.span.start as usize; let token_end = token.span.end as usize; if token_start < asserted_object_start { continue; } if token_start >= utf8_byte_offset { break; } let kind = token.kind; match kind { SyntaxTokenKind::LBrace => { let schema_path = pending_container_path(&containers); containers.push(ConfigCompletionContainer::Object { schema_path, pending_path: Vec::new(), last_identifier_from: None, trailing_dot: false, reading_value: false, }); } SyntaxTokenKind::RBrace => { pop_container(&mut containers, |container| { matches!(container, ConfigCompletionContainer::Object { .. }) }); } SyntaxTokenKind::LBracket => { let schema_path = pending_container_path(&containers); containers.push(ConfigCompletionContainer::Array { schema_path }); } SyntaxTokenKind::RBracket => { pop_container(&mut containers, |container| { matches!(container, ConfigCompletionContainer::Array { .. }) }); } SyntaxTokenKind::LParen => { let schema_path = pending_container_path(&containers); containers.push(ConfigCompletionContainer::Other { schema_path }); } SyntaxTokenKind::RParen => { pop_container(&mut containers, |container| { matches!(container, ConfigCompletionContainer::Other { .. }) }); } SyntaxTokenKind::Ident(identifier) => { let Some(ConfigCompletionContainer::Object { pending_path, last_identifier_from, trailing_dot, reading_value: false, .. }) = containers.last_mut() else { continue; }; let identifier = if token_end > utf8_byte_offset { source[token_start..utf8_byte_offset].to_owned() } else { identifier }; if *trailing_dot || pending_path.is_empty() { pending_path.push(identifier); } else { *pending_path.last_mut().expect("pending path is non-empty") = identifier; } *last_identifier_from = Some(token_start); *trailing_dot = false; } SyntaxTokenKind::Dot => { if let Some(ConfigCompletionContainer::Object { trailing_dot, reading_value: false, .. }) = containers.last_mut() { *trailing_dot = true; } } SyntaxTokenKind::Equal => { if let Some(ConfigCompletionContainer::Object { reading_value, .. }) = containers.last_mut() { *reading_value = true; } } SyntaxTokenKind::Semicolon => { if let Some(ConfigCompletionContainer::Object { pending_path, last_identifier_from, trailing_dot, reading_value, .. }) = containers.last_mut() { pending_path.clear(); *last_identifier_from = None; *trailing_dot = false; *reading_value = false; } } _ => {} } } let ConfigCompletionContainer::Object { schema_path, pending_path, last_identifier_from, trailing_dot, reading_value: false, } = containers.last()? else { return None; }; let mut schema_path = schema_path.clone(); schema_path.extend(pending_path.iter().cloned()); Some(ConfigFieldCompletionContext { schema_path, from: if *trailing_dot { utf8_byte_offset } else { last_identifier_from.unwrap_or(utf8_byte_offset) }, }) } fn pending_container_path(containers: &[ConfigCompletionContainer]) -> Vec { match containers.last() { Some(ConfigCompletionContainer::Object { schema_path, pending_path, reading_value: true, .. }) => schema_path.iter().chain(pending_path).cloned().collect(), Some(ConfigCompletionContainer::Array { schema_path }) | Some(ConfigCompletionContainer::Other { schema_path }) => schema_path.clone(), _ => Vec::new(), } } fn pop_container( containers: &mut Vec, matches: impl Fn(&ConfigCompletionContainer) -> bool, ) { if let Some(index) = containers.iter().rposition(matches) { containers.truncate(index); } } #[derive(Debug, Clone)] pub struct SnapshotEnvironment { snapshot: ConfigTreeSnapshot, schema_bundle: Option, } impl SnapshotEnvironment { pub fn new(snapshot: ConfigTreeSnapshot) -> Self { Self { snapshot, schema_bundle: None, } } pub fn with_schema_bundle(mut self, schema_bundle: WorkspaceConfigSchemaBundle) -> Self { self.schema_bundle = Some(schema_bundle); self } pub fn snapshot(&self) -> &ConfigTreeSnapshot { &self.snapshot } pub fn evaluate_contract( &self, contract: &ToolchainContract, ) -> Result> { if let Err(error) = contract.validate() { return Err(vec![self.config_error( VirtualPath::parse(WORKSPACE_CONFIG_SCHEMA_SOURCE).expect("schema path is valid"), "schema_contract", &error.to_string(), )]); } let analysis_environment = self .clone() .with_schema_bundle(contract.schema_bundle.clone()); let service = LanguageService::new(&analysis_environment); let diagnostics = self .snapshot .entries .values() .filter(|entry| entry.content_type == ConfigContentType::Decodal) .flat_map(|entry| { service .analyze(entry.path.as_str(), entry.path.as_str(), &entry.content) .diagnostics .iter() .map(|diagnostic| { project_diagnostic(&self.snapshot, entry.path.clone(), diagnostic) }) .collect::>() }) .collect::>(); if !diagnostics.is_empty() { return Err(diagnostics); } let Some(entrypoint) = contract.entrypoints.first() else { return Ok(EvaluationResult { projections: Vec::new(), projection_digest: digest_bytes(b"[]"), }); }; if contract.entrypoints.len() != 1 { return Err(vec![self.config_error( entrypoint.clone(), "entrypoint_count", "Workspace config evaluation requires exactly one entrypoint", )]); } let Some(entry) = self.snapshot.get(entrypoint) else { return Err(vec![self.config_error( entrypoint.clone(), "entrypoint_missing", "configured entrypoint is missing", )]); }; if entry.content_type != ConfigContentType::Decodal { return Err(vec![self.config_error( entrypoint.clone(), "entrypoint_not_decodal", "configured entrypoint is not Decodal source", )]); } let mut engine = decodal::Engine::new(SnapshotImportLoader { snapshot: self.snapshot.clone(), }); let schema_module = engine .add_root_source( WORKSPACE_CONFIG_SCHEMA_SOURCE, WORKSPACE_CONFIG_SCHEMA_SOURCE, &contract.schema_bundle.source, ) .map_err(|diagnostic| { vec![project_engine_diagnostic( &engine, &self.snapshot, VirtualPath::parse(WORKSPACE_CONFIG_SCHEMA_SOURCE) .expect("schema path is valid"), &diagnostic, )] })?; let schema = engine.eval_module(schema_module).map_err(|diagnostic| { vec![project_engine_diagnostic( &engine, &self.snapshot, VirtualPath::parse(WORKSPACE_CONFIG_SCHEMA_SOURCE).expect("schema path is valid"), &diagnostic, )] })?; engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema); let evaluation_source = if contract.schema_bundle.contributions.is_empty() { format!("import \"{}\"", entrypoint.as_str()) } else { WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str()) }; let evaluation_module = engine .add_root_source( "workspace-config-evaluation.dcdl", "workspace-config-evaluation.dcdl", &evaluation_source, ) .map_err(|diagnostic| { vec![project_engine_diagnostic( &engine, &self.snapshot, entrypoint.clone(), &diagnostic, )] })?; let value = engine .eval_module(evaluation_module) .map_err(|diagnostic| { vec![project_engine_diagnostic( &engine, &self.snapshot, entrypoint.clone(), &diagnostic, )] })?; let data = engine.materialize(&value).map_err(|diagnostic| { vec![project_engine_diagnostic( &engine, &self.snapshot, entrypoint.clone(), &diagnostic, )] })?; let data_json = decodal_data_to_json(&data); if let Err(message) = validate_projection_contracts(&data_json, &contract.schema_bundle.contributions) { return Err(vec![self.config_error( entrypoint.clone(), "projection_validation", message, )]); } let projection_digest = digest_bytes( serde_json::to_vec(&data_json) .expect("Decodal projection serializes") .as_slice(), ); let projections = vec![EvaluatedProjection { entrypoint: entrypoint.clone(), data_json, projection_digest, }]; let projection_digest = digest_bytes( serde_json::to_vec(&projections) .expect("projection set serializes") .as_slice(), ); Ok(EvaluationResult { projections, projection_digest, }) } pub fn analyze( &self, entrypoint: &VirtualPath, source_override: Option<&str>, ) -> Vec { let Some(entry) = self.snapshot.get(entrypoint) else { return vec![self.config_error( entrypoint.clone(), "entrypoint_missing", "configured entrypoint is missing", )]; }; let service = LanguageService::new(self); service .analyze( entrypoint.as_str(), entrypoint.as_str(), source_override.unwrap_or(&entry.content), ) .diagnostics .iter() .map(|diagnostic| project_diagnostic(&self.snapshot, entrypoint.clone(), diagnostic)) .collect() } pub fn complete( &self, entrypoint: &VirtualPath, source: &str, utf8_byte_offset: usize, explicit: bool, ) -> decodal::Result> { LanguageService::new(self).complete(entrypoint.as_str(), source, utf8_byte_offset, explicit) } pub fn complete_config( &self, entrypoint: &VirtualPath, source: &str, utf8_byte_offset: usize, explicit: bool, ) -> decodal::Result> { if self.schema_bundle.is_some() && let Some(context) = config_field_completion_context(source, utf8_byte_offset) { let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}."); member_source.push_str(&context.schema_path.join(".")); let mut completion = LanguageService::new(self).complete( entrypoint.as_str(), &member_source, member_source.len(), explicit, )?; if let Some(completion) = &mut completion { completion.from = context.from; } return Ok(completion); } self.complete(entrypoint, source, utf8_byte_offset, explicit) } pub fn format(&self, source: &str) -> Result { decodal_language_tools::format_source(source).map_err(|error| error.to_string()) } fn config_error( &self, path: VirtualPath, kind: impl Into, message: impl Into, ) -> ConfigDiagnostic { ConfigDiagnostic { path, revision: self.snapshot.revision, tree_digest: self.snapshot.digest.clone(), kind: kind.into(), span: ConfigSpan { start_byte: 0, end_byte: 0, }, message: message.into(), labels: Vec::new(), notes: Vec::new(), } } } impl HostEnvironment for &SnapshotEnvironment { type Loader = SnapshotImportLoader; fn create_loader(&self) -> Self::Loader { SnapshotImportLoader { snapshot: self.snapshot.clone(), } } fn configure_engine(&self, engine: &mut Engine) -> decodal::Result<()> { let Some(schema_bundle) = &self.schema_bundle else { return Ok(()); }; let schema_module = engine.add_root_source( WORKSPACE_CONFIG_SCHEMA_SOURCE, WORKSPACE_CONFIG_SCHEMA_SOURCE, &schema_bundle.source, )?; let schema = engine.eval_module(schema_module)?; engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema); Ok(()) } } #[derive(Debug, Clone)] pub struct SnapshotImportLoader { snapshot: ConfigTreeSnapshot, } impl SnapshotImportLoader { pub fn resolve( &self, current_key: Option<&str>, specifier: &str, ) -> Result { let current = current_key .map(VirtualPath::parse) .transpose()? .ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?; resolve_import(¤t, specifier) } } impl ImportLoader for SnapshotImportLoader { fn load( &mut self, current_key: Option<&str>, specifier: &str, ) -> decodal::Result { let path = self.resolve(current_key, specifier).map_err(import_error)?; let entry = self.snapshot.get(&path).ok_or_else(|| { Diagnostic::new( DiagnosticKind::Import, Span::default(), format!("virtual config import is missing: {path}"), ) })?; let cache_key = snapshot_import_cache_key(entry); if path.as_str().ends_with(".md") { let projection = project_markdown_document(&entry.content).map_err(|message| { Diagnostic::new( DiagnosticKind::Import, Span::default(), format!("failed to import Markdown `{path}`: {message}"), ) })?; let value = markdown_projection_to_value(projection).map_err(|message| { Diagnostic::new( DiagnosticKind::Import, Span::default(), format!("failed to import Markdown `{path}`: {message}"), ) })?; return Ok(LoadedImport::value(cache_key, value)); } Ok(LoadedImport::source( cache_key, path.as_str(), entry.content.clone(), )) } fn complete_import( &mut self, current_key: Option<&str>, prefix: &str, ) -> decodal::Result> { let current = current_key .map(VirtualPath::parse) .transpose() .map_err(import_error)?; Ok(import_completions(&self.snapshot, current.as_ref(), prefix) .into_iter() .map(|specifier| ImportCandidate::new(specifier).with_detail("virtual config source")) .collect()) } } fn snapshot_import_cache_key(entry: &ConfigEntry) -> String { // The source id remains the virtual path for diagnostics and relative-import // resolution. The cache identity also includes immutable source content so // equal paths from different revisions cannot alias in an Engine cache. format!("{}@{}", entry.path, entry.content_digest) } pub fn resolve_import( current: &VirtualPath, specifier: &str, ) -> Result { if specifier.is_empty() || specifier.starts_with('/') || specifier.contains('\\') || specifier.contains('\0') || specifier.contains("://") || specifier.contains(':') { return Err(ConfigTreeError::InvalidImport(specifier.into())); } let mut components = if specifier.starts_with("./") || specifier.starts_with("../") { current.parent_components() } else { Vec::new() }; for component in specifier.split('/') { match component { "" | "." => {} ".." => { components .pop() .ok_or_else(|| ConfigTreeError::ImportEscape(specifier.into()))?; } value => components.push(value), } } VirtualPath::parse(components.join("/")) } pub fn import_completions( snapshot: &ConfigTreeSnapshot, current: Option<&VirtualPath>, prefix: &str, ) -> Vec { let mut candidates = BTreeSet::new(); for path in snapshot.entries.keys() { if current == Some(path) { continue; } let absolute = path.as_str().to_string(); if absolute.starts_with(prefix) { candidates.insert(absolute); } if let Some(current) = current { let current_parent = current.parent_components(); let target = path.as_str().split('/').collect::>(); let mut common = 0usize; while common < current_parent.len() && common < target.len() && current_parent[common] == target[common] { common += 1; } let mut relative = vec![".."; current_parent.len().saturating_sub(common)]; relative.extend_from_slice(&target[common..]); let specifier = if relative.first().is_some_and(|item| *item == "..") { relative.join("/") } else { format!("./{}", relative.join("/")) }; if specifier.starts_with(prefix) { candidates.insert(specifier); } } } candidates.into_iter().collect() } fn project_engine_diagnostic( engine: &decodal::Engine, snapshot: &ConfigTreeSnapshot, fallback_path: VirtualPath, diagnostic: &Diagnostic, ) -> ConfigDiagnostic { let path = engine .source_name(diagnostic.span.source) .and_then(|name| VirtualPath::parse(name).ok()) .filter(|path| snapshot.entries.contains_key(path)) .unwrap_or(fallback_path); ConfigDiagnostic { path, revision: snapshot.revision, tree_digest: snapshot.digest.clone(), span: ConfigSpan { start_byte: diagnostic.span.start, end_byte: diagnostic.span.end, }, kind: format!("{:?}", diagnostic.kind).to_ascii_lowercase(), message: diagnostic.message.clone(), labels: diagnostic .labels .iter() .map(|label| ConfigDiagnosticLabel { span: ConfigSpan { start_byte: label.span.start, end_byte: label.span.end, }, message: label.message.clone(), }) .collect(), notes: diagnostic.notes.clone(), } } fn project_diagnostic( snapshot: &ConfigTreeSnapshot, fallback_path: VirtualPath, diagnostic: &Diagnostic, ) -> ConfigDiagnostic { ConfigDiagnostic { path: fallback_path, revision: snapshot.revision, tree_digest: snapshot.digest.clone(), kind: diagnostic_kind(diagnostic.kind).to_string(), span: ConfigSpan { start_byte: diagnostic.span.start, end_byte: diagnostic.span.end, }, message: diagnostic.message.clone(), labels: diagnostic .labels .iter() .map(|label| ConfigDiagnosticLabel { span: ConfigSpan { start_byte: label.span.start, end_byte: label.span.end, }, message: label.message.clone(), }) .collect(), notes: diagnostic.notes.clone(), } } fn diagnostic_kind(kind: DiagnosticKind) -> &'static str { match kind { DiagnosticKind::Syntax => "syntax", DiagnosticKind::UnresolvedIdentifier => "unresolved_identifier", DiagnosticKind::TypeMismatch => "type_mismatch", DiagnosticKind::ConstraintViolation => "constraint_violation", DiagnosticKind::Conflict => "conflict", DiagnosticKind::DefaultConflict => "default_conflict", DiagnosticKind::Cycle => "cycle", DiagnosticKind::Import => "import", DiagnosticKind::MatchFailure => "match_failure", DiagnosticKind::Materialize => "materialize", DiagnosticKind::UnsupportedFeature => "unsupported_feature", } } fn import_error(error: ConfigTreeError) -> Diagnostic { Diagnostic::new(DiagnosticKind::Import, Span::default(), error.to_string()) } fn decodal_data_to_json(data: &Data) -> serde_json::Value { match data { Data::Bool(value) => serde_json::Value::Bool(*value), Data::Int(value) => serde_json::Value::Number((*value).into()), Data::Float(value) => serde_json::Number::from_f64(*value) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), Data::String(value) => serde_json::Value::String(value.clone()), Data::Array(values) => { serde_json::Value::Array(values.iter().map(decodal_data_to_json).collect()) } Data::Object(fields) => serde_json::Value::Object( fields .iter() .map(|field| (field.name.clone(), decodal_data_to_json(&field.value))) .collect(), ), } } fn validate_projection_contracts( projection: &serde_json::Value, contributions: &[ConfigSchemaContribution], ) -> Result<(), String> { for contribution in contributions { let Some(ConfigProjectionValidator::StaticTemplateCatalog { namespace, key_aliases, }) = &contribution.projection_validator else { continue; }; let value = projection .get(namespace) .ok_or_else(|| format!("projection has no '{namespace}' template namespace"))?; let mut templates = BTreeMap::new(); flatten_string_catalog("", value, &mut templates)?; for (source, target) in key_aliases { if let Some(value) = templates.remove(source) { if templates.insert(target.clone(), value).is_some() { return Err(format!( "template catalog alias '{source}' collides with '{target}'" )); } } } validate_static_template_catalog(&templates)?; } Ok(()) } fn flatten_string_catalog( prefix: &str, value: &serde_json::Value, output: &mut BTreeMap, ) -> Result<(), String> { match value { serde_json::Value::String(source) if !prefix.is_empty() => { output.insert(prefix.to_string(), source.clone()); Ok(()) } serde_json::Value::Object(fields) => { for (name, value) in fields { let key = if prefix.is_empty() { name.clone() } else { format!("{prefix}.{name}") }; flatten_string_catalog(&key, value, output)?; } Ok(()) } _ => Err(format!( "template catalog leaf '{}' must be a string", if prefix.is_empty() { "" } else { prefix } )), } } pub fn validate_static_template_catalog( templates: &BTreeMap, ) -> Result<(), String> { if templates.is_empty() { return Err("template catalog is empty".to_string()); } let mut environment = minijinja::Environment::new(); environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); let mut graph = BTreeMap::new(); for (name, source) in templates { environment .add_template_owned(name.clone(), source.clone()) .map_err(|error| format!("template '{name}' does not compile: {error}"))?; let includes = parse_static_template_includes(name, source)?; for target in &includes { if !templates.contains_key(target) { return Err(format!( "template '{name}' includes missing target '{target}'" )); } } graph.insert(name.clone(), includes); } fn visit( node: &str, graph: &BTreeMap>, visiting: &mut Vec, visited: &mut BTreeSet, ) -> Result<(), String> { if let Some(position) = visiting.iter().position(|entry| entry == node) { let mut cycle = visiting[position..].to_vec(); cycle.push(node.to_string()); return Err(format!("template include cycle: {}", cycle.join(" -> "))); } if visited.contains(node) { return Ok(()); } visiting.push(node.to_string()); for target in &graph[node] { visit(target, graph, visiting, visited)?; } visiting.pop(); visited.insert(node.to_string()); Ok(()) } let mut visited = BTreeSet::new(); for node in graph.keys() { visit(node, &graph, &mut Vec::new(), &mut visited)?; } Ok(()) } fn parse_static_template_includes(template: &str, source: &str) -> Result, String> { let mut includes = Vec::new(); let mut rest = source; while let Some(open) = rest.find("{%") { let after_open = &rest[open + 2..]; let Some(close) = after_open.find("%}") else { break; }; let body = after_open[..close].trim(); let body = body.strip_prefix('-').unwrap_or(body).trim_start(); let body = body.strip_suffix('-').unwrap_or(body).trim_end(); if body.starts_with("include") { let argument = body["include".len()..].trim(); let bytes = argument.as_bytes(); if bytes.len() < 2 || !matches!(bytes[0], b'\'' | b'"') || bytes[bytes.len() - 1] != bytes[0] { return Err(format!( "template '{template}' include target must be one exact quoted dotted name" )); } let target = &argument[1..argument.len() - 1]; if target.is_empty() || target.contains('/') || target.contains('\\') || target.contains('$') || target.ends_with(".md") || target.split('.').any(|segment| { segment.is_empty() || !segment.chars().all(|character| { character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' }) }) { return Err(format!( "template '{template}' has invalid catalog-root include target '{target}'" )); } includes.push(target.to_string()); } rest = &after_open[close + 2..]; } Ok(includes) } fn snapshot_digest(entries: &BTreeMap) -> String { let mut hasher = Sha256::new(); hasher.update(b"yoi-config-tree-v1\0"); for (path, entry) in entries { hasher.update(path.as_str().as_bytes()); hasher.update([0]); hasher.update(entry.content_type.media_type().as_bytes()); hasher.update([0]); hasher.update(entry.content.as_bytes()); hasher.update([0]); } format_digest(hasher.finalize().as_slice()) } pub fn digest_bytes(bytes: &[u8]) -> String { format_digest(Sha256::digest(bytes).as_slice()) } fn format_digest(bytes: &[u8]) -> String { let mut output = String::from("sha256:"); for byte in bytes { use std::fmt::Write as _; let _ = write!(output, "{byte:02x}"); } output } #[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] pub enum ConfigTreeError { #[error("invalid virtual config path: {0}")] InvalidPath(String), #[error("invalid virtual config import: {0}")] InvalidImport(String), #[error("virtual config import escapes the tree: {0}")] ImportEscape(String), #[error("virtual config path already exists: {0}")] AlreadyExists(VirtualPath), #[error("virtual config path was not found: {0}")] NotFound(VirtualPath), #[error("virtual config entry changed: {0}")] EntryConflict(VirtualPath), #[error("virtual config path changed more than once in one candidate: {0}")] PathChangedMoreThanOnce(VirtualPath), #[error("duplicate virtual config path")] DuplicatePath, #[error("duplicate Workspace config schema provider: {0}")] DuplicateSchemaProvider(String), #[error("duplicate Workspace config schema namespace owner: {0}")] DuplicateSchemaNamespace(String), #[error("invalid Workspace config schema contribution: {0}")] InvalidSchemaContribution(String), #[error("virtual config limit exceeded: {0}")] LimitExceeded(&'static str), } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; use ts_rs::TS; #[test] fn exports_typescript_contract() { let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../web/workspace/src/lib/workspace/config-source/generated/types"); std::fs::create_dir_all(&output).unwrap(); macro_rules! export { ($type:ty) => { <$type>::export_all(&ts_rs::Config::default().with_out_dir(&output)).unwrap(); }; } export!(VirtualPath); export!(ConfigContentType); export!(ConfigEntry); export!(ConfigTreeSnapshot); export!(ConfigTreeChange); export!(ConfigSchemaContribution); export!(WorkspaceConfigSchemaBundle); export!(ToolchainContract); export!(ConfigSpan); export!(ConfigDiagnosticLabel); export!(ConfigDiagnostic); export!(EvaluatedProjection); export!(EvaluationResult); } fn path(value: &str) -> VirtualPath { VirtualPath::parse(value).unwrap() } fn entry(path_value: &str, content: &str) -> ConfigEntry { ConfigEntry::new(path(path_value), ConfigContentType::Decodal, content).unwrap() } fn text_entry(path_value: &str, content: &str) -> ConfigEntry { ConfigEntry::new(path(path_value), ConfigContentType::Text, content).unwrap() } #[test] fn virtual_paths_reject_ambiguous_or_escaping_forms() { for invalid in ["", "/root.dcdl", "a//b", "a/./b", "a/../b", "a\\b", "a\0b"] { assert!(VirtualPath::parse(invalid).is_err(), "{invalid:?}"); } assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl"); } #[test] fn virtual_path_serde_shape_is_a_string() { let path = path("profiles/main.dcdl"); assert_eq!( serde_json::to_value(&path).unwrap(), serde_json::json!(path.as_str()) ); assert_eq!( serde_json::from_value::(serde_json::json!(path.as_str())).unwrap(), path ); } #[test] fn candidate_changes_are_atomic_ordered_and_conflict_checked() { let base = ConfigTreeSnapshot::from_entries( 7, [ entry("profiles/a.dcdl", "{ a = 1; }"), entry("shared.dcdl", "{}"), ], ) .unwrap(); let updated = base .apply(&[ ConfigTreeChange::Update { path: path("profiles/a.dcdl"), expected_digest: base.entries[&path("profiles/a.dcdl")] .content_digest .clone(), content: "{ a = 2; }".into(), }, ConfigTreeChange::Rename { from: path("shared.dcdl"), to: path("lib/shared.dcdl"), expected_digest: base.entries[&path("shared.dcdl")].content_digest.clone(), }, ]) .unwrap(); assert_eq!( updated .entries .keys() .map(VirtualPath::as_str) .collect::>(), ["lib/shared.dcdl", "profiles/a.dcdl"] ); assert_ne!(updated.digest, base.digest); assert!(matches!( base.apply(&[ConfigTreeChange::Delete { path: path("shared.dcdl"), expected_digest: "sha256:stale".into(), }]), Err(ConfigTreeError::EntryConflict(_)) )); } #[test] fn snapshot_digest_is_deterministic() { let left = ConfigTreeSnapshot::from_entries( 1, [entry("z.dcdl", "{}"), entry("a.dcdl", "{ x = 1; }")], ) .unwrap(); let right = ConfigTreeSnapshot::from_entries( 99, [entry("a.dcdl", "{ x = 1; }"), entry("z.dcdl", "{}")], ) .unwrap(); assert_eq!(left.digest, right.digest); } #[test] fn entrypoint_can_assert_empty_workspace_schema_global() { let snapshot = ConfigTreeSnapshot::from_entries( 1, [entry( "main.dcdl", "{ answer = 42; } as WorkspaceConfigSchema", )], ) .unwrap(); let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); SnapshotEnvironment::new(snapshot) .evaluate_contract(&contract) .unwrap(); } #[test] fn schema_completion_tracks_only_asserted_config_object_paths() { assert_eq!(config_field_completion_context("{ pro", 5), None); let root = "{ pro } as WorkspaceConfigSchema"; let root_cursor = root.find("pro").unwrap() + 3; assert_eq!( config_field_completion_context(root, root_cursor), Some(ConfigFieldCompletionContext { schema_path: vec!["pro".into()], from: root_cursor - 3, }) ); let nested = "{ profile = { def } } as WorkspaceConfigSchema"; let nested_cursor = nested.find("def").unwrap() + 3; assert_eq!( config_field_completion_context(nested, nested_cursor), Some(ConfigFieldCompletionContext { schema_path: vec!["profile".into(), "def".into()], from: nested_cursor - 3, }) ); let array = "{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema"; let array_cursor = array.find("sel").unwrap() + 3; assert_eq!( config_field_completion_context(array, array_cursor), Some(ConfigFieldCompletionContext { schema_path: vec!["profile".into(), "entries".into(), "sel".into()], from: array_cursor - 3, }) ); let value = "{ profile = \"default\" } as WorkspaceConfigSchema"; let value_cursor = value.find("default").unwrap() + 3; assert_eq!(config_field_completion_context(value, value_cursor), None); } #[test] fn completion_projects_workspace_schema_fields_into_config_objects() { let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:profile", "profile", "1", "{ profile = { default_profile = String; entries = [{ selector = String; }]; }; }", ) .unwrap()]) .unwrap(); let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema); let bare_source = "{ pro }"; let bare_cursor = bare_source.find("pro").unwrap() + 3; let bare = environment .complete_config(&path("main.dcdl"), bare_source, bare_cursor, true) .unwrap(); assert!( bare.is_none_or(|completion| !completion .items .iter() .any(|item| item.label == "profile")) ); let root_source = "{ pro } as WorkspaceConfigSchema"; let root_cursor = root_source.find("pro").unwrap() + 3; let root = environment .complete_config(&path("main.dcdl"), root_source, root_cursor, true) .unwrap() .unwrap(); assert_eq!(root.from, root_cursor - 3); assert!(root.items.iter().any(|item| item.label == "profile")); let nested_source = "{ profile = { def } } as WorkspaceConfigSchema"; let nested_cursor = nested_source.find("def").unwrap() + 3; let nested = environment .complete_config(&path("main.dcdl"), nested_source, nested_cursor, true) .unwrap() .unwrap(); assert_eq!(nested.from, nested_cursor - 3); assert!( nested .items .iter() .any(|item| item.label == "default_profile") ); } #[test] fn relative_imports_and_completion_share_the_snapshot_namespace() { let snapshot = ConfigTreeSnapshot::from_entries( 1, [ entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#), entry("shared/value.dcdl", "{ answer = 42; }"), entry("other.dcdl", "{}"), ], ) .unwrap(); assert_eq!( resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(), path("shared/value.dcdl") ); assert!(resolve_import(&path("main.dcdl"), "../escape.dcdl").is_err()); assert_eq!( import_completions(&snapshot, Some(&path("profiles/main.dcdl")), "../sh"), ["../shared/value.dcdl"] ); } #[test] fn schema_bundle_is_order_independent_and_rejects_duplicate_provider() { let web = ConfigSchemaContribution::new( "builtin:web", "web", "1", "{ web = { enabled = Bool default false; }; }", ) .unwrap(); let tickets = ConfigSchemaContribution::new( "builtin:tickets", "tickets", "1", "{ tickets = { enabled = Bool default true; }; }", ) .unwrap(); let left = WorkspaceConfigSchemaBundle::compose([web.clone(), tickets.clone()]).unwrap(); let right = WorkspaceConfigSchemaBundle::compose([tickets, web.clone()]).unwrap(); assert_eq!(left, right); assert!(matches!( WorkspaceConfigSchemaBundle::compose([web.clone(), web.clone()]), Err(ConfigTreeError::DuplicateSchemaProvider(provider)) if provider == "builtin:web" )); let conflicting_namespace = ConfigSchemaContribution::new( "project:web-extension", "web", "1", "{ web = { extension = true; }; }", ) .unwrap(); assert!(matches!( WorkspaceConfigSchemaBundle::compose([web.clone(), conflicting_namespace]), Err(ConfigTreeError::DuplicateSchemaNamespace(namespace)) if namespace == "web" )); } #[test] fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() { let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; }")]).unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", "1", "{ web = { enabled = Bool default false; }; }", ) .unwrap()]) .unwrap(); let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); let result = SnapshotEnvironment::new(snapshot) .evaluate_contract(&contract) .unwrap(); assert_eq!(result.projections[0].data_json["web"]["enabled"], false); } #[test] fn workspace_schema_rejects_unknown_root_and_nested_fields() { let schema = || { WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", "1", "{ web = { enabled = Bool default false; }; }", ) .unwrap()]) .unwrap() }; for (source, unknown_field) in [ ("{ web = {}; custom = 42; }", "custom"), ("{ web = { typo = true; }; }", "typo"), ] { let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::with_schema_bundle( 1, vec![path("main.dcdl")], 1, schema(), )) .unwrap_err(); assert_eq!(diagnostics[0].path, path("main.dcdl")); assert_eq!(diagnostics[0].kind, "constraintviolation"); assert!(diagnostics[0].message.contains(unknown_field)); assert!(diagnostics[0].span.end_byte > diagnostics[0].span.start_byte); } } #[test] fn workspace_schema_supports_typed_associative_collections() { let snapshot = ConfigTreeSnapshot::from_entries( 1, [entry( "main.dcdl", "{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }", )], ) .unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:features", "features", "1", "{ features = {...{ enabled = Bool; }}; }", ) .unwrap()]) .unwrap(); let result = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::with_schema_bundle( 1, vec![path("main.dcdl")], 1, schema, )) .unwrap(); assert_eq!( result.projections[0].data_json["features"]["web"]["enabled"], true ); assert_eq!( result.projections[0].data_json["features"]["tickets"]["enabled"], false ); } #[test] fn workspace_typed_associative_values_remain_closed_and_typed() { let schema = || { WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:features", "features", "1", "{ features = {...{ enabled = Bool; }}; }", ) .unwrap()]) .unwrap() }; for (source, expected_kind) in [ ( "{ features = { web = { enabled = \"yes\"; }; }; }", "constraintviolation", ), ("{ features = { web = {}; }; }", "materialize"), ( "{ features = { web = { enabled = true; typo = 1; }; }; }", "constraintviolation", ), ] { let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::with_schema_bundle( 1, vec![path("main.dcdl")], 1, schema(), )) .unwrap_err(); assert_eq!(diagnostics[0].path, path("main.dcdl")); assert_eq!(diagnostics[0].kind, expected_kind); assert!(diagnostics[0].span.end_byte > diagnostics[0].span.start_byte); } } #[test] fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() { let source = "{} as { features = {...{ enabled = Bool; }}; web = { enabled = Bool; ...Unknown }; }"; let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("schema.dcdl", source)]).unwrap(); let environment = SnapshotEnvironment::new(snapshot); let diagnostics = environment.analyze(&path("schema.dcdl"), None); assert!( diagnostics .iter() .all(|diagnostic| diagnostic.kind != "syntax"), "{diagnostics:#?}" ); let completion_source = "{} as { web = { enabled = Unk } }"; let completion = environment .complete( &path("schema.dcdl"), completion_source, completion_source.find("Unk").unwrap() + "Unk".len(), true, ) .unwrap() .expect("explicit completion is available"); assert!(format!("{completion:?}").contains("Unknown")); let formatted = environment.format(source).unwrap(); assert!(formatted.contains(" as ")); assert!(formatted.contains("...Unknown")); assert!( environment .analyze(&path("schema.dcdl"), Some(&formatted)) .iter() .all(|diagnostic| diagnostic.kind != "syntax") ); } #[test] fn workspace_schema_preserves_fields_only_where_rest_is_explicit() { let snapshot = ConfigTreeSnapshot::from_entries( 1, [entry( "main.dcdl", "{ web = { enabled = true; extension_value = 42; }; }", )], ) .unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", "1", "{ web = { enabled = Bool; ...Unknown }; }", ) .unwrap()]) .unwrap(); let result = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::with_schema_bundle( 1, vec![path("main.dcdl")], 1, schema, )) .unwrap(); assert_eq!( result.projections[0].data_json["web"]["extension_value"], 42 ); } #[test] fn unresolved_unknown_cannot_be_materialized() { let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .unwrap_err(); assert_eq!(diagnostics[0].path, path("main.dcdl")); assert!(!diagnostics[0].message.is_empty()); } #[test] fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() { let snapshot = ConfigTreeSnapshot::from_entries( 1, [entry("main.dcdl", "{ web = { enabled = 1; }; }")], ) .unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", "1", "{ web = { enabled = Bool default false; }; }", ) .unwrap()]) .unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::with_schema_bundle( 1, vec![path("main.dcdl")], 1, schema, )) .unwrap_err(); assert_eq!(diagnostics[0].kind, "constraintviolation"); assert!(!diagnostics[0].message.is_empty()); } #[test] fn schema_bundle_changes_toolchain_fingerprint() { let empty = ToolchainContract::new(1, vec![path("main.dcdl")], 1); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", "1", "{ web = {}; }", ) .unwrap()]) .unwrap(); let configured = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); assert_ne!(empty.fingerprint, configured.fingerprint); } #[test] fn snapshot_import_cache_key_binds_virtual_path_and_content_digest() { let first = text_entry("skills/debug-rust/SKILL.md", "first"); let second = text_entry("skills/debug-rust/SKILL.md", "second"); let first_key = snapshot_import_cache_key(&first); assert!(first_key.starts_with("skills/debug-rust/SKILL.md@sha256:")); assert!(first_key.ends_with(&first.content_digest)); assert_ne!(first_key, snapshot_import_cache_key(&second)); } #[test] fn markdown_import_projects_frontmatter_and_content_as_a_value() { let markdown = concat!( "---\n", "name: debug-rust\n", "description: Debug Rust failures\n", "custom-authority: no\n", "allowed-tools: Read Grep\n", "metadata:\n owner: platform\n", "---\n", "# Debug Rust\n", ); let snapshot = ConfigTreeSnapshot::from_entries( 3, [ entry( "main.dcdl", r#"{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }"#, ), text_entry("skills/debug-rust/SKILL.md", markdown), ], ) .unwrap(); let result = SnapshotEnvironment::new(snapshot.clone()) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .unwrap(); let skill = &result.projections[0].data_json["skill"]; assert_eq!(skill["frontmatter"]["name"], "debug-rust"); assert_eq!(skill["frontmatter"]["custom-authority"], "no"); assert_eq!(skill["frontmatter"]["allowed-tools"], "Read Grep"); assert_eq!(skill["frontmatter"]["metadata"]["owner"], "platform"); assert_eq!(skill["content"], "# Debug Rust\n"); assert_eq!( snapshot.entries[&path("skills/debug-rust/SKILL.md")].content_digest, digest_bytes(markdown.as_bytes()) ); } #[test] fn markdown_import_without_frontmatter_preserves_the_whole_body() { let source = "# Plain skill\nKeep --- inside the body.\n"; assert_eq!( project_markdown_document(source).unwrap(), MarkdownDocumentProjection { frontmatter: serde_json::Map::new(), content: source.to_string(), } ); let snapshot = ConfigTreeSnapshot::from_entries( 1, [ entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#), text_entry("skills/plain/SKILL.md", source), ], ) .unwrap(); let result = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .unwrap(); assert_eq!( result.projections[0].data_json["frontmatter"], serde_json::json!({}) ); assert_eq!(result.projections[0].data_json["content"], source); } #[test] fn malformed_markdown_frontmatter_is_an_import_diagnostic() { assert_eq!( project_markdown_document("---\nname: missing-close\nbody\n").unwrap_err(), "opening YAML frontmatter delimiter has no closing delimiter" ); let snapshot = ConfigTreeSnapshot::from_entries( 1, [ entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#), text_entry( "skills/broken/SKILL.md", "---\nname: [unterminated\n---\nbody\n", ), ], ) .unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) .unwrap_err(); assert_eq!(diagnostics[0].kind, "import"); assert!(diagnostics[0].message.contains("invalid YAML frontmatter")); assert!(diagnostics[0].message.contains("skills/broken/SKILL.md")); } #[test] fn host_environment_evaluation_uses_only_snapshot_imports() { let snapshot = ConfigTreeSnapshot::from_entries( 3, [ entry("profiles/main.dcdl", r#"import "./shared.dcdl""#), entry("profiles/shared.dcdl", "{ answer = 42; }"), ], ) .unwrap(); let contract = ToolchainContract::new( DEFAULT_SCHEMA_VERSION, vec![path("profiles/main.dcdl")], DEFAULT_IMPORT_POLICY_VERSION, ); let result = SnapshotEnvironment::new(snapshot) .evaluate_contract(&contract) .unwrap(); assert_eq!(result.projections[0].data_json["answer"], 42); } #[test] fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() { let snapshot = ConfigTreeSnapshot::from_entries( 1, [ entry("workspace.dcdl", "{ answer = 42; }"), entry("unused.dcdl", "{ broken = ; }"), ], ) .unwrap(); let diagnostics = SnapshotEnvironment::new(snapshot) .evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1)) .unwrap_err(); assert_eq!(diagnostics[0].path, path("unused.dcdl")); assert_eq!(diagnostics[0].kind, "syntax"); } #[test] fn missing_import_and_cycles_are_structured_failures() { let missing = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", r#"import "./missing.dcdl""#)]) .unwrap(); let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); let diagnostics = SnapshotEnvironment::new(missing) .evaluate_contract(&contract) .unwrap_err(); assert_eq!(diagnostics[0].kind, "import"); let cycle = ConfigTreeSnapshot::from_entries( 1, [ entry("a.dcdl", r#"import "./b.dcdl""#), entry("b.dcdl", r#"import "./a.dcdl""#), ], ) .unwrap(); let diagnostics = SnapshotEnvironment::new(cycle) .evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1)) .unwrap_err(); assert_eq!(diagnostics[0].kind, "cycle"); } }