Repository: stfc/PSyclone (python)

Issue:
[nemo] PSyclone loses ‘ELEMENTAL’ and ‘IMPURE ELEMENTAL’ subroutine prefixes
Simon has reported that:

PSyclone appears to falsely drop ‘ELEMENTAL’ and ‘IMPURE ELEMENTAL’ subroutine prefixes. ‘ELEMENTAL’ and ‘IMPURE ELEMENTAL’ prefixes have recently been added to some functions in NEMO ‘main’. In builds without AGRIF processing these functions appear to be reproduced correctly by PSyclone, but after AGRIF processing the prefixes additionally apply to subroutines and seem to disappear during PSyclone processing.

We've discussed having the `ELEMENTAL` property previously. I guess this would need an extension to `RoutineSymbol`.

![image](https://user-images.githubusercontent.com/17409212/234906574-63149d4f-ef4f-4846-a19d-eb5354fc9a2e.png)


Pull request description:
(Closes #2119) Add support for elemental and pure routine attributes

Patch:
diff --git a/changelog b/changelog
index e6c7d653b..bb0e0a575 100644
--- a/changelog
+++ b/changelog
@@ -465,6 +465,8 @@
 	157) PR #2131 towards #1618. Improves the error handling when we find
 	a Routine with the same name as an LFRic Builtin.
 
+	158) PR #2147 for #2119. Add elemental and pure routine attributes.
+
 release 2.3.1 17th of June 2022
 
 	1) PR #1747 for #1720. Adds support for If blocks to PSyAD.
diff --git a/src/psyclone/domain/nemo/transformations/nemo_arrayrange2loop_trans.py b/src/psyclone/domain/nemo/transformations/nemo_arrayrange2loop_trans.py
index 3faeac8aa..4871335b5 100644
--- a/src/psyclone/domain/nemo/transformations/nemo_arrayrange2loop_trans.py
+++ b/src/psyclone/domain/nemo/transformations/nemo_arrayrange2loop_trans.py
@@ -32,7 +32,7 @@
 # POSSIBILITY OF SUCH DAMAGE.
 # -----------------------------------------------------------------------------
 # Author R. W. Ford, STFC Daresbury Lab
-# Modified S. Siso and N. Nobre, STFC Daresbury Lab
+# Modified A. R. Porter, S. Siso and N. Nobre, STFC Daresbury Lab
 
 '''Module providing a transformation that given an Assignment node to an
 ArrayReference in its left-hand-side which has at least one PSyIR Range
@@ -41,7 +41,6 @@ to the equivalent explicit loop representation using a NemoLoop node.
 
 '''
 
-from __future__ import absolute_import
 from psyclone.domain.nemo.transformations.create_nemo_kernel_trans import \
     CreateNemoKernelTrans
 from psyclone.errors import LazyString, InternalError
@@ -115,8 +114,6 @@ class NemoArrayRange2LoopTrans(Transformation):
         '''
         self.validate(node)
 
-        array_reference = node.parent
-        array_index = node.parent.indices.index(node)
         assignment = node.ancestor(Assignment)
         parent = assignment.parent
         # Ensure we always use the routine-level symbol table
@@ -198,6 +195,8 @@ class NemoArrayRange2LoopTrans(Transformation):
         :raises TransformationError: if the node argument has nested array \
             expressions with Ranges or is an invalid tree with ranges in \
             multiple locations of a structure of arrays.
+        :raises TransformationError: if the node argument contains a \
+            non-elemental Operation or Call.
 
         '''
         # Am I Range node?
@@ -238,23 +237,33 @@ class NemoArrayRange2LoopTrans(Transformation):
                     f" that contain nested Range structures, but found:"
                     f"\n{assignment.debug_string()}"))
 
-        # Does the rhs of the assignment have any operations that are not
+        # Does the rhs of the assignment have any operations/calls that are not
         # elemental?
-        for operation in assignment.rhs.walk(Operation):
-            # Allow non elemental UBOUND and LBOUND
-            if operation.operator is BinaryOperation.Operator.LBOUND:
-                continue
-            if operation.operator is BinaryOperation.Operator.UBOUND:
-                continue
-            if not operation.is_elemental():
-                raise TransformationError(
-                    f"Error in NemoArrayRange2LoopTrans transformation. This "
-                    f"transformation does not support non-elemental operations"
-                    f" on the rhs of the associated Assignment node, but found"
-                    f" '{operation.operator.name}'.")
+        for cnode in assignment.rhs.walk((Operation, Call)):
+            # Allow non elemental UBOUND and LBOUND.
+            # TODO #2156 - add support for marking routines as being 'inquiry'
+            # to improve this special-casing.
+            if isinstance(cnode, Operation):
+                if cnode.operator is BinaryOperation.Operator.LBOUND:
+                    continue
+                if cnode.operator is BinaryOperation.Operator.UBOUND:
+                    continue
+                name = cnode.operator.name
+                type_txt = "Operation"
+            else:
+                name = cnode.routine.name
+                type_txt = "Call"
+            if not cnode.is_elemental:
+                # pylint: disable=cell-var-from-loop
+                raise TransformationError(LazyString(
+                    lambda: f"Error in NemoArrayRange2LoopTrans "
+                    f"transformation. This transformation does not support non"
+                    f"-elemental {type_txt}s on the rhs of the associated "
+                    f"Assignment node, but found '{name}' in:\n"
+                    f"{assignment.debug_string()}'."))
 
         # Do a single walk to avoid doing a separate one for each type we need
-        nodes_to_check = assignment.walk((CodeBlock, Call, Reference))
+        nodes_to_check = assignment.walk((CodeBlock, Reference))
 
         # Do not allow to transform expressions with CodeBlocks
         if any(isinstance(n, CodeBlock) for n in nodes_to_check):
@@ -263,15 +272,6 @@ class NemoArrayRange2LoopTrans(Transformation):
                 f"This transformation does not support array assignments that"
                 f" contain a CodeBlock anywhere in the expression, but found:"
                 f"\n{assignment.debug_string()}"))
-        # Do not allow to transform expressions with function calls (to allow
-        # this we need to differentiate between elemental and not elemental
-        # functions as they have different semantics in array notation)
-        if any(isinstance(n, Call) for n in nodes_to_check):
-            raise TransformationError(LazyString(
-                lambda: f"Error in NemoArrayRange2LoopTrans transformation. "
-                f"This transformation does not support array assignments that"
-                f" contain a Call anywhere in the expression, but found:"
-                f"\n{assignment.debug_string()}"))
 
         references = [n for n in nodes_to_check if isinstance(n, Reference)]
         for reference in references:
diff --git a/src/psyclone/psyir/frontend/fparser2.py b/src/psyclone/psyir/frontend/fparser2.py
index efa602cd0..e3e00d5c2 100644
--- a/src/psyclone/psyir/frontend/fparser2.py
+++ b/src/psyclone/psyir/frontend/fparser2.py
@@ -99,6 +99,9 @@ INTENT_MAPPING = {"in": ArgumentInterface.Access.READ,
                   "out": ArgumentInterface.Access.WRITE,
                   "inout": ArgumentInterface.Access.READWRITE}
 
+#: Those routine prefix specifications that we support.
+SUPPORTED_ROUTINE_PREFIXES = ["ELEMENTAL", "PURE", "IMPURE"]
+
 
 # TODO #1987. It may be that this method could be made more general so
 # that it works for more intrinsics, to help minimise the number of
@@ -825,8 +828,9 @@ def _process_routine_symbols(module_ast, symbol_table, visibility_map):
     :param symbol_table: the SymbolTable to which to add the symbols.
     :type symbol_table: :py:class:`psyclone.psyir.symbols.SymbolTable`
     :param visibility_map: dict of symbol names with explicit visibilities.
-    :type visibility_map: dict with symbol names as keys and visibilities as \
-                          values
+    :type visibility_map: Dict[str, \
+        :py:class:`psyclone.psyir.symbols.Symbol.Visibility`]
+
     '''
     routines = walk(module_ast, (Fortran2003.Subroutine_Subprogram,
                                  Fortran2003.Function_Subprogram))
@@ -834,12 +838,35 @@ def _process_routine_symbols(module_ast, symbol_table, visibility_map):
     # it is at this stage so we give all functions a DeferredType.
     # TODO #1314 extend the frontend to ensure that the type of a Routine's
     # return_symbol matches the type of the associated RoutineSymbol.
-    type_map = {Fortran2003.Subroutine_Subprogram: NoType(),
-                Fortran2003.Function_Subprogram: DeferredType()}
+    type_map = {Fortran2003.Subroutine_Subprogram: NoType,
+                Fortran2003.Function_Subprogram: DeferredType}
+
     for routine in routines:
+
+        # Fortran routines are impure by default.
+        is_pure = False
+        # By default, Fortran routines are not elemental.
+        is_elemental = False
+        # Name of the routine.
         name = str(routine.children[0].children[1]).lower()
+        # Type to give the RoutineSymbol.
+        sym_type = type_map[type(routine)]()
+        # Visibility of the symbol.
         vis = visibility_map.get(name, symbol_table.default_visibility)
-        rsymbol = RoutineSymbol(name, type_map[type(routine)], visibility=vis,
+        # Check any prefixes on the routine declaration.
+        prefix = routine.children[0].children[0]
+        if prefix:
+            for child in prefix.children:
+                if isinstance(child, Fortran2003.Prefix_Spec):
+                    if child.string == "PURE":
+                        is_pure = True
+                    elif child.string == "IMPURE":
+                        is_pure = False
+                    elif child.string == "ELEMENTAL":
+                        is_elemental = True
+
+        rsymbol = RoutineSymbol(name, sym_type, visibility=vis,
+                                is_pure=is_pure, is_elemental=is_elemental,
                                 interface=DefaultModuleInterface())
         symbol_table.add(rsymbol)
 
@@ -4337,6 +4364,9 @@ class Fparser2Reader():
         :returns: PSyIR representation of node.
         :rtype: :py:class:`psyclone.psyir.nodes.Routine`
 
+        :raises NotImplementedError: if an unsupported prefix is found or no \
+            explicit type information is available for a Function.
+
         '''
         name = node.children[0].children[1].string
         routine = Routine(name, parent=parent)
@@ -4365,22 +4395,22 @@ class Fparser2Reader():
 
         self.process_declarations(routine, decl_list, arg_list)
 
-        # If this is a function then work out the return type
-        if isinstance(node, Fortran2003.Function_Subprogram):
-            # Check whether the function-stmt has a prefix specifying the
-            # return type.
-            prefix = node.children[0].children[0]
-            if prefix:
-                # If there is anything else in the prefix (PURE, ELEMENTAL or
-                # RECURSIVE) then we will create a CodeBlock for this function.
-                if len(prefix.children) > 1:
-                    raise NotImplementedError()
-                base_type, _ = self._process_type_spec(parent,
-                                                       prefix.children[0])
-            else:
-                base_type = None
+        # Check whether the function-stmt has a prefix specifying the
+        # return type (other prefixes are handled in
+        # _process_routine_symbols()).
+        base_type = None
+        prefix = node.children[0].children[0]
+        if prefix:
+            for child in prefix.children:
+                if isinstance(child, Fortran2003.Prefix_Spec):
+                    if child.string not in SUPPORTED_ROUTINE_PREFIXES:
+                        raise NotImplementedError(
+                            f"Routine has unsupported prefix: {child.string}")
+                else:
+                    base_type, _ = self._process_type_spec(parent, child)
 
-            # Check whether the function-stmt has a suffix containing
+        if isinstance(node, Fortran2003.Function_Subprogram):
+            # Check whether this function-stmt has a suffix containing
             # 'RETURNS'
             suffix = node.children[0].children[3]
             if suffix:
@@ -4409,7 +4439,9 @@ class Fparser2Reader():
                         # The type of the return value was not specified in the
                         # function prefix either therefore we have no explicit
                         # type information for it.
-                        raise NotImplementedError()
+                        raise NotImplementedError(
+                            f"No explicit type information found for "
+                            f"function '{name}'")
                     # Remove the RoutineSymbol ready to replace it with a
                     # DataSymbol.
                     routine.symbol_table.remove(symbol)
diff --git a/src/psyclone/psyir/nodes/call.py b/src/psyclone/psyir/nodes/call.py
index 9e73b78a4..1c538eaa9 100644
--- a/src/psyclone/psyir/nodes/call.py
+++ b/src/psyclone/psyir/nodes/call.py
@@ -302,6 +302,27 @@ class Call(Statement, DataNode):
         '''
         return self._routine
 
+    @property
+    def is_elemental(self):
+        '''
+        :returns: whether the routine being called is elemental (provided with\
+            an input array it will apply the operation individually to each of\
+            the array elements and return an array with the results). If this \
+            information is not known then it returns None.
+        :rtype: NoneType | bool
+        '''
+        return self._routine.is_elemental
+
+    @property
+    def is_pure(self):
+        '''
+        :returns: whether the routine being called is pure (guaranteed to \
+            return the same result when provided with the same argument \
+            values).  If this information is not known then it returns None.
+        :rtype: NoneType | bool
+        '''
+        return self._routine.is_pure
+
     @property
     def argument_names(self):
         '''
diff --git a/src/psyclone/psyir/nodes/intrinsic_call.py b/src/psyclone/psyir/nodes/intrinsic_call.py
index 58ee69378..111f233c6 100644
--- a/src/psyclone/psyir/nodes/intrinsic_call.py
+++ b/src/psyclone/psyir/nodes/intrinsic_call.py
@@ -125,7 +125,12 @@ class IntrinsicCall(Call):
                 f"'{type(routine).__name__}'.")
 
         # A Call expects a symbol, so give it an intrinsic symbol.
-        super().__init__(IntrinsicSymbol(routine.name), **kwargs)
+        super().__init__(
+            IntrinsicSymbol(
+                routine.name,
+                is_elemental=(routine in ELEMENTAL_INTRINSICS),
+                is_pure=(routine in PURE_INTRINSICS)),
+            **kwargs)
         self._intrinsic = routine
 
     @property
@@ -138,15 +143,6 @@ class IntrinsicCall(Call):
         '''
         return self._intrinsic
 
-    def is_elemental(self):
-        '''
-        :returns: whether this intrinsic is elemental (provided with an input \
-            array it will apply the operation individually to each of the \
-            array elements and return an array with the results).
-        :rtype: bool
-        '''
-        return self.intrinsic in ELEMENTAL_INTRINSICS
-
     @classmethod
     def create(cls, routine, arguments):
         '''Create an instance of this class given the type of routine and a
