#!/usr/bin/env python
#
# Copyright 2015 Google Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Custom configuration of lint for font instances."""

# The structure is a list of conditions and tests.  A condition says when to apply
# the following test.  These are processed in order and are cumulative, and
# where there is a conflict the last instructions win.

# Both conditions and tests can vary in specifity, a condition can for example simply
# indicate all fonts by a vendor, or indicate a particuar version of a particular font.

# At the end of the day, we have a particular font, and want to know which tests to
# run and which failures to ignore or report.  lint_config builds up a structure from
# a customization file that allows this api.

import argparse
import re

from nototools.py23 import basestring

spec_format = """
A spec defines a list of conditions to be run in sequence.  A condition consists of
a filter, which determines whether a font matches the condition, and a list of tests, which
determines what tests to run on that font and what exceptions to make.  By default
all tests are run on all fonts.

Conditions are created by a list of filter and test instructions.  A new condition is
started either by the term 'condition' or by adding a filter after one or more tests
have been defined, there is an initial open condition at the start that accepts all
fonts.  Filters on the same property do not accumulate, instead the previous filter
for that property is replaced.  Similarly, tests on the same tag do not accumulate.

'#' starts a comment to the next '\\n'
'\\n' and ';' separate <instruction>

instruction:
  'condition' -- starts a new condition
  'filename | name | script | variant | weight | hinted | vendor | verson' <filter> -- adds a filter
  'enable | disable' <test_list> -- adds tests

filter:
  '*' -- reset filter
  <numeric_filter> <number>
  <string_filter> <string_value>
  'in' <string_list>
  <string> -- string equality test

numeric_filter:
  '< | <= | == | != | >= | >' -- compare decimal values

string_filter:
  'is' -- string equality
  'like' -- value is a regex, true if it matches target anywhere

string_list: -- one or more strings separated by comma
  <string>+

test_list: -- one or more tests separated by comma
  <test>+

test:
  <test_tag> | <test_tag> <relation> <value_type> <value_list>

test_tag: -- name of a test node (see tags)

relation:
  'except | only' -- whether to include or exclude values matched by the selector

value_type:
  'cp | gid' -- type of select values the tag takes, cp values are hex, gid are decimal

value_list: -- numbers or ranges separated by whitespace, no space around hyphen
  (<number> | <number>'-'<number>)+
"""

def parse_int_ranges(range_string, is_hex=True, sep=' '):
  """Returns a set of ints from a string of numbers or ranges separated by sep.
  A range is two values separated by hyphen with no intervening separator.
  Result can be empty if range_string is empty."""
  result = set()
  count = 0
  base = 16 if is_hex else 10
  range_string = range_string.strip()
  if not range_string:
    return set()
  value_list = range_string.split(sep)
  for val in value_list:
    if '-' in val: # assume range
      val_list = val.split('-')
      if len(val_list) != 2:
        raise ValueError('could not parse range from \'%s\'' % val)
      lo = int(val_list[0], base)
      hi = int(val_list[1], base)
      if lo >= hi:
        raise ValueError('val range must have high > low')
      result.update(range(lo, hi + 1))
      count += hi - lo + 1
    else:
      result.add(int(val, base))
      count += 1
  if len(result) != count:
    raise ValueError('duplicate values in %s, expected count is %d but result is %s' % (
        hexlist, count, result))
  return result


def write_int_ranges(int_values, in_hex=True, sep=' '):
  """From a set or list of ints, generate a string representation that can
  be parsed by parse_int_ranges to return the original values (not order_preserving)."""

  if not int_values:
    return ''

  num_list = []

  int_values = iter(sorted(int_values))
  start = prev = next(int_values)
  single_fmt = '%04x' if in_hex else '%d'
  pair_fmt = single_fmt + '-' + single_fmt

  def emit():
    if prev == start:
      num_list.append(single_fmt % prev)
    else:
      num_list.append(pair_fmt % (start, prev))

  for v in int_values:
    if v == prev + 1:
      prev += 1
      continue
    else:
      emit()
    start = prev = v
  emit()
  return sep.join(num_list)


