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