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, file_info) = match content {
65            Some(c) => {
66                // Ephemeral file: index the content, then store it (no clone)
67                let info = FileInformation::new(&c);
68                (Some(c), Some(info))
69            }
70            None => {
71                // Disk-backed file: don't store content, but try to read for FileInformation
72                let info = std::fs::read_to_string(&path)
73                    .ok()
74                    .map(|c| FileInformation::new(&c));
75                (None, info)
76            }
77        };
78        self.files.push(SourceFile {
79            path,
80            content: stored_content,
81            file_info,
82            metadata: FileMetadata { file_type: None },
83        });
84        id
85    }
86
87    /// Add a file with pre-computed FileInformation
88    ///
89    /// This is useful when deserializing from formats (like JSON) that include
90    /// serialized FileInformation, avoiding the need to recompute line breaks
91    /// or read from disk.
92    ///
93    /// The file is created without content (content=None), so ariadne rendering
94    /// won't work, but map_offset() will work using the provided FileInformation.
95    pub fn add_file_with_info(&mut self, path: String, file_info: FileInformation) -> FileId {
96        let id = FileId(self.files.len());
97        self.files.push(SourceFile {
98            path,
99            content: None,
100            file_info: Some(file_info),
101            metadata: FileMetadata { file_type: None },
102        });
103        id
104    }
105
106    /// Add a file with a specific FileId
107    ///
108    /// This is useful when interfacing with systems that use hash-based or non-sequential
109    /// FileIds (like quarto-yaml). The FileId must not already exist in the context.
110    ///
111    /// # Panics
112    ///
113    /// Panics if the FileId already exists in the context.
114    pub fn add_file_with_id(
115        &mut self,
116        id: FileId,
117        path: String,
118        content: Option<String>,
119    ) -> FileId {
120        // Check if ID already exists
121        if self.get_file(id).is_some() {
122            panic!("FileId {:?} already exists in SourceContext", id);
123        }
124
125        // Process content same as add_file
126        let (stored_content, file_info) = match content {
127            Some(c) => {
128                let info = FileInformation::new(&c);
129                (Some(c), Some(info))
130            }
131            None => {
132                let info = std::fs::read_to_string(&path)
133                    .ok()
134                    .map(|c| FileInformation::new(&c));
135                (None, info)
136            }
137        };
138
139        // Add to files vec and create mapping
140        let index = self.files.len();
141        self.files.push(SourceFile {
142            path,
143            content: stored_content,
144            file_info,
145            metadata: FileMetadata { file_type: None },
146        });
147
148        // Store mapping from FileId to index
149        self.file_id_map.insert(id.0, index);
150
151        id
152    }
153
154    /// Get a file by ID
155    pub fn get_file(&self, id: FileId) -> Option<&SourceFile> {
156        // First check if this is a mapped ID
157        if let Some(&index) = self.file_id_map.get(&id.0) {
158            return self.files.get(index);
159        }
160
161        // Otherwise use direct indexing (for sequential IDs from add_file)
162        self.files.get(id.0)
163    }
164
165    /// Create a copy without FileInformation (for serialization)
166    ///
167    /// Note: This preserves the content field for ephemeral files, as they need
168    /// content to be serialized for proper deserialization. Only FileInformation
169    /// is removed since it can be reconstructed from content.
170    pub fn without_content(&self) -> Self {
171        SourceContext {
172            files: self
173                .files
174                .iter()
175                .map(|f| SourceFile {
176                    path: f.path.clone(),
177                    content: f.content.clone(), // Preserve content for ephemeral files
178                    file_info: None,
179                    metadata: f.metadata.clone(),
180                })
181                .collect(),
182            file_id_map: self.file_id_map.clone(), // Preserve mapping
183        }
184    }
185}
186
187impl Default for SourceContext {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_empty_context() {
199        let ctx = SourceContext::new();
200        assert!(ctx.get_file(FileId(0)).is_none());
201    }
202
203    #[test]
204    fn test_add_and_get_file() {
205        let mut ctx = SourceContext::new();
206        let id = ctx.add_file("test.qmd".to_string(), Some("# Hello".to_string()));
207
208        assert_eq!(id, FileId(0));
209        let file = ctx.get_file(id).unwrap();
210        assert_eq!(file.path, "test.qmd");
211        assert!(file.file_info.is_some());
212
213        // Verify the file info was built correctly
214        let info = file.file_info.as_ref().unwrap();
215        assert_eq!(info.total_length(), 7);
216    }
217
218    #[test]
219    fn test_multiple_files() {
220        let mut ctx = SourceContext::new();
221        let id1 = ctx.add_file("first.qmd".to_string(), Some("First".to_string()));
222        let id2 = ctx.add_file("second.qmd".to_string(), Some("Second".to_string()));
223
224        assert_eq!(id1, FileId(0));
225        assert_eq!(id2, FileId(1));
226
227        let file1 = ctx.get_file(id1).unwrap();
228        let file2 = ctx.get_file(id2).unwrap();
229
230        assert_eq!(file1.path, "first.qmd");
231        assert_eq!(file2.path, "second.qmd");
232        assert!(file1.file_info.is_some());
233        assert!(file2.file_info.is_some());
234        assert_eq!(file1.file_info.as_ref().unwrap().total_length(), 5);
235        assert_eq!(file2.file_info.as_ref().unwrap().total_length(), 6);
236    }
237
238    #[test]
239    fn test_file_without_content() {
240        let mut ctx = SourceContext::new();
241        let id = ctx.add_file("no-content.qmd".to_string(), None);
242
243        let file = ctx.get_file(id).unwrap();
244        assert_eq!(file.path, "no-content.qmd");
245        assert!(file.file_info.is_none());
246    }
247
248    #[test]
249    fn test_without_content() {
250        let mut ctx = SourceContext::new();
251        ctx.add_file("test1.qmd".to_string(), Some("Content 1".to_string()));
252        ctx.add_file("test2.qmd".to_string(), Some("Content 2".to_string()));
253
254        let ctx_no_content = ctx.without_content();
255
256        let file1 = ctx_no_content.get_file(FileId(0)).unwrap();
257        let file2 = ctx_no_content.get_file(FileId(1)).unwrap();
258
259        assert_eq!(file1.path, "test1.qmd");
260        assert_eq!(file2.path, "test2.qmd");
261        assert!(file1.file_info.is_none());
262        assert!(file2.file_info.is_none());
263    }
264
265    #[test]
266    fn test_serialization() {
267        let mut ctx = SourceContext::new();
268        ctx.add_file("test.qmd".to_string(), Some("# Test".to_string()));
269
270        let json = serde_json::to_string(&ctx).unwrap();
271        let deserialized: SourceContext = serde_json::from_str(&json).unwrap();
272
273        let file = deserialized.get_file(FileId(0)).unwrap();
274        assert_eq!(file.path, "test.qmd");
275        assert!(file.file_info.is_some());
276        assert_eq!(file.file_info.as_ref().unwrap().total_length(), 6);
277    }
278
279    #[test]
280    fn test_serialization_without_content() {
281        let mut ctx = SourceContext::new();
282        ctx.add_file("test.qmd".to_string(), Some("# Test".to_string()));
283
284        let ctx_no_content = ctx.without_content();
285        let json = serde_json::to_string(&ctx_no_content).unwrap();
286
287        // Verify that None file_info is skipped in serialization
288        assert!(!json.contains("\"file_info\""));
289    }
290}