use std::collections::{BTreeMap, BTreeSet};
use std::{error::Error, fmt};
use crate::diagnostic::{Diagnostic, DiagnosticCode, Label, Severity};
use crate::source::{SourceId, SourceSpan};
use super::{
EntryKind, QuadletDocument, QuadletUnitType, SystemdUnitKey, TypedEntry, UnitReferenceKind, ValueKind,
reference_by_suffix,
};
const MISSING_REFERENCE: DiagnosticCode = DiagnosticCode::new("QLG0001");
const AMBIGUOUS_REFERENCE: DiagnosticCode = DiagnosticCode::new("QLG0002");
const DUPLICATE_UNIT_NAME: DiagnosticCode = DiagnosticCode::new("QLG0003");
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct UnitFileName {
value: String,
unit_type: QuadletUnitType,
}
impl UnitFileName {
pub fn new(value: impl Into<String>) -> Result<Self, DocumentSetError> {
let value = value.into();
if value.is_empty() || value.contains('/') || value.contains('\\') {
return Err(DocumentSetError::InvalidUnitFileName(value));
}
let Some((stem, extension)) = value.rsplit_once('.') else {
return Err(DocumentSetError::InvalidUnitFileName(value));
};
if stem.is_empty() || extension.is_empty() {
return Err(DocumentSetError::InvalidUnitFileName(value));
}
let unit_type = QuadletUnitType::from_extension(extension)
.ok_or_else(|| DocumentSetError::UnsupportedUnitFileExtension(value.clone()))?;
Ok(Self { value, unit_type })
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.value
}
#[must_use]
pub const fn unit_type(&self) -> QuadletUnitType {
self.unit_type
}
}
impl fmt::Display for UnitFileName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.value)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NamedQuadletDocument {
name: UnitFileName,
document: QuadletDocument,
}
impl NamedQuadletDocument {
pub fn new(name: impl Into<String>, document: QuadletDocument) -> Result<Self, DocumentSetError> {
let name = UnitFileName::new(name)?;
if name.unit_type() != document.unit_type() {
return Err(DocumentSetError::UnitTypeMismatch {
name: name.as_str().to_owned(),
filename_type: name.unit_type(),
document_type: document.unit_type(),
});
}
Ok(Self { name, document })
}
#[must_use]
pub const fn name(&self) -> &UnitFileName {
&self.name
}
#[must_use]
pub const fn document(&self) -> &QuadletDocument {
&self.document
}
#[must_use]
pub fn into_parts(self) -> (UnitFileName, QuadletDocument) {
(self.name, self.document)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ReferenceResolution {
Resolved {
document_index: usize,
},
Missing,
Ambiguous {
candidates: usize,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UnitReference {
source_document: usize,
target_name: String,
kind: UnitReferenceKind,
systemd_unit_key: Option<SystemdUnitKey>,
span: SourceSpan,
resolution: ReferenceResolution,
}
impl UnitReference {
#[must_use]
pub const fn source_document(&self) -> usize {
self.source_document
}
#[must_use]
pub fn target_name(&self) -> &str {
&self.target_name
}
#[must_use]
pub const fn kind(&self) -> UnitReferenceKind {
self.kind
}
#[must_use]
pub const fn systemd_unit_key(&self) -> Option<SystemdUnitKey> {
self.systemd_unit_key
}
#[must_use]
pub const fn span(&self) -> SourceSpan {
self.span
}
#[must_use]
pub const fn resolution(&self) -> ReferenceResolution {
self.resolution
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DependencyEdge {
source_document: usize,
target_document: usize,
kind: UnitReferenceKind,
systemd_unit_key: Option<SystemdUnitKey>,
span: SourceSpan,
}
impl DependencyEdge {
#[must_use]
pub const fn source_document(self) -> usize {
self.source_document
}
#[must_use]
pub const fn target_document(self) -> usize {
self.target_document
}
#[must_use]
pub const fn kind(self) -> UnitReferenceKind {
self.kind
}
#[must_use]
pub const fn systemd_unit_key(self) -> Option<SystemdUnitKey> {
self.systemd_unit_key
}
#[must_use]
pub const fn span(self) -> SourceSpan {
self.span
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DependencyGraph {
references: Vec<UnitReference>,
edges: Vec<DependencyEdge>,
}
impl DependencyGraph {
#[must_use]
pub fn references(&self) -> &[UnitReference] {
&self.references
}
#[must_use]
pub fn edges(&self) -> &[DependencyEdge] {
&self.edges
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.references
.iter()
.all(|reference| matches!(reference.resolution, ReferenceResolution::Resolved { .. }))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct QuadletDocumentSet {
documents: Vec<NamedQuadletDocument>,
graph: DependencyGraph,
diagnostics: Vec<Diagnostic>,
}
impl QuadletDocumentSet {
pub fn new(documents: impl IntoIterator<Item = NamedQuadletDocument>) -> Result<Self, DocumentSetError> {
let documents: Vec<_> = documents.into_iter().collect();
ensure_unique_source_ids(&documents)?;
let mut by_name: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (index, document) in documents.iter().enumerate() {
by_name
.entry(document.name().as_str().to_owned())
.or_default()
.push(index);
}
let mut diagnostics = duplicate_name_diagnostics(&documents, &by_name);
let mut references = Vec::new();
let mut edges = Vec::new();
for (source_document, named_document) in documents.iter().enumerate() {
for entry in named_document.document().entries() {
let ValueKind::UnitReference(kind) = entry.value_kind() else {
continue;
};
let Some(target_name) = entry.unit_reference_name() else {
continue;
};
resolve_reference(
source_document,
target_name.to_owned(),
kind,
None,
entry.value().primary().span(),
&by_name,
&mut diagnostics,
&mut references,
&mut edges,
);
}
for reference in effective_systemd_unit_references(named_document.document()) {
resolve_reference(
source_document,
reference.target_name,
reference.kind,
Some(reference.key),
reference.span,
&by_name,
&mut diagnostics,
&mut references,
&mut edges,
);
}
}
Ok(Self {
documents,
graph: DependencyGraph { references, edges },
diagnostics,
})
}
#[must_use]
pub fn documents(&self) -> &[NamedQuadletDocument] {
&self.documents
}
#[must_use]
pub const fn graph(&self) -> &DependencyGraph {
&self.graph
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.graph.is_complete()
&& self
.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity() != Severity::Error)
}
#[must_use]
pub fn document(&self, name: &str) -> Option<&NamedQuadletDocument> {
let mut matching = self
.documents
.iter()
.filter(|document| document.name().as_str() == name);
let first = matching.next()?;
matching.next().is_none().then_some(first)
}
}
#[derive(Debug)]
struct SystemdRelationshipReference {
target_name: String,
kind: UnitReferenceKind,
key: SystemdUnitKey,
span: SourceSpan,
}
fn effective_systemd_unit_references(document: &QuadletDocument) -> Vec<SystemdRelationshipReference> {
let mut references: Vec<SystemdRelationshipReference> = Vec::new();
for entry in document.entries() {
let EntryKind::SystemdUnit(key) = entry.kind() else {
continue;
};
let Some(value) = logical_entry_value(entry) else {
continue;
};
if value.trim().is_empty() {
references.retain(|reference| reference.key != key);
continue;
}
let Some(tokens) = systemd_unit_tokens(&value) else {
continue;
};
references.extend(tokens.into_iter().filter_map(|target_name| {
let kind = reference_by_suffix(&target_name)?;
Some(SystemdRelationshipReference {
target_name,
kind,
key,
span: entry.value().primary().span(),
})
}));
}
references
}
fn logical_entry_value(entry: &TypedEntry) -> Option<String> {
let mut logical = String::new();
let segments = std::iter::once(entry.value().primary())
.chain(entry.value().continuations())
.collect::<Vec<_>>();
if entry.value().is_continued() && segments.last().is_none_or(|segment| segment.text().ends_with('\\')) {
return None;
}
for (index, segment) in segments.iter().enumerate() {
if !logical.is_empty() {
logical.push(' ');
}
let value = segment.text().trim_end();
let value = if index + 1 < segments.len() {
value.strip_suffix('\\').unwrap_or(value)
} else {
value
};
logical.push_str(value);
}
Some(logical)
}
fn systemd_unit_tokens(value: &str) -> Option<Vec<String>> {
let mut tokens = Vec::new();
let mut token = String::new();
let mut quote = None;
let mut escaped = false;
let mut started = false;
for character in value.chars() {
if escaped {
token.push(character);
escaped = false;
started = true;
} else if character == '\\' {
escaped = true;
started = true;
} else if matches!(character, '\'' | '"') {
if quote == Some(character) {
quote = None;
} else if quote.is_none() {
quote = Some(character);
} else {
token.push(character);
}
started = true;
} else if character.is_whitespace() && quote.is_none() {
if started {
tokens.push(std::mem::take(&mut token));
started = false;
}
} else {
token.push(character);
started = true;
}
}
if escaped || quote.is_some() {
return None;
}
if started {
tokens.push(token);
}
Some(tokens)
}
#[allow(clippy::too_many_arguments)]
fn resolve_reference(
source_document: usize,
target_name: String,
kind: UnitReferenceKind,
systemd_unit_key: Option<SystemdUnitKey>,
span: SourceSpan,
by_name: &BTreeMap<String, Vec<usize>>,
diagnostics: &mut Vec<Diagnostic>,
references: &mut Vec<UnitReference>,
edges: &mut Vec<DependencyEdge>,
) {
let candidates = by_name.get(&target_name).map_or(&[][..], Vec::as_slice);
let resolution = match candidates {
[] => {
diagnostics.push(Diagnostic::new(
MISSING_REFERENCE,
Severity::Error,
"Quadlet unit reference has no matching document",
Label::new(span, "add the referenced unit file to this document set"),
));
ReferenceResolution::Missing
}
[target_document] => {
edges.push(DependencyEdge {
source_document,
target_document: *target_document,
kind,
systemd_unit_key,
span,
});
ReferenceResolution::Resolved {
document_index: *target_document,
}
}
multiple => {
diagnostics.push(Diagnostic::new(
AMBIGUOUS_REFERENCE,
Severity::Error,
"Quadlet unit reference matches multiple documents",
Label::new(span, "make unit-file basenames unique in this document set"),
));
ReferenceResolution::Ambiguous {
candidates: multiple.len(),
}
}
};
references.push(UnitReference {
source_document,
target_name,
kind,
systemd_unit_key,
span,
resolution,
});
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum DocumentSetError {
InvalidUnitFileName(String),
UnsupportedUnitFileExtension(String),
UnitTypeMismatch {
name: String,
filename_type: QuadletUnitType,
document_type: QuadletUnitType,
},
DuplicateSourceId(SourceId),
}
impl fmt::Display for DocumentSetError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidUnitFileName(name) => write!(formatter, "invalid Quadlet unit-file basename `{name}`"),
Self::UnsupportedUnitFileExtension(name) => {
write!(formatter, "unsupported Quadlet unit-file extension in `{name}`")
}
Self::UnitTypeMismatch {
name,
filename_type,
document_type,
} => write!(
formatter,
"Quadlet filename `{name}` implies {filename_type:?}, but the document is {document_type:?}"
),
Self::DuplicateSourceId(source_id) => {
write!(formatter, "duplicate Quadlet source identity {}", source_id.get())
}
}
}
}
impl Error for DocumentSetError {}
fn ensure_unique_source_ids(documents: &[NamedQuadletDocument]) -> Result<(), DocumentSetError> {
let mut source_ids = BTreeSet::new();
for document in documents {
let source_id = document.document().source_id();
if !source_ids.insert(source_id) {
return Err(DocumentSetError::DuplicateSourceId(source_id));
}
}
Ok(())
}
fn duplicate_name_diagnostics(
documents: &[NamedQuadletDocument],
by_name: &BTreeMap<String, Vec<usize>>,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for indexes in by_name.values().filter(|indexes| indexes.len() > 1) {
for index in indexes.iter().skip(1) {
let document = &documents[*index];
diagnostics.push(Diagnostic::new(
DUPLICATE_UNIT_NAME,
Severity::Error,
"document set contains a duplicate Quadlet unit-file basename",
Label::new(
document.document().source_span(),
"give this document a unique unit-file basename",
),
));
}
}
diagnostics
}