1use crate::linter::{Lint, LintPolicy, Linter};
2use foundry_common::{
3 comments::{
4 Comments,
5 inline_config::{InlineConfig, InlineConfigItem},
6 },
7 errors::convert_solar_errors,
8 sh_warn,
9};
10use foundry_compilers::{ProjectPathsConfig, solc::SolcLanguage};
11use foundry_config::{
12 DenyLevel,
13 lint::{LintSpecificConfig, Severity},
14};
15use solar::{
16 ast,
17 interface::{
18 ColorChoice, Session,
19 diagnostics::{HumanEmitter, JsonEmitter, Level, SilentEmitter},
20 },
21 sema::Compiler,
22};
23use solar_lint::{LintRegistry, LintRunContext, LintRunError, LintSource, LintSuite, run_lints};
24use std::{
25 collections::HashSet,
26 path::{Path, PathBuf},
27 sync::{Arc, LazyLock},
28};
29use thiserror::Error;
30
31#[macro_use]
32pub mod macros;
33
34pub mod analysis;
35pub mod codesize;
36pub mod gas;
37pub mod high;
38pub mod info;
39pub mod low;
40pub mod med;
41pub mod naming;
42
43fn all_lints() -> impl Iterator<Item = &'static SolLint> {
45 [
46 high::REGISTERED_LINTS,
47 med::REGISTERED_LINTS,
48 low::REGISTERED_LINTS,
49 info::REGISTERED_LINTS,
50 gas::REGISTERED_LINTS,
51 codesize::REGISTERED_LINTS,
52 ]
53 .into_iter()
54 .flatten()
55}
56
57static ALL_REGISTERED_LINTS: LazyLock<Vec<&'static str>> =
58 LazyLock::new(|| all_lints().map(|lint| lint.id).collect());
59
60static DEFAULT_LINT_SPECIFIC_CONFIG: LazyLock<LintSpecificConfig> =
61 LazyLock::new(LintSpecificConfig::default);
62
63struct OwnedLintPolicy {
64 inline: Option<Arc<InlineConfig<Vec<String>>>>,
65 active: Arc<Vec<&'static str>>,
66 sources: Option<Arc<Vec<SourceLintPolicy>>>,
67}
68
69struct SourceLintPolicy {
70 file: Arc<solar::interface::source_map::SourceFile>,
71 inline: Arc<InlineConfig<Vec<String>>>,
72 active: Vec<&'static str>,
73}
74
75impl LintPolicy for OwnedLintPolicy {
76 fn is_lint_enabled(&self, id: &str) -> bool {
77 self.active.contains(&id)
78 }
79
80 fn is_lint_suppressed(&self, id: &str, span: solar::interface::Span) -> bool {
81 if !span.is_dummy()
82 && let Some(sources) = &self.sources
83 {
84 let source = sources
87 .partition_point(|source| source.file.start_pos <= span.lo())
88 .checked_sub(1)
89 .map(|idx| &sources[idx])
90 .filter(|source| source.file.contains(span.lo()));
91 return source.is_none_or(|source| {
92 !source.active.contains(&id) || source.inline.is_id_disabled(span, id)
93 });
94 }
95 self.inline.as_ref().is_some_and(|inline| inline.is_id_disabled(span, id))
96 }
97}
98
99#[derive(Clone)]
101pub struct ForgeLintSuite {
102 path_config: ProjectPathsConfig,
103 severity: Option<Vec<Severity>>,
104 lints_included: Option<Vec<SolLint>>,
105 lints_excluded: Option<Vec<SolLint>>,
106 registry: Arc<LintRegistry>,
107 sources: Option<Arc<Vec<SourceLintPolicy>>>,
108 run_active: Option<Arc<Vec<&'static str>>>,
109}
110
111impl std::fmt::Debug for ForgeLintSuite {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.debug_struct("ForgeLintSuite")
114 .field("path_config", &self.path_config)
115 .field("severity", &self.severity)
116 .field("lints_included", &self.lints_included)
117 .field("lints_excluded", &self.lints_excluded)
118 .finish_non_exhaustive()
119 }
120}
121
122impl ForgeLintSuite {
123 fn include_lint(&self, lint: SolLint) -> bool {
124 self.severity.as_ref().is_none_or(|sev| sev.contains(&lint.severity()))
125 && self.lints_included.as_ref().is_none_or(|incl| incl.contains(&lint))
126 && self.lints_excluded.as_ref().is_none_or(|excl| !excl.contains(&lint))
127 }
128
129 fn active_lints(&self, path: Option<&Path>) -> Vec<&'static str> {
130 all_lints()
131 .filter(|lint| {
132 self.include_lint(**lint)
133 && path.is_none_or(|path| {
134 !self.path_config.is_test_or_script(path)
135 || matches!(
136 lint.id,
137 "unsafe-cheatcode" | "environment-read-across-mutation"
138 )
139 })
140 })
141 .map(|lint| lint.id)
142 .collect()
143 }
144}
145
146impl LintSuite for ForgeLintSuite {
147 fn registry(&self) -> &LintRegistry {
148 &self.registry
149 }
150
151 fn source_policy(&self, source: LintSource<'_, '_>) -> Arc<dyn LintPolicy> {
152 let inline = self
153 .sources
154 .as_ref()
155 .and_then(|sources| {
156 sources
157 .binary_search_by_key(&source.file.start_pos, |source| source.file.start_pos)
158 .ok()
159 .map(|idx| sources[idx].inline.clone())
160 })
161 .unwrap_or_else(|| {
162 let comments =
163 Comments::new(source.file, source.session.source_map(), false, false, None);
164 Arc::new(parse_inline_config(source.session, &comments, source.ast))
165 });
166 Arc::new(OwnedLintPolicy {
167 inline: Some(inline),
168 active: self
169 .run_active
170 .clone()
171 .unwrap_or_else(|| Arc::new(self.active_lints(Some(source.path)))),
172 sources: self.sources.clone(),
173 })
174 }
175
176 fn project_policy(&self) -> Arc<dyn LintPolicy> {
177 Arc::new(OwnedLintPolicy {
178 inline: None,
179 active: Arc::new(self.active_lints(None)),
180 sources: None,
181 })
182 }
183}
184
185#[derive(Debug)]
188pub struct SolidityLinter<'a> {
189 path_config: ProjectPathsConfig,
190 severity: Option<Vec<Severity>>,
191 lints_included: Option<Vec<SolLint>>,
192 lints_excluded: Option<Vec<SolLint>>,
193 with_description: bool,
194 with_json_emitter: bool,
195 json_emitter_stdout: bool,
196 report_unused_suppressions: bool,
197 lint_specific: &'a LintSpecificConfig,
199}
200
201impl<'a> SolidityLinter<'a> {
202 pub fn new(path_config: ProjectPathsConfig) -> Self {
203 Self {
204 path_config,
205 with_description: true,
206 severity: None,
207 lints_included: None,
208 lints_excluded: None,
209 with_json_emitter: false,
210 json_emitter_stdout: false,
211 report_unused_suppressions: false,
212 lint_specific: &DEFAULT_LINT_SPECIFIC_CONFIG,
213 }
214 }
215
216 pub fn with_severity(mut self, severity: Option<Vec<Severity>>) -> Self {
217 self.severity = severity;
218 self
219 }
220
221 pub fn with_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
222 self.lints_included = lints;
223 self
224 }
225
226 pub fn without_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
227 self.lints_excluded = lints;
228 self
229 }
230
231 pub const fn with_description(mut self, with: bool) -> Self {
232 self.with_description = with;
233 self
234 }
235
236 pub const fn with_json_emitter(mut self, with: bool) -> Self {
237 self.with_json_emitter = with;
238 self
239 }
240
241 pub const fn with_json_emitter_stdout(mut self, with: bool) -> Self {
242 self.json_emitter_stdout = with;
243 self
244 }
245
246 pub const fn with_report_unused_suppressions(mut self, with: bool) -> Self {
247 self.report_unused_suppressions = with;
248 self
249 }
250
251 pub const fn with_lint_specific(mut self, lint_specific: &'a LintSpecificConfig) -> Self {
252 self.lint_specific = lint_specific;
253 self
254 }
255
256 pub fn to_suite(&self) -> ForgeLintSuite {
258 let lint_specific = Arc::new(self.lint_specific.clone());
259 let mut registry = LintRegistry::new();
260 high::register_lints(&mut registry, &lint_specific);
261 med::register_lints(&mut registry, &lint_specific);
262 low::register_lints(&mut registry, &lint_specific);
263 info::register_lints(&mut registry, &lint_specific);
264 gas::register_lints(&mut registry, &lint_specific);
265 codesize::register_lints(&mut registry, &lint_specific);
266
267 ForgeLintSuite {
268 path_config: self.path_config.clone(),
269 severity: self.severity.clone(),
270 lints_included: self.lints_included.clone(),
271 lints_excluded: self.lints_excluded.clone(),
272 registry: Arc::new(registry),
273 sources: None,
274 run_active: None,
275 }
276 }
277}
278
279impl<'a> Linter for SolidityLinter<'a> {
280 type Language = SolcLanguage;
281 type Lint = SolLint;
282
283 fn lint(
284 &self,
285 input: &[PathBuf],
286 deny: DenyLevel,
287 compiler: &mut Compiler,
288 ) -> eyre::Result<()> {
289 convert_solar_errors(compiler.dcx())?;
290
291 let mut warn_count_before = compiler.dcx().warn_count();
293 let mut note_count_before = compiler.dcx().note_count();
294
295 let ui_testing = std::env::var_os("FOUNDRY_LINT_UI_TESTING").is_some();
296
297 let sm = compiler.sess().clone_source_map();
298 let prev_emitter = compiler.dcx().set_emitter(if self.with_json_emitter {
299 let writer: Box<dyn std::io::Write + Send> = if self.json_emitter_stdout && !ui_testing
300 {
301 Box::new(std::io::BufWriter::new(std::io::stdout()))
302 } else {
303 Box::new(std::io::BufWriter::new(std::io::stderr()))
304 };
305 let json_emitter = JsonEmitter::new(writer, sm, ColorChoice::Never)
306 .rustc_like(true)
307 .ui_testing(ui_testing);
308 Box::new(json_emitter)
309 } else {
310 Box::new(HumanEmitter::stderr(Default::default()).source_map(Some(sm)))
311 });
312 let sess = compiler.sess_mut();
313 sess.dcx.set_flags_mut(|f| f.track_diagnostics = false);
314 if ui_testing {
315 sess.opts.unstable.ui_testing = true;
316 sess.reconfigure();
317 }
318
319 compiler.enter_mut(|compiler| -> eyre::Result<()> {
320 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
321 let _ = compiler.lower_asts();
322 }
323 convert_solar_errors(compiler.dcx())?;
324 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Analysis) {
325 let prev_emitter =
328 compiler.dcx().set_emitter(Box::new(SilentEmitter::new_boxed(None)));
329 let _ = compiler.analysis();
330 compiler.dcx().set_emitter(prev_emitter);
331 }
332 warn_count_before = compiler.dcx().warn_count();
333 note_count_before = compiler.dcx().note_count();
334
335 let gcx = compiler.gcx();
336 let mut targets = Vec::with_capacity(input.len());
337 let mut seen_sources = HashSet::new();
338 for path in input {
339 let path = self.path_config.root.join(path);
340 let Some((_, source)) = gcx.get_ast_source(&path) else {
341 _ = sh_warn!("AST source not found for {}", path.display());
344 continue;
345 };
346 if seen_sources.insert(source.file.start_pos) {
347 targets.push(path);
348 }
349 }
350
351 let mut suite = self.to_suite();
352 let mut sources = targets
353 .iter()
354 .map(|path| {
355 let (_, source) =
356 gcx.get_ast_source(path).expect("lint target was validated above");
357 let ast = source.ast.as_ref().expect("lint target AST was validated above");
358 let comments =
359 Comments::new(&source.file, gcx.sess.source_map(), false, false, None);
360 SourceLintPolicy {
361 file: source.file.clone(),
362 inline: Arc::new(parse_inline_config(gcx.sess, &comments, ast)),
363 active: suite.active_lints(Some(path)),
364 }
365 })
366 .collect::<Vec<_>>();
367 sources.sort_unstable_by_key(|source| source.file.start_pos);
368 suite.run_active = Some(Arc::new(
369 suite
370 .active_lints(None)
371 .into_iter()
372 .filter(|id| sources.iter().any(|source| source.active.contains(id)))
373 .collect(),
374 ));
375 suite.sources = Some(Arc::new(sources));
376 run_lints(
377 &suite,
378 LintRunContext {
379 gcx,
380 targets: &targets,
381 with_description: self.with_description,
382 with_ansi_help: !self.with_json_emitter,
383 },
384 )
385 .unwrap_or_else(|error| match error {
386 LintRunError::MissingAstSource(path) => {
387 unreachable!("prevalidated AST source missing for {}", path.display())
388 }
389 LintRunError::MissingAst(path) => {
390 panic!("AST missing for {}", path.display())
391 }
392 LintRunError::MissingHir(path) => {
393 panic!("HIR source not found for {}", path.display())
394 }
395 error => panic!("lint run failed: {error}"),
396 });
397
398 if self.report_unused_suppressions
399 && let Some(sources) = &suite.sources
400 {
401 for source in sources.iter() {
402 for (span, id) in source.inline.unused_suppressions(&source.active) {
403 gcx.sess
404 .dcx
405 .warn(format!("unused lint suppression for '{id}'"))
406 .span(span)
407 .emit();
408 }
409 }
410 }
411
412 Ok(())
413 })?;
414
415 let sess = compiler.sess_mut();
416 sess.dcx.set_emitter(prev_emitter);
417 if ui_testing {
418 sess.opts.unstable.ui_testing = false;
419 sess.reconfigure();
420 }
421
422 let lint_warn_count = compiler.dcx().warn_count().saturating_sub(warn_count_before);
423 let lint_note_count = compiler.dcx().note_count().saturating_sub(note_count_before);
424
425 let (w, n) = (lint_warn_count, lint_note_count);
426 let denied = match deny {
427 DenyLevel::Warnings if w > 0 && n > 0 => {
428 format!("{w} linter warning(s); {n} note(s) were also emitted")
429 }
430 DenyLevel::Warnings if w > 0 => format!("{w} linter warning(s)"),
431 DenyLevel::Notes if w > 0 && n > 0 => format!("{w} linter warning(s) and {n} note(s)"),
432 DenyLevel::Notes if w > 0 => format!("{w} linter warning(s)"),
433 DenyLevel::Notes if n > 0 => format!("{n} linter note(s)"),
434 _ => return Ok(()),
435 };
436 Err(DeniedLintDiagnostics(format!(
437 "aborting due to {denied}
438"
439 ))
440 .into())
441 }
442}
443
444fn parse_inline_config<'ast>(
445 sess: &Session,
446 comments: &Comments,
447 ast: &'ast ast::SourceUnit<'ast>,
448) -> InlineConfig<Vec<String>> {
449 let items = comments.iter().filter_map(|comment| {
450 let mut item = comment.lines.first()?.as_str();
451 if let Some(prefix) = comment.prefix() {
452 item = item.strip_prefix(prefix).unwrap_or(item);
453 }
454 if let Some(suffix) = comment.suffix() {
455 item = item.strip_suffix(suffix).unwrap_or(item);
456 }
457 let item = item.trim_start().strip_prefix("forge-lint:")?.trim();
458 let span = comment.span;
459 match InlineConfigItem::parse(item, &ALL_REGISTERED_LINTS) {
460 Ok(item) => Some((span, item)),
461 Err(e) => {
462 sess.dcx.warn(e.to_string()).span(span).emit();
463 None
464 }
465 }
466 });
467
468 InlineConfig::from_ast(items, ast, sess.source_map())
469}
470
471#[derive(Error, Debug)]
472pub enum SolLintError {
473 #[error("Unknown lint ID: {0}")]
474 InvalidId(String),
475}
476
477#[derive(Error, Debug)]
478#[error("{0}")]
479pub struct DeniedLintDiagnostics(String);
480
481#[derive(Debug, Clone, Copy, Eq, PartialEq)]
482pub struct SolLint {
483 id: &'static str,
484 description: &'static str,
485 help: &'static str,
486 severity: Severity,
487}
488
489impl SolLint {
490 pub const fn severity(self) -> Severity {
491 self.severity
492 }
493}
494
495impl Lint for SolLint {
496 fn id(&self) -> &'static str {
497 self.id
498 }
499 fn level(&self) -> Level {
500 self.severity.into()
501 }
502 fn description(&self) -> &'static str {
503 self.description
504 }
505 fn help(&self) -> &'static str {
506 self.help
507 }
508}
509
510impl<'a> TryFrom<&'a str> for SolLint {
511 type Error = SolLintError;
512
513 fn try_from(value: &'a str) -> Result<Self, Self::Error> {
514 all_lints()
515 .find(|lint| lint.id == value)
516 .copied()
517 .ok_or_else(|| SolLintError::InvalidId(value.to_string()))
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::{Severity, all_lints};
526 use crate::linter::Lint;
527 use eyre::{Result, ensure};
528 use std::{collections::BTreeSet, fs, path::Path};
529
530 #[test]
531 fn registered_lints_have_docs() {
532 let docs = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
533 let mut registered = BTreeSet::new();
534 let mut errors = Vec::new();
535 for lint in all_lints() {
536 assert!(registered.insert(lint.id().to_owned()), "duplicate lint ID: {}", lint.id());
537 let path = docs.join(format!("{}.md", lint.id()));
538 let result = fs::read_to_string(&path)
539 .map_err(eyre::Report::from)
540 .and_then(|text| validate_doc(&text, lint.id(), lint.severity()));
541 if let Err(error) = result {
542 errors.push(format!("{}: {error}", path.display()));
543 }
544 }
545 assert!(!registered.is_empty(), "no registered lints");
546 let documented = fs::read_dir(&docs)
547 .unwrap()
548 .map(|entry| entry.unwrap().path())
549 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
550 .map(|path| path.file_stem().unwrap().to_str().unwrap().to_owned())
551 .filter(|id| !matches!(id.as_str(), "README" | "_template"))
552 .collect::<BTreeSet<_>>();
553 for id in documented.difference(®istered) {
554 errors.push(format!("{id}.md: no registered lint"));
555 }
556 assert!(errors.is_empty(), "invalid lint documentation:\n{}", errors.join("\n"));
557 }
558
559 #[test]
560 fn registered_lints_have_canonical_help_url() {
561 for lint in all_lints() {
562 let expected = format!("https://getfoundry.sh/forge/linting/{}", lint.id());
563 assert_eq!(lint.help(), expected, "lint `{}` has a non-canonical help URL", lint.id());
564 }
565 }
566
567 #[derive(Debug, PartialEq)]
568 enum Kind<'a> {
569 Heading(usize),
570 Text,
571 Code(&'a str),
572 }
573
574 #[derive(Debug)]
575 struct Token<'a> {
576 kind: Kind<'a>,
577 text: String,
578 line: usize,
579 }
580
581 fn fence(line: &str) -> Option<(u8, usize, &str)> {
582 let trimmed = line.trim_start_matches(' ');
583 if line.len() - trimmed.len() > 3 {
584 return None;
585 }
586 let marker = *trimmed.as_bytes().first()?;
587 if !matches!(marker, b'`' | b'~') {
588 return None;
589 }
590 let len = trimmed.bytes().take_while(|&byte| byte == marker).count();
591 (len >= 3).then(|| (marker, len, trimmed[len..].trim()))
592 }
593
594 fn tokens(text: &str) -> Result<Vec<Token<'_>>> {
596 let mut result = Vec::new();
597 let mut lines = text.lines().enumerate();
598 while let Some((index, line)) = lines.next() {
599 let (kind, text) = if let Some((marker, len, language)) = fence(line) {
600 let mut code = String::new();
601 let mut closed = false;
602 for (_, line) in lines.by_ref() {
603 if fence(line).is_some_and(|(end, width, tail)| {
604 end == marker && width >= len && tail.is_empty()
605 }) {
606 closed = true;
607 break;
608 }
609 code.push_str(line);
610 code.push('\n');
611 }
612 ensure!(closed, "line {}: unclosed code fence", index + 1);
613 (Kind::Code(language), code)
614 } else {
615 let level = line.bytes().take_while(|&byte| byte == b'#').count();
616 if (1..=6).contains(&level) && line[level..].starts_with(' ') {
617 (Kind::Heading(level), line[level..].trim().to_owned())
618 } else if line.trim().is_empty() {
619 continue;
620 } else {
621 (Kind::Text, line.trim().to_owned())
622 }
623 };
624 result.push(Token { kind, text, line: index + 1 });
625 }
626 Ok(result)
627 }
628
629 fn validate_doc(text: &str, id: &str, severity: Severity) -> Result<()> {
630 ensure!(
631 id.starts_with(|c: char| c.is_ascii_lowercase())
632 && id.split('-').all(|part| !part.is_empty()
633 && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())),
634 "lint ID must be kebab-case"
635 );
636 let items = tokens(text)?;
637 let [title, level, identity, body @ ..] = items.as_slice() else {
638 eyre::bail!("expected title, severity, ID, and sections");
639 };
640 ensure!(title.kind == Kind::Heading(1) && !title.text.is_empty(), "start with one # title");
641 ensure!(
642 level.kind == Kind::Text && level.text == format!("**Severity**: `{severity:?}`"),
643 "line {}: severity must match the registered lint ({severity:?})",
644 level.line
645 );
646 ensure!(
647 identity.kind == Kind::Text && identity.text == format!("**ID**: `{id}`"),
648 "line {}: ID must match the registered lint and filename ({id})",
649 identity.line
650 );
651 ensure!(
652 body.first().is_some_and(|t| t.kind == Kind::Heading(2) && t.text == "What it does"),
653 "start the body with ## What it does (no introductory summary)"
654 );
655 let mut core = Vec::new();
656 let mut seen = BTreeSet::new();
657 let mut remaining = body;
658 while let Some((heading, tail)) = remaining.split_first() {
659 let end = tail.iter().position(|t| matches!(t.kind, Kind::Heading(1 | 2)));
660 let (content, rest) = tail.split_at(end.unwrap_or(tail.len()));
661 remaining = rest;
662 ensure!(heading.kind == Kind::Heading(2), "line {}: only one # title", heading.line);
663 let name = heading.text.as_str();
664 ensure!(seen.insert(name), "line {}: duplicate section {name}", heading.line);
665 match name {
666 "What it does" | "Why is this bad?" | "Why restrict this?" | "Example" => {
667 core.push(name);
668 }
669 "Configuration" | "Notes" | "Limitations" | "Known limitations" => {}
670 _ => eyre::bail!("line {}: unexpected section {name}", heading.line),
671 }
672 ensure!(
673 content
674 .iter()
675 .any(|t| !matches!(t.kind, Kind::Heading(_)) && !t.text.trim().is_empty()),
676 "line {}: {name} must not be empty",
677 heading.line
678 );
679 for token in content {
680 ensure!(
681 !(matches!(token.kind, Kind::Heading(_))
682 && matches!(token.text.as_str(), "Bad" | "Good")),
683 "line {}: use Use instead: rather than Bad/Good headings",
684 token.line
685 );
686 ensure!(
687 !(token.kind == Kind::Text
688 && (token.text.starts_with("**Severity**:")
689 || token.text.starts_with("**ID**:"))),
690 "line {}: metadata belongs only below the title",
691 token.line
692 );
693 }
694 if matches!(name, "What it does" | "Why is this bad?" | "Why restrict this?") {
695 ensure!(
696 content.iter().any(|t| t.kind == Kind::Text),
697 "{name} needs explanatory prose"
698 );
699 }
700 if name == "Example" {
701 let separators = content
702 .iter()
703 .enumerate()
704 .filter(|(_, t)| t.kind == Kind::Text && t.text == "Use instead:")
705 .map(|(index, _)| index)
706 .collect::<Vec<_>>();
707 ensure!(separators.len() == 1, "Example needs exactly one Use instead: separator");
708 let split = separators[0];
709 for side in [&content[..split], &content[split + 1..]] {
710 ensure!(
711 side.iter()
712 .any(|t| t.kind == Kind::Code("solidity") && !t.text.trim().is_empty()),
713 "Example needs a nonempty solidity code block on each side of Use instead:"
714 );
715 }
716 }
717 }
718 ensure!(
719 matches!(
720 core.as_slice(),
721 ["What it does", "Why is this bad?" | "Why restrict this?", "Example"]
722 ),
723 "expected What it does, exactly one Why section, then Example"
724 );
725 Ok(())
726 }
727
728 const VALID: &str = "# Example\n\n**Severity**: `Info`\n**ID**: `example`\n\n\
729 ## What it does\n\nReports a pattern.\n\n## Why is this bad?\n\nExplains the consequence.\n\n\
730 ## Example\n\n```solidity\nbad();\n```\n\nUse instead:\n\n```solidity\ngood();\n```\n";
731
732 #[test]
733 fn accepts_documentation_variants() {
734 for severity in [
735 Severity::High,
736 Severity::Med,
737 Severity::Low,
738 Severity::Info,
739 Severity::Gas,
740 Severity::CodeSize,
741 ] {
742 validate_doc(&VALID.replace("`Info`", &format!("`{severity:?}`")), "example", severity)
743 .unwrap();
744 }
745 for text in [
746 VALID.replace("Why is this bad?", "Why restrict this?"),
747 VALID.replace('\n', "\r\n"),
748 VALID.replace("## Why", "## Known limitations\n\nAn exclusion.\n\n## Why")
749 + "\n## Configuration\n\n```toml\nsetting = true\n```\n\n## Notes\n\nA note.\n\n## Limitations\n\nA limitation.\n",
750 include_str!("../../docs/_template.md")
751 .replace("`<High | Med | Low | Info | Gas | CodeSize>`", "`Info`")
752 .replace("`<str_id>`", "`example`"),
753 ] {
754 validate_doc(&text, "example", Severity::Info).unwrap();
755 }
756 for marker in ["````", "~~~"] {
757 let text = VALID
758 .replace("```", marker)
759 .replace("bad();", "## Example\n**ID**: `other`\nUse instead:\n### Bad\n```");
760 validate_doc(&text, "example", Severity::Info).unwrap();
761 }
762 }
763
764 #[test]
765 fn rejects_invalid_documentation() {
766 for (from, to, error) in [
767 ("# Example", "Example", "one # title"),
768 ("**Severity**: `Info`", "", "severity"),
769 ("`Info`", "`High`", "severity"),
770 ("`example`", "`other`", "ID must match"),
771 ("## What", "Summary.\n\n## What", "no introductory summary"),
772 ("## Why is this bad?\n\nExplains the consequence.\n\n", "", "exactly one Why"),
773 ("Reports a pattern.", "", "must not be empty"),
774 ("Reports a pattern.", "```solidity\nf();\n```", "explanatory prose"),
775 ("Use instead:", "", "exactly one Use instead:"),
776 ("Use instead:", "### Bad", "Bad/Good headings"),
777 ("Use instead:", "### Good", "Bad/Good headings"),
778 ("bad();", "", "nonempty solidity"),
779 ("good();", " ", "nonempty solidity"),
780 ("```solidity", "```text", "nonempty solidity"),
781 ("```solidity", "~~~solidity", "unclosed code fence"),
782 ("```solidity", "````solidity", "unclosed code fence"),
783 ] {
784 let text = VALID.replacen(from, to, 1);
785 let result = validate_doc(&text, "example", Severity::Info).unwrap_err().to_string();
786 assert!(result.contains(error), "{from:?} -> {to:?}: {result}");
787 }
788 for (suffix, error) in [
789 ("# Extra\n", "only one # title"),
790 ("## Example\n", "duplicate section"),
791 ("## Why restrict this?\n\nPolicy.\n", "exactly one Why"),
792 ("## Scope and controls\n\nText.\n", "unexpected section"),
793 ("## Notes\n\n### Detail\n", "must not be empty"),
794 ("**ID**: `example`\n", "metadata belongs only"),
795 ("Use instead:\n", "exactly one Use instead:"),
796 ] {
797 let result = validate_doc(&(VALID.to_owned() + suffix), "example", Severity::Info)
798 .unwrap_err()
799 .to_string();
800 assert!(result.contains(error), "{suffix:?}: {result}");
801 }
802 let reordered = VALID
803 .replace("## Why is this bad?", "## Temporary")
804 .replace("## Example", "## Why is this bad?")
805 .replace("## Temporary", "## Example");
806 assert!(validate_doc(&reordered, "example", Severity::Info).is_err());
807 assert!(
808 validate_doc(VALID.trim_end().trim_end_matches("```"), "example", Severity::Info)
809 .is_err()
810 );
811 for id in ["Not_kebab", "example-", "example--lint", "1example"] {
812 let text = VALID.replace("`example`", &format!("`{id}`"));
813 assert!(validate_doc(&text, id, Severity::Info).is_err(), "{id}");
814 }
815 }
816}