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//! [lint]
5//! enable  = ["class-record", "explicit-number"]
6//! disable = ["shadow-local"]
7//! strict  = true            # lints are errors (htl check) / compile errors (include_tl!)
8//!
9//! [fmt]
10//! indent = 3
11//!
12//! [check]
13//! paths = ["mods", "~/.cache/tsk/sdk"]   # extra dirs the checker resolves require() from
14//!
15//! [[contract]]
16//! dir = "mods"                 # or "sites/*" for one level of subdirectories
17//! type = "defs.Mod"
18//! require_fields = ["name", "monsters"]  # or `true` for every declared field
19//! exclude = ["defs", "modkit"] # modules in `dir` that are not held to the contract
20//! # module = "Site"            # only this module name (in each dir) is held to it
21//! ```
22//!
23//! Found by walking up from a file or directory, like `mlua-pkg.toml`. Command-line
24//! flags and the `HTL_LINTS` / `HTL_LINT` environment variables take precedence over it.
25
26use anyhow::{Context, Result};
27use serde::Deserialize;
28use std::path::{Path, PathBuf};
29
30pub const CONFIG_NAME: &str = "htl.toml";
31
32#[derive(Debug, Clone, Default, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct HtlConfig {
35    #[serde(default)]
36    pub lint: LintConfig,
37    #[serde(default)]
38    pub fmt: FmtConfig,
39    #[serde(default)]
40    pub check: CheckConfig,
41    #[serde(default)]
42    pub build: BuildConfig,
43    #[serde(default)]
44    pub fix: FixConfig,
45    #[serde(default)]
46    pub cache: CacheConfig,
47    /// Static counterpart of `TealResolver::expect_type` / `require_fields`: files
48    /// directly under `dir` must return `type`; checked by the `contract` lint.
49    #[serde(default)]
50    pub contract: Vec<Contract>,
51}
52
53/// `[cache]` — how `htl check` reuses what it already worked out.
54#[derive(Debug, Clone, Default, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct CacheConfig {
57    /// `"per-module"` (the default) or `"whole-run"`. Which one is faster depends on where
58    /// edits land in the dependency graph; the CLI's `--cache-mode` overrides this, and
59    /// `--no-cache` turns the cache off entirely, which is a separate question from how it
60    /// is grained.
61    pub mode: Option<String>,
62}
63
64/// `require_fields` of a `[[contract]]`: which fields of the contract type a module's
65/// returned table has to carry.
66///
67/// ```toml
68/// require_fields = true                            # every declared field
69/// require_fields = ["name", "monsters", "items"]   # these, so the type can grow
70/// ```
71///
72/// The list exists because every Teal record field is nilable and Teal has no `?` for
73/// record fields, so a type cannot say which of its own fields are mandatory. Without
74/// it, adding a field to a contract type makes every module already written against it
75/// fail, and the only way out is to stop checking.
76#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
77#[serde(untagged)]
78pub enum RequireFields {
79    /// `true`: every field the type declares. `false`: no field check at all.
80    All(bool),
81    /// Exactly these. A name the type does not declare is an error, not a no-op.
82    Named(Vec<String>),
83}
84
85impl Default for RequireFields {
86    fn default() -> Self {
87        Self::All(false)
88    }
89}
90
91impl RequireFields {
92    /// Is any field required at all?
93    pub fn is_on(&self) -> bool {
94        match self {
95            Self::All(b) => *b,
96            Self::Named(names) => !names.is_empty(),
97        }
98    }
99
100    /// The names asked for, or `None` when the answer is "whatever the type declares".
101    pub fn named(&self) -> Option<&[String]> {
102        match self {
103            Self::Named(names) => Some(names),
104            Self::All(_) => None,
105        }
106    }
107}
108
109/// `[[contract]]` — where this project accepts modules from outside it. One line, in the
110/// file a reader opens first; the shape those modules must have is declared on the record
111/// itself with `---@contract` (see [`crate::contract`]).
112#[derive(Debug, Clone, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct Contract {
115    /// Directory relative to `htl.toml`, e.g. `"mods"`. One path segment may be `*`
116    /// (`"sites/*"`): every subdirectory at that level is a contract directory.
117    pub dir: String,
118    /// When set, only this module name (in each matched dir) is held to the contract.
119    /// `---@contract(module = "…")` says the same thing on the record.
120    pub module: Option<String>,
121    /// Module names (file stems) inside `dir` that are not held to the contract: a
122    /// helper, or an SDK the host writes there. A declaration (`.d.tl`) is never held to
123    /// a contract and does not need listing; a `.tl` beside the modules does.
124    /// `---@contract(exclude = "a b")` says the same thing on the record.
125    #[serde(default)]
126    pub exclude: Vec<String>,
127    /// Where this contract is enforced at run time, when it is somewhere `htl check`
128    /// cannot see: a Lua-side validator, a resolver in a sibling crate, generated code,
129    /// or a resolver built by hand. Relative to `htl.toml` (`~` and absolute paths
130    /// resolve as `[check] paths` does). Turns `contract-unenforced` off for this
131    /// contract and no other.
132    ///
133    /// A path rather than a flag on purpose: the file has to exist, so the claim is one
134    /// the check can hold to something, and a missing one is reported under the same
135    /// rule. This is not a per-contract off switch.
136    pub enforced_by: Option<String>,
137}
138
139#[derive(Debug, Clone, Default, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct LintConfig {
142    /// Rules to turn on in addition to the defaults.
143    #[serde(default)]
144    pub enable: Vec<String>,
145    /// Rules to turn off.
146    #[serde(default)]
147    pub disable: Vec<String>,
148    /// `true`: lints fail `htl check` / `htl test` and `include_tl!`. `false`: advisory
149    /// everywhere (including the macro, whose built-in default is strict).
150    pub strict: Option<bool>,
151}
152
153#[derive(Debug, Clone, Default, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct FmtConfig {
156    pub indent: Option<usize>,
157}
158
159#[derive(Debug, Clone, Default, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct CheckConfig {
162    /// Extra directories `require` resolves from during checking (CLI, `include_tl!`,
163    /// and the checker behind `TealResolver::for_contract`). Relative to `htl.toml`;
164    /// absolute and `~/` paths allowed. Use it for modules the host supplies at run time
165    /// from somewhere else (an SDK cache, a mods dir).
166    #[serde(default)]
167    pub paths: Vec<String>,
168}
169
170/// `[build]`: what `htl build` cannot learn from literal `require`s alone.
171#[derive(Debug, Clone, Default, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct BuildConfig {
174    /// Modules to bundle even though no literal `require` reaches them (targets of a
175    /// dynamic `require(expr)`).
176    #[serde(default)]
177    pub extra: Vec<String>,
178    /// Modules the host provides at run time, besides those declared only by a `.d.tl`.
179    #[serde(default)]
180    pub host: Vec<String>,
181}
182
183/// `[fix]`: per-rule control over what `htl fix` applies.
184#[derive(Debug, Clone, Default, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct FixConfig {
187    /// Rules whose `unsafe` fix is applied as if it were safe (e.g. `["no-global"]`).
188    #[serde(default, rename = "unsafe")]
189    pub unsafe_: Vec<String>,
190    /// Rules whose fix is never applied.
191    #[serde(default)]
192    pub disable: Vec<String>,
193}
194
195impl HtlConfig {
196    /// Parse `htl.toml` text.
197    pub fn parse(text: &str) -> Result<Self> {
198        toml::from_str(text)
199            .map_err(|e| match moved_contract_key(text) {
200                // `type` / `require_fields` / `exclude` moved onto the record itself, and
201                // the serde message for an unknown key does not say where they went.
202                Some(k) => anyhow::anyhow!(
203                    "[[contract]] {k} moved onto the type: mark the record \
204                     `---@contract` and its mandatory fields `---@required`, and leave \
205                     `dir` (with `module` / `exclude` if you use them) here"
206                ),
207                None => anyhow::Error::from(e),
208            })
209            .context("parsing htl.toml")
210    }
211
212    /// Nearest `htl.toml` at or above `start` (a file or directory). `Ok(None)` when
213    /// there is none; `Err` when one exists but does not parse.
214    pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
215        let mut dir = if start.is_dir() {
216            start.to_path_buf()
217        } else {
218            crate::parent_dir(start)
219        };
220        if let Ok(abs) = std::fs::canonicalize(&dir) {
221            dir = abs;
222        }
223        loop {
224            let path = dir.join(CONFIG_NAME);
225            if path.is_file() {
226                let text = std::fs::read_to_string(&path)
227                    .with_context(|| format!("reading {}", path.display()))?;
228                let cfg = Self::parse(&text).with_context(|| path.display().to_string())?;
229                return Ok(Some((path, cfg)));
230            }
231            if !dir.pop() {
232                return Ok(None);
233            }
234        }
235    }
236
237    /// The `[lint]` section as a `+rule,-rule` spec for [`Htl::configure_lints`](crate::Htl::configure_lints).
238    /// Append a command-line / env spec after it so later entries win.
239    pub fn lint_spec(&self) -> String {
240        let mut parts: Vec<String> = Vec::new();
241        for r in &self.lint.enable {
242            parts.push(format!("+{r}"));
243        }
244        for r in &self.lint.disable {
245            parts.push(format!("-{r}"));
246        }
247        parts.join(",")
248    }
249
250    /// Directories the checker should search, in the order it consults them: `root`,
251    /// `root/src`, `root/types` (hand-written `.d.tl` for modules the host provides, the
252    /// DefinitelyTyped shape), then `[check] paths` (resolved against `root`, `~`
253    /// expanded). Only existing dirs. The project's own code comes before declarations
254    /// it keeps for other people's, and both come before anything supplied from outside.
255    ///
256    /// Put them on the path with [`Htl::add_search_paths`](crate::Htl::add_search_paths),
257    /// which preserves this order; `add_path` alone prepends, so adding the list front to
258    /// back reverses it.
259    ///
260    /// A `.tl` source anywhere on the path beats a `.d.tl`, so a declaration under
261    /// `types/` never shadows an implementation, and the order only decides between two
262    /// declarations of one module — which `duplicate-declaration` reports.
263    pub fn search_paths(&self, root: &Path) -> Vec<PathBuf> {
264        let mut out = vec![root.to_path_buf(), root.join("src"), root.join("types")];
265        for p in &self.check.paths {
266            out.push(resolve_path(root, p));
267        }
268        out.retain(|p| p.is_dir());
269        out.dedup();
270        out
271    }
272}
273
274/// The first `[[contract]]` key that used to live in `htl.toml` and now lives on the
275/// record, if the text still carries one. A scan of the lines after a `[[contract]]`
276/// header, which is enough to tell a stale config from an unrelated typo.
277fn moved_contract_key(text: &str) -> Option<&'static str> {
278    let mut in_contract = false;
279    for line in text.lines().map(str::trim) {
280        if line.starts_with('[') {
281            in_contract = line.starts_with("[[contract]]");
282            continue;
283        }
284        if !in_contract {
285            continue;
286        }
287        for k in ["type", "require_fields"] {
288            if line
289                .strip_prefix(k)
290                .is_some_and(|r| r.trim_start().starts_with('='))
291            {
292                return Some(k);
293            }
294        }
295    }
296    None
297}
298
299/// Combine specs in precedence order (later wins): `"+a,-b"` + `"+b"` -> `"+a,-b,+b"`.
300pub fn join_specs<'a>(specs: impl IntoIterator<Item = &'a str>) -> String {
301    specs
302        .into_iter()
303        .filter(|s| !s.trim().is_empty())
304        .collect::<Vec<_>>()
305        .join(",")
306}
307
308/// `~/x` -> `$HOME/x`; relative -> under `root`; absolute as is.
309pub fn resolve_path(root: &Path, p: &str) -> PathBuf {
310    if let Some(rest) = p.strip_prefix("~/")
311        && let Some(home) = std::env::var_os("HOME")
312    {
313        return PathBuf::from(home).join(rest);
314    }
315    let pb = PathBuf::from(p);
316    if pb.is_absolute() { pb } else { root.join(pb) }
317}