Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 55 additions & 9 deletions crates/vize_canon/src/virtual_ts/expressions/component_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,36 @@ fn prop_value_source_range(
Some(source_start..source_start + value.len())
}

/// Returns the authored range of the attribute name itself — `msg` inside
/// `:msg="expr"`, `v-bind:msg="expr"`, or `msg="text"`.
///
/// vue-tsc anchors prop-type diagnostics at the attribute name, so the
/// synthetic check identifier maps here for byte-identical positions.
fn prop_name_source_range(
source_context: ComponentPropSource<'_>,
prop: &PassedProp,
) -> Option<std::ops::Range<usize>> {
let source = source_context.template?;
let prop_start = prop.start as usize;
let raw_prop = source.get(prop_start..prop.end as usize)?;
let name_region = raw_prop.split('=').next().unwrap_or(raw_prop);
let name = prop.name.as_str();
// The authored name sits right after the binding prefix; matching there
// (instead of searching) keeps names like `bind` from anchoring inside
// `v-bind:` and modifiers like `.sync` from stealing the match.
let prefix_len = if let Some(rest) = name_region.strip_prefix("v-bind:") {
rest.starts_with(name).then_some("v-bind:".len())?
} else if let Some(rest) = name_region.strip_prefix(':') {
rest.starts_with(name).then_some(1)?
} else if name_region.starts_with(name) {
0
} else {
name_region.find(name)?
};
let source_start = source_context.offset as usize + prop_start + prefix_len;
Some(source_start..source_start + name.len())
}

fn collect_generated_class_bindings<'a>(
usage: &'a ComponentUsage,
template_prop_names: &FxHashSet<String>,
Expand Down Expand Up @@ -184,21 +214,37 @@ pub(crate) fn generate_component_prop_checks(
let check_name_end = ts.len();
append!(
*ts,
": __{component_type_name}_{idx}_prop_{safe_prop_name} = {};\n",
generated_value.as_str(),
": __{component_type_name}_{idx}_prop_{safe_prop_name} = ",
);
let value_gen_start = ts.len();
ts.push_str(generated_value.as_str());
let value_gen_end = ts.len();
ts.push_str(";\n");
let gen_stmt_end = ts.len();
append!(*ts, "{expr_indent}void {check_name};\n");

// The synthetic identifier receives the child prop-type error
// (TS2322-class), which vue-tsc anchors at the attribute name;
// the initializer keeps the exact authored expression so errors
// inside the value land on the authored bytes.
let name_src_range = prop_name_source_range(source_context, prop);
let mut sub_spans = Vec::new();
if let Some(src_range) = name_src_range.or_else(|| value_src_range.clone()) {
sub_spans.push(VizeSubSpan {
gen_range: check_name_start..check_name_end,
src_range,
});
}
if let Some(src_range) = value_src_range {
sub_spans.push(VizeSubSpan {
gen_range: value_gen_start..value_gen_end,
src_range,
});
}
mappings.push(VizeMapping {
gen_range: gen_stmt_start..gen_stmt_end,
src_range: prop_src_start..prop_src_end,
sub_spans: value_src_range
.map(|src_range| VizeSubSpan {
gen_range: check_name_start..check_name_end,
src_range,
})
.into_iter()
.collect(),
sub_spans,
});

if usage.vif_guard.is_some() {
Expand Down
109 changes: 95 additions & 14 deletions crates/vize_canon/src/virtual_ts/expressions/component_props_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const isLoading = false
}

#[test]
fn component_prop_check_maps_synthetic_name_to_bound_expression() {
fn component_prop_check_anchors_name_and_preserves_bound_expression() {
let script = r#"import Child from "./Child.vue"
const benchmarkMirror = 1
"#;
Expand All @@ -53,19 +53,37 @@ const benchmarkMirror = 1
let summary = analyzer.finish();

let output = generate_virtual_ts(&summary, Some(script), Some(&root), 0);

// The synthetic identifier — where the child prop-type error lands —
// anchors at the attribute name, matching vue-tsc.
let name_start = template.find(":code").expect("attribute present") + 1;
let name_range = name_start..name_start + "code".len();
let name_span = output
.mappings
.iter()
.flat_map(|mapping| &mapping.sub_spans)
.find(|span| span.src_range == name_range)
.expect("component prop check should anchor at the attribute name");
assert!(
output.code[name_span.gen_range.clone()].starts_with("__vize_prop_check_"),
"synthetic check identifier should map to the attribute name: {name_span:?}"
);

// The initializer keeps the exact authored expression range so errors
// inside the value land on the authored bytes.
let expression = "String(benchmarkMirror)";
let source_start = template.find(expression).expect("bound expression present");
let source_range = source_start..source_start + expression.len();
let sub_span = output
let value_span = output
.mappings
.iter()
.flat_map(|mapping| &mapping.sub_spans)
.find(|span| span.src_range == source_range)
.expect("component prop check should preserve the exact bound expression range");

assert!(
output.code[sub_span.gen_range.clone()].starts_with("__vize_prop_check_"),
"synthetic check identifier should map to the bound expression: {sub_span:?}"
assert_eq!(
&output.code[value_span.gen_range.clone()],
expression,
"initializer sub-span should cover the generated expression"
);
}

Expand Down Expand Up @@ -99,18 +117,30 @@ fn static_attribute_values_are_type_checked_like_dynamic_bindings() {
output.code
);

// The synthetic check identifier maps back to the authored attribute value.
let value_start = template.find("You did it!").expect("static value present");
let value_range = value_start..value_start + "You did it!".len();
let sub_span = output
// The synthetic check identifier anchors at the attribute name, and the
// initializer keeps the authored value range.
let name_start = template.find("msg=").expect("attribute present");
let name_range = name_start..name_start + "msg".len();
let name_span = output
.mappings
.iter()
.flat_map(|mapping| &mapping.sub_spans)
.find(|span| span.src_range == value_range)
.expect("static prop check should map to the authored attribute value");
.find(|span| span.src_range == name_range)
.expect("static prop check should anchor at the attribute name");
assert!(
output.code[sub_span.gen_range.clone()].starts_with("__vize_prop_check_"),
"synthetic check identifier should map to the static value: {sub_span:?}"
output.code[name_span.gen_range.clone()].starts_with("__vize_prop_check_"),
"synthetic check identifier should map to the attribute name: {name_span:?}"
);

let value_start = template.find("You did it!").expect("static value present");
let value_range = value_start..value_start + "You did it!".len();
assert!(
output
.mappings
.iter()
.flat_map(|mapping| &mapping.sub_spans)
.any(|span| span.src_range == value_range),
"static prop check should keep the authored value range"
);
}

Expand Down Expand Up @@ -207,3 +237,54 @@ const isLoading = false
output.code
);
}

