code: def take_action(self, production_rule: str) -> 'GrammarStatelet':
          
          left_side, right_side = production_rule.split(' -> ')
          assert self._nonterminal_stack[-1] == left_side, (f"Tried to expand {self._nonterminal_stack[-1]}"
                                                            f"but got rule {left_side} -> {right_side}")
  
          new_stack = self._nonterminal_stack[:-1]
  
          productions = self._get_productions_from_string(right_side)
          if self._reverse_productions:
              productions = list(reversed(productions))
  
          for production in productions:
              if self._is_nonterminal(production):
                  new_stack.append(production)
  
          return GrammarStatelet(nonterminal_stack=new_stack,
                                 valid_actions=self._valid_actions,
                                 is_nonterminal=self._is_nonterminal,
                                 reverse_productions=self._reverse_productions)
docs: Takes an action in the current grammar state, returning a new grammar state with whatever
          updates are necessary.  The production rule is assumed to be formatted as "LHS -> RHS".
  
          This will update the non-terminal stack.  Updating the non-terminal stack involves popping
          the non-terminal that was expanded off of the stack, then pushing on any non-terminals in
          the production rule back on the stack.
  
          For example, if our current ``nonterminal_stack`` is ``["r", "<e,r>", "d"]``, and
          ``action`` is ``d -> [<e,d>, e]``, the resulting stack will be ``["r", "<e,r>", "e",
          "<e,d>"]``.
  
          If ``self._reverse_productions`` is set to ``False`` then we push the non-terminals on in
          in their given order, which means that the first non-terminal in the production rule gets
          popped off the stack `last`.