坏蛋Dan
知乎@坏蛋Dan
发布时间:2024.4.25

前言

前几天公司的一个前端大佬用js写了一个库,基于通义千问的api将公司某项目中的js文件给配置上了d.ts。

然后我想着这玩意儿用rust写一个应该挺舒服,毕竟是工具。

代码:


通义千问

通义官网 (aliyun.com)

不过我们这里是基于api来实现的,所以我们实际要去到的网站是:如何使用通义千问API_模型服务灵积(DashScope)-阿里云帮助中心 (aliyun.com)

创建API-KEY

我们要请求它的接口,第一步就是要有请求的权限,官方要求请求得加上DashScope API-KEY:如何开通DashScope并创建API-KEY_模型服务灵积(DashScope)-阿里云帮助中心 (aliyun.com)

这里就不展示创建流程了,官方的文档已经说的很清楚了。


选择模型

我们这里没有特殊要求,所以直接用最简单的大语言模型就好:通义千问模型简介_模型服务灵积(DashScope)-阿里云帮助中心 (aliyun.com)


接口

我们要的接口:POST https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation

然后请求的时候把API-KEY传入header里即可


配置

我们是基于接口请求来实现而不是SDK(问题是也没有呀,虽然也可以通过interface的方式去接入Python的SDK),所以我们这里需要用到:


实现

初始化


Cargo.toml


demo

我们先搞一个请求的demo,确保流程跑的通

}

然后我们在根目录创建一个test文件夹,往里放一个jump.js(不要问为啥叫这名字,从公司项目里随便找了个文件copy过来的。。里面代码我删掉了,毕竟公司的资产)

然后我们cargo run

把多余的话去掉:

不是很对,这说明我们的prompt没写好,不过我们现在的目的不是这个,而是实现请求并且有反馈。目前我们做到了这点。


需求

前面我们确认了这种方案是走得通的,那么我们就基于这个方案做一下拓展,将它变成一个工具:

  1. 要有输入框,用户可以指定要处理的文件(夹)和存放输出的位置
  2. 支持批量处理js,只针对js文件里export的函数(包括箭头函数)
  3. 生成对应的typescript类型
  4. 批量写入d.ts文件

需求分析(简单分析)

  1. 我们需要有三个参数,一个要处理的目标路径input_path,一个要存放处理后文件的路径output_path,其中input_path可以是文件或者文件夹。两个路径需要兼顾相对路径和绝对路径。还需要有个deep♂的参数用来自定义是否需要递归子文件夹
  2. 将相对路径转换成绝对路径,然后根据路径收集文件名和文件内容
  3. 只处理export functionexport const xx = () => {}这两种,所以我们需要对源码进行过滤,这里就需要将js解析成 AST
  4. 将源码发送给通义千问,让它返回给对应类型
  5. 根据目标路径的文件夹结构创建同名文件夹和文件名.d.ts

那么就先简单分析到这里。


代码架构

根据上面我们分析之后的几点,我们先完成代码的架构

  1. 有一个InputArgs,用来获取三个参数和转换成绝对路径
  2. 有一个read,用来获取文件的名字和内容,转换成File结构
  3. 要有compile,即将js源码转换成AST,并且从里面拿到符合要求的源码
  4. 要有一个ask,用来访问通义千问
  5. 要有一个write,将访问得到的内容转换成对应的文件

整理一下:

说一下为啥要这么拆分,因为这么做我们才好做单元测试


获取参数

我们在src下创建一个args的文件夹和一个mod.rs,我们直接把代码放在这个mod.rs(别忘了引入到main.rs中)就好,当然,如果你还想细分,也可以再搞其他的文件。

首先我们来设计下存放输入数据的结构和方法:

  • init作为入口
  • get_input作为数据获取的主要部分
  • absoluted_path将代码转换成绝对路径

然后我们先来实现get_input的部分:

这一部分我们需要获取三个参数input_pathoutput_pathdeep,这里我们就需要借助stdoutstdin,来等待用户输入的内容

  • flush,刷新输出流,确保所有中间缓冲的内容能被我们拿到
  • read_line,这个不用多说,将拿到的数据写入path
  • trim_end_matches,移除path末尾可能存在的\n

get_input主体没啥好说的,最后一个deep参数写起来有些怪,如果你有更好的方案,可以评论区说下~

在开始实现下一个方法之前,我们需要设计下测试用例,并做单元测试(后面我就不写那么详细的测试用例了,麻烦,而且占篇幅挺大的,其实就是懒,不想写):