class IntSetFilter(object):
  """Tests whether an int (glyph or code point) is in a set."""

  def __init__(self, accept_if_in, intset):
    self.accept_if_in = accept_if_in
    self.intset = intset
    # print('IntSetFilter %s %s' % ('only' if accept_if_in else 'except', intset))

  def accept(self, cp):
    return self.accept_if_in == (cp in self.intset)


class FontInfo(object):
  def __init__(self, filename, name, style, script, variant, weight, monospace,
               hinted, vendor, version):
    self.filename = filename
    self.name = name
    self.style = style
    self.script = script
    self.variant = variant
    self.weight = weight
    self.monospace = monospace
    self.hinted = hinted
    self.vendor = vendor
    self.version = version

  def __repr__(self):
    return str(self.__dict__)


class FontCondition(object):

  def _init_fn_map():
    def test_lt(lhs, rhs):
      return float(lhs) < float(rhs)
    def test_le(lhs, rhs):
      return float(lhs) <= float(rhs)
    def test_eq(lhs, rhs):
      return float(lhs) == float(rhs)
    def test_ne(lhs, rhs):
      return float(lhs) != float(rhs)
    def test_ge(lhs, rhs):
      return float(lhs) >= float(rhs)
    def test_gt(lhs, rhs):
      return float(lhs) > float(rhs)
    def test_is(lhs, rhs):
      return lhs == rhs
    def test_in(lhs, rhs):
      return lhs in rhs
    def test_like(lhs, rhs):
      return rhs.search(lhs) is not None

    def test_is_not(lhs, rhs):
      return lhs != rhs
    def test_not_in(lhs, rhs):
      return lhs not in rhs
    def test_not_like(lhs, rhs):
      return rhs.search(lhs) is None

    return {
      '<': test_lt,
      '<=': test_le,
      '==': test_eq,
      '!=': test_ne,
      '>=': test_ge,
      '>': test_gt,
      'is': test_is,
      'in': test_in,
      'like': test_like,
      'is not': test_is_not,
      'not in': test_not_in,
      'not like': test_not_like,
      }

  fn_map = _init_fn_map()


  def __init__(self, filename=None, name=None, style=None, script=None, variant=None, weight=None,
               hinted=None, vendor=None, version=None):
    """Each arg is either a string, or a pair of a fn of two args returning bool, and an object.
    When the arg is a pair, the target string is passed to the fn as the first arg and the
    second element of the pair is passed as the second arg."""

    self.filename = filename
    self.name = name
    self.style = style
    self.script = script
    self.variant = variant
    self.weight = weight
    self.hinted = hinted
    self.vendor = vendor
    self.version = version

  def modify(self, condition_name, fn_name, value):
    if not condition_name in self.__dict__:
      raise ValueError('FontCondition does not recognize: %s' % condition_name)

    if fn_name == '*':
      # no condition
      self.__dict__[condition_name] = None
      return

    if not value:
      # fn_name is value
      self.__dict__[condition_name] = fn_name
      return

    fn = self.fn_map[fn_name]
    if fn_name == 'in':
      value = set(value.split(','))
    elif fn_name == 'like':
      value = re.compile(value)
    self.__dict__[condition_name] = (fn, value)

  line_re = re.compile(r'([^ \t]+)\s+(is not|not like|not in|[^ \t]+)(.*)')
  def modify_line(self, line):
    line = line.strip()
    m = self.line_re.match(line)
    if not m:
      raise ValueError("FontCondition could not match '%s'" % line)
    condition_name = m.group(1)
    fn_name = m.group(2)
    value = m.group(3)
    if value:
      value = value.strip()
      if not value:
        value = None
    self.modify(condition_name, fn_name, value)

  def copy(self):
    return FontCondition(
        filename=self.filename, name=self.name, style=self.style, script=self.script,
        variant=self.variant, weight=self.weight, hinted=self.hinted, vendor=self.vendor,
        version=self.version)

  def accepts(self, fontinfo):
    for k in ['filename', 'name', 'style', 'script', 'variant', 'weight', 'hinted', 'vendor',
              'version']:
      test = getattr(self, k, None)
      if test:
        val = getattr(fontinfo, k, None)
        if isinstance(test, basestring):
          if test != val:
            return False
          continue
        if not test[0](val, test[1]):
          return False

    return True

  def __repr__(self):
    def value_str(value):
      if isinstance(value, basestring):
        cond_name = 'is'
        cond_value = value
      else:
        fn = value[0]
        val = value[1]
        cond_name = None
        for fn_text, fn_obj in FontCondition.fn_map.items():
          if fn == fn_obj:
            cond_name = fn_text
            break
        if cond_name == 'like':
          cond_value = val.pattern
        else:
          if not cond_name:
            cond_name = str(fn)
          cond_value = str(val)
      return '%s %s' % (cond_name, cond_value)

    output = ['\n  %s: %s' % (k,value_str(v)) for k,v in self.__dict__.items() if v]
    return 'condition:%s' % ''.join(output)


