C++ 自定义 `linter` 或 `formatter`:基于 AST 的代码风格检查器

哈喽,各位好! 今天咱们来聊聊C++代码的"美容院"——自定义的linter和formatter,专门基于AST(抽象语法树)的。 别害怕AST,听起来高大上,其实就是把代码扒光了,变成一棵树,方便我们检查哪里不顺眼,然后咔咔几刀,让它变得更漂亮。

为啥要自己搞?

市面上已经有很多不错的linter和formatter了,比如clang-tidy、clang-format,功能强大,配置灵活。 那我们为啥还要自己动手呢? 理由很简单,就像买衣服一样,定制款永远比批量生产的更合身。

  • 独特的需求: 你的团队可能有特殊的代码规范,比如命名规则、注释风格、错误处理方式等等,现成的工具可能无法完全满足。
  • 更精细的控制: 你可能需要对某些特定类型的代码进行更深入的分析和处理,比如检查某个函数是否使用了特定的API,或者优化某个算法的实现。
  • 学习和研究: 自己动手实现一个linter/formatter,可以让你更深入地了解C++的语法和语义,以及编译器的内部工作原理。 这对于提升你的编程技能非常有帮助。
  • 乐趣: 没错,编程也可以很有趣! 当你看到自己的代码风格检查器成功地揪出一个个bug,或者把丑陋的代码变得赏心悦目时,那种成就感是无法言喻的。

AST:代码的骨架

AST,抽象语法树,是源代码的树状表示形式。 编译器在编译代码时,首先会对代码进行词法分析和语法分析,生成AST。 AST保留了代码的结构信息,但去除了不必要的细节,比如空格、注释等。

举个例子,对于C++代码 int x = 1 + 2;,AST可能长这样(简化版):

  └── DeclarationStatement
      └── VariableDeclaration
          ├── TypeSpecifier: int
          ├── Identifier: x
          └── Initialization
              └── BinaryOperation: +
                  ├── IntegerLiteral: 1
                  └── IntegerLiteral: 2

可以看到,AST把代码分解成了各种节点,每个节点代表一个语法结构,比如声明语句、变量声明、类型说明符、标识符、二元运算等等。

实现思路:三步走

  1. 解析代码,生成AST: 这是基础。我们需要一个C++解析器,把源代码转换成AST。 可以使用现成的解析器库,比如clang的libtooling或者ANTLR。
  2. 遍历AST,检查代码风格: 这是核心。我们需要编写代码,遍历AST的每个节点,检查代码是否符合规范。 比如,检查变量名是否符合命名规则,检查函数是否超过了最大长度,检查是否有未使用的变量等等。
  3. 修改AST,格式化代码: 如果需要formatter,这一步必不可少。 我们需要在遍历AST的过程中,对代码进行格式化,比如调整缩进、添加空格、换行等等。 然后,把修改后的AST转换回源代码。

第一步:选择合适的工具

生成AST,我们推荐使用 clanglibtooling。 理由如下:

  • 官方支持: clang是LLVM项目的一部分,有强大的社区支持,文档完善。
  • 准确性: clang是真正的C++编译器,对C++语法的支持非常完整和准确。
  • 可扩展性: libtooling提供了丰富的API,方便我们自定义代码分析和转换工具。
# 安装clang (不同系统安装方式不同,这里以Debian/Ubuntu为例)
sudo apt-get update
sudo apt-get install clang
sudo apt-get install libclang-dev # libtooling需要

第二步:代码框架

下面是一个简单的代码框架,演示如何使用libtooling生成AST,并遍历AST的节点:

#include <iostream>
#include <string>

#include "clang/AST/ASTConsumer.h"
#include "clang/AST/RecursiveASTVisitor.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendAction.h"
#include "clang/Tooling/Tooling.h"

using namespace clang;
using namespace clang::tooling;

// 自定义AST访问器
class MyASTVisitor : public RecursiveASTVisitor<MyASTVisitor> {
public:
  explicit MyASTVisitor(ASTContext *Context) : Context(Context) {}

