VS Code의 C/C++를 위한 c_cpp_properties.json 생성 자동화

VS Code 용 c_cpp_properties.json 파일을 자동으로 생성하는 툴을 자작하여 공유한다.

LSP 활성화 방법

VS Code에서 C/C++ 소스에 대하여 LSP(Language Server Protocol) 기능을 사용하기 위해서는 다음 2가지 방법이 있다.

  • compile_commands.json 파일 사용: 이 방법은 별도의 페이지에 작성하였다.
    C/C++ 용 LSP(Language Server Protocol) 이용하기 페이지를 참조한다.
  • c_cpp_properties.json 파일 사용: VS Code가 제공하는 프로젝트 별 C/C++ 설정을 이용하는 방법이다.
    단, 이 방법은 "includePath", "defines", "compilerPath" 등의 값을 수동으로 추가해 주어야 하는 불편함이 있는데, 이 글에서는 이를 자동화하는 자작 툴을 소개한다.

c_cpp_properties.json 수동 설정

VS Code에서 c_cpp_properties.json 파일을 사용하는 경우에는 make 빌드 시에 사용되는 -I 내용은 "includePath"에, -D 내용은 "defines"에 추가해 주어야 하고, 사용하는 GCC 툴체인 경로도 "compilerPath"에 올바르게 설정되어 있어야 한다.
그런데, 여러 모델에서 빌드 시스템이 복잡하고 빌드 옵션이 서로 다른 경우에는, 각 모델마다 매번 수동으로 이 파일을 설정해야 한다.

추가로 나 같은 경우에는 동일 모델이라도 여러 빌드 configuration이 있고 그에 따라서 C/C++ 컴파일 옵션도 달라지므로, 한 번 수작업으로 설정했더라도 빌드 configuration을 변경하면 c_cpp_properties.json 파일도 그에 맞게 다시 수정해야 했다.

이와 같이 c_cpp_properties.json 파일을 수동으로 설정하는 작업이 번거롭기도 하고, VS Code 미숙련자도 쉽게 구축할 수 있는 방법이 없을까 생각하다가 🤔, 아예 자동으로 c_cpp_properties.json 파일을 완전히 작성해 주는 파이썬 코드를 작성하게 되었다. (단, make 빌드 시스템을 사용하는 경우임)

c_cpp_properties.json 자동화 툴

아래와 같이 파이썬 코드를 작성하였고, 사용하는 프로젝트의 저장소에도 올렸다. (파일 이름은 vscode_json.py로 했고, 코드에 충분히 주석을 달았으므로, 여기서는 코드 설명은 생략함)

#!/usr/bin/python3
# -*- coding: utf-8 -*-

# 사용 예
# $ ./vscode_json.py -j

import json
import os
import subprocess
import sys
from typing import Any, Dict, List, Set, Tuple

includePath: Set[str] = set()
defines: Set[str] = set()
cStandard: Set[str] = set()
cppStandard: Set[str] = set()
gccPath: str = ""

def getBuildOutput(command: List[str]) -> List[str]:
    """입력 명령을 실행시키고, 출력 결과를 줄 단위로 얻어서 리턴한다."""
    proc = subprocess.Popen(command, stdout = subprocess.PIPE)
    outString, _ = proc.communicate()
    if proc.returncode != 0:
        print("Failed to build.")
        return [""]
    outputLines = outString.decode('utf-8').splitlines()
    return outputLines

def addOneIncludePathOrDefines(lineSliced: str, include_define: Set[str]) -> str:
    """
    "-I" 또는 "-D"로 시작하는 입력 줄에서 맨 앞의 1개 내용을 추출하여 입력 set에 추가한 후, 나머지 줄 내용을 리턴한다.
    -I, -D의 다음과 같은 사용 예들을 모두 지원한다. (-DDEBUG, -D'DEBUG', -D"DEBUG", -D DEBUG, -D 'DEBUG', -D "DEBUG")
    """
    # -I, -D 바로 뒤에 공백이 있으면 제거한다.
    lineStr = lineSliced[2:].lstrip()

    # 각 경우에 대한 내용의 시작 위치와 종료 위치를 얻는다.
    if lineStr.startswith("'"):
        startIndex = 1
        endIndex = startIndex + lineStr[startIndex:].find("'")
        if endIndex == 0:
            return ""
    elif lineStr.startswith('"'):
        startIndex = 1
        endIndex = startIndex + lineStr[startIndex:].find('"')
        if endIndex == 0:
            return ""
    else:
        startIndex = 0
        endIndex = lineStr[startIndex:].find(" ")
        if endIndex == -1:
            endIndex = len(lineStr[startIndex:])

    if lineSliced.startswith("-I"):
        # Include 경로이면 실제 존재하는 경로인 경우에만 절대 경로로 추가한다.
        command = ["readlink", "-e", "-n", lineStr[startIndex:endIndex]]
        proc = subprocess.Popen(command, stdout = subprocess.PIPE)
        outString, _ = proc.communicate()
        if proc.returncode == 0:
            out = outString.decode('utf-8')
            include_define.add(out)
    else:
        # 종료 위치 전까지의 내용을 입력 set에 추가한 후, 나머지 줄의 내용을 리턴한다.
        include_define.add(lineStr[startIndex:endIndex])

    # 이번에 처리한 내용은 제거하고, 이후의 내용을 리턴한다.
    return lineStr[endIndex:]

