Skip to main content

perl_lsp_formatting/
formatting.rs

1//! Code formatting support using Perl::Tidy for Perl parsing workflow pipeline.
2
3pub use perl_lsp_formatting_types::{
4    FormatPosition, FormatRange, FormatTextEdit, FormattedDocument, FormattingOptions,
5};
6
7/// Formatting error.
8#[derive(Debug, thiserror::Error)]
9pub enum FormattingError {
10    #[error(
11        "perltidy not found: {0}\n\nTo install perltidy:\n  - Recommended: cpanm Perl::Tidy\n  - CPAN: cpan Perl::Tidy\n  - Debian/Ubuntu: apt-get install perltidy\n  - RedHat/Fedora: yum install perltidy\n  - macOS: brew install perltidy\n  - Windows: cpanm Perl::Tidy"
12    )]
13    /// perltidy executable not found on system PATH.
14    PerltidyNotFound(String),
15
16    /// Error occurred during perltidy execution.
17    ///
18    /// This usually means perltidy ran but reported a problem — check that the
19    /// Perl code is syntactically valid, or inspect the perltidy output below.
20    #[error("perltidy error (check Perl syntax): {0}")]
21    PerltidyError(String),
22
23    /// I/O error during file operations.
24    #[error("IO error: {0}")]
25    IoError(String),
26}
27
28impl FormattingError {
29    /// Return a stable machine-readable error kind string for structured LSP error data.
30    ///
31    /// Used by LSP handlers to populate the JSON-RPC error `data` field so that
32    /// clients (e.g. the VSCode extension) can present targeted remediation actions.
33    #[must_use]
34    pub fn error_kind(&self) -> &'static str {
35        match self {
36            Self::PerltidyNotFound(_) => "perltidy_not_found",
37            Self::PerltidyError(_) => "perltidy_error",
38            Self::IoError(_) => "io_error",
39        }
40    }
41}
42
43/// Code formatter using perltidy.
44pub struct FormattingProvider<R> {
45    /// Subprocess runtime for executing perltidy.
46    runtime: R,
47    /// Optional custom perltidy path.
48    perltidy_path: Option<String>,
49}
50
51impl<R> FormattingProvider<R> {
52    /// Create a new formatting provider with the given runtime.
53    pub fn new(runtime: R) -> Self {
54        Self { runtime, perltidy_path: None }
55    }
56
57    /// Set a custom perltidy path.
58    pub fn with_perltidy_path(mut self, path: String) -> Self {
59        self.perltidy_path = Some(path);
60        self
61    }
62}
63
64impl<R: perl_lsp_tooling::SubprocessRuntime> FormattingProvider<R> {
65    /// Format the entire Perl script document with perltidy integration.
66    pub fn format_document(
67        &self,
68        content: &str,
69        options: &FormattingOptions,
70    ) -> Result<FormattedDocument, FormattingError> {
71        let formatted = self.run_perltidy(content, options)?;
72
73        if formatted == content {
74            return Ok(FormattedDocument { text: formatted, edits: vec![] });
75        }
76
77        Ok(FormattedDocument {
78            text: formatted.clone(),
79            edits: vec![FormatTextEdit {
80                range: FormatRange::whole_document(content),
81                new_text: formatted,
82            }],
83        })
84    }
85
86    /// Format a specific range in the document.
87    pub fn format_range(
88        &self,
89        content: &str,
90        range: &FormatRange,
91        options: &FormattingOptions,
92    ) -> Result<FormattedDocument, FormattingError> {
93        let lines: Vec<&str> = content.lines().collect();
94        let start_line = range.start.line as usize;
95        let end_line = (range.end.line as usize).min(lines.len().saturating_sub(1));
96
97        if start_line >= lines.len() {
98            return Ok(FormattedDocument { text: content.to_string(), edits: vec![] });
99        }
100
101        if end_line < start_line {
102            return Ok(FormattedDocument { text: content.to_string(), edits: vec![] });
103        }
104
105        let text_to_format = lines[start_line..=end_line].join("\n");
106        let formatted = self.run_perltidy(&text_to_format, options)?;
107
108        if formatted == text_to_format {
109            return Ok(FormattedDocument { text: content.to_string(), edits: vec![] });
110        }
111
112        let start_char = 0;
113        let end_char = lines[end_line].len() as u32;
114
115        Ok(FormattedDocument {
116            text: content.to_string(),
117            edits: vec![FormatTextEdit {
118                range: FormatRange::new(
119                    FormatPosition::new(start_line as u32, start_char),
120                    FormatPosition::new(end_line as u32, end_char),
121                ),
122                new_text: formatted,
123            }],
124        })
125    }
126
127    fn run_perltidy(
128        &self,
129        content: &str,
130        options: &FormattingOptions,
131    ) -> Result<String, FormattingError> {
132        let mut args = vec!["-st".to_string(), "-se".to_string()];
133
134        if options.insert_spaces {
135            args.push(format!("-et={}", options.tab_size));
136            args.push(format!("-i={}", options.tab_size));
137        } else {
138            args.push("-dt".to_string());
139            args.push(format!("-i={}", options.tab_size));
140        }
141
142        let perltidy_cmd = self.perltidy_path.as_deref().unwrap_or("perltidy");
143
144        let output = self
145            .runtime
146            .run_command(
147                perltidy_cmd,
148                &args.iter().map(String::as_str).collect::<Vec<_>>(),
149                Some(content.as_bytes()),
150            )
151            .map_err(|error| FormattingError::PerltidyNotFound(error.message))?;
152
153        if !output.success() {
154            return Err(FormattingError::PerltidyError(
155                String::from_utf8_lossy(&output.stderr).to_string(),
156            ));
157        }
158
159        Ok(String::from_utf8_lossy(&output.stdout).to_string())
160    }
161}