Repository: stfc/fparser

Issue:
(Towards #201) Symbol table does not respect use association
fparser2 fails for the following code:

    module my_mod

    contains

      subroutine log_minmax( self, log_level, label )

        use log_mod,    only : log_event, log_scratch_space,     &
                           application_log_level => log_level
        integer, intent(in) :: log_level
      end subroutine log_minmax

    end module

with:

    fparser.two.symbol_table.SymbolTableError: Symbol table already contains a use of a symbol named 'log_level' from module 'log_mod'

whereas 'log_mod' is actually renamed as 'application_log_level' in the use statement.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 410780d..e2a372b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,9 @@ Modifications by (in alphabetical order):
 * P. Vitt, University of Siegen, Germany
 * A. Voysey, UK Met Office
 
+20/09/2022 PR #376 for #349. Add support for use association to the
+           symbol table.
+
 15/09/2022 PR #378 for #375. Permit source files containing only comments
            to be parsed.
 
diff --git a/doc/developers_guide.rst b/doc/developers_guide.rst
index 57498c4..93b74d6 100644
--- a/doc/developers_guide.rst
+++ b/doc/developers_guide.rst
@@ -259,6 +259,16 @@ replaced with enumerations where it makes sense. Similarly, support
 will be added for other types of symbols (e.g. those representing
 program/subroutine names or reserved Fortran keywords).
 
+Symbols available in the scoping region of a module may be made
+available in another scoping region through one or more `USE` statements.
+In a `SymbolTable` such uses are captured as instances of `ModuleUse`:
+
+.. autoclass:: fparser.two.symbol_table.ModuleUse
+
+These instances are created by calling:
+
+.. automethod:: fparser.two.symbol_table.SymbolTable.add_use_symbols
+
 Fortran has support for nested scopes - e.g. variables declared within
 a module are in scope within any routines defined within that
 module. Therefore, when searching for the definition a symbol, we
diff --git a/src/fparser/two/Fortran2003.py b/src/fparser/two/Fortran2003.py
index d63f7ce..bc7b7f9 100644
--- a/src/fparser/two/Fortran2003.py
+++ b/src/fparser/two/Fortran2003.py
@@ -10282,17 +10282,41 @@ class Use_Stmt(StmtBase):  # pylint: disable=invalid-name
                  or None if the match fails.
         :rtype: 5-tuple of objects (module name and 4 optional) or NoneType
 
+        :raises InternalError: if an Only_List is found to contain anything \
+                               other than Name or Rename objects.
+
         """
         result = Use_Stmt._match(string)
         if result:
             table = SYMBOL_TABLES.current_scope
             if table:
                 only_list = None
-                # TODO #201 we currently ignore any symbol renaming here
+                rename_list = None
+                if "only" in result[3].lower():
+                    only_list = []
                 if isinstance(result[4], Only_List):
-                    names = walk(result[4], Name)
-                    only_list = [name.string for name in names]
-                table.add_use_symbols(str(result[2]), only_list)
+                    # An Only_List can contain either Name or Rename entries.
+                    for child in result[4].children:
+                        if isinstance(child, Name):
+                            only_list.append((child.string, None))
+                        elif isinstance(child, Rename):
+                            only_list.append(
+                                (child.children[1].string, child.children[2].string)
+                            )
+                        else:
+                            raise InternalError(
+                                f"An Only_List can contain only Name or Rename "
+                                f"entries but found '{type(child).__name__}' when matching '{string}'"
+                            )
+                elif isinstance(result[4], Rename_List):
+                    renames = walk(result[4], Rename)
+                    # Tuples of <local-name>, <use-name>
+                    rename_list = [
+                        (rename.children[1].string, rename.children[2].string)
+                        for rename in renames
+                    ]
+
+                table.add_use_symbols(str(result[2]), only_list, rename_list)
 
         return result
 
diff --git a/src/fparser/two/symbol_table.py b/src/fparser/two/symbol_table.py
index 1957459..6b25893 100644
--- a/src/fparser/two/symbol_table.py
+++ b/src/fparser/two/symbol_table.py
@@ -65,8 +65,9 @@ class SymbolTables:
         self._enable_checks = False
 
     def __str__(self):
-        result = "SymbolTables: {0} tables\n" "========================\n".format(
-            len(self._symbol_tables)
+        result = (
+            f"SymbolTables: {len(self._symbol_tables)} tables\n"
+            "========================\n"
         )
         return result + "\n".join(sorted(self._symbol_tables.keys()))
 
@@ -105,8 +106,8 @@ class SymbolTables:
         lower_name = name.lower()
         if lower_name in self._symbol_tables:
             raise SymbolTableError(
-                "The table of top-level (un-nested) symbol tables already "
-                "contains an entry for '{0}'".format(lower_name)
+                f"The table of top-level (un-nested) symbol tables already "
+                f"contains an entry for '{lower_name}'"
             )
         table = SymbolTable(lower_name, checking_enabled=self._enable_checks)
         self._symbol_tables[lower_name] = table
@@ -149,14 +150,10 @@ class SymbolTables:
         """
         if not isinstance(value, list):
             raise TypeError(
-                "Supplied value must be a list but got '{0}'".format(
-                    type(value).__name__
-                )
+                f"Supplied value must be a list but got '{type(value).__name__}'"
             )
         if not all(isinstance(item, type) for item in value):
-            raise TypeError(
-                "Supplied list must contain only classes but " "got: {0}".format(value)
-            )
+            raise TypeError(f"Supplied list must contain only classes but got: {value}")
         self._scoping_unit_classes = value
 
     @property
@@ -210,9 +207,7 @@ class SymbolTables:
                                   exit.
         """
         if not self._current_scope:
-            raise SymbolTableError(
-                "exit_scope() called but no current scope " "exists."
-            )
+            raise SymbolTableError("exit_scope() called but no current scope exists.")
         self._current_scope = self._current_scope.parent
 
     def remove(self, name):
@@ -238,13 +233,15 @@ class SymbolTables:
                 pass
 
         if lname not in self._symbol_tables:
-            msg = "Failed to find a table named '{0}' in ".format(name)
+            msg = f"Failed to find a table named '{name}' in "
             if self._current_scope:
-                msg += "either the current scope (which contains {0}) or ".format(
-                    [child.name for child in self._current_scope.children]
+                msg += (
+                    f"either the current scope (which contains "
+                    f"{[child.name for child in self._current_scope.children]}) or "
                 )
-            msg += "the list of top-level symbol tables ({0}).".format(
-                list(self._symbol_tables.keys())
+            msg += (
+                f"the list of top-level symbol tables "
+                f"({list(self._symbol_tables.keys())})."
             )
             raise SymbolTableError(msg)
 
@@ -254,15 +251,245 @@ class SymbolTables:
         if self._current_scope:
             if self._current_scope.root is top_table:
                 raise SymbolTableError(
-                    "Cannot remove top-level symbol table '{0}' because the "
-                    "current scope '{1}' has it as an ancestor.".format(
-                        name, self._current_scope.name
-                    )
+                    f"Cannot remove top-level symbol table '{name}' because the "
+                    f"current scope '{self._current_scope.name}' has it as an ancestor."
                 )
 
         del self._symbol_tables[lname]
 
 
+class ModuleUse:
+    """
+    Class capturing information on all USE statements referring to a given
+    Fortran module.
+
+    A USE statement can rename an imported symbol so as to avoid a clash in
+    the local scope, e.g. `USE my_mod, alocal => amod` where `amod` is the
+    name of the symbol declared in `my_mod`. This renaming can also occur
+    inside an Only_List, e.g. `USE my_mod, only: alocal => amod`.
+
+    :param str name: the name of the module.
+    :param only_list: list of 2-tuples giving the (local-name, module-name) \
+        of symbols that appear in an Only_List. If a symbol is not re-named \
+        then module-name can be None.
+    :type only_list: Optional[List[Tuple[str, str | NoneType]]]
+    :param rename_list: list of 2-tuples given the (local-name, module-name) \
+        of symbols that appear in a Rename_List.
+    :type rename_list: Optional[List[Tuple[str, str]]]
+
+    :raises TypeError: if any of the supplied parameters are of the wrong type.
+
+    """
+
+    def __init__(self, name, only_list=None, rename_list=None):
+        if not isinstance(name, str):
+            raise TypeError(
+                f"The name of the module must be a str but got "
+                f"'{type(name).__name__}'"
+            )
+        self._validate_tuple_list("only", only_list)
+        self._validate_tuple_list("rename", rename_list)
+
+        if only_list and not all(
+            isinstance(item[0], str) and (item[1] is None or isinstance(item[1], str))
+            for item in only_list
+        ):
+            raise TypeError(
+                f"If present, the only_list must be a list of "
+                f"2-tuples of (str, str | NoneType) but got: {only_list}"
+            )
+
+        if rename_list and not all(
+            isinstance(item[0], str) and isinstance(item[1], str)
+            for item in rename_list
+        ):
+            raise TypeError(
+                f"If present, the rename_list must be a list of "
+                f"2-tuples of (str, str) but got: {rename_list}"
+            )
+
+        self._name = name.lower()
+
+        # dict of Symbols known to be accessed in this module.
+        self._symbols = {}
+        # Mapping from local symbol name in current scope to actual, declared
+        # name in the module from which it is imported.
+        self._local_to_module_map = {}
+
+        if only_list is not None:
+            self._store_symbols(only_list)
+            self._wildcard_import = False
+            self._only_set = set(local_name.lower() for local_name, _ in only_list)
+        else:
+            self._only_set = None
+            self._wildcard_import = True
+
+        if rename_list:
+            self._store_symbols(rename_list)
+            self._rename_set = set(local_name.lower() for local_name, _ in rename_list)
+        else:
+            self._rename_set = None
+
+    @staticmethod
+    def _validate_tuple_list(name, tlist):
+        """
+        Validate the supplied list of tuples.
+
+        :param str name: the name of the list being validated.
+        :param tlist: the list of tuples to validate.
+        :type tlist: Optional[List[Tuple[str, str | NoneType]]]
+
+        :raises TypeError: if the supplied list is of the wrong type.
+
+        """
+        if not tlist:
+            # None or an empty list is fine.
+            return
+
+        if not isinstance(tlist, list):
+            raise TypeError(
+                f"If present, the {name}_list must be a list but "
+                f"got '{type(tlist).__name__}'"
+            )
+        if not all(isinstance(item, tuple) and len(item) == 2 for item in tlist):
+            raise TypeError(
+                f"If present, the {name}_list must be a list of "
+                f"2-tuples but got: {tlist}"
+            )
+
+    def _store_symbols(self, rename_list):
+        """
+        Utility that updates the local list of symbols and renaming map for
+        the given list of local-name, module-name tuples. If a symbol is
+        not renamed then module-name can be None.
+
+        :param rename_list: list of local-name, module-name pairs.
+        :type rename_list: List[Tuple[str, str | NoneType]]
+
+        """
+        for local_name, orig_name in rename_list:
+            lname = local_name.lower()
+            oname = orig_name.lower() if orig_name else None
+            if lname not in self._symbols:
+                self._symbols[lname] = SymbolTable.Symbol(lname, "unknown")
+            if oname:
+                self._local_to_module_map[lname] = oname
+
+    def update(self, other):
+        """
+        Update the current information with the information on the USE held
+        in 'other'.
+
+        :param other: the other Use instance from which to update this one.
+        :type other: :py:class:`fparser.two.symbol_table.Use`
+
+        :raises TypeError: if 'other' is of the wrong type.
+        :raises ValueError: if 'other' is for a module with a different \
+                            name to the current one.
+
+        """
+        if not isinstance(other, ModuleUse):
+            raise TypeError(
+                f"update() must be supplied with an instance of "
+                f"ModuleUse but got '{type(other).__name__}'"
+            )
+
+        if self.name != other.name:
+            raise ValueError(
+                f"The ModuleUse supplied to update() is for module "
+                f"'{other.name}' but this ModuleUse is for module "
+                f"'{self.name}'"
+            )
+
+        # Take the union of the only and rename lists and update the list
+        # of symbols.
+        if other.only_list:
+            for local_name in other.only_list:
+                if local_name not in self._symbols:
+                    self._symbols[local_name] = SymbolTable.Symbol(
+                        local_name, "unknown"
+                    )
+            # pylint: disable=protected-access
+            if self._only_set is None:
+                self._only_set = other._only_set
+            else:
+                self._only_set = self._only_set.union(other._only_set)
+            # pylint: enable=protected-access
+
+        if other.rename_list:
+            for local_name in other.rename_list:
+                if local_name not in self._symbols:
+                    self._symbols[local_name] = SymbolTable.Symbol(
+                        local_name, "unknown"
+                    )
+            # pylint: disable=protected-access
+            if self._rename_set is None:
+                self._rename_set = other._rename_set
+            else:
+                self._rename_set = self._rename_set.union(other._rename_set)
+            # pylint: enable=protected-access
+
+        # pylint: disable=protected-access
+        self._local_to_module_map.update(other._local_to_module_map)
+        # pylint: enable=protected-access
+
+        self._wildcard_import = self._wildcard_import or other.wildcard_import
+
+    @property
+    def name(self):
+        """
+        :returns: the name of the Fortran module.
+        :rtype: str
+        """
+        return self._name
+
+    @property
+    def symbol_names(self):
+        """
+        :returns: the names of all symbols associated with USE(s) of this \
+                  module.
+        :rtype: List[str]
+        """
+        return list(self._symbols.keys())
+
+    @property
+    def only_list(self):
+        """
+        :returns: the local names that appear in an Only_List or None if there \
+                  is no such list.
+        :rtype: Optional[List[str]]
+        """
+        if self._only_set is None:
+            return None
+        return list(self._only_set)
+
+    @property
+    def rename_list(self):
+        """
+        :returns: the local names that appear in a Rename_List or None if there \
+                  is no such list.
+        :rtype: Optional[List[str]]
+        """
+        if self._rename_set is None:
+            return None
+        return list(self._rename_set)
+
+    @property
+    def wildcard_import(self):
+        """
+        :returns: whether there is a wildcard import from this module.
+        :rtype: bool
+        """
+        return self._wildcard_import
+
+    def get_declared_name(self, name):
+        """
+        :returns: the name of the supplied symbol as declared in the module.
+        :rtype: str
+        """
+        return self._local_to_module_map.get(name, name)
+
+
 class SymbolTable:
     """
     Class implementing a single symbol table.
@@ -290,7 +517,8 @@ class SymbolTable:
         self._name = name.lower()
         # Symbols defined in this scope that represent data.
         self._data_symbols = {}
-        # Modules imported into this scope.
+        # dict of ModuleUse objects (indexed by module name) representing
+        # modules imported into this scope.
         self._modules = {}
         # Reference to a SymbolTable that contains this one (if any). Actual
         # value (if any) is set via setter method.
@@ -309,12 +537,7 @@ class SymbolTable:
         uses = "Used modules:\n"
         if self._modules:
             uses += "\n".join(list(self._modules.keys())) + "\n"
-        return (
-            "{0}Symbol Table '{1}'\n".format(header, self._name)
-            + symbols
-            + uses
-            + header
-        )
+        return f"{header}Symbol Table '{self._name}'\n{symbols}{uses}{header}"
 
     def add_data_symbol(self, name, primitive_type):
         """
@@ -334,16 +557,15 @@ class SymbolTable:
         """
         if not isinstance(name, str):
             raise TypeError(
-                "The name of the symbol must be a str but got "
-                "'{0}'".format(type(name).__name__)
+                f"The name of the symbol must be a str but got "
+                f"'{type(name).__name__}'"
             )
         # TODO #201 use an enumeration for the primitive type
         if not isinstance(primitive_type, str):
             raise TypeError(
-                "The primitive type of the symbol must be specified as a str "
-                "but got '{0}'".format(type(primitive_type).__name__)
+                f"The primitive type of the symbol must be specified as a str "
+                f"but got '{type(primitive_type).__name__}'"
             )
-
         lname = name.lower()
 
         if self._checking_enabled:
@@ -357,8 +579,8 @@ class SymbolTable:
                     f"Symbol table already contains a use of a "
                     f"module with name '{name}'"
                 )
