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,
66 pub declared_at: usize,
68}
69
70impl Resolved {
71 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 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
120struct Marker {
122 dir: Option<String>,
123 module: Option<String>,
124 exclude: Option<Vec<String>>,
125 dts: Option<String>,
126}
127
128pub 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 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 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
187pub 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
198fn 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
225pub fn publish(root: &Path, contracts: &[Resolved]) -> (Vec<(PathBuf, bool)>, Vec<String>) {
242 let mut written = Vec::new();
243 let mut problems = Vec::new();
244 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 if crate::same_file(&target, &c.declared_in) {
253 continue;
254 }
255 match targets.iter().find(|(t, _)| crate::same_file(t, &target)) {
256 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 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
309fn 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 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
348struct Implementation {
351 span: (usize, usize),
353 doc: Vec<String>,
355 field: Option<(Vec<String>, String)>,
358}
359
360pub 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 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 return Ok(src.to_string());
413 }
414
415 let mut out: Vec<Option<String>> = lines.iter().map(|l| Some(l.to_string())).collect();
416 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 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 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
485fn 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
496fn 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 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 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
539fn 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
546fn 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 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
568fn 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
583fn 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
618fn 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
629fn 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 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 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
733fn 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 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
765fn 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 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
810fn 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
825fn 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 names.pop()?;
846 if names.is_empty() {
847 return None;
848 }
849 names.reverse();
850 Some(format!("{module}.{}", names.join(".")))
851}
852
853fn 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}