use crate::cross_file::diagnostics::{
CrossFileDiagnostic, CrossFileDiagnosticKind, DiagnosticSeverity,
};
use crate::cross_file::graph::DependencyGraph;
use crate::cross_file::registry::{FileId, ModuleRegistry};
use vize_carton::{cstr, CompactString, FxHashMap, FxHashSet, String};
#[derive(Debug, Clone)]
pub struct PropsValidationIssue {
pub parent_file: FileId,
pub child_file: FileId,
pub component_name: CompactString,
pub kind: PropsValidationIssueKind,
pub offset: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PropsValidationIssueKind {
UndeclaredProp { prop_name: CompactString },
MissingRequiredProp { prop_name: CompactString },
TypeMismatch {
prop_name: CompactString,
expected: CompactString,
actual: CompactString,
},
}
#[derive(Debug, Default)]
struct ComponentPropsInfo {
props: FxHashMap<CompactString, PropInfo>,
}
#[derive(Debug, Clone)]
struct PropInfo {
required: bool,
#[allow(dead_code)] prop_type: Option<CompactString>,
}
pub fn analyze_props_validation(
registry: &ModuleRegistry,
graph: &DependencyGraph,
) -> (Vec<PropsValidationIssue>, Vec<CrossFileDiagnostic>) {
let mut issues = Vec::new();
let mut diagnostics = Vec::new();
let mut component_props: FxHashMap<CompactString, (FileId, ComponentPropsInfo)> =
FxHashMap::default();
for entry in registry.iter() {
if !entry.is_vue_sfc {
continue;
}
let Some(ref component_name) = entry.component_name else {
continue;
};
let mut props_info = ComponentPropsInfo::default();
for prop in entry.analysis.macros.props() {
props_info.props.insert(
prop.name.clone(),
PropInfo {
required: prop.required,
prop_type: prop.prop_type.clone(),
},
);
}
component_props.insert(component_name.clone(), (entry.id, props_info));
}
for (parent_id, child_id) in graph.component_usage() {
let Some(parent_entry) = registry.get(parent_id) else {
continue;
};
let Some(child_entry) = registry.get(child_id) else {
continue;
};
let Some(ref child_component_name) = child_entry.component_name else {
continue;
};
let Some((_, child_props_info)) = component_props.get(child_component_name) else {
continue;
};
let passed_props = extract_passed_props_for_component(
&parent_entry.analysis,
child_component_name.as_str(),
);
for (prop_name, prop_info) in &child_props_info.props {
if prop_info.required && !passed_props.contains(prop_name.as_str()) {
let issue = PropsValidationIssue {
parent_file: parent_id,
child_file: child_id,
component_name: child_component_name.clone(),
kind: PropsValidationIssueKind::MissingRequiredProp {
prop_name: prop_name.clone(),
},
offset: 0,
};
issues.push(issue);
let diagnostic = CrossFileDiagnostic::new(
CrossFileDiagnosticKind::MissingRequiredProp {
prop_name: prop_name.clone(),
component_name: child_component_name.clone(),
},
DiagnosticSeverity::Error,
parent_id,
0,
cstr!(
"**Missing Required Prop**: `{}` must be passed to `<{}>`\n\n\
This prop is declared as required in the component's `defineProps`.",
prop_name,
child_component_name
),
)
.with_related(
child_id,
0,
cstr!("Prop `{prop_name}` is declared as required here"),
);
diagnostics.push(diagnostic);
}
}
for passed_prop in &passed_props {
if is_builtin_attr(passed_prop) {
continue;
}
if passed_prop.starts_with('@') || passed_prop.starts_with("on") {
continue;
}
let is_declared = child_props_info.props.contains_key(*passed_prop);
if !is_declared {
let issue = PropsValidationIssue {
parent_file: parent_id,
child_file: child_id,
component_name: child_component_name.clone(),
kind: PropsValidationIssueKind::UndeclaredProp {
prop_name: CompactString::new(*passed_prop),
},
offset: 0,
};
issues.push(issue);
let diagnostic = CrossFileDiagnostic::new(
CrossFileDiagnosticKind::UndeclaredProp {
prop_name: CompactString::new(*passed_prop),
component_name: child_component_name.clone(),
},
DiagnosticSeverity::Warning, parent_id,
0,
cstr!(
"**Undeclared Prop**: `{}` is passed to `<{}>` but not declared\n\n\
The prop is not defined in the component's `defineProps`.\n\
If intentional, it will fall through to the root element via `$attrs`.",
passed_prop, child_component_name
),
)
.with_suggestion(cstr!(
"Add to defineProps:\n```typescript\ndefineProps<{{\n {}: unknown\n}}>()\n```\n\n\
Or use `v-bind=\"$attrs\"` in the child component for fallthrough.",
passed_prop
));
diagnostics.push(diagnostic);
}
}
}
(issues, diagnostics)
}
fn extract_passed_props_for_component<'a>(
analysis: &'a crate::Croquis,
component_name: &str,
) -> FxHashSet<&'a str> {
let mut props = FxHashSet::default();
for usage in &analysis.component_usages {
if usage.name.as_str().eq_ignore_ascii_case(component_name)
|| to_pascal_case(usage.name.as_str()).eq_ignore_ascii_case(component_name)
{
for prop in &usage.props {
props.insert(prop.name.as_str());
}
}
}
props
}
#[inline]
fn to_pascal_case(s: &str) -> String {
s.split('-')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::default(),
Some(c) => c.to_uppercase().chain(chars).collect(),
}
})
.collect()
}
#[inline]
fn is_builtin_attr(name: &str) -> bool {
matches!(
name,
"key"
| "ref"
| "is"
| "class"
| "style"
| "id"
| "slot"
| "slot-scope"
| "v-slot"
| "v-if"
| "v-else"
| "v-else-if"
| "v-for"
| "v-show"
| "v-bind"
| "v-on"
| "v-model"
| "v-html"
| "v-text"
| "v-pre"
| "v-cloak"
| "v-once"
| "v-memo"
)
}
#[cfg(test)]
mod tests {
use super::is_builtin_attr;
#[test]
fn test_is_builtin_attr() {
assert!(is_builtin_attr("key"));
assert!(is_builtin_attr("ref"));
assert!(is_builtin_attr("v-model"));
assert!(!is_builtin_attr("myProp"));
assert!(!is_builtin_attr("customAttr"));
}
}