Skip to content

Commit 56ff5ae

Browse files
authored
fix(hir): error on widget method-chain modifier syntax (#201, closes #195)
Detects `Widget(...).modifierName(...)` pattern at HIR lowering and emits a clear compile error with rewrite hint pointing at the inline-options form. Modifier set: font, fontWeight, color, foregroundColor, padding, cornerRadius, background, opacity, lineLimit, frame, bold, italic, underline, fontSize, strikethrough, multilineTextAlignment, lineSpacing (and ~5 more). 6 regression tests including a positive control (plain widget call still works). Option B from issue body — Option A (chain-folding pre-pass) deliberately deferred. Closes #195.
1 parent ab925c2 commit 56ff5ae

3 files changed

Lines changed: 154 additions & 4 deletions

File tree

crates/perry-hir/src/lower.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5881,6 +5881,35 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result<
58815881
}
58825882
}
58835883

5884+
// issue #195: WidgetCtor(...).modifierName(...) is silently dropped.
5885+
// Reject at compile time so users discover the options-object form.
5886+
if let ast::Expr::Call(inner_call) = member.obj.as_ref() {
5887+
if let ast::Callee::Expr(inner_callee) = &inner_call.callee {
5888+
if let ast::Expr::Ident(widget_ident) = inner_callee.as_ref() {
5889+
let widget_name = widget_ident.sym.as_ref();
5890+
if matches!(widget_name,
5891+
"Text" | "VStack" | "HStack" | "ZStack" |
5892+
"Image" | "Spacer" | "Divider" |
5893+
"ForEach" | "Label" | "Gauge"
5894+
) {
5895+
if matches!(ctx.lookup_native_module(widget_name),
5896+
Some(("perry/ui", _))
5897+
) {
5898+
if let ast::MemberProp::Ident(method_ident) = &member.prop {
5899+
let modifier_name = method_ident.sym.as_ref();
5900+
if is_widget_modifier_name(modifier_name) {
5901+
return Err(anyhow!(
5902+
"modifier '{}' must be passed as an option-object on the widget constructor; use: {}(\"...\", {{ {}: ... }})",
5903+
modifier_name, widget_name, modifier_name
5904+
));
5905+
}
5906+
}
5907+
}
5908+
}
5909+
}
5910+
}
5911+
}
5912+
58845913
// Check for method calls on new Big/Decimal/BigNumber() expressions
58855914
// e.g., new Big("100").div(2)
58865915
if let Some(module_name) = detect_native_instance_expr(&member.obj) {
@@ -12656,6 +12685,19 @@ fn parse_modifiers_from_args(args: &[ast::ExprOrSpread], start_idx: usize) -> Ve
1265612685
modifiers
1265712686
}
1265812687