对应的输出(不要在意\r\n为啥不一起处理):

然后是get_input的:

对应的输出:

ok,没问题,我们来实现第二个方法absouluted_path,即将内容转换成绝对路径

代码稍微长了些,因为我们需要判断这个路径是否是绝对路径,如果不是,我们需要转换成绝对路径,另外我们还需要确认路径是否存在,如果不存在,那么我们还需要帮他创建这个文件路径(当然你也可以直接报错不执行后续的)

  • canonicalize,这个方法可以把当前的相对路径转换成绝对路径,不过如果当前系统中不存在这个文件夹,那么会直接报错,所以我们还需要处理下错误的场景,帮忙创建文件夹,然后再重新将路径转换成绝对路径。
  • 当然,要处理的目标路径这个就不需要我们帮忙创建了,如果没找到直接报错。

其它没啥好说的了,测试用例就不写了,前面说过原因了。

然后我们来实现InputArgs最后一个方法:这个方法简单,只需要组装前面两个方法即可

这个方法没啥好说的

那么到这,获取参数这一块的就完成了。

最后我们可以搞一个整体的测试用例:

这里使用了tempfile,可以创建临时文件夹


读取文件内容

我们在src下创建一个file_io文件夹,里面创建readwritemod.rs(另外两个文件记得在mod.rs中导出)三个文件。

我们现在暂时不涉及write,我们先来实现read的逻辑。我们需要读取文件内容、文件的名字,另外如果输入的目标地址是一个文件夹,并且deeptrue,那么这个时候我们就需要深度递归这个文件夹,并且记录这个过程中子文件夹的名字和路径。

这一点有些复杂,需要用到递归的逻辑,另外子文件夹的路径我们可以使用字符串拼接,然后传递给下次递归,这样可以保证路径是对的,相当于维护一个单调栈,不过是字符串的形式。

我们先设计下输出的数据的结构

  • name:文件名字
  • content:文件内容
  • relative_name:如果deeptrue的话,那么我们还需要记录文件的相对路径,这样在生成文件的时候也能保证相对位置是正确的。

然后就是实现读取的部分,我直接贴代码了,递归相信大家都知道怎么写:

  • metadata,通过路径读取文件(夹)的系统信息

我们这里还单独记录了每个子文件夹的路径,这么做是为了保证输出文件在创建的过程中可以正常创建,因为最终都是绝对路径。

然后就没啥好说的了,接着也是简单的一个测试用例

然后我们把原本test/jump.js文件迁移到新建的test/haha/jump.js

就不写target_input了,直接肉眼看下是否正常:

肉眼peek正常


Compile

这部分稍微复杂一些,因为swc,这个包迭代非常非常频繁,半年前的代码现在已经不能用了(官方自己的example也有的没有更新),然后文档里面不介绍内容,需要一堆子包配合,所以比较复杂。

我们在src下创建一个compile文件夹,里面创建parse.rstransform.rsgen.rsmod.rs四个文件。

在开始写代码之前,我们需要引入几个crate

要注意包的版本,因为更新很频繁!

我们先处理parse

这里swc依赖了一个包:scoped-tls,这个包提供了(旧)标准库的scoped_thread_local!(当前标准库的文档中没看到,只有一个thread_local!, 它用来声明一个拥有其内容的线程本地存储密钥。)的功能,可以让我们自己去impl它。

GLOBALS:来看下相关的源码:

也就是给GLOBALS创建一个ScopedKey,有点类似前端localStorage的用法。现在GLOBALS有一个全局的唯一本地线程秘钥和空间。

  • ScopedKey set:前面实现了ScopedKey之后拥有的方法,它接收一个参数和一个闭包,参数会在闭包运行的过程中存在,可以调用with拿到。我们这里用不到这个数据,我们用到只是因为swc官方要求的。。。
  • Lrc//! Lrc is an alias of either Rc or Arc.
  • SourceMapspan(节点最小单位),作为span的中间器(interner)。这里面存储的span都只是指针([BytePos])指向数据的位置。简单地说,我们可以通过它拿到节点的信息。
  • Handler:这个就不多说了(相关的比如EmitterWriter也不多说了,会用即可),用来处理、收集错误的。
  • new_source_file:顾名思义,创建一个source_file,一个source_map可以存储多个source_file
  • Lexer:官方没有给这货和Capturing任何的注释,但是看名字就知道是一个词法分析器,看它的配置项比如target支持设置不同的ES版本,比如ES5、ES6也可以推测出。我们暂时就不去深入了解了。
  • Parser:解析器
  • get_source_file:顾名思义,就是用来获取文件对象信息的,src即是源码,是一个Arc<String>。注意我们不能使用我们自己的源码,它内部应该是处理过的,我们后面根据节点的startend是根据它这个处理过的源码进行定位的,所以如果我们用自己的源码容易出现偏移。

