Haml attributeの仕様

この記事はHaml 4.1.0.beta.1について書いています。最初は最新の安定板であるHaml 4.0.7について書いていたんですが、4.0.7だと書き方によってattributeが消えたりしてバグっぽいので、betaですが4.1.0について書くことにしました。

解説は全て自分用のメモです。

# 値がArray, Hash, true, false, nil以外のケース
以下のHamlのレンダリング結果は何でしょうか。`haml -t ugly ファイル名`のコマンドの出力(`escape_attrs: true, escape_html: false, format: :html5, ugly: true`)で答えてください。なお、Rubyとして有効な警告の出ないコードにコンパイルされるので、シンタックスエラーは不正解です。

```haml
- c = 'c'
- ::D = Struct.new(:id)
#a{ id: 'b' }(id=c)[D.new('e')]
.a{ :class=> 'b' }(class='c')[D.new('e')]
%div{ data: 'a' }(data='b')
```

<details>
<summary>答えはこちら</summary>
```
<div class='d' id='a_c_b_d_e'></div>
<div class='a b c d' id='d_e'></div>
<div data='a'></div>
```
</details>

## 解説
### idのパース
```haml
#a{ id: 'b' }(id=c)[D.new('e')]
```

このコードの`Haml::Parser#parse_tag`の返り値の2つ目(`attributes`)は`"#a"`で、`Haml::Parser.parse_class_and_id(attributes)`の結果は:

```rb 
{"id"=>"a"}
```

3つ目(`attributes_hashes`)は:

```rb
{:old=>" id: 'b' ", :new=>[{}, "{\"id\" => c,}"]}
```

<details>
<summary>attributes_hashesの詳細</summary>
attributes_hashes[:new] は、 static_attributes: {}, attributes_hash: "{\"id\" => c,}" になる
attributes_hashes[:old] は、 Haml::Parser#parse_static_hash の結果、static_attributes: nil になる

:id=> ならstatic hashという扱いになるが、id: のパースに対応していない。
</details>

4つ目(`object_ref`)は:

```rb
"[D.new('e')]"
```

<details>
<summary>object_refの詳細</summary>
\#&lt;struct D id="e"&gt; は、 Haml::Buffer#parse_object_refの結果、  {"id"=>"d_e", "class"=>"d"} になる
</details>

になる。つまり

| 返り値/コード | 結果 |
|:----|:-----|
|`Haml::Parser.parse_class_and_id(attributes)`| `{"id"=>"a"}` |
|`attributes_hashes`|`{:old=>" id: 'b' ", :new=>[{}, "{\"id\" => c,}"]}`|
|`Haml::Parser#parse_static_hash(attributes_hashes[:old])`|`nil`|
|`object_ref`|`"[D.new('e')]"`|

### idのマージ
`Haml::Buffer.merge_attrs`は複数のidをマージする時`_`でjoinする。

1. `Parser.parse_class_and_id(attributes)`の結果が`attributes`のベースになる
  - attributes: `{"id"=>"a"}`
2. `attributes_hashes[:new]`の1つ目`static_attributes`がマージされる
  - 2つ目`attributes_hash`が`attributes_list`に突っ込まれてコンパイラに渡される
  - attributes: `{"id"=>"a"}`, attributes_list: `["{\"id\" => c,}"]`
3. `Haml::Parser#parse_static_hash(attributes_hashes[:old])`の結果がマージされる
  - 結果がnilの場合`attributes_list`に突っ込まれてコンパイラに渡される
  - attributes: `{"id"=>"a"}`, attributes_list: `["{\"id\" => c,}", " id: 'b' "]`
4. `Haml::Compiler#compile_tag` がパーサーから`attributes: attributes, attributes_list: attributes_list, object_ref: object_ref`を受け取る
  - object_ref が空(:nil)かつ`attributes_hashes`も空で`!preserve_script`がtrue(`~`, `&~`, `!~`ではない)の場合、attributesだけで`Compiler.build_attributes`される
  - その他の場合、`"_hamlout.attributes(#{inspect_obj(attributes)}, #{object_ref},#{attributes_hashes.join(', ')})"`がレンダリングされる
  - コード: `_hamlout.attributes({"id"=>"a"}, [D.new('e')], {"id" => c,},  id: 'b' )`
5. `Haml::Buffer#attributes`が`attributes_hashes`の1つ目をstringify_keysしたものをマージする
  - attributes: `{"id"=>"a_c"}`
6. `Haml::Buffer#attributes`が`attributes_hashes`の2つ目をstringify_keysしたものをマージする
  - attributes: `{"id"=>"a_c_b"}`
7. obj_refが存在すれば`Haml::Buffer#parse_object_ref(obj_ref)`の結果をマージする
  - attributes: `{"id"=>"a_c_b_d_e", "class"=>"d"}`

