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 = 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}