  // 处理函数声明
  bool VisitFunctionDecl(FunctionDecl *FD) {
    // 获取函数名
    std::string FunctionName = FD->getNameInfo().getName().getAsString();
    // 打印函数名
    std::cout << "Function: " << FunctionName << std::endl;
    return true;
  }

private:
  ASTContext *Context;
};

// 自定义AST消费者
class MyASTConsumer : public ASTConsumer {
public:
  explicit MyASTConsumer(ASTContext *Context)
      : Visitor(Context) {}

  // 处理TranslationUnit (整个编译单元)
  void HandleTranslationUnit(ASTContext &Context) override {
    Visitor.TraverseDecl(Context.getTranslationUnitDecl());
  }

private:
  MyASTVisitor Visitor;
};

// 自定义前端Action
class MyFrontendAction : public ASTFrontendAction {
public:
  std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &Compiler,
                                                   StringRef InFile) override {
    return std::make_unique<MyASTConsumer>(&Compiler.getASTContext());
  }
};

int main(int argc, const char **argv) {
  if (argc < 2) {
    std::cerr << "Usage: my_linter <source_file>" << std::endl;
    return 1;
  }

  // 创建CommandLineArguments,模拟命令行参数
  std::vector<std::string> args;
  args.push_back("my_linter"); // 模拟程序名
  args.push_back(argv[1]);       // 模拟源文件名
  const char* constArgs[args.size()];
  for (size_t i = 0; i < args.size(); ++i) {
    constArgs[i] = args[i].c_str();
  }

  // 使用ClangTool执行分析
  ClangTool Tool(CommonOptionsParser::create(args.size(), constArgs, llvm::cl::OptionCategory::Any).getCompilations(), {argv[1]});
  return Tool.run(newFrontendActionFactory<MyFrontendAction>().get());
}

代码解释:

  • MyASTVisitor 继承自RecursiveASTVisitor,用于遍历AST。 VisitFunctionDecl 函数会在访问到函数声明节点时被调用。 你可以在这个函数里编写代码,检查函数名是否符合命名规则。
  • MyASTConsumer 继承自ASTConsumer,用于处理AST。 HandleTranslationUnit 函数会在处理整个编译单元时被调用。
  • MyFrontendAction 继承自ASTFrontendAction,用于创建ASTConsumer。
  • main函数: 创建ClangTool,并调用run函数执行分析。

编译运行:

# 使用clang++编译
clang++ -std=c++17 my_linter.cpp -o my_linter `llvm-config --cxxflags --ldflags --system-libs --libs clang-tooling clang-ast clang-frontend clang-driver clang-basic`

# 运行
./my_linter test.cpp

其中 test.cpp 是你的C++源文件。

第三步:代码风格检查实战

现在,让我们来编写一些代码,检查代码风格。

示例1:检查函数名是否符合命名规则(驼峰命名法)

// MyASTVisitor.cpp (修改后的 MyASTVisitor 类)
#include <iostream>
#include <string>
#include <regex>

#include "clang/AST/ASTConsumer.h"
#include "clang/AST/RecursiveASTVisitor.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendAction.h"
#include "clang/Tooling/Tooling.h"

using namespace clang;
using namespace clang::tooling;

class MyASTVisitor : public RecursiveASTVisitor<MyASTVisitor> {
public:
  explicit MyASTVisitor(ASTContext *Context) : Context(Context) {}

  bool VisitFunctionDecl(FunctionDecl *FD) {
    std::string FunctionName = FD->getNameInfo().getName().getAsString();
    // 检查函数名是否符合驼峰命名法
    if (!isCamelCase(FunctionName)) {
      // 获取函数声明的位置
      SourceLocation Location = FD->getLocation();
      // 打印错误信息
      Context->getDiagnostics().Report(Location, diag::err_custom_error)
          << "Function name '" << FunctionName << "' does not follow camel case convention.";
    }
    return true;
  }

private:
  ASTContext *Context;

