关于我用Cursor优化了一篇文章:30 分钟学会定制属于你的编程语言

30分钟,定制属于你的编程语言

前言:DSL -- 领域特定语言

嘿,伙计们!今天我们要聊一个特别酷的话题 - DSL(领域特定语言)。别被这个名字吓到,它其实就像是一个"专治各种不服"的编程语言。

让我们先来看看 TypeScript 这个"大明星"。它的类型系统就像是 JavaScript 的"超能力套装",虽然看起来很酷,但实际上它并不满足图灵完备的标准。所以,TypeScript 的类型系统其实就是一个带着自举编译器的外部 DSL。是不是感觉一下子就接地气多了?

DSL 与 GPL 的区别

想象一下,DSL 和 GPL 就像是"专科医生"和"全科医生"的区别:

  1. 应用范围

    • GPL: 就像全科医生,啥病都能看
    • DSL: 就像专科医生,只治特定领域的"病"
  2. 表达能力

    • GPL: 十八般武艺样样精通
    • DSL: 一招鲜吃遍天
  3. 学习曲线

    • GPL: 爬珠穆朗玛峰
    • DSL: 爬家门口的小山坡

DSL 的分类

DSL 家族有两个主要分支:

  1. 内部 DSL

    • 就像是在别人家房子里装修
    • 代表: jQuery、RSpec
    • 优点: 省心省力,还能蹭别人的基础设施
    • 缺点: 装修风格受限于原房子
  2. 外部 DSL

    • 就像自己盖房子
    • 代表: SQL、HTML
    • 优点: 想怎么盖就怎么盖
    • 缺点: 从地基到装修都得自己来

DSL 的优缺点

优点(为什么选择 DSL):

  • 开发效率蹭蹭往上涨
  • 学习成本直线下降
  • 代码可读性堪比小说
  • 错误率低到令人发指

缺点(为什么有时候不选 DSL):

  • 维护成本可能让你怀疑人生
  • 性能开销可能让你钱包哭泣
  • 调试难度堪比解谜游戏
  • 工具支持可能让你想砸键盘

看到这里,你是不是已经对 DSL 有了一个清晰的认识?没错,它就是那个专注于特定领域的编程语言。

仔细想想,你每天都在和哪些 DSL 打交道?

太多了!比如 MarkDown(写文档必备)、HTML(网页骨架)、CSS(网页美容师)、SQL(数据库管家)......

DSL 能解决什么问题?

MarkDown 就像是一个神奇的"文档翻译官",它能把简单的文本变成漂亮的网页。最棒的是,它让产品经理也能写出漂亮的文档,再也不用求着前端工程师帮忙排版了。

说到减轻负担,还记得我们之前聊过的低代码编程吗?它就像是把编程变成了搭积木,让编程变得像玩游戏一样有趣。感兴趣的话,可以看看这篇介绍:速览---低代码编辑器Blocky

1、目标语言与语法设计

1.1、选定编译结果的编程语言

选择目标语言就像选择结婚对象,要慎重!我们有很多选择:Java、Python、C++、JavaScript,甚至汇编语言。本文我们选择 JavaScript(ES6 标准),因为它:

  1. 应用场景广到没朋友
  2. 工具链丰富到挑花眼
  3. 性能表现好到让人感动
  4. 语法简单到让人想哭

1.2、语法设计

让我们来设计一个叫 ArronLang 的 DSL:

名称 语法 功能 编译结果
条件执行语法 if A then B 当 A 为真时执行 B if(A){B}
强等于判断表达式 a eq x 判断 a 是否严格等于 x a === x
循环语法 repeat N times B 重复执行 B N次 for(let i=0;i<N;i++){B}
函数定义 def name(args) do B 定义函数 name function name(args){B}
变量声明 let x = y 声明变量 x let x = y

表中对"条件执行语法"的设计,语义化了扩充一套完整的DSL语法。我们可以参照表中的设计方式,从"语法"到"编译结果"衍生构思,设计更多常见语法来满足编程需求。