其实我也不太了解怎么用,官方没有文档介绍怎么用,迭代又非常频繁导致别人的demo很容易过期,跟着过期攻略 + 官方example自己组装的。。。以后有机会可以来分析下源码。

然后我们随便搞个测试用例:

输出结果:

可以看到和babelAST类似,毕竟解析逻辑是一致的。

然后我们来到transform阶段,我们需要的是export functionexport const xx = () => {},所以我们需要写一个visitor用来visit所有的节点:

然后我们可以实现官方提供的Visit in swc_ecma_visit对象,它内部支持实现对不同类型节点的visit。这一点和babel的类似:

  • visit_export_decl:这个方法看名字就知道是用来访问export节点的,然后我们再根据它进行分析:

这里的逻辑就多说了,相信大家都看得懂。

visitor写完了,我们准备来实现接入visit的过程:

  • HELPERS:和Globals一样,我们需要这个HELPERS包裹,这样才能处理js中比如async这种语法。
  • resolver:看下官方的描述:

何时运行

解析器需要“干净”的 ast。您可以通过解析或通过 删除 AST 节点中的所有语法上下文。

它有什么作用

首先,所有作用域(fn、block)都有自己的 SyntaxContext。 Resolver 访问模块中的所有标识符,并在作用域中查找绑定标识。这些标识符现在具有范围 (fn, block) 的 SyntaxContext。执行此操作时,解析程序会尝试将普通标识符(无卫生(hygiene)信息)解析为对范围标识符的引用。如果解析器找到合适的变量,则标识符引用将与变量具有相同的上下文。

简单地说我们可以通过它去修改节点,类似babel里的transform阶段。

  • preset_env:这个我也不清楚干啥的,后面这段transformer的应该是不需要的,我是参考的过期攻略。

不管怎么样,代码是写完了,照例搞一个测试用例:

对应输出结果:

可以看到正确的拿到了我们的函数。

最后还有个gen.rs,这个实际上不是ast阶段的,只是为了好看我给放到这里了。。

哦,忘了说了,因为官方api每次请求都是有token长度限制的,所以我对每个文件还进行了二次拆分,具体限制可以自行看官方文档:

通义千问如何计量计费和查看账单_模型服务灵积(DashScope)-阿里云帮助中心 (aliyun.com)

你可以通过这个接口去确认是否超了:

Token计算API详情_模型服务灵积(DashScope)-阿里云帮助中心 (aliyun.com)


请求通义千问api

接下来就是请求阶段了,这块是我耗时最久的,不过原因则是因为通义千问官方的apiQPM(每分钟最多请求)< 200(实际更少),所以我这里要调整每秒的请求频率,很麻烦。

我们同样在src下创建一个ask文件夹,里面放client.rsmod.rs

代码没啥好说的,我直接贴出来了:

}

因为大模型有时候话很多,所以我只能是去捕捉````typescript`里的代码,注意这里用到了regex的包。

我这里没写测试用例,主要是忘了。

不过没关系,到这里我们就基本差最后一步了,可以直接在main中组装。


main中组装阶段性结果

我这里就不跑了,你们可以自行运行下


批量性写入

没啥好说的,直接上代码:

还记得前面read时收集的子文件夹相对路径集合么?我们在这里先根据这个集合创建相对文件夹,然后再创建文件,这样就不会报错了。

这里简单搞一个测试用例:

对应输出:

ok


最终组装

然后我们直接运行,不搞测试用例了。(记得先设置你的API-KEY

对应输出:

这就完成了,随便找个文件看下:color.d.ts

表现比较正常(当然,有时候也很抽风)

最后我们运行cargo build --release生成最终产物exe文件,这样就拿到立即执行文件了。


总结

中间漏了一些代码,具体可以去github上看下,这里就不多说了。

这个工具比较简单,中间的一些过程可以抽离变成一个通用的结构,这样以后写一些工具就可以灵活变通了。