1use std::collections::BTreeMap;
4
5use boxferry_engine::{
6 DiagnosticField, DiagnosticValue, NativeFinding, NativeFindingLabel, NativeFindingLabelKind, Severity,
7};
8use boxferry_model::{Identifier, ModelError, SourceId};
9use compose_lens::{
10 diagnostic::{Diagnostic as ComposeDiagnostic, LabelKind, Severity as ComposeSeverity},
11 merge::MergedProject,
12 profiles::ProfileSelection,
13 source::SourceId as ComposeSourceId,
14};
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18#[non_exhaustive]
19pub enum ComposeFindingStage {
20 Load,
22 Interpolation,
24 Merge,
26 ProfileSelection,
28 ProjectModel,
30 Validation,
32}
33
34impl ComposeFindingStage {
35 pub(crate) const fn as_str(self) -> &'static str {
36 match self {
37 Self::Load => "load",
38 Self::Interpolation => "interpolation",
39 Self::Merge => "merge",
40 Self::ProfileSelection => "profile-selection",
41 Self::ProjectModel => "project-model",
42 Self::Validation => "validation",
43 }
44 }
45
46 #[must_use]
48 pub fn native_finding(self, diagnostic: &ComposeDiagnostic) -> NativeFinding {
49 let severity = match diagnostic.severity() {
50 ComposeSeverity::Error => Severity::Error,
51 ComposeSeverity::Warning => Severity::Warning,
52 ComposeSeverity::Note => Severity::Note,
53 };
54 let mut finding = NativeFinding::new(
55 "compose",
56 "compose-lens",
57 diagnostic.code().as_str(),
58 self.as_str(),
59 severity,
60 diagnostic.message(),
61 );
62 if let Some(variable) = interpolation_variable(diagnostic.code().as_str(), diagnostic.message()) {
63 finding = finding.with_field(DiagnosticField::new("variable", DiagnosticValue::plain(variable)));
64 }
65 for label in diagnostic.labels() {
66 finding = finding.with_label(NativeFindingLabel::new(
67 match label.kind() {
68 LabelKind::Primary => NativeFindingLabelKind::Primary,
69 LabelKind::Secondary => NativeFindingLabelKind::Secondary,
70 },
71 label.span().source_id().get(),
72 label.span().start(),
73 label.span().end(),
74 label.message(),
75 ));
76 }
77 for note in diagnostic.notes() {
78 finding = finding.with_note(note);
79 }
80 finding
81 }
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct ComposeSource {
87 project: MergedProject,
88 fallback_application_name: Identifier,
89 source_ids: BTreeMap<ComposeSourceId, SourceId>,
90 profile_selection: Option<ProfileSelection>,
91 native_findings: Vec<NativeFinding>,
92 deferred_options: crate::QuadletDeferredOptions,
93 interpolation_applied: bool,
94}
95
96impl ComposeSource {
97 #[must_use]
101 pub const fn with_interpolation_applied(mut self) -> Self {
102 self.interpolation_applied = true;
103 self
104 }
105 #[must_use]
107 pub const fn interpolation_applied(&self) -> bool {
108 self.interpolation_applied
109 }
110
111 #[must_use]
116 pub fn with_deferred_options(mut self, options: crate::QuadletDeferredOptions) -> Self {
117 self.deferred_options = options;
118 self
119 }
120 #[must_use]
122 pub const fn deferred_options(&self) -> &crate::QuadletDeferredOptions {
123 &self.deferred_options
124 }
125
126 pub fn new(project: MergedProject, fallback_application_name: Identifier) -> Result<Self, ModelError> {
142 let source_ids = project
143 .source_ids()
144 .iter()
145 .copied()
146 .map(|source_id| {
147 SourceId::new(format!("compose-source-{}", source_id.get())).map(|neutral| (source_id, neutral))
148 })
149 .collect::<Result<_, _>>()?;
150 Ok(Self {
151 project,
152 fallback_application_name,
153 source_ids,
154 profile_selection: None,
155 native_findings: Vec::new(),
156 deferred_options: crate::QuadletDeferredOptions::new(),
157 interpolation_applied: false,
158 })
159 }
160
161 #[must_use]
166 pub fn with_source_id(mut self, compose: ComposeSourceId, neutral: SourceId) -> Self {
167 self.source_ids.insert(compose, neutral);
168 self
169 }
170
171 #[must_use]
173 pub fn with_profile_selection(mut self, selection: ProfileSelection) -> Self {
174 self.profile_selection = Some(selection);
175 self
176 }
177
178 #[must_use]
183 pub fn with_native_diagnostics<'a>(
184 mut self,
185 stage: ComposeFindingStage,
186 diagnostics: impl IntoIterator<Item = &'a ComposeDiagnostic>,
187 ) -> Self {
188 self.native_findings.extend(
189 diagnostics
190 .into_iter()
191 .map(|diagnostic| stage.native_finding(diagnostic)),
192 );
193 self
194 }
195
196 #[must_use]
198 pub const fn project(&self) -> &MergedProject {
199 &self.project
200 }
201
202 #[must_use]
204 pub const fn fallback_application_name(&self) -> &Identifier {
205 &self.fallback_application_name
206 }
207
208 #[must_use]
210 pub fn source_id(&self, compose: ComposeSourceId) -> Option<&SourceId> {
211 self.source_ids.get(&compose)
212 }
213
214 #[must_use]
216 pub const fn profile_selection(&self) -> Option<&ProfileSelection> {
217 self.profile_selection.as_ref()
218 }
219
220 #[must_use]
222 pub fn native_findings(&self) -> &[NativeFinding] {
223 &self.native_findings
224 }
225}
226
227pub(crate) fn native_project_finding(diagnostic: &ComposeDiagnostic) -> NativeFinding {
228 ComposeFindingStage::ProjectModel.native_finding(diagnostic)
229}
230
231fn interpolation_variable<'a>(code: &str, message: &'a str) -> Option<&'a str> {
232 if !matches!(
233 code,
234 "compose.interpolation.unset-variable" | "compose.interpolation.required-variable"
235 ) {
236 return None;
237 }
238 let (_, remainder) = message.split_once('`')?;
239 let (variable, _) = remainder.split_once('`')?;
240 let mut bytes = variable.bytes();
241 let first = bytes.next()?;
242 ((first == b'_' || first.is_ascii_alphabetic()) && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()))
243 .then_some(variable)
244}