在后文中,我们会详细介绍 ArronLang 语言的"条件执行语法"编译方式,以此来描述一个DSL语法从0到1的全过程。

2、DSL编译为通用编程语言

常见地,我们使用抽象代码树的词法分析、语法分析、转换和编译四个过程来将DSL转化为目标语言。

2.1、词法分析:生成 token 对象数组

词法分析将读取字符串形式的代码,将代码按照规则解析、生成由一个个 token 组成的 tokens 数组(令牌流),同时,它会移除空白、注释等。在代码中拆解出 token 对象的常用步骤如下:

  1. 确定 token 类型,如数字、字符串、关键词、变量等
  2. 确定 token 的匹配方法:tokenizer 函数。函数读取代码时,按照代码字符串中字符的下标递增进行迭代,递归执行 tokenizer 函数,根据 token 类型,函数以对应的正则表达式、强等于等方式匹配字符。
  3. 生成 token 对象:token 对象的属性常包括 token 的类型和代码内容。根据实际需要,token 对象也可以携带自身在编辑器中的坐标等辅助信息。
2.1.1 Token 类型定义
javascript 复制代码
const TokenType = {
  // 关键字
  KEYWORD: 'KEYWORD',
  // 标识符
  IDENTIFIER: 'IDENTIFIER',
  // 数字
  NUMBER: 'NUMBER',
  // 字符串
  STRING: 'STRING',
  // 运算符
  OPERATOR: 'OPERATOR',
  // 分隔符
  DELIMITER: 'DELIMITER',
  // 注释
  COMMENT: 'COMMENT',
  // 空白
  WHITESPACE: 'WHITESPACE',
  // 行尾
  EOL: 'EOL',
  // 文件结束
  EOF: 'EOF'
}

一段自定义语法规则的代码"if A do B"转化而成的 tokens 令牌流如下:

javascript 复制代码
let tokens = [
  {
    token: "if",
    type: "Identifier",
  },
  {
    token: "A",
    type: "identifier",
  },
  {
    token: "then",
    type: "identifier",
  },
  {
    token: "B",
    type: "identifier",
  },
];

相对应的词法分析递归函数如下:

javascript 复制代码
function tokenizer(input) {
  let current = 0
  let tokens = []
  while (current < input.length) {
    let char = input[current]
    
    // 匹配注释
    if (char === '/' && input[current + 1] === '/') {
      let value = ''
      char = input[++current]
      while (char !== '\n' && current < input.length) {
        value += char
        char = input[++current]
      }
      tokens.push({ type: 'Comment', value })
      continue
    }

    // 匹配数字
    const NUMBERS = /[0-9]/
    if (NUMBERS.test(char)) {
      let value = ''
      while (NUMBERS.test(char)) {
        value += char
        char = input[++current]
      }
      tokens.push({ type: 'Number', value: Number(value) })
      continue
    }

    //匹配空格,并删去空格
    const WHITESPACE = /\s/
    if (WHITESPACE.test(char)) {
      current++
      continue
    }

    const LETTERS = /[a-z]/i
    //匹配表达式或关键词
    if (LETTERS.test(char)) {
      let value = ''
      while (char !== undefined && LETTERS.test(char)) {
        value += char
        char = input[++current]
      }
      tokens.push({ type: 'Identifier', value })
      continue
    }

    //匹配字符串
    if (char === '"') {
      let value = ''
      char = input[++current]
      while (char !== '"') {
        value += char
        char = input[++current]
      }
      char = input[++current]
      tokens.push({ type: 'string', value })
      continue
    }

    // 匹配运算符
    const OPERATORS = /[+\-*/=<>!&|]/
    if (OPERATORS.test(char)) {
      let value = ''
      while (OPERATORS.test(char)) {
        value += char
        char = input[++current]
      }
      tokens.push({ type: 'Operator', value })
      continue
    }

    // 匹配分隔符
    const DELIMITERS = /[(){}[\],;]/
    if (DELIMITERS.test(char)) {
      tokens.push({ type: 'Delimiter', value: char })
      current++
      continue
    }

    throw new TypeError('I dont know what this character is: ' + char)
  }
  return tokens
}

