<commit_before>require 'constants/libraries/element'
require 'molecules/utils'
require 'strscan'

module Molecules
  Element = Constants::Libraries::Element
  
  # EmpiricalFormula represents the empirical formula (ex 'H(2)0') for
  # a molecule.  The formula is stored as an array of integers aligned
  # to the elements in EmpiricalFormula::ELEMENT_INDEX.  Hence:
  #
  #   EmpiricalFormula::ELEMENT_INDEX[0].name   # => "Hydrogen"
  #   EmpiricalFormula::ELEMENT_INDEX[1].name   # => "Oxygen"
  #
  #   water = EmpiricalFormula.new [2,1]
  #   water.to_s                                # => 'H(2)O'
  #   water.mass                                # => 18.0105646863
  #
  # EmpiricalFormula may be added, subtracted, and multiplied to
  # perform the expected operations:
  #
  #   alanine = EmpiricalFormula.new [5,1,3,1]
  #   (alanine - water).formula                 # => [3,0,3,1]
  #
  class EmpiricalFormula
    class << self
      def [](identifier)
        parse(identifier)
      end 

      def composition_to_factors(composition)
        factors = []
        composition.each_pair do |symbol, factor|
          next if factor == 0

          element = symbol.kind_of?(Element) ? symbol : Element.index(:symbol)[symbol]
          if element == nil
            raise UnknownElementError.new("unknown element: #{symbol}")
          end

          factors[ELEMENT_INDEX.index(element)] = factor
        end
        factors
      end

      # Parses a simple formula (formatted like those returned by 
      # Composition#to_s) into a Composition. The format consists of the 
      # element symbol followed by parenthesis with the number of that 
      # element in the composition.  Whitespace is allowed in the formula.
      # 
      #   parse("H(2)O").to_s                   # => "H(2)O"
      #   parse("H (2) O").to_s                 # => "H(2)O"
      #   parse("HO(-1)O(2)H").to_s             # => "H(2)O"
      def parse_simple(chemical_formula)
        formula = chemical_formula.to_s.gsub(/\s+/, "")

        factor = nil
        composition = Hash.new(0)
        scanner = StringScanner.new(formula.reverse)
        while scanner.restsize > 0
          case
          when scanner.scan_full(/\)(\d+-?)\(/, true, false)
            # found a factor
            factor = scanner[1].reverse.to_i
          when scanner.scan_full(/([a-z]?[A-Z])/, true, false)
            # found an element
            composition[scanner[1].reverse] += (factor == nil ? 1 : factor)

            # reset factor to nil
            factor = nil
          else
            raise ParseError.new("could not parse formula: #{chemical_formula}")
          end
        end
        factors = composition_to_factors(composition)
        block_given? ? yield(factors) : EmpiricalFormula.new(factors)
      end

      # Parses a generalized chemical formula into a Composition.
      # Formula sections can be nested with parenthesis, and multiple
      # sections can be added or subtracted within the formula.  Note
      # that the format for Composition#to_s differs from the format
      # that parse utilizes.
      #
      #   parse("H2O").to_s                   # => "H(2)O"
      #   parse("CH3(CH2)50CH3").to_s         # => "C(52)H(106)"
      #   parse("C2H3NO - H2O + NH3").to_s    # => "C(2)H(4)N(2)"
      #
      def parse(chemical_formula)
        # Remove whitespace 
        formula = chemical_formula.to_s.gsub(/\s+/, "")

        # Split and handle multipart formulae
        case formula 
        when /\+/
          return formula.split(/\+/).inject(EmpiricalFormula.new) do |current, formula|
            current + parse(formula)
          end
        when /-/
          splits = formula.split(/-/)
          first = parse(splits.shift)
          return splits.inject(first) do |current, formula|
            current - parse(formula)
          end
        when /[^A-Za-z0-9\\(\\)]/
          raise ParseError.new("unexpected characters in formula: #{chemical_formula}")
        end

        # factor is the number following an element, as 6 and 12 in 'C6H12'
        # factor == -1 indicates that a number has not been read for the 
        # next element.  This state is used later to check for hanging 
        # factors, as in '2C6' or (8OH)
        factor = nil

        # multiplier is the latest cumulative factor for a parenthesis 
        # expression.  A new multiplier is pushed on the stack for every new
        # parenthesis set, and popped off when the set terminates.
        # ex: for CH3(C(H)2)7CH 
        #   At the period       Integer at the top of the stack equals
        #   CH3(C(H)2)7.CH      1
        #   CH3(C(H)2.)7CH      7
        #   CH3(C(H.)2)7CH      14
        #   CH3(C.(H)2)7CH      7
        #   CH3.(CH)2)7CH       1
        multiplier = []
        multiplier << 1

        # composition will store the formula composition as it is parsed
        composition = Hash.new(0)

        # Parse elements and factors out of the formula from right to left     
        scanner = StringScanner.new(formula.reverse)
        while scanner.restsize > 0

          case
          when scanner.scan_full(/(\d+)/, true, false)
            # found a factor
            factor = scanner[1].reverse.to_i
          when scanner.scan_full(/([a-z]?[A-Z])/, true, false)
            # found an element

            # Adjust the factor by the multiplier.  If factor == nil 
            # then a factor has not been read for the element, as would
            # be seen in NaOH; use 1 in this case instead.
            factor = (factor.nil? ? 1 : factor) * multiplier.last

            # Add the current factor to composition, remembering to reverse the symbol
            composition[ scanner[1].reverse ] += factor

            # reset factor to nil
            factor = nil
          when scanner.scan_full(/\)/, true, false)
            # When a parenthesis ends, the current multiplier must be
            # adujusted by the current factor.  If factor == nil then a 
            # factor has not been read for the parenthesis, use 1 instead
            multiplier << (factor.nil? ? 1 : factor) * multiplier.last

            # reset factor to nil
            factor = nil
          when scanner.scan_full(/\(/, true, false)
            # When a parenthesis starts, the current multiplier is
            # popped off.  Check for hanging factors and that after 
            # popping a multiplier will remain.  If no multiplier will
            # remain, then the parenthesis must be mismatched
            raise ParseError.new("the formula contains a hanging factor: #{chemical_formula}") unless factor.nil?
            raise ParseError.new("the formula contains mismatched parenthesis: #{chemical_formula}") unless multiplier.length > 1

            multiplier.pop
          else
            raise ParseError.new("could not parse formula: #{chemical_formula}")
          end
        end

        # Check for hanging factors, that a multiplier remains, and that 
        # elements were found during parsing
        raise ParseError.new("the formula contains a hanging factor: #{chemical_formula}") unless factor.nil?
        raise ParseError.new("the formula contains mismatched parenthesis: #{chemical_formula}") unless multiplier.length == 1
        raise ParseError.new("no elements could be found in the formula: #{chemical_formula}") if composition.length == 0 && !formula.empty?

        factors = composition_to_factors(composition)
        block_given? ? yield(factors) : EmpiricalFormula.new(factors)
      end

      # Parses the input formula into an EmpiricalFormula (using
      # parse) then calculates the mass therefrom.  By default
      # the mass will be the monoisotopic mass of the formula;
      # see EmpericalFormula#mass for more details.
      def mass(formula, &block) # :yields: element
        mass = parse(formula).mass(&block)
      end
    end
    <commit_msg>Documentation and cleanup of EmpiricalFormula class.<commit_after>require 'constants/libraries/element'
require 'molecules/utils'
require 'strscan'

module Molecules
  Element = Constants::Libraries::Element
  
  # EmpiricalFormula represents the empirical formula (ex 'H(2)0') for
  # a molecule.  The formula is stored as an array of integers aligned
  # to the elements in EmpiricalFormula::ELEMENT_INDEX.  Hence:
  #
  #   EmpiricalFormula::ELEMENT_INDEX[0].name   # => "Hydrogen"
  #   EmpiricalFormula::ELEMENT_INDEX[1].name   # => "Oxygen"
  #
  #   water = EmpiricalFormula.new [2,1]
  #   water.to_s                                # => 'H(2)O'
  #   water.mass                                # => 18.0105646863
  #
  # EmpiricalFormula may be added, subtracted, and multiplied to
  # perform the expected operations:
  #
  #   alanine = EmpiricalFormula.new [5,1,3,1]
  #   (alanine - water).formula                 # => [3,0,3,1]
  #
  class EmpiricalFormula
    class << self

      # Parses a simple formula (formatted like those returned by 
      # EmpiricalFormula#to_s) into a EmpiricalFormula. Whitespace 
      # is allowed in the formula.
      # 
      #   EmpiricalFormula.parse("H(2)O").to_s           # => "H(2)O"
      #   EmpiricalFormula.parse("H (2) O").to_s         # => "H(2)O"
      #   EmpiricalFormula.parse("HO(-1)O(2)H").to_s     # => "H(2)O"
      #
      def parse_simple(chemical_formula)
        formula = chemical_formula.to_s.gsub(/\s+/, "")

        factor = nil
        composition = Hash.new(0)
        scanner = StringScanner.new(formula.reverse)
        while scanner.restsize > 0
          case
          when scanner.scan_full(/\)(\d+-?)\(/, true, false)
            # found a factor
            factor = scanner[1].reverse.to_i
          when scanner.scan_full(/([a-z]?[A-Z])/, true, false)
            # found an element
            composition[scanner[1].reverse] += (factor == nil ? 1 : factor)

            # reset factor to nil
            factor = nil
          else
            raise ParseError.new("could not parse formula: #{chemical_formula}")
          end
        end
        factors = composition_to_factors(composition)
        block_given? ? yield(factors) : EmpiricalFormula.new(factors)
      end

      # Parses a generalized chemical formula into an EmpiricalFormula.
      # Formula sections can be nested with parenthesis, and multiple
      # sections can be added or subtracted within the formula.  
      #
      #   EmpiricalFormula.parse("H2O").to_s                   # => "H(2)O"
      #   EmpiricalFormula.parse("CH3(CH2)50CH3").to_s         # => "C(52)H(106)"
      #   EmpiricalFormula.parse("C2H3NO - H2O + NH3").to_s    # => "C(2)H(4)N(2)"
      #
      # Note that the format for EmpiricalFormula#to_s differs from the 
      # format that parse utilizes.
      def parse(chemical_formula)
        # Remove whitespace 
        formula = chemical_formula.to_s.gsub(/\s+/, "")

        # Split and handle multipart formulae
        case formula 
        when /\+/
          return formula.split(/\+/).inject(EmpiricalFormula.new) do |current, formula|
            current + parse(formula)
          end
        when /-/
          splits = formula.split(/-/)
          first = parse(splits.shift)
          return splits.inject(first) do |current, formula|
            current - parse(formula)
          end
        when /[^A-Za-z0-9\\(\\)]/
          raise ParseError.new("unexpected characters in formula: #{chemical_formula}")
        end

        # factor is the number following an element, as 6 and 12 in 'C6H12'
        # factor == -1 indicates that a number has not been read for the 
        # next element.  This state is used later to check for hanging 
        # factors, as in '2C6' or (8OH)
        factor = nil

        # multiplier is the latest cumulative factor for a parenthesis 
        # expression.  A new multiplier is pushed on the stack for every new
        # parenthesis set, and popped off when the set terminates.
        # ex: for CH3(C(H)2)7CH 
        #   At the period       Integer at the top of the stack equals
        #   CH3(C(H)2)7.CH      1
        #   CH3(C(H)2.)7CH      7
        #   CH3(C(H.)2)7CH      14
        #   CH3(C.(H)2)7CH      7
        #   CH3.(CH)2)7CH       1
        multiplier = []
        multiplier << 1

        # composition will store the formula composition as it is parsed
        composition = Hash.new(0)

        # Parse elements and factors out of the formula from right to left     
        scanner = StringScanner.new(formula.reverse)
        while scanner.restsize > 0

          case
          when scanner.scan_full(/(\d+)/, true, false)
            # found a factor
            factor = scanner[1].reverse.to_i
          when scanner.scan_full(/([a-z]?[A-Z])/, true, false)
            # found an element

            # Adjust the factor by the multiplier.  If factor == nil 
            # then a factor has not been read for the element, as would
            # be seen in NaOH; use 1 in this case instead.
            factor = (factor.nil? ? 1 : factor) * multiplier.last

            # Add the current factor to composition, remembering to reverse the symbol
            composition[ scanner[1].reverse ] += factor

            # reset factor to nil
            factor = nil
          when scanner.scan_full(/\)/, true, false)
            # When a parenthesis ends, the current multiplier must be
            # adujusted by the current factor.  If factor == nil then a 
            # factor has not been read for the parenthesis, use 1 instead
            multiplier << (factor.nil? ? 1 : factor) * multiplier.last

            # reset factor to nil
            factor = nil
          when scanner.scan_full(/\(/, true, false)
            # When a parenthesis starts, the current multiplier is
            # popped off.  Check for hanging factors and that after 
            # popping a multiplier will remain.  If no multiplier will
            # remain, then the parenthesis must be mismatched
            raise ParseError.new("the formula contains a hanging factor: #{chemical_formula}") unless factor.nil?
            raise ParseError.new("the formula contains mismatched parenthesis: #{chemical_formula}") unless multiplier.length > 1

            multiplier.pop
          else
            raise ParseError.new("could not parse formula: #{chemical_formula}")
          end
        end

        # Check for hanging factors, that a multiplier remains, and that 
        # elements were found during parsing
        raise ParseError.new("the formula contains a hanging factor: #{chemical_formula}") unless factor.nil?
        raise ParseError.new("the formula contains mismatched parenthesis: #{chemical_formula}") unless multiplier.length == 1
        raise ParseError.new("no elements could be found in the formula: #{chemical_formula}") if composition.length == 0 && !formula.empty?

        factors = composition_to_factors(composition)
        block_given? ? yield(factors) : EmpiricalFormula.new(factors)
      end

      # Parses the input formula into an EmpiricalFormula and 
      # calculates the mass therefrom.  By default the mass 
      # will be the monoisotopic mass of the formula.
      #
      # See EmpericalFormula#mass for more details.
      def mass(formula, &block) # :yields: element
        mass = parse(formula).mass(&block)
      end
      
      protected
      
      # Converts a hash of (symbol, factor) pairs into a factors array,
      # suitable for initializing an EmpiricalFormula.
      def composition_to_factors(composition)
        factors = []
        composition.each_pair do |symbol, factor|
          next if factor == 0

          element = symbol.kind_of?(Element) ? symbol : Element.index(:symbol)[symbol]
          if element == nil
            raise UnknownElementError.new("unknown element: #{symbol}")
          end

          factors[ELEMENT_INDEX.index(element)] = factor
        end
        factors
      end
    end
    
{'fraction_non_alphanumeric': 0.0949577200125274, 'fraction_numerical': 0.013404321954274977, 'mean_word_length': 2.0382492863939103, 'pattern_counts': {'":': 0, '<': 15, '<?xml version=': 0, '>': 31, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 4, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '7130490', 'n_tokens_mistral': 4683, 'n_tokens_neox': 4379, 'n_words': 1405}