Algebraic Effects System for Rust

Vic/jj change kppnloqrvumt (#15)

* ✨ feat(core): add lift_map combinator for HasPut effect sequencing

This change introduces the `lift_map` method to the `Fx` type, enabling effect sequencing over multiple `HasPut` requirements. The new combinator allows chaining of effects that require different state fragments, improving composability for advanced effectful programming patterns.

A comprehensive test, `lift_map_composes_effects_with_hasput`, is added to verify correct sequencing and context usage. This test demonstrates how `lift_map` can be used to compose two effects with distinct `HasPut` requirements in a single state context.

Future work may include further generalization of effect composition utilities and additional tests for edge cases and ergonomics.

* refactor(tests): deduplicate Has/Put impls and structs in fx_test.rs 🎯

- Move common structs (A, B, S) and their Has/Put trait implementations to the top of the test file.
- Remove repeated struct and trait definitions from individual tests, updating them to use the shared types.
- Update tests to use B(i32) instead of B(&str), and adjust logic accordingly.
- Suppress dead_code warning for unused test struct fields where needed.

This refactor reduces code duplication, improves maintainability, and keeps all tests green. Future test additions can now use the shared types directly.

* feat(core): add lift_req combinator for staged context provision and test 🎯

- Add Abilities::lift_req for staged context/effect composition using HasPut
- Add test for lift_req (ability + state) in ability_test.rs
- Organize imports in ability_test.rs (no inline imports)
- Minor formatting cleanup in fx_test.rs

This change enables staged effect composition with HasPut, improving ergonomics for advanced effectful programming patterns. The new test demonstrates correct usage and context provision. Future work: extend staged context combinators and add more effect composition tests.

authored by oeiuwq.com and committed by

GitHub c095f9e6 328388ba

+120 -81
+9 -1
crates/fx/src/core/ability.rs
··· 1 1 use std::marker::PhantomData; 2 2 3 3 use crate::{ 4 - core::{handler::Handler, pair::Pair, state::State}, 4 + core::{handler::Handler, has_put::HasPut, pair::Pair, state::State}, 5 5 kernel::{ability::Ability, fx::Fx}, 6 6 }; 7 7 ··· 35 35 P: Pair<A, S>, 36 36 { 37 37 State::get().flat_map(|a: A| a.apply(i)) 38 + } 39 + 40 + pub fn lift_req<P, A>(i: I) -> Fx<'f, P, O> 41 + where 42 + A: Ability<'f, I, S, O> + Clone, 43 + P: HasPut<A> + HasPut<S> + Clone, 44 + { 45 + State::<A>::get().lift_map(|a: A| a.apply(i)) 38 46 } 39 47 } 40 48
+11
crates/fx/src/core/fx.rs
··· 89 89 ) 90 90 } 91 91 92 + // effect sequencing over HasPut requirements. 93 + pub fn lift_map<R, U, P, F>(self, f: F) -> Fx<'f, P, U> 94 + where 95 + U: Clone + 'f, 96 + R: Clone + 'f, 97 + P: HasPut<S> + HasPut<R> + Clone + 'f, 98 + F: FnOnce(V) -> Fx<'f, R, U> + Clone + 'f, 99 + { 100 + self.lift().map_m(|v| f(v).lift()) 101 + } 102 + 92 103 pub fn lift<T>(self) -> Fx<'f, T, V> 93 104 where 94 105 T: HasPut<S> + Clone + 'f,
+53 -7
crates/fx/src/core/tests/ability_test.rs
··· 1 + use crate::core::ability::{Abilities, AbilityExt}; 2 + use crate::core::handler::Handler; 3 + use crate::core::has_put::{Has, Put}; 4 + use crate::core::state::State; 5 + use crate::kernel::ability::Ability; 6 + use crate::kernel::fx::Fx; 1 7 use std::usize; 2 - 3 - use crate::{ 4 - core::ability::{Abilities, AbilityExt}, 5 - core::handler::Handler, 6 - core::state::State, 7 - kernel::{ability::Ability, fx::Fx}, 8 - }; 9 8 10 9 fn modify_and_continue_delimited_continuation<'f, A>(ability: A) 11 10 where ··· 87 86 let result = fx.eval(); 88 87 assert_eq!(result, 22); 89 88 } 89 + 90 + #[test] 91 + fn lift_req_composes_ability_and_state() { 92 + #[derive(Clone)] 93 + struct MyAbility; 94 + impl<'f> Ability<'f, usize, i32, usize> for MyAbility { 95 + fn apply(&self, i: usize) -> Fx<'f, i32, usize> { 96 + Fx::pending(move |n: i32| Fx::value(i + n as usize)) 97 + } 98 + } 99 + 100 + #[derive(Clone)] 101 + struct Ctx { 102 + ab: MyAbility, 103 + n: i32, 104 + } 105 + impl Has<MyAbility> for Ctx { 106 + fn get<'f>(&'f self) -> &'f MyAbility { 107 + &self.ab 108 + } 109 + } 110 + impl Put<MyAbility> for Ctx { 111 + fn put(mut self, ab: MyAbility) -> Self { 112 + self.ab = ab; 113 + self 114 + } 115 + } 116 + impl Has<i32> for Ctx { 117 + fn get<'f>(&'f self) -> &'f i32 { 118 + &self.n 119 + } 120 + } 121 + impl Put<i32> for Ctx { 122 + fn put(mut self, n: i32) -> Self { 123 + self.n = n; 124 + self 125 + } 126 + } 127 + 128 + let fx = Abilities::lift_req::<Ctx, MyAbility>(7); 129 + let ctx = Ctx { 130 + ab: MyAbility, 131 + n: 5, 132 + }; 133 + let result = fx.provide(ctx).eval(); 134 + assert_eq!(result, 12); 135 + }
+47 -73
crates/fx/src/core/tests/fx_test.rs
··· 4 4 }; 5 5 use std::convert::identity; 6 6 7 + // Common structs and trait implementations for all tests 8 + #[derive(Clone, Debug, PartialEq)] 9 + struct A(pub i32); 10 + #[derive(Clone, Debug, PartialEq)] 11 + struct B(pub i32); 12 + #[derive(Clone, Debug, PartialEq)] 13 + struct S { 14 + a: A, 15 + b: B, 16 + } 17 + impl Has<A> for S { 18 + fn get<'f>(&'f self) -> &'f A { 19 + &self.a 20 + } 21 + } 22 + impl Put<A> for S { 23 + fn put(mut self, value: A) -> Self { 24 + self.a = value; 25 + self 26 + } 27 + } 28 + impl Has<B> for S { 29 + fn get<'f>(&'f self) -> &'f B { 30 + &self.b 31 + } 32 + } 33 + impl Put<B> for S { 34 + fn put(mut self, value: B) -> Self { 35 + self.b = value; 36 + self 37 + } 38 + } 39 + 7 40 #[test] 8 41 fn pure() { 9 42 let e = Fx::pure(22); ··· 80 113 81 114 #[test] 82 115 fn lifts_fx_to_larger_context() { 83 - #[derive(Clone, Debug, PartialEq)] 84 - struct A(i32); 85 - #[derive(Clone, Debug, PartialEq)] 86 - struct B(&'static str); 87 - #[derive(Clone, Debug, PartialEq)] 88 - struct S { 89 - a: A, 90 - b: B, 91 - } 92 - impl Has<A> for S { 93 - fn get<'f>(&'f self) -> &'f A { 94 - &self.a 95 - } 96 - } 97 - impl Put<A> for S { 98 - fn put(mut self, value: A) -> Self { 99 - self.a = value; 100 - self 101 - } 102 - } 103 - impl Has<B> for S { 104 - fn get<'f>(&'f self) -> &'f B { 105 - &self.b 106 - } 107 - } 108 - impl Put<B> for S { 109 - fn put(mut self, value: B) -> Self { 110 - self.b = value; 111 - self 112 - } 113 - } 114 - 115 116 let fx_a: Fx<A, i32> = Fx::pending(|a: A| Fx::value(a.0 + 1)); 116 - let fx_b: Fx<B, i32> = Fx::pending(|b: B| Fx::value(b.0.len() as i32)); 117 + let fx_b: Fx<B, i32> = Fx::pending(|b: B| Fx::value(b.0)); 117 118 // This should work: lift to anything that Has<A>, Has<B> respectively. 118 119 let lifted_a: Fx<S, i32> = fx_a.lift(); 119 120 let lifted_b: Fx<S, i32> = fx_b.lift(); 120 - let s = S { 121 - a: A(41), 122 - b: B("hi!"), 123 - }; 121 + let s = S { a: A(41), b: B(3) }; 124 122 assert_eq!(lifted_a.provide(s.clone()).eval(), 42); 125 123 assert_eq!(lifted_b.provide(s.clone()).eval(), 3); 126 124 } ··· 145 143 146 144 #[test] 147 145 fn zip_lifted_fx_on_struct_with_has_a_and_b() { 148 - #[derive(Clone, Debug, PartialEq)] 149 - struct A(i32); 150 - #[derive(Clone, Debug, PartialEq)] 151 - struct B(&'static str); 152 - #[derive(Clone, Debug, PartialEq)] 153 - struct S { 154 - a: A, 155 - b: B, 156 - } 157 - impl Has<A> for S { 158 - fn get<'f>(&'f self) -> &'f A { 159 - &self.a 160 - } 161 - } 162 - impl Put<A> for S { 163 - fn put(mut self, value: A) -> Self { 164 - self.a = value; 165 - self 166 - } 167 - } 168 - impl Has<B> for S { 169 - fn get<'f>(&'f self) -> &'f B { 170 - &self.b 171 - } 172 - } 173 - impl Put<B> for S { 174 - fn put(mut self, value: B) -> Self { 175 - self.b = value; 176 - self 177 - } 178 - } 179 146 let fx_a: Fx<A, i32> = Fx::pending(|a: A| Fx::value(a.0 + 1)); 180 - let fx_b: Fx<B, i32> = Fx::pending(|b: B| Fx::value(b.0.len() as i32)); 147 + let fx_b: Fx<B, i32> = Fx::pending(|b: B| Fx::value(b.0)); 181 148 let lifted_a: Fx<S, i32> = fx_a.lift(); 182 149 let lifted_b: Fx<S, i32> = fx_b.lift(); 183 - let s = S { 184 - a: A(41), 185 - b: B("hi!"), 186 - }; 150 + let s = S { a: A(41), b: B(3) }; 187 151 let zipped = lifted_a.zip(lifted_b); 188 152 assert_eq!(zipped.provide(s).eval(), (42, 3)); 189 153 } ··· 223 187 224 188 #[test] 225 189 fn has_pending_composed() { 190 + #[allow(dead_code)] 226 191 #[derive(Clone)] 227 192 struct Ctx { 228 193 x: i32, ··· 238 203 let result = fx.provide(Ctx { x: 3, y: 4 }).eval(); 239 204 assert_eq!(result, 6); // Only x is used, y is ignored (since Has<i32> is implemented only for x) 240 205 } 206 + 207 + #[test] 208 + fn lift_map_composes_effects_with_hasput() { 209 + let a: Fx<A, A> = Fx::func(|a: A| A(a.0 + 1)); 210 + let b = |a: A| Fx::func(move |b: B| ((a.0 + b.0) * 2)); 211 + let composed: Fx<S, i32> = a.lift_map(b); 212 + let s = S { a: A(10), b: B(7) }; 213 + assert_eq!(composed.provide(s).eval(), 36); // ((a + 1) + b) * 2 214 + }