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