Skip to main content

cabalist_parser/
edit.rs

1//! Edit engine for surgical CST mutations that preserve formatting.
2//!
3//! Edits work by producing [`TextEdit`]s (insertions/deletions at byte offsets)
4//! that are collected in an [`EditBatch`] and applied atomically to the source
5//! string. After applying, the caller re-parses from scratch to get a fresh
6//! CST + AST.
7//!
8//! The key challenge is list field editing: `.cabal` files use several different
9//! formatting styles for list fields (single-line, leading-comma, trailing-comma,
10//! no-comma), and the edit engine must detect and follow the existing style.
11
12use crate::cst::{CabalCst, CstNodeKind};
13use crate::span::{NodeId, Span};
14
15// ---------------------------------------------------------------------------
16// Types
17// ---------------------------------------------------------------------------
18
19/// The detected formatting style of a list field.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ListStyle {
22    /// Single line, comma-separated: `build-depends: base, text, aeson`
23    SingleLine,
24    /// Multi-line, leading comma: each continuation starts with `, `
25    LeadingComma,
26    /// Multi-line, trailing comma: each line ends with `,`
27    TrailingComma,
28    /// Multi-line, no commas (whitespace-separated, e.g. module lists)
29    NoComma,
30}
31
32/// A text edit to apply to the source.
33#[derive(Debug, Clone)]
34pub struct TextEdit {
35    /// Byte range to replace (can be empty for pure insertions).
36    pub range: Span,
37    /// Replacement text.
38    pub replacement: String,
39}
40
41/// A batch of edits to apply atomically.
42#[derive(Debug, Clone)]
43pub struct EditBatch {
44    edits: Vec<TextEdit>,
45}
46
47impl Default for EditBatch {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl EditBatch {
54    /// Create a new empty batch.
55    pub fn new() -> Self {
56        Self { edits: Vec::new() }
57    }
58
59    /// Add an edit to the batch.
60    pub fn add(&mut self, edit: TextEdit) {
61        self.edits.push(edit);
62    }
63
64    /// Add multiple edits to the batch.
65    pub fn add_all(&mut self, edits: Vec<TextEdit>) {
66        self.edits.extend(edits);
67    }
68
69    /// Whether the batch contains any edits.
70    pub fn is_empty(&self) -> bool {
71        self.edits.is_empty()
72    }
73
74    /// Apply all edits to the source string, returning the new source.
75    ///
76    /// Edits are sorted by position and applied from end to start so earlier
77    /// offsets remain valid. Panics if any edits overlap.
78    pub fn apply(mut self, source: &str) -> String {
79        if self.edits.is_empty() {
80            return source.to_owned();
81        }
82
83        // Sort by start descending so we apply from end to start.
84        self.edits
85            .sort_by_key(|edit| std::cmp::Reverse(edit.range.start));
86
87        // Verify no overlapping edits.
88        for pair in self.edits.windows(2) {
89            // pair[0] has higher start than pair[1] (descending order).
90            assert!(
91                pair[0].range.start >= pair[1].range.end,
92                "overlapping edits: {:?} and {:?}",
93                pair[1].range,
94                pair[0].range,
95            );
96        }
97
98        let mut result = source.to_owned();
99        for edit in &self.edits {
100            result.replace_range(edit.range.start..edit.range.end, &edit.replacement);
101        }
102        result
103    }
104}
105
106// ---------------------------------------------------------------------------
107// List style detection
108// ---------------------------------------------------------------------------
109
110/// Detect the list style of a field node.
111///
112/// Examines the field's inline value and `ValueLine` children to determine
113/// which formatting convention is used.
114pub fn detect_list_style(cst: &CabalCst, field_node: NodeId) -> ListStyle {
115    let node = cst.node(field_node);
116    debug_assert_eq!(node.kind, CstNodeKind::Field);
117
118    let value_lines: Vec<NodeId> = node
119        .children
120        .iter()
121        .copied()
122        .filter(|&id| cst.node(id).kind == CstNodeKind::ValueLine)
123        .collect();
124
125    // If there are no continuation lines, it's single-line.
126    if value_lines.is_empty() {
127        return ListStyle::SingleLine;
128    }
129
130    // Multi-line: examine continuation lines for comma style.
131    let mut has_leading_comma = false;
132    let mut has_trailing_comma = false;
133    let mut has_any_comma = false;
134
135    for &vl_id in &value_lines {
136        let vl = cst.node(vl_id);
137        let text = vl.content_span.slice(&cst.source);
138        let trimmed = text.trim();
139
140        if trimmed.starts_with(',') {
141            has_leading_comma = true;
142            has_any_comma = true;
143        }
144        if trimmed.ends_with(',') {
145            has_trailing_comma = true;
146            has_any_comma = true;
147        }
148    }
149
150    // Also check the inline value for trailing comma (Style C can have first
151    // item on the field line itself ending with comma).
152    if let Some(fv) = node.field_value {
153        let fv_text = fv.slice(&cst.source).trim();
154        if fv_text.ends_with(',') {
155            has_trailing_comma = true;
156            has_any_comma = true;
157        }
158    }
159
160    if !has_any_comma {
161        return ListStyle::NoComma;
162    }
163    if has_leading_comma {
164        return ListStyle::LeadingComma;
165    }
166    if has_trailing_comma {
167        return ListStyle::TrailingComma;
168    }
169
170    // Fallback: if commas are present but we couldn't classify, default to
171    // trailing comma (most common multi-line style after leading).
172    ListStyle::TrailingComma
173}
174
175// ---------------------------------------------------------------------------
176// Helpers
177// ---------------------------------------------------------------------------
178
179/// Extract the "item name" from a list item string. For dependencies, this
180/// is the package name (everything before version constraints). For modules,
181/// it's the module name itself.
182fn item_name(item: &str) -> &str {
183    let trimmed = item.trim().trim_start_matches(',').trim();
184    // The item name is the first word (letters, digits, hyphens, underscores, dots).
185    let end = trimmed
186        .find(|c: char| c.is_whitespace() || c == '>' || c == '<' || c == '=' || c == '^')
187        .unwrap_or(trimmed.len());
188    let name = &trimmed[..end];
189    name.trim_end_matches(',')
190}
191
192/// Extract a clean item text for comparison, stripping leading/trailing commas
193/// and whitespace.
194fn clean_item_text(text: &str) -> &str {
195    text.trim()
196        .trim_start_matches(',')
197        .trim_end_matches(',')
198        .trim()
199}
200
201/// Gather all items from a field as (item_text, source_range) pairs.
202/// The source_range covers the full line/segment including indentation and
203/// newlines.
204fn gather_items(cst: &CabalCst, field_node: NodeId) -> Vec<(String, Span)> {
205    let node = cst.node(field_node);
206    let mut items = Vec::new();
207
208    // Inline value items (for single-line style).
209    if let Some(fv) = node.field_value {
210        let fv_text = fv.slice(&cst.source);
211        if !fv_text.trim().is_empty() {
212            items.push((fv_text.to_owned(), fv));
213        }
214    }
215
216    // ValueLine children.
217    for &child_id in &node.children {
218        let child = cst.node(child_id);
219        if child.kind == CstNodeKind::ValueLine {
220            let text = child.content_span.slice(&cst.source).to_owned();
221            items.push((text, child.span));
222        }
223    }
224
225    items
226}
227
228/// Find the indentation string used by existing value lines. Returns the
229/// whitespace prefix of the first value line, or a default based on the
230/// field's own indent.
231fn detect_item_indent(cst: &CabalCst, field_node: NodeId) -> String {
232    let node = cst.node(field_node);
233
234    for &child_id in &node.children {
235        let child = cst.node(child_id);
236        if child.kind == CstNodeKind::ValueLine {
237            // The span includes leading trivia (whitespace). The content_span
238            // starts at the actual content. The difference is the indentation.
239            let line_start = child.span.start;
240            let content_start = child.content_span.start;
241            if content_start > line_start {
242                return cst.source[line_start..content_start].to_owned();
243            }
244            // If span == content_span, use the indent level.
245            return " ".repeat(child.indent);
246        }
247    }
248
249    // No existing children: use field indent + 2 spaces.
250    let field_indent = node.indent;
251    " ".repeat(field_indent + 2)
252}
253
254/// Find the byte offset where the field's value area ends (after the last
255/// ValueLine child, or after the inline value if no children). This is the
256/// insertion point for appending new items.
257fn field_value_end(cst: &CabalCst, field_node: NodeId) -> usize {
258    let node = cst.node(field_node);
259
260    // If there are ValueLine children, the end is after the last one.
261    let value_lines: Vec<NodeId> = node
262        .children
263        .iter()
264        .copied()
265        .filter(|&id| cst.node(id).kind == CstNodeKind::ValueLine)
266        .collect();
267
268    if let Some(&last_vl) = value_lines.last() {
269        return cst.node(last_vl).span.end;
270    }
271
272    // No children: end is after the trailing trivia of the field node itself.
273    node.span.end
274}
275
276/// Find the insertion point and text for adding an item at a specific index
277/// in the sorted list of items.
278fn find_sorted_insert_index(items: &[(String, Span)], new_item: &str) -> usize {
279    let new_name = item_name(new_item).to_lowercase();
280    for (i, (text, _)) in items.iter().enumerate() {
281        let existing_name = item_name(clean_item_text(text)).to_lowercase();
282        if new_name < existing_name {
283            return i;
284        }
285    }
286    items.len()
287}
288
289// ---------------------------------------------------------------------------
290// High-level edit operations
291// ---------------------------------------------------------------------------
292
293/// Add an item to a list field (e.g., add a dependency to build-depends).
294///
295/// Respects the detected list style. If `sort` is true, inserts alphabetically
296/// by item name; otherwise appends at the end.
297pub fn add_list_item(cst: &CabalCst, field_node: NodeId, item: &str, sort: bool) -> Vec<TextEdit> {
298    let style = detect_list_style(cst, field_node);
299    let items = gather_items(cst, field_node);
300
301    // Empty field (no items at all).
302    if items.is_empty() {
303        return add_item_to_empty_field(cst, field_node, item, style);
304    }
305
306    let insert_idx = if sort {
307        find_sorted_insert_index(&items, item)
308    } else {
309        items.len()
310    };
311
312    match style {
313        ListStyle::SingleLine => add_item_single_line(cst, field_node, &items, item, insert_idx),
314        ListStyle::LeadingComma => {
315            add_item_leading_comma(cst, field_node, &items, item, insert_idx)
316        }
317        ListStyle::TrailingComma => {
318            add_item_trailing_comma(cst, field_node, &items, item, insert_idx)
319        }
320        ListStyle::NoComma => add_item_no_comma(cst, field_node, &items, item, insert_idx),
321    }
322}
323
324/// Add an item to a field that currently has no items.
325fn add_item_to_empty_field(
326    cst: &CabalCst,
327    field_node: NodeId,
328    item: &str,
329    style: ListStyle,
330) -> Vec<TextEdit> {
331    let node = cst.node(field_node);
332
333    match style {
334        ListStyle::SingleLine => {
335            // The field line is `field-name:\n`: insert value after the colon.
336            // Find the colon position from the content_span.
337            let content_end = node.content_span.end;
338            vec![TextEdit {
339                range: Span::new(content_end, content_end),
340                replacement: format!(" {item}"),
341            }]
342        }
343        _ => {
344            // Multi-line: add after the field line with proper indentation.
345            let indent = detect_item_indent(cst, field_node);
346            let end = field_value_end(cst, field_node);
347            vec![TextEdit {
348                range: Span::new(end, end),
349                replacement: format!("{indent}{item}\n"),
350            }]
351        }
352    }
353}
354
355/// Add item to a single-line comma-separated field.
356fn add_item_single_line(
357    cst: &CabalCst,
358    field_node: NodeId,
359    items: &[(String, Span)],
360    item: &str,
361    insert_idx: usize,
362) -> Vec<TextEdit> {
363    let node = cst.node(field_node);
364
365    // The entire value is in the field_value span.
366    if let Some(fv) = node.field_value {
367        let fv_text = fv.slice(&cst.source);
368
369        // Parse individual items from the single-line value.
370        let parts: Vec<&str> = fv_text.split(',').collect();
371
372        if insert_idx >= parts.len() || insert_idx >= items.len() {
373            // Append at the end.
374            vec![TextEdit {
375                range: Span::new(fv.end, fv.end),
376                replacement: format!(", {item}"),
377            }]
378        } else {
379            // Find the byte offset of the insert position within fv_text.
380            // We need to find where the nth comma-separated item starts.
381            let mut offset = 0;
382            for (i, part) in parts.iter().enumerate() {
383                if i == insert_idx {
384                    break;
385                }
386                offset += part.len() + 1; // +1 for the comma
387            }
388            let insert_offset = fv.start + offset;
389            vec![TextEdit {
390                range: Span::new(insert_offset, insert_offset),
391                replacement: format!("{item}, "),
392            }]
393        }
394    } else {
395        // No field value: shouldn't happen since items is non-empty, but
396        // handle gracefully.
397        add_item_to_empty_field(cst, field_node, item, ListStyle::SingleLine)
398    }
399}
400
401/// Add item to a leading-comma multi-line field.
402///
403/// Leading comma style:
404/// ```text
405/// build-depends:
406///     base >=4.14
407///   , text >=2.0
408///   , aeson ^>=2.2
409/// ```
410/// The first item has no leading comma; subsequent items start with `, `.
411fn add_item_leading_comma(
412    cst: &CabalCst,
413    field_node: NodeId,
414    items: &[(String, Span)],
415    item: &str,
416    insert_idx: usize,
417) -> Vec<TextEdit> {
418    let indent = detect_item_indent(cst, field_node);
419
420    // For leading-comma, we need to figure out the comma+indent pattern.
421    // Typically:
422    //   - First item:  "    base >=4.14"  (deeper indent, no comma)
423    //   - Other items: "  , text >=2.0"   (comma indent, then ", item")
424    //
425    // Detect the comma indent from an existing non-first ValueLine.
426    let node = cst.node(field_node);
427    let value_lines: Vec<NodeId> = node
428        .children
429        .iter()
430        .copied()
431        .filter(|&id| cst.node(id).kind == CstNodeKind::ValueLine)
432        .collect();
433
434    // Determine if the first item is the inline value or the first ValueLine.
435    let inline_value = node
436        .field_value
437        .filter(|span| !span.slice(&cst.source).trim().is_empty());
438    let has_inline = inline_value.is_some();
439
440    // Find the comma prefix used by non-first items.
441    let comma_prefix = find_leading_comma_prefix(cst, &value_lines, &indent);
442
443    if insert_idx == 0 {
444        // Inserting at the very beginning.
445        if has_inline {
446            // Replace the inline value: insert before it with the new item
447            // and demote the old first item to have a leading comma.
448            let Some(fv) = inline_value else {
449                return Vec::new();
450            };
451            let fv_text = fv.slice(&cst.source).to_owned();
452            let old_first_clean = clean_item_text(&fv_text);
453
454            // The first item uses deeper indent (from the existing inline value position).
455            // We need to move the inline value to a ValueLine and insert our new one.
456            // The simplest approach: replace inline value with new item, and insert
457            // old first as leading-comma line.
458            let first_vl_end = if value_lines.is_empty() {
459                field_value_end(cst, field_node)
460            } else {
461                // Insert right before the first ValueLine.
462                value_lines
463                    .first()
464                    .map(|id| cst.node(*id).span.start)
465                    .unwrap_or_else(|| field_value_end(cst, field_node))
466            };
467
468            vec![
469                TextEdit {
470                    range: fv,
471                    replacement: item.to_owned(),
472                },
473                TextEdit {
474                    range: Span::new(first_vl_end, first_vl_end),
475                    replacement: format!("{comma_prefix}{old_first_clean}\n"),
476                },
477            ]
478        } else if !value_lines.is_empty() {
479            // First item is the first ValueLine. We need to:
480            // 1. Replace the first ValueLine content with our new item (using
481            //    the deeper first-item indent).
482            // 2. Insert the old first item as a leading-comma line after.
483            let first_vl = cst.node(value_lines[0]);
484            let first_text = first_vl.content_span.slice(&cst.source);
485            let old_first_clean = clean_item_text(first_text).to_owned();
486
487            // The first item's full indent (no comma).
488            let first_item_indent = &cst.source[first_vl.span.start..first_vl.content_span.start];
489
490            vec![TextEdit {
491                range: first_vl.span,
492                replacement: format!(
493                    "{first_item_indent}{item}\n{comma_prefix}{old_first_clean}\n"
494                ),
495            }]
496        } else {
497            // No items at all: shouldn't reach here since items is non-empty.
498            add_item_to_empty_field(cst, field_node, item, ListStyle::LeadingComma)
499        }
500    } else if insert_idx >= items.len() {
501        // Append at end.
502        let end = field_value_end(cst, field_node);
503        vec![TextEdit {
504            range: Span::new(end, end),
505            replacement: format!("{comma_prefix}{item}\n"),
506        }]
507    } else {
508        // Insert in the middle. The new item gets a leading comma and goes
509        // before the item at insert_idx.
510        let Some(target_item) = items.get(insert_idx) else {
511            return Vec::new();
512        };
513        let target_span = target_item.1;
514        vec![TextEdit {
515            range: Span::new(target_span.start, target_span.start),
516            replacement: format!("{comma_prefix}{item}\n"),
517        }]
518    }
519}
520
521/// Find the leading-comma prefix string (e.g., "  , ") from existing value lines.
522fn find_leading_comma_prefix(
523    cst: &CabalCst,
524    value_lines: &[NodeId],
525    default_indent: &str,
526) -> String {
527    for &vl_id in value_lines {
528        let vl = cst.node(vl_id);
529        let text = vl.content_span.slice(&cst.source);
530        let trimmed = text.trim();
531        if trimmed.starts_with(',') {
532            // This line has a leading comma. The full prefix is the indentation
533            // + the comma + space.
534            // content_span starts at the comma. Find where the actual item text
535            // starts (after ", ").
536            let after_comma = trimmed.trim_start_matches(',').len();
537            let comma_and_space_len = trimmed.len() - after_comma;
538            let prefix_end = vl.content_span.start + comma_and_space_len;
539            return cst.source[vl.span.start..prefix_end].to_owned();
540        }
541    }
542    // Fallback: use the default indent with ", " prefix.
543    format!("{default_indent}, ")
544}
545
546/// Add item to a trailing-comma multi-line field.
547///
548/// Trailing comma style:
549/// ```text
550/// build-depends:
551///     base >=4.14,
552///     text >=2.0,
553///     aeson ^>=2.2
554/// ```
555fn add_item_trailing_comma(
556    cst: &CabalCst,
557    field_node: NodeId,
558    items: &[(String, Span)],
559    item: &str,
560    insert_idx: usize,
561) -> Vec<TextEdit> {
562    let indent = detect_item_indent(cst, field_node);
563
564    if insert_idx >= items.len() {
565        // Append at end in trailing-comma style.
566        let last = &items[items.len() - 1];
567        let last_span = last.1;
568        let last_node_text = last.0.trim();
569        let last_has_comma = last_node_text.ends_with(',');
570
571        let mut edits = Vec::new();
572
573        // Add trailing comma to the current last item if it doesn't have one.
574        if !last_has_comma {
575            let last_content_end = find_content_end_in_span(cst, last_span);
576            edits.push(TextEdit {
577                range: Span::new(last_content_end, last_content_end),
578                replacement: ",".to_owned(),
579            });
580        }
581
582        // Add the new item. If the existing last item already had a trailing
583        // comma (meaning this style uses trailing commas on every line),
584        // add our new item with a trailing comma too for consistency.
585        let new_item = if last_has_comma {
586            format!("{indent}{item},\n")
587        } else {
588            format!("{indent}{item}\n")
589        };
590
591        let end = field_value_end(cst, field_node);
592        edits.push(TextEdit {
593            range: Span::new(end, end),
594            replacement: new_item,
595        });
596
597        edits
598    } else if insert_idx == 0 {
599        // Insert at the beginning.
600        let Some(first) = items.first() else {
601            return Vec::new();
602        };
603        let first_span = first.1;
604        vec![TextEdit {
605            range: Span::new(first_span.start, first_span.start),
606            replacement: format!("{indent}{item},\n"),
607        }]
608    } else {
609        // Insert in the middle. The item at insert_idx-1 should already have
610        // a trailing comma.
611        let Some(target) = items.get(insert_idx) else {
612            return Vec::new();
613        };
614        let target_span = target.1;
615        vec![TextEdit {
616            range: Span::new(target_span.start, target_span.start),
617            replacement: format!("{indent}{item},\n"),
618        }]
619    }
620}
621
622/// Add item to a no-comma multi-line field (e.g., exposed-modules).
623fn add_item_no_comma(
624    cst: &CabalCst,
625    field_node: NodeId,
626    items: &[(String, Span)],
627    item: &str,
628    insert_idx: usize,
629) -> Vec<TextEdit> {
630    let indent = detect_item_indent(cst, field_node);
631
632    if insert_idx >= items.len() {
633        // Append at end.
634        let end = field_value_end(cst, field_node);
635        vec![TextEdit {
636            range: Span::new(end, end),
637            replacement: format!("{indent}{item}\n"),
638        }]
639    } else {
640        // Insert before the item at insert_idx.
641        let Some(target) = items.get(insert_idx) else {
642            return Vec::new();
643        };
644        let target_span = target.1;
645        vec![TextEdit {
646            range: Span::new(target_span.start, target_span.start),
647            replacement: format!("{indent}{item}\n"),
648        }]
649    }
650}
651
652/// Find the end of the actual text content within a span (before trailing
653/// whitespace/newline).
654fn find_content_end_in_span(cst: &CabalCst, span: Span) -> usize {
655    let text = &cst.source[span.start..span.end];
656    let trimmed_len = text.trim_end().len();
657    span.start + trimmed_len
658}
659
660// ---------------------------------------------------------------------------
661// Remove list item
662// ---------------------------------------------------------------------------
663
664/// Remove an item from a list field by prefix match on the item name.
665///
666/// For dependencies, `item_prefix` is typically the package name. The
667/// removal matches if the item's name starts with `item_prefix`.
668pub fn remove_list_item(cst: &CabalCst, field_node: NodeId, item_prefix: &str) -> Vec<TextEdit> {
669    let style = detect_list_style(cst, field_node);
670
671    // Single-line fields need special handling: the whole value is one span,
672    // so we search within the comma-separated text directly.
673    if style == ListStyle::SingleLine {
674        return remove_item_single_line(cst, field_node, item_prefix);
675    }
676
677    let items = gather_items(cst, field_node);
678
679    let prefix_lower = item_prefix.to_lowercase();
680
681    // Find the index of the item to remove.
682    let remove_idx = items.iter().position(|(text, _)| {
683        let name = item_name(clean_item_text(text)).to_lowercase();
684        name == prefix_lower || name.starts_with(&prefix_lower)
685    });
686
687    let remove_idx = match remove_idx {
688        Some(idx) => idx,
689        None => return Vec::new(), // Item not found.
690    };
691
692    match style {
693        ListStyle::SingleLine => unreachable!(),
694        ListStyle::LeadingComma => remove_item_leading_comma(cst, field_node, &items, remove_idx),
695        ListStyle::TrailingComma => remove_item_trailing_comma(cst, field_node, &items, remove_idx),
696        ListStyle::NoComma => remove_item_no_comma(&items, remove_idx),
697    }
698}
699
700/// Remove an item from a single-line comma-separated field.
701///
702/// Searches within the comma-separated value text using `item_prefix`.
703fn remove_item_single_line(cst: &CabalCst, field_node: NodeId, item_prefix: &str) -> Vec<TextEdit> {
704    let node = cst.node(field_node);
705    if let Some(fv) = node.field_value {
706        let fv_text = fv.slice(&cst.source);
707        let parts: Vec<&str> = fv_text.split(',').collect();
708
709        let prefix_lower = item_prefix.to_lowercase();
710
711        // Find which comma-separated part matches.
712        let part_idx = parts.iter().position(|part| {
713            let name = item_name(part.trim()).to_lowercase();
714            name == prefix_lower || name.starts_with(&prefix_lower)
715        });
716
717        let part_idx = match part_idx {
718            Some(idx) => idx,
719            None => return Vec::new(),
720        };
721
722        if parts.len() <= 1 {
723            // Only one item: remove the entire value.
724            let name_end = node
725                .field_name
726                .map(|s| s.end)
727                .unwrap_or(node.content_span.start);
728            let colon_end = {
729                let after_name = &cst.source[name_end..node.content_span.end];
730                let colon_pos = after_name.find(':').map(|p| name_end + p + 1);
731                colon_pos.unwrap_or(node.content_span.end)
732            };
733            return vec![TextEdit {
734                range: Span::new(colon_end, fv.end),
735                replacement: String::new(),
736            }];
737        }
738
739        // Multiple items: rebuild the value string without the removed item.
740        let mut new_parts: Vec<&str> = Vec::new();
741        for (i, part) in parts.iter().enumerate() {
742            if i != part_idx {
743                new_parts.push(part.trim());
744            }
745        }
746        let new_value = new_parts.join(", ");
747
748        vec![TextEdit {
749            range: fv,
750            replacement: new_value,
751        }]
752    } else {
753        Vec::new()
754    }
755}
756
757/// Remove an item from a leading-comma multi-line field.
758fn remove_item_leading_comma(
759    cst: &CabalCst,
760    field_node: NodeId,
761    items: &[(String, Span)],
762    remove_idx: usize,
763) -> Vec<TextEdit> {
764    let node = cst.node(field_node);
765    let inline_value = node
766        .field_value
767        .filter(|span| !span.slice(&cst.source).trim().is_empty());
768    let has_inline = inline_value.is_some();
769
770    if items.len() == 1 {
771        // Removing the only item.
772        let (_, span) = &items[0];
773        if has_inline {
774            // Clear the inline value.
775            let Some(fv) = inline_value else {
776                return Vec::new();
777            };
778            return vec![TextEdit {
779                range: fv,
780                replacement: String::new(),
781            }];
782        }
783        // Remove the ValueLine.
784        return vec![TextEdit {
785            range: *span,
786            replacement: String::new(),
787        }];
788    }
789
790    if remove_idx == 0 {
791        // Removing the first item (which has no leading comma).
792        let first_span = items[0].1;
793
794        if has_inline {
795            // The first item is inline. We need to replace it with the second
796            // item (which currently has a leading comma) and remove the second
797            // item's line.
798            let second_text = clean_item_text(&items[1].0);
799            let second_span = items[1].1;
800            let Some(fv) = inline_value else {
801                return Vec::new();
802            };
803
804            return vec![
805                TextEdit {
806                    range: fv,
807                    replacement: second_text.to_owned(),
808                },
809                TextEdit {
810                    range: second_span,
811                    replacement: String::new(),
812                },
813            ];
814        }
815
816        // First item is a ValueLine. Remove it and strip the leading comma
817        // from the second item (which becomes the new first).
818        let second_vl_id = node
819            .children
820            .iter()
821            .copied()
822            .filter(|&id| cst.node(id).kind == CstNodeKind::ValueLine)
823            .nth(1)
824            .or_else(|| node.children.get(1).copied());
825        let Some(second_vl_id) = second_vl_id else {
826            return Vec::new();
827        };
828
829        let second_vl = cst.node(second_vl_id);
830        let second_text = second_vl.content_span.slice(&cst.source);
831        let clean = clean_item_text(second_text);
832
833        // Determine the first item's indent (deeper, no comma).
834        let first_vl_id = node
835            .children
836            .iter()
837            .copied()
838            .find(|&id| cst.node(id).kind == CstNodeKind::ValueLine)
839            .unwrap_or(second_vl_id);
840        let first_vl = cst.node(first_vl_id);
841        let first_indent = &cst.source[first_vl.span.start..first_vl.content_span.start];
842
843        vec![
844            TextEdit {
845                range: first_span,
846                replacement: String::new(),
847            },
848            TextEdit {
849                range: items[1].1,
850                replacement: format!("{first_indent}{clean}\n"),
851            },
852        ]
853    } else {
854        // Removing a non-first item (which has a leading comma). Just remove
855        // the entire line.
856        let Some((_, span)) = items.get(remove_idx) else {
857            return Vec::new();
858        };
859        vec![TextEdit {
860            range: *span,
861            replacement: String::new(),
862        }]
863    }
864}
865
866/// Remove an item from a trailing-comma multi-line field.
867fn remove_item_trailing_comma(
868    cst: &CabalCst,
869    field_node: NodeId,
870    items: &[(String, Span)],
871    remove_idx: usize,
872) -> Vec<TextEdit> {
873    let _ = cst;
874    let _ = field_node;
875
876    if items.len() == 1 {
877        // Removing the only item.
878        let Some((_, span)) = items.first() else {
879            return Vec::new();
880        };
881        return vec![TextEdit {
882            range: *span,
883            replacement: String::new(),
884        }];
885    }
886
887    if remove_idx == items.len() - 1 {
888        // Removing the last item in trailing-comma style.
889        let Some((last_text, last_span)) = items.get(remove_idx) else {
890            return Vec::new();
891        };
892        let last_has_comma = last_text.trim().ends_with(',');
893
894        if !last_has_comma && items.len() > 1 {
895            // The item we're removing has no trailing comma (it was added
896            // as the last item). The previous item had a comma added by
897            // the add operation to maintain trailing-comma style. We need
898            // to remove that comma to restore the original state.
899            let Some((prev_text, prev_span)) = items.get(remove_idx - 1) else {
900                return Vec::new();
901            };
902
903            if prev_text.trim().ends_with(',') {
904                let content_end = find_content_end_in_span(cst, *prev_span);
905                return vec![
906                    TextEdit {
907                        range: Span::new(content_end.saturating_sub(1), content_end),
908                        replacement: String::new(),
909                    },
910                    TextEdit {
911                        range: *last_span,
912                        replacement: String::new(),
913                    },
914                ];
915            }
916        }
917
918        // Either the last item has a trailing comma (original style had
919        // trailing commas on every item), or there's no previous item.
920        // Just remove the line.
921        return vec![TextEdit {
922            range: *last_span,
923            replacement: String::new(),
924        }];
925    }
926
927    // Removing a non-last item. Just remove the entire line (it has a trailing
928    // comma, and the next item also has one or is the last without one: either
929    // way, removal is clean).
930    let Some((_, span)) = items.get(remove_idx) else {
931        return Vec::new();
932    };
933    vec![TextEdit {
934        range: *span,
935        replacement: String::new(),
936    }]
937}
938
939/// Remove an item from a no-comma multi-line field.
940fn remove_item_no_comma(items: &[(String, Span)], remove_idx: usize) -> Vec<TextEdit> {
941    let Some((_, span)) = items.get(remove_idx) else {
942        return Vec::new();
943    };
944    vec![TextEdit {
945        range: *span,
946        replacement: String::new(),
947    }]
948}
949
950// ---------------------------------------------------------------------------
951// Scalar field editing
952// ---------------------------------------------------------------------------
953
954/// Set a simple scalar field value. Replaces the current value text.
955///
956/// If the field has no value (e.g., `field-name:\n`), inserts the value after
957/// the colon with a space.
958pub fn set_field_value(cst: &CabalCst, field_node: NodeId, value: &str) -> TextEdit {
959    let node = cst.node(field_node);
960    debug_assert_eq!(node.kind, CstNodeKind::Field);
961
962    if let Some(fv) = node.field_value {
963        // Replace existing value.
964        TextEdit {
965            range: fv,
966            replacement: value.to_owned(),
967        }
968    } else {
969        // No existing value: insert after the content_span (which ends at the
970        // colon).
971        let insert_at = node.content_span.end;
972        TextEdit {
973            range: Span::new(insert_at, insert_at),
974            replacement: format!(" {value}"),
975        }
976    }
977}
978
979// ---------------------------------------------------------------------------
980// Root-level (top-level metadata) editing
981// ---------------------------------------------------------------------------
982
983/// Add a new top-level metadata field to the root of the file.
984///
985/// The field is inserted after the last existing top-level field and before
986/// any section (library, executable, etc.). Formatted as
987/// `{field_name}: {field_value}\n` with no indentation (top-level).
988pub fn add_field_to_root(cst: &CabalCst, field_name: &str, field_value: &str) -> TextEdit {
989    let root = cst.node(cst.root);
990
991    // Find the insertion point: after the last top-level Field node and before
992    // the first Section node.
993    let mut insert_at = 0usize;
994    for &child_id in &root.children {
995        let child = cst.node(child_id);
996        match child.kind {
997            CstNodeKind::Field | CstNodeKind::Comment | CstNodeKind::BlankLine => {
998                insert_at = child.span.end;
999            }
1000            CstNodeKind::Section => {
1001                // Insert before the first section.
1002                break;
1003            }
1004            _ => {
1005                insert_at = child.span.end;
1006            }
1007        }
1008    }
1009
1010    TextEdit {
1011        range: Span::new(insert_at, insert_at),
1012        replacement: format!("{field_name}: {field_value}\n"),
1013    }
1014}
1015
1016// ---------------------------------------------------------------------------
1017// Section-level editing
1018// ---------------------------------------------------------------------------
1019
1020/// Add a new field to a section at the end (before any conditionals).
1021///
1022/// The field is formatted as `{indent}{field_name}: {field_value}\n` where
1023/// `indent` matches the existing fields in the section.
1024pub fn add_field_to_section(
1025    cst: &CabalCst,
1026    section_node: NodeId,
1027    field_name: &str,
1028    field_value: &str,
1029) -> TextEdit {
1030    let section = cst.node(section_node);
1031    debug_assert_eq!(section.kind, CstNodeKind::Section);
1032
1033    // Determine the indentation used by existing fields in this section.
1034    let field_indent = find_section_field_indent(cst, section_node);
1035
1036    // Find the insertion point: after the last non-conditional child.
1037    let insert_at = find_field_insertion_point(cst, section_node);
1038
1039    TextEdit {
1040        range: Span::new(insert_at, insert_at),
1041        replacement: format!("{field_indent}{field_name}: {field_value}\n"),
1042    }
1043}
1044
1045/// Find the indentation string used by fields in a section.
1046fn find_section_field_indent(cst: &CabalCst, section_node: NodeId) -> String {
1047    let section = cst.node(section_node);
1048    for &child_id in &section.children {
1049        let child = cst.node(child_id);
1050        if child.kind == CstNodeKind::Field || child.kind == CstNodeKind::Import {
1051            return " ".repeat(child.indent);
1052        }
1053    }
1054    // Default: section indent + 2.
1055    " ".repeat(section.indent + 2)
1056}
1057
1058/// Find the insertion point for a new field in a section (after the last
1059/// regular field, before conditionals).
1060fn find_field_insertion_point(cst: &CabalCst, section_node: NodeId) -> usize {
1061    let section = cst.node(section_node);
1062    let mut last_field_end = section.span.start;
1063
1064    // Find trailing trivia end of the section header if no children.
1065    if section.children.is_empty() {
1066        return section.span.end;
1067    }
1068
1069    for &child_id in &section.children {
1070        let child = cst.node(child_id);
1071        match child.kind {
1072            CstNodeKind::Field
1073            | CstNodeKind::Import
1074            | CstNodeKind::Comment
1075            | CstNodeKind::BlankLine => {
1076                last_field_end = child.span.end;
1077            }
1078            CstNodeKind::Conditional => {
1079                // Insert before the first conditional.
1080                break;
1081            }
1082            _ => {
1083                last_field_end = child.span.end;
1084            }
1085        }
1086    }
1087
1088    last_field_end
1089}
1090
1091/// Add a new top-level section to the end of the file.
1092///
1093/// `keyword` is the section type (e.g., `"library"`, `"executable"`).
1094/// `name` is the optional section argument (e.g., `"my-exe"`).
1095/// `fields` is a list of `(field_name, field_value)` pairs.
1096/// `indent` is the number of spaces to indent fields within the section.
1097pub fn add_section(
1098    cst: &CabalCst,
1099    keyword: &str,
1100    name: Option<&str>,
1101    fields: &[(&str, &str)],
1102    indent: usize,
1103) -> TextEdit {
1104    let insert_at = cst.source.len();
1105    let indent_str = " ".repeat(indent);
1106
1107    let mut text = String::new();
1108
1109    // Ensure there's a blank line before the new section if the file doesn't
1110    // end with one.
1111    if !cst.source.is_empty() && !cst.source.ends_with('\n') {
1112        text.push('\n');
1113    }
1114    if !cst.source.is_empty() && !cst.source.ends_with("\n\n") {
1115        text.push('\n');
1116    }
1117
1118    // Section header.
1119    text.push_str(keyword);
1120    if let Some(n) = name {
1121        text.push(' ');
1122        text.push_str(n);
1123    }
1124    text.push('\n');
1125
1126    // Fields.
1127    for (fname, fvalue) in fields {
1128        text.push_str(&indent_str);
1129        text.push_str(fname);
1130        text.push_str(": ");
1131        text.push_str(fvalue);
1132        text.push('\n');
1133    }
1134
1135    TextEdit {
1136        range: Span::new(insert_at, insert_at),
1137        replacement: text,
1138    }
1139}
1140
1141// ---------------------------------------------------------------------------
1142// Convenience: find a field node by name within a section
1143// ---------------------------------------------------------------------------
1144
1145/// Find a field node within a section (or the root) by field name.
1146///
1147/// The search is case-insensitive and treats hyphens and underscores as
1148/// equivalent (matching `.cabal` conventions).
1149pub fn find_field(cst: &CabalCst, parent_node: NodeId, field_name: &str) -> Option<NodeId> {
1150    let parent = cst.node(parent_node);
1151    let normalized = normalize_field_name(field_name);
1152
1153    for &child_id in &parent.children {
1154        let child = cst.node(child_id);
1155        if child.kind == CstNodeKind::Field {
1156            if let Some(name_span) = child.field_name {
1157                let name = name_span.slice(&cst.source);
1158                if normalize_field_name(name) == normalized {
1159                    return Some(child_id);
1160                }
1161            }
1162        }
1163    }
1164    None
1165}
1166
1167/// Find a section node by keyword and optional name.
1168pub fn find_section(cst: &CabalCst, keyword: &str, name: Option<&str>) -> Option<NodeId> {
1169    let root = cst.node(cst.root);
1170    for &child_id in &root.children {
1171        let child = cst.node(child_id);
1172        if child.kind == CstNodeKind::Section {
1173            if let Some(kw_span) = child.section_keyword {
1174                let kw = kw_span.slice(&cst.source);
1175                if kw.eq_ignore_ascii_case(keyword) {
1176                    match name {
1177                        None => return Some(child_id),
1178                        Some(n) => {
1179                            if let Some(arg_span) = child.section_arg {
1180                                if arg_span.slice(&cst.source).eq_ignore_ascii_case(n) {
1181                                    return Some(child_id);
1182                                }
1183                            }
1184                        }
1185                    }
1186                }
1187            }
1188        }
1189    }
1190    None
1191}
1192
1193/// Normalize a field name: lowercase, replace underscores with hyphens.
1194fn normalize_field_name(name: &str) -> String {
1195    name.to_lowercase().replace('_', "-")
1196}
1197
1198// ---------------------------------------------------------------------------
1199// Tests
1200// ---------------------------------------------------------------------------
1201
1202#[cfg(test)]
1203mod tests {
1204    use super::*;
1205    use crate::parse;
1206
1207    /// Helper: parse source, apply edits, return new source.
1208    fn apply_edits(source: &str, edits: Vec<TextEdit>) -> String {
1209        let mut batch = EditBatch::new();
1210        batch.add_all(edits);
1211        batch.apply(source)
1212    }
1213
1214    // -- EditBatch tests ---------------------------------------------------
1215
1216    #[test]
1217    fn edit_batch_empty() {
1218        let source = "hello world";
1219        let batch = EditBatch::new();
1220        assert_eq!(batch.apply(source), "hello world");
1221    }
1222
1223    #[test]
1224    fn edit_batch_single_insert() {
1225        let source = "hello world";
1226        let mut batch = EditBatch::new();
1227        batch.add(TextEdit {
1228            range: Span::new(5, 5),
1229            replacement: ",".to_owned(),
1230        });
1231        assert_eq!(batch.apply(source), "hello, world");
1232    }
1233
1234    #[test]
1235    fn edit_batch_single_replace() {
1236        let source = "hello world";
1237        let mut batch = EditBatch::new();
1238        batch.add(TextEdit {
1239            range: Span::new(6, 11),
1240            replacement: "rust".to_owned(),
1241        });
1242        assert_eq!(batch.apply(source), "hello rust");
1243    }
1244
1245    #[test]
1246    fn edit_batch_single_delete() {
1247        let source = "hello world";
1248        let mut batch = EditBatch::new();
1249        batch.add(TextEdit {
1250            range: Span::new(5, 6),
1251            replacement: String::new(),
1252        });
1253        assert_eq!(batch.apply(source), "helloworld");
1254    }
1255
1256    #[test]
1257    fn edit_batch_multiple_non_overlapping() {
1258        let source = "aaa bbb ccc";
1259        let mut batch = EditBatch::new();
1260        batch.add(TextEdit {
1261            range: Span::new(0, 3),
1262            replacement: "xxx".to_owned(),
1263        });
1264        batch.add(TextEdit {
1265            range: Span::new(8, 11),
1266            replacement: "zzz".to_owned(),
1267        });
1268        assert_eq!(batch.apply(source), "xxx bbb zzz");
1269    }
1270
1271    #[test]
1272    #[should_panic(expected = "overlapping edits")]
1273    fn edit_batch_overlapping_panics() {
1274        let source = "hello world";
1275        let mut batch = EditBatch::new();
1276        batch.add(TextEdit {
1277            range: Span::new(0, 7),
1278            replacement: "hi".to_owned(),
1279        });
1280        batch.add(TextEdit {
1281            range: Span::new(5, 11),
1282            replacement: "there".to_owned(),
1283        });
1284        batch.apply(source);
1285    }
1286
1287    // -- List style detection tests ----------------------------------------
1288
1289    #[test]
1290    fn detect_style_single_line() {
1291        let src = "\
1292library
1293  build-depends: base >=4.14, text >=2.0, aeson ^>=2.2
1294";
1295        let result = parse::parse(src);
1296        let section = result.cst.node(result.cst.root).children[0];
1297        let field = find_field(&result.cst, section, "build-depends").unwrap();
1298        assert_eq!(detect_list_style(&result.cst, field), ListStyle::SingleLine);
1299    }
1300
1301    #[test]
1302    fn detect_style_leading_comma() {
1303        let src = "\
1304library
1305  build-depends:
1306      base >=4.14
1307    , text >=2.0
1308    , aeson ^>=2.2
1309";
1310        let result = parse::parse(src);
1311        let section = result.cst.node(result.cst.root).children[0];
1312        let field = find_field(&result.cst, section, "build-depends").unwrap();
1313        assert_eq!(
1314            detect_list_style(&result.cst, field),
1315            ListStyle::LeadingComma
1316        );
1317    }
1318
1319    #[test]
1320    fn detect_style_trailing_comma() {
1321        let src = "\
1322library
1323  build-depends:
1324    base >=4.14,
1325    text >=2.0,
1326    aeson ^>=2.2
1327";
1328        let result = parse::parse(src);
1329        let section = result.cst.node(result.cst.root).children[0];
1330        let field = find_field(&result.cst, section, "build-depends").unwrap();
1331        assert_eq!(
1332            detect_list_style(&result.cst, field),
1333            ListStyle::TrailingComma
1334        );
1335    }
1336
1337    #[test]
1338    fn detect_style_no_comma() {
1339        let src = "\
1340library
1341  exposed-modules:
1342    Data.Map
1343    Data.Set
1344";
1345        let result = parse::parse(src);
1346        let section = result.cst.node(result.cst.root).children[0];
1347        let field = find_field(&result.cst, section, "exposed-modules").unwrap();
1348        assert_eq!(detect_list_style(&result.cst, field), ListStyle::NoComma);
1349    }
1350
1351    // -- set_field_value tests ---------------------------------------------
1352
1353    #[test]
1354    fn set_scalar_field_value() {
1355        let src = "name: foo\nversion: 0.1.0.0\n";
1356        let result = parse::parse(src);
1357        let field = find_field(&result.cst, result.cst.root, "version").unwrap();
1358        let edit = set_field_value(&result.cst, field, "1.0.0.0");
1359        let new_src = apply_edits(src, vec![edit]);
1360        assert_eq!(new_src, "name: foo\nversion: 1.0.0.0\n");
1361    }
1362
1363    #[test]
1364    fn set_field_value_empty_field() {
1365        let src = "name:\nversion: 0.1.0.0\n";
1366        let result = parse::parse(src);
1367        let field = find_field(&result.cst, result.cst.root, "name").unwrap();
1368        let edit = set_field_value(&result.cst, field, "my-package");
1369        let new_src = apply_edits(src, vec![edit]);
1370        assert_eq!(new_src, "name: my-package\nversion: 0.1.0.0\n");
1371    }
1372
1373    // -- add_list_item tests (NoComma style) -------------------------------
1374
1375    #[test]
1376    fn add_module_no_comma() {
1377        let src = "\
1378library
1379  exposed-modules:
1380    Data.Map
1381    Data.Set
1382";
1383        let result = parse::parse(src);
1384        let section = result.cst.node(result.cst.root).children[0];
1385        let field = find_field(&result.cst, section, "exposed-modules").unwrap();
1386        let edits = add_list_item(&result.cst, field, "Data.List", true);
1387        let new_src = apply_edits(src, edits);
1388
1389        // Data.List should be inserted between Data.Map and Data.Set (sorted).
1390        assert!(new_src.contains("Data.List"));
1391        // Verify the result parses cleanly.
1392        let re_parsed = parse::parse(&new_src);
1393        assert_eq!(re_parsed.cst.render(), new_src);
1394    }
1395
1396    #[test]
1397    fn add_module_no_comma_end() {
1398        let src = "\
1399library
1400  exposed-modules:
1401    Data.Map
1402    Data.Set
1403";
1404        let result = parse::parse(src);
1405        let section = result.cst.node(result.cst.root).children[0];
1406        let field = find_field(&result.cst, section, "exposed-modules").unwrap();
1407        let edits = add_list_item(&result.cst, field, "Data.Text", true);
1408        let new_src = apply_edits(src, edits);
1409
1410        // Data.Text should appear after Data.Set (sorted).
1411        let map_pos = new_src.find("Data.Map").unwrap();
1412        let set_pos = new_src.find("Data.Set").unwrap();
1413        let text_pos = new_src.find("Data.Text").unwrap();
1414        assert!(map_pos < set_pos);
1415        assert!(set_pos < text_pos);
1416    }
1417
1418    // -- add_list_item tests (trailing comma style) ------------------------
1419
1420    #[test]
1421    fn add_dep_trailing_comma_end() {
1422        let src = "\
1423library
1424  build-depends:
1425    base >=4.14,
1426    text >=2.0,
1427    aeson ^>=2.2
1428";
1429        let result = parse::parse(src);
1430        let section = result.cst.node(result.cst.root).children[0];
1431        let field = find_field(&result.cst, section, "build-depends").unwrap();
1432        let edits = add_list_item(&result.cst, field, "zlib ^>=0.7", true);
1433        let new_src = apply_edits(src, edits);
1434
1435        assert!(new_src.contains("zlib ^>=0.7"));
1436        // aeson should now have a trailing comma.
1437        assert!(new_src.contains("aeson ^>=2.2,"));
1438        let re_parsed = parse::parse(&new_src);
1439        assert_eq!(re_parsed.cst.render(), new_src);
1440    }
1441
1442    // -- add_list_item tests (leading comma style) -------------------------
1443
1444    #[test]
1445    fn add_dep_leading_comma_end() {
1446        let src = "\
1447library
1448  build-depends:
1449      base >=4.14
1450    , text >=2.0
1451    , aeson ^>=2.2
1452";
1453        let result = parse::parse(src);
1454        let section = result.cst.node(result.cst.root).children[0];
1455        let field = find_field(&result.cst, section, "build-depends").unwrap();
1456        let edits = add_list_item(&result.cst, field, "zlib ^>=0.7", true);
1457        let new_src = apply_edits(src, edits);
1458
1459        assert!(new_src.contains("zlib ^>=0.7"));
1460        let re_parsed = parse::parse(&new_src);
1461        assert_eq!(re_parsed.cst.render(), new_src);
1462    }
1463
1464    // -- add_list_item tests (single line style) ---------------------------
1465
1466    #[test]
1467    fn add_dep_single_line_end() {
1468        let src = "\
1469library
1470  build-depends: base >=4.14, text >=2.0
1471";
1472        let result = parse::parse(src);
1473        let section = result.cst.node(result.cst.root).children[0];
1474        let field = find_field(&result.cst, section, "build-depends").unwrap();
1475        let edits = add_list_item(&result.cst, field, "aeson ^>=2.2", false);
1476        let new_src = apply_edits(src, edits);
1477
1478        assert!(new_src.contains("aeson ^>=2.2"));
1479        assert!(new_src.contains("text >=2.0, aeson ^>=2.2"));
1480    }
1481
1482    // -- remove_list_item tests --------------------------------------------
1483
1484    #[test]
1485    fn remove_module_no_comma() {
1486        let src = "\
1487library
1488  exposed-modules:
1489    Data.Map
1490    Data.Set
1491    Data.Text
1492";
1493        let result = parse::parse(src);
1494        let section = result.cst.node(result.cst.root).children[0];
1495        let field = find_field(&result.cst, section, "exposed-modules").unwrap();
1496        let edits = remove_list_item(&result.cst, field, "Data.Set");
1497        let new_src = apply_edits(src, edits);
1498
1499        assert!(!new_src.contains("Data.Set"));
1500        assert!(new_src.contains("Data.Map"));
1501        assert!(new_src.contains("Data.Text"));
1502        let re_parsed = parse::parse(&new_src);
1503        assert_eq!(re_parsed.cst.render(), new_src);
1504    }
1505
1506    #[test]
1507    fn remove_dep_trailing_comma_middle() {
1508        let src = "\
1509library
1510  build-depends:
1511    base >=4.14,
1512    text >=2.0,
1513    aeson ^>=2.2
1514";
1515        let result = parse::parse(src);
1516        let section = result.cst.node(result.cst.root).children[0];
1517        let field = find_field(&result.cst, section, "build-depends").unwrap();
1518        let edits = remove_list_item(&result.cst, field, "text");
1519        let new_src = apply_edits(src, edits);
1520
1521        assert!(!new_src.contains("text"));
1522        assert!(new_src.contains("base"));
1523        assert!(new_src.contains("aeson"));
1524        let re_parsed = parse::parse(&new_src);
1525        assert_eq!(re_parsed.cst.render(), new_src);
1526    }
1527
1528    #[test]
1529    fn remove_dep_trailing_comma_last() {
1530        let src = "\
1531library
1532  build-depends:
1533    base >=4.14,
1534    text >=2.0,
1535    aeson ^>=2.2
1536";
1537        let result = parse::parse(src);
1538        let section = result.cst.node(result.cst.root).children[0];
1539        let field = find_field(&result.cst, section, "build-depends").unwrap();
1540        let edits = remove_list_item(&result.cst, field, "aeson");
1541        let new_src = apply_edits(src, edits);
1542
1543        assert!(!new_src.contains("aeson"));
1544        assert!(new_src.contains("base"));
1545        assert!(new_src.contains("text"));
1546        // text should no longer have a trailing comma (it's now the last item).
1547        let re_parsed = parse::parse(&new_src);
1548        assert_eq!(re_parsed.cst.render(), new_src);
1549    }
1550
1551    #[test]
1552    fn remove_dep_single_line_middle() {
1553        let src = "\
1554library
1555  build-depends: base >=4.14, text >=2.0, aeson ^>=2.2
1556";
1557        let result = parse::parse(src);
1558        let section = result.cst.node(result.cst.root).children[0];
1559        let field = find_field(&result.cst, section, "build-depends").unwrap();
1560        let edits = remove_list_item(&result.cst, field, "text");
1561        let new_src = apply_edits(src, edits);
1562
1563        assert!(!new_src.contains("text"));
1564        assert!(new_src.contains("base >=4.14, aeson ^>=2.2"));
1565    }
1566
1567    // -- add_field_to_section tests ----------------------------------------
1568
1569    #[test]
1570    fn add_field_to_section_basic() {
1571        let src = "\
1572library
1573  exposed-modules: Foo
1574  build-depends: base
1575";
1576        let result = parse::parse(src);
1577        let section = result.cst.node(result.cst.root).children[0];
1578        let edit = add_field_to_section(&result.cst, section, "default-language", "GHC2021");
1579        let new_src = apply_edits(src, vec![edit]);
1580
1581        assert!(new_src.contains("default-language: GHC2021"));
1582        let re_parsed = parse::parse(&new_src);
1583        assert_eq!(re_parsed.cst.render(), new_src);
1584    }
1585
1586    // -- add_section tests -------------------------------------------------
1587
1588    #[test]
1589    fn add_new_section() {
1590        let src = "\
1591cabal-version: 3.0
1592name: foo
1593version: 0.1.0.0
1594";
1595        let result = parse::parse(src);
1596        let edit = add_section(
1597            &result.cst,
1598            "library",
1599            None,
1600            &[
1601                ("exposed-modules", "Foo"),
1602                ("build-depends", "base"),
1603                ("hs-source-dirs", "src"),
1604            ],
1605            2,
1606        );
1607        let new_src = apply_edits(src, vec![edit]);
1608
1609        assert!(new_src.contains("library\n"));
1610        assert!(new_src.contains("  exposed-modules: Foo\n"));
1611        assert!(new_src.contains("  build-depends: base\n"));
1612        let re_parsed = parse::parse(&new_src);
1613        assert_eq!(re_parsed.cst.render(), new_src);
1614    }
1615
1616    #[test]
1617    fn add_named_section() {
1618        let src = "\
1619cabal-version: 3.0
1620name: foo
1621version: 0.1.0.0
1622";
1623        let result = parse::parse(src);
1624        let edit = add_section(
1625            &result.cst,
1626            "executable",
1627            Some("my-exe"),
1628            &[("main-is", "Main.hs"), ("build-depends", "base, foo")],
1629            2,
1630        );
1631        let new_src = apply_edits(src, vec![edit]);
1632
1633        assert!(new_src.contains("executable my-exe\n"));
1634        assert!(new_src.contains("  main-is: Main.hs\n"));
1635    }
1636
1637    // -- find_field / find_section tests -----------------------------------
1638
1639    #[test]
1640    fn find_field_case_insensitive() {
1641        let src = "Name: foo\nVersion: 0.1.0.0\n";
1642        let result = parse::parse(src);
1643        assert!(find_field(&result.cst, result.cst.root, "name").is_some());
1644        assert!(find_field(&result.cst, result.cst.root, "NAME").is_some());
1645    }
1646
1647    #[test]
1648    fn find_field_underscore_hyphen() {
1649        let src = "build-depends: base\n";
1650        let result = parse::parse(src);
1651        assert!(find_field(&result.cst, result.cst.root, "build_depends").is_some());
1652        assert!(find_field(&result.cst, result.cst.root, "build-depends").is_some());
1653    }
1654
1655    #[test]
1656    fn find_section_library() {
1657        let src = "\
1658cabal-version: 3.0
1659name: foo
1660version: 0.1.0.0
1661
1662library
1663  exposed-modules: Foo
1664";
1665        let result = parse::parse(src);
1666        assert!(find_section(&result.cst, "library", None).is_some());
1667    }
1668
1669    #[test]
1670    fn find_section_named_executable() {
1671        let src = "\
1672executable my-exe
1673  main-is: Main.hs
1674";
1675        let result = parse::parse(src);
1676        assert!(find_section(&result.cst, "executable", Some("my-exe")).is_some());
1677        assert!(find_section(&result.cst, "executable", Some("other")).is_none());
1678    }
1679
1680    // -- Round-trip edit tests (add then remove) ---------------------------
1681
1682    #[test]
1683    fn round_trip_add_remove_no_comma() {
1684        let src = "\
1685library
1686  exposed-modules:
1687    Data.Map
1688    Data.Set
1689";
1690        let result = parse::parse(src);
1691        let section = result.cst.node(result.cst.root).children[0];
1692        let field = find_field(&result.cst, section, "exposed-modules").unwrap();
1693
1694        // Add.
1695        let edits = add_list_item(&result.cst, field, "Data.List", true);
1696        let added_src = apply_edits(src, edits);
1697        assert!(added_src.contains("Data.List"));
1698
1699        // Remove.
1700        let result2 = parse::parse(&added_src);
1701        let section2 = result2.cst.node(result2.cst.root).children[0];
1702        let field2 = find_field(&result2.cst, section2, "exposed-modules").unwrap();
1703        let edits2 = remove_list_item(&result2.cst, field2, "Data.List");
1704        let removed_src = apply_edits(&added_src, edits2);
1705
1706        assert_eq!(
1707            removed_src, src,
1708            "round-trip add+remove should restore original"
1709        );
1710    }
1711
1712    #[test]
1713    fn round_trip_add_remove_trailing_comma() {
1714        let src = "\
1715library
1716  build-depends:
1717    base >=4.14,
1718    aeson ^>=2.2
1719";
1720        let result = parse::parse(src);
1721        let section = result.cst.node(result.cst.root).children[0];
1722        let field = find_field(&result.cst, section, "build-depends").unwrap();
1723
1724        // Add text in sorted order.
1725        let edits = add_list_item(&result.cst, field, "text >=2.0", true);
1726        let added_src = apply_edits(src, edits);
1727        assert!(added_src.contains("text >=2.0"));
1728
1729        // Remove.
1730        let result2 = parse::parse(&added_src);
1731        let section2 = result2.cst.node(result2.cst.root).children[0];
1732        let field2 = find_field(&result2.cst, section2, "build-depends").unwrap();
1733        let edits2 = remove_list_item(&result2.cst, field2, "text");
1734        let removed_src = apply_edits(&added_src, edits2);
1735
1736        assert_eq!(
1737            removed_src, src,
1738            "round-trip add+remove should restore original"
1739        );
1740    }
1741
1742    // -- item_name helper tests -------------------------------------------
1743
1744    #[test]
1745    fn item_name_basic() {
1746        assert_eq!(item_name("base >=4.14"), "base");
1747        assert_eq!(item_name("aeson ^>=2.2"), "aeson");
1748        assert_eq!(item_name("  , text >=2.0"), "text");
1749        assert_eq!(item_name("Data.Map"), "Data.Map");
1750        assert_eq!(item_name("base,"), "base");
1751    }
1752
1753    // -- add_list_item to empty field tests --------------------------------
1754
1755    #[test]
1756    fn add_to_empty_field_single_line() {
1757        let src = "\
1758library
1759  build-depends:
1760";
1761        let result = parse::parse(src);
1762        let section = result.cst.node(result.cst.root).children[0];
1763        let field = find_field(&result.cst, section, "build-depends").unwrap();
1764        let edits = add_list_item(&result.cst, field, "base >=4.14", false);
1765        let new_src = apply_edits(src, edits);
1766
1767        assert!(new_src.contains("base >=4.14"));
1768        let re_parsed = parse::parse(&new_src);
1769        assert_eq!(re_parsed.cst.render(), new_src);
1770    }
1771
1772    // -- add_list_item sorted insertion tests ------------------------------
1773
1774    #[test]
1775    fn add_list_item_sorted_beginning() {
1776        let src = "\
1777library
1778  exposed-modules:
1779    Data.Map
1780    Data.Set
1781";
1782        let result = parse::parse(src);
1783        let section = result.cst.node(result.cst.root).children[0];
1784        let field = find_field(&result.cst, section, "exposed-modules").unwrap();
1785        let edits = add_list_item(&result.cst, field, "Data.Aeson", true);
1786        let new_src = apply_edits(src, edits);
1787
1788        // Data.Aeson should come before Data.Map.
1789        let aeson_pos = new_src.find("Data.Aeson").unwrap();
1790        let map_pos = new_src.find("Data.Map").unwrap();
1791        assert!(aeson_pos < map_pos);
1792    }
1793}