use std::fmt;
use serde::{Serialize, Serializer};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagnosticEntity {
Package,
List,
Layer,
Governance,
Variable,
Catalog,
CatalogEntry,
EvaluationContext,
EvaluationContextSample,
Value,
Rule,
}
#[derive(Debug, Clone, Copy)]
pub struct RuleMeta {
pub rule: &'static str,
pub severity: Severity,
pub entity: DiagnosticEntity,
pub title: &'static str,
pub help: &'static str,
}
macro_rules! rototo_rule_severity {
() => {
Severity::Error
};
($severity:ident) => {
Severity::$severity
};
}
macro_rules! rototo_rules {
($($variant:ident => {
id: $id:literal,
entity: $entity:ident,
title: $title:literal,
help: $help:literal $(,
severity: $severity:ident)? $(,)?
}),+ $(,)?) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RototoRuleId {
$($variant),+
}
impl RototoRuleId {
pub const ALL: &'static [Self] = &[
$(Self::$variant),+
];
pub fn iter() -> impl Iterator<Item = Self> {
Self::ALL.iter().copied()
}
pub fn meta(self) -> RuleMeta {
match self {
$(Self::$variant => RuleMeta {
rule: concat!("rototo/", $id),
severity: rototo_rule_severity!($($severity)?),
entity: DiagnosticEntity::$entity,
title: $title,
help: $help,
}),+
}
}
}
};
}
rototo_rules! {
PackageNotFound => {
id: "package-not-found",
entity: Package,
title: "Package was not found",
help: "Pass a path to an existing rototo package directory.",
},
UnrecognizedFile => {
id: "unrecognized-file",
entity: Package,
title: "File maps to no rototo entity",
help: "The file sits under a rototo-owned directory but no entity claims it, so nothing will read it. Move it to the path that names what it is (check the spelling and suffix), or remove it.",
severity: Warning,
},
PackageManifestMissing => {
id: "package-manifest-missing",
entity: Package,
title: "Package manifest is missing",
help: "Create rototo-package.toml at the package root.",
},
PackageManifestParseFailed => {
id: "package-manifest-parse-failed",
entity: Package,
title: "Package manifest could not be parsed",
help: "Fix the TOML syntax in rototo-package.toml.",
},
PackageManifestSchemaFailed => {
id: "package-manifest-schema-failed",
entity: Package,
title: "Package manifest does not match schema",
help: "Declare schema_version = 1 and optional extends in rototo-package.toml.",
},
TraceWhenMissing => {
id: "trace-when-missing",
entity: Package,
title: "Trace policy is missing when",
help: "Each [[trace]] policy must declare when = \"<expression>\".",
},
TraceWhenShape => {
id: "trace-when-shape",
entity: Package,
title: "Trace policy when expression is invalid",
help: "A [[trace]] when must be a string holding a valid boolean expression.",
},
TraceWhenInvalidReference => {
id: "trace-when-invalid-reference",
entity: Package,
title: "Trace policy when references an unknown identifier",
help: "Trace when reads context.<path>, variables[\"<id>\"], env.now, and env.resolving.variable.",
},
EvaluationContextSchemaInvalid => {
id: "evaluation-context-schema-invalid",
entity: EvaluationContext,
title: "Evaluation context schema is invalid",
help: "Fix the model/context/<id>.schema.json file so it parses and compiles as JSON Schema.",
},
EvaluationContextSampleSchemaMismatch => {
id: "evaluation-context-sample-schema-mismatch",
entity: EvaluationContextSample,
title: "Evaluation context sample does not match its schema",
help: "Update the evaluation context sample so it validates against the owning evaluation context schema.",
},
EvaluationContextSampleShape => {
id: "evaluation-context-sample-shape",
entity: EvaluationContextSample,
title: "Evaluation context sample is invalid",
help: "Evaluation context samples must parse as JSON objects.",
},
VariableParseFailed => {
id: "variable-parse-failed",
entity: Variable,
title: "Variable TOML file could not be parsed",
help: "Fix the TOML syntax so rototo can parse the variable file.",
},
VariableSchemaVersion => {
id: "variable-schema-version",
entity: Variable,
title: "Variable schema version is missing or unsupported",
help: "Declare schema_version = 1 in the variable file.",
},
VariableTypeSource => {
id: "variable-type-source",
entity: Variable,
title: "Variable type source is invalid",
help: "Declare type as a primitive type or catalog=<catalog-id>.",
},
VariableUnknownType => {
id: "variable-unknown-type",
entity: Variable,
title: "Variable type is unknown",
help: "Use one of bool, int, number, string, list, or catalog=<catalog-id>.",
},
VariableUnknownCatalog => {
id: "variable-unknown-catalog",
entity: Variable,
title: "Variable references an unknown catalog",
help: "Create the referenced catalog or update the catalog type.",
},
VariableValuesDisallowed => {
id: "variable-values-disallowed",
entity: Variable,
title: "Variable values are not allowed",
help: "Remove [values] and put literal values directly under [resolve].",
},
IdNotSnakeCase => {
id: "id-not-snake-case",
entity: Package,
title: "Identifier is not snake_case",
help: "rototo-recognized ids appear in TOML tables and CEL expressions, where a hyphen is the minus operator; use lowercase letters, digits, and underscores, with / for namespacing.",
},
VariableUnknownList => {
id: "variable-unknown-list",
entity: Variable,
title: "Variable references an unknown list",
help: "Declare the list under lists/<id>.toml or fix the list=<id> type.",
},
ListParseFailed => {
id: "list-parse-failed",
entity: List,
title: "List TOML file could not be parsed",
help: "Fix the TOML syntax error in the list file.",
},
ListSchemaVersion => {
id: "list-schema-version",
entity: List,
title: "List schema version is missing or unsupported",
help: "Declare schema_version = 1 in the list declaration.",
},
ListShape => {
id: "list-shape",
entity: List,
title: "List declaration is invalid",
help: "Declare type as one of string, int, number, or bool.",
},
VariableUnknownValue => {
id: "variable-unknown-value",
entity: Variable,
title: "Variable references an unknown catalog value",
help: "Create the referenced catalog value or update the reference.",
},
VariableValueTypeMismatch => {
id: "variable-value-type-mismatch",
entity: Variable,
title: "Variable value does not match type",
help: "Update the value so it matches the declared primitive type.",
},
CatalogParseFailed => {
id: "catalog-parse-failed",
entity: Catalog,
title: "Catalog schema file could not be parsed",
help: "Fix the JSON syntax so rototo can parse the catalog schema file.",
},
CatalogEntryParseFailed => {
id: "catalog-entry-parse-failed",
entity: CatalogEntry,
title: "Catalog value TOML file could not be parsed",
help: "Fix the TOML syntax so rototo can parse the catalog value file.",
},
CatalogIdOverlap => {
id: "catalog-id-overlap",
entity: Catalog,
title: "Catalog id is a path prefix of another catalog",
help: "Rename one catalog so no catalog id is a path prefix of another; overlapping ids make entry files under the shared directory ambiguous.",
},
CatalogSchemaInvalid => {
id: "catalog-schema-invalid",
entity: Catalog,
title: "Catalog schema is invalid",
help: "Update model/catalogs/<id>.schema.json so it is a valid JSON Schema.",
},
CatalogEntrySchemaMismatch => {
id: "catalog-entry-schema-mismatch",
entity: CatalogEntry,
title: "Catalog value does not match schema",
help: "Update the catalog value so it matches the catalog JSON Schema.",
},
CatalogEntryUnknownReference => {
id: "catalog-entry-unknown-reference",
entity: CatalogEntry,
title: "Catalog value references an invalid catalog entry",
help: "Create the referenced catalog entry, fix the pointer, or update the x-rototo-ref target.",
},
VariableResolveMissingDefault => {
id: "variable-resolve-missing-default",
entity: Variable,
title: "Variable resolve default is missing",
help: "Add [resolve].default with a value reference.",
},
VariableResolveShape => {
id: "variable-resolve-shape",
entity: Variable,
title: "Variable resolve block is invalid",
help: "Resolve blocks must be tables with default and optional rule references.",
},
GovernanceParseFailed => {
id: "governance-parse-failed",
entity: Governance,
title: "governance.toml could not be parsed",
help: "Fix the TOML syntax error in governance.toml.",
},
GovernanceShape => {
id: "governance-shape",
entity: Governance,
title: "Governance contract is invalid",
help: "governance.toml declares [<kind>.<id>] blocks (catalog, list, variable, evaluation_context, or layer) with allowed_operations/denied_operations drawn from add, update, and delete, plus update_policy/delete_policy tables whose allowed_*/denied_* lists name entries and fields. Policies exist only for update and delete; an allowlist must not be empty.",
},
GovernanceUnknownTarget => {
id: "governance-unknown-target",
entity: Governance,
title: "Governance block names an unknown target",
help: "Point the [<kind>.<id>] block at an entity the package declares, or remove it.",
},
GovernanceUnscopedUpdate => {
id: "governance-unscoped-update",
entity: Governance,
title: "Governance update grant has no field allowlist",
help: "An update grant without allowed_fields silently includes fields added to the schema later; list the fields the layer below may change.",
severity: Warning,
},
LayerParseFailed => {
id: "layer-parse-failed",
entity: Layer,
title: "Layer TOML file could not be parsed",
help: "Fix the TOML syntax error in the layer file.",
},
LayerSchemaVersion => {
id: "layer-schema-version",
entity: Layer,
title: "Layer schema version is not supported",
help: "Declare schema_version = 1 in the layer file.",
},
LayerShape => {
id: "layer-shape",
entity: Layer,
title: "Layer is invalid",
help: "A layer declares unit (a CEL expression over context), buckets (a positive integer), and [[allocation]] tables with an id, an optional status (draft, running, or concluded), an optional eligibility expression, and [[allocation.arm]] tables with a name and a \"<start>-<end>\" bucket range.",
},
LayerBucketOverlap => {
id: "layer-bucket-overlap",
entity: Layer,
title: "Layer arms claim overlapping buckets",
help: "Arms across all allocations in a layer must claim disjoint buckets so a unit lands in at most one allocation.",
},
VariableQueryShape => {
id: "variable-query-shape",
entity: Variable,
title: "Variable query resolution is invalid",
help: "method = \"query\" declares from = \"<catalog-id>\" plus optional filter, sort, order (asc or desc), limit (a positive integer), and default; it requires a catalog=<id> or array<catalog=<id>> type and no [[resolve.rule]] tables.",
},
VariableAllocationShape => {
id: "variable-allocation-shape",
entity: Variable,
title: "Variable allocation resolution is invalid",
help: "method = \"allocation\" declares allocation = \"<allocation-id>\", a default, and one [[resolve.assign]] with an arm and a value for every arm of that allocation; rules and query keys do not apply.",
},
VariableUnknownAllocation => {
id: "variable-unknown-allocation",
entity: Variable,
title: "Variable references an unknown allocation",
help: "Declare the allocation in a layer under layers/<id>.toml or fix the allocation id.",
},
VariableRuleShape => {
id: "variable-rule-shape",
entity: Variable,
title: "Variable rule is invalid",
help: "Rules must be tables with qualifier and value references.",
},
VariableRuleUnknownVariable => {
id: "variable-rule-unknown-variable",
entity: Variable,
title: "Variable rule references an unknown variable",
help: "Create the referenced variable or fix the variables[\"<id>\"] reference.",
},
ExpressionUnknownList => {
id: "expression-unknown-list",
entity: Variable,
title: "Expression references an unknown list",
help: "Declare the list under lists/<id>.toml or fix the lists.<id> reference.",
},
VariableReferenceCycle => {
id: "variable-reference-cycle",
entity: Variable,
title: "Variable participates in a reference cycle",
help: "Break the cycle: a variable's expressions cannot depend on the variable itself, directly or through other variables.",
},
VariableRuleUndeclaredContextPath => {
id: "variable-rule-undeclared-context-path",
entity: Rule,
title: "Variable rule references an undeclared context path",
help: "Declare the attribute in an evaluation context schema under model/context/<id>.schema.json, or fix the path in the rule when/query expression.",
},
VariableRuleInvalidReference => {
id: "variable-rule-invalid-reference",
entity: Rule,
title: "Variable rule references an identifier rototo does not provide",
help: "Expressions read context.<path>, entry.<path> (in queries), variables[\"<id>\"], lists.<id>, and env.now. Reference other variables as variables[\"<id>\"].",
},
VariableRuleContextPathTypeMismatch => {
id: "variable-rule-context-path-type-mismatch",
entity: Rule,
title: "Variable rule uses a context path with the wrong type",
help: "Declare the context attribute with a type that matches how the rule uses it, or change the comparison to match the declared type.",
},
VariableRuleShadowed => {
id: "variable-rule-shadowed",
entity: Rule,
title: "Variable rule is shadowed",
help: "Remove the later duplicate qualifier rule or reorder the resolve rules.",
severity: Warning,
},
VariableRuleSelectsDefaultValue => {
id: "variable-rule-selects-default-value",
entity: Rule,
title: "Variable rule selects the default value",
help: "Remove the rule or update it to select a value that differs from the resolve default.",
severity: Warning,
},
VariableEvaluationContextConflict => {
id: "variable-evaluation-context-conflict",
entity: Variable,
title: "Variable rules require incompatible evaluation contexts",
help: "Use rule conditions that share at least one compatible evaluation context, or split the behavior into separate variables.",
},
EvaluationContextParseFailed => {
id: "evaluation-context-parse-failed",
entity: EvaluationContext,
title: "Evaluation context schema JSON file could not be parsed",
help: "Fix the JSON syntax so rototo can parse the evaluation context schema file.",
},
EvaluationContextSampleParseFailed => {
id: "evaluation-context-sample-parse-failed",
entity: EvaluationContextSample,
title: "Evaluation context sample JSON file could not be parsed",
help: "Fix the JSON syntax so rototo can parse the evaluation context sample file.",
},
CustomLintFailed => {
id: "custom-lint-failed",
entity: Package,
title: "Custom lint execution failed",
help: "Update the Lua lint file or target data so custom lint can run.",
},
CustomLintRegistrationInvalid => {
id: "custom-lint-registration-invalid",
entity: Package,
title: "Custom lint registration is invalid",
help: "Register custom lint with an allowed stage, entity, field, rule metadata, and handler.",
},
CustomLintRuleConflict => {
id: "custom-lint-rule-conflict",
entity: Package,
title: "Custom lint rule metadata conflicts",
help: "Use identical title and help text for repeated custom rule declarations.",
},
CustomLintFileUnregistered => {
id: "custom-lint-file-unregistered",
entity: Package,
title: "Custom lint file registers no handlers",
help: "Register at least one handler from the Lua file or remove the file.",
severity: Warning,
},
CustomLintRegistrationDuplicate => {
id: "custom-lint-registration-duplicate",
entity: Package,
title: "Custom lint registration is duplicated",
help: "Remove duplicate custom lint registrations so handlers run once per target.",
severity: Warning,
},
SchemaUiUnknownWidget => {
id: "schema-ui-unknown-widget",
entity: Catalog,
title: "UI widget hint names an unknown widget",
help: "Use a widget from the x-rototo-ui vocabulary: color, slider, textarea.",
severity: Warning,
},
SchemaUiWidgetTypeMismatch => {
id: "schema-ui-widget-type-mismatch",
entity: Catalog,
title: "UI widget hint does not fit the property type",
help: "Pick a widget that supports the property's declared type, or change the type.",
severity: Warning,
},
SchemaUiWidgetParams => {
id: "schema-ui-widget-params",
entity: Catalog,
title: "UI widget hint parameters are invalid",
help: "Fix the x-rototo-ui object: declare a widget string, use only the widget's parameters, and give sliders bounds.",
severity: Warning,
},
}
impl Serialize for RototoRuleId {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.meta().rule)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CustomRuleId(String);
impl CustomRuleId {
pub fn parse(rule: impl AsRef<str>) -> std::result::Result<Self, CustomRuleIdError> {
let rule = rule.as_ref();
let Some((authority, id)) = rule.split_once('/') else {
return Err(CustomRuleIdError::new(
"custom rule id must use <authority>/<rule-id>",
));
};
if id.contains('/') {
return Err(CustomRuleIdError::new(
"custom rule id must contain exactly one slash",
));
}
if authority == "rototo" {
return Err(CustomRuleIdError::new(
"rototo is reserved for built-in diagnostic rules",
));
}
if !valid_rule_segment(authority) || !valid_rule_segment(id) {
return Err(CustomRuleIdError::new(
"rule id segments must use lowercase ASCII letters, digits, and hyphen",
));
}
Ok(Self(rule.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CustomRuleId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl Serialize for CustomRuleId {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct CustomRuleIdError {
message: String,
}
impl CustomRuleIdError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for CustomRuleIdError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for CustomRuleIdError {}
fn valid_rule_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomRuleDefinition {
pub rule: CustomRuleId,
pub severity: Severity,
pub title: String,
pub help: String,
}
impl CustomRuleDefinition {
pub fn new(rule: CustomRuleId, title: impl Into<String>, help: impl Into<String>) -> Self {
Self::with_severity(rule, Severity::Error, title, help)
}
pub fn with_severity(
rule: CustomRuleId,
severity: Severity,
title: impl Into<String>,
help: impl Into<String>,
) -> Self {
Self {
rule,
severity,
title: title.into(),
help: help.into(),
}
}
pub fn same_metadata(&self, other: &Self) -> bool {
self.severity == other.severity && self.title == other.title && self.help == other.help
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DiagnosticRule {
Rototo(RototoRuleId),
Custom(CustomRuleId),
}
impl DiagnosticRule {
pub fn as_string(&self) -> String {
match self {
Self::Rototo(rule) => rule.meta().rule.to_owned(),
Self::Custom(rule) => rule.as_str().to_owned(),
}
}
}
impl Serialize for DiagnosticRule {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::Rototo(rule) => rule.serialize(serializer),
Self::Custom(rule) => rule.serialize(serializer),
}
}
}
#[derive(Debug, Serialize)]
pub struct DiagnosticCatalogEntry {
pub rule: String,
pub severity: Severity,
#[serde(skip_serializing_if = "Option::is_none")]
pub entity: Option<DiagnosticEntity>,
pub title: String,
pub help: String,
}
impl DiagnosticCatalogEntry {
pub fn from_rototo(rule: RototoRuleId) -> Self {
let meta = rule.meta();
Self {
rule: meta.rule.to_owned(),
severity: meta.severity,
entity: Some(meta.entity),
title: meta.title.to_owned(),
help: meta.help.to_owned(),
}
}
pub fn from_custom(definition: &CustomRuleDefinition) -> Self {
Self {
rule: definition.rule.as_str().to_owned(),
severity: definition.severity,
entity: None,
title: definition.title.clone(),
help: definition.help.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct DocId(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LintStage {
Discover,
Parse,
Project,
Register,
Reference,
Value,
Graph,
Policy,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SemanticEntity {
Package,
Manifest,
Variable {
id: String,
},
List {
id: String,
},
Layer {
id: String,
},
Governance,
Catalog {
id: String,
},
CatalogEntry {
catalog: String,
key: String,
},
EvaluationContext {
id: String,
},
EvaluationContextSample {
evaluation_context: String,
key: String,
},
Value {
variable: String,
key: String,
},
Rule {
variable: String,
index: usize,
},
CustomLint {
path: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SemanticField {
PackageExtends,
SchemaVersion,
Description,
VariableType,
VariableSchema,
VariableValues,
VariableResolve,
VariableResolveDefault,
VariableRuleWhen,
VariableRuleValue,
VariableQueryFilter,
VariableQuerySort,
VariableAllocation,
VariableAssignValue,
Value,
ValueJsonPath { path: Vec<String> },
SchemaJson,
SchemaJsonPath { path: Vec<String> },
EvaluationContextSample,
CatalogEntry,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct SemanticTarget {
pub entity: SemanticEntity,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<SemanticField>,
}
impl SemanticTarget {
pub fn entity(entity: SemanticEntity) -> Self {
Self {
entity,
field: None,
}
}
pub fn field(entity: SemanticEntity, field: SemanticField) -> Self {
Self {
entity,
field: Some(field),
}
}
}
impl From<SemanticEntity> for SemanticTarget {
fn from(entity: SemanticEntity) -> Self {
Self::entity(entity)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SourcePosition {
pub line: usize,
pub character: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SourceRange {
pub start: SourcePosition,
pub end: SourcePosition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TextRange {
pub(crate) start: usize,
pub(crate) end: usize,
}
impl TextRange {
pub(crate) fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SourceSpan {
pub(crate) doc: DocId,
pub(crate) range: TextRange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticLocationKind {
Span,
Document,
PackageRoot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DiagnosticLocation {
#[serde(skip)]
pub kind: DiagnosticLocationKind,
#[serde(skip)]
pub doc: Option<DocId>,
#[serde(skip)]
pub(crate) span: Option<SourceSpan>,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub range: Option<SourceRange>,
}
impl DiagnosticLocation {
pub fn span(doc: DocId, path: impl Into<String>, range: SourceRange) -> Self {
Self {
kind: DiagnosticLocationKind::Span,
doc: Some(doc),
span: None,
path: path.into(),
range: Some(range),
}
}
pub(crate) fn source_span(
doc: DocId,
path: impl Into<String>,
text_range: TextRange,
rendered_range: SourceRange,
) -> Self {
Self {
kind: DiagnosticLocationKind::Span,
doc: Some(doc),
span: Some(SourceSpan {
doc,
range: text_range,
}),
path: path.into(),
range: Some(rendered_range),
}
}
pub fn document(doc: DocId, path: impl Into<String>) -> Self {
Self {
kind: DiagnosticLocationKind::Document,
doc: Some(doc),
span: None,
path: path.into(),
range: None,
}
}
pub fn package_root(path: impl Into<String>) -> Self {
Self {
kind: DiagnosticLocationKind::PackageRoot,
doc: None,
span: None,
path: path.into(),
range: None,
}
}
pub fn doc(&self) -> Option<DocId> {
self.doc
}
pub(crate) fn byte_start(&self) -> Option<usize> {
self.span.map(|span| span.range.start)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RelatedLocation {
pub location: DiagnosticLocation,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct LintDiagnostic {
pub rule: DiagnosticRule,
pub severity: Severity,
pub stage: LintStage,
pub target: SemanticTarget,
pub message: String,
pub help: String,
#[serde(rename = "location")]
pub primary: DiagnosticLocation,
pub related: Vec<RelatedLocation>,
}
impl LintDiagnostic {
pub fn rototo(
rule: RototoRuleId,
stage: LintStage,
target: impl Into<SemanticTarget>,
primary: DiagnosticLocation,
message: impl Into<String>,
) -> Self {
let meta = rule.meta();
Self {
rule: DiagnosticRule::Rototo(rule),
severity: meta.severity,
stage,
target: target.into(),
message: message.into(),
help: meta.help.to_owned(),
primary,
related: Vec::new(),
}
}
pub fn custom(
definition: &CustomRuleDefinition,
stage: LintStage,
target: impl Into<SemanticTarget>,
primary: DiagnosticLocation,
message: impl Into<String>,
) -> Self {
Self {
rule: DiagnosticRule::Custom(definition.rule.clone()),
severity: definition.severity,
stage,
target: target.into(),
message: message.into(),
help: definition.help.clone(),
primary,
related: Vec::new(),
}
}
}
#[derive(Debug, Serialize)]
pub struct Diagnostic {
pub rule: DiagnosticRule,
pub severity: Severity,
pub path: String,
pub message: String,
pub help: String,
}
impl Diagnostic {
pub fn rototo(rule: RototoRuleId, path: impl Into<String>, message: impl Into<String>) -> Self {
let meta = rule.meta();
Self {
rule: DiagnosticRule::Rototo(rule),
severity: meta.severity,
path: path.into(),
message: message.into(),
help: meta.help.to_owned(),
}
}
pub fn custom(
definition: &CustomRuleDefinition,
path: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
rule: DiagnosticRule::Custom(definition.rule.clone()),
severity: definition.severity,
path: path.into(),
message: message.into(),
help: definition.help.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warning,
}
impl Severity {
pub fn parse(value: &str) -> Option<Self> {
match value {
"error" => Some(Self::Error),
"warning" => Some(Self::Warning),
_ => None,
}
}
}
#[cfg(test)]
mod custom_rule_id_tests {
use super::CustomRuleId;
#[test]
fn custom_rule_ids_take_authority_slash_rule_in_kebab_case() {
assert!(CustomRuleId::parse("payments/max-token-budget").is_ok());
assert!(CustomRuleId::parse("a1/b2-c3").is_ok());
}
#[test]
fn the_rototo_authority_is_reserved_for_built_ins() {
let err = CustomRuleId::parse("rototo/sneaky").unwrap_err();
assert!(err.to_string().contains("reserved for built-in"));
}
#[test]
fn malformed_custom_rule_ids_are_rejected() {
for id in [
"no-slash",
"too/many/segments",
"/leading",
"trailing/",
"Upper/case",
"spaces in/rule",
"under_score/rule",
] {
assert!(CustomRuleId::parse(id).is_err(), "{id} should be rejected");
}
}
}