Skip to main content

quarto_source_map/
context.rs

1//! Source context for managing files
2
3use crate::file_info::FileInformation;
4use crate::types::FileId;
5use serde::{Deserialize, Serialize};
6
7use std::collections::HashMap;
8
9/// Context for managing source files
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SourceContext {
12    files: Vec<SourceFile>,
13    /// Sparse mapping for non-sequential file IDs (e.g., from hash-based IDs)
14    /// Only populated when add_file_with_id is used
15    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
16    file_id_map: HashMap<usize, usize>, // Maps FileId.0 -> index in files vec
17}
18
19/// A source file with content and metadata
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SourceFile {
22    /// File path or identifier
23    pub path: String,
24    /// File content (for ephemeral/in-memory files)
25    /// When Some, content is stored in memory (e.g., for <anonymous> or test files)
26    /// When None, content should be read from disk using the path
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub content: Option<String>,
29    /// File information for efficient location lookups (optional for serialization)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub file_info: Option<FileInformation>,
32    /// File metadata
33    pub metadata: FileMetadata,
34}
35
36/// Metadata about a source file
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct FileMetadata {
39    /// File type (qmd, yaml, md, etc.)
40    pub file_type: Option<String>,
41}
42
43impl SourceContext {
44    /// Create a new empty source context
45    pub fn new() -> Self {
46        SourceContext {
47            files: Vec::new(),
48            file_id_map: HashMap::new(),
49        }
50    }
51
52    /// Add a file to the context and return its ID
53    ///
54    /// - If content is Some: Creates an ephemeral (in-memory) file. Content is stored and used for ariadne rendering.
55    /// - If content is None: Creates a disk-backed file. Content will be read from disk when needed (path must exist).
56    ///
57    /// For ephemeral files, FileInformation is created immediately from the provided content.
58    /// For disk-backed files, FileInformation is created by reading from disk if the path exists.
59    pub fn add_file(&mut self, path: String, content: Option<String>) -> FileId {
60        let id = FileId(self.files.len());
61
62        // For ephemeral files (content provided), store it and create FileInformation
63        // For disk-backed files (no content), try to read from disk for FileInformation only
64        let (stored_content, content_for_info) = match content {
65            Some(c) => {
66                // Ephemeral file: store content and use it for FileInformation
67                (Some(c.clone()), Some(c))
68            }
69            None => {
70                // Disk-backed file: don't store content, but try to read for FileInformation
71                (None, std::fs::read_to_string(&path).ok())
72            }
73        };
74
75        let file_info = content_for_info.as_ref().map(|c| FileInformation::new(c));
76        self.files.push(SourceFile {
77            path,
78            content: stored_content,
79            file_info,
80            metadata: FileMetadata { file_type: None },
81        });
82        id
83    }
84
85    /// Add a file with pre-computed FileInformation
86    ///
87    /// This is useful when deserializing from formats (like JSON) that include
88    /// serialized FileInformation, avoiding the need to recompute line breaks
89    /// or read from disk.
90    ///
91    /// The file is created without content (content=None), so ariadne rendering
92    /// won't work, but map_offset() will work using the provided FileInformation.
93    pub fn add_file_with_info(&mut self, path: String, file_info: FileInformation) -> FileId {
94        let id = FileId(self.files.len());
95        self.files.push(SourceFile {
96            path,
97            content: None,
98            file_info: Some(file_info),
99            metadata: FileMetadata { file_type: None },
100        });
101        id
102    }
103
104    /// Add a file with a specific FileId
105    ///
106    /// This is useful when interfacing with systems that use hash-based or non-sequential
107    /// FileIds (like quarto-yaml). The FileId must not already exist in the context.
108    ///
109    /// # Panics
110    ///
111    /// Panics if the FileId already exists in the context.
112    pub fn add_file_with_id(
113        &mut self,
114        id: FileId,
115        path: String,
116        content: Option<String>,
117    ) -> FileId {
118        // Check if ID already exists
119        if self.get_file(id).is_some() {
120            panic!("FileId {:?} already exists in SourceContext", id);
121        }
122
123        // Process content same as add_file
124        let (stored_content, content_for_info) = match content {
125            Some(c) => (Some(c.clone()), Some(c)),
126            None => (None, std::fs::read_to_string(&path).ok()),
127        };
128
129        let file_info = content_for_info.as_ref().map(|c| FileInformation::new(c));
130
131        // Add to files vec and create mapping
132        let index = self.files.len();
133        self.files.push(SourceFile {
134            path,
135            content: stored_content,
136            file_info,
137            metadata: FileMetadata { file_type: None },
138        });
139
140        // Store mapping from FileId to index
141        self.file_id_map.insert(id.0, index);
142
143        id
144    }
145
146    /// Get a file by ID
147    pub fn get_file(&self, id: FileId) -> Option<&SourceFile> {
148        // First check if this is a mapped ID
149        if let Some(&index) = self.file_id_map.get(&id.0) {
150            return self.files.get(index);
151        }
152
153        // Otherwise use direct indexing (for sequential IDs from add_file)
154        self.files.get(id.0)
155    }
156
157    /// Create a copy without FileInformation (for serialization)
158    ///
159    /// Note: This preserves the content field for ephemeral files, as they need
160    /// content to be serialized for proper deserialization. Only FileInformation
161    /// is removed since it can be reconstructed from content.
162    pub fn without_content(&self) -> Self {
163        SourceContext {
164            files: self
165                .files
166                .iter()
167                .map(|f| SourceFile {
168                    path: f.path.clone(),
169                    content: f.content.clone(), // Preserve content for ephemeral files
170                    file_info: None,
171                    metadata: f.metadata.clone(),
172                })
173                .collect(),
174            file_id_map: self.file_id_map.clone(), // Preserve mapping
175        }
176    }
177}
178
179impl Default for SourceContext {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_empty_context() {
191        let ctx = SourceContext::new();
192        assert!(ctx.get_file(FileId(0)).is_none());
193    }
194
195    #[test]
196    fn test_add_and_get_file() {
197        let mut ctx = SourceContext::new();
198        let id = ctx.add_file("test.qmd".to_string(), Some("# Hello".to_string()));
199
200        assert_eq!(id, FileId(0));
201        let file = ctx.get_file(id).unwrap();
202        assert_eq!(file.path, "test.qmd");
203        assert!(file.file_info.is_some());
204
205        // Verify the file info was built correctly
206        let info = file.file_info.as_ref().unwrap();
207        assert_eq!(info.total_length(), 7);
208    }
209
210    #[test]
211    fn test_multiple_files() {
212        let mut ctx = SourceContext::new();
213        let id1 = ctx.add_file("first.qmd".to_string(), Some("First".to_string()));
214        let id2 = ctx.add_file("second.qmd".to_string(), Some("Second".to_string()));
215
216        assert_eq!(id1, FileId(0));
217        assert_eq!(id2, FileId(1));
218
219        let file1 = ctx.get_file(id1).unwrap();
220        let file2 = ctx.get_file(id2).unwrap();
221
222        assert_eq!(file1.path, "first.qmd");
223        assert_eq!(file2.path, "second.qmd");
224        assert!(file1.file_info.is_some());
225        assert!(file2.file_info.is_some());
226        assert_eq!(file1.file_info.as_ref().unwrap().total_length(), 5);
227        assert_eq!(file2.file_info.as_ref().unwrap().total_length(), 6);
228    }
229
230    #[test]
231    fn test_file_without_content() {
232        let mut ctx = SourceContext::new();
233        let id = ctx.add_file("no-content.qmd".to_string(), None);
234
235        let file = ctx.get_file(id).unwrap();
236        assert_eq!(file.path, "no-content.qmd");
237        assert!(file.file_info.is_none());
238    }
239
240    #[test]
241    fn test_without_content() {
242        let mut ctx = SourceContext::new();
243        ctx.add_file("test1.qmd".to_string(), Some("Content 1".to_string()));
244        ctx.add_file("test2.qmd".to_string(), Some("Content 2".to_string()));
245
246        let ctx_no_content = ctx.without_content();
247
248        let file1 = ctx_no_content.get_file(FileId(0)).unwrap();
249        let file2 = ctx_no_content.get_file(FileId(1)).unwrap();
250
251        assert_eq!(file1.path, "test1.qmd");
252        assert_eq!(file2.path, "test2.qmd");
253        assert!(file1.file_info.is_none());
254        assert!(file2.file_info.is_none());
255    }
256
257    #[test]
258    fn test_serialization() {
259        let mut ctx = SourceContext::new();
260        ctx.add_file("test.qmd".to_string(), Some("# Test".to_string()));
261
262        let json = serde_json::to_string(&ctx).unwrap();
263        let deserialized: SourceContext = serde_json::from_str(&json).unwrap();
264
265        let file = deserialized.get_file(FileId(0)).unwrap();
266        assert_eq!(file.path, "test.qmd");
267        assert!(file.file_info.is_some());
268        assert_eq!(file.file_info.as_ref().unwrap().total_length(), 6);
269    }
270
271    #[test]
272    fn test_serialization_without_content() {
273        let mut ctx = SourceContext::new();
274        ctx.add_file("test.qmd".to_string(), Some("# Test".to_string()));
275
276        let ctx_no_content = ctx.without_content();
277        let json = serde_json::to_string(&ctx_no_content).unwrap();
278
279        // Verify that None file_info is skipped in serialization
280        assert!(!json.contains("\"file_info\""));
281    }
282}