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