C++ 커스텀 컴파일러 패스 | Clang 플러그인·AST 분석·커스텀 진단

팀이 정한 코드 규칙 중에는 사람이 리뷰로 지키기 어려운 것들이 있습니다. 하드코딩된 숫자를 상수로 빼라는 규칙, malloc/free 대신 RAII 타입을 쓰라는 규칙, 헤더 선언과 구현의 매개변수 이름을 맞추라는 규칙, 게임 엔진의 Update()는 반드시 프레임 시간을 첫 인자로 받아야 한다는 도메인 규칙 같은 것들입니다. 이런 규칙을 컴파일러가 직접 경고나 에러로 알려 주면 리뷰에서 놓칠 일이 없습니다.

Clang은 컴파일 과정에서 만든 AST(추상 구문 트리)를 플러그인에 넘겨 주는 기능을 제공합니다. 플러그인은 AST를 순회하며 원하는 패턴을 찾고, 컴파일러의 진단 시스템으로 일반 경고와 똑같은 형식의 메시지를 낼 수 있습니다. 이 글은 Clang 플러그인의 구조, AST 순회와 커스텀 진단, 그리고 플러그인을 빌드하고 로드할 때 실제로 부딪히는 문제를 다룹니다. 예제는 LLVM 17~18 기준이며, Clang의 C++ API는 버전마다 조금씩 바뀌므로 다른 버전에서는 함수 이름을 확인해야 합니다.


AST와 플러그인의 위치

Clang은 소스를 전처리하고 토큰으로 나눈 뒤 구문 분석과 의미 분석을 거쳐 AST를 만들고, 그 AST에서 LLVM IR을 생성합니다. 플러그인은 AST가 완성된 시점에 개입해 분석을 수행합니다.

flowchart LR
    A[소스 코드] --> B[전처리]
    B --> C[구문·의미 분석]
    C --> D[AST]
    D --> E["플러그인 (분석·진단)"]
    D --> F[LLVM IR 생성]
    F --> G[최적화·기계어]

int x = 3 + 5;의 AST는 DeclStmt 아래 VarDecl x가 있고, 그 초기화 식으로 BinaryOperator +가 두 개의 IntegerLiteral을 자식으로 가진 형태입니다. clang -Xclang -ast-dump -fsyntax-only test.cpp로 실제 AST를 출력해 보면 어떤 노드 타입을 방문해야 하는지 쉽게 알 수 있습니다. 플러그인을 만들 때 가장 먼저 해 보는 일입니다.


플러그인의 세 구성 요소

flowchart TB
    PA[PluginASTAction] -->|CreateASTConsumer| AC[ASTConsumer]
    AC -->|HandleTranslationUnit| RV[RecursiveASTVisitor]
    RV -->|VisitFunctionDecl, VisitCallExpr ...| AST[AST 노드]

PluginASTAction은 플러그인의 진입점으로, 플러그인 인자를 처리하고 ASTConsumer를 만듭니다. ASTConsumer는 번역 단위 하나의 AST를 받아 처리하며, 보통 HandleTranslationUnit()에서 전체 AST를 한 번 순회합니다. RecursiveASTVisitor는 AST를 깊이 우선으로 순회하면서, 노드 타입별 VisitXxx 메서드를 호출해 줍니다. 관심 있는 노드 타입의 메서드만 정의하면 됩니다.

같은 구성 요소로 LibTooling 기반의 독립 실행 도구(ASTFrontendAction)를 만들 수도 있습니다. 플러그인은 기존 컴파일 명령에 옵션만 추가하면 되므로 빌드에 바로 붙일 수 있지만, 로드하는 Clang과 정확히 같은 버전으로 빌드해야 합니다. LibTooling 도구는 compile_commands.json을 읽어 별도로 실행하므로 빌드와 분리되어 있고, 리팩터링처럼 소스를 고쳐 쓰는 작업에 더 맞습니다. clang-tidy에 사용자 정의 검사를 추가하는 것도 같은 목적의 대안입니다.


