【问题标题】:Loading YAML with line number for each key使用每个键的行号加载 YAML
【发布时间】:2015-06-10 08:29:33
【问题描述】:

假设我有一个如下所示的 YAML 文件:

zh: 错误: # 一些评论 格式:“%{attribute} %{message}” # 再来一条评论 留言: “1”:“消息 1” “2”:“消息 2” 长错误消息:| 这是一个 多行消息 日期: 格式:“YYYY-MM-DD”

如何将它读入像这样的 Ruby Hash

{
  'en': {
    'errors': {
      'format': { value: '%{attribute} %{message}', line: 4 }
      'messages': {
        '1': { value: 'Message 1', line: 8 },
        '2': { value: 'Message 2', line: 9 }
      }
      'long_error_message' : { value: "This is a\nmultiline message", line: 11 }
    },
    'date': {
      'format': { value: 'YYYY-MM-DD', line: 16 }
    }
  }
}

我尝试使用YAML: Find line number of key? 中提到的技巧作为起点并实现了Psych::Handler,但感觉我必须从 Psych 重写大量代码才能使其正常工作。

有什么办法可以解决这个问题吗?

【问题讨论】:

  • 我开始研究它,似乎很头疼。我想过猴子修补默认处理程序 (Psych::TreeBuilder) 和 the calling method,但后来我意识到我必须修补 Psych::Nodes 和 Psych::Visitors::ToRuby 并且我只是放弃了。跨度>

标签: ruby yaml


【解决方案1】:

我采用了@matt 的解决方案并创建了一个不需要人工修补的版本。它还处理跨越多行的值和 YAML 的 << 运算符。

require "psych"
require "pp"

ValueWithLineNumbers = Struct.new(:value, :lines)

class Psych::Nodes::ScalarWithLineNumber < Psych::Nodes::Scalar
  attr_reader :line_number

  def initialize(*args, line_number)
    super(*args)
    @line_number = line_number
  end
end

class Psych::TreeWithLineNumbersBuilder < Psych::TreeBuilder
  attr_accessor :parser

  def scalar(*args)
    node = Psych::Nodes::ScalarWithLineNumber.new(*args, parser.mark.line)
    @last.children << node
    node
  end
end

class Psych::Visitors::ToRubyWithLineNumbers < Psych::Visitors::ToRuby
  def visit_Psych_Nodes_ScalarWithLineNumber(node)
    visit_Psych_Nodes_Scalar(node)
  end

  private

  def revive_hash(hash, node)
    node.children.each_slice(2) do |k, v|
      key = accept(k)
      val = accept(v)

      if v.is_a? Psych::Nodes::ScalarWithLineNumber
        start_line = end_line = v.line_number + 1

        if k.is_a? Psych::Nodes::ScalarWithLineNumber
          start_line = k.line_number + 1
        end
        val = ValueWithLineNumbers.new(val, start_line..end_line)
      end

      if key == SHOVEL && k.tag != "tag:yaml.org,2002:str"
        case v
        when Psych::Nodes::Alias, Psych::Nodes::Mapping
          begin
            hash.merge! val
          rescue TypeError
            hash[key] = val
          end
        when Psych::Nodes::Sequence
          begin
            h = {}
            val.reverse_each do |value|
              h.merge! value
            end
            hash.merge! h
          rescue TypeError
            hash[key] = val
          end
        else
          hash[key] = val
        end
      else
        hash[key] = val
      end
    end

    hash
  end
end

# Usage:
handler = Psych::TreeWithLineNumbersBuilder.new
handler.parser = Psych::Parser.new(handler)

handler.parser.parse(yaml)

ruby_with_line_numbers = 
Psych::Visitors::ToRubyWithLineNumbers.create.accept(handler.root)

pp ruby_with_line_numbers

我已经发布了 gist of the above 以及一些 cmets 和示例

