1use crate::diagnostics::{source_line, split_source_lines, Diagnostic};
9
10pub const MANIFEST_NAME: &str = "doge.toml";
12
13const MANIFEST_HEADLINE: &str = "very manifest. much confuse.";
15
16#[derive(Debug, Clone, PartialEq)]
18pub struct Manifest {
19 pub name: String,
21 pub version: String,
23 pub entry: String,
25 pub dependencies: Vec<Dependency>,
27}
28
29#[derive(Debug, Clone, PartialEq)]
32pub struct Dependency {
33 pub alias: String,
35 pub source: DependencySource,
37 pub line: u32,
39}
40
41#[derive(Debug, Clone, PartialEq)]
43pub enum DependencySource {
44 Path(String),
46 Git { url: String, rev: GitRev },
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub enum GitRev {
53 Default,
55 Rev(String),
57 Tag(String),
59 Branch(String),
61}
62
63impl GitRev {
64 pub fn as_ref_name(&self) -> Option<&str> {
66 match self {
67 GitRev::Default => None,
68 GitRev::Rev(r) | GitRev::Tag(r) | GitRev::Branch(r) => Some(r),
69 }
70 }
71}
72
73enum Section {
75 None,
77 Package,
78 Dependencies,
79}
80
81pub fn parse(path: &str, source: &str) -> Result<Manifest, Diagnostic> {
83 let lines = split_source_lines(source);
84 let err = |line: u32, message: &str, hint: &str| {
85 Diagnostic::new(path, line, 1, source_line(&lines, line), message)
86 .with_headline(MANIFEST_HEADLINE)
87 .with_hint(hint)
88 };
89
90 let mut section = Section::None;
91 let mut name: Option<String> = None;
92 let mut version: Option<String> = None;
93 let mut entry: Option<String> = None;
94 let mut dependencies: Vec<Dependency> = Vec::new();
95
96 for (index, raw) in lines.iter().enumerate() {
97 let line_no = (index + 1) as u32;
98 let text = strip_comment(raw).trim();
99 if text.is_empty() {
100 continue;
101 }
102
103 if let Some(header) = text.strip_prefix('[') {
104 let header = header.strip_suffix(']').ok_or_else(|| {
105 err(
106 line_no,
107 "a section header needs a closing ]",
108 "write [package] or [dependencies]",
109 )
110 })?;
111 section = match header.trim() {
112 "package" => Section::Package,
113 "dependencies" => Section::Dependencies,
114 other => {
115 return Err(err(
116 line_no,
117 &format!("doge does not know the section [{other}]"),
118 "manifests have [package] and [dependencies]",
119 ))
120 }
121 };
122 continue;
123 }
124
125 let (key, value) = split_key_value(text).ok_or_else(|| {
126 err(
127 line_no,
128 "expected a key = value line",
129 "write name = \"my_app\" under [package]",
130 )
131 })?;
132
133 match section {
134 Section::None => {
135 return Err(err(
136 line_no,
137 "this line is not under a [section]",
138 "start the file with [package]",
139 ))
140 }
141 Section::Package => {
142 let string = parse_string(value).ok_or_else(|| {
143 err(
144 line_no,
145 &format!("{key} must be a quoted string"),
146 "quote the value, e.g. name = \"my_app\"",
147 )
148 })?;
149 match key {
150 "name" => name = Some(string),
151 "version" => version = Some(string),
152 "entry" => entry = Some(string),
153 other => {
154 return Err(err(
155 line_no,
156 &format!("[package] has no key named {other}"),
157 "known keys: name, version, entry",
158 ))
159 }
160 }
161 }
162 Section::Dependencies => {
163 let source = parse_dependency(value, line_no, &err)?;
164 dependencies.push(Dependency {
165 alias: key.to_string(),
166 source,
167 line: line_no,
168 });
169 }
170 }
171 }
172
173 let name = name.ok_or_else(|| {
174 Diagnostic::new(
175 path,
176 1,
177 1,
178 source_line(&lines, 1),
179 "a manifest needs a package name",
180 )
181 .with_headline(MANIFEST_HEADLINE)
182 .with_hint("add [package] with name = \"my_app\"")
183 })?;
184
185 Ok(Manifest {
186 name,
187 version: version.unwrap_or_else(|| "0.0.0".to_string()),
188 entry: entry.unwrap_or_else(|| "main.doge".to_string()),
189 dependencies,
190 })
191}
192
193fn strip_comment(line: &str) -> &str {
195 let mut in_string = false;
196 for (i, ch) in line.char_indices() {
197 match ch {
198 '"' => in_string = !in_string,
199 '#' if !in_string => return &line[..i],
200 _ => {}
201 }
202 }
203 line
204}
205
206fn split_key_value(text: &str) -> Option<(&str, &str)> {
208 let eq = text.find('=')?;
209 let key = text[..eq].trim();
210 let value = text[eq + 1..].trim();
211 if key.is_empty() || value.is_empty() {
212 return None;
213 }
214 Some((key, value))
215}
216
217fn parse_string(raw: &str) -> Option<String> {
220 let inner = raw.strip_prefix('"')?.strip_suffix('"')?;
221 if inner.contains('"') {
222 return None;
223 }
224 Some(inner.to_string())
225}
226
227fn parse_dependency(
229 value: &str,
230 line: u32,
231 err: &impl Fn(u32, &str, &str) -> Diagnostic,
232) -> Result<DependencySource, Diagnostic> {
233 let inner = value
234 .strip_prefix('{')
235 .and_then(|v| v.strip_suffix('}'))
236 .ok_or_else(|| {
237 err(
238 line,
239 "a dependency is an inline table",
240 "write greet = { path = \"lib/greet\" }",
241 )
242 })?;
243
244 let mut path = None;
245 let mut git = None;
246 let mut rev = None;
247 let mut tag = None;
248 let mut branch = None;
249
250 for pair in inner.split(',') {
251 let pair = pair.trim();
252 if pair.is_empty() {
253 continue;
254 }
255 let (key, raw) = split_key_value(pair).ok_or_else(|| {
256 err(
257 line,
258 "each dependency field is key = \"value\"",
259 "write path = \"lib/greet\" or git = \"https://…\"",
260 )
261 })?;
262 let string = parse_string(raw).ok_or_else(|| {
263 err(
264 line,
265 &format!("{key} must be a quoted string"),
266 "quote the value, e.g. path = \"lib/greet\"",
267 )
268 })?;
269 let slot = match key {
270 "path" => &mut path,
271 "git" => &mut git,
272 "rev" => &mut rev,
273 "tag" => &mut tag,
274 "branch" => &mut branch,
275 other => {
276 return Err(err(
277 line,
278 &format!("a dependency has no field named {other}"),
279 "fields: path, git, rev, tag, branch",
280 ))
281 }
282 };
283 *slot = Some(string);
284 }
285
286 match (path, git) {
287 (Some(_), Some(_)) => Err(err(
288 line,
289 "a dependency is either a path or a git source, not both",
290 "keep one of path or git",
291 )),
292 (Some(path), None) => {
293 if rev.is_some() || tag.is_some() || branch.is_some() {
294 return Err(err(
295 line,
296 "rev/tag/branch only apply to a git dependency",
297 "drop them, or switch path to git",
298 ));
299 }
300 Ok(DependencySource::Path(path))
301 }
302 (None, Some(url)) => {
303 let rev = git_rev(rev, tag, branch, line, err)?;
304 Ok(DependencySource::Git { url, rev })
305 }
306 (None, None) => Err(err(
307 line,
308 "a dependency needs a path or a git source",
309 "write { path = \"lib/greet\" } or { git = \"https://…\" }",
310 )),
311 }
312}
313
314fn git_rev(
317 rev: Option<String>,
318 tag: Option<String>,
319 branch: Option<String>,
320 line: u32,
321 err: &impl Fn(u32, &str, &str) -> Diagnostic,
322) -> Result<GitRev, Diagnostic> {
323 let mut chosen = Vec::new();
324 if let Some(rev) = rev {
325 chosen.push(GitRev::Rev(rev));
326 }
327 if let Some(tag) = tag {
328 chosen.push(GitRev::Tag(tag));
329 }
330 if let Some(branch) = branch {
331 chosen.push(GitRev::Branch(branch));
332 }
333 match chosen.len() {
334 0 => Ok(GitRev::Default),
335 1 => Ok(chosen
336 .into_iter()
337 .next()
338 .expect("compiler bug: one git rev")),
339 _ => Err(err(
340 line,
341 "a git dependency pins one of rev, tag, or branch",
342 "keep just one of rev/tag/branch",
343 )),
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn parse_ok(source: &str) -> Manifest {
352 parse("doge.toml", source).expect("manifest should parse")
353 }
354
355 #[test]
356 fn parses_package_and_path_dependency() {
357 let m = parse_ok(
358 "[package]\nname = \"app\"\nversion = \"0.2.0\"\nentry = \"main.doge\"\n\n[dependencies]\ngreet = { path = \"lib/greet\" }\n",
359 );
360 assert_eq!(m.name, "app");
361 assert_eq!(m.version, "0.2.0");
362 assert_eq!(m.entry, "main.doge");
363 assert_eq!(m.dependencies.len(), 1);
364 assert_eq!(m.dependencies[0].alias, "greet");
365 assert_eq!(
366 m.dependencies[0].source,
367 DependencySource::Path("lib/greet".to_string())
368 );
369 assert_eq!(m.dependencies[0].line, 7);
370 }
371
372 #[test]
373 fn version_and_entry_have_defaults() {
374 let m = parse_ok("[package]\nname = \"app\"\n");
375 assert_eq!(m.version, "0.0.0");
376 assert_eq!(m.entry, "main.doge");
377 assert!(m.dependencies.is_empty());
378 }
379
380 #[test]
381 fn parses_git_dependency_with_a_tag() {
382 let m = parse_ok(
383 "[package]\nname = \"app\"\n\n[dependencies]\ncool = { git = \"https://example.com/cool.git\", tag = \"v1.0.0\" }\n",
384 );
385 assert_eq!(
386 m.dependencies[0].source,
387 DependencySource::Git {
388 url: "https://example.com/cool.git".to_string(),
389 rev: GitRev::Tag("v1.0.0".to_string()),
390 }
391 );
392 }
393
394 #[test]
395 fn a_bare_git_dependency_uses_the_default_branch() {
396 let m = parse_ok(
397 "[package]\nname = \"app\"\n\n[dependencies]\ncool = { git = \"https://example.com/cool.git\" }\n",
398 );
399 match &m.dependencies[0].source {
400 DependencySource::Git { rev, .. } => assert_eq!(*rev, GitRev::Default),
401 other => panic!("expected a git dep, got {other:?}"),
402 }
403 }
404
405 #[test]
406 fn comments_and_blank_lines_are_ignored() {
407 let m = parse_ok("# a manifest\n\n[package]\nname = \"app\" # the name\n");
408 assert_eq!(m.name, "app");
409 }
410
411 #[test]
412 fn a_missing_name_is_an_error() {
413 let err = parse("doge.toml", "[package]\nversion = \"1.0.0\"\n").unwrap_err();
414 assert_eq!(err.headline, MANIFEST_HEADLINE);
415 assert!(err.message.contains("package name"));
416 }
417
418 #[test]
419 fn path_and_git_together_are_rejected() {
420 let err = parse(
421 "doge.toml",
422 "[package]\nname = \"a\"\n\n[dependencies]\nx = { path = \"p\", git = \"https://g\" }\n",
423 )
424 .unwrap_err();
425 assert!(err.message.contains("not both"));
426 }
427
428 #[test]
429 fn two_git_revs_are_rejected() {
430 let err = parse(
431 "doge.toml",
432 "[package]\nname = \"a\"\n\n[dependencies]\nx = { git = \"https://g\", tag = \"v1\", branch = \"main\" }\n",
433 )
434 .unwrap_err();
435 assert!(err.message.contains("one of rev"));
436 }
437
438 #[test]
439 fn an_unknown_section_is_an_error() {
440 let err = parse("doge.toml", "[deps]\nx = 1\n").unwrap_err();
441 assert!(err.message.contains("[deps]"));
442 }
443
444 #[test]
445 fn an_unknown_package_key_is_an_error() {
446 let err = parse("doge.toml", "[package]\nname = \"a\"\nauthor = \"me\"\n").unwrap_err();
447 assert!(err.message.contains("author"));
448 }
449}