Skip to main content

perl_lsp_perltidy/
lib.rs

1//! Native-first Perl formatting with optional `perltidy` compatibility.
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. The default LSP formatter uses the Rust-native
6//! [`NativeFormatter`]; [`PerlTidyFormatter`] remains an explicit
7//! subprocess-backed compatibility adapter for projects that still require
8//! exact `perltidy` behavior.
9
10#![deny(unsafe_code)]
11#![cfg_attr(test, allow(clippy::panic, clippy::unwrap_used, clippy::expect_used))]
12#![warn(rust_2018_idioms)]
13#![warn(missing_docs)]
14
15use perl_subprocess_runtime::SubprocessRuntime;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::Path;
19use std::sync::Arc;
20
21pub mod native;
22
23pub use native::{
24    BracePlacement, ElsePlacement, FinalNewline, FormatConfig, FormatDiagnostic,
25    FormatDiagnosticSeverity, FormatDoc, FormatResult, FormatterMode, KeywordSpacing,
26    NativeFormatter, PerlFormatter, TextEdit, TextPosition, TextRange, TrailingComma,
27};
28
29/// Configuration for perltidy.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PerlTidyConfig {
32    /// Maximum line length.
33    pub maximum_line_length: Option<u32>,
34    /// Indent size (spaces).
35    pub indent_columns: Option<u32>,
36    /// Use tabs instead of spaces.
37    pub tabs: Option<bool>,
38    /// Opening brace on same line.
39    pub opening_brace_on_new_line: Option<bool>,
40    /// Cuddled else.
41    pub cuddled_else: Option<bool>,
42    /// Space after keyword.
43    pub space_after_keyword: Option<bool>,
44    /// Add trailing commas.
45    pub add_trailing_commas: Option<bool>,
46    /// Vertical alignment.
47    pub vertical_alignment: Option<bool>,
48    /// Block comment indentation.
49    pub block_comment_indentation: Option<u32>,
50    /// Custom perltidyrc file path.
51    pub profile: Option<String>,
52    /// Additional command line arguments.
53    pub extra_args: Vec<String>,
54    /// Timeout in seconds for the perltidy subprocess. Default: 10.
55    pub timeout_secs: u64,
56}
57
58impl Default for PerlTidyConfig {
59    fn default() -> Self {
60        Self {
61            maximum_line_length: Some(80),
62            indent_columns: Some(4),
63            tabs: Some(false),
64            opening_brace_on_new_line: Some(false),
65            cuddled_else: Some(true),
66            space_after_keyword: Some(true),
67            add_trailing_commas: Some(false),
68            vertical_alignment: Some(true),
69            block_comment_indentation: Some(0),
70            profile: None,
71            extra_args: Vec::new(),
72            timeout_secs: 10,
73        }
74    }
75}
76
77impl PerlTidyConfig {
78    /// Create a config for PBP (Perl Best Practices) style.
79    #[must_use]
80    pub fn pbp() -> Self {
81        Self {
82            maximum_line_length: Some(78),
83            indent_columns: Some(4),
84            tabs: Some(false),
85            opening_brace_on_new_line: Some(false),
86            cuddled_else: Some(false),
87            space_after_keyword: Some(true),
88            add_trailing_commas: Some(true),
89            vertical_alignment: Some(true),
90            block_comment_indentation: Some(0),
91            profile: None,
92            extra_args: vec!["--perl-best-practices".to_string()],
93            timeout_secs: 10,
94        }
95    }
96
97    /// Create a config for GNU style.
98    #[must_use]
99    pub fn gnu() -> Self {
100        Self {
101            maximum_line_length: Some(79),
102            indent_columns: Some(2),
103            tabs: Some(false),
104            opening_brace_on_new_line: Some(true),
105            cuddled_else: Some(false),
106            space_after_keyword: Some(true),
107            add_trailing_commas: Some(false),
108            vertical_alignment: Some(false),
109            block_comment_indentation: Some(2),
110            profile: None,
111            extra_args: vec!["--gnu-style".to_string()],
112            timeout_secs: 10,
113        }
114    }
115
116    /// Convert the configuration to `perltidy` command-line arguments.
117    #[must_use]
118    pub fn to_args(&self) -> Vec<String> {
119        let mut args = Vec::new();
120
121        if let Some(profile) = &self.profile {
122            args.push(format!("--profile={profile}"));
123            args.extend(self.extra_args.clone());
124            return args;
125        }
126
127        if let Some(len) = self.maximum_line_length {
128            args.push(format!("--maximum-line-length={len}"));
129        }
130
131        if let Some(indent) = self.indent_columns {
132            args.push(format!("--indent-columns={indent}"));
133        }
134
135        if let Some(tabs) = self.tabs {
136            if tabs {
137                args.push("--tabs".to_string());
138            } else {
139                args.push("--notabs".to_string());
140            }
141        }
142
143        if let Some(brace) = self.opening_brace_on_new_line {
144            if brace {
145                args.push("--opening-brace-on-new-line".to_string());
146            } else {
147                args.push("--opening-brace-always-on-right".to_string());
148            }
149        }
150
151        if let Some(cuddle) = self.cuddled_else {
152            if cuddle {
153                args.push("--cuddled-else".to_string());
154            } else {
155                args.push("--nocuddled-else".to_string());
156            }
157        }
158
159        if let Some(space) = self.space_after_keyword {
160            if space {
161                args.push("--space-after-keyword".to_string());
162            } else {
163                args.push("--nospace-after-keyword".to_string());
164            }
165        }
166
167        if let Some(comma) = self.add_trailing_commas {
168            if comma {
169                args.push("--add-trailing-commas".to_string());
170            } else {
171                args.push("--no-add-trailing-commas".to_string());
172            }
173        }
174
175        if let Some(align) = self.vertical_alignment {
176            if align {
177                args.push("--vertical-alignment".to_string());
178            } else {
179                args.push("--no-vertical-alignment".to_string());
180            }
181        }
182
183        if let Some(indent) = self.block_comment_indentation {
184            args.push(format!("--block-comment-indentation={indent}"));
185        }
186
187        args.extend(self.extra_args.clone());
188        args
189    }
190}
191
192/// Perltidy formatter.
193pub struct PerlTidyFormatter {
194    config: PerlTidyConfig,
195    cache: HashMap<String, String>,
196    runtime: Arc<dyn SubprocessRuntime>,
197}
198
199impl PerlTidyFormatter {
200    /// Creates a new formatter with the given configuration and runtime.
201    #[must_use]
202    pub fn new(config: PerlTidyConfig, runtime: Arc<dyn SubprocessRuntime>) -> Self {
203        Self { config, cache: HashMap::new(), runtime }
204    }
205
206    /// Creates a new formatter with the OS subprocess runtime (non-WASM only).
207    #[cfg(not(target_arch = "wasm32"))]
208    #[must_use]
209    pub fn with_os_runtime(config: PerlTidyConfig) -> Self {
210        use perl_subprocess_runtime::OsSubprocessRuntime;
211        // OsSubprocessRuntime::with_timeout panics on zero; clamp to 1s so
212        // misconfigured clients do not crash the language server process.
213        let timeout = config.timeout_secs.max(1);
214        Self::new(config, Arc::new(OsSubprocessRuntime::with_timeout(timeout)))
215    }
216
217    /// Format Perl code.
218    pub fn format(&mut self, code: &str) -> Result<String, String> {
219        if let Some(cached) = self.cache.get(code) {
220            return Ok(cached.clone());
221        }
222
223        let mut args = self.config.to_args();
224        args.push("-st".to_string());
225        let args_refs: Vec<&str> = args.iter().map(String::as_str).collect();
226
227        let output = self
228            .runtime
229            .run_command("perltidy", &args_refs, Some(code.as_bytes()))
230            .map_err(|e| e.message)?;
231
232        if !output.success() {
233            return Err(format!("Perltidy failed: {}", output.stderr_lossy()));
234        }
235
236        let formatted = String::from_utf8(output.stdout)
237            .map_err(|e| format!("Invalid UTF-8 from perltidy: {e}"))?;
238        self.cache.insert(code.to_string(), formatted.clone());
239        Ok(formatted)
240    }
241
242    /// Format a file in place.
243    pub fn format_file(&self, file_path: &Path) -> Result<(), String> {
244        let mut args = self.config.to_args();
245        args.push("--".to_string());
246        args.push(file_path.to_string_lossy().into_owned());
247        let args_refs: Vec<&str> = args.iter().map(String::as_str).collect();
248
249        let output =
250            self.runtime.run_command("perltidy", &args_refs, None).map_err(|e| e.message)?;
251
252        if !output.success() {
253            return Err(format!("Perltidy failed: {}", output.stderr_lossy()));
254        }
255
256        Ok(())
257    }
258
259    /// Clear any memoized formatting results.
260    pub fn clear_cache(&mut self) {
261        self.cache.clear();
262    }
263
264    /// Return the number of memoized formatting results.
265    #[must_use]
266    pub fn cache_len(&self) -> usize {
267        self.cache.len()
268    }
269
270    /// Format a range of code.
271    pub fn format_range(
272        &mut self,
273        code: &str,
274        start_line: u32,
275        end_line: u32,
276    ) -> Result<String, String> {
277        if start_line > end_line {
278            return Err(
279                "Invalid line range: start line must be less than or equal to end line".to_string()
280            );
281        }
282
283        let lines: Vec<&str> = code.lines().collect();
284
285        if start_line as usize >= lines.len() || end_line as usize >= lines.len() {
286            return Err("Line range out of bounds".to_string());
287        }
288
289        let range_code = lines[start_line as usize..=end_line as usize].join("\n");
290        let formatted_range = self.format(&range_code)?;
291
292        let mut result = Vec::new();
293        if start_line > 0 {
294            result.extend_from_slice(&lines[0..start_line as usize]);
295        }
296        result.extend(formatted_range.lines());
297        if (end_line as usize) < lines.len() - 1 {
298            result.extend_from_slice(&lines[(end_line as usize + 1)..]);
299        }
300
301        Ok(result.join("\n"))
302    }
303
304    /// Get formatting suggestions without applying them.
305    pub fn get_suggestions(&mut self, code: &str) -> Result<Vec<FormatSuggestion>, String> {
306        let formatted = self.format(code)?;
307        if formatted == code {
308            return Ok(Vec::new());
309        }
310
311        let orig_lines: Vec<&str> = code.lines().collect();
312        let fmt_lines: Vec<&str> = formatted.lines().collect();
313        let mut suggestions = Vec::new();
314        let max_lines = orig_lines.len().max(fmt_lines.len());
315
316        for i in 0..max_lines {
317            match (orig_lines.get(i), fmt_lines.get(i)) {
318                (Some(orig), Some(fmt)) if orig != fmt => suggestions.push(FormatSuggestion {
319                    line: i as u32,
320                    original: (*orig).to_string(),
321                    formatted: (*fmt).to_string(),
322                    description: "Line formatting change".to_string(),
323                }),
324                (Some(orig), None) => suggestions.push(FormatSuggestion {
325                    line: i as u32,
326                    original: (*orig).to_string(),
327                    formatted: String::new(),
328                    description: "Line removed by formatting".to_string(),
329                }),
330                (None, Some(fmt)) => suggestions.push(FormatSuggestion {
331                    line: i as u32,
332                    original: String::new(),
333                    formatted: (*fmt).to_string(),
334                    description: "Line added by formatting".to_string(),
335                }),
336                _ => {}
337            }
338        }
339
340        Ok(suggestions)
341    }
342}
343
344/// A formatting suggestion.
345#[derive(Debug, Clone)]
346pub struct FormatSuggestion {
347    /// Zero-based line number where the change applies.
348    pub line: u32,
349    /// Original line content before formatting.
350    pub original: String,
351    /// Suggested formatted line content.
352    pub formatted: String,
353    /// Human-readable description of the formatting change.
354    pub description: String,
355}
356
357/// Built-in formatter for when `perltidy` is unavailable.
358pub struct BuiltInFormatter {
359    config: PerlTidyConfig,
360}
361
362impl BuiltInFormatter {
363    /// Creates a new built-in formatter with the given configuration.
364    #[must_use]
365    pub fn new(config: PerlTidyConfig) -> Self {
366        Self { config }
367    }
368
369    /// Apply basic indentation-based formatting without invoking `perltidy`.
370    #[must_use]
371    pub fn format(&self, code: &str) -> String {
372        let mut result = String::new();
373        let mut indent_level: i32 = 0;
374        let lines: Vec<&str> = code.lines().collect();
375        let had_trailing_newline = code.ends_with('\n');
376        let indent_str = if self.config.tabs.unwrap_or(false) {
377            "\t".to_string()
378        } else {
379            " ".repeat(self.config.indent_columns.unwrap_or(4) as usize)
380        };
381
382        for (index, line) in lines.iter().enumerate() {
383            let trimmed = line.trim();
384            let leading_closers = count_leading_closers(trimmed) as i32;
385            indent_level = indent_level.saturating_sub(leading_closers);
386
387            if !trimmed.is_empty() {
388                for _ in 0..indent_level {
389                    result.push_str(&indent_str);
390                }
391                result.push_str(trimmed);
392            }
393
394            let is_last_line = index + 1 == lines.len();
395            if !is_last_line || had_trailing_newline {
396                result.push('\n');
397            }
398
399            // net_delimiter_delta counts all delimiters including leading closers.
400            // We already decremented by leading_closers before printing, so add them
401            // back to avoid double-counting: the net change for the *next* line is
402            // delta + leading_closers (leading closers cancel in the net formula).
403            indent_level = (indent_level + net_delimiter_delta(trimmed) + leading_closers).max(0);
404        }
405
406        result
407    }
408}
409
410fn count_leading_closers(line: &str) -> usize {
411    line.chars().take_while(|ch| matches!(ch, '}' | ')' | ']')).count()
412}
413
414fn net_delimiter_delta(line: &str) -> i32 {
415    let mut delta = 0_i32;
416    let mut in_single = false;
417    let mut in_double = false;
418    let mut escaped = false;
419
420    for ch in line.chars() {
421        if escaped {
422            escaped = false;
423            continue;
424        }
425
426        if ch == '\\' {
427            escaped = true;
428            continue;
429        }
430
431        if in_single {
432            if ch == '\'' {
433                in_single = false;
434            }
435            continue;
436        }
437
438        if in_double {
439            if ch == '"' {
440                in_double = false;
441            }
442            continue;
443        }
444
445        if ch == '\'' {
446            in_single = true;
447            continue;
448        }
449
450        if ch == '"' {
451            in_double = true;
452            continue;
453        }
454
455        if ch == '#' {
456            break;
457        }
458
459        match ch {
460            '{' | '(' | '[' => delta += 1,
461            '}' | ')' | ']' => delta -= 1,
462            _ => {}
463        }
464    }
465
466    delta
467}