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