@@ -265,3 +261,11 @@ REDUCTION_INTRINSICS = [
 # individually to each of the array elements and return an array with
 # the results.
 ELEMENTAL_INTRINSICS = []
+
+# Intrinsics that are 'pure' functions. Given the same input arguments, a pure
+# function will always return the same value.
+PURE_INTRINSICS = [IntrinsicCall.Intrinsic.SUM,
+                   IntrinsicCall.Intrinsic.MINVAL,
+                   IntrinsicCall.Intrinsic.MAXVAL,
+                   IntrinsicCall.Intrinsic.TINY,
+                   IntrinsicCall.Intrinsic.HUGE]
diff --git a/src/psyclone/psyir/nodes/operation.py b/src/psyclone/psyir/nodes/operation.py
index f50a4f85b..f2aafb804 100644
--- a/src/psyclone/psyir/nodes/operation.py
+++ b/src/psyclone/psyir/nodes/operation.py
@@ -256,6 +256,7 @@ class Operation(DataNode, metaclass=ABCMeta):
                 new_argument_names.append((id(child), None))
         self._argument_names = new_argument_names
 
+    @property
     def is_elemental(self):
         '''
         :returns: whether this operation is elemental (provided with an input \
diff --git a/src/psyclone/psyir/nodes/routine.py b/src/psyclone/psyir/nodes/routine.py
index f40b15a1a..ea503d60f 100644
--- a/src/psyclone/psyir/nodes/routine.py
+++ b/src/psyclone/psyir/nodes/routine.py
@@ -79,7 +79,7 @@ class Routine(Schedule, CommentableMixin):
     def __eq__(self, other):
         '''
         Checks whether two nodes are equal. Two Routine nodes are equal
-        if they have the same name, same return symbol, same is_program and
+        if they have the same name, same return symbol, same properties and
         the inherited __eq__ is True.
 
         :param object other: the object to check equality to.
@@ -207,7 +207,7 @@ class Routine(Schedule, CommentableMixin):
                     return  # The preexisting symbol already matches
                 # Otherwise raise an exception
                 raise KeyError(
-                    f"Can't assign {new_name} as the routine name because "
+                    f"Can't assign '{new_name}' as the routine name because "
                     f"its symbol table contains a symbol ({existing_symbol}) "
                     f"already tagged as 'own_routine_symbol'.")
 
diff --git a/src/psyclone/psyir/symbols/routinesymbol.py b/src/psyclone/psyir/symbols/routinesymbol.py
index 6fded5b52..91383a02c 100644
--- a/src/psyclone/psyir/symbols/routinesymbol.py
+++ b/src/psyclone/psyir/symbols/routinesymbol.py
@@ -1,7 +1,7 @@
 # -----------------------------------------------------------------------------
 # BSD 3-Clause License
 #
-# Copyright (c) 2020-2022, Science and Technology Facilities Council.
+# Copyright (c) 2020-2023, Science and Technology Facilities Council.
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
@@ -36,7 +36,6 @@
 
 ''' This module contains the RoutineSymbol.'''
 
-from __future__ import absolute_import
 from psyclone.psyir.symbols.datatypes import NoType
 from psyclone.psyir.symbols.typed_symbol import TypedSymbol
 
@@ -60,13 +59,20 @@ class RoutineSymbol(TypedSymbol):
         # specialised instead of constructed.
         if datatype is None:
             datatype = NoType()
-        super(RoutineSymbol, self).__init__(name, datatype)
+        super().__init__(name, datatype)
+        # Whether this Routine is 'elemental'. A value of None indicates that
+        # this is unknown.
+        self._is_elemental = None
+        # Whether this Routine is 'pure' (has no side effects). A value of
+        # None indicates that this is unknown.
+        self._is_pure = None
         self._process_arguments(**kwargs)
 
     def _process_arguments(self, **kwargs):
         ''' Process the arguments for the constructor and the specialise
-        methods. In this case it provides a default NoType datatype is
-        none is found or provided.
+        methods. In this case it provides a default NoType datatype if
+        none is found or provided. It also handles the 'is_pure' and
+        'is_elemental' arguments since these are specific to RoutineSymbol.
 
         :param kwargs: keyword arguments which can be:\n
             the arguments in :py:class:`psyclone.psyir.symbols.TypedSymbol`
@@ -76,12 +82,74 @@ class RoutineSymbol(TypedSymbol):
         if "datatype" not in kwargs and \
            (not hasattr(self, '_datatype') or self.datatype is None):
             kwargs["datatype"] = NoType()
-        super(RoutineSymbol, self)._process_arguments(**kwargs)
+        # Use the setters as they perform type checking.
+        self.is_elemental = kwargs.pop("is_elemental", None)
+        self.is_pure = kwargs.pop("is_pure", None)
+
+        super()._process_arguments(**kwargs)
 
     def __str__(self):
-        # This implementation could be moved to TypedSymbol but it is kept
-        # here to enable us to keep TypedSymbol abstract.
-        return f"{self.name}: {type(self).__name__}<{self.datatype}>"
+        is_pure = "unknown" if self.is_pure is None else f"{self.is_pure}"
+        is_elemental = ("unknown" if self.is_elemental is None
+                        else f"{self.is_elemental}")
+        return (f"{self.name}: {type(self).__name__}<{self.datatype}, "
+                f"pure={is_pure}, elemental={is_elemental}>")
+
+    @property
+    def is_pure(self):
+        '''
+        :returns: whether the routine represented by this Symbol has no side \
+            effects (guarantees that the routine always returns the same \
+            result for a given set of inputs).
+        :rtype: bool | NoneType
+        '''
+        return self._is_pure
+
+    @is_pure.setter
+    def is_pure(self, value):
+        '''
+        Sets whether or not the Routine represented by this Symbol is \
+        guaranteed not to have side effects (see the corresponding property \
+        description).
+
+        :param value: the new value for the is_pure property.
+        :type value: NoneType | bool
+
+        :raises TypeError: if the type of the supplied value is invalid.
+
+        '''
+        if value is not None and not isinstance(value, bool):
+            raise TypeError(f"is_pure for a {type(self).__name__} must be a "
+                            f"bool or None but got '{type(value).__name__}'")
+        self._is_pure = value
+
+    @property
+    def is_elemental(self):
+        '''
+        :returns: whether the routine represented by this Symbol is elemental \
+            (acts element-by-element on supplied array arguments) or None if \
+            this is not known.
+        :rtype: bool | NoneType
+        '''
+        return self._is_elemental
+
+    @is_elemental.setter
+    def is_elemental(self, value):
+        '''
+        Sets whether or not the Routine represented by this Symbol is
+        elemental.
+
+        :param value: the new value for the is_elemental property.
+        :type value: NoneType | bool
+
+        :raises TypeError: if the type of the supplied value is invalid.
+
+        '''
+        if value is not None and not isinstance(value, bool):
+            raise TypeError(f"is_elemental for a {type(self).__name__} must "
+                            f"be a bool or None but got "
+                            f"'{type(value).__name__}'")
+        self._is_elemental = value
 
 
 # For Sphinx AutoAPI documentation generation


Test patch:
diff --git a/src/psyclone/tests/domain/common/extract_driver_creator_test.py b/src/psyclone/tests/domain/common/extract_driver_creator_test.py
index f5edcba78..94987707b 100644
--- a/src/psyclone/tests/domain/common/extract_driver_creator_test.py
+++ b/src/psyclone/tests/domain/common/extract_driver_creator_test.py
@@ -528,7 +528,8 @@ def test_driver_creation_import_modules(fortran_reader):
     assert str(all_symbols["my_module"]) == \
         "my_module: ContainerSymbol<not linked>"
     mod_func = all_symbols["mod_func"]
-    assert str(mod_func) == "mod_func: RoutineSymbol<DeferredType>"
+    assert str(mod_func) == ("mod_func: RoutineSymbol<DeferredType, "
+                             "pure=unknown, elemental=unknown>")
 
 
 # -----------------------------------------------------------------------------
diff --git a/src/psyclone/tests/domain/nemo/transformations/nemo_arrayrange2loop_trans_test.py b/src/psyclone/tests/domain/nemo/transformations/nemo_arrayrange2loop_trans_test.py
index 4cecb1760..1967e65bf 100644
--- a/src/psyclone/tests/domain/nemo/transformations/nemo_arrayrange2loop_trans_test.py
+++ b/src/psyclone/tests/domain/nemo/transformations/nemo_arrayrange2loop_trans_test.py
@@ -1,7 +1,7 @@
 # -----------------------------------------------------------------------------
 # BSD 3-Clause License
 #
-# Copyright (c) 2020-2022, Science and Technology Facilities Council.
+# Copyright (c) 2020-2023, Science and Technology Facilities Council.
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
@@ -31,13 +31,11 @@
 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 # POSSIBILITY OF SUCH DAMAGE.
 # -----------------------------------------------------------------------------
-# Authors R. W. Ford and S. Siso, STFC Daresbury Lab
+# Authors R. W. Ford, A. R. Porter and S. Siso, STFC Daresbury Lab
 
 '''Module containing tests for the NemoArrayRange2LoopTrans
 transformation.'''
 
-from __future__ import absolute_import
-
 import os
 import pytest
 
@@ -376,8 +374,8 @@ def test_validate_with_a_function_call():
 
     with pytest.raises(TransformationError) as info:
         trans.apply(array_ref.children[2])
-    assert ("This transformation does not support array assignments that "
-            "contain a Call anywhere in the expression, but found:\n"
+    assert ("This transformation does not support non-elemental Calls on the "
+            "rhs of the associated Assignment node, but found 'func' in:\n"
             "umask(:,:,:) = 0.0d0 + func()\n" in str(info.value))
 
 
@@ -573,8 +571,8 @@ def test_validate_array_non_elemental_operator():
         trans.apply(array_ref.children[0])
     assert (
         "Error in NemoArrayRange2LoopTrans transformation. This "
-        "transformation does not support non-elemental operations on the rhs "
-        "of the associated Assignment node, but found 'MATMUL'."
+        "transformation does not support non-elemental Operations on the rhs "
+        "of the associated Assignment node, but found 'MATMUL' in:"
         in str(info.value))
 
 
diff --git a/src/psyclone/tests/psyir/backend/fortran_unknown_declns_test.py b/src/psyclone/tests/psyir/backend/fortran_unknown_declns_test.py
index dcc1324d3..6fdddee89 100644
--- a/src/psyclone/tests/psyir/backend/fortran_unknown_declns_test.py
+++ b/src/psyclone/tests/psyir/backend/fortran_unknown_declns_test.py
@@ -35,18 +35,21 @@
 # -----------------------------------------------------------------------------
 
 '''Performs pytest tests on the support for declarations of unknown type in
-   the psyclone.psyir.backend.fortran module'''
+   the psyclone.psyir.backend.fortran module.'''
 
+import os
 import pytest
 
 from psyclone.errors import InternalError
-from psyclone.psyir.backend.fortran import \
-    add_accessibility_to_unknown_declaration
+from psyclone.configuration import Config
+from psyclone.psyir.backend.fortran import (
+    add_accessibility_to_unknown_declaration)
 from psyclone.psyir.backend.visitor import VisitorError
 from psyclone.psyir.nodes import Container, Routine
-from psyclone.psyir.symbols import Symbol, DataSymbol, RoutineSymbol, \
-    UnknownType, UnknownFortranType, ImportInterface, ContainerSymbol, \
-    SymbolTable, ArgumentInterface, INTEGER_TYPE
+from psyclone.psyir.symbols import (
+    ArgumentInterface, DataSymbol, DeferredType, RoutineSymbol,
+    UnknownType, UnknownFortranType, ImportInterface, ContainerSymbol,
+    Symbol, SymbolTable, INTEGER_TYPE)
 from psyclone.tests.utilities import Compile
 
 
@@ -218,3 +221,48 @@ def test_fw_add_accessibility():
                       "  integer, private :: id\n"
                       "  integer, public :: flag\n"
                       "end type var")