2.2、语法分析:生成抽象代码树(以下简称AST)

语法分析将每个 token 对象按照一定形式解析,形成树形结构,树的每一层结构称为节点,节点们共同作用于程序代码的静态分析,同时验证语法,抛出语法错误信息。

2.2.1 AST 节点类型定义
javascript 复制代码
const NodeType = {
  // 程序
  Program: 'Program',
  // 函数声明
  FunctionDeclaration: 'FunctionDeclaration',
  // 变量声明
  VariableDeclaration: 'VariableDeclaration',
  // 表达式语句
  ExpressionStatement: 'ExpressionStatement',
  // 块语句
  BlockStatement: 'BlockStatement',
  // If语句
  IfStatement: 'IfStatement',
  // While语句
  WhileStatement: 'WhileStatement',
  // For语句
  ForStatement: 'ForStatement',
  // 二元表达式
  BinaryExpression: 'BinaryExpression',
  // 一元表达式
  UnaryExpression: 'UnaryExpression',
  // 字面量
  Literal: 'Literal',
  // 标识符
  Identifier: 'Identifier',
  // 函数调用
  CallExpression: 'CallExpression',
  // 成员表达式
  MemberExpression: 'MemberExpression'
}
2.2.2 节点构造

语义本身就代表了一个值的节点是字面量节点,在树形结构担任"叶子"角色。由于每一个被解析出的 token 都携带 type(类型)属性,我们很容易通过type属性匹配得到对应的字面量节点。

如:type属性为"string"的 token,其本身的语义就代表了一个 string 类型值,作为叶子,在树结构中没有其他子节点可被其包含,因此可以由其生成一个字面量节点,为其设置 type 属性为"StringLiteral",义为 string 类型字面量。字符串、布尔类型值、正则表达式等亦然。详细的节点命名可参照 AST 对象文档。

如:if 语句作为枝干节点,存在两个必要的属性:test、consequent,这两个属性作为if枝干节点的叶子存在:

  1. test 属性是条件表达式
  2. consequent 属性是条件为 true 时的执行语句,通常是一个块状域节点

string叶子节点和 if 语句节点的树形构建过程如下:

javascript 复制代码
function parser(tokens) {
  let current = 0
  
  // 错误处理
  function throwError(message) {
    throw new SyntaxError(`Line ${current}: ${message}`)
  }

  function walk() {
    let token = tokens[current]
    
    // 错误处理
    if (!token) {
      throwError('Unexpected end of input')
    }

    //  string 类型值   字符串   叶子节点
    if (token.type === 'string') {
      current++
      return {
        type: 'StringLiteral',
        value: token.value,
      }
    }

    // if 语句  枝干节点
    if (token.type === 'Identifier' && token.value === 'if') {
      let node = {
        type: 'IfStatement',
        name: 'if',
        test: [],
        consequent: [],
      }
      token = tokens[++current]
      if (token && token.type === 'Identifier' && token.value !== 'then') {
        node.test.push(walk())
        token = tokens[current]
      } else {
        throwError('缺少条件')
      }
      if (token && token.type === 'Identifier' && token.value === 'then') {
        node.consequent.push(walk())
        token = tokens[current]
      } else {
        throwError('缺少关键词:then')
      }
      return node
    }

    // then节点,会成为if节点的子节点
    if (token.type === 'Identifier' && token.value === 'then') {
      let node = {
        type: 'BlockStatement',
        params: [],
      }
      token = tokens[++current]
      if (token && token.type === 'Identifier') {
        node.params.push(walk())
        token = tokens[current]
      } else {
        throwError(`错误节点:${token.value}`)
      }
      return node
    }

    //假装匹配表达式A和B
    //在实际情况中,表达式较为复杂,如表达式   2 === 1  ,需要设计详细的表达式匹配规则
    if (token.type === 'Identifier' && token.value === 'A') {
      current++
      token = tokens[current]
      return {
        type: 'fake',
        value: 'A',
      }
    }
    if (token.type === 'Identifier' && token.value === 'B') {
      current++
      token = tokens[current]
      return {
        type: 'fake',
        value: 'B',
      }
    }
    throwError(token.value)
  }

  let ast = {
    type: 'Program',
    body: [],
  }
  while (current < tokens.length) {
    ast.body.push(walk())
  }
  return ast
}

