Skip to main content

htl_core/
contract.rs

1//! `---@contract` / `---@required`: the contract a directory of modules must satisfy,
2//! declared where the type is declared.
3//!
4//! ```tl
5//! local record defs
6//!    record Mod              ---@contract("mods")
7//!       name: string         ---@required
8//!       monsters: {Monster}  ---@required
9//!       factions: {Faction}
10//!    end
11//! end
12//! return defs
13//! ```
14//!
15//! Every module directly under `mods/` must return a value assignable to `defs.Mod` and
16//! set `name` and `monsters`; `factions` is there for the mods that want it. That last
17//! part is why the fields are marked rather than counted: a record cannot say which of
18//! its own fields are mandatory (every Teal record field is nilable and there is no `?`
19//! for them), and taking all of them would break every module written before the field
20//! was added.
21//!
22//! `htl.toml` holds the directory and nothing else:
23//!
24//! ```toml
25//! [[contract]]
26//! dir = "mods"
27//! ```
28//!
29//! A bare `---@contract` inherits it — one line in the file a reader opens first, saying
30//! where this project accepts modules — and `---@contract("<dir>")` overrides it, which
31//! is what a project with more than one contract writes.
32//!
33//! The markers are comments, so the file stays valid Teal and other tooling ignores them.
34//! Reading them is a scan of the search paths, not a type-check: a record is found by the
35//! line it is declared on, the way `---@struct` is (see `prelude.lua`).
36
37use crate::config::{Contract, HtlConfig, RequireFields};
38use anyhow::Result;
39use std::path::{Path, PathBuf};
40
41/// A contract as it applies: which directory, which type, which fields.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Resolved {
44    /// Directory relative to the project root, `*` in one segment expanding to every
45    /// subdirectory at that level (`"sites/*"`).
46    pub dir: String,
47    /// `"<module>.<Type>"`, e.g. `"defs.Mod"` — the module that declares the record and
48    /// the path to it inside that module.
49    pub type_path: String,
50    /// The fields a module's returned table must set, from `---@required`.
51    pub require_fields: RequireFields,
52    /// Only this module name in each matched dir is held to the contract.
53    pub module: Option<String>,
54    /// Module names in the dir that are not held to the contract.
55    pub exclude: Vec<String>,
56    /// Where to publish the declaration, from `---@contract(dts = "…")`.
57    pub dts: Option<String>,
58    /// Where this contract is enforced when the scan cannot see it, from
59    /// `[[contract]] enforced_by`. Not a marker argument: enforcement is the host's
60    /// business, and the record is published to authors who have no use for the path.
61    pub enforced_by: Option<String>,
62    /// The file the marker is in: where to point when something about this contract is
63    /// wrong. Not where the contract applies — that is [`dir`](Self::dir) — but where the
64    /// sentence that set it up was written, which is the line a person has to edit.
65    pub declared_in: PathBuf,
66    /// The line of that marker, counted from 1.
67    pub declared_at: usize,
68}
69
70impl Resolved {
71    /// Concrete contract directories under `root` (expands one `*` segment). Missing
72    /// directories are dropped; a literal `dir` that does not exist yields nothing.
73    pub fn dirs(&self, root: &Path) -> Vec<PathBuf> {
74        let mut acc = vec![root.to_path_buf()];
75        for seg in self.dir.split('/').filter(|s| !s.is_empty() && *s != ".") {
76            let mut next = Vec::new();
77            for base in &acc {
78                if seg == "*" {
79                    if let Ok(rd) = std::fs::read_dir(base) {
80                        let mut subs: Vec<PathBuf> = rd
81                            .flatten()
82                            .map(|e| e.path())
83                            .filter(|p| p.is_dir() && !crate::is_skipped_dir(p, &[]))
84                            .collect();
85                        subs.sort();
86                        next.extend(subs);
87                    }
88                } else {
89                    let p = base.join(seg);
90                    if p.is_dir() {
91                        next.push(p);
92                    }
93                }
94            }
95            acc = next;
96        }
97        acc
98    }
99
100    /// Is `module` (a file stem under a contract dir) held to this contract? The module
101    /// that declares the type is not held to it.
102    pub fn applies_to(&self, module: &str) -> bool {
103        if self
104            .type_path
105            .split_once('.')
106            .is_some_and(|(m, _)| m == module)
107        {
108            return false;
109        }
110        if self.exclude.iter().any(|e| e == module) {
111            return false;
112        }
113        match &self.module {
114            Some(only) => only == module,
115            None => true,
116        }
117    }
118}
119
120/// What the `---@contract` on one record says, before the directory is settled.
121struct Marker {
122    dir: Option<String>,
123    module: Option<String>,
124    exclude: Option<Vec<String>>,
125    dts: Option<String>,
126}
127
128/// Contracts this project declares, and what is wrong with the ones it does not.
129///
130/// The scan covers [`HtlConfig::search_paths`] — where the checker resolves modules
131/// from — one level deep, plus `<sub>/init.tl`, which is the shape Teal's own path
132/// templates resolve. A marker anywhere else is not found, and the module name of a file
133/// nested deeper cannot be written as `<module>.<Type>` anyway.
134///
135/// The second half of the pair is diagnostics: a bare marker with no `[[contract]]` to
136/// inherit from, two markers claiming one directory, a marker on a record that is not
137/// nested inside its module. They are returned rather than raised because one broken
138/// contract should not take the other contracts of the project with it.
139pub fn resolve(root: &Path, cfg: &HtlConfig) -> (Vec<Resolved>, Vec<String>) {
140    let mut out = Vec::new();
141    let mut problems = Vec::new();
142    for file in scan_targets(root, cfg) {
143        let Ok(src) = std::fs::read_to_string(&file) else {
144            continue;
145        };
146        if !src.contains("---@contract") {
147            continue;
148        }
149        match read_file(&file, &src, cfg) {
150            Ok(found) => out.extend(found),
151            Err(msgs) => problems.extend(msgs),
152        }
153    }
154    // A published declaration carries the marker it was copied from, so the same record
155    // is found twice — once in the source, once in the file written from it. That is one
156    // record making one claim, and the source is the one to keep (a `.tl` beats a `.d.tl`
157    // everywhere else too).
158    out.sort_by_key(|c| crate::is_declaration(&c.declared_in));
159    let mut kept: Vec<Resolved> = Vec::with_capacity(out.len());
160    for c in out {
161        if !kept.iter().any(|k| same_record(root, k, &c)) {
162            kept.push(c);
163        }
164    }
165    let out = kept;
166
167    // Two *different* types for one directory would each have to be the one enforced
168    // there.
169    for i in 0..out.len() {
170        if let Some(j) = out[..i].iter().position(|c| c.dir == out[i].dir) {
171            problems.push(format!(
172                "{}:{}:1: {} claims directory {:?}, which {} already claims at {}:{} \
173                 [htl contract]",
174                out[i].declared_in.display(),
175                out[i].declared_at,
176                out[i].type_path,
177                out[i].dir,
178                out[j].type_path,
179                out[j].declared_in.display(),
180                out[j].declared_at,
181            ));
182        }
183    }
184    (out, problems)
185}
186
187/// Where a contract publishes its declaration: `---@contract(dts = "…")` relative to the
188/// project root, or `types/<module>.d.tl` by default — `types/` being the directory a
189/// project keeps declarations for other people in, searched with no configuration.
190pub fn dts_target(root: &Path, c: &Resolved) -> Option<PathBuf> {
191    let module = c.type_path.split_once('.')?.0;
192    Some(match &c.dts {
193        Some(p) => crate::config::resolve_path(root, p),
194        None => root.join("types").join(format!("{module}.d.tl")),
195    })
196}
197
198/// Is `later` the record `earlier` already is, found a second time? The claim check is
199/// between records, and a record's declaration is that record rather than a second
200/// claimant of the directory.
201///
202/// Two ways one record turns up twice. It was published: `htl dts` writes the module a
203/// `---@contract` type is declared in, and `types/` is on the search path, so the scan
204/// reads the claim back out of the file it just wrote. Or the same file was reached
205/// through two search paths. Which file a publication is is the publish's own answer
206/// ([`dts_target`]) — nothing here recognises a `types/` directory or a `.d.tl` name, so
207/// a project that publishes somewhere else is the same case — and the directory is what
208/// says the two claims are one claim, since that is what the published marker carries.
209///
210/// A module of the same name in each of two contract directories is *not* this: a
211/// contract directory is resolved as a directory ([`crate::pkg::TealResolver::for_contract_dir`]
212/// roots one resolver at each) and is not on the project's search path, so `mods_a/one.tl`
213/// and `mods_b/one.tl` are two modules, each held to the record its own directory is
214/// under. There is nothing there for the claim check to report.
215fn same_record(root: &Path, earlier: &Resolved, later: &Resolved) -> bool {
216    if earlier.dir != later.dir {
217        return false;
218    }
219    if crate::same_file(&earlier.declared_in, &later.declared_in) {
220        return earlier.type_path == later.type_path;
221    }
222    dts_target(root, earlier).is_some_and(|t| crate::same_file(&t, &later.declared_in))
223}
224
225/// Publish each contract's declaration: the module that declares the contract type is
226/// what an outside author writes against, so `htl` writes it out rather than leaving the
227/// host to copy the file at run time. Returns the targets it wrote (`true`) or found
228/// already current (`false`), and what it could not publish.
229///
230/// The declaring module is written out as a declaration: bodies removed, and each
231/// function that was part of the module's interface folded into its record as a field
232/// (`function m.f(a: integer): string` -> `f: function(a: integer): string`), which is
233/// what a hand-written `.d.tl` says. A module of declarations is copied unchanged,
234/// because there is nothing to remove.
235///
236/// One write per file, not one per contract. Two records in one module publish that one
237/// module, and a pass per record would write the file twice over — each pass rewriting
238/// its own marker and leaving the other's as the author left it, so the two passes
239/// disagree, `dts: wrote` is said twice, and the file never settles. A file's markers are
240/// made self-contained together, once.
241pub fn publish(root: &Path, contracts: &[Resolved]) -> (Vec<(PathBuf, bool)>, Vec<String>) {
242    let mut written = Vec::new();
243    let mut problems = Vec::new();
244    // Each file to write and the module it is written from, in the order the contracts
245    // came in.
246    let mut targets: Vec<(PathBuf, &Resolved)> = Vec::new();
247    for c in contracts {
248        let Some(target) = dts_target(root, c) else {
249            continue;
250        };
251        // A contract already declared in a `.d.tl` is its own publication.
252        if crate::same_file(&target, &c.declared_in) {
253            continue;
254        }
255        match targets.iter().find(|(t, _)| crate::same_file(t, &target)) {
256            // Two modules cannot both be one declaration: whichever was written last
257            // would be the file, and the other would have been published and lost.
258            Some((_, first)) if !crate::same_file(&first.declared_in, &c.declared_in) => problems
259                .push(format!(
260                    "{}:{}:1: {} publishes to {}, where {} is already published from {} \
261                     [htl contract]",
262                    c.declared_in.display(),
263                    c.declared_at,
264                    c.type_path,
265                    target.display(),
266                    first.type_path,
267                    first.declared_in.display(),
268                )),
269            Some(_) => {}
270            None => targets.push((target, c)),
271        }
272    }
273    for (target, c) in targets {
274        let Ok(src) = std::fs::read_to_string(&c.declared_in) else {
275            continue;
276        };
277        // Every marker the file carries, not only the one that sent it here: what is
278        // published has to stand on its own whichever record a reader opens it for.
279        let src = contracts
280            .iter()
281            .filter(|o| crate::same_file(&o.declared_in, &c.declared_in))
282            .fold(src, |s, o| self_contained_marker(&s, o));
283        let text = match declaration_of(&src) {
284            Ok(t) => t,
285            Err(msgs) => {
286                problems.extend(msgs.into_iter().map(|m| {
287                    format!(
288                        "{}:{m} publishing {} to {} [htl contract]",
289                        c.declared_in.display(),
290                        c.type_path,
291                        target.display()
292                    )
293                }));
294                continue;
295            }
296        };
297        match crate::write_if_changed(&target, &text) {
298            Ok(w) => written.push((target, w)),
299            Err(e) => problems.push(format!(
300                "{}:1:1: writing {}: {e} [htl contract]",
301                c.declared_in.display(),
302                target.display()
303            )),
304        }
305    }
306    (written, problems)
307}
308
309/// The source with its `---@contract` rewritten so the published copy stands on its own:
310/// the directory written out (a bare marker inherits from an `htl.toml` the reader of the
311/// declaration does not have), and `dts` dropped (the copy is not itself a publisher, and
312/// the path was the publisher's).
313fn self_contained_marker(src: &str, c: &Resolved) -> String {
314    let mut args = format!("{:?}", c.dir);
315    if let Some(m) = &c.module {
316        args.push_str(&format!(", module = {m:?}"));
317    }
318    if !c.exclude.is_empty() {
319        args.push_str(&format!(", exclude = {:?}", c.exclude.join(" ")));
320    }
321    let want = format!("---@contract({args})");
322    let mut lines: Vec<String> = src.lines().map(str::to_string).collect();
323    // The marker is on the record's line or the one above it, the same two places it was
324    // read from.
325    for i in [
326        c.declared_at.saturating_sub(1),
327        c.declared_at.saturating_sub(2),
328    ] {
329        let Some(line) = lines.get_mut(i) else {
330            continue;
331        };
332        let Some(at) = line.find("---@contract") else {
333            continue;
334        };
335        let tail = &line[at + "---@contract".len()..];
336        let rest = match tail.split_once(')') {
337            Some((_, after)) if tail.trim_start().starts_with('(') => after.to_string(),
338            _ => tail.to_string(),
339        };
340        *line = format!("{}{want}{rest}", &line[..at]);
341        break;
342    }
343    let mut out = lines.join("\n");
344    out.push('\n');
345    out
346}
347
348/// One `function` statement of a module: where it sits, what to write instead, and where
349/// that goes.
350struct Implementation {
351    /// Lines to remove, `[first, last]`, zero-based, doc comment included.
352    span: (usize, usize),
353    /// The doc comment, trimmed, to reindent above the field.
354    doc: Vec<String>,
355    /// `Some((record path, field text))` for a function on the module's own table, `None`
356    /// for a `local function` — module-local, and not part of what the module declares.
357    field: Option<(Vec<String>, String)>,
358}
359
360/// The `.d.tl` for a module's source: every function body removed and its signature moved
361/// into the record it belongs to. `Err` when a `function` statement cannot be placed,
362/// rather than a file with a silently missing function in it.
363pub fn declaration_of(src: &str) -> Result<String, Vec<String>> {
364    let lines: Vec<&str> = src.lines().collect();
365    let mut problems = Vec::new();
366    let mut found: Vec<Implementation> = Vec::new();
367    let mut i = 0;
368    while i < lines.len() {
369        let Some(kind) = function_start(lines[i]) else {
370            i += 1;
371            continue;
372        };
373        // The doc comment above a function is part of it, and belongs wherever it goes.
374        let mut first = i;
375        while first > 0 && lines[first - 1].trim_start().starts_with("--") {
376            first -= 1;
377        }
378        let Some(end) = body_end(&lines, i) else {
379            problems.push(format!(
380                "{}:1: this function has no `end` at its own indentation, so its body \
381                 cannot be told from what follows:",
382                i + 1
383            ));
384            break;
385        };
386        match kind {
387            FnKind::Local => found.push(Implementation {
388                span: (first, end),
389                doc: Vec::new(),
390                field: None,
391            }),
392            FnKind::Exported => match signature(&lines, i) {
393                Ok((path, field)) => found.push(Implementation {
394                    span: (first, end),
395                    doc: lines[first..i]
396                        .iter()
397                        .map(|l| l.trim().to_string())
398                        .collect(),
399                    field: Some((path, field)),
400                }),
401                Err(e) => problems.push(format!("{}:1: {e}:", i + 1)),
402            },
403        }
404        i = end + 1;
405    }
406    if !problems.is_empty() {
407        return Err(problems);
408    }
409    if found.is_empty() {
410        // Already a declaration: nothing to strip, and copying it as it is keeps the
411        // comments and the layout the author wrote.
412        return Ok(src.to_string());
413    }
414
415    let mut out: Vec<Option<String>> = lines.iter().map(|l| Some(l.to_string())).collect();
416    // Fields first, while the line numbers still mean what they meant.
417    for imp in &found {
418        let Some((path, field)) = &imp.field else {
419            continue;
420        };
421        match record_close(&lines, path) {
422            Some(at) => {
423                // A record that already declares the field has the author's own version
424                // of this signature; a second one would be a duplicate key.
425                let name = field.split(':').next().unwrap_or_default();
426                if declares_field(&lines, at, name) {
427                    continue;
428                }
429                let indent = " ".repeat(indent_of(lines[at]) + 3);
430                let existing = out[at].take().unwrap_or_default();
431                let doc: String = imp.doc.iter().map(|l| format!("{indent}{l}\n")).collect();
432                out[at] = Some(format!("{doc}{indent}{field}\n{existing}"));
433            }
434            None => problems.push(format!(
435                "{}:1: nothing declares a record {} for this function to be a field of:",
436                imp.span.0 + 1,
437                path.join(".")
438            )),
439        }
440    }
441    if !problems.is_empty() {
442        return Err(problems);
443    }
444    for imp in &found {
445        let (first, last) = imp.span;
446        for l in out.iter_mut().take(last + 1).skip(first) {
447            *l = None;
448        }
449        // The blank line that separated this function from the next belongs to it: left
450        // behind, every removal leaves a gap where a function used to be.
451        if (first == 0 || lines[first - 1].trim().is_empty())
452            && let Some(after) = out.get_mut(last + 1)
453            && after.as_deref().is_some_and(|l| l.trim().is_empty())
454        {
455            *after = None;
456        }
457    }
458    let mut text: String = out
459        .into_iter()
460        .flatten()
461        .collect::<Vec<_>>()
462        .join("\n")
463        .trim_end()
464        .to_string();
465    text.push('\n');
466    Ok(text)
467}
468
469enum FnKind {
470    Exported,
471    Local,
472}
473
474fn function_start(line: &str) -> Option<FnKind> {
475    let t = line.trim_start();
476    if t.starts_with("local function ") {
477        Some(FnKind::Local)
478    } else if t.starts_with("function ") {
479        Some(FnKind::Exported)
480    } else {
481        None
482    }
483}
484
485/// The line closing the function that starts at `i`: the first `end` indented no deeper
486/// than the `function` itself.
487fn body_end(lines: &[&str], i: usize) -> Option<usize> {
488    let base = indent_of(lines[i]);
489    (i + 1..lines.len()).find(|&j| {
490        let t = lines[j].trim_start();
491        (t == "end" || t.starts_with("end ") || t.starts_with("end-"))
492            && indent_of(lines[j]) <= base
493    })
494}
495
496/// `function m.f(a: integer): string` -> (`["m"]`, `f: function(a: integer): string`).
497///
498/// The signature is taken as written, over as many lines as it spans: a `.d.tl` names
499/// parameters in a function type just as the implementation does, so there is nothing to
500/// rewrite. A `:` method gains the `self` its definition left implicit.
501fn signature(lines: &[&str], i: usize) -> Result<(Vec<String>, String), String> {
502    let head = lines[i].trim_start().strip_prefix("function ").unwrap();
503    let (name, rest) = head
504        .split_once('(')
505        .ok_or("a function with no parameter list")?;
506    let method = name.contains(':');
507    let mut path: Vec<String> = name
508        .split(['.', ':'])
509        .map(|s| s.trim().to_string())
510        .collect();
511    let field = path.pop().filter(|f| !f.is_empty()).ok_or("no name")?;
512    if path.is_empty() {
513        return Err("a function on no module table".into());
514    }
515    // Parameters may run over several lines; the signature ends with the line on which
516    // the parentheses close, return type and all.
517    let mut sig = rest.to_string();
518    let mut depth = 1i32 + count(rest);
519    let mut j = i;
520    while depth > 0 {
521        j += 1;
522        let next = *lines.get(j).ok_or("a parameter list that never closes")?;
523        depth += count(next);
524        sig.push('\n');
525        sig.push_str(next);
526    }
527    let sig = sig.trim_end();
528    let self_arg = if !method {
529        String::new()
530    } else if sig.trim_start().starts_with(')') {
531        // `function M:f()` takes only its receiver: no comma to separate it from.
532        format!("self: {}", path.last().unwrap())
533    } else {
534        format!("self: {}, ", path.last().unwrap())
535    };
536    Ok((path, format!("{field}: function({self_arg}{sig}")))
537}
538
539/// Net change in parenthesis depth over a line, ignoring what is inside a comment.
540fn count(line: &str) -> i32 {
541    let code = line.split("--").next().unwrap_or(line);
542    code.chars().filter(|c| *c == '(').count() as i32
543        - code.chars().filter(|c| *c == ')').count() as i32
544}
545
546/// Does the record closed at `close` already declare a field called `name`? Its body is
547/// what lies between its `record` line and that `end`, at one level of nesting.
548fn declares_field(lines: &[&str], close: usize, name: &str) -> bool {
549    let base = indent_of(lines[close]);
550    for j in (0..close).rev() {
551        let t = lines[j].trim_start();
552        // Its own `record` line: the body is behind us, and a field of that name further
553        // up belongs to some other record.
554        if indent_of(lines[j]) <= base
555            && (t.starts_with("record ") || t.starts_with("local record "))
556        {
557            return false;
558        }
559        if t.strip_prefix(name)
560            .is_some_and(|r| r.trim_start().starts_with(':'))
561        {
562            return true;
563        }
564    }
565    false
566}
567
568/// The `end` closing the record named by `path` (`["defs", "Mod"]` = `Mod` inside
569/// `defs`), searched from the outside in.
570fn record_close(lines: &[&str], path: &[String]) -> Option<usize> {
571    let mut from = 0usize;
572    let mut to = lines.len();
573    for name in path {
574        let at = (from..to).find(|&j| record_name(lines[j]).as_deref() == Some(name.as_str()))?;
575        let base = indent_of(lines[at]);
576        to = (at + 1..to)
577            .find(|&j| lines[j].trim_start().starts_with("end") && indent_of(lines[j]) <= base)?;
578        from = at + 1;
579    }
580    Some(to)
581}
582
583/// Files a marker can be found in: those directly under a search path, and the
584/// `init.tl` of an immediate subdirectory (`require("sub")` resolves to `sub/init.tl`).
585fn scan_targets(root: &Path, cfg: &HtlConfig) -> Vec<PathBuf> {
586    let mut out = Vec::new();
587    for dir in cfg.search_paths(root) {
588        let Ok(entries) = std::fs::read_dir(&dir) else {
589            continue;
590        };
591        let mut here: Vec<PathBuf> = Vec::new();
592        for e in entries.flatten() {
593            let p = e.path();
594            if p.is_file() && is_teal(&p) {
595                here.push(p);
596            } else if p.is_dir() && !crate::is_skipped_dir(&p, &[]) {
597                for name in ["init.tl", "init.d.tl"] {
598                    let init = p.join(name);
599                    if init.is_file() {
600                        here.push(init);
601                    }
602                }
603            }
604        }
605        here.sort();
606        out.extend(here);
607    }
608    out.dedup();
609    out
610}
611
612fn is_teal(p: &Path) -> bool {
613    p.file_name()
614        .and_then(|s| s.to_str())
615        .is_some_and(|n| n.ends_with(".tl"))
616}
617
618/// The module name a `require` would use for `file`: its stem, or the directory name
619/// when the file is an `init.tl`.
620fn module_name(file: &Path) -> Option<String> {
621    let stem = file.file_name()?.to_str()?.trim_end_matches(".tl");
622    let stem = stem.strip_suffix(".d").unwrap_or(stem);
623    if stem == "init" {
624        return Some(file.parent()?.file_name()?.to_str()?.to_string());
625    }
626    Some(stem.to_string())
627}
628
629/// Every contract declared in one file. `Err` carries what is wrong with the markers it
630/// does have, one message per marker, so a file with two of them reports both.
631fn read_file(file: &Path, src: &str, cfg: &HtlConfig) -> Result<Vec<Resolved>, Vec<String>> {
632    let lines: Vec<&str> = src.lines().collect();
633    let Some(module) = module_name(file) else {
634        return Ok(Vec::new());
635    };
636    let mut out = Vec::new();
637    let mut problems = Vec::new();
638    // A marker on its own line belongs to the line below it, so the record is what is
639    // looked for first: asking about the marker line by line would attribute a trailing
640    // `record Mod ---@contract` to the field declared under it as well.
641    for (i, line) in lines.iter().enumerate() {
642        let Some(record) = record_name(line) else {
643            continue;
644        };
645        let Some(marker) = marker_on(&lines, i, "contract") else {
646            continue;
647        };
648        let marker = match parse_marker(&marker) {
649            Ok(m) => m,
650            Err(e) => {
651                problems.push(format!(
652                    "{}:{}:1: {e} [htl contract]",
653                    file.display(),
654                    i + 1
655                ));
656                continue;
657            }
658        };
659        let Some(path) = type_path(&lines, i, &module, &record) else {
660            problems.push(format!(
661                "{}:{}:1: {record} is the module {module} returns, not a type inside it: \
662                 a contract type is written as <module>.<Type>, so declare it as a record \
663                 within one [htl contract]",
664                file.display(),
665                i + 1
666            ));
667            continue;
668        };
669        let dir = match (marker.dir, cfg.contract.as_slice()) {
670            (Some(d), _) => d,
671            (None, [one]) => one.dir.clone(),
672            (None, []) => {
673                problems.push(format!(
674                    "{}:{}:1: ---@contract names no directory and htl.toml declares none: \
675                     write ---@contract(\"<dir>\") here, or a [[contract]] dir = \"<dir>\" \
676                     in htl.toml [htl contract]",
677                    file.display(),
678                    i + 1
679                ));
680                continue;
681            }
682            (None, many) => {
683                problems.push(format!(
684                    "{}:{}:1: ---@contract names no directory and htl.toml declares {}: \
685                     write the directory on the marker [htl contract]",
686                    file.display(),
687                    i + 1,
688                    many.len()
689                ));
690                continue;
691            }
692        };
693        let inherited: Option<&Contract> = cfg.contract.iter().find(|c| c.dir == dir);
694        out.push(Resolved {
695            dir,
696            type_path: path,
697            require_fields: required_fields(&lines, i),
698            module: marker
699                .module
700                .or_else(|| inherited.and_then(|c| c.module.clone())),
701            exclude: marker
702                .exclude
703                .or_else(|| inherited.map(|c| c.exclude.clone()))
704                .unwrap_or_default(),
705            dts: marker.dts,
706            enforced_by: inherited.and_then(|c| c.enforced_by.clone()),
707            declared_in: file.to_path_buf(),
708            declared_at: i + 1,
709        });
710    }
711    // A marker nothing picked up: it reads as a contract and does nothing, which is worse
712    // than either being one or not being written.
713    for (i, line) in lines.iter().enumerate() {
714        if !line.contains("---@contract")
715            || record_name(line).is_some()
716            || lines.get(i + 1).and_then(|l| record_name(l)).is_some()
717        {
718            continue;
719        }
720        problems.push(format!(
721            "{}:{}:1: ---@contract is not on a record declaration [htl contract]",
722            file.display(),
723            i + 1
724        ));
725    }
726    if problems.is_empty() {
727        Ok(out)
728    } else {
729        Err(problems)
730    }
731}
732
733/// The text after `---@<name>` on line `i` or the line above it, `None` when the marker
734/// is not there. `Some("")` for a bare marker; `Some("(…)")` when it has arguments.
735///
736/// The line above counts only when the marker is the whole of it. A marker trailing a
737/// declaration belongs to that declaration, and reading it from the line below as well
738/// would make one `---@required` mark two fields.
739///
740/// What is read stops at the next `---@`: a declaration may carry two markers on one line
741/// (`record Mod   ---@contract ---@extensible`), and the second one is the other marker's
742/// text rather than this one's argument list.
743fn marker_on(lines: &[&str], i: usize, name: &str) -> Option<String> {
744    let needle = format!("---@{name}");
745    let above = i
746        .checked_sub(1)
747        .and_then(|p| lines.get(p))
748        .filter(|l| l.trim_start().starts_with("---"));
749    for line in [lines.get(i), above].into_iter().flatten() {
750        if let Some(rest) = line.split(&needle).nth(1) {
751            // `---@contracts` is not `---@contract`.
752            if rest
753                .chars()
754                .next()
755                .is_none_or(|c| !c.is_alphanumeric() && c != '_')
756            {
757                let mine = rest.split("---@").next().unwrap_or(rest);
758                return Some(mine.trim().to_string());
759            }
760        }
761    }
762    None
763}
764
765/// `("dir", module = "X", dts = "path")` — every part optional, and the whole thing
766/// optional. The first argument, if it is a bare string, is the directory.
767fn parse_marker(rest: &str) -> Result<Marker, String> {
768    let mut m = Marker {
769        dir: None,
770        module: None,
771        exclude: None,
772        dts: None,
773    };
774    if rest.is_empty() {
775        return Ok(m);
776    }
777    let Some(args) = rest.strip_prefix('(').and_then(|r| r.split(')').next()) else {
778        return Err(format!(
779            "---@contract takes no arguments or a parenthesised list, got {rest:?}"
780        ));
781    };
782    for (n, arg) in args.split(',').map(str::trim).enumerate() {
783        if arg.is_empty() {
784            continue;
785        }
786        match arg.split_once('=').map(|(k, v)| (k.trim(), v.trim())) {
787            Some(("module", v)) => m.module = Some(unquote(v)?),
788            // Space-separated inside one string: a Lua comment is not a place for a list
789            // literal, and the names are module names, which have no spaces in them.
790            Some(("exclude", v)) => {
791                m.exclude = Some(unquote(v)?.split_whitespace().map(str::to_string).collect())
792            }
793            Some(("dts", v)) => m.dts = Some(unquote(v)?),
794            Some((k, _)) => return Err(format!("---@contract has no {k:?} argument")),
795            None if n == 0 => m.dir = Some(unquote(arg)?),
796            None => return Err(format!("---@contract: {arg:?} is not <name> = <value>")),
797        }
798    }
799    Ok(m)
800}
801
802fn unquote(v: &str) -> Result<String, String> {
803    let t = v.trim();
804    t.strip_prefix('"')
805        .and_then(|t| t.strip_suffix('"'))
806        .map(str::to_string)
807        .ok_or_else(|| format!("---@contract: {v:?} is not a quoted string"))
808}
809
810/// The record declared on `line`, if it declares one.
811fn record_name(line: &str) -> Option<String> {
812    let after = line.split("record").nth(1)?;
813    let name: String = after
814        .trim_start()
815        .chars()
816        .take_while(|c| c.is_alphanumeric() || *c == '_')
817        .collect();
818    (!name.is_empty()).then_some(name)
819}
820
821fn indent_of(line: &str) -> usize {
822    line.len() - line.trim_start().len()
823}
824
825/// `<module>.<Type>` for the record declared at `lines[i]`: the module, then every
826/// record enclosing this one *except* the outermost, then the record itself. `None` when
827/// the record is the outermost one — that is the module, and a contract type has to be a
828/// type inside a module for `require("<module>")` to reach it.
829fn type_path(lines: &[&str], i: usize, module: &str, record: &str) -> Option<String> {
830    let mut names = vec![record.to_string()];
831    let mut depth = indent_of(lines[i]);
832    for line in lines[..i].iter().rev() {
833        if line.trim().is_empty() {
834            continue;
835        }
836        let d = indent_of(line);
837        if d < depth
838            && let Some(n) = record_name(line)
839        {
840            names.push(n);
841            depth = d;
842        }
843    }
844    // The outermost record is the module itself, whatever it is called.
845    names.pop()?;
846    if names.is_empty() {
847        return None;
848    }
849    names.reverse();
850    Some(format!("{module}.{}", names.join(".")))
851}
852
853/// Fields marked `---@required` in the record declared at `lines[i]`, in the order they
854/// are declared. The record ends at the first `end` indented no deeper than it.
855fn required_fields(lines: &[&str], i: usize) -> RequireFields {
856    let base = indent_of(lines[i]);
857    let mut names = Vec::new();
858    for j in i + 1..lines.len() {
859        let line = lines[j];
860        if line.trim_start().starts_with("end") && indent_of(line) <= base {
861            break;
862        }
863        let name: String = line
864            .trim_start()
865            .chars()
866            .take_while(|c| c.is_alphanumeric() || *c == '_')
867            .collect();
868        if name.is_empty()
869            || !line.trim_start()[name.len()..]
870                .trim_start()
871                .starts_with(':')
872        {
873            continue;
874        }
875        if marker_on(lines, j, "required").is_some() {
876            names.push(name);
877        }
878    }
879    RequireFields::Named(names)
880}