+
+
+def test_generating_unknowntype_routine_imports(
+        fortran_reader, tmpdir, monkeypatch, fortran_writer):
+    ''' Tests that generating UnknownType imported RoutineSymbols (if their
+    DeferredType is resolved) are not misinterpreted as interfaces.'''
+
+    # Set up include_path to import the proper modules
+    monkeypatch.setattr(Config.get(), '_include_paths', [str(tmpdir)])
+
+    filename = os.path.join(str(tmpdir), "a_mod.f90")
+    with open(filename, "w", encoding='UTF-8') as module:
+        module.write('''
+          module a_mod
+              contains
+              character(len=3) function unknown_type_symbol()
+                 unknown_type_symbol = 'a'
+              end function unknown_type_symbol
+          end module a_mod
+        ''')
+    psyir = fortran_reader.psyir_from_source('''
+          module test_mod
+              use a_mod, only: unknown_type_symbol
+              contains
+              subroutine test()
+                  integer :: a
+                  a = unknown_type_symbol()
+              end subroutine test
+          end module test_mod
+      ''')
+    module = psyir.children[0]
+    symbol = module.symbol_table.lookup('unknown_type_symbol')
+    module.symbol_table.resolve_imports()
+
+    # symbol is now a RoutineSymbol
+    assert isinstance(symbol, RoutineSymbol)
+    # but its datatype has not been resolved and is still a DeferredType
+    # instead of UnknownFortranType
+    assert not isinstance(symbol.datatype, UnknownFortranType)
+    assert isinstance(symbol.datatype, DeferredType)
+
+    # And the output does not mistake it for an interface
+    code = fortran_writer(psyir)
+    assert "use a_mod, only : unknown_type_symbol" in code
+    assert "interface" not in code.lower()
diff --git a/src/psyclone/tests/psyir/frontend/fparser2_subroutine_handler_test.py b/src/psyclone/tests/psyir/frontend/fparser2_subroutine_handler_test.py
index f7278cd86..eff5e129c 100644
--- a/src/psyclone/tests/psyir/frontend/fparser2_subroutine_handler_test.py
+++ b/src/psyclone/tests/psyir/frontend/fparser2_subroutine_handler_test.py
@@ -46,8 +46,9 @@ from psyclone.errors import InternalError
 from psyclone.psyir.frontend.fparser2 import (Fparser2Reader,
                                               TYPE_MAP_FROM_FORTRAN)
 from psyclone.psyir.nodes import Container, Routine, CodeBlock, FileContainer
-from psyclone.psyir.symbols import (DataSymbol, ScalarType, UnknownFortranType,
-                                    RoutineSymbol)
+from psyclone.psyir.symbols import (DataSymbol, DeferredType, NoType,
+                                    RoutineSymbol, ScalarType,
+                                    UnknownFortranType)
 
 IN_OUTS = []
 # subroutine no declarations
