Skip to main content

nova_pm/
manifest.rs

1//! `nova.toml`: what 3.0 reads, and the diagnostics that point into it
2//! (spec `docs/superpowers/specs/2026-10-07-phase-3-0-foundations-design.md`
3//! §5).
4
5use std::ops::Range;
6use std::path::PathBuf;
7
8use nova_diagnostics::{Diagnostic, FileId, Severity, Span};
9use toml_edit::{ImDocument, Item, Table, TableLike};
10
11use crate::name::check_name;
12
13/// The only edition this nova knows.
14pub const EDITION: &str = "2026";
15
16const PACKAGE_KEYS: [&str; 9] = [
17    "name",
18    "version",
19    "edition",
20    "description",
21    "license",
22    "repository",
23    "authors",
24    "keywords",
25    "categories",
26];
27
28/// A parsed `nova.toml`.
29#[derive(Debug, Clone, PartialEq)]
30pub struct Manifest {
31    pub package: Package,
32    pub dependencies: Vec<Dependency>,
33    pub dev_dependencies: Vec<Dependency>,
34}
35
36/// `[package]`.
37#[derive(Debug, Clone, PartialEq)]
38pub struct Package {
39    pub name: String,
40    pub version: semver::Version,
41    /// Always `"2026"`, the only edition.
42    pub edition: String,
43    pub description: Option<String>,
44    pub license: Option<String>,
45    pub repository: Option<String>,
46    pub authors: Vec<String>,
47    pub keywords: Vec<String>,
48    pub categories: Vec<String>,
49    /// Where `[package]` is, for a diagnostic about the package as a
50    /// whole (M0013, spec 3.3a §3.5).
51    pub span: Span,
52}
53
54/// One entry of `[dependencies]` or `[dev-dependencies]`: a version
55/// requirement or a path, never both (spec §5.1).
56#[derive(Debug, Clone, PartialEq)]
57pub struct Dependency {
58    pub name: String,
59    pub version: Option<semver::VersionReq>,
60    pub path: Option<PathBuf>,
61    /// Where its `path` value is, when it has one: M0007 and M0009 point
62    /// there (spec 3.3a §3.5).
63    pub path_span: Option<Span>,
64    /// The entry's key, where a diagnostic about the entry points.
65    pub span: Span,
66}
67
68/// Parse `source`, the text of a `nova.toml` that a `FileDb` holds as
69/// `file`. The parser keeps positions (toml_edit's `ImDocument`), so every
70/// diagnostic carries one. The manifest is returned when no diagnostic is
71/// an error; warnings (M0006) leave it in place.
72pub fn parse(source: &str, file: FileId) -> (Option<Manifest>, Vec<Diagnostic>) {
73    let document = match ImDocument::parse(source) {
74        Ok(document) => document,
75        Err(error) => {
76            let diagnostic = Diagnostic::error("M0001", "nova.toml is not valid TOML")
77                .with_primary_label(span(file, error.span()), error.message().trim().to_string());
78            return (None, vec![diagnostic]);
79        }
80    };
81    let mut checker = Checker {
82        file,
83        diagnostics: Vec::new(),
84    };
85    let manifest = checker.manifest(document.as_table());
86    let failed = checker
87        .diagnostics
88        .iter()
89        .any(|diagnostic| diagnostic.severity == Severity::Error);
90    (manifest.filter(|_| !failed), checker.diagnostics)
91}
92
93fn span(file: FileId, range: Option<Range<usize>>) -> Span {
94    let range = range.unwrap_or(0..0);
95    Span::new(range.start as u32, range.end as u32, file)
96}
97
98struct Checker {
99    file: FileId,
100    diagnostics: Vec<Diagnostic>,
101}
102
103impl Checker {
104    fn error(&mut self, code: &str, message: String, at: Option<Range<usize>>, label: &str) {
105        let diagnostic =
106            Diagnostic::error(code, message).with_primary_label(span(self.file, at), label);
107        self.diagnostics.push(diagnostic);
108    }
109
110    fn manifest(&mut self, top: &Table) -> Option<Manifest> {
111        self.unknown_keys(top, "", &["package", "dependencies", "dev-dependencies"]);
112        let package = match top.get("package") {
113            None => {
114                self.error(
115                    "M0002",
116                    "nova.toml has no `[package]` table".to_string(),
117                    None,
118                    "expected `[package]` with `name`, `version` and `edition`",
119                );
120                None
121            }
122            Some(item) => match item.as_table_like() {
123                Some(table) => self.package(table, item.span()),
124                None => {
125                    self.error(
126                        "M0003",
127                        format!("`package` must be a table, not {}", item.type_name()),
128                        item.span(),
129                        "expected a table",
130                    );
131                    None
132                }
133            },
134        };
135        let dependencies = self.dependencies(top, "dependencies");
136        let dev_dependencies = self.dependencies(top, "dev-dependencies");
137        Some(Manifest {
138            package: package?,
139            dependencies,
140            dev_dependencies,
141        })
142    }
143
144    fn package(&mut self, table: &dyn TableLike, at: Option<Range<usize>>) -> Option<Package> {
145        self.unknown_keys(table, "package.", &PACKAGE_KEYS);
146        let name =
147            self.required(table, "name", &at)
148                .and_then(|(name, at)| match check_name(&name) {
149                    Ok(()) => Some(name),
150                    Err(message) => {
151                        self.error("M0003", message, at, "not a valid package name");
152                        None
153                    }
154                });
155        let version = self
156            .required(table, "version", &at)
157            .and_then(|(text, at)| self.version(&text, at));
158        let edition = self
159            .required(table, "edition", &at)
160            .and_then(|(edition, at)| {
161                if edition == EDITION {
162                    Some(edition)
163                } else {
164                    self.error(
165                        "M0003",
166                        format!("unknown edition `{edition}`"),
167                        at,
168                        "this nova supports edition \"2026\"",
169                    );
170                    None
171                }
172            });
173        let description = self.optional_string(table, "description");
174        let license = self.optional_string(table, "license");
175        let repository = self.optional_string(table, "repository");
176        let authors = self.string_array(table, "authors");
177        let keywords = self.string_array(table, "keywords");
178        let categories = self.string_array(table, "categories");
179        Some(Package {
180            name: name?,
181            version: version?,
182            edition: edition?,
183            description,
184            license,
185            repository,
186            authors,
187            keywords,
188            categories,
189            span: span(self.file, at),
190        })
191    }
192
193    /// A required string key of `[package]`, with its value's position.
194    fn required(
195        &mut self,
196        table: &dyn TableLike,
197        key: &str,
198        table_at: &Option<Range<usize>>,
199    ) -> Option<(String, Option<Range<usize>>)> {
200        let Some(item) = table.get(key) else {
201            self.error(
202                "M0002",
203                format!("`[package]` has no `{key}`"),
204                table_at.clone(),
205                &format!("add `{key} = \"...\"` to this table"),
206            );
207            return None;
208        };
209        let value = self.string(item, &format!("package.{key}"))?;
210        Some((value, item.span()))
211    }
212
213    fn string(&mut self, item: &Item, what: &str) -> Option<String> {
214        if let Some(value) = item.as_str() {
215            return Some(value.to_string());
216        }
217        self.error(
218            "M0003",
219            format!("`{what}` must be a string, not {}", item.type_name()),
220            item.span(),
221            "expected a string",
222        );
223        None
224    }
225
226    fn optional_string(&mut self, table: &dyn TableLike, key: &str) -> Option<String> {
227        let item = table.get(key)?;
228        self.string(item, &format!("package.{key}"))
229    }
230
231    fn string_array(&mut self, table: &dyn TableLike, key: &str) -> Vec<String> {
232        let Some(item) = table.get(key) else {
233            return Vec::new();
234        };
235        let strings = item.as_array().and_then(|array| {
236            array
237                .iter()
238                .map(|value| value.as_str().map(str::to_string))
239                .collect::<Option<Vec<_>>>()
240        });
241        strings.unwrap_or_else(|| {
242            self.error(
243                "M0003",
244                format!("`package.{key}` must be an array of strings"),
245                item.span(),
246                "expected an array of strings",
247            );
248            Vec::new()
249        })
250    }
251
252    fn version(&mut self, text: &str, at: Option<Range<usize>>) -> Option<semver::Version> {
253        match semver::Version::parse(text) {
254            Ok(version) if version.build.is_empty() => Some(version),
255            Ok(_) => {
256                self.error(
257                    "M0003",
258                    format!("package version `{text}` carries build metadata"),
259                    at,
260                    "a package version takes no `+...` part",
261                );
262                None
263            }
264            Err(error) => {
265                self.error(
266                    "M0003",
267                    format!("`{text}` is not a version: {error}"),
268                    at,
269                    "a version is MAJOR.MINOR.PATCH, such as `0.1.0`",
270                );
271                None
272            }
273        }
274    }
275
276    fn dependencies(&mut self, top: &Table, key: &str) -> Vec<Dependency> {
277        let Some(item) = top.get(key) else {
278            return Vec::new();
279        };
280        let Some(table) = item.as_table_like() else {
281            self.error(
282                "M0003",
283                format!("`{key}` must be a table, not {}", item.type_name()),
284                item.span(),
285                "expected a table",
286            );
287            return Vec::new();
288        };
289        let mut found = Vec::new();
290        for (name, entry) in table.iter() {
291            let at = table.get_key_value(name).and_then(|(key, _)| key.span());
292            if let Some(dependency) = self.dependency(key, name, entry, at) {
293                found.push(dependency);
294            }
295        }
296        found
297    }
298
299    fn dependency(
300        &mut self,
301        table: &str,
302        name: &str,
303        entry: &Item,
304        at: Option<Range<usize>>,
305    ) -> Option<Dependency> {
306        let position = span(self.file, at.clone());
307        if let Some(requirement) = entry.as_str() {
308            let version = self.requirement(requirement, entry.span())?;
309            return Some(Dependency {
310                name: name.to_string(),
311                version: Some(version),
312                path: None,
313                path_span: None,
314                span: position,
315            });
316        }
317        let Some(fields) = entry.as_table_like() else {
318            self.error(
319                "M0003",
320                format!(
321                    "dependency `{name}` must be a version requirement or a table, not {}",
322                    entry.type_name()
323                ),
324                entry.span(),
325                "expected a string or a table",
326            );
327            return None;
328        };
329        self.unknown_keys(fields, &format!("{table}.{name}."), &["version", "path"]);
330        let path_span = fields.get("path").map(|item| span(self.file, item.span()));
331        let version = match fields.get("version") {
332            Some(item) => {
333                let text = self.string(item, &format!("{table}.{name}.version"))?;
334                Some(self.requirement(&text, item.span())?)
335            }
336            None => None,
337        };
338        let path = match fields.get("path") {
339            Some(item) => Some(PathBuf::from(
340                self.string(item, &format!("{table}.{name}.path"))?,
341            )),
342            None => None,
343        };
344        match (version, path) {
345            (None, None) => {
346                self.error(
347                    "M0004",
348                    format!("dependency `{name}` has neither `version` nor `path`"),
349                    at,
350                    "add `version = \"...\"` or `path = \"...\"`",
351                );
352                None
353            }
354            (Some(_), Some(_)) => {
355                self.error(
356                    "M0003",
357                    format!("dependency `{name}` has both `version` and `path`"),
358                    at,
359                    "use one of them; what both would mean is not decided yet",
360                );
361                None
362            }
363            (version, path) => Some(Dependency {
364                name: name.to_string(),
365                version,
366                path,
367                path_span,
368                span: position,
369            }),
370        }
371    }
372
373    fn requirement(&mut self, text: &str, at: Option<Range<usize>>) -> Option<semver::VersionReq> {
374        match semver::VersionReq::parse(text) {
375            Ok(requirement) => Some(requirement),
376            Err(error) => {
377                self.error(
378                    "M0003",
379                    format!("`{text}` is not a version requirement: {error}"),
380                    at,
381                    "a requirement looks like `1.2` or `^0.3`",
382                );
383                None
384            }
385        }
386    }
387
388    fn unknown_keys(&mut self, table: &dyn TableLike, prefix: &str, known: &[&str]) {
389        for (key, item) in table.iter() {
390            if known.contains(&key) {
391                continue;
392            }
393            let at = table
394                .get_key_value(key)
395                .and_then(|(found, _)| found.span())
396                .or_else(|| item.span());
397            let diagnostic =
398                Diagnostic::warning("M0006", format!("unknown key `{prefix}{key}`, ignored"))
399                    .with_primary_label(span(self.file, at), "this nova does not read this key");
400            self.diagnostics.push(diagnostic);
401        }
402    }
403}