1use std::collections::BTreeSet;
8use std::io::{self, Write};
9use std::path::Path;
10
11const METADATA_MARKER: &str = "/*! par-term shader metadata";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LintSeverity {
15 Error,
16 Warning,
17 Info,
18}
19
20impl LintSeverity {
21 fn label(self) -> &'static str {
22 match self {
23 Self::Error => "error",
24 Self::Warning => "warning",
25 Self::Info => "info",
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LintDiagnostic {
32 pub severity: LintSeverity,
33 pub line: Option<usize>,
34 pub message: String,
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct ShaderLintReport {
39 pub diagnostics: Vec<LintDiagnostic>,
40 pub metadata_present: bool,
41 pub control_count: usize,
42}
43
44impl ShaderLintReport {
45 pub fn has_errors(&self) -> bool {
46 self.diagnostics
47 .iter()
48 .any(|diagnostic| diagnostic.severity == LintSeverity::Error)
49 }
50
51 fn push(&mut self, severity: LintSeverity, line: Option<usize>, message: impl Into<String>) {
52 self.diagnostics.push(LintDiagnostic {
53 severity,
54 line,
55 message: message.into(),
56 });
57 }
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct ReadabilityScore {
62 pub score: u8,
63 pub suggested_brightness: f32,
64 pub suggested_text_opacity: f32,
65 pub notes: Vec<String>,
66}
67
68#[derive(Debug, Clone, Copy, Default, PartialEq)]
69pub struct ReadabilityCurrentSettings {
70 pub brightness: Option<f32>,
71 pub text_opacity: Option<f32>,
72}
73
74pub fn lint_shader_source(source: &str) -> ShaderLintReport {
75 let mut report = ShaderLintReport::default();
76
77 if !source.contains("mainImage") {
78 report.push(
79 LintSeverity::Error,
80 None,
81 "Shader must define a Shadertoy-style mainImage function",
82 );
83 }
84
85 let metadata = match extract_metadata_yaml(source) {
86 MetadataYaml::Absent => {
87 report.push(
88 LintSeverity::Warning,
89 None,
90 "Missing par-term shader metadata block",
91 );
92 None
93 }
94 MetadataYaml::Unterminated => {
95 report.metadata_present = true;
96 report.push(
97 LintSeverity::Error,
98 None,
99 "Unterminated par-term shader metadata block",
100 );
101 None
102 }
103 MetadataYaml::Present(yaml) => {
104 report.metadata_present = true;
105 match serde_yaml_ng::from_str::<par_term_config::ShaderMetadata>(yaml) {
106 Ok(metadata) => Some(metadata),
107 Err(error) => {
108 report.push(
109 LintSeverity::Error,
110 None,
111 format!("Invalid shader metadata YAML: {error}"),
112 );
113 None
114 }
115 }
116 }
117 };
118
119 validate_metadata_defaults(metadata.as_ref(), &mut report);
120 validate_channel_references(source, metadata.as_ref(), &mut report);
121 validate_controls(source, &mut report);
122
123 report
124}
125
126pub fn lint_shader_file(path: &Path) -> Result<ShaderLintReport, String> {
127 let source = std::fs::read_to_string(path)
128 .map_err(|error| format!("Failed to read shader '{}': {error}", path.display()))?;
129 Ok(lint_shader_source(&source))
130}
131
132pub fn score_shader_readability(source: &str) -> ReadabilityScore {
133 let lower = source.to_lowercase();
134 let color_estimate = estimate_color_brightness(source);
135 let mut penalty = 0_i32;
136 let mut notes = Vec::new();
137
138 if color_estimate.max_component >= 0.85 {
139 penalty += 30;
140 notes.push("bright shader output can reduce contrast behind text".to_string());
141 } else if color_estimate.max_component >= 0.65 {
142 penalty += 15;
143 notes.push("moderately bright shader output may need dimming".to_string());
144 }
145
146 if color_estimate.average_component >= 0.60 {
147 penalty += 15;
148 notes.push("high average luminance leaves less contrast headroom".to_string());
149 }
150
151 if lower.contains("itime") {
152 penalty += 10;
153 notes.push("animation can distract during dense terminal reading".to_string());
154 }
155
156 if lower.contains("sin(") || lower.contains("cos(") || lower.contains("tan(") {
157 penalty += 5;
158 }
159
160 if lower.contains("noise") || lower.contains("random") || lower.contains("hash") {
161 penalty += 10;
162 notes.push("noise/random patterns can create busy backgrounds".to_string());
163 }
164
165 if has_high_frequency_time_factor(&lower) {
166 penalty += 10;
167 notes.push("fast time modulation may cause visible flicker".to_string());
168 }
169
170 if lower.contains("ichannel4") || lower.contains("fragcoord") && lower.contains("+") {
171 penalty += 8;
172 notes.push(
173 "terminal-content sampling or coordinate distortion can affect glyph clarity"
174 .to_string(),
175 );
176 }
177
178 let score = (100 - penalty).clamp(0, 100) as u8;
179 let (suggested_brightness, suggested_text_opacity) = match score {
180 85..=100 => (0.75, 0.90),
181 70..=84 => (0.60, 0.93),
182 55..=69 => (0.45, 0.96),
183 _ => (0.30, 1.00),
184 };
185
186 if notes.is_empty() {
187 notes.push("low-distraction source-level readability profile".to_string());
188 }
189
190 ReadabilityScore {
191 score,
192 suggested_brightness,
193 suggested_text_opacity,
194 notes,
195 }
196}
197
198pub fn format_lint_report(
199 path: &Path,
200 report: &ShaderLintReport,
201 readability: Option<&ReadabilityScore>,
202) -> String {
203 format_lint_report_with_current_settings(
204 path,
205 report,
206 readability,
207 ReadabilityCurrentSettings::default(),
208 )
209}
210
211pub fn format_lint_report_with_current_settings(
212 path: &Path,
213 report: &ShaderLintReport,
214 readability: Option<&ReadabilityScore>,
215 current: ReadabilityCurrentSettings,
216) -> String {
217 let mut output = String::new();
218 output.push_str(&format!("Shader lint: {}\n", path.display()));
219
220 if report.diagnostics.is_empty() {
221 output.push_str("No lint issues found.\n");
222 } else {
223 for diagnostic in &report.diagnostics {
224 let location = diagnostic
225 .line
226 .map(|line| format!(":{line}"))
227 .unwrap_or_default();
228 output.push_str(&format!(
229 "{}{}: {}: {}\n",
230 path.display(),
231 location,
232 diagnostic.severity.label(),
233 diagnostic.message
234 ));
235 }
236 }
237
238 output.push_str(&format!(
239 "Metadata: {}\n",
240 if report.metadata_present {
241 "present"
242 } else {
243 "missing"
244 }
245 ));
246 output.push_str(&format!("Controls: {}\n", report.control_count));
247
248 if let Some(readability) = readability {
249 output.push_str(&format!("Readability: {}/100\n", readability.score));
250 append_readability_recommendations(&mut output, readability, current);
251 if !readability.notes.is_empty() {
252 output.push_str("Notes:\n");
253 for note in &readability.notes {
254 output.push_str(&format!(" - {note}\n"));
255 }
256 }
257 }
258
259 output
260}
261
262pub fn apply_readability_defaults(path: &Path, score: &ReadabilityScore) -> Result<(), String> {
263 let source = std::fs::read_to_string(path)
264 .map_err(|error| format!("Failed to read shader '{}': {error}", path.display()))?;
265 let mut metadata = par_term_config::parse_shader_metadata(&source).unwrap_or_default();
266 metadata.defaults.brightness = Some(score.suggested_brightness);
267 metadata.defaults.text_opacity = Some(score.suggested_text_opacity);
268 par_term_config::update_shader_metadata_file(path, &metadata)
269}
270
271pub fn shader_lint_settings_report(
272 path: &Path,
273 current_brightness: Option<f32>,
274 current_text_opacity: Option<f32>,
275) -> Result<String, String> {
276 let source = std::fs::read_to_string(path)
277 .map_err(|error| format!("Failed to read shader '{}': {error}", path.display()))?;
278 let report = lint_shader_source(&source);
279 let readability = score_shader_readability(&source);
280 let metadata_current = current_settings_from_metadata(&source);
281 let current = ReadabilityCurrentSettings {
282 brightness: current_brightness.or(metadata_current.brightness),
283 text_opacity: current_text_opacity.or(metadata_current.text_opacity),
284 };
285 Ok(format_lint_report_with_current_settings(
286 path,
287 &report,
288 Some(&readability),
289 current,
290 ))
291}
292
293pub fn shader_lint_cli(
294 path: &Path,
295 include_readability: bool,
296 apply: bool,
297 prompt_to_apply: bool,
298) -> anyhow::Result<()> {
299 let source = std::fs::read_to_string(path)?;
300 let report = lint_shader_source(&source);
301 let readability = (include_readability || apply).then(|| score_shader_readability(&source));
302 let current = current_settings_from_metadata(&source);
303 print!(
304 "{}",
305 format_lint_report_with_current_settings(path, &report, readability.as_ref(), current)
306 );
307
308 if report.has_errors() {
309 return Err(anyhow::anyhow!("shader lint failed"));
310 }
311
312 if let Some(score) = readability.as_ref()
313 && has_readability_recommendations(score, current)
314 {
315 let should_apply = apply || (prompt_to_apply && prompt_user_to_apply()?);
316 if should_apply {
317 apply_readability_defaults(path, score).map_err(|error| anyhow::anyhow!(error))?;
318 println!(
319 "Applied suggested readability defaults to {}",
320 path.display()
321 );
322 }
323 }
324
325 Ok(())
326}
327
328fn current_settings_from_metadata(source: &str) -> ReadabilityCurrentSettings {
329 let Some(metadata) = par_term_config::parse_shader_metadata(source) else {
330 return ReadabilityCurrentSettings::default();
331 };
332
333 ReadabilityCurrentSettings {
334 brightness: metadata.defaults.brightness,
335 text_opacity: metadata.defaults.text_opacity,
336 }
337}
338
339fn append_readability_recommendations(
340 output: &mut String,
341 readability: &ReadabilityScore,
342 current: ReadabilityCurrentSettings,
343) {
344 let recommend_brightness =
345 recommendation_needed(current.brightness, readability.suggested_brightness);
346 let recommend_text_opacity =
347 recommendation_needed(current.text_opacity, readability.suggested_text_opacity);
348
349 if !recommend_brightness && !recommend_text_opacity {
350 output.push_str("Suggested defaults: already match current settings\n");
351 return;
352 }
353
354 output.push_str("Suggested defaults:\n");
355 if recommend_brightness {
356 output.push_str(&format!(
357 " custom_shader_brightness = {:.2}\n",
358 readability.suggested_brightness
359 ));
360 }
361 if recommend_text_opacity {
362 output.push_str(&format!(
363 " custom_shader_text_opacity = {:.2}\n",
364 readability.suggested_text_opacity
365 ));
366 }
367}
368
369fn has_readability_recommendations(
370 readability: &ReadabilityScore,
371 current: ReadabilityCurrentSettings,
372) -> bool {
373 recommendation_needed(current.brightness, readability.suggested_brightness)
374 || recommendation_needed(current.text_opacity, readability.suggested_text_opacity)
375}
376
377fn recommendation_needed(current: Option<f32>, suggested: f32) -> bool {
378 const EPSILON: f32 = 0.005;
379 current.is_none_or(|value| (value - suggested).abs() > EPSILON)
380}
381
382fn prompt_user_to_apply() -> anyhow::Result<bool> {
383 print!("Apply suggested readability defaults to shader metadata? [y/N] ");
384 io::stdout().flush()?;
385
386 let mut response = String::new();
387 io::stdin().read_line(&mut response)?;
388 Ok(matches!(
389 response.trim().to_ascii_lowercase().as_str(),
390 "y" | "yes"
391 ))
392}
393
394enum MetadataYaml<'a> {
395 Absent,
396 Unterminated,
397 Present(&'a str),
398}
399
400fn extract_metadata_yaml(source: &str) -> MetadataYaml<'_> {
401 let Some(start_marker) = source.find(METADATA_MARKER) else {
402 return MetadataYaml::Absent;
403 };
404
405 let Some(yaml_start_offset) = source[start_marker + METADATA_MARKER.len()..].find('\n') else {
406 return MetadataYaml::Unterminated;
407 };
408 let yaml_start = start_marker + METADATA_MARKER.len() + yaml_start_offset + 1;
409
410 let Some(yaml_end_offset) = source[yaml_start..].find("*/") else {
411 return MetadataYaml::Unterminated;
412 };
413
414 MetadataYaml::Present(source[yaml_start..yaml_start + yaml_end_offset].trim())
415}
416
417fn validate_metadata_defaults(
418 metadata: Option<&par_term_config::ShaderMetadata>,
419 report: &mut ShaderLintReport,
420) {
421 let Some(metadata) = metadata else {
422 return;
423 };
424
425 if let Some(brightness) = metadata.defaults.brightness
426 && (!brightness.is_finite() || !(0.05..=1.0).contains(&brightness))
427 {
428 report.push(
429 LintSeverity::Warning,
430 None,
431 "defaults.brightness should be a finite value in 0.05..=1.0",
432 );
433 }
434
435 if let Some(text_opacity) = metadata.defaults.text_opacity
436 && (!text_opacity.is_finite() || !(0.0..=1.0).contains(&text_opacity))
437 {
438 report.push(
439 LintSeverity::Warning,
440 None,
441 "defaults.text_opacity should be a finite value in 0.0..=1.0",
442 );
443 }
444
445 if let Some(animation_speed) = metadata.defaults.animation_speed
446 && (!animation_speed.is_finite() || animation_speed <= 0.0)
447 {
448 report.push(
449 LintSeverity::Warning,
450 None,
451 "defaults.animation_speed should be a finite positive value",
452 );
453 }
454}
455
456fn validate_channel_references(
457 source: &str,
458 metadata: Option<&par_term_config::ShaderMetadata>,
459 report: &mut ShaderLintReport,
460) {
461 let references = referenced_channels(source);
462 let defaults = metadata.map(|metadata| &metadata.defaults);
463
464 for channel in references {
465 match channel {
466 0..=3 => {
467 let configured = defaults.is_some_and(|defaults| match channel {
468 0 => {
469 defaults.channel0.is_some()
470 || defaults.use_background_as_channel0 == Some(true)
471 }
472 1 => defaults.channel1.is_some(),
473 2 => defaults.channel2.is_some(),
474 3 => defaults.channel3.is_some(),
475 _ => false,
476 });
477 if !configured {
478 report.push(
479 LintSeverity::Warning,
480 None,
481 format!(
482 "Shader references iChannel{channel}, but metadata defaults.channel{channel} is not set"
483 ),
484 );
485 }
486 }
487 4 if defaults.is_none_or(|defaults| defaults.full_content != Some(true)) => {
488 report.push(
489 LintSeverity::Warning,
490 None,
491 "Shader references iChannel4; set metadata defaults.full_content: true when terminal content sampling is required",
492 );
493 }
494 _ => {}
495 }
496 }
497
498 if source.contains("iCubemap") {
499 let configured = defaults.is_some_and(|defaults| {
500 defaults.cubemap.is_some() && defaults.cubemap_enabled != Some(false)
501 });
502 if !configured {
503 report.push(
504 LintSeverity::Warning,
505 None,
506 "Shader references iCubemap, but metadata defaults.cubemap is not set or cubemap is disabled",
507 );
508 }
509 }
510}
511
512fn validate_controls(source: &str, report: &mut ShaderLintReport) {
513 let control_parse = par_term_config::parse_shader_controls(source);
514 report.control_count = control_parse.controls.len();
515 for warning in control_parse.warnings {
516 report.push(LintSeverity::Warning, Some(warning.line), warning.message);
517 }
518}
519
520fn referenced_channels(source: &str) -> BTreeSet<u8> {
521 let mut references = BTreeSet::new();
522 for channel in 0_u8..=4 {
523 if source.contains(&format!("iChannel{channel}")) {
524 references.insert(channel);
525 }
526 }
527 references
528}
529
530#[derive(Debug, Clone, Copy)]
531struct ColorEstimate {
532 max_component: f32,
533 average_component: f32,
534}
535
536fn estimate_color_brightness(source: &str) -> ColorEstimate {
537 let mut values = Vec::new();
538 for constructor in ["vec3(", "vec4("] {
539 let mut remaining = source;
540 while let Some(index) = remaining.find(constructor) {
541 let start = index + constructor.len();
542 let Some(end) = remaining[start..].find(')') else {
543 break;
544 };
545 let args = &remaining[start..start + end];
546 values.extend(
547 args.split(',')
548 .take(3)
549 .filter_map(parse_normalized_float_prefix),
550 );
551 remaining = &remaining[start + end + 1..];
552 }
553 }
554
555 if values.is_empty() {
556 values.extend(
557 source
558 .split(|ch: char| !(ch.is_ascii_digit() || ch == '.'))
559 .filter_map(|token| {
560 token.parse::<f32>().ok().filter(|value| {
561 value.is_finite() && (0.0..=1.0).contains(value) && *value != 0.0
562 })
563 }),
564 );
565 }
566
567 if values.is_empty() {
568 return ColorEstimate {
569 max_component: 0.5,
570 average_component: 0.5,
571 };
572 }
573
574 let max_component = values.iter().copied().fold(0.0, f32::max);
575 let average_component = values.iter().sum::<f32>() / values.len() as f32;
576 ColorEstimate {
577 max_component,
578 average_component,
579 }
580}
581
582fn parse_normalized_float_prefix(value: &str) -> Option<f32> {
583 let token: String = value
584 .trim_start()
585 .chars()
586 .take_while(|ch| ch.is_ascii_digit() || *ch == '.')
587 .collect();
588 token
589 .parse::<f32>()
590 .ok()
591 .filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
592}
593
594fn has_high_frequency_time_factor(source: &str) -> bool {
595 source.contains("itime * 8.")
596 || source.contains("itime*8.")
597 || source.contains("itime * 9")
598 || source.contains("itime*9")
599 || source.contains("itime * 10")
600 || source.contains("itime*10")
601 || source.contains("itime * 20")
602 || source.contains("itime*20")
603}