1use std::{collections::BTreeMap, fmt};
4
5use boxferry_engine::{
6 Diagnostic, DiagnosticField, DiagnosticValue, InvalidDiagnosticCode, NativeFinding, NativeFindingLabel,
7 NativeFindingLabelKind, RuleId, Severity,
8};
9use boxferry_model::{Identifier, ModelError, SourceId};
10use compose_lens::{
11 diagnostic::{Diagnostic as ComposeDiagnostic, LabelKind, Severity as ComposeSeverity},
12 merge::MergedProject,
13 profiles::ProfileSelection,
14 render::render_canonical,
15 source::SourceId as ComposeSourceId,
16};
17
18#[derive(Clone, Eq, PartialEq)]
25pub struct CanonicalComposeDocument {
26 text: String,
27 sensitive: bool,
28}
29
30impl CanonicalComposeDocument {
31 #[must_use]
33 pub fn text(&self) -> &str {
34 &self.text
35 }
36
37 #[must_use]
39 pub const fn is_sensitive(&self) -> bool {
40 self.sensitive
41 }
42}
43
44impl fmt::Debug for CanonicalComposeDocument {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 formatter
47 .debug_struct("CanonicalComposeDocument")
48 .field("text", &"<redacted>")
49 .field("sensitive", &self.sensitive)
50 .finish()
51 }
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ComposeCanonicalization {
60 document: Option<CanonicalComposeDocument>,
61 diagnostics: Vec<Diagnostic>,
62}
63
64impl ComposeCanonicalization {
65 #[must_use]
67 pub const fn document(&self) -> Option<&CanonicalComposeDocument> {
68 self.document.as_ref()
69 }
70
71 #[must_use]
73 pub fn diagnostics(&self) -> &[Diagnostic] {
74 &self.diagnostics
75 }
76
77 #[must_use]
79 pub fn into_parts(self) -> (Option<CanonicalComposeDocument>, Vec<Diagnostic>) {
80 (self.document, self.diagnostics)
81 }
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86#[non_exhaustive]
87pub enum ComposeFindingStage {
88 Load,
90 Interpolation,
92 Merge,
94 ProfileSelection,
96 ProjectModel,
98 Validation,
100 Rendering,
102}
103
104impl ComposeFindingStage {
105 pub(crate) const fn as_str(self) -> &'static str {
106 match self {
107 Self::Load => "load",
108 Self::Interpolation => "interpolation",
109 Self::Merge => "merge",
110 Self::ProfileSelection => "profile-selection",
111 Self::ProjectModel => "project-model",
112 Self::Validation => "validation",
113 Self::Rendering => "rendering",
114 }
115 }
116
117 #[must_use]
119 pub fn native_finding(self, diagnostic: &ComposeDiagnostic) -> NativeFinding {
120 let severity = match diagnostic.severity() {
121 ComposeSeverity::Error => Severity::Error,
122 ComposeSeverity::Warning => Severity::Warning,
123 ComposeSeverity::Note => Severity::Note,
124 };
125 let mut finding = NativeFinding::new(
126 "compose",
127 "compose-lens",
128 diagnostic.code().as_str(),
129 self.as_str(),
130 severity,
131 diagnostic.message(),
132 );
133 if let Some(variable) = interpolation_variable(diagnostic.code().as_str(), diagnostic.message()) {
134 finding = finding.with_field(DiagnosticField::new("variable", DiagnosticValue::plain(variable)));
135 }
136 for label in diagnostic.labels() {
137 finding = finding.with_label(NativeFindingLabel::new(
138 match label.kind() {
139 LabelKind::Primary => NativeFindingLabelKind::Primary,
140 LabelKind::Secondary => NativeFindingLabelKind::Secondary,
141 },
142 label.span().source_id().get(),
143 label.span().start(),
144 label.span().end(),
145 label.message(),
146 ));
147 }
148 for note in diagnostic.notes() {
149 finding = finding.with_note(note);
150 }
151 finding
152 }
153}
154
155#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct ComposeSource {
158 project: MergedProject,
159 fallback_application_name: Identifier,
160 source_ids: BTreeMap<ComposeSourceId, SourceId>,
161 profile_selection: Option<ProfileSelection>,
162 native_findings: Vec<NativeFinding>,
163}
164
165impl ComposeSource {
166 pub fn new(project: MergedProject, fallback_application_name: Identifier) -> Result<Self, ModelError> {
180 let source_ids = project
181 .source_ids()
182 .iter()
183 .copied()
184 .map(|source_id| {
185 SourceId::new(format!("compose-source-{}", source_id.get())).map(|neutral| (source_id, neutral))
186 })
187 .collect::<Result<_, _>>()?;
188 Ok(Self {
189 project,
190 fallback_application_name,
191 source_ids,
192 profile_selection: None,
193 native_findings: Vec::new(),
194 })
195 }
196
197 #[must_use]
202 pub fn with_source_id(mut self, compose: ComposeSourceId, neutral: SourceId) -> Self {
203 self.source_ids.insert(compose, neutral);
204 self
205 }
206
207 #[must_use]
209 pub fn with_profile_selection(mut self, selection: ProfileSelection) -> Self {
210 self.profile_selection = Some(selection);
211 self
212 }
213
214 #[must_use]
219 pub fn with_native_diagnostics<'a>(
220 mut self,
221 stage: ComposeFindingStage,
222 diagnostics: impl IntoIterator<Item = &'a ComposeDiagnostic>,
223 ) -> Self {
224 self.native_findings.extend(
225 diagnostics
226 .into_iter()
227 .map(|diagnostic| stage.native_finding(diagnostic)),
228 );
229 self
230 }
231
232 #[must_use]
234 pub const fn project(&self) -> &MergedProject {
235 &self.project
236 }
237
238 #[must_use]
240 pub const fn fallback_application_name(&self) -> &Identifier {
241 &self.fallback_application_name
242 }
243
244 #[must_use]
246 pub fn source_id(&self, compose: ComposeSourceId) -> Option<&SourceId> {
247 self.source_ids.get(&compose)
248 }
249
250 #[must_use]
252 pub const fn profile_selection(&self) -> Option<&ProfileSelection> {
253 self.profile_selection.as_ref()
254 }
255
256 #[must_use]
258 pub fn native_findings(&self) -> &[NativeFinding] {
259 &self.native_findings
260 }
261
262 pub fn canonicalize(&self) -> Result<ComposeCanonicalization, InvalidDiagnosticCode> {
274 let rendered = render_canonical(self.project(), self.profile_selection());
275 let mut findings = self.native_findings.clone();
276 findings.extend(
277 rendered
278 .diagnostics()
279 .iter()
280 .map(|diagnostic| ComposeFindingStage::Rendering.native_finding(diagnostic)),
281 );
282 let diagnostics = findings
283 .into_iter()
284 .map(compose_native_diagnostic)
285 .collect::<Result<Vec<_>, _>>()?;
286 let valid = rendered.is_valid()
287 && diagnostics
288 .iter()
289 .all(|diagnostic| diagnostic.severity() != Severity::Error);
290 let sensitive = rendered.is_sensitive();
291 let document = valid.then(|| CanonicalComposeDocument {
292 text: rendered.into_output(),
293 sensitive,
294 });
295 Ok(ComposeCanonicalization { document, diagnostics })
296 }
297}
298
299pub(crate) fn native_project_finding(diagnostic: &ComposeDiagnostic) -> NativeFinding {
300 ComposeFindingStage::ProjectModel.native_finding(diagnostic)
301}
302
303fn compose_native_diagnostic(finding: NativeFinding) -> Result<Diagnostic, InvalidDiagnosticCode> {
304 let rule = match finding.code() {
305 "compose.interpolation.unset-variable" => RuleId::ComposeUnsetVariable,
306 "compose.interpolation.required-variable" => RuleId::ComposeRequiredVariable,
307 "compose.interpolation.invalid-expression" => RuleId::ComposeInterpolationInvalid,
308 "compose.interpolation.nesting-limit" => RuleId::ComposeInterpolationNestingLimit,
309 _ => match finding.severity() {
310 Severity::Error => RuleId::ComposeNativeError,
311 Severity::Note => RuleId::ComposeNativeNote,
312 _ => RuleId::ComposeNativeWarning,
313 },
314 };
315 Ok(Diagnostic::new(
316 rule.definition().diagnostic_code()?,
317 finding.severity(),
318 "ComposeLens reported a native Compose finding",
319 )
320 .with_native_finding(finding))
321}
322
323fn interpolation_variable<'a>(code: &str, message: &'a str) -> Option<&'a str> {
324 if !matches!(
325 code,
326 "compose.interpolation.unset-variable" | "compose.interpolation.required-variable"
327 ) {
328 return None;
329 }
330 let (_, remainder) = message.split_once('`')?;
331 let (variable, _) = remainder.split_once('`')?;
332 let mut bytes = variable.bytes();
333 let first = bytes.next()?;
334 ((first == b'_' || first.is_ascii_alphabetic()) && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()))
335 .then_some(variable)
336}