diff --git a/docs/architecture.md b/docs/architecture.md index 07fce59..ffc5c98 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,3 +61,15 @@ CLI ツール (約 2000 行) では、抽象を足すほど薄いディレクト C++ の厳密な依存解析は難しいので、識別子名の照合で近似する。余分に拾う分には安全 (取りこぼしだけが依存漏れ = コンパイルエラーになる) なので、メンバ名やマクロ名まで拾っても気にしない。 例外は namespace 名だけ。`atcoder` のような名前はほぼ全ファイルに現れ、定義として登録すると逆引きで大半のヘッダーが依存扱いになって tree-shaking が効かなくなる。過剰検出が裏目に出る唯一のケースなので、namespace 名は集めない (中のメンバは個別に拾う)。詳細は identifiers.rs の `NAME_NODES` を参照。 + +## 実装ファイルは「実装先の型名」で引く + +宣言と実装が別ファイルに分かれたライブラリでは、識別子名の照合だけでは依存を辿れない依存が生じる。典型は演算子オーバーロードで、利用側は `f * g` と書くだけで `operator*=` というトークンは現れず、実装ファイル (`operator*=` の本体だけを置いたファイル) が逆引きに引っかからない。過剰検出側に倒す方針でも、これは「取りこぼし」なので放置できない。 + +素朴な対策 (必要ファイルを include するファイルを全部残す / 識別子検出を残存コード全体へ広げる) はいずれも巻き込みが連鎖して tree-shaking をほぼ無効化する (Nyaan's Library で実測)。決め手は、クラス外実装が `void FormalPowerSeries::set_fft()` のように**実装先の型名を必ず自分で書いている**という観察。そこで登録時にこの型名 (クラス外修飾定義の修飾側・明示的特殊化の主テンプレート名) を `implements` として別に記録し、バンドル時に「必要ファイルが定義する型名を実装先に持つファイルも必要」と引く。識別子の逆引きと同型の引き方をもう 1 種足し、増えなくなるまで反復する。これは「余分に残すのは安全」の一貫であり、実装先を静的に特定できない依存 (自由関数の演算子だけのファイル等) は依然取りこぼすが、その場合も提出時のコンパイル/リンクエラーとして顕在化する。 + +## tags.json のスキーマ変化は黙って吸収する + +tags.json はライブラリ実体から決定的に再生成できるキャッシュであり、可搬性を持たない (`path` は絶対パス)。だから schema_version がバイナリの現行と食い違うのは「ユーザーの落ち度」ではなく「risundle の都合」であり、ユーザー操作を要求すべきでない。バンドル時に食い違う登録をライブラリ実体から黙って作り直す (`std` の初回自動登録と同じ発想)。 + +この非対称が肝: **ユーザーの内容が変わった (ハッシュ不一致) は確認させ、risundle の形式が変わった (スキーマ不一致) は黙って直す**。前者は意味的な事象なので `update` か `--no-check` の判断をユーザーに委ね、後者はキャッシュの世代交代なので自動で吸収する。`update`・`add-std` は登録を作り直すコマンドなので、スキーマ非依存の中核 (`Registration` = `path` + 任意の `compilers`) だけを検証せず読んで回復する。`list`・`show` は読み取りに徹し、再生成せず読める情報だけ出す。 diff --git a/docs/spec.ja.md b/docs/spec.ja.md index 925c6b7..027ce21 100644 --- a/docs/spec.ja.md +++ b/docs/spec.ja.md @@ -17,7 +17,7 @@ README より踏み込んだ、各コマンドの挙動・エラー条件・出 - `add ` — `` をインクルードパスとして登録する。 - `` が `std` の場合はエラー (標準ライブラリは `add-std` を使う)。 - 既に同じ `` が登録済みの場合はエラー。 - - 登録時に、各ファイルの定義識別子一覧と、`` 以下の内容から計算した集約ハッシュを記録する。 + - 登録時に、各ファイルの定義識別子一覧・実装先の型名一覧と、`` 以下の内容から計算した集約ハッシュを記録する。実装先の型名とは、クラス外の修飾付き定義 (`X<...>::method`) の修飾側や明示的特殊化 (`template <> struct T<...>`) の主テンプレート名で、そのファイルが「どの型の実装か」を表す。 - `add-std []` — 標準ライブラリ (`std`) を登録する。 - `` (省略時 `g++`) のシステム include 探索パスを自動検出する。 - 繰り返し呼ぶと、その都度コンパイラを認識集合に加える (加算式)。集合全コンパイラの探索パスを 1 つに統合するため、複数コンパイラを使い分けられる。 @@ -28,7 +28,7 @@ README より踏み込んだ、各コマンドの挙動・エラー条件・出 - `list` — 登録済みライブラリの ID とインクルードパスを一覧する。 - `show [-v | --verbose] ` — ライブラリの詳細を表示する。未登録の場合はエラー。 - 既定では ID・インクルードパス・種別・定義識別子を持つファイル数を表示する。 - - `-v` 指定時は、集約ハッシュと各ファイルの定義識別子一覧も表示する。 + - `-v` 指定時は、集約ハッシュ、各ファイルの定義識別子一覧、実装先の型名一覧も表示する。 ## バンドル @@ -54,6 +54,16 @@ README より踏み込んだ、各コマンドの挙動・エラー条件・出 tree-shaking がうまくいかないときの一時的なフォールバック用途を想定しており、`.risundlerc.toml` からは設定できない (設定ファイルで常用できるようにすると、CLI から tree-shaking を有効に戻すオプションが対で必要になり、仕様が複雑化するため)。 +### 実装ファイルの維持 + +宣言と実装が別ファイルに分かれたライブラリでは、演算子オーバーロードのように識別子として検出できない依存が生じる (利用側は `f * g` と書くだけで、`operator*=` というトークンは現れない)。このため識別子の逆引きに加えて、次の規則を適用する。 + +> 必要と判定されたファイルが定義する型名を「実装先の型名」として持つファイルも、必要とみなす。 + +新たに必要になったファイルがあれば `-M` の必要集合を取り直し、増えなくなるまで繰り返す (対象は出力に現れたファイルに限られ単調増加のため、必ず停止する)。実装先の照合対象はあくまで出力に現れたファイルであり、include されていない実装ファイルを補うことはしない。 + +実装先を静的に特定できない修飾 (decltype・依存名など) は記録されず、この規則の対象にならない。名前の付いた定義もクラス外実装も持たないファイル (自由関数の演算子だけを定義するファイル等) も対象外で、tree-shaking で消え得る。 + ### 出力形式 - 先頭行に `// Bundled with risundle v` のクレジットを付ける。 @@ -72,12 +82,15 @@ tree-shaking がうまくいかないときの一時的なフォールバック ```json { - "schema_version": 1, - "path": "/usr/local/include", + "schema_version": 2, + "path": "/home/user/cp-library", "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "files": { - "atcoder/modint.hpp": ["modint", "modint_base", "modint_common"], - "atcoder/segtree.hpp": ["segtree"] + "matrix/matrix.hpp": ["Matrix", "identity"], + "matrix/matrix-mul.hpp": ["pow"] + }, + "implements": { + "matrix/matrix-mul.hpp": ["Matrix"] } } ``` @@ -86,14 +99,15 @@ tree-shaking がうまくいかないときの一時的なフォールバック ```json { - "schema_version": 1, + "schema_version": 2, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/x86_64-linux-gnu-g++-14"] } ``` -- `schema_version`: スキーマの互換性チェック用の整数。未知の値の場合は再生成を促すエラーを出す。 +- `schema_version`: スキーマの互換性チェック用の整数。現行と異なる値の登録は、バンドル時にライブラリ実体から自動で再生成する (キャッシュの形式差は risundle 側の都合であり、ユーザー操作を要求しない)。`update`・`add-std` も同じく登録パス (`std` ならコンパイラ集合) だけを読んで作り直すため、スキーマが合わなくても回復できる。`list`・`show` は読み取りに徹し、再生成せず読める情報だけを出す (`show` は詳細の代わりに再生成の案内を出す)。 - `path`: インクルードパス (絶対パス)。`std` では検出したシステム include パスのうち代表 (最初のコンパイラの C++ 標準ライブラリ dir) を表示用に記録する。 - `compilers`: `std` のみ。std を認識させたコンパイラの集合 (実体の絶対パスへ正規化済み)。 - `hash`: `path` 以下の全ファイルの相対パスと内容から計算した集約ハッシュ。`std` では持たない。 - `files`: ライブラリルートからの相対パスをキーとし、そのファイルが定義する識別子名の配列を値とする。`std` では持たない。`std` 以外は識別子が無くても空オブジェクト `{}` を持つため、構造上 `std` と区別できる。 +- `implements`: ライブラリルートからの相対パスをキーとし、そのファイルの実装先の型名の配列を値とする。実装先を持たないファイルはキー自体を持たない。`std` では持たない。読み込み時にキーが無い場合は空として扱う。 diff --git a/docs/spec.md b/docs/spec.md index 14be413..248fa8a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -17,7 +17,7 @@ This document goes deeper than the README, covering each command's behavior, err - `add ` — Register `` as an include path. - Errors if `` is `std` (use `add-std` for the standard library). - Errors if the same `` is already registered. - - On registration, records the list of defined identifiers for each file and an aggregate hash computed from the contents under ``. + - On registration, records the list of defined identifiers for each file, the list of implementation target type names, and an aggregate hash computed from the contents under ``. An implementation target is the qualifier of an out-of-class qualified definition (`X<...>::method`) or the primary template name of an explicit specialization (`template <> struct T<...>`), expressing "which type this file implements". - `add-std []` — Register the standard library (`std`). - Auto-detects the system include search paths of `` (default `g++`). - Calling it repeatedly adds that compiler to the recognized set each time (additive). It merges the search paths of all compilers in the set into one, so you can switch between multiple compilers. @@ -28,7 +28,7 @@ This document goes deeper than the README, covering each command's behavior, err - `list` — List the IDs and include paths of registered libraries. - `show [-v | --verbose] ` — Show details of a library. Errors if not registered. - By default, shows the ID, include path, kind, and the number of files that have defined identifiers. - - With `-v`, also shows the aggregate hash and the list of defined identifiers per file. + - With `-v`, also shows the aggregate hash, the list of defined identifiers per file, and the implementation target type names. ## Bundling @@ -54,6 +54,16 @@ With `--no-tree-shaking`, identifier detection, dependency-header reverse lookup This option is intended as a temporary fallback for when tree-shaking goes wrong, and it cannot be set from `.risundlerc.toml` (allowing it in the configuration file would require a paired CLI option to turn tree-shaking back on, complicating the specification). +### Keeping implementation files + +In a library that splits declarations and implementations across files, some dependencies cannot be detected as identifiers — operator overloads are the typical case (the user writes `f * g`; the token `operator*=` never appears). In addition to the identifier reverse lookup, the following rule therefore applies. + +> A file whose implementation target names include a type defined by an already-needed file is also considered needed. + +Whenever new files become needed, the `-M` required set is recomputed, repeating until nothing is added (the candidates are limited to files present in the output and grow monotonically, so this always terminates). The matching only considers files present in the output; implementation files that are not included are never pulled in. + +Qualifiers whose target cannot be determined statically (decltype, dependent names, etc.) are not recorded and are outside this rule. Files with neither a named definition nor an out-of-class implementation (e.g. a file defining only free-function operators) are also outside it and may still be dropped by tree-shaking. + ### Output format - The first line carries the credit `// Bundled with risundle v`. @@ -72,12 +82,15 @@ When `` is not `std`: ```json { - "schema_version": 1, - "path": "/usr/local/include", + "schema_version": 2, + "path": "/home/user/cp-library", "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "files": { - "atcoder/modint.hpp": ["modint", "modint_base", "modint_common"], - "atcoder/segtree.hpp": ["segtree"] + "matrix/matrix.hpp": ["Matrix", "identity"], + "matrix/matrix-mul.hpp": ["pow"] + }, + "implements": { + "matrix/matrix-mul.hpp": ["Matrix"] } } ``` @@ -86,14 +99,15 @@ When `` is `std`: ```json { - "schema_version": 1, + "schema_version": 2, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/x86_64-linux-gnu-g++-14"] } ``` -- `schema_version`: An integer for schema compatibility checks. On an unknown value, it raises an error prompting regeneration. +- `schema_version`: An integer for schema compatibility checks. Registrations whose value differs from the current one are automatically regenerated from the library sources at bundle time (a cache format difference is risundle's own concern and requires no user action). `update` and `add-std` likewise read only the registered path (or the compiler set for `std`) and rebuild, so they recover even from a mismatched schema. `list` and `show` stay read-only and never regenerate, printing what they can read (`show` prints regeneration guidance in place of the details). - `path`: The include path (absolute). For `std`, it records a representative one among the detected system include paths (the C++ standard library dir of the first compiler) for display. - `compilers`: `std` only. The set of compilers that `std` was registered with (normalized to the absolute paths of the actual binaries). - `hash`: An aggregate hash computed from the relative paths and contents of all files under `path`. Not present for `std`. - `files`: Keys are paths relative to the library root, and values are the arrays of identifier names that the file defines. Not present for `std`. Non-`std` libraries always carry an empty object `{}` even when there are no identifiers, so they are structurally distinguishable from `std`. +- `implements`: Keys are paths relative to the library root, and values are the arrays of implementation target type names of the file. Files without implementation targets have no key. Not present for `std`. Treated as empty when the key is missing on load. diff --git a/src/bundle/inventory.rs b/src/bundle/inventory.rs index ce4fc06..a95dad9 100644 --- a/src/bundle/inventory.rs +++ b/src/bundle/inventory.rs @@ -1,6 +1,7 @@ //! 登録済みライブラリの突き合わせ用インベントリ。`tags.json` を読み込み、バンドルの各工程が必要と -//! する 4 つの問い合わせに答える: インクルードパスの組み立て (`-I`)、`-nostdinc` の要否、ハッシュ -//! 検証、そして `識別子 → 依存ヘッダー` の逆引き。維持指定 (keep) と種別 (`std` / 通常) を保持し、 +//! する問い合わせに答える: インクルードパスの組み立て (`-I`)、`-nostdinc` の要否、ハッシュ検証、 +//! `識別子 → 依存ヘッダー` の逆引き、そして `定義した型 → その実装ファイル` の逆引き。維持指定 +//! (keep) と種別 (`std` / 通常) を保持し、 //! 「維持指定された (tree-shaking 対象外の) ライブラリと `std` は識別子情報を使わない」という仕様の //! 区別を、各メソッドで一貫して適用する。 @@ -27,6 +28,12 @@ struct Library { kind: TagsKind, } +/// 1 ファイルの `tags.json` レコード。定義識別子と実装先の型名を対で保持する。 +struct FileTags<'a> { + defines: &'a [String], + implements: &'a [String], +} + pub struct Inventory { libraries: Vec, } @@ -116,27 +123,59 @@ impl Inventory { present .iter() .filter(|path| { - self.defined_identifiers(path) - .is_some_and(|names| names.iter().any(|name| used.contains(name))) + self.file_tags(path) + .is_some_and(|tags| tags.defines.iter().any(|name| used.contains(name))) + }) + .cloned() + .collect() + } + + /// `present` のうち、`needed` 内のいずれかのファイルが定義する型を実装しているファイルを返す。 + /// + /// 「実装している」は登録時に記録した実装先の型名 (`tags.json` の `implements`) と、`needed` 側の + /// 定義識別子との照合で判定する。演算子オーバーロードのような、定義識別子として現れない依存を + /// 拾うための逆引き。 + pub fn implementation_files( + &self, + needed: &BTreeSet, + present: &BTreeSet, + ) -> BTreeSet { + let needed_names: BTreeSet<&String> = needed + .iter() + .filter_map(|path| self.file_tags(path)) + .flat_map(|tags| tags.defines) + .collect(); + present + .iter() + .filter(|path| { + self.file_tags(path) + .is_some_and(|tags| tags.implements.iter().any(|t| needed_names.contains(t))) }) .cloned() .collect() } - /// realpath 済みパスが属する維持指定外ライブラリを特定し、そのファイルが `tags.json` で定義する - /// 識別子一覧を返す。linemarker の絶対パスを `files` の相対キーへ (`/` 区切り・`path` prefix 除去で) - /// 対応づける処理がここに集約される。定義を持たないファイルや対象外パスは `None`。 - fn defined_identifiers(&self, canonical: &Path) -> Option<&[String]> { + /// realpath 済みパスが維持指定外ライブラリ配下なら、そのファイルの `tags.json` レコードを返す。 + /// 対象外パスは `None`。ライブラリ配下だが定義や実装先を持たないファイルは、対応するスライスが + /// 空になる。linemarker の絶対パスを相対キーへ (`/` 区切り・`path` prefix 除去で) 対応づける処理が + /// ここに集約される。 + fn file_tags(&self, canonical: &Path) -> Option> { for lib in &self.libraries { if lib.keep { continue; } - let TagsKind::Library { files, .. } = &lib.kind else { + let TagsKind::Library { + files, implements, .. + } = &lib.kind + else { continue; }; if let Ok(relative) = canonical.strip_prefix(&lib.path) { let key = relpath::to_slash(relative).ok()?; - return files.get(&key).map(Vec::as_slice); + return Some(FileTags { + defines: files.get(&key).map(Vec::as_slice).unwrap_or_default(), + implements: implements.get(&key).map(Vec::as_slice).unwrap_or_default(), + }); } } None @@ -192,9 +231,15 @@ mod tests { } fn library_kind(files: &[(&str, &[&str])]) -> TagsKind { - TagsKind::Library { - hash: "sha256:placeholder".to_owned(), - files: files + library_kind_with_implements(files, &[]) + } + + fn library_kind_with_implements( + files: &[(&str, &[&str])], + implements: &[(&str, &[&str])], + ) -> TagsKind { + let to_map = |entries: &[(&str, &[&str])]| { + entries .iter() .map(|(key, names)| { ( @@ -202,7 +247,12 @@ mod tests { names.iter().map(|n| (*n).to_owned()).collect(), ) }) - .collect(), + .collect() + }; + TagsKind::Library { + hash: "sha256:placeholder".to_owned(), + files: to_map(files), + implements: to_map(implements), } } @@ -284,6 +334,57 @@ mod tests { assert_eq!(headers, BTreeSet::from([dsu])); } + #[test] + fn implementation_files_are_found_via_needed_definitions() { + let local = TempDir::new().unwrap(); + let store = LocalStore::with_root(local.path()); + let lib_path = register( + &store, + "mylib", + library_kind_with_implements( + &[("fps.hpp", &["FPS"]), ("other.hpp", &["other"])], + &[ + ("fps-impl.hpp", &["FPS"]), + ("unrelated-impl.hpp", &["Tree"]), + ], + ), + ); + let fps = lib_path.join("fps.hpp"); + let implementation = lib_path.join("fps-impl.hpp"); + let present = BTreeSet::from([ + fps.clone(), + implementation.clone(), + lib_path.join("other.hpp"), + lib_path.join("unrelated-impl.hpp"), + ]); + + let inventory = Inventory::load(&store, &keep_set(&["std"])).unwrap(); + let files = inventory.implementation_files(&BTreeSet::from([fps]), &present); + + // needed が定義する FPS の実装ファイルだけが選ばれ、別の型 (Tree) の実装は巻き込まない。 + assert_eq!(files, BTreeSet::from([implementation])); + } + + #[test] + fn implementation_files_outside_present_are_not_pulled() { + let local = TempDir::new().unwrap(); + let store = LocalStore::with_root(local.path()); + let lib_path = register( + &store, + "mylib", + library_kind_with_implements(&[("fps.hpp", &["FPS"])], &[("fps-impl.hpp", &["FPS"])]), + ); + let fps = lib_path.join("fps.hpp"); + + let inventory = Inventory::load(&store, &keep_set(&["std"])).unwrap(); + // present に無い (= include されていない) ファイルは、実装先が一致しても補わない。 + assert!( + inventory + .implementation_files(&BTreeSet::from([fps.clone()]), &BTreeSet::from([fps])) + .is_empty() + ); + } + #[test] fn files_absent_from_output_are_never_pulled_even_if_they_define_the_identifier() { // include されない test/example/*.cpp が `main` を定義していても、present に無ければ依存に @@ -382,6 +483,7 @@ mod tests { kind: TagsKind::Library { hash: real_hash, files, + implements: BTreeMap::new(), }, } .save(&store.tags_json("lib")) diff --git a/src/commands/bundle.rs b/src/commands/bundle.rs index e0cfcf5..bcf08eb 100644 --- a/src/commands/bundle.rs +++ b/src/commands/bundle.rs @@ -33,6 +33,7 @@ pub fn run(args: BundleArgs) -> Result<()> { if let Err(err) = cmd_library::auto_setup_std(&store) { eprintln!("warning: failed to auto-register the standard library: {err:#}"); } + cmd_library::auto_migrate(&store)?; let settings = Settings::resolve(&args)?; let inventory = Inventory::load(&store, &settings.keep)?; @@ -170,8 +171,19 @@ fn unused_origins( // 出力に現れた維持指定外ライブラリのファイル群。これが逆引きの母集合かつ不要判定の候補になる。 let present: BTreeSet = pruneable.values().cloned().collect(); let used = detect::identifiers(&target_code); - let dependency_headers = inventory.dependency_headers(&used, &present); - let needed = needed_headers(&settings.compiler, compiler_args, &dependency_headers)?; + let mut dependency_headers = inventory.dependency_headers(&used, &present); + + // 必要ファイルが定義する型の実装ファイル (演算子オーバーロード等、識別子に現れない依存) を + // 逆引きで加え、増えなくなるまで `-M` の必要集合と交互に更新する。dependency_headers は単調 + // 増加で present に有界なので必ず停止する。 + let needed = loop { + let needed = needed_headers(&settings.compiler, compiler_args, &dependency_headers)?; + let implementations = inventory.implementation_files(&needed, &present); + if implementations.is_subset(&dependency_headers) { + break needed; + } + dependency_headers.extend(implementations); + }; let unused = prune::unused_headers(&present, &needed); diff --git a/src/commands/library.rs b/src/commands/library.rs index 3a6d30a..b88b4fe 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -7,7 +7,7 @@ use crate::cli::LibraryCommand; use crate::commands::compiler::resolve as resolve_compiler; use crate::config::Config; use crate::library::local::LocalStore; -use crate::library::tags::{Tags, TagsKind}; +use crate::library::tags::{Registration, SchemaMismatch, Tags, TagsKind}; use crate::library::{dummy, hash, identifiers}; /// `std` として扱うライブラリ ID。識別子情報を持たず、更新検知の対象外とする。 @@ -109,10 +109,11 @@ fn existing_std_compilers(store: &LocalStore) -> Result> { if !store.is_registered(STD_ID) { return Ok(Vec::new()); } - match Tags::load(&store.tags_json(STD_ID))?.kind { - TagsKind::Std { compilers } => Ok(compilers), - TagsKind::Library { .. } => Ok(Vec::new()), - } + // add-std は登録を作り直すため、既存の tags.json からは認識コンパイラ集合しか使わない。 + // update と同じく、スキーマの合わない登録からでも集合を引き継げるよう検証せずに読む。 + Ok(Registration::load(&store.tags_json(STD_ID))? + .compilers + .unwrap_or_default()) } fn discover_all(compilers: &[PathBuf]) -> Result)>> { @@ -131,11 +132,15 @@ fn register_library(store: &LocalStore, id: &str, source_root: &Path) -> Result< dummy::generate(source_root, &store.dummy_dir(id))?; // 識別子抽出はファイル数に比例して時間がかかるため、処理中のファイル名を逐次表示する。 - let files = identifiers::enumerate(source_root, |relative| eprintln!(" {relative}"))?; + let names = identifiers::enumerate(source_root, |relative| eprintln!(" {relative}"))?; let hash = hash::aggregate(source_root)?; Tags { path: source_root.to_path_buf(), - kind: TagsKind::Library { hash, files }, + kind: TagsKind::Library { + hash, + files: names.definitions, + implements: names.implements, + }, } .save(&store.tags_json(id)) } @@ -291,11 +296,38 @@ fn update(store: &LocalStore, id: Option<&str>, path: Option<&Path>) -> Result<( fn update_one(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { validate_id(id)?; ensure_registered(store, id)?; - let tags = Tags::load(&store.tags_json(id))?; - eprintln!("updating library `{id}`..."); - match tags.kind { - TagsKind::Std { compilers } => { + reregister(store, id, path)?; + println!("updated library `{id}`"); + Ok(()) +} + +/// バンドル前に呼ぶ。schema_version が現行と合わない登録を、ライブラリ実体から黙って作り直す。 +/// +/// tags.json はライブラリ実体から再生成できるキャッシュであり、形式の不一致は risundle 側の都合 +/// なので、ユーザーに `update` を要求せず自動で回復する (`std` の初回自動登録と同じ発想)。 +/// 現行スキーマで読めるものは触らない。再生成に失敗した場合はエラーを返し、呼び出し側が案内する。 +pub fn auto_migrate(store: &LocalStore) -> Result<()> { + for id in store.library_ids()? { + match Tags::load(&store.tags_json(&id)) { + Ok(_) => continue, // 現行スキーマ: 触らない + Err(err) if err.downcast_ref::().is_some() => {} // 旧スキーマ: 下で作り直す + Err(err) => return Err(err), // 破損など: 呼び出し側へ委ねる + } + eprintln!("migrating library `{id}` to the current tags format..."); + reregister(store, &id, None)?; + } + Ok(()) +} + +/// 既存の登録の中核 (`Registration`) からライブラリ実体を再走査し、登録を作り直す。 +/// +/// [`Registration`] はスキーマ検証をせず読むため、旧スキーマの登録からでも回復できる。std は +/// コンパイラ集合を、通常ライブラリは登録パス (または明示された `path`) を種にして作り直す。 +fn reregister(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { + let reg = Registration::load(&store.tags_json(id))?; + match reg.compilers { + Some(compilers) => { if path.is_some() { bail!( "a path cannot be specified for the standard library (it is auto-detected from the compiler)" @@ -304,16 +336,14 @@ fn update_one(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { let discovered = discover_all(&compilers)?; register_std(store, &discovered)?; } - TagsKind::Library { .. } => { + None => { let source_root = match path { Some(path) => resolve_source_root(path)?, - None => tags.path, + None => reg.path, }; register_library(store, id, &source_root)?; } } - - println!("updated library `{id}`"); Ok(()) } @@ -323,21 +353,16 @@ fn list(store: &LocalStore) -> Result<()> { println!("no libraries are registered"); return Ok(()); } + // ID・種別・パスしか出さず、いずれも全スキーマバージョンに共通のため、スキーマ検証をしない + // Registration で読む。一覧はアップグレード直後でもエラーにせず動くべき (バンドル時に自動移行)。 // 種別を足しつつタブ区切りを保ち、grep/awk などでのパイプ処理を妨げない。 for id in ids { - let tags = Tags::load(&store.tags_json(&id))?; - println!("{id}\t{}\t{}", kind_label(&tags.kind), tags.path.display()); + let reg = Registration::load(&store.tags_json(&id))?; + println!("{id}\t{}\t{}", reg.kind_label(), reg.path.display()); } Ok(()) } -fn kind_label(kind: &TagsKind) -> &'static str { - match kind { - TagsKind::Std { .. } => "std", - TagsKind::Library { .. } => "library", - } -} - /// `show` の 1 項目を、ラベル幅を揃えて出力する。最長ラベル `Compilers` に合わせる。 fn show_field(label: &str, value: &str) { println!("{label:<9} {value}"); @@ -346,7 +371,28 @@ fn show_field(label: &str, value: &str) { fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { validate_id(id)?; ensure_registered(store, id)?; - let tags = Tags::load(&store.tags_json(id))?; + match Tags::load(&store.tags_json(id)) { + Ok(tags) => show_tags(id, &tags, verbose), + // 詳細 (定義識別子・ハッシュ) は現行スキーマでないと読めない。読み取りコマンドが状態を + // 書き換えるのは避けたいので、自動移行はせず、読める基本情報だけ出して update を案内する。 + Err(err) => match err.downcast_ref::() { + Some(mismatch) => show_outdated(store, id, &mismatch.to_string()), + None => Err(err), + }, + } +} + +/// スキーマが古く詳細を読めない登録について、`Registration` から読める基本情報だけ表示する。 +fn show_outdated(store: &LocalStore, id: &str, reason: &str) -> Result<()> { + let reg = Registration::load(&store.tags_json(id))?; + show_field("ID", id); + show_field("Path", ®.path.display().to_string()); + show_field("Kind", reg.kind_label()); + show_field("Details", &format!("unavailable ({reason})")); + Ok(()) +} + +fn show_tags(id: &str, tags: &Tags, verbose: bool) -> Result<()> { show_field("ID", id); show_field("Path", &tags.path.display().to_string()); match &tags.kind { @@ -357,7 +403,11 @@ fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { println!(" {}", compiler.display()); } } - TagsKind::Library { hash, files } => { + TagsKind::Library { + hash, + files, + implements, + } => { show_field("Kind", "library"); show_field( "Files", @@ -369,6 +419,12 @@ fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { for (file, names) in files { println!(" {file}: {}", names.join(", ")); } + if !implements.is_empty() { + println!("Implements:"); + for (file, names) in implements { + println!(" {file}: {}", names.join(", ")); + } + } } } } @@ -397,6 +453,19 @@ mod tests { LocalStore::with_root(local.path()) } + /// 登録済みライブラリの tags.json を、現行と異なる schema_version に書き換える (移行の検証用)。 + fn downgrade_schema(store: &LocalStore, id: &str) { + let tags_path = store.tags_json(id); + let old = fs::read_to_string(&tags_path) + .unwrap() + .replace("\"schema_version\": 2", "\"schema_version\": 1"); + fs::write(&tags_path, old).unwrap(); + assert!( + Tags::load(&tags_path).is_err(), + "旧スキーマは通常読み込みでは弾かれる前提" + ); + } + #[test] fn registers_non_std_library_with_files_dummy_and_hash() { let local = TempDir::new().unwrap(); @@ -408,7 +477,7 @@ mod tests { assert!(store.is_registered("ac-library")); let tags = Tags::load(&store.tags_json("ac-library")).unwrap(); match tags.kind { - TagsKind::Library { hash, files } => { + TagsKind::Library { hash, files, .. } => { assert!(hash.starts_with("sha256:")); assert!(files["atcoder/modint.hpp"].contains(&"modint".to_owned())); } @@ -564,6 +633,86 @@ mod tests { } } + #[test] + fn update_migrates_tags_from_an_older_schema() { + // 「スキーマ不一致は update で再生成」というエラー案内の受け皿として、update 自身は + // 旧スキーマの tags.json からでも登録パスを読み出して再登録できなければならない。 + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let source = source_with(&[("vec.hpp", "struct Vec {};")]); + add(&store, "mylib", source.path()).unwrap(); + downgrade_schema(&store, "mylib"); + + update(&store, Some("mylib"), None).unwrap(); + + assert!( + Tags::load(&store.tags_json("mylib")).is_ok(), + "update 後は現行スキーマで読めるべき" + ); + } + + #[test] + fn auto_migrate_regenerates_outdated_libraries_and_leaves_current_ones() { + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let source = source_with(&[("vec.hpp", "struct Vec {};")]); + add(&store, "mylib", source.path()).unwrap(); + downgrade_schema(&store, "mylib"); + + auto_migrate(&store).unwrap(); + assert!( + Tags::load(&store.tags_json("mylib")).is_ok(), + "古い登録は移行されるべき" + ); + + // 既に現行スキーマなら再度呼んでも成功し、読める状態を保つ。 + auto_migrate(&store).unwrap(); + assert!(Tags::load(&store.tags_json("mylib")).is_ok()); + } + + #[test] + fn auto_migrate_propagates_non_schema_errors() { + // スキーマ不一致 (回復可) と、破損など回復不能なエラーは区別する。後者は握り潰さず伝播する。 + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let source = source_with(&[("vec.hpp", "struct Vec {};")]); + add(&store, "mylib", source.path()).unwrap(); + std::fs::write(store.tags_json("mylib"), "{ not valid json").unwrap(); + + assert!(auto_migrate(&store).is_err()); + } + + #[test] + fn list_and_show_tolerate_outdated_schema() { + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let source = source_with(&[("vec.hpp", "struct Vec {};")]); + add(&store, "mylib", source.path()).unwrap(); + downgrade_schema(&store, "mylib"); + + // 読み取りコマンドはスキーマ不一致でもエラーにせず、移行もしない (状態は古いまま)。 + list(&store).unwrap(); + show(&store, "mylib", true).unwrap(); + assert!( + Tags::load(&store.tags_json("mylib")).is_err(), + "読み取りコマンドは登録を書き換えない" + ); + } + + #[test] + fn add_std_keeps_the_compiler_set_from_an_older_schema() { + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let Ok(g) = resolve_compiler(Path::new("g++")) else { + return; // g++ が無い環境ではスキップ + }; + add_std(&store, Some(&g)).unwrap(); + downgrade_schema(&store, "std"); + + add_std(&store, Some(&g)).unwrap(); + assert!(Tags::load(&store.tags_json("std")).is_ok()); + } + #[test] fn update_with_new_path_reregisters_from_it() { let local = TempDir::new().unwrap(); diff --git a/src/library/identifiers.rs b/src/library/identifiers.rs index f1709b0..e211f61 100644 --- a/src/library/identifiers.rs +++ b/src/library/identifiers.rs @@ -44,44 +44,61 @@ const SKIP_DESCENT: &[&str] = &[ /// tree-shaking が無効化される。namespace 内のメンバは子の再帰で個別に拾うため取りこぼさない。 const NAME_NODES: &[&str] = &["identifier", "type_identifier", "field_identifier"]; -/// `source_root` 以下のソースファイルを走査し、各ファイルが定義する識別子名を集約する。 -/// -/// キーは `source_root` からの相対パス (`/` 区切り)、値は重複排除・昇順の識別子名一覧。 -/// 定義を一つも持たないファイル (インクルードのみの拡張子なしファイル等) は結果に含めない。 -/// 対象ファイルの選別は [`source::walk_sources`] が担う。 +/// [`enumerate`] の結果。どちらもキーは `source_root` からの相対パス (`/` 区切り)、値は重複排除・ +/// 昇順の名前一覧で、該当する名前を持たないファイルはキー自体を含めない。 +pub struct Enumeration { + /// 各ファイルが定義する識別子名 (tags.json の `files`)。 + pub definitions: BTreeMap>, + /// 各ファイルの「実装先の型名」(tags.json の `implements`)。クラス外の修飾付き定義 + /// (`X<...>::method`) の修飾側や、明示的特殊化 (`template <> struct T<...>`) の主テンプレート名。 + /// 演算子オーバーロードのように定義識別子が残らない実装ファイルでも、依存を逆引きできるように + /// する。namespace 修飾 (`void ns::f()`) の namespace 名も混ざるが、namespace 名は定義 + /// 識別子として登録されない ([`NAME_NODES`] 参照) ため逆引きで一致することがなく、無害である。 + pub implements: BTreeMap>, +} + +/// `source_root` 以下のソースファイルを走査し、各ファイルが定義する識別子名と実装先の型名を +/// 集約する。対象ファイルの選別は [`source::walk_sources`] が担う。 /// /// ファイルを処理する直前に `on_progress(相対パス)` を呼ぶ。登録に時間がかかるため、呼び出し側が /// 進捗を表示できるようにする。 -pub fn enumerate( - source_root: &Path, - mut on_progress: impl FnMut(&str), -) -> Result>> { +pub fn enumerate(source_root: &Path, mut on_progress: impl FnMut(&str)) -> Result { let mut parser = Parser::new(); parser .set_language(&tree_sitter_cpp::LANGUAGE.into()) .context("failed to initialize the C++ parser")?; - let mut files = BTreeMap::new(); + let mut result = Enumeration { + definitions: BTreeMap::new(), + implements: BTreeMap::new(), + }; source::walk_sources(source_root, |relative, content| { let slug = relpath::to_slash(relative)?; on_progress(&slug); - let names = definitions_in(&mut parser, content) + let (definitions, implements) = names_in_file(&mut parser, content) .with_context(|| format!("failed to extract identifiers from {slug}"))?; - if !names.is_empty() { - files.insert(slug, names); + if !definitions.is_empty() { + result.definitions.insert(slug.clone(), definitions); + } + if !implements.is_empty() { + result.implements.insert(slug, implements); } Ok(()) })?; - Ok(files) + Ok(result) } -/// 1 ファイルのソースから、定義された識別子名を重複排除・昇順で返す。 -fn definitions_in(parser: &mut Parser, source: &[u8]) -> Result> { +/// 1 ファイルのソースから、(定義された識別子名, 実装先の型名) をそれぞれ重複排除・昇順で返す。 +fn names_in_file(parser: &mut Parser, source: &[u8]) -> Result<(Vec, Vec)> { let tree = parser .parse(source, None) .ok_or_else(|| anyhow!("failed to parse the source"))?; let mut names = BTreeSet::new(); - collect_definitions(tree.root_node(), source, &mut names); - Ok(names.into_iter().collect()) + let mut implements = BTreeSet::new(); + collect_definitions(tree.root_node(), source, &mut names, &mut implements); + Ok(( + names.into_iter().collect(), + implements.into_iter().collect(), + )) } /// 構文木を辿り、各宣言ノードが導入する名前を `names` に集める。`SKIP_DESCENT` のノードには降りない。 @@ -90,18 +107,23 @@ fn definitions_in(parser: &mut Parser, source: &[u8]) -> Result> { /// (継承元)、`scope` (修飾子) などは既存の名前への参照であり、辿ると使用箇所まで識別子化して /// 逆引きを汚すため辿らない。`type` フィールド内に書かれた埋め込み型定義 (`struct X {} v;`) は、 /// 全子ノードの再帰訪問で当該 `struct` ノードに到達し、その `name` から拾える。 -fn collect_definitions(node: Node, source: &[u8], names: &mut BTreeSet) { +fn collect_definitions( + node: Node, + source: &[u8], + names: &mut BTreeSet, + implements: &mut BTreeSet, +) { if SKIP_DESCENT.contains(&node.kind()) { return; } for field in ["name", "declarator"] { if let Some(child) = node.child_by_field_name(field) { - collect_leaf(child, source, names); + collect_leaf(child, source, names, implements); } } let mut cursor = node.walk(); for child in node.named_children(&mut cursor) { - collect_definitions(child, source, names); + collect_definitions(child, source, names, implements); } } @@ -110,11 +132,33 @@ fn collect_definitions(node: Node, source: &[u8], names: &mut BTreeSet) /// 宣言子は `pointer_declarator` → `function_declarator` → `identifier` のように入れ子になるため、 /// 同名フィールドを辿り続けて末端へ到達する。`qualified_identifier` は `name` 側のみ辿るため、 /// `Foo::bar` からは `bar` を得る (逆引きはトークン単位で行うため修飾子は不要)。 -fn collect_leaf(node: Node, source: &[u8], names: &mut BTreeSet) { +fn collect_leaf( + node: Node, + source: &[u8], + names: &mut BTreeSet, + implements: &mut BTreeSet, +) { + // クラス外の修飾付き定義 (`X<...>::method`) は、修飾側 (`X`) が実装先の型名。定義名の探索とは + // 独立に記録する。name 側の探索は下のフィールド辿りが担う (入れ子の `a::b::f` も再帰で各段の + // 修飾側を拾う)。 + if node.kind() == "qualified_identifier" + && let Some(scope) = node.child_by_field_name("scope") + { + collect_implement_target(scope, source, implements); + } + // 明示的特殊化 (`template <> struct T` / `template <> void f()`) では定義名の位置に + // テンプレート名 + 実引数のノードが来る。主テンプレート `T` / `f` は他ファイルで定義されている + // 見込みが高いので、実装先としても記録する。 + if matches!( + node.kind(), + "template_type" | "template_function" | "template_method" + ) { + collect_implement_target(node, source, implements); + } let mut descended = false; for field in ["name", "declarator"] { if let Some(child) = node.child_by_field_name(field) { - collect_leaf(child, source, names); + collect_leaf(child, source, names, implements); descended = true; } } @@ -124,7 +168,7 @@ fn collect_leaf(node: Node, source: &[u8], names: &mut BTreeSet) { if node.kind() == "operator_name" { let mut cursor = node.walk(); for child in node.named_children(&mut cursor) { - collect_leaf(child, source, names); + collect_leaf(child, source, names, implements); descended = true; } } @@ -136,6 +180,27 @@ fn collect_leaf(node: Node, source: &[u8], names: &mut BTreeSet) { } } +/// 実装先の型名ノードから、その基底の名前を採用する。`FormalPowerSeries` のような +/// テンプレート実引数付きはテンプレート名だけを取り、実引数には降りない (実引数は参照であって +/// 実装先ではない)。 +fn collect_implement_target(node: Node, source: &[u8], implements: &mut BTreeSet) { + match node.kind() { + "namespace_identifier" | "identifier" | "type_identifier" => { + if let Ok(text) = node.utf8_text(source) { + implements.insert(text.to_owned()); + } + } + "template_type" | "template_function" | "template_method" => { + if let Some(name) = node.child_by_field_name("name") { + collect_implement_target(name, source, implements); + } + } + // decltype や依存名などの複雑な修飾は、実装先を静的に特定できないため拾わない。 + // 拾い漏れてもこの逆引きが効かないだけで、定義識別子による依存検出には影響しない。 + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -156,6 +221,18 @@ mod tests { write_file(&root, "lib.hpp", source); enumerate(&root, |_| {}) .unwrap() + .definitions + .remove("lib.hpp") + .unwrap_or_default() + } + + fn implements_in(source: &str) -> Vec { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("source"); + write_file(&root, "lib.hpp", source); + enumerate(&root, |_| {}) + .unwrap() + .implements .remove("lib.hpp") .unwrap_or_default() } @@ -170,7 +247,7 @@ mod tests { "namespace atcoder { struct segtree {}; }", ); - let files = enumerate(&source, |_| {}).unwrap(); + let files = enumerate(&source, |_| {}).unwrap().definitions; assert!(files["atcoder/segtree.hpp"].contains(&"segtree".to_owned())); } @@ -287,6 +364,58 @@ mod tests { assert!(!names.contains(&"height".to_owned())); } + #[test] + fn out_of_class_definition_records_the_implement_target() { + let source = "void Foo::bar() { }"; + assert_eq!(names_in(source), vec!["bar".to_owned()]); + assert_eq!(implements_in(source), vec!["Foo".to_owned()]); + } + + #[test] + fn templated_out_of_class_definition_records_the_primary_name() { + // テンプレート実引数 (mint) は参照であって実装先ではないので拾わない。 + let source = "template \n\ + void FormalPowerSeries::set_fft() { }"; + assert_eq!(implements_in(source), vec!["FormalPowerSeries".to_owned()]); + } + + #[test] + fn operator_only_file_still_gets_an_implement_target() { + // 演算子だけの実装ファイルは定義識別子が空になるため、実装先の記録だけが依存の手がかりになる。 + let source = "template \n\ + FormalPowerSeries& FormalPowerSeries::operator*=(const FormalPowerSeries& r) { return *this; }"; + assert_eq!(names_in(source), Vec::::new()); + assert_eq!(implements_in(source), vec!["FormalPowerSeries".to_owned()]); + } + + #[test] + fn explicit_specialization_records_the_primary_template() { + let source = "template <> struct Trait { static const bool value = true; };"; + assert!(implements_in(source).contains(&"Trait".to_owned())); + } + + #[test] + fn nested_qualifiers_record_each_scope() { + let source = "void outer::Inner::f() { }"; + assert_eq!( + implements_in(source), + vec!["Inner".to_owned(), "outer".to_owned()] + ); + } + + #[test] + fn in_class_definitions_have_no_implement_target() { + let source = "struct V { V operator+(V o) { return o; } void f() { } };"; + assert_eq!(implements_in(source), Vec::::new()); + } + + #[test] + fn type_references_do_not_become_implement_targets() { + // 使用側の修飾 (`internal::barrett bt;` や `Trait::value` の参照) は実装先ではない。 + let source = "struct modint { static internal::barrett bt; };\nint x = Trait::value;"; + assert_eq!(implements_in(source), Vec::::new()); + } + #[test] fn names_are_deduplicated_and_sorted() { // 前方宣言と定義で同名が複数回現れても 1 つにまとまり、昇順で返る。 @@ -301,7 +430,9 @@ mod tests { // AC Library の拡張子なしファイルのように、インクルードのみで定義を持たない。 write_file(&source, "atcoder/modint", "#include "); - assert!(enumerate(&source, |_| {}).unwrap().is_empty()); + let result = enumerate(&source, |_| {}).unwrap(); + assert!(result.definitions.is_empty()); + assert!(result.implements.is_empty()); } #[test] diff --git a/src/library/tags.rs b/src/library/tags.rs index fdd7032..0b44abe 100644 --- a/src/library/tags.rs +++ b/src/library/tags.rs @@ -7,7 +7,29 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -const CURRENT_SCHEMA_VERSION: u32 = 1; +const CURRENT_SCHEMA_VERSION: u32 = 2; + +/// `tags.json` の schema_version が現行と異なることを表すエラー。 +/// +/// tags.json はライブラリ実体から再生成できるキャッシュであり、形式の不一致は risundle 側の都合 +/// なので、呼び出し側はこのエラーを検知したら自動再登録で回復してよい (バンドル前の自動移行や +/// `list`/`show` の寛容表示が [`anyhow::Error::downcast_ref`] で判別する)。 +#[derive(Debug, PartialEq, Eq)] +pub struct SchemaMismatch { + pub found: u32, +} + +impl std::fmt::Display for SchemaMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "tags.json schema_version {} is not supported (supported: {CURRENT_SCHEMA_VERSION}); regenerate it with `risundle library update`", + self.found + ) + } +} + +impl std::error::Error for SchemaMismatch {} #[derive(Debug, Clone, PartialEq, Eq)] pub struct Tags { @@ -30,6 +52,11 @@ pub enum TagsKind { hash: String, // 出力順を安定させるため BTreeMap を使う (HashMap だと tags.json の diff が毎回ぶれる)。 files: BTreeMap>, + /// ファイルごとの「実装先の型名」一覧。クラス外の修飾付き定義 (`X<...>::method`) や明示的 + /// 特殊化 (`template <> struct T<...>`) が対象とする型名で、`files` (定義識別子) と対になる。 + /// 定義識別子に現れない依存 (演算子オーバーロード等) をバンドル時に逆引きするために使う。 + /// 実装先を持たないファイルはキー自体を持たない。 + implements: BTreeMap>, }, } @@ -55,6 +82,45 @@ impl Tags { } } +/// スキーマに依存しない登録の中核。`path` と (std なら) `compilers` だけを、スキーマ検証をせずに +/// 読み出す。 +/// +/// これらは全スキーマバージョンに共通して存在するため、[`Tags`] が読めない (スキーマ不一致の) 登録 +/// からでも取り出せる。登録の作り直し・種別とパスの表示 (`update`・`add-std`・バンドル時の自動移行・ +/// `list`・`show` の寛容表示) は、この中核だけで足りる。 +pub struct Registration { + pub path: PathBuf, + /// `std` の登録なら `Some` (認識コンパイラ集合)、通常ライブラリなら `None`。 + pub compilers: Option>, +} + +impl Registration { + pub fn load(path: &Path) -> Result { + #[derive(Deserialize)] + struct Probe { + path: PathBuf, + compilers: Option>, + } + let json = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let probe: Probe = serde_json::from_str(&json) + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(Self { + path: probe.path, + compilers: probe.compilers, + }) + } + + /// 種別ラベル。`compilers` の有無が `std` / 通常ライブラリを一意に決める。 + pub fn kind_label(&self) -> &'static str { + if self.compilers.is_some() { + "std" + } else { + "library" + } + } +} + /// `tags.json` の生のシリアライズ表現。`std` は `compiler` のみ、通常ライブラリは `hash`・`files` を /// 持つ。種別ごとに排他なので、どちらの組が揃っているかで [`TagsKind`] を復元する。 #[derive(Debug, Serialize, Deserialize)] @@ -68,6 +134,8 @@ struct RawTags { hash: Option, #[serde(skip_serializing_if = "Option::is_none")] files: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + implements: Option>>, } impl TryFrom for Tags { @@ -75,15 +143,20 @@ impl TryFrom for Tags { fn try_from(raw: RawTags) -> Result { if raw.schema_version != CURRENT_SCHEMA_VERSION { - bail!( - "tags.json schema_version {} is not supported (supported: {}); regenerate it with `risundle library update`", - raw.schema_version, - CURRENT_SCHEMA_VERSION - ); + return Err(SchemaMismatch { + found: raw.schema_version, + } + .into()); } let kind = match (raw.compilers, raw.hash, raw.files) { - (Some(compilers), None, None) => TagsKind::Std { compilers }, - (None, Some(hash), Some(files)) => TagsKind::Library { hash, files }, + (Some(compilers), None, None) if raw.implements.is_none() => { + TagsKind::Std { compilers } + } + (None, Some(hash), Some(files)) => TagsKind::Library { + hash, + files, + implements: raw.implements.unwrap_or_default(), + }, _ => { bail!( "invalid tags.json kind (std requires only compilers; a regular library requires both hash and files)" @@ -99,9 +172,18 @@ impl TryFrom for Tags { impl From<&Tags> for RawTags { fn from(tags: &Tags) -> Self { - let (compilers, hash, files) = match &tags.kind { - TagsKind::Std { compilers } => (Some(compilers.clone()), None, None), - TagsKind::Library { hash, files } => (None, Some(hash.clone()), Some(files.clone())), + let (compilers, hash, files, implements) = match &tags.kind { + TagsKind::Std { compilers } => (Some(compilers.clone()), None, None, None), + TagsKind::Library { + hash, + files, + implements, + } => ( + None, + Some(hash.clone()), + Some(files.clone()), + Some(implements.clone()), + ), }; Self { schema_version: CURRENT_SCHEMA_VERSION, @@ -109,6 +191,7 @@ impl From<&Tags> for RawTags { compilers, hash, files, + implements, } } } @@ -128,6 +211,10 @@ mod tests { ("atcoder/modint.hpp".to_owned(), vec!["modint".to_owned()]), ("atcoder/segtree.hpp".to_owned(), vec!["segtree".to_owned()]), ]), + implements: BTreeMap::from([( + "atcoder/modint_impl.hpp".to_owned(), + vec!["modint".to_owned()], + )]), }, } } @@ -171,6 +258,7 @@ mod tests { kind: TagsKind::Library { hash: "sha256:abc".to_owned(), files: BTreeMap::new(), + implements: BTreeMap::new(), }, }; let json = tags.to_json().unwrap(); @@ -178,9 +266,26 @@ mod tests { assert_eq!(Tags::from_json(&json).unwrap(), tags); } + #[test] + fn library_without_implements_parses_as_empty() { + // v2 で implements を省いた JSON も空として受理する (手書き・移行時の寛容さ)。 + let json = r#"{ "schema_version": 2, "path": "/p", "hash": "sha256:abc", "files": {} }"#; + let tags = Tags::from_json(json).unwrap(); + assert!(matches!( + tags.kind, + TagsKind::Library { ref implements, .. } if implements.is_empty() + )); + } + + #[test] + fn std_with_implements_is_rejected() { + let json = r#"{ "schema_version": 2, "path": "/p", "compilers": ["/usr/bin/g++"], "implements": {} }"#; + assert!(Tags::from_json(json).is_err()); + } + #[test] fn parses_spec_std_example() { - let json = r#"{ "schema_version": 1, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; + let json = r#"{ "schema_version": 2, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; let tags = Tags::from_json(json).unwrap(); assert_eq!( tags.kind, @@ -191,41 +296,46 @@ mod tests { } #[test] - fn unsupported_schema_version_is_rejected() { - let json = r#"{ "schema_version": 2, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; + fn unsupported_schema_version_is_reported_as_schema_mismatch() { + // スキーマ不一致は SchemaMismatch として判別でき、呼び出し側が自動再登録で回復できる。 + let json = r#"{ "schema_version": 1, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; let error = Tags::from_json(json).unwrap_err(); - assert!(error.to_string().contains("schema_version")); + assert_eq!( + error.downcast_ref::(), + Some(&SchemaMismatch { found: 1 }) + ); + assert!(error.to_string().contains("library update")); } #[test] fn std_without_compilers_is_rejected() { // compilers も hash/files も無い旧 std 形式は不正 (add-std での再登録が必要)。 - let json = r#"{ "schema_version": 1, "path": "/usr/include/c++/12" }"#; + let json = r#"{ "schema_version": 2, "path": "/usr/include/c++/12" }"#; assert!(Tags::from_json(json).is_err()); } #[test] fn hash_without_files_is_rejected() { - let json = r#"{ "schema_version": 1, "path": "/p", "hash": "sha256:abc" }"#; + let json = r#"{ "schema_version": 2, "path": "/p", "hash": "sha256:abc" }"#; assert!(Tags::from_json(json).is_err()); } #[test] fn files_without_hash_is_rejected() { - let json = r#"{ "schema_version": 1, "path": "/p", "files": {} }"#; + let json = r#"{ "schema_version": 2, "path": "/p", "files": {} }"#; assert!(Tags::from_json(json).is_err()); } #[test] fn std_with_hash_is_rejected() { // std (compilers) と通常ライブラリ (hash/files) の情報が混在する形式は不正。 - let json = r#"{ "schema_version": 1, "path": "/p", "compilers": ["/usr/bin/g++"], "hash": "sha256:abc" }"#; + let json = r#"{ "schema_version": 2, "path": "/p", "compilers": ["/usr/bin/g++"], "hash": "sha256:abc" }"#; assert!(Tags::from_json(json).is_err()); } #[test] fn unknown_field_is_rejected() { - let json = r#"{ "schema_version": 1, "path": "/p", "unknown": true }"#; + let json = r#"{ "schema_version": 2, "path": "/p", "unknown": true }"#; assert!(Tags::from_json(json).is_err()); } diff --git a/tests/cli.rs b/tests/cli.rs index 8316e6b..5f47f0e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -263,6 +263,54 @@ fn bundle_no_tree_shaking_keeps_unused_headers() { assert_eq!(compile_and_run(&sandbox, &expanded).trim(), "42"); } +#[test] +fn bundle_keeps_operator_implementation_files() { + // 宣言 (vec2.hpp) と演算子の実装 (vec2-ops.hpp) が分かれたライブラリ。実装側は定義識別子を + // 持たないため、実装先の型名 (implements) の逆引きだけが依存の手がかりになる。 + let sandbox = Sandbox::new(); + sandbox.write( + "mylib/vec2.hpp", + "#pragma once\nstruct Vec2 { int x, y; Vec2 operator+(const Vec2& r) const; };\n", + ); + sandbox.write( + "mylib/vec2-ops.hpp", + "#pragma once\n#include \n\ + Vec2 Vec2::operator+(const Vec2& r) const { return {x + r.x, y + r.y}; }\n", + ); + let unused = sandbox.write( + "mylib/unused.hpp", + "#pragma once\nstruct Unused { int unused_value() const { return 1; } };\n", + ); + let lib_root = unused.parent().unwrap().to_path_buf(); + sandbox + .risundle() + .args(["library", "add", "mylib"]) + .arg(&lib_root) + .assert() + .success(); + + sandbox.write( + "main.cpp", + "#include \n#include \n#include \n#include \n\ + int main() { Vec2 a{1, 2}, b{3, 4}; Vec2 c = a + b; std::printf(\"%d\\n\", c.x + c.y); return 0; }\n", + ); + + let bundled = run_bundle(&sandbox, &["-k", STD]); + + // 演算子の使用 (a + b) は識別子として検出できないが、実装ファイルは残るべき。実装が消えた + // 場合は宣言だけでコンパイルは通り、undefined reference としてリンクで初めて顕在化するため、 + // リンクまで行う compile_and_run で検証する。 + assert!( + bundled.contains("Vec2 Vec2::operator+"), + "実装ファイルが tree-shaking で消えてはいけない" + ); + assert!( + !bundled.contains("struct Unused"), + "実装ファイルの救済で無関係な未使用ヘッダーまで残してはいけない" + ); + assert_eq!(compile_and_run(&sandbox, &bundled).trim(), "10"); +} + #[test] fn bundle_keeps_transitively_required_headers() { let sandbox = Sandbox::new();