1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
//! A text edit.

use ink_analyzer_ir::syntax::{AstNode, SyntaxKind, TextRange, TextSize};
use ink_analyzer_ir::{FromSyntax, InkFile};
use once_cell::sync::Lazy;
use regex::Regex;

use super::utils;

/// A text edit (with an optional snippet - i.e tab stops and/or placeholders).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TextEdit {
    /// Replacement text for the text edit.
    pub text: String,
    /// Range to which the text edit will be applied.
    pub range: TextRange,
    /// Formatted snippet for the text edit (includes tab stops and/or placeholders).
    pub snippet: Option<String>,
}

impl TextEdit {
    /// Creates text edit.
    pub fn new(text: String, range: TextRange, snippet: Option<String>) -> Self {
        Self {
            text,
            range,
            snippet,
        }
    }

    /// Creates text edit for inserting at the given offset.
    pub fn insert(text: String, offset: TextSize) -> Self {
        Self::insert_with_snippet(text, offset, None)
    }

    /// Creates text edit for inserting at the given offset (including an optional snippet).
    pub fn insert_with_snippet(text: String, offset: TextSize, snippet: Option<String>) -> Self {
        Self {
            text,
            range: TextRange::new(offset, offset),
            snippet,
        }
    }

    /// Creates text edit for replacing the given range.
    pub fn replace(text: String, range: TextRange) -> Self {
        Self::replace_with_snippet(text, range, None)
    }

    /// Creates text edit for replacing the given range (including an optional snippet) - i.e an alias of [`Self::new`].
    pub fn replace_with_snippet(text: String, range: TextRange, snippet: Option<String>) -> Self {
        Self::new(text, range, snippet)
    }

    /// Creates a text edit for deleting the specified range.
    pub fn delete(range: TextRange) -> Self {
        Self {
            text: String::new(),
            range,
            snippet: None,
        }
    }
}

/// Format text edits (i.e. add indenting and new lines based on context).
pub fn format_edits(edits: Vec<TextEdit>, file: &InkFile) -> impl Iterator<Item = TextEdit> + '_ {
    edits.into_iter().map(|item| format_edit(item, file))
}

