use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
fn is_rustdoc_test_attribute(token: &str) -> bool {
matches!(token, "no_run" | "ignore" | "should_panic" | "compile_fail")
|| token
.strip_prefix("edition")
.is_some_and(|year| !year.is_empty() && year.bytes().all(|byte| byte.is_ascii_digit()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Language {
Bash,
C,
Csharp,
Dart,
Docker,
Elixir,
Go,
Java,
Json,
Kotlin,
Mermaid,
Php,
PowerShell,
Python,
R,
Ruby,
Rust,
Swift,
Text,
Toml,
TypeScript,
Xml,
Yaml,
Zig,
Unknown,
}
impl Language {
#[must_use]
pub fn from_fence_tag(tag: &str) -> Self {
match tag.trim().to_lowercase().as_str() {
"bash" | "sh" | "shell" | "zsh" | "console" => Self::Bash,
"c" => Self::C,
"csharp" | "c#" | "cs" => Self::Csharp,
"dart" => Self::Dart,
"docker" | "dockerfile" => Self::Docker,
"elixir" | "ex" | "exs" => Self::Elixir,
"go" | "golang" => Self::Go,
"java" => Self::Java,
"json" => Self::Json,
"kotlin" | "kt" | "kts" => Self::Kotlin,
"mermaid" => Self::Mermaid,
"php" => Self::Php,
"powershell" | "ps" | "ps1" | "pwsh" => Self::PowerShell,
"python" | "py" | "python3" => Self::Python,
"r" | "rscript" => Self::R,
"ruby" | "rb" => Self::Ruby,
"rust" | "rs" => Self::Rust,
"swift" => Self::Swift,
"text" | "txt" | "plain" => Self::Text,
"toml" => Self::Toml,
"typescript" | "ts" | "javascript" | "js" => Self::TypeScript,
"xml" => Self::Xml,
"yaml" | "yml" => Self::Yaml,
"zig" => Self::Zig,
_ => Self::Unknown,
}
}
#[must_use]
pub fn from_fence_info(info: &str) -> Self {
let tokens: Vec<&str> = info
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
.collect();
let language_tokens: Vec<&str> = tokens
.into_iter()
.filter(|token| !is_rustdoc_test_attribute(&token.to_lowercase()))
.collect();
match language_tokens.as_slice() {
[] => Self::Rust,
[only] if only.eq_ignore_ascii_case("rust") => Self::Rust,
[only] => Self::from_fence_tag(only),
_ => Self::Unknown,
}
}
#[must_use]
pub fn is_binding_target(self) -> bool {
matches!(
self,
Self::C
| Self::Csharp
| Self::Dart
| Self::Elixir
| Self::Go
| Self::Java
| Self::Kotlin
| Self::Php
| Self::Python
| Self::R
| Self::Ruby
| Self::Rust
| Self::Swift
| Self::TypeScript
| Self::Zig
)
}
#[must_use]
pub fn from_session_target(target: &str) -> Self {
match Self::normalize_session_target(target).as_str() {
"node" | "wasm" => Self::TypeScript,
"kotlin_android" => Self::Kotlin,
"core" | "rust_core" => Self::Rust,
"c_ffi" | "ffi" => Self::C,
other => Self::from_fence_tag(other),
}
}
#[must_use]
pub fn normalize_session_target(target: &str) -> String {
target.trim().to_lowercase().replace('-', "_")
}
#[must_use]
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"sh" | "bash" => Self::Bash,
"c" | "h" => Self::C,
"cs" => Self::Csharp,
"dart" => Self::Dart,
"dockerfile" => Self::Docker,
"ex" | "exs" => Self::Elixir,
"go" => Self::Go,
"java" => Self::Java,
"json" => Self::Json,
"kt" | "kts" => Self::Kotlin,
"php" => Self::Php,
"py" => Self::Python,
"r" => Self::R,
"rb" => Self::Ruby,
"rs" => Self::Rust,
"swift" => Self::Swift,
"toml" => Self::Toml,
"ts" | "js" | "mts" | "mjs" => Self::TypeScript,
"zig" => Self::Zig,
_ => Self::Unknown,
}
}
#[must_use]
pub fn from_dir_name(name: &str) -> Self {
match name.to_lowercase().as_str() {
"bash" | "shell" => Self::Bash,
"c" => Self::C,
"csharp" | "c-sharp" | "dotnet" => Self::Csharp,
"dart" => Self::Dart,
"docker" => Self::Docker,
"elixir" => Self::Elixir,
"go" | "golang" => Self::Go,
"java" => Self::Java,
"json" => Self::Json,
"kotlin" | "kotlin_android" | "kotlin-android" => Self::Kotlin,
"php" => Self::Php,
"python" => Self::Python,
"r" => Self::R,
"ruby" => Self::Ruby,
"rust" => Self::Rust,
"swift" => Self::Swift,
"toml" => Self::Toml,
"typescript" | "wasm" | "node" => Self::TypeScript,
"zig" => Self::Zig,
_ => Self::Unknown,
}
}
}
impl fmt::Display for Language {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bash => write!(f, "bash"),
Self::C => write!(f, "c"),
Self::Csharp => write!(f, "csharp"),
Self::Dart => write!(f, "dart"),
Self::Docker => write!(f, "docker"),
Self::Elixir => write!(f, "elixir"),
Self::Go => write!(f, "go"),
Self::Java => write!(f, "java"),
Self::Json => write!(f, "json"),
Self::Kotlin => write!(f, "kotlin"),
Self::Mermaid => write!(f, "mermaid"),
Self::Php => write!(f, "php"),
Self::PowerShell => write!(f, "powershell"),
Self::Python => write!(f, "python"),
Self::R => write!(f, "r"),
Self::Ruby => write!(f, "ruby"),
Self::Rust => write!(f, "rust"),
Self::Swift => write!(f, "swift"),
Self::Text => write!(f, "text"),
Self::Toml => write!(f, "toml"),
Self::TypeScript => write!(f, "typescript"),
Self::Xml => write!(f, "xml"),
Self::Yaml => write!(f, "yaml"),
Self::Zig => write!(f, "zig"),
Self::Unknown => write!(f, "unknown"),
}
}
}
pub fn resolve_required_language(value: &str) -> Result<Language, String> {
let language = Language::from_session_target(value);
if language == Language::Unknown {
Err(format!(
"unknown language `{value}` (expected a snippet fence tag such as `python`/`go`/`kotlin`, or a \
session target name such as `kotlin_android`/`node`/`wasm`)"
))
} else {
Ok(language)
}
}
impl std::str::FromStr for Language {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let language = Self::from_fence_tag(s);
if language == Self::Unknown {
Err(format!("unknown language: {s}"))
} else {
Ok(language)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ValidationLevel {
Syntax,
Compile,
TypeCheck,
Run,
}
impl fmt::Display for ValidationLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Syntax => write!(f, "syntax"),
Self::Compile => write!(f, "compile"),
Self::TypeCheck => write!(f, "typecheck"),
Self::Run => write!(f, "run"),
}
}
}
impl std::str::FromStr for ValidationLevel {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"syntax" => Ok(Self::Syntax),
"compile" => Ok(Self::Compile),
"typecheck" | "type-check" => Ok(Self::TypeCheck),
"run" => Ok(Self::Run),
_ => Err(format!("unknown validation level: {s}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SnippetAnnotationKind {
Skip,
CompileOnly,
SyntaxOnly,
TypeCheckOnly,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnippetAnnotation {
pub kind: SnippetAnnotationKind,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SnippetMetadata {
pub id: Option<String>,
pub language: Option<Language>,
pub target: Option<String>,
pub title: Option<String>,
pub level: Option<ValidationLevel>,
pub skip: bool,
pub reason: Option<String>,
pub tags: Vec<String>,
pub requires: Vec<String>,
pub side_effect: Option<SideEffectClass>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SideEffectClass {
#[serde(alias = "none", alias = "local")]
Safe,
Network,
Process,
Install,
Server,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SnippetStatus {
Pass,
Downgraded,
Fail,
Skip,
Error,
Unavailable,
}
impl fmt::Display for SnippetStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pass => write!(f, "pass"),
Self::Downgraded => write!(f, "downgraded"),
Self::Fail => write!(f, "fail"),
Self::Skip => write!(f, "skip"),
Self::Error => write!(f, "error"),
Self::Unavailable => write!(f, "unavailable"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snippet {
pub id: Option<String>,
pub path: PathBuf,
pub language: Language,
pub title: Option<String>,
pub code: String,
pub start_line: usize,
pub block_index: usize,
pub annotation: Option<SnippetAnnotation>,
pub metadata: SnippetMetadata,
pub source_origin: SourceOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceOrigin {
pub path: PathBuf,
pub line: usize,
pub block_index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DowngradeReason {
Declared,
Annotation,
ValidatorCapability,
Environment,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub snippet: Snippet,
pub status: SnippetStatus,
pub level: ValidationLevel,
pub requested_level: ValidationLevel,
pub effective_level: ValidationLevel,
pub message: Option<String>,
pub duration_ms: u64,
#[serde(default)]
pub capability_capped: bool,
#[serde(default)]
pub downgrade_reason: Option<DowngradeReason>,
#[serde(default)]
pub unresolved_dependency: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunSummary {
pub schema_version: u32,
pub total: usize,
pub passed: usize,
pub downgraded: usize,
pub failed: usize,
pub skipped: usize,
pub errors: usize,
pub unavailable: usize,
#[serde(default)]
pub capability_capped: usize,
#[serde(default)]
pub declared_capped: usize,
#[serde(default)]
pub unresolved_dependency: usize,
pub results: Vec<ValidationResult>,
}
impl RunSummary {
#[must_use]
pub fn from_results(results: Vec<ValidationResult>) -> Self {
let mut summary = Self {
schema_version: 1,
total: results.len(),
passed: 0,
downgraded: 0,
failed: 0,
skipped: 0,
errors: 0,
unavailable: 0,
capability_capped: 0,
declared_capped: 0,
unresolved_dependency: 0,
results,
};
for result in &summary.results {
if result.capability_capped {
summary.capability_capped += 1;
}
if result.downgrade_reason == Some(DowngradeReason::Declared) {
summary.declared_capped += 1;
}
if result.unresolved_dependency {
summary.unresolved_dependency += 1;
}
match result.status {
SnippetStatus::Pass => summary.passed += 1,
SnippetStatus::Downgraded => summary.downgraded += 1,
SnippetStatus::Fail => summary.failed += 1,
SnippetStatus::Skip => summary.skipped += 1,
SnippetStatus::Error => summary.errors += 1,
SnippetStatus::Unavailable => summary.unavailable += 1,
}
}
summary
}
#[must_use]
pub const fn has_failures(&self) -> bool {
self.failed > 0 || self.errors > 0
}
}
#[cfg(test)]
mod tests {
use super::{
Language, RunSummary, SideEffectClass, Snippet, SnippetAnnotationKind, SnippetMetadata, SnippetStatus,
SourceOrigin, ValidationLevel, ValidationResult,
};
fn result(status: SnippetStatus, unresolved_dependency: bool) -> ValidationResult {
ValidationResult {
snippet: Snippet {
id: None,
path: "example.md".into(),
language: Language::Go,
title: None,
code: "package main".into(),
start_line: 1,
block_index: 0,
annotation: None,
metadata: SnippetMetadata::default(),
source_origin: SourceOrigin {
path: "example.md".into(),
line: 1,
block_index: 0,
},
},
status,
level: ValidationLevel::Compile,
requested_level: ValidationLevel::Compile,
effective_level: ValidationLevel::Compile,
message: None,
duration_ms: 0,
capability_capped: false,
downgrade_reason: None,
unresolved_dependency,
}
}
#[test]
fn unresolved_dependency_is_a_reconcilable_subset_of_unavailable() {
let summary = RunSummary::from_results(vec![
result(SnippetStatus::Unavailable, true),
result(SnippetStatus::Unavailable, false),
result(SnippetStatus::Fail, false),
result(SnippetStatus::Pass, false),
]);
assert_eq!(summary.total, 4);
assert_eq!(summary.unavailable, 2);
assert_eq!(summary.unresolved_dependency, 1);
assert!(summary.unresolved_dependency <= summary.unavailable);
assert_eq!(summary.failed, 1);
assert_eq!(summary.passed, 1);
assert_eq!(
summary.total,
summary.passed
+ summary.downgraded
+ summary.failed
+ summary.skipped
+ summary.errors
+ summary.unavailable
);
assert!(summary.has_failures());
}
#[test]
fn from_fence_info_parses_rustdoc_attribute_combinations() {
let cases = [
("rust", Language::Rust),
("", Language::Rust),
("no_run", Language::Rust),
("ignore", Language::Rust),
("should_panic", Language::Rust),
("compile_fail", Language::Rust),
("rust,no_run", Language::Rust),
("rust,ignore", Language::Rust),
("rust,no_run,should_panic", Language::Rust),
("rust,edition2021", Language::Rust),
("python", Language::Python),
("some_unknown_language", Language::Unknown),
];
for (fence_info, expected) in cases {
assert_eq!(
Language::from_fence_info(fence_info),
expected,
"fence info `{fence_info}` should resolve to {expected:?}"
);
}
}
#[test]
fn validation_level_parses_typecheck_aliases() {
assert_eq!("typecheck".parse::<ValidationLevel>(), Ok(ValidationLevel::TypeCheck));
assert_eq!("type-check".parse::<ValidationLevel>(), Ok(ValidationLevel::TypeCheck));
assert_eq!("TypeCheck".parse::<ValidationLevel>(), Ok(ValidationLevel::TypeCheck));
assert_eq!(ValidationLevel::TypeCheck.to_string(), "typecheck");
}
#[test]
fn typecheck_orders_between_compile_and_run() {
assert!(ValidationLevel::Compile < ValidationLevel::TypeCheck);
assert!(ValidationLevel::TypeCheck < ValidationLevel::Run);
}
#[test]
fn typecheck_only_annotation_kind_is_distinct() {
assert_ne!(SnippetAnnotationKind::TypeCheckOnly, SnippetAnnotationKind::CompileOnly);
}
#[test]
fn side_effects_round_trip_and_accept_legacy_safe_aliases() {
for class in [
SideEffectClass::Safe,
SideEffectClass::Network,
SideEffectClass::Process,
SideEffectClass::Install,
SideEffectClass::Server,
] {
let encoded = serde_json::to_string(&class).unwrap();
assert_eq!(serde_json::from_str::<SideEffectClass>(&encoded).unwrap(), class);
}
assert_eq!(
serde_json::from_str::<SideEffectClass>(r#""none""#).unwrap(),
SideEffectClass::Safe
);
assert_eq!(
serde_json::from_str::<SideEffectClass>(r#""local""#).unwrap(),
SideEffectClass::Safe
);
assert!(serde_json::from_str::<SideEffectClass>(r#""external_mutation""#).is_err());
}
}