diff scripts/vsutil.py @ 0:5d2c0db51914

Initial commit
author Ludovic Chabant <ludovic@chabant.com>
date Tue, 17 Sep 2019 13:24:24 -0700
parents
children 949c4f536f26
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/vsutil.py	Tue Sep 17 13:24:24 2019 -0700
@@ -0,0 +1,592 @@
+import copy
+import logging
+import os.path
+import pickle
+import re
+import xml.etree.ElementTree as etree
+
+
+# Known VS project types.
+PROJ_TYPE_FOLDER = '2150E333-8FDC-42A3-9474-1A3956D46DE8'
+PROJ_TYPE_NMAKE = '8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942'
+PROJ_TYPE_CSHARP = 'FAE04EC0-301F-11D3-BF4B-00C04F79EFBC'
+
+PROJ_TYPE_NAMES = {
+    PROJ_TYPE_FOLDER: 'folder',
+    PROJ_TYPE_NMAKE: 'nmake',
+    PROJ_TYPE_CSHARP: 'csharp'
+}
+
+# Known VS item types.
+ITEM_TYPE_CPP_SRC = 'ClCompile'
+ITEM_TYPE_CPP_HDR = 'ClInclude'
+
+ITEM_TYPE_CS_REF = 'Reference'
+ITEM_TYPE_CS_PROJREF = 'ProjectReference'
+ITEM_TYPE_CS_SRC = 'Compile'
+
+ITEM_TYPE_NONE = 'None'
+
+ITEM_TYPE_SOURCE_FILES = (ITEM_TYPE_CPP_SRC, ITEM_TYPE_CPP_HDR,
+                          ITEM_TYPE_CS_SRC)
+
+
+# Known VS properties.
+PROP_CONFIGURATION_TYPE = 'ConfigurationType'
+PROP_NMAKE_PREPROCESSOR_DEFINITIONS = 'NMakePreprocessorDefinitions'
+PROP_NMAKE_INCLUDE_SEARCH_PATH = 'NMakeIncludeSearchPath'
+
+
+logger = logging.getLogger(__name__)
+
+
+def _strip_ns(tag):
+    """ Remove the XML namespace from a tag name. """
+    if tag[0] == '{':
+        i = tag.index('}')
+        return tag[i+1:]
+    return tag
+
+
+re_msbuild_var = re.compile(r'\$\((?P<var>[\w\d_]+)\)')
+
+
+def _resolve_value(val, env):
+    """ Expands MSBuild property values given a build environment. """
+    def _repl_vars(m):
+        varname = m.group('var')
+        varval = env.get(varname, '')
+        return varval
+
+    if not val:
+        return val
+    return re_msbuild_var.sub(_repl_vars, val)
+
+
+def _evaluate_condition(cond, env):
+    """ Expands MSBuild property values in a condition and evaluates it. """
+    left, right = _resolve_value(cond, env).split('==')
+    return left == right
+
+
+class VSBaseGroup:
+    """ Base class for VS project stuff that has conditional stuff inside.
+
+        For instance, a property group called 'Blah' might have some common
+        (always valid) stuff, but a bunch of other stuff that should only
+        be considered when the solution configuration is Debug, Release, or
+        whatever else. In that case, each 'conditional' (i.e. values for Debug,
+        values for Release, etc.) is listed and tracked separately until
+        we are asked to 'resolve' ourselves based on a given build environment.
+    """
+    def __init__(self, label):
+        self.label = label
+        self.conditionals = {}
+
+    def get_conditional(self, condition):
+        """ Adds a conditional sub-group. """
+        return self.conditionals.get(condition)
+
+    def get_or_create_conditional(self, condition):
+        """ Gets or creates a new conditional sub-group. """
+        c = self.get_conditional(condition)
+        if not c:
+            c = self.__class__(self.label)
+            self.conditionals[condition] = c
+        return c
+
+    def resolve(self, env):
+        """ Resolves this group by evaluating each conditional sub-group
+            based on the given build environment. Returns a 'flattened'
+            version of ourselves.
+        """
+        c = self.__class__(self.label)
+        c._collapse_child(self, env)
+
+        for cond, child in self.conditionals.items():
+            if _evaluate_condition(cond, env):
+                c._collapse_child(child, env)
+
+        return c
+
+
+class VSProjectItem:
+    """ A VS project item, like a source code file. """
+    def __init__(self, include, itemtype=None):
+        self.include = include
+        self.itemtype = itemtype
+        self.metadata = {}
+
+    def resolve(self, env):
+        c = VSProjectItem(_resolve_value(self.include), self.itemtype)
+        c.metadata = {k: _resolve_value(v, env)
+                      for k, v in self.metadata.items()}
+        return c
+
+    def __str__(self):
+        return "(%s)%s" % (self.itemtype, self.include)
+
+
+class VSProjectItemGroup(VSBaseGroup):
+    """ A VS project item group, like a list of source code files,
+        or a list of resources.
+    """
+    def __init__(self, label):
+        super().__init__(label)
+        self.items = []
+
+    def get_source_items(self):
+        for i in self.items:
+            if i.itemtype in ITEM_TYPE_SOURCE_FILES:
+                yield i
+
+    def _collapse_child(self, child, env):
+        self.items += [i.resolve(env) for i in child.items]
+
+
+class VSProjectProperty:
+    """ A VS project property, like an include path or compiler flag. """
+    def __init__(self, name, value):
+        self.name = name
+        self.value = value
+
+    def resolve(self, env):
+        c = VSProjectProperty(self.name, _resolve_value(self.value, env))
+        return c
+
+    def __str__(self):
+        return "%s=%s" % (self.name, self.value)
+
+
+class VSProjectPropertyGroup(VSBaseGroup):
+    """ A VS project property group, such as compiler macros or flags. """
+    def __init__(self, label):
+        super().__init__(label)
+        self.properties = []
+
+    def get(self, propname):
+        try:
+            return self[propname]
+        except IndexError:
+            return None
+
+    def __getitem__(self, propname):
+        for p in self.properties:
+            if p.name == propname:
+                return p.value
+        raise IndexError()
+
+    def _collapse_child(self, child, env):
+        self.properties += [p.resolve(env) for p in child.properties]
+
+
+class VSProject:
+    """ A VS project. """
+    def __init__(self, projtype, name, path, guid):
+        self.type = projtype
+        self.name = name
+        self.path = path
+        self.guid = guid
+        self._itemgroups = None
+        self._propgroups = None
+        self._sln = None
+
+    @property
+    def is_folder(self):
+        """ Returns whether this project is actually just a solution
+            folder, used as a container for other projects.
+        """
+        return self.type == PROJ_TYPE_FOLDER
+
+    @property
+    def abspath(self):
+        abspath = self.path
+        if self._sln and self._sln.path:
+            abspath = os.path.join(self._sln.dirpath, self.path)
+        return abspath
+
+    @property
+    def absdirpath(self):
+        return os.path.dirname(self.abspath)
+
+    @property
+    def itemgroups(self):
+        self._ensure_loaded()
+        return self._itemgroups.values()
+
+    @property
+    def propertygroups(self):
+        self._ensure_loaded()
+        return self._propgroups.values()
+
+    def itemgroup(self, label, resolved_with=None):
+        self._ensure_loaded()
+        ig = self._itemgroups.get(label)
+        if resolved_with is not None and ig is not None:
+            logger.debug("Resolving item group '%s'." % ig.label)
+            ig = ig.resolve(resolved_with)
+        return ig
+
+    def defaultitemgroup(self, resolved_with=None):
+        return self.itemgroup(None, resolved_with=resolved_with)
+
+    def propertygroup(self, label, resolved_with=None):
+        self._ensure_loaded()
+        pg = self._propgroups.get(label)
+        if resolved_with is not None and pg is not None:
+            logger.debug("Resolving property group '%s'." % pg.label)
+            pg = pg.resolve(resolved_with)
+        return pg
+
+    def defaultpropertygroup(self, resolved_with=None):
+        return self.propertygroup(None, resolved_with=resolved_with)
+
+    def get_abs_item_include(self, item):
+        return os.path.abspath(os.path.join(self.absdirpath, item.include))
+
+    def resolve(self, env):
+        self._ensure_loaded()
+
+        propgroups = list(self._propgroups)
+        itemgroups = list(self._itemgroups)
+        self._propgroups[:] = []
+        self._itemgroups[:] = []
+
+        for pg in propgroups:
+            rpg = pg.resolve(env)
+            self._propgroups.append(rpg)
+
+        for ig in itemgroups:
+            rig = ig.resolve(env)
+            self._itemgroups.append(rig)
+
+    def _ensure_loaded(self):
+        if self._itemgroups is None or self._propgroups is None:
+            self._load()
+
+    def _load(self):
+        if not self.path:
+            raise Exception("The current project has no path.")
+        if self.is_folder:
+            logger.debug(f"Skipping folder project {self.name}")
+            self._itemgroups = {}
+            self._propgroups = {}
+            return
+
+        ns = {'ms': 'http://schemas.microsoft.com/developer/msbuild/2003'}
+
+        abspath = self.abspath
+        logger.debug(f"Loading project {self.name} ({self.path}) from: {abspath}")
+        tree = etree.parse(abspath)
+        root = tree.getroot()
+        if _strip_ns(root.tag) != 'Project':
+            raise Exception(f"Expected root node 'Project', got '{root.tag}'")
+
+        self._itemgroups = {}
+        for itemgroupnode in root.iterfind('ms:ItemGroup', ns):
+            label = itemgroupnode.attrib.get('Label')
+            itemgroup = self._itemgroups.get(label)
+            if not itemgroup:
+                itemgroup = VSProjectItemGroup(label)
+                self._itemgroups[label] = itemgroup
+                logger.debug(f"Adding itemgroup '{label}'")
+
+            condition = itemgroupnode.attrib.get('Condition')
+            if condition:
+                itemgroup = itemgroup.get_or_create_conditional(condition)
+
+            for itemnode in itemgroupnode:
+                incval = itemnode.attrib.get('Include')
+                item = VSProjectItem(incval, _strip_ns(itemnode.tag))
+                itemgroup.items.append(item)
+                for metanode in itemnode:
+                    item.metadata[_strip_ns(metanode.tag)] = metanode.text
+
+        self._propgroups = {}
+        for propgroupnode in root.iterfind('ms:PropertyGroup', ns):
+            label = propgroupnode.attrib.get('Label')
+            propgroup = self._propgroups.get(label)
+            if not propgroup:
+                propgroup = VSProjectPropertyGroup(label)
+                self._propgroups[label] = propgroup
+                logger.debug(f"Adding propertygroup '{label}'")
+
+            condition = propgroupnode.attrib.get('Condition')
+            if condition:
+                propgroup = propgroup.get_or_create_conditional(condition)
+
+            for propnode in propgroupnode:
+                propgroup.properties.append(VSProjectProperty(
+                    _strip_ns(propnode.tag),
+                    propnode.text))
+
+
+class MissingVSProjectError(Exception):
+    pass
+
+
+class VSGlobalSectionEntry:
+    """ An entry in a VS solution's global section. """
+    def __init__(self, name, value):
+        self.name = name
+        self.value = value
+
+
+class VSGlobalSection:
+    """ A global section in a VS solution. """
+    def __init__(self, name):
+        self.name = name
+        self.entries = []
+
+
+class VSSolution:
+    """ A VS solution. """
+    def __init__(self, path=None):
+        self.path = path
+        self.projects = []
+        self.sections = []
+
+    @property
+    def dirpath(self):
+        return os.path.dirname(self.path)
+
+    def find_project_by_name(self, name, missing_ok=True):
+        for p in self.projects:
+            if p.name == name:
+                return p
+        if missing_ok:
+            return None
+        return MissingVSProjectError(f"Can't find project with name: {name}")
+
+    def find_project_by_path(self, path, missing_ok=True):
+        for p in self.projects:
+            if p.abspath == path:
+                return p
+        if missing_ok:
+            return None
+        raise MissingVSProjectError(f"Can't find project with path: {path}")
+
+    def find_project_by_guid(self, guid, missing_ok=True):
+        for p in self.projects:
+            if p.guid == guid:
+                return p
+        if missing_ok:
+            return None
+        raise MissingVSProjectError(f"Can't find project for guid: {guid}")
+
+    def globalsection(self, name):
+        for sec in self.sections:
+            if sec.name == name:
+                return sec
+        return None
+
+    def find_project_configuration(self, proj_guid, sln_config):
+        configs = self.globalsection('ProjectConfigurationPlatforms')
+        if not configs:
+            return None
+
+        entry_name = '{%s}.%s.Build.0' % (proj_guid, sln_config)
+        for entry in configs.entries:
+            if entry.name == entry_name:
+                return entry.value
+        return None
+
+
+_re_sln_project_decl_start = re.compile(
+    r'^Project\("\{(?P<type>[A-Z0-9\-]+)\}"\) \= '
+    r'"(?P<name>[^"]+)", "(?P<path>[^"]+)", "\{(?P<guid>[A-Z0-9\-]+)\}"$')
+_re_sln_project_decl_end = re.compile(
+    r'^EndProject$')
+
+_re_sln_global_start = re.compile(r'^Global$')
+_re_sln_global_end = re.compile(r'^EndGlobal$')
+_re_sln_global_section_start = re.compile(
+    r'^\s*GlobalSection\((?P<name>\w+)\) \= (?P<step>\w+)$')
+_re_sln_global_section_end = re.compile(r'^\s*EndGlobalSection$')
+
+
+def parse_sln_file(slnpath):
+    """ Parses a solution file, returns a solution object.
+        The projects are not loaded (they will be lazily loaded upon
+        first access to their items/properties/etc.).
+    """
+    logging.debug(f"Reading {slnpath}")
+    slnobj = VSSolution(slnpath)
+    with open(slnpath, 'r') as fp:
+        lines = fp.readlines()
+        _parse_sln_file_text(slnobj, lines)
+    return slnobj
+
+
+def _parse_sln_file_text(slnobj, lines):
+    until = None
+    in_global = False
+    in_global_section = None
+
+    for i, line in enumerate(lines):
+        if until:
+            # We need to parse something until a given token, so let's
+            # do that and ignore everything else.
+            m = until.search(line)
+            if m:
+                until = None
+            continue
+
+        if in_global:
+            # We're in the 'global' part of the solution. It should contain
+            # a bunch of 'global sections' that we need to parse individually.
+            if in_global_section:
+                # Keep parsing the current section until we reach the end.
+                m = _re_sln_global_section_end.search(line)
+                if m:
+                    in_global_section = None
+                    continue
+
+                ename, evalue = line.strip().split('=')
+                in_global_section.entries.append(VSGlobalSectionEntry(
+                    ename.strip(),
+                    evalue.strip()))
+                continue
+
+            m = _re_sln_global_section_start.search(line)
+            if m:
+                # Found the start of a new section.
+                in_global_section = VSGlobalSection(m.group('name'))
+                logging.debug(f"   Adding global section {in_global_section.name}")
+                slnobj.sections.append(in_global_section)
+                continue
+
+            m = _re_sln_global_end.search(line)
+            if m:
+                # Found the end of the 'global' part.
+                in_global = False
+                continue
+
+        # We're not in a specific part of the solution, so do high-level
+        # parsing. First, ignore root-level comments.
+        if not line or line[0] == '#':
+            continue
+
+        m = _re_sln_project_decl_start.search(line)
+        if m:
+            # Found the start of a project declaration.
+            try:
+                p = VSProject(
+                    m.group('type'), m.group('name'), m.group('path'),
+                    m.group('guid'))
+            except:
+                raise Exception(f"Error line {i}: unexpected project syntax.")
+            logging.debug(f"  Adding project {p.name}")
+            slnobj.projects.append(p)
+            p._sln = slnobj
+
+            until = _re_sln_project_decl_end
+            continue
+
+        m = _re_sln_global_start.search(line)
+        if m:
+            # Reached the start of the 'global' part, where global sections
+            # are defined.
+            in_global = True
+            continue
+
+        # Ignore the rest (like visual studio version flags).
+        continue
+
+
+class SolutionCache:
+    """ A class that contains a VS solution object, along with pre-indexed
+        lists of items. It's meant to be saved on disk.
+    """
+    VERSION = 3
+
+    def __init__(self, slnobj):
+        self.slnobj = slnobj
+        self.index = None
+
+    def build_cache(self):
+        self.index = {}
+        for proj in self.slnobj.projects:
+            if proj.is_folder:
+                continue
+            itemgroup = proj.defaultitemgroup()
+            if not itemgroup:
+                continue
+
+            item_cache = set()
+            self.index[proj.abspath] = item_cache
+
+            for item in itemgroup.get_source_items():
+                item_path = proj.get_abs_item_include(item).lower()
+                item_cache.add(item_path)
+
+    def save(self, path):
+        pathdir = os.path.dirname(path)
+        if not os.path.exists(pathdir):
+            os.makedirs(pathdir)
+        with open(path, 'wb') as fp:
+            pickle.dump(self, fp)
+
+    @staticmethod
+    def load_or_rebuild(slnpath, cachepath):
+        if cachepath:
+            res = _try_load_from_cache(slnpath, cachepath)
+            if res is not None:
+                return res
+
+        slnobj = parse_sln_file(slnpath)
+        cache = SolutionCache(slnobj)
+
+        if cachepath:
+            logger.debug(f"Regenerating cache: {cachepath}")
+            cache.build_cache()
+            cache.save(cachepath)
+
+        return (cache, False)
+
+
+def _try_load_from_cache(slnpath, cachepath):
+    try:
+        sln_dt = os.path.getmtime(slnpath)
+        cache_dt = os.path.getmtime(cachepath)
+    except OSError:
+        logger.debug("Can't read solution or cache files.")
+        return None
+
+    # If the solution file is newer, bail out.
+    if sln_dt >= cache_dt:
+        logger.debug("Solution is newer than cache.")
+        return None
+
+    # Our cache is at least valid for the solution stuff. Some of our
+    # projects might be out of date, but at least there can't be any
+    # added or removed projects from the solution (otherwise the solution
+    # file would have been touched). Let's load the cache.
+    with open(cachepath, 'rb') as fp:
+        cache = pickle.load(fp)
+
+    # Check that the cache version is up-to-date with this code.
+    loaded_ver = getattr(cache, 'VERSION')
+    if loaded_ver != SolutionCache.VERSION:
+        logger.debug(f"Cache was saved with older format: {cachepath}")
+        return None
+
+    slnobj = cache.slnobj
+
+    # Check that none of the project files in the solution are newer
+    # than this cache.
+    proj_dts = []
+    for p in slnobj.projects:
+        if not p.is_folder:
+            try:
+                proj_dts.append(os.path.getmtime(p.abspath))
+            except OSError:
+                logger.debug(f"Found missing project: {p.abspath}")
+                return None
+
+    if all([cache_dt > pdt for pdt in proj_dts]):
+        logger.debug(f"Cache is up to date: {cachepath}")
+        return (cache, True)
+
+    logger.debug("Cache has outdated projects.")
+    return None