  // 检查字符串是否符合驼峰命名法
  bool isCamelCase(const std::string &str) {
    std::regex pattern("^[a-z]+(?:[A-Z][a-z]+)*$");
    return std::regex_match(str, pattern);
  }
};

class MyASTConsumer : public ASTConsumer {
public:
  explicit MyASTConsumer(ASTContext *Context)
      : Visitor(Context) {}

  void HandleTranslationUnit(ASTContext &Context) override {
    Visitor.TraverseDecl(Context.getTranslationUnitDecl());
  }

  void Initialize(ASTContext &Context) override {
    Context.getDiagnostics().addDiagnostic(diag::err_custom_error, "Custom Error");
  }
private:
  MyASTVisitor Visitor;
};

class MyFrontendAction : public ASTFrontendAction {
public:
  std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &Compiler,
                                                   StringRef InFile) override {
    return std::make_unique<MyASTConsumer>(&Compiler.getASTContext());
  }
};

int main(int argc, const char **argv) {
  if (argc < 2) {
    std::cerr << "Usage: my_linter <source_file>" << std::endl;
    return 1;
  }

  std::vector<std::string> args;
  args.push_back("my_linter");
  args.push_back(argv[1]);
  const char* constArgs[args.size()];
  for (size_t i = 0; i < args.size(); ++i) {
    constArgs[i] = args[i].c_str();
  }

  ClangTool Tool(CommonOptionsParser::create(args.size(), constArgs, llvm::cl::OptionCategory::Any).getCompilations(), {argv[1]});
  return Tool.run(newFrontendActionFactory<MyFrontendAction>().get());
}

代码解释:

  • isCamelCase函数: 使用正则表达式检查字符串是否符合驼峰命名法。
  • VisitFunctionDecl函数: 如果函数名不符合驼峰命名法,就使用 Context->getDiagnostics().Report 打印错误信息。
  • Initialize 函数:MyASTConsumer 中添加 Initialize 函数,用于注册自定义的诊断信息。

示例2:检查函数是否超过最大长度(例如100行)

// MyASTVisitor.cpp (修改后的 MyASTVisitor 类)
// ... (省略前面的代码)

bool VisitFunctionDecl(FunctionDecl *FD) {
    std::string FunctionName = FD->getNameInfo().getName().getAsString();
    // 检查函数体是否超过最大长度
    Stmt *FunctionBody = FD->getBody();
    if (FunctionBody) {
        SourceLocation StartLoc = FunctionBody->getBeginLoc();
        SourceLocation EndLoc = FunctionBody->getEndLoc();
        if (StartLoc.isValid() && EndLoc.isValid()) {
            SourceManager &SM = Context->getSourceManager();
            int StartLine = SM.getSpellingLineNumber(StartLoc);
            int EndLine = SM.getSpellingLineNumber(EndLoc);
            int FunctionLength = EndLine - StartLine + 1;

            if (FunctionLength > 100) {
                SourceLocation Location = FD->getLocation();
                Context->getDiagnostics().Report(Location, diag::err_custom_error)
                    << "Function '" << FunctionName << "' exceeds maximum length (100 lines).";
            }
        }
    }
    return true;
}

// ... (省略后面的代码)

代码解释:

  • FD->getBody() 获取函数体。
  • SM.getSpellingLineNumber() 获取源代码行号。
  • 如果函数体超过100行,就打印错误信息。

示例3:检查是否有未使用的变量

这个检查稍微复杂一些,需要记录变量的声明和使用情况。

// MyASTVisitor.cpp (修改后的 MyASTVisitor 类)
// ... (省略前面的代码)
#include <set>

class MyASTVisitor : public RecursiveASTVisitor<MyASTVisitor> {
public:
  explicit MyASTVisitor(ASTContext *Context) : Context(Context) {}

  // 处理变量声明
  bool VisitVarDecl(VarDecl *VD) {
    // 获取变量名
    std::string VarName = VD->getNameAsString();
    // 记录变量声明
    DeclaredVariables.insert(VD);
    return true;
  }

