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 = true
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 /// Static counterpart of `TealResolver::expect_type` / `require_fields`: files
42 /// directly under `dir` must return `type`; checked by the `contract` lint.
43 #[serde(default)]
44 pub contract: Vec<Contract>,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct Contract {
50 /// Directory relative to `htl.toml`, e.g. `"mods"`. One path segment may be `*`
51 /// (`"sites/*"`): every subdirectory at that level is a contract directory.
52 pub dir: String,
53 /// `"<module>.<Type>"`, e.g. `"defs.Mod"`.
54 #[serde(rename = "type")]
55 pub type_path: String,
56 /// Every declared field must appear in the module's returned table literal.
57 #[serde(default)]
58 pub require_fields: bool,
59 /// Module names (file stems) inside `dir` that are not held to the contract, e.g.
60 /// an SDK the host writes there (`defs`, `modkit`). The module that declares `type`
61 /// is always exempt.
62 #[serde(default)]
63 pub exclude: Vec<String>,
64 /// When set, only this module name (in each matched dir) is held to the contract.
65 pub module: Option<String>,
66}
67
68#[derive(Debug, Clone, Default, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct LintConfig {
71 /// Rules to turn on in addition to the defaults.
72 #[serde(default)]
73 pub enable: Vec<String>,
74 /// Rules to turn off.
75 #[serde(default)]
76 pub disable: Vec<String>,
77 /// `true`: lints fail `htl check` / `htl test` and `include_tl!`. `false`: advisory
78 /// everywhere (including the macro, whose built-in default is strict).
79 pub strict: Option<bool>,
80}
81
82#[derive(Debug, Clone, Default, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct FmtConfig {
85 pub indent: Option<usize>,
86}
87
88#[derive(Debug, Clone, Default, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct CheckConfig {
91 /// Extra directories `require` resolves from during checking (CLI, `include_tl!`,
92 /// and the checker behind `TealResolver::for_contract`). Relative to `htl.toml`;
93 /// absolute and `~/` paths allowed. Use it for modules the host supplies at run time
94 /// from somewhere else (an SDK cache, a mods dir).
95 #[serde(default)]
96 pub paths: Vec<String>,
97}
98
99impl HtlConfig {
100 /// Parse `htl.toml` text.
101 pub fn parse(text: &str) -> Result<Self> {
102 toml::from_str(text).context("parsing htl.toml")
103 }
104
105 /// Nearest `htl.toml` at or above `start` (a file or directory). `Ok(None)` when
106 /// there is none; `Err` when one exists but does not parse.
107 pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
108 let mut dir = if start.is_dir() { start.to_path_buf() } else { crate::parent_dir(start) };
109 if let Ok(abs) = std::fs::canonicalize(&dir) {
110 dir = abs;
111 }
112 loop {
113 let path = dir.join(CONFIG_NAME);
114 if path.is_file() {
115 let text = std::fs::read_to_string(&path)
116 .with_context(|| format!("reading {}", path.display()))?;
117 let cfg = Self::parse(&text).with_context(|| path.display().to_string())?;
118 return Ok(Some((path, cfg)));
119 }
120 if !dir.pop() {
121 return Ok(None);
122 }
123 }
124 }
125
126 /// The `[lint]` section as a `+rule,-rule` spec for [`Htl::configure_lints`](crate::Htl::configure_lints).
127 /// Append a command-line / env spec after it so later entries win.
128 pub fn lint_spec(&self) -> String {
129 let mut parts: Vec<String> = Vec::new();
130 for r in &self.lint.enable {
131 parts.push(format!("+{r}"));
132 }
133 for r in &self.lint.disable {
134 parts.push(format!("-{r}"));
135 }
136 parts.join(",")
137 }
138
139 /// Directories the checker should search, in order: `root`, `root/src`, then
140 /// `[check] paths` (resolved against `root`, `~` expanded). Only existing dirs.
141 pub fn search_paths(&self, root: &Path) -> Vec<PathBuf> {
142 let mut out = vec![root.to_path_buf(), root.join("src")];
143 for p in &self.check.paths {
144 out.push(resolve_path(root, p));
145 }
146 out.retain(|p| p.is_dir());
147 out.dedup();
148 out
149 }
150}
151
152impl Contract {
153 /// Concrete contract directories under `root` (expands one `*` segment). Missing
154 /// directories are dropped; a literal `dir` that does not exist yields nothing.
155 pub fn dirs(&self, root: &Path) -> Vec<PathBuf> {
156 let mut acc = vec![root.to_path_buf()];
157 for seg in self.dir.split('/').filter(|s| !s.is_empty() && *s != ".") {
158 let mut next = Vec::new();
159 for base in &acc {
160 if seg == "*" {
161 if let Ok(rd) = std::fs::read_dir(base) {
162 let mut subs: Vec<PathBuf> = rd
163 .flatten()
164 .map(|e| e.path())
165 .filter(|p| p.is_dir() && !crate::is_skipped_dir(p, &[]))
166 .collect();
167 subs.sort();
168 next.extend(subs);
169 }
170 } else {
171 let p = base.join(seg);
172 if p.is_dir() {
173 next.push(p);
174 }
175 }
176 }
177 acc = next;
178 }
179 acc
180 }
181
182 /// Is a module with this name (file stem) held to the contract?
183 pub fn applies_to(&self, module: &str) -> bool {
184 if self.type_path.split_once('.').is_some_and(|(m, _)| m == module) {
185 return false;
186 }
187 if self.exclude.iter().any(|e| e == module) {
188 return false;
189 }
190 match &self.module {
191 Some(only) => only == module,
192 None => true,
193 }
194 }
195}
196
197/// Combine specs in precedence order (later wins): `"+a,-b"` + `"+b"` -> `"+a,-b,+b"`.
198pub fn join_specs<'a>(specs: impl IntoIterator<Item = &'a str>) -> String {
199 specs
200 .into_iter()
201 .filter(|s| !s.trim().is_empty())
202 .collect::<Vec<_>>()
203 .join(",")
204}
205
206/// `~/x` -> `$HOME/x`; relative -> under `root`; absolute as is.
207pub fn resolve_path(root: &Path, p: &str) -> PathBuf {
208 if let Some(rest) = p.strip_prefix("~/")
209 && let Some(home) = std::env::var_os("HOME")
210 {
211 return PathBuf::from(home).join(rest);
212 }
213 let pb = PathBuf::from(p);
214 if pb.is_absolute() { pb } else { root.join(pb) }
215}