2.3、AST转换

在此步骤,我们把AST结构转为适合编译的形态,将参数或关键字写入树的节点中。为每种节点设计了不同类型的转换函数: visitor[type].enter,运用 traverseNode 函数深度遍历每一个节点进行转化。

javascript 复制代码
const visitor = {
  StringLiteral: {
    enter(node, parent) {
      const expression = {
        type: 'StringLiteral',
        value: node.value,
      }
      parent._context.push(expression)
    },
  },
  fake: {
    enter(node, parent) {
      const expression = {
        type: 'FakeExpression',
        value: node.value,
      }
      parent._context.push(expression)
    },
  },
  BlockStatement: {
    enter(node, parent) {
      let expression = {
        type: 'BlockStatement',
        arguments: [],
      }
      node._context = expression.arguments
      parent._context.push(expression)
    },
  },
  IfStatement: {
    enter(node, parent) {
      console.log(node)
      let expression = {
        type: 'IfStatement',
        callee: {
          type: 'Identifier',
          name: 'if',
        },
        arguments: [],
      }
      node._context = expression.arguments
      parent._context.push(expression)
    },
  },
}

function traverser(ast) {
  function traverseArray(array, parent) {
    array.forEach((child) => {
      traverseNode(child, parent)
    })
  }
  function traverseNode(node, parent) {
    let methods = visitor[node.type]
    if (methods && methods.enter) {
      methods.enter(node, parent)
    }

    switch (node.type) {
      case 'Program':
        traverseArray(node.body, node)
        break

      case 'BlockStatement':
        traverseArray(node.params, node)
        break

      case 'IfStatement':
        traverseArray(node.test, node)
        traverseArray(node.consequent, node)
        break
      //叶子节点,没有子节点,不进行转换
      case 'StringLiteral':
      case 'fake':
        break

      default:
        throw new TypeError(node.type)
    }

    if (methods && methods.exit) {
      methods.exit(node, parent)
    }
  }
  traverseNode(ast, null)
}

function transformer(ast) {
  let newAst = {
    type: 'Program',
    body: [],
  }
  ast._context = newAst.body
  traverser(ast)

  return newAst
}

2.4、编译AST,生成目标代码

在最后,我们解析转换后的代码树,编译成为JS代码。函数对每种类型的节点提供解析方法,从枝干节点开始,递归解析其子节点并返回编译内容。

javascript 复制代码
function codeGenerator(node) {
  if (node) {
    switch (node.type) {
      //项目
      case 'Program':
        return node.body.map(codeGenerator).join(';\n')
      //if语句节点
      case 'IfStatement':
        let length = node.arguments.length
        console.log('node', node)
        return (
          codeGenerator(node.callee) +
          '(' +
          node.arguments
            .slice(0, length - 1)
            .map(codeGenerator)
            .join(' ') +
          ')' +
          codeGenerator(node.arguments[length - 1])
        )
      //块状域
      case 'BlockStatement':
        return '{' + node.arguments.map(codeGenerator) + '}'
      //关键字
      case 'Identifier':
        return node.name
      //假表达式,用于编译本文中的A、B
      case 'FakeExpression':
        return node.value
      //字符串
      case 'StringLiteral':
        return '"' + node.value + '"'
      default:
        throw new TypeError(node.type)
    }
  }
}

