comparison 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
comparison
equal deleted inserted replaced
-1:000000000000 0:5d2c0db51914
1 import copy
2 import logging
3 import os.path
4 import pickle
5 import re
6 import xml.etree.ElementTree as etree
7
8
9 # Known VS project types.
10 PROJ_TYPE_FOLDER = '2150E333-8FDC-42A3-9474-1A3956D46DE8'
11 PROJ_TYPE_NMAKE = '8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942'
12 PROJ_TYPE_CSHARP = 'FAE04EC0-301F-11D3-BF4B-00C04F79EFBC'
13
14 PROJ_TYPE_NAMES = {
15 PROJ_TYPE_FOLDER: 'folder',
16 PROJ_TYPE_NMAKE: 'nmake',
17 PROJ_TYPE_CSHARP: 'csharp'
18 }
19
20 # Known VS item types.
21 ITEM_TYPE_CPP_SRC = 'ClCompile'
22 ITEM_TYPE_CPP_HDR = 'ClInclude'
23
24 ITEM_TYPE_CS_REF = 'Reference'
25 ITEM_TYPE_CS_PROJREF = 'ProjectReference'
26 ITEM_TYPE_CS_SRC = 'Compile'
27
28 ITEM_TYPE_NONE = 'None'
29
30 ITEM_TYPE_SOURCE_FILES = (ITEM_TYPE_CPP_SRC, ITEM_TYPE_CPP_HDR,
31 ITEM_TYPE_CS_SRC)
32
33
34 # Known VS properties.
35 PROP_CONFIGURATION_TYPE = 'ConfigurationType'
36 PROP_NMAKE_PREPROCESSOR_DEFINITIONS = 'NMakePreprocessorDefinitions'
37 PROP_NMAKE_INCLUDE_SEARCH_PATH = 'NMakeIncludeSearchPath'
38
39
40 logger = logging.getLogger(__name__)
41
42
43 def _strip_ns(tag):
44 """ Remove the XML namespace from a tag name. """
45 if tag[0] == '{':
46 i = tag.index('}')
47 return tag[i+1:]
48 return tag
49
50
51 re_msbuild_var = re.compile(r'\$\((?P<var>[\w\d_]+)\)')
52
53
54 def _resolve_value(val, env):
55 """ Expands MSBuild property values given a build environment. """
56 def _repl_vars(m):
57 varname = m.group('var')
58 varval = env.get(varname, '')
59 return varval
60
61 if not val:
62 return val
63 return re_msbuild_var.sub(_repl_vars, val)
64
65
66 def _evaluate_condition(cond, env):
67 """ Expands MSBuild property values in a condition and evaluates it. """
68 left, right = _resolve_value(cond, env).split('==')
69 return left == right
70
71
72 class VSBaseGroup:
73 """ Base class for VS project stuff that has conditional stuff inside.
74
75 For instance, a property group called 'Blah' might have some common
76 (always valid) stuff, but a bunch of other stuff that should only
77 be considered when the solution configuration is Debug, Release, or
78 whatever else. In that case, each 'conditional' (i.e. values for Debug,
79 values for Release, etc.) is listed and tracked separately until
80 we are asked to 'resolve' ourselves based on a given build environment.
81 """
82 def __init__(self, label):
83 self.label = label
84 self.conditionals = {}
85
86 def get_conditional(self, condition):
87 """ Adds a conditional sub-group. """
88 return self.conditionals.get(condition)
89
90 def get_or_create_conditional(self, condition):
91 """ Gets or creates a new conditional sub-group. """
92 c = self.get_conditional(condition)
93 if not c:
94 c = self.__class__(self.label)
95 self.conditionals[condition] = c
96 return c
97
98 def resolve(self, env):
99 """ Resolves this group by evaluating each conditional sub-group
100 based on the given build environment. Returns a 'flattened'
101 version of ourselves.
102 """
103 c = self.__class__(self.label)
104 c._collapse_child(self, env)
105
106 for cond, child in self.conditionals.items():
107 if _evaluate_condition(cond, env):
108 c._collapse_child(child, env)
109
110 return c
111
112
113 class VSProjectItem:
114 """ A VS project item, like a source code file. """
115 def __init__(self, include, itemtype=None):
116 self.include = include
117 self.itemtype = itemtype
118 self.metadata = {}
119
120 def resolve(self, env):
121 c = VSProjectItem(_resolve_value(self.include), self.itemtype)
122 c.metadata = {k: _resolve_value(v, env)
123 for k, v in self.metadata.items()}
124 return c
125
126 def __str__(self):
127 return "(%s)%s" % (self.itemtype, self.include)
128
129
130 class VSProjectItemGroup(VSBaseGroup):
131 """ A VS project item group, like a list of source code files,
132 or a list of resources.
133 """
134 def __init__(self, label):
135 super().__init__(label)
136 self.items = []
137
138 def get_source_items(self):
139 for i in self.items:
140 if i.itemtype in ITEM_TYPE_SOURCE_FILES:
141 yield i
142
143 def _collapse_child(self, child, env):
144 self.items += [i.resolve(env) for i in child.items]
145
146
147 class VSProjectProperty:
148 """ A VS project property, like an include path or compiler flag. """
149 def __init__(self, name, value):
150 self.name = name
151 self.value = value
152
153 def resolve(self, env):
154 c = VSProjectProperty(self.name, _resolve_value(self.value, env))
155 return c
156
157 def __str__(self):
158 return "%s=%s" % (self.name, self.value)
159
160
161 class VSProjectPropertyGroup(VSBaseGroup):
162 """ A VS project property group, such as compiler macros or flags. """
163 def __init__(self, label):
164 super().__init__(label)
165 self.properties = []
166
167 def get(self, propname):
168 try:
169 return self[propname]
170 except IndexError:
171 return None
172
173 def __getitem__(self, propname):
174 for p in self.properties:
175 if p.name == propname:
176 return p.value
177 raise IndexError()
178
179 def _collapse_child(self, child, env):
180 self.properties += [p.resolve(env) for p in child.properties]
181
182
183 class VSProject:
184 """ A VS project. """
185 def __init__(self, projtype, name, path, guid):
186 self.type = projtype
187 self.name = name
188 self.path = path
189 self.guid = guid
190 self._itemgroups = None
191 self._propgroups = None
192 self._sln = None
193
194 @property
195 def is_folder(self):
196 """ Returns whether this project is actually just a solution
197 folder, used as a container for other projects.
198 """
199 return self.type == PROJ_TYPE_FOLDER
200
201 @property
202 def abspath(self):
203 abspath = self.path
204 if self._sln and self._sln.path:
205 abspath = os.path.join(self._sln.dirpath, self.path)
206 return abspath
207
208 @property
209 def absdirpath(self):
210 return os.path.dirname(self.abspath)
211
212 @property
213 def itemgroups(self):
214 self._ensure_loaded()
215 return self._itemgroups.values()
216
217 @property
218 def propertygroups(self):
219 self._ensure_loaded()
220 return self._propgroups.values()
221
222 def itemgroup(self, label, resolved_with=None):
223 self._ensure_loaded()
224 ig = self._itemgroups.get(label)
225 if resolved_with is not None and ig is not None:
226 logger.debug("Resolving item group '%s'." % ig.label)
227 ig = ig.resolve(resolved_with)
228 return ig
229
230 def defaultitemgroup(self, resolved_with=None):
231 return self.itemgroup(None, resolved_with=resolved_with)
232
233 def propertygroup(self, label, resolved_with=None):
234 self._ensure_loaded()
235 pg = self._propgroups.get(label)
236 if resolved_with is not None and pg is not None:
237 logger.debug("Resolving property group '%s'." % pg.label)
238 pg = pg.resolve(resolved_with)
239 return pg
240
241 def defaultpropertygroup(self, resolved_with=None):
242 return self.propertygroup(None, resolved_with=resolved_with)
243
244 def get_abs_item_include(self, item):
245 return os.path.abspath(os.path.join(self.absdirpath, item.include))
246
247 def resolve(self, env):
248 self._ensure_loaded()
249
250 propgroups = list(self._propgroups)
251 itemgroups = list(self._itemgroups)
252 self._propgroups[:] = []
253 self._itemgroups[:] = []
254
255 for pg in propgroups:
256 rpg = pg.resolve(env)
257 self._propgroups.append(rpg)
258
259 for ig in itemgroups:
260 rig = ig.resolve(env)
261 self._itemgroups.append(rig)
262
263 def _ensure_loaded(self):
264 if self._itemgroups is None or self._propgroups is None:
265 self._load()
266
267 def _load(self):
268 if not self.path:
269 raise Exception("The current project has no path.")
270 if self.is_folder:
271 logger.debug(f"Skipping folder project {self.name}")
272 self._itemgroups = {}
273 self._propgroups = {}
274 return
275
276 ns = {'ms': 'http://schemas.microsoft.com/developer/msbuild/2003'}
277
278 abspath = self.abspath
279 logger.debug(f"Loading project {self.name} ({self.path}) from: {abspath}")
280 tree = etree.parse(abspath)
281 root = tree.getroot()
282 if _strip_ns(root.tag) != 'Project':
283 raise Exception(f"Expected root node 'Project', got '{root.tag}'")
284
285 self._itemgroups = {}
286 for itemgroupnode in root.iterfind('ms:ItemGroup', ns):
287 label = itemgroupnode.attrib.get('Label')
288 itemgroup = self._itemgroups.get(label)
289 if not itemgroup:
290 itemgroup = VSProjectItemGroup(label)
291 self._itemgroups[label] = itemgroup
292 logger.debug(f"Adding itemgroup '{label}'")
293
294 condition = itemgroupnode.attrib.get('Condition')
295 if condition:
296 itemgroup = itemgroup.get_or_create_conditional(condition)
297
298 for itemnode in itemgroupnode:
299 incval = itemnode.attrib.get('Include')
300 item = VSProjectItem(incval, _strip_ns(itemnode.tag))
301 itemgroup.items.append(item)
302 for metanode in itemnode:
303 item.metadata[_strip_ns(metanode.tag)] = metanode.text
304
305 self._propgroups = {}
306 for propgroupnode in root.iterfind('ms:PropertyGroup', ns):
307 label = propgroupnode.attrib.get('Label')
308 propgroup = self._propgroups.get(label)
309 if not propgroup:
310 propgroup = VSProjectPropertyGroup(label)
311 self._propgroups[label] = propgroup
312 logger.debug(f"Adding propertygroup '{label}'")
313
314 condition = propgroupnode.attrib.get('Condition')
315 if condition:
316 propgroup = propgroup.get_or_create_conditional(condition)
317
318 for propnode in propgroupnode:
319 propgroup.properties.append(VSProjectProperty(
320 _strip_ns(propnode.tag),
321 propnode.text))
322
323
324 class MissingVSProjectError(Exception):
325 pass
326
327
328 class VSGlobalSectionEntry:
329 """ An entry in a VS solution's global section. """
330 def __init__(self, name, value):
331 self.name = name
332 self.value = value
333
334
335 class VSGlobalSection:
336 """ A global section in a VS solution. """
337 def __init__(self, name):
338 self.name = name
339 self.entries = []
340
341
342 class VSSolution:
343 """ A VS solution. """
344 def __init__(self, path=None):
345 self.path = path
346 self.projects = []
347 self.sections = []
348
349 @property
350 def dirpath(self):
351 return os.path.dirname(self.path)
352
353 def find_project_by_name(self, name, missing_ok=True):
354 for p in self.projects:
355 if p.name == name:
356 return p
357 if missing_ok:
358 return None
359 return MissingVSProjectError(f"Can't find project with name: {name}")
360
361 def find_project_by_path(self, path, missing_ok=True):
362 for p in self.projects:
363 if p.abspath == path:
364 return p
365 if missing_ok:
366 return None
367 raise MissingVSProjectError(f"Can't find project with path: {path}")
368
369 def find_project_by_guid(self, guid, missing_ok=True):
370 for p in self.projects:
371 if p.guid == guid:
372 return p
373 if missing_ok:
374 return None
375 raise MissingVSProjectError(f"Can't find project for guid: {guid}")
376
377 def globalsection(self, name):
378 for sec in self.sections:
379 if sec.name == name:
380 return sec
381 return None
382
383 def find_project_configuration(self, proj_guid, sln_config):
384 configs = self.globalsection('ProjectConfigurationPlatforms')
385 if not configs:
386 return None
387
388 entry_name = '{%s}.%s.Build.0' % (proj_guid, sln_config)
389 for entry in configs.entries:
390 if entry.name == entry_name:
391 return entry.value
392 return None
393
394
395 _re_sln_project_decl_start = re.compile(
396 r'^Project\("\{(?P<type>[A-Z0-9\-]+)\}"\) \= '
397 r'"(?P<name>[^"]+)", "(?P<path>[^"]+)", "\{(?P<guid>[A-Z0-9\-]+)\}"$')
398 _re_sln_project_decl_end = re.compile(
399 r'^EndProject$')
400
401 _re_sln_global_start = re.compile(r'^Global$')
402 _re_sln_global_end = re.compile(r'^EndGlobal$')
403 _re_sln_global_section_start = re.compile(
404 r'^\s*GlobalSection\((?P<name>\w+)\) \= (?P<step>\w+)$')
405 _re_sln_global_section_end = re.compile(r'^\s*EndGlobalSection$')
406
407
408 def parse_sln_file(slnpath):
409 """ Parses a solution file, returns a solution object.
410 The projects are not loaded (they will be lazily loaded upon
411 first access to their items/properties/etc.).
412 """
413 logging.debug(f"Reading {slnpath}")
414 slnobj = VSSolution(slnpath)
415 with open(slnpath, 'r') as fp:
416 lines = fp.readlines()
417 _parse_sln_file_text(slnobj, lines)
418 return slnobj
419
420
421 def _parse_sln_file_text(slnobj, lines):
422 until = None
423 in_global = False
424 in_global_section = None
425
426 for i, line in enumerate(lines):
427 if until:
428 # We need to parse something until a given token, so let's
429 # do that and ignore everything else.
430 m = until.search(line)
431 if m:
432 until = None
433 continue
434
435 if in_global:
436 # We're in the 'global' part of the solution. It should contain
437 # a bunch of 'global sections' that we need to parse individually.
438 if in_global_section:
439 # Keep parsing the current section until we reach the end.
440 m = _re_sln_global_section_end.search(line)
441 if m:
442 in_global_section = None
443 continue
444
445 ename, evalue = line.strip().split('=')
446 in_global_section.entries.append(VSGlobalSectionEntry(
447 ename.strip(),
448 evalue.strip()))
449 continue
450
451 m = _re_sln_global_section_start.search(line)
452 if m:
453 # Found the start of a new section.
454 in_global_section = VSGlobalSection(m.group('name'))
455 logging.debug(f" Adding global section {in_global_section.name}")
456 slnobj.sections.append(in_global_section)
457 continue
458
459 m = _re_sln_global_end.search(line)
460 if m:
461 # Found the end of the 'global' part.
462 in_global = False
463 continue
464
465 # We're not in a specific part of the solution, so do high-level
466 # parsing. First, ignore root-level comments.
467 if not line or line[0] == '#':
468 continue
469
470 m = _re_sln_project_decl_start.search(line)
471 if m:
472 # Found the start of a project declaration.
473 try:
474 p = VSProject(
475 m.group('type'), m.group('name'), m.group('path'),
476 m.group('guid'))
477 except:
478 raise Exception(f"Error line {i}: unexpected project syntax.")
479 logging.debug(f" Adding project {p.name}")
480 slnobj.projects.append(p)
481 p._sln = slnobj
482
483 until = _re_sln_project_decl_end
484 continue
485
486 m = _re_sln_global_start.search(line)
487 if m:
488 # Reached the start of the 'global' part, where global sections
489 # are defined.
490 in_global = True
491 continue
492
493 # Ignore the rest (like visual studio version flags).
494 continue
495
496
497 class SolutionCache:
498 """ A class that contains a VS solution object, along with pre-indexed
499 lists of items. It's meant to be saved on disk.
500 """
501 VERSION = 3
502
503 def __init__(self, slnobj):
504 self.slnobj = slnobj
505 self.index = None
506
507 def build_cache(self):
508 self.index = {}
509 for proj in self.slnobj.projects:
510 if proj.is_folder:
511 continue
512 itemgroup = proj.defaultitemgroup()
513 if not itemgroup:
514 continue
515
516 item_cache = set()
517 self.index[proj.abspath] = item_cache
518
519 for item in itemgroup.get_source_items():
520 item_path = proj.get_abs_item_include(item).lower()
521 item_cache.add(item_path)
522
523 def save(self, path):
524 pathdir = os.path.dirname(path)
525 if not os.path.exists(pathdir):
526 os.makedirs(pathdir)
527 with open(path, 'wb') as fp:
528 pickle.dump(self, fp)
529
530 @staticmethod
531 def load_or_rebuild(slnpath, cachepath):
532 if cachepath:
533 res = _try_load_from_cache(slnpath, cachepath)
534 if res is not None:
535 return res
536
537 slnobj = parse_sln_file(slnpath)
538 cache = SolutionCache(slnobj)
539
540 if cachepath:
541 logger.debug(f"Regenerating cache: {cachepath}")
542 cache.build_cache()
543 cache.save(cachepath)
544
545 return (cache, False)
546
547
548 def _try_load_from_cache(slnpath, cachepath):
549 try:
550 sln_dt = os.path.getmtime(slnpath)
551 cache_dt = os.path.getmtime(cachepath)
552 except OSError:
553 logger.debug("Can't read solution or cache files.")
554 return None
555
556 # If the solution file is newer, bail out.
557 if sln_dt >= cache_dt:
558 logger.debug("Solution is newer than cache.")
559 return None
560
561 # Our cache is at least valid for the solution stuff. Some of our
562 # projects might be out of date, but at least there can't be any
563 # added or removed projects from the solution (otherwise the solution
564 # file would have been touched). Let's load the cache.
565 with open(cachepath, 'rb') as fp:
566 cache = pickle.load(fp)
567
568 # Check that the cache version is up-to-date with this code.
569 loaded_ver = getattr(cache, 'VERSION')
570 if loaded_ver != SolutionCache.VERSION:
571 logger.debug(f"Cache was saved with older format: {cachepath}")
572 return None
573
574 slnobj = cache.slnobj
575
576 # Check that none of the project files in the solution are newer
577 # than this cache.
578 proj_dts = []
579 for p in slnobj.projects:
580 if not p.is_folder:
581 try:
582 proj_dts.append(os.path.getmtime(p.abspath))
583 except OSError:
584 logger.debug(f"Found missing project: {p.abspath}")
585 return None
586
587 if all([cache_dt > pdt for pdt in proj_dts]):
588 logger.debug(f"Cache is up to date: {cachepath}")
589 return (cache, True)
590
591 logger.debug("Cache has outdated projects.")
592 return None