  // 处理变量引用
  bool VisitDeclRefExpr(DeclRefExpr *DRE) {
    // 获取变量声明
    ValueDecl *VD = DRE->getDecl();
    // 如果变量被使用,从已声明变量集合中移除
    DeclaredVariables.erase(dyn_cast<VarDecl>(VD));
    return true;
  }

  // 处理TranslationUnit结束后,检查是否有未使用的变量
  void EndTranslationUnit() {
    for (VarDecl *VD : DeclaredVariables) {
      // 获取变量名
      std::string VarName = VD->getNameAsString();
      // 获取变量声明的位置
      SourceLocation Location = VD->getLocation();
      // 打印警告信息
      Context->getDiagnostics().Report(Location, diag::warn_unused_variable)
          << "Unused variable '" << VarName << "'.";
    }
  }

private:
  ASTContext *Context;
  std::set<VarDecl*> DeclaredVariables;
};

class MyASTConsumer : public ASTConsumer {
public:
  explicit MyASTConsumer(ASTContext *Context)
      : Visitor(Context) {}

  void HandleTranslationUnit(ASTContext &Context) override {
    Visitor.TraverseDecl(Context.getTranslationUnitDecl());
  }

  void Initialize(ASTContext &Context) override {
    Context.getDiagnostics().addDiagnostic(diag::err_custom_error, "Custom Error");
    Context.getDiagnostics().addDiagnostic(diag::warn_unused_variable, "Unused Variable");
  }

  void HandleEndOfTranslationUnit(ASTContext &Context) override {
    Visitor.EndTranslationUnit();
  }

private:
  MyASTVisitor Visitor;
};

// ... (省略后面的代码)

代码解释:

  • DeclaredVariables 一个std::set,用于记录已声明的变量。
  • VisitVarDecl 在访问到变量声明节点时,把变量添加到DeclaredVariables中。
  • VisitDeclRefExpr 在访问到变量引用节点时,从DeclaredVariables中移除该变量。
  • EndTranslationUnit 在处理完整个编译单元后,遍历DeclaredVariables,如果还有变量存在,说明该变量未被使用,打印警告信息。

第四步:代码格式化(Formatter)

代码格式化比代码检查更复杂,因为需要修改AST。 libtooling提供了Rewriter类,用于修改源代码。

示例:自动添加头文件包含

假设我们需要自动添加 <iostream> 头文件,如果代码中使用了 std::cout

// MyASTVisitor.cpp (修改后的 MyASTVisitor 类, 仅修改visitor类部分)
#include <iostream>
#include <string>
#include <regex>
#include <set>

#include "clang/AST/ASTConsumer.h"
#include "clang/AST/RecursiveASTVisitor.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendAction.h"
#include "clang/Tooling/Tooling.h"
#include "clang/Rewrite/Core/Rewriter.h"

using namespace clang;
using namespace clang::tooling;

class MyASTVisitor : public RecursiveASTVisitor<MyASTVisitor> {
public:
  explicit MyASTVisitor(ASTContext *Context, Rewriter &Rewriter)
      : Context(Context), Rewriter(Rewriter), NeedIncludeIOStream(false) {}

  bool VisitDeclRefExpr(DeclRefExpr *DRE) {
    if (DRE->getNameInfo().getName().getAsString() == "cout") {
      NeedIncludeIOStream = true;
    }
    return true;
  }

  void EndTranslationUnit() {
    if (NeedIncludeIOStream) {
      // 获取TranslationUnitDecl
      TranslationUnitDecl *TUD = Context->getTranslationUnitDecl();
      // 获取源代码起始位置
      SourceLocation StartLoc = TUD->getBeginLoc();

      // 构造插入代码
      std::string IncludeCode = "#include <iostream>n";

      // 在文件开头插入#include <iostream>
      Rewriter.InsertText(StartLoc, IncludeCode, true, true);
    }
  }

private:
  ASTContext *Context;
  Rewriter &Rewriter;
  bool NeedIncludeIOStream;
};

class MyASTConsumer : public ASTConsumer {
public:
  MyASTConsumer(ASTContext *Context, Rewriter &Rewriter)
      : Visitor(Context, Rewriter), Rewriter(Rewriter) {}

