【问题标题】:Building a "Semi-Natural Language" DSL in Ruby用 Ruby 构建“半自然语言”DSL
【发布时间】:2026-02-24 07:05:01
【问题描述】:

我对在 Ruby 中构建用于解析微博更新的 DSL 很感兴趣。具体来说,我认为我可以将文本转换为 Ruby 字符串,就像 Rails gem 允许“4.days.ago”一样。我已经有了可以翻译文本的正则表达式代码

@USER_A: give X points to @USER_B for accomplishing some task
@USER_B: take Y points from @USER_A for not giving me enough points

变成类似的东西

Scorekeeper.new.give(x).to("USER_B").for("accomplishing some task").giver("USER_A")
Scorekeeper.new.take(x).from("USER_A").for("not giving me enough points").giver("USER_B")

我可以接受将更新的语法形式化,以便只提供和解析标准化的文本,从而使我能够巧妙地处理更新。因此,似乎更多的是如何实现 DSL 类的问题。我有以下存根类(删除了所有错误检查并用 cmets 替换了一些以最小化粘贴):

class Scorekeeper

  attr_accessor :score, :user, :reason, :sender

  def give(num)
    # Can 'give 4' or can 'give a -5'; ensure 'to' called
    self.score = num
    self
  end

  def take(num)
    # ensure negative and 'from' called
    self.score = num < 0 ? num : num * -1
    self
  end

  def plus
    self.score > 0
  end

  def to (str)
    self.user = str
    self
  end

  def from(str)
    self.user = str
    self
  end

  def for(str)
    self.reason = str
    self
  end

  def giver(str)
    self.sender = str
    self
  end

  def command
    str = plus ? "giving @#{user} #{score} points" : "taking #{score * -1} points from @#{user}"
    "@#{sender} is #{str} for #{reason}"
  end

end

运行以下命令:

t = eval('Scorekeeper.new.take(4).from("USER_A").for("not giving me enough points").giver("USER_B")')
p t.command
p t.inspect

产生预期结果:

"@USER_B is taking 4 points from @USER_A for not giving me enough points"
"#<Scorekeeper:0x100152010 @reason=\"not giving me enough points\", @user=\"USER_A\", @score=4, @sender=\"USER_B\">"

所以我的问题主要是,在此实现的基础上,我有没有做任何事情让自己自责? 有没有人有任何改进 DSL 类本身的示例或对我的任何警告?

顺便说一句,要获取 eval 字符串,我主要使用 sub/gsub 和 regex,我认为这是最简单的方法,但我可能错了。

【问题讨论】:

  • 我应该补充一点,我使用链式方法这样做只是因为我不知道如何使用像 eval("Scorekeeper give 4 to USER_A for doing something") 这样的裸字符串发送它,因为我不知道如何将包含空格的字符串放入方法参数列表中。非常欢迎对此提出想法。

标签: ruby regex parsing dsl


【解决方案1】:

我对您的理解是否正确:您想从用户那里获取一个字符串并使其触发某些行为?

根据您列出的两个示例,您可能可以使用正则表达式。

例如解析这个例子:

@USER_A: give X points to @USER_B for accomplishing some task

使用 Ruby:

input = "@abe: give 2 points to @bob for writing clean code"
PATTERN = /^@(.+?): give ([0-9]+) points to @(.+?) for (.+?)$/
input =~ PATTERN
user_a = $~[1] # => "abe"
x      = $~[2] # => "2"
user_b = $~[3] # => "bob"
why    = $~[4] # => "writing clean code"

但如果有更多的复杂性,在某些时候您可能会发现使用真正的解析器更容易且更易于维护。如果你想要一个与 Ruby 兼容的解析器,我推荐 Treetop:http://treetop.rubyforge.org/

获取一个字符串并将其转换为要评估的代码的想法让我感到紧张。使用 eval 是一个很大的风险,应该尽可能避免。还有其他方法可以实现您的目标。如果您愿意,我很乐意提供一些想法。

关于您建议的 DSL 的问题:您打算在应用程序的另一部分本地使用它吗?或者只是计划将其用作将字符串转换为您想要的行为的过程的一部分?如果不了解更多信息,我不确定什么是最好的,但如果您只是解析字符串,则可能不需要 DSL。

