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 オブジェクトへの変換は、
- 入力された文字列をパースし、AST を構築する
- 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
の場合です。
#is_a?
をオーバーライドするのか、別のメソッドを用意するの検討する必要があります。
Kernel#Integer
に関して
Kernel#Integer
に文字列を与えた場合、基数を指定しなくても、接頭辞をみて適切な基数で整数に変換してくれます。しかし、移譲先が文字列な MyValue
を Kernel#Integer
に与えてもこの機能は働きません。
これは、「接頭辞を見て変換する」機能が 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 から入手できます。