Skip to main content

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}