【讨论】:

    【解决方案2】:

    我建议您选择@matt 的解决方案。除了更加谨慎之外,它还可以正确处理标量。


    诀窍可能是monkeypatch TreeBuilder#scalar 方法:

    y='
    en:
      errors:
        # Some comment
        format: "%{attribute} %{message}"
    
        # One more comment
        messages:
          "1": "Message 1"
          "2": "Message 2"
    
      long_error_message: |
        This is a
        multiline message
    
      date:
        format: "YYYY-MM-DD"'
    
    require 'yaml'
    
    yphc = Class.new(YAML.parser.handler.class) do
      def scalar value, anchor, tag, plain, quoted, style
        value = { value: value, line: $line } if style > 1 
        $line = $parser.mark.line + 1  # handle multilines properly
        super value, anchor, tag, plain, quoted, style
      end 
    end
    
    $parser = Psych::Parser.new(yphc.new)
    
    # more careful handling required for multidocs    
    result = $parser.parse(y).handler.root.to_ruby[0]
    

    实际上,我们差不多完成了。唯一剩下的就是留下带有行号的修补值只在叶子中。我不是故意把这个逻辑放在解析东西里的。

    def unmark_keys hash
      hash.map do |k,v|
        [k.is_a?(Hash) ? k[:value] : k, v.is_a?(Hash) ? unmark_keys(v) : v]
      end.to_h
    end
    
    p unmark_keys result
    
    #⇒ {"en"=>
    #⇒   {"errors"=>
    #⇒     {
    #⇒       "format"=>{:value=>"%{attribute} %{message}", :line=>4},
    #⇒       "messages"=>
    #⇒          {
    #⇒            "1"=>{:value=>"Message 1", :line=>8}, 
    #⇒            "2"=>{:value=>"Message 2", :line=>9}
    #⇒       }
    #⇒     }, 
    #⇒     "long_error_message"=>{
    #⇒        :value=>"This is a\nmultiline message\n", :line=>11
    #⇒     }, 
    #⇒     "date"=>{"format"=>{:value=>"YYYY-MM-DD", :line=>16}}
    #⇒   }
    #⇒ }
    

    肯定有人想摆脱全局变量等。我试图让核心实现尽可能干净。

    我们开始吧。希望对您有所帮助。

    UPD感谢@matt,上面的代码在标量上失败了:

    key1:
      val1
    
    key2: val2
    

    这种语法是 YAML 允许的,但是上面的方法没有机会正确处理它。不会为此返回任何行。除了令人讨厌的缺乏标量支持之外,其他任何东西都可以正确报告行,请参阅 cmets 到此答案以获取更多详细信息。

    【讨论】:

    • 这在映射值与键不在同一行时不起作用(它将给出键的行号)。我们两个答案的根本问题似乎是我们无法在元素的 start 处获取解析器信息,只能在 end 处获取解析器信息。 (还有一个错误:mark 给出的行是从零开始的,我们希望从 1 开始,所以你需要 +1。这在你的代码中并不明显,因为你在开头有一个空行你的 Yaml 字符串)。
    • @matt 感谢off-by-one error 注意:已修​​复。我不明白“当映射值与键不在同一行时”是什么意思。是 YAML,不是吗?在我的示例中,long_error_message 的行被正确检测到。
    • 问题中给出的示例中没有出现,但请参阅gist.github.com/mattwildig/f109bdea61e9d8742811。在这种情况下,我的解决方案也会受到影响,因为解析器需要继续运行,直到确定元素完成为止。总体而言,您的方法似乎给出了更好的结果,但两者都不准确。
    • @matt Huh。对于您的示例,我的方法给出了 {"line1"=&gt;"line2", "line4"=&gt;{:value=&gt;"line5\nline6", :line=&gt;4}}... 后者 :line =&gt; 4 很好,但 前一种情况没有行,因为 Psych 为它返回 style==1。更新了一个答案来提及这一点。
    • @matt BTW,通过将line2 更改为"line2" 我得到了:{"line1"=&gt;{:value=&gt;"line2", :line=&gt;1}, "line4"=&gt;{:value=&gt;"line5\nline6", :line=&gt;4}}。因此,线路被正确检测;我的代码在检测像a: b 这样的标量时存在问题。
    【解决方案3】:

    我们可以通过递归通过 Psych 提供的已解析哈希并找到每个键的行号来手动添加数字。以下代码将匹配您指定的结果。

    require 'psych'
    
    def add_line_numbers(lines, hash)
      # Ruby cannot iterate and modify a hash at the same time.
      # So we dup the hash and iterate over the dup.
      iterator = hash.dup
      iterator.each do |key, value|
        if value.is_a?(Hash)
          add_line_numbers(lines, value)
        else
          index = lines.index { |line| line =~ /^\s.?*#{key}.?\:/ }
          hash[key] = { "value" => value, "line" => (index + 1) }
        end
      end
    end
    
    yaml_file = File.expand_path('../foo.yml', __FILE__)
    lines = File.readlines(yaml_file)
    data = Psych.load(lines.join("\n"))
    add_line_numbers(lines, data)
    puts data
    

    【讨论】:

    • 谢谢。我不确定是否可以使用正则表达式来查找行号。我用更复杂的 YAML 更新了我的问题。
    • 没问题。我对现在处理更复杂的 YAML 的正则表达式添加了一个调整。我们只需要在字符串键周围允许可选字符。
    • 如果一个键在 YAML 中存在两次(在不同的子哈希中),这不会失败吗?
    【解决方案4】:

    您似乎想要获取作为映射值的任何标量值,并将其替换为带有包含原始值的 value 键和带有行号的 line 键的散列。

    以下几乎可以工作,主要问题是多行字符串,其中给定的行号是 Yaml 中下一件事的开始。问题是,当处理程序scalar 方法被调用时,解析器已经移动到感兴趣的标量之外,所以mark 在它知道标量已经结束时给出位置线。在您的示例中的大多数情况下,这无关紧要,但是对于多行情况,它会给出错误的值。如果不进入 Psych C 代码,我看不到任何从 mark 获取解析器信息以获取标量开头的方法。

    require 'psych'
    
    # Psych's first step is to parse the Yaml into an AST of Node objects
    # so we open the Node class and add a way to track the line.
    class Psych::Nodes::Node
      attr_accessor :line
    end
    
    # We need to provide a handler that will add the line to the node
    # as it is parsed. TreeBuilder is the "usual" handler, that
    # creates the AST.
    class LineNumberHandler < Psych::TreeBuilder
    
      # The handler needs access to the parser in order to call mark
      attr_accessor :parser
    
      # We are only interested in scalars, so here we override 
      # the method so that it calls mark and adds the line info
      # to the node.
      def scalar value, anchor, tag, plain, quoted, style
        mark = parser.mark
        s = super
        s.line = mark.line
        s
      end
    end
    
    # The next step is to convert the AST to a Ruby object.
    # Psych does this using the visitor pattern with the ToRuby
    # visitor. Here we patch ToRuby rather than inherit from it
    # as it makes the last step a little easier.
    class Psych::Visitors::ToRuby
    
      # This is the method for creating hashes. There may be problems
      # with Yaml mappings that have tags.
      def revive_hash hash, o
        o.children.each_slice(2) { |k,v|
          key = accept(k)
          val = accept(v)
    
          # This is the important bit. If the value is a scalar,
          # we replace it with the desired hash.
          if v.is_a? ::Psych::Nodes::Scalar
            val = { "value" => val, "line" => v.line + 1} # line is 0 based, so + 1
          end
    
          # Code dealing with << (for merging hashes) omitted.
          # If you need this you will probably need to copy it
          # in here. See the method:
          # https://github.com/tenderlove/psych/blob/v2.0.13/lib/psych/visitors/to_ruby.rb#L333-L365
    
          hash[key] = val
        }
        hash
      end
    end
    
    yaml = get_yaml_from_wherever
    
    # Put it all together    
    handler = LineNumberHandler.new
    parser =  Psych::Parser.new(handler)
    # Provide the handler with a reference to the parser
    handler.parser = parser
    
    # The actual parsing
    parser.parse yaml
    # We patched ToRuby rather than inherit so we can use to_ruby here
    puts handler.root.to_ruby
    

    【讨论】:

    • 我赞成你的回答,因为它让我知道了如何解决它。诀窍在于,人们可能会简单地欺骗传递{value: value, line: line} 的默认解析器,而不是修补ToRuby 类。您的实现也存在多行问题(您捕获最后一个行号,而 OP 要求捕获第一个。)
    • 你的方法终于赢了:) 在LineNumberHandler 上引入@mark 实例变量,然后在scalar 内部引入:s.line = @mark ; @mark = parser.mark.line
    • 感谢 matt 提供了这个出色的解决方案,感谢 @mudasobwa 找到了一种方法来使它甚至适用于多行字符串!我将此标记为答案。
    猜你喜欢
    • 1970-01-01
    • 2019-05-24
    • 2019-05-16
    • 2011-09-16
    • 2016-03-07
    • 1970-01-01
    • 2017-04-26
    • 2012-03-01
    • 1970-01-01
    相关资源
    最近更新 更多