### classのパース
これはidの例とは別の場所がstaticにパースされるような例にしてある。

```haml
.a{ :class=> 'b' }(class='c')[D.new('e')]
```

このコードの`Haml::Parser#parse_tag`の返り値の2つ目(`attributes`)は`".a"`で、`Haml::Parser.parse_class_and_id(attributes)`の結果は:

```rb 
{"class"=>"a"}
```

3つ目(`attributes_hashes`)は:

```rb
{:old=>" :class=> 'b' ", :new=>[{"class"=>"c"}, nil]}
```

<details>
<summary>attributes_hashesの詳細</summary>
attributes_hashes[:new] は、 static_attributes: {"class"=>"c"}, attributes_hash: nil になる。これがidの例と逆。
attributes_hashes[:old] は、 Haml::Parser#parse_static_hash の結果、static_attributes: {"class"=>"b"} になる。
:class=> はパースできるので、staticという扱いになる。

</details>

4つ目(`object_ref`)は同じ。つまり

| 返り値/コード | 結果 |
|:----|:-----|
|`Haml::Parser.parse_class_and_id(attributes)`| `{"class"=>"a"}` |
|`attributes_hashes`|`{:old=>" :class=> 'b' ", :new=>[{"class"=>"c"}, nil]}`|
|`Haml::Parser#parse_static_hash(attributes_hashes[:old])`|`{"class"=>"b"}`|
|`object_ref`|`"[D.new('e')]"`|

### classのマージ
`Haml::Buffer.merge_attrs`は複数のclassをマージする時uniqやsortを行い、` `でjoinする。

1. `Parser.parse_class_and_id(attributes)`の結果が`attributes`のベースになる
  - attributes: `{"class"=>"a"}`
2. `attributes_hashes[:new]`の1つ目`static_attributes`がマージされる
  - 2つ目`attributes_hash`が`attributes_list`に突っ込まれてコンパイラに渡される
  - attributes: `{"class"=>"a c"}`, attributes_list: `[nil]`
3. `Haml::Parser#parse_static_hash(attributes_hashes[:old])`の結果がマージされる
  - 結果がnilの場合`attributes_list`に突っ込まれてコンパイラに渡される
  - attributes: `{"class"=>"a b c"}`, attributes_list: `[nil]`
4. `Haml::Compiler#compile_tag` がパーサーから`attributes: attributes, attributes_list: attributes_list, object_ref: object_ref`を受け取る
  - object_ref が空(:nil)かつ`attributes_hashes`も空で`!preserve_script`がtrue(`~`, `&~`, `!~`ではない)の場合、attributesだけで`Compiler.build_attributes`される
  - その他の場合、`"_hamlout.attributes(#{inspect_obj(attributes)}, #{object_ref},#{attributes_hashes.join(', ')})"`がレンダリングされる
  - コード: `_hamlout.attributes({"class"=>"a b c"}, [D.new('e')])`
5. `Haml::Buffer#attributes`が`attributes_hashes`の1つ目をstringify_keysしたものをマージする
  - attributes: `{"class"=>"a b c"}` (attributes_hashesが空なのでスキップ)
6. `Haml::Buffer#attributes`が`attributes_hashes`の2つ目をstringify_keysしたものをマージする
  - attributes: `{"class"=>"a b c"}` (attributes_hashesが空なのでスキップ)
7. obj_refが存在すれば`Haml::Buffer#parse_object_ref(obj_ref)`の結果をマージする
  - attributes: `{"class"=>"a b c d", "id"=>"d_e"}`

### その他のパース
```haml
%div{ data: 'a' }(data='b')
```

| 返り値/コード | 結果 |
|:----|:-----|
|`Haml::Parser.parse_class_and_id(attributes)`| `{}` |
|`attributes_hashes`|`{:old=>" data: 'a' ", :new=>[{"data"=>"b"}, nil]}`|
|`Haml::Parser#parse_static_hash(attributes_hashes[:old])`|`nil`|
|`object_ref`|`nil`|

### その他のマージ
`Haml::Buffer.merge_attrs`は複数のid,class以外のキーをマージする時、後にマージされた方を優先する。

1. `Parser.parse_class_and_id(attributes)`の結果が`attributes`のベースになる
  - attributes: `{}`
2. `attributes_hashes[:new]`の1つ目`static_attributes`がマージされる
  - 2つ目`attributes_hash`が`attributes_list`に突っ込まれてコンパイラに渡される
  - attributes: `{"data"=>"b"}`, attributes_list: `[nil]`
3. `Haml::Parser#parse_static_hash(attributes_hashes[:old])`の結果がマージされる
  - 結果がnilの場合`attributes_list`に突っ込まれてコンパイラに渡される
  - attributes: `{"data"=>"b"}`, attributes_list: `[nil, " data: 'a' "]`