2.5、执行过程及数据

javascript 复制代码
//执行函数:
function complieCode(code = 'if A then B') {
  //tokenizer(code)
  //parser(tokenizer(code))
  //transformer(parser(tokenizer(code)))
  //codeGenerator(transformer(parser(tokenizer(code))))
  return codeGenerator(transformer(parser(tokenizer(code))))
}

//过程代码如下:
//1.ArronLang 代码:    if A then B
//2.tokens令牌流
const testToken = [
  { type: 'Identifier', value: 'if' },
  { type: 'Identifier', value: 'A' },
  { type: 'Identifier', value: 'then' },
  { type: 'Identifier', value: 'B' },
]

//3.tokens 构建 AST
const testParse = {
  type: 'Program',
  body: [
    {
      type: 'IfStatement',
      test: [
        {
          type: 'fake',
          value: 'A',
        },
      ],
      consequent: [
        {
          type: 'BlockStatement',
          params: [
            {
              type: 'fake',
              value: 'B',
            },
          ],
        },
      ],
    },
  ],
}

//4.AST结构转换
const testTansformer = {
  type: 'Program',
  body: [
    {
      type: 'IfStatement',
      callee: {
        type: 'Identifier',
        name: 'if',
      },
      arguments: [
        {
          type: 'FakeExpression',
          value: 'A',
        },
        {
          type: 'BlockStatement',
          arguments: [
            {
              type: 'FakeExpression',
              value: 'B',
            },
          ],
        },
      ],
    },
  ],
}

//5.目标代码(JS):if(A){B}

3、搭建功能完善的在线编译器

为了让自创的DSL具有更高的可用性,还应该为语法设计一套代码报错、代码提示与高亮规则。笔者习惯使用 vscode 进行编码,对 vscode 的编译器风格更加习惯,因此,本节介绍如何接入使用能最大程度模拟 vscode 操作风格的Web端编译器:Monaco-Editor。

3.1、安装Monaco-Editor

A. 下载安装monaco-editor

bash 复制代码
npm install monaco-editor

B. 我的安装目录在

ruby 复制代码
C://Windows//SystemApps//Microsoft.MicrosoftEdgeDevToolsClient_8wekyb3d8bbwe//23//common//monaco-editor/

3.2、一切尽在代码中

