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