glregistry.py
3969d955
 #!/usr/bin/env python3
 
 
 ## Help
 """
 glregistry 1.0
 
 Cache and query the OpenGL registry locally.
 
 Usage:
   glregistry xml
   glregistry xml-path
   glregistry ext            <extension>
   glregistry ext-path       <extension>
   glregistry exts
   glregistry exts-download
   glregistry exts-all       <name>
034e0946
   glregistry vendors
3969d955
   glregistry type           <type>
034e0946
   glregistry aliases        <enum>
3969d955
   glregistry value          <enum>
   glregistry enum           <value>
   glregistry supports       <name>
   glregistry names          [<support>]
   glregistry groups         [<enum>]
   glregistry enums          [<group>]
   glregistry enums-tree     [<group>]
   glregistry params         [<group>]
   glregistry params-tree    [<group>]
   glregistry audit          [<path>]
   glregistry audit-tree     [<path>]
295d1b6e
   glregistry refs           <name>
   glregistry refs-all       <name>
3969d955
   glregistry -h|--help
 
 Commands:
   xml
     Download the registry XML and open it with an editor.
   xml-path
     Download the registry XML and print its local path.
   ext <extension>
     Download the <extension> spec and open it with an editor.
   ext-path <extension>
     Download the <extension> spec and print its local path.
   exts
     Print the names of all extension specs.
   exts-download
     Download all extension specs.
   exts-all <name>
     Print all downloaded extensions that mention <name>.
034e0946
   vendors
     Print all vendor abbreviations.
3969d955
   type <type>
     Print the definition of <type>.
034e0946
   aliases <enum>
     Print the KHR, ARB, and EXT aliases of <enum>.
3969d955
   value <enum>
     Print the value of <enum>.
   enum <value>
6248f9bd
     Print the enum(s) that has the given <value>.
3969d955
   supports <name>
     Print the OpenGL version or extension required to use <name>.
   names [<support>]
     Print the names introduced by the OpenGL version or extension <support> if
     given, or all names if omitted. The special values VERSION and EXTENSION
     print the names introduced by all versions or all extensions respectively.
   groups [<enum>]
     Print the groups of <enum> if given, or all groups if omitted.
   enums [<group>]
     Print the enums in <group> if given, or all enums if omitted.
   enums-tree [<group>]
     Print the enums in <group> if given, or all enums if omitted, sorted on
     support, in a tree.
   params [<group>]
     Print the parameter names of <group> if given, or all parameter names if
     omitted.
   params-tree [<group>]
     Print the parameter names of <group> if given, or all parameter names if
     omitted, sorted on count, together with the commands sorted on support, in
     a tree.
   audit [<path>]
     Search files in <path> if given, or the current directory if omitted,
     recursively for OpenGL API names and print them sorted on location,
     support, and name, in a list.
   audit-tree [<path>]
     Search files in <path> if given, or the current directory if omitted,
     recursively for OpenGL API names and print them sorted on support, name,
     and location, in a tree.
295d1b6e
   refs <name>
     Print all URLs of all reference pages with name <name>.
   refs-all <name>
     Print all URLs of all reference pages that mention <name>, sorted on
     support, in a tree.
3969d955
 
 Environment variables:
   GLREGISTRY_CACHE
     The directory to cache files in. Defaults to `$XDG_CACHE_HOME/glregistry`
     or, if `$XDG_CACHE_HOME` is not defined, `$HOME/.cache/glregistry`.
   GLREGISTRY_EDITOR
     The editor to use when opening files. Defaults to `$EDITOR` or, if
     `$EDITOR` is not defined, `editor` if it exists in `$PATH`, else `vi`. The
     value is interpreted by the shell.
   GLREGISTRY_PAGER
     The pager to use when viewing output. Defaults to `$PAGER` or, if `$PAGER`
     is not defined, `pager` if it exists in `$PATH`, else `less` . The value is
     interpreted by the shell. If the `$LESS` environment variable is unset, it
     is set to `FR`.
320a682c
   GLREGISTRY_COLORS
     If standard out is a terminal, the colors used in output of the `enums`,
     `enums-tree`, `params`, `params-tree`, `audit,` and `audit-tree` commands.
     It uses the same format (and defaults) as GREP_COLORS, i.e. a
     colon-separated list of capabilties: `ms` (matching selected), `fn` (file
     name), `ln` (line number), `se` (separators). Added custom capabilities
     are: `ve` (version), `ex` (extension), `un` (unsupported). Defaults to
     `ms=01;31:fn=35:ln=32:se=36:ve=01;34:ex=34:un=01;33`.
3969d955
 """
 
 
 ## Imports
 import os
 import sys
 import collections
 import re
