Skip to main content

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    /// Ensure inlines are parsed, then resolve inline word anchors (ยง2.3.1) on
144    /// the stored reference nodes. A `TextContent` is a single logical line, so
145    /// the surrounding-word rules apply within this run. Idempotent given the
146    /// same content.
147    pub fn ensure_inline_parsed_with_anchors(&mut self) {
148        self.ensure_inline_parsed();
149        if let TextRepresentation::Inlines { nodes, .. } = &mut self.inner {
150            crate::lex::anchoring::resolve_word_anchors(nodes);
151        }
152    }
153
154    // ============================================================================
155    // LSP-FRIENDLY APIS (Issue #290)
156    // ============================================================================
157
158    /// Get parsed inline nodes if available (LSP API).
159    ///
160    /// Returns `Some` if inlines have been parsed via `parse_inlines()` or `inlines_or_parse()`.
161    /// Returns `None` if content is still in plain text form.
162    ///
163    /// This is a convenience alias for `inline_nodes()`.
164    #[inline]
165    pub fn inlines(&self) -> Option<&[InlineNode]> {
166        self.inline_nodes()
167    }
168
169    /// Parse text into inline nodes and store the result (LSP API).
170    ///
171    /// This method is idempotent - calling it multiple times has no additional effect.
172    /// After calling this method, `inlines()` will return `Some`.
173    ///
174    /// This is a convenience alias for `ensure_inline_parsed()`.
175    #[inline]
176    pub fn parse_inlines(&mut self) {
177        self.ensure_inline_parsed();
178    }
179
180    /// Get or parse inline nodes (LSP API).
181    ///
182    /// If inlines are already parsed, returns a reference to them.
183    /// Otherwise, parses the text into inlines, stores the result, and returns a reference.
184    ///
185    /// This is the recommended method for LSP features that need access to inline elements.
186    pub fn inlines_or_parse(&mut self) -> &[InlineNode] {
187        self.ensure_inline_parsed();
188        self.inline_nodes()
189            .expect("inline_nodes should be available after ensure_inline_parsed")
190    }
191}
192
193impl Default for TextContent {
194    fn default() -> Self {
195        Self::empty()
196    }
197}
198
199impl From<String> for TextContent {
200    fn from(text: String) -> Self {
201        Self::from_string(text, None)
202    }
203}
204
205impl From<&str> for TextContent {
206    fn from(text: &str) -> Self {
207        Self::from_string(text.to_string(), None)
208    }
209}
210
211impl AsRef<str> for TextContent {
212    fn as_ref(&self) -> &str {
213        self.as_string()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_create_from_string() {
223        let content = TextContent::from_string("Hello".to_string(), None);
224        assert_eq!(content.as_string(), "Hello");
225    }
226
227    #[test]
228    fn test_empty() {
229        let content = TextContent::empty();
230        assert!(content.is_empty());
231        assert_eq!(content.as_string().len(), 0);
232    }
233
234    #[test]
235    fn test_from_string_trait() {
236        let content = TextContent::from("Hello".to_string());
237        assert_eq!(content.as_string(), "Hello");
238    }
239
240    #[test]
241    fn test_from_str_trait() {
242        let content = TextContent::from("Hello");
243        assert_eq!(content.as_string(), "Hello");
244    }
245
246    #[test]
247    fn test_as_ref() {
248        let content = TextContent::from("Hello");
249        let text: &str = content.as_ref();
250        assert_eq!(text, "Hello");
251    }
252
253    #[test]
254    fn test() {
255        let location = Range::new(0..0, Position::new(0, 0), Position::new(0, 5));
256        let content = TextContent::from_string("Hello".to_string(), Some(location.clone()));
257        assert_eq!(content.location, Some(location));
258    }
259
260    #[test]
261    fn test_mutate() {
262        let mut content = TextContent::from_string("Hello".to_string(), None);
263        *content.as_string_mut() = "World".to_string();
264        assert_eq!(content.as_string(), "World");
265    }
266
267    #[test]
268    fn parses_inline_items() {
269        use crate::lex::inlines::InlineNode;
270
271        let content = TextContent::from_string("Hello *world*".to_string(), None);
272        let nodes = content.inline_items();
273        assert_eq!(nodes.len(), 2);
274        assert_eq!(nodes[0], InlineNode::plain("Hello ".into()));
275        match &nodes[1] {
276            InlineNode::Strong { content, .. } => {
277                assert_eq!(content, &vec![InlineNode::plain("world".into())]);
278            }
279            other => panic!("Unexpected inline node: {other:?}"),
280        }
281    }
282
283    #[test]
284    fn persists_inline_nodes_after_parsing() {
285        use crate::lex::inlines::InlineNode;
286
287        let mut content = TextContent::from_string("Hello *world*".to_string(), None);
288        assert!(content.inline_nodes().is_none());
289
290        content.ensure_inline_parsed();
291        let nodes = content.inline_nodes().expect("expected inline nodes");
292        assert_eq!(nodes.len(), 2);
293        assert_eq!(nodes[0], InlineNode::plain("Hello ".into()));
294        match &nodes[1] {
295            InlineNode::Strong { content, .. } => {
296                assert_eq!(content, &vec![InlineNode::plain("world".into())]);
297            }
298            other => panic!("Unexpected inline node: {other:?}"),
299        }
300
301        // inline_items should reuse the stored nodes rather than re-parse
302        assert_eq!(content.inline_items(), nodes.to_vec());
303        assert_eq!(content.as_string(), "Hello *world*");
304    }
305
306    use super::super::range::Position;
307
308    // ============================================================================
309    // LSP API TESTS (Issue #290)
310    // ============================================================================
311
312    #[test]
313    fn test_inlines_alias() {
314        let mut content = TextContent::from_string("Hello *world*".to_string(), None);
315
316        // Before parsing
317        assert!(content.inlines().is_none());
318
319        // After parsing
320        content.parse_inlines();
321        let nodes = content.inlines().expect("expected inline nodes");
322        assert_eq!(nodes.len(), 2);
323    }
324
325    #[test]
326    fn test_parse_inlines_alias() {
327        let mut content = TextContent::from_string("Hello *world*".to_string(), None);
328
329        content.parse_inlines();
330        assert!(content.inlines().is_some());
331
332        // Idempotent - calling again should not panic
333        content.parse_inlines();
334        assert!(content.inlines().is_some());
335    }
336
337    #[test]
338    fn test_inlines_or_parse() {
339        let mut content = TextContent::from_string("Hello *world*".to_string(), None);
340
341        // First call parses
342        {
343            let nodes1 = content.inlines_or_parse();
344            assert_eq!(nodes1.len(), 2);
345        }
346
347        // Second call returns cached result
348        {
349            let nodes2 = content.inlines_or_parse();
350            assert_eq!(nodes2.len(), 2);
351        }
352    }
353
354    #[test]
355    fn test_inlines_or_parse_with_references() {
356        use crate::lex::inlines::InlineNode;
357
358        let mut content =
359            TextContent::from_string("See [42] and [https://example.com]".to_string(), None);
360        let nodes = content.inlines_or_parse();
361
362        // Should have: Plain, Reference, Plain, Reference
363        assert_eq!(nodes.len(), 4);
364        assert!(matches!(nodes[0], InlineNode::Plain { .. }));
365        assert!(matches!(nodes[1], InlineNode::Reference { .. }));
366        assert!(matches!(nodes[2], InlineNode::Plain { .. }));
367        assert!(matches!(nodes[3], InlineNode::Reference { .. }));
368    }
369}