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