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", "~/.cache/tsk/sdk"] # 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 pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
357 let mut dir = if start.is_dir() {
358 start.to_path_buf()
359 } else {
360 crate::parent_dir(start)
361 };
362 if let Ok(abs) = std::fs::canonicalize(&dir) {
363 dir = abs;
364 }
365 loop {
366 let path = dir.join(CONFIG_NAME);
367 if path.is_file() {
368 let text = std::fs::read_to_string(&path)
369 .with_context(|| format!("reading {}", path.display()))?;
370 let cfg = Self::parse(&text).with_context(|| path.display().to_string())?;
371 return Ok(Some((path, cfg)));
372 }
373 if !dir.pop() {
374 return Ok(None);
375 }
376 }
377 }
378
379 /// The `[lint.rules]` table as a `rule=level` spec for
380 /// [`Htl::configure_lints`](crate::Htl::configure_lints). Append a command-line / env
381 /// spec after it so later entries win.
382 ///
383 /// The spec is also part of a cache key, so the rendering is ordered (the table is a
384 /// `BTreeMap`): two runs that say the same thing have to produce the same string.
385 pub fn lint_spec(&self) -> String {
386 self.lint
387 .rules
388 .iter()
389 .map(|(rule, level)| format!("{rule}={level}"))
390 .collect::<Vec<_>>()
391 .join(",")
392 }
393
394 /// Directories the checker should search, in the order it consults them: `root`,
395 /// `root/src`, `root/types` (hand-written `.d.tl` for modules the host provides, the
396 /// DefinitelyTyped shape), then `[check] paths` (resolved against `root`, `~`
397 /// expanded). Only existing dirs. The project's own code comes before declarations
398 /// it keeps for other people's, and both come before anything supplied from outside.
399 ///
400 /// Put them on the path with [`Htl::add_search_paths`](crate::Htl::add_search_paths),
401 /// which preserves this order; `add_path` alone prepends, so adding the list front to
402 /// back reverses it.
403 ///
404 /// A `.tl` source anywhere on the path beats a `.d.tl`, so a declaration under
405 /// `types/` never shadows an implementation, and the order only decides between two
406 /// declarations of one module — which `duplicate-declaration` reports.
407 pub fn search_paths(&self, root: &Path) -> Vec<PathBuf> {
408 let types = root.join("types");
409 let mut out = vec![root.to_path_buf(), root.join("src"), types.clone()];
410 // `types/<crate>/` holding declarations materialised from that crate: on the path
411 // itself, so the module keeps the name it was declared under whatever the crate
412 // shipping it is called (`crate::materialised_types_dirs`). After `types/`, so a
413 // declaration the project wrote by hand is the one read and the shipped one is
414 // what `duplicate-declaration` reports as shadowed.
415 out.extend(crate::materialised_types_dirs(&types));
416 for p in &self.check.paths {
417 out.push(resolve_path(root, p));
418 }
419 out.retain(|p| p.is_dir());
420 out.dedup();
421 out
422 }
423}
424
425/// The first `[[contract]]` key that used to live in `htl.toml` and now lives on the
426/// record, if the text still carries one. A scan of the lines after a `[[contract]]`
427/// header, which is enough to tell a stale config from an unrelated typo.
428fn moved_contract_key(text: &str) -> Option<&'static str> {
429 let mut in_contract = false;
430 for line in text.lines().map(str::trim) {
431 if line.starts_with('[') {
432 in_contract = line.starts_with("[[contract]]");
433 continue;
434 }
435 if !in_contract {
436 continue;
437 }
438 for k in ["type", "require_fields"] {
439 if line
440 .strip_prefix(k)
441 .is_some_and(|r| r.trim_start().starts_with('='))
442 {
443 return Some(k);
444 }
445 }
446 }
447 None
448}
449
450/// The message for a config that still writes `[lint] enable` / `disable`, or `None` when
451/// it does not.
452///
453/// The keys are gone rather than deprecated: `HtlConfig` is `deny_unknown_fields`, so a
454/// removed key fails loudly instead of being read as "no rules configured", which is the
455/// behaviour to want for a key that used to decide what a run reports. What serde says on
456/// its own — `unknown field \`enable\`` — is true and not actionable, so this writes the
457/// replacement table out of the file's own names: `enable` said "report it", which is
458/// `warn`, and `disable` said "do not", which is `allow`.
459fn removed_lint_lists(text: &str) -> Option<String> {
460 let table: toml::Table = toml::from_str(text).ok()?;
461 let lint = table.get("lint")?.as_table()?;
462 let names = |key: &str| -> Vec<String> {
463 lint.get(key)
464 .and_then(toml::Value::as_array)
465 .map(|a| {
466 a.iter()
467 .filter_map(|v| v.as_str().map(str::to_string))
468 .collect()
469 })
470 .unwrap_or_default()
471 };
472 let (enabled, disabled) = (names("enable"), names("disable"));
473 let present: Vec<&str> = ["enable", "disable"]
474 .into_iter()
475 .filter(|k| lint.contains_key(*k))
476 .collect();
477 if present.is_empty() {
478 return None;
479 }
480 let mut lines = vec![format!(
481 "[lint] {} replaced by a level per rule. Write instead:\n\n [lint.rules]",
482 present.join(" and ")
483 )];
484 // The `#` in one column, so the block pastes as it reads.
485 let width = enabled
486 .iter()
487 .chain(&disabled)
488 .map(|r| r.len())
489 .max()
490 .unwrap_or(0);
491 for (rules, level, was) in [
492 (&enabled, lint::Level::Warn, "enable"),
493 (&disabled, lint::Level::Allow, "disable"),
494 ] {
495 for rule in rules {
496 // Every name is quoted: `tl:*` has to be, and one spelling reads better than
497 // two in the same block.
498 let assign = format!(
499 "\"{rule}\"{:pad$} = \"{level}\"",
500 "",
501 pad = width - rule.len()
502 );
503 // The longest assignment is the widest name at the longest level word
504 // (`"allow"`, seven characters with its quotes and three for the ` = `).
505 lines.push(format!(" {assign:<w$} # was in {was}", w = width + 12));
506 }
507 }
508 if enabled.is_empty() && disabled.is_empty() {
509 lines.push(" \"nil-index\" = \"deny\"".to_string());
510 }
511 lines.push(String::new());
512 lines.push(
513 "allow = not reported, warn = reported and advisory, deny = reported and fails \
514 the run (htl check --list-lints lists every rule with its default)"
515 .to_string(),
516 );
517 Some(lines.join("\n"))
518}
519
520/// Combine specs in precedence order (later wins): `"+a,-b"` + `"+b"` -> `"+a,-b,+b"`.
521pub fn join_specs<'a>(specs: impl IntoIterator<Item = &'a str>) -> String {
522 specs
523 .into_iter()
524 .filter(|s| !s.trim().is_empty())
525 .collect::<Vec<_>>()
526 .join(",")
527}
528
529/// `~/x` -> `$HOME/x`; relative -> under `root`; absolute as is.
530pub fn resolve_path(root: &Path, p: &str) -> PathBuf {
531 if let Some(rest) = p.strip_prefix("~/")
532 && let Some(home) = std::env::var_os("HOME")
533 {
534 return PathBuf::from(home).join(rest);
535 }
536 let pb = PathBuf::from(p);
537 if pb.is_absolute() { pb } else { root.join(pb) }
538}