Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions doc/markdown-samples/MARKUP_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,20 +458,42 @@ nth 番目の要素を返します。
- `undef` — そのクラスで意図的に未定義化されているメソッド
(例: `Complex` における `Comparable` の比較メソッド。旧 `@undef` 相当)。
`kind = :undefined` になり、ページ生成・検索の対象外になる
- `since="X"` / `until="X"` — このシグネチャの名前が Ruby X から存在する
(`since`)/ Ruby X で削除される(`until`。半開区間: X 未満のバージョンでは
存在し、X 以降には存在しない)ことを明示する。X は `"3.2"` のように数字と
ドットのみをダブルクォートで囲んで書く。シグネチャ見出しの横に
since/until バッジとして表示される

規則:

- 属性行は**直前のシグネチャ見出し行のみ**に束縛される(kramdown の Block IAL と
同じ解釈)。別名(複数シグネチャ)のエントリで使う場合はすべてのシグネチャ見出しの
直後にそれぞれ付ける。一部にだけ付いているとビルドエラー
(kind はエントリ単位でしか持てないため)
- ただし `since=`/`until=` は `nomethod`/`undef` と違い**シグネチャ単位**の
値であり、別名ごとに異なるバージョンを持ってよい(全シグネチャで揃える必要は
ない)。追加/削除時期が別名ごとに違う場合が本来の用途:

```markdown
### def -@ -> object
{: since="2.0.0"}
### def dedup -> object
{: since="3.0"}
```

(本体 `-@` は Ruby 2.0.0 から、別名 `dedup` はそれより後の Ruby 3.0 から、
という例。それぞれのシグネチャ見出しの横に別々の since バッジが付く)
- シグネチャ見出し行と属性行が連続している範囲がひとつのエントリ(別名グループ)に
なる。属性行の直後に別エントリを続ける場合は空行で区切る
- パーサーの属性行探索はシグネチャブロックの直後で打ち切る
(コードブロック中の `{: ...}` 風の行を誤検出しない。空行を挟んだ `{: ...}` は
ただの本文)
- 未知の属性はビルドエラー(typo 検出)。`since` / `until` は将来のバージョン情報表示
(バージョン別 DB へのメタデータ付与)のために予約されており、対応するまではエラー
- 未知の属性・不正な形式(`since=X` のような非引用値、`since=""` のような
空値、数字とドット以外を含む値、未知のキーなど)はビルドエラー(typo 検出)
- 明示された `since`/`until` は、全バージョンの DB を横断して自動算出する
`bitclust methodsince` サブコマンドの結果より常に優先される(算出側は既に
値がある名前を上書きしない)。主な用途は自動算出が届かない 1.8.7 以前からの
存在(フロア救済)や、算出結果の補正

---

