1use crate::config::{Contract, HtlConfig, RequireFields};
38use anyhow::Result;
39use std::path::{Path, PathBuf};
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Resolved {
44 pub dir: String,
47 pub type_path: String,
50 pub require_fields: RequireFields,
52 pub module: Option<String>,
54 pub exclude: Vec<String>,
56 pub dts: Option<String>,
58 pub enforced_by: Option<String>,
62 pub declared_in: PathBuf,
65 pub declared_at: usize,
66}
67
68impl Resolved {
69 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 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
118struct Marker {
120 dir: Option<String>,
121 module: Option<String>,
122 exclude: Option<Vec<String>>,
123 dts: Option<String>,
124}
125
126pub 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 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 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
178pub 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
189pub 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 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
240fn 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 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
279struct Implementation {
282 span: (usize, usize),
284 doc: Vec<String>,
286 field: Option<(Vec<String>, String)>,
289}
290
291pub 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 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 return Ok(src.to_string());
344 }
345
346 let mut out: Vec<Option<String>> = lines.iter().map(|l| Some(l.to_string())).collect();
347 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 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 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
416fn 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
427fn 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 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 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
470fn 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
477fn 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 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
499fn 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
514fn 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
549fn 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
560fn 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 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 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
664fn 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 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
691fn 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 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
736fn 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
751fn 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 names.pop()?;
772 if names.is_empty() {
773 return None;
774 }
775 names.reverse();
776 Some(format!("{module}.{}", names.join(".")))
777}
778
779fn 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}