  void HandleTranslationUnit(ASTContext &Context) override {
    Visitor.TraverseDecl(Context.getTranslationUnitDecl());
  }

  void HandleEndOfTranslationUnit(ASTContext &Context) override {
    Visitor.EndTranslationUnit();
  }

  void Initialize(ASTContext &Context) override {
      Rewriter.setSourceMgr(Context.getSourceManager(), Context.getLangOpts());
  }

private:
  MyASTVisitor Visitor;
  Rewriter &Rewriter;
};

class MyFrontendAction : public ASTFrontendAction {
public:
  MyFrontendAction() {}

  void setRewriter(Rewriter &rewriter) {
      this->rewriter = &rewriter;
  }

  std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &Compiler,
                                                   StringRef InFile) override {
    Compiler.getDiagnosticOpts().ShowPresumedLoc = false; //关闭显示位置信息

    std::unique_ptr<MyASTConsumer> consumer = std::make_unique<MyASTConsumer>(&Compiler.getASTContext(), *rewriter);
    return consumer;
  }

  bool BeginSourceFileAction(CompilerInstance &CI) override {
    rewriter = std::make_unique<Rewriter>(CI.getSourceManager(), CI.getLangOpts());
    return true;
  }

  void EndSourceFileAction() override {
      SourceManager &SM = rewriter->getSourceMgr();
      const LangOptions &LangOpts = rewriter->getLangOpts();

      // 输出修改后的代码
      rewriter->getEditBuffer(SM.getMainFileID()).write(llvm::outs());
  }

private:
  std::unique_ptr<Rewriter> rewriter;
};

int main(int argc, const char **argv) {
  if (argc < 2) {
    std::cerr << "Usage: my_formatter <source_file>" << std::endl;
    return 1;
  }

  std::vector<std::string> args;
  args.push_back("my_formatter");
  args.push_back(argv[1]);
  const char* constArgs[args.size()];
  for (size_t i = 0; i < args.size(); ++i) {
    constArgs[i] = args[i].c_str();
  }

  ClangTool Tool(CommonOptionsParser::create(args.size(), constArgs, llvm::cl::OptionCategory::Any).getCompilations(), {argv[1]});

  // MyFrontendAction 必须动态创建
  MyFrontendAction* action = new MyFrontendAction();

  int result = Tool.run(std::unique_ptr<FrontendActionFactory>(new FrontendActionFactoryAdaptor<MyFrontendAction>(action)).get());
  delete action;
  return result;
}

代码解释:

  • Rewriter 用于修改源代码。
  • NeedIncludeIOStream 一个标志位,用于记录是否需要包含 <iostream> 头文件。
  • VisitDeclRefExpr 如果代码中使用了 std::cout,就设置 NeedIncludeIOStreamtrue
  • EndTranslationUnit 如果 NeedIncludeIOStreamtrue,就在文件开头插入 #include <iostream>
  • Rewriter.InsertText 在指定位置插入代码。

注意事项:

  • 代码格式化非常复杂,需要考虑各种情况,比如注释、字符串、预处理指令等等。
  • 修改AST时,需要小心谨慎,避免破坏代码的结构。
  • 可以使用clang-format的代码作为参考,学习代码格式化的最佳实践。

总结

自定义C++ linter和formatter是一个充满挑战但也非常有意义的项目。 它能帮助你更好地理解C++语言,提升编程技能,并为你的团队打造一个更规范、更高效的开发环境。 掌握了这些,你就相当于拥有了一个代码美容院,可以随心所欲地打造你的代码风格!

一些进阶方向

  • 配置化: 把代码风格规则配置化,方便用户自定义规则。
  • 自动修复: 实现自动修复功能,自动修复代码风格问题。
  • 集成到IDE: 把linter/formatter集成到IDE中,提供实时代码检查和格式化功能。
  • 增量分析: 只分析修改过的代码,提高分析速度。

希望这篇文章能帮助你入门C++ linter和formatter的开发。 祝你编程愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注