1use 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
13pub 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#[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#[derive(Debug, Clone, PartialEq)]
38pub struct Package {
39 pub name: String,
40 pub version: semver::Version,
41 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 pub span: Span,
52}
53
54#[derive(Debug, Clone, PartialEq)]
57pub struct Dependency {
58 pub name: String,
59 pub version: Option<semver::VersionReq>,
60 pub path: Option<PathBuf>,
61 pub path_span: Option<Span>,
64 pub span: Span,
66}
67
68pub 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 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}