2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RubyAdvent Calendar 2022

Day 10

YAML 上の位置を取得する

Last updated at Posted at 2022-12-09

YAML 上の位置を取得する

はじめに

YAML は設定ファイルのフォーマットとして、Ruby 界隈ではよく使われるフォーマットです。Ruby に同梱されている gem を使えば、簡単に、パースを行い、Ruby のオブジェクトに変換することができます。

YAML 上の文法エラーがあった場合、位置情報と共に例外があがるので、どこでエラーがあったかが簡単にわかります。しかし、変換後の結果に不備があった場合、Ruby のオブジェクトへの変換の際に位置情報を落としているので、どこに問題があるのか分かりません。なので、入力の YAML とにらめっこする羽目になります。

Ruby 2.6 に同梱されている Psych から、YAML の AST を扱うクラスに位置情報が含まれるようになりました。これを用いて、変換後のオブジェクトに位置情報を追加してみようと思います。

方針

YAML の基本構成要素である、Array/Hash とその値オブジェクトに対して、以下の方針で位置情報を付加することにします。

  • Array/Hash には開始点の位置情報を付加する
  • Array 中にある値オブジェクト全てに位置情報を付加する
  • Hash の構成要素であるキーと値オブジェクトのうち、値オブジェクトにのみ位置情報を付加する
    • 値の取り出しが面倒になりそうなので、キーには位置情報を付加しない

例えば、以下のような YAML の場合、

children:
  - name: Kanta
    age: 5
  - name: Kaede
    age: 1

以下の様に展開されますが、

{ # Hash 1
  "children" => [ # Array 1
    { "name" => "Kanta", "age" => 5 }, # Hash 2
    { "name" => "Kaede", "age" => 1 }  # Hash 3
  ]
}

付加される位置情報は以下のようになります。

Object Line Column
Hash 1 1 1
"children" NA NA
Array 1 2 3
Hash 2 2 5
"name" NA NA
"Kanta" 2 11
"age" NA NA
5 3 10
Hash 3 4 5
"name" NA NA
"Kaede" 4 11
"age" NA NA
1 5 10

位置情報を付加する

概要

YAML から Ruby オブジェクトへの変換は、

  1. 入力された文字列をパースし、AST を構築する
  2. AST を走査し、種類に応じて Ruby オブジェクトに変換する

の様に行われます。AST のノードオブジェクトは、

  • 値の種類を示すフラグ
  • 開始位置情報と終了位置情報

を持っているので、AST ノードを Ruby オブジェクトに変換する際に、

  • 種類を示すフラグを確認する
  • 付加対象のオブジェクトなら、ノードから開始位置情報を取得し、変換後のオブジェクトに付加

すれば良さそうです。ただし、ドキュメントにある通り、ノードには Hash のキーかを示すフラグはないので、別途付加する必要があり、これは AST を構築する際に行う必要があります。

Hash のキーかどうかの判定

先述したとおり、AST ノードのベースクラスである Psych::Nodes::Node には、Hash のキーかどうかを示すフラグはありません。まずは、Psync::Nodes::Node に、#refine を使って、Hash のキーかどうかを示すフラグを追加します。

module MyNodeExtension
  refine Psych::Nodes::Node do
    def mapping_key?
      @mapping_key
    end

    attr_writer :mapping_key
  end
end

using MyNodeExtension

Psych::TreeBuilder が AST の組み立てを担当しており、メソッド #scala で値を表す AST ノードの生成と、親ノードへの追加が行われます。キーも値も一緒くたに子ノードとして親ノードに追加されます。なので、親ノードが Hash かつ奇数個目の子ノードであれば、Hash のキーと判定できます。インスタンス変数 @last で親ノードを取得できるので、Hash のキーかどうかの判定は以下のようになります。

class MyTreeBuilder < Psych::TreeBuilder
  def scalar(value, anchor, tag, plain, quoted, style)
    node = super
    node.mapping_key = @last.mapping? && @last.children.size.odd?
    node
  end
end

位置情報の付加

変換後のオブジェクトに位置情報を付加したとしても、付加前とできる限り同じように扱いたいものです。委譲を手早く実現するために、Delegator を利用して、変換後のオブジェクトに位置情報を持たせます。

class MyValue < SimpleDelegator
  def initialize(value, line, column)
    super(value)
    @line = line
    @column = column
  end

  attr_reader :line, :column
end

Psych::Visitors::ToRuby が、AST の走査を行い、Ruby オブジェクトへの変換を行っています。メソッド #accept が AST ノードを受け取り、ノードの種類に応じた Ruby オブジェクトへの変換を行っています。なので、このメソッドで、位置情報の付加を行うことができます。

class MyVisitor < Psych::Visitors::ToRuby
  def accept(node)
    object = super
    if node.mapping? || node.sequence? || (node.scala? && !node.mapping_key?)
      # 0 始まりなので、start_line/start_column に +1 する必要がある
      MyValue.new(object, node.start_line + 1, node.start_column + 1)
    else
      object
    end
  end
end

YAML の読み込み

既存メソッドを参考に、YAML をパースし、Ruby オブジェクトに変換するメソッドを作ります。

def my_yaml_load(yaml)
  parser = Psych::Parser.new(MyTreeBuilder.new)
  parser.parse(yaml)
  ast = parser.handler.root.children[0]

  visitor = MyVisitor.create
  visitor.accept(ast)
end

早速、使ってみます。

yaml = <<~YAML
  children:
    - name: Kanta
      age: 5
    - name: Kaede
      age: 1
YAML

obj = my_yaml_load(yaml)

p [obj.line, obj.column] #=> [1, 1]
p [obj['children'].line, obj['children'].column] #=> [2, 3]
p [obj['children'][0].line, obj['children'][0].column] #=> [2, 5]
p [obj['children'][0]['name'].line, obj['children'][0]['name'].column] #=> [2, 11]
p [obj['children'][0]['age'].line, obj['children'][0]['age'].column] #=> [3, 10]
p [obj['children'][1].line, obj['children'][1].column] #=> [4, 5]
p [obj['children'][1]['name'].line, obj['children'][1]['name'].column] #=> [4, 11]
p [obj['children'][1]['age'].line, obj['children'][1]['age'].column] #=> [5, 10]

無事に期待通りの位置情報を取り出すことができました。

注意

位置情報を持つオブジェクトの実装に Delegator を使用しました。これに関連し、以下の点を注意する必要があります。

#is_a? の扱い

MyValue#is_a? が true を返すのは、移譲先のクラスではなく、MyValue の場合です。
image.png
#is_a? をオーバーライドするのか、別のメソッドを用意するの検討する必要があります。

Kernel#Integer に関して

Kernel#Integer に文字列を与えた場合、基数を指定しなくても、接頭辞をみて適切な基数で整数に変換してくれます。しかし、移譲先が文字列な MyValueKernel#Integer に与えてもこの機能は働きません。
image.png
これは、「接頭辞を見て変換する」機能が C レベルで実装されてるためです。なので、これを期待通りに動作させるには、以下の様に、Kernel#Integer を上書きする必要があります。

module Kernel
  alias_method :__orignal_Integer, :Integer

  def Integer(arg, base = 0, exception: true)
    arg = arg.__getobj__ if arg.is_a?(::Delegator)
    __orignal_Integer(arg, base, exception: exception)
  end
end

さいごに

今回作成したコードは、https://gist.github.com/taichi-ishitani/4ed3cdf848ca0feb425afc2752341ca4 から入手できます。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?