use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use crate::game::eu4::coercion::{alias_reads_three_decimals, alias_value_reads_three_decimals};
use crate::game::eu4::editor::position::{byte_offset, range_from_span};
use crate::game::eu4::script::is_clausewitz_date_key;
use crate::game::eu4::script::parser::{
AstStatement, AstValue, ScalarValue, ScriptSyntax, SpanRange, parse_clausewitz_statements,
unquoted_scalar,
};
use crate::game::schema::query::{
CompiledAlias, CompiledAliasCategory, CompiledBindFieldMatch, CompiledFieldAttributes,
CompiledLink, CompiledRoot, CompiledRuleCondition, CompiledRuleField, CompiledRuleOrigin,
CompiledRuleValue, CompiledSeverity, CompiledSubtype, CwtQuery, RuleContext, SchemaScalarType,
parse_schema_marker,
};
use crate::merge::semantic_fingerprint::value_fingerprint;
use crate::model::{GamePath, LocalisationDefinition, Severity};
use super::{
EditorPosition, EditorRange, SchemaCompletion, SchemaCompletionKind, SchemaDiagnostic,
SchemaHover, SchemaWorkspace,
};
const CWTOOLS_SOURCE: &str = "cwtools";
const FOCH_CWTOOLS_SOURCE: &str = "foch-cwtools";
const FOCH_DETECT_SOURCE: &str = "foch-detect";
fn rule_source(origin: CompiledRuleOrigin) -> &'static str {
match origin {
CompiledRuleOrigin::Base => CWTOOLS_SOURCE,
CompiledRuleOrigin::Extension => FOCH_CWTOOLS_SOURCE,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct KeyPathTarget {
parent_path: Vec<String>,
key: String,
range: EditorRange,
}
pub(super) fn schema_hover(
engine: &CwtQuery,
file_path: &GamePath,
text: &str,
position: EditorPosition,
dynamic_values: Option<&SchemaWorkspace>,
) -> Option<SchemaHover> {
let offset = byte_offset(text, position)?;
let parsed = parse_clausewitz_statements(ScriptSyntax::for_game_path(file_path), text);
let target = find_hover_target(&parsed.statements, text, offset, &[])?;
let document = schema_document_at_position(engine, file_path, &parsed.statements, position);
let parent_path = target
.parent_path
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let parent_context =
schema_bind_context(engine, dynamic_values, file_path, document, &parent_path)?;
let active_subtypes =
schema_active_subtypes_for_path(engine, file_path, document, &parent_path);
let field_match = schema_bind_field_match(
engine,
dynamic_values,
parent_context,
&target.key,
&active_subtypes,
)?;
Some(SchemaHover {
markdown: render_schema_hover_markdown(engine, dynamic_values, &target.key, &field_match),
range: target.range,
})
}
fn find_hover_target(
statements: &[AstStatement],
text: &str,
offset: usize,
parent_path: &[String],
) -> Option<KeyPathTarget> {
for statement in statements {
let AstStatement::Assignment {
key,
key_span,
value,
..
} = statement
else {
continue;
};
if (key_span.start.offset..key_span.end.offset).contains(&offset) {
return Some(KeyPathTarget {
parent_path: parent_path.to_vec(),
key: key.clone(),
range: range_from_span(text, key_span)?,
});
}
if let AstValue::Block { items, span } = value
&& (span.start.offset..span.end.offset).contains(&offset)
{
let mut child_path = parent_path.to_vec();
child_path.push(key.clone());
if let Some(target) = find_hover_target(items, text, offset, &child_path) {
return Some(target);
}
}
}
None
}
fn render_schema_hover_markdown(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
key: &str,
field_match: &CompiledBindFieldMatch<'_>,
) -> String {
let value = field_match.value();
let mut sections = vec![
format!("**{key}**"),
format!("Type: `{}`", rule_value_kind(value)),
];
if let Some(values) = schema_dynamic_alias_source(engine, dynamic_values, key, field_match) {
sections.push(format!(
"Dynamic alias: `{}` `{}`",
values.kind.label(),
values.name
));
}
if let Some(values) = schema_dynamic_key_source(engine, dynamic_values, key, field_match) {
sections.push(format!(
"Dynamic key: `{}` `{}`",
values.kind.label(),
values.name
));
}
if let Some(values) = schema_allowed_values(engine, dynamic_values, value) {
sections.push(format!(
"Value set: `{}` `{}`",
values.kind.label(),
values.name
));
sections.push(format!(
"Allowed values: {}",
format_allowed_values(&values.values)
));
}
if let Some(description) = schema_hover_description(field_match) {
sections.push(description.to_string());
}
if let Some(scope_context) = schema_hover_scope_context(field_match) {
sections.push(format!("Scope context: {scope_context}"));
}
if let Some(cardinality) = schema_hover_cardinality(field_match) {
sections.push(format!("Cardinality: `{cardinality}`"));
}
sections.join("\n\n")
}
fn schema_dynamic_key_source<'a>(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
key: &str,
field_match: &'a CompiledBindFieldMatch<'a>,
) -> Option<SchemaAllowedValues<'a>> {
let field = field_match.field();
if field.key == key {
return None;
}
let (head, name) = parse_schema_marker(&field.key)?;
if head == "alias_name" {
return None;
}
let values = schema_allowed_values_for_marker(engine, dynamic_values, head, name)?;
values
.values
.iter()
.any(|value| value == key)
.then_some(values)
}
fn rule_value_kind(value: &CompiledRuleValue) -> &'static str {
match value {
CompiledRuleValue::Scalar(_) => "Scalar",
CompiledRuleValue::Block(_) => "Block",
CompiledRuleValue::Marker(marker) if schema_allowed_value_marker(marker).is_some() => {
"Scalar"
}
CompiledRuleValue::Marker(_) => "Marker",
}
}
fn schema_hover_description<'a>(field_match: &'a CompiledBindFieldMatch<'a>) -> Option<&'a str> {
field_match
.field()
.attributes
.description
.as_deref()
.or_else(|| {
field_match
.alias()
.and_then(|alias| alias.attributes.description.as_deref())
})
}
fn schema_hover_scope_context(field_match: &CompiledBindFieldMatch<'_>) -> Option<String> {
let field_attributes = &field_match.field().attributes;
let alias_attributes = field_match.alias().map(|alias| &alias.attributes);
let mut parts = Vec::new();
if let Some(push_scope) = field_attributes
.push_scope
.as_deref()
.or_else(|| alias_attributes.and_then(|attributes| attributes.push_scope.as_deref()))
{
parts.push(format!("push_scope=`{push_scope}`"));
}
let scope = if !field_attributes.scope.is_empty() {
Some(field_attributes.scope.as_slice())
} else {
alias_attributes.and_then(|attributes| {
(!attributes.scope.is_empty()).then_some(attributes.scope.as_slice())
})
};
if let Some(scope) = scope {
parts.push(format!("scope={}", format_scope_values(scope)));
}
let replace_scope = if !field_attributes.replace_scope.is_empty() {
Some(&field_attributes.replace_scope)
} else {
alias_attributes.and_then(|attributes| {
(!attributes.replace_scope.is_empty()).then_some(&attributes.replace_scope)
})
};
if let Some(replace_scope) = replace_scope {
let mut entries = replace_scope
.iter()
.map(|(source, target)| format!("`{source}`→`{target}`"))
.collect::<Vec<_>>();
entries.sort();
parts.push(format!("replace_scope={}", entries.join(", ")));
}
(!parts.is_empty()).then(|| parts.join("; "))
}
fn schema_dynamic_alias_source<'a>(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
key: &str,
field_match: &'a CompiledBindFieldMatch<'a>,
) -> Option<SchemaAllowedValues<'a>> {
let alias = field_match.alias()?;
if alias.name == key {
return None;
}
let values = schema_dynamic_alias_values(engine, dynamic_values, alias)?;
values
.values
.iter()
.any(|value| value == key)
.then_some(values)
}
fn format_scope_values(values: &[String]) -> String {
values
.iter()
.map(|value| format!("`{value}`"))
.collect::<Vec<_>>()
.join(", ")
}
fn format_scope_refs(values: &[String]) -> String {
values
.iter()
.map(|value| format!("`{value}`"))
.collect::<Vec<_>>()
.join(", ")
}
fn schema_hover_cardinality(field_match: &CompiledBindFieldMatch<'_>) -> Option<String> {
schema_match_cardinality(field_match).map(format_cardinality)
}
fn format_cardinality(cardinality: (u32, Option<u32>)) -> String {
match cardinality {
(minimum, Some(maximum)) => format!("{minimum}..{maximum}"),
(minimum, None) => format!("{minimum}..inf"),
}
}
fn editor_range_from_span(span: &SpanRange) -> EditorRange {
EditorRange {
start: EditorPosition {
line: span.start.line.saturating_sub(1) as u32,
character: span.start.column.saturating_sub(1) as u32,
},
end: EditorPosition {
line: span.end.line.saturating_sub(1) as u32,
character: span.end.column.saturating_sub(1) as u32,
},
}
}
fn span_contains_position(span: &SpanRange, position: EditorPosition) -> bool {
let line = position.line as usize + 1;
let column = position.character as usize + 1;
(line > span.start.line || (line == span.start.line && column >= span.start.column))
&& (line < span.end.line || (line == span.end.line && column < span.end.column))
}
pub(super) fn schema_completion_candidates(
engine: &CwtQuery,
file_path: &GamePath,
text: &str,
position: EditorPosition,
prefix_lower: &str,
) -> Option<Vec<SchemaCompletion>> {
schema_completion_candidates_with_index(engine, file_path, text, position, prefix_lower, None)
}
pub(super) fn schema_completion_candidates_with_index(
engine: &CwtQuery,
file_path: &GamePath,
text: &str,
position: EditorPosition,
prefix_lower: &str,
dynamic_values: Option<&SchemaWorkspace>,
) -> Option<Vec<SchemaCompletion>> {
if !is_schema_key_completion_position(text, position) {
let parsed = parse_clausewitz_statements(ScriptSyntax::for_game_path(file_path), text);
return schema_value_completion_candidates(
engine,
dynamic_values,
file_path,
&parsed.statements,
text,
position,
prefix_lower,
);
}
let parsed = parse_clausewitz_statements(ScriptSyntax::for_game_path(file_path), text);
let parent_path = find_completion_parent_path(&parsed.statements, position, &[])?;
let parent_path = parent_path.iter().map(String::as_str).collect::<Vec<_>>();
let document = schema_document_at_position(engine, file_path, &parsed.statements, position);
let parent_context =
schema_bind_context(engine, dynamic_values, file_path, document, &parent_path)?;
let active_scopes =
schema_active_scopes_for_path(engine, dynamic_values, file_path, document, &parent_path);
let active_subtypes =
schema_active_subtypes_for_path(engine, file_path, document, &parent_path);
let mut candidates = Vec::new();
for field in completion_rule_fields(parent_context)
.into_iter()
.filter(|field| schema_conditions_match(&field.conditions, &active_subtypes))
{
candidates.extend(schema_completion_entries_for_field(
engine,
dynamic_values,
field,
&active_scopes,
prefix_lower,
));
}
candidates.sort_by(|left, right| left.label.cmp(&right.label));
candidates.dedup_by(|left, right| left.label == right.label);
Some(candidates)
}
fn schema_value_completion_candidates(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
file_path: &GamePath,
statements: &[AstStatement],
text: &str,
position: EditorPosition,
prefix_lower: &str,
) -> Option<Vec<SchemaCompletion>> {
let line = text.lines().nth(position.line as usize).unwrap_or_default();
let upto: String = line.chars().take(position.character as usize).collect();
let key = current_assignment_key(&upto)?;
let parent_path = find_completion_parent_path(statements, position, &[])?;
let parent_path = parent_path.iter().map(String::as_str).collect::<Vec<_>>();
let statements = schema_document_at_position(engine, file_path, statements, position);
let parent_context =
schema_bind_context(engine, dynamic_values, file_path, statements, &parent_path)?;
let active_subtypes =
schema_active_subtypes_for_path(engine, file_path, statements, &parent_path);
let field_match = schema_bind_field_match(
engine,
dynamic_values,
parent_context,
key,
&active_subtypes,
)?;
let values = schema_allowed_values(engine, dynamic_values, field_match.value())?;
let kind = values.kind.completion_kind();
let mut candidates = values
.values
.iter()
.filter_map(|value| {
let value_lower = value.to_ascii_lowercase();
if !prefix_lower.is_empty() && !value_lower.starts_with(prefix_lower) {
return None;
}
Some(SchemaCompletion {
label: value.clone(),
insert_text: value.clone(),
kind,
detail: format!("cwt {} {}", values.kind.label(), values.name),
})
})
.collect::<Vec<_>>();
candidates.sort_by(|left, right| left.label.cmp(&right.label));
Some(candidates)
}
fn is_schema_key_completion_position(text: &str, position: EditorPosition) -> bool {
let line = text
.lines()
.nth(position.line as usize)
.map(str::to_string)
.unwrap_or_default();
let upto: String = line.chars().take(position.character as usize).collect();
current_assignment_key(&upto).is_none()
}
fn find_completion_parent_path(
statements: &[AstStatement],
position: EditorPosition,
parent_path: &[String],
) -> Option<Vec<String>> {
for statement in statements {
match statement {
AstStatement::Assignment {
key,
key_span,
value,
span,
} => {
if span_contains_position(key_span, position) {
return Some(parent_path.to_vec());
}
if let AstValue::Block { items, span } = value
&& span_contains_position(span, position)
{
let mut child_path = parent_path.to_vec();
child_path.push(key.clone());
return find_completion_parent_path(items, position, &child_path)
.or(Some(child_path));
}
if span_contains_position(span, position) {
return Some(parent_path.to_vec());
}
}
AstStatement::Item { value, span } => {
if let AstValue::Block {
items,
span: block_span,
} = value && span_contains_position(block_span, position)
{
return find_completion_parent_path(items, position, parent_path)
.or_else(|| Some(parent_path.to_vec()));
}
if span_contains_position(span, position) {
return Some(parent_path.to_vec());
}
}
AstStatement::Comment { span, .. } => {
if span_contains_position(span, position) {
return Some(parent_path.to_vec());
}
}
}
}
Some(parent_path.to_vec())
}
fn completion_rule_fields(context: RuleContext<'_>) -> Vec<&CompiledRuleField> {
match context {
RuleContext::RootType(root) => root.rules.iter().collect(),
RuleContext::Subtype(root, subtype) => {
subtype.rules.iter().chain(root.rules.iter()).collect()
}
RuleContext::RuleField(field) => match &field.value {
CompiledRuleValue::Block(children) => children.iter().collect(),
_ => Vec::new(),
},
RuleContext::AliasRules(rules) => rules.iter().collect(),
}
}
fn schema_completion_entries_for_field(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
field: &CompiledRuleField,
active_scopes: &[String],
prefix_lower: &str,
) -> Vec<SchemaCompletion> {
let Some((head, payload)) = parse_schema_marker(&field.key) else {
return direct_schema_completion_entry(field, prefix_lower)
.into_iter()
.collect();
};
if head != "alias_name" {
if let Some(values) =
schema_allowed_values_for_marker(engine, dynamic_values, head, payload)
{
return schema_dynamic_key_completion_entries(&values, prefix_lower);
}
return direct_schema_completion_entry(field, prefix_lower)
.into_iter()
.collect();
}
let category = CompiledAliasCategory::from_name(payload);
let declared_modifiers = if category == CompiledAliasCategory::Modifier {
engine.declared_modifiers()
} else {
&[]
};
let mut candidates = engine
.aliases()
.iter()
.filter(|alias| alias.category == category)
.chain(declared_modifiers)
.filter(|alias| schema_alias_scope_matches(engine, alias, active_scopes))
.flat_map(|alias| {
schema_completion_entries_for_alias(engine, dynamic_values, field, alias, prefix_lower)
})
.collect::<Vec<_>>();
candidates.sort_by(|left, right| left.label.cmp(&right.label));
candidates.dedup_by(|right, left| right.label == left.label);
candidates
}
fn schema_completion_entries_for_alias(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
field: &CompiledRuleField,
alias: &CompiledAlias,
prefix_lower: &str,
) -> Vec<SchemaCompletion> {
if schema_allowed_value_marker(&alias.name).is_some() {
let Some(values) = schema_dynamic_alias_values(engine, dynamic_values, alias) else {
return Vec::new();
};
return values
.values
.iter()
.filter_map(|value| {
let value_lower = value.to_ascii_lowercase();
if !prefix_lower.is_empty() && !value_lower.starts_with(prefix_lower) {
return None;
}
Some(SchemaCompletion {
label: value.clone(),
insert_text: value.clone(),
kind: SchemaCompletionKind::Function,
detail: format!("cwt dynamic alias {} {}", values.kind.label(), values.name),
})
})
.collect();
}
let alias_name = &alias.name;
let alias_name_lower = alias_name.to_ascii_lowercase();
if !prefix_lower.is_empty() && !alias_name_lower.starts_with(prefix_lower) {
return Vec::new();
}
vec![SchemaCompletion {
label: alias_name.clone(),
insert_text: alias_name.clone(),
kind: SchemaCompletionKind::Function,
detail: schema_completion_detail(
alias
.attributes
.description
.as_deref()
.or(field.attributes.description.as_deref()),
),
}]
}
fn schema_dynamic_key_completion_entries(
values: &SchemaAllowedValues<'_>,
prefix_lower: &str,
) -> Vec<SchemaCompletion> {
values
.values
.iter()
.filter_map(|value| {
let value_lower = value.to_ascii_lowercase();
if !prefix_lower.is_empty() && !value_lower.starts_with(prefix_lower) {
return None;
}
Some(SchemaCompletion {
label: value.clone(),
insert_text: value.clone(),
kind: SchemaCompletionKind::Field,
detail: format!("cwt dynamic key {} {}", values.kind.label(), values.name),
})
})
.collect()
}
fn schema_active_scopes_for_path(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
file_path: &GamePath,
document_statements: &[AstStatement],
path: &[&str],
) -> Vec<String> {
let mut active_scopes = Vec::new();
let mut parent_context = None;
for index in 0..=path.len() {
let context = schema_bind_context(
engine,
dynamic_values,
file_path,
document_statements,
&path[..index],
);
if let Some(context) = context {
let scopes = schema_context_own_active_scopes(engine, context);
if !scopes.is_empty() {
active_scopes = scopes;
}
}
if let (Some(RuleContext::AliasRules(_)), Some(parent), Some(key)) = (
context,
parent_context,
index
.checked_sub(1)
.and_then(|parent_len| path.get(parent_len)),
) {
let active_subtypes = schema_active_subtypes_for_path(
engine,
file_path,
document_statements,
&path[..index - 1],
);
if let Some(CompiledBindFieldMatch::Alias { alias, .. }) =
schema_bind_block_field_match(engine, dynamic_values, parent, key, &active_subtypes)
{
let scopes = schema_alias_pushed_scopes(engine, &alias.attributes);
if !scopes.is_empty() {
active_scopes = scopes;
}
}
}
if let Some(component) = path.get(index) {
active_scopes = schema_active_scopes_after_key(engine, component, active_scopes);
}
parent_context = context;
}
active_scopes
}
fn schema_alias_pushed_scopes(
engine: &CwtQuery,
attributes: &CompiledFieldAttributes,
) -> Vec<String> {
attributes
.push_scope
.as_ref()
.or_else(|| attributes.replace_scope.get("this"))
.map(|scope| schema_listed_scope(engine, scope))
.into_iter()
.collect()
}
fn schema_listed_scope(engine: &CwtQuery, scope: &str) -> String {
if engine.is_unlisted_scope(scope) {
"any".to_string()
} else {
scope.to_string()
}
}
fn schema_active_scopes_after_key(
engine: &CwtQuery,
key: &str,
active_scopes: Vec<String>,
) -> Vec<String> {
if is_schema_keyword(SCHEMA_UNTRACKED_SCOPE_KEYWORDS, key) || is_schema_event_target(key) {
return Vec::new();
}
match schema_link_output_scope(engine, key, &active_scopes) {
Some(output_scope) => vec![output_scope.to_string()],
None => active_scopes,
}
}
fn schema_context_own_active_scopes(engine: &CwtQuery, context: RuleContext<'_>) -> Vec<String> {
let root_scopes = |root: &CompiledRoot| {
root.push_scope
.iter()
.map(|scope| schema_listed_scope(engine, scope))
.collect::<Vec<_>>()
};
match context {
RuleContext::RootType(root) => root_scopes(root),
RuleContext::Subtype(root, subtype) => {
let scopes = schema_active_scopes_from_attributes(engine, &subtype.attributes);
if scopes.is_empty() {
root_scopes(root)
} else {
scopes
}
}
RuleContext::RuleField(field) => {
schema_active_scopes_from_attributes(engine, &field.attributes)
}
RuleContext::AliasRules(_) => Vec::new(),
}
}
fn schema_active_scopes_from_attributes(
engine: &CwtQuery,
attributes: &CompiledFieldAttributes,
) -> Vec<String> {
if let Some(push_scope) = attributes.push_scope.as_ref() {
return vec![schema_listed_scope(engine, push_scope)];
}
if let Some(this_scope) = attributes.replace_scope.get("this") {
return vec![schema_listed_scope(engine, this_scope)];
}
attributes
.scope
.iter()
.map(|scope| schema_listed_scope(engine, scope))
.collect()
}
fn schema_link_output_scope<'a>(
engine: &'a CwtQuery,
key: &str,
active_scopes: &[String],
) -> Option<&'a str> {
let link = schema_scope_link(engine, key)?;
if !schema_link_accepts_active_scope(engine, link, active_scopes) {
return None;
}
link.output_scope.as_deref()
}
fn schema_link_accepts_active_scope(
engine: &CwtQuery,
link: &CompiledLink,
active_scopes: &[String],
) -> bool {
if active_scopes.is_empty() || link.input_scopes.is_empty() {
return true;
}
link.input_scopes.iter().any(|input_scope| {
active_scopes
.iter()
.any(|active_scope| engine.scope_matches(input_scope, active_scope))
})
}
fn schema_alias_scope_matches(
engine: &CwtQuery,
alias: &CompiledAlias,
active_scopes: &[String],
) -> bool {
if active_scopes.is_empty()
|| alias.attributes.scope.is_empty()
|| alias
.attributes
.scope
.iter()
.any(|scope| engine.is_unlisted_scope(scope))
|| is_schema_declared_modifier(engine, alias)
{
return true;
}
alias.attributes.scope.iter().any(|scope| {
active_scopes
.iter()
.any(|active| engine.scope_matches(scope, active))
})
}
fn is_schema_declared_modifier(engine: &CwtQuery, alias: &CompiledAlias) -> bool {
alias.category == CompiledAliasCategory::Modifier
&& engine
.declared_modifier(&alias.name)
.is_some_and(|declared| std::ptr::eq(declared, alias))
}
fn schema_bind_context<'p>(
engine: &'p CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
file_path: &GamePath,
document_statements: &[AstStatement],
path: &[&str],
) -> Option<RuleContext<'p>> {
let root = schema_root_owning_path(engine, file_path, path);
if schema_path_is_above_definitions(root, path) {
return None;
}
for prefix_len in 0..=path.len() {
if schema_path_is_above_definitions(root, &path[..prefix_len]) {
continue;
}
let Some(mut context) = schema_engine_bind_context(
engine,
dynamic_values,
file_path,
document_statements,
&path[..prefix_len],
) else {
continue;
};
let mut resolved_path = path[..prefix_len]
.iter()
.map(|segment| (*segment).to_string())
.collect::<Vec<_>>();
let mut resolved_all = true;
for segment in &path[prefix_len..] {
let resolved_refs = resolved_path.iter().map(String::as_str).collect::<Vec<_>>();
let active_subtypes = schema_active_subtypes_for_path(
engine,
file_path,
document_statements,
&resolved_refs,
);
let Some(field_match) = schema_bind_block_field_match(
engine,
dynamic_values,
context,
segment,
&active_subtypes,
) else {
resolved_all = false;
break;
};
context = schema_child_context_for_field_match(field_match);
resolved_path.push((*segment).to_string());
}
if resolved_all {
return Some(context);
}
}
None
}
fn schema_root_owning_path<'p>(
engine: &'p CwtQuery,
file_path: &GamePath,
path: &[&str],
) -> Option<&'p CompiledRoot> {
if let Some(root) = engine.bind_root(file_path) {
return Some(root);
}
if path.is_empty() {
return None;
}
let mut roots = engine.matching_roots(file_path).into_iter().filter(|root| {
!root.type_per_file
&& root
.skip_root_keys
.iter()
.zip(path)
.all(|(skip_key, key)| skip_key == "any" || skip_key == key)
});
let root = roots.next()?;
roots.next().is_none().then_some(root)
}
fn schema_path_is_above_definitions(root: Option<&CompiledRoot>, path: &[&str]) -> bool {
let Some(root) = root else {
return false;
};
!root.type_per_file
&& path.len() <= root.skip_root_keys.len()
&& root
.skip_root_keys
.iter()
.zip(path)
.all(|(skip_key, key)| skip_key == "any" || skip_key == key)
}
fn schema_context_describes_body(context: RuleContext<'_>) -> bool {
match context {
RuleContext::RootType(_) | RuleContext::Subtype(..) => {
!completion_rule_fields(context).is_empty()
}
RuleContext::RuleField(field) => matches!(field.value, CompiledRuleValue::Block(_)),
RuleContext::AliasRules(_) => true,
}
}
fn schema_definition_named_like_body_field<'p>(
engine: &'p CwtQuery,
file_path: &GamePath,
path: &[&str],
context: RuleContext<'_>,
) -> Option<&'p CompiledRoot> {
if matches!(context, RuleContext::RootType(_) | RuleContext::Subtype(..)) {
return None;
}
let root = engine.bind_root(file_path)?;
let (key, wrappers) = path.split_last()?;
(!root.type_per_file
&& wrappers.len() == root.skip_root_keys.len()
&& root
.type_key_filter
.as_ref()
.is_none_or(|filter| filter.matches(key)))
.then_some(root)
}
fn schema_engine_bind_context<'p>(
engine: &'p CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
file_path: &GamePath,
document_statements: &[AstStatement],
path: &[&str],
) -> Option<RuleContext<'p>> {
if path.iter().any(|segment| is_clausewitz_date_key(segment)) {
return None;
}
let Some(context) = engine.bind_context(file_path, path) else {
return schema_shared_folder_definition_context(engine, file_path, path);
};
if let Some(root) = schema_definition_named_like_body_field(engine, file_path, path, context) {
return Some(RuleContext::RootType(root));
}
if schema_context_describes_body(context) {
return Some(context);
}
let (RuleContext::RootType(root) | RuleContext::Subtype(root, _)) = context else {
return None;
};
let (_, parent_path) = path.split_last()?;
let parent = schema_bind_context(
engine,
dynamic_values,
file_path,
document_statements,
parent_path,
)?;
let active_subtypes =
schema_active_subtypes_for_path(engine, file_path, document_statements, parent_path);
let type_key = format!("<{}>", root.name);
completion_rule_fields(parent)
.into_iter()
.filter(|field| schema_conditions_match(&field.conditions, &active_subtypes))
.find(|field| field.key == type_key)
.map(RuleContext::RuleField)
}
fn schema_shared_folder_definition_context<'p>(
engine: &'p CwtQuery,
file_path: &GamePath,
path: &[&str],
) -> Option<RuleContext<'p>> {
if path.len() != 1 {
return None;
}
let key = path[0];
let mut roots = engine.matching_roots(file_path).into_iter().filter(|root| {
!root.type_per_file
&& root.skip_root_keys.is_empty()
&& root
.type_key_filter
.as_ref()
.is_none_or(|filter| filter.matches(key))
&& schema_context_describes_body(RuleContext::RootType(root))
});
let root = roots.next()?;
roots
.next()
.is_none()
.then_some(RuleContext::RootType(root))
}
fn schema_child_context_for_field_match<'p>(
field_match: CompiledBindFieldMatch<'p>,
) -> RuleContext<'p> {
match field_match {
CompiledBindFieldMatch::Field(field) => RuleContext::RuleField(field),
CompiledBindFieldMatch::Alias { alias, .. } => {
RuleContext::AliasRules(alias.rules.as_slice())
}
}
}
fn schema_bind_field_match<'p>(
engine: &'p CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
parent: RuleContext<'p>,
key: &str,
active_subtypes: &HashSet<String>,
) -> Option<CompiledBindFieldMatch<'p>> {
schema_bind_field_alternatives(engine, dynamic_values, parent, key, active_subtypes)
.into_iter()
.next()
}
fn schema_bind_field_alternatives<'p>(
engine: &'p CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
parent: RuleContext<'p>,
key: &str,
active_subtypes: &HashSet<String>,
) -> Vec<CompiledBindFieldMatch<'p>> {
if is_clausewitz_date_key(key) {
let date_matches =
schema_conditioned_field_matches(engine, parent, "date_field", active_subtypes);
if !date_matches.is_empty() {
return date_matches;
}
}
let rule_fields = completion_rule_fields(parent);
if !rule_fields.iter().any(|field| field.key == key) {
let mut folded_rule_keys = rule_fields
.iter()
.map(|field| field.key.as_str())
.filter(|rule_key| rule_key.eq_ignore_ascii_case(key))
.collect::<Vec<_>>();
folded_rule_keys.sort_unstable();
folded_rule_keys.dedup();
let folded_matches = folded_rule_keys
.into_iter()
.flat_map(|rule_key| {
schema_conditioned_field_matches(engine, parent, rule_key, active_subtypes)
})
.collect::<Vec<_>>();
if !folded_matches.is_empty() {
return folded_matches;
}
}
let static_matches = schema_conditioned_field_matches(engine, parent, key, active_subtypes);
if !static_matches.is_empty() {
let mut matches = static_matches;
matches.extend(schema_primitive_key_field_matches(
parent,
key,
active_subtypes,
));
return matches;
}
let lower_key = key.to_ascii_lowercase();
if lower_key != key {
let folded_matches =
schema_conditioned_field_matches(engine, parent, &lower_key, active_subtypes);
if !folded_matches.is_empty() {
return folded_matches;
}
}
let dynamic_alias_matches =
schema_dynamic_alias_field_matches(engine, dynamic_values, parent, key, active_subtypes);
if !dynamic_alias_matches.is_empty() {
return dynamic_alias_matches;
}
schema_dynamic_key_field_matches(engine, dynamic_values, parent, key, active_subtypes)
.or_else(|| {
schema_scope_field_match(engine, parent, key, active_subtypes)
.map(|field_match| vec![field_match])
})
.unwrap_or_else(|| schema_primitive_key_field_matches(parent, key, active_subtypes))
}
fn schema_primitive_key_field_matches<'p>(
parent: RuleContext<'p>,
key: &str,
active_subtypes: &HashSet<String>,
) -> Vec<CompiledBindFieldMatch<'p>> {
let key_scalar = if key.parse::<f64>().is_ok() {
ScalarValue::Number(key.to_string())
} else {
ScalarValue::Identifier(key.to_string())
};
completion_rule_fields(parent)
.into_iter()
.filter(|field| schema_conditions_match(&field.conditions, active_subtypes))
.filter(|field| schema_primitive_key_matches(&field.key, &key_scalar))
.map(CompiledBindFieldMatch::Field)
.collect()
}
fn schema_primitive_key_matches(rule_key: &str, key: &ScalarValue) -> bool {
let rule_value = CompiledRuleValue::Scalar(rule_key.to_string());
if let Some(scalar_type) = SchemaScalarType::from_rule_value(&rule_value) {
return schema_scalar_type_matches(scalar_type, key);
}
rule_key != "date_field" && SCHEMA_ANY_SCALAR_TYPES.contains(&rule_key)
}
fn schema_conditioned_field_matches<'p>(
engine: &'p CwtQuery,
parent: RuleContext<'p>,
key: &str,
active_subtypes: &HashSet<String>,
) -> Vec<CompiledBindFieldMatch<'p>> {
engine.bind_field_overloads(parent, key, |field| {
schema_conditions_match(&field.conditions, active_subtypes)
})
}
fn schema_bind_block_field_match<'p>(
engine: &'p CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
parent: RuleContext<'p>,
key: &str,
active_subtypes: &HashSet<String>,
) -> Option<CompiledBindFieldMatch<'p>> {
let alternatives =
schema_bind_field_alternatives(engine, dynamic_values, parent, key, active_subtypes);
alternatives
.iter()
.copied()
.find(|field_match| schema_value_shape(field_match.value()) == SchemaValueShape::Block)
.or_else(|| alternatives.first().copied())
}
const SCHEMA_SCOPE_FIELD_KEYWORDS: &[&str] = &[
"THIS",
"ROOT",
"FROM",
"PREV",
"PREV_PREV",
"AND",
"OR",
"NOR",
"NOT",
"hidden_effect",
"hidden_trigger",
];
const SCHEMA_UNTRACKED_SCOPE_KEYWORDS: &[&str] = &["ROOT", "FROM", "PREV", "PREV_PREV"];
fn schema_scope_field_match<'p>(
engine: &'p CwtQuery,
parent: RuleContext<'p>,
key: &str,
active_subtypes: &HashSet<String>,
) -> Option<CompiledBindFieldMatch<'p>> {
if !is_schema_scope_field_key(engine, key) {
return None;
}
completion_rule_fields(parent)
.into_iter()
.filter(|field| schema_conditions_match(&field.conditions, active_subtypes))
.find_map(|field| {
let (head, category) = parse_schema_marker(&field.key)?;
if head != "alias_name" {
return None;
}
let alias = engine.alias(CompiledAliasCategory::from_name(category), "scope_field")?;
Some(CompiledBindFieldMatch::Alias {
wildcard: field,
alias,
})
})
}
fn is_schema_scope_field_key(engine: &CwtQuery, key: &str) -> bool {
is_schema_keyword(SCHEMA_SCOPE_FIELD_KEYWORDS, key)
|| is_schema_event_target(key)
|| schema_scope_link(engine, key).is_some()
}
const SCHEMA_SCOPE_VALUE_KEYWORDS: &[&str] = &["THIS", "ROOT", "FROM", "PREV", "PREV_PREV"];
fn is_schema_event_target(text: &str) -> bool {
text.strip_prefix("event_target:")
.is_some_and(|name| !name.is_empty())
}
fn schema_scope_reference_matches(engine: &CwtQuery, required_scope: &str, text: &str) -> bool {
if is_schema_keyword(SCHEMA_SCOPE_VALUE_KEYWORDS, text) || is_schema_event_target(text) {
return true;
}
schema_scope_link(engine, text).is_some_and(|link| {
link.output_scope
.as_deref()
.is_none_or(|output_scope| engine.scope_matches(required_scope, output_scope))
})
}
fn is_schema_keyword(keywords: &[&str], key: &str) -> bool {
keywords
.iter()
.any(|keyword| keyword.eq_ignore_ascii_case(key))
}
fn schema_scope_link<'a>(engine: &'a CwtQuery, key: &str) -> Option<&'a CompiledLink> {
engine
.link(key)
.or_else(|| engine.link(&key.to_ascii_lowercase()))
}
fn schema_dynamic_alias_field_matches<'p>(
engine: &'p CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
parent: RuleContext<'p>,
key: &str,
active_subtypes: &HashSet<String>,
) -> Vec<CompiledBindFieldMatch<'p>> {
if is_schema_dynamic_key_marker(key) {
return Vec::new();
}
let mut matches = completion_rule_fields(parent)
.into_iter()
.filter(|field| schema_conditions_match(&field.conditions, active_subtypes))
.filter_map(|field| {
let (head, payload) = parse_schema_marker(&field.key)?;
(head == "alias_name").then_some((field, CompiledAliasCategory::from_name(payload)))
})
.flat_map(|(field, category)| {
engine
.aliases()
.iter()
.filter(move |alias| alias.category == category)
.filter(move |alias| {
schema_dynamic_alias_matches(engine, dynamic_values, alias, key)
})
.map(move |alias| CompiledBindFieldMatch::Alias {
wildcard: field,
alias,
})
})
.collect::<Vec<_>>();
matches.reverse();
matches
}
fn schema_dynamic_key_field_matches<'p>(
engine: &'p CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
parent: RuleContext<'p>,
key: &str,
active_subtypes: &HashSet<String>,
) -> Option<Vec<CompiledBindFieldMatch<'p>>> {
if is_schema_dynamic_key_marker(key) {
return None;
}
let matches = completion_rule_fields(parent)
.into_iter()
.filter(|field| schema_conditions_match(&field.conditions, active_subtypes))
.filter(|field| schema_dynamic_key_field_accepts(engine, dynamic_values, field, key))
.collect::<Vec<_>>();
let first = matches.first()?;
matches.iter().all(|field| field.key == first.key).then(|| {
matches
.into_iter()
.map(CompiledBindFieldMatch::Field)
.collect()
})
}
fn schema_dynamic_key_field_accepts(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
field: &CompiledRuleField,
key: &str,
) -> bool {
let Some((head, name)) = parse_schema_marker(&field.key) else {
return SchemaDefinitionKeyTemplate::parse(&field.key).is_some_and(|template| {
dynamic_values.is_some_and(|workspace| template.accepts(workspace, key))
});
};
if head == "alias_name" {
return false;
}
schema_allowed_values_for_marker(engine, dynamic_values, head, name)
.is_some_and(|values| schema_allowed_values_contain(&values, key))
}
fn schema_dynamic_alias_matches(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
alias: &CompiledAlias,
key: &str,
) -> bool {
if alias.name == SCHEMA_SCRIPT_PARAMETER_KEY_ENUM {
return is_script_parameter_key(key);
}
if let Some(template) = SchemaDefinitionKeyTemplate::parse(&alias.name) {
return dynamic_values.is_some_and(|workspace| template.accepts(workspace, key));
}
schema_dynamic_alias_values(engine, dynamic_values, alias)
.is_some_and(|values| schema_allowed_values_contain(&values, key))
}
const SCHEMA_SCRIPT_PARAMETER_KEY_ENUM: &str = "enum[scripted_effect_params_dollar]";
fn is_script_parameter_key(key: &str) -> bool {
key.len() > 2 && key.starts_with('$') && key.ends_with('$')
}
fn contains_script_parameter(text: &str) -> bool {
text.contains('$')
}
fn schema_key_after_conditional_opener(key: &str, range: EditorRange) -> (&str, EditorRange) {
let mut rest = key;
while let Some(after) = rest
.strip_prefix("[[")
.and_then(|tail| tail.split_once(']'))
.filter(|(name, after)| {
let name = name.strip_prefix('!').unwrap_or(name);
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
&& !after.is_empty()
})
.map(|(_, after)| after)
{
rest = after;
}
let mut range = range;
range.start.character += (key.len() - rest.len()) as u32;
(rest, range)
}
fn schema_value_before_conditional_closer(value: &AstValue) -> Cow<'_, AstValue> {
let AstValue::Scalar {
value: ScalarValue::Identifier(text),
span,
} = value
else {
return Cow::Borrowed(value);
};
let trimmed = text.trim_end_matches(']');
if trimmed.len() == text.len() || trimmed.is_empty() || trimmed.contains('[') {
return Cow::Borrowed(value);
}
let mut span = span.clone();
span.end.column -= text.len() - trimmed.len();
span.end.offset -= text.len() - trimmed.len();
Cow::Owned(AstValue::Scalar {
value: unquoted_scalar(trimmed),
span,
})
}
struct SchemaDefinitionKeyTemplate<'a> {
prefix: &'a str,
type_name: &'a str,
suffix: &'a str,
}
impl SchemaDefinitionKeyTemplate<'_> {
fn parse(key: &str) -> Option<SchemaDefinitionKeyTemplate<'_>> {
if key.chars().any(char::is_whitespace) {
return None;
}
let open = key.find('<')?;
let close = open + key[open..].find('>')?;
let (prefix, inner, suffix) = (&key[..open], &key[open + 1..close], &key[close + 1..]);
if inner.is_empty() || suffix.contains(['<', '>']) {
return None;
}
Some(SchemaDefinitionKeyTemplate {
prefix,
type_name: inner
.split_once('.')
.map_or(inner, |(type_name, _)| type_name),
suffix,
})
}
fn accepts(&self, workspace: &SchemaWorkspace, key: &str) -> bool {
key.strip_prefix(self.prefix)
.and_then(|rest| rest.strip_suffix(self.suffix))
.is_some_and(|name| !name.is_empty() && workspace.defines(self.type_name, name))
}
}
fn schema_conditions_match(
conditions: &[CompiledRuleCondition],
active_subtypes: &HashSet<String>,
) -> bool {
conditions.iter().all(|condition| match condition {
CompiledRuleCondition::SubtypeActive(label) => active_subtypes.contains(label),
CompiledRuleCondition::SubtypeInactive(label) => !active_subtypes.contains(label),
})
}
fn schema_active_subtypes_for_path(
engine: &CwtQuery,
file_path: &GamePath,
statements: &[AstStatement],
path: &[&str],
) -> HashSet<String> {
let mut active = HashSet::new();
if let Some(root_key) = path.first()
&& let Some(RuleContext::Subtype(_, subtype)) = engine.bind_context(file_path, &[*root_key])
{
active.insert(subtype.name.clone());
}
let Some(root) = schema_definition_root(engine, file_path, path) else {
return active;
};
let root_statements = if root.type_per_file {
Some(statements)
} else {
schema_root_block_statements(root, statements, path)
};
let Some(root_statements) = root_statements else {
return active;
};
let definition_key = schema_definition_key(root, file_path, path);
for subtype in &root.subtypes {
if schema_subtype_matches_body(engine, subtype, definition_key, root_statements) {
active.insert(subtype.name.clone());
}
}
active
}
fn schema_definition_root<'p>(
engine: &'p CwtQuery,
file_path: &GamePath,
path: &[&str],
) -> Option<&'p CompiledRoot> {
if let Some(root) = schema_root_owning_path(engine, file_path, path) {
return Some(root);
}
let root_key = path.first()?;
match schema_shared_folder_definition_context(engine, file_path, &[*root_key])? {
RuleContext::RootType(root) => Some(root),
_ => None,
}
}
fn schema_root_block_statements<'a>(
root: &CompiledRoot,
statements: &'a [AstStatement],
path: &[&str],
) -> Option<&'a [AstStatement]> {
let mut current = statements;
for segment in path.iter().take(root.skip_root_keys.len() + 1) {
current = current.iter().find_map(|statement| {
let AstStatement::Assignment { key, value, .. } = statement else {
return None;
};
if key != segment {
return None;
}
let AstValue::Block { items, .. } = value else {
return None;
};
Some(items.as_slice())
})?;
}
Some(current)
}
fn schema_definition_document<'a>(
engine: &CwtQuery,
file_path: &GamePath,
statements: &'a [AstStatement],
top: &'a AstStatement,
) -> &'a [AstStatement] {
if engine
.bind_root(file_path)
.is_some_and(|root| root.type_per_file)
{
return statements;
}
std::slice::from_ref(top)
}
fn schema_document_at_position<'a>(
engine: &CwtQuery,
file_path: &GamePath,
statements: &'a [AstStatement],
position: EditorPosition,
) -> &'a [AstStatement] {
statements
.iter()
.find(|statement| span_contains_position(statement_span(statement), position))
.map_or(statements, |top| {
schema_definition_document(engine, file_path, statements, top)
})
}
fn schema_definition_key<'k>(
root: &CompiledRoot,
file_path: &'k GamePath,
path: &[&'k str],
) -> Option<&'k str> {
if root.type_per_file {
return file_path.file_stem();
}
path.get(root.skip_root_keys.len()).copied()
}
fn schema_subtype_matches_body(
engine: &CwtQuery,
subtype: &CompiledSubtype,
definition_key: Option<&str>,
statements: &[AstStatement],
) -> bool {
if let (Some(filter), Some(definition_key)) = (&subtype.type_key_filter, definition_key) {
return filter.matches(definition_key)
&& (subtype.rules.is_empty()
|| schema_rule_fields_match_ast(engine, &subtype.rules, statements));
}
!subtype.rules.is_empty() && schema_rule_fields_match_ast(engine, &subtype.rules, statements)
}
fn schema_rule_fields_match_ast(
engine: &CwtQuery,
rules: &[CompiledRuleField],
statements: &[AstStatement],
) -> bool {
rules.iter().all(|rule| {
let present = statements
.iter()
.any(|statement| schema_rule_field_matches_ast(engine, rule, statement));
match rule.attributes.cardinality {
Some((_, Some(0))) => !present,
Some((0, _)) => {
present
|| !statements.iter().any(|statement| {
matches!(
statement,
AstStatement::Assignment { key, .. } if schema_rule_key_matches_ast(&rule.key, key)
)
})
}
_ => present,
}
})
}
fn schema_rule_field_matches_ast(
engine: &CwtQuery,
rule: &CompiledRuleField,
statement: &AstStatement,
) -> bool {
let AstStatement::Assignment { key, value, .. } = statement else {
return false;
};
schema_rule_key_matches_ast(&rule.key, key)
&& schema_rule_value_matches_ast(engine, &rule.value, value)
}
fn schema_rule_key_matches_ast(rule_key: &str, key: &str) -> bool {
if rule_key == key {
return true;
}
if rule_key == "date_field" {
return is_clausewitz_date_key(key);
}
let key_scalar = if key.parse::<f64>().is_ok() {
ScalarValue::Number(key.to_string())
} else {
ScalarValue::Identifier(key.to_string())
};
schema_primitive_key_matches(rule_key, &key_scalar)
}
fn schema_rule_value_matches_ast(
engine: &CwtQuery,
rule_value: &CompiledRuleValue,
ast_value: &AstValue,
) -> bool {
match (rule_value, ast_value) {
(
CompiledRuleValue::Scalar(expected) | CompiledRuleValue::Marker(expected),
AstValue::Scalar { value, .. },
) => schema_rule_value_accepts_scalar(engine, rule_value, expected, value),
(CompiledRuleValue::Block(rules), AstValue::Block { items, .. }) => {
rules.is_empty() || schema_rule_fields_match_ast(engine, rules, items)
}
_ => false,
}
}
const SCHEMA_ANY_SCALAR_TYPES: &[&str] = &[
"scalar",
"localisation",
"localisation_synced",
"localisation_inline",
"localization",
"filepath",
"date_field",
"percentage_field",
"variable_field",
"int_variable_field",
"value_field",
"int_value_field",
];
fn schema_rule_value_accepts_scalar(
engine: &CwtQuery,
rule_value: &CompiledRuleValue,
expected: &str,
scalar: &ScalarValue,
) -> bool {
if let Some(scalar_type) = SchemaScalarType::from_rule_value(rule_value) {
return schema_scalar_type_matches(scalar_type, scalar);
}
if SCHEMA_ANY_SCALAR_TYPES.contains(&expected) || is_schema_angle_dynamic_key_marker(expected) {
return true;
}
let text = scalar.as_text();
match parse_schema_marker(expected) {
Some(("enum", name)) => engine
.enum_values(name)
.is_none_or(|values| values.iter().any(|value| value == &text)),
Some(_) => true,
None => text == expected,
}
}
fn direct_schema_completion_entry(
field: &CompiledRuleField,
prefix_lower: &str,
) -> Option<SchemaCompletion> {
if is_schema_dynamic_key_marker(&field.key) {
return None;
}
let field_key_lower = field.key.to_ascii_lowercase();
if !prefix_lower.is_empty() && !field_key_lower.starts_with(prefix_lower) {
return None;
}
Some(SchemaCompletion {
label: field.key.clone(),
insert_text: field.key.clone(),
kind: SchemaCompletionKind::Field,
detail: schema_completion_detail(field.attributes.description.as_deref()),
})
}
fn schema_completion_detail(description: Option<&str>) -> String {
description
.and_then(|text| text.lines().next())
.map(|line| format!("cwt: {line}"))
.unwrap_or_else(|| "cwt schema field".to_string())
}
fn is_schema_dynamic_key_marker(key: &str) -> bool {
is_schema_angle_dynamic_key_marker(key)
|| matches!(
parse_schema_marker(key),
Some(("enum" | "value" | "value_set" | "scope", _))
)
}
fn is_schema_angle_dynamic_key_marker(key: &str) -> bool {
key.len() > 2
&& key.starts_with('<')
&& key.ends_with('>')
&& !key.chars().any(char::is_whitespace)
}
pub(super) fn schema_diagnostics_for_text(
engine: &CwtQuery,
file_path: &GamePath,
text: &str,
) -> Vec<SchemaDiagnostic> {
schema_diagnostics_for_text_with_index(engine, file_path, text, None)
}
pub(super) fn schema_diagnostics_for_text_with_index(
engine: &CwtQuery,
file_path: &GamePath,
text: &str,
dynamic_values: Option<&SchemaWorkspace>,
) -> Vec<SchemaDiagnostic> {
let parsed = parse_clausewitz_statements(ScriptSyntax::for_game_path(file_path), text);
schema_diagnostics_for_ast_with_index(engine, file_path, &parsed.statements, dynamic_values)
}
const SCHEMA_SHAPE_CODE: &str = "V006";
pub(crate) fn schema_shape_violations(file_path: &GamePath, statements: &[AstStatement]) -> usize {
schema_diagnostics_for_ast_with_index(
crate::game::eu4::cwt::rule_engine(),
file_path,
statements,
None,
)
.iter()
.filter(|diagnostic| diagnostic.code.as_deref() == Some(SCHEMA_SHAPE_CODE))
.count()
}
fn schema_diagnostics_for_ast_with_index(
engine: &CwtQuery,
file_path: &GamePath,
statements: &[AstStatement],
dynamic_values: Option<&SchemaWorkspace>,
) -> Vec<SchemaDiagnostic> {
let mut diagnostics = Vec::new();
let context = SchemaDiagnosticContext {
engine,
dynamic_values,
file_path,
document_statements: statements,
};
collect_schema_diagnostics(
&context,
None,
None,
statements,
&[],
None,
&mut diagnostics,
);
diagnostics.extend(schema_reserved_country_tag_diagnostics(
file_path, statements,
));
sort_and_dedup_diagnostics(&mut diagnostics);
diagnostics
}
fn schema_reserved_country_tag_diagnostics(
file_path: &GamePath,
statements: &[AstStatement],
) -> Vec<SchemaDiagnostic> {
if !file_path.is_inside(&["common", "country_tags"], str::eq) {
return Vec::new();
}
statements
.iter()
.filter_map(|statement| {
let AstStatement::Assignment { key, key_span, .. } = statement else {
return None;
};
is_dynamic_country_tag(key).then(|| SchemaDiagnostic {
range: editor_range_from_span(key_span),
severity: Some(Severity::Warning),
code: Some("V010".to_string()),
source: Some(FOCH_DETECT_SOURCE.to_string()),
message: format!(
"tag `{key}` is one the game assigns to countries it creates at runtime; a country defined under it conflicts with them"
),
})
})
.collect()
}
pub(super) fn schema_localisation_diagnostics_for_text(
engine: &CwtQuery,
file_path: &GamePath,
text: &str,
definitions: &[LocalisationDefinition],
) -> Vec<SchemaDiagnostic> {
let parsed = parse_clausewitz_statements(ScriptSyntax::for_game_path(file_path), text);
schema_localisation_diagnostics_for_ast(engine, file_path, &parsed.statements, definitions)
}
fn schema_localisation_diagnostics_for_ast(
engine: &CwtQuery,
file_path: &GamePath,
statements: &[AstStatement],
definitions: &[LocalisationDefinition],
) -> Vec<SchemaDiagnostic> {
let defined_keys = definitions
.iter()
.map(|definition| definition.key.as_str())
.collect::<HashSet<_>>();
let mut diagnostics = schema_localisation_references_for_ast(engine, file_path, statements)
.into_iter()
.filter(|reference| !defined_keys.contains(reference.key.as_str()))
.map(|reference| schema_missing_localisation_diagnostic(&reference))
.collect::<Vec<_>>();
sort_and_dedup_diagnostics(&mut diagnostics);
diagnostics
}
#[derive(Clone, Debug)]
struct SchemaLocalisationReference {
key: String,
range: EditorRange,
origin: CompiledRuleOrigin,
}
#[derive(Clone, Debug)]
struct SchemaRootInstance<'a> {
path: Vec<String>,
items: &'a [AstStatement],
identity: String,
identity_range: EditorRange,
}
fn schema_localisation_references_for_ast(
engine: &CwtQuery,
file_path: &GamePath,
statements: &[AstStatement],
) -> Vec<SchemaLocalisationReference> {
let Some(root) = engine.bind_root(file_path) else {
return Vec::new();
};
if root.localisation.is_empty() {
return Vec::new();
}
let instances = schema_root_instances(root, statements, file_path);
let mut references = Vec::new();
for instance in instances {
let path = instance.path.iter().map(String::as_str).collect::<Vec<_>>();
let active_subtypes =
schema_active_subtypes_for_instance(engine, file_path, root, instance.items, &path);
for rule in &root.localisation {
if !schema_conditions_match(&rule.conditions, &active_subtypes) {
continue;
}
let Some(pattern) = schema_rule_scalar_pattern(&rule.value) else {
continue;
};
if pattern.contains('$') {
let key = pattern.replace('$', &instance.identity);
if !key.is_empty() {
references.push(SchemaLocalisationReference {
key,
range: instance.identity_range,
origin: rule.origin,
});
}
continue;
}
if let Some((key, range)) = scalar_assignment_in_statements(instance.items, pattern) {
references.push(SchemaLocalisationReference {
key,
range,
origin: rule.origin,
});
}
}
}
references.sort_by(|left, right| {
left.key
.cmp(&right.key)
.then_with(|| range_start(&left.range).cmp(&range_start(&right.range)))
});
references.dedup_by(|left, right| left.key == right.key && left.range == right.range);
references
}
fn schema_rule_scalar_pattern(value: &CompiledRuleValue) -> Option<&str> {
match value {
CompiledRuleValue::Scalar(value) | CompiledRuleValue::Marker(value) => Some(value),
CompiledRuleValue::Block(_) => None,
}
}
pub(super) fn schema_definition_names(
root: &CompiledRoot,
statements: &[AstStatement],
file_path: &GamePath,
) -> Vec<String> {
if root.type_per_file {
return file_path
.file_stem()
.map(str::to_string)
.into_iter()
.collect();
}
schema_root_instances(root, statements, file_path)
.into_iter()
.filter(|instance| {
root.type_key_filter
.as_ref()
.is_none_or(|filter| instance.path.last().is_some_and(|key| filter.matches(key)))
})
.map(|instance| instance.identity)
.collect()
}
fn schema_root_instances<'a>(
root: &CompiledRoot,
statements: &'a [AstStatement],
file_path: &GamePath,
) -> Vec<SchemaRootInstance<'a>> {
let mut instances = Vec::new();
collect_schema_root_instances(
root,
statements,
&root.skip_root_keys,
Vec::new(),
file_path,
&mut instances,
);
instances
}
fn collect_schema_root_instances<'a>(
root: &CompiledRoot,
statements: &'a [AstStatement],
remaining_skip_keys: &[String],
path: Vec<String>,
file_path: &GamePath,
out: &mut Vec<SchemaRootInstance<'a>>,
) {
if let Some((skip_key, rest)) = remaining_skip_keys.split_first() {
for statement in statements {
let AstStatement::Assignment { key, value, .. } = statement else {
continue;
};
if skip_key != "any" && key != skip_key {
continue;
}
let AstValue::Block { items, .. } = value else {
continue;
};
let mut child_path = path.clone();
child_path.push(key.clone());
collect_schema_root_instances(root, items, rest, child_path, file_path, out);
}
return;
}
for statement in statements {
let AstStatement::Assignment {
key,
key_span,
value,
..
} = statement
else {
continue;
};
let AstValue::Block { items, .. } = value else {
continue;
};
let mut instance_path = path.clone();
instance_path.push(key.clone());
let (identity, identity_range) =
schema_root_instance_identity(root, key, key_span, items, file_path);
out.push(SchemaRootInstance {
path: instance_path,
items,
identity,
identity_range,
});
}
}
fn schema_root_instance_identity(
root: &CompiledRoot,
key: &str,
key_span: &SpanRange,
items: &[AstStatement],
file_path: &GamePath,
) -> (String, EditorRange) {
if let Some(name_field) = root.name_field.as_deref()
&& let Some((value, range)) = scalar_assignment_in_statements(items, name_field)
{
return (value, range);
}
if root.name_from_file
&& let Some(stem) = file_path.file_stem()
{
return (stem.to_string(), editor_range_from_span(key_span));
}
(key.to_string(), editor_range_from_span(key_span))
}
fn scalar_assignment_in_statements(
statements: &[AstStatement],
target_key: &str,
) -> Option<(String, EditorRange)> {
statements.iter().find_map(|statement| {
let AstStatement::Assignment { key, value, .. } = statement else {
return None;
};
if key != target_key {
return None;
}
let AstValue::Scalar { value, span } = value else {
return None;
};
Some((value.as_text(), editor_range_from_span(span)))
})
}
fn schema_active_subtypes_for_instance(
engine: &CwtQuery,
file_path: &GamePath,
root: &CompiledRoot,
statements: &[AstStatement],
path: &[&str],
) -> HashSet<String> {
let mut active = HashSet::new();
if let Some(RuleContext::Subtype(_, subtype)) = engine.bind_context(file_path, path) {
active.insert(subtype.name.clone());
}
let definition_key = schema_definition_key(root, file_path, path);
for subtype in &root.subtypes {
if schema_subtype_matches_body(engine, subtype, definition_key, statements) {
active.insert(subtype.name.clone());
}
}
active
}
fn schema_missing_localisation_diagnostic(
reference: &SchemaLocalisationReference,
) -> SchemaDiagnostic {
SchemaDiagnostic {
range: reference.range,
severity: Some(Severity::Warning),
code: Some("missing-localisation".to_string()),
source: Some(rule_source(reference.origin).to_string()),
message: format!("localisation key not found: {}", reference.key),
}
}
#[derive(Clone, Copy)]
struct SchemaDiagnosticContext<'a> {
engine: &'a CwtQuery,
dynamic_values: Option<&'a SchemaWorkspace>,
file_path: &'a GamePath,
document_statements: &'a [AstStatement],
}
fn schema_select_field_alternative<'p>(
context: &SchemaDiagnosticContext<'p>,
alternatives: &[CompiledBindFieldMatch<'p>],
value: &AstValue,
active_subtypes: &HashSet<String>,
active_scopes: &[String],
) -> Option<CompiledBindFieldMatch<'p>> {
let actual = SchemaValueShape::from_ast_value(value);
let out_of_scope = |field_match: &CompiledBindFieldMatch<'p>| {
field_match
.alias()
.is_some_and(|alias| !schema_alias_scope_matches(context.engine, alias, active_scopes))
};
let mut same_shape = alternatives
.iter()
.copied()
.filter(|field_match| schema_value_shape(field_match.value()) == actual)
.collect::<Vec<_>>();
same_shape.sort_by_key(out_of_scope);
let first_same_shape = same_shape.first().copied();
let accepted = match value {
AstValue::Scalar { value: scalar, .. } => same_shape.into_iter().find(|field_match| {
schema_field_match_accepts_scalar(
context.engine,
context.dynamic_values,
field_match,
scalar,
)
}),
AstValue::Block { items, .. } => same_shape.into_iter().min_by_key(|field_match| {
schema_block_mismatch_count(
context,
schema_child_context_for_field_match(*field_match),
items,
active_subtypes,
)
}),
};
accepted
.or(first_same_shape)
.or_else(|| alternatives.first().copied())
}
fn schema_block_mismatch_count(
context: &SchemaDiagnosticContext<'_>,
rule_context: RuleContext<'_>,
statements: &[AstStatement],
active_subtypes: &HashSet<String>,
) -> usize {
statements
.iter()
.filter(|statement| {
let AstStatement::Assignment { key, value, .. } = statement else {
return false;
};
let alternatives = schema_bind_field_alternatives(
context.engine,
context.dynamic_values,
rule_context,
key,
active_subtypes,
);
let actual = SchemaValueShape::from_ast_value(value);
!alternatives.iter().any(|field_match| {
schema_value_shape(field_match.value()) == actual
&& match value {
AstValue::Scalar { value: scalar, .. } => {
schema_field_match_accepts_scalar(
context.engine,
context.dynamic_values,
field_match,
scalar,
)
}
AstValue::Block { .. } => true,
}
})
})
.count()
}
#[derive(Clone)]
struct SchemaBlockHint<'a> {
context: RuleContext<'a>,
scopes: Vec<String>,
}
impl<'a> SchemaBlockHint<'a> {
fn for_child(
engine: &CwtQuery,
field_match: CompiledBindFieldMatch<'a>,
key: &str,
parent_scopes: &[String],
) -> Self {
let context = schema_child_context_for_field_match(field_match);
let mut scopes = schema_active_scopes_after_key(engine, key, parent_scopes.to_vec());
let own = schema_context_own_active_scopes(engine, context);
if !own.is_empty() {
scopes = own;
}
if let CompiledBindFieldMatch::Alias { alias, .. } = field_match {
let pushed = schema_alias_pushed_scopes(engine, &alias.attributes);
if !pushed.is_empty() {
scopes = pushed;
}
}
Self { context, scopes }
}
}
fn collect_schema_diagnostics<'a>(
context: &SchemaDiagnosticContext<'a>,
hint: Option<&SchemaBlockHint<'a>>,
parent_alias: Option<&'a CompiledAlias>,
statements: &[AstStatement],
parent_path: &[String],
parent_range: Option<EditorRange>,
diagnostics: &mut Vec<SchemaDiagnostic>,
) {
let context_path = parent_path.iter().map(String::as_str).collect::<Vec<_>>();
let parent_context = hint.map(|hint| hint.context).or_else(|| {
schema_bind_context(
context.engine,
context.dynamic_values,
context.file_path,
context.document_statements,
&context_path,
)
});
let skip_unknown = matches!(parent_context, Some(RuleContext::AliasRules(_)));
let active_scopes = match hint {
Some(hint) => hint.scopes.clone(),
None => schema_active_scopes_for_path(
context.engine,
context.dynamic_values,
context.file_path,
context.document_statements,
&context_path,
),
};
let active_subtypes = schema_active_subtypes_for_path(
context.engine,
context.file_path,
context.document_statements,
&context_path,
);
let mut cardinality_bounds = HashMap::<String, SchemaCardinalityBound>::new();
let mut present_key_counts = HashMap::<String, u32>::new();
for statement in statements {
match statement {
AstStatement::Assignment {
key,
key_span,
value,
..
} => {
let (key, key_range) =
schema_key_after_conditional_opener(key, editor_range_from_span(key_span));
let value = schema_value_before_conditional_closer(value);
let value = value.as_ref();
let alternatives = parent_context
.map(|rule_context| {
schema_bind_field_alternatives(
context.engine,
context.dynamic_values,
rule_context,
key,
&active_subtypes,
)
})
.unwrap_or_default();
let field_match = schema_select_field_alternative(
context,
&alternatives,
value,
&active_subtypes,
&active_scopes,
)
.filter(|_| {
!contains_script_parameter(key) || matches!(value, AstValue::Block { .. })
});
let block_form_count = alternatives
.iter()
.filter(|field_match| {
schema_value_shape(field_match.value()) == SchemaValueShape::Block
})
.count();
if let Some(rule_context) = parent_context
&& field_match.is_none()
&& !skip_unknown
&& !contains_script_parameter(key)
{
diagnostics.push(schema_unknown_key_diagnostic(
key_range,
key,
schema_context_origin(rule_context),
));
}
if let Some(field_match) = field_match
&& let Some(diagnostic) = schema_alias_scope_diagnostic(
context.engine,
&field_match,
key_range,
key,
&active_scopes,
) {
diagnostics.push(diagnostic);
}
if let Some(field_match) = field_match
&& let Some(diagnostic) =
schema_value_shape_diagnostic(&field_match, key, value)
{
diagnostics.push(diagnostic);
}
if let Some(field_match) = field_match
&& let AstValue::Scalar {
value: scalar,
span,
} = value && !contains_script_parameter(&scalar.as_text())
{
if let Some(diagnostic) = schema_invalid_value_diagnostic(
context.engine,
context.dynamic_values,
&field_match,
key,
scalar,
span,
) {
diagnostics.push(diagnostic);
}
if let Some(diagnostic) =
schema_scalar_type_diagnostic(&field_match, key, scalar, span)
{
diagnostics.push(diagnostic);
}
if let Some(diagnostic) = schema_value_coercion_diagnostic(
&field_match,
parent_alias,
key,
scalar,
span,
) {
diagnostics.push(diagnostic);
}
}
if let Some(field_match) = field_match {
*present_key_counts
.entry(field_match.field().key.clone())
.or_default() += 1;
}
if let Some(field_match) = field_match
&& let Some((upper_bound, origin)) =
schema_restated_cardinality_upper(&field_match, &alternatives)
{
let severity =
schema_match_diagnostic_severity(&field_match, Severity::Warning);
let bound = cardinality_bounds
.entry(key.to_string())
.or_insert_with(|| SchemaCardinalityBound {
upper_bound,
severity,
origin,
occurrences: Vec::new(),
});
if upper_bound > bound.upper_bound {
bound.upper_bound = upper_bound;
bound.severity = severity;
bound.origin = origin;
}
bound.occurrences.push((
key_range,
match value {
AstValue::Scalar { value: scalar, .. } => {
SchemaRepeatValue::Scalar(scalar.as_text())
}
AstValue::Block { .. } => {
SchemaRepeatValue::Block(value_fingerprint(value))
}
},
));
}
if let AstValue::Block {
items,
span: block_span,
} = value
{
let mut child_path = parent_path.to_vec();
child_path.push(key.to_string());
let child_context = if parent_path.is_empty() {
SchemaDiagnosticContext {
document_statements: schema_definition_document(
context.engine,
context.file_path,
context.document_statements,
statement,
),
..*context
}
} else {
*context
};
let child_hint = field_match
.filter(|_| block_form_count > 1 || hint.is_some())
.filter(|field_match| {
schema_value_shape(field_match.value()) == SchemaValueShape::Block
})
.map(|field_match| {
SchemaBlockHint::for_child(
context.engine,
field_match,
key,
&active_scopes,
)
});
collect_schema_diagnostics(
&child_context,
child_hint.as_ref(),
field_match.and_then(|field_match| field_match.alias()),
items,
&child_path,
Some(editor_range_from_span(block_span)),
diagnostics,
);
}
}
AstStatement::Item {
value: AstValue::Block {
items,
span: block_span,
},
..
} => collect_schema_diagnostics(
context,
hint,
parent_alias,
items,
parent_path,
Some(editor_range_from_span(block_span)),
diagnostics,
),
AstStatement::Item { .. } | AstStatement::Comment { .. } => {}
}
}
for (key, bound) in cardinality_bounds {
let mut seen_values = HashSet::new();
let mut counted = 0_usize;
for (index, (range, repeat_value)) in bound.occurrences.into_iter().enumerate() {
let is_block = matches!(repeat_value, SchemaRepeatValue::Block(_));
if !seen_values.insert(repeat_value) {
if is_block && index >= bound.upper_bound as usize {
diagnostics.push(schema_repeated_block_diagnostic(
range,
&key,
bound.upper_bound,
));
}
continue;
}
counted += 1;
if counted > bound.upper_bound as usize {
diagnostics.push(schema_cardinality_diagnostic(
range,
&key,
bound.upper_bound,
bound.severity,
bound.origin,
));
}
}
}
let blank_file = parent_path.is_empty()
&& statements
.iter()
.all(|statement| matches!(statement, AstStatement::Comment { .. }));
if let Some(parent_context) = parent_context
&& !skip_unknown
&& !blank_file
{
let range = schema_context_diagnostic_range(parent_range, statements);
for (field, minimum) in schema_required_fields(parent_context, &active_subtypes) {
let present = present_key_counts
.get(&field.key)
.copied()
.unwrap_or_default();
if present < minimum {
let severity =
schema_attributes_diagnostic_severity(&field.attributes, Severity::Error);
diagnostics.push(schema_required_key_diagnostic(
range,
&field.key,
minimum,
severity,
field.origin,
));
}
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum SchemaRepeatValue {
Scalar(String),
Block(String),
}
struct SchemaCardinalityBound {
upper_bound: u32,
severity: Severity,
origin: CompiledRuleOrigin,
occurrences: Vec<(EditorRange, SchemaRepeatValue)>,
}
fn schema_context_origin(context: RuleContext<'_>) -> CompiledRuleOrigin {
match context {
RuleContext::RootType(root) | RuleContext::Subtype(root, _) => root.origin,
RuleContext::RuleField(field) => field.origin,
RuleContext::AliasRules(rules) => rules
.first()
.map_or(CompiledRuleOrigin::Base, |rule| rule.origin),
}
}
fn schema_cardinality_upper(field_match: &CompiledBindFieldMatch<'_>) -> Option<u32> {
if field_match.alias().is_some() {
return None;
}
schema_match_cardinality(field_match).and_then(|(_, upper_bound)| upper_bound)
}
fn schema_restated_cardinality_upper(
field_match: &CompiledBindFieldMatch<'_>,
alternatives: &[CompiledBindFieldMatch<'_>],
) -> Option<(u32, CompiledRuleOrigin)> {
let mut bound = (schema_cardinality_upper(field_match)?, field_match.origin());
for alternative in alternatives {
if alternative.field().key == field_match.field().key
&& alternative.value().states_same_rule(field_match.value())
{
let upper_bound = schema_cardinality_upper(alternative)?;
if upper_bound > bound.0
|| (upper_bound == bound.0 && alternative.origin() == CompiledRuleOrigin::Base)
{
bound = (upper_bound, alternative.origin());
}
}
}
Some(bound)
}
fn schema_match_cardinality(
field_match: &CompiledBindFieldMatch<'_>,
) -> Option<(u32, Option<u32>)> {
field_match
.field()
.attributes
.cardinality
.or_else(|| schema_required_cardinality(&field_match.field().attributes))
.or_else(|| {
field_match
.alias()
.and_then(|alias| schema_field_attributes_cardinality(&alias.attributes))
})
}
fn schema_required_fields<'p>(
context: RuleContext<'p>,
active_subtypes: &HashSet<String>,
) -> Vec<(&'p CompiledRuleField, u32)> {
let mut fields = completion_rule_fields(context)
.into_iter()
.filter(|field| schema_conditions_match(&field.conditions, active_subtypes))
.filter(|field| parse_schema_marker(&field.key).is_none())
.filter(|field| !is_schema_dynamic_key_marker(&field.key))
.filter_map(|field| {
schema_field_attributes_cardinality(&field.attributes)
.map(|(minimum, _)| (field, minimum))
})
.filter(|(_, minimum)| *minimum > 0)
.collect::<Vec<_>>();
fields.sort_by(|(left, _), (right, _)| left.key.cmp(&right.key));
fields.dedup_by(|(left, _), (right, _)| left.key == right.key);
fields
}
fn schema_field_attributes_cardinality(
attributes: &CompiledFieldAttributes,
) -> Option<(u32, Option<u32>)> {
attributes
.cardinality
.or_else(|| schema_required_cardinality(attributes))
}
fn schema_required_cardinality(attributes: &CompiledFieldAttributes) -> Option<(u32, Option<u32>)> {
attributes
.raw
.iter()
.any(|(key, value)| key == "required" && value.is_empty())
.then_some((1, None))
}
fn schema_match_diagnostic_severity(
field_match: &CompiledBindFieldMatch<'_>,
default: Severity,
) -> Severity {
field_match
.alias()
.and_then(|alias| alias.attributes.severity)
.or(field_match.field().attributes.severity)
.map(cwt_diagnostic_severity)
.unwrap_or(default)
}
fn schema_attributes_diagnostic_severity(
attributes: &CompiledFieldAttributes,
default: Severity,
) -> Severity {
attributes
.severity
.map(cwt_diagnostic_severity)
.unwrap_or(default)
}
fn cwt_diagnostic_severity(severity: CompiledSeverity) -> Severity {
match severity {
CompiledSeverity::Error => Severity::Error,
CompiledSeverity::Warning => Severity::Warning,
CompiledSeverity::Info => Severity::Info,
}
}
fn schema_context_diagnostic_range(
parent_range: Option<EditorRange>,
statements: &[AstStatement],
) -> EditorRange {
parent_range.unwrap_or_else(|| {
statements
.first()
.map(ast_statement_range)
.unwrap_or_else(zero_range)
})
}
fn ast_statement_range(statement: &AstStatement) -> EditorRange {
editor_range_from_span(statement_span(statement))
}
fn statement_span(statement: &AstStatement) -> &SpanRange {
match statement {
AstStatement::Assignment { span, .. }
| AstStatement::Item { span, .. }
| AstStatement::Comment { span, .. } => span,
}
}
fn zero_range() -> EditorRange {
EditorRange {
start: EditorPosition {
line: 0,
character: 0,
},
end: EditorPosition {
line: 0,
character: 0,
},
}
}
fn schema_invalid_value_diagnostic(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
field_match: &CompiledBindFieldMatch<'_>,
key: &str,
scalar: &ScalarValue,
span: &SpanRange,
) -> Option<SchemaDiagnostic> {
let values = schema_allowed_values(engine, dynamic_values, field_match.value())?;
let text = scalar.as_text();
if schema_allowed_values_accept(engine, &values, &text) {
return None;
}
Some(SchemaDiagnostic {
range: editor_range_from_span(span),
severity: Some(schema_match_diagnostic_severity(
field_match,
Severity::Error,
)),
code: Some("V003".to_string()),
source: Some(rule_source(field_match.origin()).to_string()),
message: format!(
"value `{text}` for `{key}` is not in schema {} `{}` (allowed: {})",
values.kind.label(),
values.name,
format_allowed_values(&values.values)
),
})
}
fn schema_allowed_values_accept(
engine: &CwtQuery,
values: &SchemaAllowedValues<'_>,
text: &str,
) -> bool {
schema_allowed_values_contain(values, text)
|| (values.kind == SchemaAllowedValueKind::Scope
&& schema_scope_reference_matches(engine, values.name, text))
}
fn schema_allowed_values_contain(values: &SchemaAllowedValues<'_>, text: &str) -> bool {
values
.values
.iter()
.any(|value| schema_allowed_value_matches(value, text))
|| (values.kind == SchemaAllowedValueKind::ComplexEnum
&& values.name == "country_tags"
&& is_dynamic_country_tag(text))
}
fn schema_allowed_value_matches(value: &str, text: &str) -> bool {
let mut value_words = value.split_ascii_whitespace();
let mut text_words = text.split_ascii_whitespace();
loop {
match (value_words.next(), text_words.next()) {
(None, None) => return true,
(Some(value_word), Some(text_word)) if value_word.eq_ignore_ascii_case(text_word) => {}
_ => return false,
}
}
}
fn is_dynamic_country_tag(text: &str) -> bool {
let [prefix, tens @ b'0'..=b'9', ones @ b'0'..=b'9'] = text.as_bytes() else {
return false;
};
let count = match prefix {
b'C' | b'D' | b'T' => 75,
b'E' => 50,
b'F' => 20,
b'K' => 100,
b'O' | b'S' => 10,
_ => return false,
};
u32::from(tens - b'0') * 10 + u32::from(ones - b'0') < count
}
fn schema_field_match_accepts_scalar(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
field_match: &CompiledBindFieldMatch<'_>,
scalar: &ScalarValue,
) -> bool {
if contains_script_parameter(&scalar.as_text()) {
return true;
}
if let Some(expected) = SchemaScalarType::from_rule_value(field_match.value())
&& !schema_scalar_type_matches(expected, scalar)
{
return false;
}
schema_allowed_values(engine, dynamic_values, field_match.value())
.is_none_or(|values| schema_allowed_values_accept(engine, &values, &scalar.as_text()))
}
fn schema_scalar_type_diagnostic(
field_match: &CompiledBindFieldMatch<'_>,
key: &str,
scalar: &ScalarValue,
span: &SpanRange,
) -> Option<SchemaDiagnostic> {
let expected = SchemaScalarType::from_rule_value(field_match.value())?;
if schema_scalar_type_matches(expected, scalar) {
return None;
}
Some(SchemaDiagnostic {
range: editor_range_from_span(span),
severity: Some(schema_match_diagnostic_severity(
field_match,
Severity::Error,
)),
code: Some("V005".to_string()),
source: Some(rule_source(field_match.origin()).to_string()),
message: format!(
"value `{}` for `{key}` does not match schema type `{}`",
scalar.as_text(),
expected.label()
),
})
}
fn schema_value_coercion_diagnostic(
field_match: &CompiledBindFieldMatch<'_>,
parent_alias: Option<&CompiledAlias>,
key: &str,
scalar: &ScalarValue,
span: &SpanRange,
) -> Option<SchemaDiagnostic> {
let (code, severity, message) = match (
SchemaScalarType::from_rule_value(field_match.value()),
scalar,
) {
(Some(SchemaScalarType::Bool), ScalarValue::Identifier(text))
if text.eq_ignore_ascii_case("yes") || text.eq_ignore_ascii_case("no") =>
{
(
"V009",
schema_match_diagnostic_severity(field_match, Severity::Error),
format!(
"`{key}` is read as false: the game compares against the exact lowercase `yes`, so `{text}` is not a boolean"
),
)
}
(_, scalar) if schema_field_reads_three_decimals(field_match, parent_alias, key) => {
let text = schema_scalar_number_text(scalar)?;
let (integer, fraction) = text.split_once('.')?;
let (kept, discarded) = fraction.split_at_checked(3)?;
if discarded.is_empty() {
return None;
}
let message = if discarded.bytes().all(|byte| byte == b'0') {
format!(
"`{key}` keeps three decimals: the game reads `{text}` as `{integer}.{kept}`; the dropped digits are zeros, so the value is unchanged"
)
} else {
format!(
"`{key}` keeps three decimals: the game reads `{text}` as `{integer}.{kept}` and discards `{discarded}`"
)
};
("V008", Severity::Info, message)
}
_ => return None,
};
Some(SchemaDiagnostic {
range: editor_range_from_span(span),
severity: Some(severity),
code: Some(code.to_string()),
source: Some(FOCH_DETECT_SOURCE.to_string()),
message,
})
}
fn schema_field_reads_three_decimals(
field_match: &CompiledBindFieldMatch<'_>,
parent_alias: Option<&CompiledAlias>,
key: &str,
) -> bool {
field_match
.alias()
.is_some_and(|alias| alias_reads_three_decimals(&alias.category, &alias.name))
|| key == "value"
&& parent_alias
.is_some_and(|alias| alias_value_reads_three_decimals(&alias.category, &alias.name))
}
fn schema_value_shape_diagnostic(
field_match: &CompiledBindFieldMatch<'_>,
key: &str,
value: &AstValue,
) -> Option<SchemaDiagnostic> {
let expected = schema_value_shape(field_match.value());
let actual = SchemaValueShape::from_ast_value(value);
if expected == actual {
return None;
}
Some(SchemaDiagnostic {
range: editor_range_from_span(value.span()),
severity: Some(schema_match_diagnostic_severity(
field_match,
Severity::Error,
)),
code: Some(SCHEMA_SHAPE_CODE.to_string()),
source: Some(rule_source(field_match.origin()).to_string()),
message: format!(
"value for `{key}` is a schema {}, but this assignment uses a {}",
expected.label(),
actual.label()
),
})
}
fn schema_alias_scope_diagnostic(
engine: &CwtQuery,
field_match: &CompiledBindFieldMatch<'_>,
range: EditorRange,
key: &str,
active_scopes: &[String],
) -> Option<SchemaDiagnostic> {
let alias = field_match.alias()?;
if schema_alias_scope_matches(engine, alias, active_scopes) {
return None;
}
Some(SchemaDiagnostic {
range,
severity: Some(schema_match_diagnostic_severity(
field_match,
Severity::Error,
)),
code: Some("V007".to_string()),
source: Some(rule_source(alias.origin).to_string()),
message: format!(
"alias `{key}` is scoped to {}, but current schema scope is {}",
format_scope_values(&alias.attributes.scope),
format_scope_refs(active_scopes)
),
})
}
fn schema_scalar_type_matches(scalar_type: SchemaScalarType, scalar: &ScalarValue) -> bool {
match scalar_type {
SchemaScalarType::Int { range } => schema_scalar_number_text(scalar).is_some_and(|value| {
value
.parse::<i64>()
.is_ok_and(|number| range.is_none_or(|range| range.contains(number)))
}),
SchemaScalarType::Float { range } => {
schema_scalar_number_text(scalar).is_some_and(|value| {
value
.parse::<f64>()
.is_ok_and(|number| range.is_none_or(|range| range.contains(number)))
})
}
SchemaScalarType::Bool => matches!(scalar, ScalarValue::Bool(_)),
}
}
fn schema_scalar_number_text(scalar: &ScalarValue) -> Option<&str> {
match scalar {
ScalarValue::Number(value) => Some(value),
ScalarValue::String(value) if matches!(unquoted_scalar(value), ScalarValue::Number(_)) => {
Some(value)
}
ScalarValue::Identifier(value) | ScalarValue::String(value) => {
value.strip_prefix('+').filter(|unsigned| {
!unsigned.starts_with('-')
&& matches!(unquoted_scalar(unsigned), ScalarValue::Number(_))
})
}
_ => None,
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SchemaValueShape {
Scalar,
Block,
}
impl SchemaValueShape {
fn from_ast_value(value: &AstValue) -> Self {
match value {
AstValue::Scalar { .. } => Self::Scalar,
AstValue::Block { .. } => Self::Block,
}
}
fn label(self) -> &'static str {
match self {
Self::Scalar => "scalar",
Self::Block => "block",
}
}
}
fn schema_value_shape(value: &CompiledRuleValue) -> SchemaValueShape {
match value {
CompiledRuleValue::Scalar(_) | CompiledRuleValue::Marker(_) => SchemaValueShape::Scalar,
CompiledRuleValue::Block(_) => SchemaValueShape::Block,
}
}
fn schema_allowed_values<'a>(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
value: &'a CompiledRuleValue,
) -> Option<SchemaAllowedValues<'a>> {
let value = match value {
CompiledRuleValue::Scalar(value) | CompiledRuleValue::Marker(value) => value,
CompiledRuleValue::Block(_) => return None,
};
let (head, name) = schema_allowed_value_marker(value)?;
schema_allowed_values_for_marker(engine, dynamic_values, head, name)
}
fn schema_dynamic_alias_values<'a>(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
alias: &'a CompiledAlias,
) -> Option<SchemaAllowedValues<'a>> {
let (head, name) = schema_allowed_value_marker(&alias.name)?;
schema_allowed_values_for_marker(engine, dynamic_values, head, name)
}
fn schema_allowed_values_for_marker<'a>(
engine: &CwtQuery,
dynamic_values: Option<&SchemaWorkspace>,
head: &'a str,
name: &'a str,
) -> Option<SchemaAllowedValues<'a>> {
let (kind, values) = match head {
"enum" => {
if let Some(values) = engine.enum_values(name) {
(SchemaAllowedValueKind::Enum, values.to_vec())
} else if let Some(complex_enum) = engine.complex_enum(name) {
(
SchemaAllowedValueKind::ComplexEnum,
dynamic_values?
.complex_enums
.get(&complex_enum.name)
.cloned()?,
)
} else {
return None;
}
}
"value" => (
SchemaAllowedValueKind::Value,
engine.value_set_values(name)?.to_vec(),
),
"value_set" => (
SchemaAllowedValueKind::ValueSet,
engine.value_set_values(name)?.to_vec(),
),
"scope" => (SchemaAllowedValueKind::Scope, engine.scope_values(name)),
_ => return None,
};
(!values.is_empty()).then_some(SchemaAllowedValues { kind, name, values })
}
fn schema_allowed_value_marker(text: &str) -> Option<(&str, &str)> {
match parse_schema_marker(text) {
Some(("enum", name)) => Some(("enum", name)),
Some(("value", name)) => Some(("value", name)),
Some(("value_set", name)) => Some(("value_set", name)),
Some(("scope", name)) => Some(("scope", name)),
_ => None,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct SchemaAllowedValues<'a> {
kind: SchemaAllowedValueKind,
name: &'a str,
values: Vec<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SchemaAllowedValueKind {
Enum,
ComplexEnum,
Value,
ValueSet,
Scope,
}
impl SchemaAllowedValueKind {
fn label(self) -> &'static str {
match self {
Self::Enum => "enum",
Self::ComplexEnum => "complex_enum",
Self::Value => "value",
Self::ValueSet => "value_set",
Self::Scope => "scope",
}
}
fn completion_kind(self) -> SchemaCompletionKind {
match self {
Self::Enum => SchemaCompletionKind::EnumMember,
Self::ComplexEnum => SchemaCompletionKind::EnumMember,
Self::Value => SchemaCompletionKind::Value,
Self::ValueSet => SchemaCompletionKind::Value,
Self::Scope => SchemaCompletionKind::Reference,
}
}
}
fn format_allowed_values(values: &[String]) -> String {
const MAX_VALUES: usize = 12;
let mut formatted = values
.iter()
.take(MAX_VALUES)
.map(|value| format!("`{value}`"))
.collect::<Vec<_>>();
if values.len() > MAX_VALUES {
formatted.push(format!("... {} more", values.len() - MAX_VALUES));
}
formatted.join(", ")
}
fn schema_unknown_key_diagnostic(
range: EditorRange,
key: &str,
origin: CompiledRuleOrigin,
) -> SchemaDiagnostic {
SchemaDiagnostic {
range,
severity: Some(Severity::Warning),
code: Some("V001".to_string()),
source: Some(rule_source(origin).to_string()),
message: format!("schema does not allow key `{key}` in this context"),
}
}
fn schema_cardinality_diagnostic(
range: EditorRange,
key: &str,
upper_bound: u32,
severity: Severity,
origin: CompiledRuleOrigin,
) -> SchemaDiagnostic {
SchemaDiagnostic {
range,
severity: Some(severity),
code: Some("V002".to_string()),
source: Some(rule_source(origin).to_string()),
message: format!("key `{key}` exceeds schema cardinality upper bound of {upper_bound}"),
}
}
fn schema_repeated_block_diagnostic(
range: EditorRange,
key: &str,
upper_bound: u32,
) -> SchemaDiagnostic {
SchemaDiagnostic {
range,
severity: Some(Severity::Info),
code: Some("V002".to_string()),
source: Some(FOCH_CWTOOLS_SOURCE.to_string()),
message: format!(
"key `{key}` repeats an identical block beyond the schema cardinality upper bound of {upper_bound}; if the game reads every copy, this block takes effect again"
),
}
}
fn schema_required_key_diagnostic(
range: EditorRange,
key: &str,
minimum: u32,
severity: Severity,
origin: CompiledRuleOrigin,
) -> SchemaDiagnostic {
SchemaDiagnostic {
range,
severity: Some(severity),
code: Some("V004".to_string()),
source: Some(rule_source(origin).to_string()),
message: format!("schema requires key `{key}` at least {minimum} time(s) in this context"),
}
}
fn current_assignment_key(line_prefix: &str) -> Option<&str> {
let equals = line_prefix.rfind('=')?;
let before = line_prefix[..equals].trim_end();
if before.is_empty() {
return None;
}
let mut start = before.len();
let bytes = before.as_bytes();
while start > 0 {
let character = bytes[start - 1] as char;
if is_identifier_char(character) {
start -= 1;
} else {
break;
}
}
(start != before.len()).then_some(&before[start..])
}
fn is_identifier_char(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | ':' | '$' | '@' | '-')
}
fn sort_and_dedup_diagnostics(diagnostics: &mut Vec<SchemaDiagnostic>) {
diagnostics.sort_by(|left, right| {
range_start(&left.range)
.cmp(&range_start(&right.range))
.then_with(|| left.message.cmp(&right.message))
});
diagnostics.dedup_by(|left, right| {
left.range == right.range && left.code == right.code && left.message == right.message
});
}
fn range_start(range: &EditorRange) -> (u32, u32) {
(range.start.line, range.start.character)
}