changeset 10:f444739dd8af

Improvements to YCM dynamic flags. - Fallback to a "companion" item (e.g. header/source) or a nearby item when no flags are found for an item. - Finding a "companion" is also exposed as a standalone script. - Ability to pass extra clang flags, including some from a special file found in the .vimcrosoft directory. - Add support for PCH and other forced-include files. - Add options for short/long args, or forcing forward slashes. - Debugging/troubleshooting options, including dumping a batch file and response file to run clang directly, and the ability to auto-load a solution's last known environment when running in command line.
author Ludovic Chabant <ludovic@chabant.com>
date Thu, 24 Sep 2020 23:02:16 -0700
parents 4ba6df1b2f97
children 096e80c13781
files .hgignore autoload/vimcrosoft/youcompleteme.vim scripts/find_companion.py scripts/vshelpers.py scripts/ycm_extra_conf.py
diffstat 5 files changed, 402 insertions(+), 41 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Thu Sep 24 23:02:16 2020 -0700
@@ -0,0 +1,3 @@
+scripts/clang_ycm_args.rsp
+scripts/clang_ycm_invoke.cmd
+scripts/clang_ycm_invoke.log
--- a/autoload/vimcrosoft/youcompleteme.vim	Thu Sep 24 22:57:50 2020 -0700
+++ b/autoload/vimcrosoft/youcompleteme.vim	Thu Sep 24 23:02:16 2020 -0700
@@ -1,3 +1,5 @@
+
+let g:vimcrosoft_extra_clang_args = get(g:, 'vimcrosoft_extra_clang_args', [])
 
 function! vimcrosoft#youcompleteme#init() abort
 endfunction
@@ -8,7 +10,8 @@
                 \'g:vimcrosoft_current_sln',
                 \'g:vimcrosoft_current_sln_cache',
                 \'g:vimcrosoft_current_config',
