Skip to main content

weavatrix_rust_refactor/
declaration.rs

1//! The full source range of a declaration.
2//!
3//! Neither source of truth carries it. The graph records only the identifier — `one`, three
4//! characters — and the parser's declaration span stops at the name, covering `pub fn one`. An
5//! edit that means "replace this function" needs where the body ends, and getting that wrong
6//! writes over whatever follows.
7//!
8//! So the end is found by matching the declaration's opening brace, over the tokenizer rather
9//! than the raw text: a `}` inside a string literal or a comment is not a closing brace, and
10//! counting characters would treat it as one.
11
12use weavatrix_parse::{Language, Token, TokenKind, tokenize};
13
14/// A declaration located in one file, in byte offsets.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct DeclarationRange {
17    /// First byte of the declaration, including modifiers like `pub` or `export`.
18    pub start: usize,
19    /// One past the last byte: the closing brace, or the terminating `;`.
20    pub end: usize,
21    /// Whether the end was located in the source, or is only where the signature stopped.
22    ///
23    /// A caller must not offer "insert after this declaration" on an unproven end — it would
24    /// place the text in the middle of something.
25    pub end_proven: bool,
26}
27
28/// The 1-based line range the declaration named `name` occupies, body included.
29///
30/// The graph records a symbol's span as its declaration line alone, so a caller that asks it
31/// "where does `run` reference this?" is told line 3 while the call sits on line 4. Anything
32/// looking for a reference *inside* a symbol has to widen that span to the body, which is what
33/// this does — and it refuses to widen past an end it could not prove, since guessing there
34/// would sweep in whatever follows the function.
35#[must_use]
36pub fn body_lines(source: &str, path: &str, name: &str, line: u32) -> (u32, u32) {
37    let Some(range) = locate(source, path, name, line).filter(|range| range.end_proven) else {
38        return (line, line);
39    };
40    let line_of = |offset: usize| {
41        u32::try_from(source[..offset.min(source.len())].matches('\n').count() + 1)
42            .unwrap_or(u32::MAX)
43    };
44    let start = line_of(range.start);
45    (start, line_of(range.end).max(start))
46}
47
48/// Finds the declaration named `name` whose signature begins on `line`.
49///
50/// The line disambiguates same-named declarations in one file, which is why it is required
51/// rather than taking the first match.
52#[must_use]
53pub fn locate(source: &str, path: &str, name: &str, line: u32) -> Option<DeclarationRange> {
54    let facts = weavatrix_parse::extract_path(path, source)?;
55    let declaration = facts
56        .declarations
57        .iter()
58        .find(|candidate| candidate.name == name && candidate.span.line == line)
59        .or_else(|| {
60            facts
61                .declarations
62                .iter()
63                .find(|candidate| candidate.name == name)
64        })?;
65    let start = declaration.span.start;
66    let language = Language::from_extension(path.rsplit_once('.')?.1)?;
67    Some(declaration_end(source, language, start).map_or(
68        DeclarationRange {
69            start,
70            end: declaration.span.end,
71            end_proven: false,
72        },
73        |end| DeclarationRange {
74            start,
75            end,
76            end_proven: true,
77        },
78    ))
79}
80
81/// The offset one past whatever ends the declaration starting at `start`.
82///
83/// A braced declaration ends at the brace matching its first one; a declaration without a body
84/// ends at its terminator. Returns `None` when neither is found — an indentation-delimited
85/// language, or a file that ends mid-declaration — so the caller knows the end is unproven
86/// rather than being handed a plausible guess.
87fn declaration_end(source: &str, language: Language, start: usize) -> Option<usize> {
88    let tokens = tokenize(source, language);
89    let mut depth = 0_u32;
90    let mut opened = false;
91    for token in tokens.iter().skip_while(|token| token.end <= start) {
92        if !is_code(token) {
93            continue;
94        }
95        let text = source.get(token.start..token.end)?;
96        for (offset, character) in text.char_indices() {
97            match character {
98                '{' => {
99                    depth += 1;
100                    opened = true;
101                }
102                '}' if depth > 0 => {
103                    depth -= 1;
104                    if depth == 0 {
105                        return Some(token.start + offset + character.len_utf8());
106                    }
107                }
108                // A terminator before any brace ends a declaration that has no body.
109                ';' if !opened => return Some(token.start + offset + character.len_utf8()),
110                _ => {}
111            }
112        }
113    }
114    None
115}
116
117/// Whether a token can carry a real brace. Strings and comments cannot.
118fn is_code(token: &Token) -> bool {
119    !matches!(
120        token.kind,
121        TokenKind::String
122            | TokenKind::LineComment
123            | TokenKind::BlockComment
124            | TokenKind::Regex
125            | TokenKind::Unterminated
126    )
127}
128
129#[cfg(test)]
130mod tests {
131    use super::locate;
132
133    #[test]
134    fn a_function_range_covers_modifiers_through_the_closing_brace() {
135        let source = "pub fn one() -> u32 {\n    1\n}\n\npub fn two() {}\n";
136        let range = locate(source, "src/lib.rs", "one", 1).expect("declaration");
137        assert!(range.end_proven);
138        assert_eq!(
139            &source[range.start..range.end],
140            "pub fn one() -> u32 {\n    1\n}"
141        );
142    }
143
144    #[test]
145    fn a_brace_inside_a_string_does_not_close_the_body() {
146        let source = "pub fn one() -> &'static str {\n    \"}\"\n}\n";
147        let range = locate(source, "src/lib.rs", "one", 1).expect("declaration");
148        assert!(
149            source[range.start..range.end].ends_with("}\"\n}"),
150            "the string's brace must not end the declaration, got {:?}",
151            &source[range.start..range.end]
152        );
153    }
154
155    #[test]
156    fn a_brace_inside_a_comment_does_not_close_the_body() {
157        let source = "pub fn one() -> u32 {\n    // }\n    1\n}\n";
158        let range = locate(source, "src/lib.rs", "one", 1).expect("declaration");
159        assert_eq!(
160            &source[range.start..range.end],
161            "pub fn one() -> u32 {\n    // }\n    1\n}"
162        );
163    }
164
165    #[test]
166    fn nested_braces_are_matched_to_the_outermost() {
167        let source = "pub fn one() -> u32 {\n    if true { 1 } else { 2 }\n}\n";
168        let range = locate(source, "src/lib.rs", "one", 1).expect("declaration");
169        assert!(source[range.start..range.end].contains("else { 2 }"));
170        assert!(source[range.start..range.end].ends_with("\n}"));
171    }
172
173    #[test]
174    fn the_line_picks_between_same_named_declarations() {
175        let source = "mod a {\n    pub fn one() -> u32 { 1 }\n}\nmod b {\n    pub fn one() -> u32 { 2 }\n}\n";
176        let first = locate(source, "src/lib.rs", "one", 2);
177        let second = locate(source, "src/lib.rs", "one", 5);
178        if let (Some(first), Some(second)) = (first, second) {
179            assert_ne!(
180                first.start, second.start,
181                "the line must select the declaration, not the first match"
182            );
183        }
184    }
185
186    #[test]
187    fn a_declaration_without_a_body_ends_at_its_terminator() {
188        let source = "pub const ONE: u32 = 1;\n";
189        if let Some(range) = locate(source, "src/lib.rs", "ONE", 1) {
190            assert!(range.end_proven);
191            assert_eq!(&source[range.start..range.end], "pub const ONE: u32 = 1;");
192        }
193    }
194
195    #[test]
196    fn an_unknown_name_locates_nothing() {
197        let source = "pub fn one() {}\n";
198        assert!(locate(source, "src/lib.rs", "absent", 1).is_none());
199    }
200}