# -*-python-*-
#
# Copyright (C) 1999-2023 The ViewCVS Group. All Rights Reserved.
#
# By using this file, you agree to the terms and conditions set forth in
# the LICENSE.html file which can be found at the top level of the ViewVC
# distribution or at http://viewvc.org/license-1.html.
#
# For more information, visit http://viewvc.org/
#
# -----------------------------------------------------------------------

"""common.py: common classes and functions for the RCS parsing tools."""

import calendar
import string

class Sink:
  """Interface to be implemented by clients.  The RCS parser calls this as
  it parses the RCS file.

  All these methods have stub implementations that do nothing, so you only
  have to override the callbacks that you care about.
  """
  def set_head_revision(self, revision):
    """Reports the head revision for this RCS file.

    This is the value of the 'head' header in the admin section of the RCS
    file.  This function can only be called before admin_completed().

    Parameter: REVISION is a string containing a revision number.  This is
    an actual revision number, not a branch number.
    """
    pass

  def set_principal_branch(self, branch_name):
    """Reports the principal branch for this RCS file.  This is only called
    if the principal branch is not trunk.

    This is the value of the 'branch' header in the admin section of the RCS
    file.  This function can only be called before admin_completed().

    Parameter: BRANCH_NAME is a string containing a branch number.  If this
    function is called, the parameter is typically "1.1.1", indicating the
    vendor branch.
    """
    pass

  def set_access(self, accessors):
    """Reports the access control list for this RCS file.  This function is
    only called if the ACL is set.  If this function is not called then
    there is no ACL and all users are allowed access.

    This is the value of the 'access' header in the admin section of the RCS
    file.  This function can only be called before admin_completed().

    Parameter: ACCESSORS is a list of strings.  Each string is a username.
    The user is allowed access if and only if their username is in the list,
    OR the user owns the RCS file on disk, OR the user is root.

    Note that CVS typically doesn't use this field.
    """
    pass

  def define_tag(self, name, revision):
    """Reports a tag or branch definition.  This function will be called
    once for each tag or branch.

    This is taken from the 'symbols' header in the admin section of the RCS
    file.  This function can only be called before admin_completed().

    Parameters: NAME is a string containing the tag or branch name.
    REVISION is a string containing a revision number.  This may be
    an actual revision number (for a tag) or a branch number.

    The revision number consists of a number of decimal components separated
    by dots.  There are three common forms.  If there are an odd number of
    components, it's a branch.  Otherwise, if the next-to-last component is
    zero, it's a branch (and the next-to-last component is an artifact of
    CVS and should not be shown to the user).  Otherwise, it's a tag.

    This function is called in the order that the tags appear in the RCS
    file header.  For CVS, this appears to be in reverse chronological
    order of tag/branch creation.
    """
    pass

  def set_locker(self, revision, locker):
    """Reports a lock on this RCS file.  This function will be called once
    for each lock.

    This is taken from the 'locks' header in the admin section of the RCS
    file.  This function can only be called before admin_completed().

    Parameters: REVISION is a string containing a revision number.  This is
    an actual revision number, not a branch number.
    LOCKER is a string containing a username.
    """
    pass

  def set_locking(self, mode):
    """Signals strict locking mode.  This function will be called if and
    only if the RCS file is in strict locking mode.

    This is taken from the 'strict' header in the admin section of the RCS
    file.  This function can only be called before admin_completed().

    Parameters: MODE is always the string 'strict'.
    """
    pass

  def set_comment(self, comment):
    """Reports the comment for this RCS file.

    This is the value of the 'comment' header in the admin section of the
    RCS file.  This function can only be called before admin_completed().

    Parameter: COMMENT is a string containing the comment.  This may be
    multi-line.

    This field does not seem to be used by CVS.    
    """
    pass

  def set_expansion(self, mode):
    """Reports the keyword expansion mode for this RCS file.

    This is the value of the 'expand' header in the admin section of the
    RCS file.  This function can only be called before admin_completed().

    Parameter: MODE is a string containing the keyword expansion mode.
    Possible values include 'o' and 'b', amongst others.
    """
    pass

  def admin_completed(self):
    """Reports that the initial RCS header has been parsed.  This function is
    called exactly once.
    """
    pass

  def define_revision(self, revision, timestamp, author, state,
                      branches, next):
    """Reports metadata about a single revision.

    This function is called for each revision.  It is called later than
    admin_completed() and earlier than tree_completed().

    Parameter: REVISION is a revision number, as a string.  This is an
    actual revision number, not a branch number.
    TIMESTAMP is the date and time that the revision was created, as an
    integer number of seconds since the epoch.  (I.e. "UNIX time" format).
    AUTHOR is the author name, as a string.
    STATE is the state of the revision, as a string.  Common values are
    "Exp" and "dead".
    BRANCHES is a list of strings, with each string being an actual
    revision number (not a branch number).  For each branch which is based
    on this revision and has commits, the revision number of the first
    branch commit is listed here.
    NEXT is either None or a string representing an actual revision number
    (not a branch number).

    When on trunk, NEXT points to what humans might consider to be the
    'previous' revision number.  For example, 1.3's NEXT is 1.2.
    However, on a branch, NEXT really does point to what humans would
    consider to be the 'next' revision number.  For example, 1.1.2.1's
    NEXT would be 1.1.2.2.
    In other words, NEXT always means "where to find the next deltatext
    that you need this revision to retrieve".
    """
    pass

  def tree_completed(self):
    """Reports that the RCS revision tree has been parsed.  This function is
    called exactly once.  This function will be called later than
    admin_completed().
    """
    pass

  def set_description(self, description):
    """Reports the description from the RCS file.  This is set using the
    "-m" flag to "cvs add".  However, many CVS users don't use that option,
    so this is often empty.

    This function is called once, after tree_completed().

    Parameter: DESCRIPTION is a string containing the description.  This may
    be multi-line.
    """
    pass

  def set_revision_info(self, revision, log, text):
    """Reports the log message and contents of a CVS revision.

    This function is called for each revision.  It is called later than
    set_description().

    Parameters: REVISION is a string containing the actual revision number.
    LOG is a string containing the log message.  This may be multi-line.
    TEXT is the contents of the file in this revision, either as full-text or
    as a diff.  This is usually multi-line, and often quite large and/or
    binary.
    """
    pass

  def parse_completed(self):
    """Reports that parsing an RCS file is complete.

    This function is called once.  After it is called, no more calls will be
    made via this interface.
    """
    pass


