1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CorpusScope {
12 Production,
13 Test,
14 Fixture,
15 Example,
16 Generated,
17 Vendor,
18 Docs,
19}
20
21impl CorpusScope {
22 pub const ALL: [Self; 7] = [
23 Self::Production,
24 Self::Test,
25 Self::Fixture,
26 Self::Example,
27 Self::Generated,
28 Self::Vendor,
29 Self::Docs,
30 ];
31
32 pub fn as_str(self) -> &'static str {
33 match self {
34 Self::Production => "production",
35 Self::Test => "test",
36 Self::Fixture => "fixture",
37 Self::Example => "example",
38 Self::Generated => "generated",
39 Self::Vendor => "vendor",
40 Self::Docs => "docs",
41 }
42 }
43
44 pub fn from_str_opt(value: &str) -> Option<Self> {
45 Some(match value {
46 "production" | "prod" => Self::Production,
47 "test" | "tests" => Self::Test,
48 "fixture" | "fixtures" => Self::Fixture,
49 "example" | "examples" => Self::Example,
50 "generated" => Self::Generated,
51 "vendor" | "vendored" => Self::Vendor,
52 "docs" | "documentation" => Self::Docs,
53 _ => return None,
54 })
55 }
56
57 pub fn classify_path(file: &str) -> Self {
71 if file.starts_with("dep:") {
72 return Self::Vendor;
73 }
74 let lower = file.replace('\\', "/").to_ascii_lowercase();
75 let components = lower.split('/').collect::<Vec<_>>();
76 let basename = components.last().copied().unwrap_or(&lower);
77
78 if components.iter().any(|component| {
79 matches!(
80 *component,
81 "vendor" | "vendored" | "third_party" | "third-party" | "node_modules"
82 )
83 }) {
84 return Self::Vendor;
85 }
86 if components.iter().any(|component| {
87 matches!(
88 *component,
89 "generated" | "autogen" | "auto-generated" | "generated-src"
90 )
91 }) || basename.contains(".generated.")
92 || basename.contains("_generated.")
93 || basename.ends_with(".g.rs")
94 || basename.ends_with(".pb.go")
95 || basename.ends_with(".designer.cs")
96 {
97 return Self::Generated;
98 }
99 if components.iter().any(|component| {
100 matches!(
101 *component,
102 "fixture"
103 | "fixtures"
104 | "golden"
105 | "testdata"
106 | "test-data"
107 | "snapshot"
108 | "snapshots"
109 | "__snapshots__"
110 )
111 }) {
112 return Self::Fixture;
113 }
114 if components.iter().any(|component| {
115 matches!(
116 *component,
117 "example" | "examples" | "sample" | "samples" | "demo" | "demos"
118 )
119 }) {
120 return Self::Example;
121 }
122 if components
123 .iter()
124 .any(|component| matches!(*component, "test" | "tests" | "spec" | "specs"))
125 || components.first().is_some_and(|component| {
126 matches!(
127 *component,
128 "harness"
129 | "bench"
130 | "benches"
131 | "benchmark"
132 | "benchmarks"
133 | "eval"
134 | "evals"
135 | "e2e"
136 )
137 })
138 || basename.starts_with("test_")
139 || basename == "tests.rs"
140 || basename.ends_with("_tests.rs")
141 || basename.contains("_test.")
142 || basename.contains(".test.")
143 || basename.contains("_spec.")
144 || basename.contains(".spec.")
145 {
146 return Self::Test;
147 }
148 if components
149 .first()
150 .is_some_and(|component| matches!(*component, "docs" | "doc" | "documentation"))
151 || matches!(
152 basename,
153 "readme"
154 | "readme.md"
155 | "readme.mdx"
156 | "changelog.md"
157 | "contributing.md"
158 | "architecture.md"
159 )
160 || [".md", ".mdx", ".rst", ".adoc", ".asciidoc"]
161 .iter()
162 .any(|extension| basename.ends_with(extension))
163 {
164 return Self::Docs;
165 }
166 Self::Production
167 }
168}
169
170impl std::fmt::Display for CorpusScope {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 f.write_str(self.as_str())
173 }
174}
175
176impl std::str::FromStr for CorpusScope {
177 type Err = String;
178
179 fn from_str(value: &str) -> Result<Self, Self::Err> {
180 Self::from_str_opt(value).ok_or_else(|| {
181 format!(
182 "unknown scope `{value}` (expected production, test, fixture, example, generated, vendor, or docs)"
183 )
184 })
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
193pub struct NodeId(String);
194
195impl NodeId {
196 pub fn new(id: impl Into<String>) -> Self {
197 Self(id.into())
198 }
199
200 pub fn as_str(&self) -> &str {
201 &self.0
202 }
203
204 pub fn qualified(&self) -> &str {
207 match self.0.split_once('#') {
208 Some((_, rest)) => rest
209 .rsplit_once('@')
210 .map_or(rest, |(qualified, _)| qualified),
211 None => &self.0,
212 }
213 }
214}
215
216impl std::fmt::Display for NodeId {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 f.write_str(&self.0)
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
230pub struct SymbolKey(String);
231
232impl SymbolKey {
233 pub const PREFIX: &'static str = "symbol:";
234
235 pub fn new(kind: SymbolKind, file: &str, qualified: &str) -> Self {
236 Self(format!(
237 "{}{}:{}:{file}#{qualified}",
238 Self::PREFIX,
239 kind.as_str(),
240 file.len()
241 ))
242 }
243
244 pub fn parse(encoded: impl Into<String>) -> Option<Self> {
245 let key = Self(encoded.into());
246 key.parts()?;
247 Some(key)
248 }
249
250 pub fn as_str(&self) -> &str {
251 &self.0
252 }
253
254 pub fn parts(&self) -> Option<(SymbolKind, &str, &str)> {
255 let rest = self.0.strip_prefix(Self::PREFIX)?;
256 let (kind, rest) = rest.split_once(':')?;
257 let (file_len, rest) = rest.split_once(':')?;
258 let file_len: usize = file_len.parse().ok()?;
259 let file = rest.get(..file_len)?;
260 let qualified = rest.get(file_len..)?.strip_prefix('#')?;
261 if file.is_empty() || qualified.is_empty() {
262 return None;
263 }
264 Some((SymbolKind::from_str_opt(kind)?, file, qualified))
265 }
266}
267
268impl std::fmt::Display for SymbolKey {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 f.write_str(&self.0)
271 }
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
277pub struct Span {
278 pub start: u64,
279 pub end: u64,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
283pub enum SymbolKind {
284 Function,
285 Method,
286 Struct,
287 Class,
288 Enum,
289 Variant,
290 Trait,
291 Interface,
292 TypeAlias,
293 Constant,
294 Static,
295 Variable,
296 Field,
297 Module,
298 Macro,
299 File,
300 Section,
303 Table,
305 View,
307 Column,
309 Index,
311}
312
313impl SymbolKind {
314 pub fn as_str(self) -> &'static str {
315 match self {
316 Self::Function => "function",
317 Self::Method => "method",
318 Self::Struct => "struct",
319 Self::Class => "class",
320 Self::Enum => "enum",
321 Self::Variant => "variant",
322 Self::Trait => "trait",
323 Self::Interface => "interface",
324 Self::TypeAlias => "typealias",
325 Self::Constant => "constant",
326 Self::Static => "static",
327 Self::Variable => "variable",
328 Self::Field => "field",
329 Self::Module => "module",
330 Self::Macro => "macro",
331 Self::File => "file",
332 Self::Section => "section",
333 Self::Table => "table",
334 Self::View => "view",
335 Self::Column => "column",
336 Self::Index => "index",
337 }
338 }
339
340 pub fn from_str_opt(s: &str) -> Option<Self> {
343 Some(match s {
344 "function" => Self::Function,
345 "method" => Self::Method,
346 "struct" => Self::Struct,
347 "class" => Self::Class,
348 "enum" => Self::Enum,
349 "variant" => Self::Variant,
350 "trait" => Self::Trait,
351 "interface" => Self::Interface,
352 "typealias" => Self::TypeAlias,
353 "constant" => Self::Constant,
354 "static" => Self::Static,
355 "variable" => Self::Variable,
356 "field" => Self::Field,
357 "module" => Self::Module,
358 "macro" => Self::Macro,
359 "file" => Self::File,
360 "section" => Self::Section,
361 "table" => Self::Table,
362 "view" => Self::View,
363 "column" => Self::Column,
364 "index" => Self::Index,
365 _ => return None,
366 })
367 }
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct Node {
374 pub id: NodeId,
375 pub kind: SymbolKind,
376 pub name: String,
377 pub file: String,
379 pub span: Span,
380 pub signature: String,
382 pub doc: Option<String>,
383}
384
385impl Node {
386 pub fn symbol_key(&self) -> SymbolKey {
390 SymbolKey::new(self.kind, &self.file, self.id.qualified())
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::{CorpusScope, Node, NodeId, Span, SymbolKey, SymbolKind};
397
398 fn node(id: &str, signature: &str) -> Node {
399 Node {
400 id: NodeId::new(id),
401 kind: SymbolKind::Function,
402 name: "run".to_string(),
403 file: "src/lib.rs".to_string(),
404 span: Span { start: 10, end: 20 },
405 signature: signature.to_string(),
406 doc: None,
407 }
408 }
409
410 #[test]
411 fn symbol_key_survives_offset_changes() {
412 let before = node("src/lib.rs#Runner::run@10", "fn run()");
413 let after = node("src/lib.rs#Runner::run@200", "fn run()");
414 assert_ne!(before.id, after.id);
415 assert_eq!(before.symbol_key(), after.symbol_key());
416 assert_eq!(
417 before.symbol_key().parts(),
418 Some((SymbolKind::Function, "src/lib.rs", "Runner::run"))
419 );
420 assert_eq!(
421 SymbolKey::parse(before.symbol_key().to_string()),
422 Some(before.symbol_key())
423 );
424 }
425
426 #[test]
427 fn overloads_share_a_key_without_claiming_uniqueness() {
428 let one = node("src/lib.rs#run@10", "fn run(u8)");
429 let two = node("src/lib.rs#run@30", "fn run(u16)");
430 assert_eq!(one.symbol_key(), two.symbol_key());
431 }
432
433 #[test]
434 fn sql_symbol_kinds_round_trip_through_capture_names() {
435 for kind in [
436 SymbolKind::Table,
437 SymbolKind::View,
438 SymbolKind::Column,
439 SymbolKind::Index,
440 ] {
441 assert_eq!(SymbolKind::from_str_opt(kind.as_str()), Some(kind));
442 }
443 }
444
445 #[test]
446 fn corpus_scope_uses_conservative_path_roles() {
447 let cases = [
448 ("src/lib.rs", CorpusScope::Production),
449 ("tests/integration.rs", CorpusScope::Test),
450 ("harness/golden/example/main.rs", CorpusScope::Fixture),
451 ("examples/client.rs", CorpusScope::Example),
452 ("src/generated/schema.pb.go", CorpusScope::Generated),
453 ("third_party/parser.c", CorpusScope::Vendor),
454 ("docs/architecture.md", CorpusScope::Docs),
455 ("src/contest.rs", CorpusScope::Production),
456 ("harness/eval/runner/scoring.rs", CorpusScope::Test),
457 (
458 "harness/eval/fixtures/agent-flow/main.rs",
459 CorpusScope::Fixture,
460 ),
461 (
462 "harness/golden/fixtures/go-basic/main.go",
463 CorpusScope::Fixture,
464 ),
465 ("benches/ask.rs", CorpusScope::Test),
466 ("e2e/smoke.ts", CorpusScope::Test),
467 ("crates/sinter-cli/tests/cli.rs", CorpusScope::Test),
468 ("crates/eval/src/lib.rs", CorpusScope::Production),
469 ("src/eval/mod.rs", CorpusScope::Production),
470 ("src/tests.rs", CorpusScope::Test),
471 ("crates/foo/src/bar/tests.rs", CorpusScope::Test),
472 ("src/bar_tests.rs", CorpusScope::Test),
473 ("src/test_bar.rs", CorpusScope::Test),
474 ("crates/foo/src/bar/tests/cases.rs", CorpusScope::Test),
475 ("src/contests.rs", CorpusScope::Production),
476 ];
477 for (path, expected) in cases {
478 assert_eq!(CorpusScope::classify_path(path), expected, "{path}");
479 }
480 }
481}