Skip to main content

wolf_project/
manifest.rs

1//! `wolfpack.toml` (spec chapter 6, section 6).
2
3use 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/// The two kinds of projects.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Kind {
14    /// A program with `src/main.wolf`.
15    Cli,
16    /// A library with `src/lib.wolf`.
17    Lib,
18}
19
20/// The three translation targets.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub enum Target {
23    /// TypeScript
24    Ts,
25    /// Go
26    Go,
27    /// Rust
28    Rust,
29}
30
31impl Target {
32    /// All targets.
33    pub const ALL: [Target; 3] = [Target::Ts, Target::Go, Target::Rust];
34
35    /// The name used in the manifest and on the command line.
36    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/// The value of a `[config]` override.
50#[derive(Clone, Debug, PartialEq)]
51pub enum ConfigValue {
52    /// A TOML integer, within 64 bits.
53    Int(i64),
54    /// A TOML float.
55    Float(f64),
56    /// A TOML boolean.
57    Bool(bool),
58    /// A TOML string.
59    Str(String),
60}
61
62impl ConfigValue {
63    /// The wolfpack type a constant must have to take this value.
64    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/// One override from `[config.<module path>]`.
75#[derive(Clone, Debug, PartialEq)]
76pub struct ConfigOverride {
77    /// The module path, for example `["users", "storage"]`.
78    pub module: Vec<String>,
79    /// The constant name.
80    pub name: String,
81    /// The new value.
82    pub value: ConfigValue,
83    /// The key in the manifest.
84    pub key_span: Span,
85    /// The value in the manifest.
86    pub value_span: Span,
87}
88
89/// A validated manifest.
90#[derive(Clone, Debug, PartialEq)]
91pub struct Manifest {
92    /// The manifest file.
93    pub file: FileId,
94    /// `[package] name`, in kebab-case.
95    pub name: String,
96    /// `[package] version`.
97    pub version: String,
98    /// `[package] kind`.
99    pub kind: Kind,
100    /// Where `kind` is written, for layout errors about the entry file.
101    pub kind_span: Span,
102    /// `[targets] default`.
103    pub default_target: Target,
104    /// `[targets] enabled`.
105    pub enabled: Vec<Target>,
106    /// All `[config.*]` overrides, sorted by module and name.
107    pub config: Vec<ConfigOverride>,
108    /// `[extern.<target>.dependencies]`: package and version, sorted by package.
109    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    /// Reports every key of `table` that is not in `allowed`.
131    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
185/// Parses and validates the manifest text. Returns `None` if it is unusable; every problem is
186/// reported as E0611 or E0613 with the exact key.
187pub 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            // `default` is `"ts"` unless written, and must be enabled (chapter 6, rule 6.2).
400            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
416/// `[config.users.storage] max_users = 200`: nested tables are module path segments, the
417/// leaves are constants (spec chapter 6, section 6.3).
418fn 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
571/// `[a-z][a-z0-9]*(-[a-z0-9]+)*`
572pub 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}