Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
2 changes: 1 addition & 1 deletion crates/vize/tests/check_server_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ fn check_server_maps_dependency_patch_diagnostics_to_the_parent_sfc() {
"message": "Type 'number' is not assignable to type 'string'.",
"severity": "error",
"line": 7,
"column": 34,
"column": 27,
"code": "TS2322"
}])
);
Expand Down

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

1 change: 1 addition & 0 deletions crates/vize_canon/src/virtual_ts/expressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
mod component_props;
#[cfg(test)]
mod component_props_tests;
mod prop_sources;
mod reserved_props;
mod statements;
mod vif_chain;
Expand Down
89 changes: 26 additions & 63 deletions crates/vize_canon/src/virtual_ts/expressions/component_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,53 +7,14 @@

use super::super::helpers::{to_camel_case, to_safe_identifier_fragment};
use super::super::types::{VizeMapping, VizeSubSpan};
use super::reserved_props::rewrite_reserved_template_prop;
use super::prop_sources::{generated_prop_value, prop_name_source_range, prop_value_source_range};
use vize_carton::FxHashMap;
use vize_carton::FxHashSet;
use vize_carton::String;
use vize_carton::append;
use vize_carton::cstr;
use vize_carton::profile;
use vize_croquis::croquis::{ComponentUsage, PassedProp};
use vize_croquis::drawer::strip_js_comments;

fn push_ts_string_literal(out: &mut String, value: &str) {
out.push('"');
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out.push('"');
}

fn generated_prop_value(
prop: &PassedProp,
template_prop_names: &FxHashSet<String>,
) -> Option<String> {
if !prop.is_dynamic {
let mut value = String::default();
if let Some(static_value) = prop.value.as_ref() {
push_ts_string_literal(&mut value, static_value.as_str());
} else {
value.push_str("true");
}
return Some(value);
}

let value = strip_js_comments(prop.value.as_ref()?.as_str());
let trimmed_value = value.as_ref().trim();
let rewritten_value = rewrite_reserved_template_prop(trimmed_value, template_prop_names);
Some(rewritten_value.as_ref().map_or_else(
|| String::from(value.as_ref()),
|s| String::from(s.as_str()),
))
}

fn has_inference_props(usage: &ComponentUsage) -> bool {
usage.props.iter().any(is_checkable_prop)
Expand All @@ -75,20 +36,6 @@ impl<'a> ComponentPropSource<'a> {
}
}

fn prop_value_source_range(
source_context: ComponentPropSource<'_>,
prop: &PassedProp,
) -> Option<std::ops::Range<usize>> {
let source = source_context.template?;
let value = prop.value.as_ref()?.as_str();
let prop_start = prop.start as usize;
let prop_end = prop.end as usize;
let raw_prop = source.get(prop_start..prop_end)?;
let relative_start = raw_prop.rfind(value)?;
let source_start = source_context.offset as usize + prop_start + relative_start;
Some(source_start..source_start + value.len())
}

fn collect_generated_class_bindings<'a>(
usage: &'a ComponentUsage,
template_prop_names: &FxHashSet<String>,
Expand Down Expand Up @@ -184,21 +131,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.
96 changes: 96 additions & 0 deletions crates/vize_canon/src/virtual_ts/expressions/prop_sources.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Authored source ranges and generated values for component prop checks.
//!
//! These helpers translate one passed prop into the pieces check emission
//! needs: the escaped or rewritten generated value, the authored
//! attribute-name range that anchors child prop-type errors (matching
//! vue-tsc), and the authored value range that anchors errors inside the
//! bound expression.

use super::component_props::ComponentPropSource;
use super::reserved_props::rewrite_reserved_template_prop;
use vize_carton::FxHashSet;
use vize_carton::String;
use vize_croquis::croquis::PassedProp;
use vize_croquis::drawer::strip_js_comments;

fn push_ts_string_literal(out: &mut String, value: &str) {
out.push('"');
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out.push('"');
}

pub(super) fn generated_prop_value(
prop: &PassedProp,
template_prop_names: &FxHashSet<String>,
) -> Option<String> {
if !prop.is_dynamic {
let mut value = String::default();
if let Some(static_value) = prop.value.as_ref() {
push_ts_string_literal(&mut value, static_value.as_str());
} else {
value.push_str("true");
}
return Some(value);
}

let value = strip_js_comments(prop.value.as_ref()?.as_str());
let trimmed_value = value.as_ref().trim();
let rewritten_value = rewrite_reserved_template_prop(trimmed_value, template_prop_names);
Some(rewritten_value.as_ref().map_or_else(
|| String::from(value.as_ref()),
|s| String::from(s.as_str()),
))
}

pub(super) fn prop_value_source_range(
source_context: ComponentPropSource<'_>,
prop: &PassedProp,
) -> Option<std::ops::Range<usize>> {
let source = source_context.template?;
let value = prop.value.as_ref()?.as_str();
let prop_start = prop.start as usize;
let prop_end = prop.end as usize;
let raw_prop = source.get(prop_start..prop_end)?;
let relative_start = raw_prop.rfind(value)?;
let source_start = source_context.offset as usize + prop_start + relative_start;
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.
pub(super) 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())
}
Loading
Loading