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}