#[test]
fn attribute_name_anchor_survives_prefixes_and_modifiers() {
let script = r#"import Child from "./Child.vue"
const bind = 1
const sync = 2
const fooBar = 3
"#;
let template = r#"<Child v-bind:bind="bind" :sync.camel="sync" :foo-bar="fooBar" />"#;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let allocator = vize_carton::Bump::new();
let (root, _) = vize_armature::parse(&allocator, template);
let mut analyzer = Analyzer::with_options(AnalyzerOptions::full());
analyzer.analyze_script_setup(script);
analyzer.analyze_template(&root);
let summary = analyzer.finish();

let output = generate_virtual_ts(&summary, Some(script), Some(&root), 0);
let sub_spans: Vec<_> = output
.mappings
.iter()
.flat_map(|mapping| &mapping.sub_spans)
.collect();

let bind_name = template.find("v-bind:bind").expect("v-bind attr") + "v-bind:".len();
assert!(
sub_spans.iter().any(
|span| span.src_range == (bind_name..bind_name + "bind".len())
&& output.code[span.gen_range.clone()].starts_with("__vize_prop_check_")
),
"v-bind: prefixed name should anchor after the prefix"
);

let sync_name = template.find(":sync.camel").expect("modifier attr") + 1;
assert!(
sub_spans.iter().any(
|span| span.src_range == (sync_name..sync_name + "sync".len())
&& output.code[span.gen_range.clone()].starts_with("__vize_prop_check_")
),
"modifier attributes should anchor at the name, not the modifier"
);

let kebab_name = template.find(":foo-bar").expect("kebab attr") + 1;
assert!(
sub_spans.iter().any(
|span| span.src_range == (kebab_name..kebab_name + "foo-bar".len())
&& output.code[span.gen_range.clone()].starts_with("__vize_prop_check_")
),
"kebab-case names should anchor across the whole hyphenated name, not a leading segment"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
44 changes: 23 additions & 21 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading