1use crate::cst::{CabalCst, CstNodeKind};
13use crate::span::{NodeId, Span};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ListStyle {
22 SingleLine,
24 LeadingComma,
26 TrailingComma,
28 NoComma,
30}
31
32#[derive(Debug, Clone)]
34pub struct TextEdit {
35 pub range: Span,
37 pub replacement: String,
39}
40
41#[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 pub fn new() -> Self {
56 Self { edits: Vec::new() }
57 }
58
59 pub fn add(&mut self, edit: TextEdit) {
61 self.edits.push(edit);
62 }
63
64 pub fn add_all(&mut self, edits: Vec<TextEdit>) {
66 self.edits.extend(edits);
67 }
68
69 pub fn is_empty(&self) -> bool {
71 self.edits.is_empty()
72 }
73
74 pub fn apply(mut self, source: &str) -> String {
79 if self.edits.is_empty() {
80 return source.to_owned();
81 }
82
83 self.edits
85 .sort_by_key(|edit| std::cmp::Reverse(edit.range.start));
86
87 for pair in self.edits.windows(2) {
89 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
106pub 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 value_lines.is_empty() {
127 return ListStyle::SingleLine;
128 }
129
130 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 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 ListStyle::TrailingComma
173}
174
175fn item_name(item: &str) -> &str {
183 let trimmed = item.trim().trim_start_matches(',').trim();
184 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
192fn clean_item_text(text: &str) -> &str {
195 text.trim()
196 .trim_start_matches(',')
197 .trim_end_matches(',')
198 .trim()
199}
200
201fn 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 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 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
228fn 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 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 return " ".repeat(child.indent);
246 }
247 }
248
249 let field_indent = node.indent;
251 " ".repeat(field_indent + 2)
252}
253
254fn field_value_end(cst: &CabalCst, field_node: NodeId) -> usize {
258 let node = cst.node(field_node);
259
260 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 node.span.end
274}
275
276fn 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
289pub 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 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
324fn 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 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 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
355fn 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 if let Some(fv) = node.field_value {
367 let fv_text = fv.slice(&cst.source);
368
369 let parts: Vec<&str> = fv_text.split(',').collect();
371
372 if insert_idx >= parts.len() || insert_idx >= items.len() {
373 vec![TextEdit {
375 range: Span::new(fv.end, fv.end),
376 replacement: format!(", {item}"),
377 }]
378 } else {
379 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; }
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 add_item_to_empty_field(cst, field_node, item, ListStyle::SingleLine)
398 }
399}
400
401fn 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 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 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 let comma_prefix = find_leading_comma_prefix(cst, &value_lines, &indent);
442
443 if insert_idx == 0 {
444 if has_inline {
446 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 let first_vl_end = if value_lines.is_empty() {
459 field_value_end(cst, field_node)
460 } else {
461 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 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 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 add_item_to_empty_field(cst, field_node, item, ListStyle::LeadingComma)
499 }
500 } else if insert_idx >= items.len() {
501 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 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
521fn 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 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 format!("{default_indent}, ")
544}
545
546fn 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 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 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 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 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 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
622fn 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 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 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
652fn 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
660pub 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 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 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(), };
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
700fn 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 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 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 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
757fn 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 let (_, span) = &items[0];
773 if has_inline {
774 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 return vec![TextEdit {
785 range: *span,
786 replacement: String::new(),
787 }];
788 }
789
790 if remove_idx == 0 {
791 let first_span = items[0].1;
793
794 if has_inline {
795 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 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 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 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
866fn 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 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 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 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 return vec![TextEdit {
922 range: *last_span,
923 replacement: String::new(),
924 }];
925 }
926
927 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
939fn 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
950pub 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 TextEdit {
965 range: fv,
966 replacement: value.to_owned(),
967 }
968 } else {
969 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
979pub fn add_field_to_root(cst: &CabalCst, field_name: &str, field_value: &str) -> TextEdit {
989 let root = cst.node(cst.root);
990
991 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 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
1016pub 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 let field_indent = find_section_field_indent(cst, section_node);
1035
1036 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
1045fn find_section_field_indent(cst: &CabalCst, section_node: NodeId) -> String {
1047 let section = cst.node(section_node);
1048 for &child_id in §ion.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 " ".repeat(section.indent + 2)
1056}
1057
1058fn 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 if section.children.is_empty() {
1066 return section.span.end;
1067 }
1068
1069 for &child_id in §ion.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 break;
1081 }
1082 _ => {
1083 last_field_end = child.span.end;
1084 }
1085 }
1086 }
1087
1088 last_field_end
1089}
1090
1091pub 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 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 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 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
1141pub 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
1167pub 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
1193fn normalize_field_name(name: &str) -> String {
1195 name.to_lowercase().replace('_', "-")
1196}
1197
1198#[cfg(test)]
1203mod tests {
1204 use super::*;
1205 use crate::parse;
1206
1207 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 #[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 #[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 #[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 #[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 assert!(new_src.contains("Data.List"));
1391 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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 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}