1use std::collections::BTreeSet;
4use std::path::Path;
5
6use sva_ast::{Binds, Expr, Graph, Skip, Source, children, ref_spans, resolve_ref_path};
7use sva_ast::{MAX_TAG_CHARS, MAX_TAGS, is_plain_tag, parse_doc_comment};
8use sva_core::{
9 CliError, Diagnostic, LintCode, LintViolation, ROOT, Severity, lint_diagnostic, prepared,
10 refuse_unresolved_bars, settled,
11};
12use sva_engine::EngineError;
13
14pub struct Finding {
16 pub code: LintCode,
17 pub severity: Severity,
18 pub subject: String,
19 pub message: String,
20 pub line: Option<usize>,
22}
23
24impl Finding {
25 pub fn diagnostic(&self) -> Diagnostic {
26 lint_diagnostic(
27 self.code,
28 &self.subject,
29 &self.message,
30 self.severity,
31 self.line,
32 )
33 }
34}
35
36pub struct LintReport {
37 pub nodes: usize,
38 pub findings: Vec<Finding>,
39}
40
41pub fn lint(dir: &Path, target: Option<&str>) -> Result<LintReport, CliError> {
44 let source = sva_ast::Dir::at(dir);
45 match target {
46 None => lint_whole(&source),
47 Some(target) => lint_reaching(&source, target),
48 }
49}
50
51fn not_nodes(graph: &Graph) -> Vec<Finding> {
54 graph
55 .skipped()
56 .iter()
57 .filter(|s| s.reason != Skip::Unnameable || !is_document(&s.path))
58 .map(|s| {
59 let path = &s.path;
60 let (code, message) = match s.reason {
61 Skip::Unnameable => (
62 LintCode::NotANode,
63 format!("no ref can name `{path}`, so it is not read as a node"),
64 ),
65 Skip::Special => (
66 LintCode::NotAFile,
67 format!("`{path}` is a socket, FIFO or device, so no text was read from it"),
68 ),
69 };
70 Finding {
71 code,
72 severity: Severity::Advice,
73 subject: path.clone(),
74 message,
75 line: None,
76 }
77 })
78 .collect()
79}
80
81fn is_document(path: &str) -> bool {
83 match path.rsplit_once('.') {
84 Some((_, suffix)) => matches!(suffix.to_ascii_lowercase().as_str(), "md" | "json"),
85 None => false,
86 }
87}
88
89fn lint_whole(source: &dyn Source) -> Result<LintReport, CliError> {
90 let graph = prepared(source)?;
91 refuse_unresolved_bars(&graph)?;
92 let violations = lint_violations(source, &graph);
93 if violations.is_empty() && graph.defines(ROOT) {
95 sva_engine::check_structure(&graph, ROOT).map_err(CliError::Engine)?;
96 }
97
98 let mut findings = entry_typing(&graph);
99 if !graph.defines(ROOT) {
100 findings.push(Finding {
101 code: LintCode::NoDefaultRoot,
102 severity: Severity::Advice,
103 subject: ROOT.to_string(),
104 message: format!(
105 "no `{ROOT}` file, so a render must name the node it wants — every node is \
106 still a root"
107 ),
108 line: None,
109 });
110 }
111 findings.extend(not_nodes(&graph));
112 findings.extend(unreached(&graph));
113 findings.extend(grid_row_counts(&graph));
114 findings.extend(tag_findings(source, &graph));
115 findings.extend(crate::variables::key_findings(&graph));
116 findings.extend(crate::rates::rate_findings(&graph));
117 findings.extend(crate::windows::window_findings(
118 &graph,
119 &whole_roots(&graph),
120 ));
121 verdict(graph.paths().count(), violations, findings)
122}
123
124fn verdict(
126 nodes: usize,
127 violations: Vec<LintViolation>,
128 findings: Vec<Finding>,
129) -> Result<LintReport, CliError> {
130 if violations.is_empty() {
131 return Ok(LintReport { nodes, findings });
132 }
133 let mut every = violations;
134 every.extend(findings.into_iter().map(|f| LintViolation {
135 code: f.code,
136 severity: f.severity,
137 subject: f.subject,
138 message: f.message,
139 line: f.line,
140 }));
141 Err(CliError::LintRefused(every))
142}
143
144fn whole_roots(graph: &Graph) -> Vec<String> {
145 let mut out: Vec<String> = graph
146 .defines(ROOT)
147 .then(|| ROOT.to_string())
148 .into_iter()
149 .collect();
150 out.extend(entry_points(graph));
151 out
152}
153
154fn entry_typing(graph: &Graph) -> Vec<Finding> {
158 entry_points(graph)
159 .into_iter()
160 .filter(|p| p != ROOT)
161 .filter_map(|path| {
162 let refused = sva_engine::check_structure(graph, &path).err()?;
163 Some(Finding {
164 code: LintCode::EntryPointRefused,
165 severity: Severity::Warning,
166 subject: path,
167 message: refused.to_string(),
168 line: None,
169 })
170 })
171 .collect()
172}
173
174fn lint_reaching(source: &dyn Source, target: &str) -> Result<LintReport, CliError> {
175 let held = sva_core::roots_of(source, Some(target))?;
176 let roots: Vec<&str> = held.iter().map(String::as_str).collect();
177 let mut graph = settled(sva_ast::load_reaching(source, &roots))?;
178 refuse_unresolved_bars(&graph)?;
179 let violations = lint_violations(source, &graph);
180 let root = match graph.defines(target) {
182 true => target.to_string(),
183 false => {
184 sva_core::define_probe_for(&mut graph, target)?;
185 sva_core::PROBE.to_string()
186 }
187 };
188 if let Err(refused) = sva_engine::check_structure(&graph, &root)
189 && violations.is_empty()
190 {
191 return match sva_core::instances_behind(source, target, &refused).as_deref() {
193 Some([only]) if only != target => lint_reaching(source, only),
194 Some(held) => Err(CliError::Engine(EngineError::AmbiguousNode(
195 target.to_string(),
196 held.to_vec(),
197 ))),
198 None => Err(CliError::Engine(refused)),
199 };
200 }
201 let mut findings = grid_row_counts(&graph);
203 findings.extend(tag_findings(source, &graph));
204 findings.extend(crate::variables::key_findings(&graph));
205 findings.extend(crate::rates::rate_findings(&graph));
206 findings.extend(crate::windows::window_findings(&graph, &[root]));
207 verdict(graph.paths().count(), violations, findings)
208}
209
210fn grid_row_counts(graph: &Graph) -> Vec<Finding> {
212 let mut findings = Vec::new();
213 for path in graph.paths() {
214 let Some(grid) = graph.grid(path) else {
215 continue;
216 };
217 let Some(bars) = grid.bar_span.filter(|b| *b > 0.0) else {
218 continue;
219 };
220 let rows_per_bar = grid.row_count as f64 / bars;
221 let bars_per_row = bars / grid.row_count as f64;
222 let whole = |v: f64| (v - v.round()).abs() <= 1e-9;
223 if !whole(rows_per_bar) && !whole(bars_per_row) {
224 findings.push(Finding {
225 code: LintCode::GridRowsPerBar,
226 severity: Severity::Warning,
227 subject: path.to_string(),
228 message: format!(
229 "`{path}` has {} rows over {bars} bar(s), {rows_per_bar:.4} rows/bar and \
230 {bars_per_row:.4} bars/row — neither a whole subdivision of a bar nor a \
231 whole number of bars a row, likely a stray or missing row",
232 grid.row_count
233 ),
234 line: None,
235 });
236 }
237 }
238 findings
239}
240
241fn lint_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
244 let mut violations = Vec::new();
245 violations.extend(doc_comment_violations(source, graph));
246 violations.extend(long_comment_block_violations(source, graph));
247 violations.extend(long_expression_body_violations(source, graph));
248 violations
249}
250
251const LONG_COMMENT_BLOCK_CHARS: usize = 1000;
253
254fn long_comment_block_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
256 let mut violations = Vec::new();
257 for path in graph.paths() {
258 let Ok(Some(text)) = source.get(path) else {
259 continue;
260 };
261 let mut run: Option<(usize, usize, usize)> = None;
263 for (idx, line) in text.lines().enumerate() {
264 if line.trim_start().starts_with(';') {
265 let line_chars = line.chars().count();
266 run = Some(match run {
267 Some((start, chars, first)) => (start, chars + line_chars, first),
268 None => (idx + 1, line_chars, line_chars),
269 });
270 } else {
271 flush_comment_run(run.take(), path, &mut violations);
272 }
273 }
274 flush_comment_run(run, path, &mut violations);
275 }
276 violations
277}
278
279fn flush_comment_run(
281 run: Option<(usize, usize, usize)>,
282 path: &str,
283 violations: &mut Vec<LintViolation>,
284) {
285 let Some((start, chars, first)) = run else {
286 return;
287 };
288 let chars = if start == 1 {
289 chars.saturating_sub(first)
290 } else {
291 chars
292 };
293 if chars > LONG_COMMENT_BLOCK_CHARS {
294 violations.push(LintViolation {
295 code: LintCode::LongCommentBlock,
296 severity: Severity::Error,
297 subject: path.to_string(),
298 message: format!(
299 "`{path}` has a {chars}-character comment block, over the \
300 {LONG_COMMENT_BLOCK_CHARS}-character threshold — split it or trim it"
301 ),
302 line: Some(start),
303 });
304 }
305}
306
307fn doc_comment_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
310 let mut violations = Vec::new();
311 for path in graph.paths() {
312 let Ok(Some(text)) = source.get(path) else {
313 continue;
314 };
315 let header: Vec<&str> = text
316 .lines()
317 .take_while(|line| line.trim_start().starts_with(';'))
318 .collect();
319 match header.len() {
320 0 => violations.push(LintViolation {
321 code: LintCode::MissingComment,
322 severity: Severity::Error,
323 subject: path.to_string(),
324 message: format!(
325 "`{path}` has no `;`-comment — every composition node must carry one `; \
326 Models: ... | Neglects: ... | IO: ... -> ... | Tags: ...` line \
327 documenting it"
328 ),
329 line: None,
330 }),
331 1 => {
332 if let Err(reason) = parse_doc_comment(header[0]) {
333 violations.push(LintViolation {
334 code: LintCode::MalformedComment,
335 severity: Severity::Error,
336 subject: path.to_string(),
337 message: format!(
338 "`{path}`'s comment does not match `Models: ... | Neglects: ... \
339 | IO: ... -> ... | Tags: ...` ({reason})"
340 ),
341 line: None,
342 });
343 }
344 }
345 n => violations.push(LintViolation {
346 code: LintCode::MultilineComment,
347 severity: Severity::Error,
348 subject: path.to_string(),
349 message: format!(
350 "`{path}` has {n} `;`-comment lines — exactly one is required, written \
351 denser instead of split across lines"
352 ),
353 line: None,
354 }),
355 }
356 }
357 violations
358}
359
360fn tag_advice(line: &str) -> Option<String> {
364 let held = parse_doc_comment(line).ok()?.tags;
365 let odd: Vec<&str> = held
366 .iter()
367 .map(String::as_str)
368 .filter(|t| !is_plain_tag(t))
369 .collect();
370 let mut notes = Vec::new();
371 if held.len() > MAX_TAGS {
372 notes.push(format!(
373 "{} tags; {MAX_TAGS} keeps `Tags:` a triage aid rather than a second `Neglects:`",
374 held.len()
375 ));
376 }
377 if !odd.is_empty() {
378 notes.push(format!(
379 "`{}` reads as free text; a lowercase word of up to {MAX_TAG_CHARS} characters, \
380 hyphens between segments, sorts and greps with the rest",
381 odd.join("`, `")
382 ));
383 }
384 (!notes.is_empty()).then(|| notes.join("; "))
385}
386
387fn tag_findings(source: &dyn Source, graph: &Graph) -> Vec<Finding> {
389 let mut findings = Vec::new();
390 for path in graph.paths() {
391 let Ok(Some(text)) = source.get(path) else {
392 continue;
393 };
394 let Some(line) = text.lines().find(|l| l.trim_start().starts_with(';')) else {
395 continue;
396 };
397 if let Some(message) = tag_advice(line) {
398 findings.push(Finding {
399 code: LintCode::TagShape,
400 severity: Severity::Advice,
401 subject: path.to_string(),
402 message: format!("`{path}`'s `Tags:` field: {message}"),
403 line: None,
404 });
405 }
406 }
407 findings
408}
409
410const EXPRESSION_BODY_CHARS: usize = 10_000;
413
414const REF_PLACEHOLDER: &str = "@x";
416
417fn long_expression_body_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
420 let mut violations = Vec::new();
421 for path in graph.paths() {
422 let Ok(Some(text)) = source.get(path) else {
423 continue;
424 };
425 let body: String = text
426 .lines()
427 .skip_while(|line| line.trim_start().starts_with(';'))
428 .collect::<Vec<_>>()
429 .join("\n");
430 let chars = body.chars().count();
431 if chars <= EXPRESSION_BODY_CHARS {
432 continue;
433 }
434 let (chars, has_refs) = ref_stripped_char_count(&body);
435 if chars > EXPRESSION_BODY_CHARS {
436 let message = if has_refs {
437 format!(
438 "`{path}` has a {chars}-character expression body even with every `@ref` \
439 name collapsed to `{REF_PLACEHOLDER}`, over the \
440 {EXPRESSION_BODY_CHARS}-character threshold — decompose it into sub-nodes"
441 )
442 } else {
443 format!(
444 "`{path}` has a {chars}-character expression body, over the \
445 {EXPRESSION_BODY_CHARS}-character threshold — decompose it into sub-nodes"
446 )
447 };
448 violations.push(LintViolation {
449 code: LintCode::LongExpressionBody,
450 severity: Severity::Error,
451 subject: path.to_string(),
452 message,
453 line: None,
454 });
455 }
456 }
457 violations
458}
459
460fn ref_stripped_char_count(body: &str) -> (usize, bool) {
465 let spans = ref_spans(body);
466 let has_refs = !spans.is_empty();
467 let mut reduced = String::with_capacity(body.len());
468 let mut cursor = 0;
469 for span in spans {
470 reduced.push_str(&body[cursor..span.start]);
471 reduced.push_str(REF_PLACEHOLDER);
472 cursor = span.end;
473 }
474 reduced.push_str(&body[cursor..]);
475 (reduced.chars().count(), has_refs)
476}
477
478pub fn referenced(graph: &Graph) -> BTreeSet<String> {
479 let mut reached: BTreeSet<String> = BTreeSet::new();
480 for path in graph.paths() {
481 if let Some(expr) = graph.expr(path) {
482 collect_refs(path, expr, &mut reached);
483 }
484 }
485 reached
486}
487
488pub fn entry_points(graph: &Graph) -> Vec<String> {
490 let reached = referenced(graph);
491 graph
492 .paths()
493 .filter(|p| !reached.contains(*p))
494 .filter(|p| !sva_core::RESERVED_VARIABLES.contains(&p.rsplit('/').next().unwrap_or(p)))
495 .map(str::to_string)
496 .collect()
497}
498
499fn unreached(graph: &Graph) -> Vec<Finding> {
501 entry_points(graph)
502 .into_iter()
503 .filter(|p| p != ROOT)
504 .map(|p| Finding {
505 code: LintCode::EntryPoint,
506 severity: Severity::Advice,
507 subject: p.to_string(),
508 message: format!("nothing refs `{p}`, so it is an entry point — or a typo"),
509 line: None,
510 })
511 .collect()
512}
513
514fn collect_refs(from: &str, expr: &Expr, out: &mut BTreeSet<String>) {
515 if let Expr::Ref { path, .. } = expr
516 && let Some(resolved) = resolve_ref_path(from, path)
517 {
518 out.insert(resolved);
519 }
520 for child in children(expr, Binds::Substitute) {
521 collect_refs(from, child, out);
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use std::fs;
529
530 fn dir_of(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
531 let dir =
532 std::env::temp_dir().join(format!("sva-cli-lint-{name}-{:x}", std::process::id()));
533 let _ = fs::remove_dir_all(&dir);
534 fs::create_dir_all(&dir).unwrap();
535 for (rel, content) in files {
536 fs::write(dir.join(rel), content).unwrap();
537 }
538 dir
539 }
540
541 fn doc(models: &str) -> String {
543 format!(
544 "; Models: {models} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
545 fixture\n"
546 )
547 }
548
549 #[test]
552 fn both_lint_response_paths_answer_the_same_diagnostic_shape() {
553 let dir = dir_of(
554 "one-envelope",
555 &[
556 ("master", &(doc("the mix") + "@kick*0.5\n")),
557 ("kick", &(doc("a thump") + "sin(2*pi*50*t)\n")),
558 ("spare", &(doc("a spare") + "sin(2*pi*80*t)\n")),
559 ],
560 );
561 let report = lint(&dir, None).expect("a documented composition lints clean");
562 let found: Vec<Diagnostic> = report.findings.iter().map(Finding::diagnostic).collect();
563 let json = sva_core::success_envelope(
564 &crate::output::lint_data(&dir.display().to_string(), None, report.nodes),
565 &found,
566 );
567 assert!(json.contains("\"diagnostics\""), "{json}");
568 assert!(json.contains("\"code\": \"lint.entry_point\""), "{json}");
569 assert!(json.contains("\"severity\": \"advice\""), "{json}");
570 assert!(
571 json.contains("\"location\": { \"file\": \"spare\", \"span\": null, \"start\": null, \"end\": null }"),
572 "{json}"
573 );
574 assert!(json.contains("\"help\""), "{json}");
575
576 let undocumented = dir_of(
577 "one-envelope-refused",
578 &[("master", "@kick*0.5\n"), ("kick", "sin(t)\n")],
579 );
580 let Err(err) = lint(&undocumented, None) else {
581 panic!("an undocumented node must refuse");
582 };
583 let refused = sva_core::error_envelope(err.code(), &err.message(), &err.diagnostics());
584 assert!(refused.contains("\"diagnostics\""), "{refused}");
585 assert!(
586 refused.contains("\"code\": \"lint.missing_comment\""),
587 "{refused}"
588 );
589 assert!(refused.contains("\"severity\": \"error\""), "{refused}");
590 assert!(
591 refused
592 .contains("\"location\": { \"file\": \"kick\", \"span\": null, \"start\": null, \"end\": null }"),
593 "{refused}"
594 );
595 assert!(refused.contains("\"help\""), "{refused}");
596
597 let _ = fs::remove_dir_all(&dir);
598 let _ = fs::remove_dir_all(&undocumented);
599 }
600
601 fn advised(result: Result<LintReport, CliError>, subject: &str) -> Vec<LintCode> {
604 let report = result.unwrap_or_else(|e| panic!("`{subject}` should lint: {}", e.message()));
605 report
606 .findings
607 .iter()
608 .filter(|f| f.subject == subject)
609 .map(|f| f.code)
610 .collect()
611 }
612
613 fn assert_refused(result: Result<LintReport, CliError>, code: LintCode, subject: &str) {
614 match result {
615 Ok(report) => panic!(
616 "expected a `{code:?}` refusal for `{subject}`, got a clean report: {:?}",
617 report.findings.iter().map(|f| &f.code).collect::<Vec<_>>()
618 ),
619 Err(CliError::LintRefused(violations)) => assert!(
620 violations
621 .iter()
622 .any(|v| v.code == code && v.subject == subject),
623 "expected a `{code:?}` refusal for `{subject}`, got: {:?}",
624 violations
625 .iter()
626 .map(|v| (v.code, &v.subject))
627 .collect::<Vec<_>>()
628 ),
629 Err(other) => panic!("expected a `{code:?}` refusal, got a different error: {other:?}"),
630 }
631 }
632
633 #[test]
634 fn a_loop_the_engine_can_schedule_lints_clean() {
635 let dir = dir_of(
636 "long-loop",
637 &[
638 (
639 "a",
640 &(doc("a fixture signal") + "sin(t) + @b(t - 0.01s)*0.5\n"),
641 ),
642 ("b", &(doc("a fixture signal") + "@a(t - 0.01s)*0.5\n")),
643 ("master", &(doc("a fixture signal") + "@a\n")),
644 ],
645 );
646 assert!(
647 lint(&dir, None).is_ok(),
648 "a schedulable loop is not a finding"
649 );
650 }
651
652 #[test]
655 fn a_target_skips_the_entry_point_check_a_whole_directory_lint_would_raise() {
656 let dir = dir_of(
657 "orphan",
658 &[
659 ("master", &(doc("a fixture signal") + "@drums\n")),
660 ("drums", &(doc("a fixture signal") + "sin(t)\n")),
661 ("orphan", &(doc("a fixture signal") + "sin(t)*0.5\n")),
662 ],
663 );
664 let whole = lint(&dir, None).unwrap();
665 assert!(
666 whole
667 .findings
668 .iter()
669 .any(|f| f.code == LintCode::EntryPoint && f.subject == "orphan"),
670 "a whole-directory lint must flag the unreferenced `orphan`"
671 );
672
673 let targeted = lint(&dir, Some("master")).unwrap();
674 assert!(
675 !targeted
676 .findings
677 .iter()
678 .any(|f| f.code == LintCode::EntryPoint),
679 "a targeted lint must not run the entry-point check at all"
680 );
681 }
682
683 #[test]
686 fn a_grid_with_a_trailing_blank_row_over_its_bar_span_is_flagged() {
687 let dir = dir_of(
688 "trailing-blank-row",
689 &[
690 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
691 (
692 "pattern-4b",
693 &(doc("a fixture pattern") + &("@kick\n".repeat(32) + "\n")),
694 ),
695 ("master", &(doc("a fixture signal") + "@pattern-4b\n")),
696 ],
697 );
698 let whole = lint(&dir, None).unwrap();
699 assert!(
700 whole
701 .findings
702 .iter()
703 .any(|f| f.code == LintCode::GridRowsPerBar && f.subject == "pattern-4b"),
704 "33 rows over 4 bars must be flagged: {:?}",
705 whole.findings.iter().map(|f| &f.code).collect::<Vec<_>>()
706 );
707
708 let targeted = lint(&dir, Some("master")).unwrap();
709 assert!(
710 targeted
711 .findings
712 .iter()
713 .any(|f| f.code == LintCode::GridRowsPerBar),
714 "the target-scoped path must run this check too"
715 );
716 }
717
718 #[test]
719 fn a_grid_whose_rows_tile_its_bar_span_evenly_is_clean() {
720 let dir = dir_of(
721 "clean-grid",
722 &[
723 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
724 (
725 "pattern-4b",
726 &(doc("a fixture pattern") + &"@kick\n".repeat(32)),
727 ),
728 ("master", &(doc("a fixture signal") + "@pattern-4b\n")),
729 ],
730 );
731 assert!(
732 !lint(&dir, None)
733 .unwrap()
734 .findings
735 .iter()
736 .any(|f| f.code == LintCode::GridRowsPerBar),
737 "32 rows over 4 bars is an exact subdivision"
738 );
739 assert!(
740 !lint(&dir, Some("master"))
741 .unwrap()
742 .findings
743 .iter()
744 .any(|f| f.code == LintCode::GridRowsPerBar)
745 );
746 }
747
748 #[test]
749 fn a_grid_spanned_in_seconds_has_no_bar_count_to_check() {
750 let dir = dir_of(
751 "seconds-spanned",
752 &[
753 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
754 (
755 "pattern-2s",
756 &(doc("a fixture pattern") + &("@kick\n".repeat(33) + "\n")),
757 ),
758 ("master", &(doc("a fixture signal") + "@pattern-2s\n")),
759 ],
760 );
761 assert!(
762 !lint(&dir, None)
763 .unwrap()
764 .findings
765 .iter()
766 .any(|f| f.code == LintCode::GridRowsPerBar),
767 "a `-Ns` grid declares no bars, so there is nothing to divide"
768 );
769 }
770
771 type ThresholdCase = (&'static str, std::path::PathBuf, Box<dyn Fn()>);
772
773 #[test]
774 fn every_length_threshold_sits_at_its_boundary_and_trips_one_char_over() {
775 let cases: Vec<ThresholdCase> = vec![
776 (
777 "long-comment-block, 1000 chars",
778 dir_of(
779 "boundary-comment",
780 &[(
781 "master",
782 &(doc("a fixture signal")
783 + "sin(t)\n"
784 + &format!(";{}", "x".repeat(999))
785 + "\n"),
786 )],
787 ),
788 Box::new(|| {
789 let trailing = format!(";{}", "x".repeat(1000));
790 let dir = dir_of(
791 "long-comment",
792 &[
793 (
794 "long",
795 &(doc("a fixture signal") + "sin(t)\n" + &trailing + "\n"),
796 ),
797 ("master", &(doc("a fixture signal") + "@long\n")),
798 ],
799 );
800 assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
801 assert_refused(
802 lint(&dir, Some("master")),
803 LintCode::LongCommentBlock,
804 "long",
805 );
806 }) as Box<dyn Fn()>,
807 ),
808 (
809 "long-expression-body, 10000 chars",
810 dir_of(
811 "boundary-expression-body",
812 &[(
813 "drone",
814 &(String::from(
815 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
816 amplitude | Tags: drone\n",
817 ) + &"0".repeat(10_000)
818 + "\n"),
819 )],
820 ),
821 Box::new(|| {
822 let body = "0".repeat(10_001);
823 let dir = dir_of(
824 "long-expression-body",
825 &[
826 (
827 "drone",
828 &(String::from(
829 "; Models: a sustained drone | Neglects: envelope, detune | \
830 IO: t -> amplitude | Tags: drone\n",
831 ) + &body
832 + "\n"),
833 ),
834 (
835 "master",
836 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
837 ),
838 ],
839 );
840 assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
841 assert_refused(
842 lint(&dir, Some("master")),
843 LintCode::LongExpressionBody,
844 "drone",
845 );
846 }),
847 ),
848 (
849 "tag-shape, 24 chars",
850 dir_of(
851 "tag-at-threshold",
852 &[("master", &(doc_with_tags(&"a".repeat(24)) + "sin(t)\n"))],
853 ),
854 Box::new(|| {
855 let dir = dir_of(
856 "tag-too-long",
857 &[("master", &(doc_with_tags(&"a".repeat(25)) + "sin(t)\n"))],
858 );
859 assert_eq!(
860 advised(lint(&dir, None), "master"),
861 vec![LintCode::TagShape]
862 );
863 }),
864 ),
865 ];
866 for (label, at, over) in cases {
867 assert!(
868 lint(&at, None).is_ok(),
869 "{label}: exactly at the threshold, not over it"
870 );
871 over();
872 }
873 }
874
875 #[test]
877 fn a_long_but_well_formed_leading_doc_comment_is_not_a_long_comment_block() {
878 let filler = "x".repeat(2000);
879 let header = format!(
880 "; Models: {filler} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
881 fixture\n"
882 );
883 let dir = dir_of("long-header-exempt", &[("master", &(header + "sin(t)\n"))]);
884 assert!(
885 lint(&dir, None).is_ok(),
886 "a single well-formed doc-comment line is exempt from the long-comment-block \
887 budget regardless of its own length"
888 );
889 }
890
891 #[test]
893 fn the_line_one_header_annotation_is_excluded_from_its_run() {
894 let header = ";header"; let boundary_line = format!(";{}", "x".repeat(999)); let over_line = format!(";{}", "x".repeat(1000)); let under = dir_of(
899 "header-excluded-under",
900 &[("master", &format!("{header}\n{boundary_line}\nsin(t)\n"))],
901 );
902 let Err(CliError::LintRefused(violations)) = lint(&under, None) else {
903 panic!("a 2-line leading run is also multiline-comment, so this must still refuse")
904 };
905 assert!(
906 !violations
907 .iter()
908 .any(|v| v.code == LintCode::LongCommentBlock),
909 "1007 chars from line 1 is 1000 once the header is excluded: {:?}",
910 violations.iter().map(|v| &v.code).collect::<Vec<_>>()
911 );
912
913 let over = dir_of(
914 "header-excluded-over",
915 &[("master", &format!("{header}\n{over_line}\nsin(t)\n"))],
916 );
917 assert_refused(lint(&over, None), LintCode::LongCommentBlock, "master");
918 }
919
920 const WELL_FORMED_MASTER_COMMENT: &str = "; Models: a test harness's master node | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: fixture\n";
922
923 #[test]
924 fn a_node_with_no_comment_at_all_is_missing_comment_in_both_modes() {
925 let dir = dir_of(
926 "missing-comment",
927 &[
928 ("plucked", "sin(t)\n"),
929 (
930 "master",
931 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
932 ),
933 ],
934 );
935 assert_refused(lint(&dir, None), LintCode::MissingComment, "plucked");
936 assert_refused(
937 lint(&dir, Some("master")),
938 LintCode::MissingComment,
939 "plucked",
940 );
941 }
942
943 #[test]
944 fn two_contiguous_comment_lines_are_multiline_comment_in_both_modes() {
945 let dir = dir_of(
946 "multiline-comment",
947 &[
948 (
949 "stacked",
950 "; Models: a thing\n; Neglects: nothing | IO: t -> out\nsin(t)\n",
951 ),
952 (
953 "master",
954 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@stacked\n"),
955 ),
956 ],
957 );
958 assert_refused(lint(&dir, None), LintCode::MultilineComment, "stacked");
959 assert_refused(
960 lint(&dir, Some("master")),
961 LintCode::MultilineComment,
962 "stacked",
963 );
964 }
965
966 #[test]
967 fn one_correctly_shaped_comment_line_under_the_length_threshold_is_clean() {
968 let dir = dir_of(
969 "well-formed-comment",
970 &[
971 (
972 "plucked",
973 "; Models: a plucked string's fundamental decay | Neglects: pick-position \
974 comb, body coupling | IO: note -> supersaw base | Tags: pluck\nsin(t)\n",
975 ),
976 (
977 "master",
978 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
979 ),
980 ],
981 );
982 assert!(
983 lint(&dir, None).is_ok(),
984 "a single well-formed, short comment must not refuse"
985 );
986 assert!(
987 lint(&dir, Some("master")).is_ok(),
988 "a single well-formed, short comment must not refuse"
989 );
990 }
991
992 #[test]
993 fn a_single_free_text_comment_line_is_malformed_comment_in_both_modes() {
994 let dir = dir_of(
995 "malformed-comment",
996 &[
997 (
998 "plucked",
999 "; a plucked string, decays over time, no body resonance modeled\nsin(t)\n",
1000 ),
1001 (
1002 "master",
1003 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
1004 ),
1005 ],
1006 );
1007 assert_refused(lint(&dir, None), LintCode::MalformedComment, "plucked");
1008 assert_refused(
1009 lint(&dir, Some("master")),
1010 LintCode::MalformedComment,
1011 "plucked",
1012 );
1013 }
1014
1015 #[test]
1019 fn a_grids_own_inline_comment_below_a_well_formed_header_is_not_multiline_comment() {
1020 let dir = dir_of(
1021 "grid-inline-comment",
1022 &[
1023 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
1024 (
1025 "pattern-1b",
1026 "; Models: a kick pattern | Neglects: dynamics, humanization | \
1027 IO: (t) -> amplitude | Tags: kick\n@kick\n; lane\n@kick\n",
1028 ),
1029 (
1030 "master",
1031 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@pattern-1b\n"),
1032 ),
1033 ],
1034 );
1035 assert!(
1036 lint(&dir, None).is_ok(),
1037 "a grid's own inline comment must not be mistaken for a second doc comment"
1038 );
1039 }
1040
1041 #[test]
1044 fn the_leading_doc_comment_is_excluded_from_the_expression_body_count() {
1045 let body = "0".repeat(9_999);
1046 let dir = dir_of(
1047 "doc-comment-excluded",
1048 &[(
1049 "drone",
1050 &(String::from(
1051 "; Models: a sustained drone with a longer than usual doc comment header \
1052 | Neglects: envelope, detune | IO: t -> amplitude | Tags: drone\n",
1053 ) + &body
1054 + "\n"),
1055 )],
1056 );
1057 assert!(
1058 lint(&dir, None).is_ok(),
1059 "the doc comment header must not count toward the body length"
1060 );
1061 }
1062
1063 #[test]
1066 fn a_body_over_budget_only_from_verbose_ref_names_is_clean_once_they_collapse() {
1067 let long_name = "a".repeat(50);
1068 let n = 200; let refs: Vec<String> = std::iter::repeat_n(format!("@{long_name}"), n).collect();
1070 let body = refs.join(" + ");
1071 let raw = body.chars().count();
1072 assert!(
1073 raw > EXPRESSION_BODY_CHARS,
1074 "raw count must be over budget: {raw}"
1075 );
1076
1077 let reduced: usize = n * REF_PLACEHOLDER.chars().count() + (n - 1) * 3;
1078 assert!(
1079 reduced <= EXPRESSION_BODY_CHARS,
1080 "reduced count must be under budget: {reduced}"
1081 );
1082
1083 let dir = dir_of(
1084 "ref-collapse-clean",
1085 &[
1086 (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
1087 (
1088 "drone",
1089 &(String::from(
1090 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1091 amplitude | Tags: drone\n",
1092 ) + &body
1093 + "\n"),
1094 ),
1095 (
1096 "master",
1097 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1098 ),
1099 ],
1100 );
1101 assert!(
1102 lint(&dir, None).is_ok(),
1103 "a body over budget only from a verbose ref name must clear once ref names collapse"
1104 );
1105 }
1106
1107 #[test]
1109 fn a_body_still_over_budget_after_ref_collapse_is_still_flagged() {
1110 let long_name = "b".repeat(50);
1111 let filler = "0".repeat(10_500);
1112 let body = format!("@{long_name} + @{long_name} + {filler}");
1113 let raw = body.chars().count();
1114 assert!(
1115 raw > EXPRESSION_BODY_CHARS,
1116 "raw count must be over budget: {raw}"
1117 );
1118 let reduced = 2 * REF_PLACEHOLDER.chars().count() + 2 * 3 + filler.chars().count();
1119 assert!(
1120 reduced > EXPRESSION_BODY_CHARS,
1121 "reduced count must still be over budget: {reduced}"
1122 );
1123
1124 let dir = dir_of(
1125 "ref-collapse-still-over",
1126 &[
1127 (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
1128 (
1129 "drone",
1130 &(String::from(
1131 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1132 amplitude | Tags: drone\n",
1133 ) + &body
1134 + "\n"),
1135 ),
1136 (
1137 "master",
1138 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1139 ),
1140 ],
1141 );
1142 assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
1143 }
1144
1145 #[test]
1148 fn a_body_with_refs_under_the_raw_cap_is_clean() {
1149 let dir = dir_of(
1150 "under-budget-with-refs",
1151 &[
1152 ("kick", &(doc("a fixture kick") + "sin(t)\n")),
1153 (
1154 "drone",
1155 &(String::from(
1156 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1157 amplitude | Tags: drone\n",
1158 ) + &"@kick + ".repeat(20)
1159 + "0.5\n"),
1160 ),
1161 (
1162 "master",
1163 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1164 ),
1165 ],
1166 );
1167 assert!(
1168 lint(&dir, None).is_ok(),
1169 "this body is well under the raw cap"
1170 );
1171 }
1172
1173 fn doc_with_tags(tags: &str) -> String {
1176 format!(
1177 "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> mix | \
1178 Tags: {tags}\n"
1179 )
1180 }
1181
1182 #[test]
1183 fn a_comment_with_the_old_three_fields_and_no_tags_is_refused_as_missing_a_fourth_field() {
1184 let dir = dir_of(
1185 "old-three-field-comment",
1186 &[(
1187 "master",
1188 "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> \
1189 mix\nsin(t)\n",
1190 )],
1191 );
1192 let Err(CliError::LintRefused(violations)) = lint(&dir, None) else {
1193 panic!("a comment with no `Tags:` field must refuse")
1194 };
1195 let violation = violations
1196 .iter()
1197 .find(|v| v.code == LintCode::MalformedComment && v.subject == "master")
1198 .unwrap_or_else(|| {
1199 panic!(
1200 "expected a malformed-comment refusal, got: {:?}",
1201 violations.iter().map(|v| v.code).collect::<Vec<_>>()
1202 )
1203 });
1204 assert!(
1205 violation.message.contains("four"),
1206 "expected the refusal to name the missing fourth field: {}",
1207 violation.message
1208 );
1209 }
1210
1211 #[test]
1212 fn a_tags_field_with_no_tags_is_refused() {
1213 let dir = dir_of(
1214 "empty-tags-field",
1215 &[("master", &(doc_with_tags("") + "sin(t)\n"))],
1216 );
1217 assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1218 }
1219
1220 #[test]
1221 fn a_tags_field_with_four_tags_is_advised() {
1222 let dir = dir_of(
1223 "four-tags",
1224 &[(
1225 "master",
1226 &(doc_with_tags("piano, sustained-pad, mellow, extra") + "sin(t)\n"),
1227 )],
1228 );
1229 assert_eq!(
1230 advised(lint(&dir, None), "master"),
1231 vec![LintCode::TagShape]
1232 );
1233 }
1234
1235 #[test]
1236 fn a_tag_containing_a_space_is_advised() {
1237 let dir = dir_of(
1238 "tag-with-space",
1239 &[("master", &(doc_with_tags("sustained pad") + "sin(t)\n"))],
1240 );
1241 assert_eq!(
1242 advised(lint(&dir, None), "master"),
1243 vec![LintCode::TagShape]
1244 );
1245 }
1246
1247 #[test]
1248 fn an_empty_tag_between_commas_is_refused() {
1249 let dir = dir_of(
1250 "empty-tag-between-commas",
1251 &[("master", &(doc_with_tags("piano,,pad") + "sin(t)\n"))],
1252 );
1253 assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1254 }
1255
1256 #[test]
1257 fn a_single_valid_tag_is_clean() {
1258 let dir = dir_of(
1259 "one-valid-tag",
1260 &[("master", &(doc_with_tags("piano") + "sin(t)\n"))],
1261 );
1262 assert!(
1263 lint(&dir, None).is_ok(),
1264 "a single valid tag is not a finding"
1265 );
1266 }
1267
1268 #[test]
1269 fn three_valid_comma_separated_tags_are_clean() {
1270 let dir = dir_of(
1271 "three-valid-tags",
1272 &[(
1273 "master",
1274 &(doc_with_tags("piano, sustained-pad, mellow") + "sin(t)\n"),
1275 )],
1276 );
1277 assert!(
1278 lint(&dir, None).is_ok(),
1279 "three valid comma-separated tags are not a finding"
1280 );
1281 }
1282
1283 #[test]
1287 fn a_well_formed_tagged_header_does_not_exempt_a_later_long_comment_block() {
1288 let trailing = format!(";{}", "x".repeat(1000)); let dir = dir_of(
1290 "tagged-header-long-block",
1291 &[
1292 (
1293 "long",
1294 &(doc_with_tags("fixture") + "sin(t)\n" + &trailing + "\n"),
1295 ),
1296 ("master", &(doc_with_tags("fixture") + "@long\n")),
1297 ],
1298 );
1299 assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
1300 }
1301}