feat: register Workspace Profiles as Decodal values (T-710)
Project the evaluated Workspace Profile value into a revision-bound JSON archive without source-path registration or Decodal re-evaluation. Preserve partial-value omissions and explicit overrides, validate before save, and reject legacy registrations with a documented explicit migration.
Fix array-element schema completion and provide fingerprint-bound authoring shapes independently of materialization defaults. Carry metadata through REST, regenerate TypeScript/OpenAPI/WASM artifacts, and exercise save/projection/Runtime consumption and persisted Manifest restore boundaries.
Validation: root cargo check; cargo test -p config-source; cargo test -p worker-runtime; cargo test -p server-api --features typescript; focused profile_settings/value_profiles/archive tests; 39 web config-source/profile API tests; deno task check; cargo fmt --all -- --check; git diff --check HEAD.
Full workspace-server test: 641 passed, one pre-existing ticket_checker_suppresses_stale_and_clean_results_and_diagnoses_invalid_output assertion failure. The same filter fails at base 3c424fd17d in a separately archived source tree and isolated target directory. Remaining server crate tests pass with only that filter skipped (641 lib + 11 bin); unrelated checker code is unchanged.
This commit is contained in:
+651
-37
@@ -487,6 +487,9 @@ pub struct ConfigSchemaContribution {
|
||||
pub namespace: String,
|
||||
pub version: String,
|
||||
pub source: String,
|
||||
// Optional editor-only schema; never used to validate or materialize config.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub authoring_source: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub projection_validator: Option<ConfigProjectionValidator>,
|
||||
pub source_digest: String,
|
||||
@@ -529,16 +532,31 @@ impl ConfigSchemaContribution {
|
||||
version,
|
||||
source_digest: digest_bytes(source.as_bytes()),
|
||||
source,
|
||||
authoring_source: None,
|
||||
projection_validator: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_authoring_source(mut self, source: impl Into<String>) -> Self {
|
||||
self.authoring_source = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_projection_validator(mut self, validator: ConfigProjectionValidator) -> Self {
|
||||
self.projection_validator = Some(validator);
|
||||
self
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), ConfigTreeError> {
|
||||
if self
|
||||
.authoring_source
|
||||
.as_ref()
|
||||
.is_some_and(|source| source.trim().is_empty())
|
||||
{
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(
|
||||
"authoring schema source must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let expected = digest_bytes(self.source.as_bytes());
|
||||
if self.source_digest != expected {
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(format!(
|
||||
@@ -591,27 +609,41 @@ impl WorkspaceConfigSchemaBundle {
|
||||
.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(),
|
||||
);
|
||||
let mut fingerprint_input = 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");
|
||||
let authoring_sources = contributions
|
||||
.iter()
|
||||
.filter_map(|contribution| {
|
||||
contribution
|
||||
.authoring_source
|
||||
.as_deref()
|
||||
.map(|source| (contribution.provider_id.as_str(), source))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// Preserve legacy fingerprints when no editor-only metadata is present.
|
||||
if !authoring_sources.is_empty() {
|
||||
fingerprint_input.extend(
|
||||
serde_json::to_vec(&("authoring_sources", authoring_sources))
|
||||
.expect("authoring schema fingerprint input serializes"),
|
||||
);
|
||||
}
|
||||
let fingerprint = digest_bytes(&fingerprint_input);
|
||||
Ok(Self {
|
||||
contributions,
|
||||
source,
|
||||
@@ -619,6 +651,25 @@ impl WorkspaceConfigSchemaBundle {
|
||||
})
|
||||
}
|
||||
|
||||
fn authoring_source(&self) -> String {
|
||||
if self.contributions.is_empty() {
|
||||
return self.source.clone();
|
||||
}
|
||||
self.contributions
|
||||
.iter()
|
||||
.map(|contribution| {
|
||||
format!(
|
||||
"({})",
|
||||
contribution
|
||||
.authoring_source
|
||||
.as_deref()
|
||||
.unwrap_or(&contribution.source)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ")
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self::compose(Vec::new()).expect("empty schema bundle is valid")
|
||||
}
|
||||
@@ -751,26 +802,32 @@ pub struct EvaluationResult {
|
||||
pub projection_digest: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ConfigCompletionPathSegment {
|
||||
Field(String),
|
||||
Index(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ConfigFieldCompletionContext {
|
||||
schema_path: Vec<String>,
|
||||
schema_path: Vec<ConfigCompletionPathSegment>,
|
||||
from: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ConfigCompletionContainer {
|
||||
Object {
|
||||
schema_path: Vec<String>,
|
||||
schema_path: Vec<ConfigCompletionPathSegment>,
|
||||
pending_path: Vec<String>,
|
||||
last_identifier_from: Option<usize>,
|
||||
trailing_dot: bool,
|
||||
reading_value: bool,
|
||||
},
|
||||
Array {
|
||||
schema_path: Vec<String>,
|
||||
schema_path: Vec<ConfigCompletionPathSegment>,
|
||||
},
|
||||
Other {
|
||||
schema_path: Vec<String>,
|
||||
schema_path: Vec<ConfigCompletionPathSegment>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -855,9 +912,18 @@ fn config_field_completion_context(
|
||||
});
|
||||
}
|
||||
SyntaxTokenKind::LBracket => {
|
||||
let schema_path = pending_container_path(&containers);
|
||||
let mut schema_path = pending_container_path(&containers);
|
||||
schema_path.push(ConfigCompletionPathSegment::Index(0));
|
||||
containers.push(ConfigCompletionContainer::Array { schema_path });
|
||||
}
|
||||
SyntaxTokenKind::Comma => {
|
||||
if let Some(ConfigCompletionContainer::Array { schema_path }) =
|
||||
containers.last_mut()
|
||||
&& let Some(ConfigCompletionPathSegment::Index(index)) = schema_path.last_mut()
|
||||
{
|
||||
*index += 1;
|
||||
}
|
||||
}
|
||||
SyntaxTokenKind::RBracket => {
|
||||
pop_container(&mut containers, |container| {
|
||||
matches!(container, ConfigCompletionContainer::Array { .. })
|
||||
@@ -943,7 +1009,12 @@ fn config_field_completion_context(
|
||||
return None;
|
||||
};
|
||||
let mut schema_path = schema_path.clone();
|
||||
schema_path.extend(pending_path.iter().cloned());
|
||||
schema_path.extend(
|
||||
pending_path
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(ConfigCompletionPathSegment::Field),
|
||||
);
|
||||
Some(ConfigFieldCompletionContext {
|
||||
schema_path,
|
||||
from: if *trailing_dot {
|
||||
@@ -954,14 +1025,25 @@ fn config_field_completion_context(
|
||||
})
|
||||
}
|
||||
|
||||
fn pending_container_path(containers: &[ConfigCompletionContainer]) -> Vec<String> {
|
||||
fn pending_container_path(
|
||||
containers: &[ConfigCompletionContainer],
|
||||
) -> Vec<ConfigCompletionPathSegment> {
|
||||
match containers.last() {
|
||||
Some(ConfigCompletionContainer::Object {
|
||||
schema_path,
|
||||
pending_path,
|
||||
reading_value: true,
|
||||
..
|
||||
}) => schema_path.iter().chain(pending_path).cloned().collect(),
|
||||
}) => schema_path
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
pending_path
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(ConfigCompletionPathSegment::Field),
|
||||
)
|
||||
.collect(),
|
||||
Some(ConfigCompletionContainer::Array { schema_path })
|
||||
| Some(ConfigCompletionContainer::Other { schema_path }) => schema_path.clone(),
|
||||
_ => Vec::new(),
|
||||
@@ -1201,12 +1283,37 @@ impl SnapshotEnvironment {
|
||||
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("."));
|
||||
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
|
||||
member_source.push('.');
|
||||
let (schema_path, prefix) = if context.from == utf8_byte_offset {
|
||||
(context.schema_path.as_slice(), "")
|
||||
} else {
|
||||
let Some((ConfigCompletionPathSegment::Field(prefix), schema_path)) =
|
||||
context.schema_path.split_last()
|
||||
else {
|
||||
return self.complete(entrypoint, source, utf8_byte_offset, explicit);
|
||||
};
|
||||
(schema_path, prefix.as_str())
|
||||
};
|
||||
let has_array = schema_path
|
||||
.iter()
|
||||
.any(|segment| matches!(segment, ConfigCompletionPathSegment::Index(_)));
|
||||
let mut member_source = WORKSPACE_CONFIG_SCHEMA_GLOBAL.to_owned();
|
||||
// Ordinary object paths retain the language service's member traversal,
|
||||
// but both branches install the composed authoring schema.
|
||||
if !has_array {
|
||||
for segment in schema_path {
|
||||
if let ConfigCompletionPathSegment::Field(field) = segment {
|
||||
member_source.push('.');
|
||||
member_source.push_str(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut completion = LanguageService::new(self).complete(
|
||||
member_source.push('.');
|
||||
member_source.push_str(prefix);
|
||||
let completion_environment = ConfigFieldCompletionEnvironment {
|
||||
environment: self,
|
||||
schema_path: if has_array { schema_path } else { &[] },
|
||||
};
|
||||
let mut completion = LanguageService::new(completion_environment).complete(
|
||||
entrypoint.as_str(),
|
||||
&member_source,
|
||||
member_source.len(),
|
||||
@@ -1270,6 +1377,91 @@ impl HostEnvironment for &SnapshotEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
// Decodal has no indexed member syntax. Resolve array ranges only in this
|
||||
// completion environment, then reuse the language service's member behavior.
|
||||
struct ConfigFieldCompletionEnvironment<'a> {
|
||||
environment: &'a SnapshotEnvironment,
|
||||
schema_path: &'a [ConfigCompletionPathSegment],
|
||||
}
|
||||
|
||||
impl HostEnvironment for ConfigFieldCompletionEnvironment<'_> {
|
||||
type Loader = SnapshotImportLoader;
|
||||
|
||||
fn create_loader(&self) -> Self::Loader {
|
||||
self.environment.create_loader()
|
||||
}
|
||||
|
||||
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> decodal::Result<()> {
|
||||
use decodal::runtime::{ConcreteValue, ObjectField, ObjectValue, RuntimeValue};
|
||||
|
||||
let Some(bundle) = &self.environment.schema_bundle else {
|
||||
return Ok(());
|
||||
};
|
||||
let module = engine.add_root_source(
|
||||
WORKSPACE_CONFIG_SCHEMA_SOURCE,
|
||||
WORKSPACE_CONFIG_SCHEMA_SOURCE,
|
||||
&bundle.authoring_source(),
|
||||
)?;
|
||||
let mut schema = Some(engine.eval_module(module)?);
|
||||
for segment in self.schema_path {
|
||||
let Some(value) = schema else {
|
||||
break;
|
||||
};
|
||||
schema = match segment {
|
||||
ConfigCompletionPathSegment::Field(field) => engine
|
||||
.value_fields(&value)?
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|(name, _)| name == field)
|
||||
.map(|(_, value)| value),
|
||||
ConfigCompletionPathSegment::Index(index) => {
|
||||
let element = match value {
|
||||
RuntimeValue::Concrete(ConcreteValue::Array(elements)) => {
|
||||
elements.get(*index).copied()
|
||||
}
|
||||
RuntimeValue::Abstract(range) => {
|
||||
range.constraints.iter().find_map(|entry| {
|
||||
if let decodal::Constraint::ArrayItems(element) = entry.constraint {
|
||||
Some(element)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(element) = element {
|
||||
// value_fields is the public API for forcing lazy fields.
|
||||
// Wrap the element thunk without materializing its schema.
|
||||
let object = RuntimeValue::Concrete(ConcreteValue::Object(ObjectValue {
|
||||
fields: vec![ObjectField {
|
||||
name: "element".into(),
|
||||
value: element,
|
||||
span: Span::default(),
|
||||
}],
|
||||
rest: None,
|
||||
}));
|
||||
engine
|
||||
.value_fields(&object)?
|
||||
.and_then(|fields| fields.into_iter().next().map(|(_, value)| value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(schema) = schema {
|
||||
engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema);
|
||||
} else {
|
||||
engine.bind_global(
|
||||
WORKSPACE_CONFIG_SCHEMA_GLOBAL,
|
||||
Value::object([] as [(&str, Value); 0]),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SnapshotImportLoader {
|
||||
snapshot: ConfigTreeSnapshot,
|
||||
@@ -1890,7 +2082,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
config_field_completion_context(root, root_cursor),
|
||||
Some(ConfigFieldCompletionContext {
|
||||
schema_path: vec!["pro".into()],
|
||||
schema_path: vec![ConfigCompletionPathSegment::Field("pro".into())],
|
||||
from: root_cursor - 3,
|
||||
})
|
||||
);
|
||||
@@ -1899,7 +2091,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
config_field_completion_context(nested, nested_cursor),
|
||||
Some(ConfigFieldCompletionContext {
|
||||
schema_path: vec!["profile".into(), "def".into()],
|
||||
schema_path: vec![
|
||||
ConfigCompletionPathSegment::Field("profile".into()),
|
||||
ConfigCompletionPathSegment::Field("def".into()),
|
||||
],
|
||||
from: nested_cursor - 3,
|
||||
})
|
||||
);
|
||||
@@ -1908,7 +2103,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
config_field_completion_context(array, array_cursor),
|
||||
Some(ConfigFieldCompletionContext {
|
||||
schema_path: vec!["profile".into(), "entries".into(), "sel".into()],
|
||||
schema_path: vec![
|
||||
ConfigCompletionPathSegment::Field("profile".into()),
|
||||
ConfigCompletionPathSegment::Field("entries".into()),
|
||||
ConfigCompletionPathSegment::Index(0),
|
||||
ConfigCompletionPathSegment::Field("sel".into()),
|
||||
],
|
||||
from: array_cursor - 3,
|
||||
})
|
||||
);
|
||||
@@ -1991,6 +2191,420 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn profile_entries_completion_schema() -> WorkspaceConfigSchemaBundle {
|
||||
WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:profile",
|
||||
"profile",
|
||||
"1",
|
||||
"{ profile = { default_profile = String; entries = [...{ selector = String; profile = { slug = String; worker = { mode = String; }; }; }]; }; }",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_projects_array_element_and_nested_profile_fields() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
|
||||
let environment = SnapshotEnvironment::new(snapshot)
|
||||
.with_schema_bundle(profile_entries_completion_schema());
|
||||
for (marked_source, expected, prefix_len) in [
|
||||
(
|
||||
"{ profile.entries = [{ sel| }] } as WorkspaceConfigSchema",
|
||||
"selector",
|
||||
3,
|
||||
),
|
||||
(
|
||||
"{ profile = { entries = [{ pro| }] } } as WorkspaceConfigSchema",
|
||||
"profile",
|
||||
3,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
|
||||
"selector",
|
||||
0,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
|
||||
"profile",
|
||||
0,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ profile = { slu| } }] } as WorkspaceConfigSchema",
|
||||
"slug",
|
||||
3,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ profile = { | } }] } as WorkspaceConfigSchema",
|
||||
"worker",
|
||||
0,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ profile.worker.mo| }] } as WorkspaceConfigSchema",
|
||||
"mode",
|
||||
2,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ profile.worker.| }] } as WorkspaceConfigSchema",
|
||||
"mode",
|
||||
0,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ profile = { worker = { mo| } } }] } as WorkspaceConfigSchema",
|
||||
"mode",
|
||||
2,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{}, { sel| }] } as WorkspaceConfigSchema",
|
||||
"selector",
|
||||
3,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ selector = \"日本語\"; profile = { slu|g } }] } as WorkspaceConfigSchema",
|
||||
"slug",
|
||||
3,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ pro|file }] } as WorkspaceConfigSchema",
|
||||
"profile",
|
||||
3,
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [({ profile = { worker = { mo| } } })] } as WorkspaceConfigSchema",
|
||||
"mode",
|
||||
2,
|
||||
),
|
||||
] {
|
||||
let cursor = marked_source.find('|').unwrap();
|
||||
let source = marked_source.replace('|', "");
|
||||
let completion = environment
|
||||
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
|
||||
assert_eq!(completion.from, cursor - prefix_len, "{marked_source}");
|
||||
assert!(
|
||||
completion.items.iter().any(|item| item.label == expected),
|
||||
"{marked_source}: {completion:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_tracks_fixed_array_positions_and_nested_array_ranges() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:profile",
|
||||
"profile",
|
||||
"1",
|
||||
"{ profile = { entries = [{ selector = String; }, { profile = { slug = String; worker = { mode = String; }; }; }]; matrix = [...[...{ selector = String; }]]; }; }",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
|
||||
for (marked_source, expected, unexpected) in [
|
||||
(
|
||||
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
|
||||
"selector",
|
||||
"profile",
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{}, { | }] } as WorkspaceConfigSchema",
|
||||
"profile",
|
||||
"selector",
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{}, { profile.worker.mo| }] } as WorkspaceConfigSchema",
|
||||
"mode",
|
||||
"selector",
|
||||
),
|
||||
(
|
||||
"{ profile.matrix = [[{}, { sel| }]] } as WorkspaceConfigSchema",
|
||||
"selector",
|
||||
"profile",
|
||||
),
|
||||
(
|
||||
"{ profile.matrix = [[], [{ sel| }]] } as WorkspaceConfigSchema",
|
||||
"selector",
|
||||
"profile",
|
||||
),
|
||||
] {
|
||||
let cursor = marked_source.find('|').unwrap();
|
||||
let source = marked_source.replace('|', "");
|
||||
let completion = environment
|
||||
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
|
||||
assert!(
|
||||
completion.items.iter().any(|item| item.label == expected),
|
||||
"{marked_source}: {completion:?}"
|
||||
);
|
||||
assert!(
|
||||
!completion.items.iter().any(|item| item.label == unexpected),
|
||||
"{marked_source}: {completion:?}"
|
||||
);
|
||||
}
|
||||
for marked_source in [
|
||||
"{ profile.entries = [{}, {}, { | }] } as WorkspaceConfigSchema",
|
||||
"{ profile.entries = [{ missing = { | } }] } as WorkspaceConfigSchema",
|
||||
] {
|
||||
let cursor = marked_source.find('|').unwrap();
|
||||
let source = marked_source.replace('|', "");
|
||||
let completion = environment
|
||||
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||
.unwrap();
|
||||
assert!(
|
||||
completion.is_none_or(|completion| !completion
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| matches!(item.label.as_str(), "selector" | "profile"))),
|
||||
"{marked_source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_preserves_general_schema_member_behavior() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
|
||||
let environment = SnapshotEnvironment::new(snapshot)
|
||||
.with_schema_bundle(profile_entries_completion_schema());
|
||||
for source in [
|
||||
"WorkspaceConfigSchema.pro",
|
||||
"WorkspaceConfigSchema.profile.",
|
||||
"WorkspaceConfigSchema.profile.entries.",
|
||||
"WorkspaceConfigSchema.profile.default_pro",
|
||||
"let schema = WorkspaceConfigSchema; in schema.profile.def",
|
||||
] {
|
||||
assert_eq!(
|
||||
environment
|
||||
.complete_config(&path("main.dcdl"), source, source.len(), true)
|
||||
.unwrap(),
|
||||
environment
|
||||
.complete(&path("main.dcdl"), source, source.len(), true)
|
||||
.unwrap(),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_array_schema_retains_nested_profile_diagnostics() {
|
||||
let valid = "{ profile = { default_profile = \"default\"; entries = [{ selector = \"default\"; profile = { slug = \"default\"; worker = { mode = \"interactive\"; }; }; }]; }; } as WorkspaceConfigSchema";
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(7, [entry("main.dcdl", valid)]).unwrap();
|
||||
let environment = SnapshotEnvironment::new(snapshot)
|
||||
.with_schema_bundle(profile_entries_completion_schema());
|
||||
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
|
||||
for (source, field) in [
|
||||
(
|
||||
valid.replace("selector = \"default\"", "selector = 42"),
|
||||
"selector",
|
||||
),
|
||||
(valid.replace("slug = \"default\"", "slug = 42"), "slug"),
|
||||
(valid.replace("mode = \"interactive\"", "mode = 42"), "mode"),
|
||||
(
|
||||
valid.replace("mode = \"interactive\"", "typo = \"interactive\""),
|
||||
"typo",
|
||||
),
|
||||
] {
|
||||
let diagnostics = environment.analyze(&path("main.dcdl"), Some(&source));
|
||||
assert!(
|
||||
diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.kind == "constraint_violation"
|
||||
&& diagnostic.path == path("main.dcdl")
|
||||
&& diagnostic.revision == 7
|
||||
&& diagnostic.span.end_byte > diagnostic.span.start_byte
|
||||
&& (diagnostic.message.contains(field)
|
||||
|| diagnostic
|
||||
.labels
|
||||
.iter()
|
||||
.any(|label| label.message.contains(field)))
|
||||
}),
|
||||
"{field}: {diagnostics:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_authoring_schema_preserves_omitted_profile_fields_in_evaluation() {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:profile",
|
||||
"profile",
|
||||
"1",
|
||||
"{ profile = { defaults = {...Unknown}; entries = [...{ selector = String; profile = {...Unknown}; }]; }; }",
|
||||
)
|
||||
.unwrap()
|
||||
.with_authoring_source(
|
||||
"{ profile = { defaults = { slug = String; worker = { mode = String; }; }; entries = [...{ selector = String; profile = { slug = String; worker = { mode = String; }; }; }]; }; }",
|
||||
)])
|
||||
.unwrap();
|
||||
let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
|
||||
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
|
||||
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
|
||||
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
|
||||
let evaluated = environment.evaluate_contract(&contract).unwrap();
|
||||
assert_eq!(
|
||||
evaluated.projections[0].data_json,
|
||||
serde_json::json!({"profile": {"defaults": {}, "entries": [{"selector": "default", "profile": {}}]}})
|
||||
);
|
||||
// Both ordinary object paths and array-element paths use editor metadata.
|
||||
for (marked_source, expected) in [
|
||||
(
|
||||
"{ profile.defaults = { slu| } } as WorkspaceConfigSchema",
|
||||
"slug",
|
||||
),
|
||||
(
|
||||
"{ profile.defaults.worker = { mo| } } as WorkspaceConfigSchema",
|
||||
"mode",
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ pro| }] } as WorkspaceConfigSchema",
|
||||
"profile",
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ profile = { slu| } }] } as WorkspaceConfigSchema",
|
||||
"slug",
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ profile.worker = { mo| } }] } as WorkspaceConfigSchema",
|
||||
"mode",
|
||||
),
|
||||
(
|
||||
"{ profile.entries = [{ profile = { | } }] } as WorkspaceConfigSchema",
|
||||
"worker",
|
||||
),
|
||||
] {
|
||||
let cursor = marked_source.find('|').unwrap();
|
||||
let source = marked_source.replace('|', "");
|
||||
let completion = environment
|
||||
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
|
||||
assert!(
|
||||
completion.items.iter().any(|item| item.label == expected),
|
||||
"{marked_source}: {completion:?}"
|
||||
);
|
||||
}
|
||||
// Completion does not install its schema into later analysis/evaluation.
|
||||
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
|
||||
assert_eq!(environment.evaluate_contract(&contract).unwrap(), evaluated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_authoring_sources_compose_with_validation_source_fallbacks() {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([
|
||||
ConfigSchemaContribution::new(
|
||||
"builtin:editor",
|
||||
"editor",
|
||||
"1",
|
||||
"{ editor = {...Unknown}; }",
|
||||
)
|
||||
.unwrap()
|
||||
.with_authoring_source(
|
||||
"{ editor = { theme = String; settings = { enabled = Bool; }; }; }",
|
||||
),
|
||||
ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = { enabled = Bool; }; }",
|
||||
)
|
||||
.unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
|
||||
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
|
||||
for (marked_source, expected) in [
|
||||
("{ ed| } as WorkspaceConfigSchema", "editor"),
|
||||
("{ editor = { th| } } as WorkspaceConfigSchema", "theme"),
|
||||
(
|
||||
"{ editor.settings = { en| } } as WorkspaceConfigSchema",
|
||||
"enabled",
|
||||
),
|
||||
("{ web = { en| } } as WorkspaceConfigSchema", "enabled"),
|
||||
] {
|
||||
let cursor = marked_source.find('|').unwrap();
|
||||
let source = marked_source.replace('|', "");
|
||||
let completion = environment
|
||||
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
completion.items.iter().any(|item| item.label == expected),
|
||||
"{marked_source}: {completion:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoring_schema_metadata_is_optional_validated_and_fingerprinted() {
|
||||
let contribution = ConfigSchemaContribution::new(
|
||||
"builtin:editor",
|
||||
"editor",
|
||||
"1",
|
||||
"{ editor = {...Unknown}; }",
|
||||
)
|
||||
.unwrap();
|
||||
let serialized = serde_json::to_value(&contribution).unwrap();
|
||||
assert!(serialized.get("authoring_source").is_none());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ConfigSchemaContribution>(serialized).unwrap(),
|
||||
contribution
|
||||
);
|
||||
let legacy = WorkspaceConfigSchemaBundle::compose([contribution.clone()]).unwrap();
|
||||
let legacy_input = serde_json::to_vec(&(
|
||||
CONFIG_SOURCE_CONTRACT_VERSION,
|
||||
DECODAL_VERSION,
|
||||
vec![(
|
||||
contribution.provider_id.as_str(),
|
||||
contribution.namespace.as_str(),
|
||||
contribution.version.as_str(),
|
||||
contribution.source_digest.as_str(),
|
||||
contribution.projection_validator.as_ref(),
|
||||
)],
|
||||
digest_bytes(legacy.source.as_bytes()),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(legacy.fingerprint, digest_bytes(&legacy_input));
|
||||
legacy.validate().unwrap();
|
||||
|
||||
let authoring = contribution
|
||||
.clone()
|
||||
.with_authoring_source("{ editor = { theme = String; }; }");
|
||||
assert_eq!(authoring.source_digest, contribution.source_digest);
|
||||
let serialized = serde_json::to_value(&authoring).unwrap();
|
||||
assert_eq!(
|
||||
serialized["authoring_source"],
|
||||
"{ editor = { theme = String; }; }"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ConfigSchemaContribution>(serialized).unwrap(),
|
||||
authoring
|
||||
);
|
||||
let authored = WorkspaceConfigSchemaBundle::compose([authoring]).unwrap();
|
||||
authored.validate().unwrap();
|
||||
assert_eq!(authored.source, legacy.source);
|
||||
assert_ne!(authored.fingerprint, legacy.fingerprint);
|
||||
assert_ne!(
|
||||
ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, authored.clone())
|
||||
.fingerprint,
|
||||
ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, legacy)
|
||||
.fingerprint,
|
||||
);
|
||||
let changed = WorkspaceConfigSchemaBundle::compose([contribution
|
||||
.clone()
|
||||
.with_authoring_source("{ editor = { theme = String; mode = String; }; }")])
|
||||
.unwrap();
|
||||
assert_ne!(changed.fingerprint, authored.fingerprint);
|
||||
let mut tampered = authored;
|
||||
tampered.contributions[0].authoring_source =
|
||||
changed.contributions[0].authoring_source.clone();
|
||||
assert!(tampered.validate().is_err());
|
||||
for source in ["", " \n\t "] {
|
||||
assert!(
|
||||
matches!(WorkspaceConfigSchemaBundle::compose([contribution.clone().with_authoring_source(source)]), Err(ConfigTreeError::InvalidSchemaContribution(message)) if message.contains("authoring"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_imports_and_completion_share_the_snapshot_namespace() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
|
||||
Reference in New Issue
Block a user