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