class TestSpec(object):
  data = """
  filename -- filename tests
    script
    name
  name -- name table tests
    unable_to_check
    copyright
    family
    subfamily
    unique_id
    full_name
    version
      match_head
      out_of_range
    postscript_name
    trademark
    manufacturer
    designer
    description
    vendor_url
    designer_url
    license
    license_url
    preferred_family
    preferred_subfamily
    wws_family
    wws_subfamily
    unused -- checks for unused entries in name table
  cmap -- cmap table tests
    tables
      missing
      unexpected
      format_12_has_bmp
      format_4_subset_of_12
      notaliased
    required
    script_required except|only cp
    private_use except|only cp
    non_characters
    disallowed_ascii
    variants
    unexpected
  head -- head table tests
    hhea
      ascent
      descent
      linegap
    vhea
      linegap
    os2
      fstype
      ascender
      descender
      linegap
      winascent
      windescent
      achvendid
      weight_class
      fsselection
      unicoderange
      panose
        family
        serif
        proportion
  bounds -- glyf limits etc
    glyph
      ui_ymax except|only gid
      ui_ymin except|only gid
      ymax except|only gid
      ymin except|only gid
    font
      ui_ymax
      ui_ymin
      ymax
      ymin
  paths -- outline tests
    extrema -- missing on-curve extrema
    intersection -- self-intersecting paths
  gdef -- gdef tests
    classdef
      not_present -- table is missing but there are mark glyphs
      unlisted except|only cp -- mark glyph is present and expected to be listed
      combining_mismatch except|only cp -- mark glyph is combining but not listed as combining
      not_combining_mismatch except|only cp -- mark glyph is not combining but listed as combining
    attachlist
      duplicates
      out_of_order
    ligcaretlist
      not_present -- table is missing but there are ligatures
      not_ligature -- listed but not a ligature
      unlisted -- is a ligature but no caret
  complex -- gpos and gsub tests
    gpos
      missing
      ui_name_id -- FeatureParamsStylisticSet.UINameID not in name table
    gsub
      missing
      ui_name_id -- FeatureParamsStylisticSet.UINameID not in name table
      variants -- expect standard variants to be supported in gsub
  bidi -- tests bidi pairs, properties
    rtlm_non_mirrored -- rtlm GSUB feature applied to private-use or non-mirrored character
    ompl_rtlm -- rtlm GSUB feature applied to ompl char
    ompl_missing_pair -- ompl sibling not in cmap
    rtlm_unlisted -- non-ompl bidi char does not have rtlm GSUB feature
  hints
    unexpected_tables -- unhinted fonts shouldn't have hint tables
    missing_bytecode except|only gid -- hinted tt fonts should have bytecodes
    unexpected_bytecode -- unhinted tt fonts should not have bytecodes
  advances
    digits -- checks that ASCII digits have same advance as digit zero
    comma_period -- checks that comma and period have same advance
    whitespace -- checks for expected advance relationships in whitespace
    spacing_marks -- checks that particular spacing marks have non-zero advances
  stem -- stem widths
    left_joining -- non-zero lsb
    right_joining -- rsb not -70
  reachable except|only gid
  """

  # fields are:
  # 0: zero or more spaces
  # 1: tag, lower case alphanumeric plus underscore
  # 2: optional relation regex, delimited by whitespace'
  # 3: optional (with relation) value type regex, delimited by whitespace'
  # 4: optional '--' followed by comment to end of line
  def _process_data(data):
    """data is a hierarchy of tags. any level down to root can be enabled or disabled.  this
    builds a representation of the tag hierarchy from the text description."""
    _data_line_re = re.compile(r'(\s*)([a-z0-9_]+)(?:\s+([^\s]+)\s+([^\s]+))?\s*(?:--\s*(.+)\s*)?$')
    tag_data = {}
    indent = (0, '', None)
    for line in data.splitlines():
      if not line.strip():
        continue
      m = _data_line_re.match(line)
      if not m:
        raise ValueError('failed to match line: \'%s\'' % line)
      line_indent = m.group(1)
      tag_part = m.group(2)
      relation = m.group(3)
      arg_type = m.group(4)
      comment = m.group(5)

      while len(line_indent) <= indent[0]:
        if indent[2]:
          indent = indent[2]
        else:
          break
      tag = indent[1]
      if tag:
        tag += '/' + tag_part
      else:
        tag = tag_part
      tag_data[tag] = (relation, arg_type, comment)
      if len(line_indent) > indent[0]:
        indent = (len(line_indent), tag, indent)
    return tag_data

  tag_data = _process_data(data)
  tag_set = frozenset(tag_data.keys())

  def __init__(self):
    self.touched_tags = set()
    self.enabled_tags = set()
    self.tag_options = {}

  def _get_single_tag(self, tag):
    """Resolve tag to a single node"""
    if not tag in self.tag_set:
      unique_tag = None
      # try to find a unique tag with this as a segment
      for t in TestSpec.tag_set:
        ix = t.find(tag)
        if ix != -1:
          if ix > 0 and t[ix-1] not in '/_':
            continue
          ix += len(tag)
          if ix < len(t) and t[ix] not in '/_':
            continue
          if unique_tag:
            raise ValueError('multiple matches for partial tag %s' % tag)
          unique_tag = t
      if not unique_tag:
        raise ValueError('unknown tag: %s' % tag)
      tag = unique_tag
    return tag

  def _get_tag_set(self, tag):
    """Resolve tag to a single node, and return it and all of its descendants."""
    if tag == '*':
      return TestSpec.tag_set
    tag = self._get_single_tag(tag)
    result = set()
    for candidate in self.tag_set:
      if candidate.startswith(tag):
        result.add(candidate)
    return result

  def _get_ancestor_tag_set(self, tag):
    """Resolve tag to a single node, and return all of its ancestors."""
    result = set()
    if tag != '*':
      tag = self._get_single_tag(tag)
      while tag:
        ix = tag.rfind('/')
        if ix == -1:
          break
        tag = tag[:ix]
        result.add(tag)
    return result

  def _set_enable_options(self, tag, relation, arg_type, arg):
    allowed_options = TestSpec.tag_data[tag]
    if not allowed_options[0]:
      raise ValueError('tag \'%s\' does not allow options' % tag)
    if not re.match(allowed_options[0], relation):
      raise ValueError('tag \'%s\' does not allow relation \'%s\'' % (tag, relation))
    if not re.match(allowed_options[1], arg_type):
      raise ValueError('tag \'%s\' and relation \'%s\' does not allow arg type %s' % (
          tag, relation, arg_type))

    if arg_type == 'cp' or arg_type == 'gid':
      is_hex = arg_type == 'cp'
      int_set = parse_int_ranges(arg, is_hex)
      self.tag_options[tag] = (arg_type, IntSetFilter(relation != 'except', int_set))
    else:
      raise ValueError('illegal state - unrecognized arg_type \'%s\'' % arg_type)

  def enable(self, tag, relation=None, arg_type=None, arg=None):
    tags = self._get_tag_set(tag)
    if relation is not None:
      if len(tags) > 1:
        raise ValueError('options cannot be applied to multiple tags')
      tag = next(iter(tags))
      self._set_enable_options(tag, relation, arg_type, arg)
    self.touched_tags |= tags
    tags |= self._get_ancestor_tag_set(tag)
    self.enabled_tags |= tags

  tag_rx = re.compile(r'\s*([0-9a-z/_]+)(?:\s+(except|only)\s+(cp|gid)\s+(.*))?\s*$')
  def enable_tag(self, tag_seg):
    m = self.tag_rx.match(tag_seg)
    if not m:
      raise ValueError('TestSpec could not parse:\n  "%s"\n'
                       'expecting:\n  "<tag_name> except|only cp|gid <value>+"'% tag_seg)
    self.enable(m.group(1), relation=m.group(2), arg_type=m.group(3), arg=m.group(4))

  def disable(self, tag):
    tags = self._get_tag_set(tag)
    self.touched_tags |= tags
    self.enabled_tags -= tags

  def apply_spec(self, result, options):
    result -= self.touched_tags
    result |= self.enabled_tags
    for tag in self.touched_tags:
      options.pop(tag, None)
    for tag in self.enabled_tags:
      if tag in self.tag_options:
        options[tag] = self.tag_options[tag]


  # TODO(dougfelt): remove modify_line if no longer used
  line_rx = re.compile(r'\s*(enable|disable)\s+([0-9a-z/]+)(?:\s+(except|only)\s+(cp|gid)\s+(.*))?\s*')
  def modify_line(self, line):
    m = self.line_rx.match(line)
    if not m:
      raise ValueError('TestSpec could not parse ' + line)
    if m.group(1) == 'enable':
      self.enable(m.group(2), m.group(3), m.group(4), m.group(5))
    else:
      self.disable(m.group(2))

  def copy(self):
    result = TestSpec()
    result.touched_tags |= self.touched_tags
    result.enabled_tags |= self.enabled_tags
    return result

  def __repr__(self):
    enable_list = []
    disable_list = []
    for tag in self.touched_tags:
      if tag in self.enabled_tags:
        enable_list.append(tag)
      else:
        disable_list.append(tag)
    output = []
    if enable_list:
      output.append('enable:')
      output.extend('  %s' % item for item in sorted(enable_list))
    if disable_list:
      output.append('disable:')
      output.extend('  %s' % item for item in sorted(disable_list))
    return '\n'.join(output)


