Skip to main content

slack_morphism/models/blocks/
dsl.rs

1#![macro_use]
2
3/// Builds a [`SlackBlockMarkDownText`](crate::blocks::SlackBlockMarkDownText),
4/// converted into the caller's target type via [`Into`].
5///
6/// `md!(text)` wraps `text` as-is; `md!(fmt, args...)` formats first, the same
7/// way [`format!`] does.
8///
9/// Because the expansion already ends in an untyped `.into()`, a bare
10/// `md!(..)` item cannot be placed directly into `slack_blocks!` for a
11/// text-only list — the compiler has nothing to infer the target type from
12/// (`E0283`). Wrap it with `some(..)` (`slack_blocks![some(md!("hi"))]`) or
13/// give the list an explicit element type, e.g.
14/// `SlackContextBlock::new(vec![md!("hi")])`.
15///
16/// ```
17/// use slack_morphism::prelude::*;
18///
19/// let text: SlackBlockMarkDownText = md!("hello");
20/// let formatted: SlackBlockMarkDownText = md!("hello {}", "world");
21/// ```
22#[macro_export]
23macro_rules! md {
24    ($e : expr) => {
25        $crate::blocks::SlackBlockMarkDownText::new($e.into()).into()
26    };
27
28    ($e : expr, $($es:expr),+ $(,)?) => {
29        $crate::md!(::std::format!($e,$($es),+))
30    };
31}
32
33/// Builds a [`SlackBlockPlainText`](crate::blocks::SlackBlockPlainText),
34/// converted into the caller's target type via [`Into`].
35///
36/// Same rules and same `E0283` limitation as [`md!`]: `pt!(text)` wraps as-is,
37/// `pt!(fmt, args...)` formats first, and a bare `pt!(..)` item needs an
38/// explicit target type to appear directly in a `slack_blocks!` list.
39///
40/// ```
41/// use slack_morphism::prelude::*;
42///
43/// let text: SlackBlockPlainText = pt!("hello");
44/// let formatted: SlackBlockPlainText = pt!("hello {}", "world");
45/// ```
46#[macro_export]
47macro_rules! pt {
48    ($e : expr) => {
49        $crate::blocks::SlackBlockPlainText::new($e.into()).into()
50    };
51
52    ($e : expr, $($es:expr),+ $(,)?) => {
53        $crate::pt!(::std::format!($e,$($es),+))
54    };
55}
56
57#[macro_export]
58macro_rules! slack_block_item {
59    (optionally ($pred:expr => $item:expr)) => {{
60        if $pred {
61            $crate::slack_block_item! (some_into $item)
62        }
63        else {
64            None
65        }
66    }};
67
68    (some $item:expr) => {{
69        Some($item)
70    }};
71
72    (optionally_into ($pred:expr => $item:expr)) => {{
73        if $pred {
74            $crate::slack_block_item! (some_into $item)
75        }
76        else {
77            None
78        }
79    }};
80
81    (some_into $item:expr ) => {{
82        Some($item.into())
83    }};
84}
85
86/// Builds a `Vec<T>` of block-kit items from a comma-separated list, in one
87/// of two syntaxes that may be freely mixed within a single list:
88///
89/// - **Bare form**: any expression is pushed via `.into()`. `..iter` splices
90///   every element of `iter` in, each also converted via `.into()`.
91///   `optionally(pred => item)` converts and pushes `item` the same way a
92///   bare item does, only when `pred` is true, evaluating neither `pred` nor
93///   `item` otherwise. `Into::into` is the identity conversion for an
94///   already-typed item, so `item` may be a block struct or an already-built
95///   target type; the one thing it cannot be is an expression that itself
96///   ends in an untyped `.into()`, such as `md!(..)`/`pt!(..)` — the same
97///   limit a bare item has (see [`md!`]).
98/// - **Legacy keyword form**: `some(item)`, `some_into(item)`,
99///   `optionally(pred => item)`, `optionally_into(pred => item)`, matching
100///   [`slack_block_item!`].
101///
102/// `slack_blocks![]` and a trailing comma on the last item are both allowed.
103///
104/// See [`md!`] for a caveat on placing a bare `md!(..)`/`pt!(..)` item
105/// directly in a list.
106///
107/// ```
108/// use slack_morphism::prelude::*;
109///
110/// let show_divider = true;
111///
112/// let items: Vec<SlackBlock> = slack_blocks![
113///     SlackHeaderBlock::new(pt!("Title")),
114///     optionally(show_divider => SlackDividerBlock::new()),
115///     ..vec![SlackDividerBlock::new()],
116/// ];
117/// assert_eq!(items.len(), 3);
118///
119/// // Legacy keyword form, still supported:
120/// let legacy_items: Vec<SlackBlock> = slack_blocks![
121///     some_into(SlackHeaderBlock::new(pt!("Title"))),
122///     optionally_into(true => SlackDividerBlock::new()),
123/// ];
124/// assert_eq!(legacy_items.len(), 2);
125/// ```
126#[macro_export]
127macro_rules! slack_blocks {
128    (@acc $v:ident;) => {};
129
130    (@acc $v:ident; some_into($item:expr) $(, $($rest:tt)*)?) => {
131        $v.push(::core::convert::Into::into($item));
132        $crate::slack_blocks!(@acc $v; $($($rest)*)?);
133    };
134
135    (@acc $v:ident; some($item:expr) $(, $($rest:tt)*)?) => {
136        $v.push($item);
137        $crate::slack_blocks!(@acc $v; $($($rest)*)?);
138    };
139
140    (@acc $v:ident; optionally($pred:expr => $item:expr) $(, $($rest:tt)*)?) => {
141        if $pred { $v.push(::core::convert::Into::into($item)); }
142        $crate::slack_blocks!(@acc $v; $($($rest)*)?);
143    };
144
145    (@acc $v:ident; optionally_into($pred:expr => $item:expr) $(, $($rest:tt)*)?) => {
146        if $pred { $v.push(::core::convert::Into::into($item)); }
147        $crate::slack_blocks!(@acc $v; $($($rest)*)?);
148    };
149
150    (@acc $v:ident; ..$iter:expr $(, $($rest:tt)*)?) => {
151        for __slack_item in $iter { $v.push(::core::convert::Into::into(__slack_item)); }
152        $crate::slack_blocks!(@acc $v; $($($rest)*)?);
153    };
154
155    (@acc $v:ident; $kw:ident($pred:expr => $item:expr) $(, $($rest:tt)*)?) => {
156        ::core::compile_error!(::core::concat!("slack_blocks!: unknown keyword `", ::core::stringify!($kw),
157            "`; expected `optionally(pred => item)` or `optionally_into(pred => item)`"));
158    };
159
160    (@acc $v:ident; $item:expr $(, $($rest:tt)*)?) => {
161        $v.push(::core::convert::Into::into($item));
162        $crate::slack_blocks!(@acc $v; $($($rest)*)?);
163    };
164
165    (@acc $v:ident; $($t:tt)+) => {
166        ::core::compile_error!(::core::concat!("slack_blocks!: expected a comma-separated list of items, found `",
167            ::core::stringify!($($t)+), "`"));
168    };
169
170    () => { ::std::vec::Vec::new() };
171
172    ($($t:tt)*) => {{
173        let mut __slack_blocks = ::std::vec::Vec::new();
174        { $crate::slack_blocks!(@acc __slack_blocks; $($t)*); }
175        __slack_blocks
176    }};
177}
178
179#[cfg(test)]
180mod tests {
181    use crate::blocks::{
182        SlackActionBlockElement, SlackActionsBlock, SlackBlock, SlackBlockButtonElement,
183        SlackBlockMarkDownText, SlackContextBlock, SlackContextBlockElement, SlackDividerBlock,
184        SlackHeaderBlock,
185    };
186
187    #[test]
188    fn legacy_mixed_forms_build_vec_slack_block() {
189        let blocks: Vec<SlackBlock> = crate::slack_blocks![
190            some_into(SlackHeaderBlock::new(crate::pt!("Title"))),
191            some_into(SlackDividerBlock::new()),
192            optionally_into(true => SlackDividerBlock::new()),
193            optionally_into(false => SlackDividerBlock::new()),
194        ];
195        assert_eq!(blocks.len(), 3);
196    }
197
198    #[test]
199    fn bare_items_include_method_chain_and_nested_list() {
200        let blocks: Vec<SlackBlock> = crate::slack_blocks![
201            SlackHeaderBlock::new(crate::pt!("Title")),
202            SlackContextBlock::new(crate::slack_blocks![some(crate::md!("x"))]),
203        ];
204        assert_eq!(blocks.len(), 2);
205        let json = serde_json::to_value(&blocks).unwrap();
206        assert_eq!(json[1]["elements"][0]["text"], "x");
207    }
208
209    #[test]
210    fn empty_list_is_empty() {
211        let blocks: Vec<SlackBlock> = crate::slack_blocks![];
212        assert!(blocks.is_empty());
213    }
214
215    #[test]
216    fn trailing_comma_allowed_for_bare_and_legacy_items() {
217        let bare: Vec<SlackBlock> = crate::slack_blocks![SlackDividerBlock::new(),];
218        assert_eq!(bare.len(), 1);
219
220        let legacy: Vec<SlackBlock> = crate::slack_blocks![some_into(SlackDividerBlock::new()),];
221        assert_eq!(legacy.len(), 1);
222    }
223
224    #[test]
225    fn mixed_legacy_and_bare_items_in_one_list() {
226        let blocks: Vec<SlackBlock> = crate::slack_blocks![
227            some_into(SlackDividerBlock::new()),
228            SlackDividerBlock::new(),
229            optionally(true => SlackBlock::Divider(SlackDividerBlock::new())),
230        ];
231        assert_eq!(blocks.len(), 3);
232    }
233
234    #[test]
235    fn spread_from_iterator_map_converts_each_item() {
236        let ids = ["a", "b", "c"];
237        let blocks: Vec<SlackBlock> = crate::slack_blocks![
238            ..ids
239                .iter()
240                .map(|id| SlackDividerBlock::new().with_block_id(id.to_string().into())),
241        ];
242        assert_eq!(blocks.len(), 3);
243    }
244
245    #[test]
246    fn spread_of_vec_slack_block() {
247        let source: Vec<SlackBlock> = vec![
248            SlackDividerBlock::new().into(),
249            SlackDividerBlock::new().into(),
250        ];
251        let blocks: Vec<SlackBlock> = crate::slack_blocks![..source];
252        assert_eq!(blocks.len(), 2);
253    }
254
255    #[test]
256    fn spread_of_option_yields_zero_or_one_items() {
257        let some_block: Option<SlackBlock> = Some(SlackDividerBlock::new().into());
258        let none_block: Option<SlackBlock> = None;
259
260        let blocks: Vec<SlackBlock> = crate::slack_blocks![..some_block, ..none_block];
261        assert_eq!(blocks.len(), 1);
262    }
263
264    #[test]
265    fn spread_only_list_is_typed_by_the_let_binding() {
266        let source: Vec<SlackBlock> = vec![SlackDividerBlock::new().into()];
267        let blocks: Vec<SlackBlock> = crate::slack_blocks![..source];
268        assert_eq!(blocks.len(), 1);
269    }
270
271    #[test]
272    fn optionally_predicate_is_lazy_and_short_circuits_item_evaluation() {
273        let mut evaluations = 0;
274        let items: Vec<SlackBlock> = crate::slack_blocks![optionally(false => {
275            evaluations += 1;
276            SlackBlock::Divider(SlackDividerBlock::new())
277        })];
278        assert!(items.is_empty());
279        assert_eq!(evaluations, 0);
280    }
281
282    #[test]
283    fn context_block_elements_from_some_and_bare_markdown() {
284        let elements: Vec<SlackContextBlockElement> = crate::slack_blocks![
285            some(crate::md!("a")),
286            SlackBlockMarkDownText::new("b".into()),
287        ];
288        assert_eq!(elements.len(), 2);
289    }
290
291    #[test]
292    fn action_block_elements_from_bare_button() {
293        let elements: Vec<SlackActionBlockElement> =
294            crate::slack_blocks![SlackBlockButtonElement::new(
295                "btn-1".into(),
296                crate::pt!("Click")
297            ),];
298        assert_eq!(elements.len(), 1);
299        let action_block = SlackActionsBlock::new(elements);
300        let json = serde_json::to_value(&action_block).unwrap();
301        assert_eq!(json["elements"][0]["action_id"], "btn-1");
302    }
303
304    #[test]
305    fn optionally_converts_its_item_like_a_bare_item() {
306        let blocks: Vec<SlackBlock> = crate::slack_blocks![
307            optionally(true => SlackDividerBlock::new()),
308            optionally(false => SlackDividerBlock::new()),
309        ];
310        assert_eq!(blocks.len(), 1);
311
312        let item: Option<SlackBlock> =
313            crate::slack_block_item!(optionally (true => SlackDividerBlock::new()));
314        assert!(item.is_some());
315    }
316
317    #[test]
318    fn slack_block_item_still_works() {
319        let some_item =
320            crate::slack_block_item!(some SlackBlock::Divider(SlackDividerBlock::new()));
321        assert!(some_item.is_some());
322
323        let none_item: Option<SlackBlock> = crate::slack_block_item!(optionally (false => SlackBlock::Divider(SlackDividerBlock::new())));
324        assert!(none_item.is_none());
325    }
326
327    #[test]
328    fn md_and_pt_format_args_arms_produce_expected_text() {
329        let markdown: SlackBlockMarkDownText = crate::md!("hi {}", "there");
330        assert_eq!(markdown.text, "hi there");
331
332        let plain: crate::blocks::SlackBlockPlainText = crate::pt!("hi {}", "there");
333        assert_eq!(plain.text, "hi there");
334    }
335
336    #[test]
337    fn macros_are_crate_hygienic_without_prelude_glob_import() {
338        let blocks: Vec<SlackBlock> = crate::slack_blocks![
339            SlackDividerBlock::new(),
340            some(SlackBlock::Divider(SlackDividerBlock::new())),
341            ..[SlackBlock::Divider(SlackDividerBlock::new())],
342        ];
343        assert_eq!(blocks.len(), 3);
344
345        let markdown: SlackBlockMarkDownText = crate::md!("x");
346        assert_eq!(markdown.text, "x");
347
348        let plain: crate::blocks::SlackBlockPlainText = crate::pt!("x");
349        assert_eq!(plain.text, "x");
350    }
351}