html 复制代码
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
  </head>
  <body>
    <div
      id="container"
      style="width: 800px; height: 600px; border: 1px solid grey;"
    ></div>
    <script src="C://Windows//SystemApps//Microsoft.MicrosoftEdgeDevToolsClient_8wekyb3d8bbwe//23//common//monaco-editor//min//vs//loader.js"></script>
    <script type="module">
      require.config({
        paths: {
          vs:'C://Windows//SystemApps//Microsoft.MicrosoftEdgeDevToolsClient_8wekyb3d8bbwe//23//common//monaco-editor//min//vs',
        },
      })
      require(['vs/editor/editor.main'], function () {
        const LangId = 'ArronLang'
        // 注册编辑器语言
        monaco.languages.register({ id: LangId })
        
        // 定制高亮与提示
        monaco.languages.setMonarchTokensProvider(LangId, {
          tokenizer: {
            root: [
              [/\s*(if|then)\s*/, 'IfStatement'],
              [/\s*([A-Za-z0-9\_])\s*/, 'Expression'],
            ],
          },
          keywords: ['if', 'then'],
          whitespace: [
            [/[ \t\r\n]+/, 'white'],
            [/#(.*)/, 'comment', '@comment'],
          ],
        })

        // 添加代码提示
        monaco.languages.registerCompletionItemProvider(LangId, {
          provideCompletionItems: function(model, position) {
            const suggestions = [
              {
                label: 'if',
                kind: monaco.languages.CompletionItemKind.Keyword,
                insertText: 'if ${1:condition} then ${2:action}',
                insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
                documentation: '条件执行语句'
              },
              {
                label: 'then',
                kind: monaco.languages.CompletionItemKind.Keyword,
                insertText: 'then',
                documentation: 'then 关键字'
              }
            ]
            return { suggestions }
          }
        })

        // 添加错误提示
        monaco.languages.registerDiagnosticsAdapter(LangId, {
          getDiagnostics: function(model) {
            const text = model.getValue()
            const errors = []
            
            // 简单的语法检查
            if (!text.includes('then') && text.includes('if')) {
              errors.push({
                severity: monaco.MarkerSeverity.Error,
                message: '缺少 then 关键字',
                startLineNumber: 1,
                startColumn: 1,
                endLineNumber: 1,
                endColumn: text.length
              })
            }
            
            return errors
          }
        })
        
        // 定制主题与样式
        monaco.editor.defineTheme(LangId, {
          base: 'vs',
          inherit: true,
          rules: [
            { token: 'IfStatement', foreground: '840095' },
            { token: 'Expression', foreground: '0082FF' },
          ],
          colors: {
            'editorLineNumber.foreground': '#999999',
          },
        })
        
        let editor = monaco.editor.create(
          document.getElementById('container'),
          {
            value: 'if A then B',
            language: LangId,
            theme: LangId,
            fontSize: 15,
            fontWeight: 400,
            lineHeight: 25,
            letterSpacing: 1,
            automaticLayout: true,
            scrollBeyondLastLine: false,
            renderLineHighlight: 'none',
            minimap: {
              enabled: true
            },
            suggestOnTriggerCharacters: true,
            quickSuggestions: true,
            parameterHints: {
              enabled: true
            }
          }
        )
      })
    </script>
  </body>
</html>

至此,你已经完成了一套DSL的设计以及对应的编译器定制,赶紧打开HTML文件编写你的DSL吧。

3.3、更多尝试

4、结语

希望 DSL 能为你日后工作提供解决思路,解决业务难题。

Arron 快要有一年没更文了,这一年真的变化了很多,不光是 Arron,还有整个编码世界。不知道大家有没有一种感觉,从前需要在掘金检索攻克的技术难题,如今敲给大模型就能得到答案,甚至全套的解决方案。掘金的海量文章和各站的技术文档,被大模型采样学习转化为更一针见血的Agent,输入自然语言就能获得整个世界。

这篇文章我使用了Cursor来帮忙,我让ChatGPT-4.1为我优化了诙谐幽默的行文风格,并补充了基础用例。我想了想,说不定Cursor直接就能写出这篇文章并创造一套更完整的DSL,我让它做这些小事,屈才了。

相关推荐
Sun_light1 分钟前
深入理解JavaScript中的「this」:从概念到实战
前端·javascript
小桥风满袖2 分钟前
Three.js-硬要自学系列33之专项学习基础材质
前端·css·three.js
聪明的水跃鱼7 分钟前
Nextjs15 构建API端点
前端·next.js
小明爱吃瓜23 分钟前
AI IDE(Copilot/Cursor/Trae)图生代码能力测评
前端·ai编程·trae
水冗水孚26 分钟前
🚀四种方案解决浏览器地址栏预览txt文本乱码问题🚀Content-Type: text/plain;没有charset=utf-8
javascript·nginx·node.js
不爱说话郭德纲29 分钟前
🔥Vue组件的data是一个对象还是函数?为什么?
前端·vue.js·面试
绅士玖31 分钟前
JavaScript 中的 arguments、柯里化和展开运算符详解
前端·javascript·ecmascript 6
每天都想着怎么摸鱼的前端菜鸟33 分钟前
【uniapp】uniapp热更新WGT资源,简单的多环境WGT打包脚本
javascript·uni-app
GIS之路34 分钟前
OpenLayers 图层控制
前端
断竿散人34 分钟前
专题一、HTML5基础教程-http-equiv属性深度解析:网页的隐形控制中心
前端·html