Skip to main content

big_code_analysis_ast/lang_helpers/
elixir.rs

1//! Elixir: keyword `Call` identity (`def`, `defmodule`, `quote`, …),
2//! and applied versus named operators.
3
4use crate::Elixir;
5use crate::node::{Ancestors, Node};
6
7/// Reads the text of the `target` field of an Elixir `Call` node.
8///
9/// Most of Elixir's control-flow constructs (`if`, `unless`, `for`,
10/// `while`, `case`, `cond`, `with`, `try`) and method-defining macros
11/// (`def`, `defp`, `defmacro`, …) parse as `Call` nodes whose `target`
12/// is an `Identifier` whose source text spells the keyword. The
13/// `Cyclomatic` and `Exit` impls already follow this pattern; this
14/// helper centralises the byte-text lookup so `Cognitive` and `Abc`
15/// can share it.
16///
17/// Returns `None` for Calls whose target is not a simple identifier
18/// (e.g. `Module.func(…)` parses as `RemoteCallWithParentheses` with
19/// the dotted name as target) or when the bytes are not valid UTF-8.
20#[inline]
21#[must_use]
22pub fn elixir_call_keyword<'a>(node: &'a Node<'a>, code: &'a [u8]) -> Option<&'a str> {
23    if node.kind_id() != Elixir::Call as u16 {
24        return None;
25    }
26    let target = node.child_by_field_name("target")?;
27    if target.kind_id() != Elixir::Identifier as u16 {
28        return None;
29    }
30    target.utf8_text(code)
31}
32
33/// Method-defining macros (`def`, `defp`, `defmacro`, `defmacrop`). The set
34/// is duplicated across checker, getter, and several metric impls
35/// because each consults it from a different trait surface; centralising
36/// the literal here keeps future additions (e.g. `defguard`) consistent.
37#[inline]
38#[must_use]
39pub fn elixir_is_method_macro(kw: &str) -> bool {
40    matches!(kw, "def" | "defp" | "defmacro" | "defmacrop")
41}
42
43/// Class-defining macro (`defmodule`). Paired with [`elixir_is_method_macro`]
44/// where a caller needs both ("any space-opening declaration").
45#[inline]
46#[must_use]
47pub fn elixir_is_class_macro(kw: &str) -> bool {
48    kw == "defmodule"
49}
50
51/// Returns true when `node` is lexically nested inside the `do_block` of a
52/// `quote do … end` Call (Elixir's metaprogramming template). A `def` /
53/// `defp` / `defmacro` / `defmacrop` inside `quote` does not define a
54/// method of any enclosing module — the syntax tree is a code template
55/// emitted later, when the surrounding macro is invoked. Treating those
56/// quoted Calls as methods inflates `Wmc` and disagrees with `Npm`'s
57/// direct-children classification (#310).
58///
59/// Walks the ancestor chain looking for a `quote` Call ancestor. Stops at
60/// the first match (true) or at the root (false). Each step is a single
61/// `child_by_field_name("target")` + identifier byte compare, so the cost
62/// is O(steps) when `ancestors` is known — with `Ancestors::unknown` each
63/// step additionally pays `Node::parent`'s O(depth) (#1084).
64#[inline]
65#[must_use]
66pub fn elixir_is_inside_quote_block<'a>(
67    node: &Node<'a>,
68    code: &[u8],
69    ancestors: Ancestors<'a, '_>,
70) -> bool {
71    ancestors
72        .iter(node)
73        .any(|(n, _)| elixir_call_keyword(&n, code) == Some("quote"))
74}
75
76/// Iterates the direct-child `Call` nodes inside the `do_block` of an
77/// Elixir Call (typically a `defmodule`). Used by `Npm` / `Npa` to scan
78/// a module body for method-defining macros / `defstruct` without
79/// descending into nested modules. Yields no items when the Call has
80/// no `do_block`.
81#[inline]
82pub fn elixir_do_block_call_children<'a>(
83    node: &'a Node<'a>,
84) -> impl Iterator<Item = Node<'a>> + 'a {
85    node.children()
86        .filter(|child| child.kind_id() == Elixir::DoBlock as u16)
87        .flat_map(|do_block| do_block.children())
88        .filter(|stmt| stmt.kind_id() == Elixir::Call as u16)
89}
90
91/// The `binary_operator` that applies `op`, an operator token, or `None`
92/// when the token is not applied there.
93///
94/// An operator token also *names* the operator: `&==/2`, `&and/2` and
95/// `Kernel.||(a, b)` hold it inside an `operator_identifier`, and the
96/// single-token `atom` / `keyword` spell it too. None of those is a
97/// `binary_operator` with operands to compare or chain, and under error
98/// recovery the parent can be an `ERROR` with no operands, so every
99/// decision metric keys on this parent rather than on the token (#1531,
100/// #1534). A remote call that names the operator still *applies* it,
101/// though; see [`elixir_applying_remote_call`].
102///
103/// Matched by rule name rather than by `kind_id`: the grammar aliases
104/// `binary_operator` to three ids (`Elixir::BinaryOperator` through
105/// `BinaryOperator3`), and a name comparison stays correct when a bump
106/// adds a fourth (grammar-dispatch §1).
107#[must_use]
108pub fn elixir_applying_operator<'a>(
109    op: &Node<'a>,
110    ancestors: Ancestors<'a, '_>,
111) -> Option<Node<'a>> {
112    ancestors
113        .parent(op)
114        .filter(|parent| parent.kind() == "binary_operator")
115}
116
117/// The name of the short-circuit operator a remote call on `Kernel` with
118/// arguments applies, or `None` for any other node: the `||` of
119/// `Kernel.||(a, b)`, or of `x |> Kernel.||(y)`, which the pipe rewrites
120/// to it. Such a call expands the `Kernel` macro exactly as `a || b`
121/// does, so it short-circuits and decides as the operator does, where a
122/// capture (`&Kernel.||/2`, a call with no arguments) only names it. It
123/// has no `binary_operator`, so its operands are the call's arguments,
124/// and under a pipe the piped value, rather than `left` / `right` fields.
125///
126/// The receiver must spell `Kernel` (or `Elixir.Kernel`): a user module
127/// may define `||/2` as an ordinary function, and `Foo.||(a, b)` neither
128/// short-circuits nor decides.
129///
130/// The grammar lexes a symbolic name as a token inside the call's
131/// `operator_identifier`, which is what is returned. `Kernel.and(a, b)`
132/// and `Kernel.or(a, b)` spell their names as a plain `identifier`, with
133/// no `And` / `Or` token to find, so that identifier is returned instead
134/// (#1582). Any other name — `Kernel.+`, `Kernel.!`, `Kernel.not`,
135/// `Kernel.in` — is a function or decides nothing, and is not found; nor
136/// is the childless `..`. [`elixir_short_circuit_name`] says which
137/// operation the name applies.
138///
139/// Every identifier a walk meets asks this of its call, so the receiver,
140/// which turns nearly all of them away without a cursor, is read before
141/// the call's children are scanned for its `arguments`.
142///
143/// Matched by rule name: `dot` and `arguments` are aliased to several
144/// ids each (grammar-dispatch §1).
145#[must_use]
146pub fn elixir_kernel_short_circuit_name<'a>(call: &Node<'a>, code: &[u8]) -> Option<Node<'a>> {
147    kernel_short_circuit_parts(call, code).map(|(name, _)| name)
148}
149
150fn kernel_short_circuit_parts<'a>(call: &Node<'a>, code: &[u8]) -> Option<(Node<'a>, Node<'a>)> {
151    if call.kind() != "call" {
152        return None;
153    }
154    let dot = call.child_by_field_name("target").filter(|target| {
155        target.kind() == "dot"
156            && target.child_by_field_name("left").is_some_and(|receiver| {
157                matches!(
158                    code.get(receiver.start_byte()..receiver.end_byte()),
159                    Some(b"Kernel" | b"Elixir.Kernel")
160                )
161            })
162    })?;
163    let name = dot.child_by_field_name("right")?;
164    let name = if name.kind() == "operator_identifier" {
165        name.child(0)?
166    } else {
167        name
168    };
169    elixir_short_circuit_name(&name, code)
170        .and_then(|_| call.children().find(|child| child.kind() == "arguments"))
171        .map(|arguments| (name, arguments))
172}
173
174/// The short-circuit operation an operator token kind applies, keyed by
175/// its symbol: `&&` for `&&` and `and`, `||` for `||` and `or`. A word
176/// form demands a boolean left operand, but it is the same conjunction or
177/// disjunction to read, so one sequence key serves both spellings.
178/// `None` for any other kind.
179#[must_use]
180pub fn elixir_short_circuit_kind(kind_id: u16) -> Option<Elixir> {
181    match kind_id.into() {
182        Elixir::AMPAMP | Elixir::And => Some(Elixir::AMPAMP),
183        Elixir::PIPEPIPE | Elixir::Or => Some(Elixir::PIPEPIPE),
184        _ => None,
185    }
186}
187
188/// [`elixir_short_circuit_kind`] for an operator's name as
189/// [`elixir_kernel_short_circuit_name`] returns it: a token, or the
190/// `identifier` spelling `Kernel.and` / `Kernel.or` (#1582), whose
191/// bytes decide. Ask it only of such a name — an identifier is an `and`
192/// operator nowhere else.
193#[must_use]
194pub fn elixir_short_circuit_name(name: &Node, code: &[u8]) -> Option<Elixir> {
195    if name.kind_id() != Elixir::Identifier as u16 {
196        return elixir_short_circuit_kind(name.kind_id());
197    }
198    match code.get(name.start_byte()..name.end_byte())? {
199        b"and" => Some(Elixir::AMPAMP),
200        b"or" => Some(Elixir::PIPEPIPE),
201        _ => None,
202    }
203}
204
205/// A recognized short-circuit `Kernel` call and the context its operands need.
206pub struct ElixirRemoteCall<'tree, 'chain> {
207    /// The call that applies the operator.
208    pub call: Node<'tree>,
209    /// The argument list found while recognizing the call.
210    pub arguments: Node<'tree>,
211    /// The call's own ancestry.
212    pub ancestors: Ancestors<'tree, 'chain>,
213}
214
215/// The `Kernel` remote call that applies `op`, a short-circuit operator's
216/// name — the call [`elixir_kernel_short_circuit_name`] answers `op` for
217/// — with its argument list and ancestry, or `None` when `op` sits
218/// anywhere else. A token's parents are the `operator_identifier`, then
219/// the `dot`, then the call; the `identifier` naming `Kernel.and` sits
220/// on the `dot` directly. Any other node fails on the first one or two
221/// parents' kinds, so the check is cheap enough to ask of every
222/// identifier a walk meets.
223#[must_use]
224pub fn elixir_applying_remote_call<'a, 'c>(
225    op: &Node<'a>,
226    code: &[u8],
227    ancestors: Ancestors<'a, 'c>,
228) -> Option<ElixirRemoteCall<'a, 'c>> {
229    let mut up = ancestors.iter(op);
230    let (mut dot, _) = up.next()?;
231    if dot.kind() == "operator_identifier" {
232        dot = up.next()?.0;
233    }
234    if dot.kind() != "dot" {
235        return None;
236    }
237    let (call, call_ancestors) = up.next()?;
238    kernel_short_circuit_parts(&call, code)
239        .filter(|(name, _)| name.id() == op.id())
240        .map(|(_, arguments)| ElixirRemoteCall {
241            call,
242            arguments,
243            ancestors: call_ancestors,
244        })
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::ElixirCode;
251    use crate::node::Tree;
252    use crate::test_support::for_each_node_with_chain;
253
254    #[cfg(feature = "elixir")]
255    #[test]
256    fn short_circuit_name_requires_the_identifiers_source_span() {
257        let source = b"Kernel.and(a, b)";
258        let tree = Tree::new::<ElixirCode>(source);
259        let name = tree
260            .get_root()
261            .preorder()
262            .find(|node| {
263                node.kind_id() == Elixir::Identifier as u16 && node.utf8_text(source) == Some("and")
264            })
265            .expect("fixture contains the operator name");
266        assert_eq!(
267            elixir_short_circuit_name(&name, source),
268            Some(Elixir::AMPAMP)
269        );
270        assert_eq!(
271            elixir_short_circuit_name(&name, &source[..name.end_byte() - 1]),
272            None
273        );
274    }
275
276    #[cfg(feature = "elixir")]
277    #[test]
278    fn remote_call_lookup_rejects_a_root_and_keeps_its_argument_list() {
279        let source = b"Kernel.and(a, b)\nKernel.||(c, d)\n";
280        let mut recognized = Vec::new();
281        for_each_node_with_chain::<ElixirCode>(source, |node, chain| {
282            let known = elixir_applying_remote_call(node, source, Ancestors::known(chain));
283            let unknown = elixir_applying_remote_call(node, source, Ancestors::unknown());
284            assert_eq!(
285                known.as_ref().map(|r| (r.call.id(), r.arguments.id())),
286                unknown.map(|r| (r.call.id(), r.arguments.id()))
287            );
288            if chain.is_empty() {
289                assert!(known.is_none());
290            }
291            if let Some(remote) = known {
292                assert_eq!(remote.arguments.kind(), "arguments");
293                assert_eq!(
294                    remote.ancestors.parent(&remote.call).map(|p| p.kind()),
295                    Some("source")
296                );
297                recognized.push((
298                    node.utf8_text(source)
299                        .expect("operator is UTF-8")
300                        .to_owned(),
301                    remote
302                        .arguments
303                        .utf8_text(source)
304                        .expect("arguments are UTF-8")
305                        .to_owned(),
306                ));
307            }
308        });
309        assert_eq!(
310            recognized,
311            [
312                ("and".to_owned(), "(a, b)".to_owned()),
313                ("||".to_owned(), "(c, d)".to_owned())
314            ]
315        );
316    }
317}