12688+
/// Returns true if `name` is a known widget modifier key (used to detect
12689+
/// unsupported method-chain modifier calls, e.g. `Text("hi").font("title")`).
12690+
fn is_widget_modifier_name(name: &str) -> bool {
12691+
matches!(name,
12692+
"font" | "fontWeight" | "weight" | "foregroundColor" | "color" | "foreground" |
12693+
"padding" | "cornerRadius" | "background" | "backgroundColor" |
12694+
"opacity" | "lineLimit" | "frame" | "minimumScaleFactor" |
12695+
"containerBackground" | "maxWidth" | "url" |
12696+
"bold" | "italic" | "underline" | "fontSize" |
12697+
"strikethrough" | "multilineTextAlignment" | "lineSpacing"
12698+
)
12699+
}
12700+
1265912701
/// Parse a single modifier from key/value
1266012702
fn parse_single_modifier(key: &str, value: &ast::Expr) -> Option<WidgetModifier> {
1266112703
match key {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/// Regression tests for issue #195: method-chain modifier syntax on perry/ui widgets
2+
/// must produce a compile error rather than silently dropping the modifier.
3+
///
4+
/// Supported form: Text("hi", { font: "title" })
5+
/// Rejected form: Text("hi").font("title") ← compile error
6+
7+
use perry_diagnostics::SourceCache;
8+
use perry_hir::lower_module;
9+
use perry_parser::parse_typescript_with_cache;
10+
11+
fn lower_result(src: &str) -> Result<perry_hir::Module, String> {
12+
let src = src.to_string();
13+
std::thread::Builder::new()
14+
.stack_size(32 * 1024 * 1024)
15+
.spawn(move || {
16+
let mut cache = SourceCache::new();
17+
let parsed = parse_typescript_with_cache(&src, "test.ts", &mut cache)
18+
.expect("parse should succeed");
19+
lower_module(&parsed.module, "test", "test.ts")
20+
.map_err(|e| e.to_string())
21+
})
22+
.expect("spawn lower thread")
23+
.join()
24+
.expect("lower thread panicked")
25+
}
26+
27+
/// Direct chain `Text("hi").font("title")` must fail with a diagnostic naming the modifier.
28+
#[test]
29+
fn text_dot_font_is_rejected() {
30+
let result = lower_result(r#"
31+
import { Text } from "perry/ui";
32+
Text("hi").font("title");
33+
"#);
34+
let err = result.unwrap_err();
35+
assert!(
36+
err.contains("modifier 'font'"),
37+
"expected error mentioning modifier 'font', got: {err}"
38+
);
39+
}
40+
41+
/// `.color(...)` on a widget constructor must also be rejected.
42+
#[test]
43+
fn text_dot_color_is_rejected() {
44+
let result = lower_result(r#"
45+
import { Text } from "perry/ui";
46+
Text("hi").color("red");
47+
"#);
48+
let err = result.unwrap_err();
49+
assert!(
50+
err.contains("modifier 'color'"),
51+
"expected error mentioning modifier 'color', got: {err}"
52+
);
53+
}
54+
55+
/// Chained modifiers fail on the FIRST one in the chain.
56+
#[test]
57+
fn chained_modifiers_fail_on_first() {
58+
let result = lower_result(r#"
59+
import { Text } from "perry/ui";
60+
Text("hi").font("title").color("red");
61+
"#);
62+
let err = result.unwrap_err();
63+
// Should mention 'font' (the first modifier in the chain).
64+
assert!(
65+
err.contains("modifier 'font'"),
66+
"expected error mentioning first modifier 'font', got: {err}"
67+
);
68+
}
69+
70+
/// Zero-arg modifier `.bold()` must also be rejected.
71+
#[test]
72+
fn text_dot_bold_is_rejected() {
73+
let result = lower_result(r#"
74+
import { Text } from "perry/ui";
75+
Text("hi").bold();
76+
"#);
77+
let err = result.unwrap_err();
78+
assert!(
79+
err.contains("modifier 'bold'"),
80+
"expected error mentioning modifier 'bold', got: {err}"
81+
);
82+
}
83+
84+
/// VStack with chained modifier must also be rejected.
85+
#[test]
86+
fn vstack_dot_padding_is_rejected() {
87+
let result = lower_result(r#"
88+
import { VStack, Text } from "perry/ui";
89+
VStack([Text("hi")]).padding(16);
90+
"#);
91+
let err = result.unwrap_err();
92+
assert!(
93+
err.contains("modifier 'padding'"),
94+
"expected error mentioning modifier 'padding', got: {err}"
95+
);
96+
}
97+
98+
/// Plain widget call with no modifiers must compile without error.
99+
#[test]
100+
fn plain_widget_call_is_accepted() {
101+
let result = lower_result(r#"
102+
import { Text } from "perry/ui";
103+
const handle = Text("hi");
104+
"#);
105+
result.expect("Text(\"hi\") with no modifier should compile without error");
106+
}

docs/src/widgets/components.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ Text(`${entry.name}: ${entry.value}`)
1111

1212
### Text Modifiers
1313

14+
Pass modifiers as a second argument options object — method-chain modifier syntax (e.g. `.font(...)`) produces a compile error:
15+
1416
```typescript,no-test
15-
const t = Text("Styled");
16-
t.font("title"); // .title, .headline, .body, .caption, etc.
17-
t.color("blue"); // Named color or hex
18-
t.bold();
17+
Text("Styled", { font: "title" }) // .title, .headline, .body, .caption, etc.
18+
Text("Styled", { color: "blue" }) // Named color or hex
19+
Text("Styled", { bold: true })
20+
Text("Styled", { font: "title", color: "blue", bold: true }) // combined
1921
```
2022

2123
## Layout

0 commit comments

Comments
 (0)