Skip to main content

par_term/
shader_lint.rs

1//! Shader linting and readability scoring helpers.
2//!
3//! The lint pass is intentionally lightweight: it validates the par-term shader
4//! metadata/control surface and catches common configuration mismatches without
5//! requiring a GPU device.
6
7use 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}