4. `Haml::Compiler#compile_tag` がパーサーから`attributes: attributes, attributes_list: attributes_list, object_ref: object_ref`を受け取る
  - object_ref が空(:nil)かつ`attributes_hashes`も空で`!preserve_script`がtrue(`~`, `&~`, `!~`ではない)の場合、attributesだけで`Compiler.build_attributes`される
  - その他の場合、`"_hamlout.attributes(#{inspect_obj(attributes)}, #{object_ref},#{attributes_hashes.join(', ')})"`がレンダリングされる
  - コード: `_hamlout.attributes({"data"=>"b"}, nil,  data: 'a' )`
5. `Haml::Buffer#attributes`が`attributes_hashes`の1つ目をstringify_keysしたものをマージする
  - attributes: `{"class"=>"a"}`
6. `Haml::Buffer#attributes`が`attributes_hashes`の2つ目をstringify_keysしたものをマージする
  - attributes: `{"class"=>"a"}` (空なのでスキップ)
7. obj_refが存在すれば`Haml::Buffer#parse_object_ref(obj_ref)`の結果をマージする
  - attributes: `{"class"=>"a"}` (空なのでスキップ)

# true, false, nilのケース
今度はこれはどうなるでしょうか。


```haml
- ::D = Struct.new(:id)
#static{ id: true }(id=false)[D.new(nil)]
%div{ id: true }(id=true)
%div{ id: true }(id=false)
%div{ id: false }(id=true)
%div{ id: false }(id=false)

.static{ class: true }(class=nil)[D.new(false)]
%div{ class: true }(class=true)
%div{ class: false }(class=true)
%div{ class: true }(class=false)
%div{ class: false }(class=false)

%div{ data: 'true' }(data=true)
%div{ data: true }(data=true)
%div{ data: false }(data=true)
%div{ data: true }(data=false)
%div{ data: false }(data=false)
```

<details>
<summary>答えはこちら</summary>
```
<div class='d' id='static_true_d_new'></div>
<div id='true_true'></div>
<div id='true'></div>
<div id='true'></div>
<div class='d static true' id='d_new'></div>
<div class='true'></div>
<div class='true'></div>
<div class='true'></div>
<div></div>
<div data='true'></div>
<div data></div>
<div></div>
<div data></div>
<div></div>
```

</details>

## idの場合
- false, nilが来てもなかったことにされる
- trueが来ても`to_s`され、`"true"`と同じに解釈される
- 複数のマージは`"_"`でjoin
- 全てnilかfalseならレンダリングされない
- object_refは、値がnil/falseの時idに`_new`がつく

## classの場合
- false, nilが来てもなかったことにされる
- trueが来ても`to_s`され、`"true"`と同じに解釈される
- 複数のマージはuniq & sort
- object_refは、そもそもclassに関しては値が影響しない

## その他
- old attribute(`{...}`)の値がnew attribute(`(...)`)の値を上書きする
  - id, class以外は、単に後にマージした方が優先され、かつoldが後からマージされるため
- `true`と`"true"`は区別される
- マージ結果がnil/falseならレンダリングされない
- `foo: true`の時、format=html5なら`data`、format=xhtmlなら`data='data'`がレンダリングされる

# 値にHashを含むケース
くぅ〜w 疲れましたw

```haml
- str = 'str'
- hash1 = { 'k1' => 'v1', 'key1' => { 'nk1' => 'nv1' }}
- hash2 = { 'k1' => 'v2', 'key1' => { 'nk2' => 'nv2' }}
#static{ id: 'str' }(id=hash1)
#static{ id: hash1 }(id=str)
%div{ id: hash1 }
%div{ id: hash1 }(id=hash2)
%div{ id: hash2 }(id=hash1)

.static{ class: 'str' }(class=hash1)
.static{ class: hash1 }(class=str)
%div{ class: hash1 }
%div{ class: hash1 }(class=hash2)

%div{ data: hash1 }
%div(data=hash2)
%div{ data: hash1 }(data='str')
%div{ data: 'str' }(data=hash1)
%div{ data: hash1 }(data=hash2)
%div{ data: hash2 }(data=hash1)
```