class LintTests(object):
  def __init__(self, tag_set, tag_filters):
    self.tag_set = tag_set
    self.tag_filters = tag_filters
    self.run_log = set()
    self.skip_log = set()

  def get_filter(self, tag):
    if tag not in TestSpec.tag_set:
      raise ValueError('unrecognized tag ' + tag)
    return self.tag_filters.get(tag, None)

  def check(self, tag):
    if tag not in TestSpec.tag_set:
      raise ValueError('unrecognized tag ' + tag)
    run = tag in self.tag_set
    if run:
      self.run_log.add(tag)
    else:
      self.skip_log.add(tag)
    return run

  def valuetype(self, tag):
    """If the tag filters values, return the type of the value ('gid' or 'cp')
    being filtered, or None."""
    if tag in self.tag_filters:
      return self.tag_filters[tag][0]
    return None

  def checkvalue(self, tag, value):
    run = self.check(tag)
    if run and tag in self.tag_filters:
      run = self.tag_filters[tag][1].accept(value)
    return run

  def runlog(self):
    return self.run_log

  def skiplog(self):
    return self.skip_log

  def __repr__(self):
    lines = []
    if not (self.run_log or self.skip_log):
      for tag in sorted(self.tag_set):
        tag_filter = self.tag_filters.get(tag, None)
        if tag_filter:
          lines.append('%s %s' % (tag, tag_filter))
        else:
          lines.append(tag)
    if self.run_log:
      lines.append('run:')
      lines.append('  ' + t for t in self.run_log)
    if self.skip_log:
      lines.append('skipped:')
      lines.append('  ' + t for t in self.skip_log)
    return '\n'.join(lines)


