通用代码格式化工具的实现
起因
最近在做一个对比工具时需要将jce文件格式化,在网上寻找一番后,没有找到一款通用的jce格式化类库(也有可能没找到?如果有用过的同学可以留言一下)。 目前找到的唯一一款可格式化的是TarsTools这个idea的插件,细看源码之后发现并不通用,其格式化主要依赖于idea本身提供的ast解析和FormattingModel,格式化代码严重依赖特定语法树节点,通用性较差。 那么就开始动手写一个吧。
实现
实现思路
语言格式化主要有两个流程,语法树解析和格式化输出两个部分。其中主要难点在于格式化输出代码是否可以减轻掉对具体语法树节点的依赖性,这样可以实现一款通用的语言格式化工具,而非特定语言的格式化工具。 参考目前的主流实现,我们可以制定出一些通用规则来针对语法树节点进行处理,例如:
- 缩进大小,缩进方式(space,tab)
- token前忽略空格
- 语法规则之前(之后)进行换行
- 语法规则内部进行缩进
在语法树上对通用规则进行处理,这样不同的语言就可以简单配置具体规则所需要的的语法树节点、token类型去实现代码格式化
语法解析
语法解析选择antlr4来做,目前tars项目中有一款现成的jce parser,但使用的是antlr3来实现的,而且没有具体的语法文件提供。tars net这个项目的cli工具中有提供一个简单版的jce grammer,但是缺失了一部分语法。那么我们还是基于antlr4语法自己做吧。
为了简单起见,使用就不分开写lexer和parser了,使用混合语法文件搞定。其中需要注意的几个点
- 格式化的情况下要保留注释,因此需要将注释和换行都输出到channel。
- 行注释和块注释的处理逻辑不一样,所以需要分开解析。
- 目前4.8的语法文件定义隐藏通道的语法和4.6之前的不一样,具体参照下面这种。
最后语法文件如下:
grammar Jce;
@lexer::members {
public static final int COMMENT = 1;
public static final int OFF_CHANNEL = 2;
}
tarsDefinition: includeDefinition* moduleDefinition*;
includeDefinition: '#include' String;
moduleDefinition
: 'module' moduleName '{' memberDefinition* '}' ';'?
;
memberDefinition
: enumDefinition
| structDefinition
| interfaceDefinition
| constDefinition
| keyDefinition
;
moduleName
: ID
| moduleName '.' ID
;
constDefinition
: 'const' typeDeclaration name '='? fieldValue? ';'
;
keyDefinition
: 'key' '[' name (',' name)* ']' ';'
| 'key' '[' ']' ';'
;
interfaceDefinition
: 'interface' name '{' methodDefinition* '}' ';'?
;
methodDefinition
: typeDeclaration name '(' methodParameterDefinition* ')' ';'
;
methodParameterDefinition
: 'out'? typeDeclaration name '='? fieldValue? ','?
;
structDefinition
: 'struct' name '{' fieldDefinition* '}' ';'?
;
fieldDefinition
: fieldOrder fieldOption typeDeclaration name '='? fieldValue? ';'
;
fieldOrder: Int;
fieldOption: 'require' | 'optional';
fieldValue
: Int
| Float
| String
;
typeDeclaration
: ID
| ID '.' ID
| 'vector' '<'typeDeclaration '>'
| 'map' '<'typeDeclaration ',' typeDeclaration '>'
;
enumDefinition: 'enum' name '{' enumDeclaration* '}' ';'?;
enumDeclaration: name '='? fieldValue? ','?;
name: ID;
ID : ID_Letter (ID_Letter | Digit)* ;
fragment ID_Letter : 'a'..'z' | 'A'..'Z' | '_' ;
fragment Digit : '0'..'9';
fragment Number : Digit | '-' Digit;
Int : Number+ ;
Float : Number+ '.' Digit* ;
String : '"' (ESC | .)*? '"' ;
fragment ESC : '\\' [btnr"\\] ;
LineComment: '//' .*? ~( '\r' | '\n' )* -> channel (1);
BlockComment : '/*' .*? '*/' -> channel (1);
WS: [ \t\n\r]+ -> channel (2);
用下面这个文件简单测试一下
#include "DemoServer.jce"
module servant
{
const int SUCC = 0;
const string SUC = "";
key[CommonInParam, appId, areaId];
/**
* 通用输入参数
*/
struct CommonInParam
{
0 optional int appId; // AppId
1 optional int areaId; // AreaId
2 optional long userId; // 用户ID
3 optional string deviceId; // 设备ID
4 optional string userIp; // 用户IP
5 optional string serverIp; // 服务器IP
};
struct CommonOutResult
{
0 optional int code; // 返回状态码,0成功,其他失败
1 optional string message; // 返回提示消息
};
enum CodeEnum
{
SUCCESS = 0, // 成功
FAIL = -1, // 失败
PARAMETER_ERROR = -2, // 参数错误
DB_ERROR = -3, // 数据库错误
CACHE_ERROR = -4, // 缓存错误
};
interface HerTafExample
{
CommonOutResult ping2();
string ping();
void testCos(CommonInParam inParam, out CommonOutResult outResult);
void testCmq(CommonInParam inParam, vector<string> mqList, out map<string, CommonOutResult> resultMap);
};
};
没有报错,那么语法解析完成。
格式化输出
通用化配置
接着是重中之重了,需要完成对语法树的格式化的通用抽象,这里借鉴了antlr官方提供的一个demo,在此基础上实现了通用语法树的格式化输出。
在antlr的api中提供了对语法树的监听器接口如下:
public interface ParseTreeListener {
void visitTerminal(TerminalNode node);
void visitErrorNode(ErrorNode node);
void enterEveryRule(ParserRuleContext ctx);
void exitEveryRule(ParserRuleContext ctx);
}
我们可以通过ParseTreeWalker
对语法树进行遍历,那么其实我们只需要在这几个方法中实现对应的逻辑即可,举个换行+缩进的例子
@Override
public void enterEveryRule(ParserRuleContext ctx) {
if (newlineBeforeRules.contains(ctx.getClass())) {
writeCR();//换行
}
if (indentRules.contains(ctx.getClass())) {
indent++;//增加缩进
}
}
我们实现了enterEveryRule
这个方法,其含义是在进入每一个语法rule之前,那么我们如果需要实现在某个语法模块(对应到jce
例如 structDefinition
)前换行的操作的话,在这里输出换行即可writeCR();
具体的操作就不一一列举了,通过visitTerminal
、enterEveryRule
、exitEveryRule
这三个方法,我们可以实现几乎大多数的格式化操作。
针对jce我的具体设置
noSpacingBeforeTokens: "(",",",";", ")","]","<",">"
noSpacingAfterTokens: "(","[","<",","
newlineAfterRules:
-JceParser.IncludeDefinitionContext.class
newlineBeforeRules:
-JceParser.StructDefinitionContext.class
-JceParser.ModuleDefinitionContext.class
-JceParser.EnumDefinitionContext.class
-JceParser.EnumDeclarationContext.class
-JceParser.InterfaceDefinitionContext.class
-JceParser.ConstDefinitionContext.class
-JceParser.KeyDefinitionContext.class
-JceParser.MethodDefinitionContext.class
-JceParser.FieldDefinitionContext.class
noIndentTokens: "{", "}"
indentRules:
-JceParser.ModuleDefinitionContext.class
-JceParser.StructDefinitionContext.class
-JceParser.EnumDefinitionContext.class
-JceParser.InterfaceDefinitionContext.class
newlineBeforeTokens: "{", "}"
indentSize: 4
indentType: IndentType.space
保留注释
在语法文件中我们通过隐藏通道跳过了所有注释的处理过程,那么在正常情况下在visitTerminal
这个方法中是不可能遇到注释节点出现的,因此需要在每一节点的前后进行判断是否有注释出现,逻辑如下
@Override
public void visitTerminal(TerminalNode node) {
handleLeftCommentTokens(node);
formatterListener.visitTerminal(node);
handleRightCommentTokens(node);
}
在具体的处理过程中,只需要判断行注释和列注释两种情况,根据具体情况去还原注释的位置即可
关于缩进
目前来说处理缩进的方法应该比较原始,由于时间比较短,还没来得及去研究idea、vscode这些成熟的ide是如何实现不同类型的缩进,例如c++的大括号缩进和java的大括号缩进。现在是通过indentRules
和noIndentTokens
这两个规则去实现简单的缩进。其中indentRules
的原理是将某一个语法规则内部所有的子规则进行缩进,noIndentTokens
如果配置之后,遇到配置的token,则会忽略缩进项。代码如下:
if (!isInParenth()) {
if (noIndentTokens.contains(node.toString())) {
indent--;
}
writeCR();
if (noIndentTokens.contains(node.toString())) {
indent++;
}
}
简单测试一下
然后把下面这个文件进行格式化
module Test
{
struct HelloRequest
{0 require string name;
1 require int ord;
};
struct HelloResponse {
0 require string message; };
interface Hello
{ int hello(HelloRequest tReq, out HelloResponse tRsp);
};
interface Hello2
{
int hello(HelloRequest tReq, out HelloResponse tRsp);
};
};
输出结果如下:
module Test
{
struct HelloRequest
{
0 require string name;
1 require int ord;
};
struct HelloResponse
{
0 require string message;
};
interface Hello
{
int hello(HelloRequest tReq,out HelloResponse tRsp);
};
interface Hello2
{
int hello(HelloRequest tReq,out HelloResponse tRsp);
};
};
总结
目前实现的功能比起idea,vscode提供的功能还是欠缺很多,但是可以快速支持一些简单文件格式化(例如jce)。