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