Skip to main content

doge_compiler/
manifest.rs

1//! Parsing `doge.toml`, a project's manifest. A manifest names the package and
2//! lists its dependencies (by local path or git). The format is a small, strict
3//! subset of TOML — `[package]`/`[dependencies]` tables, `key = "string"` pairs,
4//! and inline-table dependency values — so this parser stays hand-written and
5//! doge-compiler keeps zero third-party dependencies. Every failure is a
6//! doge-flavored [`Diagnostic`] anchored at the offending line.
7
8use crate::diagnostics::{source_line, split_source_lines, Diagnostic};
9
10/// The manifest file name discovered at a project root.
11pub const MANIFEST_NAME: &str = "doge.toml";
12
13/// The headline for every manifest problem.
14const MANIFEST_HEADLINE: &str = "very manifest. much confuse.";
15
16/// A parsed `doge.toml`.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Manifest {
19    /// The package name (also the default binary name for `doge build`).
20    pub name: String,
21    /// The package version. Defaults to `0.0.0` when omitted.
22    pub version: String,
23    /// The entry script, relative to the project root. Defaults to `main.doge`.
24    pub entry: String,
25    /// Declared dependencies, in file order.
26    pub dependencies: Vec<Dependency>,
27}
28
29/// One `[dependencies]` entry: a local alias bound by `so <alias>` and where its
30/// package lives.
31#[derive(Debug, Clone, PartialEq)]
32pub struct Dependency {
33    /// The alias `so <alias>` imports; also the module binding name.
34    pub alias: String,
35    /// Where the dependency's package is fetched from.
36    pub source: DependencySource,
37    /// 1-based line in `doge.toml`, so resolution errors point at the right line.
38    pub line: u32,
39}
40
41/// Where a dependency's package comes from.
42#[derive(Debug, Clone, PartialEq)]
43pub enum DependencySource {
44    /// A directory relative to the declaring package's root.
45    Path(String),
46    /// A git repository, pinned to a revision.
47    Git { url: String, rev: GitRev },
48}
49
50/// Which git revision a git dependency resolves to.
51#[derive(Debug, Clone, PartialEq)]
52pub enum GitRev {
53    /// No `rev`/`tag`/`branch`: the repository's default branch.
54    Default,
55    /// A commit sha (`rev = "…"`).
56    Rev(String),
57    /// A tag (`tag = "…"`).
58    Tag(String),
59    /// A branch (`branch = "…"`).
60    Branch(String),
61}
62
63impl GitRev {
64    /// The git ref to check out, or `None` for the default branch.
65    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
73/// The section a line belongs to while parsing.
74enum Section {
75    /// Before any `[table]` header.
76    None,
77    Package,
78    Dependencies,
79}
80
81/// Parse `doge.toml` source (named `path` for diagnostics) into a [`Manifest`].
82pub 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
193/// Drop a trailing `# comment` from a line, ignoring `#` inside a quoted string.
194fn 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
206/// Split `key = value` on the first top-level `=`, returning the trimmed halves.
207fn 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
217/// Parse a double-quoted string. The subset has no escapes, so a value may not
218/// contain a `"` (paths and URLs never do).
219fn 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
227/// Parse a dependency inline table `{ path = "…" }` or `{ git = "…", tag = "…" }`.
228fn 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
314/// Fold the optional `rev`/`tag`/`branch` fields into one [`GitRev`], rejecting a
315/// combination of more than one.
316fn 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}