3543cd70
 import functools
3969d955
 import subprocess
 import shutil
 import shlex
 import urllib.request
 import docopt
 from lxml import etree
 
 
 ## Constants
295d1b6e
 REFPAGES_URL  = 'https://registry.khronos.org/OpenGL-Refpages/'
3969d955
 REGISTRY_URL  = 'https://registry.khronos.org/OpenGL/'
295d1b6e
 REFPAGES_GIT  = 'https://github.com/KhronosGroup/OpenGL-Refpages'
3969d955
 XML_PATH      = 'xml/gl.xml'
965e7beb
 USER_AGENT    = 'Mozilla/5.0'
3969d955
 REGEX         = r'\b(gl|GL_)[0-9A-Z][0-9A-Za-z_]+\b'
 EXCLUDE_DIRS  = ['.?*', '_*']
 EXCLUDE_FILES = ['README*', 'TODO*']
 INDENT        = 2
 ENV_XDG = lambda var, default: (
     os.environ.get(f'GLREGISTRY_{var}') or
     os.path.join(
         (
             os.environ.get(f'XDG_{var}_HOME') or
             os.path.expanduser(default)
         ),
         'glregistry',
     )
 )
 ENV_PRG = lambda var, default: (
     os.environ.get(f'GLREGISTRY_{var}') or
     os.environ.get(var) or
     shutil.which(var.lower()) or
     default
 )
 CACHE  = ENV_XDG('CACHE',  os.path.join('~', '.cache'))
 EDITOR = ENV_PRG('EDITOR', 'vi')
 PAGER  = ENV_PRG('PAGER',  'less')
 LESS   = os.environ.get('LESS') or 'FR'
320a682c
 COLORS = collections.defaultdict(str, [
     (color.split('=') + [''])[:2]
     for color in
     filter(None, (
         os.environ.get('GLREGISTRY_COLORS') or
         (lambda x, y: ':'.join([x, x and y]))(
             (
                 os.environ.get('GREP_COLORS') or
                 'ms=01;31:fn=35:ln=32:se=36'
             ),
             've=01;34:ex=34:un=01;33',
         )
     ).split(':'))
 ])
3969d955
 IN    = lambda a, v, s: f"contains(concat('{s}',@{a},'{s}'),'{s}{v}{s}')"
 MAYBE = lambda a, v:    f"(@{a}='{v}' or not(@{a}))"
 TYPES    = "/registry/types"
 ENUMS    = "/registry/enums"
 COMMANDS = "/registry/commands"
 CATEGORY_ATTRIBS = {
     'VERSION': [
         "/registry/feature[@api='gl']",
         'number',
     ],
     'EXTENSION': [
         f"/registry/extensions/extension[{IN('supported','gl','|')}]",
         'name',
     ],
 }
 REQUIRE = f"require[{MAYBE('api','gl')} and {MAYBE('profile','core')}]"
88ca5082
 REMOVE  = f"remove[{ MAYBE('api','gl')} and {MAYBE('profile','core')}]"
 CHANGE_PREFIXES = [
     [REQUIRE, '' ],
     [REMOVE,  '<'],
 ]
034e0946
 VENDORS = ['KHR', 'ARB', 'EXT']
