Skip to main content

monty_type_checking/
type_check.rs

1use std::{fmt, io::ErrorKind, mem};
2
3use monty_types::{TypeCheckingConfig, TypeCheckingFormat};
4use ruff_db::{
5    Db as _,
6    diagnostic::{
7        Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, DisplayDiagnostics,
8        UnifiedFile,
9    },
10    file_revision::FileRevision,
11    files::{File, system_path_to_file},
12    system::{DbWithTestSystem, DbWithWritableSystem as _, SystemPath, SystemPathBuf},
13};
14use ruff_text_size::{TextRange, TextSize};
15use salsa::Setter as _;
16use ty_python_semantic::{Db as _, check_file_unwrap};
17
18use crate::db::{MemoryDb, SRC_ROOT};
19
20/// Definition of a source file.
21pub struct SourceFile<'a> {
22    /// source code
23    pub source_code: &'a str,
24    /// file path
25    pub path: &'a str,
26}
27
28impl<'a> SourceFile<'a> {
29    /// Create a new source file.
30    #[must_use]
31    pub fn new(source_code: &'a str, path: &'a str) -> Self {
32        Self { source_code, path }
33    }
34}
35
36#[derive(Debug, Default)]
37pub struct TypeChecker {
38    db: MemoryDb,
39    touched_files: Vec<TouchedRootFile>,
40    next_revision: u128,
41}
42
43impl TypeChecker {
44    /// Type check some python source code, checking if it's valid to run with monty.
45    ///
46    /// # Arguments
47    /// * `python_source` - The python source code to type check.
48    /// * `stubs_file` - Optional stubs file to use for type checking.
49    /// * `config` - Configuration for the type checking diagnostics.
50    ///
51    /// # Returns
52    /// * `Ok(None)` - If there are no typing errors.
53    /// * `Ok(Some(string))` - If there are typing errors.
54    /// * `Err(String)` - If there was an unexpected/internal error during type checking.
55    pub fn run<'a>(
56        &'a mut self,
57        python_source: &SourceFile<'_>,
58        stubs_file: Option<&SourceFile<'_>>,
59        config: TypeCheckingConfig,
60    ) -> Result<Option<TypeCheckingDiagnostics<'a>>, String> {
61        let src_root = SystemPathBuf::from(SRC_ROOT);
62        let main_path = src_root.join(python_source.path);
63        let main_source = python_source.source_code;
64
65        let (main_file, code_offset): (File, u32) = if let Some(stubs_file) = stubs_file {
66            let stubs_path = src_root.join(stubs_file.path);
67            self.write_root_file(&stubs_path, stubs_file.source_code)?;
68
69            // prepend the stub import to the main source code
70            let stub_stem = stubs_file
71                .path
72                .split_once('.')
73                .map_or(stubs_file.path, |(before, _)| before);
74            let mut new_source = format!("from {stub_stem} import *\n");
75            let offset = u32::try_from(new_source.len()).map_err(to_string)?;
76            new_source.push_str(main_source);
77
78            let main_file = self.write_root_file(&main_path, &new_source)?;
79            // one line offset for errors vs. the original source code since we injected the stub import
80            (main_file, offset)
81        } else {
82            let main_file = self.write_root_file(&main_path, main_source)?;
83            (main_file, 0)
84        };
85
86        // Use `check_file_unwrap` (not `check_types` alone) so that parser errors
87        // and unsupported-syntax errors are included — otherwise malformed input
88        // (e.g. deeply nested parentheses that ruff's parser rejects) would silently
89        // type-check clean.
90        let mut diagnostics = check_file_unwrap(&self.db, self.db.program_file(main_file));
91        diagnostics.retain(filter_diagnostics);
92
93        if diagnostics.is_empty() {
94            Ok(None)
95        } else {
96            // without all this errors would appear on the wrong line because we injected `from type_stubs import *`
97
98            // if we injected the stubs import, we need to write the actual source back to the file in the database
99            self.rewrite_root_file(&main_path, main_source)?;
100            // and then adjust each span in the error message to account for the injected stubs import
101            if code_offset > 0 {
102                let offset = TextSize::new(code_offset);
103                let source_len = TextSize::try_from(main_source.len()).map_err(to_string)?;
104                for diagnostic in &mut diagnostics {
105                    // Adjust spans in main diagnostic annotations (only for spans in the main file)
106                    for ann in diagnostic.annotations_mut() {
107                        adjust_annotation_span(ann, main_file, offset, source_len);
108                    }
109                    // Adjust spans in sub-diagnostic annotations (e.g., "info: Function defined here")
110                    for sub in diagnostic.sub_diagnostics_mut() {
111                        for ann in sub.annotations_mut() {
112                            adjust_annotation_span(ann, main_file, offset, source_len);
113                        }
114                    }
115                }
116            }
117            // Sort diagnostics by line number
118            diagnostics.sort_by(|a, b| a.rendering_sort_key(&self.db).cmp(&b.rendering_sort_key(&self.db)));
119
120            Ok(Some(TypeCheckingDiagnostics {
121                diagnostics,
122                type_checker: self,
123                config,
124            }))
125        }
126    }
127
128    /// Write one root file into the db and remember it for mandatory cleanup.
129    ///
130    /// A path already tracked from an earlier `run` is overwritten rather than
131    /// tracked twice: a session rewrites the same script path on every feed,
132    /// so the tracking list must not grow with the number of feeds.
133    fn write_root_file(&mut self, path: &SystemPathBuf, source: &str) -> Result<File, String> {
134        self.db.write_file(path, source).map_err(to_string)?;
135        self.bump_revisions(path);
136
137        // The write above succeeded, so interning the path must succeed — otherwise the
138        // file would live in the db but be untracked, poisoning cleanup, hence the panic.
139        let file = system_path_to_file(&self.db, path)
140            .unwrap_or_else(|e| panic!("interning a just-written root file must succeed, DB in an unsafe state: {e}"));
141
142        if !self.touched_files.iter().any(|t| &t.path == path) {
143            self.touched_files.push(TouchedRootFile::new(path.clone(), file));
144        }
145        Ok(file)
146    }
147
148    /// Overwrite the contents of a root file that was previously written via
149    /// [`Self::write_root_file`].
150    ///
151    /// Panics if `path` is not already tracked — the caller would otherwise be
152    /// leaving an untracked write behind that cleanup would miss, so a later
153    /// [`Self::reset`] would leave the file visible to the next session.
154    fn rewrite_root_file(&mut self, path: &SystemPathBuf, source: &str) -> Result<(), String> {
155        assert!(
156            self.touched_files.iter().any(|t| &t.path == path),
157            "rewrite_root_file called for untracked path '{path}' — must call write_root_file first",
158        );
159        self.db.write_file(path, source).map_err(to_string)?;
160        self.bump_revisions(path);
161        Ok(())
162    }
163
164    /// Force a fresh Salsa revision for `path` and its ancestor directories.
165    ///
166    /// `write_file` derives the revision from an mtime, and wasm's clock only
167    /// ticks once a millisecond, so two writes to one path can share a revision
168    /// and the second becomes invisible. Directories need it too — module
169    /// resolution keys off them, so stubs recreated after [`Self::reset`] would
170    /// stay unresolvable.
171    fn bump_revisions(&mut self, path: &SystemPath) {
172        let mut files = Vec::new();
173        let mut current = Some(path);
174        while let Some(path) = current {
175            if let Some(file) = self.db.files().try_system(&self.db, path) {
176                files.push(file);
177            }
178            current = path.parent();
179        }
180        self.next_revision = self.next_revision.wrapping_add(1);
181        for file in files {
182            file.set_revision(&mut self.db)
183                .to(FileRevision::new(self.next_revision));
184        }
185    }
186
187    /// Remove every file written since the last reset and sync the filesystem
188    /// changes, so the checker can be reused for unrelated code.
189    ///
190    /// Security-critical: without this a recycled worker would let one
191    /// session's modules and stubs resolve while checking the next session's
192    /// code. Each `TouchedRootFile::cleanup` removes its own file and walks its
193    /// ancestor chain up to `SRC_ROOT`, removing any directory that has become
194    /// empty. Shared parent directories collapse naturally once the last file
195    /// inside them is gone. We sync `SRC_ROOT` once at the end so the next
196    /// session cannot observe the previous root directory listing.
197    pub fn reset(&mut self) -> Result<(), String> {
198        let touched_files = mem::take(&mut self.touched_files);
199
200        for touched in touched_files.iter().rev() {
201            touched.cleanup(&mut self.db)?;
202        }
203
204        File::sync_path(&mut self.db, &SystemPathBuf::from(SRC_ROOT));
205        Ok(())
206    }
207}
208
209/// Adjust the span of an annotation by subtracting the given offset.
210///
211/// This is used when we inject a stub import at the beginning of the source code,
212/// and need to adjust all spans to account for the injected code.
213/// Only adjusts spans that belong to the main file being type-checked.
214///
215/// The result is clamped into the source: `TextSize` is a `u32`, so a span
216/// inside the injected import would wrap and panic the renderer, killing the
217/// session.
218fn adjust_annotation_span(ann: &mut Annotation, main_file: File, offset: TextSize, source_len: TextSize) {
219    let span = ann.get_span();
220    // Only adjust spans for the main file (not stubs or other files)
221    if let UnifiedFile::Ty(span_file) = span.file()
222        && *span_file == main_file
223        && let Some(range) = span.range()
224    {
225        let shift = |position: TextSize| position.checked_sub(offset).unwrap_or_default().min(source_len);
226        let new_range = TextRange::new(shift(range.start()), shift(range.end()));
227        let new_span = span.clone().with_range(new_range);
228        ann.set_span(new_span);
229    }
230}
231
232/// The diagnostics of one failed type check, rendered on `Display`.
233///
234/// Borrows the checker because ty's diagnostics only resolve their spans (file
235/// paths, source snippets) against the database that produced them — which is
236/// why the format is chosen up front, in [`TypeChecker::run`], and why callers
237/// that outlive the checker (anything across a process boundary) must keep the
238/// rendered string rather than this.
239pub struct TypeCheckingDiagnostics<'a> {
240    /// The actual diagnostic message
241    diagnostics: Vec<Diagnostic>,
242    /// The type checker, the db is needed to display diagnostics.
243    type_checker: &'a TypeChecker,
244    /// How to format the output and whether to use color.
245    config: TypeCheckingConfig,
246}
247
248impl fmt::Display for TypeCheckingDiagnostics<'_> {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        let format = match self.config.format {
251            TypeCheckingFormat::Full => DiagnosticFormat::Full,
252            TypeCheckingFormat::Concise => DiagnosticFormat::Concise,
253            TypeCheckingFormat::Azure => DiagnosticFormat::Azure,
254            TypeCheckingFormat::Json => DiagnosticFormat::Json,
255            TypeCheckingFormat::JsonLines => DiagnosticFormat::JsonLines,
256            TypeCheckingFormat::Rdjson => DiagnosticFormat::Rdjson,
257            TypeCheckingFormat::Pylint => DiagnosticFormat::Pylint,
258            TypeCheckingFormat::Gitlab => DiagnosticFormat::Gitlab,
259            TypeCheckingFormat::Github => DiagnosticFormat::Github,
260        };
261
262        let config = DisplayDiagnosticConfig::new("monty")
263            .format(format)
264            .color(self.config.color);
265        DisplayDiagnostics::new(&self.type_checker.db, &config, &self.diagnostics).fmt(f)
266    }
267}
268
269/// Filter out diagnostics we want to ignore.
270///
271/// Should only be necessary until <https://github.com/astral-sh/ty/issues/2599> is fixed.
272fn filter_diagnostics(d: &Diagnostic) -> bool {
273    !(matches!(d.id(), DiagnosticId::InvalidSyntax)
274        && matches!(
275            d.headline_message(),
276            "`await` statement outside of a function" | "`await` outside of an asynchronous function"
277        ))
278}
279
280/// File written into a pooled database during one type-check run.
281///
282/// The path is used to remove the file from the in-memory filesystem, and the
283/// interned `File` handle is then synced so Salsa observes the deletion before
284/// the db is returned to the pool.
285#[derive(Debug)]
286struct TouchedRootFile {
287    path: SystemPathBuf,
288    file: File,
289}
290
291impl TouchedRootFile {
292    /// Track a root file and its interned handle for mandatory cleanup.
293    fn new(path: SystemPathBuf, file: File) -> Self {
294        Self { path, file }
295    }
296
297    /// Remove the file from the in-memory filesystem, then walk its ancestor
298    /// chain and remove every directory under `SRC_ROOT` that has become empty.
299    ///
300    /// `remove_directory` requires the directory to be empty, so if two touched
301    /// files live in the same directory the first cleanup hits
302    /// `DirectoryNotEmpty` (silently swallowed) and the second succeeds once its
303    /// file is gone. This gives us correct cleanup without needing to sort paths
304    /// or coordinate across `TouchedRootFile`s.
305    fn cleanup(&self, db: &mut MemoryDb) -> Result<(), String> {
306        match db.memory_file_system().remove_file(&self.path) {
307            Ok(()) => {}
308            Err(err) if err.kind() == ErrorKind::NotFound => {}
309            Err(err) => {
310                return Err(format!(
311                    "Failed to remove pooled type-check file '{}': {err}",
312                    self.path
313                ));
314            }
315        }
316        self.file.sync(db);
317
318        // Walk parents up to but not including SRC_ROOT, removing each empty directory
319        // and syncing its path so Salsa invalidates any cached directory listing.
320        let src_root = SystemPathBuf::from(SRC_ROOT);
321        let mut ancestor = self.path.parent();
322        while let Some(dir) = ancestor
323            && dir != src_root.as_path()
324        {
325            match db.memory_file_system().remove_directory(dir) {
326                Ok(()) => {}
327                // Another touched file still lives in this directory; it will be
328                // removed by a later `cleanup` call. Every ancestor above this
329                // one is necessarily also non-empty (they contain this directory),
330                // so there is no point walking further up.
331                //
332                // `MemoryFileSystem::remove_directory` reports "directory not
333                // empty" as `io::Error::other(...)` (kind `Other`), so we match on
334                // the message rather than on `ErrorKind::DirectoryNotEmpty`.
335                Err(err) if err.to_string().contains("directory not empty") => break,
336                // `NotFound` at this point would mean the directory never existed
337                // or was already removed, both of which indicate a logic bug
338                // (e.g. the same path tracked twice) — fail loudly.
339                Err(err) => {
340                    return Err(format!("Failed to remove pooled type-check directory '{dir}': {err}"));
341                }
342            }
343            File::sync_path(db, dir);
344            ancestor = dir.parent();
345        }
346        Ok(())
347    }
348}
349
350/// Convert a displayable error into the string type used throughout type checking.
351pub(crate) fn to_string(err: impl fmt::Display) -> String {
352    err.to_string()
353}