【讨论】:

  • 我真的只是想想出一个健壮的方法来巧妙地解析一个字符串,例如“记分员,给@USER_A Y 分,从@USER_B 那里拿 X 分,因为他是个混蛋。”从这个字符串中,我需要提取“+Y->USER_A”和“-X->USER_B for 'being a jerk'”对于“点+用户”之前的“用户+点”——显然,我不是正则表达式大师)。我只是认为探索 DSL 选项可能是明智的,但由于我实际上并没有在其他地方使用它,所以强大的正则表达式可能是一个更好的选择。会喜欢一些想法。谢谢。
  • 我刚刚在上面的答案中发布了一个示例正则表达式。 (我尝试将其粘贴在这里,但格式不是很好。)
  • 谢谢!这比我拥有的正则表达式要干净得多。我试图通过创建 DSL 来回答的一个问题是如何巧妙地解析它和最后一个评论示例。例如,在用户“给@bob 4 分”之后允许点数也应该是有效的,“for”是可选的,等等。但是我现在所拥有的匹配较小的位和匹配你所写的完整语句可能允许我更好地匹配多个可能的模式,例如,给错误的个人打分错误的分数。谢谢。
  • @mettadore 我建议创建一个正则表达式模式数组并尝试匹配每个模式。 (不要忘记编写测试,否则这可能会变得非常丑陋。)希望这会让你快速上手。如果您发现正则表达式模式的数量超过了 5 或 10 个,那么我肯定会尝试 Treetop - 像解析器一样思考您的问题会使其更加健壮。另一方面,在正则表达式中思考更多的是一种快速而肮脏的解决方案。无论如何,我认为您不需要为此使用 DSL。只要有一个好的 OO 设计就可以了。
【解决方案2】:

这与我对切线项目(旧式文本 MOO)的一些想法相呼应。

我不相信编译器风格的解析器会是程序处理模糊英文文本的最佳方式。我目前的想法让我将对英语的理解分成单独的对象——所以一个盒子理解“打开盒子”而不是“按下按钮”等——然后让这些对象使用某种 DSL 来调用集中式代码实际上使事情发生。

我不确定您是否已经了解 DSL 实际上将如何为您提供帮助。也许你需要先看看英文文本是如何变成 DSL 的。我并不是说你不需要 DSL。你很可能是对的。

至于如何做到这一点的提示?好吧,我想如果我是你,我会寻找特定的动词。每个动词都会“知道”它应该从它周围的文本中得到什么样的东西。因此,在您的示例中,“to”和“from”会期望用户立即关注。

这与您在此处发布的代码并没有特别不同,IMO。

查看my question 的答案可能会有所帮助。一位评论者向我指出了解释器模式,我发现它特别有启发性:有一个很好的 Ruby 示例 here

【讨论】:

  • 天啊。一读到“解释器模式”,我就想“嗯,嗯!”现在我觉得很傻。不太清楚为什么我没有首先探索更简单的解决方案。目前我什至正在阅读“Ruby 中的设计模式”。多么可笑。如果普通的正则表达式不够健壮,这可能是我应该采取的路线。
  • 让你比我聪明——我从没听说过。
【解决方案3】:

基于@David_James 的回答,我提出了一个纯正则表达式的解决方案,因为我实际上并没有在其他任何地方使用 DSL 来构建分数,而只是向用户解析积分。我有两种用于搜索的模式:

SEARCH_STRING = "@Scorekeeper give a healthy 4 to the great @USER_A for doing something 
really cool.Then give the friendly @USER_B a healthy five points for working on this. 
Then take seven points from the jerk @USER_C."

PATTERN_A = /\b(give|take)[\s\w]*([+-]?[0-9]|one|two|three|four|five|six|seven|eight|nine|ten)[\s\w]*\b(to|from)[\s\w]*@([a-zA-Z0-9_]*)\b/i

PATTERN_B = /\bgive[\s\w]*@([a-zA-Z0-9_]*)\b[\s\w]*([+-]?[0-9]|one|two|three|four|five|six|seven|eight|nine|ten)/i

SEARCH_STRING.scan(PATTERN_A) # => [["give", "4", "to", "USER_A"],
                              #     ["take", "seven", "from", "USER_C"]]
SEARCH_STRING.scan(PATTERN_B) # => [["USER_B", "five"]]

正则表达式可能会稍微清理一下,但这让我的语法允许一些有趣的形容词,同时仍然使用“name->points”和“points->name”语法来提取核心信息。它不允许我抓住原因,但这太复杂了,以至于现在我将只存储整个更新,因为在除异常情况之外的所有情况下,整个更新都将与每个分数的上下文相关。获取“给予者”用户名也可以在其他地方完成。

我也写了a description of these expressions,希望其他人可能会发现它有用(这样我就可以回到它并记住那一长串gobbledygook的含义:)

【讨论】: