nginx_lint_plugin/types.rs
1//! Core types for plugin development.
2//!
3//! This module provides the fundamental types needed to build nginx-lint plugins:
4//!
5//! - [`Plugin`] - The trait every plugin must implement
6//! - [`PluginSpec`] - Plugin metadata (name, category, description, examples)
7//! - [`LintError`] - Lint errors reported by plugins
8//! - [`ErrorBuilder`] - Helper for creating errors with pre-filled rule/category
9//! - [`Fix`] - Autofix actions (replace, delete, insert)
10//! - [`ConfigExt`] - Extension trait for traversing the nginx config AST
11//! - [`DirectiveExt`] - Extension trait for inspecting and modifying directives
12//! - [`DirectiveWithContext`] - A directive paired with its parent block context
13//!
14//! These types mirror the nginx-lint AST and error types for use in WASM plugins.
15
16use serde::{Deserialize, Serialize};
17
18/// Current API version for the plugin SDK
19pub const API_VERSION: &str = "1.1";
20
21/// Plugin metadata describing a lint rule.
22///
23/// Created via [`PluginSpec::new()`] and configured with builder methods.
24///
25/// # Example
26///
27/// ```
28/// use nginx_lint_plugin::PluginSpec;
29///
30/// let spec = PluginSpec::new("my-rule", "security", "Short description")
31/// .with_severity("warning")
32/// .with_why("Detailed explanation of why this rule exists.")
33/// .with_bad_example("server {\n bad_directive on;\n}")
34/// .with_good_example("server {\n bad_directive off;\n}")
35/// .with_references(vec![
36/// "https://nginx.org/en/docs/...".to_string(),
37/// ]);
38///
39/// assert_eq!(spec.name, "my-rule");
40/// assert_eq!(spec.category, "security");
41/// assert_eq!(spec.severity, Some("warning".to_string()));
42/// ```
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PluginSpec {
45 /// Unique name for the rule (e.g., "my-custom-rule")
46 pub name: String,
47 /// Category (e.g., "security", "style", "best_practices", "custom")
48 pub category: String,
49 /// Human-readable description
50 pub description: String,
51 /// API version the plugin uses for input/output format
52 pub api_version: String,
53 /// Severity level (error, warning)
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub severity: Option<String>,
56 /// Why this rule exists (detailed explanation)
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub why: Option<String>,
59 /// Example of bad configuration
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub bad_example: Option<String>,
62 /// Example of good configuration
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub good_example: Option<String>,
65 /// References (URLs, documentation links)
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub references: Option<Vec<String>>,
68 /// Minimum nginx version this rule applies to (inclusive, e.g. `"0.6.27"`).
69 /// `None` means unbounded on the lower end.
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub min_nginx_version: Option<String>,
72 /// Maximum nginx version this rule applies to (inclusive, e.g. `"1.30.0"`).
73 /// `None` means unbounded on the upper end.
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub max_nginx_version: Option<String>,
76}
77
78impl PluginSpec {
79 /// Create a new PluginSpec with the current API version
80 pub fn new(
81 name: impl Into<String>,
82 category: impl Into<String>,
83 description: impl Into<String>,
84 ) -> Self {
85 Self {
86 name: name.into(),
87 category: category.into(),
88 description: description.into(),
89 api_version: API_VERSION.to_string(),
90 severity: None,
91 why: None,
92 bad_example: None,
93 good_example: None,
94 references: None,
95 min_nginx_version: None,
96 max_nginx_version: None,
97 }
98 }
99
100 /// Set the severity level
101 pub fn with_severity(mut self, severity: impl Into<String>) -> Self {
102 self.severity = Some(severity.into());
103 self
104 }
105
106 /// Set the why documentation
107 pub fn with_why(mut self, why: impl Into<String>) -> Self {
108 self.why = Some(why.into());
109 self
110 }
111
112 /// Set the bad example
113 pub fn with_bad_example(mut self, example: impl Into<String>) -> Self {
114 self.bad_example = Some(example.into());
115 self
116 }
117
118 /// Set the good example
119 pub fn with_good_example(mut self, example: impl Into<String>) -> Self {
120 self.good_example = Some(example.into());
121 self
122 }
123
124 /// Set references
125 pub fn with_references(mut self, refs: Vec<String>) -> Self {
126 self.references = Some(refs);
127 self
128 }
129
130 /// Declare the lowest nginx version this rule applies to (inclusive).
131 pub fn with_min_version(mut self, version: impl Into<String>) -> Self {
132 self.min_nginx_version = Some(version.into());
133 self
134 }
135
136 /// Declare the highest nginx version this rule applies to (inclusive).
137 pub fn with_max_version(mut self, version: impl Into<String>) -> Self {
138 self.max_nginx_version = Some(version.into());
139 self
140 }
141
142 /// Create an error builder that uses this plugin's name and category
143 ///
144 /// This reduces boilerplate when creating errors in the check method.
145 ///
146 /// # Example
147 ///
148 /// ```
149 /// use nginx_lint_plugin::{PluginSpec, Severity};
150 ///
151 /// let spec = PluginSpec::new("my-rule", "security", "Check something");
152 /// let err = spec.error_builder();
153 ///
154 /// // Instead of:
155 /// // LintError::warning("my-rule", "security", "message", 10, 5)
156 /// // Use:
157 /// let warning = err.warning("message", 10, 5);
158 /// assert_eq!(warning.rule, "my-rule");
159 /// assert_eq!(warning.category, "security");
160 /// assert_eq!(warning.severity, Severity::Warning);
161 /// ```
162 pub fn error_builder(&self) -> ErrorBuilder {
163 ErrorBuilder {
164 rule: self.name.clone(),
165 category: self.category.clone(),
166 }
167 }
168}
169
170/// Builder for creating LintError with pre-filled rule and category
171#[derive(Debug, Clone)]
172pub struct ErrorBuilder {
173 rule: String,
174 category: String,
175}
176
177impl ErrorBuilder {
178 /// Create an error with Error severity
179 pub fn error(&self, message: &str, line: usize, column: usize) -> LintError {
180 LintError::error(&self.rule, &self.category, message, line, column)
181 }
182
183 /// Create an error with Warning severity
184 pub fn warning(&self, message: &str, line: usize, column: usize) -> LintError {
185 LintError::warning(&self.rule, &self.category, message, line, column)
186 }
187
188 /// Create an error from a directive's location
189 pub fn error_at(&self, message: &str, directive: &(impl DirectiveExt + ?Sized)) -> LintError {
190 self.error(message, directive.line(), directive.column())
191 }
192
193 /// Create a warning from a directive's location
194 pub fn warning_at(&self, message: &str, directive: &(impl DirectiveExt + ?Sized)) -> LintError {
195 self.warning(message, directive.line(), directive.column())
196 }
197}
198
199/// Severity level for lint errors
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum Severity {
203 Error,
204 Warning,
205}
206
207/// Represents a fix that can be applied to automatically resolve a lint error.
208///
209/// Fixes can operate in two modes:
210///
211/// - **Line-based**: Operate on entire lines (delete, insert after, replace text on a line)
212/// - **Range-based**: Operate on byte offsets for precise edits (multiple fixes per line)
213///
214/// Use the convenience methods on [`DirectiveExt`] to create fixes from directives:
215///
216/// ```
217/// use nginx_lint_plugin::prelude::*;
218///
219/// let config = nginx_lint_plugin::parse_string("server_tokens on;").unwrap();
220/// let directive = config.all_directives().next().unwrap();
221///
222/// // Replace the entire directive
223/// let fix = directive.replace_with("server_tokens off;");
224/// assert!(fix.is_range_based());
225///
226/// // Delete the directive's line
227/// let fix = directive.delete_line();
228/// assert!(fix.is_range_based());
229///
230/// // Insert a new line after the directive
231/// let fix = directive.insert_after("add_header X-Frame-Options DENY;");
232/// assert!(fix.is_range_based());
233///
234/// // Insert before the directive
235/// let fix = directive.insert_before("# Security headers");
236/// assert!(fix.is_range_based());
237/// ```
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct Fix {
240 /// Line number where the fix should be applied (1-indexed)
241 pub line: usize,
242 /// The original text to replace (if None and new_text is empty, delete the line)
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub old_text: Option<String>,
245 /// The new text to insert (empty string with old_text=None means delete)
246 pub new_text: String,
247 /// Whether to delete the entire line
248 #[serde(default)]
249 pub delete_line: bool,
250 /// Whether to insert new_text as a new line after the specified line
251 #[serde(default)]
252 pub insert_after: bool,
253 /// Start byte offset for range-based fix (0-indexed, inclusive)
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub start_offset: Option<usize>,
256 /// End byte offset for range-based fix (0-indexed, exclusive)
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub end_offset: Option<usize>,
259}
260
261impl Fix {
262 /// Create a fix that deletes an entire line
263 #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
264 pub fn delete(line: usize) -> Self {
265 Self {
266 line,
267 old_text: None,
268 new_text: String::new(),
269 delete_line: true,
270 insert_after: false,
271 start_offset: None,
272 end_offset: None,
273 }
274 }
275
276 /// Create a fix that inserts a new line after the specified line
277 #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
278 pub fn insert_after(line: usize, new_text: &str) -> Self {
279 Self {
280 line,
281 old_text: None,
282 new_text: new_text.to_string(),
283 delete_line: false,
284 insert_after: true,
285 start_offset: None,
286 end_offset: None,
287 }
288 }
289
290 /// Create a range-based fix that replaces bytes from start to end offset
291 ///
292 /// This allows multiple fixes on the same line as long as their ranges don't overlap.
293 pub fn replace_range(start_offset: usize, end_offset: usize, new_text: &str) -> Self {
294 Self {
295 line: 0, // Not used for range-based fixes
296 old_text: None,
297 new_text: new_text.to_string(),
298 delete_line: false,
299 insert_after: false,
300 start_offset: Some(start_offset),
301 end_offset: Some(end_offset),
302 }
303 }
304
305 /// Check if this is a range-based fix
306 pub fn is_range_based(&self) -> bool {
307 self.start_offset.is_some() && self.end_offset.is_some()
308 }
309}
310
311/// A lint error reported by a plugin.
312///
313/// Create errors using [`LintError::error()`] / [`LintError::warning()`] directly,
314/// or more conveniently via [`ErrorBuilder`] (obtained from [`PluginSpec::error_builder()`]):
315///
316/// ```
317/// use nginx_lint_plugin::prelude::*;
318///
319/// let spec = PluginSpec::new("my-rule", "security", "Check something");
320/// let err = spec.error_builder();
321///
322/// // Warning at a specific line/column
323/// let warning = err.warning("message", 10, 5);
324/// assert_eq!(warning.line, Some(10));
325///
326/// // Warning at a directive's location (most common pattern)
327/// let config = nginx_lint_plugin::parse_string("autoindex on;").unwrap();
328/// let directive = config.all_directives().next().unwrap();
329/// let error = err.warning_at("use 'off'", directive)
330/// .with_fix(directive.replace_with("autoindex off;"));
331/// assert_eq!(error.fixes.len(), 1);
332/// ```
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct LintError {
335 pub rule: String,
336 pub category: String,
337 pub message: String,
338 pub severity: Severity,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub line: Option<usize>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub column: Option<usize>,
343 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub fixes: Vec<Fix>,
345}
346
347impl LintError {
348 /// Create a new error with Error severity
349 pub fn error(rule: &str, category: &str, message: &str, line: usize, column: usize) -> Self {
350 Self {
351 rule: rule.to_string(),
352 category: category.to_string(),
353 message: message.to_string(),
354 severity: Severity::Error,
355 line: if line > 0 { Some(line) } else { None },
356 column: if column > 0 { Some(column) } else { None },
357 fixes: Vec::new(),
358 }
359 }
360
361 /// Create a new error with Warning severity
362 pub fn warning(rule: &str, category: &str, message: &str, line: usize, column: usize) -> Self {
363 Self {
364 rule: rule.to_string(),
365 category: category.to_string(),
366 message: message.to_string(),
367 severity: Severity::Warning,
368 line: if line > 0 { Some(line) } else { None },
369 column: if column > 0 { Some(column) } else { None },
370 fixes: Vec::new(),
371 }
372 }
373
374 /// Attach a fix to this error
375 pub fn with_fix(mut self, fix: Fix) -> Self {
376 self.fixes.push(fix);
377 self
378 }
379
380 /// Attach multiple fixes to this error
381 pub fn with_fixes(mut self, fixes: Vec<Fix>) -> Self {
382 self.fixes.extend(fixes);
383 self
384 }
385}
386
387/// Trait that all plugins must implement.
388///
389/// A plugin consists of two parts:
390/// - **Metadata** ([`spec()`](Plugin::spec)) describing the rule name, category, severity, and documentation
391/// - **Logic** ([`check()`](Plugin::check)) that inspects the parsed nginx config and reports errors
392///
393/// Plugins must also derive [`Default`], which is used by [`export_component_plugin!`](crate::export_component_plugin)
394/// to instantiate the plugin.
395///
396/// # Example
397///
398/// ```
399/// use nginx_lint_plugin::prelude::*;
400///
401/// #[derive(Default)]
402/// pub struct MyPlugin;
403///
404/// impl Plugin for MyPlugin {
405/// fn spec(&self) -> PluginSpec {
406/// PluginSpec::new("my-rule", "security", "Check for something")
407/// .with_severity("warning")
408/// }
409///
410/// fn check(&self, config: &Config, _path: &str) -> Vec<LintError> {
411/// let mut errors = Vec::new();
412/// let err = self.spec().error_builder();
413///
414/// for ctx in config.all_directives_with_context() {
415/// if ctx.is_inside("http") && ctx.directive.is("bad_directive") {
416/// errors.push(err.warning_at("Avoid bad_directive", ctx.directive));
417/// }
418/// }
419/// errors
420/// }
421/// }
422///
423/// // export_component_plugin!(MyPlugin); // Required for WASM build
424///
425/// // Verify it works
426/// let plugin = MyPlugin;
427/// let config = nginx_lint_plugin::parse_string("http { bad_directive on; }").unwrap();
428/// let errors = plugin.check(&config, "test.conf");
429/// assert_eq!(errors.len(), 1);
430/// ```
431pub trait Plugin: Default {
432 /// Return plugin metadata.
433 ///
434 /// This is called once at plugin load time. Use [`PluginSpec::new()`] to create
435 /// the spec, then chain builder methods like [`with_severity()`](PluginSpec::with_severity),
436 /// [`with_why()`](PluginSpec::with_why), [`with_bad_example()`](PluginSpec::with_bad_example), etc.
437 fn spec(&self) -> PluginSpec;
438
439 /// Check the configuration and return any lint errors.
440 ///
441 /// Called once per file being linted. The `config` parameter contains the parsed
442 /// AST of the nginx configuration file. The `path` parameter is the file path
443 /// being checked (useful for error messages).
444 ///
445 /// Use [`config.all_directives()`](ConfigExt::all_directives) for simple iteration
446 /// or [`config.all_directives_with_context()`](ConfigExt::all_directives_with_context)
447 /// when you need to know the parent block context.
448 fn check(&self, config: &Config, path: &str) -> Vec<LintError>;
449}
450
451// Re-export AST types from nginx-lint-common
452pub use nginx_lint_common::parser::ast::{
453 Argument, ArgumentValue, Block, Comment, Config, ConfigItem, Directive, Position, Span,
454};
455pub use nginx_lint_common::parser::context::{AllDirectivesWithContextIter, DirectiveWithContext};
456
457/// Extension trait for [`Config`] providing iteration and include-context helpers.
458///
459/// This trait is automatically available when using `use nginx_lint_plugin::prelude::*`.
460///
461/// # Traversal
462///
463/// Two traversal methods are provided:
464///
465/// - [`all_directives()`](ConfigExt::all_directives) - Simple recursive iteration over all directives
466/// - [`all_directives_with_context()`](ConfigExt::all_directives_with_context) - Iteration with
467/// parent block context (e.g., know if a directive is inside `http`, `server`, `location`)
468///
469/// # Include Context
470///
471/// When nginx-lint processes `include` directives, the included file's [`Config`] receives
472/// an `include_context` field recording the parent block names. For example, a file included
473/// from `http { server { include conf.d/*.conf; } }` would have
474/// `include_context = ["http", "server"]`.
475///
476/// The `is_included_from_*` methods check this context:
477///
478/// ```
479/// use nginx_lint_plugin::prelude::*;
480///
481/// let mut config = nginx_lint_plugin::parse_string("server { listen 80; }").unwrap();
482/// assert!(!config.is_included_from_http());
483///
484/// // Simulate being included from http context
485/// config.include_context = vec!["http".to_string()];
486/// assert!(config.is_included_from_http());
487/// ```
488pub trait ConfigExt {
489 /// Iterate over all directives recursively.
490 ///
491 /// Traverses the entire config tree depth-first, yielding each [`Directive`].
492 fn all_directives(&self) -> nginx_lint_common::parser::ast::AllDirectives<'_>;
493
494 /// Iterate over all directives with parent context information.
495 ///
496 /// Each item is a [`DirectiveWithContext`] that includes the parent block stack.
497 /// This is the recommended traversal method for most plugins, as it allows
498 /// checking whether a directive is inside a specific block (e.g., `http`, `server`).
499 fn all_directives_with_context(&self) -> AllDirectivesWithContextIter<'_>;
500
501 /// Check if this config is included from within a specific context.
502 fn is_included_from(&self, context: &str) -> bool;
503
504 /// Check if this config is included from within `http` context.
505 fn is_included_from_http(&self) -> bool;
506
507 /// Check if this config is included from within `http > server` context.
508 fn is_included_from_http_server(&self) -> bool;
509
510 /// Check if this config is included from within `http > ... > location` context.
511 fn is_included_from_http_location(&self) -> bool;
512
513 /// Check if this config is included from within `stream` context.
514 fn is_included_from_stream(&self) -> bool;
515
516 /// Get the immediate parent context (last element in include_context).
517 fn immediate_parent_context(&self) -> Option<&str>;
518}
519
520impl ConfigExt for Config {
521 fn all_directives(&self) -> nginx_lint_common::parser::ast::AllDirectives<'_> {
522 // Delegate to Config's inherent method
523 Config::all_directives(self)
524 }
525
526 fn all_directives_with_context(&self) -> AllDirectivesWithContextIter<'_> {
527 // Delegate to Config's inherent method
528 Config::all_directives_with_context(self)
529 }
530
531 fn is_included_from(&self, context: &str) -> bool {
532 Config::is_included_from(self, context)
533 }
534
535 fn is_included_from_http(&self) -> bool {
536 Config::is_included_from_http(self)
537 }
538
539 fn is_included_from_http_server(&self) -> bool {
540 Config::is_included_from_http_server(self)
541 }
542
543 fn is_included_from_http_location(&self) -> bool {
544 Config::is_included_from_http_location(self)
545 }
546
547 fn is_included_from_stream(&self) -> bool {
548 Config::is_included_from_stream(self)
549 }
550
551 fn immediate_parent_context(&self) -> Option<&str> {
552 Config::immediate_parent_context(self)
553 }
554}
555
556/// Extension trait for [`Directive`] providing inspection and fix-generation helpers.
557///
558/// This trait adds convenience methods to [`Directive`] for:
559/// - **Inspection**: [`is()`](DirectiveExt::is), [`first_arg()`](DirectiveExt::first_arg),
560/// [`has_arg()`](DirectiveExt::has_arg), etc.
561/// - **Fix generation**: [`replace_with()`](DirectiveExt::replace_with),
562/// [`delete_line()`](DirectiveExt::delete_line), [`insert_after()`](DirectiveExt::insert_after), etc.
563///
564/// # Example
565///
566/// ```
567/// use nginx_lint_plugin::prelude::*;
568///
569/// let config = nginx_lint_plugin::parse_string(
570/// "proxy_pass http://backend;"
571/// ).unwrap();
572/// let directive = config.all_directives().next().unwrap();
573///
574/// assert!(directive.is("proxy_pass"));
575/// assert_eq!(directive.first_arg(), Some("http://backend"));
576/// assert_eq!(directive.arg_count(), 1);
577///
578/// // Generate a fix to replace the directive
579/// let fix = directive.replace_with("proxy_pass http://new-backend;");
580/// assert!(fix.is_range_based());
581/// ```
582pub trait DirectiveExt {
583 /// Check if the directive has the given name.
584 fn is(&self, name: &str) -> bool;
585 /// Get the first argument's string value, if any.
586 fn first_arg(&self) -> Option<&str>;
587 /// Check if the first argument equals the given value.
588 fn first_arg_is(&self, value: &str) -> bool;
589 /// Get the argument at the given index.
590 fn arg_at(&self, index: usize) -> Option<&str>;
591 /// Get the last argument's string value, if any.
592 fn last_arg(&self) -> Option<&str>;
593 /// Check if any argument equals the given value.
594 fn has_arg(&self, value: &str) -> bool;
595 /// Return the number of arguments.
596 fn arg_count(&self) -> usize;
597 /// Get the start line number (1-based).
598 fn line(&self) -> usize;
599 /// Get the start column number (1-based).
600 fn column(&self) -> usize;
601 /// Get the byte offset including leading whitespace.
602 fn full_start_offset(&self) -> usize;
603 /// Create a [`Fix`] that replaces this directive with new text, preserving indentation.
604 fn replace_with(&self, new_text: &str) -> Fix;
605 /// Create a [`Fix`] that deletes this directive's line.
606 fn delete_line(&self) -> Fix;
607 /// Create a [`Fix`] that inserts a new line after this directive, matching indentation.
608 fn insert_after(&self, new_text: &str) -> Fix;
609 /// Create a [`Fix`] that inserts multiple new lines after this directive.
610 fn insert_after_many(&self, lines: &[&str]) -> Fix;
611 /// Create a [`Fix`] that inserts a new line before this directive, matching indentation.
612 fn insert_before(&self, new_text: &str) -> Fix;
613 /// Create a [`Fix`] that inserts multiple new lines before this directive.
614 fn insert_before_many(&self, lines: &[&str]) -> Fix;
615}
616
617impl DirectiveExt for Directive {
618 fn is(&self, name: &str) -> bool {
619 self.name == name
620 }
621
622 fn first_arg(&self) -> Option<&str> {
623 self.args.first().map(|a| a.as_str())
624 }
625
626 fn first_arg_is(&self, value: &str) -> bool {
627 self.first_arg() == Some(value)
628 }
629
630 fn arg_at(&self, index: usize) -> Option<&str> {
631 self.args.get(index).map(|a| a.as_str())
632 }
633
634 fn last_arg(&self) -> Option<&str> {
635 self.args.last().map(|a| a.as_str())
636 }
637
638 fn has_arg(&self, value: &str) -> bool {
639 self.args.iter().any(|a| a.as_str() == value)
640 }
641
642 fn arg_count(&self) -> usize {
643 self.args.len()
644 }
645
646 fn line(&self) -> usize {
647 self.span.start.line
648 }
649
650 fn column(&self) -> usize {
651 self.span.start.column
652 }
653
654 fn full_start_offset(&self) -> usize {
655 self.span.start.offset - self.leading_whitespace.len()
656 }
657
658 fn replace_with(&self, new_text: &str) -> Fix {
659 let start = self.full_start_offset();
660 let end = self.span.end.offset;
661 let fixed = format!("{}{}", self.leading_whitespace, new_text);
662 Fix::replace_range(start, end, &fixed)
663 }
664
665 fn delete_line(&self) -> Fix {
666 let start = self.full_start_offset();
667 let end = self.span.end.offset + self.trailing_whitespace.len();
668 // Include trailing comment if present
669 let end = if let Some(ref comment) = self.trailing_comment {
670 comment.span.end.offset + comment.trailing_whitespace.len()
671 } else {
672 end
673 };
674 // Remove the preceding newline to actually delete the line
675 // (if not the first line)
676 if start > 0 {
677 Fix::replace_range(start - 1, end, "")
678 } else {
679 // First line: remove trailing newline instead
680 Fix::replace_range(start, end + 1, "")
681 }
682 }
683
684 fn insert_after(&self, new_text: &str) -> Fix {
685 self.insert_after_many(&[new_text])
686 }
687
688 fn insert_after_many(&self, lines: &[&str]) -> Fix {
689 let indent = " ".repeat(self.span.start.column.saturating_sub(1));
690 let fix_text: String = lines
691 .iter()
692 .map(|line| format!("\n{}{}", indent, line))
693 .collect();
694 let insert_offset = self.span.end.offset;
695 Fix::replace_range(insert_offset, insert_offset, &fix_text)
696 }
697
698 fn insert_before(&self, new_text: &str) -> Fix {
699 self.insert_before_many(&[new_text])
700 }
701
702 fn insert_before_many(&self, lines: &[&str]) -> Fix {
703 let indent = " ".repeat(self.span.start.column.saturating_sub(1));
704 let fix_text: String = lines
705 .iter()
706 .map(|line| format!("{}{}\n", indent, line))
707 .collect();
708 let line_start_offset = self.span.start.offset - (self.span.start.column - 1);
709 Fix::replace_range(line_start_offset, line_start_offset, &fix_text)
710 }
711}
712
713impl<T: DirectiveExt + ?Sized> DirectiveExt for &T {
714 fn is(&self, name: &str) -> bool {
715 (**self).is(name)
716 }
717 fn first_arg(&self) -> Option<&str> {
718 (**self).first_arg()
719 }
720 fn first_arg_is(&self, value: &str) -> bool {
721 (**self).first_arg_is(value)
722 }
723 fn arg_at(&self, index: usize) -> Option<&str> {
724 (**self).arg_at(index)
725 }
726 fn last_arg(&self) -> Option<&str> {
727 (**self).last_arg()
728 }
729 fn has_arg(&self, value: &str) -> bool {
730 (**self).has_arg(value)
731 }
732 fn arg_count(&self) -> usize {
733 (**self).arg_count()
734 }
735 fn line(&self) -> usize {
736 (**self).line()
737 }
738 fn column(&self) -> usize {
739 (**self).column()
740 }
741 fn full_start_offset(&self) -> usize {
742 (**self).full_start_offset()
743 }
744 fn replace_with(&self, new_text: &str) -> Fix {
745 (**self).replace_with(new_text)
746 }
747 fn delete_line(&self) -> Fix {
748 (**self).delete_line()
749 }
750 fn insert_after(&self, new_text: &str) -> Fix {
751 (**self).insert_after(new_text)
752 }
753 fn insert_after_many(&self, lines: &[&str]) -> Fix {
754 (**self).insert_after_many(lines)
755 }
756 fn insert_before(&self, new_text: &str) -> Fix {
757 (**self).insert_before(new_text)
758 }
759 fn insert_before_many(&self, lines: &[&str]) -> Fix {
760 (**self).insert_before_many(lines)
761 }
762}
763
764impl DirectiveExt for Box<Directive> {
765 fn is(&self, name: &str) -> bool {
766 (**self).is(name)
767 }
768 fn first_arg(&self) -> Option<&str> {
769 (**self).first_arg()
770 }
771 fn first_arg_is(&self, value: &str) -> bool {
772 (**self).first_arg_is(value)
773 }
774 fn arg_at(&self, index: usize) -> Option<&str> {
775 (**self).arg_at(index)
776 }
777 fn last_arg(&self) -> Option<&str> {
778 (**self).last_arg()
779 }
780 fn has_arg(&self, value: &str) -> bool {
781 (**self).has_arg(value)
782 }
783 fn arg_count(&self) -> usize {
784 (**self).arg_count()
785 }
786 fn line(&self) -> usize {
787 (**self).line()
788 }
789 fn column(&self) -> usize {
790 (**self).column()
791 }
792 fn full_start_offset(&self) -> usize {
793 (**self).full_start_offset()
794 }
795 fn replace_with(&self, new_text: &str) -> Fix {
796 (**self).replace_with(new_text)
797 }
798 fn delete_line(&self) -> Fix {
799 (**self).delete_line()
800 }
801 fn insert_after(&self, new_text: &str) -> Fix {
802 (**self).insert_after(new_text)
803 }
804 fn insert_after_many(&self, lines: &[&str]) -> Fix {
805 (**self).insert_after_many(lines)
806 }
807 fn insert_before(&self, new_text: &str) -> Fix {
808 (**self).insert_before(new_text)
809 }
810 fn insert_before_many(&self, lines: &[&str]) -> Fix {
811 (**self).insert_before_many(lines)
812 }
813}
814
815/// Extension trait for Argument to add source reconstruction
816pub trait ArgumentExt {
817 /// Reconstruct the source text for this argument
818 fn to_source(&self) -> String;
819}
820
821impl ArgumentExt for Argument {
822 fn to_source(&self) -> String {
823 match &self.value {
824 ArgumentValue::Literal(s) => s.clone(),
825 ArgumentValue::QuotedString(s) => format!("\"{}\"", s),
826 ArgumentValue::SingleQuotedString(s) => format!("'{}'", s),
827 ArgumentValue::Variable(s) => format!("${}", s),
828 }
829 }
830}