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