88ca5082
 KEY_SUBS = [
034e0946
     *[[f'^{   prefix}',  f''       ] for _, prefix in CHANGE_PREFIXES],
     *[[f'{    vendor}$', f'{   i}' ] for i, vendor in enumerate(VENDORS)],
     *[[f'^GL_{vendor}_', f'GL_{i}_'] for i, vendor in enumerate(VENDORS)],
88ca5082
 ]
3969d955
 
 
 ## Helpers
 
 
 ### `log`
 def log(*args, **kwargs):
     print(*args, file=sys.stderr, flush=True, **kwargs)
 
 
 ### `edit`
 def edit(paths):
     if EDITOR and sys.stdout.isatty():
         args = ' '.join([EDITOR, *map(shlex.quote, paths)])
         subprocess.run(args, shell=True)
     else:
         for path in paths:
             with open(path) as f:
                 shutil.copyfileobj(f, sys.stdout)
 
 
 ### `page`
 def page(lines):
     lines = ''.join(f'{line}\n' for line in lines)
     if lines and PAGER and sys.stdout.isatty():
         args = f'LESS={shlex.quote(LESS)} {PAGER}'
         subprocess.run(args, shell=True, text=True, input=lines)
     else:
         sys.stdout.write(lines)
 
 
320a682c
 ### `color`
 def color(capability, string):
     if not sys.stdout.isatty():
         return string
     return f'\x1b[{COLORS[capability]}m{string}\x1b[m'
 
 
 ### `color_supports`
 def color_supports(supports):
     for support in supports:
         if support == 'UNSUPPORTED' or support.startswith('<'):
             yield color('un', support)
         elif support.startswith('GL_'):
             yield color('ex', support)
         else:
             yield color('ve', support)
 
 
3969d955
 ### `indentjoin`
 def indentjoin(indent, sep, parts):
320a682c
     return ' ' * INDENT * indent + color('se', sep).join(map(str, parts))
3969d955
 
 
 ### `removeprefix`
 def removeprefix(prefix, string):
     if string.startswith(prefix):
         return string[len(prefix):]
     return string
 
 
88ca5082
 ### `key`
 def key(item):
     for sub in KEY_SUBS:
         item = re.sub(*sub, item)
     return item
 
 
3969d955
 ### `download`
 def download(path, exit_on_failure=True):
     remote = urllib.parse.urljoin(REGISTRY_URL, path)
     local  = os.path.join        (CACHE,        path)
     if not os.path.exists(local):
         try:
             log(f"Downloading '{path}' ... ", end='')
             with urllib.request.urlopen(remote) as response:
                 os.makedirs(os.path.dirname(local), exist_ok=True)
                 with open(local, 'wb') as f:
                     shutil.copyfileobj(response, f)
         except urllib.error.URLError as error:
             log(error.reason)
             if exit_on_failure:
                 exit(1)
         else:
             log(response.reason)
     return local
 
 
 ### `grep`
 def grep(
     path=None,
     regex=REGEX,
     exclude_dirs=EXCLUDE_DIRS,
     exclude_files=EXCLUDE_FILES,
     silent=False,
 ):
     path          = path if path else '.'
     cmd           = ['grep', '-EIrno']
     exclude_dirs  = [f'--exclude-dir={exclude}' for exclude in exclude_dirs]
     exclude_files = [f'--exclude={exclude}'     for exclude in exclude_files]
     process = subprocess.run(
         [*cmd, *exclude_dirs, *exclude_files, regex, path],
         stdout=subprocess.PIPE,
         stderr=subprocess.DEVNULL if silent else None,
         text=True,
     )
     for string in process.stdout.splitlines():
         string           = removeprefix(f'.{os.path.sep}', string)
         file, line, name = string.split(':', 2)
         line             = int(line)
         yield file, line, name
 
 
 ## Commands
 
 
 ### `xml_`
 def xml_():
     return etree.parse(download(XML_PATH))
 
 
 ### `xml`
 def xml():
     return [download(XML_PATH)]
 
 
 ### `ext_`
 def ext_(extension):
     prefix, vendor, name = extension.split('_', 2)
     if prefix != 'GL':
         log("Extension names must start with 'GL_'.")
         exit(1)
     return f'extensions/{vendor}/{vendor}_{name}.txt'
 
 
 ### `ext`
 def ext(extension):
     return [download(ext_(extension))]
 
 
 ### `exts`
 def exts(xml):
     category, attrib = CATEGORY_ATTRIBS['EXTENSION']
     exts = xml.xpath(f"{category}/@{attrib}")
