Skip to main content

webots_proto_schema/proto/
validation.rs

1//! PROTO document validation and diagnostics.
2//!
3//! This module provides validation for Webots PROTO documents, including:
4//! - Header validation (version and encoding)
5//! - PROTO interface validation (field types and default value conformance)
6//! - Node validation against Webots schema
7//! - IS bindings validation
8//! - DEF/USE validation
9//! - Array arity validation
10//! - Nullability validation
11
12use crate::proto::ast::*;
13use crate::proto::builtin_nodes::get_builtin_schema;
14use crate::proto::span::Span;
15use std::collections::HashMap;
16
17/// A Webots node schema field definition.
18#[derive(Debug, Clone)]
19pub struct SchemaField {
20    pub name: String,
21    pub field_type: FieldType,
22}
23
24/// A Webots node schema.
25#[derive(Debug, Clone)]
26pub struct NodeSchema {
27    pub name: &'static str,
28    pub fields: Vec<SchemaField>,
29}
30
31impl NodeSchema {
32    pub fn get_field_type(&self, name: &str) -> Option<FieldType> {
33        self.fields
34            .iter()
35            .find(|field| field.name == name)
36            .map(|field| field.field_type.clone())
37    }
38}
39
40/// A diagnostic message with location information.
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub struct Diagnostic {
43    /// The span where the diagnostic applies.
44    pub span: Span,
45    /// The severity of the diagnostic.
46    pub severity: Severity,
47    /// The diagnostic message.
48    pub message: String,
49    /// An optional suggestion for fixing the issue.
50    pub suggestion: Option<String>,
51}
52
53/// The severity of a diagnostic.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Severity {
56    /// An error that prevents correct processing.
57    Error,
58    /// A warning that doesn't prevent processing but may indicate a problem.
59    Warning,
60}
61
62/// A collection of diagnostics.
63#[derive(Debug, Clone, Default)]
64pub struct DiagnosticSet {
65    diagnostics: Vec<Diagnostic>,
66}
67
68impl DiagnosticSet {
69    /// Creates a new empty diagnostic set.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Adds a diagnostic to the set.
75    ///
76    /// # Arguments
77    ///
78    /// * `diagnostic` - The diagnostic to add.
79    pub fn add(&mut self, diagnostic: Diagnostic) {
80        self.diagnostics.push(diagnostic);
81    }
82
83    /// Adds multiple diagnostics to the set.
84    pub fn extend(&mut self, diagnostics: impl IntoIterator<Item = Diagnostic>) {
85        self.diagnostics.extend(diagnostics);
86    }
87
88    /// Returns an iterator over the diagnostics.
89    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
90        self.diagnostics.iter()
91    }
92
93    /// Returns true if there are any errors in the set.
94    pub fn has_errors(&self) -> bool {
95        self.diagnostics
96            .iter()
97            .any(|d| matches!(d.severity, Severity::Error))
98    }
99
100    /// Returns the number of diagnostics.
101    pub fn len(&self) -> usize {
102        self.diagnostics.len()
103    }
104
105    /// Returns true if there are no diagnostics.
106    pub fn is_empty(&self) -> bool {
107        self.diagnostics.is_empty()
108    }
109}
110
111pub(crate) fn validate_document(document: &Proto) -> DiagnosticSet {
112    let mut diagnostics = DiagnosticSet::new();
113    let mut context = ValidationContext::new();
114
115    diagnostics.extend(validate_header(document));
116
117    if let Some(proto_def) = &document.proto {
118        context.interface_fields = proto_def.fields.clone();
119        diagnostics.extend(validate_interface(proto_def));
120        diagnostics.extend(validate_node_body(&proto_def.body, &mut context));
121    }
122
123    for node in &document.root_nodes {
124        diagnostics.extend(validate_ast_node(node, &mut context));
125    }
126
127    diagnostics
128}
129
130/// Validates runtime-only semantic rules on an AST node tree.
131///
132/// This intentionally skips schema typing and IS/DEF/USE checks.
133pub(crate) fn validate_runtime_semantics(node: &AstNode) -> DiagnosticSet {
134    let mut diagnostics = DiagnosticSet::new();
135    validate_runtime_semantics_recursive(node, &mut diagnostics);
136    diagnostics
137}
138
139/// Validation context to track DEF/USE and interface fields.
140#[derive(Debug, Clone, Default)]
141pub struct ValidationContext {
142    pub defs: HashMap<String, String>, // name -> type_name
143    pub interface_fields: Vec<ProtoField>,
144}
145
146impl ValidationContext {
147    pub fn new() -> Self {
148        Self::default()
149    }
150}
151
152/// Validates a PROTO document header.
153fn validate_header(document: &Proto) -> DiagnosticSet {
154    let mut diagnostics = DiagnosticSet::new();
155
156    if let Some(header) = &document.header {
157        // Validate version
158        if header.version != "R2025a" {
159            diagnostics.add(Diagnostic {
160                span: header.span.clone(),
161                severity: Severity::Error,
162                message: format!("Unsupported version: {}", header.version),
163                suggestion: Some("Use R2025a".to_string()),
164            });
165        }
166
167        // Validate encoding
168        if header.encoding != "utf8" {
169            diagnostics.add(Diagnostic {
170                span: header.span.clone(),
171                severity: Severity::Error,
172                message: format!("Unsupported encoding: {}", header.encoding),
173                suggestion: Some("Use utf8".to_string()),
174            });
175        }
176    } else if document.proto.is_some() || !document.root_nodes.is_empty() {
177        diagnostics.add(Diagnostic {
178            span: Span::default(),
179            severity: Severity::Error,
180            message: "Missing PROTO header".to_string(),
181            suggestion: Some("Add #VRML_SIM R2025a utf8 at the beginning of the file".to_string()),
182        });
183    }
184
185    diagnostics
186}
187
188/// Validates a PROTO interface.
189fn validate_interface(proto_def: &ProtoDefinition) -> DiagnosticSet {
190    let mut diagnostics = DiagnosticSet::new();
191
192    for field in &proto_def.fields {
193        if let Some(default_value) = &field.default_value {
194            diagnostics.extend(validate_field_value(
195                default_value,
196                &field.field_type,
197                &field.span,
198            ));
199
200            if let Some(restrictions) = &field.restrictions {
201                // Check if default value satisfies restrictions
202                if !restrictions
203                    .iter()
204                    .any(|value| values_are_equal(value, default_value))
205                {
206                    diagnostics.add(Diagnostic {
207                        span: field.span.clone(),
208                        severity: Severity::Error,
209                        message: format!(
210                            "Default value {:?} is not in the allowed restrictions",
211                            default_value
212                        ),
213                        suggestion: None,
214                    });
215                }
216            }
217        }
218
219        if let Some(restrictions) = &field.restrictions {
220            for res in restrictions {
221                diagnostics.extend(validate_field_value(res, &field.field_type, &field.span));
222            }
223        }
224    }
225
226    diagnostics
227}
228
229fn values_are_equal(a: &FieldValue, b: &FieldValue) -> bool {
230    if let (FieldValue::Int(ia, _), FieldValue::Float(fb, _)) = (a, b) {
231        *ia as f64 == *fb
232    } else if let (FieldValue::Float(fa, _), FieldValue::Int(ib, _)) = (a, b) {
233        *fa == *ib as f64
234    } else {
235        a == b
236    }
237}
238
239/// Validates a PROTO node body.
240fn validate_node_body(items: &[ProtoBodyItem], context: &mut ValidationContext) -> DiagnosticSet {
241    let mut diagnostics = DiagnosticSet::new();
242
243    for item in items {
244        match item {
245            ProtoBodyItem::Node(node) => {
246                diagnostics.extend(validate_ast_node(node, context));
247            }
248            ProtoBodyItem::Template(_) => {
249                // Template blocks are skipped in static validation for now
250            }
251        }
252    }
253
254    diagnostics
255}
256
257/// Validates an AST node.
258fn validate_ast_node(node: &AstNode, context: &mut ValidationContext) -> DiagnosticSet {
259    let mut diagnostics = DiagnosticSet::new();
260
261    match &node.kind {
262        AstNodeKind::Node {
263            type_name,
264            def_name,
265            fields,
266        } => {
267            if let Some(name) = def_name {
268                context.defs.insert(name.clone(), type_name.clone());
269            }
270
271            diagnostics.extend(validate_runtime_semantics_for_node(node));
272
273            if let Some(schema) = get_builtin_schema(type_name) {
274                for element in fields {
275                    if let NodeBodyElement::Field(field) = element {
276                        // 1. Schema Validation (Type Check)
277                        if let Some(expected_type) = schema.get_field_type(&field.name) {
278                            diagnostics.extend(validate_field_value(
279                                &field.value,
280                                &expected_type,
281                                &field.span,
282                            ));
283                            diagnostics.extend(validate_node_field_semantics(
284                                type_name,
285                                &field.name,
286                                &field.value,
287                                &field.span,
288                            ));
289
290                            // Check IS binding
291                            if let FieldValue::Is(ref_name) = &field.value {
292                                if let Some(interface_field) = context
293                                    .interface_fields
294                                    .iter()
295                                    .find(|f| f.name == *ref_name)
296                                {
297                                    if !types_are_compatible(
298                                        &interface_field.field_type,
299                                        &expected_type,
300                                    ) {
301                                        diagnostics.add(Diagnostic {
302                                            span: field.span.clone(),
303                                            severity: Severity::Error,
304                                            message: format!(
305                                                "Type mismatch in IS binding: interface field '{}' is {}, but node field '{}' expects {}",
306                                                ref_name,
307                                                field_type_name(&interface_field.field_type),
308                                                field.name,
309                                                field_type_name(&expected_type)
310                                            ),
311                                            suggestion: None,
312                                        });
313                                    }
314                                } else {
315                                    diagnostics.add(Diagnostic {
316                                        span: field.span.clone(),
317                                        severity: Severity::Error,
318                                        message: format!("Undefined IS reference: {}", ref_name),
319                                        suggestion: None,
320                                    });
321                                }
322                            }
323                        } else {
324                            let suggestion = find_closest_match(
325                                &field.name,
326                                schema.fields.iter().map(|field| field.name.as_str()),
327                            );
328                            diagnostics.add(Diagnostic {
329                                span: field.span.clone(),
330                                severity: Severity::Warning,
331                                message: format!(
332                                    "Unknown field '{}' for node '{}'",
333                                    field.name, type_name
334                                ),
335                                suggestion: suggestion.map(|s| format!("Did you mean '{}'?", s)),
336                            });
337                        }
338
339                        // 2. Content Recursion (DEF/USE Registration & Child Validation)
340                        // Verify content regardless of whether the field is known in the schema
341                        if let FieldValue::Node(child_node) = &field.value {
342                            diagnostics.extend(validate_ast_node(child_node, context));
343                        } else if let FieldValue::Array(arr) = &field.value {
344                            for el in &arr.elements {
345                                if let FieldValue::Node(child_node) = &el.value {
346                                    diagnostics.extend(validate_ast_node(child_node, context));
347                                }
348                            }
349                        }
350                    }
351                }
352            } else {
353                diagnostics.add(Diagnostic {
354                    span: node.span.clone(),
355                    severity: Severity::Warning,
356                    message: format!("Unknown node type '{}'", type_name),
357                    suggestion: None,
358                });
359
360                // Still validate fields even if node type is unknown (to find DEF/USE inside)
361                for element in fields {
362                    if let NodeBodyElement::Field(field) = element {
363                        if let FieldValue::Node(child_node) = &field.value {
364                            diagnostics.extend(validate_ast_node(child_node, context));
365                        } else if let FieldValue::Array(arr) = &field.value {
366                            for el in &arr.elements {
367                                if let FieldValue::Node(child_node) = &el.value {
368                                    diagnostics.extend(validate_ast_node(child_node, context));
369                                }
370                            }
371                        }
372                    }
373                }
374            }
375        }
376        AstNodeKind::Use { use_name } => {
377            if !context.defs.contains_key(use_name) {
378                diagnostics.add(Diagnostic {
379                    span: node.span.clone(),
380                    severity: Severity::Error,
381                    message: format!("Undefined USE reference: {}", use_name),
382                    suggestion: None,
383                });
384            }
385        }
386    }
387
388    diagnostics
389}
390
391fn validate_runtime_semantics_for_node(node: &AstNode) -> DiagnosticSet {
392    let mut diagnostics = DiagnosticSet::new();
393
394    let AstNodeKind::Node {
395        type_name, fields, ..
396    } = &node.kind
397    else {
398        return diagnostics;
399    };
400
401    if matches!(type_name.as_str(), "Solid" | "Robot") {
402        if solid_requires_inertia_warning(fields) {
403            diagnostics.add(Diagnostic {
404                span: node.span.clone(),
405                severity: Severity::Warning,
406                message:
407                    "Undefined inertia matrix: using the identity matrix. Please specify 'boundingObject' or 'inertiaMatrix' values."
408                        .to_string(),
409                suggestion: Some(
410                    "Add a non-NULL `boundingObject` on this Solid or set `Physics.inertiaMatrix`."
411                        .to_string(),
412                ),
413            });
414        }
415
416        diagnostics.extend(validate_sibling_solid_name_uniqueness(fields));
417    }
418
419    diagnostics
420}
421
422fn validate_runtime_semantics_recursive(node: &AstNode, diagnostics: &mut DiagnosticSet) {
423    diagnostics.extend(validate_runtime_semantics_for_node(node));
424
425    let AstNodeKind::Node { fields, .. } = &node.kind else {
426        return;
427    };
428
429    for element in fields {
430        let NodeBodyElement::Field(field) = element else {
431            continue;
432        };
433        collect_runtime_semantics_from_value(&field.value, diagnostics);
434    }
435}
436
437fn collect_runtime_semantics_from_value(value: &FieldValue, diagnostics: &mut DiagnosticSet) {
438    match value {
439        FieldValue::Node(node) => validate_runtime_semantics_recursive(node, diagnostics),
440        FieldValue::Array(array) => {
441            for element in &array.elements {
442                collect_runtime_semantics_from_value(&element.value, diagnostics);
443            }
444        }
445        _ => {}
446    }
447}
448
449fn solid_requires_inertia_warning(fields: &[NodeBodyElement]) -> bool {
450    let Some(physics_field) = get_node_field(fields, "physics") else {
451        return false;
452    };
453
454    // Webots warns when a physics-enabled Solid lacks both boundingObject and inertiaMatrix.
455    let physics_node = match &physics_field.value {
456        FieldValue::Node(node) => node,
457        FieldValue::Null => return false,
458        FieldValue::Template(_) | FieldValue::Is(_) => return false,
459        _ => return false,
460    };
461
462    let has_bounding_object = matches!(
463        get_node_field(fields, "boundingObject").map(|f| &f.value),
464        Some(FieldValue::Node(_)) | Some(FieldValue::Template(_)) | Some(FieldValue::Is(_))
465    );
466
467    if has_bounding_object {
468        return false;
469    }
470
471    !physics_has_explicit_inertia_matrix(physics_node)
472}
473
474fn physics_has_explicit_inertia_matrix(physics_node: &AstNode) -> bool {
475    let AstNodeKind::Node { fields, .. } = &physics_node.kind else {
476        return false;
477    };
478
479    let Some(inertia_matrix) = get_node_field(fields, "inertiaMatrix") else {
480        return false;
481    };
482
483    match &inertia_matrix.value {
484        FieldValue::Null => false,
485        FieldValue::Array(items) => !items.elements.is_empty(),
486        FieldValue::Template(_) | FieldValue::Is(_) => true,
487        _ => true,
488    }
489}
490
491fn validate_sibling_solid_name_uniqueness(fields: &[NodeBodyElement]) -> DiagnosticSet {
492    let mut diagnostics = DiagnosticSet::new();
493    let mut seen: HashMap<String, Span> = HashMap::new();
494
495    let child_solids = collect_immediate_child_solids(fields);
496    for child_solid in child_solids {
497        if let Some(first_span) = seen.get(&child_solid.name) {
498            diagnostics.add(Diagnostic {
499                span: child_solid.span.clone(),
500                severity: Severity::Warning,
501                message: format!(
502                    "'name' field value should be unique among sibling Solid nodes: '{}'",
503                    child_solid.name
504                ),
505                suggestion: Some(format!(
506                    "Rename one of the sibling Solid nodes; first occurrence is at line {}.",
507                    first_span.start_line
508                )),
509            });
510        } else {
511            seen.insert(child_solid.name, child_solid.span);
512        }
513    }
514
515    diagnostics
516}
517
518#[derive(Debug)]
519struct ChildSolidInfo {
520    name: String,
521    span: Span,
522}
523
524fn collect_immediate_child_solids(fields: &[NodeBodyElement]) -> Vec<ChildSolidInfo> {
525    let Some(children_field) = get_node_field(fields, "children") else {
526        return Vec::new();
527    };
528
529    collect_child_solids_from_value(&children_field.value)
530}
531
532fn collect_child_solids_from_value(value: &FieldValue) -> Vec<ChildSolidInfo> {
533    match value {
534        FieldValue::Node(node) => collect_child_solids_from_node(node),
535        FieldValue::Array(array) => {
536            let mut solids = Vec::new();
537            for element in &array.elements {
538                solids.extend(collect_child_solids_from_value(&element.value));
539            }
540            solids
541        }
542        _ => Vec::new(),
543    }
544}
545
546fn collect_child_solids_from_node(node: &AstNode) -> Vec<ChildSolidInfo> {
547    let AstNodeKind::Node {
548        type_name, fields, ..
549    } = &node.kind
550    else {
551        return Vec::new();
552    };
553
554    if type_name == "Solid" {
555        return vec![ChildSolidInfo {
556            name: get_effective_solid_name(node),
557            span: node.span.clone(),
558        }];
559    }
560
561    // Joint endPoint solids are siblings of regular children in Webots' Solid tree.
562    if let Some(end_point) = get_node_field(fields, "endPoint")
563        && let FieldValue::Node(end_point_node) = &end_point.value
564        && is_solid_node(end_point_node)
565    {
566        return vec![ChildSolidInfo {
567            name: get_effective_solid_name(end_point_node),
568            span: end_point_node.span.clone(),
569        }];
570    }
571
572    Vec::new()
573}
574
575fn is_solid_node(node: &AstNode) -> bool {
576    matches!(
577        &node.kind,
578        AstNodeKind::Node {
579            type_name,
580            fields: _,
581            def_name: _,
582        } if type_name == "Solid"
583    )
584}
585
586fn get_effective_solid_name(node: &AstNode) -> String {
587    let AstNodeKind::Node { fields, .. } = &node.kind else {
588        return "solid".to_string();
589    };
590
591    if let Some(name_field) = get_node_field(fields, "name")
592        && let FieldValue::String(name) = &name_field.value
593    {
594        return name.clone();
595    }
596
597    "solid".to_string()
598}
599
600fn get_node_field<'a>(fields: &'a [NodeBodyElement], name: &str) -> Option<&'a NodeField> {
601    fields.iter().find_map(|element| match element {
602        NodeBodyElement::Field(field) if field.name == name => Some(field),
603        _ => None,
604    })
605}
606
607fn validate_node_field_semantics(
608    parent_type_name: &str,
609    field_name: &str,
610    value: &FieldValue,
611    span: &Span,
612) -> DiagnosticSet {
613    let mut diagnostics = DiagnosticSet::new();
614
615    if parent_type_name == "Shape"
616        && field_name == "geometry"
617        && let FieldValue::Node(node) = value
618        && let AstNodeKind::Node { type_name, .. } = &node.kind
619        && type_name == "CadShape"
620    {
621        diagnostics.add(Diagnostic {
622            span: span.clone(),
623            severity: Severity::Warning,
624            message: "Skipped node: Cannot insert CadShape node in 'geometry' field of Shape node."
625                .to_string(),
626            suggestion: Some(
627                "Use a geometry node supported by Shape.geometry, such as Mesh, Box, Sphere, or IndexedFaceSet."
628                    .to_string(),
629            ),
630        });
631    }
632
633    diagnostics
634}
635
636/// Validates a field value against its expected type.
637fn validate_field_value(
638    value: &FieldValue,
639    expected_type: &FieldType,
640    span: &Span,
641) -> DiagnosticSet {
642    let mut diagnostics = DiagnosticSet::new();
643
644    let scalar_type_match = matches!(
645        (value, expected_type),
646        (FieldValue::Bool(_), FieldType::SFBool)
647            | (FieldValue::Int(_, _), FieldType::SFInt32)
648            | (FieldValue::Float(_, _), FieldType::SFFloat)
649            | (FieldValue::Int(_, _), FieldType::SFFloat)
650            | (FieldValue::String(_), FieldType::SFString)
651            | (FieldValue::Vec2f(_), FieldType::SFVec2f)
652            | (FieldValue::Vec3f(_), FieldType::SFVec3f)
653            | (FieldValue::Rotation(_), FieldType::SFRotation)
654            | (FieldValue::Color(_), FieldType::SFColor)
655            | (FieldValue::Node(_), FieldType::SFNode)
656            | (FieldValue::Null, FieldType::SFNode)
657    );
658
659    if scalar_type_match {
660        // Int can be promoted to Float by design.
661    } else if let FieldValue::Array(arr) = value {
662        if expected_type_is_multiple(expected_type) {
663            if !validate_flat_numeric_array(arr, expected_type, span, &mut diagnostics) {
664                let element_type = get_element_type(expected_type);
665                for el in &arr.elements {
666                    diagnostics.extend(validate_field_value(&el.value, &element_type, span));
667                }
668            }
669        } else {
670            diagnostics.add(Diagnostic {
671                span: span.clone(),
672                severity: Severity::Error,
673                message: format!(
674                    "Type mismatch: expected {}, found {:?}",
675                    field_type_name(expected_type),
676                    value
677                ),
678                suggestion: None,
679            });
680        }
681    } else if matches!(value, FieldValue::Is(_)) {
682        // Handled in validate_ast_node for IS bindings
683    } else if let FieldValue::NumberSequence(seq) = value {
684        if seq.elements.len() == 1 {
685            // Treat single number sequence as a single number/float
686            diagnostics.extend(validate_field_value(
687                &seq.elements[0].value,
688                expected_type,
689                span,
690            ));
691        } else {
692            // Check if sequence matches expected fixed-size type
693            let length = seq.elements.len();
694            if (expected_type == &FieldType::SFVec2f && length == 2)
695                || (expected_type == &FieldType::SFVec3f && length == 3)
696                || (expected_type == &FieldType::SFColor && length == 3)
697                || (expected_type == &FieldType::SFRotation && length == 4)
698            {
699            } else {
700                diagnostics.add(Diagnostic {
701                    span: span.clone(),
702                    severity: Severity::Error,
703                    message: format!(
704                        "Invalid number sequence length for {}",
705                        field_type_name(expected_type)
706                    ),
707                    suggestion: None,
708                });
709            }
710        }
711    } else if matches!(value, FieldValue::Null) && expected_type == &FieldType::MFNode {
712        // NULL is not allowed for MFNode (should be [])
713        diagnostics.add(Diagnostic {
714            span: span.clone(),
715            severity: Severity::Error,
716            message: "NULL not allowed for MFNode; use [] instead".to_string(),
717            suggestion: Some("[]".to_string()),
718        });
719    } else if matches!(value, FieldValue::Template(_)) {
720        // Templates are dynamic, assume they are correct type-wise for now
721    } else {
722        diagnostics.add(Diagnostic {
723            span: span.clone(),
724            severity: Severity::Error,
725            message: format!(
726                "Type mismatch: expected {}, found {:?}",
727                field_type_name(expected_type),
728                value
729            ),
730            suggestion: None,
731        });
732    }
733
734    diagnostics
735}
736
737fn validate_flat_numeric_array(
738    array: &ArrayValue,
739    expected_type: &FieldType,
740    span: &Span,
741    diagnostics: &mut DiagnosticSet,
742) -> bool {
743    let Some(group_size) = flat_numeric_group_size(expected_type) else {
744        return false;
745    };
746
747    if array.elements.is_empty() {
748        return true;
749    }
750
751    if !array
752        .elements
753        .iter()
754        .all(|element| is_numeric_scalar(&element.value))
755    {
756        return false;
757    }
758
759    if array.elements.len() % group_size != 0 {
760        diagnostics.add(Diagnostic {
761            span: span.clone(),
762            severity: Severity::Error,
763            message: format!(
764                "Invalid flat array length for {}: expected a multiple of {} values",
765                field_type_name(expected_type),
766                group_size
767            ),
768            suggestion: None,
769        });
770    }
771
772    true
773}
774
775fn flat_numeric_group_size(expected_type: &FieldType) -> Option<usize> {
776    match expected_type {
777        FieldType::MFVec2f => Some(2),
778        FieldType::MFVec3f => Some(3),
779        FieldType::MFRotation => Some(4),
780        FieldType::MFColor => Some(3),
781        _ => None,
782    }
783}
784
785fn is_numeric_scalar(value: &FieldValue) -> bool {
786    matches!(value, FieldValue::Int(_, _) | FieldValue::Float(_, _))
787}
788
789fn types_are_compatible(actual: &FieldType, expected: &FieldType) -> bool {
790    // For IS bindings, Webots generally requires exact matches
791    actual == expected
792}
793
794fn expected_type_is_multiple(t: &FieldType) -> bool {
795    matches!(
796        t,
797        FieldType::MFBool
798            | FieldType::MFInt32
799            | FieldType::MFFloat
800            | FieldType::MFString
801            | FieldType::MFVec2f
802            | FieldType::MFVec3f
803            | FieldType::MFColor
804            | FieldType::MFRotation
805            | FieldType::MFNode
806    )
807}
808
809fn get_element_type(t: &FieldType) -> FieldType {
810    if *t == FieldType::MFBool {
811        FieldType::SFBool
812    } else if *t == FieldType::MFInt32 {
813        FieldType::SFInt32
814    } else if *t == FieldType::MFFloat {
815        FieldType::SFFloat
816    } else if *t == FieldType::MFString {
817        FieldType::SFString
818    } else if *t == FieldType::MFVec2f {
819        FieldType::SFVec2f
820    } else if *t == FieldType::MFVec3f {
821        FieldType::SFVec3f
822    } else if *t == FieldType::MFColor {
823        FieldType::SFColor
824    } else if *t == FieldType::MFRotation {
825        FieldType::SFRotation
826    } else if *t == FieldType::MFNode {
827        FieldType::SFNode
828    } else {
829        t.clone()
830    }
831}
832
833fn field_type_name(t: &FieldType) -> String {
834    match t {
835        FieldType::SFBool => "SFBool".to_string(),
836        FieldType::SFInt32 => "SFInt32".to_string(),
837        FieldType::SFFloat => "SFFloat".to_string(),
838        FieldType::SFString => "SFString".to_string(),
839        FieldType::SFVec2f => "SFVec2f".to_string(),
840        FieldType::SFVec3f => "SFVec3f".to_string(),
841        FieldType::SFRotation => "SFRotation".to_string(),
842        FieldType::SFColor => "SFColor".to_string(),
843        FieldType::SFNode => "SFNode".to_string(),
844        FieldType::MFBool => "MFBool".to_string(),
845        FieldType::MFInt32 => "MFInt32".to_string(),
846        FieldType::MFFloat => "MFFloat".to_string(),
847        FieldType::MFString => "MFString".to_string(),
848        FieldType::MFVec2f => "MFVec2f".to_string(),
849        FieldType::MFVec3f => "MFVec3f".to_string(),
850        FieldType::MFRotation => "MFRotation".to_string(),
851        FieldType::MFColor => "MFColor".to_string(),
852        FieldType::MFNode => "MFNode".to_string(),
853        FieldType::Unknown(s) => format!("Unknown({})", s),
854    }
855}
856
857fn find_closest_match<'a>(
858    name: &str,
859    candidates: impl Iterator<Item = &'a str>,
860) -> Option<&'a str> {
861    let mut best_match = None;
862    let mut best_distance = usize::MAX;
863
864    for candidate in candidates {
865        let distance = levenshtein_distance(name, candidate);
866        if distance < best_distance && distance <= 3 {
867            best_distance = distance;
868            best_match = Some(candidate);
869        }
870    }
871
872    best_match
873}
874
875fn levenshtein_distance(s1: &str, s2: &str) -> usize {
876    let v1: Vec<char> = s1.chars().collect();
877    let v2: Vec<char> = s2.chars().collect();
878    let n = v1.len();
879    let m = v2.len();
880
881    let mut dp = vec![vec![0; m + 1]; n + 1];
882
883    for (i, row) in dp.iter_mut().enumerate().take(n + 1) {
884        row[0] = i;
885    }
886    for (j, cell) in dp[0].iter_mut().enumerate().take(m + 1) {
887        *cell = j;
888    }
889
890    for i in 1..=n {
891        for j in 1..=m {
892            let cost = if v1[i - 1] == v2[j - 1] { 0 } else { 1 };
893            dp[i][j] = std::cmp::min(
894                dp[i - 1][j] + 1,
895                std::cmp::min(dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost),
896            );
897        }
898    }
899
900    dp[n][m]
901}
902
903impl IntoIterator for DiagnosticSet {
904    type Item = Diagnostic;
905    type IntoIter = std::vec::IntoIter<Diagnostic>;
906
907    fn into_iter(self) -> Self::IntoIter {
908        self.diagnostics.into_iter()
909    }
910}