Skip to main content

makefile_lossless/
text.rs

1//! Text-based utilities for navigating Makefile source text.
2//!
3//! These functions work on raw source text (not the syntax tree) and are useful
4//! for editor integrations that need to understand what is at a given cursor position.
5
6/// Extract a variable name from `$(VAR)` or `${VAR}` surrounding the given byte offset.
7///
8/// Returns `None` if the offset is not inside a variable reference.
9///
10/// # Example
11/// ```
12/// use makefile_lossless::variable_at_offset;
13/// assert_eq!(variable_at_offset("$(FOO)", 2), Some("FOO"));
14/// assert_eq!(variable_at_offset("${BAR}", 3), Some("BAR"));
15/// assert_eq!(variable_at_offset("plain text", 3), None);
16/// ```
17pub fn variable_at_offset(text: &str, offset: usize) -> Option<&str> {
18    let bytes = text.as_bytes();
19    let mut start = None;
20    let mut i = offset;
21    while i >= 2 {
22        i -= 1;
23        if i > 0 && (bytes[i] == b'(' || bytes[i] == b'{') && bytes[i - 1] == b'$' {
24            start = Some(i + 1);
25            break;
26        }
27        if bytes[i] == b')' || bytes[i] == b'}' || bytes[i] == b'\n' {
28            return None;
29        }
30    }
31    let start = start?;
32    let rest = &text[start..];
33    let end = rest.find([')', '}'])?;
34    let var_name = &rest[..end];
35    if offset >= start && offset <= start + end {
36        Some(var_name)
37    } else {
38        None
39    }
40}
41
42/// Extract the word (identifier) at the given byte offset.
43///
44/// A word consists of ASCII alphanumeric characters, underscores, dots, and hyphens.
45/// Returns `None` if the offset is not on a word character.
46///
47/// # Example
48/// ```
49/// use makefile_lossless::word_at_offset;
50/// assert_eq!(word_at_offset("hello world", 0), Some("hello"));
51/// assert_eq!(word_at_offset("hello world", 5), None); // space
52/// assert_eq!(word_at_offset("hello world", 6), Some("world"));
53/// ```
54pub fn word_at_offset(text: &str, offset: usize) -> Option<&str> {
55    if offset > text.len() {
56        return None;
57    }
58    let bytes = text.as_bytes();
59    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-';
60    if offset < text.len() && !is_ident(bytes[offset]) {
61        return None;
62    }
63    let start = (0..offset)
64        .rev()
65        .take_while(|&i| is_ident(bytes[i]))
66        .last()
67        .unwrap_or(offset);
68    let end = (offset..text.len())
69        .take_while(|&i| is_ident(bytes[i]))
70        .last()
71        .map(|i| i + 1)
72        .unwrap_or(offset);
73    if start == end {
74        return None;
75    }
76    Some(&text[start..end])
77}
78
79/// Determine if the given byte offset is in the prerequisites area of a rule line
80/// (i.e. after the first `:` on a non-recipe line).
81///
82/// # Example
83/// ```
84/// use makefile_lossless::is_in_prerequisites;
85/// let text = "all: build test\n\techo ok\n";
86/// assert!(!is_in_prerequisites(text, 0));  // 'a' in target
87/// assert!(is_in_prerequisites(text, 5));   // 'b' in prerequisites
88/// assert!(!is_in_prerequisites(text, 17)); // 'e' in recipe
89/// ```
90pub fn is_in_prerequisites(text: &str, offset: usize) -> bool {
91    let line_start = text[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
92    let line = &text[line_start..];
93    // Recipe lines start with a tab
94    if line.starts_with('\t') {
95        return false;
96    }
97    let col = offset - line_start;
98    // Check if there's a `:` before our position on this line
99    line[..col].contains(':')
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_variable_at_offset_parens() {
108        assert_eq!(variable_at_offset("$(FOO)", 2), Some("FOO"));
109        assert_eq!(variable_at_offset("$(FOO)", 4), Some("FOO"));
110    }
111
112    #[test]
113    fn test_variable_at_offset_braces() {
114        assert_eq!(variable_at_offset("${BAR}", 2), Some("BAR"));
115    }
116
117    #[test]
118    fn test_variable_at_offset_none() {
119        assert_eq!(variable_at_offset("plain text", 3), None);
120    }
121
122    #[test]
123    fn test_variable_at_offset_nested_context() {
124        let text = "\t$(CC) main.c";
125        assert_eq!(variable_at_offset(text, 3), Some("CC"));
126    }
127
128    #[test]
129    fn test_word_at_offset_basic() {
130        assert_eq!(word_at_offset("hello world", 0), Some("hello"));
131        assert_eq!(word_at_offset("hello world", 3), Some("hello"));
132        assert_eq!(word_at_offset("hello world", 5), None);
133        assert_eq!(word_at_offset("hello world", 6), Some("world"));
134    }
135
136    #[test]
137    fn test_word_at_offset_special_chars() {
138        assert_eq!(word_at_offset("foo-bar.o", 0), Some("foo-bar.o"));
139        assert_eq!(word_at_offset("FOO_BAR", 3), Some("FOO_BAR"));
140    }
141
142    #[test]
143    fn test_is_in_prerequisites() {
144        let text = "all: build test\n\techo ok\n";
145        assert!(!is_in_prerequisites(text, 0)); // 'a' in target
146        assert!(is_in_prerequisites(text, 5)); // 'b' in prerequisites
147        assert!(!is_in_prerequisites(text, 17)); // 'e' in recipe
148    }
149
150    #[test]
151    fn test_is_in_prerequisites_no_colon() {
152        let text = "VAR = value\n";
153        assert!(!is_in_prerequisites(text, 6));
154    }
155}