1#![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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PerlTidyConfig {
22 pub maximum_line_length: Option<u32>,
24 pub indent_columns: Option<u32>,
26 pub tabs: Option<bool>,
28 pub opening_brace_on_new_line: Option<bool>,
30 pub cuddled_else: Option<bool>,
32 pub space_after_keyword: Option<bool>,
34 pub add_trailing_commas: Option<bool>,
36 pub vertical_alignment: Option<bool>,
38 pub block_comment_indentation: Option<u32>,
40 pub profile: Option<String>,
42 pub extra_args: Vec<String>,
44 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 #[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 #[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 #[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
181pub struct PerlTidyFormatter {
183 config: PerlTidyConfig,
184 cache: HashMap<String, String>,
185 runtime: Arc<dyn SubprocessRuntime>,
186}
187
188impl PerlTidyFormatter {
189 #[must_use]
191 pub fn new(config: PerlTidyConfig, runtime: Arc<dyn SubprocessRuntime>) -> Self {
192 Self { config, cache: HashMap::new(), runtime }
193 }
194
195 #[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 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 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 pub fn clear_cache(&mut self) {
248 self.cache.clear();
249 }
250
251 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 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#[derive(Debug, Clone)]
307pub struct FormatSuggestion {
308 pub line: u32,
310 pub original: String,
312 pub formatted: String,
314 pub description: String,
316}
317
318pub struct BuiltInFormatter {
320 config: PerlTidyConfig,
321}
322
323impl BuiltInFormatter {
324 #[must_use]
326 pub fn new(config: PerlTidyConfig) -> Self {
327 Self { config }
328 }
329
330 #[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}