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