@@ -303,14 +304,25 @@ def test_function_unsupported_type(fortran_reader):
         "    complex :: my_func\n"
         "    my_func = CMPLX(1.0, 1.0)\n"
         "  end function my_func\n"
+        "\n"
+        "  character(len=3) function Agrif_CFixed()\n"
+        "    Agrif_CFixed = '0'\n"
+        "  end function Agrif_CFixed\n"
         "end module\n")
     psyir = fortran_reader.psyir_from_source(code)
-    routine = psyir.children[0].children[0]
-    assert isinstance(routine, Routine)
-    assert routine.return_symbol.name == "my_func"
-    assert isinstance(routine.return_symbol.datatype, UnknownFortranType)
-    assert (routine.return_symbol.datatype.declaration.lower() ==
+    routines = psyir.walk(Routine)
+    assert routines[0].return_symbol.name == "my_func"
+    assert isinstance(routines[0].return_symbol.datatype, UnknownFortranType)
+    assert (routines[0].return_symbol.datatype.declaration.lower() ==
             "complex :: my_func")
+    # The Agrif_CFixed function ends up as a CodeBlock because of the
+    # unsupported type prefix.
+    assert isinstance(routines[0].parent.children[1], CodeBlock)
+    table = psyir.children[0].symbol_table
+    for name in ["my_func", "agrif_cfixed"]:
+        sym = table.lookup(name)
+        assert isinstance(sym, RoutineSymbol)
+        assert isinstance(sym.datatype, DeferredType)
 
 
 def test_function_unsupported_derived_type(fortran_reader):
@@ -339,20 +351,50 @@ def test_function_unsupported_derived_type(fortran_reader):
     assert sym.datatype.declaration.lower() == "type(my_type), pointer :: var1"
 
 
-@pytest.mark.parametrize("fn_prefix",
-                         ["pure real", "real pure", "recursive", "elemental"])
-def test_unsupported_function_prefix(fortran_reader, fn_prefix):
-    ''' Check that we get a CodeBlock if a Fortran function has an unsupported
+@pytest.mark.parametrize("fn_prefix", ["elemental", "pure", "impure",
+                                       "pure elemental"])
+@pytest.mark.parametrize("routine_type", ["function", "subroutine"])
+def test_supported_prefix(fortran_reader, fn_prefix, routine_type):
+    '''Check that the frontend correctly handles a routine with the various
+    prefixes that the PSyIR supports.'''
+    code = (
+        f"module a\n"
+        f"contains\n"
+        f"  {fn_prefix} {routine_type} my_func()\n"
+        f"    real :: my_func\n"
+        f"    my_func = 1.0\n"
+        f"  end {routine_type} my_func\n"
+        f"end module\n")
+    psyir = fortran_reader.psyir_from_source(code)
+    routine = psyir.walk(Routine)[0]
+    rsym = routine.parent.scope.symbol_table.lookup("my_func")
+    assert isinstance(rsym, RoutineSymbol)
+    assert rsym.is_elemental is ("elemental" in fn_prefix)
+    assert rsym.is_pure is fn_prefix.startswith("pure")
+
+
+@pytest.mark.parametrize("routine_type", ["function", "subroutine"])
+@pytest.mark.parametrize("fn_prefix", ["recursive", "module"])
+def test_unsupported_routine_prefix(fortran_reader, fn_prefix, routine_type):
+    ''' Check that we get a CodeBlock if a Fortran routine has an unsupported
     prefix. '''
     code = (
         f"module a\n"
         f"contains\n"
-        f"  {fn_prefix} function my_func()\n"
+        f"  {fn_prefix} {routine_type} my_func()\n"
+        f"    real :: my_func\n"
         f"    my_func = 1.0\n"
-        f"  end function my_func\n"
+        f"  end {routine_type} my_func\n"
         f"end module\n")
     psyir = fortran_reader.psyir_from_source(code)
     assert isinstance(psyir.children[0].children[0], CodeBlock)
+    # The Symbol for this routine should be of either NoType or DeferredType.
+    fsym = psyir.children[0].symbol_table.lookup("my_func")
+    assert isinstance(fsym, RoutineSymbol)
+    if routine_type == "subroutine":
+        assert isinstance(fsym.datatype, NoType)
+    else:
+        assert isinstance(fsym.datatype, DeferredType)
 
 
 def test_unsupported_char_len_function(fortran_reader):
@@ -368,3 +410,6 @@ def test_unsupported_char_len_function(fortran_reader):
     cblock = psyir.children[0].children[0]
     assert isinstance(cblock, CodeBlock)
     assert "LEN = 2" in str(cblock.get_ast_nodes[0])
+    fsym = psyir.children[0].symbol_table.lookup("my_func")
+    assert isinstance(fsym, RoutineSymbol)
+    assert isinstance(fsym.datatype, DeferredType)
diff --git a/src/psyclone/tests/psyir/nodes/call_test.py b/src/psyclone/tests/psyir/nodes/call_test.py
index 415647497..b6581f072 100644
--- a/src/psyclone/tests/psyir/nodes/call_test.py
+++ b/src/psyclone/tests/psyir/nodes/call_test.py
@@ -70,6 +70,28 @@ def test_call_init():
     assert call.children == []
 
 
+def test_call_is_elemental():
+    '''Test the is_elemental property of a Call is set correctly and can be
+    queried.'''
+    routine = RoutineSymbol("zaphod", NoType())
+    call = Call(routine)
+    assert call.is_elemental is None
+    routine = RoutineSymbol("beeblebrox", NoType(), is_elemental=True)
+    call = Call(routine)
+    assert call.is_elemental is True
+
+
+def test_call_is_pure():
+    '''Test the is_pure property of a Call is set correctly and can be
+    queried.'''
+    routine = RoutineSymbol("zaphod", NoType())
+    call = Call(routine)
+    assert call.is_pure is None
+    routine = RoutineSymbol("beeblebrox", NoType(), is_pure=True)
+    call = Call(routine)
+    assert call.is_pure is True
+
+
 def test_call_equality():
     '''Test the __eq__ method of the Call class. '''
     # routine arguments
diff --git a/src/psyclone/tests/psyir/nodes/intrinsic_call_test.py b/src/psyclone/tests/psyir/nodes/intrinsic_call_test.py
index 40a2b51e3..2d8c378da 100644
--- a/src/psyclone/tests/psyir/nodes/intrinsic_call_test.py
+++ b/src/psyclone/tests/psyir/nodes/intrinsic_call_test.py
@@ -82,7 +82,15 @@ def test_intrinsiccall_is_elemental():
 
     '''
     intrinsic = IntrinsicCall(IntrinsicCall.Intrinsic.SUM)
-    assert not intrinsic.is_elemental()
+    assert intrinsic.is_elemental is False
+
+
+def test_intrinsiccall_is_pure():
+    '''Tests that the is_pure() method works as expected.'''
+    intrinsic = IntrinsicCall(IntrinsicCall.Intrinsic.SUM)
+    assert intrinsic.is_pure is True
+    intrinsic = IntrinsicCall(IntrinsicCall.Intrinsic.ALLOCATE)
+    assert intrinsic.is_pure is False
 
 
 def test_intrinsiccall_alloc_create():
diff --git a/src/psyclone/tests/psyir/nodes/operation_test.py b/src/psyclone/tests/psyir/nodes/operation_test.py
index 8e69960ce..266315ea0 100644
--- a/src/psyclone/tests/psyir/nodes/operation_test.py
+++ b/src/psyclone/tests/psyir/nodes/operation_test.py
@@ -525,9 +525,9 @@ def test_binaryoperation_is_elemental():
     for binary_operator in BinaryOperation.Operator:
         operation = BinaryOperation(binary_operator)
         if binary_operator in not_elemental:
-            assert not operation.is_elemental()
+            assert operation.is_elemental is False
         else:
-            assert operation.is_elemental()
+            assert operation.is_elemental is True
 
 
 # Test UnaryOperation class
@@ -691,7 +691,7 @@ def test_unaryoperation_is_elemental():
     # All unary operators are elemental
     for unary_operator in UnaryOperation.Operator:
         operation = UnaryOperation(unary_operator)
-        assert operation.is_elemental()
+        assert operation.is_elemental is True
 
 
 # Test NaryOperation class
@@ -824,7 +824,7 @@ def test_naryoperation_is_elemental():
     # All nary operations are elemental
     for nary_operator in NaryOperation.Operator:
         operation = NaryOperation(nary_operator)
-        assert operation.is_elemental()
+        assert operation.is_elemental is True
 
 
 def test_operations_can_be_copied():
diff --git a/src/psyclone/tests/psyir/nodes/routine_test.py b/src/psyclone/tests/psyir/nodes/routine_test.py
index bf05ca1a6..99b318b76 100644
--- a/src/psyclone/tests/psyir/nodes/routine_test.py
+++ b/src/psyclone/tests/psyir/nodes/routine_test.py
@@ -1,7 +1,7 @@
 # -----------------------------------------------------------------------------
 # BSD 3-Clause License
 #
-# Copyright (c) 2020-2022, Science and Technology Facilities Council.
+# Copyright (c) 2020-2023, Science and Technology Facilities Council.
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
@@ -37,7 +37,6 @@
 
 ''' This module contains the pytest tests for the Routine class. '''
 
-from __future__ import absolute_import
 import pytest
 from psyclone.psyir.nodes import Routine, Assignment, Reference, Literal, \
     ScopingNode
@@ -111,9 +110,10 @@ def test_routine_name_setter_preexisting_tag():
     # having a differently named 'own_routine_symbol' tag in the symbol table
     with pytest.raises(KeyError) as err:
         node2 = Routine("bye", symbol_table=symtab.deep_copy())
-    assert ("Can't assign bye as the routine name because its symbol table "
-            "contains a symbol (hello: RoutineSymbol<NoType>) already tagged "
-            "as 'own_routine_symbol'." in str(err.value))
+    assert ("Can't assign 'bye' as the routine name because its symbol table "
+            "contains a symbol (hello: RoutineSymbol<NoType, pure=unknown, "
+            "elemental=unknown>) already tagged as 'own_routine_symbol'."
+            in str(err.value))
 
     # But it is fine if the name is the same
     node2 = Routine("hello", symbol_table=symtab.deep_copy())
@@ -121,8 +121,8 @@ def test_routine_name_setter_preexisting_tag():
     assert node2.symbol_table is not node.symbol_table
     # And successive name changes are also fine
     node2.name = "bye"
-    assert node2.symbol_table.lookup_with_tag("own_routine_symbol").name == \
-        "bye"
+    assert (node2.symbol_table.lookup_with_tag("own_routine_symbol").name ==
+            "bye")
 
 
 def test_routine_return_symbol_setter():
diff --git a/src/psyclone/tests/psyir/symbols/routinesymbol_test.py b/src/psyclone/tests/psyir/symbols/routinesymbol_test.py
index 25e1d3021..dc4f28c31 100644
--- a/src/psyclone/tests/psyir/symbols/routinesymbol_test.py
+++ b/src/psyclone/tests/psyir/symbols/routinesymbol_test.py
@@ -1,7 +1,7 @@
 # -----------------------------------------------------------------------------
 # BSD 3-Clause License
 #
-# Copyright (c) 2020-2022, Science and Technology Facilities Council.
+# Copyright (c) 2020-2023, Science and Technology Facilities Council.
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
@@ -31,15 +31,15 @@
 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 # POSSIBILITY OF SUCH DAMAGE.
 # -----------------------------------------------------------------------------
-# Authors R. W. Ford and S. Siso, STFC Daresbury Lab
+# Authors R. W. Ford, A. R. Porter and S. Siso, STFC Daresbury Lab
 # -----------------------------------------------------------------------------
 
 ''' Perform py.test tests on the psygen.psyir.symbols.routinesymbol file '''
 
-from __future__ import absolute_import
 import pytest
-from psyclone.psyir.symbols import RoutineSymbol, Symbol, UnresolvedInterface,\
-    NoType, INTEGER_TYPE, DeferredType, DataTypeSymbol
+from psyclone.psyir.symbols import (
+    RoutineSymbol, Symbol, UnresolvedInterface,
+    NoType, INTEGER_TYPE, DeferredType, DataTypeSymbol)
 
 
 def test_routinesymbol_init():
@@ -48,6 +48,9 @@ def test_routinesymbol_init():
     jo_sym = RoutineSymbol('jo')
     assert isinstance(jo_sym, RoutineSymbol)
     assert isinstance(jo_sym.datatype, NoType)
+    # By default we don't know whether a symbol is pure or elemental.
+    assert jo_sym.is_pure is None
+    assert jo_sym.is_elemental is None
     ellie_sym = RoutineSymbol('ellie', INTEGER_TYPE,
                               visibility=Symbol.Visibility.PRIVATE)
     assert isinstance(ellie_sym, RoutineSymbol)
@@ -61,6 +64,12 @@ def test_routinesymbol_init():
     tam_sym = RoutineSymbol('tam', tam_type)
     assert isinstance(tam_sym, RoutineSymbol)
     assert tam_sym.datatype is tam_type
+    # Check that is_pure and is_elemental can be specified.
+    marvin_sym = RoutineSymbol('marvin', DeferredType(), is_pure=True)
+    assert marvin_sym.is_pure is True
+    paranoid_sym = RoutineSymbol('paranoid', DeferredType(),
+                                 is_elemental=False)
+    assert paranoid_sym.is_elemental is False
 
 
 def test_routinesymbol_init_error():
@@ -76,6 +85,14 @@ def test_routinesymbol_init_error():
         _ = RoutineSymbol("isaac", "integer")
     assert ("datatype of a RoutineSymbol must be specified using either a "
             "DataType or a DataTypeSymbol but got: 'str'" in str(error.value))
+    with pytest.raises(TypeError) as error:
+        _ = RoutineSymbol("android", DeferredType(), is_pure="maybe")
+    assert ("is_pure for a RoutineSymbol must be a bool or None but got "
+            "'str'" in str(error.value))
+    with pytest.raises(TypeError) as error:
+        _ = RoutineSymbol("android", DeferredType(), is_elemental="maybe")
+    assert ("is_elemental for a RoutineSymbol must be a bool or None but got "
+            "'str'" in str(error.value))
 
 
 def test_routinesymbol_specialise_and_process_arguments():
@@ -85,6 +102,8 @@ def test_routinesymbol_specialise_and_process_arguments():
     # Try to make a RoutineSymbol without a datatype
     sym1 = Symbol("symbol1")
     sym1.specialise(RoutineSymbol)
+    # pylint gets confused because it doesn't know about specialise()
+    # pylint: disable=no-member
     assert isinstance(sym1.datatype, NoType)
 
     # Include a datatype
@@ -92,15 +111,34 @@ def test_routinesymbol_specialise_and_process_arguments():
     sym2.specialise(RoutineSymbol, datatype=INTEGER_TYPE)
     assert sym2.datatype is INTEGER_TYPE
 
+    # Include is_pure
+    sym3 = Symbol("sym3")
+    sym3.specialise(RoutineSymbol, is_pure=False)
+    assert sym3.is_pure is False
+
+    # Include is_elemental
+    sym4 = Symbol("sym4")
+    sym4.specialise(RoutineSymbol, is_elemental=True)
+    assert sym4.is_elemental is True
+
 
 def test_routinesymbol_str():
     '''Test that the __str__ method in routinesymbol behaves as expected.'''
     routine_symbol = RoutineSymbol("roo")
-    assert routine_symbol.__str__() == "roo: RoutineSymbol<NoType>"
+    assert (str(routine_symbol) == "roo: RoutineSymbol<NoType, "
+            "pure=unknown, elemental=unknown>")
     routine_symbol = RoutineSymbol("roo", INTEGER_TYPE)
-    assert (routine_symbol.__str__() ==
-            "roo: RoutineSymbol<Scalar<INTEGER, UNDEFINED>>")
+    assert (str(routine_symbol) ==
+            "roo: RoutineSymbol<Scalar<INTEGER, UNDEFINED>, pure=unknown, "
+            "elemental=unknown>")
     type_sym = DataTypeSymbol("some_type", DeferredType())
-    routine_symbol = RoutineSymbol("roo", type_sym)
-    assert (routine_symbol.__str__() ==
-            "roo: RoutineSymbol<some_type: DataTypeSymbol>")
+    routine_symbol = RoutineSymbol("roo", type_sym, is_elemental=True,
+                                   is_pure=True)
+    assert (str(routine_symbol) ==
+            "roo: RoutineSymbol<some_type: DataTypeSymbol, pure=True, "
+            "elemental=True>")
+    routine_symbol = RoutineSymbol("eyore", type_sym, is_elemental=False,
+                                   is_pure=True)
+    assert (str(routine_symbol) ==
+            "eyore: RoutineSymbol<some_type: DataTypeSymbol, pure=True, "
+            "elemental=False>")
diff --git a/src/psyclone/tests/psyir/symbols/symbol_test.py b/src/psyclone/tests/psyir/symbols/symbol_test.py
index 14b80ceb6..2660e0b0f 100644
--- a/src/psyclone/tests/psyir/symbols/symbol_test.py
+++ b/src/psyclone/tests/psyir/symbols/symbol_test.py
@@ -265,7 +265,8 @@ def test_symbol_specialise():
     assert str(asym) == "a: Symbol<Automatic>"
     asym.specialise(RoutineSymbol)
     assert type(asym) is RoutineSymbol
-    assert str(asym) == "a: RoutineSymbol<NoType>"
+    assert (str(asym) == "a: RoutineSymbol<NoType, pure=unknown, "
+            "elemental=unknown>")
 
 
 @pytest.mark.parametrize("test_class", [Symbol, RoutineSymbol])