/// Format text edit (i.e. add indenting and new lines based on context).
pub fn format_edit(mut edit: TextEdit, file: &InkFile) -> TextEdit {
    // Determines the token right before the start of the edit offset.
    let token_before_option = file
        .syntax()
        .token_at_offset(edit.range.start())
        .left_biased()
        .filter(|it| it.text_range().end() <= edit.range.start());
    // Determines the token right after the end of the edit offset.
    let token_after_option = file
        .syntax()
        .token_at_offset(edit.range.end())
        .right_biased()
        .filter(|it| it.text_range().start() >= edit.range.end());

    if edit.text.is_empty() {
        // Handles deletes.
        // Removes whitespace immediately following a delete if the text is surrounded by whitespace,
        // but only the token right after the whitespace is not a closing curly break
        // (because it would otherwise break the indenting of the closing curly bracket).
        if let Some(token_after) = token_after_option {
            let token_before_is_whitespace =
                token_before_option.as_ref().map_or(false, |token_before| {
                    token_before.kind() == SyntaxKind::WHITESPACE
                });
            let is_at_the_end_block = token_after
                .next_token()
                .map_or(false, |it| it.kind() == SyntaxKind::R_CURLY);
            if token_before_is_whitespace
                && token_after.kind() == SyntaxKind::WHITESPACE
                && !is_at_the_end_block
            {
                edit.range = TextRange::new(edit.range.start(), token_after.text_range().end());
            }
        }
    } else {
        // Handles inserts and replaces.
        if let Some(token_before) = token_before_option {
            let (prefix, suffix) = match token_before.kind() {
                // Handles edits after whitespace.
                SyntaxKind::WHITESPACE => {
                    (
                        // No formatting prefix.
                        None,
                        // Adds formatting suffix only if the edit is not surrounded by whitespace
                        // (treats end of the file like whitespace)
                        // and its preceding whitespace contains a new line but doesn't end with a new line.
                        (token_after_option.as_ref().map_or(false, |token_after| {
                            token_after.kind() != SyntaxKind::WHITESPACE
                        }) && token_before.text().contains('\n')
                            && !token_before.text().ends_with('\n'))
                        .then_some(format!("\n{}", utils::end_indenting(token_before.text()),)),
                    )
                }
                // Handles edits at the beginning of blocks (i.e right after the opening curly bracket).
                SyntaxKind::L_CURLY => {
                    (
                        // Adds formatting prefix only if the edit doesn't start with a new line
                        // and then only add indenting if the edit doesn't start with a space (i.e ' ') or a tab (i.e. '\t').
                        (!edit.text.starts_with('\n')).then(|| {
                            format!(
                                "\n{}",
                                (!edit.text.starts_with(' ') && !edit.text.starts_with('\t'))
                                    .then(|| {
                                        ink_analyzer_ir::parent_ast_item(&token_before)
                                            .map(|it| utils::item_children_indenting(it.syntax()))
                                    })
                                    .flatten()
                                    .as_deref()
                                    .unwrap_or_default()
                            )
                        }),
                        // Adds formatting suffix if the edit is followed by either a non-whitespace character
                        // or whitespace that doesn't start with at least 2 new lines
                        // (the new lines can be interspersed with other whitespace)
                        // and the edit doesn't end with 2 new lines.
                        token_after_option.as_ref().and_then(|token_after| {
                            ((token_after.kind() != SyntaxKind::WHITESPACE
                                || !starts_with_two_or_more_newlines(token_after.text()))
                                && !edit.text.ends_with("\n\n"))
                            .then_some(format!(
                                "\n{}",
                                if token_after.text().starts_with('\n') {
                                    ""
                                } else {
                                    "\n"
                                }
                            ))
                        }),
                    )
                }
                // Handles edits at the end a statement or block or after a comment.
                SyntaxKind::SEMICOLON | SyntaxKind::R_CURLY | SyntaxKind::COMMENT => {
                    (
                        // Adds formatting prefix only if the edit doesn't start with a new line
                        // and then only add indenting if the edit doesn't start with a space (i.e ' ') or a tab (i.e. '\t').
                        (!edit.text.starts_with('\n')).then(|| {
                            format!(
                                "\n{}{}",
                                // Extra new line at the end of statements and blocks.
                                if token_before.kind() == SyntaxKind::COMMENT {
                                    ""
                                } else {
                                    "\n"
                                },
                                (!edit.text.starts_with(' ') && !edit.text.starts_with('\t'))
                                    .then(|| {
                                        ink_analyzer_ir::parent_ast_item(&token_before)
                                            .and_then(|it| utils::item_indenting(it.syntax()))
                                    })
                                    .flatten()
                                    .as_deref()
                                    .unwrap_or_default()
                            )
                        }),
                        // No formatting suffix.
                        None,
                    )
                }
                // Ignores all other cases.
                _ => (None, None),
            };

            // Adds formatting if necessary.
            if prefix.is_some() || suffix.is_some() {
                edit.text = format!(
                    "{}{}{}",
                    prefix.as_deref().unwrap_or_default(),
                    edit.text,
                    suffix.as_deref().unwrap_or_default(),
                );
                edit.snippet = edit.snippet.map(|snippet| {
                    format!(
                        "{}{snippet}{}",
                        prefix.as_deref().unwrap_or_default(),
                        suffix.as_deref().unwrap_or_default()
                    )
                });
            }
        }
    }
    edit
}

/// Checks whether the given text starts with at least 2 new lines (the new lines can be interspersed with other whitespace).
fn starts_with_two_or_more_newlines(text: &str) -> bool {
    static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^([^\S\n]*\n[^\S\n]*){2,}").unwrap());
    RE.is_match(text)
}