class LintSpec(object):

  def __init__(self):
    self.specs = []

  def add_spec(self, font_condition, test_spec):
    self.specs.append((font_condition, test_spec))

  def get_tests(self, font_info):
    result = set()
    options = {}
    result |= TestSpec.tag_set
    for condition, spec in self.specs:
      if condition.accepts(font_info):
        spec.apply_spec(result, options)

    return LintTests(frozenset(result), options)

  def __repr__(self):
    return '--- spec ---\n' + '\n--- spec ---\n'.join('%s\n%s' % spec for spec in self.specs)


def parse_spec(spec, lint_spec=None):
  if not lint_spec:
    lint_spec = LintSpec()
  if not spec:
    return lint_spec

  cur_condition = FontCondition()
  cur_test_spec = TestSpec()
  have_test = False
  for line in spec.splitlines():
    ix = line.find('#')
    if ix > -1:
      line = line[:ix]
    line = line.strip()
    if not line:
      continue
    for segment in line.split(';'):
      segment = segment.strip()
      if segment == 'condition':
        if have_test:
          lint_spec.add_spec(cur_condition.copy(), cur_test_spec)
          cur_test_spec = TestSpec()
          have_test = False
        cur_condition = FontCondition()
      elif segment.startswith('enable '):
        segment = segment[len('enable '):]
        for seg in segment.split(','):
          cur_test_spec.enable_tag(seg.strip())
        have_test = True
      elif segment.startswith('disable '):
        segment = segment[len('disable '):]
        for seg in segment.split(','):
          cur_test_spec.disable(seg.strip())
        have_test = True
      else:
        if have_test:
          lint_spec.add_spec(cur_condition.copy(), cur_test_spec)
          cur_test_spec = TestSpec()
          have_test = False
        cur_condition.modify_line(segment)
  if have_test:
    lint_spec.add_spec(cur_condition, cur_test_spec)

  return lint_spec


def parse_spec_file(filename):
  with open(filename) as f:
    return parse_spec(f.read())


def main():
  parser = argparse.ArgumentParser()
  parser.add_argument('--tags', help='list all tags supported by the parser', action='store_true')
  parser.add_argument('--comments', help='list tags with comments when present', action='store_true')
  parser.add_argument('--filters', help='list tags with filters when present', action='store_true')
  parser.add_argument('--spec', help='prints the syntax', action='store_true')
  parser.add_argument('--parsefile', help='prints the parsed spec', metavar='spec')
  args = parser.parse_args()

  if not (args.tags or args.comments or args.filters or args.spec or args.parsefile):
    print('nothing to do.')
    return

  if args.spec:
    print(spec_format)
    return

  for tag in sorted(TestSpec.tag_set):
    data = TestSpec.tag_data[tag]
    comment = args.comments and data[2]
    if args.filters and (data[0] or data[1]):
      filter = ' '.join(data[:2])
    else:
      filter = None
    show_tag = args.tags or comment or filter
    if show_tag:
      print(tag)
      if filter:
        print('  ' + filter)
      if comment:
        print('  -- ' + comment)

  if args.parsefile:
    print(parse_spec_file(args.parsefile))


if __name__ == '__main__':
    main()