034e0946
     return sorted(exts, key=key)
3969d955
 
 
 ### `exts_download`
 def exts_download(xml):
     for ext in exts(xml):
         download(ext_(ext), exit_on_failure=False)
     return []
 
 
 ### `exts_all`
 def exts_all(xml, name):
     # exts_download(xml)
     exts_all = set(
         'GL_' + os.path.splitext(os.path.basename(file))[0]
         for name in set([
             name,
             removeprefix('gl',  name),
             removeprefix('GL_', name),
         ])
         for file, *_ in
         grep(os.path.join(CACHE, 'extensions'), rf'\b{name}\b', [], [])
     )
034e0946
     return sorted(exts_all, key=key)
 
 
 ### `vendors`
 def vendors(xml):
     vendors = set(extension.split('_')[1] for extension in exts(xml))
     return sorted(vendors, key=key)
3969d955
 
 
 ### `type`
 def type(xml, type):
     return [xml.xpath(f"string({TYPES}/type/name[text()='{type}']/..)")]
 
 
034e0946
 ### `aliases`
 def aliases(xml, name, supports_=None, vendors_=[]):
     if not vendors_:
         vendors_[:] = vendors(xml)
     for vendor in vendors_:
         if name.endswith(f'_{vendor}'):
             return []
     value_  = value(xml, name)
     if not value_:
         return []
     if not supports_:
         supports_ = supports(xml, name, False)
     if not supports_:
         return []
     aliases = []
     # for vendor in vendors_:
     for vendor in VENDORS:
         alias = f'{name}_{vendor}'
         if supports(xml, alias, False) and value(xml, alias) == value_:
             aliases.append(alias)
     return sorted(aliases, key=key)
 
 
3969d955
 ### `value`
 def value(xml, enum):
     return xml.xpath(f"{ENUMS}/enum[@name='{enum}']/@value")
 
 
 ### `enum`
 def enum(xml, value):
6248f9bd
     def conv(s):
         return int(s, 16 if s.startswith('0x') else 10)
     value = conv(value)
     enum = (
         enum.get('name')
         for enum in xml.xpath(f"{ENUMS}/enum")
         if conv(enum.get('value')) == value
     )
034e0946
     return sorted(enum, key=key)
3969d955
 
 
 ### `supports`
3543cd70
 @functools.cache
034e0946
 def supports(xml, name, use_aliases=True):
3969d955
     category, attrib = CATEGORY_ATTRIBS['EXTENSION']
     if xml.xpath(f"{category}[@{attrib}='{name}']"):
         return ['EXTENSION']
     supports_ = [
88ca5082
         f'{prefix}{support}'
3969d955
         for category, attrib in CATEGORY_ATTRIBS.values()
88ca5082
         for change, prefix   in CHANGE_PREFIXES
3969d955
         for support in
88ca5082
         xml.xpath(f"{category}/{change}/*[@name='{name}']/../../@{attrib}")
3969d955
     ]
034e0946
     if supports_ and use_aliases:
         supports_.extend(
             support
             for alias   in aliases (xml, name, supports_)
             for support in supports(xml, alias, False)
         )
88ca5082
     return sorted(supports_, key=key)
3969d955
 
 
 ### `names`
 def names(xml, support=None):
     if support in CATEGORY_ATTRIBS.keys():
         category_attribs = [
             [category, ""]
             for category, _ in [CATEGORY_ATTRIBS[support]]
         ]
     elif support:
         category_attribs = [
             [category, f"[@{attrib}='{support}']"]
             for category, attrib in CATEGORY_ATTRIBS.values()
         ]
     else:
         category_attribs = [
             [category, ""]
             for category, _ in CATEGORY_ATTRIBS.values()
         ]
     names = set(
         name
         for category, attrib in category_attribs
         for name in
         xml.xpath(f"{category}{attrib}/{REQUIRE}/*/@name")
     )
034e0946
     return sorted(names, key=key)
3969d955
 
 
 ### `groups`
 def groups(xml, enum=None):
     name = f"[@name='{enum}']" if enum else ""
     return sorted(set(
         group
         for groups in xml.xpath(f"{ENUMS}/enum{name}/@group")
         for group  in groups.split(',')
     ))
 
 
 ### `enums_`
 def enums_(xml, group=None):
     group = f"[{IN('group',group,',')}]" if group else ""
     enums_ = collections.defaultdict(list)
     for enum in xml.xpath(f"{ENUMS}/enum{group}/@name"):
         supports_ = supports(xml, enum)
         if supports_:
             enums_[tuple(supports_)].append(enum)
     return enums_
 
 
 ### `enums`
 def enums(xml, group=None):
     enums = [
         enum
         for _, enums in enums_(xml, group).items()
         for enum     in enums
     ]
034e0946
     return sorted(enums, key=key)
3969d955
 
 
 ### `enums_tree`
 def enums_tree(xml, group=None):
     for supports, enums in sorted(enums_(xml, group).items()):
320a682c
         yield indentjoin(0, ',', color_supports(supports))
3969d955
         for enum in sorted(enums):
320a682c
             yield indentjoin(1, '', [color('ms', enum)])
3969d955
 
 
 ### `params_`
 def params_(xml, group=None):
     group   = f"[@group='{group}']" if group else ""
     counts  = collections.defaultdict(int)
     params_ = collections.defaultdict(lambda: collections.defaultdict(list))
     for xmlcommand in xml.xpath(f"{COMMANDS}/command/param{group}/.."):
         command   = xmlcommand.xpath(f"string(proto/name)")
         supports_ = supports(xml, command)
         if supports_:
             for xmlparam in xmlcommand.xpath(f"param{group}"):
                 param = xmlparam.xpath(f"string(name)")
                 params_[param][tuple(supports_)].append(command)
                 counts[param] -= 1
     return {
         (count, param): params_[param]
         for param, count in counts.items()
     }
 
 
 ### `params`
 def params(xml, group=None):
     params = [param for (_, param), _ in params_(xml, group).items()]
     return sorted(params)
 
 
 ### `params_tree`
 def params_tree(xml, group=None):
     for (count, param), occurences in sorted(params_(xml, group).items()):
         yield indentjoin(0, ':', [
320a682c
             color('ms', param),
             color('ln', -count),
3969d955
         ])
         for supports_, commands in sorted(occurences.items()):
320a682c
             yield indentjoin(1, ',', color_supports(supports_))
3969d955
             for command in sorted(commands):
320a682c
                 yield indentjoin(2, '', [color('fn', command)])
3969d955
 
 
 ### `audit_`
 def audit_(xml, path=None):
     audit_ = collections.defaultdict(lambda: collections.defaultdict(list))
     for file, line, name in grep(path):
         supports_ = supports(xml, name)
         if not supports_:
             supports_ = ['UNSUPPORTED']
         audit_[tuple(supports_)][name].append([file, line])
     return audit_
 
 
 ### `audit`
 def audit(xml, path=None):
     for file, line, supports, name in sorted(
         [file, line, supports, name]
         for supports, names  in audit_(xml, path).items()
         for name, locations  in names.items()
         for file, line       in locations
     ):
         yield indentjoin(0, ':', [
320a682c
             color('fn', file),
             color('ln', line),
             indentjoin(0, ',', color_supports(supports)),
             color('ms', name),
3969d955
         ])
 
 
 ### `audit_tree`
 def audit_tree(xml, path=None):
     for supports, names in sorted(audit_(xml, path).items()):
