Files
yoi/crates/config-source/src/lib.rs
T
2026-08-20 15:16:29 +09:00

2438 lines
85 KiB
Rust

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<str>) -> Result<Self, ConfigTreeError> {
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::<Vec<_>>();
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<String, serde_json::Value>,
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<MarkdownDocumentProjection, String> {
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::<serde_yaml::Value>(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<serde_json::Value, String> {
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::<Result<Vec<_>, _>>()
.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<Value, String> {
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<Value, String> {
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::<Result<Vec<_>, _>>()
.map(Value::array),
serde_json::Value::Object(values) => values
.into_iter()
.map(|(name, value)| Ok((name, json_to_decodal_value(value)?)))
.collect::<Result<Vec<_>, _>>()
.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<String>,
) -> Result<Self, ConfigTreeError> {
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<VirtualPath, ConfigEntry>,
}
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<Item = ConfigEntry>,
) -> Result<Self, ConfigTreeError> {
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<ConfigTreeChange> {
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<Self, ConfigTreeError> {
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 &current.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 &current.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 &current.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<String, String>,
},
}
#[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<ConfigProjectionValidator>,
pub source_digest: String,
}
impl ConfigSchemaContribution {
pub fn new(
provider_id: impl Into<String>,
namespace: impl Into<String>,
version: impl Into<String>,
source: impl Into<String>,
) -> Result<Self, ConfigTreeError> {
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<ConfigSchemaContribution>,
pub source: String,
pub fingerprint: String,
}
impl WorkspaceConfigSchemaBundle {
pub fn compose(
contributions: impl IntoIterator<Item = ConfigSchemaContribution>,
) -> Result<Self, ConfigTreeError> {
let mut contributions = contributions.into_iter().collect::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>(),
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<VirtualPath>,
pub import_policy_version: u32,
pub schema_bundle: WorkspaceConfigSchemaBundle,
pub fingerprint: String,
}
impl ToolchainContract {
pub fn new(
schema_version: u32,
entrypoints: Vec<VirtualPath>,
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<VirtualPath>,
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<ConfigDiagnosticLabel>,
pub notes: Vec<String>,
}
#[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<EvaluatedProjection>,
pub projection_digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfigFieldCompletionContext {
schema_path: Vec<String>,
from: usize,
}
#[derive(Debug)]
enum ConfigCompletionContainer {
Object {
schema_path: Vec<String>,
pending_path: Vec<String>,
last_identifier_from: Option<usize>,
trailing_dot: bool,
reading_value: bool,
},
Array {
schema_path: Vec<String>,
},
Other {
schema_path: Vec<String>,
},
}
fn workspace_schema_assertion_object_start(
tokens: &[SyntaxToken],
utf8_byte_offset: usize,
) -> Option<usize> {
let mut object_stack = Vec::<usize>::new();
let mut matching_assertions = Vec::<usize>::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<ConfigFieldCompletionContext> {
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::<ConfigCompletionContainer>::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<String> {
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<ConfigCompletionContainer>,
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<WorkspaceConfigSchemaBundle>,
}
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<EvaluationResult, Vec<ConfigDiagnostic>> {
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::<Vec<_>>()
})
.collect::<Vec<_>>();
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<ConfigDiagnostic> {
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<Option<CompletionResult>> {
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<Option<CompletionResult>> {
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<String, String> {
decodal_language_tools::format_source(source).map_err(|error| error.to_string())
}
fn config_error(
&self,
path: VirtualPath,
kind: impl Into<String>,
message: impl Into<String>,
) -> 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<Self::Loader>) -> 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<VirtualPath, ConfigTreeError> {
let current = current_key
.map(VirtualPath::parse)
.transpose()?
.ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?;
resolve_import(&current, specifier)
}
}
impl ImportLoader for SnapshotImportLoader {
fn load(
&mut self,
current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedImport> {
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<Vec<ImportCandidate>> {
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<VirtualPath, ConfigTreeError> {
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<String> {
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::<Vec<_>>();
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<SnapshotImportLoader>,
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<String, String>,
) -> 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() { "<root>" } else { prefix }
)),
}
}
pub fn validate_static_template_catalog(
templates: &BTreeMap<String, String>,
) -> 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<String, Vec<String>>,
visiting: &mut Vec<String>,
visited: &mut BTreeSet<String>,
) -> 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<Vec<String>, 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<VirtualPath, ConfigEntry>) -> 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::<VirtualPath>(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::<Vec<_>>(),
["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");
}
}