Skip to main content

monty_type_checking/
type_check.rs

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