Mercurial > vim-crosoft
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 |