view scripts/vsutil.py @ 15:cfcac4ed7d21 default tip

Improve loading of solution files - New argument to force a rebuild of the cache - Gracefully handle missing projects in a solution - Handle more different xml namespaces - Support more edge cases
author Ludovic Chabant <ludovic@chabant.com>
date Tue, 29 Aug 2023 12:59:54 -0700
parents 4ba6df1b2f97
children
line wrap: on
line source

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, ITEM_TYPE_NONE)

# 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, env), 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):
        return self.get_items_of_types(ITEM_TYPE_SOURCE_FILES)

    def get_items_of_type(self, itemtype):
        for i in self.items:
            if i.itemtype == itemtype:
                yield i

    def get_items_of_types(self, itemtypes):
        typeset = set(itemtypes)
        for i in self.items:
            if i.itemtype in typeset:
                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, owner, projtype, name, path, guid):
        self.owner = owner
        self.type = projtype
        self.name = name
        self.path = path
        self.guid = guid
        self._itemgroups = None
        self._propgroups = None
        self._sln = None
        self._missing = False

    @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)
            self._validate_build_env(resolved_with)
            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)
            self._validate_build_env(resolved_with)
            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()

        self._validate_build_env(env)

        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 _validate_build_env(self, buildenv):
        buildenv['SolutionDir'] = self.owner.dirpath + os.path.sep
        buildenv['ProjectDir'] = self.absdirpath + os.path.sep

    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}")
        try:
            tree = etree.parse(abspath)
        except (FileNotFoundError, OSError) as ex:
            logger.debug(f"Error loading project {self.name}: " + str(ex))
            self._itemgroups = {}
            self._propgroups= {}
            self._missing = True
            return

        root = tree.getroot()
        if _strip_ns(root.tag) != 'Project':
            raise Exception(f"Expected root node 'Project', got '{root.tag}'")

        # Load ItemGroups and PropertyGroups via both namespaced names and raw
        # names because not all types of VS projects use the MS namespaces.
        self._itemgroups = {}
        for itemgroupnode in root.iterfind('ItemGroup', ns):
            self._load_item_group(itemgroupnode)
        for itemgroupnode in root.iterfind('ms:ItemGroup', ns):
            self._load_item_group(itemgroupnode)

        self._propgroups = {}
        for propgroupnode in root.iterfind('PropertyGroup', ns):
            self._load_property_group(propgroupnode)
        for propgroupnode in root.iterfind('ms:PropertyGroup', ns):
            self._load_property_group(propgroupnode)

    def _load_item_group(self, itemgroupnode):
        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

    def _load_property_group(self, propgroupnode):
        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} (line {i})")
                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(
                    slnobj,
                    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} (line {i})")
            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 = 5

    def __init__(self, slnobj):
        self.slnobj = slnobj
        self.index = None
        self._saved_version = SolutionCache.VERSION

    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():
                if item.include:
                    item_path = proj.get_abs_item_include(item).lower()
                    item_cache.add(item_path)
                # else: it's an item from our shortlist (cpp, cs, etc files)
                # but it somehow doesn't have a path, which can happen with
                # some obscure VS features.

    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, force_rebuild=False):
        if cachepath and not force_rebuild:
            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.
    try:
        with open(cachepath, 'rb') as fp:
            cache = pickle.load(fp)
    except Exception as ex:
        logger.debug("Error loading solution cache: %s" % ex)
        logger.debug("Deleting cache: %s" % cachepath)
        os.remove(cachepath)
        return None

    # Check that the cache version is up-to-date with this code.
    loaded_ver = getattr(cache, '_saved_version', 0)
    if loaded_ver != SolutionCache.VERSION:
        logger.debug(f"Cache was saved with older format: {cachepath} "
                     f"(got {loaded_ver}, expected {SolutionCache.VERSION})")
        return None
    logger.debug(f"Cache has correct version: {loaded_ver}")

    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))
                # The project was missing last time we built the cache,
                # but now it exists. Force a rebuild.
                if p._missing:
                    return None
            except OSError:
                if not p._missing:
                    logger.debug(f"Found missing project: {p.abspath}")
                    return None
                # else: it was already missing last time we built the
                # cache, so nothing has changed.

    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