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 #[test]
773 fn a_comment_block_over_a_thousand_chars_is_flagged_in_both_modes() {
774 let trailing = format!(";{}", "x".repeat(1000)); let dir = dir_of(
776 "long-comment",
777 &[
778 (
779 "long",
780 &(doc("a fixture signal") + "sin(t)\n" + &trailing + "\n"),
781 ),
782 ("master", &(doc("a fixture signal") + "@long\n")),
783 ],
784 );
785 assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
786 assert_refused(
787 lint(&dir, Some("master")),
788 LintCode::LongCommentBlock,
789 "long",
790 );
791 }
792
793 #[test]
794 fn a_comment_block_of_exactly_a_thousand_chars_sits_at_the_threshold_not_over_it() {
795 let trailing = format!(";{}", "x".repeat(999)); let dir = dir_of(
797 "boundary-comment",
798 &[(
799 "master",
800 &(doc("a fixture signal") + "sin(t)\n" + &trailing + "\n"),
801 )],
802 );
803 assert!(
804 lint(&dir, None).is_ok(),
805 "exactly 1000 chars is the threshold itself, not over it"
806 );
807 }
808
809 #[test]
811 fn a_long_but_well_formed_leading_doc_comment_is_not_a_long_comment_block() {
812 let filler = "x".repeat(2000);
813 let header = format!(
814 "; Models: {filler} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
815 fixture\n"
816 );
817 let dir = dir_of("long-header-exempt", &[("master", &(header + "sin(t)\n"))]);
818 assert!(
819 lint(&dir, None).is_ok(),
820 "a single well-formed doc-comment line is exempt from the long-comment-block \
821 budget regardless of its own length"
822 );
823 }
824
825 #[test]
827 fn the_line_one_header_annotation_is_excluded_from_its_run() {
828 let header = ";header"; let boundary_line = format!(";{}", "x".repeat(999)); let over_line = format!(";{}", "x".repeat(1000)); let under = dir_of(
833 "header-excluded-under",
834 &[("master", &format!("{header}\n{boundary_line}\nsin(t)\n"))],
835 );
836 let Err(CliError::LintRefused(violations)) = lint(&under, None) else {
837 panic!("a 2-line leading run is also multiline-comment, so this must still refuse")
838 };
839 assert!(
840 !violations
841 .iter()
842 .any(|v| v.code == LintCode::LongCommentBlock),
843 "1007 chars from line 1 is 1000 once the header is excluded: {:?}",
844 violations.iter().map(|v| &v.code).collect::<Vec<_>>()
845 );
846
847 let over = dir_of(
848 "header-excluded-over",
849 &[("master", &format!("{header}\n{over_line}\nsin(t)\n"))],
850 );
851 assert_refused(lint(&over, None), LintCode::LongCommentBlock, "master");
852 }
853
854 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";
856
857 #[test]
858 fn a_node_with_no_comment_at_all_is_missing_comment_in_both_modes() {
859 let dir = dir_of(
860 "missing-comment",
861 &[
862 ("plucked", "sin(t)\n"),
863 (
864 "master",
865 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
866 ),
867 ],
868 );
869 assert_refused(lint(&dir, None), LintCode::MissingComment, "plucked");
870 assert_refused(
871 lint(&dir, Some("master")),
872 LintCode::MissingComment,
873 "plucked",
874 );
875 }
876
877 #[test]
878 fn two_contiguous_comment_lines_are_multiline_comment_in_both_modes() {
879 let dir = dir_of(
880 "multiline-comment",
881 &[
882 (
883 "stacked",
884 "; Models: a thing\n; Neglects: nothing | IO: t -> out\nsin(t)\n",
885 ),
886 (
887 "master",
888 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@stacked\n"),
889 ),
890 ],
891 );
892 assert_refused(lint(&dir, None), LintCode::MultilineComment, "stacked");
893 assert_refused(
894 lint(&dir, Some("master")),
895 LintCode::MultilineComment,
896 "stacked",
897 );
898 }
899
900 #[test]
901 fn one_correctly_shaped_comment_line_under_the_length_threshold_is_clean() {
902 let dir = dir_of(
903 "well-formed-comment",
904 &[
905 (
906 "plucked",
907 "; Models: a plucked string's fundamental decay | Neglects: pick-position \
908 comb, body coupling | IO: note -> supersaw base | Tags: pluck\nsin(t)\n",
909 ),
910 (
911 "master",
912 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
913 ),
914 ],
915 );
916 assert!(
917 lint(&dir, None).is_ok(),
918 "a single well-formed, short comment must not refuse"
919 );
920 assert!(
921 lint(&dir, Some("master")).is_ok(),
922 "a single well-formed, short comment must not refuse"
923 );
924 }
925
926 #[test]
927 fn a_single_free_text_comment_line_is_malformed_comment_in_both_modes() {
928 let dir = dir_of(
929 "malformed-comment",
930 &[
931 (
932 "plucked",
933 "; a plucked string, decays over time, no body resonance modeled\nsin(t)\n",
934 ),
935 (
936 "master",
937 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
938 ),
939 ],
940 );
941 assert_refused(lint(&dir, None), LintCode::MalformedComment, "plucked");
942 assert_refused(
943 lint(&dir, Some("master")),
944 LintCode::MalformedComment,
945 "plucked",
946 );
947 }
948
949 #[test]
953 fn a_grids_own_inline_comment_below_a_well_formed_header_is_not_multiline_comment() {
954 let dir = dir_of(
955 "grid-inline-comment",
956 &[
957 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
958 (
959 "pattern-1b",
960 "; Models: a kick pattern | Neglects: dynamics, humanization | \
961 IO: (t) -> amplitude | Tags: kick\n@kick\n; lane\n@kick\n",
962 ),
963 (
964 "master",
965 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@pattern-1b\n"),
966 ),
967 ],
968 );
969 assert!(
970 lint(&dir, None).is_ok(),
971 "a grid's own inline comment must not be mistaken for a second doc comment"
972 );
973 }
974
975 #[test]
976 fn an_expression_body_over_ten_thousand_chars_is_flagged_in_both_modes() {
977 let body = "0".repeat(10_001);
978 let dir = dir_of(
979 "long-expression-body",
980 &[
981 (
982 "drone",
983 &(String::from(
984 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
985 amplitude | Tags: drone\n",
986 ) + &body
987 + "\n"),
988 ),
989 (
990 "master",
991 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
992 ),
993 ],
994 );
995 assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
996 assert_refused(
997 lint(&dir, Some("master")),
998 LintCode::LongExpressionBody,
999 "drone",
1000 );
1001 }
1002
1003 #[test]
1004 fn an_expression_body_of_exactly_ten_thousand_chars_sits_at_the_threshold_not_over_it() {
1005 let body = "0".repeat(10_000);
1006 let dir = dir_of(
1007 "boundary-expression-body",
1008 &[(
1009 "drone",
1010 &(String::from(
1011 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1012 amplitude | Tags: drone\n",
1013 ) + &body
1014 + "\n"),
1015 )],
1016 );
1017 assert!(
1018 lint(&dir, None).is_ok(),
1019 "exactly 10000 chars is the threshold itself, not over it"
1020 );
1021 }
1022
1023 #[test]
1026 fn the_leading_doc_comment_is_excluded_from_the_expression_body_count() {
1027 let body = "0".repeat(9_999);
1028 let dir = dir_of(
1029 "doc-comment-excluded",
1030 &[(
1031 "drone",
1032 &(String::from(
1033 "; Models: a sustained drone with a longer than usual doc comment header \
1034 | Neglects: envelope, detune | IO: t -> amplitude | Tags: drone\n",
1035 ) + &body
1036 + "\n"),
1037 )],
1038 );
1039 assert!(
1040 lint(&dir, None).is_ok(),
1041 "the doc comment header must not count toward the body length"
1042 );
1043 }
1044
1045 #[test]
1048 fn a_body_over_budget_only_from_verbose_ref_names_is_clean_once_they_collapse() {
1049 let long_name = "a".repeat(50);
1050 let n = 200; let refs: Vec<String> = std::iter::repeat_n(format!("@{long_name}"), n).collect();
1052 let body = refs.join(" + ");
1053 let raw = body.chars().count();
1054 assert!(
1055 raw > EXPRESSION_BODY_CHARS,
1056 "raw count must be over budget: {raw}"
1057 );
1058
1059 let reduced: usize = n * REF_PLACEHOLDER.chars().count() + (n - 1) * 3;
1060 assert!(
1061 reduced <= EXPRESSION_BODY_CHARS,
1062 "reduced count must be under budget: {reduced}"
1063 );
1064
1065 let dir = dir_of(
1066 "ref-collapse-clean",
1067 &[
1068 (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
1069 (
1070 "drone",
1071 &(String::from(
1072 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1073 amplitude | Tags: drone\n",
1074 ) + &body
1075 + "\n"),
1076 ),
1077 (
1078 "master",
1079 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1080 ),
1081 ],
1082 );
1083 assert!(
1084 lint(&dir, None).is_ok(),
1085 "a body over budget only from a verbose ref name must clear once ref names collapse"
1086 );
1087 }
1088
1089 #[test]
1091 fn a_body_still_over_budget_after_ref_collapse_is_still_flagged() {
1092 let long_name = "b".repeat(50);
1093 let filler = "0".repeat(10_500);
1094 let body = format!("@{long_name} + @{long_name} + {filler}");
1095 let raw = body.chars().count();
1096 assert!(
1097 raw > EXPRESSION_BODY_CHARS,
1098 "raw count must be over budget: {raw}"
1099 );
1100 let reduced = 2 * REF_PLACEHOLDER.chars().count() + 2 * 3 + filler.chars().count();
1101 assert!(
1102 reduced > EXPRESSION_BODY_CHARS,
1103 "reduced count must still be over budget: {reduced}"
1104 );
1105
1106 let dir = dir_of(
1107 "ref-collapse-still-over",
1108 &[
1109 (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
1110 (
1111 "drone",
1112 &(String::from(
1113 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1114 amplitude | Tags: drone\n",
1115 ) + &body
1116 + "\n"),
1117 ),
1118 (
1119 "master",
1120 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1121 ),
1122 ],
1123 );
1124 assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
1125 }
1126
1127 #[test]
1130 fn a_body_with_refs_under_the_raw_cap_is_clean() {
1131 let dir = dir_of(
1132 "under-budget-with-refs",
1133 &[
1134 ("kick", &(doc("a fixture kick") + "sin(t)\n")),
1135 (
1136 "drone",
1137 &(String::from(
1138 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
1139 amplitude | Tags: drone\n",
1140 ) + &"@kick + ".repeat(20)
1141 + "0.5\n"),
1142 ),
1143 (
1144 "master",
1145 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
1146 ),
1147 ],
1148 );
1149 assert!(
1150 lint(&dir, None).is_ok(),
1151 "this body is well under the raw cap"
1152 );
1153 }
1154
1155 fn doc_with_tags(tags: &str) -> String {
1158 format!(
1159 "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> mix | \
1160 Tags: {tags}\n"
1161 )
1162 }
1163
1164 #[test]
1165 fn a_comment_with_the_old_three_fields_and_no_tags_is_refused_as_missing_a_fourth_field() {
1166 let dir = dir_of(
1167 "old-three-field-comment",
1168 &[(
1169 "master",
1170 "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> \
1171 mix\nsin(t)\n",
1172 )],
1173 );
1174 let Err(CliError::LintRefused(violations)) = lint(&dir, None) else {
1175 panic!("a comment with no `Tags:` field must refuse")
1176 };
1177 let violation = violations
1178 .iter()
1179 .find(|v| v.code == LintCode::MalformedComment && v.subject == "master")
1180 .unwrap_or_else(|| {
1181 panic!(
1182 "expected a malformed-comment refusal, got: {:?}",
1183 violations.iter().map(|v| v.code).collect::<Vec<_>>()
1184 )
1185 });
1186 assert!(
1187 violation.message.contains("four"),
1188 "expected the refusal to name the missing fourth field: {}",
1189 violation.message
1190 );
1191 }
1192
1193 #[test]
1194 fn a_tags_field_with_no_tags_is_refused() {
1195 let dir = dir_of(
1196 "empty-tags-field",
1197 &[("master", &(doc_with_tags("") + "sin(t)\n"))],
1198 );
1199 assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1200 }
1201
1202 #[test]
1203 fn a_tags_field_with_four_tags_is_advised() {
1204 let dir = dir_of(
1205 "four-tags",
1206 &[(
1207 "master",
1208 &(doc_with_tags("piano, sustained-pad, mellow, extra") + "sin(t)\n"),
1209 )],
1210 );
1211 assert_eq!(
1212 advised(lint(&dir, None), "master"),
1213 vec![LintCode::TagShape]
1214 );
1215 }
1216
1217 #[test]
1218 fn a_tag_containing_a_space_is_advised() {
1219 let dir = dir_of(
1220 "tag-with-space",
1221 &[("master", &(doc_with_tags("sustained pad") + "sin(t)\n"))],
1222 );
1223 assert_eq!(
1224 advised(lint(&dir, None), "master"),
1225 vec![LintCode::TagShape]
1226 );
1227 }
1228
1229 #[test]
1230 fn a_tag_over_twenty_four_characters_is_advised() {
1231 let long_tag = "a".repeat(25);
1232 let dir = dir_of(
1233 "tag-too-long",
1234 &[("master", &(doc_with_tags(&long_tag) + "sin(t)\n"))],
1235 );
1236 assert_eq!(
1237 advised(lint(&dir, None), "master"),
1238 vec![LintCode::TagShape]
1239 );
1240 }
1241
1242 #[test]
1243 fn a_tag_of_exactly_twenty_four_characters_sits_at_the_threshold_not_over_it() {
1244 let boundary_tag = "a".repeat(24);
1245 let dir = dir_of(
1246 "tag-at-threshold",
1247 &[("master", &(doc_with_tags(&boundary_tag) + "sin(t)\n"))],
1248 );
1249 assert!(
1250 lint(&dir, None).is_ok(),
1251 "exactly 24 characters is the threshold itself, not over it"
1252 );
1253 }
1254
1255 #[test]
1256 fn an_empty_tag_between_commas_is_refused() {
1257 let dir = dir_of(
1258 "empty-tag-between-commas",
1259 &[("master", &(doc_with_tags("piano,,pad") + "sin(t)\n"))],
1260 );
1261 assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1262 }
1263
1264 #[test]
1265 fn a_single_valid_tag_is_clean() {
1266 let dir = dir_of(
1267 "one-valid-tag",
1268 &[("master", &(doc_with_tags("piano") + "sin(t)\n"))],
1269 );
1270 assert!(
1271 lint(&dir, None).is_ok(),
1272 "a single valid tag is not a finding"
1273 );
1274 }
1275
1276 #[test]
1277 fn three_valid_comma_separated_tags_are_clean() {
1278 let dir = dir_of(
1279 "three-valid-tags",
1280 &[(
1281 "master",
1282 &(doc_with_tags("piano, sustained-pad, mellow") + "sin(t)\n"),
1283 )],
1284 );
1285 assert!(
1286 lint(&dir, None).is_ok(),
1287 "three valid comma-separated tags are not a finding"
1288 );
1289 }
1290
1291 #[test]
1295 fn a_well_formed_tagged_header_does_not_exempt_a_later_long_comment_block() {
1296 let trailing = format!(";{}", "x".repeat(1000)); let dir = dir_of(
1298 "tagged-header-long-block",
1299 &[
1300 (
1301 "long",
1302 &(doc_with_tags("fixture") + "sin(t)\n" + &trailing + "\n"),
1303 ),
1304 ("master", &(doc_with_tags("fixture") + "@long\n")),
1305 ],
1306 );
1307 assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
1308 }
1309}