Mercurial > vim-crosoft
annotate scripts/vsutil.py @ 3:949c4f536f26
Add `None` file items to source solution files.
author | Ludovic Chabant <ludovic@chabant.com> |
---|---|
date | Thu, 24 Oct 2019 11:14:39 -0700 |
parents | 5d2c0db51914 |
children | ae0fb567f459 |
rev | line source |
---|---|
0 | 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, | |
3
949c4f536f26
Add `None` file items to source solution files.
Ludovic Chabant <ludovic@chabant.com>
parents:
0
diff
changeset
|
31 ITEM_TYPE_CS_SRC, ITEM_TYPE_NONE) |
0 | 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): | |
3
949c4f536f26
Add `None` file items to source solution files.
Ludovic Chabant <ludovic@chabant.com>
parents:
0
diff
changeset
|
139 return self.get_items_of_types(ITEM_TYPE_SOURCE_FILES) |
949c4f536f26
Add `None` file items to source solution files.
Ludovic Chabant <ludovic@chabant.com>
parents:
0
diff
changeset
|
140 |
949c4f536f26
Add `None` file items to source solution files.
Ludovic Chabant <ludovic@chabant.com>
parents:
0
diff
changeset
|
141 def get_items_of_types(self, *itemtypes): |
949c4f536f26
Add `None` file items to source solution files.
Ludovic Chabant <ludovic@chabant.com>
parents:
0
diff
changeset
|
142 typeset = set(*itemtypes) |
0 | 143 for i in self.items: |
3
949c4f536f26
Add `None` file items to source solution files.
Ludovic Chabant <ludovic@chabant.com>
parents:
0
diff
changeset
|
144 if i.itemtype in typeset: |
0 | 145 yield i |
146 | |
147 def _collapse_child(self, child, env): | |
148 self.items += [i.resolve(env) for i in child.items] | |
149 | |
150 | |
151 class VSProjectProperty: | |
152 """ A VS project property, like an include path or compiler flag. """ | |
153 def __init__(self, name, value): | |
154 self.name = name | |
155 self.value = value | |
156 | |
157 def resolve(self, env): | |
158 c = VSProjectProperty(self.name, _resolve_value(self.value, env)) | |
159 return c | |
160 | |
161 def __str__(self): | |
162 return "%s=%s" % (self.name, self.value) | |
163 | |
164 | |
165 class VSProjectPropertyGroup(VSBaseGroup): | |
166 """ A VS project property group, such as compiler macros or flags. """ | |
167 def __init__(self, label): | |
168 super().__init__(label) | |
169 self.properties = [] | |
170 | |
171 def get(self, propname): | |
172 try: | |
173 return self[propname] | |
174 except IndexError: | |
175 return None | |
176 | |
177 def __getitem__(self, propname): | |
178 for p in self.properties: | |
179 if p.name == propname: | |
180 return p.value | |
181 raise IndexError() | |
182 | |
183 def _collapse_child(self, child, env): | |
184 self.properties += [p.resolve(env) for p in child.properties] | |
185 | |
186 | |
187 class VSProject: | |
188 """ A VS project. """ | |
189 def __init__(self, projtype, name, path, guid): | |
190 self.type = projtype | |
191 self.name = name | |
192 self.path = path | |
193 self.guid = guid | |
194 self._itemgroups = None | |
195 self._propgroups = None | |
196 self._sln = None | |
197 | |
198 @property | |
199 def is_folder(self): | |
200 """ Returns whether this project is actually just a solution | |
201 folder, used as a container for other projects. | |
202 """ | |
203 return self.type == PROJ_TYPE_FOLDER | |
204 | |
205 @property | |
206 def abspath(self): | |
207 abspath = self.path | |
208 if self._sln and self._sln.path: | |
209 abspath = os.path.join(self._sln.dirpath, self.path) | |
210 return abspath | |
211 | |
212 @property | |
213 def absdirpath(self): | |
214 return os.path.dirname(self.abspath) | |
215 | |
216 @property | |
217 def itemgroups(self): | |
218 self._ensure_loaded() | |
219 return self._itemgroups.values() | |
220 | |
221 @property | |
222 def propertygroups(self): | |
223 self._ensure_loaded() | |
224 return self._propgroups.values() | |
225 | |
226 def itemgroup(self, label, resolved_with=None): | |
227 self._ensure_loaded() | |
228 ig = self._itemgroups.get(label) | |
229 if resolved_with is not None and ig is not None: | |
230 logger.debug("Resolving item group '%s'." % ig.label) | |
231 ig = ig.resolve(resolved_with) | |
232 return ig | |
233 | |
234 def defaultitemgroup(self, resolved_with=None): | |
235 return self.itemgroup(None, resolved_with=resolved_with) | |
236 | |
237 def propertygroup(self, label, resolved_with=None): | |
238 self._ensure_loaded() | |
239 pg = self._propgroups.get(label) | |
240 if resolved_with is not None and pg is not None: | |
241 logger.debug("Resolving property group '%s'." % pg.label) | |
242 pg = pg.resolve(resolved_with) | |
243 return pg | |
244 | |
245 def defaultpropertygroup(self, resolved_with=None): | |
246 return self.propertygroup(None, resolved_with=resolved_with) | |
247 | |
248 def get_abs_item_include(self, item): | |
249 return os.path.abspath(os.path.join(self.absdirpath, item.include)) | |
250 | |
251 def resolve(self, env): | |
252 self._ensure_loaded() | |
253 | |
254 propgroups = list(self._propgroups) | |
255 itemgroups = list(self._itemgroups) | |
256 self._propgroups[:] = [] | |
257 self._itemgroups[:] = [] | |
258 | |
259 for pg in propgroups: | |
260 rpg = pg.resolve(env) | |
261 self._propgroups.append(rpg) | |
262 | |
263 for ig in itemgroups: | |
264 rig = ig.resolve(env) | |
265 self._itemgroups.append(rig) | |
266 | |
267 def _ensure_loaded(self): | |
268 if self._itemgroups is None or self._propgroups is None: | |
269 self._load() | |
270 | |
271 def _load(self): | |
272 if not self.path: | |
273 raise Exception("The current project has no path.") | |
274 if self.is_folder: | |
275 logger.debug(f"Skipping folder project {self.name}") | |
276 self._itemgroups = {} | |
277 self._propgroups = {} | |
278 return | |
279 | |
280 ns = {'ms': 'http://schemas.microsoft.com/developer/msbuild/2003'} | |
281 | |
282 abspath = self.abspath | |
283 logger.debug(f"Loading project {self.name} ({self.path}) from: {abspath}") | |
284 tree = etree.parse(abspath) | |
285 root = tree.getroot() | |
286 if _strip_ns(root.tag) != 'Project': | |
287 raise Exception(f"Expected root node 'Project', got '{root.tag}'") | |
288 | |
289 self._itemgroups = {} | |
290 for itemgroupnode in root.iterfind('ms:ItemGroup', ns): | |
291 label = itemgroupnode.attrib.get('Label') | |
292 itemgroup = self._itemgroups.get(label) | |
293 if not itemgroup: | |
294 itemgroup = VSProjectItemGroup(label) | |
295 self._itemgroups[label] = itemgroup | |
296 logger.debug(f"Adding itemgroup '{label}'") | |
297 | |
298 condition = itemgroupnode.attrib.get('Condition') | |
299 if condition: | |
300 itemgroup = itemgroup.get_or_create_conditional(condition) | |
301 | |
302 for itemnode in itemgroupnode: | |
303 incval = itemnode.attrib.get('Include') | |
304 item = VSProjectItem(incval, _strip_ns(itemnode.tag)) | |
305 itemgroup.items.append(item) | |
306 for metanode in itemnode: | |
307 item.metadata[_strip_ns(metanode.tag)] = metanode.text | |
308 | |
309 self._propgroups = {} | |
310 for propgroupnode in root.iterfind('ms:PropertyGroup', ns): | |
311 label = propgroupnode.attrib.get('Label') | |
312 propgroup = self._propgroups.get(label) | |
313 if not propgroup: | |
314 propgroup = VSProjectPropertyGroup(label) | |
315 self._propgroups[label] = propgroup | |
316 logger.debug(f"Adding propertygroup '{label}'") | |
317 | |
318 condition = propgroupnode.attrib.get('Condition') | |
319 if condition: | |
320 propgroup = propgroup.get_or_create_conditional(condition) | |
321 | |
322 for propnode in propgroupnode: | |
323 propgroup.properties.append(VSProjectProperty( | |
324 _strip_ns(propnode.tag), | |
325 propnode.text)) | |
326 | |
327 | |
328 class MissingVSProjectError(Exception): | |
329 pass | |
330 | |
331 | |
332 class VSGlobalSectionEntry: | |
333 """ An entry in a VS solution's global section. """ | |
334 def __init__(self, name, value): | |
335 self.name = name | |
336 self.value = value | |
337 | |
338 | |
339 class VSGlobalSection: | |
340 """ A global section in a VS solution. """ | |
341 def __init__(self, name): | |
342 self.name = name | |
343 self.entries = [] | |
344 | |
345 | |
346 class VSSolution: | |
347 """ A VS solution. """ | |
348 def __init__(self, path=None): | |
349 self.path = path | |
350 self.projects = [] | |
351 self.sections = [] | |
352 | |
353 @property | |
354 def dirpath(self): | |
355 return os.path.dirname(self.path) | |
356 | |
357 def find_project_by_name(self, name, missing_ok=True): | |
358 for p in self.projects: | |
359 if p.name == name: | |
360 return p | |
361 if missing_ok: | |
362 return None | |
363 return MissingVSProjectError(f"Can't find project with name: {name}") | |
364 | |
365 def find_project_by_path(self, path, missing_ok=True): | |
366 for p in self.projects: | |
367 if p.abspath == path: | |
368 return p | |
369 if missing_ok: | |
370 return None | |
371 raise MissingVSProjectError(f"Can't find project with path: {path}") | |
372 | |
373 def find_project_by_guid(self, guid, missing_ok=True): | |
374 for p in self.projects: | |
375 if p.guid == guid: | |
376 return p | |
377 if missing_ok: | |
378 return None | |
379 raise MissingVSProjectError(f"Can't find project for guid: {guid}") | |
380 | |
381 def globalsection(self, name): | |
382 for sec in self.sections: | |
383 if sec.name == name: | |
384 return sec | |
385 return None | |
386 | |
387 def find_project_configuration(self, proj_guid, sln_config): | |
388 configs = self.globalsection('ProjectConfigurationPlatforms') | |
389 if not configs: | |
390 return None | |
391 | |
392 entry_name = '{%s}.%s.Build.0' % (proj_guid, sln_config) | |
393 for entry in configs.entries: | |
394 if entry.name == entry_name: | |
395 return entry.value | |
396 return None | |
397 | |
398 | |
399 _re_sln_project_decl_start = re.compile( | |
400 r'^Project\("\{(?P<type>[A-Z0-9\-]+)\}"\) \= ' | |
401 r'"(?P<name>[^"]+)", "(?P<path>[^"]+)", "\{(?P<guid>[A-Z0-9\-]+)\}"$') | |
402 _re_sln_project_decl_end = re.compile( | |
403 r'^EndProject$') | |
404 | |
405 _re_sln_global_start = re.compile(r'^Global$') | |
406 _re_sln_global_end = re.compile(r'^EndGlobal$') | |
407 _re_sln_global_section_start = re.compile( | |
408 r'^\s*GlobalSection\((?P<name>\w+)\) \= (?P<step>\w+)$') | |
409 _re_sln_global_section_end = re.compile(r'^\s*EndGlobalSection$') | |
410 | |
411 | |
412 def parse_sln_file(slnpath): | |
413 """ Parses a solution file, returns a solution object. | |
414 The projects are not loaded (they will be lazily loaded upon | |
415 first access to their items/properties/etc.). | |
416 """ | |
417 logging.debug(f"Reading {slnpath}") | |
418 slnobj = VSSolution(slnpath) | |
419 with open(slnpath, 'r') as fp: | |
420 lines = fp.readlines() | |
421 _parse_sln_file_text(slnobj, lines) | |
422 return slnobj | |
423 | |
424 | |
425 def _parse_sln_file_text(slnobj, lines): | |
426 until = None | |
427 in_global = False | |
428 in_global_section = None | |
429 | |
430 for i, line in enumerate(lines): | |
431 if until: | |
432 # We need to parse something until a given token, so let's | |
433 # do that and ignore everything else. | |
434 m = until.search(line) | |
435 if m: | |
436 until = None | |
437 continue | |
438 | |
439 if in_global: | |
440 # We're in the 'global' part of the solution. It should contain | |
441 # a bunch of 'global sections' that we need to parse individually. | |
442 if in_global_section: | |
443 # Keep parsing the current section until we reach the end. | |
444 m = _re_sln_global_section_end.search(line) | |
445 if m: | |
446 in_global_section = None | |
447 continue | |
448 | |
449 ename, evalue = line.strip().split('=') | |
450 in_global_section.entries.append(VSGlobalSectionEntry( | |
451 ename.strip(), | |
452 evalue.strip())) | |
453 continue | |
454 | |
455 m = _re_sln_global_section_start.search(line) | |
456 if m: | |
457 # Found the start of a new section. | |
458 in_global_section = VSGlobalSection(m.group('name')) | |
459 logging.debug(f" Adding global section {in_global_section.name}") | |
460 slnobj.sections.append(in_global_section) | |
461 continue | |
462 | |
463 m = _re_sln_global_end.search(line) | |
464 if m: | |
465 # Found the end of the 'global' part. | |
466 in_global = False | |
467 continue | |
468 | |
469 # We're not in a specific part of the solution, so do high-level | |
470 # parsing. First, ignore root-level comments. | |
471 if not line or line[0] == '#': | |
472 continue | |
473 | |
474 m = _re_sln_project_decl_start.search(line) | |
475 if m: | |
476 # Found the start of a project declaration. | |
477 try: | |
478 p = VSProject( | |
479 m.group('type'), m.group('name'), m.group('path'), | |
480 m.group('guid')) | |
481 except: | |
482 raise Exception(f"Error line {i}: unexpected project syntax.") | |
483 logging.debug(f" Adding project {p.name}") | |
484 slnobj.projects.append(p) | |
485 p._sln = slnobj | |
486 | |
487 until = _re_sln_project_decl_end | |
488 continue | |
489 | |
490 m = _re_sln_global_start.search(line) | |
491 if m: | |
492 # Reached the start of the 'global' part, where global sections | |
493 # are defined. | |
494 in_global = True | |
495 continue | |
496 | |
497 # Ignore the rest (like visual studio version flags). | |
498 continue | |
499 | |
500 | |
501 class SolutionCache: | |
502 """ A class that contains a VS solution object, along with pre-indexed | |
503 lists of items. It's meant to be saved on disk. | |
504 """ | |
505 VERSION = 3 | |
506 | |
507 def __init__(self, slnobj): | |
508 self.slnobj = slnobj | |
509 self.index = None | |
510 | |
511 def build_cache(self): | |
512 self.index = {} | |
513 for proj in self.slnobj.projects: | |
514 if proj.is_folder: | |
515 continue | |
516 itemgroup = proj.defaultitemgroup() | |
517 if not itemgroup: | |
518 continue | |
519 | |
520 item_cache = set() | |
521 self.index[proj.abspath] = item_cache | |
522 | |
523 for item in itemgroup.get_source_items(): | |
524 item_path = proj.get_abs_item_include(item).lower() | |
525 item_cache.add(item_path) | |
526 | |
527 def save(self, path): | |
528 pathdir = os.path.dirname(path) | |
529 if not os.path.exists(pathdir): | |
530 os.makedirs(pathdir) | |
531 with open(path, 'wb') as fp: | |
532 pickle.dump(self, fp) | |
533 | |
534 @staticmethod | |
535 def load_or_rebuild(slnpath, cachepath): | |
536 if cachepath: | |
537 res = _try_load_from_cache(slnpath, cachepath) | |
538 if res is not None: | |
539 return res | |
540 | |
541 slnobj = parse_sln_file(slnpath) | |
542 cache = SolutionCache(slnobj) | |
543 | |
544 if cachepath: | |
545 logger.debug(f"Regenerating cache: {cachepath}") | |
546 cache.build_cache() | |
547 cache.save(cachepath) | |
548 | |
549 return (cache, False) | |
550 | |
551 | |
552 def _try_load_from_cache(slnpath, cachepath): | |
553 try: | |
554 sln_dt = os.path.getmtime(slnpath) | |
555 cache_dt = os.path.getmtime(cachepath) | |
556 except OSError: | |
557 logger.debug("Can't read solution or cache files.") | |
558 return None | |
559 | |
560 # If the solution file is newer, bail out. | |
561 if sln_dt >= cache_dt: | |
562 logger.debug("Solution is newer than cache.") | |
563 return None | |
564 | |
565 # Our cache is at least valid for the solution stuff. Some of our | |
566 # projects might be out of date, but at least there can't be any | |
567 # added or removed projects from the solution (otherwise the solution | |
568 # file would have been touched). Let's load the cache. | |
569 with open(cachepath, 'rb') as fp: | |
570 cache = pickle.load(fp) | |
571 | |
572 # Check that the cache version is up-to-date with this code. | |
573 loaded_ver = getattr(cache, 'VERSION') | |
574 if loaded_ver != SolutionCache.VERSION: | |
575 logger.debug(f"Cache was saved with older format: {cachepath}") | |
576 return None | |
577 | |
578 slnobj = cache.slnobj | |
579 | |
580 # Check that none of the project files in the solution are newer | |
581 # than this cache. | |
582 proj_dts = [] | |
583 for p in slnobj.projects: | |
584 if not p.is_folder: | |
585 try: | |
586 proj_dts.append(os.path.getmtime(p.abspath)) | |
587 except OSError: | |
588 logger.debug(f"Found missing project: {p.abspath}") | |
589 return None | |
590 | |
591 if all([cache_dt > pdt for pdt in proj_dts]): | |
592 logger.debug(f"Cache is up to date: {cachepath}") | |
593 return (cache, True) | |
594 | |
595 logger.debug("Cache has outdated projects.") | |
596 return None |