<details>
<summary>答えはこちら</summary>
```
<div id='static_{"k1"=&gt;"v1", "key1"=&gt;{"nk1"=&gt;"nv1"}}_str'></div>
<div id='static_str_{"k1"=&gt;"v1", "key1"=&gt;{"nk1"=&gt;"nv1"}}'></div>
<div id='{"k1"=&gt;"v1", "key1"=&gt;{"nk1"=&gt;"nv1"}}'></div>
<div id='{"k1"=&gt;"v2", "key1"=&gt;{"nk2"=&gt;"nv2"}}_{"k1"=&gt;"v1", "key1"=&gt;{"nk1"=&gt;"nv1"}}'></div>
<div id='{"k1"=&gt;"v1", "key1"=&gt;{"nk1"=&gt;"nv1"}}_{"k1"=&gt;"v2", "key1"=&gt;{"nk2"=&gt;"nv2"}}'></div>
<div class='"key1"=&gt;{"nk1"=&gt;"nv1"}} static str {"k1"=&gt;"v1",'></div>
<div class='"key1"=&gt;{"nk1"=&gt;"nv1"}} static str {"k1"=&gt;"v1",'></div>
<div class='{"k1"=&gt;"v1", "key1"=&gt;{"nk1"=&gt;"nv1"}}'></div>
<div class='"key1"=&gt;{"nk1"=&gt;"nv1"}} "key1"=&gt;{"nk2"=&gt;"nv2"}} {"k1"=&gt;"v1", {"k1"=&gt;"v2",'></div>
<div data-k1='v1' data-key1-nk1='nv1'></div>
<div data-k1='v2' data-key1-nk2='nv2'></div>
<div data-k1='v1' data-key1-nk1='nv1' data='str'></div>
<div data-k1='v1' data-key1-nk1='nv1' data='str'></div>
<div data-k1='v1' data-key1-nk1='nv1' data='str'></div>
<div data-k1='v1' data-key1-nk1='nv1' data='str'></div>
```

</details>

## idの場合
- 値は全てto_sされる
- キーが被っても、マージされた順序でto_sの結果を並べて`"_"`でjoinするだけ

## classの場合
- 値は全てto_sされる
- キーが被った場合、to_s後uniq & sortする

## その他
- hyphenate(ネストしたレベルごとに`"-"`で連結)される
- キーが被った場合
  - 片方がHashの場合は両方の値が保持され、Hashの方がキー名がhyphenateされ、そうでない方はキーがそのままのため両方個別にレンダリングされる
  - Hash同士のマージでは、`Hash#merge!`するだけなので浅いマージ
     - そういえば4.1.0.beta.1はバグってるから連続して書いてレンダリングすると壊れるけど、本当は後にマージした値(old attribute)が採用される意図のはず

# Arrayを含むケース
まだあるんだった…idとclassだけ特別な挙動をするんですよ〜

```haml
- str = 'str'
- arr1 = ['arr1_1', 'arr1_2']
- arr2 = ['arr2_1', 'arr2_2']
#static{ id: 'str' }(id=arr1)
#static{ id: arr1 }(id=str)
%div{ id: arr1 }
%div{ id: arr1 }(id=arr2)
%div{ id: arr2 }(id=arr1)

.static{ class: 'str' }(class=arr1)
.static{ class: arr1 }(class=str)
%div{ class: arr1 }
%div{ class: arr1 }(class=arr2)
%div{ class: arr2 }(class=arr1)

%div{ data: arr1 }
%div{ data: arr1 }(data='str')
%div{ data: 'str' }(data=arr1)
%div{ data: arr1 }(data=arr2)
%div{ data: arr2 }(data=arr1)

```

<details>
<summary>答えはこちら</summary>
```
<div id='static_arr1_1_arr1_2_str'></div>
<div id='static_str_arr1_1_arr1_2'></div>
<div id='arr1_1_arr1_2'></div>
<div id='arr2_1_arr2_2_arr1_1_arr1_2'></div>
<div id='arr1_1_arr1_2_arr2_1_arr2_2'></div>
<div class='arr1_1 arr1_2 static str'></div>
<div class='arr1_1 arr1_2 static str'></div>
<div class='arr1_1 arr1_2'></div>
<div class='arr1_1 arr1_2 arr2_1 arr2_2'></div>
<div class='arr1_1 arr1_2 arr2_1 arr2_2'></div>
<div data='["arr1_1", "arr1_2"]'></div>
<div data='["arr1_1", "arr1_2"]'></div>
<div data='str'></div>
<div data='["arr1_1", "arr1_2"]'></div>
<div data='["arr2_1", "arr2_2"]'></div>
```

</details>

## idの場合
- マージされた順に`"_"`でjoinされるんじゃないだろうか
  - flattenされるので、ネストしたarrayは平になる。それ以外はto_sされる

## classの場合
- flatten, sort, uniqした上で`" "`でjoinされるんじゃないかな
  - flatten後の値は全てto_s

## その他
- 普通。単に後にマージされる方が採用される。
  - 後になるのは大体はold attributeだけど、old attributeのkeyとvalueが両方文字列リテラルかつハッシュロケットの場合、new attributeがarrayだったらそっちが後になる