1#![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#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PerlTidyConfig {
32 pub maximum_line_length: Option<u32>,
34 pub indent_columns: Option<u32>,
36 pub tabs: Option<bool>,
38 pub opening_brace_on_new_line: Option<bool>,
40 pub cuddled_else: Option<bool>,
42 pub space_after_keyword: Option<bool>,
44 pub add_trailing_commas: Option<bool>,
46 pub vertical_alignment: Option<bool>,
48 pub block_comment_indentation: Option<u32>,
50 pub profile: Option<String>,
52 pub extra_args: Vec<String>,
54 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 #[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 #[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 #[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
192pub struct PerlTidyFormatter {
194 config: PerlTidyConfig,
195 cache: HashMap<String, String>,
196 runtime: Arc<dyn SubprocessRuntime>,
197}
198
199impl PerlTidyFormatter {
200 #[must_use]
202 pub fn new(config: PerlTidyConfig, runtime: Arc<dyn SubprocessRuntime>) -> Self {
203 Self { config, cache: HashMap::new(), runtime }
204 }
205
206 #[cfg(not(target_arch = "wasm32"))]
208 #[must_use]
209 pub fn with_os_runtime(config: PerlTidyConfig) -> Self {
210 use perl_subprocess_runtime::OsSubprocessRuntime;
211 let timeout = config.timeout_secs.max(1);
214 Self::new(config, Arc::new(OsSubprocessRuntime::with_timeout(timeout)))
215 }
216
217 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 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 pub fn clear_cache(&mut self) {
261 self.cache.clear();
262 }
263
264 #[must_use]
266 pub fn cache_len(&self) -> usize {
267 self.cache.len()
268 }
269
270 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 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#[derive(Debug, Clone)]
346pub struct FormatSuggestion {
347 pub line: u32,
349 pub original: String,
351 pub formatted: String,
353 pub description: String,
355}
356
357pub struct BuiltInFormatter {
359 config: PerlTidyConfig,
360}
361
362impl BuiltInFormatter {
363 #[must_use]
365 pub fn new(config: PerlTidyConfig) -> Self {
366 Self { config }
367 }
368
369 #[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 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}