-                \'g:vimcrosoft_current_platform'
+                \'g:vimcrosoft_current_platform',
+				\'g:vimcrosoft_extra_clang_args'
                 \]
 endfunction
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/find_companion.py	Thu Sep 24 23:02:16 2020 -0700
@@ -0,0 +1,88 @@
+import argparse
+import logging
+import os.path
+import sys
+
+
+if True: # 'vim' in sys.modules:
+    sys.path.append(os.path.dirname(__file__))
+
+
+from logutil import setup_logging
+from vshelpers import load_vimcrosoft_auto_env, find_vimcrosoft_slncache, find_item_project
+from vsutil import SolutionCache, ITEM_TYPE_CPP_SRC, ITEM_TYPE_CPP_HDR
+
+
+logger = logging.getLogger(__name__)
+
+
+def _find_companion_item(solution, item_path, companion_name=None, companion_type=None, slncache=None):
+    # Try to guess the default companion file if needed.
+    if companion_name == None or companion_type == None:
+        primary_name, primary_ext = os.path.splitext(item_path)
+        primary_name = os.path.basename(primary_name)
+        if primary_ext == '.cpp':
+            companion_name = primary_name + '.h'
+            companion_type = ITEM_TYPE_CPP_HDR
+        elif primary_ext == '.h':
+            companion_name = primary_name + '.cpp'
+            companion_type = ITEM_TYPE_CPP_SRC
+        else:
+            raise Exception("Can't guess the companion file for: %s" % item_path)
+
+    # Find the primary file in the solution.
+    cache, proj = find_item_project(item_path, solution, slncache)
+    logger.debug("Found project %s: %s" % (proj.name, proj.abspath))
+
+    # Look for the companion file in that project:
+    candidates = []
+    dfgroup = proj.defaultitemgroup()
+    for cur_item in dfgroup.get_items_of_type(companion_type):
+        cur_item_name = os.path.basename(cur_item.include)
+        if cur_item_name == companion_name:
+            cur_item_path = proj.get_abs_item_include(cur_item)
+            candidates.append((cur_item, _get_companion_score(cur_item_path, item_path)))
+    candidates = sorted(candidates, key=lambda i: i[1], reverse=True)
+    logger.debug("Found candidates: %s" % [(c[0].include, c[1]) for c in candidates])
+    if candidates:
+        return proj.get_abs_item_include(candidates[0][0])
+    return None
+
+
+def _get_companion_score(item_path, ref_path):
+    for i, c in enumerate(zip(item_path, ref_path)):
+        if c[0] != c[1]:
+            return i
+    return min(len(item_path, ref_path))
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument('solution',
+                        help="The solution file")
+    parser.add_argument('filename',
+                        help="The filename for which to get the companion")
+    parser.add_argument('--no-auto-env',
+                        action='store_true',
+                        help="Don't read configuration information from Vimcrosoft cache")
+    parser.add_argument('-c', '--cache',
+                        help="The solution cache to use")
+    parser.add_argument('-v', '--verbose',
+                        action='store_true',
+                        help="Show debugging information")
+    args = parser.parse_args()
+    setup_logging(args.verbose)
+
+    build_env = {}
+    slncache = args.cache
+    if not args.no_auto_env:
+        load_vimcrosoft_auto_env(args.solution, build_env)
+        if not slncache:
+            slncache = find_vimcrosoft_slncache(args.solution)
+
+    companion = _find_companion_item(args.solution, args.filename,
+                                     slncache=slncache)
+    print(companion)
+
+if __name__ == '__main__':
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/vshelpers.py	Thu Sep 24 23:02:16 2020 -0700
@@ -0,0 +1,81 @@
+import os.path
+import logging
+from vsutil import SolutionCache
+
+
+logger = logging.getLogger(__name__)
+
+config_format = 2  # Keep in sync with vimcrosoft.vim
+
+
+def load_vimcrosoft_auto_env(sln_file, build_env):
+    cache_file = os.path.join(os.path.dirname(sln_file), '.vimcrosoft', 'config.txt')
+    if not os.path.isfile(cache_file):
+        logger.warn("No Vimcrosoft cache file found, you will probably have to specify "
+                    "all configuration values in the command line!")
+        return
+
+    with open(cache_file, 'r', encoding='utf8') as fp:
+        lines = fp.readlines()
+
+    tokens = [
+            '_VimcrosoftConfigFormat',
+            '_VimcrosoftCurrentSolution',
+            'Configuration',
+            'Platform',
+            '_VimcrosoftActiveProject']
+    for i, line in enumerate(lines):
+        token = tokens[i]
+        build_env[token] = line.strip()
+
+    try:
+        found_conffmt = int(build_env['_VimcrosoftConfigFormat'].split('=')[-1])
+    except:
+        raise Exception("Invalid Vimcrosoft cache file found.")
+    if found_conffmt != config_format:
+        raise Exception("Incompatible Vimcrosoft cache file found. "
+                        "Expected format %d but got %d" % (config_format, found_conffmt))
+
+    logger.info("Loaded cached configuration|platform: %s|%s" %
+                (build_env['Configuration'], build_env['Platform']))
+
+
+def find_vimcrosoft_slncache(sln_file):
+    return os.path.join(os.path.dirname(sln_file), '.vimcrosoft', 'slncache.bin')
+
+
+def get_solution_cache(solution, slncache=None):
+    if not solution:
+        raise Exception(
+            "No solution path was provided!")
+
+    cache, loaded = SolutionCache.load_or_rebuild(solution, slncache)
+    if not loaded:
+        cache.build_cache()
+        if slncache:
+            logger.debug(f"Saving solution cache: {slncache}")
+            cache.save(slncache)
+
+    return cache
+
+
+def find_item_project(item_path, solution, slncache=None):
+    # Load the solution
+    cache = get_solution_cache(solution, slncache)
+
+    # Find the primary file in the solution.
+    item_path_lower = item_path.lower()
+    projpath = None
+    for pp, pi in cache.index.items():
+        if item_path_lower in pi:
+            projpath = pp
+            break
+    else:
+        raise Exception("File doesn't belong to the solution: %s" % item_path)
+
+    # Find the project that our file belongs to.
+    proj = cache.slnobj.find_project_by_path(projpath)
+    if not proj:
+        raise Exception("Can't find project in solution: %s" % projpath)
+
+    return cache, proj
--- a/scripts/ycm_extra_conf.py	Thu Sep 24 22:57:50 2020 -0700
+++ b/scripts/ycm_extra_conf.py	Thu Sep 24 23:02:16 2020 -0700
@@ -1,6 +1,7 @@
 import argparse
 import logging
 import os.path
+import shutil
 import sys
 
 
@@ -9,36 +10,88 @@
 
 
 from logutil import setup_logging
-from vsutil import SolutionCache
+from vshelpers import load_vimcrosoft_auto_env, find_vimcrosoft_slncache, find_item_project
+from vsutil import SolutionCache, ITEM_TYPE_CPP_SRC, ITEM_TYPE_CPP_HDR
 
 
 logger = logging.getLogger(__name__)
 
 
-def _build_cflags(filename, solution, buildenv=None, slncache=None):
-    # Load the solution.
-    if not solution:
-        raise Exception(
-            "No solution path was provided in the client data!")
+def _split_paths_property(val):
+    if val:
+        return val.strip(';').split(';')
+    return []
+
 
-    cache, loaded = SolutionCache.load_or_rebuild(solution, slncache)
-    if not loaded:
-        cache.build_cache()
+def _split_paths_property_and_make_absolute(basedir, val):
+    return [os.path.abspath(os.path.join(basedir, p))
+            for p in _split_paths_property(val)]
 
-    # Find the current file in the solution.
+
+def _get_item_specific_flags(projdir, clcompileitems, filename):
+    logger.debug("Looking through %d items to find: %s" % (len(clcompileitems), filename))
     filename_lower = filename.lower()
-    projpath = None
-    for pp, pi in cache.index.items():
-        if filename_lower in pi:
-            projpath = pp
-            break
-    else:
-        raise Exception("File doesn't belong to the solution: %s" % filename)
+    for item in clcompileitems:
+        absiteminclude = os.path.normpath(os.path.join(projdir, item.include))
+        if absiteminclude.lower() != filename_lower:
+            continue
+        logger.debug("Found file-specific flags for: %s" % filename)
+        incpaths = _split_paths_property_and_make_absolute(
+                projdir, item.metadata.get('AdditionalIncludeDirectories'))
+        incfiles = _split_paths_property_and_make_absolute(
+                projdir, item.metadata.get('ForcedIncludeFiles'))
+        return (incpaths, incfiles)
+    return ([], [])
+
+
+def _find_any_possible_item_specific_flags(
+        solution, slncache, projdir, clcompileitems, filename, incpaths, incfiles, *,
+        search_neighbours=True):
+    # First, find any actual flags for this item.
+    item_incpaths, item_incfiles = _get_item_specific_flags(projdir, clcompileitems, filename)
+    if item_incpaths or item_incfiles:
+        incpaths += item_incpaths
+        incfiles += item_incfiles
+        return True
 
-    # Find the project that our file belongs to.
-    proj = cache.slnobj.find_project_by_path(projpath)
-    if not proj:
-        raise Exception("Can't find project in solution: %s" % projpath)
+    logger.debug("Requested item didn't have any flags, looking for companion item")
+    from find_companion import _find_companion_item
+    companion_item = _find_companion_item(solution, filename, slncache=slncache)
+    if companion_item:
+        logger.debug("Found companion item: %s" % companion_item)
+        item_incpaths, item_incfiles = _get_item_specific_flags(projdir, clcompileitems, companion_item)
+        incpaths += item_incpaths
+        incfiles += item_incfiles
+        return True
+
+    #logger.debug("No companion item found, see if we can find flags for a neighbour")
+    #os.path.dirname(filename)
+    return False
+
+
+def _expand_extra_flags_with_solution_extra_flags(solution, extraflags):
+    argfilename = os.path.join(
+            os.path.dirname(solution),
+            '.vimcrosoft',
+            (os.path.basename(solution) + '.flags'))
+    try:
+        with open(argfilename, 'r', encoding='utf8') as fp:
+            lines = fp.readlines()
+            logger.debug("Read extra flags from: %s (%d lines)" % (argfilename, len(lines)))
+    except OSError:
+        return extraflags
+
+    extraflags = extraflags or []
+    for line in lines:
+        if not line.startswith('#'):
+            extraflags.append(line.strip())
+    return extraflags
+
+
+def _build_cflags(filename, solution, buildenv=None, slncache=None, extraflags=None,
+                  force_fwd_slashes=True, short_flags=True):
+    # Find the current file in the solution.
+    cache, proj = find_item_project(filename, solution, slncache)
     logger.debug("Found project %s: %s" % (proj.name, proj.abspath))
 
     # Get the provided config/platform combo, which represent a solution
@@ -73,6 +126,7 @@
     # Let's prepare a list of standard stuff for C++.
     preproc = []
     incpaths = []
+    incfiles = []
     projdir = os.path.dirname(proj.abspath)
 
     if cfgtype == 'Makefile':
@@ -83,11 +137,28 @@
         defaultpropgroup = proj.defaultpropertygroup(proj_buildenv)
 
         nmake_preproc = defaultpropgroup.get('NMakePreprocessorDefinitions')
-        preproc += nmake_preproc.strip(';').split(';')
+        preproc += _split_paths_property(nmake_preproc)
+
+        vs_incpaths = defaultpropgroup.get('IncludePath')
+        if vs_incpaths:
+            incpaths += _split_paths_property_and_make_absolute(
+                    projdir, vs_incpaths)
 
         nmake_incpaths = defaultpropgroup.get('NMakeIncludeSearchPath')
-        incpaths += [os.path.abspath(os.path.join(projdir, p))
-                     for p in nmake_incpaths.strip(';').split(';')]
+        if nmake_incpaths:
+            incpaths += _split_paths_property_and_make_absolute(
+                    projdir, nmake_incpaths)
+
+        nmake_forcedincs = defaultpropgroup.get('NMakeForcedIncludes')
+        if nmake_forcedincs:
+            incfiles += _split_paths_property_and_make_absolute(
+                    projdir, nmake_forcedincs)
+
+        # Find stuff specific to the file we are working on.
+        defaultitemgroup = proj.defaultitemgroup(proj_buildenv)
+        clcompileitems = list(defaultitemgroup.get_items_of_types([ITEM_TYPE_CPP_SRC, ITEM_TYPE_CPP_HDR]))
+        _find_any_possible_item_specific_flags(
+                solution, slncache, projdir, clcompileitems, filename, incpaths, incfiles)
 
     else:
         # We should definitely support standard VC++ projects here but
@@ -95,21 +166,66 @@
         raise Exception("Don't know how to handle configuration type: %s" %
                         cfgtype)
 
+    # We need to duplicate all the forced-included files because they could
+    # have a VS-generated PCH file next to them. Clang then tries to pick it
+    # up and complains that it doesn't use a valid format... :(
+    incfiles = _cache_pch_files(incfiles)
+
     # Build the clang/YCM flags with what we found.
     flags = ['-x', 'c++']  # TODO: check language type from project file.
 
     for symbol in preproc:
         flags.append('-D%s' % symbol)
     for path in incpaths:
+        flagname = '-I'
         if path.startswith("C:\\Program Files"):
-            flags.append('-isystem')
+            flagname = '-isystem'
+        flagval = path.replace('\\', '/') if force_fwd_slashes else path
+        if short_flags:
+            flags.append('%s%s' % (flagname, flagval))
         else:
-            flags.append('-I')
-        flags.append(path)
+            flags.append(flagname)
+            flags.append(flagval)
+    # For some reason it seems VS applies those in last-to-first order.
+    incfiles = list(reversed(incfiles))
+    for path in incfiles:
+        if force_fwd_slashes:
+            flags.append('--include=%s' % path.replace('\\', '/'))
+        else:
+            flags.append('--include=%s' % path)
+
+    if extraflags:
+        flags += extraflags
 
     return {'flags': flags}
 
 
+_clang_shadow_pch_suffix = '-for-clang'
+
+
+def _cache_pch_files(paths):
+    outpaths = []
+    for path in paths:
+        name, ext = os.path.splitext(path)
+        outpath = "%s%s%s" % (name, _clang_shadow_pch_suffix, ext)
+
+        do_cache = False
+        orig_mtime = os.path.getmtime(path)
+        try:
+            out_mtime = os.path.getmtime(outpath)
+            if orig_mtime >= out_mtime:
+                do_cache = True
+        except OSError:
+            do_cache = True
+
+        if do_cache:
+            logger.debug("Creating shadow PCH file: %s" % path)
+            shutil.copy2(path, outpath)
+
+        outpaths.append(outpath)
+    return outpaths
+
+
 def _build_env_from_vim(client_data):
     buildenv = {}
     buildenv['Configuration'] = client_data.get('g:vimcrosoft_current_config', '')
@@ -117,6 +233,35 @@
     return buildenv
 
 
+_dump_debug_file = True
+
+
+def _do_dump_debug_file(args, flags, debug_filename):
+    import pprint
+
+    with open(debug_filename, 'w') as fp:
+        fp.write("  args:  \n")
+        fp.write("=========\n")
+        pp = pprint.PrettyPrinter(indent=2, stream=fp)
+        pp.pprint(args)
+        fp.write("\n\n")
+
+        fp.write("  flags:  \n")
+        fp.write("==========\n")
+        flags = flags.get('flags')
+        if flags:
+            fp.write(flags[0])
+            for flag in flags[1:]:
+                if flag[0] == '-':
+                    fp.write("\n")
+                else:
+                    fp.write("  ")
+                fp.write(flag)
+        else:
+            fp.write("<no flags found>\n")
+        fp.write("\n\n")
+
+
 def Settings(**kwargs):
     language = kwargs.get('language')
     filename = kwargs.get('filename')
@@ -127,17 +272,24 @@
         solution = client_data.get('solution')
         slncache = client_data.get('slncache')
         buildenv = client_data.get('env', {})
+        extraflags = client_data.get('extra_flags')
     else:
+        if not client_data:
+            raise Exception("No client data provided by Vim host!")
         solution = client_data.get('g:vimcrosoft_current_sln')
         slncache = client_data.get('g:vimcrosoft_current_sln_cache')
         buildenv = _build_env_from_vim(client_data)
+        extraflags = client_data.get('g:vimcrosoft_extra_clang_args')
+
+    extraflags = _expand_extra_flags_with_solution_extra_flags(solution, extraflags)
 
     flags = None
 
     if language == 'cfamily':
         try:
             flags = _build_cflags(filename, solution,
-                                  buildenv=buildenv, slncache=slncache)
+                                  buildenv=buildenv, slncache=slncache,
+                                  extraflags=extraflags)
         except Exception as exc:
             if from_cli:
                 raise
@@ -145,13 +297,11 @@
     else:
         flags = {'error': f"Unknown language: {language}"}
 
-    with open("D:\\P4\\DevEditor\\debug.txt", 'w') as fp:
-        fp.write("kwargs:")
-        fp.write(str(kwargs))
-        fp.write("client_data:")
-        fp.write(str(list(kwargs['client_data'].items())))
-        fp.write("flags:")
-        fp.write(str(flags))
+    if _dump_debug_file:
+        debug_filename = os.path.join(
+                os.path.dirname(solution), '.vimcrosoft', 'debug_flags.txt')
+        _do_dump_debug_file(kwargs, flags, debug_filename)
+
     return flags
 
 
@@ -175,11 +325,17 @@
                         help="The solution file")
     parser.add_argument('filename',
                         help="The filename for which to get flags")
+    parser.add_argument('--no-auto-env',
+                        action='store_true',
+                        help="Don't read configuration information from Vimcrosoft cache")
     parser.add_argument('-p', '--property',
                         action="append",
                         help="Specifies a build property")
     parser.add_argument('-c', '--cache',
                         help="The solution cache to use")
+    parser.add_argument('--cmdline',
+                        action='store_true',
+                        help="Output flags in a command-line form")
     parser.add_argument('-v', '--verbose',
                         action='store_true',
                         help="Show debugging information")
@@ -190,13 +346,18 @@
     logger.debug(f"Got language {lang} for {args.filename}")
 
     build_env = {}
+    slncache = args.cache
+    if not args.no_auto_env:
+        load_vimcrosoft_auto_env(args.solution, build_env)
+        if not slncache:
+            slncache = find_vimcrosoft_slncache(args.solution)
     if args.property:
         for p in args.property:
             pname, pval = p.split('=', 1)
             build_env[pname] = pval
     logger.debug(f"Got build environment: {build_env}")
     client_data = {'solution': args.solution,
-                   'slncache': args.cache,
+                   'slncache': slncache,
                    'env': build_env}
 
     params = {'from_cli': True,
@@ -205,10 +366,35 @@
               'client_data': client_data
               }
     flags = Settings(**params)
-    logger.info("Flags:")
-    import pprint
-    pp = pprint.PrettyPrinter(indent=2)
-    pp.pprint(flags)
+    if args.cmdline:
+        import shlex
+        if hasattr(shlex, 'join'):
+            joinargs = shlex.join
+        else:
+            joinargs = lambda a: ' '.join(a)
+
+        with open('clang_ycm_args.rsp', 'w', encoding='utf8') as fp:
+            fp.write("%s -fsyntax-only \"%s\"" % (
+                joinargs(sanitizeargs(flags['flags'])),
+                args.filename.replace('\\', '/')
+                ))
+        with open('clang_ycm_invoke.cmd', 'w', encoding='utf8') as fp:
+            fp.write("\"c:\\Program Files\\LLVM\\bin\\clang++.exe\" @clang_ycm_args.rsp > clang_ycm_invoke.log 2>&1")
+
+        logger.info("Command line written to: clang_ycm_invoke.cmd")
+    else:
+        logger.info("Flags:")
+        import pprint
+        pp = pprint.PrettyPrinter(indent=2)
+        pp.pprint(flags)
+
+
+def sanitizeargs(args):
+    for arg in args:
+        if ' ' in arg:
+            yield '"%s"' % arg
+        else:
+            yield arg
 
 
 if __name__ == '__main__':