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}