Coverage Report

Created: 2026-06-10 00:27

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}