def getStandardVersion(lineSliced: str) -> str:
    """
    "-std="로 시작하는 입력 줄에서 표준 C/C++ 번호를 추출하여 set에 추가한 후, 나머지 줄 내용을 리턴한다.
    """
    endIndex = lineSliced.find(" ")
    if endIndex == -1:
        endIndex = len(lineSliced)
    stdVerStr = lineSliced[5:endIndex]

    # gnu++이나 c++과 같이 "++" 문자열이 있으면 C++ 정보로, 없으면 C 정보로 추가한다.
    if stdVerStr.find("++") == -1:
        cStandard.add(stdVerStr)
    else:
        cppStandard.add(stdVerStr)

    return lineSliced[endIndex:]

def parseCompileOptions(line: str) -> None:
    """입력 줄에서 gcc 실행 경로를 얻어서 gccPath에 저장하고, include와 define 값을 추출해서 해당 set에 추가한다."""
    global gccPath

    # gcc/g++ 실행 경로를 추출한다. (만약에 "/"로 시작하지 않으면 PATH를 통해서 경로를 얻음)
    index = line.find(" ")
    if index == -1:
        return
    gccCmd = line[:index]
    if gccCmd.startswith("/"):
        gccPath = gccCmd
    else:
        gccPath = os.popen("which " + gccCmd).read().strip('\n')
    lineOptionStr = line[index+1:]

    # 입력 줄 내용에서 모든 -I, -D, -std 내용을 해당 set에 추가한다.
    while lineOptionStr != "":
        lineOptionStr = lineOptionStr.strip()
        if lineOptionStr.startswith("-I"):
            lineOptionStr = addOneIncludePathOrDefines(lineOptionStr, includePath)
        elif lineOptionStr.startswith("-D"):
            lineOptionStr = addOneIncludePathOrDefines(lineOptionStr, defines)
        elif lineOptionStr.startswith("-std="):
            lineOptionStr = getStandardVersion(lineOptionStr)
        else:
            startIndex = lineOptionStr.find(" ")
            if startIndex == -1:
                break
            lineOptionStr = lineOptionStr[startIndex:]

def parseBuildOutput(lines: List[str]) -> int:
    """입력으로 받은 make 실행 결과 전체를 파싱하고, 파싱된 줄 수를 리턴한다."""
    # 각 줄에서 gcc 또는 g++로 빌드하는 줄이면 include, define을 찾아서 처리한다.
    builtLineNum = 0
    for line in lines:
        index = line.find(" ")
        if index == -1:
            continue
        lineCmd = line[:index]
        if lineCmd.endswith("gcc") or lineCmd.endswith("g++"):
            if "-M" in line:
                continue
            builtLineNum += 1
            parseCompileOptions(line)

    return builtLineNum

def getStandardCVersion(toolchainPath: str) -> Tuple[str, str]:
    """
    컴파일시 -std 옵션으로 지정된 C/C++ 표준 번호를 얻는다.
    지정 옵션이 없는 경우에는 임시 파일을 높은 표준 번호부터 빌드해서 에러가 발생하지 않는 C/C++ 표준 번호를 얻는다.
    """
    StdCVersion = ""
    StdCppVersion = ""

    # 컴파일 옵션에 표준 번호가 지정되어 있었으면 이 정보를 사용한다.
    if len(cStandard) > 0:
        StdCVersion = list(cStandard)[0]
    if len(cppStandard) > 0:
        StdCppVersion = list(cppStandard)[0]
    if len(cStandard) > 0 and len(cppStandard) > 0:
        return StdCVersion, StdCppVersion

    # 입력 GCC 명령이 올바르지 않으면 리턴한다.
    if not (toolchainPath.endswith("gcc") or toolchainPath.endswith("g++")):
        return StdCVersion, StdCppVersion

    # C 파일을 -std 옵션으로 높은 표준 번호부터 세팅해서 빌드 에러가 발생하지 않을 때의 표준 번호를 얻는다.
    if StdCVersion == "":
        tempCFile = open("tmp_build_test.c", "w")
        tempCFile.write("int main(){return 0;}\n")
        tempCFile.close()
        stdOptions = ["-std=c17", "-std=c11", "-std=c99", "-std=c89"]
        command = [toolchainPath, "", "tmp_build_test.c", "-o", "tmp_build_test"]
        for option in stdOptions:
            command[1] = option
            proc = subprocess.Popen(command, stderr = subprocess.DEVNULL)
            _, _ = proc.communicate()
            if proc.returncode == 0:
                StdCVersion = command[1][5:8]
                break
        os.remove("tmp_build_test.c")

    # C++ 파일을 -std 옵션으로 높은 표준 번호부터 세팅해서 빌드 에러가 발생하지 않을 때의 표준 번호를 얻는다.
    if StdCppVersion == "":
        tempCppFile = open("tmp_build_test.cpp", "w")
        tempCppFile.write("int main(){return 0;}\n")
        tempCppFile.close()
        stdOptions = ["-std=c++17", "-std=c++14", "-std=c++11", "-std=c++03", "-std=c++98"]
        command = [toolchainPath, "", "tmp_build_test.cpp", "-o", "tmp_build_test"]
        for option in stdOptions:
            command[1] = option
            proc = subprocess.Popen(command, stderr = subprocess.DEVNULL)
            _, _ = proc.communicate()
            if proc.returncode == 0:
                StdCppVersion = command[1][5:10]
                break
        os.remove("tmp_build_test.cpp")

    # 빌드된 임시 실행 파일을 삭제한다.
    os.remove("tmp_build_test")

    return StdCVersion, StdCppVersion

def writeJsonFile(jsonFileName: str) -> None:
    """VS Code 용 c_cpp_properties.json 파일을 위한 JSON 데이터를 생성하여, 입력받은 이름으로 저장한다."""
    # 표준 C/C++ 번호를 얻는다.
    stdCVer, stdCppVer = getStandardCVersion(gccPath)

    # JSON에서 "configurations" 항목을 dictionary 타입으로 구성한다.
    configDict: Dict[str, Any] = dict()
    configDict["name"] = "Linux"
    configDict["includePath"] = sorted(includePath)
    configDict["defines"] = sorted(defines)
    configDict["compilerPath"] = gccPath
    configDict["cStandard"] = stdCVer
    configDict["cppStandard"] = stdCppVer

    # JSON을 dictionary 타입으로 구성하고, configurations 정보는 리스트 형식으로 저장한다.
    outputJson: Dict[str, Any] = dict()
    outputJson["configurations"] = [configDict]
    outputJson["version"] = 4

    # Dictionary를 JSON 문자열로 변환한다.
    jsonMsg = json.dumps(outputJson, indent=4)

    # JSON 데이터를 입력 이름으로 저장한다.
    try:
        outFile = open(jsonFileName, "w")
    except IOError:
        print("Failed to open " + jsonFileName + " file.")
        sys.exit(1)
    outFile.write(jsonMsg)
    outFile.close()

# VS Code를 위한 c_cpp_properties.json 파일을 생성한다.
if __name__ == '__main__':
    # 빌드 명령을 준비한다. (dry-run 모드로 make 실행)
    commands = ["make", "-n"]

    # 현재 경로와 프로젝트 경로가 다른 경우(다른 경로에서 이 파일을 실행시키는 경우)를 처리한다.
    curPath = os.getcwd()
    projectPath = os.path.dirname(os.path.abspath(__file__))
    if curPath == projectPath:
        jsonFileName = ".vscode/c_cpp_properties.json"
    else:
        jsonFileName = projectPath + "/" + ".vscode/c_cpp_properties.json"
        commands.append("-C")
        commands.append(projectPath)

    # 입력 아규먼트에 make 빌드 옵션이 있으면 빌드 명령에 추가한다.
    for arg in sys.argv[1:]:
        commands.append(arg)

    # 빌드 명령을 실행시키고, 출력 결과를 줄 단위로 얻는다.
    print(' '.join(commands))
    print("Dry-run building...")
    makeOutputLines = getBuildOutput(commands)
    if makeOutputLines[0] == "":
        print("No build output.")
        sys.exit(1)
    print("Dry-run building is done.")

    # 얻은 빌드 출력 결과를 파싱한다.
    print("Output parsing...")
    parsedLineNum = parseBuildOutput(makeOutputLines)
    if parsedLineNum == 0:
        print("No files are dry-run build done. At least 1 file need to be built.")
    else:
        print(f"Output parsing is done (total {parsedLineNum} lines).")

    # 파싱한 데이터를 JSON 파일로 저장한다.
    if os.path.exists(".vscode") is False:
        os.mkdir(".vscode")
    writeJsonFile(jsonFileName)
    os.system("ls -lgG " + jsonFileName)

참고로 위의 코드에서는 type annotation을 추가하였고, 다음과 같이 정적 분석을 한 경우에 문제가 없음을 확인하였다.

$ pip3 install mypy
$ mypy --strict vscode_json.py

맺음말

위와 같은 자동화 툴을 소스 저장소에 올려놓고, 각 프로젝트마다 사용해 보니 VS Code에서 프로젝트 별로 C/C++ 개발 환경을 아주 편하게 구축할 수 있었다. 😛

카테고리: ,

업데이트: