Skip to main content

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.2";
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    /// Declare the directive names this plugin's [`check()`](Plugin::check)
451    /// reads, if it only reads a fixed, known set.
452    ///
453    /// When overridden, the host builds `config` from a snapshot pruned to
454    /// only those directive names (plus the ancestor directives needed for
455    /// block-context queries like [`is_inside`](DirectiveWithContext::is_inside)
456    /// to keep working) instead of the whole file, which is faster the less
457    /// of the file is relevant. **Comments and blank lines are always
458    /// omitted** from the pruned config, so do not override this if `check`
459    /// reads [`ConfigItem::Comment`] or [`ConfigItem::BlankLine`] — the
460    /// default (`None`) gives `check` the complete, unpruned config, as
461    /// before this method existed.
462    ///
463    /// This is purely a guest-side, non-breaking hint: it is never part of
464    /// the WIT component interface, so overriding it does not change what a
465    /// plugin exports or its compatibility with any host version.
466    fn relevant_directives(&self) -> Option<&'static [&'static str]> {
467        None
468    }
469}
470
471// Re-export AST types from nginx-lint-common
472pub use nginx_lint_common::parser::ast::{
473    Argument, ArgumentValue, Block, Comment, Config, ConfigItem, Directive, Position, Span,
474};
475pub use nginx_lint_common::parser::context::{AllDirectivesWithContextIter, DirectiveWithContext};
476
477/// Extension trait for [`Config`] providing iteration and include-context helpers.
478///
479/// This trait is automatically available when using `use nginx_lint_plugin::prelude::*`.
480///
481/// # Traversal
482///
483/// Two traversal methods are provided:
484///
485/// - [`all_directives()`](ConfigExt::all_directives) - Simple recursive iteration over all directives
486/// - [`all_directives_with_context()`](ConfigExt::all_directives_with_context) - Iteration with
487///   parent block context (e.g., know if a directive is inside `http`, `server`, `location`)
488///
489/// # Include Context
490///
491/// When nginx-lint processes `include` directives, the included file's [`Config`] receives
492/// an `include_context` field recording the parent block names. For example, a file included
493/// from `http { server { include conf.d/*.conf; } }` would have
494/// `include_context = ["http", "server"]`.
495///
496/// The `is_included_from_*` methods check this context:
497///
498/// ```
499/// use nginx_lint_plugin::prelude::*;
500///
501/// let mut config = nginx_lint_plugin::parse_string("server { listen 80; }").unwrap();
502/// assert!(!config.is_included_from_http());
503///
504/// // Simulate being included from http context
505/// config.include_context = vec!["http".to_string()];
506/// assert!(config.is_included_from_http());
507/// ```
508pub trait ConfigExt {
509    /// Iterate over all directives recursively.
510    ///
511    /// Traverses the entire config tree depth-first, yielding each [`Directive`].
512    fn all_directives(&self) -> nginx_lint_common::parser::ast::AllDirectives<'_>;
513
514    /// Iterate over all directives with parent context information.
515    ///
516    /// Each item is a [`DirectiveWithContext`] that includes the parent block stack.
517    /// This is the recommended traversal method for most plugins, as it allows
518    /// checking whether a directive is inside a specific block (e.g., `http`, `server`).
519    fn all_directives_with_context(&self) -> AllDirectivesWithContextIter<'_>;
520
521    /// Check if this config is included from within a specific context.
522    fn is_included_from(&self, context: &str) -> bool;
523
524    /// Check if this config is included from within `http` context.
525    fn is_included_from_http(&self) -> bool;
526
527    /// Check if this config is included from within `http > server` context.
528    fn is_included_from_http_server(&self) -> bool;
529
530    /// Check if this config is included from within `http > ... > location` context.
531    fn is_included_from_http_location(&self) -> bool;
532
533    /// Check if this config is included from within `stream` context.
534    fn is_included_from_stream(&self) -> bool;
535
536    /// Get the immediate parent context (last element in include_context).
537    fn immediate_parent_context(&self) -> Option<&str>;
538}
539
540impl ConfigExt for Config {
541    fn all_directives(&self) -> nginx_lint_common::parser::ast::AllDirectives<'_> {
542        // Delegate to Config's inherent method
543        Config::all_directives(self)
544    }
545
546    fn all_directives_with_context(&self) -> AllDirectivesWithContextIter<'_> {
547        // Delegate to Config's inherent method
548        Config::all_directives_with_context(self)
549    }
550
551    fn is_included_from(&self, context: &str) -> bool {
552        Config::is_included_from(self, context)
553    }
554
555    fn is_included_from_http(&self) -> bool {
556        Config::is_included_from_http(self)
557    }
558
559    fn is_included_from_http_server(&self) -> bool {
560        Config::is_included_from_http_server(self)
561    }
562
563    fn is_included_from_http_location(&self) -> bool {
564        Config::is_included_from_http_location(self)
565    }
566
567    fn is_included_from_stream(&self) -> bool {
568        Config::is_included_from_stream(self)
569    }
570
571    fn immediate_parent_context(&self) -> Option<&str> {
572        Config::immediate_parent_context(self)
573    }
574}
575
576/// Extension trait for [`Directive`] providing inspection and fix-generation helpers.
577///
578/// This trait adds convenience methods to [`Directive`] for:
579/// - **Inspection**: [`is()`](DirectiveExt::is), [`first_arg()`](DirectiveExt::first_arg),
580///   [`has_arg()`](DirectiveExt::has_arg), etc.
581/// - **Fix generation**: [`replace_with()`](DirectiveExt::replace_with),
582///   [`delete_line()`](DirectiveExt::delete_line), [`insert_after()`](DirectiveExt::insert_after), etc.
583///
584/// # Example
585///
586/// ```
587/// use nginx_lint_plugin::prelude::*;
588///
589/// let config = nginx_lint_plugin::parse_string(
590///     "proxy_pass http://backend;"
591/// ).unwrap();
592/// let directive = config.all_directives().next().unwrap();
593///
594/// assert!(directive.is("proxy_pass"));
595/// assert_eq!(directive.first_arg(), Some("http://backend"));
596/// assert_eq!(directive.arg_count(), 1);
597///
598/// // Generate a fix to replace the directive
599/// let fix = directive.replace_with("proxy_pass http://new-backend;");
600/// assert!(fix.is_range_based());
601/// ```
602pub trait DirectiveExt {
603    /// Check if the directive has the given name.
604    fn is(&self, name: &str) -> bool;
605    /// Get the first argument's string value, if any.
606    fn first_arg(&self) -> Option<&str>;
607    /// Check if the first argument equals the given value.
608    fn first_arg_is(&self, value: &str) -> bool;
609    /// Get the argument at the given index.
610    fn arg_at(&self, index: usize) -> Option<&str>;
611    /// Get the last argument's string value, if any.
612    fn last_arg(&self) -> Option<&str>;
613    /// Check if any argument equals the given value.
614    fn has_arg(&self, value: &str) -> bool;
615    /// Return the number of arguments.
616    fn arg_count(&self) -> usize;
617    /// Get the start line number (1-based).
618    fn line(&self) -> usize;
619    /// Get the start column number (1-based).
620    fn column(&self) -> usize;
621    /// Get the byte offset including leading whitespace.
622    fn full_start_offset(&self) -> usize;
623    /// Create a [`Fix`] that replaces this directive with new text, preserving indentation.
624    fn replace_with(&self, new_text: &str) -> Fix;
625    /// Create a [`Fix`] that deletes this directive's line.
626    fn delete_line(&self) -> Fix;
627    /// Create a [`Fix`] that inserts a new line after this directive, matching indentation.
628    fn insert_after(&self, new_text: &str) -> Fix;
629    /// Create a [`Fix`] that inserts multiple new lines after this directive.
630    fn insert_after_many(&self, lines: &[&str]) -> Fix;
631    /// Create a [`Fix`] that inserts a new line before this directive, matching indentation.
632    fn insert_before(&self, new_text: &str) -> Fix;
633    /// Create a [`Fix`] that inserts multiple new lines before this directive.
634    fn insert_before_many(&self, lines: &[&str]) -> Fix;
635}
636
637impl DirectiveExt for Directive {
638    fn is(&self, name: &str) -> bool {
639        self.name == name
640    }
641
642    fn first_arg(&self) -> Option<&str> {
643        self.args.first().map(|a| a.as_str())
644    }
645
646    fn first_arg_is(&self, value: &str) -> bool {
647        self.first_arg() == Some(value)
648    }
649
650    fn arg_at(&self, index: usize) -> Option<&str> {
651        self.args.get(index).map(|a| a.as_str())
652    }
653
654    fn last_arg(&self) -> Option<&str> {
655        self.args.last().map(|a| a.as_str())
656    }
657
658    fn has_arg(&self, value: &str) -> bool {
659        self.args.iter().any(|a| a.as_str() == value)
660    }
661
662    fn arg_count(&self) -> usize {
663        self.args.len()
664    }
665
666    fn line(&self) -> usize {
667        self.span.start.line
668    }
669
670    fn column(&self) -> usize {
671        self.span.start.column
672    }
673
674    fn full_start_offset(&self) -> usize {
675        self.span.start.offset - self.leading_whitespace.len()
676    }
677
678    fn replace_with(&self, new_text: &str) -> Fix {
679        let start = self.full_start_offset();
680        let end = self.span.end.offset;
681        let fixed = format!("{}{}", self.leading_whitespace, new_text);
682        Fix::replace_range(start, end, &fixed)
683    }
684
685    fn delete_line(&self) -> Fix {
686        let start = self.full_start_offset();
687        let end = self.span.end.offset + self.trailing_whitespace.len();
688        // Include trailing comment if present
689        let end = if let Some(ref comment) = self.trailing_comment {
690            comment.span.end.offset + comment.trailing_whitespace.len()
691        } else {
692            end
693        };
694        // Remove the preceding newline to actually delete the line
695        // (if not the first line)
696        if start > 0 {
697            Fix::replace_range(start - 1, end, "")
698        } else {
699            // First line: remove trailing newline instead
700            Fix::replace_range(start, end + 1, "")
701        }
702    }
703
704    fn insert_after(&self, new_text: &str) -> Fix {
705        self.insert_after_many(&[new_text])
706    }
707
708    fn insert_after_many(&self, lines: &[&str]) -> Fix {
709        let indent = " ".repeat(self.span.start.column.saturating_sub(1));
710        let fix_text: String = lines
711            .iter()
712            .map(|line| format!("\n{}{}", indent, line))
713            .collect();
714        let insert_offset = self.span.end.offset;
715        Fix::replace_range(insert_offset, insert_offset, &fix_text)
716    }
717
718    fn insert_before(&self, new_text: &str) -> Fix {
719        self.insert_before_many(&[new_text])
720    }
721
722    fn insert_before_many(&self, lines: &[&str]) -> Fix {
723        let indent = " ".repeat(self.span.start.column.saturating_sub(1));
724        let fix_text: String = lines
725            .iter()
726            .map(|line| format!("{}{}\n", indent, line))
727            .collect();
728        let line_start_offset = self.span.start.offset - (self.span.start.column - 1);
729        Fix::replace_range(line_start_offset, line_start_offset, &fix_text)
730    }
731}
732
733impl<T: DirectiveExt + ?Sized> DirectiveExt for &T {
734    fn is(&self, name: &str) -> bool {
735        (**self).is(name)
736    }
737    fn first_arg(&self) -> Option<&str> {
738        (**self).first_arg()
739    }
740    fn first_arg_is(&self, value: &str) -> bool {
741        (**self).first_arg_is(value)
742    }
743    fn arg_at(&self, index: usize) -> Option<&str> {
744        (**self).arg_at(index)
745    }
746    fn last_arg(&self) -> Option<&str> {
747        (**self).last_arg()
748    }
749    fn has_arg(&self, value: &str) -> bool {
750        (**self).has_arg(value)
751    }
752    fn arg_count(&self) -> usize {
753        (**self).arg_count()
754    }
755    fn line(&self) -> usize {
756        (**self).line()
757    }
758    fn column(&self) -> usize {
759        (**self).column()
760    }
761    fn full_start_offset(&self) -> usize {
762        (**self).full_start_offset()
763    }
764    fn replace_with(&self, new_text: &str) -> Fix {
765        (**self).replace_with(new_text)
766    }
767    fn delete_line(&self) -> Fix {
768        (**self).delete_line()
769    }
770    fn insert_after(&self, new_text: &str) -> Fix {
771        (**self).insert_after(new_text)
772    }
773    fn insert_after_many(&self, lines: &[&str]) -> Fix {
774        (**self).insert_after_many(lines)
775    }
776    fn insert_before(&self, new_text: &str) -> Fix {
777        (**self).insert_before(new_text)
778    }
779    fn insert_before_many(&self, lines: &[&str]) -> Fix {
780        (**self).insert_before_many(lines)
781    }
782}
783
784impl DirectiveExt for Box<Directive> {
785    fn is(&self, name: &str) -> bool {
786        (**self).is(name)
787    }
788    fn first_arg(&self) -> Option<&str> {
789        (**self).first_arg()
790    }
791    fn first_arg_is(&self, value: &str) -> bool {
792        (**self).first_arg_is(value)
793    }
794    fn arg_at(&self, index: usize) -> Option<&str> {
795        (**self).arg_at(index)
796    }
797    fn last_arg(&self) -> Option<&str> {
798        (**self).last_arg()
799    }
800    fn has_arg(&self, value: &str) -> bool {
801        (**self).has_arg(value)
802    }
803    fn arg_count(&self) -> usize {
804        (**self).arg_count()
805    }
806    fn line(&self) -> usize {
807        (**self).line()
808    }
809    fn column(&self) -> usize {
810        (**self).column()
811    }
812    fn full_start_offset(&self) -> usize {
813        (**self).full_start_offset()
814    }
815    fn replace_with(&self, new_text: &str) -> Fix {
816        (**self).replace_with(new_text)
817    }
818    fn delete_line(&self) -> Fix {
819        (**self).delete_line()
820    }
821    fn insert_after(&self, new_text: &str) -> Fix {
822        (**self).insert_after(new_text)
823    }
824    fn insert_after_many(&self, lines: &[&str]) -> Fix {
825        (**self).insert_after_many(lines)
826    }
827    fn insert_before(&self, new_text: &str) -> Fix {
828        (**self).insert_before(new_text)
829    }
830    fn insert_before_many(&self, lines: &[&str]) -> Fix {
831        (**self).insert_before_many(lines)
832    }
833}
834
835/// Extension trait for Argument to add source reconstruction
836pub trait ArgumentExt {
837    /// Reconstruct the source text for this argument
838    fn to_source(&self) -> String;
839}
840
841impl ArgumentExt for Argument {
842    fn to_source(&self) -> String {
843        match &self.value {
844            ArgumentValue::Literal(s) => s.clone(),
845            ArgumentValue::QuotedString(s) => format!("\"{}\"", s),
846            ArgumentValue::SingleQuotedString(s) => format!("'{}'", s),
847            ArgumentValue::Variable(s) => format!("${}", s),
848        }
849    }
850}