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