Skip to content

Commit 24bf557

Browse files
committed
Bind working somewhat
1 parent 626a09c commit 24bf557

File tree

4 files changed

+217
-10
lines changed

4 files changed

+217
-10
lines changed

src/codegen/js.rs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,8 +1482,46 @@ fn gen_op_with_dicts(ctx: &CodegenCtx, op: &crate::cst::QualifiedIdent) -> JsExp
14821482
}
14831483
}
14841484

1485-
/// Apply a binary operator: `op(left)(right)`
1486-
fn apply_binop_js(op_expr: JsExpr, left: JsExpr, right: JsExpr) -> JsExpr {
1485+
/// Check if an operator is `$` (apply) or `#` (applyFlipped) which should be inlined
1486+
/// as direct function application rather than generating `apply(f)(x)`.
1487+
fn is_inline_apply_op(ctx: &CodegenCtx, op_name: Symbol) -> Option<bool> {
1488+
// Returns Some(false) for `$` (normal order: left(right))
1489+
// Returns Some(true) for `#` (flipped order: right(left))
1490+
// Returns None for other operators
1491+
if let Some((_, target_fn)) = ctx.operator_targets.get(&op_name) {
1492+
let name = interner::resolve(*target_fn).unwrap_or_default();
1493+
// Only inline operators that target regular functions, not class methods.
1494+
// operator_class_targets contains operators whose targets are class methods
1495+
// (e.g., <*> → Control.Apply.apply). Operators NOT in this map target regular
1496+
// functions (e.g., $ → Data.Function.apply) and are safe to inline.
1497+
let is_class_op = ctx.exports.operator_class_targets.contains_key(&op_name)
1498+
|| ctx.module.imports.iter().any(|imp| {
1499+
ctx.registry.lookup(&imp.module.parts)
1500+
.map_or(false, |e| e.operator_class_targets.contains_key(&op_name))
1501+
});
1502+
if is_class_op {
1503+
return None;
1504+
}
1505+
match name.as_str() {
1506+
"apply" => return Some(false), // $ : f $ x → f(x)
1507+
"applyFlipped" => return Some(true), // # : x # f → f(x)
1508+
_ => {}
1509+
}
1510+
}
1511+
None
1512+
}
1513+
1514+
/// Apply a binary operator: `op(left)(right)`, with inlining for `$` and `#`.
1515+
fn apply_binop_js(ctx: &CodegenCtx, op_name: Symbol, op_expr: JsExpr, left: JsExpr, right: JsExpr) -> JsExpr {
1516+
if let Some(flipped) = is_inline_apply_op(ctx, op_name) {
1517+
if flipped {
1518+
// `#` (applyFlipped): `x # f` → `f(x)`
1519+
return JsExpr::App(Box::new(right), vec![left]);
1520+
} else {
1521+
// `$` (apply): `f $ x` → `f(x)`
1522+
return JsExpr::App(Box::new(left), vec![right]);
1523+
}
1524+
}
14871525
JsExpr::App(
14881526
Box::new(JsExpr::App(Box::new(op_expr), vec![left])),
14891527
vec![right],
@@ -1515,7 +1553,7 @@ fn gen_op_chain(
15151553
let op_js = gen_op_with_dicts(ctx, &operators[0].value);
15161554
let l = gen_expr(ctx, operands[0]);
15171555
let r = gen_expr(ctx, operands[1]);
1518-
return apply_binop_js(op_js, l, r);
1556+
return apply_binop_js(ctx, operators[0].value.name, op_js, l, r);
15191557
}
15201558

15211559
// Generate JS for all operands and operators
@@ -1539,7 +1577,7 @@ fn gen_op_chain(
15391577
op_stack.pop();
15401578
let right_val = output.pop().unwrap();
15411579
let left_val = output.pop().unwrap();
1542-
let result = apply_binop_js(op_js[top_idx].clone(), left_val, right_val);
1580+
let result = apply_binop_js(ctx, operators[top_idx].value.name, op_js[top_idx].clone(), left_val, right_val);
15431581
output.push(result);
15441582
} else {
15451583
break;
@@ -1554,7 +1592,7 @@ fn gen_op_chain(
15541592
while let Some(top_idx) = op_stack.pop() {
15551593
let right_val = output.pop().unwrap();
15561594
let left_val = output.pop().unwrap();
1557-
let result = apply_binop_js(op_js[top_idx].clone(), left_val, right_val);
1595+
let result = apply_binop_js(ctx, operators[top_idx].value.name, op_js[top_idx].clone(), left_val, right_val);
15581596
output.push(result);
15591597
}
15601598

@@ -1852,6 +1890,7 @@ fn resolve_operator(ctx: &CodegenCtx, op_qident: &QualifiedIdent) -> JsExpr {
18521890
if ctx.local_names.contains(target_fn) {
18531891
return JsExpr::Var(js_name);
18541892
}
1893+
18551894
if let Some(source_parts) = ctx.name_source.get(target_fn) {
18561895
if let Some(js_mod) = ctx.import_map.get(source_parts) {
18571896
return JsExpr::ModuleAccessor(js_mod.clone(), js_name);
@@ -2615,14 +2654,16 @@ fn gen_curried_lambda(params: &[String], body: JsExpr) -> JsExpr {
26152654
result
26162655
}
26172656

2618-
fn make_qualified_ref(_ctx: &CodegenCtx, qual_mod: Option<&Ident>, name: &str) -> JsExpr {
2657+
fn make_qualified_ref(ctx: &CodegenCtx, qual_mod: Option<&Ident>, name: &str) -> JsExpr {
26192658
if let Some(mod_sym) = qual_mod {
26202659
let mod_str = interner::resolve(*mod_sym).unwrap_or_default();
26212660
let js_mod = any_name_to_js(&mod_str.replace('.', "_"));
26222661
JsExpr::ModuleAccessor(js_mod, any_name_to_js(name))
26232662
} else {
2624-
// Unqualified: look for it in scope
2625-
JsExpr::Var(any_name_to_js(name))
2663+
// Resolve through name_source for proper module qualification
2664+
let sym = interner::intern(name);
2665+
let base = resolve_name_to_js(ctx, sym);
2666+
maybe_insert_dict_args(ctx, sym, base)
26262667
}
26272668
}
26282669

src/typechecker/check.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,15 @@ fn is_prim_module(module_name: &crate::cst::ModuleName) -> bool {
961961
&& crate::interner::resolve(module_name.parts[0]).unwrap_or_default() == "Prim"
962962
}
963963

964+
/// Check if a deferred class constraint should be silently skipped in Pass 3.
965+
/// - `IsSymbol`: compiler-solved for any type-level string literal.
966+
/// - `Bind`: synthesized by do-notation desugaring; always resolvable when the
967+
/// Bind class is imported (standalone tests without Control.Bind skip this).
968+
fn is_skip_deferred_class(class_name: Symbol) -> bool {
969+
let name = crate::interner::resolve(class_name).unwrap_or_default();
970+
matches!(name.as_str(), "IsSymbol" | "Bind")
971+
}
972+
964973
/// Check if a CST ModuleName is a Prim submodule (e.g. Prim.Coerce, Prim.Row).
965974
fn is_prim_submodule(module_name: &crate::cst::ModuleName) -> bool {
966975
module_name.parts.len() >= 2
@@ -1026,13 +1035,14 @@ fn prim_submodule_exports(module_name: &crate::cst::ModuleName) -> ModuleExports
10261035
exports.class_param_counts.insert(intern("RowToList"), 2);
10271036
}
10281037
"Symbol" => {
1029-
// classes: Append, Compare, Cons
1030-
for class in &["Append", "Compare", "Cons"] {
1038+
// classes: Append, Compare, Cons, IsSymbol
1039+
for class in &["Append", "Compare", "Cons", "IsSymbol"] {
10311040
exports.instances.insert(intern(class), Vec::new());
10321041
}
10331042
exports.class_param_counts.insert(intern("Append"), 3);
10341043
exports.class_param_counts.insert(intern("Compare"), 3);
10351044
exports.class_param_counts.insert(intern("Cons"), 3);
1045+
exports.class_param_counts.insert(intern("IsSymbol"), 1);
10361046
}
10371047
"TypeError" => {
10381048
// classes: Fail, Warn; type constructors: Text, Beside, Above, Quote, QuoteLabel
@@ -1462,6 +1472,7 @@ pub fn check_module(module: &Module, registry: &ModuleRegistry) -> CheckResult {
14621472
}
14631473
}
14641474

1475+
14651476
// Process imports: bring imported names into scope
14661477
let explicitly_imported_types = process_imports(
14671478
module,
@@ -5233,9 +5244,14 @@ pub fn check_module(module: &Module, registry: &ModuleRegistry) -> CheckResult {
52335244

52345245
// If the class itself is not known (not in any instance map and no
52355246
// methods registered), produce UnknownClass instead of NoInstanceFound.
5247+
// Exception: compiler-solved/synthesized classes (IsSymbol, Bind from
5248+
// do-notation) are silently skipped when not imported.
52365249
let class_is_known = instances.contains_key(class_name)
52375250
|| ctx.class_methods.values().any(|(cn, _)| cn == class_name);
52385251
if !class_is_known {
5252+
if is_skip_deferred_class(*class_name) {
5253+
continue;
5254+
}
52395255
errors.push(TypeError::UnknownClass {
52405256
span: *span,
52415257
name: *class_name,
@@ -6123,6 +6139,14 @@ fn process_imports(
61236139
}
61246140
}
61256141
}
6142+
// Also import instance_registry and instance_modules so codegen
6143+
// can resolve concrete dict expressions (e.g., bindEffect, applicativeEffect).
6144+
for (key, inst_name) in &module_exports.instance_registry {
6145+
ctx.instance_registry.insert(*key, *inst_name);
6146+
}
6147+
for (inst_name, mod_parts) in &module_exports.instance_modules {
6148+
ctx.instance_modules.entry(*inst_name).or_insert_with(|| mod_parts.clone());
6149+
}
61266150
}
61276151
Some(ImportList::Hiding(items)) => {
61286152
let hidden: HashSet<Symbol> = items.iter().map(|i| import_name(i)).collect();

src/typechecker/infer.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2425,6 +2425,12 @@ impl InferCtx {
24252425
if env.lookup(bind_sym).is_none() {
24262426
return Err(TypeError::UndefinedVariable { span, name: bind_sym });
24272427
}
2428+
// Defer a Bind constraint so codegen can resolve the concrete dictionary
2429+
// (e.g., bindEffect for Effect monad). Do-notation desugars to `bind` calls
2430+
// but doesn't go through normal variable inference, so we must explicitly
2431+
// create the constraint here.
2432+
let bind_class = crate::interner::intern("Bind");
2433+
self.deferred_constraints.push((span, bind_class, vec![monad_ty.clone()], self.current_binding_name));
24282434
}
24292435

24302436
// Check that `discard` is in scope when do-notation has non-last discards

tests/node_integration.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
//! Node.js integration tests for the full compiler pipeline.
2+
//!
3+
//! These tests compile PureScript modules using the test-compiler-fun project
4+
//! (with real Prelude, Effect, etc.) and run the output with Node.js to verify
5+
//! correctness of the generated JavaScript.
6+
7+
use std::path::{Path, PathBuf};
8+
use std::process::Command;
9+
10+
/// Get the project root directory (where Cargo.toml lives).
11+
fn project_root() -> PathBuf {
12+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
13+
}
14+
15+
/// Get the test-compiler-fun project directory.
16+
fn test_project_dir() -> PathBuf {
17+
project_root().join("..").join("test-compiler-fun")
18+
}
19+
20+
/// Mutex to serialize integration tests that share the filesystem.
21+
use std::sync::Mutex;
22+
static TEST_MUTEX: Mutex<()> = Mutex::new(());
23+
24+
/// Write a Main.purs to the test project, compile all modules, and return
25+
/// the output directory path. Panics on compilation failure.
26+
fn compile_with_main(main_source: &str) -> PathBuf {
27+
let test_dir = test_project_dir();
28+
let main_path = test_dir.join("src").join("Main.purs");
29+
let output_dir = project_root().join("output");
30+
31+
// Write the Main.purs source
32+
std::fs::write(&main_path, main_source).expect("Failed to write Main.purs");
33+
34+
// Clean output
35+
let _ = std::fs::remove_dir_all(&output_dir);
36+
37+
// Compile using cargo run
38+
let status = Command::new("cargo")
39+
.args([
40+
"run",
41+
"--",
42+
"compile",
43+
"../test-compiler-fun/src/**/*.purs",
44+
"../test-compiler-fun/.spago/*/src/**/*.purs",
45+
])
46+
.current_dir(&project_root())
47+
.stdout(std::process::Stdio::piped())
48+
.stderr(std::process::Stdio::piped())
49+
.status()
50+
.expect("Failed to run cargo");
51+
52+
assert!(status.success(), "Compilation failed");
53+
output_dir
54+
}
55+
56+
/// Run a Node.js expression that imports and executes Main, returning stdout.
57+
fn run_main(output_dir: &Path) -> String {
58+
let main_js = output_dir.join("Main").join("index.js");
59+
assert!(
60+
main_js.exists(),
61+
"Main/index.js not found at {}",
62+
main_js.display()
63+
);
64+
65+
let script = format!(
66+
"import('{}').then(m => {{ if (typeof m.main === 'function') m.main(); }})",
67+
main_js.display()
68+
);
69+
70+
let output = Command::new("node")
71+
.args(["--input-type=module", "-e", &script])
72+
.output()
73+
.expect("Failed to run node");
74+
75+
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
76+
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
77+
78+
if !output.status.success() {
79+
panic!(
80+
"Node.js execution failed:\n--- stdout ---\n{}\n--- stderr ---\n{}",
81+
stdout, stderr
82+
);
83+
}
84+
85+
stdout
86+
}
87+
88+
/// Compile and run a Main.purs, returning stdout.
89+
fn compile_and_run(main_source: &str) -> String {
90+
let output_dir = compile_with_main(main_source);
91+
run_main(&output_dir)
92+
}
93+
94+
// ===== Integration tests =====
95+
96+
#[test]
97+
fn node_simple_map_show_log() {
98+
let _lock = TEST_MUTEX.lock().unwrap();
99+
let output = compile_and_run(
100+
r#"module Main where
101+
102+
import Prelude
103+
104+
import Effect (Effect)
105+
import Effect.Console (log)
106+
107+
main :: Effect Unit
108+
main =
109+
[ 1, 2, 3 ] <#> (\x -> x + 1)
110+
# show >>> log
111+
"#,
112+
);
113+
assert_eq!(output.trim(), "[2,3,4]");
114+
}
115+
116+
#[test]
117+
fn node_do_notation_bind_pure() {
118+
let _lock = TEST_MUTEX.lock().unwrap();
119+
let output = compile_and_run(
120+
r#"module Main where
121+
122+
import Prelude
123+
124+
import Effect (Effect)
125+
import Effect.Console (log)
126+
127+
main :: Effect Unit
128+
main = do
129+
twoThreeFour <- pure $ [ 1, 2, 3 ] <#> (\x -> x + 1)
130+
log $ show twoThreeFour
131+
log $ "HI!"
132+
pure unit
133+
"#,
134+
);
135+
assert_eq!(output.trim(), "[2,3,4]\nHI!");
136+
}

0 commit comments

Comments
 (0)