1use std::collections::BTreeMap;
4use std::ops::Range;
5
6use toml::Spanned;
7use toml::de::{DeTable, DeValue};
8use wolf_diag::{Diagnostic, codes};
9use wolf_span::{FileId, Span};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Kind {
14 Cli,
16 Lib,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub enum Target {
23 Ts,
25 Go,
27 Rust,
29}
30
31impl Target {
32 pub const ALL: [Target; 3] = [Target::Ts, Target::Go, Target::Rust];
34
35 pub fn name(self) -> &'static str {
37 match self {
38 Target::Ts => "ts",
39 Target::Go => "go",
40 Target::Rust => "rust",
41 }
42 }
43
44 fn from_name(name: &str) -> Option<Target> {
45 Target::ALL.into_iter().find(|t| t.name() == name)
46 }
47}
48
49#[derive(Clone, Debug, PartialEq)]
51pub enum ConfigValue {
52 Int(i64),
54 Float(f64),
56 Bool(bool),
58 Str(String),
60}
61
62impl ConfigValue {
63 pub fn type_name(&self) -> &'static str {
65 match self {
66 ConfigValue::Int(_) => "Int",
67 ConfigValue::Float(_) => "Float",
68 ConfigValue::Bool(_) => "Bool",
69 ConfigValue::Str(_) => "String",
70 }
71 }
72}
73
74#[derive(Clone, Debug, PartialEq)]
76pub struct ConfigOverride {
77 pub module: Vec<String>,
79 pub name: String,
81 pub value: ConfigValue,
83 pub key_span: Span,
85 pub value_span: Span,
87}
88
89#[derive(Clone, Debug, PartialEq)]
91pub struct Manifest {
92 pub file: FileId,
94 pub name: String,
96 pub version: String,
98 pub kind: Kind,
100 pub kind_span: Span,
102 pub default_target: Target,
104 pub enabled: Vec<Target>,
106 pub config: Vec<ConfigOverride>,
108 pub dependencies: BTreeMap<Target, Vec<(String, String)>>,
110}
111
112struct Reader {
113 file: FileId,
114 diagnostics: Vec<Diagnostic>,
115}
116
117impl Reader {
118 fn span(&self, range: Range<usize>) -> Span {
119 let start = u32::try_from(range.start).unwrap_or(u32::MAX);
120 let end = u32::try_from(range.end).unwrap_or(u32::MAX);
121 Span::new(self.file, start, end)
122 }
123
124 fn error(&mut self, range: Range<usize>, message: impl Into<String>, label: impl Into<String>) {
125 let span = self.span(range);
126 self.diagnostics
127 .push(Diagnostic::new(codes::E0611, message).primary(span, label));
128 }
129
130 fn unknown_keys(&mut self, table: &DeTable<'_>, allowed: &[&str], place: &str) {
132 for (key, _) in table.iter() {
133 if !allowed.contains(&key.get_ref().as_ref()) {
134 let name = key.get_ref().to_string();
135 let range = key.span();
136 let message = format!("unknown key `{name}` in {place}");
137 let label = format!("allowed here: {}", allowed.join(", "));
138 self.error(range, message, label);
139 }
140 }
141 }
142
143 fn table<'a, 'i>(
144 &mut self,
145 value: &'a Spanned<DeValue<'i>>,
146 what: &str,
147 ) -> Option<&'a DeTable<'i>> {
148 match value.get_ref() {
149 DeValue::Table(table) => Some(table),
150 other => {
151 let found = other.type_str();
152 self.error(
153 value.span(),
154 format!("`{what}` must be a table"),
155 format!("found {found}"),
156 );
157 None
158 }
159 }
160 }
161
162 fn string(&mut self, value: &Spanned<DeValue<'_>>, what: &str) -> Option<String> {
163 match value.get_ref() {
164 DeValue::String(text) => Some(text.to_string()),
165 other => {
166 let found = other.type_str();
167 self.error(
168 value.span(),
169 format!("`{what}` must be a string"),
170 format!("found {found}"),
171 );
172 None
173 }
174 }
175 }
176}
177
178fn get<'a, 'i>(table: &'a DeTable<'i>, key: &str) -> Option<&'a Spanned<DeValue<'i>>> {
179 table
180 .iter()
181 .find(|(k, _)| k.get_ref().as_ref() == key)
182 .map(|(_, v)| v)
183}
184
185pub fn parse_manifest(file: FileId, text: &str) -> (Option<Manifest>, Vec<Diagnostic>) {
188 let mut reader = Reader {
189 file,
190 diagnostics: Vec::new(),
191 };
192 let document = match DeTable::parse(text) {
193 Ok(document) => document,
194 Err(error) => {
195 let range = error.span().unwrap_or(0..0);
196 let message = error.message().to_string();
197 reader.error(range, "invalid TOML in `wolfpack.toml`", message);
198 return (None, reader.diagnostics);
199 }
200 };
201 let root = document.get_ref();
202 reader.unknown_keys(
203 root,
204 &["package", "targets", "config", "extern"],
205 "`wolfpack.toml`",
206 );
207
208 let manifest = read(&mut reader, root);
209 (manifest, reader.diagnostics)
210}
211
212fn read(reader: &mut Reader, root: &DeTable<'_>) -> Option<Manifest> {
213 let Some(package_value) = get(root, "package") else {
214 reader.error(
215 0..0,
216 "missing `[package]` table",
217 "every manifest starts with `[package]`",
218 );
219 return None;
220 };
221 let package = reader.table(package_value, "package")?;
222 reader.unknown_keys(package, &["name", "version", "kind"], "`[package]`");
223
224 let mut ok = true;
225 let name = match get(package, "name") {
226 Some(value) => match reader.string(value, "name") {
227 Some(name) if is_kebab_case(&name) => Some(name),
228 Some(name) => {
229 reader.error(
230 value.span(),
231 format!("invalid package name `{name}`"),
232 "use lower-case words separated by `-`, for example `my-app`",
233 );
234 None
235 }
236 None => None,
237 },
238 None => {
239 reader.error(
240 package_value.span(),
241 "missing `name` in `[package]`",
242 "add `name = \"my-app\"`",
243 );
244 None
245 }
246 };
247 let version = match get(package, "version") {
248 Some(value) => match reader.string(value, "version") {
249 Some(version) if is_semver(&version) => Some(version),
250 Some(version) => {
251 reader.error(
252 value.span(),
253 format!("invalid version `{version}`"),
254 "use MAJOR.MINOR.PATCH, for example `0.1.0`",
255 );
256 None
257 }
258 None => None,
259 },
260 None => {
261 reader.error(
262 package_value.span(),
263 "missing `version` in `[package]`",
264 "add `version = \"0.1.0\"`",
265 );
266 None
267 }
268 };
269 let (kind, kind_span) = match get(package, "kind") {
270 Some(value) => {
271 let span = reader.span(value.span());
272 match reader.string(value, "kind").as_deref() {
273 Some("cli") => (Some(Kind::Cli), span),
274 Some("lib") => (Some(Kind::Lib), span),
275 Some(other) => {
276 reader.error(
277 value.span(),
278 format!("unknown kind `{other}`"),
279 "expected `cli` or `lib`",
280 );
281 (None, span)
282 }
283 None => (None, span),
284 }
285 }
286 None => {
287 reader.error(
288 package_value.span(),
289 "missing `kind` in `[package]`",
290 "add `kind = \"cli\"` or `kind = \"lib\"`",
291 );
292 (None, reader.span(0..0))
293 }
294 };
295
296 let (default_target, enabled) = targets(reader, root).unwrap_or_else(|| {
297 ok = false;
298 (Target::Ts, Target::ALL.to_vec())
299 });
300 let config = config(reader, root);
301 let dependencies = dependencies(reader, root);
302
303 match (name, version, kind) {
304 (Some(name), Some(version), Some(kind)) if ok => Some(Manifest {
305 file: reader.file,
306 name,
307 version,
308 kind,
309 kind_span,
310 default_target,
311 enabled,
312 config,
313 dependencies,
314 }),
315 _ => None,
316 }
317}
318
319fn targets(reader: &mut Reader, root: &DeTable<'_>) -> Option<(Target, Vec<Target>)> {
320 let Some(value) = get(root, "targets") else {
321 return Some((Target::Ts, Target::ALL.to_vec()));
322 };
323 let table = reader.table(value, "targets")?;
324 reader.unknown_keys(table, &["default", "enabled"], "`[targets]`");
325 let mut enabled = Target::ALL.to_vec();
326 let mut valid = true;
327 if let Some(list) = get(table, "enabled") {
328 match list.get_ref() {
329 DeValue::Array(items) => {
330 enabled.clear();
331 for item in items.iter() {
332 match item.get_ref().as_str().and_then(Target::from_name) {
333 Some(target) if !enabled.contains(&target) => enabled.push(target),
334 Some(_) => {
335 reader.error(
336 item.span(),
337 "target listed twice",
338 "remove the duplicate",
339 );
340 valid = false;
341 }
342 None => {
343 reader.error(
344 item.span(),
345 "unknown target",
346 "targets are `ts`, `go` and `rust`",
347 );
348 valid = false;
349 }
350 }
351 }
352 if enabled.is_empty() && valid {
353 reader.error(
354 list.span(),
355 "no target enabled",
356 "enable at least one of `ts`, `go`, `rust`",
357 );
358 valid = false;
359 }
360 }
361 other => {
362 let found = other.type_str();
363 reader.error(
364 list.span(),
365 "`enabled` must be an array of targets",
366 format!("found {found}"),
367 );
368 valid = false;
369 }
370 }
371 }
372 let mut default = Target::Ts;
373 match get(table, "default") {
374 Some(value) => match reader
375 .string(value, "default")
376 .as_deref()
377 .map(Target::from_name)
378 {
379 Some(Some(target)) if enabled.contains(&target) => default = target,
380 Some(Some(target)) => {
381 reader.error(
382 value.span(),
383 format!("default target `{}` is not enabled", target.name()),
384 "add it to `enabled` or choose an enabled target",
385 );
386 valid = false;
387 }
388 Some(None) => {
389 reader.error(
390 value.span(),
391 "unknown target",
392 "targets are `ts`, `go` and `rust`",
393 );
394 valid = false;
395 }
396 None => valid = false,
397 },
398 None => {
399 if valid && !enabled.contains(&Target::Ts) {
401 let span = get(table, "enabled")
402 .map(|v| v.span())
403 .unwrap_or(value.span());
404 reader.error(
405 span,
406 "the default target `ts` is not enabled",
407 "add `default = ...` with an enabled target",
408 );
409 valid = false;
410 }
411 }
412 }
413 valid.then_some((default, enabled))
414}
415
416fn config(reader: &mut Reader, root: &DeTable<'_>) -> Vec<ConfigOverride> {
419 let mut out = Vec::new();
420 let Some(value) = get(root, "config") else {
421 return out;
422 };
423 let Some(table) = reader.table(value, "config") else {
424 return out;
425 };
426 walk_config(reader, table, &mut Vec::new(), &mut out);
427 out.sort_by(|a, b| (&a.module, &a.name).cmp(&(&b.module, &b.name)));
428 out
429}
430
431fn walk_config(
432 reader: &mut Reader,
433 table: &DeTable<'_>,
434 path: &mut Vec<String>,
435 out: &mut Vec<ConfigOverride>,
436) {
437 for (key, value) in table.iter() {
438 let name = key.get_ref().to_string();
439 let key_span = reader.span(key.span());
440 let value_span = reader.span(value.span());
441 let constant = match value.get_ref() {
442 DeValue::Table(inner) => {
443 path.push(name);
444 walk_config(reader, inner, path, out);
445 path.pop();
446 continue;
447 }
448 DeValue::Integer(integer) => {
449 let digits = integer.as_str().replace('_', "");
450 let (negative, digits) = match digits.strip_prefix('-') {
451 Some(rest) => (true, rest.to_string()),
452 None => (false, digits.trim_start_matches('+').to_string()),
453 };
454 let digits = digits
455 .trim_start_matches("0x")
456 .trim_start_matches("0o")
457 .trim_start_matches("0b")
458 .to_string();
459 let parsed = i128::from_str_radix(&digits, integer.radix())
460 .ok()
461 .map(|v| if negative { -v } else { v })
462 .and_then(|v| i64::try_from(v).ok());
463 match parsed {
464 Some(v) => ConfigValue::Int(v),
465 None => {
466 reader.diagnostics.push(
467 Diagnostic::new(
468 codes::E0613,
469 "config override does not fit into `Int`",
470 )
471 .primary(value_span, "outside the 64-bit range"),
472 );
473 continue;
474 }
475 }
476 }
477 DeValue::Float(float) => match float.as_str().replace('_', "").parse::<f64>() {
478 Ok(v) => ConfigValue::Float(v),
479 Err(_) => {
480 reader.diagnostics.push(
481 Diagnostic::new(codes::E0613, "invalid float in a config override")
482 .primary(value_span, "not a number"),
483 );
484 continue;
485 }
486 },
487 DeValue::Boolean(v) => ConfigValue::Bool(*v),
488 DeValue::String(text) => ConfigValue::Str(text.to_string()),
489 other => {
490 let found = other.type_str();
491 reader.diagnostics.push(
492 Diagnostic::new(
493 codes::E0613,
494 format!("unsupported value for constant `{name}`"),
495 )
496 .primary(value_span, format!("found {found}"))
497 .note("config constants are `Int`, `Float`, `Bool` or `String`"),
498 );
499 continue;
500 }
501 };
502 if path.is_empty() {
503 reader.diagnostics.push(
504 Diagnostic::new(
505 codes::E0613,
506 format!("config override `{name}` has no module"),
507 )
508 .primary(key_span, "write it in a table named after the module")
509 .help(format!(
510 "for example `[config.main]` and then `{name} = ...`"
511 )),
512 );
513 continue;
514 }
515 out.push(ConfigOverride {
516 module: path.clone(),
517 name,
518 value: constant,
519 key_span,
520 value_span,
521 });
522 }
523}
524
525fn dependencies(
526 reader: &mut Reader,
527 root: &DeTable<'_>,
528) -> BTreeMap<Target, Vec<(String, String)>> {
529 let mut out = BTreeMap::new();
530 let Some(value) = get(root, "extern") else {
531 return out;
532 };
533 let Some(table) = reader.table(value, "extern") else {
534 return out;
535 };
536 for (key, target_value) in table.iter() {
537 let Some(target) = Target::from_name(key.get_ref()) else {
538 reader.error(
539 key.span(),
540 format!("unknown target `{}`", key.get_ref()),
541 "targets are `ts`, `go` and `rust`",
542 );
543 continue;
544 };
545 let Some(target_table) = reader.table(target_value, key.get_ref()) else {
546 continue;
547 };
548 reader.unknown_keys(
549 target_table,
550 &["dependencies"],
551 &format!("`[extern.{}]`", target.name()),
552 );
553 let Some(deps_value) = get(target_table, "dependencies") else {
554 continue;
555 };
556 let Some(deps) = reader.table(deps_value, "dependencies") else {
557 continue;
558 };
559 let mut list = Vec::new();
560 for (package, version) in deps.iter() {
561 if let Some(version) = reader.string(version, package.get_ref()) {
562 list.push((package.get_ref().to_string(), version));
563 }
564 }
565 list.sort();
566 out.insert(target, list);
567 }
568 out
569}
570
571pub fn is_kebab_case(name: &str) -> bool {
573 let mut parts = name.split('-');
574 let first_ok = parts
575 .next()
576 .is_some_and(|p| p.starts_with(|c: char| c.is_ascii_lowercase()) && simple(p));
577 first_ok && parts.all(|p| !p.is_empty() && simple(p))
578}
579
580fn simple(part: &str) -> bool {
581 part.chars()
582 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
583}
584
585fn is_semver(version: &str) -> bool {
586 let parts: Vec<&str> = version.split('.').collect();
587 parts.len() == 3
588 && parts.iter().all(|p| {
589 !p.is_empty()
590 && p.chars().all(|c| c.is_ascii_digit())
591 && (p.len() == 1 || !p.starts_with('0'))
592 })
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 fn parse(text: &str) -> (Option<Manifest>, Vec<&'static str>) {
600 let mut map = wolf_span::SourceMap::new();
601 let id = map.add("wolfpack.toml", text.to_string());
602 let (manifest, diagnostics) = parse_manifest(id, text);
603 (manifest, diagnostics.iter().map(|d| d.code.id()).collect())
604 }
605
606 const MINIMAL: &str = "[package]\nname = \"my-app\"\nversion = \"0.1.0\"\nkind = \"cli\"\n";
607
608 #[test]
609 fn minimal_manifest_has_defaults() {
610 let (manifest, codes) = parse(MINIMAL);
611 assert!(codes.is_empty());
612 let manifest = manifest.unwrap();
613 assert_eq!(manifest.kind, Kind::Cli);
614 assert_eq!(manifest.default_target, Target::Ts);
615 assert_eq!(manifest.enabled, Target::ALL);
616 }
617
618 #[test]
619 fn full_manifest() {
620 let text = format!(
621 "{MINIMAL}\n[targets]\ndefault = \"go\"\nenabled = [\"go\", \"rust\"]\n\n[config.users.storage]\nmax_users = 200\nratio = 0.5\nname = \"x\"\nflag = true\n\n[extern.go.dependencies]\n\"github.com/a/b\" = \"v1.2.0\"\n"
622 );
623 let (manifest, codes) = parse(&text);
624 assert!(codes.is_empty(), "{codes:?}");
625 let manifest = manifest.unwrap();
626 assert_eq!(manifest.default_target, Target::Go);
627 assert_eq!(manifest.enabled, [Target::Go, Target::Rust]);
628 let names: Vec<_> = manifest
629 .config
630 .iter()
631 .map(|c| (c.module.join("."), c.name.clone()))
632 .collect();
633 assert_eq!(names.len(), 4);
634 assert!(names.contains(&("users.storage".to_string(), "max_users".to_string())));
635 assert_eq!(
636 manifest.dependencies[&Target::Go],
637 [("github.com/a/b".to_string(), "v1.2.0".to_string())]
638 );
639 }
640
641 #[test]
642 fn errors_point_at_keys() {
643 assert_eq!(
644 parse("[package]\nname = \"MyApp\"\nversion = \"0.1.0\"\nkind = \"cli\"\n").1,
645 ["E0611"]
646 );
647 assert_eq!(
648 parse("[package]\nname = \"a\"\nversion = \"1.0\"\nkind = \"cli\"\n").1,
649 ["E0611"]
650 );
651 assert_eq!(
652 parse("[package]\nname = \"a\"\nversion = \"1.0.0\"\nkind = \"app\"\n").1,
653 ["E0611"]
654 );
655 assert_eq!(parse(&format!("{MINIMAL}colour = 1\n")).1, ["E0611"]);
656 assert_eq!(
657 parse(&format!(
658 "{MINIMAL}[targets]\ndefault = \"go\"\nenabled = [\"ts\"]\n"
659 ))
660 .1,
661 ["E0611"]
662 );
663 assert_eq!(parse("[package\n").1, ["E0611"]);
664 assert_eq!(
665 parse(&format!(
666 "{MINIMAL}[config.main]\nbig = 99999999999999999999\n"
667 ))
668 .1,
669 ["E0613"]
670 );
671 assert_eq!(
672 parse(&format!("{MINIMAL}[config]\nloose = 1\n")).1,
673 ["E0613"]
674 );
675 }
676
677 #[test]
678 fn name_and_version_rules() {
679 assert!(is_kebab_case("my-app2"));
680 assert!(!is_kebab_case("my--app"));
681 assert!(!is_kebab_case("2app"));
682 assert!(!is_kebab_case("my_app"));
683 assert!(is_semver("0.10.1"));
684 assert!(!is_semver("01.0.0"));
685 assert!(!is_semver("1.0"));
686 }
687}