mago_database/file.rs
1use std::borrow::Cow;
2use std::hash::DefaultHasher;
3use std::hash::Hash;
4use std::hash::Hasher;
5use std::path::Path;
6use std::path::PathBuf;
7
8use crate::error::DatabaseError;
9use crate::utils::read_file;
10
11/// A stable, unique identifier for a file.
12///
13/// This ID is generated by hashing the file's logical name, ensuring it remains
14/// consistent across application runs and is unaffected by content modifications.
15#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, PartialOrd, Ord)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[repr(transparent)]
18pub struct FileId(u64);
19
20/// Distinguishes between the origins of source files.
21#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, PartialOrd, Ord)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[repr(u8)]
24pub enum FileType {
25 /// The file is part of the primary project source code.
26 /// These files typically reside on the filesystem and are actively developed.
27 Host,
28
29 /// The file belongs to a third-party dependency (e.g., a Composer package).
30 /// These files exist on the filesystem within the project (e.g., in `vendor/`)
31 /// but are not considered part of the primary source code.
32 Vendored,
33
34 /// The file represents a built-in language construct (e.g., a core PHP function or class).
35 /// These "files" do not exist on the filesystem and their content is typically
36 /// provided as pre-defined stubs for analysis.
37 Builtin,
38
39 /// The file is a user-provided patch that overrides type information for vendored or
40 /// built-in code with corrected PHPDoc / type declarations.
41 /// Like vendored files, patches are not actively analyzed, linted, or formatted,
42 /// but their metadata takes precedence over both vendored and built-in definitions.
43 Patch,
44
45 /// An in-memory source contributed by an external analyzer plugin during initialization.
46 /// External files are scanned for symbols, but are never analyzed, linted, formatted, or fixed.
47 External,
48}
49
50/// A file that's either stored on the host system's file system or in the vendored file system.
51///
52/// This struct encapsulates all the necessary information about a file, including its content,
53/// location, and metadata for change detection.
54#[derive(Debug, Eq, PartialEq, Hash)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56pub struct File {
57 /// A stable, unique identifier for the file, generated from its logical name.
58 /// This ID persists across application runs and content modifications.
59 pub id: FileId,
60
61 /// The logical name of the file, typically the path relative to the root of the project.
62 pub name: Cow<'static, [u8]>,
63
64 /// The absolute path of the file on the host's filesystem, if it exists there.
65 /// This will be `None` for vendored files that don't have a physical counterpart.
66 pub path: Option<PathBuf>,
67
68 /// The type of the file, indicating its origin.
69 pub file_type: FileType,
70
71 /// The contents of the file, if available.
72 pub contents: Cow<'static, [u8]>,
73
74 /// The size of the file's contents in bytes.
75 pub size: u32,
76
77 /// A vector containing the starting byte offsets of each line in `contents`.
78 /// The first line always starts at offset 0. This is useful for quickly
79 /// navigating to a specific line number without scanning the whole file.
80 pub lines: Vec<u32>,
81}
82
83pub trait HasFileId {
84 /// Returns the unique identifier of the file.
85 fn file_id(&self) -> FileId;
86}
87
88impl File {
89 /// Creates a new `File` instance from its name, type, path, and contents.
90 ///
91 /// It automatically calculates the size, and line start offsets.
92 #[inline]
93 #[must_use]
94 pub fn new(
95 name: Cow<'static, [u8]>,
96 file_type: FileType,
97 path: Option<PathBuf>,
98 contents: Cow<'static, [u8]>,
99 ) -> Self {
100 let id = FileId::new(&name);
101 let size = contents.len() as u32;
102 let lines = line_starts(contents.as_ref());
103
104 Self { id, name, path, file_type, contents, size, lines }
105 }
106
107 /// Creates a new `File` instance by reading its contents from the filesystem.
108 ///
109 /// This is the primary factory function for creating a `File` from a disk source.
110 /// It handles determining the file's logical name relative to the workspace,
111 /// reading its contents, and robustly handling non-UTF-8 text via lossy conversion.
112 ///
113 /// # Arguments
114 ///
115 /// * `workspace`: The root directory of the project, used to calculate the logical name.
116 /// * `path`: The absolute path to the file to read from disk.
117 /// * `file_type`: The [`FileType`] to assign to the created file.
118 ///
119 /// # Errors
120 ///
121 /// Returns a [`DatabaseError::IOError`] if the file cannot be read from the disk.
122 #[inline(always)]
123 pub fn read(workspace: &Path, path: &Path, file_type: FileType) -> Result<Self, DatabaseError> {
124 read_file(workspace, path, file_type)
125 }
126
127 /// Creates an ephemeral, in-memory `File` from a name and content.
128 ///
129 /// This is a convenience method for situations like testing or formatting where
130 /// a full file context (e.g., a real path) is not required. It defaults to
131 /// `FileType::Host` and a `path` of `None`.
132 #[inline]
133 #[must_use]
134 pub fn ephemeral(name: Cow<'static, [u8]>, contents: Cow<'static, [u8]>) -> Self {
135 Self::new(name, FileType::Host, None, contents)
136 }
137
138 /// Retrieve the line number for the given byte offset.
139 ///
140 /// # Parameters
141 ///
142 /// - `offset`: The byte offset to retrieve the line number for.
143 ///
144 /// # Returns
145 ///
146 /// The line number for the given byte offset (0-based index).
147 #[inline]
148 #[must_use]
149 pub fn line_number(&self, offset: u32) -> u32 {
150 self.lines.binary_search(&offset).unwrap_or_else(|next_line| next_line - 1) as u32
151 }
152
153 /// Retrieve the byte offset for the start of the given line.
154 ///
155 /// # Parameters
156 ///
157 /// - `line`: The line number to retrieve the start offset for.
158 ///
159 /// # Returns
160 ///
161 /// The byte offset for the start of the given line (0-based index).
162 #[inline]
163 #[must_use]
164 pub fn get_line_start_offset(&self, line: u32) -> Option<u32> {
165 self.lines.get(line as usize).copied()
166 }
167
168 /// Retrieve the byte offset for the end of the given line.
169 ///
170 /// # Parameters
171 ///
172 /// - `line`: The line number to retrieve the end offset for.
173 ///
174 /// # Returns
175 ///
176 /// The byte offset for the end of the given line (0-based index).
177 #[inline]
178 #[must_use]
179 pub fn get_line_end_offset(&self, line: u32) -> Option<u32> {
180 match self.lines.get(line as usize + 1) {
181 Some(&end) => Some(end - 1),
182 None if line as usize == self.lines.len() - 1 => Some(self.size),
183 _ => None,
184 }
185 }
186
187 /// Retrieve the column number for the given byte offset.
188 ///
189 /// # Parameters
190 ///
191 /// - `offset`: The byte offset to retrieve the column number for.
192 ///
193 /// # Returns
194 ///
195 /// The column number for the given byte offset (0-based index).
196 #[inline]
197 #[must_use]
198 pub fn column_number(&self, offset: u32) -> u32 {
199 let line = self.line_number(offset) as usize;
200
201 offset - self.lines[line]
202 }
203}
204
205impl FileType {
206 /// Returns `true` if the file is a host file, meaning it is part of the project's source code.
207 #[inline]
208 #[must_use]
209 pub const fn is_host(self) -> bool {
210 matches!(self, FileType::Host)
211 }
212
213 /// Returns `true` if the file is a vendored file, meaning it comes from an external library or dependency.
214 #[inline]
215 #[must_use]
216 pub const fn is_vendored(self) -> bool {
217 matches!(self, FileType::Vendored)
218 }
219
220 /// Returns `true` if the file is a built-in file, meaning it represents a core language construct.
221 #[inline]
222 #[must_use]
223 pub const fn is_builtin(self) -> bool {
224 matches!(self, FileType::Builtin)
225 }
226
227 /// Returns `true` if the file is a patch, meaning it overrides type information for vendored or built-in code.
228 #[must_use]
229 pub const fn is_patch(self) -> bool {
230 matches!(self, FileType::Patch)
231 }
232
233 /// Returns `true` if the file is an in-memory source contributed by an external analyzer plugin.
234 #[must_use]
235 pub const fn is_external(self) -> bool {
236 matches!(self, FileType::External)
237 }
238}
239
240impl FileId {
241 #[inline]
242 #[must_use]
243 pub fn new(logical_name: &[u8]) -> Self {
244 let mut hasher = DefaultHasher::new();
245 logical_name.hash(&mut hasher);
246 Self(hasher.finish())
247 }
248
249 #[inline]
250 #[must_use]
251 pub const fn zero() -> Self {
252 Self(0)
253 }
254
255 #[inline]
256 #[must_use]
257 pub const fn is_zero(self) -> bool {
258 self.0 == 0
259 }
260
261 #[inline]
262 #[must_use]
263 pub fn as_u64(self) -> u64 {
264 self.0
265 }
266}
267
268impl HasFileId for File {
269 #[inline]
270 fn file_id(&self) -> FileId {
271 self.id
272 }
273}
274
275impl std::fmt::Display for FileId {
276 #[inline]
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 write!(f, "{}", self.0)
279 }
280}
281
282/// Returns a vec over the starting byte offsets of each line in `source`.
283#[inline]
284pub(crate) fn line_starts(source: &[u8]) -> Vec<u32> {
285 // Heuristic: On the test corpus, the mean length is about 30 bytes, the median is 23.
286 // Since the whole vec will be small, we prefer slight over-allocation to avoid re-allocations
287 // in the common case
288 const LINE_WIDTH_HEURISTIC: usize = 20;
289
290 // Pre-allocate to avoid calling `realloc` thousands of times per file.
291 let mut lines = Vec::with_capacity(source.len() / LINE_WIDTH_HEURISTIC);
292 lines.push(0);
293
294 // Detect line ending style from the first \r or \n. Real files use one
295 // convention throughout, so we never need to handle mixed \r\n / bare \r.
296 match memchr::memchr2(b'\r', b'\n', source) {
297 // No line endings: single-line file, nothing more to push.
298 None => {}
299 // Old Mac (\r only): first line-ending char is a bare \r.
300 Some(cr) if source[cr] == b'\r' && source.get(cr + 1) != Some(&b'\n') => {
301 for pos in memchr::memchr_iter(b'\r', source) {
302 lines.push((pos + 1) as u32);
303 }
304 }
305 // Unix (\n only) or Windows (\r\n): \n marks every line start.
306 _ => {
307 for pos in memchr::memchr_iter(b'\n', source) {
308 lines.push((pos + 1) as u32);
309 }
310 }
311 }
312
313 lines
314}