/home/runner/work/risundle/risundle/src/fs/source.rs
Line | Count | Source |
1 | | //! ライブラリ内の「ソースファイル」だけを選別して走査する共通レイヤ。バンドル対象になりうるのは |
2 | | //! C++ のヘッダ・ソースのみのため、それ以外の拡張子 (`.md` / `.txt` / `.py` 等) を除外する。 |
3 | | //! 拡張子を持たないファイル (AC Library の `atcoder/modint` のような include 専用ファイル) は |
4 | | //! C++ か否か判別できないため通すが、内容に NUL バイトを含むバイナリは拡張子の有無に関わらず弾く。 |
5 | | //! ドット始まりの隠しファイル・ディレクトリ (`.git/` `.clang-format` 等) は VCS・設定メタデータで |
6 | | //! あって include 対象になりえないため、パスのどこかに含まれた時点で除外する。特に `.git/` を含めると |
7 | | //! commit のたびに集約ハッシュが変わり、ライブラリ更新を誤検知してしまう。 |
8 | | //! |
9 | | //! ダミー生成・識別子列挙・ハッシュ計算がいずれもこの同じ選別を共有することで、3 者が常に同一の |
10 | | //! ファイル集合を対象にする。対象がずれると `tags.json` の `files` と更新検知用ハッシュが食い違う。 |
11 | | |
12 | | use std::path::{Component, Path}; |
13 | | |
14 | | use anyhow::{Context, Result}; |
15 | | |
16 | | use crate::fs::walk; |
17 | | |
18 | | /// ソースとして扱う拡張子。競技プログラミングのライブラリに現れる C++ のヘッダ・ソースを網羅する。 |
19 | | const SOURCE_EXTENSIONS: &[&str] = &[ |
20 | | "h", "hpp", "hh", "hxx", "h++", "ipp", "tcc", "inc", "c", "cpp", "cc", "cxx", "c++", |
21 | | ]; |
22 | | |
23 | | /// `root` 以下のソースファイルについて `visit(相対パス, 内容)` を呼ぶ。 |
24 | | /// |
25 | | /// 選別は隠し要素 (`is_hidden`)・拡張子 (`has_source_extension`)・内容 (`looks_like_text`) で行う。 |
26 | | /// 隠し要素と非ソースは読み取りすらせずスキップし、バイナリは読み取り後に弾く。シンボリックリンクは |
27 | | /// 辿らない (`walk::walk_files` に従う)。 |
28 | 144 | pub fn walk_sources(root: &Path, mut visit: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result<()> { |
29 | 42.5k | walk::walk_files144 (root144 , |relative, absolute| { |
30 | 42.5k | if is_hidden(relative) || !has_source_extension(relative)42.1k { |
31 | 4.03k | return Ok(()); |
32 | 38.5k | } |
33 | 38.5k | let content = std::fs::read(absolute) |
34 | 38.5k | .with_context(|| format!0 ("failed to read {}", absolute0 .display0 ()))?0 ; |
35 | 38.5k | if !looks_like_text(&content) { |
36 | 1 | return Ok(()); |
37 | 38.5k | } |
38 | 38.5k | visit(relative, &content) |
39 | 42.5k | }) |
40 | 144 | } |
41 | | |
42 | | /// パスのいずれかの要素がドット始まり (隠しファイル・ディレクトリ) なら真。 |
43 | 42.5k | fn is_hidden(relative: &Path) -> bool { |
44 | 180k | relative.components()42.5k .any42.5k (|component| { |
45 | 180k | matches!363 (component, Component::Normal(name363 ) |
46 | 180k | if name.to_str().is_some_and(|name| name.starts_with('.'))363 ) |
47 | 180k | }) |
48 | 42.5k | } |
49 | | |
50 | | /// ソース拡張子を持つか、または拡張子を持たない場合に真。拡張子は大文字小文字を区別しない (`.H` 等)。 |
51 | 42.1k | fn has_source_extension(relative: &Path) -> bool { |
52 | 42.1k | match relative.extension() { |
53 | 1.92k | None => true, |
54 | 40.2k | Some(extension) => extension.to_str().is_some_and(|extension| { |
55 | 40.2k | SOURCE_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str()) |
56 | 40.2k | }), |
57 | | } |
58 | 42.1k | } |
59 | | |
60 | | /// NUL バイトを含まなければテキストとみなす。バイナリファイルを弾くための簡易判定。 |
61 | 38.5k | fn looks_like_text(content: &[u8]) -> bool { |
62 | 38.5k | !content.contains(&0) |
63 | 38.5k | } |
64 | | |
65 | | #[cfg(test)] |
66 | | mod tests { |
67 | | use super::*; |
68 | | |
69 | | use std::collections::BTreeSet; |
70 | | use std::fs; |
71 | | use std::path::PathBuf; |
72 | | |
73 | | use tempfile::TempDir; |
74 | | |
75 | 20 | fn write_file(root: &Path, relative: &str, content: &[u8]) { |
76 | 20 | let path = root.join(relative); |
77 | 20 | fs::create_dir_all(path.parent().unwrap()).unwrap(); |
78 | 20 | fs::write(path, content).unwrap(); |
79 | 20 | } |
80 | | |
81 | 6 | fn visited_paths(root: &Path) -> BTreeSet<PathBuf> { |
82 | 6 | let mut seen = BTreeSet::new(); |
83 | 12 | walk_sources6 (root6 , |relative, _content| { |
84 | 12 | seen.insert(relative.to_path_buf()); |
85 | 12 | Ok(()) |
86 | 12 | }) |
87 | 6 | .unwrap(); |
88 | 6 | seen |
89 | 6 | } |
90 | | |
91 | | #[test] |
92 | 1 | fn passes_source_extensions() { |
93 | 1 | let temp = TempDir::new().unwrap(); |
94 | 1 | let root = temp.path().join("lib"); |
95 | 6 | for name in ["a.hpp", 1 "b.h"1 , "c.cpp"1 , "d.cc"1 , "e.hxx", "f.tcc"] { |
96 | 6 | write_file(&root, name, b"int x;"); |
97 | 6 | } |
98 | | |
99 | 1 | let expected: BTreeSet<PathBuf> = ["a.hpp", "b.h", "c.cpp", "d.cc", "e.hxx", "f.tcc"] |
100 | 1 | .iter() |
101 | 1 | .map(PathBuf::from) |
102 | 1 | .collect(); |
103 | 1 | assert_eq!(visited_paths(&root), expected); |
104 | 1 | } |
105 | | |
106 | | #[test] |
107 | 1 | fn passes_extensionless_files() { |
108 | | // AC Library の `atcoder/modint` のように、include 専用の拡張子なしファイルは通す。 |
109 | 1 | let temp = TempDir::new().unwrap(); |
110 | 1 | let root = temp.path().join("lib"); |
111 | 1 | write_file(&root, "atcoder/modint", b"#include <atcoder/modint.hpp>"); |
112 | | |
113 | 1 | assert!(visited_paths(&root).contains(&PathBuf::from("atcoder/modint"))); |
114 | 1 | } |
115 | | |
116 | | #[test] |
117 | 1 | fn rejects_non_source_extensions() { |
118 | 1 | let temp = TempDir::new().unwrap(); |
119 | 1 | let root = temp.path().join("lib"); |
120 | 1 | write_file(&root, "README.md", b"# title"); |
121 | 1 | write_file(&root, "build.py", b"print(1)"); |
122 | 1 | write_file(&root, "data.json", b"{}"); |
123 | 1 | write_file(&root, "keep.hpp", b"int x;"); |
124 | | |
125 | 1 | assert_eq!( |
126 | 1 | visited_paths(&root), |
127 | 1 | BTreeSet::from([PathBuf::from("keep.hpp")]) |
128 | | ); |
129 | 1 | } |
130 | | |
131 | | #[test] |
132 | 1 | fn rejects_binary_even_with_source_extension() { |
133 | 1 | let temp = TempDir::new().unwrap(); |
134 | 1 | let root = temp.path().join("lib"); |
135 | 1 | write_file(&root, "blob.h", b"\x00\x01\x02binary"); |
136 | 1 | write_file(&root, "text.h", b"int x;"); |
137 | | |
138 | 1 | assert_eq!( |
139 | 1 | visited_paths(&root), |
140 | 1 | BTreeSet::from([PathBuf::from("text.h")]) |
141 | | ); |
142 | 1 | } |
143 | | |
144 | | #[test] |
145 | 1 | fn extension_match_is_case_insensitive() { |
146 | 1 | let temp = TempDir::new().unwrap(); |
147 | 1 | let root = temp.path().join("lib"); |
148 | 1 | write_file(&root, "a.HPP", b"int x;"); |
149 | 1 | write_file(&root, "b.H", b"int y;"); |
150 | | |
151 | 1 | let expected = BTreeSet::from([PathBuf::from("a.HPP"), PathBuf::from("b.H")]); |
152 | 1 | assert_eq!(visited_paths(&root), expected); |
153 | 1 | } |
154 | | |
155 | | #[test] |
156 | 1 | fn rejects_hidden_files_and_directories() { |
157 | | // .git/ 配下や .clang-format などの隠し要素は VCS・設定メタデータであり対象外。 |
158 | 1 | let temp = TempDir::new().unwrap(); |
159 | 1 | let root = temp.path().join("lib"); |
160 | 1 | write_file(&root, ".git/config", b"[core]"); |
161 | 1 | write_file(&root, ".gitignore", b"target"); |
162 | 1 | write_file(&root, ".clang-format", b"BasedOnStyle: Google"); |
163 | 1 | write_file(&root, "atcoder/dsu.hpp", b"struct dsu {};"); |
164 | | |
165 | 1 | assert_eq!( |
166 | 1 | visited_paths(&root), |
167 | 1 | BTreeSet::from([PathBuf::from("atcoder/dsu.hpp")]) |
168 | | ); |
169 | 1 | } |
170 | | |
171 | | #[test] |
172 | 1 | fn passes_content_to_visitor() { |
173 | 1 | let temp = TempDir::new().unwrap(); |
174 | 1 | let root = temp.path().join("lib"); |
175 | 1 | write_file(&root, "a.hpp", b"struct modint {};"); |
176 | | |
177 | 1 | let mut content = Vec::new(); |
178 | 1 | walk_sources(&root, |_relative, bytes| { |
179 | 1 | content = bytes.to_vec(); |
180 | 1 | Ok(()) |
181 | 1 | }) |
182 | 1 | .unwrap(); |
183 | 1 | assert_eq!(content, b"struct modint {};"); |
184 | 1 | } |
185 | | } |