use lex_core::lex::ast::{
Annotation, ContentItem, Document, Range as CoreRange, Session, Verbatim,
};
use lex_core::lex::wire::to_wire_node;
use lex_extension::wire::{HostNodeKind, Range as WireRange, WireNode};
use lex_extension::{schema::Schema, AnnotationBody, LabelCtx, NodeRef};
use lex_extension_host::Registry;
use crate::diagnostics::{
AnalysisDiagnostic, DiagnosticKind, DiagnosticSeverity, SchemaValidationKind,
};
pub fn dispatch_labels(
document: &Document,
registry: &Registry,
diagnostics: &mut Vec<AnalysisDiagnostic>,
) {
if registry.namespace_count() == 0 {
return;
}
for annotation in document.annotations() {
visit_annotation(annotation, HostNodeKind::Document, registry, diagnostics);
}
walk_session(&document.root, HostNodeKind::Session, registry, diagnostics);
}
fn walk_session(
session: &Session,
self_kind: HostNodeKind,
registry: &Registry,
diagnostics: &mut Vec<AnalysisDiagnostic>,
) {
for annotation in session.annotations() {
visit_annotation(annotation, self_kind, registry, diagnostics);
}
for child in session.children.iter() {
visit_content(child, HostNodeKind::Session, registry, diagnostics);
}
}
fn visit_content(
item: &ContentItem,
_parent_kind: HostNodeKind,
registry: &Registry,
diagnostics: &mut Vec<AnalysisDiagnostic>,
) {
match item {
ContentItem::Paragraph(p) => {
for ann in p.annotations() {
visit_annotation(ann, HostNodeKind::Paragraph, registry, diagnostics);
}
}
ContentItem::Session(s) => walk_session(s, HostNodeKind::Session, registry, diagnostics),
ContentItem::Definition(def) => {
for ann in def.annotations() {
visit_annotation(ann, HostNodeKind::Definition, registry, diagnostics);
}
for child in def.children.iter() {
visit_content(child, HostNodeKind::Definition, registry, diagnostics);
}
}
ContentItem::List(list) => {
for ann in list.annotations() {
visit_annotation(ann, HostNodeKind::List, registry, diagnostics);
}
for entry in &list.items {
if let ContentItem::ListItem(li) = entry {
for ann in li.annotations() {
visit_annotation(ann, HostNodeKind::ListItem, registry, diagnostics);
}
for child in li.children.iter() {
visit_content(child, HostNodeKind::ListItem, registry, diagnostics);
}
}
}
}
ContentItem::Annotation(a) => {
visit_annotation(a, HostNodeKind::Annotation, registry, diagnostics);
}
ContentItem::VerbatimBlock(v) => {
visit_verbatim(v, registry, diagnostics);
for ann in v.annotations() {
visit_annotation(ann, HostNodeKind::Verbatim, registry, diagnostics);
}
}
ContentItem::Table(t) => {
for ann in t.annotations() {
visit_annotation(ann, HostNodeKind::Table, registry, diagnostics);
}
}
_ => {}
}
}
fn visit_annotation(
annotation: &Annotation,
attached_to: HostNodeKind,
registry: &Registry,
diagnostics: &mut Vec<AnalysisDiagnostic>,
) {
let label = annotation.data.label.value.clone();
let Some(schema) = registry.schema_for(&label) else {
if let Some((ns, _)) = label.split_once('.') {
if registry.is_namespace_healthy(ns) {
diagnostics.push(AnalysisDiagnostic {
range: annotation.location.clone(),
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::UnknownLabel),
message: format!(
"label `{label}` is not declared in registered namespace `{ns}`"
),
});
}
}
return;
};
let wire = to_wire_node(&ContentItem::Annotation(annotation.clone()));
let WireNode::Annotation {
label: _,
params,
body,
range,
origin,
} = wire
else {
return;
};
let body = match serde_json::from_value::<AnnotationBody>(body.clone()) {
Ok(b) => b,
Err(_) => AnnotationBody::None,
};
let ctx = LabelCtx {
label: label.clone(),
params: params.clone(),
body,
node: NodeRef {
kind: attached_to.as_str().to_string(),
range,
origin,
},
};
if let Some(diag) = pre_validate(&schema, &ctx, attached_to, &annotation.location) {
diagnostics.push(diag);
return;
}
if schema.hooks.label {
registry.dispatch_label(&ctx);
}
let namespace = label
.split_once('.')
.map(|(n, _)| n.to_string())
.unwrap_or_else(|| label.clone());
if schema.hooks.validate {
for d in registry.dispatch_validate(&ctx) {
diagnostics.push(handler_diagnostic_to_analysis(
d,
namespace.clone(),
annotation.location.clone(),
));
}
}
for d in registry.take_root_diagnostics() {
diagnostics.push(handler_diagnostic_to_analysis(
d,
namespace.clone(),
annotation.location.clone(),
));
}
}
fn visit_verbatim(v: &Verbatim, registry: &Registry, diagnostics: &mut Vec<AnalysisDiagnostic>) {
let label = v.closing_data.label.value.clone();
if label.is_empty() {
return;
}
let Some(schema) = registry.schema_for(&label) else {
if let Some((ns, _)) = label.split_once('.') {
if registry.is_namespace_healthy(ns) {
diagnostics.push(AnalysisDiagnostic {
range: v.location.clone(),
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::UnknownLabel),
message: format!(
"verbatim label `{label}` is not declared in registered namespace `{ns}`"
),
});
}
}
return;
};
if !schema.verbatim_label {
diagnostics.push(AnalysisDiagnostic {
range: v.location.clone(),
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment),
message: format!(
"label `{label}` is not declared as a verbatim closing (verbatim_label: false)"
),
});
return;
}
let wire = to_wire_node(&ContentItem::VerbatimBlock(Box::new(v.clone())));
let WireNode::Verbatim {
params,
body_text,
range,
origin,
..
} = wire
else {
return;
};
let ctx = LabelCtx {
label: label.clone(),
params,
body: AnnotationBody::Text(body_text),
node: NodeRef {
kind: HostNodeKind::Verbatim.as_str().to_string(),
range,
origin,
},
};
if let Some(diag) = pre_validate(&schema, &ctx, HostNodeKind::Verbatim, &v.location) {
diagnostics.push(diag);
return;
}
let namespace = label
.split_once('.')
.map(|(n, _)| n.to_string())
.unwrap_or_else(|| label.clone());
if schema.hooks.label {
registry.dispatch_label(&ctx);
}
if schema.hooks.validate {
for d in registry.dispatch_validate(&ctx) {
diagnostics.push(handler_diagnostic_to_analysis(
d,
namespace.clone(),
v.location.clone(),
));
}
}
for d in registry.take_root_diagnostics() {
diagnostics.push(handler_diagnostic_to_analysis(
d,
namespace.clone(),
v.location.clone(),
));
}
}
fn pre_validate(
schema: &Schema,
ctx: &LabelCtx,
attached_to: HostNodeKind,
range: &CoreRange,
) -> Option<AnalysisDiagnostic> {
use lex_extension::schema::{BodyKind, BodyPresence};
let attached_str = attached_to.as_str();
if !schema.attaches_to.is_empty() && !schema.attaches_to.iter().any(|kind| kind == attached_str)
{
return Some(AnalysisDiagnostic {
range: range.clone(),
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment),
message: format!(
"label `{}` is not permitted on `{attached_str}` (attaches_to: {})",
schema.label,
schema.attaches_to.join(", ")
),
});
}
let params_obj = ctx.params.as_object();
for (name, spec) in &schema.params {
let provided = params_obj.and_then(|m| m.get(name));
match (provided, spec.required) {
(None, true) => {
return Some(AnalysisDiagnostic {
range: range.clone(),
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::MissingParam),
message: format!(
"label `{}` is missing required param `{name}`",
schema.label
),
});
}
(None, false) => continue,
(Some(value), _) => {
if let Some(diag) = check_param_type(name, value, spec, schema, range) {
return Some(diag);
}
}
}
}
let body_effectively_empty = body_is_empty(&ctx.body);
if !body_effectively_empty {
let kind_matches = match (&ctx.body, schema.body.kind) {
(AnnotationBody::Text(_), BodyKind::Text) => true,
(AnnotationBody::Lex { .. }, BodyKind::Lex) => true,
(_, BodyKind::None) => false,
_ => false,
};
if !kind_matches {
return Some(AnalysisDiagnostic {
range: range.clone(),
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::BodyShapeMismatch),
message: format!(
"label `{}` body does not match declared body.kind: {:?}",
schema.label, schema.body.kind
),
});
}
}
if matches!(schema.body.presence, BodyPresence::Required)
&& body_effectively_empty
&& !matches!(schema.body.kind, BodyKind::None)
{
return Some(AnalysisDiagnostic {
range: range.clone(),
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::BodyShapeMismatch),
message: format!(
"label `{}` declares body.presence: required but no body was provided",
schema.label
),
});
}
None
}
fn body_is_empty(body: &AnnotationBody) -> bool {
match body {
AnnotationBody::None => true,
AnnotationBody::Text(s) => s.trim().is_empty(),
AnnotationBody::Lex { children } => {
children.is_empty() || children.iter().all(is_blank_wire_node)
}
}
}
fn is_blank_wire_node(node: &WireNode) -> bool {
match node {
WireNode::Paragraph { inlines, .. } => inlines.is_empty(),
WireNode::Blank { .. } => true,
_ => false,
}
}
fn check_param_type(
name: &str,
value: &serde_json::Value,
spec: &lex_extension::schema::ParamSpec,
schema: &Schema,
range: &CoreRange,
) -> Option<AnalysisDiagnostic> {
use lex_extension::schema::ParamType;
let mismatch = |reason: String| AnalysisDiagnostic {
range: range.clone(),
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::ParamTypeMismatch),
message: format!(
"label `{}` param `{name}` type mismatch: {reason}",
schema.label
),
};
match spec.ty {
ParamType::String => {
if !value.is_string() {
return Some(mismatch(format!("expected string, got {value}")));
}
}
ParamType::Bool => {
if !value.is_boolean()
&& !matches!(
value.as_str().map(str::to_ascii_lowercase).as_deref(),
Some("true") | Some("false")
)
{
return Some(mismatch(format!("expected bool, got {value}")));
}
}
ParamType::Int => {
let ok = value.is_i64()
|| value.is_u64()
|| value
.as_str()
.map(|s| s.parse::<i64>().is_ok())
.unwrap_or(false);
if !ok {
return Some(mismatch(format!("expected int, got {value}")));
}
}
ParamType::Float => {
let ok = value.is_number()
|| value
.as_str()
.map(|s| s.parse::<f64>().is_ok())
.unwrap_or(false);
if !ok {
return Some(mismatch(format!("expected float, got {value}")));
}
}
ParamType::Enum => {
let s = value.as_str().unwrap_or("").to_string();
let known = spec.values.iter().any(|v| v.name == s);
if !known {
return Some(mismatch(format!(
"value {value} is not in declared enum (allowed: {})",
spec.values
.iter()
.map(|v| v.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
}
_ => {}
}
None
}
fn handler_diagnostic_to_analysis(
diag: lex_extension::Diagnostic,
namespace_label: String,
fallback_range: CoreRange,
) -> AnalysisDiagnostic {
let range = wire_range_to_core(&diag.range, &fallback_range);
let severity = match diag.severity {
lex_extension::DiagnosticSeverity::Error => DiagnosticSeverity::Error,
lex_extension::DiagnosticSeverity::Warning => DiagnosticSeverity::Warning,
lex_extension::DiagnosticSeverity::Info => DiagnosticSeverity::Info,
lex_extension::DiagnosticSeverity::Hint => DiagnosticSeverity::Hint,
_ => DiagnosticSeverity::Info,
};
AnalysisDiagnostic {
range,
severity,
kind: DiagnosticKind::Handler {
namespace: namespace_label,
code: diag.code,
},
message: diag.message,
}
}
fn wire_range_to_core(wire: &WireRange, fallback: &CoreRange) -> CoreRange {
use lex_core::lex::ast::Position as CorePosition;
let zero = wire.start.0 == 0 && wire.start.1 == 0 && wire.end.0 == 0 && wire.end.1 == 0;
if zero {
return fallback.clone();
}
CoreRange::new(
0..0,
CorePosition::new(wire.start.0 as usize, wire.start.1 as usize),
CorePosition::new(wire.end.0 as usize, wire.end.1 as usize),
)
}
#[cfg(test)]
mod tests {
use super::*;
use lex_core::lex::loader::DocumentLoader;
fn parse(src: &str) -> Document {
DocumentLoader::from_string(src)
.parse()
.expect("parse fixture")
}
use lex_extension::schema::{
BodyKind, BodyPresence, BodyShape, Capabilities, HookSet, ParamSpec, ParamType, Schema,
};
use lex_extension::{HandlerError, LexHandler};
use std::collections::BTreeMap;
fn schema(label: &str, attaches_to: Vec<&str>, hooks: HookSet) -> Schema {
Schema {
schema_version: 1,
label: label.into(),
description: None,
params: BTreeMap::new(),
attaches_to: attaches_to.into_iter().map(String::from).collect(),
body: BodyShape {
kind: BodyKind::None,
presence: BodyPresence::Optional,
description: None,
},
verbatim_label: false,
capabilities: Capabilities::default(),
hooks,
handler: None,
}
}
struct EchoValidate;
impl LexHandler for EchoValidate {
fn on_validate(
&self,
ctx: &LabelCtx,
) -> Result<Vec<lex_extension::Diagnostic>, HandlerError> {
Ok(vec![lex_extension::Diagnostic {
severity: lex_extension::DiagnosticSeverity::Warning,
message: format!("validate {}", ctx.label),
range: ctx.node.range,
code: Some("test.code".into()),
related: Vec::new(),
}])
}
}
#[test]
fn empty_registry_is_a_noop() {
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
let mut diags = Vec::new();
dispatch_labels(&doc, ®istry, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn unknown_namespace_silently_passes_through() {
let doc = parse(":: foo.bar ::\n");
let registry = Registry::new();
let acme = schema(
"acme.task",
vec!["paragraph", "annotation"],
HookSet {
validate: true,
..HookSet::default()
},
);
registry
.register_namespace("acme", vec![acme], Box::new(EchoValidate))
.unwrap();
let mut diags = Vec::new();
dispatch_labels(&doc, ®istry, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn registered_label_dispatches_validate_and_collects_diagnostic() {
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
let s = schema(
"acme.task",
vec!["annotation", "document"],
HookSet {
validate: true,
..HookSet::default()
},
);
registry
.register_namespace("acme", vec![s], Box::new(EchoValidate))
.unwrap();
let mut diags = Vec::new();
dispatch_labels(&doc, ®istry, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("acme.task"));
match &diags[0].kind {
DiagnosticKind::Handler { namespace, code } => {
assert_eq!(namespace, "acme");
assert_eq!(code.as_deref(), Some("test.code"));
}
other => panic!("expected Handler, got {other:?}"),
}
}
#[test]
fn missing_required_param_produces_schema_diagnostic_without_dispatching() {
let mut params = BTreeMap::new();
params.insert(
"src".into(),
ParamSpec {
ty: ParamType::String,
required: true,
default: None,
description: None,
pattern: None,
values: Vec::new(),
},
);
let s = Schema {
schema_version: 1,
label: "acme.thing".into(),
description: None,
params,
attaches_to: vec!["annotation".into(), "document".into()],
body: BodyShape {
kind: BodyKind::None,
presence: BodyPresence::Optional,
description: None,
},
verbatim_label: false,
capabilities: Capabilities::default(),
hooks: HookSet {
validate: true,
..HookSet::default()
},
handler: None,
};
struct Boom;
impl LexHandler for Boom {
fn on_validate(
&self,
_ctx: &LabelCtx,
) -> Result<Vec<lex_extension::Diagnostic>, HandlerError> {
panic!("handler must not be called; schema pre-validation failed");
}
}
let registry = Registry::new();
registry
.register_namespace("acme", vec![s], Box::new(Boom))
.unwrap();
let doc = parse(":: acme.thing ::\n");
let mut diags = Vec::new();
dispatch_labels(&doc, ®istry, &mut diags);
assert_eq!(diags.len(), 1);
match &diags[0].kind {
DiagnosticKind::SchemaValidation(SchemaValidationKind::MissingParam) => {}
other => panic!("expected MissingParam, got {other:?}"),
}
assert!(diags[0].message.contains("src"));
}
#[test]
fn bad_attachment_produces_schema_diagnostic() {
let s = schema(
"acme.def",
vec!["definition"],
HookSet {
validate: true,
..HookSet::default()
},
);
let registry = Registry::new();
registry
.register_namespace("acme", vec![s], Box::new(EchoValidate))
.unwrap();
let doc = parse("Some paragraph.\n:: acme.def ::\n");
let mut diags = Vec::new();
dispatch_labels(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| matches!(
d.kind,
DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment)
)),
"expected at least one BadAttachment diag, got: {diags:?}"
);
}
#[test]
fn document_level_annotation_matches_document_attaches_to() {
let s = schema(
"acme.docmeta",
vec!["document"],
HookSet {
validate: true,
..HookSet::default()
},
);
let registry = Registry::new();
registry
.register_namespace("acme", vec![s], Box::new(EchoValidate))
.unwrap();
let doc = parse(":: acme.docmeta ::\n");
let mut diags = Vec::new();
dispatch_labels(&doc, ®istry, &mut diags);
assert!(
!diags.iter().any(|d| matches!(
d.kind,
DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment)
)),
"document-level annotation should match attaches_to: [document], got: {diags:?}"
);
}
}