첫 플러그인: 함수와 클래스 이름 출력

// PrintNamesPlugin.cpp
#include "clang/AST/ASTConsumer.h"
#include "clang/AST/RecursiveASTVisitor.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendPluginRegistry.h"
#include "llvm/Support/raw_ostream.h"

using namespace clang;

namespace {

class PrintNamesVisitor : public RecursiveASTVisitor<PrintNamesVisitor> {
public:
  bool VisitFunctionDecl(FunctionDecl *D) {
    if (D->getDeclContext()->isFileContext())        // 네임스페이스·전역 범위의 함수
      llvm::outs() << "Function: " << D->getQualifiedNameAsString() << "\n";
    return true;
  }
  bool VisitCXXRecordDecl(CXXRecordDecl *D) {
    if (D->isThisDeclarationADefinition() && D->getDeclContext()->isFileContext())
      llvm::outs() << "Class: " << D->getQualifiedNameAsString() << "\n";
    return true;
  }
};

class PrintNamesConsumer : public ASTConsumer {
public:
  void HandleTranslationUnit(ASTContext &Ctx) override {
    Visitor.TraverseDecl(Ctx.getTranslationUnitDecl());
  }
private:
  PrintNamesVisitor Visitor;
};

class PrintNamesAction : public PluginASTAction {
protected:
  std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &, llvm::StringRef) override {
    return std::make_unique<PrintNamesConsumer>();
  }
  bool ParseArgs(const CompilerInstance &, const std::vector<std::string> &) override {
    return true;
  }
  // 명시적인 -plugin 옵션 없이도 일반 컴파일과 함께 실행
  ActionType getActionType() override { return AddAfterMainAction; }
};

}  // namespace

static FrontendPluginRegistry::Add<PrintNamesAction>
    X("print-names", "Print namespace-scope function and class names");

getActionType()의 기본값은 Cmdline이라서, 이 경우 플러그인을 로드하는 것만으로는 실행되지 않고 -Xclang -plugin -Xclang print-names(메인 동작을 대체)나 -Xclang -add-plugin -Xclang print-names(메인 동작에 추가)로 명시해야 합니다. 위처럼 AddAfterMainAction이나 AddBeforeMainAction을 반환하면 -fplugin=으로 로드하기만 해도 컴파일과 함께 실행됩니다. 로드했는데 아무 출력이 없다면 가장 먼저 확인할 부분입니다.

빌드

LLVM은 기본적으로 RTTI 없이(-fno-rtti) 빌드되므로, 플러그인도 같은 옵션으로 컴파일해야 합니다. 그렇지 않으면 로드할 때 undefined symbol: typeinfo for clang::ASTConsumer 같은 오류가 납니다. llvm-config --cxxflags가 LLVM 빌드와 맞는 플래그를 알려 줍니다.

# Linux: 배포판의 clang과 같은 버전의 개발 패키지(예: llvm-18-dev, libclang-18-dev) 필요
clang++ -shared -fPIC $(llvm-config-18 --cxxflags) \
  PrintNamesPlugin.cpp -o PrintNamesPlugin.so

clang++-18 -fplugin=./PrintNamesPlugin.so -fsyntax-only test.cpp

플러그인을 Clang 라이브러리(libclangAST.a 등)와 정적으로 링크하지 않는다는 점에 주의해야 합니다. 플러그인은 clang 실행 파일 안에 로드되어 그 안에 이미 있는 Clang 심볼을 사용합니다. 정적 라이브러리를 다시 링크하면 같은 전역 객체가 두 벌 생겨 CommandLine Error: Option '...' registered more than once 같은 오류로 실패합니다. macOS에서는 링크 시 미해결 심볼을 허용하도록 -undefined dynamic_lookup을 추가합니다. Windows의 Clang은 기본 빌드에서 플러그인을 지원하지 않으며, LLVM을 LLVM_EXPORT_SYMBOLS_FOR_PLUGINS=ON으로 직접 빌드해야 합니다.

LLVM 소스 트리 밖에서 CMake로 빌드한다면 LLVM이 제공하는 설정을 씁니다.

cmake_minimum_required(VERSION 3.20)
project(OurChecker LANGUAGES CXX)

find_package(Clang REQUIRED CONFIG)   # -DClang_DIR=<llvm>/lib/cmake/clang
set(CMAKE_CXX_STANDARD 17)

add_library(OurChecker MODULE OurChecker.cpp)
target_include_directories(OurChecker PRIVATE ${LLVM_INCLUDE_DIRS} ${CLANG_INCLUDE_DIRS})
target_compile_definitions(OurChecker PRIVATE ${LLVM_DEFINITIONS})
if(NOT LLVM_ENABLE_RTTI)
  target_compile_options(OurChecker PRIVATE -fno-rtti)
endif()
if(APPLE)
  target_link_options(OurChecker PRIVATE -undefined dynamic_lookup)
endif()

RecursiveASTVisitor로 패턴 찾기

찾고 싶은 것메서드
함수 선언·정의VisitFunctionDecl
클래스·구조체VisitCXXRecordDecl
함수 호출VisitCallExpr
정수 리터럴VisitIntegerLiteral
변수 선언VisitVarDecl
멤버 접근VisitMemberExpr
제어문VisitIfStmt, VisitForStmt 등

VisitStmt처럼 상위 타입을 오버라이드하고 안에서 dyn_cast로 거르는 것보다, 원하는 노드 타입의 메서드를 직접 정의하는 편이 의도가 분명하고 호출 횟수도 적습니다.

VisitXxx의 반환값은 순회를 계속할지 여부입니다. false를 반환하면 그 노드의 하위 트리만 건너뛰는 것이 아니라 전체 순회가 중단됩니다. 특정 하위 트리만 건너뛰려면 TraverseXxx를 오버라이드해 조건에 맞으면 부모 클래스의 TraverseXxx를 호출하지 않고 true를 반환합니다.

// 테스트 코드 네임스페이스 안의 함수는 검사하지 않음
bool TraverseNamespaceDecl(NamespaceDecl *NS) {
  if (NS->getName() == "testing") return true;   // 자식을 방문하지 않고 계속 진행
  return RecursiveASTVisitor::TraverseNamespaceDecl(NS);
}

템플릿은 기본적으로 템플릿 정의만 방문하고 인스턴스화된 코드는 방문하지 않습니다(shouldVisitTemplateInstantiations()가 false). 인스턴스화마다 같은 진단이 반복되는 것을 막아 주므로 대부분의 규칙 검사에는 이 기본값이 맞습니다. 컴파일러가 암시적으로 만든 코드(기본 생성자 등)도 shouldVisitImplicitCode()가 기본 false라서 방문하지 않습니다.

매직 넘버 탐지

#include "llvm/ADT/StringExtras.h"

class MagicNumberVisitor : public RecursiveASTVisitor<MagicNumberVisitor> {
public:
  MagicNumberVisitor(ASTContext &Ctx) : Ctx(Ctx), Diag(Ctx.getDiagnostics()) {
    ID = Diag.getCustomDiagID(DiagnosticsEngine::Warning,
                              "magic number %0; consider using a named constant");
  }

  bool VisitIntegerLiteral(IntegerLiteral *Lit) {
    SourceLocation Loc = Lit->getBeginLoc();
    if (!isUserCode(Loc)) return true;

    const llvm::APInt &Val = Lit->getValue();
    if (Val.ule(1)) return true;   // 0과 1은 허용 (-1은 단항 마이너스 + 리터럴 1)

    Diag.Report(Loc, ID) << llvm::toString(Val, 10, /*Signed=*/false);
    return true;
  }

  // 상수 정의 자체는 경고하지 않음: constexpr/const 변수 초기화 식 안의 리터럴 건너뛰기
  bool TraverseVarDecl(VarDecl *VD) {
    if (VD->isConstexpr() || VD->getType().isConstQualified()) return true;
    return RecursiveASTVisitor::TraverseVarDecl(VD);
  }

private:
  bool isUserCode(SourceLocation Loc) const {
    const SourceManager &SM = Ctx.getSourceManager();
    Loc = SM.getExpansionLoc(Loc);          // 매크로 안이면 매크로를 사용한 위치
    return Loc.isValid() && !SM.isInSystemHeader(Loc);
  }

  ASTContext &Ctx;
  DiagnosticsEngine &Diag;
  unsigned ID;
};

C++의 정수 리터럴은 항상 음수가 아닙니다. 소스의 -1은 단항 마이너스 연산자가 리터럴 1에 적용된 식이므로, 리터럴 값으로 -1을 검사하는 조건은 의미가 없습니다. 상수를 정의하는 곳(constexpr int kMaxRetry = 3;)까지 경고하면 규칙을 지키는 코드에도 경고가 나므로, 상수 변수의 초기화 식은 순회에서 제외했습니다. 실제로 쓰려면 배열 크기, 열거형 값, 비트 시프트 양 같은 예외를 더 정해야 합니다. 이런 세부 정책을 정하는 것이 구현보다 시간이 더 걸리는 부분입니다. llvm::toString(APInt, ...)은 LLVM 13에서 APInt::toString(unsigned, bool) 멤버 함수가 제거되면서 생긴 대체 함수입니다.

금지된 함수 호출

class ForbiddenCallVisitor : public RecursiveASTVisitor<ForbiddenCallVisitor> {
public:
  explicit ForbiddenCallVisitor(ASTContext &Ctx) : Ctx(Ctx), Diag(Ctx.getDiagnostics()) {
    ID = Diag.getCustomDiagID(DiagnosticsEngine::Error,
                              "use of '%0' is forbidden; use %1 instead");
  }

  bool VisitCallExpr(CallExpr *E) {
    const FunctionDecl *Callee = E->getDirectCallee();
    if (!Callee) return true;                         // 함수 포인터 호출 등
    const IdentifierInfo *II = Callee->getIdentifier();
    if (!II) return true;                             // 연산자, 생성자 등은 일반 식별자가 없음
    if (Ctx.getSourceManager().isInSystemHeader(E->getBeginLoc())) return true;

    llvm::StringRef Name = II->getName();
    if (!Callee->getDeclContext()->getRedeclContext()->isTranslationUnit() &&
        !Callee->isInStdNamespace())
      return true;                                    // 사용자 네임스페이스의 같은 이름 함수는 제외
    if (Name == "malloc" || Name == "free")
      Diag.Report(E->getBeginLoc(), ID) << Name << "std::unique_ptr or std::vector";
    else if (Name == "sprintf")
      Diag.Report(E->getBeginLoc(), ID) << Name << "snprintf or std::format";
    return true;
  }

private:
  ASTContext &Ctx;
  DiagnosticsEngine &Diag;
  unsigned ID;
};

FunctionDecl::getName()은 이름이 일반 식별자가 아닌 선언(operator+, 생성자, 변환 함수)에서 호출하면 디버그 빌드의 Clang에서 단언 실패로 종료됩니다. 위처럼 getIdentifier()가 있는지 먼저 확인합니다. 이름만 비교하면 사용자가 만든 mylib::free 같은 함수도 걸리므로 전역 또는 std 범위의 함수인지도 확인했습니다. 진단 레벨을 Error로 하면 빌드가 실패하므로, 기존 코드가 많은 저장소라면 처음에는 Warning으로 도입해 위반 목록을 정리한 뒤 올리는 것이 현실적입니다.


DiagnosticsEngine으로 진단 내기

getCustomDiagID(레벨, 형식 문자열)로 진단 ID를 만들고 Report(위치, ID)에 <<로 인자를 넘깁니다. 형식 문자열의 %0, %1이 순서대로 채워집니다. 같은 레벨과 문자열로 다시 호출하면 같은 ID를 돌려주지만, 방문할 때마다 만들 필요는 없으므로 생성자에서 한 번 만들어 둡니다.

레벨의미
DiagnosticsEngine::Warning경고. -Werror면 에러로 승격
DiagnosticsEngine::Error에러. 컴파일이 실패함
DiagnosticsEngine::Note직전 진단에 붙는 보충 설명
DiagnosticsEngine::Remark정보성 메시지 (Clang 내장 리마크는 -R 옵션으로 켬)

플러그인의 진단은 Clang의 일반 진단과 같은 형식으로 출력되므로 IDE와 CI가 그대로 인식합니다. 다만 커스텀 진단에는 -W 옵션 이름이 없어서 사용자가 -Wno-...로 끌 수 없습니다. 끄는 방법이 필요하면 플러그인 인자로 제공합니다.

재선언의 매개변수 이름 불일치

// ParamNameChecker.cpp
#include "clang/AST/ASTConsumer.h"
#include "clang/AST/RecursiveASTVisitor.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendPluginRegistry.h"

using namespace clang;

namespace {

class ParamNameVisitor : public RecursiveASTVisitor<ParamNameVisitor> {
public:
  explicit ParamNameVisitor(ASTContext &Ctx) : Ctx(Ctx), Diag(Ctx.getDiagnostics()) {
    WarnID = Diag.getCustomDiagID(DiagnosticsEngine::Warning,
        "parameter '%0' has a different name in the previous declaration ('%1')");
    NoteID = Diag.getCustomDiagID(DiagnosticsEngine::Note, "previous declaration is here");
  }

  bool VisitFunctionDecl(FunctionDecl *FD) {
    const FunctionDecl *Prev = FD->getPreviousDecl();
    if (!Prev || FD->getNumParams() != Prev->getNumParams()) return true;
    if (Ctx.getSourceManager().isInSystemHeader(FD->getLocation())) return true;

    for (unsigned i = 0; i < FD->getNumParams(); ++i) {
      const ParmVarDecl *P = FD->getParamDecl(i);
      const ParmVarDecl *Q = Prev->getParamDecl(i);
      if (!P->getIdentifier() || !Q->getIdentifier()) continue;   // 이름 없는 매개변수는 제외
      if (P->getIdentifier() != Q->getIdentifier()) {
        Diag.Report(P->getLocation(), WarnID) << P->getName() << Q->getName();
        Diag.Report(Q->getLocation(), NoteID);
      }
    }
    return true;
  }

private:
  ASTContext &Ctx;
  DiagnosticsEngine &Diag;
  unsigned WarnID, NoteID;
};

class ParamNameConsumer : public ASTConsumer {
public:
  void HandleTranslationUnit(ASTContext &Ctx) override {
    ParamNameVisitor V(Ctx);
    V.TraverseDecl(Ctx.getTranslationUnitDecl());
  }
};

class ParamNameChecker : public PluginASTAction {
protected:
  std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &, llvm::StringRef) override {
    return std::make_unique<ParamNameConsumer>();
  }
  bool ParseArgs(const CompilerInstance &, const std::vector<std::string> &) override {
    return true;
  }
  ActionType getActionType() override { return AddAfterMainAction; }
};

}  // namespace

static FrontendPluginRegistry::Add<ParamNameChecker>
    X("check-param-names", "Check parameter name consistency across redeclarations");
// test.cpp
int divide(int numerator, int denominator);
int main() {
  return divide(10, 2);
}
int divide(int denominator, int numerator) {  // 이름이 서로 바뀜
  return numerator / denominator;
}
clang++ -fplugin=./ParamNameChecker.so -c test.cpp
test.cpp:6:16: warning: parameter 'denominator' has a different name in the previous declaration ('numerator')
    6 | int divide(int denominator, int numerator) {  // 이름이 서로 바뀜
      |                ^
test.cpp:2:16: note: previous declaration is here
    2 | int divide(int numerator, int denominator);
      |                ^
test.cpp:6:33: warning: parameter 'numerator' has a different name in the previous declaration ('denominator')
...

선언과 정의에서 같은 위치의 매개변수 이름이 다르다고 해서 항상 버그는 아니지만, 위 예처럼 이름이 서로 뒤바뀐 경우는 호출하는 쪽이 헤더만 보고 인자 순서를 착각하게 만듭니다. clang-tidy의 readability-inconsistent-declaration-parameter-name 검사가 같은 일을 하므로, 이미 clang-tidy를 쓰고 있다면 그쪽을 켜는 것이 더 간단합니다. 플러그인의 가치는 이런 범용 검사가 아니라 팀만의 규칙에서 나옵니다.


도메인 규칙 검사 예: Update(float) 시그니처

bool VisitCXXMethodDecl(CXXMethodDecl *M) {
  if (!M->getIdentifier() || M->getName() != "Update") return true;
  if (!isUserCode(M->getLocation())) return true;

  if (M->getNumParams() == 0 ||
      !M->getParamDecl(0)->getType()->isSpecificBuiltinType(BuiltinType::Float)) {
    Diag.Report(M->getLocation(), UpdateSigID);   // "Update() must take 'float delta_time' as its first parameter"
  }
  return true;
}

isFloatingType()은 double과 long double도 참으로 판단하므로, 정확히 float을 요구한다면 isSpecificBuiltinType(BuiltinType::Float)으로 확인합니다. 타입 별칭(using Seconds = float;)을 거친 경우도 맞게 처리하려면 getCanonicalType()으로 정규화한 타입을 검사합니다. 위 메서드는 CXXMethodDecl을 직접 방문하므로 클래스 정의마다 메서드 목록을 순회할 필요가 없습니다.


매크로와 위치 처리

매크로에서 나온 코드의 SourceLocation은 두 위치를 가집니다. 매크로 본문에 실제로 그 토큰이 적힌 철자 위치(spelling location)와, 매크로가 사용된 확장 위치(expansion location)입니다. 진단을 사용자가 볼 위치에 내려면 대개 확장 위치를 기준으로 시스템 헤더 여부를 판단하고, 매크로 정의 자체가 문제라면 철자 위치를 봅니다.

const SourceManager &SM = Ctx.getSourceManager();
SourceLocation Loc = E->getBeginLoc();
if (Loc.isMacroID()) {
  SourceLocation Exp = SM.getExpansionLoc(Loc);   // 매크로를 사용한 곳
  SourceLocation Spell = SM.getSpellingLoc(Loc);  // 매크로 본문에서 그 토큰이 적힌 곳
}
if (!SM.isInMainFile(SM.getExpansionLoc(Loc))) return true;   // 메인 소스 파일만 검사하는 경우

시스템 헤더의 매크로(예: assert)를 사용자 코드에서 쓰면 확장 위치는 사용자 코드이고 철자 위치는 시스템 헤더입니다. 어느 쪽을 기준으로 할지에 따라 진단이 나오거나 안 나오므로, 매크로가 많은 코드베이스에서는 이 기준을 명확히 정해야 합니다.


플러그인 인자와 빌드 통합

플러그인 인자는 -fplugin-arg-<플러그인이름>-<인자>로 넘기고, ParseArgs가 <인자> 부분을 문자열 목록으로 받습니다. Clang은 -fplugin-arg- 뒤의 첫 -까지를 플러그인 이름으로 보므로, 이 방식을 쓰려면 플러그인 이름에 -를 넣지 않는 것이 좋습니다. -Xclang -plugin-arg-<이름> -Xclang <인자> 형식은 이름에 -가 있어도 동작합니다.

bool ParseArgs(const CompilerInstance &CI, const std::vector<std::string> &Args) override {
  for (const std::string &A : Args) {
    llvm::StringRef Arg(A);
    if (Arg == "warn-only") {
      WarnOnly = true;
    } else if (Arg.consume_front("allow=")) {
      AllowedFunctions.push_back(Arg.str());
    } else {
      DiagnosticsEngine &D = CI.getDiagnostics();
      D.Report(D.getCustomDiagID(DiagnosticsEngine::Error, "unknown plugin argument '%0'")) << A;
      return false;   // false를 반환하면 컴파일이 중단됨
    }
  }
  return true;
}

StringRef::startswith는 LLVM 18에서 starts_with로 바뀌며 deprecated되었으므로, 버전에 상관없이 쓰려면 위처럼 consume_front나 == 비교를 쓰는 편이 편합니다.

CMake 프로젝트에 적용할 때는 플러그인 타깃을 먼저 빌드하고, 검사할 타깃의 컴파일 옵션에 플러그인을 추가합니다. 대상 타깃은 플러그인을 빌드한 Clang과 같은 버전의 Clang으로 컴파일해야 합니다.

add_dependencies(MyApp OurChecker)
target_compile_options(MyApp PRIVATE
  -fplugin=$<TARGET_FILE:OurChecker>
  -fplugin-arg-ourchecker-warn-only)

플러그인은 컴파일마다 AST를 한 번 더 순회하므로 컴파일 시간이 늘어납니다. 대개 순회 비용은 파싱과 코드 생성에 비해 작지만, 템플릿이 많은 큰 번역 단위에서 노드마다 무거운 작업(문자열 생성, 맵 조회)을 하면 눈에 띌 수 있으므로 도입 전에 전체 빌드 시간을 비교해 보는 것이 좋습니다. Clang을 업그레이드하면 플러그인도 다시 빌드해야 하고, API 변경으로 소스를 고쳐야 할 때도 많습니다. 이 유지 비용 때문에 CI에서는 플러그인 빌드 실패가 메인 빌드를 막지 않도록 검사 단계를 분리해 두는 경우가 많습니다.


자주 만나는 문제

Unable to load plugin과 함께 undefined symbol이 나오면, 그 심볼 이름을 보면 원인을 알 수 있습니다. typeinfo for clang::...이면 RTTI 설정이 LLVM과 다른 것이고, 일반 Clang 함수 이름이면 플러그인을 빌드한 헤더와 로드한 clang의 버전이 다른 것입니다. 배포판에는 clang-17, clang-18처럼 여러 버전이 함께 설치되는 경우가 많으므로, clang++ --version과 플러그인을 빌드할 때 쓴 llvm-config --version이 같은지 확인합니다.

플러그인이 로드되었는데 아무 일도 하지 않는다면 getActionType()이 Cmdline(기본값)인데 -plugin/-add-plugin을 주지 않은 경우가 대부분입니다. 또 -fsyntax-only와 AddAfterMainAction 조합처럼 메인 동작이 무엇인지에 따라 실행 여부가 달라질 수 있으므로, 처음에는 llvm::errs()로 CreateASTConsumer가 호출되는지부터 확인합니다.

Visit 메서드 안에서 AST 노드를 수정하면 안 됩니다. RecursiveASTVisitor는 순회 중 트리가 바뀌지 않는다고 가정하므로 크래시나 누락이 생깁니다. 소스를 고쳐 써야 한다면 순회 중에는 수정할 위치만 모으고, Rewriter나 clang::tooling::Replacement로 소스 텍스트를 바꾸는 방식이 일반적입니다. 이런 변환 작업은 플러그인보다 LibTooling 도구나 clang-tidy의 FixIt으로 만드는 편이 다루기 쉽습니다.


같이 보면 좋은 글