320a682c
         yield indentjoin(0, ',', color_supports(supports))
3969d955
         for name, locations in sorted(names.items()):
320a682c
             yield indentjoin(1, '', [color('ms', name)])
3969d955
             for file, line in sorted(locations):
                 yield indentjoin(2, ':', [
320a682c
                     color('fn', file),
                     color('ln', line),
3969d955
                 ])
 
 
295d1b6e
 ### `refs_`
 def refs_(name):
     local = os.path.join(CACHE, os.path.basename(REFPAGES_GIT))
     if not os.path.exists(local):
         os.makedirs(os.path.dirname(local), exist_ok=True)
         subprocess.run(['git', 'clone', REFPAGES_GIT, local])
     refs_ = collections.defaultdict(set)
     for file, *_ in grep(local, rf'\b{name}\b', [], [], True):
         file = removeprefix(f'{local}{os.path.sep}', file)
         try:
             support, *_, dir, base = os.path.normpath(file).split(os.path.sep)
         except:
             continue
         if support.startswith('gl') and dir.endswith('html'):
             support   = removeprefix('gl', support)
             name, ext = os.path.splitext(base)
             url       = urllib.parse.urljoin(REFPAGES_URL, file)
             if ext in ['.xml', '.xhtml']:
                 refs_[support].add((name, url))
     return refs_
 
 
 ### `refs`
 def refs(name):
     return sorted(
         url
         for support, locations in refs_(name).items()
         for name_, url         in locations
         if name_ == name
     )
 
 
 ### `refs_all`
 def refs_all(name):
     for support, locations in sorted(refs_(name).items()):
320a682c
         yield indentjoin(0, ',', color_supports([support]))
295d1b6e
         for name_, url in sorted(locations):
             yield indentjoin(1, ':', [
320a682c
                 color('ms', name_),
                 color('fn', url),
295d1b6e
             ])
 
 
3969d955
 ## Main
 def main():
965e7beb
     opener = urllib.request.build_opener()
     opener.addheaders = [('User-Agent', USER_AGENT)]
     urllib.request.install_opener(opener)
3969d955
     args = docopt.docopt(__doc__)
     if args['xml']:           edit(xml          ())
     if args['xml-path']:      page(xml          ())
     if args['ext']:           edit(ext          (args['<extension>']))
     if args['ext-path']:      page(ext          (args['<extension>']))
     if args['exts']:          page(exts         (xml_()))
     if args['exts-download']: page(exts_download(xml_()))
     if args['exts-all']:      page(exts_all     (xml_(), args['<name>']))
034e0946
     if args['vendors']:       page(vendors      (xml_()))
3969d955
     if args['type']:          page(type         (xml_(), args['<type>']))
034e0946
     if args['aliases']:       page(aliases      (xml_(), args['<enum>']))
3969d955
     if args['value']:         page(value        (xml_(), args['<enum>']))
     if args['enum']:          page(enum         (xml_(), args['<value>']))
     if args['supports']:      page(supports     (xml_(), args['<name>']))
     if args['names']:         page(names        (xml_(), args['<support>']))
     if args['groups']:        page(groups       (xml_(), args['<enum>']))
     if args['enums']:         page(enums        (xml_(), args['<group>']))
     if args['enums-tree']:    page(enums_tree   (xml_(), args['<group>']))
     if args['params']:        page(params       (xml_(), args['<group>']))
     if args['params-tree']:   page(params_tree  (xml_(), args['<group>']))
     if args['audit']:         page(audit        (xml_(), args['<path>']))
     if args['audit-tree']:    page(audit_tree   (xml_(), args['<path>']))
295d1b6e
     if args['refs']:          page(refs         (args['<name>']))
     if args['refs-all']:      page(refs_all     (args['<name>']))
3969d955
 
 
 if __name__ == '__main__':
     main()