Skip to main content

nova_diagnostics/
lines.rs

1//! Placing an edit by the lines of a source text (spec
2//! `docs/superpowers/specs/2026-10-10-phase-3-4b-fixes-and-colour-design.md`
3//! §4.7). Offsets are bytes. The rules step over ASCII only, so every
4//! offset they return is on a character boundary.
5
6/// The text's own line ending: `\r\n` when it holds one, else `\n`.
7pub fn ending(text: &str) -> &'static str {
8    if text.contains("\r\n") {
9        "\r\n"
10    } else {
11        "\n"
12    }
13}
14
15/// Where the line holding byte `at` starts.
16pub fn line_start(text: &str, at: usize) -> usize {
17    text[..at].rfind('\n').map_or(0, |i| i + 1)
18}
19
20/// Where the line after the one holding byte `at` starts, or the text's
21/// end.
22pub fn next_line_start(text: &str, at: usize) -> usize {
23    text[at..].find('\n').map_or(text.len(), |i| at + i + 1)
24}
25
26/// Whether `line` holds only spaces, tabs and its line ending.
27pub fn is_blank(line: &str) -> bool {
28    line.chars().all(|c| matches!(c, ' ' | '\t' | '\r' | '\n'))
29}
30
31/// Whether `line`'s first characters after spaces and tabs are `//`.
32pub fn is_comment_line(line: &str) -> bool {
33    line.trim_start_matches([' ', '\t']).starts_with("//")
34}
35
36/// The edit adding `line` on a line of its own after the line holding byte
37/// `at`: where it goes, and what it inserts, in the text's own ending.
38pub fn line_after(text: &str, at: usize, line: &str) -> (usize, String) {
39    let nl = ending(text);
40    // Past spaces and whole block comments, so a block comment that starts
41    // on this line and goes on stays whole (spec 3.5a §3, follow-up 9).
42    let bytes = text.as_bytes();
43    let mut i = at;
44    loop {
45        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
46            i += 1;
47        }
48        if !text[i..].starts_with("/*") {
49            break;
50        }
51        i = text[i + 2..]
52            .find("*/")
53            .map_or(text.len(), |n| i + 2 + n + 2);
54    }
55    let next = next_line_start(text, i);
56    if next == text.len() && !text.ends_with('\n') {
57        (next, format!("{nl}{line}"))
58    } else {
59        (next, format!("{line}{nl}"))
60    }
61}
62
63/// The edit adding `line` and a blank line before the item starting at
64/// byte `item_start`, above the comment lines directly over it. A comment
65/// a blank line separates from the item stays above (spec §4.7).
66pub fn line_before_item(text: &str, item_start: usize, line: &str) -> (usize, String) {
67    let nl = ending(text);
68    let mut at = line_start(text, item_start);
69    // The comments directly above, each with no blank line after it (spec
70    // 3.5a §3, follow-up 9). One that ends on the line `at` starts, beside
71    // a comment already taken or the item, comes with that line.
72    for &(start, end) in comments_before(text, at).iter().rev() {
73        if end > at {
74            at = at.min(line_start(text, start));
75            continue;
76        }
77        if text[end..at].matches('\n').count() > 1 {
78            break;
79        }
80        at = line_start(text, start);
81    }
82    (at, format!("{line}{nl}{nl}"))
83}
84
85/// The comments before byte `end` of `text`, which holds only comments
86/// and blank space there, by a scan from its start (plan decision 5).
87/// Block comments do not nest.
88fn comments_before(text: &str, end: usize) -> Vec<(usize, usize)> {
89    let bytes = text.as_bytes();
90    let mut out = Vec::new();
91    let mut i = 0;
92    while i < end {
93        match bytes[i] {
94            b' ' | b'\t' | b'\r' | b'\n' => i += 1,
95            b'/' if bytes.get(i + 1) == Some(&b'/') => {
96                let e = text[i..].find('\n').map_or(text.len(), |n| i + n);
97                out.push((i, e));
98                i = e;
99            }
100            b'/' if bytes.get(i + 1) == Some(&b'*') => {
101                let e = text[i + 2..]
102                    .find("*/")
103                    .map_or(text.len(), |n| i + 2 + n + 2);
104                out.push((i, e));
105                i = e;
106            }
107            _ => break,
108        }
109    }
110    out
111}
112
113/// Where the keyword before the item name at byte `name_start` starts:
114/// `fn`, `record`, `type`, `trait` or `const`, with an `async` before
115/// `fn` (spec §4.7).
116pub fn keyword_start(text: &str, name_start: usize) -> Option<usize> {
117    let bytes = text.as_bytes();
118    let back_over_space = |mut i: usize| {
119        while i > 0 && matches!(bytes[i - 1], b' ' | b'\t') {
120            i -= 1;
121        }
122        i
123    };
124    let back_over_word = |mut i: usize| {
125        while i > 0 && bytes[i - 1].is_ascii_alphabetic() {
126            i -= 1;
127        }
128        i
129    };
130    let end = back_over_space(name_start);
131    let start = back_over_word(end);
132    let keyword = &text[start..end];
133    if !matches!(keyword, "fn" | "record" | "type" | "trait" | "const") {
134        return None;
135    }
136    if keyword == "fn" {
137        let end = back_over_space(start);
138        let word = back_over_word(end);
139        if &text[word..end] == "async" {
140            return Some(word);
141        }
142    }
143    Some(start)
144}
145
146/// What removing a match arm, bytes `start..end`, takes (spec §4.7): a
147/// comma after it and the spaces after that, and when nothing else shares
148/// its lines but a `//` comment ending them, those whole lines. `None`
149/// when anything but a comma, a `//` comment, a line ending, the text's end
150/// or `}` follows the arm (plan decision 13).
151pub fn arm_removal(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
152    let bytes = text.as_bytes();
153    let skip = |mut i: usize| {
154        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
155            i += 1;
156        }
157        i
158    };
159    let mut after = skip(end);
160    match bytes.get(after) {
161        Some(b',') => after = skip(after + 1),
162        None | Some(b'\r' | b'\n' | b'}') => {}
163        Some(b'/') if bytes.get(after + 1) == Some(&b'/') => {}
164        Some(_) => return None,
165    }
166    let first = line_start(text, start);
167    let alone_before = text[first..start]
168        .bytes()
169        .all(|b| matches!(b, b' ' | b'\t'));
170    let alone_after =
171        text[after..].starts_with("//") || matches!(bytes.get(after), None | Some(b'\r' | b'\n'));
172    if alone_before && alone_after {
173        Some((first, next_line_start(text, after)))
174    } else {
175        Some((start, after))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn a_line_after_keeps_a_trailing_comment_on_its_line() {
185        let text = "import a // why\nfn main() {}\n";
186        assert_eq!(
187            line_after(text, "import a".len(), "import b::{x}"),
188            (16, "import b::{x}\n".to_string())
189        );
190        let crlf = "import a\r\nfn f() {}\r\n";
191        assert_eq!(
192            line_after(crlf, 8, "import b"),
193            (10, "import b\r\n".to_string())
194        );
195    }
196
197    #[test]
198    fn a_line_after_the_last_line_without_an_ending_gets_one() {
199        assert_eq!(
200            line_after("import a", 8, "import b"),
201            (8, "\nimport b".to_string())
202        );
203    }
204
205    #[test]
206    fn a_line_before_an_item_goes_above_its_comments_and_below_a_header() {
207        let text = "// header\n\n// about f\n/// Doc.\nfn f() {}\n";
208        let item = text.find("/// Doc.").unwrap();
209        assert_eq!(
210            line_before_item(text, item, "import m::{x}"),
211            (11, "import m::{x}\n\n".to_string())
212        );
213    }
214
215    #[test]
216    fn the_keyword_before_a_name_takes_async_with_it() {
217        let text = "async fn go() {}\nrecord P {}\ntype S =\n  | A\nfn f() {}\nlet x = 1\n";
218        assert_eq!(keyword_start(text, text.find("go").unwrap()), Some(0));
219        assert_eq!(
220            keyword_start(text, text.find("P {").unwrap()),
221            text.find("record")
222        );
223        assert_eq!(
224            keyword_start(text, text.find("S =").unwrap()),
225            text.find("type")
226        );
227        assert_eq!(
228            keyword_start(text, text.find("f()").unwrap()),
229            text.find("fn f")
230        );
231        assert_eq!(keyword_start(text, text.find("x =").unwrap()), None);
232    }
233
234    #[test]
235    fn an_arm_alone_on_its_lines_goes_with_them() {
236        let text = "match x {\n    1 => a,\n    _ => {\n        b\n    }\n}\n";
237        let start = text.find("1 =>").unwrap();
238        assert_eq!(
239            arm_removal(text, start, start + "1 => a".len()),
240            Some((10, 22))
241        );
242        let start = text.find("_ =>").unwrap();
243        let end = text.rfind("    }").unwrap() + "    }".len();
244        assert_eq!(
245            arm_removal(text, start, end).map(|(s, e)| &text[s..e]),
246            Some("    _ => {\n        b\n    }\n")
247        );
248        // A comment ending the arm's line goes with it.
249        let text = "match x {\n    1 => a, // one\n    _ => b\n}\n";
250        let start = text.find("1 =>").unwrap();
251        assert_eq!(
252            arm_removal(text, start, start + "1 => a".len()),
253            Some((10, 29))
254        );
255    }
256
257    #[test]
258    fn an_arm_sharing_its_line_goes_alone_with_its_comma() {
259        let text = "match x { 1 => a, 2 => b }";
260        let start = text.find("1 =>").unwrap();
261        assert_eq!(
262            arm_removal(text, start, start + 6).map(|(s, e)| &text[s..e]),
263            Some("1 => a, ")
264        );
265        let start = text.find("2 =>").unwrap();
266        assert_eq!(
267            arm_removal(text, start, start + 6).map(|(s, e)| &text[s..e]),
268            Some("2 => b ")
269        );
270        // A comment between the body and its comma: no fix (plan decision 13).
271        assert_eq!(arm_removal("1 => a /* c */,", 0, 6), None);
272    }
273
274    #[test]
275    fn the_line_ending_is_the_texts_own() {
276        assert_eq!(ending("a\r\nb\r\n"), "\r\n");
277        assert_eq!(ending("a\nb"), "\n");
278        assert!(is_blank("  \t\r\n") && !is_blank(" x\n"));
279        assert!(is_comment_line("    // c\n") && !is_comment_line("x // c\n"));
280    }
281
282    #[test]
283    fn a_line_after_an_import_goes_past_a_block_comment_that_goes_on() {
284        // Follow-up 9 (spec 3.5a §3; plan decision 5).
285        let text = "import a /* one\n   two */\nfn main() {}\n";
286        let after = text.find("two */\n").unwrap() + "two */\n".len();
287        assert_eq!(
288            line_after(text, "import a".len(), "import b::{x}"),
289            (after, "import b::{x}\n".to_string())
290        );
291        let crlf = text.replace('\n', "\r\n");
292        let after = crlf.find("two */\r\n").unwrap() + "two */\r\n".len();
293        assert_eq!(
294            line_after(&crlf, "import a".len(), "import b::{x}"),
295            (after, "import b::{x}\r\n".to_string())
296        );
297    }
298
299    #[test]
300    fn a_line_before_an_item_goes_above_a_block_comment_over_it() {
301        // Follow-up 9: the comment's blank line is inside it, and the
302        // header a blank line separates stays above.
303        let text = "// header\n\n/* about f\n\n   more */\nfn f() {}\n";
304        let item = text.find("fn f").unwrap();
305        assert_eq!(
306            line_before_item(text, item, "import m::{x}"),
307            (
308                text.find("/* about").unwrap(),
309                "import m::{x}\n\n".to_string()
310            )
311        );
312        let crlf = text.replace('\n', "\r\n");
313        let item = crlf.find("fn f").unwrap();
314        assert_eq!(
315            line_before_item(&crlf, item, "import m::{x}"),
316            (
317                crlf.find("/* about").unwrap(),
318                "import m::{x}\r\n\r\n".to_string()
319            )
320        );
321    }
322
323    #[test]
324    fn comments_sharing_a_line_above_an_item_go_with_it() {
325        // Follow-up 9: two comments on one line, and a block comment ending
326        // on the item's own line, are walked over whole, not sliced.
327        let two = "/* a */ // b\nfn f() {}\n";
328        assert_eq!(
329            line_before_item(two, two.find("fn f").unwrap(), "import m::{x}"),
330            (0, "import m::{x}\n\n".to_string())
331        );
332        let shared = "/* a\n */ fn f() {}\n";
333        assert_eq!(
334            line_before_item(shared, shared.find("fn f").unwrap(), "import m::{x}"),
335            (0, "import m::{x}\n\n".to_string())
336        );
337    }
338}