1use std::collections::BTreeMap;
4use std::path::Path;
5
6use sva_ast::parse_doc_comment;
7use sva_ast::{Graph, Source, ref_spans};
8use sva_core::{CliError, Job, LintCode, LintViolation, QuietTail, Severity, lint_diagnostic};
9use sva_core::{Diagnostic, prepared, refuse_unresolved_bars, settled};
10use sva_engine::QUIET_LEVEL;
11
12pub struct Finding {
14 pub code: LintCode,
15 pub severity: Severity,
16 pub subject: String,
17 pub message: String,
18 pub line: Option<usize>,
20}
21
22impl Finding {
23 pub fn diagnostic(&self) -> Diagnostic {
24 lint_diagnostic(
25 self.code,
26 &self.subject,
27 &self.message,
28 self.severity,
29 self.line,
30 )
31 }
32}
33
34pub struct LintReport {
35 pub nodes: usize,
36 pub findings: Vec<Finding>,
37 pub interval: Option<(f64, f64)>,
39}
40
41pub fn lint(dir: &Path, target: Option<&str>) -> Result<LintReport, CliError> {
45 let source = sva_ast::Dir::at(dir);
46 match target {
47 None => lint_files(&source, prepared(&source)?),
48 Some(target) => lint_reaching(&source, target),
49 }
50}
51
52fn lint_files(source: &dyn Source, graph: Graph) -> Result<LintReport, CliError> {
55 refuse_unresolved_bars(&graph)?;
56 let mut violations = lint_violations(source, &graph);
57 let mut tails = Vec::new();
58 for root in crate::trace::entry_points(&graph) {
59 let target = format!("@{root}");
60 let job = Job::over(source, &target);
61 if violations.is_empty() {
62 sva_core::types(&job)?;
63 }
64 if let Ok(found) = sva_core::quiet_tails(&job) {
65 tails.extend(found);
66 }
67 }
68 violations.extend(quiet_tail(tails));
69 verdict(graph.paths().count(), violations, per_file(&graph), None)
70}
71
72fn per_file(graph: &Graph) -> Vec<Finding> {
74 let mut findings = grid_row_counts(graph);
75 findings.extend(crate::variables::key_findings(graph));
76 findings
77}
78
79fn verdict(
81 nodes: usize,
82 violations: Vec<LintViolation>,
83 findings: Vec<Finding>,
84 interval: Option<(f64, f64)>,
85) -> Result<LintReport, CliError> {
86 if violations.is_empty() {
87 return Ok(LintReport {
88 nodes,
89 findings,
90 interval,
91 });
92 }
93 let mut every = violations;
94 every.extend(findings.into_iter().map(|f| LintViolation {
95 code: f.code,
96 severity: f.severity,
97 subject: f.subject,
98 message: f.message,
99 line: f.line,
100 }));
101 Err(CliError::LintRefused(every))
102}
103
104fn lint_reaching(source: &dyn Source, target: &str) -> Result<LintReport, CliError> {
107 let expr = sva_core::target(target)?.expr;
108 let held = sva_core::roots_of(source, &expr)?;
109 let roots: Vec<&str> = held.iter().map(String::as_str).collect();
110 let graph = settled(sva_ast::load_reaching(source, &roots))?;
111 refuse_unresolved_bars(&graph)?;
112 let mut violations = lint_violations(source, &graph);
113 let mut interval = None;
114 if violations.is_empty() {
115 let job = Job::over(source, target);
116 let render = sva_core::plan(&job)?;
117 let rate = f64::from(render.config.rate);
118 interval = render
119 .range
120 .map(|r| (r.start_secs(render.config.rate), r.end as f64 / rate));
121 violations.extend(quiet_tail(sva_core::quiet_tails(&job)?));
122 }
123 verdict(
124 graph.paths().count(),
125 violations,
126 per_file(&graph),
127 interval,
128 )
129}
130
131fn quiet_tail(tails: Vec<QuietTail>) -> Vec<LintViolation> {
134 let mut instances: BTreeMap<String, QuietTail> = BTreeMap::new();
135 for tail in tails {
136 match instances.get_mut(&tail.instance) {
137 Some(held) => held.runs_to = held.runs_to.max(tail.runs_to),
138 None => {
139 instances.insert(tail.instance.clone(), tail);
140 }
141 }
142 }
143 let mut files: BTreeMap<String, Vec<QuietTail>> = BTreeMap::new();
144 for tail in instances.into_values() {
145 files.entry(tail.file.clone()).or_default().push(tail);
146 }
147 let secs = |t: f64| format!("{}s", (t * 1000.0).ceil() / 1000.0);
148 let db = 20.0 * QUIET_LEVEL.log10();
149 files
150 .into_iter()
151 .map(|(file, held)| {
152 let from = held.iter().map(|t| t.quiet_from).fold(0.0, f64::max);
153 let to = held.iter().map(|t| t.runs_to).fold(0.0, f64::max);
154 let runs = match to.is_finite() {
155 true => format!("its extent runs to {}", secs(to)),
156 false => "its extent never ends".to_string(),
157 };
158 let count = match held.len() {
159 1 => String::new(),
160 n => format!(" in each of its {n} instances"),
161 };
162 LintViolation {
163 code: LintCode::QuietTail,
164 severity: Severity::Error,
165 subject: file.clone(),
166 message: format!(
167 "`{file}` is under {db:.1} dBFS from {} on{count}, yet {runs}",
168 secs(from)
169 ),
170 line: None,
171 }
172 })
173 .collect()
174}
175
176fn grid_row_counts(graph: &Graph) -> Vec<Finding> {
178 let mut findings = Vec::new();
179 for path in graph.paths() {
180 let Some(grid) = graph.grid(path) else {
181 continue;
182 };
183 let Some(bars) = grid.bar_span.filter(|b| *b > 0.0) else {
184 continue;
185 };
186 let rows_per_bar = grid.row_count as f64 / bars;
187 let bars_per_row = bars / grid.row_count as f64;
188 let whole = |v: f64| (v - v.round()).abs() <= 1e-9;
189 if !whole(rows_per_bar) && !whole(bars_per_row) {
190 findings.push(Finding {
191 code: LintCode::GridRowsPerBar,
192 severity: Severity::Warning,
193 subject: path.to_string(),
194 message: format!(
195 "`{path}` has {} rows over {bars} bar(s), {rows_per_bar:.4} rows/bar and \
196 {bars_per_row:.4} bars/row — neither a whole subdivision of a bar nor a \
197 whole number of bars a row, likely a stray or missing row",
198 grid.row_count
199 ),
200 line: None,
201 });
202 }
203 }
204 findings
205}
206
207fn lint_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
210 let mut violations = Vec::new();
211 violations.extend(doc_comment_violations(source, graph));
212 violations.extend(long_comment_block_violations(source, graph));
213 violations.extend(long_expression_body_violations(source, graph));
214 violations.extend(crate::rates::rate_violations(graph));
215 violations.extend(crate::arity::arity_violations(graph));
216 violations
217}
218
219const LONG_COMMENT_BLOCK_CHARS: usize = 1000;
221
222fn long_comment_block_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
224 let mut violations = Vec::new();
225 for path in graph.paths() {
226 let Ok(Some(text)) = source.get(path) else {
227 continue;
228 };
229 let mut run: Option<(usize, usize, usize)> = None;
231 for (idx, line) in text.lines().enumerate() {
232 if line.trim_start().starts_with(';') {
233 let line_chars = line.chars().count();
234 run = Some(match run {
235 Some((start, chars, first)) => (start, chars + line_chars, first),
236 None => (idx + 1, line_chars, line_chars),
237 });
238 } else {
239 flush_comment_run(run.take(), path, &mut violations);
240 }
241 }
242 flush_comment_run(run, path, &mut violations);
243 }
244 violations
245}
246
247fn flush_comment_run(
249 run: Option<(usize, usize, usize)>,
250 path: &str,
251 violations: &mut Vec<LintViolation>,
252) {
253 let Some((start, chars, first)) = run else {
254 return;
255 };
256 let chars = if start == 1 {
257 chars.saturating_sub(first)
258 } else {
259 chars
260 };
261 if chars > LONG_COMMENT_BLOCK_CHARS {
262 violations.push(LintViolation {
263 code: LintCode::LongCommentBlock,
264 severity: Severity::Error,
265 subject: path.to_string(),
266 message: format!(
267 "`{path}` has a {chars}-character comment block, over the \
268 {LONG_COMMENT_BLOCK_CHARS}-character threshold — split it or trim it"
269 ),
270 line: Some(start),
271 });
272 }
273}
274
275fn doc_comment_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
278 let mut violations = Vec::new();
279 for path in graph.paths() {
280 let Ok(Some(text)) = source.get(path) else {
281 continue;
282 };
283 let header: Vec<&str> = text
284 .lines()
285 .take_while(|line| line.trim_start().starts_with(';'))
286 .collect();
287 match header.len() {
288 0 => violations.push(LintViolation {
289 code: LintCode::MissingComment,
290 severity: Severity::Error,
291 subject: path.to_string(),
292 message: format!(
293 "`{path}` has no `;`-comment — every composition node must carry one `; \
294 Models: ... | Neglects: ... | IO: ... -> ... | Tags: ...` line \
295 documenting it"
296 ),
297 line: None,
298 }),
299 1 => {
300 if let Err(reason) = parse_doc_comment(header[0]) {
301 violations.push(LintViolation {
302 code: LintCode::MalformedComment,
303 severity: Severity::Error,
304 subject: path.to_string(),
305 message: format!(
306 "`{path}`'s comment does not match `Models: ... | Neglects: ... \
307 | IO: ... -> ... | Tags: ...` ({reason})"
308 ),
309 line: None,
310 });
311 }
312 }
313 n => violations.push(LintViolation {
314 code: LintCode::MultilineComment,
315 severity: Severity::Error,
316 subject: path.to_string(),
317 message: format!(
318 "`{path}` has {n} `;`-comment lines — exactly one is required, written \
319 denser instead of split across lines"
320 ),
321 line: None,
322 }),
323 }
324 }
325 violations
326}
327
328const EXPRESSION_BODY_CHARS: usize = 10_000;
331
332const REF_PLACEHOLDER: &str = "@x";
334
335fn long_expression_body_violations(source: &dyn Source, graph: &Graph) -> Vec<LintViolation> {
338 let mut violations = Vec::new();
339 for path in graph.paths() {
340 let Ok(Some(text)) = source.get(path) else {
341 continue;
342 };
343 let body: String = text
344 .lines()
345 .skip_while(|line| line.trim_start().starts_with(';'))
346 .collect::<Vec<_>>()
347 .join("\n");
348 let chars = body.chars().count();
349 if chars <= EXPRESSION_BODY_CHARS {
350 continue;
351 }
352 let (chars, has_refs) = ref_stripped_char_count(&body);
353 if chars > EXPRESSION_BODY_CHARS {
354 let message = if has_refs {
355 format!(
356 "`{path}` has a {chars}-character expression body even with every `@ref` \
357 name collapsed to `{REF_PLACEHOLDER}`, over the \
358 {EXPRESSION_BODY_CHARS}-character threshold — decompose it into sub-nodes"
359 )
360 } else {
361 format!(
362 "`{path}` has a {chars}-character expression body, over the \
363 {EXPRESSION_BODY_CHARS}-character threshold — decompose it into sub-nodes"
364 )
365 };
366 violations.push(LintViolation {
367 code: LintCode::LongExpressionBody,
368 severity: Severity::Error,
369 subject: path.to_string(),
370 message,
371 line: None,
372 });
373 }
374 }
375 violations
376}
377
378fn ref_stripped_char_count(body: &str) -> (usize, bool) {
383 let spans = ref_spans(body);
384 let has_refs = !spans.is_empty();
385 let mut reduced = String::with_capacity(body.len());
386 let mut cursor = 0;
387 for span in spans {
388 reduced.push_str(&body[cursor..span.start]);
389 reduced.push_str(REF_PLACEHOLDER);
390 cursor = span.end;
391 }
392 reduced.push_str(&body[cursor..]);
393 (reduced.chars().count(), has_refs)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use std::fs;
400
401 fn dir_of(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
402 let dir =
403 std::env::temp_dir().join(format!("sva-cli-lint-{name}-{:x}", std::process::id()));
404 let _ = fs::remove_dir_all(&dir);
405 fs::create_dir_all(&dir).unwrap();
406 for (rel, content) in files {
407 let path = dir.join(rel);
408 fs::create_dir_all(path.parent().unwrap()).unwrap();
409 fs::write(path, content).unwrap();
410 }
411 dir
412 }
413
414 fn doc(models: &str) -> String {
416 format!(
417 "; Models: {models} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
418 fixture\n"
419 )
420 }
421
422 #[test]
425 fn both_lint_response_paths_answer_the_same_diagnostic_shape() {
426 let dir = dir_of(
427 "one-envelope",
428 &[
429 ("master", &(doc("the mix") + "@kick*0.5\n")),
430 ("kick", &(doc("a thump") + "sin(2*pi*50*t)\n")),
431 ("variables/key", &(doc("the key") + "sin(t)\n")),
432 ],
433 );
434 let report = lint(&dir, None).expect("a documented composition lints clean");
435 let found: Vec<Diagnostic> = report.findings.iter().map(Finding::diagnostic).collect();
436 let json = sva_core::success_envelope(
437 &crate::output::lint_data(&dir.display().to_string(), None, report.nodes, None),
438 &found,
439 );
440 assert!(json.contains("\"diagnostics\""), "{json}");
441 assert!(
442 json.contains("\"code\": \"lint.key_is_not_a_pitch\""),
443 "{json}"
444 );
445 assert!(json.contains("\"severity\": \"warning\""), "{json}");
446 assert!(
447 json.contains("\"location\": { \"file\": \"variables/key\", \"span\": null, \"start\": null, \"end\": null }"),
448 "{json}"
449 );
450 assert!(json.contains("\"help\""), "{json}");
451
452 let undocumented = dir_of(
453 "one-envelope-refused",
454 &[("master", "@kick*0.5\n"), ("kick", "sin(t)\n")],
455 );
456 let Err(err) = lint(&undocumented, None) else {
457 panic!("an undocumented node must refuse");
458 };
459 let refused = sva_core::error_envelope(err.code(), &err.message(), &err.diagnostics());
460 assert!(refused.contains("\"diagnostics\""), "{refused}");
461 assert!(
462 refused.contains("\"code\": \"lint.missing_comment\""),
463 "{refused}"
464 );
465 assert!(refused.contains("\"severity\": \"error\""), "{refused}");
466 assert!(
467 refused
468 .contains("\"location\": { \"file\": \"kick\", \"span\": null, \"start\": null, \"end\": null }"),
469 "{refused}"
470 );
471 assert!(refused.contains("\"help\""), "{refused}");
472
473 let _ = fs::remove_dir_all(&dir);
474 let _ = fs::remove_dir_all(&undocumented);
475 }
476
477 fn assert_refused(result: Result<LintReport, CliError>, code: LintCode, subject: &str) {
478 match result {
479 Ok(report) => panic!(
480 "expected a `{code:?}` refusal for `{subject}`, got a clean report: {:?}",
481 report.findings.iter().map(|f| &f.code).collect::<Vec<_>>()
482 ),
483 Err(CliError::LintRefused(violations)) => assert!(
484 violations
485 .iter()
486 .any(|v| v.code == code && v.subject == subject),
487 "expected a `{code:?}` refusal for `{subject}`, got: {:?}",
488 violations
489 .iter()
490 .map(|v| (v.code, &v.subject))
491 .collect::<Vec<_>>()
492 ),
493 Err(other) => panic!("expected a `{code:?}` refusal, got a different error: {other:?}"),
494 }
495 }
496
497 #[test]
498 fn a_loop_the_engine_can_schedule_lints_clean() {
499 let dir = dir_of(
500 "long-loop",
501 &[
502 (
503 "a",
504 &(doc("a fixture signal") + "sin(t) + @b(t - 0.01s)*0.5\n"),
505 ),
506 ("b", &(doc("a fixture signal") + "@a(t - 0.01s)*0.5\n")),
507 ("master", &(doc("a fixture signal") + "@a\n")),
508 ],
509 );
510 assert!(
511 lint(&dir, None).is_ok(),
512 "a schedulable loop is not a finding"
513 );
514 }
515
516 #[test]
518 fn plain_lint_refuses_the_type_refusal_a_targeted_lint_does() {
519 let body = "crop(self(t - 1sp) + sample(sin(2*pi*100*t))*1sp, 0s, 1s)\n";
520 let dir = dir_of("typed", &[("master", &(doc("a discrete loop") + body))]);
521 for target in [None, Some("@master")] {
522 let Err(err) = lint(&dir, target) else {
523 panic!("{target:?}: `self(t - d)` in a discrete loop must refuse");
524 };
525 let codes: Vec<String> = err.diagnostics().into_iter().map(|d| d.code).collect();
526 assert!(
527 codes.iter().any(|c| c == "type.discrete_self_at_time"),
528 "{target:?}: {codes:?}"
529 );
530 }
531 let _ = fs::remove_dir_all(&dir);
532 }
533
534 #[test]
537 fn a_grid_with_a_trailing_blank_row_over_its_bar_span_is_flagged() {
538 let dir = dir_of(
539 "trailing-blank-row",
540 &[
541 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
542 (
543 "pattern-4b",
544 &(doc("a fixture pattern") + &("@kick\n".repeat(32) + "\n")),
545 ),
546 ("master", &(doc("a fixture signal") + "@pattern-4b\n")),
547 ],
548 );
549 let whole = lint(&dir, None).unwrap();
550 assert!(
551 whole
552 .findings
553 .iter()
554 .any(|f| f.code == LintCode::GridRowsPerBar && f.subject == "pattern-4b"),
555 "33 rows over 4 bars must be flagged: {:?}",
556 whole.findings.iter().map(|f| &f.code).collect::<Vec<_>>()
557 );
558
559 let targeted = lint(&dir, Some("@master([0, 1s])")).unwrap();
560 assert!(
561 targeted
562 .findings
563 .iter()
564 .any(|f| f.code == LintCode::GridRowsPerBar),
565 "the target-scoped path must run this check too"
566 );
567 }
568
569 #[test]
570 fn a_grid_whose_rows_tile_its_bar_span_evenly_is_clean() {
571 let dir = dir_of(
572 "clean-grid",
573 &[
574 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
575 (
576 "pattern-4b",
577 &(doc("a fixture pattern") + &"@kick\n".repeat(32)),
578 ),
579 ("master", &(doc("a fixture signal") + "@pattern-4b\n")),
580 ],
581 );
582 assert!(
583 !lint(&dir, None)
584 .unwrap()
585 .findings
586 .iter()
587 .any(|f| f.code == LintCode::GridRowsPerBar),
588 "32 rows over 4 bars is an exact subdivision"
589 );
590 assert!(
591 !lint(&dir, Some("@master([0, 1s])"))
592 .unwrap()
593 .findings
594 .iter()
595 .any(|f| f.code == LintCode::GridRowsPerBar)
596 );
597 }
598
599 #[test]
600 fn a_grid_spanned_in_seconds_has_no_bar_count_to_check() {
601 let dir = dir_of(
602 "seconds-spanned",
603 &[
604 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
605 (
606 "pattern-2s",
607 &(doc("a fixture pattern") + &("@kick\n".repeat(33) + "\n")),
608 ),
609 ("master", &(doc("a fixture signal") + "@pattern-2s\n")),
610 ],
611 );
612 assert!(
613 !lint(&dir, None)
614 .unwrap()
615 .findings
616 .iter()
617 .any(|f| f.code == LintCode::GridRowsPerBar),
618 "a `-Ns` grid declares no bars, so there is nothing to divide"
619 );
620 }
621
622 type ThresholdCase = (&'static str, std::path::PathBuf, Box<dyn Fn()>);
623
624 #[test]
625 fn every_length_threshold_sits_at_its_boundary_and_trips_one_char_over() {
626 let cases: Vec<ThresholdCase> = vec![
627 (
628 "long-comment-block, 1000 chars",
629 dir_of(
630 "boundary-comment",
631 &[(
632 "master",
633 &(doc("a fixture signal")
634 + "sin(t)\n"
635 + &format!(";{}", "x".repeat(999))
636 + "\n"),
637 )],
638 ),
639 Box::new(|| {
640 let trailing = format!(";{}", "x".repeat(1000));
641 let dir = dir_of(
642 "long-comment",
643 &[
644 (
645 "long",
646 &(doc("a fixture signal") + "sin(t)\n" + &trailing + "\n"),
647 ),
648 ("master", &(doc("a fixture signal") + "@long\n")),
649 ],
650 );
651 assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
652 assert_refused(
653 lint(&dir, Some("@master([0, 1s])")),
654 LintCode::LongCommentBlock,
655 "long",
656 );
657 }) as Box<dyn Fn()>,
658 ),
659 (
660 "long-expression-body, 10000 chars",
661 dir_of(
662 "boundary-expression-body",
663 &[(
664 "drone",
665 &(String::from(
666 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
667 amplitude | Tags: drone\n",
668 ) + &"0".repeat(10_000)
669 + "\n"),
670 )],
671 ),
672 Box::new(|| {
673 let body = "0".repeat(10_001);
674 let dir = dir_of(
675 "long-expression-body",
676 &[
677 (
678 "drone",
679 &(String::from(
680 "; Models: a sustained drone | Neglects: envelope, detune | \
681 IO: t -> amplitude | Tags: drone\n",
682 ) + &body
683 + "\n"),
684 ),
685 (
686 "master",
687 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
688 ),
689 ],
690 );
691 assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
692 assert_refused(
693 lint(&dir, Some("@master([0, 1s])")),
694 LintCode::LongExpressionBody,
695 "drone",
696 );
697 }),
698 ),
699 ];
700 for (label, at, over) in cases {
701 assert!(
702 lint(&at, None).is_ok(),
703 "{label}: exactly at the threshold, not over it"
704 );
705 over();
706 }
707 }
708
709 #[test]
711 fn a_long_but_well_formed_leading_doc_comment_is_not_a_long_comment_block() {
712 let filler = "x".repeat(2000);
713 let header = format!(
714 "; Models: {filler} | Neglects: nothing, it's a fixture | IO: t -> mix | Tags: \
715 fixture\n"
716 );
717 let dir = dir_of("long-header-exempt", &[("master", &(header + "sin(t)\n"))]);
718 assert!(
719 lint(&dir, None).is_ok(),
720 "a single well-formed doc-comment line is exempt from the long-comment-block \
721 budget regardless of its own length"
722 );
723 }
724
725 #[test]
727 fn the_line_one_header_annotation_is_excluded_from_its_run() {
728 let header = ";header"; let boundary_line = format!(";{}", "x".repeat(999)); let over_line = format!(";{}", "x".repeat(1000)); let under = dir_of(
733 "header-excluded-under",
734 &[("master", &format!("{header}\n{boundary_line}\nsin(t)\n"))],
735 );
736 let Err(CliError::LintRefused(violations)) = lint(&under, None) else {
737 panic!("a 2-line leading run is also multiline-comment, so this must still refuse")
738 };
739 assert!(
740 !violations
741 .iter()
742 .any(|v| v.code == LintCode::LongCommentBlock),
743 "1007 chars from line 1 is 1000 once the header is excluded: {:?}",
744 violations.iter().map(|v| &v.code).collect::<Vec<_>>()
745 );
746
747 let over = dir_of(
748 "header-excluded-over",
749 &[("master", &format!("{header}\n{over_line}\nsin(t)\n"))],
750 );
751 assert_refused(lint(&over, None), LintCode::LongCommentBlock, "master");
752 }
753
754 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";
756
757 #[test]
758 fn a_node_with_no_comment_at_all_is_missing_comment_in_both_modes() {
759 let dir = dir_of(
760 "missing-comment",
761 &[
762 ("plucked", "sin(t)\n"),
763 (
764 "master",
765 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
766 ),
767 ],
768 );
769 assert_refused(lint(&dir, None), LintCode::MissingComment, "plucked");
770 assert_refused(
771 lint(&dir, Some("@master([0, 1s])")),
772 LintCode::MissingComment,
773 "plucked",
774 );
775 }
776
777 #[test]
778 fn two_contiguous_comment_lines_are_multiline_comment_in_both_modes() {
779 let dir = dir_of(
780 "multiline-comment",
781 &[
782 (
783 "stacked",
784 "; Models: a thing\n; Neglects: nothing | IO: t -> out\nsin(t)\n",
785 ),
786 (
787 "master",
788 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@stacked\n"),
789 ),
790 ],
791 );
792 assert_refused(lint(&dir, None), LintCode::MultilineComment, "stacked");
793 assert_refused(
794 lint(&dir, Some("@master([0, 1s])")),
795 LintCode::MultilineComment,
796 "stacked",
797 );
798 }
799
800 #[test]
801 fn one_correctly_shaped_comment_line_under_the_length_threshold_is_clean() {
802 let dir = dir_of(
803 "well-formed-comment",
804 &[
805 (
806 "plucked",
807 "; Models: a plucked string's fundamental decay | Neglects: pick-position \
808 comb, body coupling | IO: note -> supersaw base | Tags: pluck\nsin(t)\n",
809 ),
810 (
811 "master",
812 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
813 ),
814 ],
815 );
816 assert!(
817 lint(&dir, None).is_ok(),
818 "a single well-formed, short comment must not refuse"
819 );
820 assert!(
821 lint(&dir, Some("@master([0, 1s])")).is_ok(),
822 "a single well-formed, short comment must not refuse"
823 );
824 }
825
826 #[test]
827 fn a_single_free_text_comment_line_is_malformed_comment_in_both_modes() {
828 let dir = dir_of(
829 "malformed-comment",
830 &[
831 (
832 "plucked",
833 "; a plucked string, decays over time, no body resonance modeled\nsin(t)\n",
834 ),
835 (
836 "master",
837 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@plucked\n"),
838 ),
839 ],
840 );
841 assert_refused(lint(&dir, None), LintCode::MalformedComment, "plucked");
842 assert_refused(
843 lint(&dir, Some("@master([0, 1s])")),
844 LintCode::MalformedComment,
845 "plucked",
846 );
847 }
848
849 #[test]
853 fn a_grids_own_inline_comment_below_a_well_formed_header_is_not_multiline_comment() {
854 let dir = dir_of(
855 "grid-inline-comment",
856 &[
857 ("kick", &(doc("a fixture kick") + "sin(2*pi*50*t)\n")),
858 (
859 "pattern-1b",
860 "; Models: a kick pattern | Neglects: dynamics, humanization | \
861 IO: (t) -> amplitude | Tags: kick\n@kick\n; lane\n@kick\n",
862 ),
863 (
864 "master",
865 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@pattern-1b\n"),
866 ),
867 ],
868 );
869 assert!(
870 lint(&dir, None).is_ok(),
871 "a grid's own inline comment must not be mistaken for a second doc comment"
872 );
873 }
874
875 #[test]
878 fn the_leading_doc_comment_is_excluded_from_the_expression_body_count() {
879 let body = "0".repeat(9_999);
880 let dir = dir_of(
881 "doc-comment-excluded",
882 &[(
883 "drone",
884 &(String::from(
885 "; Models: a sustained drone with a longer than usual doc comment header \
886 | Neglects: envelope, detune | IO: t -> amplitude | Tags: drone\n",
887 ) + &body
888 + "\n"),
889 )],
890 );
891 assert!(
892 lint(&dir, None).is_ok(),
893 "the doc comment header must not count toward the body length"
894 );
895 }
896
897 #[test]
900 fn a_body_over_budget_only_from_verbose_ref_names_is_clean_once_they_collapse() {
901 let long_name = "a".repeat(50);
902 let n = 200; let refs: Vec<String> = std::iter::repeat_n(format!("@{long_name}"), n).collect();
904 let body = refs.join(" + ");
905 let raw = body.chars().count();
906 assert!(
907 raw > EXPRESSION_BODY_CHARS,
908 "raw count must be over budget: {raw}"
909 );
910
911 let reduced: usize = n * REF_PLACEHOLDER.chars().count() + (n - 1) * 3;
912 assert!(
913 reduced <= EXPRESSION_BODY_CHARS,
914 "reduced count must be under budget: {reduced}"
915 );
916
917 let dir = dir_of(
918 "ref-collapse-clean",
919 &[
920 (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
921 (
922 "drone",
923 &(String::from(
924 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
925 amplitude | Tags: drone\n",
926 ) + &body
927 + "\n"),
928 ),
929 (
930 "master",
931 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
932 ),
933 ],
934 );
935 assert!(
936 lint(&dir, None).is_ok(),
937 "a body over budget only from a verbose ref name must clear once ref names collapse"
938 );
939 }
940
941 #[test]
943 fn a_body_still_over_budget_after_ref_collapse_is_still_flagged() {
944 let long_name = "b".repeat(50);
945 let filler = "0".repeat(10_500);
946 let body = format!("@{long_name} + @{long_name} + {filler}");
947 let raw = body.chars().count();
948 assert!(
949 raw > EXPRESSION_BODY_CHARS,
950 "raw count must be over budget: {raw}"
951 );
952 let reduced = 2 * REF_PLACEHOLDER.chars().count() + 2 * 3 + filler.chars().count();
953 assert!(
954 reduced > EXPRESSION_BODY_CHARS,
955 "reduced count must still be over budget: {reduced}"
956 );
957
958 let dir = dir_of(
959 "ref-collapse-still-over",
960 &[
961 (&long_name, &(doc("a fixture ref target") + "sin(t)\n")),
962 (
963 "drone",
964 &(String::from(
965 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
966 amplitude | Tags: drone\n",
967 ) + &body
968 + "\n"),
969 ),
970 (
971 "master",
972 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
973 ),
974 ],
975 );
976 assert_refused(lint(&dir, None), LintCode::LongExpressionBody, "drone");
977 }
978
979 #[test]
982 fn a_body_with_refs_under_the_raw_cap_is_clean() {
983 let dir = dir_of(
984 "under-budget-with-refs",
985 &[
986 ("kick", &(doc("a fixture kick") + "sin(t)\n")),
987 (
988 "drone",
989 &(String::from(
990 "; Models: a sustained drone | Neglects: envelope, detune | IO: t -> \
991 amplitude | Tags: drone\n",
992 ) + &"@kick + ".repeat(20)
993 + "0.5\n"),
994 ),
995 (
996 "master",
997 &(String::from(WELL_FORMED_MASTER_COMMENT) + "@drone\n"),
998 ),
999 ],
1000 );
1001 assert!(
1002 lint(&dir, None).is_ok(),
1003 "this body is well under the raw cap"
1004 );
1005 }
1006
1007 fn doc_with_tags(tags: &str) -> String {
1010 format!(
1011 "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> mix | \
1012 Tags: {tags}\n"
1013 )
1014 }
1015
1016 #[test]
1017 fn a_comment_with_the_old_three_fields_and_no_tags_is_refused_as_missing_a_fourth_field() {
1018 let dir = dir_of(
1019 "old-three-field-comment",
1020 &[(
1021 "master",
1022 "; Models: a fixture signal | Neglects: nothing, it's a fixture | IO: t -> \
1023 mix\nsin(t)\n",
1024 )],
1025 );
1026 let Err(CliError::LintRefused(violations)) = lint(&dir, None) else {
1027 panic!("a comment with no `Tags:` field must refuse")
1028 };
1029 let violation = violations
1030 .iter()
1031 .find(|v| v.code == LintCode::MalformedComment && v.subject == "master")
1032 .unwrap_or_else(|| {
1033 panic!(
1034 "expected a malformed-comment refusal, got: {:?}",
1035 violations.iter().map(|v| v.code).collect::<Vec<_>>()
1036 )
1037 });
1038 assert!(
1039 violation.message.contains("four"),
1040 "expected the refusal to name the missing fourth field: {}",
1041 violation.message
1042 );
1043 }
1044
1045 #[test]
1046 fn a_tags_field_with_no_tags_is_refused() {
1047 let dir = dir_of(
1048 "empty-tags-field",
1049 &[("master", &(doc_with_tags("") + "sin(t)\n"))],
1050 );
1051 assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1052 }
1053
1054 #[test]
1055 fn an_empty_tag_between_commas_is_refused() {
1056 let dir = dir_of(
1057 "empty-tag-between-commas",
1058 &[("master", &(doc_with_tags("piano,,pad") + "sin(t)\n"))],
1059 );
1060 assert_refused(lint(&dir, None), LintCode::MalformedComment, "master");
1061 }
1062
1063 #[test]
1064 fn a_single_valid_tag_is_clean() {
1065 let dir = dir_of(
1066 "one-valid-tag",
1067 &[("master", &(doc_with_tags("piano") + "sin(t)\n"))],
1068 );
1069 assert!(
1070 lint(&dir, None).is_ok(),
1071 "a single valid tag is not a finding"
1072 );
1073 }
1074
1075 #[test]
1076 fn three_valid_comma_separated_tags_are_clean() {
1077 let dir = dir_of(
1078 "three-valid-tags",
1079 &[(
1080 "master",
1081 &(doc_with_tags("piano, sustained-pad, mellow") + "sin(t)\n"),
1082 )],
1083 );
1084 assert!(
1085 lint(&dir, None).is_ok(),
1086 "three valid comma-separated tags are not a finding"
1087 );
1088 }
1089
1090 #[test]
1094 fn a_well_formed_tagged_header_does_not_exempt_a_later_long_comment_block() {
1095 let trailing = format!(";{}", "x".repeat(1000)); let dir = dir_of(
1097 "tagged-header-long-block",
1098 &[
1099 (
1100 "long",
1101 &(doc_with_tags("fixture") + "sin(t)\n" + &trailing + "\n"),
1102 ),
1103 ("master", &(doc_with_tags("fixture") + "@long\n")),
1104 ],
1105 );
1106 assert_refused(lint(&dir, None), LintCode::LongCommentBlock, "long");
1107 }
1108}