Skip to main content

perl_lsp_perltidy/
lib.rs

1//! Perltidy integration for code formatting.
2//!
3//! This crate isolates Perl formatting concerns behind a small API so the
4//! broader tooling crate can focus on composition rather than formatter
5//! implementation details.
6
7#![deny(unsafe_code)]
8#![cfg_attr(test, allow(clippy::panic, clippy::unwrap_used, clippy::expect_used))]
9#![warn(rust_2018_idioms)]
10#![warn(missing_docs)]
11#![warn(clippy::all)]
12
13use perl_subprocess_runtime::SubprocessRuntime;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::Path;
17use std::sync::Arc;
18
19/// Configuration for perltidy.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PerlTidyConfig {
22    /// Maximum line length.
23    pub maximum_line_length: Option<u32>,
24    /// Indent size (spaces).
25    pub indent_columns: Option<u32>,
26    /// Use tabs instead of spaces.
27    pub tabs: Option<bool>,
28    /// Opening brace on same line.
29    pub opening_brace_on_new_line: Option<bool>,
30    /// Cuddled else.
31    pub cuddled_else: Option<bool>,
32    /// Space after keyword.
33    pub space_after_keyword: Option<bool>,
34    /// Add trailing commas.
35    pub add_trailing_commas: Option<bool>,
36    /// Vertical alignment.
37    pub vertical_alignment: Option<bool>,
38    /// Block comment indentation.
39    pub block_comment_indentation: Option<u32>,
40    /// Custom perltidyrc file path.
41    pub profile: Option<String>,
42    /// Additional command line arguments.
43    pub extra_args: Vec<String>,
44    /// Timeout in seconds for the perltidy subprocess. Default: 10.
45    pub timeout_secs: u64,
46}
47
48impl Default for PerlTidyConfig {
49    fn default() -> Self {
50        Self {
51            maximum_line_length: Some(80),
52            indent_columns: Some(4),
53            tabs: Some(false),
54            opening_brace_on_new_line: Some(false),
55            cuddled_else: Some(true),
56            space_after_keyword: Some(true),
57            add_trailing_commas: Some(false),
58            vertical_alignment: Some(true),
59            block_comment_indentation: Some(0),
60            profile: None,
61            extra_args: Vec::new(),
62            timeout_secs: 10,
63        }
64    }
65}
66
67impl PerlTidyConfig {
68    /// Create a config for PBP (Perl Best Practices) style.
69    #[must_use]
70    pub fn pbp() -> Self {
71        Self {
72            maximum_line_length: Some(78),
73            indent_columns: Some(4),
74            tabs: Some(false),
75            opening_brace_on_new_line: Some(false),
76            cuddled_else: Some(false),
77            space_after_keyword: Some(true),
78            add_trailing_commas: Some(true),
79            vertical_alignment: Some(true),
80            block_comment_indentation: Some(0),
81            profile: None,
82            extra_args: vec!["--perl-best-practices".to_string()],
83            timeout_secs: 10,
84        }
85    }
86
87    /// Create a config for GNU style.
88    #[must_use]
89    pub fn gnu() -> Self {
90        Self {
91            maximum_line_length: Some(79),
92            indent_columns: Some(2),
93            tabs: Some(false),
94            opening_brace_on_new_line: Some(true),
95            cuddled_else: Some(false),
96            space_after_keyword: Some(true),
97            add_trailing_commas: Some(false),
98            vertical_alignment: Some(false),
99            block_comment_indentation: Some(2),
100            profile: None,
101            extra_args: vec!["--gnu-style".to_string()],
102            timeout_secs: 10,
103        }
104    }
105
106    /// Convert the configuration to `perltidy` command-line arguments.
107    #[must_use]
108    pub fn to_args(&self) -> Vec<String> {
109        let mut args = Vec::new();
110
111        if let Some(profile) = &self.profile {
112            args.push(format!("--profile={profile}"));
113            return args;
114        }
115
116        if let Some(len) = self.maximum_line_length {
117            args.push(format!("--maximum-line-length={len}"));
118        }
119
120        if let Some(indent) = self.indent_columns {
121            args.push(format!("--indent-columns={indent}"));
122        }
123
124        if let Some(tabs) = self.tabs {
125            if tabs {
126                args.push("--tabs".to_string());
127            } else {
128                args.push("--notabs".to_string());
129            }
130        }
131
132        if let Some(brace) = self.opening_brace_on_new_line {
133            if brace {
134                args.push("--opening-brace-on-new-line".to_string());
135            } else {
136                args.push("--opening-brace-always-on-right".to_string());
137            }
138        }
139
140        if let Some(cuddle) = self.cuddled_else {
141            if cuddle {
142                args.push("--cuddled-else".to_string());
143            } else {
144                args.push("--nocuddled-else".to_string());
145            }
146        }
147
148        if let Some(space) = self.space_after_keyword {
149            if space {
150                args.push("--space-after-keyword".to_string());
151            } else {
152                args.push("--nospace-after-keyword".to_string());
153            }
154        }
155
156        if let Some(comma) = self.add_trailing_commas {
157            if comma {
158                args.push("--add-trailing-commas".to_string());
159            } else {
160                args.push("--no-add-trailing-commas".to_string());
161            }
162        }
163
164        if let Some(align) = self.vertical_alignment {
165            if align {
166                args.push("--vertical-alignment".to_string());
167            } else {
168                args.push("--no-vertical-alignment".to_string());
169            }
170        }
171
172        if let Some(indent) = self.block_comment_indentation {
173            args.push(format!("--block-comment-indentation={indent}"));
174        }
175
176        args.extend(self.extra_args.clone());
177        args
178    }
179}
180
181/// Perltidy formatter.
182pub struct PerlTidyFormatter {
183    config: PerlTidyConfig,
184    cache: HashMap<String, String>,
185    runtime: Arc<dyn SubprocessRuntime>,
186}
187
188impl PerlTidyFormatter {
189    /// Creates a new formatter with the given configuration and runtime.
190    #[must_use]
191    pub fn new(config: PerlTidyConfig, runtime: Arc<dyn SubprocessRuntime>) -> Self {
192        Self { config, cache: HashMap::new(), runtime }
193    }
194
195    /// Creates a new formatter with the OS subprocess runtime (non-WASM only).
196    #[cfg(not(target_arch = "wasm32"))]
197    #[must_use]
198    pub fn with_os_runtime(config: PerlTidyConfig) -> Self {
199        use perl_subprocess_runtime::OsSubprocessRuntime;
200        let timeout = config.timeout_secs;
201        Self::new(config, Arc::new(OsSubprocessRuntime::with_timeout(timeout)))
202    }
203
204    /// Format Perl code.
205    pub fn format(&mut self, code: &str) -> Result<String, String> {
206        if let Some(cached) = self.cache.get(code) {
207            return Ok(cached.clone());
208        }
209
210        let mut args = self.config.to_args();
211        args.push("-st".to_string());
212        let args_refs: Vec<&str> = args.iter().map(String::as_str).collect();
213
214        let output = self
215            .runtime
216            .run_command("perltidy", &args_refs, Some(code.as_bytes()))
217            .map_err(|e| e.message)?;
218
219        if !output.success() {
220            return Err(format!("Perltidy failed: {}", output.stderr_lossy()));
221        }
222
223        let formatted = String::from_utf8(output.stdout)
224            .map_err(|e| format!("Invalid UTF-8 from perltidy: {e}"))?;
225        self.cache.insert(code.to_string(), formatted.clone());
226        Ok(formatted)
227    }
228
229    /// Format a file in place.
230    pub fn format_file(&self, file_path: &Path) -> Result<(), String> {
231        let mut args = self.config.to_args();
232        args.push("--".to_string());
233        args.push(file_path.to_string_lossy().into_owned());
234        let args_refs: Vec<&str> = args.iter().map(String::as_str).collect();
235
236        let output =
237            self.runtime.run_command("perltidy", &args_refs, None).map_err(|e| e.message)?;
238
239        if !output.success() {
240            return Err(format!("Perltidy failed: {}", output.stderr_lossy()));
241        }
242
243        Ok(())
244    }
245
246    /// Clear any memoized formatting results.
247    pub fn clear_cache(&mut self) {
248        self.cache.clear();
249    }
250
251    /// Format a range of code.
252    pub fn format_range(
253        &mut self,
254        code: &str,
255        start_line: u32,
256        end_line: u32,
257    ) -> Result<String, String> {
258        let lines: Vec<&str> = code.lines().collect();
259
260        if start_line as usize >= lines.len() || end_line as usize >= lines.len() {
261            return Err("Line range out of bounds".to_string());
262        }
263
264        let range_code = lines[start_line as usize..=end_line as usize].join("\n");
265        let formatted_range = self.format(&range_code)?;
266
267        let mut result = Vec::new();
268        if start_line > 0 {
269            result.extend_from_slice(&lines[0..start_line as usize]);
270        }
271        result.extend(formatted_range.lines());
272        if (end_line as usize) < lines.len() - 1 {
273            result.extend_from_slice(&lines[(end_line as usize + 1)..]);
274        }
275
276        Ok(result.join("\n"))
277    }
278
279    /// Get formatting suggestions without applying them.
280    pub fn get_suggestions(&mut self, code: &str) -> Result<Vec<FormatSuggestion>, String> {
281        let formatted = self.format(code)?;
282        if formatted == code {
283            return Ok(Vec::new());
284        }
285
286        let orig_lines: Vec<&str> = code.lines().collect();
287        let fmt_lines: Vec<&str> = formatted.lines().collect();
288        let mut suggestions = Vec::new();
289
290        for (i, (orig, fmt)) in orig_lines.iter().zip(fmt_lines.iter()).enumerate() {
291            if orig != fmt {
292                suggestions.push(FormatSuggestion {
293                    line: i as u32,
294                    original: (*orig).to_string(),
295                    formatted: (*fmt).to_string(),
296                    description: "Line formatting change".to_string(),
297                });
298            }
299        }
300
301        Ok(suggestions)
302    }
303}
304
305/// A formatting suggestion.
306#[derive(Debug, Clone)]
307pub struct FormatSuggestion {
308    /// Zero-based line number where the change applies.
309    pub line: u32,
310    /// Original line content before formatting.
311    pub original: String,
312    /// Suggested formatted line content.
313    pub formatted: String,
314    /// Human-readable description of the formatting change.
315    pub description: String,
316}
317
318/// Built-in formatter for when `perltidy` is unavailable.
319pub struct BuiltInFormatter {
320    config: PerlTidyConfig,
321}
322
323impl BuiltInFormatter {
324    /// Creates a new built-in formatter with the given configuration.
325    #[must_use]
326    pub fn new(config: PerlTidyConfig) -> Self {
327        Self { config }
328    }
329
330    /// Apply basic indentation-based formatting without invoking `perltidy`.
331    #[must_use]
332    pub fn format(&self, code: &str) -> String {
333        let mut result = String::new();
334        let mut indent_level: i32 = 0;
335        let indent_str = if self.config.tabs.unwrap_or(false) {
336            "\t".to_string()
337        } else {
338            " ".repeat(self.config.indent_columns.unwrap_or(4) as usize)
339        };
340
341        for line in code.lines() {
342            let trimmed = line.trim();
343            if trimmed.starts_with('}') || trimmed.starts_with(')') || trimmed.starts_with(']') {
344                indent_level = indent_level.saturating_sub(1);
345            }
346
347            if !trimmed.is_empty() {
348                for _ in 0..indent_level {
349                    result.push_str(&indent_str);
350                }
351                result.push_str(trimmed);
352            }
353            result.push('\n');
354
355            if trimmed.ends_with('{') || trimmed.ends_with('(') || trimmed.ends_with('[') {
356                indent_level += 1;
357            }
358        }
359
360        result
361    }
362}