Expand Down
109 changes: 97 additions & 12 deletions lib/bitclust/rrdparser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
require 'bitclust/preprocessor'
require 'bitclust/methodid'
require 'bitclust/methoddatabase'
require 'bitclust/methodsignature'
require 'bitclust/lineinput'
require 'bitclust/parseutils'
require 'bitclust/nameutils'
Expand Down Expand Up @@ -482,9 +483,9 @@ def define_method(chunk)
attrs = method_attributes(chunk)
@db.open_method(id) {|m|
m.names = chunk.names.sort
m.kind = if attrs.include?('undef')
m.kind = if attrs.flags.include?('undef')
:undefined
elsif attrs.include?('nomethod')
elsif attrs.flags.include?('nomethod')
:nomethod
else
@kind
Expand All @@ -494,6 +495,10 @@ def define_method(chunk)
# steep:ignore:end
m.source = chunk.source
m.source_location = chunk.source.location
attrs.since_until.each do |name, kv|
m.fill_since(name, kv['since'] || raise) if kv['since']
m.fill_until(name, kv['until'] || raise) if kv['until']
end
case @kind
when :added, :redefined
@library.add_method m
Expand All @@ -502,42 +507,122 @@ def define_method(chunk)
end

# 現在サポートするメソッド属性({: ...} 属性行のトークン)。
# since/until は将来のバージョン情報表示(#132)用に予約
# - nomethod/undef: 裸語。kind はエントリ単位でしか持てないので、
# 別名(複数シグネチャ)のエントリでは全シグネチャに同じ属性が
# 付いていることを要求する
# - since="X"/until="X"(bitclust#132 P4): シグネチャ単位で束縛され、
# そのシグネチャの名前だけに適用される。nomethod/undef と違い、
# 別名ごとに異なる値を持てる(全シグネチャ一致は要求しない)のが
# 本来の用途。X は "3.2" のように数字とドットのみ
METHOD_ATTRIBUTES = %w[nomethod undef]
KV_METHOD_ATTRIBUTES = %w[since until]
KV_METHOD_ATTRIBUTE_VALUE_RE = /\A\d+(?:\.\d+)*\z/

# md の `### def name ...`/`### module_function def name ...`/
# `### const name`/`### gvar $name` シグネチャ行を rd 形式
# (`--- name ...`)へ正規化するための接頭辞(MDParser::SIG_RE と
# 同じパターン)。属性の紐付け先となるメソッド名を
# MethodSignature.parse で取り出すために使う
MD_METHOD_SIG_PREFIX_RE = /\A### (?:module_function def |def |const |gvar )/

# method_attributes の返り値。
# - flags: 裸語トークン(nomethod/undef)の集合。エントリ単位の属性
# なので全シグネチャで同一であることを method_attributes が保証済み
# - since_until: シグネチャ単位で束縛された since="X"/until="X" を
# { メソッド名 => { "since" => "X", "until" => "X" } } の形で保持する
MethodAttributes = Struct.new(:flags, :since_until)

# kramdown Block IAL 風の {: ...} 属性行からメタデータを集める。
# 属性行は「直前のシグネチャ行のみ」に束縛される(kramdown と同じ解釈)。
# kind はエントリ単位でしか持てないので、別名(複数シグネチャ)の
# エントリでは全シグネチャに同じ属性が付いていることを要求する。
# 裸語トークン(nomethod/undef)は kind がエントリ単位でしか持てないため
# 全シグネチャで同じであることを要求するが、since=/until= はシグネチャ
# 単位でそのままの名前に適用するため、この一致要求からは除外する。
# 本文に入ったら探索を打ち切る(コード例中の {: ...} を誤検出しないため)
def method_attributes(chunk)
per_sig = [] #: Array[Array[String]]
since_until = {} #: Hash[String, Hash[String, String]]
current_name = nil #: String?
current_keys = nil #: Hash[String, bool]?
chunk.source.each_line do |line_|
line = line_.chomp
case line
when /\A---\s/, /\A\#\#\#\s/
per_sig.push []
current_name = method_attribute_target_name(line)
current_keys = {}
when /\A\{:(.*)\}[ \t]*\z/
break if per_sig.empty?
($1 || raise).strip.split(/\s+/).each do |token|
unless METHOD_ATTRIBUTES.include?(token)
raise ParseError,
"#{chunk.source.location}: unknown method attribute #{token.inspect} (supported: #{METHOD_ATTRIBUTES.join(', ')})"
if METHOD_ATTRIBUTES.include?(token)
per_sig.last&.push token
else
key, value = parse_kv_method_attribute(token, chunk)
keys = current_keys or raise
if keys.key?(key)
raise ParseError,
"#{chunk.source.location}: duplicate method attribute #{key.inspect} on the same signature"
end
keys[key] = true
name = current_name or
raise ParseError,
"#{chunk.source.location}: cannot determine the signature name to bind method attribute #{token.inspect} to"
(since_until[name] ||= {})[key] = value
end
per_sig.last&.push token
end
else
break
end
end
return [] if per_sig.empty?
return MethodAttributes.new([], {}) if per_sig.empty?
sets = per_sig.map {|a| a.uniq.sort }
unless sets.uniq.size == 1
raise ParseError,
"#{chunk.source.location}: method attributes must be the same on every signature of an entry: #{sets.inspect}"
end
sets.first || raise
end
# 紐付け先の名前がエントリの names と食い違ったら黙って捨てずに
# エラーにする(名前導出の規約ずれを CI で検出するための安全網)
since_until.each_key do |name|
unless chunk.names.include?(name)
raise ParseError,
"#{chunk.source.location}: since/until attribute bound to unknown signature name #{name.inspect} (entry names: #{chunk.names.join(', ')})"
end
end
MethodAttributes.new(sets.first || raise, since_until)
end

# since="X"/until="X" 形式のトークンを解析して [key, value] を返す。
# 受理する形式以外(キーが不明・値が非引用・値が数字とドット以外を
# 含む等)はすべて ParseError にする
def parse_kv_method_attribute(token, chunk)
m = /\A(\w+)=(.*)\z/.match(token)
if m
key = m[1] || raise
raw_value = m[2] || raise
quoted = /\A"(.*)"\z/.match(raw_value)
if quoted && KV_METHOD_ATTRIBUTES.include?(key) && KV_METHOD_ATTRIBUTE_VALUE_RE.match?(quoted[1] || raise)
return [key, (quoted[1] || raise)]
end
end
raise ParseError,
"#{chunk.source.location}: invalid method attribute #{token.inspect} " \
"(supported: #{METHOD_ATTRIBUTES.join(', ')}, " \
"#{KV_METHOD_ATTRIBUTES.map {|k| %Q(#{k}="X") }.join('/')} where X is digits and dots, e.g. since=\"3.2\")"
end
private :parse_kv_method_attribute

# シグネチャ行(rd の `--- ...` / md の `### def ...` 等)から、
# 属性の紐付け先となるメソッド名を取り出す。
# 特殊変数のシグネチャ名は "$SAFE" のように $ 付きだが、エントリの
# names は先頭の $ を除いた形("SAFE"。"$$" なら "$")で格納される
# (method_signature の Signature.new(nil, '$', name[1..-1]) と同じ規約)
# ため、since_by_name のキーも names に合わせて $ を1つ剥がす
def method_attribute_target_name(line)
normalized = line.sub(MD_METHOD_SIG_PREFIX_RE, '--- ')
MethodSignature.parse(normalized).name.sub(/\A\$/, '')
rescue ParseError
nil
end
private :method_attribute_target_name

def method_id(chunk)
id = MethodID.new
Expand Down
10 changes: 10 additions & 0 deletions lib/bitclust/version_badges.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ module VersionBadges
# signature's <dt> (+first+ true), instead of being repeated on every
# alias's <dt>; otherwise (mixed/partial) it is looked up per +name+.
def heading_version_badges(entry, name, first)
name = badge_lookup_name(name)
join_badge_spans(
heading_badge_span(entry, name, first, entry.since_map,
SINCE_CSS_CLASS, SINCE_CATALOG_KEY),
Expand All @@ -46,6 +47,7 @@ def heading_version_badges(entry, name, first)
# its own line, so this always looks the version up by +name+ directly
# -- no uniform-across-all-names aggregation, unlike heading_version_badges.
def row_version_badges(entry, name)
name = badge_lookup_name(name)
join_badge_spans(
badge_span(entry.since_map[name], SINCE_CSS_CLASS, SINCE_CATALOG_KEY),
badge_span(entry.until_map[name], UNTIL_CSS_CLASS, UNTIL_CATALOG_KEY)
Expand All @@ -54,6 +56,14 @@ def row_version_badges(entry, name)

private

# シグネチャ行から取り出した名前を since_map/until_map のキーに合わせる。
# 特殊変数のシグネチャ名は "$SAFE" のように $ 付きだが、エントリの names
# (= マップのキー)は先頭の $ を除いた形("SAFE"。"$$" なら "$")で
# 格納されている(rrdparser の method_signature と同じ規約)
def badge_lookup_name(name)
name.sub(/\A\$/, '')
end

def heading_badge_span(entry, name, first, map, css_class, catalog_key)
return nil if map.empty?
version = uniform_map?(entry, map) ? (first ? map.values.first : nil) : map[name]
Expand Down
26 changes: 25 additions & 1 deletion sig/bitclust/rrdparser.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,33 @@ module BitClust

METHOD_ATTRIBUTES: Array[String]

def method_attributes: (Chunk chunk) -> Array[String]
KV_METHOD_ATTRIBUTES: Array[String]

KV_METHOD_ATTRIBUTE_VALUE_RE: ::Regexp

MD_METHOD_SIG_PREFIX_RE: ::Regexp

def method_attributes: (Chunk chunk) -> MethodAttributes

private

def parse_kv_method_attribute: (String token, Chunk chunk) -> [String, String]

def method_attribute_target_name: (String line) -> String?

public

def method_id: (Chunk chunk) -> MethodID

# method_attributes の返り値
class MethodAttributes < Struct[untyped]
attr_accessor flags: Array[String]

attr_accessor since_until: Hash[String, Hash[String, String]]

def self.new: (Array[String] flags, Hash[String, Hash[String, String]] since_until) -> instance
| ...
end
end

class Chunk
Expand Down
2 changes: 2 additions & 0 deletions sig/bitclust/version_badges.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ module BitClust

private

def badge_lookup_name: (String name) -> String

def heading_badge_span: (MethodEntry entry, String name, bool first, Hash[String, String] map, String css_class, String catalog_key) -> String?

def uniform_map?: (MethodEntry entry, Hash[String, String] map) -> bool
Expand Down
45 changes: 45 additions & 0 deletions test/test_mdparser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -490,4 +490,49 @@ def test_method_attribute_on_every_alias_signature
assert_equal(:nomethod, fuga.kind)
assert_equal(['fuga', 'fuga2'], fuga.names)
end

def test_method_attribute_since_is_recorded
md = <<~MD
---
library: _builtin
---
# class Hoge

クラスの説明。

## Instance Methods

### def fuga -> nil
{: since="3.1"}

説明のためのエントリです。
MD
db, = parse_md(md)
entry = db.get_method(BitClust::MethodSpec.parse('Hoge#fuga'))
assert_equal('3.1', entry.since_of('fuga'))
end

def test_method_attribute_since_differs_per_alias_signature
md = <<~MD
---
library: _builtin
---
# class Hoge

クラスの説明。

## Instance Methods

### def fuga -> nil
{: since="2.0.0"}
### def fuga2 -> nil
{: since="3.0"}

説明のためのエントリです。
MD
db, = parse_md(md)
entry = db.get_method(BitClust::MethodSpec.parse('Hoge#fuga'))
assert_equal('2.0.0', entry.since_of('fuga'))
assert_equal('3.0', entry.since_of('fuga2'))
end
end
21 changes: 21 additions & 0 deletions test/test_method_since_calculator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
# [x] apply は冪等: 2回目は floor_skipped 以外すべて0
# [x] 既に値がある名前は上書きしない(著者値が算出値より優先)
# [x] 対象DBのバージョンがラダーに無ければ apply は UserError
# [x] 統合: `{: since="X"}` 属性行(#132 P4)でパース時に記録された値は
# 算出結果で上書きされない(RD ソース上の明示注記が算出値より優先)
class TestMethodSinceCalculator < Test::Unit::TestCase
RD = <<~'RD'
description
Expand All @@ -42,6 +44,13 @@ class TestMethodSinceCalculator < Test::Unit::TestCase
説明
#@end

#@since 3.0
--- overridden
{: since="2.5"}

説明
#@end

#@since 2.0.0
--- -@
#@since 3.0
Expand Down Expand Up @@ -203,6 +212,18 @@ def test_author_value_takes_precedence_over_computed_value
assert_equal '1.9.9', find_entry('3.0', 'i', 'newmeth').since_of('newmeth')
end

def test_author_since_attribute_parsed_from_source_survives_apply
# overridden は #@since 3.0 でゲートされているのでラダー上は 3.0 と算出
# されるが、RD ソースの {: since="2.5"} がパース時点(update_by_stdlibtree)
# で既に記録されているため、apply(fill_since は未設定時のみ書く)では
# 上書きされずそのまま残る
run_calculator('2.0.0')
run_calculator('3.0')

assert_nil find_entry('2.0.0', 'i', 'overridden')
assert_equal '2.5', find_entry('3.0', 'i', 'overridden').since_of('overridden')
end

def test_apply_rejects_target_whose_version_is_not_in_ladder
build_db('4.0')
calc = BitClust::MethodSinceCalculator.new(ladder_dbs)
Expand Down
Loading
Loading