1# Distributed under the OSI-approved BSD 3-Clause License.  See accompanying
2# file Copyright.txt or https://cmake.org/licensing for details.
3
4import argparse
5import codecs
6import copy
7import logging
8import json
9import os
10
11from collections import OrderedDict
12from xml.dom.minidom import parse, parseString, Element
13
14
15class VSFlags:
16    """Flags corresponding to cmIDEFlagTable."""
17    UserValue = "UserValue"  # (1 << 0)
18    UserIgnored = "UserIgnored"  # (1 << 1)
19    UserRequired = "UserRequired"  # (1 << 2)
20    Continue = "Continue"  #(1 << 3)
21    SemicolonAppendable = "SemicolonAppendable"  # (1 << 4)
22    UserFollowing = "UserFollowing"  # (1 << 5)
23    CaseInsensitive = "CaseInsensitive"  # (1 << 6)
24    UserValueIgnored = [UserValue, UserIgnored]
25    UserValueRequired = [UserValue, UserRequired]
26
27
28def vsflags(*args):
29    """Combines the flags."""
30    values = []
31
32    for arg in args:
33        __append_list(values, arg)
34
35    return values
36
37
38def read_msbuild_xml(path, values=None):
39    """Reads the MS Build XML file at the path and returns its contents.
40
41    Keyword arguments:
42    values -- The map to append the contents to (default {})
43    """
44    if values is None:
45        values = {}
46
47    # Attempt to read the file contents
48    try:
49        document = parse(path)
50    except Exception as e:
51        logging.exception('Could not read MS Build XML file at %s', path)
52        return values
53
54    # Convert the XML to JSON format
55    logging.info('Processing MS Build XML file at %s', path)
56
57    # Get the rule node
58    rule = document.getElementsByTagName('Rule')[0]
59
60    rule_name = rule.attributes['Name'].value
61
62    logging.info('Found rules for %s', rule_name)
63
64    # Proprocess Argument values
65    __preprocess_arguments(rule)
66
67    # Get all the values
68    converted_values = []
69    __convert(rule, 'EnumProperty', converted_values, __convert_enum)
70    __convert(rule, 'BoolProperty', converted_values, __convert_bool)
71    __convert(rule, 'StringListProperty', converted_values,
72              __convert_string_list)
73    __convert(rule, 'StringProperty', converted_values, __convert_string)
74    __convert(rule, 'IntProperty', converted_values, __convert_string)
75
76    values[rule_name] = converted_values
77
78    return values
79
80
81def read_msbuild_json(path, values=None):
82    """Reads the MS Build JSON file at the path and returns its contents.
83
84    Keyword arguments:
85    values -- The list to append the contents to (default [])
86    """
87    if values is None:
88        values = []
89
90    if not os.path.exists(path):
91        logging.info('Could not find MS Build JSON file at %s', path)
92        return values
93
94    try:
95        values.extend(__read_json_file(path))
96    except Exception as e:
97        logging.exception('Could not read MS Build JSON file at %s', path)
98        return values
99
100    logging.info('Processing MS Build JSON file at %s', path)
101
102    return values
103
104def main():
105    """Script entrypoint."""
106    # Parse the arguments
107    parser = argparse.ArgumentParser(
108        description='Convert MSBuild XML to JSON format')
109
110    parser.add_argument(
111        '-t', '--toolchain', help='The name of the toolchain', required=True)
112    parser.add_argument(
113        '-o', '--output', help='The output directory', default='')
114    parser.add_argument(
115        '-r',
116        '--overwrite',
117        help='Whether previously output should be overwritten',
118        dest='overwrite',
119        action='store_true')
120    parser.set_defaults(overwrite=False)
121    parser.add_argument(
122        '-d',
123        '--debug',
124        help="Debug tool output",
125        action="store_const",
126        dest="loglevel",
127        const=logging.DEBUG,
128        default=logging.WARNING)
129    parser.add_argument(
130        '-v',
131        '--verbose',
132        help="Verbose output",
133        action="store_const",
134        dest="loglevel",
135        const=logging.INFO)
136    parser.add_argument('input', help='The input files', nargs='+')
137
138    args = parser.parse_args()
139
140    toolchain = args.toolchain
141
142    logging.basicConfig(level=args.loglevel)
143    logging.info('Creating %s toolchain files', toolchain)
144
145    values = {}
146
147    # Iterate through the inputs
148    for input in args.input:
149        input = __get_path(input)
150
151        read_msbuild_xml(input, values)
152
153    # Determine if the output directory needs to be created
154    output_dir = __get_path(args.output)
155
156    if not os.path.exists(output_dir):
157        os.mkdir(output_dir)
158        logging.info('Created output directory %s', output_dir)
159
160    for key, value in values.items():
161        output_path = __output_path(toolchain, key, output_dir)
162
163        if os.path.exists(output_path) and not args.overwrite:
164            logging.info('Comparing previous output to current')
165
166            __merge_json_values(value, read_msbuild_json(output_path))
167        else:
168            logging.info('Original output will be overwritten')
169
170        logging.info('Writing MS Build JSON file at %s', output_path)
171
172        __write_json_file(output_path, value)
173
174
175###########################################################################################
176# private joining functions
177def __merge_json_values(current, previous):
178    """Merges the values between the current and previous run of the script."""
179    for value in current:
180        name = value['name']
181
182        # Find the previous value
183        previous_value = __find_and_remove_value(previous, value)
184
185        if previous_value is not None:
186            flags = value['flags']
187            previous_flags = previous_value['flags']
188
189            if flags != previous_flags:
190                logging.warning(
191                    'Flags for %s are different. Using previous value.', name)
192
193                value['flags'] = previous_flags
194        else:
195            logging.warning('Value %s is a new value', name)
196
197    for value in previous:
198        name = value['name']
199        logging.warning(
200            'Value %s not present in current run. Appending value.', name)
201
202        current.append(value)
203
204
205def __find_and_remove_value(list, compare):
206    """Finds the value in the list that corresponds with the value of compare."""
207    # next throws if there are no matches
208    try:
209        found = next(value for value in list
210                     if value['name'] == compare['name'] and value['switch'] ==
211                     compare['switch'])
212    except:
213        return None
214
215    list.remove(found)
216
217    return found
218
219
220def __normalize_switch(switch, separator):
221    new = switch
222    if switch.startswith("/") or switch.startswith("-"):
223      new = switch[1:]
224    if new and separator:
225      new = new + separator
226    return new
227
228###########################################################################################
229# private xml functions
230def __convert(root, tag, values, func):
231    """Converts the tag type found in the root and converts them using the func
232    and appends them to the values.
233    """
234    elements = root.getElementsByTagName(tag)
235
236    for element in elements:
237        converted = func(element)
238
239        # Append to the list
240        __append_list(values, converted)
241
242
243def __convert_enum(node):
244    """Converts an EnumProperty node to JSON format."""
245    name = __get_attribute(node, 'Name')
246    logging.debug('Found EnumProperty named %s', name)
247
248    converted_values = []
249
250    for value in node.getElementsByTagName('EnumValue'):
251        converted = __convert_node(value)
252
253        converted['value'] = converted['name']
254        converted['name'] = name
255
256        # Modify flags when there is an argument child
257        __with_argument(value, converted)
258
259        converted_values.append(converted)
260
261    return converted_values
262
263
264def __convert_bool(node):
265    """Converts an BoolProperty node to JSON format."""
266    converted = __convert_node(node, default_value='true')
267
268    # Check for a switch for reversing the value
269    reverse_switch = __get_attribute(node, 'ReverseSwitch')
270
271    if reverse_switch:
272        __with_argument(node, converted)
273
274        converted_reverse = copy.deepcopy(converted)
275
276        converted_reverse['switch'] = reverse_switch
277        converted_reverse['value'] = 'false'
278
279        return [converted_reverse, converted]
280
281    # Modify flags when there is an argument child
282    __with_argument(node, converted)
283
284    return __check_for_flag(converted)
285
286
287def __convert_string_list(node):
288    """Converts a StringListProperty node to JSON format."""
289    converted = __convert_node(node)
290
291    # Determine flags for the string list
292    flags = vsflags(VSFlags.UserValue)
293
294    # Check for a separator to determine if it is semicolon appendable
295    # If not present assume the value should be ;
296    separator = __get_attribute(node, 'Separator', default_value=';')
297
298    if separator == ';':
299        flags = vsflags(flags, VSFlags.SemicolonAppendable)
300
301    converted['flags'] = flags
302
303    return __check_for_flag(converted)
304
305
306def __convert_string(node):
307    """Converts a StringProperty node to JSON format."""
308    converted = __convert_node(node, default_flags=vsflags(VSFlags.UserValue))
309
310    return __check_for_flag(converted)
311
312
313def __convert_node(node, default_value='', default_flags=vsflags()):
314    """Converts a XML node to a JSON equivalent."""
315    name = __get_attribute(node, 'Name')
316    logging.debug('Found %s named %s', node.tagName, name)
317
318    converted = {}
319    converted['name'] = name
320
321    switch = __get_attribute(node, 'Switch')
322    separator = __get_attribute(node, 'Separator')
323    converted['switch'] = __normalize_switch(switch, separator)
324
325    converted['comment'] = __get_attribute(node, 'DisplayName')
326    converted['value'] = default_value
327
328    # Check for the Flags attribute in case it was created during preprocessing
329    flags = __get_attribute(node, 'Flags')
330
331    if flags:
332        flags = flags.split(',')
333    else:
334        flags = default_flags
335
336    converted['flags'] = flags
337
338    return converted
339
340
341def __check_for_flag(value):
342    """Checks whether the value has a switch value.
343
344    If not then returns None as it should not be added.
345    """
346    if value['switch']:
347        return value
348    else:
349        logging.warning('Skipping %s which has no command line switch',
350                        value['name'])
351        return None
352
353
354def __with_argument(node, value):
355    """Modifies the flags in value if the node contains an Argument."""
356    arguments = node.getElementsByTagName('Argument')
357
358    if arguments:
359        logging.debug('Found argument within %s', value['name'])
360        value['flags'] = vsflags(VSFlags.UserValueIgnored, VSFlags.Continue)
361
362
363def __preprocess_arguments(root):
364    """Preprocesses occurrences of Argument within the root.
365
366    Argument XML values reference other values within the document by name. The
367    referenced value does not contain a switch. This function will add the
368    switch associated with the argument.
369    """
370    # Set the flags to require a value
371    flags = ','.join(vsflags(VSFlags.UserValueRequired))
372
373    # Search through the arguments
374    arguments = root.getElementsByTagName('Argument')
375
376    for argument in arguments:
377        reference = __get_attribute(argument, 'Property')
378        found = None
379
380        # Look for the argument within the root's children
381        for child in root.childNodes:
382            # Ignore Text nodes
383            if isinstance(child, Element):
384                name = __get_attribute(child, 'Name')
385
386                if name == reference:
387                    found = child
388                    break
389
390        if found is not None:
391            logging.info('Found property named %s', reference)
392            # Get the associated switch
393            switch = __get_attribute(argument.parentNode, 'Switch')
394
395            # See if there is already a switch associated with the element.
396            if __get_attribute(found, 'Switch'):
397                logging.debug('Copying node %s', reference)
398                clone = found.cloneNode(True)
399                root.insertBefore(clone, found)
400                found = clone
401
402            found.setAttribute('Switch', switch)
403            found.setAttribute('Flags', flags)
404        else:
405            logging.warning('Could not find property named %s', reference)
406
407
408def __get_attribute(node, name, default_value=''):
409    """Retrieves the attribute of the given name from the node.
410
411    If not present then the default_value is used.
412    """
413    if node.hasAttribute(name):
414        return node.attributes[name].value.strip()
415    else:
416        return default_value
417
418
419###########################################################################################
420# private path functions
421def __get_path(path):
422    """Gets the path to the file."""
423    if not os.path.isabs(path):
424        path = os.path.join(os.getcwd(), path)
425
426    return os.path.normpath(path)
427
428
429def __output_path(toolchain, rule, output_dir):
430    """Gets the output path for a file given the toolchain, rule and output_dir"""
431    filename = '%s_%s.json' % (toolchain, rule)
432    return os.path.join(output_dir, filename)
433
434
435###########################################################################################
436# private JSON file functions
437def __read_json_file(path):
438    """Reads a JSON file at the path."""
439    with open(path, 'r') as f:
440        return json.load(f)
441
442
443def __write_json_file(path, values):
444    """Writes a JSON file at the path with the values provided."""
445    # Sort the keys to ensure ordering
446    sort_order = ['name', 'switch', 'comment', 'value', 'flags']
447    sorted_values = [
448        OrderedDict(
449            sorted(
450                value.items(), key=lambda value: sort_order.index(value[0])))
451        for value in values
452    ]
453
454    with open(path, 'w') as f:
455        json.dump(sorted_values, f, indent=2, separators=(',', ': '))
456        f.write("\n")
457
458###########################################################################################
459# private list helpers
460def __append_list(append_to, value):
461    """Appends the value to the list."""
462    if value is not None:
463        if isinstance(value, list):
464            append_to.extend(value)
465        else:
466            append_to.append(value)
467
468###########################################################################################
469# main entry point
470if __name__ == "__main__":
471    main()
472