lex_core/lex/ast/
text_content.rs

1//! TextContent facade for representing user content text
2//!
3//! This module provides the `TextContent` type, which serves as a stable
4//! interface for user-provided text throughout the AST. The facade is designed
5//! to evolve over time:
6//!
7//! - Phase 1 (current): Plain text strings with source position tracking
8//! - Phase 2 (future): Parsed inline elements (bold, italic, links, etc.)
9//!
10//! By using a facade, we can evolve from Phase 1 to Phase 2 without changing
11//! the AST node types. External code accesses text via stable API methods
12//! (.as_string(), future: .as_inlines()), which work regardless of the
13//! internal representation.
14
15use super::range::Range;
16use crate::lex::inlines::{InlineContent, InlineNode};
17
18/// Represents user-provided text content with source position tracking.
19///
20/// TextContent acts as a facade over different internal representations,
21/// allowing the text layer to evolve without breaking the AST structure.
22/// Currently stores plain text; future versions will support parsed inline nodes.
23#[derive(Debug, Clone, PartialEq)]
24pub struct TextContent {
25    /// Location in the source covering this text
26    pub location: Option<Range>,
27    /// Internal representation (evolves over time)
28    inner: TextRepresentation,
29}
30
31/// Internal representation of text content.
32///
33/// This enum encapsulates the actual text storage format. It can evolve
34/// without changing the public TextContent API.
35#[derive(Debug, Clone, PartialEq)]
36enum TextRepresentation {
37    /// Plain text as a String.
38    /// May contain formatting markers like "bold" or "_italic_"
39    /// that will be parsed in Phase 2.
40    Text(String),
41    /// Parsed inline nodes along with the original raw string.
42    Inlines { raw: String, nodes: InlineContent },
43}
44
45impl TextContent {
46    /// Create TextContent from a string and optional source location.
47    ///
48    /// # Arguments
49    /// * `text` - The raw text content
50    /// * `location` - Optional source location of this text
51    ///
52    ///
53    pub fn from_string(text: String, location: Option<Range>) -> Self {
54        Self {
55            location,
56            inner: TextRepresentation::Text(text),
57        }
58    }
59
60    /// Create empty TextContent.
61    pub fn empty() -> Self {
62        Self {
63            location: None,
64            inner: TextRepresentation::Text(String::new()),
65        }
66    }
67
68    /// Get the text content as a string slice.
69    ///
70    /// Works regardless of internal representation. In Phase 1, returns the
71    /// stored string directly. In Phase 2, would flatten inline nodes to text.
72    ///
73    ///
74    pub fn as_string(&self) -> &str {
75        match &self.inner {
76            TextRepresentation::Text(s) => s,
77            TextRepresentation::Inlines { raw, .. } => raw,
78        }
79    }
80
81    /// Get mutable access to the text content.
82    ///
83    /// Note: Only available in Phase 1. Once inlines are parsed,
84    /// you would need to reconstruct inlines after mutations.
85    ///
86    /// # Panics
87    /// In Phase 2, this may panic or return an error if inlines have been parsed.
88    pub fn as_string_mut(&mut self) -> &mut String {
89        match &mut self.inner {
90            TextRepresentation::Text(s) => s,
91            TextRepresentation::Inlines { .. } => {
92                panic!(
93                    "TextContent::as_string_mut cannot be used after inline parsing has occurred"
94                )
95            }
96        }
97    }
98
99    /// Check if content is empty.
100    pub fn is_empty(&self) -> bool {
101        self.as_string().is_empty()
102    }
103
104    /// Get the length of the content in characters.
105    pub fn len(&self) -> usize {
106        self.as_string().len()
107    }
108
109    /// Parse inline items contained in this text.
110    pub fn inline_items(&self) -> InlineContent {
111        match &self.inner {
112            TextRepresentation::Text(s) => crate::lex::inlines::parse_inlines(s),
113            TextRepresentation::Inlines { nodes, .. } => nodes.clone(),
114        }
115    }
116
117    /// Returns a reference to parsed inline nodes when available.
118    pub fn inline_nodes(&self) -> Option<&[InlineNode]> {
119        match &self.inner {
120            TextRepresentation::Inlines { nodes, .. } => Some(nodes),
121            _ => None,
122        }
123    }
124
125    /// Parse inline nodes (if not already parsed) and store them in this TextContent.
126    pub fn ensure_inline_parsed(&mut self) {
127        if matches!(self.inner, TextRepresentation::Inlines { .. }) {
128            return;
129        }
130
131        let raw = match std::mem::replace(&mut self.inner, TextRepresentation::Text(String::new()))
132        {
133            TextRepresentation::Text(raw) => raw,
134            TextRepresentation::Inlines { raw, nodes } => {
135                self.inner = TextRepresentation::Inlines { raw, nodes };
136                return;
137            }
138        };
139        let nodes = crate::lex::inlines::parse_inlines(&raw);
140        self.inner = TextRepresentation::Inlines { raw, nodes };
141    }
142
143    // ============================================================================
144    // LSP-FRIENDLY APIS (Issue #290)
145    // ============================================================================
146
147    /// Get parsed inline nodes if available (LSP API).
148    ///
149    /// Returns `Some` if inlines have been parsed via `parse_inlines()` or `inlines_or_parse()`.
150    /// Returns `None` if content is still in plain text form.
151    ///
152    /// This is a convenience alias for `inline_nodes()`.
153    #[inline]
154    pub fn inlines(&self) -> Option<&[InlineNode]> {
155        self.inline_nodes()
156    }
157
158    /// Parse text into inline nodes and store the result (LSP API).
159    ///
160    /// This method is idempotent - calling it multiple times has no additional effect.
161    /// After calling this method, `inlines()` will return `Some`.
162    ///
163    /// This is a convenience alias for `ensure_inline_parsed()`.
164    #[inline]
165    pub fn parse_inlines(&mut self) {
166        self.ensure_inline_parsed();
167    }
168
169    /// Get or parse inline nodes (LSP API).
170    ///
171    /// If inlines are already parsed, returns a reference to them.
172    /// Otherwise, parses the text into inlines, stores the result, and returns a reference.
173    ///
174    /// This is the recommended method for LSP features that need access to inline elements.
175    pub fn inlines_or_parse(&mut self) -> &[InlineNode] {
176        self.ensure_inline_parsed();
177        self.inline_nodes()
178            .expect("inline_nodes should be available after ensure_inline_parsed")
179    }
180}
181
182impl Default for TextContent {
183    fn default() -> Self {
184        Self::empty()
185    }
186}
187
188impl From<String> for TextContent {
189    fn from(text: String) -> Self {
190        Self::from_string(text, None)
191    }
192}
193
194impl From<&str> for TextContent {
195    fn from(text: &str) -> Self {
196        Self::from_string(text.to_string(), None)
197    }
198}
199
200impl AsRef<str> for TextContent {
201    fn as_ref(&self) -> &str {
202        self.as_string()
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_create_from_string() {
212        let content = TextContent::from_string("Hello".to_string(), None);
213        assert_eq!(content.as_string(), "Hello");
214    }
215
216    #[test]
217    fn test_empty() {
218        let content = TextContent::empty();
219        assert!(content.is_empty());
220        assert_eq!(content.as_string().len(), 0);
221    }
222
223    #[test]
224    fn test_from_string_trait() {
225        let content = TextContent::from("Hello".to_string());
226        assert_eq!(content.as_string(), "Hello");
227    }
228
229    #[test]
230    fn test_from_str_trait() {
231        let content = TextContent::from("Hello");
232        assert_eq!(content.as_string(), "Hello");
233    }
234
235    #[test]
236    fn test_as_ref() {
237        let content = TextContent::from("Hello");
238        let text: &str = content.as_ref();
239        assert_eq!(text, "Hello");
240    }
241
242    #[test]
243    fn test() {
244        let location = Range::new(0..0, Position::new(0, 0), Position::new(0, 5));
245        let content = TextContent::from_string("Hello".to_string(), Some(location.clone()));
246        assert_eq!(content.location, Some(location));
247    }
248
249    #[test]
250    fn test_mutate() {
251        let mut content = TextContent::from_string("Hello".to_string(), None);
252        *content.as_string_mut() = "World".to_string();
253        assert_eq!(content.as_string(), "World");
254    }
255
256    #[test]
257    fn parses_inline_items() {
258        use crate::lex::inlines::InlineNode;
259
260        let content = TextContent::from_string("Hello *world*".to_string(), None);
261        let nodes = content.inline_items();
262        assert_eq!(nodes.len(), 2);
263        assert_eq!(nodes[0], InlineNode::plain("Hello ".into()));
264        match &nodes[1] {
265            InlineNode::Strong { content, .. } => {
266                assert_eq!(content, &vec![InlineNode::plain("world".into())]);
267            }
268            other => panic!("Unexpected inline node: {other:?}"),
269        }
270    }
271
272    #[test]
273    fn persists_inline_nodes_after_parsing() {
274        use crate::lex::inlines::InlineNode;
275
276        let mut content = TextContent::from_string("Hello *world*".to_string(), None);
277        assert!(content.inline_nodes().is_none());
278
279        content.ensure_inline_parsed();
280        let nodes = content.inline_nodes().expect("expected inline nodes");
281        assert_eq!(nodes.len(), 2);
282        assert_eq!(nodes[0], InlineNode::plain("Hello ".into()));
283        match &nodes[1] {
284            InlineNode::Strong { content, .. } => {
285                assert_eq!(content, &vec![InlineNode::plain("world".into())]);
286            }
287            other => panic!("Unexpected inline node: {other:?}"),
288        }
289
290        // inline_items should reuse the stored nodes rather than re-parse
291        assert_eq!(content.inline_items(), nodes.to_vec());
292        assert_eq!(content.as_string(), "Hello *world*");
293    }
294
295    use super::super::range::Position;
296
297    // ============================================================================
298    // LSP API TESTS (Issue #290)
299    // ============================================================================
300
301    #[test]
302    fn test_inlines_alias() {
303        let mut content = TextContent::from_string("Hello *world*".to_string(), None);
304
305        // Before parsing
306        assert!(content.inlines().is_none());
307
308        // After parsing
309        content.parse_inlines();
310        let nodes = content.inlines().expect("expected inline nodes");
311        assert_eq!(nodes.len(), 2);
312    }
313
314    #[test]
315    fn test_parse_inlines_alias() {
316        let mut content = TextContent::from_string("Hello *world*".to_string(), None);
317
318        content.parse_inlines();
319        assert!(content.inlines().is_some());
320
321        // Idempotent - calling again should not panic
322        content.parse_inlines();
323        assert!(content.inlines().is_some());
324    }
325
326    #[test]
327    fn test_inlines_or_parse() {
328        let mut content = TextContent::from_string("Hello *world*".to_string(), None);
329
330        // First call parses
331        {
332            let nodes1 = content.inlines_or_parse();
333            assert_eq!(nodes1.len(), 2);
334        }
335
336        // Second call returns cached result
337        {
338            let nodes2 = content.inlines_or_parse();
339            assert_eq!(nodes2.len(), 2);
340        }
341    }
342
343    #[test]
344    fn test_inlines_or_parse_with_references() {
345        use crate::lex::inlines::InlineNode;
346
347        let mut content =
348            TextContent::from_string("See [42] and [https://example.com]".to_string(), None);
349        let nodes = content.inlines_or_parse();
350
351        // Should have: Plain, Reference, Plain, Reference
352        assert_eq!(nodes.len(), 4);
353        assert!(matches!(nodes[0], InlineNode::Plain { .. }));
354        assert!(matches!(nodes[1], InlineNode::Reference { .. }));
355        assert!(matches!(nodes[2], InlineNode::Plain { .. }));
356        assert!(matches!(nodes[3], InlineNode::Reference { .. }));
357    }
358}