1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
//! Parser for `stack.yaml` files (Haskell/Stack).
//!
//! Extracts `extra-deps:` entries. Stack uses Stackage snapshots for most deps;
//! `extra-deps` lists packages not on the snapshot (usually pinned versions or git).
//!
//! Uses indent-aware line parsing rather than a full YAML library.
use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
/// Parser for `stack.yaml` files.
pub struct StackParser;
impl ManifestParser for StackParser {
fn filename(&self) -> &'static str {
"stack.yaml"
}
fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
let mut deps = Vec::new();
let mut in_extra_deps = false;
let mut list_indent = 0usize;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.len() - line.trim_start().len();
// Top-level key detection
if indent == 0 {
in_extra_deps = trimmed.starts_with("extra-deps:");
list_indent = 0;
// Handle inline list: `extra-deps: []`
if in_extra_deps && trimmed.contains('[') {
// Empty or inline — not common, skip
in_extra_deps = false;
}
continue;
}
if !in_extra_deps {
continue;
}
// List items start with `- `
if trimmed.starts_with("- ") || trimmed.starts_with('-') {
if list_indent == 0 {
list_indent = indent;
}
let item = trimmed.trim_start_matches('-').trim();
if let Some(dep) = parse_stack_dep(item) {
deps.push(dep);
}
}
}
Ok(ParsedManifest {
ecosystem: "stackage",
name: None,
version: None,
dependencies: deps,
})
}
}
fn parse_stack_dep(item: &str) -> Option<DeclaredDep> {
let item = item.trim().trim_matches('"').trim_matches('\'');
if item.is_empty() {
return None;
}
// Git dep: `git: ...` (multi-line object, heuristic: skip, we can't fully parse)
if item == "git:" || item.starts_with("git:") {
return None;
}
// Hackage form: `pkg-name-1.2.3` or `pkg-name-1.2.3@sha256:...`
// The package name uses hyphens; version is the last hyphenated segment starting with digit
let base = item.split('@').next().unwrap_or(item);
// Find where the version starts (last hyphen before a digit)
let name;
let version_req;
if let Some(ver_start) = find_version_start(base) {
name = base[..ver_start - 1].to_string(); // strip trailing hyphen
version_req = Some(base[ver_start..].to_string());
} else {
name = base.to_string();
version_req = None;
}
if name.is_empty() {
return None;
}
Some(DeclaredDep {
name,
version_req,
kind: DepKind::Normal,
})
}
/// Find the index where the version part starts in a `pkg-name-1.2.3` string.
/// Returns the index of the first digit of the version (after the separating hyphen).
fn find_version_start(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
// Walk backwards from the end to find last hyphen before a digit sequence
(1..bytes.len())
.rev()
.find(|&i| bytes[i - 1] == b'-' && bytes[i].is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ManifestParser;
#[test]
fn test_parse_stack_yaml() {
let content = r#"resolver: lts-21.0
packages:
- .
extra-deps:
- acme-pkg-1.2.3
- aeson-2.1.2.1
- text-2.0.2@sha256:abc123
"#;
let m = StackParser.parse(content).unwrap();
assert_eq!(m.ecosystem, "stackage");
assert_eq!(m.dependencies.len(), 3);
let acme = m
.dependencies
.iter()
.find(|d| d.name == "acme-pkg")
.unwrap();
assert_eq!(acme.version_req.as_deref(), Some("1.2.3"));
let text = m.dependencies.iter().find(|d| d.name == "text").unwrap();
assert_eq!(text.version_req.as_deref(), Some("2.0.2"));
}
}