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.sort_by(|a, b| b.range.start.cmp(&a.range.start));
85
86 for pair in self.edits.windows(2) {
88 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
105pub 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 value_lines.is_empty() {
126 return ListStyle::SingleLine;
127 }
128
129 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 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 ListStyle::TrailingComma
172}
173
174fn item_name(item: &str) -> &str {
182 let trimmed = item.trim().trim_start_matches(',').trim();
183 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
191fn clean_item_text(text: &str) -> &str {
194 text.trim()
195 .trim_start_matches(',')
196 .trim_end_matches(',')
197 .trim()
198}
199
200fn 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 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 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
227fn 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 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 return " ".repeat(child.indent);
245 }
246 }
247
248 let field_indent = node.indent;
250 " ".repeat(field_indent + 2)
251}
252
253fn field_value_end(cst: &CabalCst, field_node: NodeId) -> usize {
257 let node = cst.node(field_node);
258
259 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 node.span.end
273}
274
275fn 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
288pub 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 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
323fn 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 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 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
354fn 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 if let Some(fv) = node.field_value {
366 let fv_text = fv.slice(&cst.source);
367
368 let parts: Vec<&str> = fv_text.split(',').collect();
370
371 if insert_idx >= parts.len() || insert_idx >= items.len() {
372 vec![TextEdit {
374 range: Span::new(fv.end, fv.end),
375 replacement: format!(", {item}"),
376 }]
377 } else {
378 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; }
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 add_item_to_empty_field(cst, field_node, item, ListStyle::SingleLine)
397 }
398}
399
400fn 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 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 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 let comma_prefix = find_leading_comma_prefix(cst, &value_lines, &indent);
444
445 if insert_idx == 0 {
446 if has_inline {
448 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 let first_vl_end = if value_lines.is_empty() {
459 field_value_end(cst, field_node)
460 } else {
461 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 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 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 add_item_to_empty_field(cst, field_node, item, ListStyle::LeadingComma)
496 }
497 } else if insert_idx >= items.len() {
498 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 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
516fn 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 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 format!("{default_indent}, ")
539}
540
541fn 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 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 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 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 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 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
613fn 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 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 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
641fn 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
649pub 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 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 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(), };
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
689fn 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 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 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 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
746fn 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 let (_, span) = &items[0];
765 if has_inline {
766 let fv = node.field_value.unwrap();
768 return vec![TextEdit {
769 range: fv,
770 replacement: String::new(),
771 }];
772 }
773 return vec![TextEdit {
775 range: *span,
776 replacement: String::new(),
777 }];
778 }
779
780 if remove_idx == 0 {
781 let first_span = items[0].1;
783
784 if has_inline {
785 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 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 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 let span = items[remove_idx].1;
842 vec![TextEdit {
843 range: span,
844 replacement: String::new(),
845 }]
846 }
847}
848
849fn 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 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 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 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 return vec![TextEdit {
901 range: last_span,
902 replacement: String::new(),
903 }];
904 }
905
906 let span = items[remove_idx].1;
910 vec![TextEdit {
911 range: span,
912 replacement: String::new(),
913 }]
914}
915
916fn 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
925pub 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 TextEdit {
940 range: fv,
941 replacement: value.to_owned(),
942 }
943 } else {
944 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
954pub fn add_field_to_root(cst: &CabalCst, field_name: &str, field_value: &str) -> TextEdit {
964 let root = cst.node(cst.root);
965
966 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 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
991pub 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 let field_indent = find_section_field_indent(cst, section_node);
1010
1011 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
1020fn find_section_field_indent(cst: &CabalCst, section_node: NodeId) -> String {
1022 let section = cst.node(section_node);
1023 for &child_id in §ion.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 " ".repeat(section.indent + 2)
1031}
1032
1033fn 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 if section.children.is_empty() {
1041 return section.span.end;
1042 }
1043
1044 for &child_id in §ion.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 break;
1056 }
1057 _ => {
1058 last_field_end = child.span.end;
1059 }
1060 }
1061 }
1062
1063 last_field_end
1064}
1065
1066pub 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 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 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 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
1116pub 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
1142pub 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
1168fn normalize_field_name(name: &str) -> String {
1170 name.to_lowercase().replace('_', "-")
1171}
1172
1173#[cfg(test)]
1178mod tests {
1179 use super::*;
1180 use crate::parse;
1181
1182 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 #[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 #[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 #[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 #[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 assert!(new_src.contains("Data.List"));
1366 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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 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}