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>
   glregistry type           <type>
   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>.
   type <type>
     Print the definition of <type>.
   value <enum>
     Print the value of <enum>.
   enum <value>
     Print the enum(s) that has the given <value>, using exact string matching.
   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`.
 """
 
 
 ## Imports
 import os
 import sys
 import collections
 import re
 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'
 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'
 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')}]"
 
 
 ## 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)
 
 
 ### `indentjoin`
 def indentjoin(indent, sep, parts):
     return ' ' * INDENT * indent + sep.join(map(str, parts))
 
 
 ### `removeprefix`
 def removeprefix(prefix, string):
     if string.startswith(prefix):
         return string[len(prefix):]
     return string
 
 
 ### `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}")
     return sorted(exts)
 
 
 ### `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', [], [])
     )
     return sorted(exts_all)
 
 
 ### `type`
 def type(xml, type):
     return [xml.xpath(f"string({TYPES}/type/name[text()='{type}']/..)")]
 
 
 ### `value`
 def value(xml, enum):
     return xml.xpath(f"{ENUMS}/enum[@name='{enum}']/@value")
 
 
 ### `enum`
 def enum(xml, value):
     enum = xml.xpath(f"{ENUMS}/enum[@value='{value}']/@name")
     return sorted(enum)
 
 
 ### `supports`
 def supports(xml, name):
     category, attrib = CATEGORY_ATTRIBS['EXTENSION']
     if xml.xpath(f"{category}[@{attrib}='{name}']"):
         return ['EXTENSION']
     supports_ = [
         support
         for category, attrib in CATEGORY_ATTRIBS.values()
         for support in
         xml.xpath(f"{category}/{REQUIRE}/*[@name='{name}']/../../@{attrib}")
     ]
     return sorted(supports_)
 
 
 ### `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")
     )
     return sorted(names)
 
 
 ### `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
     ]
     return sorted(enums)
 
 
 ### `enums_tree`
 def enums_tree(xml, group=None):
     for supports, enums in sorted(enums_(xml, group).items()):
         yield indentjoin(0, ',', supports)
         for enum in sorted(enums):
             yield indentjoin(1, '', [enum])
 
 
 ### `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, ':', [
             param,
             -count,
         ])
         for supports_, commands in sorted(occurences.items()):
             yield indentjoin(1, ',', supports_)
             for command in sorted(commands):
                 yield indentjoin(2, '', [command])
 
 
 ### `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, ':', [
             file,
             line,
             indentjoin(0, ',', supports),
             name
         ])
 
 
 ### `audit_tree`
 def audit_tree(xml, path=None):
     for supports, names in sorted(audit_(xml, path).items()):
         yield indentjoin(0, ',', supports)
         for name, locations in sorted(names.items()):
             yield indentjoin(1, '', [name])
             for file, line in sorted(locations):
                 yield indentjoin(2, ':', [
                     file,
                     line,
                 ])
 
 
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()):
         yield indentjoin(0, ',', [support])
         for name_, url in sorted(locations):
             yield indentjoin(1, ':', [
                 name_,
                 url,
             ])
 
 
3969d955
 ## Main
 def main():
     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>']))
     if args['type']:          page(type         (xml_(), args['<type>']))
     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()