use std::{collections::BTreeSet, error::Error, fmt};
mod document_set;
pub use document_set::{
DependencyEdge, DependencyGraph, DocumentSetError, NamedQuadletDocument, QuadletDocumentSet, ReferenceResolution,
UnitFileName, UnitReference,
};
use crate::diagnostic::{Diagnostic, DiagnosticCode, Label, Severity};
use crate::path::{PathForm, classify_path};
use crate::source::{SourceId, SourceSpan, SourceText};
use crate::syntax::{ParseResult, SyntaxDocument, SyntaxLineKind};
const MISSING_SECTION: DiagnosticCode = DiagnosticCode::new("QLM0001");
const MISSING_IMAGE: DiagnosticCode = DiagnosticCode::new("QLM0002");
const FOREIGN_NATIVE_SECTION: DiagnosticCode = DiagnosticCode::new("QLM0003");
const REPEATED_SINGLETON: DiagnosticCode = DiagnosticCode::new("QLM0004");
const EMPTY_IMAGE: DiagnosticCode = DiagnosticCode::new("QLM0005");
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum QuadletUnitType {
Container,
Pod,
Network,
Volume,
}
impl QuadletUnitType {
#[must_use]
pub fn from_extension(extension: &str) -> Option<Self> {
match extension {
"container" => Some(Self::Container),
"pod" => Some(Self::Pod),
"network" => Some(Self::Network),
"volume" => Some(Self::Volume),
_ => None,
}
}
#[must_use]
pub const fn native_section(self) -> SectionKind {
match self {
Self::Container => SectionKind::Container,
Self::Pod => SectionKind::Pod,
Self::Network => SectionKind::Network,
Self::Volume => SectionKind::Volume,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum SectionKind {
Unit,
Service,
Install,
Container,
Pod,
Network,
Volume,
Unknown,
}
impl SectionKind {
fn classify(name: &str) -> Self {
match name {
"Unit" => Self::Unit,
"Service" => Self::Service,
"Install" => Self::Install,
"Container" => Self::Container,
"Pod" => Self::Pod,
"Network" => Self::Network,
"Volume" => Self::Volume,
_ => Self::Unknown,
}
}
const fn is_native(self) -> bool {
matches!(self, Self::Container | Self::Pod | Self::Network | Self::Volume)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum ContainerKey {
Image,
Exec,
Environment,
EnvironmentFile,
PublishPort,
Volume,
Network,
Pod,
HealthCmd,
PodmanArgs,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum PodKey {
PodName,
PublishPort,
Network,
Volume,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum NetworkKey {
NetworkName,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum VolumeKey {
VolumeName,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum EntryKind {
GenericSystemd,
Container(ContainerKey),
Pod(PodKey),
Network(NetworkKey),
Volume(VolumeKey),
Unknown,
}
impl EntryKind {
#[must_use]
pub const fn is_repeatable(self) -> bool {
matches!(
self,
Self::GenericSystemd
| Self::Container(
ContainerKey::Environment
| ContainerKey::EnvironmentFile
| ContainerKey::PublishPort
| ContainerKey::Volume
| ContainerKey::Network
| ContainerKey::PodmanArgs
)
| Self::Pod(PodKey::PublishPort | PodKey::Network | PodKey::Volume)
| Self::Unknown
)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum UnitReferenceKind {
Image,
Build,
Pod,
Network,
Volume,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ValueKind {
Opaque,
Path(PathForm),
UnitReference(UnitReferenceKind),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourcedText {
text: String,
span: SourceSpan,
}
impl SourcedText {
fn from_span(source: &SourceText, span: SourceSpan) -> Result<Self, TypedModelError> {
let text = source
.slice(span)
.ok_or(TypedModelError::InvalidSourceSpan(span))?
.to_owned();
Ok(Self { text, span })
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub const fn span(&self) -> SourceSpan {
self.span
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthoredValue {
primary: SourcedText,
continuations: Vec<SourcedText>,
has_continuation_marker: bool,
}
impl AuthoredValue {
#[must_use]
pub const fn primary(&self) -> &SourcedText {
&self.primary
}
#[must_use]
pub fn continuations(&self) -> &[SourcedText] {
&self.continuations
}
#[must_use]
pub fn is_continued(&self) -> bool {
self.has_continuation_marker
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TypedEntry {
key: SourcedText,
value: AuthoredValue,
kind: EntryKind,
value_kind: ValueKind,
source_line: usize,
}
impl TypedEntry {
#[must_use]
pub const fn key(&self) -> &SourcedText {
&self.key
}
#[must_use]
pub const fn value(&self) -> &AuthoredValue {
&self.value
}
#[must_use]
pub const fn kind(&self) -> EntryKind {
self.kind
}
#[must_use]
pub const fn value_kind(&self) -> ValueKind {
self.value_kind
}
#[must_use]
pub const fn source_line(&self) -> usize {
self.source_line
}
#[must_use]
pub fn unit_reference_name(&self) -> Option<&str> {
let ValueKind::UnitReference(_) = self.value_kind else {
return None;
};
let value = self.value.primary.text.trim();
match self.kind {
EntryKind::Container(ContainerKey::Volume) | EntryKind::Pod(PodKey::Volume) => {
Some(value.split_once(':').map_or(value, |(source, _)| source).trim())
}
EntryKind::Container(ContainerKey::Network | ContainerKey::Pod) | EntryKind::Pod(PodKey::Network) => {
Some(first_token(value))
}
EntryKind::Container(ContainerKey::Image) => Some(value),
_ => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TypedSection {
name: SourcedText,
kind: SectionKind,
entries: Vec<TypedEntry>,
source_line: usize,
}
impl TypedSection {
#[must_use]
pub const fn name(&self) -> &SourcedText {
&self.name
}
#[must_use]
pub const fn kind(&self) -> SectionKind {
self.kind
}
#[must_use]
pub fn entries(&self) -> &[TypedEntry] {
&self.entries
}
#[must_use]
pub const fn source_line(&self) -> usize {
self.source_line
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct QuadletDocument {
source_id: SourceId,
source_span: SourceSpan,
unit_type: QuadletUnitType,
sections: Vec<TypedSection>,
}
impl QuadletDocument {
pub fn interpret(
unit_type: QuadletUnitType,
syntax: &SyntaxDocument,
) -> Result<(Self, Vec<Diagnostic>), TypedModelError> {
let mut sections: Vec<TypedSection> = Vec::new();
let mut current_section = None;
for (line_index, line) in syntax.lines().iter().enumerate() {
match line.kind() {
SyntaxLineKind::Section(section) => {
let name = SourcedText::from_span(syntax.source(), section.name())?;
let kind = SectionKind::classify(name.text());
sections.push(TypedSection {
name,
kind,
entries: Vec::new(),
source_line: line_index,
});
current_section = Some(sections.len() - 1);
}
SyntaxLineKind::Entry(entry) => {
let Some(section_index) = current_section else {
continue;
};
let key = SourcedText::from_span(syntax.source(), entry.key())?;
let primary = SourcedText::from_span(syntax.source(), entry.value())?;
let continuations = collect_continuations(syntax, line_index)?;
let section_kind = sections[section_index].kind;
let kind = classify_entry(section_kind, key.text());
let value_kind = classify_value(kind, primary.text());
sections[section_index].entries.push(TypedEntry {
key,
value: AuthoredValue {
primary,
continuations,
has_continuation_marker: entry.continues(),
},
kind,
value_kind,
source_line: line_index,
});
}
SyntaxLineKind::Blank
| SyntaxLineKind::Comment(_)
| SyntaxLineKind::Continuation(_)
| SyntaxLineKind::Invalid => {}
}
}
let document = Self {
source_id: syntax.source().id(),
source_span: SourceSpan::new(syntax.source().id(), 0, syntax.source().text().len()),
unit_type,
sections,
};
let diagnostics = document.validate_shape(syntax.source());
Ok((document, diagnostics))
}
pub fn parse(
unit_type: QuadletUnitType,
source_id: SourceId,
text: impl Into<String>,
) -> Result<QuadletParseResult, TypedModelError> {
let syntax = SyntaxDocument::parse(source_id, text);
let (document, model_diagnostics) = Self::interpret(unit_type, syntax.document())?;
Ok(QuadletParseResult {
syntax,
document,
model_diagnostics,
})
}
#[must_use]
pub const fn source_id(&self) -> SourceId {
self.source_id
}
#[must_use]
pub const fn source_span(&self) -> SourceSpan {
self.source_span
}
#[must_use]
pub const fn unit_type(&self) -> QuadletUnitType {
self.unit_type
}
#[must_use]
pub fn sections(&self) -> &[TypedSection] {
&self.sections
}
pub fn entries(&self) -> impl Iterator<Item = &TypedEntry> {
self.sections.iter().flat_map(|section| section.entries.iter())
}
fn validate_shape(&self, source: &SourceText) -> Vec<Diagnostic> {
let expected = self.unit_type.native_section();
let mut diagnostics = Vec::new();
let mut expected_sections = self.sections.iter().filter(|section| section.kind == expected);
let first_expected = expected_sections.next();
if first_expected.is_none() {
diagnostics.push(Diagnostic::new(
MISSING_SECTION,
Severity::Error,
"Quadlet unit is missing its required native section",
Label::new(
SourceSpan::new(source.id(), 0, source.text().len()),
"add the native section required by the selected unit type",
),
));
}
for section in &self.sections {
if section.kind.is_native() && section.kind != expected {
diagnostics.push(Diagnostic::new(
FOREIGN_NATIVE_SECTION,
Severity::Warning,
"Quadlet unit contains a native section for another unit type",
Label::new(
section.name.span(),
"this section does not match the selected file type",
),
));
}
}
let mut singletons = BTreeSet::new();
for entry in self.entries() {
if !entry.kind.is_repeatable() && !singletons.insert(entry.kind) {
diagnostics.push(Diagnostic::new(
REPEATED_SINGLETON,
Severity::Warning,
"single-value Quadlet key is repeated",
Label::new(entry.key.span(), "the later value may replace an earlier value"),
));
}
}
if self.unit_type == QuadletUnitType::Container {
let images: Vec<_> = self
.sections
.iter()
.filter(|section| section.kind == SectionKind::Container)
.flat_map(|section| section.entries.iter())
.filter(|entry| entry.kind == EntryKind::Container(ContainerKey::Image))
.collect();
if images.is_empty() {
if let Some(section) = first_expected {
diagnostics.push(Diagnostic::new(
MISSING_IMAGE,
Severity::Error,
"container unit is missing its required Image entry",
Label::new(section.name.span(), "add `Image=` to this Container section"),
));
}
} else {
diagnostics.extend(
images
.iter()
.filter(|entry| entry.value.primary.text.trim().is_empty())
.map(|entry| {
Diagnostic::new(
EMPTY_IMAGE,
Severity::Error,
"container Image entry is empty",
Label::new(entry.value.primary.span(), "provide an image or unit reference"),
)
}),
);
}
}
diagnostics
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct QuadletParseResult {
syntax: ParseResult,
document: QuadletDocument,
model_diagnostics: Vec<Diagnostic>,
}
impl QuadletParseResult {
#[must_use]
pub const fn syntax(&self) -> &ParseResult {
&self.syntax
}
#[must_use]
pub const fn document(&self) -> &QuadletDocument {
&self.document
}
#[must_use]
pub fn model_diagnostics(&self) -> &[Diagnostic] {
&self.model_diagnostics
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.syntax.is_valid()
&& self
.model_diagnostics
.iter()
.all(|diagnostic| diagnostic.severity() != Severity::Error)
}
#[must_use]
pub fn into_parts(self) -> (ParseResult, QuadletDocument, Vec<Diagnostic>) {
(self.syntax, self.document, self.model_diagnostics)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum TypedModelError {
InvalidSourceSpan(SourceSpan),
}
impl fmt::Display for TypedModelError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidSourceSpan(span) => write!(formatter, "invalid parser source span: {span:?}"),
}
}
}
impl Error for TypedModelError {}
fn collect_continuations(syntax: &SyntaxDocument, entry_line: usize) -> Result<Vec<SourcedText>, TypedModelError> {
let mut values = Vec::new();
for line in syntax.lines().iter().skip(entry_line + 1) {
match line.kind() {
SyntaxLineKind::Continuation(continuation) => {
values.push(SourcedText::from_span(syntax.source(), continuation.value())?);
}
SyntaxLineKind::Comment(comment) if comment.within_continuation() => {}
_ => break,
}
}
Ok(values)
}
fn classify_entry(section: SectionKind, key: &str) -> EntryKind {
match section {
SectionKind::Unit | SectionKind::Service | SectionKind::Install => EntryKind::GenericSystemd,
SectionKind::Container => match key {
"Image" => EntryKind::Container(ContainerKey::Image),
"Exec" => EntryKind::Container(ContainerKey::Exec),
"Environment" => EntryKind::Container(ContainerKey::Environment),
"EnvironmentFile" => EntryKind::Container(ContainerKey::EnvironmentFile),
"PublishPort" => EntryKind::Container(ContainerKey::PublishPort),
"Volume" => EntryKind::Container(ContainerKey::Volume),
"Network" => EntryKind::Container(ContainerKey::Network),
"Pod" => EntryKind::Container(ContainerKey::Pod),
"HealthCmd" => EntryKind::Container(ContainerKey::HealthCmd),
"PodmanArgs" => EntryKind::Container(ContainerKey::PodmanArgs),
_ => EntryKind::Unknown,
},
SectionKind::Pod => match key {
"PodName" => EntryKind::Pod(PodKey::PodName),
"PublishPort" => EntryKind::Pod(PodKey::PublishPort),
"Network" => EntryKind::Pod(PodKey::Network),
"Volume" => EntryKind::Pod(PodKey::Volume),
_ => EntryKind::Unknown,
},
SectionKind::Network => match key {
"NetworkName" => EntryKind::Network(NetworkKey::NetworkName),
_ => EntryKind::Unknown,
},
SectionKind::Volume => match key {
"VolumeName" => EntryKind::Volume(VolumeKey::VolumeName),
_ => EntryKind::Unknown,
},
SectionKind::Unknown => EntryKind::Unknown,
}
}
fn classify_value(kind: EntryKind, raw: &str) -> ValueKind {
let value = raw.trim();
match kind {
EntryKind::Container(ContainerKey::Image) => reference_by_suffix(value)
.filter(|kind| matches!(kind, UnitReferenceKind::Image | UnitReferenceKind::Build))
.map_or(ValueKind::Opaque, ValueKind::UnitReference),
EntryKind::Container(ContainerKey::EnvironmentFile) => {
let path = value.strip_prefix('-').unwrap_or(value).trim_start();
ValueKind::Path(classify_path(path))
}
EntryKind::Container(ContainerKey::Volume) => {
let source = value.split_once(':').map_or(value, |(source, _)| source);
if reference_by_suffix(source) == Some(UnitReferenceKind::Volume) {
ValueKind::UnitReference(UnitReferenceKind::Volume)
} else {
ValueKind::Path(classify_path(source))
}
}
EntryKind::Container(ContainerKey::Network) => reference_by_suffix(first_token(value))
.filter(|kind| *kind == UnitReferenceKind::Network)
.map_or(ValueKind::Opaque, ValueKind::UnitReference),
EntryKind::Container(ContainerKey::Pod) => reference_by_suffix(first_token(value))
.filter(|kind| *kind == UnitReferenceKind::Pod)
.map_or(ValueKind::Opaque, ValueKind::UnitReference),
EntryKind::Pod(PodKey::Volume) => {
let source = value.split_once(':').map_or(value, |(source, _)| source);
if reference_by_suffix(source) == Some(UnitReferenceKind::Volume) {
ValueKind::UnitReference(UnitReferenceKind::Volume)
} else {
ValueKind::Path(classify_path(source))
}
}
EntryKind::Pod(PodKey::Network) => reference_by_suffix(first_token(value))
.filter(|kind| *kind == UnitReferenceKind::Network)
.map_or(ValueKind::Opaque, ValueKind::UnitReference),
_ => ValueKind::Opaque,
}
}
fn first_token(value: &str) -> &str {
value.split_ascii_whitespace().next().unwrap_or(value)
}
fn reference_by_suffix(value: &str) -> Option<UnitReferenceKind> {
let (stem, suffix) = value.rsplit_once('.')?;
if stem.is_empty() {
return None;
}
match suffix {
"image" => Some(UnitReferenceKind::Image),
"build" => Some(UnitReferenceKind::Build),
"pod" => Some(UnitReferenceKind::Pod),
"network" => Some(UnitReferenceKind::Network),
"volume" => Some(UnitReferenceKind::Volume),
_ => None,
}
}