Skip to main content

htl_core/
config.rs

1//! `htl.toml`: project-level settings shared by the CLI and `include_tl!`.
2//!
3//! ```toml
4//! [toolchain]
5//! htl = "0.4"               # the htl command this project expects; a mismatch is refused
6//!
7//! [lint]
8//! strict = true             # for this run, every `warn` counts as `deny` (htl check
9//!                           # only); lints also fail include_tl!
10//!
11//! [lint.rules]              # a level per rule: allow (not reported) / warn (reported,
12//! nil-index = "deny"        # advisory) / deny (reported, fails htl check)
13//! class-record = "warn"
14//! shadow-local = "allow"
15//! "tl:hint" = "allow"       # Teal's warning kinds: quote the key, `:` is not a bare one
16//!
17//! [fmt]
18//! indent = 3
19//!
20//! [check]
21//! paths = ["mods"]   # extra dirs the checker resolves require() from
22//!
23//! [[contract]]
24//! dir = "mods"                 # or "sites/*" for one level of subdirectories
25//! type = "defs.Mod"
26//! require_fields = ["name", "monsters"]  # or `true` for every declared field
27//! exclude = ["defs", "modkit"] # modules in `dir` that are not held to the contract
28//! # module = "Site"            # only this module name (in each dir) is held to it
29//! ```
30//!
31//! Found by walking up from a file or directory, like `mlua-pkg.toml`. Command-line
32//! flags and the `HTL_LINTS` / `HTL_LINT` environment variables take precedence over it.
33
34use crate::BuildTarget;
35use crate::lint;
36use anyhow::{Context, Result};
37use semver::{Version, VersionReq};
38use serde::Deserialize;
39use std::path::{Path, PathBuf};
40
41/// The file name walked up for, and written by `htl new`. One name in one place, so that
42/// the search, the scaffold and the error that names it cannot disagree.
43pub const CONFIG_NAME: &str = "htl.toml";
44
45/// A project's `htl.toml`, parsed.
46///
47/// Every section defaults, so a project may write only the one it has an opinion about and
48/// a project with no file at all is this struct's [`Default`]. `deny_unknown_fields`
49/// throughout: a key nobody reads is a key the writer believed in, and reporting it is the
50/// only way they find out it did nothing.
51#[derive(Debug, Clone, Default, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct HtlConfig {
54    /// Which `htl` command the project expects. Checked once where the config is loaded,
55    /// before the command reads anything else.
56    #[serde(default)]
57    pub toolchain: ToolchainConfig,
58    /// `[lint]` — which rules this project has an opinion about, and whether what they
59    /// report stops a run.
60    #[serde(default)]
61    pub lint: LintConfig,
62    /// `[fmt]` — what `htl fmt` writes where the formatter has a choice.
63    #[serde(default)]
64    pub fmt: FmtConfig,
65    /// `[check]` — where `require` may resolve from besides the project's own tree.
66    #[serde(default)]
67    pub check: CheckConfig,
68    /// `[build]` — what `htl build` cannot learn from the sources alone.
69    #[serde(default)]
70    pub build: BuildConfig,
71    /// `[fix]` — per-rule control over what `htl fix` applies.
72    #[serde(default)]
73    pub fix: FixConfig,
74    /// `[cache]` — how `htl check` reuses what it already worked out.
75    #[serde(default)]
76    pub cache: CacheConfig,
77    /// Static counterpart of `TealResolver::expect_type` / `require_fields`: files
78    /// directly under `dir` must return `type`; checked by the `contract` lint.
79    #[serde(default)]
80    pub contract: Vec<Contract>,
81}
82
83/// `[toolchain]` — the `htl` command a project expects to be checked by.
84///
85/// `Cargo.toml` already pins the `htl` *crate* a Rust host builds against, and nothing
86/// pinned the command. The command is what decides whether the project checks: a default
87/// lint added in a release turns a green project red on unchanged sources, and without
88/// this key the first place that shows up is a teammate's terminal rather than the line
89/// in this file that says which release the project moved to.
90///
91/// htl does not install anything — it is one binary, not a toolchain manager — so a
92/// mismatch is reported and the message names `cargo install htl-cli`.
93#[derive(Debug, Clone, Default, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ToolchainConfig {
96    /// A Cargo-style requirement the running command must satisfy: `"0.4"` for 0.4.x,
97    /// `"1"` for 1.x, `">=0.4.2, <0.6"` when a project needs to say more. Absent, any
98    /// command runs the project, which is what every project did before the key existed.
99    pub htl: Option<String>,
100}
101
102impl ToolchainConfig {
103    /// The requirement, parsed. `Ok(None)` when the key is absent; `Err` when it is there
104    /// and is not a requirement — which [`HtlConfig::parse`] raises with the rest of the
105    /// config errors, so a typo here is found where a typo in `[lint]` is.
106    pub fn req(&self) -> Result<Option<VersionReq>> {
107        let Some(text) = &self.htl else {
108            return Ok(None);
109        };
110        match VersionReq::parse(text) {
111            Ok(req) => Ok(Some(req)),
112            Err(e) => Err(anyhow::anyhow!(
113                "[toolchain] htl = \"{text}\" is not a version requirement: {e}"
114            )),
115        }
116    }
117}
118
119/// Refuse the run when the config names a toolchain this command is not.
120///
121/// `running` is the command's own `CARGO_PKG_VERSION`, passed in rather than read here so
122/// that the version answered for is the binary the person invoked, not whichever crate
123/// this code was compiled into.
124///
125/// Refusing rather than warning is the point of a pin: a warning is ignorable, and a pin
126/// that can be ignored stops being one. The cost is bounded — the fix is the one line
127/// this message quotes.
128///
129/// Matching is cargo's, pre-release rule included: `0.4.0-rc.1` does not satisfy `"0.4"`,
130/// the same way it does not satisfy the `htl = "0.4"` beside it in `Cargo.toml`.
131pub fn check_toolchain(cfg: &HtlConfig, path: &Path, running: &str) -> Result<()> {
132    let Some(req) = cfg.toolchain.req()? else {
133        return Ok(());
134    };
135    let version = Version::parse(running)
136        .with_context(|| format!("this htl reports its version as {running}, which is not one"))?;
137    if req.matches(&version) {
138        return Ok(());
139    }
140    let text = cfg.toolchain.htl.as_deref().unwrap_or_default();
141    anyhow::bail!(
142        "htl {running} does not satisfy the toolchain this project asks for\n  \
143         {}: [toolchain] htl = \"{text}\"\n  \
144         htl installs nothing: cargo install htl-cli --version \"{text}\"",
145        path.display()
146    )
147}
148
149/// `[cache]` — how `htl check` reuses what it already worked out.
150#[derive(Debug, Clone, Default, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct CacheConfig {
153    /// `"per-module"` (the default) or `"whole-run"`. Which one is faster depends on where
154    /// edits land in the dependency graph; the CLI's `--cache-mode` overrides this, and
155    /// `--no-cache` turns the cache off entirely, which is a separate question from how it
156    /// is grained.
157    pub mode: Option<String>,
158}
159
160/// `require_fields` of a `[[contract]]`: which fields of the contract type a module's
161/// returned table has to carry.
162///
163/// ```toml
164/// require_fields = true                            # every declared field
165/// require_fields = ["name", "monsters", "items"]   # these, so the type can grow
166/// ```
167///
168/// The list exists because every Teal record field is nilable and Teal has no `?` for
169/// record fields, so a type cannot say which of its own fields are mandatory. Without
170/// it, adding a field to a contract type makes every module already written against it
171/// fail, and the only way out is to stop checking.
172#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
173#[serde(untagged)]
174pub enum RequireFields {
175    /// `true`: every field the type declares. `false`: no field check at all.
176    All(bool),
177    /// Exactly these. A name the type does not declare is an error, not a no-op.
178    Named(Vec<String>),
179}
180
181impl Default for RequireFields {
182    fn default() -> Self {
183        Self::All(false)
184    }
185}
186
187impl RequireFields {
188    /// Is any field required at all?
189    pub fn is_on(&self) -> bool {
190        match self {
191            Self::All(b) => *b,
192            Self::Named(names) => !names.is_empty(),
193        }
194    }
195
196    /// The names asked for, or `None` when the answer is "whatever the type declares".
197    pub fn named(&self) -> Option<&[String]> {
198        match self {
199            Self::Named(names) => Some(names),
200            Self::All(_) => None,
201        }
202    }
203}
204
205/// `[[contract]]` — where this project accepts modules from outside it. One line, in the
206/// file a reader opens first; the shape those modules must have is declared on the record
207/// itself with `---@contract` (see [`crate::contract`]).
208#[derive(Debug, Clone, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct Contract {
211    /// Directory relative to `htl.toml`, e.g. `"mods"`. One path segment may be `*`
212    /// (`"sites/*"`): every subdirectory at that level is a contract directory.
213    pub dir: String,
214    /// When set, only this module name (in each matched dir) is held to the contract.
215    /// `---@contract(module = "…")` says the same thing on the record.
216    pub module: Option<String>,
217    /// Module names (file stems) inside `dir` that are not held to the contract: a
218    /// helper, or an SDK the host writes there. A declaration (`.d.tl`) is never held to
219    /// a contract and does not need listing; a `.tl` beside the modules does.
220    /// `---@contract(exclude = "a b")` says the same thing on the record.
221    #[serde(default)]
222    pub exclude: Vec<String>,
223    /// Where this contract is enforced at run time, when it is somewhere `htl check`
224    /// cannot see: a Lua-side validator, a resolver in a sibling crate, generated code,
225    /// or a resolver built by hand. Relative to `htl.toml` (`~` and absolute paths
226    /// resolve as `[check] paths` does). Turns `contract-unenforced` off for this
227    /// contract and no other.
228    ///
229    /// A path rather than a flag on purpose: the file has to exist, so the claim is one
230    /// the check can hold to something, and a missing one is reported under the same
231    /// rule. This is not a per-contract off switch.
232    pub enforced_by: Option<String>,
233}
234
235/// `[lint]` — which rules run at what level, and whether what they report stops the run.
236///
237/// The two keys are the same question at two grains: [`rules`](Self::rules) names one rule,
238/// [`strict`](Self::strict) promotes every `warn` of a run at once.
239#[derive(Debug, Clone, Default, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct LintConfig {
242    /// `[lint.rules]` — the level of each rule this project has an opinion about.
243    ///
244    /// A key is any entry of [`crate::lint::RULES`] — one of htl's own rules,
245    /// or one of the vendored Teal compiler's warning kinds under its `tl:` prefix
246    /// (`"tl:hint"`, `"tl:unused"`, ..., which have to be quoted because `:` is not a bare
247    /// TOML key). `htl check --list-lints` prints them all with their defaults. A value is
248    /// `"allow"`, `"warn"` or `"deny"`. An unknown name or level is refused rather than
249    /// ignored: a typo that turned nothing on would read exactly like a rule that found
250    /// nothing, and a misspelt `"deny"` would read like a run that passed.
251    ///
252    /// One place per rule says everything about that rule. The `enable` / `disable` lists
253    /// this replaced said it in two places that had to be read together, and neither could
254    /// say what a rule was worth. A rule this table does not name keeps its default level.
255    #[serde(default)]
256    pub rules: std::collections::BTreeMap<String, lint::Level>,
257    /// `true`: every finding this run reports at `warn` counts as `deny`, so Teal's
258    /// warnings and htl's lints fail `htl check`; lints also fail `include_tl!` (the macro
259    /// reports Teal's warnings and builds anyway). `false`: advisory everywhere (including
260    /// the macro, whose built-in default is strict), except for a rule the project set to
261    /// `deny`, which fails `htl check` with or without this key.
262    ///
263    /// A run-wide promotion rather than a concept of its own: `strict` and a `[lint.rules]`
264    /// level are the same question asked at two grains.
265    ///
266    /// `htl test` does not read it, by design: a test run's verdict is its tests, plus
267    /// the type errors that stop a file from running at all. Warnings and lints are
268    /// still reported there; `htl check` is where they are judged.
269    pub strict: Option<bool>,
270}
271
272/// `[fmt]` — what `htl fmt` writes where the formatter has a choice.
273#[derive(Debug, Clone, Default, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct FmtConfig {
276    /// Spaces per level of indentation. `None` leaves the formatter's own default, which
277    /// is 3 — what `tl` itself writes, and what the scaffold puts in a new project's
278    /// `htl.toml` so that the number is visible rather than assumed. `--indent` overrides
279    /// it for one run.
280    pub indent: Option<usize>,
281}
282
283/// `[check]` — where `require` may resolve from besides the project's own tree.
284#[derive(Debug, Clone, Default, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct CheckConfig {
287    /// Extra directories `require` resolves from during checking (CLI, `include_tl!`,
288    /// and the checker behind `TealResolver::for_contract`). Relative to `htl.toml`;
289    /// absolute and `~/` paths allowed. Use it for modules the host supplies at run time
290    /// from somewhere else (an SDK cache, a mods dir).
291    #[serde(default)]
292    pub paths: Vec<String>,
293}
294
295/// `[build]`: what `htl build` cannot learn from literal `require`s alone.
296#[derive(Debug, Clone, Default, Deserialize)]
297#[serde(deny_unknown_fields)]
298pub struct BuildConfig {
299    /// Modules to bundle even though no literal `require` reaches them (targets of a
300    /// dynamic `require(expr)`).
301    #[serde(default)]
302    pub extra: Vec<String>,
303    /// Modules the host provides at run time, besides those declared only by a `.d.tl`.
304    #[serde(default)]
305    pub host: Vec<String>,
306    /// What runs this project's output; absent means [`BuildTarget::Hb`], which is what
307    /// plain `htl build` produces and what every project without Rust in it is. Written by
308    /// `htl new --target <name>` when the project's htl pin reads this key (see
309    /// `HtlPin::knows_build_target` in `htl-cli`), read by every command that loads the file.
310    /// `htl build` refuses a project whose target is not `hb`.
311    #[serde(default)]
312    pub target: Option<BuildTarget>,
313}
314
315/// `[fix]`: per-rule control over what `htl fix` applies.
316#[derive(Debug, Clone, Default, Deserialize)]
317#[serde(deny_unknown_fields)]
318pub struct FixConfig {
319    /// Rules whose `unsafe` fix is applied as if it were safe (e.g. `["no-global"]`).
320    #[serde(default, rename = "unsafe")]
321    pub unsafe_: Vec<String>,
322    /// Rules whose fix is never applied.
323    #[serde(default)]
324    pub disable: Vec<String>,
325}
326
327impl HtlConfig {
328    /// Parse `htl.toml` text.
329    pub fn parse(text: &str) -> Result<Self> {
330        let cfg: Self = toml::from_str(text)
331            .map_err(
332                |e| match (moved_contract_key(text), removed_lint_lists(text)) {
333                    // `type` / `require_fields` / `exclude` moved onto the record itself, and
334                    // the serde message for an unknown key does not say where they went.
335                    (Some(k), _) => anyhow::anyhow!(
336                        "[[contract]] {k} moved onto the type: mark the record \
337                     `---@contract` and its mandatory fields `---@required`, and leave \
338                     `dir` (with `module` / `exclude` if you use them) here"
339                    ),
340                    // `enable` / `disable` became a level per rule. The message writes the
341                    // replacement out of this file's own names, so the fix is a paste.
342                    (_, Some(msg)) => anyhow::anyhow!("{msg}"),
343                    _ => anyhow::Error::from(e),
344                },
345            )
346            .context("parsing htl.toml")?;
347        // Here rather than at the comparison: a requirement that is not one is a fact
348        // about the file, so it is reported when the file is read and by every reader of
349        // it, including the one that never compares versions.
350        cfg.toolchain.req().context("parsing htl.toml")?;
351        Ok(cfg)
352    }
353
354    /// Nearest `htl.toml` at or above `start` (a file or directory). `Ok(None)` when
355    /// there is none; `Err` when one exists but does not parse.
356    ///
357    /// A `htl.toml` inside a directory an enclosing project declares as a dependency's —
358    /// a `patch_dir`, a `target_dir` — is passed over, and the walk goes on to the
359    /// project's own. `htl pkg patch` copies a dependency's package root whole, config
360    /// file included, and the copy is code the project owns rather than a project of its
361    /// own: one root, one store, one lint selection over the whole tree, the patched
362    /// directories with it. The question is `pkg::owning_project`'s, asked here and by
363    /// [`Project::find`](crate::pkg::Project::find) so that the manifest and the config
364    /// cannot disagree about where the root is.
365    pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
366        let mut dir = if start.is_dir() {
367            start.to_path_buf()
368        } else {
369            crate::parent_dir(start)
370        };
371        if let Ok(abs) = std::fs::canonicalize(&dir) {
372            dir = abs;
373        }
374        loop {
375            let path = dir.join(CONFIG_NAME);
376            if path.is_file() && crate::pkg::owning_project(&dir).is_none() {
377                let text = std::fs::read_to_string(&path)
378                    .with_context(|| format!("reading {}", path.display()))?;
379                let cfg = Self::parse(&text).with_context(|| path.display().to_string())?;
380                return Ok(Some((path, cfg)));
381            }
382            if !dir.pop() {
383                return Ok(None);
384            }
385        }
386    }
387
388    /// The `[lint.rules]` table as a `rule=level` spec for
389    /// [`Htl::configure_lints`](crate::Htl::configure_lints). Append a command-line / env
390    /// spec after it so later entries win.
391    ///
392    /// The spec is also part of a cache key, so the rendering is ordered (the table is a
393    /// `BTreeMap`): two runs that say the same thing have to produce the same string.
394    pub fn lint_spec(&self) -> String {
395        self.lint
396            .rules
397            .iter()
398            .map(|(rule, level)| format!("{rule}={level}"))
399            .collect::<Vec<_>>()
400            .join(",")
401    }
402
403    /// Directories the checker should search, in the order it consults them: `root`,
404    /// `root/src`, `root/types` (hand-written `.d.tl` for modules the host provides, the
405    /// DefinitelyTyped shape), then `[check] paths` (resolved against `root`, `~`
406    /// expanded). Only existing dirs. The project's own code comes before declarations
407    /// it keeps for other people's, and both come before anything supplied from outside.
408    ///
409    /// Put them on the path with [`Htl::add_search_paths`](crate::Htl::add_search_paths),
410    /// which preserves this order; `add_path` alone prepends, so adding the list front to
411    /// back reverses it.
412    ///
413    /// A `.tl` source anywhere on the path beats a `.d.tl`, so a declaration under
414    /// `types/` never shadows an implementation, and the order only decides between two
415    /// declarations of one module — which `duplicate-declaration` reports.
416    pub fn search_paths(&self, root: &Path) -> Vec<PathBuf> {
417        let types = root.join("types");
418        let mut out = vec![root.to_path_buf(), root.join("src"), types.clone()];
419        // `types/<crate>/` holding declarations materialised from that crate: on the path
420        // itself, so the module keeps the name it was declared under whatever the crate
421        // shipping it is called (`crate::materialised_types_dirs`). After `types/`, so a
422        // declaration the project wrote by hand is the one read and the shipped one is
423        // what `duplicate-declaration` reports as shadowed.
424        out.extend(crate::materialised_types_dirs(&types));
425        for p in &self.check.paths {
426            out.push(resolve_path(root, p));
427        }
428        out.retain(|p| p.is_dir());
429        out.dedup();
430        out
431    }
432}
433
434/// The first `[[contract]]` key that used to live in `htl.toml` and now lives on the
435/// record, if the text still carries one. A scan of the lines after a `[[contract]]`
436/// header, which is enough to tell a stale config from an unrelated typo.
437fn moved_contract_key(text: &str) -> Option<&'static str> {
438    let mut in_contract = false;
439    for line in text.lines().map(str::trim) {
440        if line.starts_with('[') {
441            in_contract = line.starts_with("[[contract]]");
442            continue;
443        }
444        if !in_contract {
445            continue;
446        }
447        for k in ["type", "require_fields"] {
448            if line
449                .strip_prefix(k)
450                .is_some_and(|r| r.trim_start().starts_with('='))
451            {
452                return Some(k);
453            }
454        }
455    }
456    None
457}
458
459/// The message for a config that still writes `[lint] enable` / `disable`, or `None` when
460/// it does not.
461///
462/// The keys are gone rather than deprecated: `HtlConfig` is `deny_unknown_fields`, so a
463/// removed key fails loudly instead of being read as "no rules configured", which is the
464/// behaviour to want for a key that used to decide what a run reports. What serde says on
465/// its own — `unknown field \`enable\`` — is true and not actionable, so this writes the
466/// replacement table out of the file's own names: `enable` said "report it", which is
467/// `warn`, and `disable` said "do not", which is `allow`.
468fn removed_lint_lists(text: &str) -> Option<String> {
469    let table: toml::Table = toml::from_str(text).ok()?;
470    let lint = table.get("lint")?.as_table()?;
471    let names = |key: &str| -> Vec<String> {
472        lint.get(key)
473            .and_then(toml::Value::as_array)
474            .map(|a| {
475                a.iter()
476                    .filter_map(|v| v.as_str().map(str::to_string))
477                    .collect()
478            })
479            .unwrap_or_default()
480    };
481    let (enabled, disabled) = (names("enable"), names("disable"));
482    let present: Vec<&str> = ["enable", "disable"]
483        .into_iter()
484        .filter(|k| lint.contains_key(*k))
485        .collect();
486    if present.is_empty() {
487        return None;
488    }
489    let mut lines = vec![format!(
490        "[lint] {} replaced by a level per rule. Write instead:\n\n  [lint.rules]",
491        present.join(" and ")
492    )];
493    // The `#` in one column, so the block pastes as it reads.
494    let width = enabled
495        .iter()
496        .chain(&disabled)
497        .map(|r| r.len())
498        .max()
499        .unwrap_or(0);
500    for (rules, level, was) in [
501        (&enabled, lint::Level::Warn, "enable"),
502        (&disabled, lint::Level::Allow, "disable"),
503    ] {
504        for rule in rules {
505            // Every name is quoted: `tl:*` has to be, and one spelling reads better than
506            // two in the same block.
507            let assign = format!(
508                "\"{rule}\"{:pad$} = \"{level}\"",
509                "",
510                pad = width - rule.len()
511            );
512            // The longest assignment is the widest name at the longest level word
513            // (`"allow"`, seven characters with its quotes and three for the ` = `).
514            lines.push(format!("  {assign:<w$}  # was in {was}", w = width + 12));
515        }
516    }
517    if enabled.is_empty() && disabled.is_empty() {
518        lines.push("  \"nil-index\" = \"deny\"".to_string());
519    }
520    lines.push(String::new());
521    lines.push(
522        "allow = not reported, warn = reported and advisory, deny = reported and fails \
523         the run (htl check --list-lints lists every rule with its default)"
524            .to_string(),
525    );
526    Some(lines.join("\n"))
527}
528
529/// Combine specs in precedence order (later wins): `"+a,-b"` + `"+b"` -> `"+a,-b,+b"`.
530pub fn join_specs<'a>(specs: impl IntoIterator<Item = &'a str>) -> String {
531    specs
532        .into_iter()
533        .filter(|s| !s.trim().is_empty())
534        .collect::<Vec<_>>()
535        .join(",")
536}
537
538/// `~/x` -> `$HOME/x`; relative -> under `root`; absolute as is.
539pub fn resolve_path(root: &Path, p: &str) -> PathBuf {
540    if let Some(rest) = p.strip_prefix("~/")
541        && let Some(home) = std::env::var_os("HOME")
542    {
543        return PathBuf::from(home).join(rest);
544    }
545    let pb = PathBuf::from(p);
546    if pb.is_absolute() { pb } else { root.join(pb) }
547}