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