# --------------------------------------------------------------------------
#
# EXCEPTIONS USED BY RCSPARSE
#

class RCSParseError(Exception):
  pass


class RCSIllegalCharacter(RCSParseError):
  pass


class RCSExpected(RCSParseError):
  def __init__(self, got, wanted):
    RCSParseError.__init__(
        self,
        'Unexpected parsing error in RCS file.\n'
        'Expected token: %s, but saw: %s'
        % (wanted, got)
        )


class RCSStopParser(Exception):
  pass


# --------------------------------------------------------------------------
#
# STANDARD TOKEN STREAM-BASED PARSER
#

class _Parser:
  stream_class = None   # subclasses need to define this

  def _read_until_semicolon(self):
    """Read all tokens up to and including the next semicolon token.

    Return the tokens (not including the semicolon) as a list."""

    tokens = []

    while 1:
      token = self.ts.get()
      if token == ';':
        break
      tokens.append(token)

    return tokens

  def _parse_admin_head(self, token):
    rev = self.ts.get()
    if rev == ';':
      # The head revision is not specified.  Just drop the semicolon
      # on the floor.
      pass
    else:
      self.sink.set_head_revision(rev)
      self.ts.match(';')

  def _parse_admin_branch(self, token):
    branch = self.ts.get()
    if branch != ';':
      self.sink.set_principal_branch(branch)
      self.ts.match(';')

  def _parse_admin_access(self, token):
    accessors = self._read_until_semicolon()
    if accessors:
      self.sink.set_access(accessors)

  def _parse_admin_symbols(self, token):
    while 1:
      tag_name = self.ts.get()
      if tag_name == ';':
        break
      self.ts.match(':')
      tag_rev = self.ts.get()
      self.sink.define_tag(tag_name, tag_rev)

  def _parse_admin_locks(self, token):
    while 1:
      locker = self.ts.get()
      if locker == ';':
        break
      self.ts.match(':')
      rev = self.ts.get()
      self.sink.set_locker(rev, locker)

  def _parse_admin_strict(self, token):
    self.sink.set_locking("strict")
    self.ts.match(';')

  def _parse_admin_comment(self, token):
    self.sink.set_comment(self.ts.get())
    self.ts.match(';')

  def _parse_admin_expand(self, token):
    expand_mode = self.ts.get()
    self.sink.set_expansion(expand_mode)
    self.ts.match(';')

  admin_token_map = {
      'head' : _parse_admin_head,
      'branch' : _parse_admin_branch,
      'access' : _parse_admin_access,
      'symbols' : _parse_admin_symbols,
      'locks' : _parse_admin_locks,
      'strict' : _parse_admin_strict,
      'comment' : _parse_admin_comment,
      'expand' : _parse_admin_expand,
      'desc' : None,
      }

  def parse_rcs_admin(self):
    while 1:
      # Read initial token at beginning of line
      token = self.ts.get()

      try:
        f = self.admin_token_map[token]
      except KeyError:
        # We're done once we reach the description of the RCS tree
        if token[0] in string.digits:
          self.ts.unget(token)
          return
        else:
          # Chew up "newphrase"
          # warn("Unexpected RCS token: $token\n")
          while self.ts.get() != ';':
            pass
      else:
        if f is None:
          self.ts.unget(token)
          return
        else:
          f(self, token)

  def _parse_rcs_tree_entry(self, revision):
    # Parse date
    self.ts.match('date')
    date = self.ts.get()
    self.ts.match(';')

    # Convert date into standard UNIX time format (seconds since epoch)
    date_fields = string.split(date, '.')
    # According to rcsfile(5): the year "contains just the last two
    # digits of the year for years from 1900 through 1999, and all the
    # digits of years thereafter".
    if len(date_fields[0]) == 2:
      date_fields[0] = '19' + date_fields[0]
    date_fields = map(string.atoi, date_fields)
    EPOCH = 1970
    if date_fields[0] < EPOCH:
      raise ValueError, 'invalid year for revision %s' % (revision,)
    try:
      timestamp = calendar.timegm(tuple(date_fields) + (0, 0, 0,))
    except ValueError, e:
      raise ValueError, 'invalid date for revision %s: %s' % (revision, e,)

    # Parse author
    ### NOTE: authors containing whitespace are violations of the
    ### RCS specification.  We are making an allowance here because
    ### CVSNT is known to produce these sorts of authors.
    self.ts.match('author')
    author = ' '.join(self._read_until_semicolon())

    # Parse state
    self.ts.match('state')
    state = ''
    while 1:
      token = self.ts.get()
      if token == ';':
        break
      state = state + token + ' '
    state = state[:-1]   # toss the trailing space

    # Parse branches
    self.ts.match('branches')
    branches = self._read_until_semicolon()

    # Parse revision of next delta in chain
    self.ts.match('next')
    next = self.ts.get()
    if next == ';':
      next = None
    else:
      self.ts.match(';')

    # there are some files with extra tags in them. for example:
    #    owner	640;
    #    group	15;
    #    permissions	644;
    #    hardlinks	@configure.in@;
    #    commitid	mLiHw3bulRjnTDGr;
    # this is "newphrase" in RCSFILE(5). we just want to skip over these.
    while 1:
      token = self.ts.get()
      if token == 'desc' or token[0] in string.digits:
        self.ts.unget(token)
        break
      # consume everything up to the semicolon
      self._read_until_semicolon()

    self.sink.define_revision(revision, timestamp, author, state, branches,
                              next)

  def parse_rcs_tree(self):
    while 1:
      revision = self.ts.get()

      # End of RCS tree description ?
      if revision == 'desc':
        self.ts.unget(revision)
        return

      self._parse_rcs_tree_entry(revision)

  def parse_rcs_description(self):
    self.ts.match('desc')
    self.sink.set_description(self.ts.get())

  def parse_rcs_deltatext(self):
    while 1:
      revision = self.ts.get()
      if revision is None:
        # EOF
        break
      text, sym2, log, sym1 = self.ts.mget(4)
      if sym1 != 'log':
        print `text[:100], sym2[:100], log[:100], sym1[:100]`
        raise RCSExpected(sym1, 'log')
      if sym2 != 'text':
        raise RCSExpected(sym2, 'text')
      ### need to add code to chew up "newphrase"
      self.sink.set_revision_info(revision, log, text)

  def parse(self, file, sink):
    """Parse an RCS file.

    Parameters: FILE is the file object to parse.  (I.e. an object of the
    built-in Python type "file", usually created using Python's built-in
    "open()" function).
    SINK is an instance of (some subclass of) Sink.  It's methods will be
    called as the file is parsed; see the definition of Sink for the
    details.
    """
    self.ts = self.stream_class(file)
    self.sink = sink

    self.parse_rcs_admin()

    # let sink know when the admin section has been completed
    self.sink.admin_completed()

    self.parse_rcs_tree()

    # many sinks want to know when the tree has been completed so they can
    # do some work to prep for the arrival of the deltatext
    self.sink.tree_completed()

    self.parse_rcs_description()
    self.parse_rcs_deltatext()

    # easiest for us to tell the sink it is done, rather than worry about
    # higher level software doing it.
    self.sink.parse_completed()

    self.ts = self.sink = None

# --------------------------------------------------------------------------