-            for mod_name, var_list in self._modules.items():
-                if var_list and lname in var_list:
+            for mod_name, mod in self._modules.items():
+                if mod.symbol_names and lname in mod.symbol_names:
                     raise SymbolTableError(
                         f"Symbol table already contains a use of a symbol "
                         f"named '{name}' from module '{mod_name}'"
@@ -366,7 +588,7 @@ class SymbolTable:
 
         self._data_symbols[lname] = SymbolTable.Symbol(lname, primitive_type.lower())
 
-    def add_use_symbols(self, name, only_list=None):
+    def add_use_symbols(self, name, only_list=None, rename_list=None):
         """
         Creates an entry in the table for the USE of a module with the supplied
         name. If no `only_list` is supplied then this USE represents a wildcard
@@ -374,55 +596,30 @@ class SymbolTable:
         has an ONLY clause but without any named symbols then `only_list`
         should be an empty list.
 
+        A USE can also have one or more rename entries *without* an only list.
+
         :param str name: the name of the module being imported via a USE. Not \
             case sensitive.
-        :param only_list: Whether or not there is an 'only:' clause on the \
-            USE statement and, if so, the names of the symbols being imported \
-            (not case sensitive).
-        :type only_list: NoneType or list of str
+        :param only_list: if there is an 'only:' clause on the USE statement \
+            then this contains a list of tuples, each holding the local name \
+            of the symbol and its name in the module from which it is \
+            imported. These names are case insensitive.
+        :type only_list: Optional[List[Tuple[str, str | NoneType]]]
+        :param rename_list: a list of symbols that are renamed from the scope \
+            being imported. Each entry is a tuple containing the name in the \
+            local scope and the corresponding name in the module from which it\
+            is imported. These names are case insensitive.
+        :type rename_list: Optional[List[Tuple[str, str]]]
 
-        :raises TypeError: if either of the supplied parameters are of the \
-                           wrong type.
         """
-        if not isinstance(name, str):
-            raise TypeError(
-                "The name of the module must be a str but got "
-                "'{0}'".format(type(name).__name__)
-            )
-        if only_list and not isinstance(only_list, list):
-            raise TypeError(
-                "If present, the only_list must be a list but got "
-                "'{0}'".format(type(only_list).__name__)
-            )
-        if only_list and not all(isinstance(item, str) for item in only_list):
-            raise TypeError(
-                "If present, the only_list must be a list of str "
-                "but got: {0}".format([type(item).__name__ for item in only_list])
-            )
-
-        # Convert the list of names to lower case
-        if only_list is not None:
-            lowered_list = [var_name.lower() for var_name in only_list]
-        else:
-            lowered_list = None
+        use = ModuleUse(name, only_list, rename_list)
 
-        lname = name.lower()
-        if lname in self._modules:
+        if use.name in self._modules:
             # The same module can appear in more than one use statement
             # in Fortran.
-            if lowered_list:
-                if self._modules[lname] is None:
-                    # We already have a wildcard import for this module but
-                    # now we also know the names of some specific symbols that
-                    # are imported.
-                    # TODO #294 improve the data structures used to hold
-                    # information on use statements so that we can capture
-                    # this.
-                    pass
-                else:
-                    self._modules[lname].extend(lowered_list)
+            self._modules[use.name].update(use)
         else:
-            self._modules[lname] = lowered_list
+            self._modules[use.name] = use
 
     def lookup(self, name):
         """
@@ -443,7 +640,7 @@ class SymbolTable:
         # No match in this scope - search in parent scope (if any)
         if self.parent:
             return self.parent.lookup(lname)
-        raise KeyError("Failed to find symbol named '{0}'".format(lname))
+        raise KeyError(f"Failed to find symbol named '{lname}'")
 
     @property
     def name(self):
@@ -475,8 +672,8 @@ class SymbolTable:
         """
         if value is not None and not isinstance(value, SymbolTable):
             raise TypeError(
-                "Unless it is None, the parent of a SymbolTable must also be "
-                "a SymbolTable but got '{0}'".format(type(value).__name__)
+                f"Unless it is None, the parent of a SymbolTable must also be "
+                f"a SymbolTable but got '{type(value).__name__}'"
             )
         self._parent = value
 
@@ -492,9 +689,7 @@ class SymbolTable:
         """
         if not isinstance(child, SymbolTable):
             raise TypeError(
-                "Expected a SymbolTable instance but got '{0}'".format(
-                    type(child).__name__
-                )
+                f"Expected a SymbolTable instance but got '{type(child).__name__}'"
             )
         self._children.append(child)
 
@@ -515,8 +710,7 @@ class SymbolTable:
                 break
         else:
             raise KeyError(
-                "Symbol table '{0}' does not contain a table named "
-                "'{1}'".format(self.name, name)
+                f"Symbol table '{self.name}' does not contain a table named '{name}'"
             )
 
     @property


Test patch:
diff --git a/src/fparser/two/tests/fortran2003/test_usestmt_r1109.py b/src/fparser/two/tests/fortran2003/test_usestmt_r1109.py
index ab1389e..69f5d2a 100644
--- a/src/fparser/two/tests/fortran2003/test_usestmt_r1109.py
+++ b/src/fparser/two/tests/fortran2003/test_usestmt_r1109.py
@@ -40,7 +40,7 @@ Use statement.
 import pytest
 from fparser.api import get_reader
 from fparser.two.Fortran2003 import Use_Stmt
-from fparser.two.symbol_table import SYMBOL_TABLES
+from fparser.two.symbol_table import SYMBOL_TABLES, ModuleUse
 from fparser.two.utils import NoMatchError, InternalError
 
 # match() use ...
@@ -88,15 +88,24 @@ def test_use_nature(f2003_create):
 
 
 # match() 'use x, rename'
-def test_use_rename(f2003_create):
-    """Check that a use with a nename clause is parsed correctly."""
-    line = "use my_module, name=>new_name"
+@pytest.mark.usefixtures("f2003_create", "fake_symbol_table")
+def test_use_rename():
+    """Check that a use with a rename clause is parsed correctly."""
+    line = "use my_module, new_name=>name"
     ast = Use_Stmt(line)
-    assert "USE my_module, name => new_name" in str(ast)
+    assert "USE my_module, new_name => name" in str(ast)
     assert repr(ast) == (
         "Use_Stmt(None, None, Name('my_module'), ',', Rename_List(',', "
-        "(Rename(None, Name('name'), Name('new_name')),)))"
+        "(Rename(None, Name('new_name'), Name('name')),)))"
     )
+    table = SYMBOL_TABLES.current_scope
+    assert "my_module" in table._modules
+    use = table._modules["my_module"]
+    # We only store the *local* names of the symbols in rename_list
+    assert use.rename_list == ["new_name"]
+    # The original name of the symbol in the module being imported is stored
+    # in a separate map.
+    assert use._local_to_module_map["new_name"] == "name"
 
 
 # match() 'use x, only: y'
@@ -117,20 +126,60 @@ def test_use_only(f2003_create):
     ast = Use_Stmt(line)
     table = SYMBOL_TABLES.current_scope
     assert "my_model" in table._modules
-    assert table._modules["my_model"] == ["name"]
+    use = table._modules["my_model"]
+    assert use.name == "my_model"
+    assert use.only_list == ["name"]
+    assert use.rename_list is None
     SYMBOL_TABLES.exit_scope()
 
 
 # match() 'use x, only:'
 def test_use_only_empty(f2003_create):
     """Check that a use statement is parsed correctly when there is an
-    only clause without any content.
+    only clause without any content. Test both with and without a scoping region.
 
     """
     line = "use my_model, only:"
     ast = Use_Stmt(line)
     assert "USE my_model, ONLY:" in str(ast)
     assert repr(ast) == ("Use_Stmt(None, None, Name('my_model'), ', ONLY:', None)")
+    # Repeat when there is a scoping region.
+    SYMBOL_TABLES.enter_scope("test_scope")
+    ast = Use_Stmt(line)
+    table = SYMBOL_TABLES.current_scope
+    assert "my_model" in table._modules
+    use = table._modules["my_model"]
+    assert isinstance(use, ModuleUse)
+    assert use.only_list == []
+    assert use.rename_list is None
+    SYMBOL_TABLES.exit_scope()
+
+
+# match() 'use x, only: b => c'
+def test_use_only_plus_rename(f2003_create):
+    """Check that a use statement with an only clause and some variable
+    renaming is parsed correctly.
+
+    """
+    line = "use my_model, only: a, b=>c"
+    ast = Use_Stmt(line)
+    assert repr(ast) == (
+        "Use_Stmt(None, None, Name('my_model'), "
+        "', ONLY:', Only_List(',', (Name('a'), "
+        "Rename(None, Name('b'), Name('c')))))"
+    )
+    # Repeat when there is a scoping region.
+    SYMBOL_TABLES.enter_scope("test_scope")
+    ast = Use_Stmt(line)
+    table = SYMBOL_TABLES.current_scope
+    assert "my_model" in table._modules
+    use = table._modules["my_model"]
+    assert isinstance(use, ModuleUse)
+    assert use.symbol_names == ["a", "b"]
+    assert sorted(use.only_list) == ["a", "b"]
+    assert use.rename_list is None
+    assert use._local_to_module_map["b"] == "c"
+    SYMBOL_TABLES.exit_scope()
 
 
 # match() '  use  ,  nature  ::  x  ,  name=>new_name'
@@ -249,3 +298,24 @@ def test_use_internal_error3(f2003_create):
     with pytest.raises(InternalError) as excinfo:
         str(ast)
     assert "entry 3 should be a string but found 'None'" in str(excinfo.value)
+
+
+@pytest.mark.usefixtures("f2003_create", "fake_symbol_table")
+def test_use_internal_error_only_list(monkeypatch):
+    """Check that an internal error is raised if an Only_List contains an
+    unexpected entry.
+
+    """
+    # First get a valid parse tree and then break it.
+    line = "use my_model, only: var"
+    ast = Use_Stmt(line)
+    only_list = ast.children[4]
+    monkeypatch.setattr(only_list, "items", ("wrong",))
+    # Monkeypatch the underlying _match method to return this broken tree.
+    monkeypatch.setattr(Use_Stmt, "_match", lambda x: ast.children)
+    with pytest.raises(InternalError) as err:
+        Use_Stmt(line)
+    assert (
+        "An Only_List can contain only Name or Rename entries but found "
+        "'str' when matching 'use my_model, only: var'" in str(err.value)
+    )
diff --git a/src/fparser/two/tests/test_module_use.py b/src/fparser/two/tests/test_module_use.py
new file mode 100644
index 0000000..96626d8
--- /dev/null
+++ b/src/fparser/two/tests/test_module_use.py
@@ -0,0 +1,162 @@
+# -----------------------------------------------------------------------------
+# Copyright (c) 2022 Science and Technology Facilities Council.
+# All rights reserved.
+#
+# Modifications made as part of the fparser project are distributed
+# under the following license:
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+# -----------------------------------------------------------------------------
+
+""" Module containing tests for the ModuleUse class. """
+
+import pytest
+from fparser.two.symbol_table import ModuleUse
+
+
+def test_moduse_constructor():
+    """Checks for the ModuleUse constructor."""
+    # Name only.
+    muse1 = ModuleUse("Nancy")
+    assert muse1.name == "nancy"
+    assert muse1.only_list is None
+    assert muse1.rename_list is None
+    assert muse1.wildcard_import is True
+    # pylint: disable=use-implicit-booleaness-not-comparison
+    assert muse1.symbol_names == []
+    # Only-list.
+    muse2 = ModuleUse("peggy", only_list=[("amazon", None), ("swallow", None)])
+    assert sorted(muse2.only_list) == ["amazon", "swallow"]
+    assert muse2.wildcard_import is False
+    assert muse2.rename_list is None
+    assert muse2.symbol_names == ["amazon", "swallow"]
+    # Only-list with renaming.
+    muse3 = ModuleUse("roger", only_list=[("swallow", None), ("amazon", "dingy")])
+    assert sorted(muse3.only_list) == ["amazon", "swallow"]
+    assert muse3.get_declared_name("amazon") == "dingy"
+    assert muse3.symbol_names == ["swallow", "amazon"]
+    # Rename list.
+    muse4 = ModuleUse("susan", rename_list=[("swallow", "amazon"), ("boat", "dingy")])
+    assert sorted(muse4.rename_list) == ["boat", "swallow"]
+    assert muse4.get_declared_name("swallow") == "amazon"
+    assert muse4.get_declared_name("boat") == "dingy"
+    assert muse4.symbol_names == ["swallow", "boat"]
+
+
+def test_moduse_constructor_validate():
+    """Checks for the type-checking performed by the constructor."""
+    # Module name.
+    with pytest.raises(TypeError) as err:
+        _ = ModuleUse(1)
+    assert "name of the module must be a str but got" in str(err.value)
+    # Check that it calls _validate_tuple_list.
+    with pytest.raises(TypeError) as err:
+        _ = ModuleUse("titty", only_list="polly")
+    assert "If present, the only_list must be a list but got 'str'" in str(err.value)
+    with pytest.raises(TypeError) as err:
+        _ = ModuleUse("titty", rename_list="polly")
+    assert "If present, the rename_list must be a list but got 'str'" in str(err.value)
+    with pytest.raises(TypeError) as err:
+        _ = ModuleUse("titty", only_list=[("polly", 1)])
+    assert (
+        "If present, the only_list must be a list of 2-tuples of "
+        "(str, str | NoneType) but got: [('polly', 1)]" in str(err.value)
+    )
+    with pytest.raises(TypeError) as err:
+        _ = ModuleUse("titty", rename_list=[("polly", 1)])
+    assert (
+        "If present, the rename_list must be a list of 2-tuples of "
+        "(str, str) but got: [('polly', 1)]" in str(err.value)
+    )
+
+
+def test_moduse_validate_tuple_list():
+    """Checks for the _validate_tuple_list() method."""
+    with pytest.raises(TypeError) as err:
+        _ = ModuleUse._validate_tuple_list("only", ["polly"])
+    assert (
+        "If present, the only_list must be a list of 2-tuples but "
+        "got: ['polly']" in str(err.value)
+    )
+    with pytest.raises(TypeError) as err:
+        _ = ModuleUse._validate_tuple_list("only", [("polly", "gibber", 1)])
+    assert (
+        "If present, the only_list must be a list of 2-tuples but "
+        "got: [('polly', 'gibber', 1)]" in str(err.value)
+    )
+
+
+def test_moduse_update():
+    """Checks for the update() method."""
+    moduse = ModuleUse("flint")
+    assert moduse.wildcard_import is True
+    # Wrong argument type.
+    with pytest.raises(TypeError) as err:
+        moduse.update("hello")
+    assert (
+        "update() must be supplied with an instance of ModuleUse but got "
+        "'str'" in str(err.value)
+    )
+    # Wrong module name.
+    moduse1 = ModuleUse("houseboat", only_list=[("cannon", None)])
+    with pytest.raises(ValueError) as err:
+        moduse.update(moduse1)
+    assert (
+        "ModuleUse supplied to update() is for module 'houseboat' but this "
+        "ModuleUse is for module 'flint'" in str(err.value)
+    )
+    # Add an import with an ONLY: clause.
+    moduse2 = ModuleUse("flint", only_list=[("cannon", None)])
+    moduse.update(moduse2)
+    assert moduse.wildcard_import is True
+    assert moduse.only_list == ["cannon"]
+    assert moduse.rename_list is None
+    # Add an import with a Rename_List.
+    moduse3 = ModuleUse("Flint", rename_list=[("uncle", "jim")])
+    moduse.update(moduse3)
+    assert moduse.only_list == ["cannon"]
+    assert moduse.rename_list == ["uncle"]
+    assert moduse.get_declared_name("uncle") == "jim"
+    assert moduse.symbol_names == ["cannon", "uncle"]
+    # Add further Only and Rename lists.
+    moduse4 = ModuleUse("Flint", rename_list=[("rogEr", "giBber")])
+    moduse.update(moduse4)
+    assert moduse.get_declared_name("uncle") == "jim"
+    assert moduse.get_declared_name("roger") == "gibber"
+    assert moduse.symbol_names == ["cannon", "uncle", "roger"]
+    assert sorted(moduse.rename_list) == ["roger", "uncle"]
+    moduse5 = ModuleUse("Flint", only_list=[("maTe", "peggy")])
+    moduse.update(moduse5)
+    assert sorted(moduse.only_list) == ["cannon", "mate"]
+    assert moduse.get_declared_name("roger") == "gibber"
+    assert moduse.get_declared_name("mate") == "peggy"
+    assert moduse.symbol_names == ["cannon", "uncle", "roger", "mate"]
+    # Adding a wildcard import.
+    moduse6 = ModuleUse("flInt")
+    moduse5.update(moduse6)
+    assert moduse5.wildcard_import is True
diff --git a/src/fparser/two/tests/test_symbol_table.py b/src/fparser/two/tests/test_symbol_table.py
index 200ebd5..e619079 100644
--- a/src/fparser/two/tests/test_symbol_table.py
+++ b/src/fparser/two/tests/test_symbol_table.py
@@ -87,7 +87,7 @@ def test_add_data_symbol():
     )
     # Check a clash with a USE statement - both the module name and the
     # name of imported variables
-    table.add_use_symbols("mod1", ["var3"])
+    table.add_use_symbols("mod1", only_list=[("var3", None)])
     with pytest.raises(SymbolTableError) as err:
         table.add_data_symbol("mod1", "real")
     assert "table already contains a use of a module with name 'mod1'" in str(err.value)
@@ -107,9 +107,9 @@ def test_add_data_symbols_no_checks():
     table.add_data_symbol("var", "real")
     sym = table.lookup("var")
     assert sym.primitive_type == "real"
-    table.add_use_symbols("mod1", ["var3"])
+    table.add_use_symbols("mod1", [("var3", None)])
     table.add_data_symbol("mod1", "real")
-    table.add_use_symbols("mod2", ["var3"])
+    table.add_use_symbols("mod2", [("var3", None)])
     table.add_data_symbol("var3", "real")
     assert table.lookup("var3").primitive_type == "real"
 
@@ -119,17 +119,19 @@ def test_add_use_symbols():
     table = SymbolTable("basic")
     # A use without an 'only' clause
     table.add_use_symbols("mod1")
-    assert table._modules["mod1"] is None
+    assert table._modules["mod1"].only_list is None
+    assert table._modules["mod1"].rename_list is None
+    assert table._modules["mod1"].wildcard_import is True
     # Fortran permits other use statements for the same module
-    table.add_use_symbols("mod1", ["var"])
-    # Since we already have a wildcard import and don't yet capture any
-    # additional, specific imports (TODO #294) the list of associated symbols
-    # should still be None.
-    assert table._modules["mod1"] is None
-    table.add_use_symbols("mod2", ["iVar"])
-    assert table._modules["mod2"] == ["ivar"]
-    table.add_use_symbols("mod2", ["jvar"])
-    assert table._modules["mod2"] == ["ivar", "jvar"]
+    table.add_use_symbols("mod1", only_list=[("var", None)])
+    # Since we already have a wildcard import that should remain true while
+    # we now also capture those symbols that are explicitly imported.
+    assert table._modules["mod1"].only_list == ["var"]
+    assert table._modules["mod1"].wildcard_import
+    table.add_use_symbols("mod2", only_list=[("iVar", None)])
+    assert table._modules["mod2"].only_list == ["ivar"]
+    table.add_use_symbols("mod2", only_list=[("jvar", None)])
+    assert sorted(table._modules["mod2"].only_list) == ["ivar", "jvar"]
 
 
 def test_add_use_symbols_errors():
@@ -143,10 +145,10 @@ def test_add_use_symbols_errors():
         table.add_use_symbols("mod3", only_list="hello")
     assert "If present, the only_list must be a list but got 'str'" in str(err.value)
     with pytest.raises(TypeError) as err:
-        table.add_use_symbols("mod3", only_list=["hello", table])
+        table.add_use_symbols("mod3", only_list=[("hello", None, None)])
     assert (
-        "If present, the only_list must be a list of str but got: ['str', "
-        "'SymbolTable']" in str(err.value)
+        "If present, the only_list must be a list of 2-tuples but got: "
+        "[('hello', None, None)]" in str(err.value)
     )
 
 
@@ -241,9 +243,40 @@ END PROGRAM a_prog
     assert isinstance(table, SymbolTable)
     assert table.parent is None
     assert "some_mod" in table._modules
-    assert table._modules["some_mod"] is None
+    assert table._modules["some_mod"].only_list == []
     assert "mod2" in table._modules
-    assert sorted(table._modules["mod2"]) == ["that_one", "this_one"]
+    assert sorted(table._modules["mod2"].only_list) == ["that_one", "this_one"]
+
+
+def test_module_use_with_rename(f2003_parser):
+    """Check that USE statements with renamed imported symbols are correctly
+    captured in the symbol table and do not clash."""
+    _ = f2003_parser(
+        get_reader(
+            """\
+PROGRAM a_prog
+  use mod2, only: this_one => that_one
+  use mod3, local => other
+  integer :: that_one
+  logical :: other
+END PROGRAM a_prog
+    """
+        )
+    )
+    tables = SYMBOL_TABLES
+    table = tables.lookup("a_prog")
+    assert isinstance(table, SymbolTable)
+    assert table.parent is None
+    assert "mod2" in table._modules
+    mod2 = table._modules["mod2"]
+    assert mod2.only_list == ["this_one"]
+    assert mod2.get_declared_name("this_one") == "that_one"
+    sym = table.lookup("that_one")
+    assert sym.primitive_type == "integer"
+    mod3 = table._modules["mod3"]
+    assert mod3.rename_list == ["local"]
+    assert mod3.get_declared_name("local") == "other"
+    assert table.lookup("other").primitive_type == "logical"
 
 
 def test_module_definition(f2003_parser):